openapi: 3.1.0
info:
  title: GISL Compression API
  description: |
    REST API for the GISL (Give It Smaller) file compression and processing service.

    **Architecture:**
    - Upload files to get a `file_id`
    - Create workflows referencing uploaded files with operations (compress, thumbnail, image_watermark, text_watermark, merge, archive, convert, custom_luma, audio_overlay, audio_watermark)
    - Poll status, stream SSE events, or receive webhook callbacks
    - Download results per operation output

    **Response envelope:**
    All mutation and query endpoints return `{ success: true, data: {...} }` on success
    and `{ success: false, error: "...", details: [...] }` on failure.
    Exceptions: `GET /api/operations/schema` returns raw JSON (per-tier
    private caching with ETag revalidation per ADR-0002 + I3),
    health probes return flat objects, and `POST /api/contact` returns
    204 with no body.

    **Availability metadata.**
    This spec uses the `x-availability` vendor extension as **decorative
    documentation only**. Per [ADR-0001](../docs/decisions/0001-contract-first-availability.md)
    §1.5, the runtime endpoint `GET /api/operations/schema` (ticket I3) is the
    authoritative source; the sidecar `availability.json` (ticket I3b) is the
    authoritative companion (generated, never hand-edited; CI cross-checks
    runtime ⇄ sidecar). SDKs MUST NOT depend on `x-availability` reaching
    generated code — code-generators that surface vendor extensions may emit
    it as documentation, but consumers read availability from the runtime
    endpoint, not from the generated bindings.

    The 5-value vocabulary (`stable | beta | experimental | planned |
    deprecated`) is defined in the `AvailabilityValue` schema. See
    `schemas/FORMAT.md` §Availability Taxonomy for the operational rules
    (parser obligation: absent = stable; per-enum-value granularity is the
    `per_value_availability` primitive landed via ticket I17).

    **Localisation (per ticket [I26](https://trello.com/c/rcnqwgI4)).**

    Error responses + paused/blocked workflow statuses carry a localised
    human-readable `message` alongside a stable, never-localised
    `message_key`. Machine-readable fields (`error`, enum values, status
    codes) stay canonical English.

    - **Currently committed locales:** `en-GB` only (per ticket
      [`4GKyuYo6`](https://trello.com/c/4GKyuYo6)). The I26 carrier
      shape (`Accept-Language` + `Content-Language` + `Vary` headers +
      `locale` envelope field + `message_key` + `message_params`) is
      stable and exercised; the **catalog** of translated `message`
      strings is en-GB-only at runtime today. Additional locales (e.g.
      `pt-PT`) will be advertised by name when their catalogs ship —
      the request/response carrier shape does NOT change when a new
      locale lands. Treat unrequested locales as "machine-code +
      `message_key` path is committed; localised `message` prose is
      not" until this prose enumerates them by name.
    - **Request:** `Accept-Language` header per RFC 9110 §12.5.4 (q-value
      negotiation supported). The server selects the best-match locale
      from its supported list; falls back to `en-GB` when no match —
      which, until additional catalogs land, is every non-`en-GB`
      `Accept-Language`.
    - **Response:** `Content-Language: <locale>` echo on every localised
      response; `Vary: Accept-Language` on every response (CDN/cache
      correctness — different `Accept-Language` requests produce
      different responses). `Vary` is emitted unconditionally so the
      header contract does not flip when a second locale ships.
    - **Fallback locale:** `en-GB` (also the canonical locale for
      `message_key` translations and English `message` prose).
    - **SDK guidance:** switch on `error` (machine code) for typed
      error branches; surface `message_key` to client-side i18n
      catalogs (SDK companion work tracked at X19, cross-repo);
      display `message` for end-user UI; **never parse `message` for
      control flow** — it changes per locale.

    Carrier shape lives on `ErrorEnvelope` (envelope-level optional
    `message_key` + `message` + `locale` + `message_params`) and
    `ValidationErrorEnvelope` (also per-`details[]` entry). Existing
    402 / 403 / 422 envelopes (`BalanceExhaustedResponse`,
    `FeatureNotAvailableResponse`, `FeatureTierRestrictedResponse`,
    `WorkflowPausedDetail`) inherit the convention.

    **Upload thresholds (per tickets
    [u0ar7Yye](https://trello.com/c/u0ar7Yye) +
    [58nBQLWQ](https://trello.com/c/58nBQLWQ)).** Canonical upload
    constants (single-shot cap, multipart chunk size, multipart
    concurrency default, multipart first-chunk size) live on the
    `UploadThresholds` schema with
    `const:`-pinned values. SDK generators emit these as typed
    binding constants so frontend / API / SDKs reference one source
    of truth instead of hardcoding magic numbers. A runtime
    `GET /api/uploads/limits` endpoint for dynamic discovery
    (per-tier / per-environment overrides) is a deferred follow-up.
  version: 2.152.0
  contact:
    name: API Support

servers:
  - url: http://localhost:8080
    description: Local development
  - url: https://api.staging.giveitsmaller.com
    description: Staging environment

tags:
  - name: Upload
    description: File upload operations (single and multipart)
  - name: Workflow
    description: Workflow creation, status, download, and real-time events
  - name: Operations
    description: Operation schema introspection and retry
  - name: Health
    description: Health check endpoints
  - name: Contact
    description: Contact form submissions
  - name: Auth
    description: Authentication and session management
  - name: Billing
    description: Credit balance, usage history, and reservation behaviour
  - name: AudioWatermark
    description: Steganographic audio watermark embed and decode

# Spec-root default per ADR-0016 D2: anonymous-OK unless overridden
# per-operation. Every inbound operation declares an explicit `security:`
# block (one of `[]`, `[{bearerAuth: []}, {sessionAuth: []}]`, or
# `[{}, {bearerAuth: []}, {sessionAuth: []}]`) so the contract is
# self-documenting — `[]` here protects against future endpoints that
# accidentally inherit a stricter default.
security: []

paths:
  # ============================================
  # UPLOAD ENDPOINTS
  # ============================================

  /api/uploads:
    post:
      summary: Upload a file
      description: |
        Upload a single file for later use in workflows. Returns a `file_id` that can be
        referenced when creating workflows.

        No job or workflow is created — the file is stored in S3 and persisted in the
        `uploads` table. Create a workflow via `POST /api/workflows` to process it.

        **Single-shot file size cap:** `UploadThresholds.single_shot_max_bytes`
        (10 MB). Files above this size MUST use the multipart upload flow
        instead (`POST /api/uploads/multipart/initiate`).
      operationId: uploadFile
      security: [{}, {bearerAuth: []}, {sessionAuth: []}]  # optional (anon-OK; auth attribution if present)
      tags:
        - Upload
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/SingleUploadRequest'
      responses:
        '200':
          description: File uploaded successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadSuccessEnvelope'
              example:
                success: true
                data:
                  file_id: "019539ab-1111-7000-8000-000000000001"
                  original_name: "photo.jpg"
                  mime_type: "image/jpeg"
                  size_bytes: 2457600
                  constraints_applied:
                    max_size_bytes: 10485760
                    max_duration_seconds: null
                    processing_class_pre_assignment: short_form
        '400':
          description: Validation failed (missing file, invalid filename)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "File is required"
        '403':
          description: |
            Forbidden — tier-quota or feature-tier restriction. Discriminated
            by `error_type`:
            - `tier_restriction`: request-level quota (file size or MIME).
            - `feature_tier_restricted`: request body referenced a
              feature gated by a higher subscription tier (rare on this
              endpoint; surfaced when upload metadata invokes a
              tier-gated capability check).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/TierRestrictionResponse'
                  - $ref: '#/components/schemas/FeatureTierRestrictedResponse'
                discriminator:
                  propertyName: error_type
                  mapping:
                    tier_restriction: '#/components/schemas/TierRestrictionResponse'
                    feature_tier_restricted: '#/components/schemas/FeatureTierRestrictedResponse'
              examples:
                tier_restriction_mime:
                  summary: Free tier uploading a video
                  value:
                    success: false
                    error: "Your tier does not permit this MIME type"
                    error_type: "tier_restriction"
                    restriction_kind: "mime_type"
                    current_tier: "free"
                    required_tier: "pro"
                tier_restriction_size:
                  summary: Pro tier uploading a 1 GB file
                  value:
                    success: false
                    error: "File exceeds your tier's size cap"
                    error_type: "tier_restriction"
                    restriction_kind: "file_size"
                    current_tier: "pro"
                    required_tier: "enterprise"
        '413':
          description: |
            File exceeds the absolute maximum upload size (across all
            tiers). For tier-specific size caps below this absolute
            limit, the server returns 403 with
            `error_type: tier_restriction, restriction_kind: file_size`
            instead.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "File size exceeds maximum allowed (100 GiB)"
        '415':
          description: |
            Unsupported file type at the contract level (no tier permits
            this MIME). For tier-specific MIME restrictions where some
            tier does permit the MIME, the server returns 403 with
            `error_type: tier_restriction, restriction_kind: mime_type`
            instead.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "Unsupported MIME type: application/x-msdownload"
        '422':
          description: |
            Tier-relative cap exceeded for this upload per ticket
            [I15-CONS](https://trello.com/c/YZpBKzOM) F8.1. Either:

            - `upload_size_exceeds_tier`: the file is below the
              absolute 413 cap but above the tier+MIME-relative size
              cap. Caller resolves by upgrading tier or using a
              smaller file. Returned as
              `UploadSizeExceedsTierResponse`.
            - `upload_duration_exceeds_tier`: the file's measured
              duration is above the tier+MIME-relative duration cap
              (applies to audio/video MIMEs only). Returned as
              `UploadDurationExceedsTierResponse`.

            413 still covers absolute (across-tier) size limits; 415
            covers contract-level MIME rejections; 403
            `tier_restriction` covers MIME restrictions where some
            tier permits the MIME. 422 here is the size/duration
            tier-relative response newly added by F8.1.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/UploadSizeExceedsTierResponse'
                  - $ref: '#/components/schemas/UploadDurationExceedsTierResponse'
                discriminator:
                  propertyName: error_type
                  mapping:
                    upload_size_exceeds_tier: '#/components/schemas/UploadSizeExceedsTierResponse'
                    upload_duration_exceeds_tier: '#/components/schemas/UploadDurationExceedsTierResponse'
              examples:
                size_exceeds_tier:
                  summary: Free-tier caller uploads a 200MB file (cap is 10 MiB)
                  value:
                    success: false
                    error: "Upload exceeds size cap for free tier (max 10 MiB; pro permits up to 5 GiB)."
                    error_type: "upload_size_exceeds_tier"
                    current_tier: "free"
                    max_size_bytes: 10485760
                    required_tier: "pro"
                duration_exceeds_tier:
                  summary: Pro-tier caller uploads a 30-minute video (cap is 5min on short_form)
                  value:
                    success: false
                    error: "Upload exceeds short_form duration cap (max 5 minutes; long_form permits up to 12 hours)."
                    error_type: "upload_duration_exceeds_tier"
                    current_tier: "pro"
                    max_duration_seconds: 300
                    required_tier: "pro"
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/uploads/multipart/initiate:
    post:
      summary: Initiate direct S3 multipart upload
      description: |
        Start a direct-to-S3 chunked upload. The client sends the first chunk
        (fixed `UploadThresholds.multipart_first_chunk_size` = 8 MiB) and the
        total file size. The API uses the first chunk for:
        - MIME type detection (to validate supported formats)
        - Upload throughput measurement (to calculate optimal chunk size)

        The API stores the first chunk as S3 multipart upload part 1, calculates the
        optimal chunk size and total number of parts, then returns pre-signed PUT URLs
        for the remaining parts. The client uploads parts 2-N directly to S3.

        **Recommended client behaviour:** chunk size
        `UploadThresholds.multipart_chunk_size` (16 MiB) with
        `UploadThresholds.multipart_concurrency_default` parallel uploads.
        The server returns `recommended_chunk_size` per file based on
        first-chunk throughput; SDKs SHOULD prefer that runtime value when
        present and fall back to the contract constants otherwise.

        **Chunk sizing strategy:**
        - First chunk: fixed `UploadThresholds.multipart_first_chunk_size` = 8 MiB (sent to API)
        - Remaining chunks: API calculates `recommended_chunk_size` from throughput * 5s,
          clamped to 16 MiB–100 MiB
        - The last part may be smaller than 16 MiB (S3 allows this for the final part)
        - Maximum 10,000 parts per upload (the S3 multipart hard limit;
          raised from 500 by CON-1/ADR-0011 for the 120 GB Enterprise envelope)

        **Pre-signed URL TTL:**
        Dynamic based on estimated upload duration * 2, clamped between 900s and 3600s.

        After all parts are uploaded to S3, call the complete endpoint to finalise.
      operationId: multipartInitiate
      security: [{}, {bearerAuth: []}, {sessionAuth: []}]  # optional (anon-tolerant; manifest.userId pinned to caller if authed)
      tags:
        - Upload
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/MultipartInitiateRequest'
      responses:
        '200':
          description: Multipart upload initiated, pre-signed URLs returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MultipartInitiateSuccessEnvelope'
              example:
                success: true
                data:
                  upload_id: "019539ab-1111-7000-8000-000000000001"
                  mime_type: "image/jpeg"
                  first_chunk_etag: '"d8e8fca2dc0f896fd7cb4cb0031ba249"'
                  first_chunk_size_bytes: 8388608
                  total_parts: 5
                  recommended_chunk_size: 16777216
                  presigned_urls:
                    - part_number: 2
                      url: "https://gisl-stg-euw1-input.s3.eu-west-1.amazonaws.com/uploads/...?X-Amz-Expires=1800&..."
                      expires_at: "2026-03-06T12:30:00.000Z"
                    - part_number: 3
                      url: "https://gisl-stg-euw1-input.s3.eu-west-1.amazonaws.com/uploads/...?X-Amz-Expires=1800&..."
                      expires_at: "2026-03-06T12:30:00.000Z"
                  constraints_applied:
                    max_size_bytes: 524288000
                    max_duration_seconds: null
                    processing_class_pre_assignment: short_form
        '400':
          description: Validation failed (missing fields, invalid file)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "File is required"
        '403':
          description: |
            Forbidden — tier-quota or feature-tier restriction. Same shape
            as `POST /api/uploads`. Discriminated by `error_type`.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/TierRestrictionResponse'
                  - $ref: '#/components/schemas/FeatureTierRestrictedResponse'
                discriminator:
                  propertyName: error_type
                  mapping:
                    tier_restriction: '#/components/schemas/TierRestrictionResponse'
                    feature_tier_restricted: '#/components/schemas/FeatureTierRestrictedResponse'
        '413':
          description: |
            File exceeds the absolute maximum upload size (across all
            tiers). For tier-specific size caps below this absolute
            limit, the server returns 403 with
            `error_type: tier_restriction, restriction_kind: file_size`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '415':
          description: |
            Unsupported file type at the contract level. For tier-specific
            MIME restrictions, the server returns 403 with
            `error_type: tier_restriction, restriction_kind: mime_type`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '422':
          description: |
            Tier-relative cap exceeded for this multipart session per
            ticket [I15-CONS](https://trello.com/c/YZpBKzOM) F8.1.
            Same shape as `POST /api/uploads` 422 — discriminated by
            `error_type`. Note that `upload_duration_exceeds_tier`
            may be deferred to multipart-complete time when the first
            chunk doesn't carry container metadata; the initiate path
            still returns 422 if the caller's `metadata_hint`
            duration exceeds the tier cap.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/UploadSizeExceedsTierResponse'
                  - $ref: '#/components/schemas/UploadDurationExceedsTierResponse'
                discriminator:
                  propertyName: error_type
                  mapping:
                    upload_size_exceeds_tier: '#/components/schemas/UploadSizeExceedsTierResponse'
                    upload_duration_exceeds_tier: '#/components/schemas/UploadDurationExceedsTierResponse'
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/uploads/multipart/complete:
    post:
      summary: Complete direct S3 multipart upload
      description: |
        Finalise a direct-to-S3 multipart upload after all parts have been uploaded.

        The client sends the `upload_id` (from the initiate response) and an array of
        part ETags collected from the S3 PUT responses for parts 2-N. The API already
        has the part 1 ETag from the initiate step.

        The API calls S3 CompleteMultipartUpload and persists the upload record.
        No job or workflow is created — use `POST /api/workflows` to process the file.

        **Ownership enforcement** (per session-resume work, ticket
        [`HxUmVr3Y`](https://trello.com/c/HxUmVr3Y)): when
        `manifest.userId` is non-null, `multipart/complete` refuses
        completion by any caller other than the original authenticator
        (returns 403 `MULTIPART_SESSION_OWNERSHIP`). The sister
        endpoints `/status`, `/presign`, `/keepalive` apply the same
        rule and additionally refuse anonymous-initiated sessions
        outright (returning 403 `MULTIPART_SESSION_AUTH_REQUIRED`).
      operationId: multipartComplete
      security: [{}, {bearerAuth: []}, {sessionAuth: []}]  # optional (anon-tolerant; ownership-scoped per HxUmVr3Y when manifest.userId set)
      x-identity-scoped: true  # session/ownership-scoped per ADR-0016 D3
      tags:
        - Upload
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MultipartCompleteRequest'
      responses:
        '201':
          description: |
            Upload completed successfully. The returned `upload_id` is the identifier
            of the finalised upload and can be passed as `file_id` when creating
            workflows via `POST /api/workflows`. File metadata (original name, MIME
            type, size) is available from `GET /api/uploads/{id}/metadata` if the
            client did not retain the values captured during initiate.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MultipartCompleteSuccessEnvelope'
              example:
                success: true
                data:
                  upload_id: "019539ab-1111-7000-8000-000000000001"
                  status: "completed"
        '400':
          description: Invalid upload_id, missing parts, or ETag mismatch
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "Missing ETags for parts: 3, 5"
        '403':
          description: |
            Forbidden — session's `manifest.userId` is non-null and
            differs from the authenticated caller. Per ticket
            [`HxUmVr3Y`](https://trello.com/c/HxUmVr3Y) ownership
            enforcement (carried by the same controller path as the
            three new resume endpoints).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "MULTIPART_SESSION_OWNERSHIP"
        '404':
          description: Upload session not found or expired
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "Upload session not found or expired"
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/uploads/multipart/{uploadId}/status:
    get:
      summary: Cursor over uploaded parts of an in-flight multipart session
      description: |
        Returns the list of parts already received by S3 for an in-flight
        multipart session. Used by SDKs to resume an interrupted upload —
        the client walks the cursor pages, collects which `part_number`
        slots are filled, and re-presigns / re-uploads only the missing
        slots via `POST /api/uploads/multipart/{uploadId}/presign`.

        The response shape is a 1:1 passthrough of the S3 ListParts API —
        `next_part_number_marker` and `is_truncated` mirror the upstream
        wire shape verbatim (no translation layer, zero impedance
        mismatch for SDKs that already speak S3 vocabulary).

        **Authentication required.** Anonymous-initiated sessions (allowed
        at `/initiate` today; the `8LABloaz` follow-up flips that to
        require-auth) cannot use this endpoint and receive 403
        `MULTIPART_SESSION_AUTH_REQUIRED`. Authenticated callers can only
        list parts of sessions they initiated (403
        `MULTIPART_SESSION_OWNERSHIP` otherwise).

        Cursor semantics: pass `cursor=0` (default) for the first page;
        the server returns up to `limit` parts plus `next_part_number_marker`
        and `is_truncated`. To walk: pass each `next_part_number_marker`
        as the next request's `cursor` until `is_truncated: false`. The
        marker is `null` on the final page.

        Completion-readiness is client-derived (walk all pages, compare
        the count of returned parts to `total_parts`).
      operationId: multipartStatus
      security: [{bearerAuth: []}, {sessionAuth: []}]  # required (anon-initiated sessions rejected with MULTIPART_SESSION_AUTH_REQUIRED — see ErrorCode.authentication_required prose)
      x-identity-scoped: true  # session/ownership-scoped per ADR-0016 D3
      tags:
        - Upload
      parameters:
        - name: uploadId
          in: path
          required: true
          description: Multipart upload session identifier from the initiate response.
          schema:
            $ref: '#/components/schemas/UuidV7'
        - name: cursor
          in: query
          required: false
          description: |
            S3 ListParts `PartNumberMarker` (the part-number to start
            listing **after**). `0` (default) returns from the first
            part. Walk by passing each response's
            `next_part_number_marker` until `is_truncated: false`.
          schema:
            type: integer
            minimum: 0
            maximum: 9999
            default: 0
        - name: limit
          in: query
          required: false
          description: |
            Page size — maximum number of parts returned per request.
            `1000` is the default and the upstream S3 `MaxParts` cap.
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 1000
      responses:
        '200':
          description: Page of uploaded parts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MultipartStatusSuccessEnvelope'
              example:
                success: true
                data:
                  upload_id: "019539ab-1111-7000-8000-000000000001"
                  multipart_upload_id: "5MqcMpgg9_pKVdoO9.X9eK0w2g3Sq5_h"
                  cloud_key: "uploads/01/019539ab-1111-7000-8000-000000000001/source"
                  total_parts: 5
                  uploaded_parts:
                    - part_number: 1
                      etag: '"d8e8fca2dc0f896fd7cb4cb0031ba249"'
                      size_bytes: 8388608
                      last_modified: "2026-05-20T13:30:00.000Z"
                    - part_number: 2
                      etag: '"7c3e2c4b6e3e7e0e8f9d1a2b3c4d5e6f"'
                      size_bytes: 16777216
                      last_modified: "2026-05-20T13:31:14.000Z"
                  next_part_number_marker: null
                  is_truncated: false
                  manifest_expires_at: "2026-05-22T13:31:14.000Z"
                  recommended_chunk_size: 16777216
        '401':
          description: Authentication required.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '403':
          description: |
            Forbidden — either the session was anonymously initiated
            (`MULTIPART_SESSION_AUTH_REQUIRED`) or the authenticated
            caller is not the session owner (`MULTIPART_SESSION_OWNERSHIP`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                auth_required:
                  summary: Anonymous-initiated session
                  value:
                    success: false
                    error: "MULTIPART_SESSION_AUTH_REQUIRED"
                ownership:
                  summary: Session belongs to a different authenticated caller
                  value:
                    success: false
                    error: "MULTIPART_SESSION_OWNERSHIP"
        '404':
          description: Multipart session not found or expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "MULTIPART_SESSION_NOT_FOUND"
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/uploads/multipart/{uploadId}/presign:
    post:
      summary: Sparse re-presign of multipart parts (resume / retry)
      description: |
        Returns fresh pre-signed PUT URLs for a caller-supplied subset
        of part numbers. Used by SDKs to (a) resume an interrupted
        upload after the original presigned URLs have expired, and
        (b) retry individual failed PUTs without re-presigning the
        whole upload.

        Part numbers MUST satisfy `2 ≤ n ≤ total_parts`. **Part 1 is
        rejected** because the manifest stores its ETag from `/initiate`
        — re-presigning part 1 would break the eventual
        `multipart/complete` call. Request the list with
        `uniqueItems`; duplicates are rejected with a 400.

        At most 100 part numbers per request. SDKs paginate larger
        sets across multiple `/presign` calls.

        **Authentication required.** Same `MULTIPART_SESSION_*` codes
        apply as on `/status`.
      operationId: multipartPresign
      security: [{bearerAuth: []}, {sessionAuth: []}]  # required (anon-initiated sessions rejected; resume-only endpoint)
      x-identity-scoped: true  # session/ownership-scoped per ADR-0016 D3
      tags:
        - Upload
      parameters:
        - name: uploadId
          in: path
          required: true
          description: Multipart upload session identifier from the initiate response.
          schema:
            $ref: '#/components/schemas/UuidV7'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MultipartPresignRequest'
      responses:
        '200':
          description: Pre-signed URLs returned for the requested parts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MultipartPresignSuccessEnvelope'
              example:
                success: true
                data:
                  upload_id: "019539ab-1111-7000-8000-000000000001"
                  presigned_urls:
                    - part_number: 3
                      url: "https://gisl-stg-euw1-input.s3.eu-west-1.amazonaws.com/uploads/...?X-Amz-Expires=1800&..."
                      expires_at: "2026-05-20T14:01:14.000Z"
                    - part_number: 5
                      url: "https://gisl-stg-euw1-input.s3.eu-west-1.amazonaws.com/uploads/...?X-Amz-Expires=1800&..."
                      expires_at: "2026-05-20T14:01:14.000Z"
        '400':
          description: |
            Validation failed — e.g. body too large, malformed JSON,
            `part_numbers` empty / >100, contains duplicates, contains
            `1`, contains values outside `[2, total_parts]`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "part_numbers must contain unique values in [2, total_parts]; part 1 is sealed"
        '401':
          description: Authentication required.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '403':
          description: |
            Forbidden — either the session was anonymously initiated
            (`MULTIPART_SESSION_AUTH_REQUIRED`) or the authenticated
            caller is not the session owner (`MULTIPART_SESSION_OWNERSHIP`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                auth_required:
                  value:
                    success: false
                    error: "MULTIPART_SESSION_AUTH_REQUIRED"
                ownership:
                  value:
                    success: false
                    error: "MULTIPART_SESSION_OWNERSHIP"
        '404':
          description: Multipart session not found or expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "MULTIPART_SESSION_NOT_FOUND"
        '422':
          description: |
            Session no longer accepts re-presign requests because the
            assembled object would exceed the S3 multipart capacity
            cap. Pre-S3 server-side capacity gate; distinct from
            tier-quota rejections at `/initiate` time.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "FILE_TOO_LARGE_FOR_MULTIPART"
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/uploads/multipart/{uploadId}/keepalive:
    post:
      summary: Refresh the multipart session manifest TTL
      description: |
        Resets the session manifest's TTL to 48 h ahead of now. The
        server uses an atomic Redis `EXPIRE`; the operation is
        idempotent — calling it multiple times in succession produces
        the same effective TTL window.

        SDKs SHOULD call this periodically on resume flows (e.g. once
        per minute while a resumable upload is paused) to prevent the
        manifest from lapsing. The manifest TTL is decoupled from
        individual presigned-URL TTLs (which are typically 900s–3600s);
        re-presigning expired URLs requires the manifest still be
        alive.

        Empty request body.

        **Authentication required.** Same `MULTIPART_SESSION_*` codes
        apply as on `/status` and `/presign`.
      operationId: multipartKeepalive
      security: [{bearerAuth: []}, {sessionAuth: []}]  # required (anon-initiated sessions rejected; resume-only endpoint)
      x-identity-scoped: true  # session/ownership-scoped per ADR-0016 D3
      tags:
        - Upload
      parameters:
        - name: uploadId
          in: path
          required: true
          description: Multipart upload session identifier from the initiate response.
          schema:
            $ref: '#/components/schemas/UuidV7'
      responses:
        '200':
          description: Manifest TTL refreshed; new expiry returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MultipartKeepaliveSuccessEnvelope'
              example:
                success: true
                data:
                  upload_id: "019539ab-1111-7000-8000-000000000001"
                  manifest_expires_at: "2026-05-22T13:31:14.000Z"
        '401':
          description: Authentication required.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '403':
          description: |
            Forbidden — either the session was anonymously initiated
            (`MULTIPART_SESSION_AUTH_REQUIRED`) or the authenticated
            caller is not the session owner (`MULTIPART_SESSION_OWNERSHIP`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                auth_required:
                  value:
                    success: false
                    error: "MULTIPART_SESSION_AUTH_REQUIRED"
                ownership:
                  value:
                    success: false
                    error: "MULTIPART_SESSION_OWNERSHIP"
        '404':
          description: Multipart session not found or expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "MULTIPART_SESSION_NOT_FOUND"
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/uploads/{id}/metadata:
    get:
      summary: Get file metadata
      description: |
        Returns metadata extracted from an uploaded file. Useful for inspecting files
        before building workflows (e.g. check dimensions to decide if resize is needed,
        check codec to decide on compression options).

        Fields vary by MIME type:
        - **Images:** dimensions, color_space, dpi, has_alpha, exif, dominant_colors
        - **Video:** dimensions, duration, codec, fps, audio_codec, bitrate
        - **Audio:** duration, bitrate, channels, sample_rate, codec
        - **Documents:** page_count, dimensions (for PDF)
      operationId: getUploadMetadata
      security: [{}, {bearerAuth: []}, {sessionAuth: []}]  # optional (anon-OK; identity-scoped to caller's session/identity)
      x-identity-scoped: true  # ownership-scoped per ADR-0016 D3
      tags:
        - Upload
      parameters:
        - name: id
          in: path
          required: true
          description: Upload file ID (UUID v7)
          schema:
            $ref: '#/components/schemas/UuidV7'
      responses:
        '200':
          description: File metadata retrieved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MetadataSuccessEnvelope'
              example:
                success: true
                data:
                  file_id: "019539ab-1111-7000-8000-000000000001"
                  original_name: "photo.jpg"
                  mime_type: "image/jpeg"
                  size_bytes: 4521984
                  created_at: "2026-04-26T14:30:00Z"
                  dimensions:
                    width: 4032
                    height: 3024
                  color_space: "sRGB"
                  dpi: 72
                  has_alpha: false
                  exif:
                    camera: "iPhone 15 Pro"
                    datetime: "2026-03-01T14:30:00Z"
                    gps:
                      lat: 51.5074
                      lon: -0.1278
                    copyright: null
                  dominant_colors:
                    - "#2a5c3f"
                    - "#8b6f4e"
                    - "#d4c5a9"
        '404':
          description: Upload not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/uploads/{id}/probe:
    post:
      summary: Preflight probe an uploaded file
      description: |
        Run a server-side probe against an uploaded file to detect
        problems BEFORE the caller submits a workflow that depends on
        it. Designed for the F11 long-form merge edge case (per plan
        v5 round 10): a single-job long-form merge fails on the first
        bad input, so callers preflight each clip first and exclude
        bad ones before submit.

        Per ticket [I28 `KbVAnGCm`](https://trello.com/c/KbVAnGCm).
        `availability: planned` until the cross-repo Lambda support
        ships; the runtime returns `feature_not_available` (422)
        until then. Per Tension 1 (ADR-0001 §1.3).

        **SDK helper intent.** SDKs are expected to expose a
        `client.preflight_clips([file_ids])` helper that compiles to N
        parallel probe requests with structured aggregation per
        ticket X18 (cross-repo). Contract ships the per-file endpoint
        only; the helper is SDK-side.

        **Tier scoping.** The probe respects the caller's tier — the
        `processing_class_pre_assignment` reflects the same logic
        F8.1 upload-side gating uses (per ticket I15-CONS), so a
        free-tier caller probing a 30-minute video sees
        `blocked` rather than the long-form pre-assignment a
        pro-tier caller would see.

        **Idempotent.** Probing the same file_id twice returns the
        same `probed_at` timestamp + result (cached server-side per
        upload).
      operationId: probeUpload
      security: [{bearerAuth: []}, {sessionAuth: []}]  # required (explicit 401 in response set; ownership-scoped)
      x-identity-scoped: true  # ownership-scoped per ADR-0016 D3
      tags:
        - Upload
      x-availability: planned
      parameters:
        - name: id
          in: path
          required: true
          description: Upload file ID (UUID v7)
          schema:
            $ref: '#/components/schemas/UuidV7'
      responses:
        '200':
          description: |
            Probe completed. The `data.probe_status` field indicates
            whether the file is workflow-ready (`ok`) or has issues
            that warrant exclusion / re-upload.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadProbeSuccessEnvelope'
              examples:
                ok_short_form:
                  summary: Short-form clip probed cleanly
                  value:
                    success: true
                    data:
                      file_id: "019539ab-1111-7000-8000-000000000001"
                      probe_status: "ok"
                      media_metadata:
                        duration_seconds: 187
                        width: 1920
                        height: 1080
                        codec: "h264"
                        container: "mp4"
                        audio_layout: "stereo"
                        probed_at: "2026-04-26T13:50:00Z"
                      processing_class_pre_assignment: "short_form"
                ok_long_form:
                  summary: Long-form clip — full M2 union populated (video + audio fields)
                  value:
                    success: true
                    data:
                      file_id: "019539ab-1111-7000-8000-000000000002"
                      probe_status: "ok"
                      media_metadata:
                        duration_seconds: 5400
                        width: 3840
                        height: 2160
                        codec: "h265"
                        audio_codec: "aac"
                        container: "mp4"
                        fps: 23.976
                        bitrate_bps: 18000000
                        audio_layout: "stereo"
                        channels: 2
                        sample_rate_hz: 48000
                        probed_at: "2026-04-26T13:50:00Z"
                      processing_class_pre_assignment: "long_form"
                corrupt:
                  summary: File header damaged — caller should exclude
                  value:
                    success: true
                    data:
                      file_id: "019539ab-1111-7000-8000-000000000003"
                      probe_status: "corrupt"
                      media_metadata:
                        probed_at: "2026-04-26T13:50:00Z"
                      processing_class_pre_assignment: "blocked"
                unsupported_codec:
                  summary: Container readable but codec unsupported
                  value:
                    success: true
                    data:
                      file_id: "019539ab-1111-7000-8000-000000000004"
                      probe_status: "unsupported_codec"
                      media_metadata:
                        duration_seconds: 600
                        width: 1280
                        height: 720
                        codec: "prores_4444"
                        container: "mov"
                        probed_at: "2026-04-26T13:50:00Z"
                      processing_class_pre_assignment: "blocked"
                blocked_by_tier:
                  summary: Free-tier caller probes a long-form clip
                  value:
                    success: true
                    data:
                      file_id: "019539ab-1111-7000-8000-000000000005"
                      probe_status: "ok"
                      media_metadata:
                        duration_seconds: 5400
                        width: 1920
                        height: 1080
                        codec: "h264"
                        container: "mp4"
                        audio_layout: "stereo"
                        probed_at: "2026-04-26T13:50:00Z"
                      processing_class_pre_assignment: "blocked"
        '401':
          description: Authentication required.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '404':
          description: Upload not found OR not owned by the caller.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '422':
          description: |
            The endpoint is `availability: planned` and the runtime
            Lambda has not yet shipped — returned as
            `FeatureNotAvailableResponse` with `error_type:
            feature_not_available`. Per Tension 1 (ADR-0001 §1.3).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FeatureNotAvailableResponse'
        '429':
          description: |
            Rate limit exceeded. Probe is rate-limited independently
            from workflow-create so callers running large preflight
            sweeps don't starve their workflow-create budget.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  # ============================================
  # WORKFLOW ENDPOINTS
  # ============================================

  /api/workflows:
    get:
      summary: List the caller's workflows
      description: |
        Cursor-paginated, **user-scoped** summary list of the caller's
        workflows, most-recent-first (`created_at DESC`, with an id
        tiebreaker for same-second rows). Each row is a **lightweight
        summary** (id / status / created_at + per-job type+status + a
        deliverable-output count) for list rendering — it deliberately does
        NOT inline per-operation `result_metadata` or output details (avoids
        N×M×K payload blow-up). Drill in via `GET /api/workflows/{id}/status`
        (per-op detail incl. `result_metadata`) and
        `GET /api/workflows/{id}/downloads` (deliverables). Rows persist until
        GDPR purge — workflows whose 24h download window has expired are still
        listed. Per ticket [`nLK1IO1k`](https://trello.com/c/nLK1IO1k) (A1).
      operationId: listWorkflows
      security: [{bearerAuth: []}, {sessionAuth: []}]  # required (explicit 401 — user-scoped list, no anonymous access)
      x-identity-scoped: true  # caller's own workflows — implicit identity-scoped, per ADR-0016 D3 (cf. credits/usage)
      tags:
        - Workflows
      parameters:
        - name: cursor
          in: query
          required: false
          description: |
            Opaque pagination cursor from a previous page's `next_cursor`.
            Omit (or send empty) for the first page; walk pages by passing
            each `next_cursor` until `is_truncated: false`. Encodes the
            `(created_at, id)` position — treat as opaque, do not parse.
          schema:
            type: string
        - name: limit
          in: query
          required: false
          description: Maximum rows per page (1-100; default 20).
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        '200':
          description: Cursor-paginated summary list of the caller's workflows.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkflowListSuccessEnvelope'
        '401':
          description: |
            Authentication required — anonymous callers cannot list
            workflows (the list is user-scoped).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
    post:
      summary: Create a workflow
      description: |
        Create a workflow containing one or more jobs, each with one or
        more operations. V2 job shape per [ADR-0004](../docs/decisions/0004-job-shape.md)
        — see `JobDefinition` schema for the full contract.

        **Job source shape** (V2; greenfield-cutover from V1's
        `file_id`/`source`/`inputs` mutex):
        - **Single-input jobs**: provide `source` (a `WorkflowSource`
          discriminated union with 4 leaves: `upload` /
          `external_import` / `connection` / `job_output`).
        - **Multi-input jobs** (merge, archive, image_watermark,
          custom_luma, audio_overlay, audio_to_video, video_watermark):
          provide `inputs[]` with
          `JobInputV2` entries. Each entry has its own `source`
          (a `MultiInputSource` — the 3-leaf subset of `WorkflowSource`
          that **excludes `upload`**: `job_output` / `external_import` /
          `connection`), plus optional `role`
          (image_watermark, custom_luma, audio_overlay, audio_to_video,
          video_watermark) and
          `per_input_options`. To feed an uploaded file into a
          multi-input op, create a `passthrough` source job (single-input,
          `operations: [{type: passthrough}]`) and reference it from
          `inputs[].source` as `{type: job_output, from: <id>}` — see the
          `v2_merge_two_uploads` example and the `passthrough` bullet in
          `OperationType`.

        Exactly one of `source` or `inputs` is required per job.

        **`id` is optional.** Server auto-generates `job_N` when omitted;
        required when the job is referenced by another job's
        `source.from`, `inputs[].source.from`, `workflow_edges`, or
        `delivery.selection.explicit.refs`. User-supplied IDs matching
        the reserved pattern `^job_\d+$` are rejected.

        **`workflow_edges`** is rarely needed. The server computes
        `depends_on` from `JobOutputSource.from` references at job-level
        and inputs[]-level for typical DAGs; `workflow_edges` is
        retained as an optional override for non-typical cases (e.g.
        side-effect dependencies that don't surface as `from`
        references).

        **Default operation:** if `operations` is omitted — or
        explicitly empty (`[]`) — on a single-input job with
        `source: { type: upload, ... }`, the default is
        `[{ "type": "compress" }]` with default options for the
        detected MIME type. Multi-input jobs must always specify
        operations explicitly.

        **Compression opt-out:** V2 has no `skip_compression` flag —
        `operations[]` *is* the chain. To opt a job out of
        compression, send an explicit non-empty `operations[]` that
        does not include `compress`; on a single-input upload job an
        empty `[]` does *not* opt out (it is treated as omitted and
        still receives the implicit `compress` — see above). Per
        [ADR-0004](../docs/decisions/0004-job-shape.md).

        **Inert passthrough (lossless source jobs).** A single-input
        source job whose SOLE operation is `passthrough`
        (`operations: [{type: passthrough}]`) emits its source bytes
        UNCHANGED — no compression, no transformation, no Lambda. This
        is the EXPLICIT lossless path (built on the opt-out rule above:
        a non-empty `operations[]` without `compress`); it is distinct
        from `operations: []`, which KEEPS its implicit-compress meaning
        on a single-input upload job. The API **self-completes** a
        passthrough job at publish: the job's terminal output IS the
        upload's `{bucket, key}` unchanged, and `passthrough` is **never
        published to SNS** (no `ops-passthrough` queue; absent from the
        AsyncAPI routing enums). Its purpose is to bridge an uploaded
        file into a multi-input op (`merge` / `archive` /
        `image_watermark` / …): since `JobInputV2.source` excludes
        upload-direct, the upload becomes a `passthrough` source job
        that the downstream multi-input job references via
        `{type: job_output, from: <id>}` — preserving billing / DAG /
        lineage. Per ticket
        [`4som89Uh`](https://trello.com/c/4som89Uh).

        **Multi-input constraint:** multi-input jobs (`inputs[]`) must
        have exactly one operation, which must be a multi-input type
        (`merge`, `archive`, `image_watermark`, `custom_luma`,
        `audio_overlay`, `audio_to_video`, or `video_watermark`).
        Chaining after a multi-input operation is
        not valid — create a downstream `job_output`-sourced job
        instead.

        **Atomic credit reservation (F9 — per ticket
        [I23](https://trello.com/c/DffjC3zm)).** Workflow creation is
        atomic across the cost ledger and the workflow row. The server
        computes a cost estimate from the resolved `processing_plan`
        (per [I15-CONS](https://trello.com/c/YZpBKzOM)) AFTER source
        resolution but BEFORE the workflow is committed. If the
        estimate exceeds the caller's `available_credits` (monthly +
        purchased + overdraft headroom — see
        `GET /api/v2/credits/balance`), the request is rejected with
        `402 BalanceExhaustedResponse` and **no** workflow row, ledger
        entry, or downstream dispatch occurs. On success, a single
        immutable ledger transaction is written in the same DB
        transaction as the workflow row; the response's
        `processing_plan` reflects the same estimate the reservation
        was sized against. Refunds (e.g. on terminal failure that the
        server policy refunds) are emitted as separate ledger rows
        referencing the reservation via `reference_id`. The
        idempotency-key wire shape for retried reservation attempts
        is deferred per ADR-0001 §1.7 row 5.
      operationId: createWorkflow
      security: [{}, {bearerAuth: []}, {sessionAuth: []}]  # optional (anon-OK on free-tier baseline; auth required for tier-gated operations)
      # x-identity-scoped: the request body dereferences identity-bound upload refs.
      # CARVE-OUT (ADR-0016 Amendment, nGYbgChX): cross-identity/anonymous upload refs
      # are 404 UPLOAD_NOT_FOUND IDOR/BOLA-masked (NEVER 403) so the response does not
      # reveal another user's upload exists — see the createWorkflow 404 response.
      # ADR-0016 D3's generic "identity-scoped -> 403" gloss over-claimed for uploads;
      # the runtime always 404-masked (CreateWorkflowCommandHandler.php:132-148).
      # (§D4 sample showed identity_scoped: false; B3 audit flipped to true per §D3 — SDK-acked 2026-05-28.)
      x-identity-scoped: true
      tags:
        - Workflow
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WorkflowCreateRequest'
            examples:
              v2_simple_compress:
                summary: V2 single-input compress (auto-generated id)
                value:
                  jobs:
                    - source:
                        type: upload
                        file_id: "019539ab-1111-7000-8000-000000000001"
                      operations:
                        - type: compress
                          options:
                            quality: 80
              v2_flat_single_source:
                summary: >-
                  V2 flat form (D0Gsri8V) — top-level source + operations,
                  no jobs[] wrapper. Exactly equivalent to wrapping these in
                  jobs:[{source,operations}]. Compress + a thumbnail derived
                  from the original.
                value:
                  source:
                    type: upload
                    file_id: "019539ab-1111-7000-8000-000000000001"
                  operations:
                    - type: compress
                      options:
                        quality: 80
                    - type: thumbnail
                      base: original
                      options:
                        width: 320
                        height: 240
              v2_chain_compress_thumbnail:
                summary: V2 chained compress -> thumbnail (id required on first job)
                value:
                  jobs:
                    - id: compressed
                      source:
                        type: upload
                        file_id: "019539ab-1111-7000-8000-000000000001"
                      operations:
                        - type: compress
                    - source:
                        type: job_output
                        from: compressed
                      operations:
                        - type: thumbnail
                          options:
                            width: 320
                            height: 240
              v2_merge_two_uploads:
                summary: V2 multi-input merge (two uploads via passthrough source jobs)
                value:
                  jobs:
                    - id: src_a
                      source:
                        type: upload
                        file_id: "019539ab-1111-7000-8000-000000000001"
                      operations:
                        - type: passthrough
                    - id: src_b
                      source:
                        type: upload
                        file_id: "019539ab-1111-7000-8000-000000000002"
                      operations:
                        - type: passthrough
                    - inputs:
                        - source:
                            type: job_output
                            from: src_a
                        - source:
                            type: job_output
                            from: src_b
                      operations:
                        - type: merge
              v2_external_import:
                summary: V2 single-input from POST /api/external-imports handle
                value:
                  jobs:
                    - source:
                        type: external_import
                        external_source_id: "019539ab-2222-7000-8000-000000000001"
                      operations:
                        - type: compress
      responses:
        '201':
          description: Workflow created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkflowCreateSuccessEnvelope'
              examples:
                simple:
                  summary: Single file, single operation
                  value:
                    success: true
                    data:
                      workflow_id: "019539ac-2222-7000-8000-000000000001"
                      status: "pending"
                      created_at: "2026-04-26T14:30:00Z"
                      jobs:
                        - ref: "main"
                          job_id: "019539ad-3333-7000-8000-000000000001"
                          status: "pending"
                          depends_on: []
                          operations:
                            - id: "019539ae-4444-7000-8000-000000000001"
                              type: "compress"
                              status: "pending"
                      delivery_plan:
                        mode: "individual"
                        selection_type: "terminal"
                        outputs:
                          - job_id: "019539ad-3333-7000-8000-000000000001"
                            operation: "compress"
                            reason: "leaf_terminal"
                        hidden_outputs: []
                      processing_plan:
                        jobs:
                          - job_id: "019539ad-3333-7000-8000-000000000001"
                            processing_class: "short_form"
                            execution_pool: "standard"
                            estimated_queue_seconds: { min: 1, p50: 3, max: 10 }
                            estimated_processing_seconds: { min: 5, p50: 12, max: 45 }
                            estimate_quality: "metadata_exact"
                            reason: "within_short_form_limits"
                        estimated_total_seconds: { min: 6, p50: 15, max: 55 }
                        concurrency_assumption:
                          max_parallel_long_form_jobs: 2
                      warnings: []
                batch:
                  summary: Batch compress (3 independent files)
                  value:
                    success: true
                    data:
                      workflow_id: "019539ac-2222-7000-8000-000000000002"
                      status: "pending"
                      created_at: "2026-04-26T14:30:00Z"
                      jobs:
                        - ref: "file1"
                          job_id: "019539ad-3333-7000-8000-000000000002"
                          status: "pending"
                          depends_on: []
                          operations:
                            - id: "019539ae-4444-7000-8000-000000000002"
                              type: "compress"
                              status: "pending"
                        - ref: "file2"
                          job_id: "019539ad-3333-7000-8000-000000000003"
                          status: "pending"
                          depends_on: []
                          operations:
                            - id: "019539ae-4444-7000-8000-000000000003"
                              type: "compress"
                              status: "pending"
                        - ref: "file3"
                          job_id: "019539ad-3333-7000-8000-000000000004"
                          status: "pending"
                          depends_on: []
                          operations:
                            - id: "019539ae-4444-7000-8000-000000000004"
                              type: "compress"
                              status: "pending"
                      delivery_plan:
                        mode: "individual"
                        selection_type: "terminal"
                        outputs:
                          - job_id: "019539ad-3333-7000-8000-000000000002"
                            operation: "compress"
                            reason: "leaf_terminal"
                          - job_id: "019539ad-3333-7000-8000-000000000003"
                            operation: "compress"
                            reason: "leaf_terminal"
                          - job_id: "019539ad-3333-7000-8000-000000000004"
                            operation: "compress"
                            reason: "leaf_terminal"
                        hidden_outputs: []
                      processing_plan:
                        jobs:
                          - job_id: "019539ad-3333-7000-8000-000000000002"
                            processing_class: "short_form"
                            execution_pool: "standard"
                            estimated_queue_seconds: { min: 1, p50: 3, max: 10 }
                            estimated_processing_seconds: { min: 5, p50: 12, max: 45 }
                            estimate_quality: "metadata_exact"
                            reason: "within_short_form_limits"
                          - job_id: "019539ad-3333-7000-8000-000000000003"
                            processing_class: "short_form"
                            execution_pool: "standard"
                            estimated_queue_seconds: { min: 1, p50: 3, max: 10 }
                            estimated_processing_seconds: { min: 5, p50: 12, max: 45 }
                            estimate_quality: "metadata_exact"
                            reason: "within_short_form_limits"
                          - job_id: "019539ad-3333-7000-8000-000000000004"
                            processing_class: "short_form"
                            execution_pool: "standard"
                            estimated_queue_seconds: { min: 1, p50: 3, max: 10 }
                            estimated_processing_seconds: { min: 5, p50: 12, max: 45 }
                            estimate_quality: "metadata_exact"
                            reason: "within_short_form_limits"
                        estimated_total_seconds: { min: 18, p50: 45, max: 165 }
                        concurrency_assumption:
                          max_parallel_long_form_jobs: 2
                      warnings: []
        '400':
          description: |
            Bad request — malformed request body, or a workflow-DAG
            structural violation caught before semantic validation. The
            `ErrorEnvelope.error` field carries a specific machine code (per
            [`g8PPkbNu`](https://trello.com/c/g8PPkbNu) — narrower
            DAG-validation codes; HTTP statuses unchanged, only the code
            narrows). Conformance-safe: `error` is a free string (no enum).
            - `WORKFLOW_EDGE_REFERENCES_UNKNOWN_JOB` — a `workflow_edges`
              entry, a job-level `source`, or an `inputs[].source` references
              a job `ref`/`id` not present in the request (one code across
              all unknown-job-reference sites).
            - `CYCLIC_JOB_OUTPUT_SOURCE_GRAPH` — an implicit cycle in the
              `job_output` source graph, detected during effective-input-MIME
              resolution (distinct from explicit `workflow_edges` cycles,
              which are 422 `CYCLIC_WORKFLOW_EDGES`).
            - `RESERVED_JOB_ID_PATTERN` — a user-supplied job `id` matches the
              reserved `^job_\d+$` pattern **and is the SOLE validation
              failure**; mixed-violation requests keep the aggregated generic
              `BAD_REQUEST`.
            - `BAD_REQUEST` — malformed JSON / generic body validation, or any
              mixed/aggregated violation set.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                edge_references_unknown_job:
                  summary: A workflow reference points at a job that does not exist
                  value:
                    success: false
                    error: "WORKFLOW_EDGE_REFERENCES_UNKNOWN_JOB"
                    message: "A workflow reference points at a job that does not exist."
                    message_key: "error.workflow_edge_references_unknown_job"
                reserved_job_id_pattern:
                  summary: Sole violation — a job id uses the reserved pattern
                  value:
                    success: false
                    error: "RESERVED_JOB_ID_PATTERN"
                    message: "Job id must not use the reserved pattern (job_<number>)."
                    message_key: "error.reserved_job_id_pattern"
                cyclic_job_output_source_graph:
                  summary: Implicit cycle in the job_output source graph
                  value:
                    success: false
                    error: "CYCLIC_JOB_OUTPUT_SOURCE_GRAPH"
                    message: "Job output sources form a cycle."
                    message_key: "error.cyclic_job_output_source_graph"
                bad_request_malformed:
                  summary: Malformed JSON / generic or mixed-violation body
                  value:
                    success: false
                    error: "BAD_REQUEST"
                    message: "Invalid workflow request"
        '403':
          description: |
            Forbidden — tier-quota or feature-tier restriction. Discriminated
            by `error_type`:
            - `tier_restriction`: request-level quota (rare on this
              endpoint; surfaces if a workflow body references a file
              the caller's tier cannot process — but these are usually
              caught at upload time).
            - `feature_tier_restricted`: workflow body references one or
              more operations / mime_groups / options gated by a higher
              subscription tier than the caller has. Each violation is
              listed in `violations[]` with `required_tier` populated.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/TierRestrictionResponse'
                  - $ref: '#/components/schemas/FeatureTierRestrictedResponse'
                discriminator:
                  propertyName: error_type
                  mapping:
                    tier_restriction: '#/components/schemas/TierRestrictionResponse'
                    feature_tier_restricted: '#/components/schemas/FeatureTierRestrictedResponse'
              examples:
                feature_tier_restricted:
                  summary: Free tier referencing a Pro-tier operation
                  value:
                    success: false
                    error: "Workflow references features that require a higher subscription tier"
                    error_type: "feature_tier_restricted"
                    violations:
                      - feature: "operation.image_watermark"
                        availability: "stable"
                        required_tier: "pro"
                        documentation_url: "https://docs.giveitsmaller.com/operations/image_watermark"
        '402':
          description: |
            Credit reservation failed (per ticket
            [I23](https://trello.com/c/DffjC3zm) F9 atomic-reservation
            invariant) — caller's `available_credits` (monthly +
            purchased + overdraft headroom) is insufficient for the
            server-computed cost estimate. **No** workflow row is
            created, **no** ledger entry is written, **no** SNS
            dispatch occurs. Caller refetches
            `GET /api/v2/credits/balance` for current numeric state
            and acts on `required_action` (`add_credits` /
            `upgrade_plan` / `wait_for_renewal`).

            **No numeric deficit fields** on this envelope (per plan
            v5 §F9 round 13) — frontend reads numeric state from the
            balance endpoint, not from the error envelope. Decoupling
            the error from numeric state lets the server change
            cost-model granularity (e.g. per-second micro-credits)
            without churning the 402 wire shape.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BalanceExhaustedResponse'
              examples:
                add_credits_pro_overdraft_exhausted:
                  summary: Pro tier; monthly + purchased + overdraft all exhausted
                  value:
                    success: false
                    error: "Credit balance exhausted; top-up required."
                    error_type: "balance_exhausted"
                    required_action: "add_credits"
                    links:
                      top_up: "https://giveitsmaller.com/account/billing/top-up"
                upgrade_plan_free_long_form:
                  summary: Free tier; long-form requires pro upgrade
                  value:
                    success: false
                    error: "Workflow requires more credits than the free tier provides."
                    error_type: "balance_exhausted"
                    required_action: "upgrade_plan"
                    links:
                      upgrade: "https://giveitsmaller.com/account/billing/upgrade"
                wait_for_renewal_imminent_cycle:
                  summary: Pro tier; cycle resets soon and post-renewal balance covers cost
                  value:
                    success: false
                    error: "Insufficient credits this cycle; balance refreshes in 18 hours."
                    error_type: "balance_exhausted"
                    required_action: "wait_for_renewal"
        '404':
          description: |
            A referenced upload was not found. Returns the stable machine
            code `UPLOAD_NOT_FOUND` (`message_key: "upload.not_found"`).

            Uploads are referenced via `jobs[].source` (a `WorkflowSource`
            with `type: upload`), including the `passthrough` source jobs
            that bridge an uploaded file into a multi-input operation —
            multi-input `inputs[].source` cannot reference an upload
            directly (`MultiInputSource` excludes the `upload` leaf).

            **BOLA/IDOR existence-mask (deliberate, security-reasoned):**
            this 404 is ALSO returned when the upload exists but is owned
            by a different identity (or an anonymous workflow references an
            owned upload) — reported as not-found, **never 403**, so the
            response does not reveal that another user's upload exists.
            Consistent with the workflow-owner 404s on
            cancel / resume / get-workflow. Ownerless (anonymous-intake)
            uploads have no owner to violate and stay usable by any caller.
            See [ADR-0016](../docs/decisions/0016-per-endpoint-auth-identity-modeling.md)
            §"Amendment — upload IDOR mask".
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "UPLOAD_NOT_FOUND"
                message: "Upload not found"
                message_key: "upload.not_found"
                locale: "en-GB"
        '422':
          description: |
            Semantically invalid request. One of:

            - Generic validation error (unknown operation type, invalid
              option combinations, multi-input job with multiple
              operations) — returned as `ValidationErrorEnvelope`
              (`error_type: "validation_error"`, per ADR-0018).
            - Cyclic explicit `workflow_edges` (a cycle or self-edge in the
              edge DAG) — returned as a `ValidationErrorEnvelope` with
              `error_type: "validation_error"`, `error:
              "CYCLIC_WORKFLOW_EDGES"`, `message_key:
              "error.cyclic_workflow_edges"`, and a `details[0].field =
              "workflow_edges"` entry (per
              [`g8PPkbNu`](https://trello.com/c/g8PPkbNu)). Implicit
              `job_output` source-graph cycles and unknown-job references are
              narrower **400** codes (`CYCLIC_JOB_OUTPUT_SOURCE_GRAPH` /
              `WORKFLOW_EDGE_REFERENCES_UNKNOWN_JOB`) — see the 400 response.
            - Feature-availability violation (workflow references one or
              more `planned` / `experimental` / non-enabled-`beta`
              features) — returned as `FeatureNotAvailableResponse`
              with `error_type: feature_not_available`. Per ADR-0001
              §1.3 Tension 1 rule, the server MUST honour tagged
              availability.
            - Processing-class band-ceiling overflow — returned as
              `ProcessingClassExceedsBandResponse` with `error_type:
              processing_class_exceeds_band`. Fires when one or more
              jobs cannot be classified because input size/duration
              exceeds the `long_form` (or `long_form_re_encode`)
              ceiling under the caller's effective per-tier caps
              (`per_tier_constraints` overlay per
              [ADR-0011](../docs/decisions/0011-per-tier-processing-class-constraints.md)).
              Per-violation `required_tier` names the tier (if any)
              whose `per_tier_constraints` would accommodate the
              request (`null` = no tier resolves, e.g. enterprise
              already at the 120 GB hard ceiling). Distinct from
              `TierRestrictionResponse` (403, request-level upload
              quota — see [ADR-0012](../docs/decisions/0012-processing-class-band-reject-envelope.md)
              for the band-vs-tier split). Per ADR-0001 §F6
              batched-violations rule, `violations[]` is fail-all.
            - Re-encode required (per ticket I16-CONS, Trello
              `7nCZXEru`). Returned for `merge.video` jobs with
              `re_encode_mode: never` when the compatibility probe
              detects mismatched inputs (codec, container, timebase,
              framerate, resolution, pixel format, profile/level,
              audio layout, rotation). Returned as
              `ValidationErrorEnvelope` with stable `error:
              "REQUIRES_REENCODE"`; `details[]` lists per-mismatched-
              field entries shaped as
              `{operation: "merge", option: "re_encode_mode", field:
              "inputs[i].<dim> vs inputs[j].<dim>", message: "..."}`.
              Caller resolves by switching to `re_encode_mode: auto`
              or `always`. The compatibility-probe algorithm is
              server-side and deliberately opaque per plan v5 §F8.2.

            **Branch dispatch via the `error_type` discriminator** (per
            [ADR-0018](../docs/decisions/0018-universal-422-error-type-discriminator.md)).
            All four branches carry a required `error_type` enum, so the
            `oneOf` declares an explicit `discriminator`
            (`propertyName: error_type`):
            - `validation_error` → `ValidationErrorEnvelope` (carries
              `details[]`; the specific failure is in the `error` machine
              code — `INVALID_OPTIONS`, `REQUIRES_REENCODE`,
              `CYCLIC_WORKFLOW_EDGES`, …). The `REQUIRES_REENCODE` flavour
              (per I16-CONS) reuses this same envelope/`error_type` and is
              distinguished only by its `error` code.
            - `feature_not_available` → `FeatureNotAvailableResponse`
              (`violations[]`).
            - `processing_class_exceeds_band` →
              `ProcessingClassExceedsBandResponse` (`violations[]`).
            - `probe_pending` → `ProbePendingResponse` (`job_ref`, no
              `violations[]`/`details[]`), emitted when the probe-pending
              gate is on and a job's upload has not yet been probed.

            The prior 422 shape is otherwise preserved unchanged — the
            `details[]` / `violations[]` / `job_ref` required-field shapes
            still hold, so structural duck-typing keeps working; the
            discriminator simply lets generated SDK clients dispatch on a
            single property instead of mis-firing `instanceOf` guards
            (camelCase-vs-snake_case). `error_type: validation_error` is a
            **new required field** on `ValidationErrorEnvelope` — the
            server populates it at every emission site (rollout coordinated
            with the API per ADR-0018).
          headers:
            Retry-After:
              required: false
              description: |
                Optional, best-effort. Present only on the
                `probe_pending` branch — suggested seconds to wait
                before re-polling the upload probe and retrying the
                workflow. Delta-seconds (RFC 9110), mirroring the 429
                login path's `Retry-After` convention. Absent until the
                server-side probe-staleness bound (API `e1Idv6UL`)
                lands; consumers MUST treat absence as "retry on your
                own backoff schedule."
              schema:
                type: integer
                minimum: 0
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ValidationErrorEnvelope'
                  - $ref: '#/components/schemas/FeatureNotAvailableResponse'
                  - $ref: '#/components/schemas/ProcessingClassExceedsBandResponse'
                  - $ref: '#/components/schemas/ProbePendingResponse'
                discriminator:
                  propertyName: error_type
                  mapping:
                    validation_error: '#/components/schemas/ValidationErrorEnvelope'
                    feature_not_available: '#/components/schemas/FeatureNotAvailableResponse'
                    processing_class_exceeds_band: '#/components/schemas/ProcessingClassExceedsBandResponse'
                    probe_pending: '#/components/schemas/ProbePendingResponse'
              examples:
                validation_error:
                  summary: Generic validation error (legacy shape)
                  value:
                    success: false
                    error_type: "validation_error"
                    error: "INVALID_OPTIONS"
                    details:
                      - operation: "compress"
                        option: "quality"
                        message: "Must be between 1 and 100"
                      - operation: "thumbnail"
                        option: "width"
                        message: "Required field"
                cyclic_workflow_edges:
                  summary: Cyclic or self-referential explicit workflow_edges (g8PPkbNu)
                  value:
                    success: false
                    error_type: "validation_error"
                    error: "CYCLIC_WORKFLOW_EDGES"
                    message: "Workflow edges form a cycle."
                    message_key: "error.cyclic_workflow_edges"
                    details:
                      - field: "workflow_edges"
                        message: "Workflow edges form a cycle."
                feature_not_available:
                  summary: Workflow references planned features
                  value:
                    success: false
                    error: "Workflow references features that are not yet available"
                    error_type: "feature_not_available"
                    violations:
                      - feature: "operation.audio_overlay"
                        availability: "planned"
                        eta: "2026-Q3"
                        documentation_url: "https://docs.giveitsmaller.com/operations/audio_overlay"
                      - feature: "operation.merge.mime_group.image.option.transition.dissolve"
                        availability: "experimental"
                probe_pending:
                  summary: |
                    Probe-pending gate is on and a single-op video
                    compress references an upload whose server-side
                    probe has not yet landed. Client polls the upload
                    probe endpoint, then re-POSTs the workflow. The
                    `Retry-After` header (5s here) hints the poll delay.
                  value:
                    success: false
                    error: "Upload probe has not completed; poll the upload probe endpoint and retry"
                    error_type: "probe_pending"
                    job_ref: "job_0"
                    message_key: "error.probe_pending"
                processing_class_exceeds_band_combined_size:
                  summary: |
                    merge job whose summed inputs (130 GB) exceed the
                    Enterprise long_form_re_encode ceiling (120 GB
                    hard cap per ADR-0011). Terminal — no tier
                    resolves it; `required_tier` is null.
                  value:
                    success: false
                    error: "Workflow contains inputs that exceed the long-form processing-class ceiling"
                    error_type: "processing_class_exceeds_band"
                    violations:
                      - reason: "combined_size_exceeds_long_form"
                        job_ref: "stitched"
                        operation: "merge"
                        processing_class: "long_form_re_encode"
                        actual: 130000000000
                        ceiling: 120000000000
                        required_tier: null
                        documentation_url: "https://docs.giveitsmaller.com/processing-class/long-form"
                processing_class_exceeds_band_single_input_duration:
                  summary: |
                    compress.video job whose single input duration
                    (15 hours = 54000s) exceeds the Pro long_form
                    `max_input_duration` (PT12H = 43200s). Upgrade to
                    Enterprise resolves the duration cap.
                  value:
                    success: false
                    error: "Workflow contains inputs that exceed the long-form processing-class ceiling"
                    error_type: "processing_class_exceeds_band"
                    violations:
                      - reason: "input_duration_exceeds_long_form"
                        job_ref: "job_0"
                        operation: "compress"
                        processing_class: "long_form"
                        actual: 54000
                        ceiling: 43200
                        required_tier: "enterprise"
                requires_reencode:
                  summary: merge.video re_encode_mode=never with incompatible inputs (I16-CONS)
                  value:
                    success: false
                    error_type: "validation_error"
                    error: "REQUIRES_REENCODE"
                    details:
                      - operation: "merge"
                        option: "re_encode_mode"
                        field: "inputs[0].codec vs inputs[1].codec"
                        message: "Inputs use different codecs (h264 vs h265); set re_encode_mode to auto or always."
                      - operation: "merge"
                        option: "re_encode_mode"
                        field: "inputs[0].framerate vs inputs[1].framerate"
                        message: "Inputs use different frame rates (29.97 vs 30); set re_encode_mode to auto or always."
        '429':
          description: |
            Rate limited. Two distinct triggers share this status — branch
            on the `error` code, NOT on the status:

            1. **Long-form concurrency cap** — the caller already holds the
               maximum number of concurrent in-flight long-form (Fargate)
               workflows their tier permits (Pro 2 / Max 5; Enterprise +
               Free uncapped). Body is `LongFormConcurrencyLimitResponse`:
               `error: LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED`, `message_key:
               job.long_form_concurrency_exceeded`, a `links.upgrade` CTA,
               and **no `Retry-After`** — the limit clears when an in-flight
               long-form workflow finishes, not on a timer.
            2. **Infrastructure rate-limit** — too many requests in the
               current window. The links-absent subset of the same shape: a
               plain `ErrorEnvelope` with a `Retry-After` header.
          headers:
            Retry-After:
              description: |
                Seconds to wait before retrying. Delta-seconds (RFC 9110).
                Present ONLY for the infrastructure rate-limit trigger —
                ABSENT for `LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED` (that
                limit clears on workflow completion, not on a timer).
              schema:
                type: integer
                minimum: 0
              example: 60
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LongFormConcurrencyLimitResponse'
              examples:
                long_form_concurrency_exceeded:
                  summary: Caller already holds the max concurrent long-form workflows for their tier
                  value:
                    success: false
                    error: "LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED"
                    message: "You already have the maximum of 5 concurrent long-form workflows for your tier. Wait for one to finish or upgrade for a higher limit."
                    message_key: "job.long_form_concurrency_exceeded"
                    locale: "en-GB"
                    links:
                      upgrade: "https://giveitsmaller.com/pricing"
                infrastructure_rate_limit:
                  summary: Too many requests in the current window (generic throttle, carries Retry-After)
                  value:
                    success: false
                    error: "Too many requests. Please try again later."
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/workflows/{id}/status:
    get:
      summary: Get workflow status
      description: |
        Retrieve the current status of a workflow with nested job and operation breakdown.
        Each operation includes its current progress and, when completed, its result
        (download URL, size, metrics).
      operationId: getWorkflowStatus
      security: [{}, {bearerAuth: []}, {sessionAuth: []}]  # optional (anon-OK; identity-scoped to workflow owner)
      x-identity-scoped: true  # workflow-ownership-scoped per ADR-0016 D3
      tags:
        - Workflow
      parameters:
        - name: id
          in: path
          required: true
          description: Workflow ID (UUID v7)
          schema:
            $ref: '#/components/schemas/UuidV7'
        - name: X-Workflow-Capability
          in: header
          required: false
          schema:
            type: string
          description: |
            Capability token (the `cap` from the anonymous workflow-create
            response) authorizing this read for a **null-owner** workflow.
            Optional — an authenticated owner does not need it. A wrong or
            missing cap on a null-owner workflow returns 404 `WorkflowNotFound`
            (no existence oracle — not 401/403). Per ticket
            [`YQt88cq2`](https://trello.com/c/YQt88cq2).
      responses:
        '200':
          description: Workflow status retrieved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkflowStatusSuccessEnvelope'
              examples:
                in_progress:
                  summary: Workflow in progress
                  value:
                    success: true
                    data:
                      workflow_id: "019539ac-2222-7000-8000-000000000001"
                      status: "in_progress"
                      created_at: "2026-04-26T14:30:00Z"
                      updated_at: "2026-04-26T14:30:15Z"
                      jobs:
                        - ref: "main"
                          job_id: "019539ad-3333-7000-8000-000000000001"
                          status: "in_progress"
                          depends_on: []
                          operations:
                            - id: "019539ae-4444-7000-8000-000000000001"
                              type: "compress"
                              status: "in_progress"
                              progress: 65
                completed:
                  summary: Workflow completed
                  value:
                    success: true
                    data:
                      workflow_id: "019539ac-2222-7000-8000-000000000001"
                      status: "completed"
                      created_at: "2026-04-26T14:30:00Z"
                      updated_at: "2026-04-26T14:30:42Z"
                      jobs:
                        - ref: "main"
                          job_id: "019539ad-3333-7000-8000-000000000001"
                          status: "completed"
                          depends_on: []
                          operations:
                            - id: "019539ae-4444-7000-8000-000000000001"
                              type: "compress"
                              status: "completed"
                              result:
                                download_url: "https://cdn.giveitsmaller.com/jobs/019539ad-.../019539ae-.../photo.jpg?token=..."
                                size_bytes: 1105920
                                metrics:
                                  compression_ratio: 0.45
                                  duration_ms: 2340
        '404':
          description: Workflow not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/workflows/{id}/downloads:
    get:
      summary: Get download URLs
      description: |
        Get download URLs for all completed operation outputs in a workflow.
        Each job's operations are listed with their output files and pre-signed
        download URLs.

        When a workflow-level "download all" bundle (zip of every output) has
        been produced, the response also carries a `bundle` object with its own
        pre-signed URL. The `bundle` is **absent until ready** (see
        `DownloadBundle`); the per-file `downloads` array is always present.
      operationId: getWorkflowDownloads
      security: [{}, {bearerAuth: []}, {sessionAuth: []}]  # optional (anon-OK; identity-scoped to workflow owner)
      x-identity-scoped: true  # workflow-ownership-scoped per ADR-0016 D3
      tags:
        - Workflow
      parameters:
        - name: id
          in: path
          required: true
          description: Workflow ID (UUID v7)
          schema:
            $ref: '#/components/schemas/UuidV7'
        - name: X-Workflow-Capability
          in: header
          required: false
          schema:
            type: string
          description: |
            Capability token (the `cap` from the anonymous workflow-create
            response) authorizing this read for a **null-owner** workflow.
            Optional — an authenticated owner does not need it. A wrong or
            missing cap on a null-owner workflow returns 404 `WorkflowNotFound`
            (no existence oracle — not 401/403). Per ticket
            [`YQt88cq2`](https://trello.com/c/YQt88cq2).
      responses:
        '200':
          description: Download URLs retrieved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkflowDownloadSuccessEnvelope'
              example:
                success: true
                data:
                  downloads:
                    - ref: "main"
                      job_id: "019539ad-3333-7000-8000-000000000001"
                      files:
                        - operation: "compress"
                          operation_id: "019539ae-4444-7000-8000-000000000001"
                          filename: "photo.jpg"
                          size_bytes: 1105920
                          download_url: "https://cdn.giveitsmaller.com/jobs/019539ad-.../019539ae-.../photo.jpg?token=..."
                  bundle:
                    download_url: "https://cdn.giveitsmaller.com/bundles/019539ad-.../all-outputs.zip?token=..."
                    size_bytes: 1184912
                    expires_at: "2026-06-21T18:00:00Z"
        '404':
          description: Workflow not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/workflows/{id}/events:
    get:
      summary: Stream workflow events (SSE)
      description: |
        Server-Sent Events endpoint for real-time workflow progress. The server pushes
        events as workflow, job, and operation statuses change.

        The connection stays open until the workflow reaches a terminal state
        (`completed`, `failed`, `partially_failed`) or the client disconnects.

        **Event types:**
        - `operation.progress` — operation progress update (progress percentage)
        - `operation.completed` — individual operation finished successfully
        - `operation.failed` — individual operation failed
        - `job.completed` — all operations in a job finished
        - `job.failed` — job failed
        - `workflow.completed` — all jobs completed successfully
        - `workflow.failed` — all jobs finished, at least one failed
        - `workflow.partially_failed` — some jobs succeeded, some failed

        **Event data format:**
        ```
        event: operation.progress
        data: {"job_ref":"main","operation_id":"op-uuid","type":"compress","progress":65}

        event: operation.completed
        data: {"job_ref":"main","operation_id":"op-uuid","type":"compress","status":"completed","progress":100}

        event: workflow.completed
        data: {"workflow_id":"wf-uuid","status":"completed"}
        ```

        **Reconnect & delivery semantics:**

        Delivery is **at-least-once per connection with no client-driven
        resume**. The server does **not** read an inbound `Last-Event-ID`
        header; each connection re-derives state from scratch and replays
        every underlying stream from the start. Consequently **terminal events
        (`operation.completed` / `operation.failed`, `job.completed` /
        `job.failed`, `workflow.completed` / `workflow.failed` /
        `workflow.partially_failed`) are re-delivered on every (re)connection**.
        Within a single connection events are not duplicated (the cursor
        advances as events are sent), so duplicates only arise across
        reconnects. Clients MUST therefore treat the stream as idempotent and
        dedupe terminal events themselves (e.g. by `operation_id` + event type,
        or by workflow/job terminal status) rather than relying on
        resume-from-cursor. Resume-from-`Last-Event-ID` is **not** implemented;
        if added later it will be a separate, explicitly-specified feature.

        **`id:` field (SSE event id):** emitted **only on operation frames**
        (`operation.progress` / `operation.completed` / `operation.failed`),
        where it carries the underlying stream's event id. **Transition frames**
        (`job.completed` / `job.failed`, `workflow.*`) carry **no `id:`**.
        Because there is no `Last-Event-ID` resume, any emitted `id:` is
        informational only — clients cannot use it to resume a stream.

        **Framing vs payload:** the `Sse*Data` component schemas describe only
        the `data:` JSON payload of each event. The SSE envelope framing itself
        (the `id:` and `event:` lines) is transport-level and intentionally
        **not** schema-described.
      operationId: streamWorkflowEvents
      security: [{}, {bearerAuth: []}, {sessionAuth: []}]  # optional (anon-OK; identity-scoped to workflow owner)
      x-identity-scoped: true  # workflow-ownership-scoped per ADR-0016 D3
      tags:
        - Workflow
      parameters:
        - name: id
          in: path
          required: true
          description: Workflow ID (UUID v7)
          schema:
            $ref: '#/components/schemas/UuidV7'
        - name: X-Workflow-Capability
          in: header
          required: false
          schema:
            type: string
          description: |
            Capability token (the `cap` from the anonymous workflow-create
            response) authorizing this read for a **null-owner** workflow.
            Optional — an authenticated owner does not need it. A wrong or
            missing cap on a null-owner workflow returns 404 `WorkflowNotFound`
            (no existence oracle — not 401/403). Per ticket
            [`YQt88cq2`](https://trello.com/c/YQt88cq2).

            **SSE transport note:** native browser `EventSource` cannot set
            request headers (its constructor accepts only `url` + `withCredentials`
            per the WHATWG SSE spec), so an anonymous caller must reach this
            endpoint with a header-capable SSE client (a `fetch`-based
            `EventSource` implementation). The cap is deliberately NOT accepted as
            a query parameter — that would leak the bearer into URLs / logs /
            referrers.
      responses:
        '200':
          description: SSE event stream
          content:
            text/event-stream:
              schema:
                type: string
                description: |
                  Server-Sent Events stream. Each event has an `event` field (type)
                  and a `data` field (JSON payload). See endpoint description for
                  event types and payload shapes.
        '404':
          description: Workflow not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/workflows/{id}/cancel:
    post:
      summary: Cancel a workflow
      description: |
        Cancel a workflow. Per ticket
        [I24 `e50uXLcl`](https://trello.com/c/e50uXLcl).

        Transitions the workflow to `cancelled`. Idempotent —
        cancelling an already-cancelled workflow returns 200 with the
        same shape. Cancelling a `completed` / `failed` /
        `partially_failed` / `expired` workflow is a 409 (already
        terminal — this endpoint cannot reverse those states).

        The response's `billing_effect` field tells the caller what
        happened to outstanding reservations:
        - `unspent_reservation_released`: the workflow was in
          `pending` / `in_progress` / `paused_insufficient_credits`,
          and the unspent portion of the original reservation has
          been refunded to the caller's balance. The refund appears
          as a separate `CreditTransaction` with `type: refund`.
        - `none`: no refund was issued (e.g. all reserved credits
          were already consumed by completed jobs at cancel time;
          or the workflow was already terminal in a previous cancel
          request, idempotent re-cancel).

        In-flight operations may continue to run briefly after
        cancellation while their Lambda processes terminate; the
        cancel response is the binding "no further reservations
        will be made" signal.
      operationId: cancelWorkflow
      security: [{bearerAuth: []}, {sessionAuth: []}]  # required (explicit 401 in response set)
      x-identity-scoped: true  # workflow-ownership-scoped per ADR-0016 D3
      tags:
        - Workflow
      parameters:
        - name: id
          in: path
          required: true
          description: Workflow ID (UUID v7)
          schema:
            $ref: '#/components/schemas/UuidV7'
      responses:
        '200':
          description: Workflow cancelled (or already cancelled — idempotent).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkflowCancelSuccessEnvelope'
              examples:
                cancelled_with_refund:
                  summary: Cancel released unspent reservation
                  value:
                    success: true
                    data:
                      workflow_id: "019539ac-2222-7000-8000-000000000001"
                      status: "cancelled"
                      cancelled_at: "2026-04-26T14:00:00Z"
                      billing_effect: "unspent_reservation_released"
                cancelled_no_refund:
                  summary: Cancel after all reservations consumed
                  value:
                    success: true
                    data:
                      workflow_id: "019539ac-2222-7000-8000-000000000002"
                      status: "cancelled"
                      cancelled_at: "2026-04-26T14:00:00Z"
                      billing_effect: "none"
                idempotent_recancel:
                  summary: Cancelling already-cancelled workflow
                  value:
                    success: true
                    data:
                      workflow_id: "019539ac-2222-7000-8000-000000000003"
                      status: "cancelled"
                      cancelled_at: "2026-04-26T13:55:00Z"
                      billing_effect: "none"
        '401':
          description: Authentication required.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '404':
          description: Workflow not found OR not owned by the caller (same shape — no ownership leak).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '409':
          description: |
            Workflow is in a terminal state (`completed`, `failed`,
            `partially_failed`, `expired`) and cannot be cancelled —
            this endpoint cannot reverse those states. Caller should
            check status via `GET /api/workflows/{id}/status`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/workflows/{id}/resume:
    post:
      summary: Resume a paused workflow
      description: |
        Resume a workflow that is in `paused_insufficient_credits`.
        Per ticket [I24 `e50uXLcl`](https://trello.com/c/e50uXLcl).

        Resume succeeds only when the caller's `available_credits`
        cover the next reservation the server would attempt. If the
        balance is still insufficient, the response is 402
        `BalanceExhaustedResponse` (mirroring the workflow-create
        flow per ticket I23) and the workflow remains
        `paused_insufficient_credits`.

        If the workflow is past its `expires_at` (default 7-day TTL
        from `paused_at`), the response is 422 `workflow_expired` and
        the workflow has transitioned to `expired`. Callers cannot
        un-expire a workflow; create a new workflow instead.

        Resuming a workflow not in `paused_insufficient_credits` is
        a 409 (no-op — the workflow is already running, terminal,
        or cancelled).
      operationId: resumeWorkflow
      security: [{bearerAuth: []}, {sessionAuth: []}]  # required (explicit 401 in response set)
      x-identity-scoped: true  # workflow-ownership-scoped per ADR-0016 D3
      tags:
        - Workflow
      parameters:
        - name: id
          in: path
          required: true
          description: Workflow ID (UUID v7)
          schema:
            $ref: '#/components/schemas/UuidV7'
      responses:
        '200':
          description: Workflow resumed; status is now `in_progress`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkflowResumeSuccessEnvelope'
              example:
                success: true
                data:
                  workflow_id: "019539ac-2222-7000-8000-000000000001"
                  status: "in_progress"
                  resumed_at: "2026-04-26T14:00:00Z"
        '401':
          description: Authentication required.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '402':
          description: |
            Credit balance still insufficient to cover the next
            reservation. Same envelope as workflow-create 402 (per
            ticket I23). Caller tops up via the `top_up` link, then
            retries this endpoint.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BalanceExhaustedResponse'
        '404':
          description: Workflow not found OR not owned by the caller.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '409':
          description: |
            Workflow is not in `paused_insufficient_credits`. Caller
            should check status via `GET /api/workflows/{id}/status`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '422':
          description: |
            Workflow is past its `expires_at` and has transitioned to
            `expired` — cannot be resumed. Discriminated by
            `error_type: workflow_expired`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkflowExpiredResponse'
              example:
                success: false
                error: "Workflow expired and cannot be resumed."
                error_type: "workflow_expired"
                expired_at: "2026-04-26T14:00:00Z"
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  # ============================================
  # OPERATIONS ENDPOINTS
  # ============================================

  /api/operations/schema:
    get:
      summary: Get operation schema
      description: |
        Returns the operations schema describing all available operation
        types, their options, constraints, defaults, MIME-type
        applicability, and **availability metadata** (per ADR-0001 §1.3 +
        §1.4; tickets I1, I17).

        **This endpoint does NOT use the standard ResponseEnvelope.** It
        returns the raw schema JSON directly.

        **Tier-scoped per caller.** Authenticated callers receive a
        response shaped by their subscription tier (`free` / `pro` /
        `enterprise`); features gated by `required_tier` render with the
        appropriate availability tag for the caller's tier. Anonymous
        (unauthenticated) callers receive the `free` tier baseline with
        `user_tier: null`.

        **Per-tier private caching.** Per ADR-0002, the response is
        per-caller; CDN-style public caching is NOT used. Clients
        revalidate via the strong `ETag` — send `If-None-Match` on
        subsequent requests; the server returns `304 Not Modified` when
        the cached response is still fresh. The content `ETag` is the
        **sole** conditional validator (per `sUyA9ZXD`): `If-Modified-Since`
        is **not** honored and `Last-Modified` is informational only.

        Cache-key composition (server-side): `user_tier + schema_version + capabilities_version + environment`.

        Supports optional query parameters to filter the schema:
        - `mime_type`: Filter to operations available for a specific MIME type
        - `operation`: Filter to a specific operation type

        The schema includes conditional validation rules using
        `depends_on` (e.g. `crf` is only valid when `encoding_mode: crf`
        for compress video).
        Per-enum-value availability uses the `per_value_availability` map
        on `OptionSchema` (ticket I17). Clients should use these to build
        dynamic forms with availability-aware UX.
      operationId: getOperationsSchema
      security: [{}, {bearerAuth: []}, {sessionAuth: []}]  # optional (anon-OK returns free-tier baseline; auth tier-scopes the response)
      tags:
        - Operations
      parameters:
        - name: mime_type
          in: query
          required: false
          description: Filter by MIME type (e.g. `image/jpeg`, `video/mp4`)
          schema:
            type: string
            example: "image/jpeg"
        - name: operation
          in: query
          required: false
          description: Filter by operation type
          schema:
            $ref: '#/components/schemas/OperationType'
        - name: If-None-Match
          in: header
          required: false
          description: |
            Conditional revalidation: send the previously-received `ETag`
            value to receive `304 Not Modified` when the response is
            still fresh. Strong-ETag comparison.
          schema:
            type: string
          example: '"v2-pro-49"'
        - name: If-Modified-Since
          in: header
          required: false
          description: |
            **NOT honored for conditional `304` (per `sUyA9ZXD`).** The
            server ignores `If-Modified-Since`; the content `ETag`
            (`If-None-Match`) is the sole conditional validator. Retained
            for backward compatibility — sending it has no effect on
            freshness; use `If-None-Match` instead.
          schema:
            type: string
            format: http-date
          example: "Sun, 26 Apr 2026 09:00:00 GMT"
      responses:
        '200':
          description: |
            Operation schema (raw JSON, no ResponseEnvelope). Tier-scoped
            per caller; revalidate via the `ETag` (`If-None-Match`).
          headers:
            Cache-Control:
              schema:
                type: string
              description: |
                Per-tier private caching. Public CDN caching is NOT used
                because the cache varies per caller's `user_tier`. Use
                ETag-based revalidation for freshness.
              example: "private, max-age=300, must-revalidate"
            ETag:
              schema:
                type: string
              description: |
                Strong entity tag. Clients send `If-None-Match: <etag>`
                on subsequent requests to revalidate; the server returns
                `304 Not Modified` when the response is still fresh.
              example: '"v2-pro-49"'
            Last-Modified:
              schema:
                type: string
                format: http-date
              description: |
                HTTP-date of last response generation. **Informational
                only** — NOT a conditional validator (the server does not
                honor `If-Modified-Since`, per `sUyA9ZXD`). Use the `ETag`
                (`If-None-Match`) for revalidation.
              example: "Sun, 26 Apr 2026 09:00:00 GMT"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OperationsSchemaResponse'
              examples:
                full_schema:
                  summary: Full schema (truncated for brevity)
                  value:
                    schema_version: "2.0.0"
                    capabilities_version: 49
                    generated_at: "2026-04-26T09:00:00Z"
                    user_tier: "pro"
                    operations:
                      compress:
                        description: "Reduce file size while maintaining acceptable quality"
                        default: true
                        input_model: "single"
                        # availability absent → stable (parser obligation per ADR-0001 §1.4)
                        # Real shape: options live inside mime_groups
                        # (image / audio / video / document_*). Per
                        # ticket IXE7QDxe — operation-level `options`
                        # is omitted for ops with mime_groups.
                        mime_groups:
                          # Illustrative shape only. Real compress.image ships
                          # per-input-format groups (image_jpeg/image_png/
                          # image_avif/image); image compress is lossy-only
                          # (Option B) — no `mode`/`icc_profile`.
                          image_jpeg:
                            mimes: ["image/jpeg"]
                            options:
                              quality:
                                type: "integer"
                                min: 1
                                max: 100
                              progressive:
                                type: "boolean"
                      thumbnail:
                        description: "Generate a preview image from the source file"
                        default: false
                        input_model: "single"
                        # Real shape: options live inside mime_groups.
                        mime_groups:
                          image:
                            mimes: ["image/jpeg", "image/png", "image/webp"]
                            options:
                              width:
                                type: "integer"
                                min: 1
                                max: 4096
                                required: true
                              height:
                                type: "integer"
                                min: 1
                                max: 4096
                                required: true
                              fit:
                                type: "enum"
                                values: ["max", "crop", "scale"]
        '304':
          description: |
            Cached response is still fresh (the client's `If-None-Match`
            matched the current content `ETag`). Server emits `ETag` +
            (informational) `Last-Modified` headers. `If-Modified-Since`
            does not drive this response (per `sUyA9ZXD`). Body empty.
          headers:
            Cache-Control:
              schema:
                type: string
              description: Same as the 200 response header.
              example: "private, max-age=300, must-revalidate"
            ETag:
              schema:
                type: string
              example: '"v2-pro-49"'
            Last-Modified:
              schema:
                type: string
                format: http-date
              example: "Sun, 26 Apr 2026 09:00:00 GMT"
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/operations/{id}/retry:
    post:
      summary: Retry a failed operation
      description: |
        Retry a failed operation without recreating the entire workflow. Creates a new
        operation that replaces the failed one, using the same source file and options.

        The original failed operation is marked as `retried` and a new operation with
        a new ID is created in `pending` state.
      operationId: retryOperation
      security: [{}, {bearerAuth: []}, {sessionAuth: []}]  # optional (anon-OK; identity-scoped to operation owner)
      x-identity-scoped: true  # operation-ownership-scoped per ADR-0016 D3
      tags:
        - Operations
      parameters:
        - name: id
          in: path
          required: true
          description: Failed operation ID (UUID v7)
          schema:
            $ref: '#/components/schemas/UuidV7'
      responses:
        '202':
          description: Retry accepted, new operation queued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RetrySuccessEnvelope'
              example:
                success: true
                data:
                  operation_id: "019539af-5555-7000-8000-000000000001"
                  original_operation_id: "019539ae-4444-7000-8000-000000000001"
                  status: "pending"
        '404':
          description: Operation not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '409':
          description: Operation is not in a failed state, or has already been retried
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "Operation is not in a failed state"
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  # ============================================
  # HEALTH ENDPOINTS
  # ============================================

  /healthz:
    get:
      summary: Liveness probe
      description: Kubernetes liveness probe endpoint
      operationId: liveness
      security: []  # anonymous (k8s probe — auth irrelevant)
      tags:
        - Health
      responses:
        '200':
          description: Application is alive
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LivenessResponse'
              example:
                app: true

  /readyz:
    get:
      summary: Readiness probe
      description: Kubernetes readiness probe endpoint - checks database and cache connectivity
      operationId: readiness
      security: []  # anonymous (k8s probe — auth irrelevant)
      tags:
        - Health
      responses:
        '200':
          description: Application is ready to receive traffic
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReadinessResponse'
        '503':
          description: Application is not ready
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReadinessResponse'

  # ============================================
  # AUTH ENDPOINTS
  # ============================================

  /api/auth/login:
    post:
      summary: Authenticate user and establish session
      description: |
        Authenticates an email/password pair. On success, the Symfony firewall
        sets a session cookie on the response; subsequent requests authenticate
        via that cookie.

        Failure responses distinguish between:
          - `401 invalid_credentials` — wrong password OR correct password on an
            unverified account (collapsed for anti-enumeration).
          - `403 account_locked|account_disabled|account_deleted|account_deletion_expired`
            — persisted account-state failures.
          - `429` — infrastructure rate-limit (too many attempts in the current
            window). Distinct from `account_locked`, which is persisted state.
      operationId: loginUser
      security: []  # anonymous (entry point — you're logging in, no creds yet)
      tags:
        - Auth
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
                - password
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 254
                password:
                  type: string
                  minLength: 1
            example:
              email: "user@example.com"
              password: "s3cret"
      responses:
        '200':
          description: |
            Authentication successful. A session cookie is issued via the
            `Set-Cookie` response header; subsequent requests authenticate via
            that cookie (Symfony firewall-managed).
          headers:
            Set-Cookie:
              description: Session cookie issued by the Symfony firewall.
              schema:
                type: string
              example: "PHPSESSID=abc123; Path=/; HttpOnly; Secure; SameSite=Lax"
          content:
            application/json:
              schema:
                type: object
                required:
                  - success
                  - data
                properties:
                  success:
                    type: boolean
                    enum: [true]
                  data:
                    type: object
                    required:
                      - user
                    properties:
                      user:
                        type: object
                        required:
                          - id
                          - email
                        properties:
                          id:
                            $ref: '#/components/schemas/UuidV7'
                          email:
                            type: string
                            format: email
                            maxLength: 254
                          name:
                            type:
                              - string
                              - "null"
                          tier:
                            type:
                              - string
                              - "null"
              example:
                success: true
                data:
                  user:
                    id: "019539ab-1111-7000-8000-000000000001"
                    email: "user@example.com"
                    name: "Jane Doe"
                    tier: "free"
        '400':
          description: Malformed request body (missing/invalid `email` or `password`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "Request body is invalid."
        '401':
          description: |
            Invalid credentials, or correct password on an unverified account
            (collapsed into `invalid_credentials` for anti-enumeration). The
            `error` field carries the human-readable message; consumers must
            not assume a particular phrasing across releases.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthErrorResponse'
              example:
                success: false
                error: "Invalid credentials."
                error_type: "invalid_credentials"
        '403':
          description: |
            Account-status failure — `error_type` is one of `account_locked`,
            `account_disabled`, `account_deleted`, or `account_deletion_expired`.
            These reflect persisted account state, not infrastructure throttling
            (for that see 429).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthErrorResponse'
              examples:
                accountLocked:
                  summary: Account is in a persisted locked state
                  value:
                    success: false
                    error: "Account is temporarily locked. Please try again later."
                    error_type: "account_locked"
                accountDisabled:
                  summary: Account disabled by administrator
                  value:
                    success: false
                    error: "This account has been disabled."
                    error_type: "account_disabled"
                accountDeleted:
                  summary: Account has been deleted
                  value:
                    success: false
                    error: "This account has been deleted."
                    error_type: "account_deleted"
                accountDeletionExpired:
                  summary: Deletion grace period has expired
                  value:
                    success: false
                    error: "This account has been scheduled for deletion and the grace period has expired."
                    error_type: "account_deletion_expired"
        '429':
          description: |
            Rate limit exceeded (too many login attempts in the current window).
            Distinct from `403 account_locked`: this is infrastructure-level
            throttling, not persisted account state, and carries no `error_type`.
            This is the first 429 in this spec to declare a `Retry-After`
            response header — future rate-limited endpoints should follow the
            same pattern.
          headers:
            Retry-After:
              description: Seconds to wait before retrying. Delta-seconds (RFC 9110).
              schema:
                type: integer
                minimum: 0
              example: 60
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "Too many login attempts. Please try again later."
        '500':
          description: Internal server error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/auth/logout:
    post:
      summary: Invalidate the current session
      description: |
        Logs the caller out by invalidating the session cookie issued
        at `POST /api/auth/login`. The Symfony firewall clears the
        session server-side; the response's `Set-Cookie` header
        carries an expired session cookie so the browser drops its
        local copy.

        Idempotent — calling logout without an active session
        returns 401, but calling it twice in a row from the same
        session returns 200 on the first call and 401 on the
        subsequent call. SDKs treat 200 and 401 both as "logged
        out" for typed-client logout-flow correctness.

        Per ticket
        [FX6mbTJD](https://trello.com/c/FX6mbTJD) — contracts-align
        sweep for the auth surface (the API has shipped this
        endpoint since `AuthController.php:19-22`; this declaration
        catches the contract up).
      operationId: logoutUser
      security: [{bearerAuth: []}, {sessionAuth: []}]  # required (logout requires an authenticated principal to invalidate)
      x-identity-scoped: true  # identity-bound (caller's own session) per ADR-0016 D3 — by symmetry with credits/balance per §D4 sample
      tags:
        - Auth
      responses:
        '200':
          description: |
            Logout successful. The Symfony firewall has invalidated
            the session server-side; the response's `Set-Cookie`
            header carries an expired session cookie.
          headers:
            Set-Cookie:
              description: |
                Expired session cookie clearing the browser's local
                copy.
              schema:
                type: string
              example: "PHPSESSID=deleted; Path=/; HttpOnly; Secure; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptySuccessEnvelope'
              example:
                success: true
                data: {}
        '401':
          description: |
            No active session to invalidate. Returned when logout is
            called without authentication, OR when the session was
            already invalidated by a prior logout call (idempotent
            re-call of logout). SDKs treat this as "already logged
            out" rather than as an error condition.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "No active session"
        '429':
          description: |
            Rate limit exceeded. Returned when the rate-limiter
            window catches abusive logout patterns; rare in practice.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/auth/register:
    post:
      summary: Register a new account
      description: |
        Creates an unverified account from an email/password pair and
        dispatches a verification email. The account exists but cannot
        authenticate until the verification token is redeemed at
        `POST /api/auth/verify-email`.

        Disposable / blocklisted email domains and other invalid-argument
        rejections collapse into a non-validation `422` envelope
        (`error: UNPROCESSABLE_ENTITY`, no `details[]`) — distinct from the
        structured `ValidationErrorEnvelope` returned for malformed input.
      operationId: registerUser
      security: []  # anonymous (sign-up entry point — no creds yet)
      tags:
        - Auth
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
                - password
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 254
                password:
                  type: string
                  minLength: 8
                  maxLength: 128
            example:
              email: "user@example.com"
              password: "s3cretpw"
      responses:
        '201':
          description: |
            Account created. A verification email has been dispatched; the
            account cannot authenticate until verified.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptySuccessEnvelope'
              example:
                success: true
                data: {}
        '400':
          description: Request body is invalid (malformed JSON, wrong field types, or body too large).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "Request body is invalid."
        '422':
          description: |
            Structured input validation failure (`ValidationErrorEnvelope`,
            `error_type: validation_error`).

            A non-validation `422` (`error: UNPROCESSABLE_ENTITY`, no
            `details[]`) is also returned for disposable/blocklisted email
            domains.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ValidationErrorEnvelope'
                  - $ref: '#/components/schemas/AuthRejectionEnvelope'
                discriminator:
                  propertyName: error_type
                  mapping:
                    validation_error: '#/components/schemas/ValidationErrorEnvelope'
                    unprocessable_entity: '#/components/schemas/AuthRejectionEnvelope'
              example:
                success: false
                error_type: validation_error
                error: "Validation failed"
                details:
                  - field: "password"
                    message: "This value is too short."
        '429':
          description: |
            Rate limit exceeded (too many registration attempts in the
            current window). Carries a `Retry-After` response header per the
            convention established at `POST /api/auth/login`.
          headers:
            Retry-After:
              description: Seconds to wait before retrying. Delta-seconds (RFC 9110).
              schema:
                type: integer
                minimum: 0
              example: 60
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/auth/verify-email:
    post:
      summary: Verify an account email address
      description: |
        Redeems a verification token emailed at registration time, marking
        the account verified so it can authenticate. A failed or expired
        verification collapses into a non-validation `422` envelope
        (no `details[]`) — distinct from the structured
        `ValidationErrorEnvelope` returned for malformed input.
      operationId: verifyEmail
      security: []  # anonymous (token-bearer — pre-authentication)
      tags:
        - Auth
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - token
              properties:
                token:
                  type: string
                  minLength: 1
                  maxLength: 128
            example:
              token: "8f3c1d9a4b2e6f0c1a7d5e9b3c2f4a6d"
      responses:
        '200':
          description: Email verified. The account can now authenticate.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptySuccessEnvelope'
              example:
                success: true
                data: {}
        '400':
          description: Request body is invalid (malformed JSON, wrong field types, or body too large).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "Request body is invalid."
        '422':
          description: |
            Structured input validation failure (`ValidationErrorEnvelope`,
            `error_type: validation_error`).

            A non-validation `422` (`error: UNPROCESSABLE_ENTITY`, no
            `details[]`) is returned when the verification link is invalid
            or expired.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ValidationErrorEnvelope'
                  - $ref: '#/components/schemas/AuthRejectionEnvelope'
                discriminator:
                  propertyName: error_type
                  mapping:
                    validation_error: '#/components/schemas/ValidationErrorEnvelope'
                    unprocessable_entity: '#/components/schemas/AuthRejectionEnvelope'
              example:
                success: false
                error_type: validation_error
                error: "Validation failed"
                details:
                  - field: "token"
                    message: "This value should not be blank."
        '429':
          description: |
            Rate limit exceeded (too many verification attempts in the
            current window). Carries a `Retry-After` response header.
          headers:
            Retry-After:
              description: Seconds to wait before retrying. Delta-seconds (RFC 9110).
              schema:
                type: integer
                minimum: 0
              example: 60
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/auth/forgot-password:
    post:
      summary: Request a password-reset email
      description: |
        Dispatches a password-reset email if the address maps to an account.

        **Anti-enumeration: this endpoint ALWAYS returns `200`**, even when
        the email does not correspond to any account. Callers cannot use the
        response to discover whether an account exists. The only non-success
        responses are structured input validation (`422`) and rate-limiting
        (`429`).
      operationId: forgotPassword
      security: []  # anonymous (account-recovery entry point)
      tags:
        - Auth
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
              properties:
                email:
                  type: string
                  format: email
            example:
              email: "user@example.com"
      responses:
        '200':
          description: |
            Request accepted. A reset email is dispatched only if the address
            maps to an account; the response shape is identical either way
            (anti-enumeration).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptySuccessEnvelope'
              example:
                success: true
                data: {}
        '400':
          description: Request body is invalid (malformed JSON, wrong field types, or body too large).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "Request body is invalid."
        '422':
          description: Structured input validation failure (malformed `email`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationErrorEnvelope'
              example:
                success: false
                error_type: validation_error
                error: "Validation failed"
                details:
                  - field: "email"
                    message: "This value is not a valid email address."
        '429':
          description: |
            Rate limit exceeded (too many reset requests in the current
            window). Carries a `Retry-After` response header.
          headers:
            Retry-After:
              description: Seconds to wait before retrying. Delta-seconds (RFC 9110).
              schema:
                type: integer
                minimum: 0
              example: 60
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/auth/reset-password:
    post:
      summary: Reset a password using a reset token
      description: |
        Sets a new password using a token issued by
        `POST /api/auth/forgot-password`. An invalid or expired token is
        rejected with a flat `400 BAD_REQUEST` envelope — distinct from the
        structured `422 ValidationErrorEnvelope` returned for malformed input.
      operationId: resetPassword
      security: []  # anonymous (token-bearer — pre-authentication)
      tags:
        - Auth
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - token
                - password
              properties:
                token:
                  type: string
                  minLength: 1
                password:
                  type: string
                  minLength: 8
            example:
              token: "8f3c1d9a4b2e6f0c1a7d5e9b3c2f4a6d"
              password: "news3cretpw"
      responses:
        '200':
          description: Password reset. The caller can now authenticate with the new password.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptySuccessEnvelope'
              example:
                success: true
                data: {}
        '400':
          description: |
            The reset token is invalid, expired, or already consumed
            (`error: BAD_REQUEST`). Distinct from the structured `422`
            returned for malformed input.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "BAD_REQUEST"
        '422':
          description: Structured input validation failure (e.g. blank `token`, short `password`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationErrorEnvelope'
              example:
                success: false
                error_type: validation_error
                error: "Validation failed"
                details:
                  - field: "password"
                    message: "This value is too short."
        '429':
          description: |
            Rate limit exceeded (too many reset attempts in the current
            window). Carries a `Retry-After` response header.
          headers:
            Retry-After:
              description: Seconds to wait before retrying. Delta-seconds (RFC 9110).
              schema:
                type: integer
                minimum: 0
              example: 60
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/auth/change-password:
    post:
      summary: Change the authenticated user's password
      description: |
        Changes the caller's password after re-verifying the current one.
        Requires an authenticated principal (session cookie or bearer token).

        A wrong `current_password` is rejected with a flat `400 BAD_REQUEST`
        envelope — distinct from the structured `422 ValidationErrorEnvelope`
        returned for malformed input. `details[].field` values use the wire
        keys `current_password` / `new_password`.
      operationId: changePassword
      security: [{bearerAuth: []}, {sessionAuth: []}]  # required (authenticated principal)
      x-identity-scoped: true  # identity-bound (caller's own credential)
      tags:
        - Auth
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - current_password
                - new_password
              properties:
                current_password:
                  type: string
                  minLength: 1
                new_password:
                  type: string
                  minLength: 8
            example:
              current_password: "olds3cretpw"
              new_password: "news3cretpw"
      responses:
        '200':
          description: Password changed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptySuccessEnvelope'
              example:
                success: true
                data: {}
        '400':
          description: |
            The supplied `current_password` is incorrect
            (`error: BAD_REQUEST`). Distinct from the structured `422`
            returned for malformed input.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "BAD_REQUEST"
        '401':
          description: No authenticated principal.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "AUTHENTICATION_REQUIRED"
                error_type: "authentication_required"
                message: "Authentication required."
        '404':
          description: |
            The authenticated principal no longer resolves to a stored user
            (`error: USER_NOT_FOUND`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "USER_NOT_FOUND"
        '422':
          description: Structured input validation failure (e.g. blank `current_password`, short `new_password`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationErrorEnvelope'
              example:
                success: false
                error_type: validation_error
                error: "Validation failed"
                details:
                  - field: "new_password"
                    message: "This value is too short."
        '429':
          description: |
            Rate limit exceeded. Carries a `Retry-After` response header.
          headers:
            Retry-After:
              description: Seconds to wait before retrying. Delta-seconds (RFC 9110).
              schema:
                type: integer
                minimum: 0
              example: 60
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/auth/confirm-email-change:
    post:
      summary: Confirm a pending email-address change
      description: |
        Redeems a token issued when the caller requested an email change via
        `PATCH /api/auth/profile`, applying the new address to the account.

        Failure modes carry their own `error_type` discriminators
        (`token_not_found`, etc.) on the flat `ErrorEnvelope` shape and are
        distinct from the structured `422 ValidationErrorEnvelope` returned
        for malformed input.
      operationId: confirmEmailChange
      security: []  # anonymous (token-bearer — confirmation link)
      tags:
        - Auth
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - token
              properties:
                token:
                  type: string
                  minLength: 1
            example:
              token: "8f3c1d9a4b2e6f0c1a7d5e9b3c2f4a6d"
      responses:
        '200':
          description: Email address changed.
          content:
            application/json:
              schema:
                type: object
                required:
                  - success
                  - data
                properties:
                  success:
                    type: boolean
                    enum: [true]
                  data:
                    type: object
                    required:
                      - message
                    properties:
                      message:
                        type: string
              example:
                success: true
                data:
                  message: "Email address has been changed successfully."
        '400':
          description: "Malformed or otherwise rejected confirmation (`error: BAD_REQUEST`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "BAD_REQUEST"
        '404':
          description: |
            The confirmation token does not match any pending email change
            (`error: TOKEN_NOT_FOUND`, `error_type: token_not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "TOKEN_NOT_FOUND"
                error_type: "token_not_found"
        '409':
          description: |
            The target email address was claimed by another account before
            confirmation (`error: EMAIL_TAKEN`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "EMAIL_TAKEN"
        '410':
          description: |
            The confirmation token has expired or was already used
            (`error: TOKEN_EXPIRED` or `TOKEN_USED`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "TOKEN_EXPIRED"
        '422':
          description: Structured input validation failure (e.g. blank `token`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationErrorEnvelope'
              example:
                success: false
                error_type: validation_error
                error: "Validation failed"
                details:
                  - field: "token"
                    message: "This value should not be blank."
        '500':
          description: Internal server error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/auth/api-keys:
    post:
      summary: Create an API key for the authenticated user
      description: |
        Mints a new API key for the caller. The plaintext `key` is returned
        **once** in the creation response and never again — store it
        immediately.

        **Session-auth only.** API-key management requires session
        authentication; callers authenticated via an API key (bearer) are
        rejected with `403`. This prevents an API key from minting further
        keys.
      operationId: createApiKey
      security: [{sessionAuth: []}]  # session-only (API-key callers get 403)
      x-identity-scoped: true  # identity-bound (caller's own keys)
      tags:
        - Auth
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 50
            example:
              name: "ci-pipeline"
      responses:
        '201':
          description: |
            API key created. The plaintext `key` is shown here once and is
            never recoverable afterwards.
          content:
            application/json:
              schema:
                type: object
                required:
                  - success
                  - data
                properties:
                  success:
                    type: boolean
                    enum: [true]
                  data:
                    type: object
                    required:
                      - id
                      - name
                      - prefix
                      - created_at
                      - key
                    properties:
                      id:
                        $ref: '#/components/schemas/UuidV7'
                      name:
                        type: string
                      prefix:
                        type: string
                        description: Short non-secret identifier prefix for the key.
                      created_at:
                        type: string
                        format: date-time
                      key:
                        type: string
                        description: Plaintext API key — shown once, never returned again.
              example:
                success: true
                data:
                  id: "019539ab-1111-7000-8000-000000000010"
                  name: "ci-pipeline"
                  prefix: "gisl_ci"
                  created_at: "2026-06-02T12:00:00Z"
                  key: "gisl_ci_8f3c1d9a4b2e6f0c1a7d5e9b3c2f4a6d"
        '400':
          description: Request body is invalid (malformed JSON, wrong field types, or body too large).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "Request body is invalid."
        '401':
          description: No authenticated principal.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "AUTHENTICATION_REQUIRED"
                error_type: "authentication_required"
                message: "Authentication required."
        '403':
          description: |
            The caller authenticated via an API key (bearer); key management
            requires session authentication.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "FORBIDDEN"
        '422':
          description: |
            Discriminated `oneOf` on `error_type` (per
            [ADR-0019](../docs/decisions/0019-auth-422-discriminated-oneof.md)):
            - **`validation_error`** (`ValidationErrorEnvelope`, with
              `details[]`) — structured input validation failure (e.g.
              blank or over-long `name`).
            - **`unprocessable_entity`** (`AuthRejectionEnvelope`, flat,
              no `details[]`) — domain rejection: duplicate or otherwise
              invalid key name (`error: UNPROCESSABLE_ENTITY`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ValidationErrorEnvelope'
                  - $ref: '#/components/schemas/AuthRejectionEnvelope'
                discriminator:
                  propertyName: error_type
                  mapping:
                    validation_error: '#/components/schemas/ValidationErrorEnvelope'
                    unprocessable_entity: '#/components/schemas/AuthRejectionEnvelope'
              example:
                success: false
                error_type: validation_error
                error: "Validation failed"
                details:
                  - field: "name"
                    message: "This value should not be blank."
        '429':
          description: |
            Rate limit exceeded. Carries a `Retry-After` response header.
          headers:
            Retry-After:
              description: Seconds to wait before retrying. Delta-seconds (RFC 9110).
              schema:
                type: integer
                minimum: 0
              example: 60
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/auth/api-keys/{apiKeyId}:
    delete:
      summary: Revoke an API key for the authenticated user
      description: |
        Revokes (permanently deletes) one of the caller's API keys by id.

        **Session-auth only.** Like key creation, revocation requires session
        authentication; callers authenticated via an API key (bearer) are
        rejected with `403`. This prevents an API key from revoking other
        keys.

        **Identity-scoped.** The revoke is bound to the caller's own keys, so
        a key id that belongs to another account is indistinguishable from a
        non-existent one — both return `404`.
      operationId: revokeApiKey
      security: [{sessionAuth: []}]  # session-only (API-key callers get 403)
      x-identity-scoped: true  # identity-bound (caller's own keys)
      tags:
        - Auth
      parameters:
        - name: apiKeyId
          in: path
          required: true
          description: Identifier of the API key to revoke (from the creation response `id`).
          schema:
            $ref: '#/components/schemas/UuidV7'
      responses:
        '200':
          description: |
            API key revoked. The body is the shared empty-success envelope
            (`data` is an empty object, never `null`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptySuccessEnvelope'
              example:
                success: true
                data: {}
        '400':
          description: The `apiKeyId` path segment is not a well-formed identifier.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "INVALID_ID"
        '401':
          description: No authenticated principal.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "AUTHENTICATION_REQUIRED"
                error_type: "authentication_required"
                message: "Authentication required."
        '403':
          description: |
            The caller authenticated via an API key (bearer); key management
            requires session authentication.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "FORBIDDEN"
        '404':
          description: |
            No API key with this id belongs to the caller — the key does not
            exist, or it belongs to another account (identity-scoped, so the
            two cases are indistinguishable).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "API_KEY_NOT_FOUND"
        '500':
          description: Internal server error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/auth/profile:
    patch:
      summary: Update the authenticated user's profile
      description: |
        Updates the caller's display name and/or email after re-verifying
        the current password. **At least one of `name` or `email` must be
        provided**, otherwise the request is rejected with `400`.

        Changing the email does not apply it immediately — it triggers an
        email-change confirmation flow redeemed at
        `POST /api/auth/confirm-email-change`; the response signals this via
        `email_change_requested`.

        Note the two distinct `422` shapes: structured input validation
        uses `ValidationErrorEnvelope`, whereas the `EMAIL_SAME` business
        rejection (new email equals current) is a flat `ErrorEnvelope` that
        is also a `422` but **not** the validation envelope.
        `details[].field` values use the wire keys `current_password` /
        `name` / `email`.
      operationId: updateProfile
      security: [{bearerAuth: []}, {sessionAuth: []}]  # required (authenticated principal)
      x-identity-scoped: true  # identity-bound (caller's own profile)
      tags:
        - Auth
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - current_password
              properties:
                current_password:
                  type: string
                  minLength: 1
                name:
                  type:
                    - string
                    - "null"
                  maxLength: 255
                email:
                  type:
                    - string
                    - "null"
                  format: email
                  maxLength: 254
            example:
              current_password: "s3cretpw"
              name: "Jane Doe"
              email: "jane.new@example.com"
      responses:
        '200':
          description: |
            Profile updated. `name_changed` and `email_change_requested`
            flag which mutations took effect; an email change is pending
            confirmation rather than applied.
          content:
            application/json:
              schema:
                type: object
                required:
                  - success
                  - data
                properties:
                  success:
                    type: boolean
                    enum: [true]
                  data:
                    type: object
                    required:
                      - updated
                    properties:
                      updated:
                        type: boolean
                        enum: [true]
                      name_changed:
                        type: boolean
                        enum: [true]
                      email_change_requested:
                        type: boolean
                        enum: [true]
                      message:
                        type: string
              example:
                success: true
                data:
                  updated: true
                  name_changed: true
                  email_change_requested: true
                  message: "A confirmation email has been sent to your new address."
        '400':
          description: |
            No mutable field was provided — at least one of `name` or `email`
            is required (`error: BAD_REQUEST`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "BAD_REQUEST"
        '401':
          description: No authenticated principal.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "AUTHENTICATION_REQUIRED"
                error_type: "authentication_required"
                message: "Authentication required."
        '403':
          description: |
            The supplied `current_password` is incorrect
            (`error: INVALID_PASSWORD`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "INVALID_PASSWORD"
        '404':
          description: |
            The authenticated principal no longer resolves to a stored user
            (`error: USER_NOT_FOUND`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "USER_NOT_FOUND"
        '409':
          description: |
            The requested email is already claimed by another account
            (`error: EMAIL_TAKEN`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "EMAIL_TAKEN"
        '422':
          description: |
            Structured input validation failure (`ValidationErrorEnvelope`,
            `error_type: validation_error`).

            A non-validation `422` (`error: EMAIL_SAME`, `error_type:
            email_same`, no `details[]`) is returned when the new email
            equals the current one — a flat `ErrorEnvelope` that is also
            `422` but **not** the validation envelope.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ValidationErrorEnvelope'
                  - $ref: '#/components/schemas/AuthRejectionEnvelope'
                discriminator:
                  propertyName: error_type
                  mapping:
                    validation_error: '#/components/schemas/ValidationErrorEnvelope'
                    email_same: '#/components/schemas/AuthRejectionEnvelope'
              example:
                success: false
                error_type: validation_error
                error: "Validation failed"
                details:
                  - field: "email"
                    message: "This value is not a valid email address."
        '429':
          description: |
            Rate limit exceeded. Carries a `Retry-After` response header.
          headers:
            Retry-After:
              description: Seconds to wait before retrying. Delta-seconds (RFC 9110).
              schema:
                type: integer
                minimum: 0
              example: 60
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  # ============================================
  # EXTERNAL IMPORT ENDPOINT (ticket I10)
  # ============================================

  /api/external-imports:
    post:
      summary: Register a one-shot external import URL
      description: |
        Register a one-shot bearer URL (S3 presigned / GCS signed /
        Azure SAS / Dropbox shared link / public HTTPS) and receive an
        opaque `external_source_id` handle. Subsequent workflows
        reference the handle via `WorkflowSource` of `type:
        external_import`; the original URL + password are encrypted
        server-side and never returned in any response.

        Per [ADR-0005](../docs/decisions/0005-external-sources.md) — see
        §"SSRF posture" for the 8 validation rules applied at
        registration time AND again at fetch time.

        **Availability:** `planned` — runtime depends on cross-repo
        ticket [`MbosYtJD`](https://trello.com/c/MbosYtJD) (Lambda team
        publishes capability manifest) and the
        [`POST /api/connections`](https://trello.com/c/MbosYtJD) vault
        endpoint shipping. The contract surface ships now (contract-first
        per ADR-0001 §1.3); the runtime endpoint returns `404` until
        cross-repo wiring lands.
      operationId: createExternalImport
      # Auth: REQUIRED defensively. Endpoint is availability:planned (no
      # controller yet); but the endpoint stores encrypted server-side
      # secrets (bearer URLs, passwords) and anon-callable secret
      # storage is almost certainly wrong. Locking the contract here
      # while we still own the source of truth; runtime tightens to
      # match when the controller lands. Per ADR-0016 / karen review
      # on yN309QVb-B2.
      security: [{bearerAuth: []}, {sessionAuth: []}]
      x-availability: planned
      tags:
        - Upload
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExternalImportRequest'
      responses:
        '201':
          description: External-import handle created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExternalImportCreatedSuccessEnvelope'
              example:
                success: true
                data:
                  external_source_id: "019539ab-2222-7000-8000-000000000001"
                  expires_at: "2026-04-26T15:00:00Z"
                  provider: "s3_presigned"
        '400':
          description: Validation failed (missing url, malformed URI, scheme not https).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: "URL is required and must be https://"
        '403':
          description: |
            Forbidden — SSRF policy violation (private IP, loopback,
            cloud metadata endpoint), tier-quota restriction, or
            provider not enabled for caller's tier.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/TierRestrictionResponse'
                  - $ref: '#/components/schemas/FeatureTierRestrictedResponse'
                  - $ref: '#/components/schemas/ErrorEnvelope'
        '422':
          description: |
            Unprocessable URL — DNS resolution failed, target
            unreachable, content-type / magic-byte sniff failed,
            `expires_at` already past, or `feature_not_available`
            (provider tagged `planned` for caller's tier).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ValidationErrorEnvelope'
                  - $ref: '#/components/schemas/FeatureNotAvailableResponse'
                discriminator:
                  propertyName: error_type
                  mapping:
                    validation_error: '#/components/schemas/ValidationErrorEnvelope'
                    feature_not_available: '#/components/schemas/FeatureNotAvailableResponse'
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  # ============================================
  # AUDIO WATERMARK DECODE
  # ============================================

  /api/audio-watermark/decode:
    post:
      summary: Decode an embedded audio watermark
      description: |
        Extract the steganographic watermark embedded by a prior
        `audio_watermark` operation (per ticket
        [I20](https://trello.com/c/omiCq7Vn)). Pairs with the
        `audio_watermark` operation declared in
        `schemas/operations/audio_watermark.yaml` — the operation
        embeds; this endpoint decodes.

        **Tier-restricted.** This endpoint is `enterprise`-only. Free
        and `pro` callers receive a 403 `feature_tier_restricted`.
        Anonymous callers receive a 401.

        **Scope: own watermarks only.** The decoder will refuse to
        extract from media the caller did not mark themselves
        (server-side audit log of watermark origin per
        `watermark_id`). Per spike S11 acceptance — this avoids the
        privacy implications of arbitrary-media decode while still
        serving the forensic-tracking use case (find a leaked asset
        on a third-party platform, decode the watermark, look up the
        distribution channel that received the marked copy).

        **Rate-limited.** Request rate per caller is enforced
        independently from the workflow-create rate limit; abusive
        decode patterns flag for audit per [ADR-0001](../docs/decisions/0001-contract-first-availability.md).

        **`availability: planned`** — operation Lambda + decode
        Lambda are cross-repo follow-up. The endpoint declaration
        ships now (contract-first); the runtime returns
        `feature_not_available` (422) until the Lambda lands. Per
        Tension 1 (ADR-0001 §1.3).
      operationId: decodeAudioWatermark
      security: [{bearerAuth: []}, {sessionAuth: []}]  # required (explicit 401 in response set; tier-gated runtime call)
      tags:
        - AudioWatermark
      x-availability: planned
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AudioWatermarkDecodeRequest'
      responses:
        '200':
          description: Watermark successfully extracted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AudioWatermarkDecodeResponse'
        '401':
          description: Authentication required (anonymous callers).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '403':
          description: |
            Tier insufficient (free / pro caller) — returned as
            `FeatureTierRestrictedResponse` with
            `error_type: feature_tier_restricted`. Per ADR-0001 §1.3.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/TierRestrictionResponse'
                  - $ref: '#/components/schemas/FeatureTierRestrictedResponse'
                discriminator:
                  propertyName: error_type
                  mapping:
                    tier_restriction: '#/components/schemas/TierRestrictionResponse'
                    feature_tier_restricted: '#/components/schemas/FeatureTierRestrictedResponse'
        '404':
          description: |
            No watermark detected in the supplied asset, OR the
            detected watermark was not issued by this caller (the
            scope-limit applies — own-watermarks-only).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '422':
          description: |
            Either generic validation error
            (`ValidationErrorEnvelope`) or the operation is `planned`
            and the runtime Lambda has not yet shipped
            (`FeatureNotAvailableResponse` with `error_type:
            feature_not_available`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ValidationErrorEnvelope'
                  - $ref: '#/components/schemas/FeatureNotAvailableResponse'
                discriminator:
                  propertyName: error_type
                  mapping:
                    validation_error: '#/components/schemas/ValidationErrorEnvelope'
                    feature_not_available: '#/components/schemas/FeatureNotAvailableResponse'
        '429':
          description: |
            Rate limit exceeded for decode requests. Decode
            rate-limit is enforced independently from
            workflow-create.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  # ============================================
  # CREDITS + BILLING ENDPOINTS (F9 — per ticket I23)
  # ============================================

  /api/v2/credits/balance:
    get:
      summary: Get current credit balance
      description: |
        Returns a snapshot of the caller's credit position at request
        time. Per ticket [I23 `DffjC3zm`](https://trello.com/c/DffjC3zm)
        + plan v5 §F9 round-13 narrowing — this endpoint is the
        canonical user-visible billing-state surface.

        `/api/v2/credits/estimate` and `/api/billing/summary` are NOT
        shipped (round-13 walk-back); this endpoint plus the
        `BalanceExhaustedResponse` 402 envelope on
        `POST /api/workflows` cover the whole user-visible state.

        SDKs gate UI on `available_credits` for the spend-now affordance
        and on `tier` for tier-upgrade prompts.
      operationId: getCreditsBalance
      security: [{bearerAuth: []}, {sessionAuth: []}]  # required (identity-scoped to caller's wallet)
      x-identity-scoped: true  # caller's wallet — per ADR-0016 §D4 sample
      tags:
        - Billing
      responses:
        '200':
          description: Balance snapshot
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditsBalanceSuccessEnvelope'
              examples:
                free_tier_baseline:
                  summary: Free tier, fresh cycle
                  value:
                    success: true
                    data:
                      monthly_balance: 50
                      purchased_balance: 0
                      overdraft_limit: 0
                      overdraft_debt: 0
                      available_credits: 50
                      monthly_allowance: 50
                      tier: free
                pro_with_topup:
                  summary: Pro tier with a top-up balance
                  value:
                    success: true
                    data:
                      monthly_balance: 1200
                      purchased_balance: 5000
                      overdraft_limit: 500
                      overdraft_debt: 0
                      available_credits: 6700
                      monthly_allowance: 2000
                      tier: pro
                pro_overdrafted:
                  summary: Pro tier in overdraft (monthly + purchased exhausted)
                  value:
                    success: true
                    data:
                      monthly_balance: 0
                      purchased_balance: 0
                      overdraft_limit: 500
                      overdraft_debt: 350
                      available_credits: 150
                      monthly_allowance: 2000
                      tier: pro
        '401':
          description: Authentication required.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /api/v2/credits/usage:
    get:
      summary: Get paginated credit transaction history
      description: |
        Returns a paginated list of ledger entries for the caller.
        Per ticket [I23 `DffjC3zm`](https://trello.com/c/DffjC3zm).

        Each transaction is immutable once written. Workflow-create
        reservations appear here as a single row; refunds appear as
        separate rows referencing the original via `reference_id`
        (and `reference_type: workflow`).

        Default page is 20 transactions, ordered most-recent-first.
      operationId: getCreditsUsage
      security: [{bearerAuth: []}, {sessionAuth: []}]  # required (identity-scoped to caller's usage history)
      x-identity-scoped: true  # caller's usage history — per ADR-0016 D3
      tags:
        - Billing
      parameters:
        - name: limit
          in: query
          required: false
          description: |
            Page size. Server may reject invalid `limit` / `offset`
            with `422 VALIDATION_FAILED` (per project-wide query-param
            validation convention).
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          required: false
          description: |
            Page offset (zero-based). Server may reject invalid
            `limit` / `offset` with `422 VALIDATION_FAILED`.
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: Page of transactions
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditsUsageSuccessEnvelope'
              examples:
                empty:
                  summary: New caller with no history yet
                  value:
                    success: true
                    data:
                      transactions: []
                      total: 0
                      limit: 20
                      offset: 0
                paginated_workflow_reservations:
                  summary: Two workflow reservations, page 1 of N (most-recent-first ledger chain)
                  value:
                    success: true
                    data:
                      transactions:
                        # Most-recent first: small monthly-only reservation
                        # at 13:55, chained off the older mixed reservation
                        # at 13:54. balance_before on each row equals
                        # balance_after of the next-older row.
                        - id: "019539ad-3333-7000-8000-aaaaaaaaaa01"
                          type: "reservation"
                          amount: -45
                          monthly_balance_before: 1000
                          monthly_balance_after: 955
                          purchased_balance_before: 4975
                          purchased_balance_after: 4975
                          source_bucket: "monthly"
                          monthly_amount: -45
                          purchased_amount: null
                          pricing_version: "v3.2.0"
                          description: "Workflow reservation: compress.video short_form"
                          reference_type: "workflow"
                          reference_id: "019539ac-2222-7000-8000-000000000001"
                          created_at: "2026-04-26T13:55:00Z"
                        - id: "019539ad-3333-7000-8000-aaaaaaaaaa02"
                          type: "reservation"
                          amount: -180
                          monthly_balance_before: 1155
                          monthly_balance_after: 1000
                          purchased_balance_before: 5000
                          purchased_balance_after: 4975
                          source_bucket: "mixed"
                          monthly_amount: -155
                          purchased_amount: -25
                          pricing_version: "v3.2.0"
                          description: "Workflow reservation: merge.video long_form_re_encode"
                          reference_type: "workflow"
                          reference_id: "019539ac-2222-7000-8000-000000000002"
                          created_at: "2026-04-26T13:54:00Z"
                      total: 47
                      limit: 20
                      offset: 0
                refund_followup:
                  summary: Refund row referencing the prior workflow_001 reservation
                  value:
                    success: true
                    data:
                      transactions:
                        # Refund of workflow_001's -45 reservation at
                        # 13:55. Chains off that row: before equals
                        # workflow_001.monthly_balance_after (955); after
                        # adds back 45 → 1000.
                        - id: "019539ad-3333-7000-8000-aaaaaaaaaa03"
                          type: "refund"
                          amount: 45
                          monthly_balance_before: 955
                          monthly_balance_after: 1000
                          purchased_balance_before: 4975
                          purchased_balance_after: 4975
                          source_bucket: "monthly"
                          monthly_amount: 45
                          purchased_amount: null
                          pricing_version: "v3.2.0"
                          description: "Refund: terminal failure on operation-compression-video"
                          reference_type: "workflow"
                          reference_id: "019539ac-2222-7000-8000-000000000001"
                          created_at: "2026-04-26T13:56:00Z"
                      total: 47
                      limit: 20
                      offset: 0
                overdraft_deduction:
                  summary: Pure-overdraft deduction — both monthly and purchased exhausted
                  value:
                    success: true
                    data:
                      transactions:
                        # Caller reached zero monthly + purchased earlier in
                        # the cycle and is now drawing against overdraft.
                        # source_bucket: overdraft is the marker; both
                        # monthly_amount and purchased_amount are null per
                        # the null-when-not-applicable convention. The
                        # entire -90 amount drew from the overdraft pool.
                        - id: "019539ad-3333-7000-8000-aaaaaaaaaa04"
                          type: "deduction"
                          amount: -90
                          monthly_balance_before: 0
                          monthly_balance_after: 0
                          purchased_balance_before: 0
                          purchased_balance_after: 0
                          source_bucket: "overdraft"
                          monthly_amount: null
                          purchased_amount: null
                          pricing_version: "v3.2.0"
                          description: "Workflow deduction: compress.image short_form (overdraft: 90 of 90)"
                          reference_type: "workflow"
                          reference_id: "019539ac-2222-7000-8000-000000000003"
                          created_at: "2026-04-26T14:01:00Z"
                      total: 47
                      limit: 20
                      offset: 0
        '401':
          description: Authentication required.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '422':
          description: |
            Invalid pagination parameters (`limit` out of range,
            `offset` negative). Per the project-wide query-param
            validation convention: the API runtime translates parameter
            constraint violations into `ValidationErrorEnvelope` with
            `error: "VALIDATION_FAILED"` and structured `details[]`
            describing each rejected parameter. Replaces the prior
            `400 ErrorEnvelope` shape — wire change landed via
            [`gCfdFhdo`](https://trello.com/c/gCfdFhdo) on the API side
            (contracts caught up via [`d4rJzTcU`](https://trello.com/c/d4rJzTcU)).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationErrorEnvelope'
              example:
                success: false
                error_type: "validation_error"
                error: "VALIDATION_FAILED"
                details:
                  - field: "limit"
                    message: "limit must be between 1 and 100"
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  # ============================================
  # ACCOUNT LIMITS ENDPOINT
  # ============================================

  /api/v2/account/limits:
    get:
      summary: Get the caller's effective account limits
      description: |
        Returns the authed caller's **effective** (override-aware) account
        limits. `effective = per-account override ?? tier_default`. Until a
        per-account override is set, every limit's `effective` equals its
        `tier_default` and `overridden` is `false`.

        These are **request-level tier-quota** limits — the per-file upload
        cap (`UserTier.maxFileSizeBytes`) and the merge combined-size band.
        They are **distinct** from the per-operation **processing-class**
        band caps in each operation schema's `processing_class.constraints`
        (which `GET /api/operations/schema` advertises account-agnostically
        as tier DEFAULTS). This endpoint is the account-scoped,
        override-aware **effective** view — the pre-upload cap surface
        previously missing (tickets
        [`zcRRQHVC`](https://trello.com/c/zcRRQHVC) +
        [`uKsFzORi`](https://trello.com/c/uKsFzORi)).

        v1 keys: `max_upload_size_bytes` (per-file upload cap) and
        `max_total_input_size_bytes` (merge combined-size band). SDKs/FE
        gate the upload affordance on
        `limits.max_upload_size_bytes.effective`, surface
        `tier_default` for upgrade-prompt copy, and show an
        "extended limit" affordance when `overridden` is `true`.

        Per ticket [`ViRVI2MP`](https://trello.com/c/ViRVI2MP).
      operationId: getAccountLimits
      security: [{bearerAuth: []}, {sessionAuth: []}]  # required (identity-scoped to caller's account)
      x-identity-scoped: true  # caller's account limits — per ADR-0016 §D4 sample
      tags:
        - Billing
      responses:
        '200':
          description: Effective account-limits snapshot
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountLimitsSuccessEnvelope'
              examples:
                free_tier_defaults:
                  summary: Free tier, no overrides (effective == tier_default)
                  value:
                    success: true
                    data:
                      tier: free
                      limits:
                        max_upload_size_bytes:
                          effective: 10485760
                          tier_default: 10485760
                          overridden: false
                        max_total_input_size_bytes:
                          effective: 1073741824
                          tier_default: 1073741824
                          overridden: false
                enterprise_with_upload_override:
                  summary: Enterprise tier with a raised per-account upload override
                  value:
                    success: true
                    data:
                      tier: enterprise
                      limits:
                        max_upload_size_bytes:
                          effective: 128849018880
                          tier_default: 107374182400
                          overridden: true
                        max_total_input_size_bytes:
                          effective: 120000000000
                          tier_default: 120000000000
                          overridden: false
        '401':
          description: Authentication required.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  # ============================================
  # BILLING CHECKOUT ENDPOINT
  # ============================================

  /api/billing/checkout:
    post:
      summary: Start a Stripe hosted-Checkout session
      description: |
        Create a Stripe **hosted Checkout** session for a subscription
        upgrade or a credit-pack purchase, and return the Stripe-hosted
        `checkout_url` for the frontend to redirect the browser to.

        The client supplies only `type` + `key`; the Stripe
        `success_url` / `cancel_url` redirect targets are **server-set**.

        Gated behind the server `STRIPE_CHECKOUT_ENABLED` flag: while the
        flag is OFF the endpoint returns `422` `feature_not_available`
        (`FeatureNotAvailableResponse`, single violation
        `endpoint.billing.checkout`, `availability: planned`) so the
        frontend can hide/disable the upgrade affordance without
        hard-coding the rollout state.

        The Stripe **webhook** (`POST /api/webhooks/stripe`) is a
        server-to-server, signature-authenticated callback and is
        deliberately **not** part of this client contract.

        Per the Stripe go-live (routed 2026-07-02).
      operationId: createBillingCheckoutSession
      security: [{bearerAuth: []}, {sessionAuth: []}]  # required (IS_AUTHENTICATED_FULLY; anonymous/partial sessions → 401)
      x-identity-scoped: true  # the checkout session is created for the authenticated caller
      tags:
        - Billing
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BillingCheckoutRequest'
            examples:
              subscription_pro:
                summary: Upgrade to the Pro subscription
                value:
                  type: subscription
                  key: pro
              credit_pack:
                summary: Buy a 25-credit pack
                value:
                  type: pack
                  key: pack_25
      responses:
        '200':
          description: Checkout session created; redirect the browser to `checkout_url`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingCheckoutSuccessEnvelope'
              example:
                success: true
                data:
                  checkout_url: "https://checkout.stripe.com/c/pay/cs_test_a1b2c3"
                  session_id: "cs_test_a1b2c3"
        '400':
          description: "Malformed JSON request body (`error: INVALID_JSON`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: INVALID_JSON
                message: "Malformed JSON in request body."
        '401':
          description: |
            Authentication required (`error: UNAUTHORIZED`). Anonymous or
            partially-authenticated (remember-me) sessions are rejected —
            the endpoint requires a fully-authenticated caller.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: UNAUTHORIZED
                message: "Authentication required."
        '422':
          description: |
            One of three flavours — dispatch on `error_type` (present only
            on the feature-gate branch), then on the `error` machine code:

            - **`error_type: feature_not_available`**
              (`FeatureNotAvailableResponse`, carries `violations[]`) —
              checkout is flag-gated OFF (`STRIPE_CHECKOUT_ENABLED=false`).
              The single violation names feature `endpoint.billing.checkout`
              (`availability: planned`). The frontend hides the upgrade
              affordance on this branch.
            - **`error: VALIDATION_FAILED`** (plain `ErrorEnvelope`, no
              `error_type`) — `type` is missing or not one of
              `subscription` / `pack`, or `key` is empty.
            - **`error: UNPROCESSABLE_ENTITY`** (plain `ErrorEnvelope`, no
              `error_type`) — the `type` + `key` pair does not resolve to a
              provisioned plan or pack (a mismatched pair such as
              `subscription` + `pack_10`, or an unknown/unprovisioned
              `key`). The pairing is validated server-side, not by the
              request schema.
          content:
            application/json:
              schema:
                # Non-overlapping oneOf, discriminable by `error_type` presence:
                # the flag-off branch (FeatureNotAvailableResponse) always carries
                # `error_type: feature_not_available` + `violations[]`; the generic
                # validation branch is a plain ErrorEnvelope with NO `error_type`
                # (the `not: required` guard keeps the two branches mutually
                # exclusive so a feature-gate body cannot also match the generic
                # branch — the workflows 422 uses a discriminated oneOf, but that
                # needs `error_type` on every branch, which these two flat codes
                # do not carry).
                oneOf:
                  - $ref: '#/components/schemas/FeatureNotAvailableResponse'
                  - allOf:
                      - $ref: '#/components/schemas/ErrorEnvelope'
                      - not:
                          required: [error_type]
              examples:
                feature_not_available:
                  summary: Checkout flag is off (STRIPE_CHECKOUT_ENABLED=false)
                  value:
                    success: false
                    error_type: feature_not_available
                    error: FEATURE_NOT_AVAILABLE
                    message: "Checkout is not yet available."
                    violations:
                      - feature: endpoint.billing.checkout
                        availability: planned
                        message_key: feature.not_available
                validation_failed:
                  summary: Missing / invalid type or empty key
                  value:
                    success: false
                    error: VALIDATION_FAILED
                    message: "Invalid checkout request: 'type' must be 'subscription' or 'pack' and 'key' is required."
                unprocessable_entity:
                  summary: type/key pair does not resolve to a provisioned plan or pack
                  value:
                    success: false
                    error: UNPROCESSABLE_ENTITY
                    message: "The requested plan or pack is not available."
        '429':
          description: |
            Rate limit exceeded (`error: TOO_MANY_REQUESTS`). The billing
            endpoint family has its own tiered limiter; the response carries
            `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`
            headers.
          headers:
            X-RateLimit-Limit:
              required: false
              description: Requests permitted in the current window.
              schema:
                type: integer
                minimum: 0
            X-RateLimit-Remaining:
              required: false
              description: Requests remaining in the current window.
              schema:
                type: integer
                minimum: 0
            X-RateLimit-Reset:
              required: false
              description: Unix epoch seconds when the current window resets.
              schema:
                type: integer
                minimum: 0
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '503':
          description: |
            Checkout temporarily unavailable — the flag is on but Stripe is
            not configured on the server (`error: SERVICE_UNAVAILABLE`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error: SERVICE_UNAVAILABLE
                message: "Checkout is temporarily unavailable."
        '500':
          description: Internal server error (e.g. an unexpected Stripe SDK error).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  # ============================================
  # CONTACT ENDPOINT
  # ============================================

  /api/contact:
    post:
      summary: Submit contact form
      description: |
        Submit a contact form message. All fields except `name` and `website` are required.

        The `website` field is a honeypot for bot detection. It is hidden from real users
        via CSS. Legitimate submissions must omit this field or send an empty string.
        The API rejects any request where `website` is non-empty.

        On success, returns 204 with no body. The API sends the message via its
        configured delivery channel (e.g. email, queue).
      operationId: submitContact
      security: []  # anonymous (public contact form)
      tags:
        - Contact
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactRequest'
            example:
              name: "Jane Doe"
              email: "jane@example.com"
              subject: "general_enquiry"
              message: "I have a question about supported file formats."
      responses:
        '204':
          description: Contact form submitted successfully. No response body.
        '400':
          description: Validation failed - one or more fields are invalid
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactValidationErrorResponse'
              example:
                errors:
                  email:
                    - "This value is not a valid email address."
                  message:
                    - "This field is required."
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

# ============================================
# WEBHOOKS (outbound callbacks to consumer URLs)
# ============================================

webhooks:
  workflowCallback:
    post:
      summary: Workflow event callback
      operationId: webhookWorkflowCallback
      description: |
        POSTed to the `callback_url` provided in `WorkflowCreateRequest` when a
        subscribed event occurs. The consumer endpoint must return a 2xx status
        to acknowledge receipt.
      parameters:
        - name: X-GIS-Signature
          in: header
          required: true
          schema:
            type: string
            pattern: '^sha256=[0-9a-f]{64}$'
          description: |
            HMAC-SHA256 signature of the raw request body using the per-workflow
            `webhook_secret` as the key. Format: `sha256=<hex-digest>`.

            Verification pseudocode:
            ```
            expected = "sha256=" + hex(hmac_sha256(webhook_secret, raw_body))
            valid = constant_time_equal(header_value, expected)
            ```
          example: "sha256=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayload'
      responses:
        '200':
          description: Callback acknowledged
        '202':
          description: Callback accepted for processing
        '204':
          description: Callback acknowledged (no body)

components:
  # Per ADR-0016: auth presence is modeled with OpenAPI-native
  # `security` / `securitySchemes` (NOT a custom `auth:` field) so codegen,
  # spectral, and oasdiff understand it natively. Two schemes — the same
  # Symfony `api` firewall accepts BOTH a bearer API-key and a login-session
  # cookie. Per-operation `security:` blocks OR the two schemes for the
  # three idioms (anonymous / required / optional). Spec root carries
  # `security: []` default (anonymous-OK); per-operation overrides flip
  # the requirement.
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        API-key bearer authentication. The `ApiKeyAuthenticator`
        (Symfony `api` firewall) validates
        `Authorization: Bearer <api-key>` per the prose under
        `ErrorCode.authentication_required`.
    sessionAuth:
      type: apiKey
      in: cookie
      name: PHPSESSID
      description: |
        Login-session cookie authentication. The `ApiEntryPoint`
        (Symfony `api` firewall) recognises an authenticated session
        when the cookie is present and the session has a valid
        principal. Set by `POST /api/auth/login`; cleared by
        `POST /api/auth/logout`. Cookie name `PHPSESSID` follows the
        Symfony / PHP default — no `framework.session.name` override
        in `compression/config/packages/framework.yaml` as of v2.16.6
        verification. Update this declaration if the API repo ever
        adds a `session.name` override.

  schemas:
    # ============================================
    # SHARED PRIMITIVES
    # ============================================

    UuidV7:
      type: string
      format: uuid
      pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
      description: UUID v7 format identifier (time-ordered)
      example: "019539ab-1111-7000-8000-000000000001"

    # ============================================
    # RESPONSE ENVELOPES
    # ============================================

    ResponseEnvelope:
      type: object
      description: |
        Standard response wrapper. All API responses (except GET /api/operations/schema,
        health probes, and POST /api/contact) use this envelope.
        Success: `{ success: true, data: {...} }`.
        Error: `{ success: false, error: "...", details: [...] }`.
      required:
        - success
      properties:
        success:
          type: boolean

    EmptyData:
      type: object
      additionalProperties: false
      description: |
        No-payload success `data` slot — always an empty object `{}`,
        never `null`. Preserves the `{ success: true, data: {...} }`
        envelope invariant for operations that return no body (auth
        side-effect endpoints: register, logout, verify-email,
        forgot-password, reset-password, change-password). Modelled as
        an explicit empty object (not `type: null`) so every SDK
        generator emits a clean type — a `null`-typed `data` makes
        typescript-fetch import a non-existent `Null` model (TS2307)
        and crashes the python openapi-generator (`getPydanticType`).
        See ticket [VGqxO3fO](https://trello.com/c/VGqxO3fO).

    EmptySuccessEnvelope:
      type: object
      description: |
        Success envelope for no-payload operations. `data` is always
        the empty object `{}`. Shared by the auth side-effect endpoints
        so their codegen stays clean and uniform.
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/EmptyData'

    ErrorEnvelope:
      type: object
      description: |
        Base error response envelope. All error responses (4xx /
        5xx) extend or use this shape. Per ticket
        [I26](https://trello.com/c/rcnqwgI4) every error carrier
        supports the optional localisation triple (`message_key` +
        `locale` + `message_params`) plus the existing
        machine-readable `error` code.

        **SDK consumer guidance.** Switch on `error` (canonical
        machine code, never localised) for typed error-branch
        helpers. Surface `message_key` (canonical lookup vocab,
        never localised) to your SDK's translation layer. Display
        `message` (localised per the `Accept-Language` request
        header). **Never parse `message` for control flow** — it
        changes per locale.

        See the API-level "Localisation" section in `info.description`
        for the request/response header contract (`Accept-Language`
        ↔ `Content-Language` + `Vary: Accept-Language`) and the
        fallback locale (`en-GB`).
      required:
        - success
        - error
      properties:
        success:
          type: boolean
          enum: [false]
        error:
          type: string
          description: |
            Stable, machine-readable error code (e.g.
            `INVALID_OPTIONS`, `BALANCE_EXHAUSTED`,
            `REQUIRES_REENCODE`). Canonical English; never localised.
            SDKs duck-type on this field for typed error-branch
            helpers.

            Multipart-session resume codes (per ticket
            [`HxUmVr3Y`](https://trello.com/c/HxUmVr3Y), V2.10.0):
            - `MULTIPART_SESSION_NOT_FOUND` (404) — upload_id does
              not match an in-flight session (expired / never
              existed / wrong account namespace). Fired by /status,
              /presign, /keepalive.
            - `MULTIPART_SESSION_OWNERSHIP` (403) — authenticated
              caller is not the session owner. Fired by /status,
              /presign, /keepalive, /complete (when manifest.userId
              is non-null and differs).
            - `MULTIPART_SESSION_AUTH_REQUIRED` (403) — session was
              anonymously initiated; the three resume endpoints
              require authentication. The `8LABloaz` follow-up will
              flip `/initiate` to also require auth.
            - `FILE_TOO_LARGE_FOR_MULTIPART` (422) — assembled object
              would exceed the S3 multipart capacity cap. Pre-S3
              server-side capacity gate; distinct from tier-quota
              rejections (`upload_size_exceeds_tier`).

            Workflow-create code (per ticket
            [`nGYbgChX`](https://trello.com/c/nGYbgChX) / sdks
            [`DRjIyMt9`](https://trello.com/c/DRjIyMt9)):
            - `UPLOAD_NOT_FOUND` (404) — a `POST /api/workflows` request
              references an upload that does not exist OR exists but is
              owned by a different identity (deliberate BOLA/IDOR
              existence-mask: reported as not-found, **never 403**, so the
              response does not reveal another user's upload exists).
              `message_key: "upload.not_found"`. See the createWorkflow
              404 response + ADR-0016 Amendment.
            - `LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED` (429) — caller already
              holds the maximum concurrent in-flight long-form workflows
              their tier permits (Pro 2 / Max 5; Enterprise uncapped).
              DISTINCT from an infra rate-limit 429: carries no
              `Retry-After` (it clears on workflow completion) and adds a
              `links.upgrade` CTA. `message_key:
              "job.long_form_concurrency_exceeded"`. See the createWorkflow
              429 response + `LongFormConcurrencyLimitResponse`.
        message:
          type: string
          description: |
            Human-readable error message, localised per the
            request's `Accept-Language` header (fallback locale
            `en-GB`). The response carries `Content-Language:
            <locale>` + `Vary: Accept-Language` headers. **Never
            parse this field for control flow** — it changes per
            locale.
        message_key:
          type: string
          description: |
            Stable canonical lookup key for the message (e.g.
            `error.balance_exhausted.add_credits`,
            `error.upload_size_exceeds_tier`). Never localised. SDK +
            frontend translation layers gate on this for client-side
            i18n catalogs (per ticket X19, cross-repo SDK companion
            work). Stable across server message-prose updates.
        locale:
          type: string
          description: |
            BCP 47 locale tag echoing the resolved `Content-Language`
            response header value. Currently always `en-GB` (the only
            committed locale per `info.description` Localisation block
            + ticket [`4GKyuYo6`](https://trello.com/c/4GKyuYo6));
            additional values will appear here when their catalogs
            ship. Lets the SDK confirm which locale the server
            selected when the request used q-value negotiation across
            multiple `Accept-Language` values.
          example: "en-GB"
        message_params:
          type: object
          additionalProperties: true
          description: |
            Optional interpolation values for the localised
            `message`. Keys are stable parameter names referenced by
            the translation table (e.g.
            `{ "filename": "photo.heic", "max_size_mb": 100 }`).
            **Excludes cost / monetary numbers** per plan v5 §F11
            round-13 narrowing — pricing-related localisation reads
            numeric state from `GET /api/v2/credits/balance`, not
            from this field. Values are JSON-native scalars
            (`string` / `integer` / `number` / `boolean` / `null`)
            — no nested objects, to keep translation-table
            integration simple.

    ValidationErrorEnvelope:
      type: object
      description: |
        Validation error response with structured details.
        Used for 422 responses where multiple validation issues are reported.

        Carries the I26 localisation triple (`message_key` +
        `locale` + `message_params`) at envelope level AND on each
        `details[]` entry, so per-violation messages can be
        localised independently. See `ErrorEnvelope` for the
        canonical convention.
      required:
        - success
        - error
        - error_type
        - details
      properties:
        success:
          type: boolean
          enum: [false]
        error_type:
          type: string
          enum:
            - validation_error
          description: |
            Discriminator for the multi-branch 422 `oneOf` (per
            [ADR-0018](../docs/decisions/0018-universal-422-error-type-discriminator.md)).
            Always `validation_error` on this envelope. Added so the four
            422 branches (`ValidationErrorEnvelope`,
            `FeatureNotAvailableResponse`,
            `ProcessingClassExceedsBandResponse`, `ProbePendingResponse`)
            share a single discriminator property — generated SDK clients
            dispatch on `error_type` instead of structural `instanceOf`
            guards (which mis-fire on camelCase-vs-snake_case property
            names). Distinct from the `error` machine code below: `error`
            carries the specific failure code (`INVALID_OPTIONS`,
            `REQUIRES_REENCODE`, `CYCLIC_WORKFLOW_EDGES`, …) while
            `error_type` only names the envelope shape.
        error:
          type: string
          description: |
            Stable error code. Common values: `INVALID_OPTIONS`
            (generic option/value validation failure), `REQUIRES_REENCODE`
            (per ticket I16-CONS — `merge.video` with
            `re_encode_mode: never` and incompatible inputs; caller
            resolves by switching to `re_encode_mode: auto` or `always`),
            `CYCLIC_WORKFLOW_EDGES` (cyclic/self explicit `workflow_edges`,
            per `g8PPkbNu`), `VALIDATION_FAILED` (request/query-param
            validation failure per the project-wide convention — e.g.
            `GET /api/v2/credits/usage` invalid `limit`/`offset`).
            SDKs duck-type on this field for typed error-branch helpers.
        message:
          type: string
          description: |
            Envelope-level human-readable summary, localised per
            `Accept-Language`. See `ErrorEnvelope.message`. **Never
            parse for control flow.**
        message_key:
          type: string
          description: |
            Envelope-level canonical lookup key (e.g.
            `error.invalid_options.summary`). See
            `ErrorEnvelope.message_key`.
        locale:
          type: string
          description: BCP 47 locale tag. See `ErrorEnvelope.locale`.
        message_params:
          type: object
          additionalProperties: true
          description: |
            Envelope-level interpolation values. See
            `ErrorEnvelope.message_params`. Excludes cost numbers per
            plan v5 §F11 round-13 narrowing.
        details:
          type: array
          description: List of individual validation errors
          items:
            type: object
            required:
              - message
            properties:
              operation:
                type: string
                description: Operation type where the error occurred
              option:
                type: string
                description: Option name that failed validation
              field:
                type: string
                description: |
                  Field or sub-field path; populated for cross-field
                  validation errors (e.g. input-pair mismatches in
                  `REQUIRES_REENCODE` shaped as `inputs[i].<dim> vs
                  inputs[j].<dim>`) and for non-operation validation
                  errors where `option` alone is too coarse. May appear
                  alongside `operation` + `option` when the violation
                  spans multiple fields rather than a single option.
              message:
                type: string
                description: |
                  Per-detail human-readable message, localised per
                  `Accept-Language`. See `ErrorEnvelope.message`.
              message_key:
                type: string
                description: |
                  Per-detail canonical lookup key (e.g.
                  `error.invalid_options.quality_out_of_range`,
                  `error.requires_reencode.codec_mismatch`). Stable
                  across server message-prose updates. See
                  `ErrorEnvelope.message_key`.
              message_params:
                type: object
                additionalProperties: true
                description: |
                  Per-detail interpolation values for the localised
                  `message` (e.g.
                  `{ "min": 1, "max": 100, "given": 200 }`). See
                  `ErrorEnvelope.message_params`. Excludes cost
                  numbers.

    AuthRejectionEnvelope:
      type: object
      description: |
        Flat **domain-rejection** 422 envelope for the auth surface — the
        non-validation branch of the auth-422 `oneOf` (per
        [ADR-0019](../docs/decisions/0019-auth-422-discriminated-oneof.md),
        the sibling adoption of [ADR-0018](../docs/decisions/0018-universal-422-error-type-discriminator.md)
        anticipated for non-workflow `oneOf` sites). Distinct from
        `ValidationErrorEnvelope`: it has **no `details[]`** (the
        rejection is a single business-rule failure, not a field-by-field
        validation report). Carries the same I26 localisation triple as
        `ErrorEnvelope`.

        Returned alongside `ValidationErrorEnvelope` on the four auth
        endpoints that have a domain-reject 422 branch: `register`
        (disposable/blocklisted email), `verify-email` (invalid/expired
        token), `api-keys` POST (duplicate/invalid key name) — all
        `error: UNPROCESSABLE_ENTITY`, `error_type: unprocessable_entity`
        — and `profile` PATCH (`error: EMAIL_SAME`, `error_type:
        email_same`, new email equals current).

        **`error_type` vs `error`** (per ADR-0018): `error_type` is the
        `oneOf` branch discriminator; the specific failure stays in the
        `error` machine code. `email_same` is retained as its own
        discriminator value (pre-existing wire shape) rather than folded
        into `unprocessable_entity`; both map to this envelope.
      required:
        - success
        - error
        - error_type
      properties:
        success:
          type: boolean
          enum: [false]
        error:
          type: string
          description: |
            Stable machine-readable failure code. `UNPROCESSABLE_ENTITY`
            for the generic auth domain rejections (register /
            verify-email / api-keys); `EMAIL_SAME` for the profile
            new-email-equals-current rejection. Canonical English; never
            localised. See `ErrorEnvelope.error`.
        error_type:
          type: string
          enum:
            - unprocessable_entity
            - email_same
          description: |
            Discriminator for the auth-422 `oneOf` (per ADR-0019). Names
            the envelope **shape**, not the failure — the failure is in
            `error`. `unprocessable_entity` for the generic flat auth
            rejections; `email_same` preserved as the profile-specific
            pre-existing wire value. Both resolve to this
            `AuthRejectionEnvelope`. Distinct from
            `ValidationErrorEnvelope.error_type` (`validation_error`),
            the other branch of the auth-422 `oneOf`.
        message:
          type: string
          description: |
            Human-readable message, localised per `Accept-Language`
            (fallback `en-GB`). Never parse for control flow. See
            `ErrorEnvelope.message`.
        message_key:
          type: string
          description: |
            Stable canonical lookup key for the message. Never localised.
            See `ErrorEnvelope.message_key`.
        locale:
          type: string
          description: BCP 47 locale tag echoing `Content-Language`. See `ErrorEnvelope.locale`.
          example: "en-GB"
        message_params:
          type: object
          additionalProperties: true
          description: Optional interpolation values for the localised `message`. See `ErrorEnvelope.message_params`.

    ReEncodeDecision:
      type: string
      description: |
        Path chosen for `merge.video` when `re_encode_mode=auto`.
        Server reports the actual path so callers can see why
        `auto` took the slow path. Absent for non-`merge.video`
        operations and for `merge.video` when `re_encode_mode` is
        `always` or `never` (path was caller-fixed). Per ticket
        I16-CONS (Trello 7nCZXEru).

        Mirrors the AsyncAPI `ReEncodeDecision` (and
        `OperationMetrics.re_encode_decision`); exposed on the REST
        `OperationResult.metrics` per ticket zS4e9CI2 so the SDK
        customer path carries the same signal as the SSE/message
        surface. Cross-spec enum parity is verified by
        `tests/test_asyncapi_named_schemas.py`.
      enum:
        - stream_copy
        - re_encode

    # ============================================
    # AVAILABILITY METADATA (decorative)
    # ============================================

    AvailabilityValue:
      type: string
      description: |
        Availability level for an operation, mime_group, option, or
        per-enum-value entry per ADR-0001 §1.3. Used as the value of
        decorative `x-availability` extensions in this spec, and as the
        typed schema referenced by the runtime capability endpoint
        (`GET /api/operations/schema`, ticket I3) and the sidecar
        `availability.json` (ticket I3b).

        Per ADR-0001 §1.4: when `availability` is absent on a schema
        entry, the implicit value is `stable` for SDK / client / external
        consumers. `stable_pending_audit` is internal CI / audit
        accounting only and never surfaces to consumers.

        See `schemas/FORMAT.md` §Availability Taxonomy and
        `docs/decisions/0001-contract-first-availability.md` for the full
        rules, vocabulary rationale, and parser obligations.
      enum:
        - stable
        - beta
        - experimental
        - planned
        - deprecated

    PerValueAvailabilityEntry:
      type: object
      description: |
        Single per-enum-value availability entry. Attached as a value
        within an option's `per_value_availability` map (per
        `schemas/FORMAT.md` §Availability Taxonomy → "per_value_availability").

        Used to tag individual enum values when an option's values ship
        at different availability levels. Example: FFmpeg xfade
        transition catalog where some values are `stable`
        (`none` / `fade` / `dissolve`), others `beta` (smooth/circle
        variants), and some `planned` (custom_luma transitions).

        Untagged enum values default to `availability: stable` per the
        absent-equals-stable parser obligation (ADR-0001 §1.4). Keys
        present in this map MUST be a subset of the option's `values[]`
        array — verified by `make check-per-value-availability`
        (`scripts/check-per-value-availability.py`).
      required:
        - availability
      properties:
        availability:
          $ref: '#/components/schemas/AvailabilityValue'
        required_tier:
          description: |
            Tier required to use this enum value. Optional — omit when
            availability is gated by readiness rather than subscription.
          oneOf:
            - $ref: '#/components/schemas/UserTier'
            - type: 'null'
        eta:
          type: string
          description: |
            ISO-8601 date (`2026-09-15`) or quarter (`2026-Q3`) when this
            value is expected to ship. Only meaningful for
            `availability: planned`.
        documentation_url:
          type: string
          format: uri
        sunset:
          type: string
          format: date
          description: |
            Sunset date for `availability: deprecated` entries. The
            server emits paired `Deprecation` and `Sunset` HTTP headers
            (RFC 9745 / RFC 8594) when handling requests that touch a
            deprecated value.

    PerValueAvailability:
      type: object
      description: |
        Map of enum-value → `PerValueAvailabilityEntry`. Attached to an
        option's `per_value_availability` field when individual enum
        values need different availability tags.

        Keys MUST be a subset of the option's `values[]` array. Used by
        the runtime capability endpoint ([I3 `eCWIpug8`](https://trello.com/c/eCWIpug8))
        and sidecar `availability.json` ([I3b `GbQU8FaS`](https://trello.com/c/GbQU8FaS))
        to surface per-value granularity. Consumed by SDK generators
        (TypeScript / PHP / Python / Rust) when emitting typed enum
        bindings with availability metadata.
      additionalProperties:
        $ref: '#/components/schemas/PerValueAvailabilityEntry'

    PerMimeAvailability:
      type: object
      description: |
        Map of MIME-type → `PerValueAvailabilityEntry`. Attached to a
        mime_group's `per_mime_availability` field when individual
        MIMEs within the same `mimes:` list ship at different
        availability levels (e.g. `image/jpeg: stable, image/heic:
        planned`).

        Keys MUST be a subset of the parent `mimes:` array
        (CI-checked by `scripts/check-per-mime-availability.py`).
        Absent keys default to `availability: stable` per ADR-0001
        §1.4. Reuses `PerValueAvailabilityEntry` as the entry shape
        — same fields, same semantics — so SDK generators emit a
        single typed binding.

        Per ticket [`YXYOo6gg`](https://trello.com/c/YXYOo6gg).
        Distinct from the parallel-mime_group pattern (per
        FORMAT.md §"Parallel mime_group naming") — that pattern
        remains the right tool when the differentiated subset has
        materially different option shape (animated GIF
        watermarking, video-base watermarking, etc.). This
        primitive is for pure availability differentiation within
        an otherwise-homogeneous mime list.
      additionalProperties:
        $ref: '#/components/schemas/PerValueAvailabilityEntry'

    PerRoleCardinality:
      type: object
      description: |
        Map of role-name → `PerRoleCardinalityEntry`. Attached to
        a role-based multi-input operation's `per_role_cardinality`
        field to declare the required and maximum input count per
        role. **Cardinality overlay, not availability overlay** —
        sits alongside `PerValueAvailability` / `PerMimeAvailability`
        structurally but expresses a different concern.

        Keys MUST be a subset of the `JobInputRole` enum
        (CI-checked by `scripts/check-per-role-cardinality.py`).
        The script additionally enforces arithmetic-consistency:
        `sum(role.min) == OperationSchemaDefinition.min_inputs`
        and `sum(role.max) == OperationSchemaDefinition.max_inputs`
        — this is the load-bearing invariant that makes the
        primitive useful.

        All five role-based ops now declare `per_role_cardinality`
        (audio_to_video, video_watermark, audio_overlay,
        image_watermark, custom_luma — the last three migrated from
        prose-only by ticket oAP5BQOx). Absence of the block keeps
        prose-only semantics for any future role-based op until it
        migrates to the predicate form; the `JobInputRole` description
        remains the human-readable companion to the rules.

        Per ticket [`SlluxMBN`](https://trello.com/c/SlluxMBN) /
        ADR-0015. Consumed by `OperationInputRoleValidator` in
        `compression_api` (rule table becomes data-driven) and by
        SDK generators when emitting typed role-binding helpers.
      additionalProperties:
        $ref: '#/components/schemas/PerRoleCardinalityEntry'

    PerRoleCardinalityEntry:
      type: object
      description: |
        Per-role cardinality entry. `min: 0` makes the role optional.
        `max: 1` is the most common upper bound (one base, one
        overlay); future role-based ops may declare higher maxima
        (e.g. multi-overlay video composites). Both fields default to
        `1` when absent on a role key — but consumers SHOULD treat
        absence of either field as a contract bug surfaced by
        `scripts/check-per-role-cardinality.py` rather than silently
        defaulting.
      required:
        - min
        - max
      properties:
        min:
          type: integer
          minimum: 0
          description: Minimum input count for this role (0 = optional).
        max:
          type: integer
          minimum: 1
          description: |
            Maximum input count for this role. MUST be >= `min`.

    # ============================================
    # EXTERNAL SOURCES + DESTINATIONS
    # ============================================
    #
    # Per ADR-0005. WorkflowSource is the discriminated union consumed
    # by V2 single-input JobDefinition.source (wired by I12). Multi-input
    # JobInputV2.source consumes the narrower MultiInputSource (excludes
    # the upload leaf; see MultiInputSource). ExternalDestination is the
    # write-side counterpart.

    UploadSource:
      type: object
      description: |
        References an upload created via `POST /api/uploads` (single)
        or completed via `POST /api/uploads/multipart/complete`
        (multipart). Both flows yield the same `file_id` shape.
      required:
        - type
        - file_id
      properties:
        type:
          type: string
          const: upload
        file_id:
          $ref: '#/components/schemas/UuidV7'

    JobOutputSource:
      type: object
      description: |
        References the output of an upstream job in the same workflow.
        Used to chain operations: workflow runs job A first, then job B
        consumes job A's output via this source. The server computes
        `depends_on` edges from these `from` references.
      required:
        - type
        - from
      properties:
        type:
          type: string
          const: job_output
        from:
          type: string
          description: Upstream job ref (must be defined in the same workflow).
        operation:
          type: string
          description: |
            Optional operation type filter when the upstream job has
            multiple operations (e.g. `from: enhance, operation: thumbnail`).

    ExternalImportToken:
      type: object
      description: |
        Opaque handle returned by `POST /api/external-imports` for a
        one-shot bearer URL (S3 presigned, GCS signed, Azure SAS,
        Dropbox shared link, public HTTPS). The original URL + password
        are encrypted server-side and never returned in any subsequent
        response. Per [ADR-0005](../docs/decisions/0005-external-sources.md)
        §"ExternalImportToken".
      required:
        - type
        - external_source_id
      properties:
        type:
          type: string
          const: external_import
        external_source_id:
          $ref: '#/components/schemas/UuidV7'

    ConnectionSource:
      type: object
      description: |
        Reference to a vaulted connection (pre-registered via
        `POST /api/connections` — owned by cross-repo `compression_api`
        work item X10 per ADR-0005; vault endpoint not yet filed as a
        Trello ticket). For recurring access
        to the customer's S3 / GCS / Azure / Dropbox / Google Drive /
        OneDrive. Credentials NEVER appear on the wire; only the
        `connection_id` handle does.

        Per [ADR-0005](../docs/decisions/0005-external-sources.md)
        §"ConnectionSource".
      required:
        - type
        - connection_id
        - path
      properties:
        type:
          type: string
          const: connection
        connection_id:
          $ref: '#/components/schemas/UuidV7'
        path:
          type: string
          description: |
            Object key / path within the connection's scoped bucket /
            folder. The connection itself carries provider + root
            scoping; `path` is the resource within that scope.

    WorkflowSource:
      description: |
        Discriminated union of source leaves (all 4, including
        `upload`) consumed by V2 single-input `JobDefinition.source`.
        Multi-input `JobInputV2.source` consumes the narrower
        `MultiInputSource` (this union minus the `upload` leaf) — see
        that schema and `JobInputV2`. Per
        [ADR-0005](../docs/decisions/0005-external-sources.md) +
        [ADR-0004](../docs/decisions/0004-job-shape.md).

        Wiring into `JobDefinition` lands via ticket
        [I12 (`Gr0VKFya`)](https://trello.com/c/Gr0VKFya).
      oneOf:
        - $ref: '#/components/schemas/UploadSource'
        - $ref: '#/components/schemas/JobOutputSource'
        - $ref: '#/components/schemas/ExternalImportToken'
        - $ref: '#/components/schemas/ConnectionSource'
      discriminator:
        propertyName: type
        mapping:
          upload: '#/components/schemas/UploadSource'
          job_output: '#/components/schemas/JobOutputSource'
          external_import: '#/components/schemas/ExternalImportToken'
          connection: '#/components/schemas/ConnectionSource'

    MultiInputSource:
      description: |
        Discriminated union of source leaves consumed by `JobInputV2.source`
        (multi-input operation inputs). A 3-leaf subset of `WorkflowSource`
        that **excludes the `upload` leaf** — uploads cannot be referenced
        directly inside a multi-input `inputs[]` array. An uploaded file
        that must feed a multi-input operation enters via a `passthrough`
        source job (a single-input job with `operations: [{type: passthrough}]`)
        and is referenced here as `{ type: job_output, from: <id> }`, which
        preserves billing / DAG / lineage. Structured like the `ExternalSource`
        subset alias but retaining `job_output` (the passthrough bridge).
        Per ticket [`4som89Uh`](https://trello.com/c/4som89Uh) + ADR-0004 /
        ADR-0005.
      oneOf:
        - $ref: '#/components/schemas/JobOutputSource'
        - $ref: '#/components/schemas/ExternalImportToken'
        - $ref: '#/components/schemas/ConnectionSource'
      discriminator:
        propertyName: type
        mapping:
          job_output: '#/components/schemas/JobOutputSource'
          external_import: '#/components/schemas/ExternalImportToken'
          connection: '#/components/schemas/ConnectionSource'

    ExternalSource:
      description: |
        Subset alias of `WorkflowSource` that excludes uploads and
        job-outputs. Used in contexts where only external sources make
        sense (e.g. a future "import" surface that doesn't accept
        inline `file_id`s). Per ADR-0005.
      oneOf:
        - $ref: '#/components/schemas/ExternalImportToken'
        - $ref: '#/components/schemas/ConnectionSource'
      discriminator:
        propertyName: type
        mapping:
          external_import: '#/components/schemas/ExternalImportToken'
          connection: '#/components/schemas/ConnectionSource'

    ExternalDestination:
      description: |
        Write-side counterpart to `ExternalSource`. Used when a
        workflow's `delivery_plan` writes outputs to a
        customer-controlled destination (S3 bucket, GCS bucket,
        Dropbox folder, etc.). Per ADR-0005, write auth semantics
        differ from read auth (validation + scoping is
        write-permission-based, not SSRF-based) — the schema family
        mirrors `ExternalSource` shape; auth/policy differences live
        in runtime.

        Replaces V1 `ExportConfig` schema (deleted at V2 cutover via
        ticket I12).

        Future extension may add write-policy fields (`write_mode`,
        `overwrite_policy`, etc.) — tracked as a follow-up ticket.
      oneOf:
        - $ref: '#/components/schemas/ExternalImportToken'
        - $ref: '#/components/schemas/ConnectionSource'
      discriminator:
        propertyName: type
        mapping:
          external_import: '#/components/schemas/ExternalImportToken'
          connection: '#/components/schemas/ConnectionSource'

    ExternalImportRequest:
      type: object
      description: |
        Request body for `POST /api/external-imports`. Caller registers
        a one-shot bearer URL; server validates (SSRF + TTL + magic-byte
        + content-type per ADR-0005 §"SSRF posture"), encrypts the URL
        + password at rest, and returns an opaque `external_source_id`.
      required:
        - url
      properties:
        url:
          type: string
          format: uri
          pattern: '^https://'
          description: |
            HTTPS-only. Server resolves DNS server-side and rejects
            private / loopback / link-local / cloud-metadata IPs per
            ADR-0005 §"SSRF posture" rule 3. Bearer URLs (S3 presigned,
            GCS signed, Azure SAS, OneDrive `downloadUrl`) have
            redirects disabled by default.
        provider_hint:
          type: string
          description: |
            Optional, advisory only. Triggers server-side URL
            normalisation (e.g. Dropbox `?dl=0` → `?dl=1` for
            force-download). Server behaviour MUST NOT depend on this
            for auth or security decisions — the actual provider is
            detected from URL structure and confirmed by the SSRF
            validator.

            Per-value availability tags below are **decorative only**
            in this OpenAPI request schema — the I17 CI guard
            (`scripts/check-per-value-availability.py`) is scoped to
            `schemas/operations/*.yaml` and does not validate this
            field. SDK consumers should still respect the tagged
            availability per the runtime capability endpoint (I3) +
            sidecar (I3b).
          enum:
            - s3_presigned
            - gcs_signed
            - azure_sas
            - dropbox
            - public_https
            - gdrive
            - onedrive
          per_value_availability:
            s3_presigned: { availability: stable }
            gcs_signed:   { availability: stable }
            azure_sas:    { availability: beta }
            dropbox:      { availability: beta }
            public_https: { availability: beta }
            gdrive:       { availability: planned }
            onedrive:     { availability: planned }
        password:
          type: string
          description: |
            Password for password-protected shared links (Dropbox Pro+,
            etc.). Stored encrypted; never returned in any response
            after creation.
        expires_at:
          type: string
          format: date-time
          description: |
            Caller-asserted expiration matching the signed URL's TTL.
            Server validates minimum TTL at create time AND again at
            fetch time (TOCTOU defence per ADR-0005 §"SSRF posture").

    ExternalImportCreatedResponse:
      type: object
      description: Inner data shape for a successful external-import registration.
      required:
        - external_source_id
        - expires_at
        - provider
      properties:
        external_source_id:
          $ref: '#/components/schemas/UuidV7'
        expires_at:
          type: string
          format: date-time
        provider:
          type: string
          description: |
            Server-detected provider classification from the URL
            (`s3_presigned` / `gcs_signed` / `azure_sas` / `dropbox` /
            `public_https`). May differ from the caller's
            `provider_hint` if the hint was wrong; server's value is
            authoritative.

    ExternalImportCreatedSuccessEnvelope:
      type: object
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/ExternalImportCreatedResponse'

    # ============================================
    # AUDIO WATERMARK DECODE (per I20)
    # ============================================

    AudioWatermarkDecodeRequest:
      type: object
      description: |
        Request body for `POST /api/audio-watermark/decode`. Per
        ticket [I20](https://trello.com/c/omiCq7Vn). The asset to
        decode is identified by an upload `file_id` (from the standard
        upload flow); the decoder fetches the asset from S3 and
        extracts the watermark.

        Same scope-limit applies as documented on the endpoint:
        own-watermarks-only. The runtime cross-references the
        decoded `watermark_id` against the caller's audit log of
        previously-issued watermarks; mismatches return 404 (rather
        than leaking that *some* watermark was detected).
      required:
        - file_id
      properties:
        file_id:
          type: string
          format: uuid
          description: |
            UUID-v7 file ID of the uploaded asset to decode. Must
            reference an upload owned by the caller.
          example: "019539ab-1111-7000-8000-000000000aa1"
        method_hint:
          type: string
          enum: [auto, psychoacoustic, neural]
          default: auto
          description: |
            Hint to the decoder about which method was used at embed
            time. `auto` (default) tries each method in order until
            a watermark is detected. Specifying the method directly
            shortens decode latency when the caller already knows.

    AudioWatermarkDecodeResponse:
      type: object
      description: |
        200 response from `POST /api/audio-watermark/decode`. Per
        ticket [I20](https://trello.com/c/omiCq7Vn).
      required:
        - watermark_id
        - confidence
        - method
        - detected_at
      properties:
        watermark_id:
          type: string
          format: uuid
          description: |
            The `watermark_id` of the detected watermark — either the
            UUID-v7 the server generated at embed time (when the
            caller did not supply a `payload`), or the
            server-assigned ID for caller-supplied payloads.
          example: "019539ab-2222-7000-8000-bbbbbbbbbbbb"
        payload:
          type: string
          description: |
            Caller-supplied payload that was embedded at
            watermark-creation time, when present. Absent when the
            embed call did not specify a `payload` (server-generated
            `watermark_id` only). Max 256 characters.
          maxLength: 256
          example: "license-key-abc-123"
        confidence:
          type: number
          format: float
          minimum: 0.0
          maximum: 1.0
          description: |
            Decoder confidence in the extracted watermark. Values
            below 0.6 indicate a noisy / partial detection; the
            caller should treat such results as advisory.
          example: 0.94
        method:
          type: string
          enum: [psychoacoustic, neural]
          description: |
            Method that successfully extracted the watermark. (The
            `auto` value is request-side only; the response always
            reports the concrete method used.)
          example: psychoacoustic
        detected_at:
          type: string
          format: date-time
          description: ISO-8601 timestamp at which the decode call ran.
          example: "2026-04-26T14:00:00Z"

    # ============================================
    # CREDITS + BILLING (F9 — per ticket I23)
    # ============================================

    CreditTransactionSourceBucket:
      type: string
      description: |
        Which credit pool a transaction debited / credited.
        - `monthly`: tier allowance pool (refreshed each billing cycle).
        - `purchased`: top-up pool (no expiry; survives renewal).
        - `mixed`: a single transaction split across both buckets
          (e.g. a job whose cost exceeded the remaining monthly
          balance and drew the difference from purchased credits).
          When `mixed`, both `monthly_amount` and `purchased_amount`
          on `CreditTransaction` are populated with non-zero values.
        - `overdraft`: drawn entirely against the overdraft pool
          (`overdraft_limit` / `overdraft_debt` on
          `CreditsBalanceResponse`) when both monthly and purchased
          balances are exhausted. Per `CreditAccount::resolveSourceBucket()`
          precedence, this value is set only when neither bucket
          contributed — `monthly_amount` and `purchased_amount` on
          `CreditTransaction` are both `null` when this value is
          set (per the `null`-when-not-applicable convention; never
          normalised to `0`). The overdraft contribution can be
          reconstructed from the transaction `amount` (the entire
          signed amount drew from the overdraft pool).
      enum:
        - monthly
        - purchased
        - mixed
        - overdraft

    CreditsBalanceResponse:
      type: object
      description: |
        Snapshot of the caller's credit position at request time.
        Per ticket [I23 `DffjC3zm`](https://trello.com/c/DffjC3zm) +
        plan v5 §F9 round-13 narrowing — this is the canonical
        user-visible billing surface (`/api/v2/credits/estimate` and
        `/api/billing/summary` are NOT shipped).

        All integer fields are non-negative whole credits except
        `available_credits` which is server-computed and not
        constrained to non-negative (an overdraft-exhausted caller
        with active reservation may transiently report negative;
        SDKs should clamp at zero for display).
      required:
        - monthly_balance
        - purchased_balance
        - overdraft_limit
        - overdraft_debt
        - available_credits
        - monthly_allowance
        - tier
      properties:
        monthly_balance:
          type: integer
          minimum: 0
          description: Remaining credits in the monthly tier-allowance pool.
        purchased_balance:
          type: integer
          minimum: 0
          description: Remaining credits from one-off top-ups (no expiry).
        overdraft_limit:
          type: integer
          minimum: 0
          description: Maximum credits this caller can go negative against.
        overdraft_debt:
          type: integer
          minimum: 0
          description: Credits currently owed against the overdraft pool.
        available_credits:
          type: integer
          description: |
            Convenience field — what the caller can spend right now
            (`monthly_balance + purchased_balance + (overdraft_limit -
            overdraft_debt)`). Server-computed; SDKs MAY recompute
            from the bucket fields if they prefer.
        monthly_allowance:
          type: integer
          minimum: 0
          description: Tier base allowance per cycle (informational).
        tier:
          $ref: '#/components/schemas/UserTier'

    BillingCheckoutRequest:
      type: object
      additionalProperties: false
      description: |
        Request body for `POST /api/billing/checkout`. The client supplies
        only the target `type` + `key`; the Stripe `success_url` /
        `cancel_url` redirect targets are server-set. Unknown extra
        properties are rejected.

        Valid `key` values partition by `type`:
        - `type: subscription` → `key` ∈ { `pro`, `max` }
        - `type: pack` → `key` ∈ { `pack_10`, `pack_25`, `pack_50` }

        The `type` ↔ `key` pairing is validated **server-side**, not by this
        schema (the `key` enum lists all values across both types). A
        mismatched or unprovisioned pair returns `422` `UNPROCESSABLE_ENTITY`;
        a missing `type` or empty `key` returns `422` `VALIDATION_FAILED`.
      required:
        - type
        - key
      properties:
        type:
          type: string
          enum: [subscription, pack]
          description: |
            Whether the checkout is for a recurring `subscription` upgrade
            or a one-off credit `pack` purchase.
        key:
          type: string
          enum: [pro, max, pack_10, pack_25, pack_50]
          description: |
            The specific plan or pack to purchase. Must be consistent with
            `type` (subscription → `pro` / `max`; pack → `pack_10` /
            `pack_25` / `pack_50`) — the pairing is server-validated.

    BillingCheckoutSession:
      type: object
      # Open payload (the response-data target stays open for additive
      # evolution — matches CreditsBalanceResponse / AccountLimits; the
      # enclosing BillingCheckoutSuccessEnvelope is the closed shape). New
      # top-level fields (e.g. an expiry) may be added without a breaking
      # change.
      description: |
        The created Stripe hosted-Checkout session — the redirect URL plus
        the Stripe session id.
      required:
        - checkout_url
        - session_id
      properties:
        checkout_url:
          type: string
          format: uri
          description: The Stripe-hosted Checkout URL to redirect the browser to.
        session_id:
          type: string
          description: |
            The Stripe Checkout Session id (`cs_...`). Opaque; useful for
            correlation / observability.

    BillingCheckoutSuccessEnvelope:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/BillingCheckoutSession'

    CreditsBalanceSuccessEnvelope:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/CreditsBalanceResponse'

    AccountLimitsSuccessEnvelope:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/AccountLimits'

    AccountLimits:
      type: object
      # Open payload (response data target stays open for additive evolution —
      # the success-envelope tightening convention; new top-level fields may
      # be added without a breaking change).
      description: |
        The caller's effective (override-aware) account limits
        (`GET /api/v2/account/limits`, ticket
        [`ViRVI2MP`](https://trello.com/c/ViRVI2MP)). `tier` is the caller's
        subscription tier; `limits` maps a fixed v1 key set to each limit's
        effective / tier-default / override state.

        **v1 limit keys** (both REQUIRED):
        - `max_upload_size_bytes`: the per-FILE upload cap
          (`UserTier.maxFileSizeBytes` — the request-level tier quota the
          upload endpoints enforce). **Distinct** from the per-operation
          processing ceiling `max_input_size_bytes` in operation schemas
          (different axis AND number). Tier defaults: free 10 MiB, pro 5 GiB,
          enterprise 100 GiB.
        - `max_total_input_size_bytes`: the effective merge combined-input
          size cap (the summed-inputs ceiling). Same key as the
          operation-schema merge band; the band is processing-class +
          tier dependent (`short_form_concat` baseline 1 GiB; the
          `long_form_re_encode` band where the tier permits it — Pro 5 GB,
          Enterprise 120 GB). The server resolves the effective value for
          the caller.
      required:
        - tier
        - limits
      properties:
        tier:
          $ref: '#/components/schemas/UserTier'
        limits:
          type: object
          # Typed-open: the v1 keys below are REQUIRED + frozen, but future
          # limit keys may be added additively (each an AccountLimitEntry) —
          # forward-compatible per the open-payload convention.
          additionalProperties:
            $ref: '#/components/schemas/AccountLimitEntry'
          required:
            - max_upload_size_bytes
            - max_total_input_size_bytes
          properties:
            max_upload_size_bytes:
              $ref: '#/components/schemas/AccountLimitEntry'
            max_total_input_size_bytes:
              $ref: '#/components/schemas/AccountLimitEntry'

    AccountLimitEntry:
      type: object
      additionalProperties: false
      description: |
        One account limit: the effective value (override ?? tier default),
        the tier default, and whether a per-account override is active. The
        override mechanism / store is NOT exposed — only the resolved
        numbers plus the `overridden` flag.
      required:
        - effective
        - tier_default
        - overridden
      properties:
        effective:
          type: integer
          minimum: 1
          description: |
            The cap in force for this account, in bytes
            (`override ?? tier_default`).
        tier_default:
          type: integer
          minimum: 1
          description: |
            The tier's default cap for this limit, in bytes (already public
            via the tier model).
        overridden:
          type: boolean
          description: |
            True iff a per-account override is active (i.e. `effective`
            comes from an override rather than the tier default).

    CreditTransaction:
      type: object
      description: |
        Single ledger entry. Immutable once written. A workflow-create
        reservation appears here as a single row with `source_bucket`
        indicating which pool(s) were debited; refunds appear as
        separate rows referencing the original via `reference_id` and
        `reference_type: workflow`.

        `type`, `pricing_version`, and `reference_type` are
        deliberately free-form strings — their value sets evolve with
        billing event taxonomy and pricing-table revisions; SDKs
        duck-type and treat opaque (mirrors the
        `OperationMetrics.re_encode_reason` opacity precedent from
        I16-CONS).
      required:
        - id
        - type
        - amount
        - monthly_balance_before
        - monthly_balance_after
        - purchased_balance_before
        - purchased_balance_after
        - source_bucket
        - monthly_amount
        - purchased_amount
        - pricing_version
        - description
        - reference_type
        - reference_id
        - created_at
      properties:
        id:
          $ref: '#/components/schemas/UuidV7'
        type:
          type: string
          description: |
            Ledger entry type — free-form string. Common values:
            `reservation`, `refund`, `top_up`, `monthly_grant`,
            `adjustment`. Not enumerated to avoid contract churn as
            billing event taxonomy evolves.
        amount:
          type: integer
          description: |
            Signed delta against the caller's total balance. Positive
            for grants/top-ups, negative for debits/reservations.
            Equals `monthly_amount + purchased_amount` for ledger
            entries that touch only those two buckets.
        monthly_balance_before:
          type: integer
          minimum: 0
        monthly_balance_after:
          type: integer
          minimum: 0
        purchased_balance_before:
          type: integer
          minimum: 0
        purchased_balance_after:
          type: integer
          minimum: 0
        source_bucket:
          $ref: '#/components/schemas/CreditTransactionSourceBucket'
        monthly_amount:
          type: [integer, "null"]
          description: |
            Signed portion debited from / credited to the monthly
            pool. `null` when the monthly bucket did not contribute
            to this transaction (`source_bucket: purchased` or
            `source_bucket: overdraft`). Non-null on both
            `monthly_amount` and `purchased_amount` when
            `source_bucket: mixed`. May be `0` (not `null`) on the
            specific `OverdraftRepayment` ledger row that
            `applyMonthlyGrant()` emits when an inbound grant is
            fully absorbed by overdraft debt — that row carries
            `type: overdraft_repayment` + `amount: 0` and
            distinguishes itself from "no contribution" by the
            non-null zero rather than null.

            **Partial-overdraft reconstruction.** A transaction can
            split across a normal bucket *and* the overdraft pool
            (e.g. monthly partially covered the cost and the
            shortfall fell to overdraft). In that case the resolver
            returns `source_bucket: monthly` / `purchased` / `mixed`
            based on which normal bucket(s) contributed, NOT
            `overdraft`. Consumers that need the overdraft
            contribution can reconstruct it as
            `abs(amount) - abs(monthly_amount ?? 0) -
            abs(purchased_amount ?? 0)`; a positive remainder is the
            overdraft draw. Pure-overdraft rows still have
            `source_bucket: overdraft` with both fields `null`.
        purchased_amount:
          type: [integer, "null"]
          description: |
            Signed portion debited from / credited to the purchased
            pool. `null` when the purchased bucket did not
            contribute to this transaction (`source_bucket:
            monthly` or `source_bucket: overdraft`). Non-null on
            both `monthly_amount` and `purchased_amount` when
            `source_bucket: mixed`. Same `null`-when-not-applicable
            convention as `monthly_amount`; partial-overdraft
            reconstruction formula in `monthly_amount` description
            applies symmetrically.
        pricing_version:
          type: string
          description: |
            Pricing-table version applied to this transaction.
            Free-form string (server emits a semver-shaped or
            date-shaped tag — e.g. `v3.2.0` or `2026-04-01`); SDKs
            MUST treat opaque. Stable across the transaction's
            lifetime; refund rows carry the same `pricing_version`
            as the original reservation so credit/debit
            reconciliation is deterministic.
        description:
          type: string
          description: Human-readable description of the ledger entry.
        reference_type:
          type: string
          description: |
            What the transaction references. Common values:
            `workflow`, `top_up`, `cycle_grant`, `adjustment`.
            Free-form string.
        reference_id:
          type: string
          description: |
            Identifier of the referenced entity. Typically a UUID v7
            for `workflow` references, but other reference types
            (`top_up` carrying a Stripe payment intent ID,
            `cycle_grant` carrying a cycle date, etc.) may use
            non-UUID identifiers. NOT constrained to `UuidV7`.
        created_at:
          type: string
          format: date-time
        # Activity-display fields (ticket B9ppslt2 / odct4sPB) — additive, all
        # nullable + optional. Populate ONLY on `type: reservation` rows going
        # forward; legacy reservation rows (pre-migration) and all non-reservation
        # ledger types emit `null`. The frontend composes the human "Recent
        # activity" label from these STRUCTURED fields (operation_summary +
        # operation_count + primary_filename + type/state) instead of the opaque
        # `description` legacy fallback. No separate `label` field — FE composes
        # it (en/pt). Part A (state/state_changed_at) is live on API main (#426);
        # Part B (operation_summary/operation_count/primary_filename) lands
        # additively from the API ahead of this contract.
        state:
          type: [string, "null"]
          description: |
            Reservation lifecycle state — free-form string (kept opaque like
            `type`/`reference_type` for churn-resistance; SDKs duck-type).
            Current values: `active` (held), `settled` (charged), `released` /
            `cancelled_released` (refunded / cancelled), `expired`. `null` for
            non-reservation ledger types. Lets the consumer distinguish an
            internal HOLD from a final charge rather than showing raw
            reservations as spends.
        state_changed_at:
          type: [string, "null"]
          format: date-time
          description: |
            RFC3339 / ISO-8601 timestamp of the last `state` transition.
            `null` for non-reservation types.
        operation_summary:
          type: [string, "null"]
          description: |
            Distinct canonical operation type(s) for the referenced workflow,
            comma-joined (e.g. `"compress"` or `"convert, compress"`). `null`
            for legacy rows + non-reservation types. Human-label input.
        operation_count:
          type: [integer, "null"]
          minimum: 0
          description: |
            Count of operations in the referenced workflow. `null` for legacy /
            non-reservation rows. Human-label input (e.g. "Compressed 3 images").
        primary_filename:
          type: [string, "null"]
          description: |
            Original filename of the primary (first) input of the referenced
            workflow. `null` for legacy / non-reservation rows. Human-label input.

    CreditsUsageResponse:
      type: object
      required:
        - transactions
        - total
        - limit
        - offset
      properties:
        transactions:
          type: array
          description: Most-recent-first ordered page of transactions.
          items:
            $ref: '#/components/schemas/CreditTransaction'
        total:
          type: integer
          minimum: 0
          description: Total transaction count for this caller (across all pages).
        limit:
          type: integer
          minimum: 1
          maximum: 100
        offset:
          type: integer
          minimum: 0

    CreditsUsageSuccessEnvelope:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/CreditsUsageResponse'

    BalanceExhaustedResponse:
      description: |
        402 response body emitted from `POST /api/workflows` when the
        server-side cost estimate exceeds the caller's
        `available_credits` (monthly + purchased + overdraft
        headroom). Per ticket
        [I23 `DffjC3zm`](https://trello.com/c/DffjC3zm) F9 atomic-
        reservation invariant.

        Carries `error_type: balance_exhausted` plus a structured
        `required_action` (one of `add_credits`, `upgrade_plan`,
        `wait_for_renewal`) and `links` to the relevant top-up /
        upgrade page.

        **Deliberate omission.** This envelope carries NO numeric
        deficit fields (no `required`, `available`, `shortfall`).
        Per plan v5 §F9 round 13 — frontend reads the current state
        from `GET /api/v2/credits/balance`, not from the error
        envelope. Decoupling the error from numeric state keeps the
        402 wire shape stable as cost-model granularity changes
        (e.g. shift to per-second micro-credits). Frontend
        re-fetches balance after a 402.

        Carries the optional localisation triple (`message_key` +
        `message` + `locale` + `message_params`) per ticket
        [I26](https://trello.com/c/rcnqwgI4) — see `ErrorEnvelope`
        for the canonical convention. `message_params` excludes
        cost / monetary numbers per the round-13 narrowing
        (frontend reads numeric balance from `/credits/balance`).
      allOf:
        - $ref: '#/components/schemas/ErrorEnvelope'
        - type: object
          required:
            - error_type
            - required_action
          properties:
            error_type:
              type: string
              enum: [balance_exhausted]
              description: Discriminator value for this 402 envelope.
            message_key:
              type: string
              description: |
                Canonical lookup key per ticket
                [I26](https://trello.com/c/rcnqwgI4). See
                `ErrorEnvelope.message_key`.
            message:
              type: string
              description: |
                Human-readable, optionally localised explanation. See
                `ErrorEnvelope.message`. Never parse for control flow.
            locale:
              type: string
              description: BCP 47 locale tag. See `ErrorEnvelope.locale`.
            message_params:
              type: object
              additionalProperties: true
              description: |
                Optional interpolation values for the localised
                `message`. See `ErrorEnvelope.message_params`.
                **Excludes cost / monetary numbers** — frontend
                reads numeric balance from
                `GET /api/v2/credits/balance` per the round-13
                narrowing.
            required_action:
              type: string
              enum:
                - add_credits
                - upgrade_plan
                - wait_for_renewal
              description: |
                What the caller must do to retry successfully.
                - `add_credits`: caller has overdraft headroom only on
                  the purchased pool — buy a top-up.
                - `upgrade_plan`: caller's tier allowance is too small
                  for sustained use — upgrade to pro/enterprise.
                - `wait_for_renewal`: monthly cycle resets soon and
                  the caller's projected post-renewal balance covers
                  the cost — no money required.
            links:
              type: object
              description: Caller-actionable URLs matching `required_action`.
              properties:
                upgrade:
                  type: string
                  format: uri
                  description: Upgrade-plan deep link.
                top_up:
                  type: string
                  format: uri
                  description: Top-up deep link.

    LongFormConcurrencyLimitResponse:
      type: object
      description: |
        `429` response body for `POST /api/workflows` when the caller
        already holds the maximum number of concurrent in-flight
        long-form (Fargate) workflows their tier permits
        (`UserTier.maxConcurrentLongFormJobs`: Pro 2 / Max 5; Enterprise
        + Free uncapped — Free has no long-form access at all). Carried
        on a `429` but DISTINCT from an infrastructure rate-limit `429`:

        - `error` is the stable code `LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED`
          (`message_key: job.long_form_concurrency_exceeded`).
        - **No `Retry-After`.** The limit clears when an in-flight
          long-form workflow finishes, not on a timer — the client waits
          on workflow completion (or upgrades), it does not back off for
          N seconds.
        - Carries `links.upgrade` (the pricing / upgrade deep link) so the
          frontend can render a "raise your concurrent long-form limit"
          CTA rather than "try again later".

        A generic infrastructure rate-limit `429` on this endpoint is the
        links-absent subset of this shape: a plain `ErrorEnvelope` with a
        `Retry-After` header and no `links`. `error` is left as a
        documented string (not a strict enum) so the rate-limit subset
        validates against the same schema and consumers tolerate unknown
        codes; SDKs branch on the `LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED`
        value to distinguish the two.

        Mirrors the runtime shape produced by `compression_api`'s
        `WorkflowController` +
        `CreateWorkflowResult::longFormConcurrencyExceeded`
        (PR #484, API ticket CSPBSIVm).
      allOf:
        - $ref: '#/components/schemas/ErrorEnvelope'
        - type: object
          properties:
            links:
              type: object
              description: |
                Caller-actionable URLs. Carries `upgrade` for the
                long-form concurrency `429`; absent on the infrastructure
                rate-limit subset.
              properties:
                upgrade:
                  type: string
                  format: uri
                  description: Upgrade-plan / pricing deep link.

    # ============================================
    # WORKFLOW LIFECYCLE — CANCEL / RESUME (per ticket I24)
    # ============================================

    WorkflowCancelBillingEffect:
      type: string
      description: |
        Effect of a workflow cancel on outstanding credit
        reservations.
        - `unspent_reservation_released`: caller refunded for the
          unspent portion of the original reservation; refund
          appears as a separate `CreditTransaction` (`type: refund`,
          `reference_type: workflow`, `reference_id` matching the
          cancelled workflow).
        - `none`: no refund issued (all reserved credits already
          consumed by completed jobs at cancel time, OR the
          workflow was already terminal in a previous cancel — the
          idempotent re-cancel path).
      enum:
        - unspent_reservation_released
        - none

    WorkflowCancelResponse:
      type: object
      description: |
        Response body for `POST /api/workflows/{id}/cancel`. Per
        ticket [I24 `e50uXLcl`](https://trello.com/c/e50uXLcl).
      required:
        - workflow_id
        - status
        - cancelled_at
        - billing_effect
      properties:
        workflow_id:
          $ref: '#/components/schemas/UuidV7'
        status:
          type: string
          enum:
            - cancelled
          description: |
            Always `cancelled` on a 200 response. The `WorkflowStatus`
            enum carries the broader state space; this response is
            constrained to the cancel outcome.
        cancelled_at:
          type: string
          format: date-time
          description: |
            ISO-8601 timestamp the cancel was applied. Idempotent —
            a re-cancel of an already-cancelled workflow returns the
            original `cancelled_at`.
        billing_effect:
          $ref: '#/components/schemas/WorkflowCancelBillingEffect'

    WorkflowCancelSuccessEnvelope:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/WorkflowCancelResponse'

    WorkflowResumeResponse:
      type: object
      description: |
        Response body for `POST /api/workflows/{id}/resume`. Per
        ticket [I24 `e50uXLcl`](https://trello.com/c/e50uXLcl).
      required:
        - workflow_id
        - status
        - resumed_at
      properties:
        workflow_id:
          $ref: '#/components/schemas/UuidV7'
        status:
          type: string
          enum:
            - in_progress
          description: |
            Always `in_progress` on a 200 response (resume succeeded
            and the workflow is now running again).
        resumed_at:
          type: string
          format: date-time

    WorkflowResumeSuccessEnvelope:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/WorkflowResumeResponse'

    WorkflowExpiredResponse:
      allOf:
        - $ref: '#/components/schemas/ErrorEnvelope'
        - type: object
          required:
            - error_type
            - expired_at
          properties:
            error_type:
              type: string
              enum:
                - workflow_expired
              description: Discriminator value for this 422 envelope.
            expired_at:
              type: string
              format: date-time
              description: |
                ISO-8601 timestamp the workflow transitioned to
                `expired`. Default TTL is 7 days from `paused_at`;
                server-side configurable per ADR-0001 §1.7 row 6.

    WorkflowPauseRequiredAction:
      type: string
      description: |
        Action the caller must take to resume a paused workflow.
        Same enum as `BalanceExhaustedResponse.required_action` —
        the pause cause is always insufficient credits, so the
        resolution affordances align.
      enum:
        - add_credits
        - upgrade_plan
        - wait_for_renewal

    # ============================================
    # WORKFLOW ADVISORY WARNINGS (per ticket I25)
    # ============================================

    WarningType:
      type: string
      description: |
        Workflow advisory warning type. V2.0 ships with a single
        value; SDKs MUST treat the enum as **additive** (ignore
        unknown values encountered at runtime — forward-compatible
        convention per the same precedent as `ProgressStatus`,
        `ProcessingClassReason`, `WorkflowStatus`).

        **V2.0 value:**
        - `redundant_pre_encode_before_reencode_merge`: Upstream
          single-output `compress` or `convert` jobs feed a
          downstream `merge` with `re_encode_mode != never`. The
          pre-encode work is wasted because the merge re-encodes
          the output regardless. Caller should either drop the
          upstream encode step OR set `merge.re_encode_mode: auto`
          to let the server decide.

        **V2.1+ candidates (non-normative — future tickets per
        plan v5 §F11):**
        - `redundant_thumbnail_for_compressed_input`
        - `output_format_unchanged_after_convert`
        - `merge_inputs_already_normalised`
        - (additional detection rules per plan v5 §F11 land per-
          rule when product/Lambda confirms detection logic.)

        Per ticket [I25 `i5yCuSZc`](https://trello.com/c/i5yCuSZc).
      enum:
        - redundant_pre_encode_before_reencode_merge

    WorkflowWarningSeverity:
      type: string
      description: |
        Severity of a workflow advisory warning. V2.0 ships with a
        single value `advisory` — non-blocking; the workflow
        proceeds regardless. Future severities reserved for cases
        where a warning escalates to blocking-but-overrideable (no
        such cases in V2.0).
      enum:
        - advisory

    WorkflowWarning:
      type: object
      description: |
        Advisory warning surfaced on `POST /api/workflows` when the
        server detects a likely-wasteful pattern. Non-blocking —
        the workflow is created and dispatched regardless. Per
        ticket [I25 `i5yCuSZc`](https://trello.com/c/i5yCuSZc) +
        plan v5 §F11 round 10/11.

        **V2.0 detection rule** (`redundant_pre_encode_before_reencode_merge`):

        1. Upstream jobs are single-output `compress` / `convert`
           (transcode).
        2. Outputs feed exactly one downstream `merge` job.
        3. Merge has `re_encode_mode != never` (will re-encode
           regardless).
        4. Upstream jobs are not `deliver: true` and not in
           `delivery.selection.explicit.refs[]` (i.e. their output
           is not separately delivered).
        5. Upstream encode options are identical to or subsumed by
           the downstream merge transcode options.

        All five conditions must hold. Detection is server-side
        and deliberately opaque (algorithm can evolve without
        contract churn).

        Carries the I26 localisation triple — `message` is
        localised per `Accept-Language`; `message_key` is the
        stable lookup key. See `ErrorEnvelope` for the canonical
        convention.
      required:
        - warning_type
        - severity
        - jobs
      properties:
        warning_type:
          $ref: '#/components/schemas/WarningType'
        severity:
          $ref: '#/components/schemas/WorkflowWarningSeverity'
        jobs:
          type: array
          description: |
            UUID-v7 job IDs the warning applies to. For
            `redundant_pre_encode_before_reencode_merge`, this is
            the upstream encode jobs that produce the redundant
            work (NOT the downstream merge).
          minItems: 1
          items:
            type: string
            format: uuid
        message:
          type: string
          description: |
            Human-readable, optionally localised explanation. See
            `ErrorEnvelope.message`. Never parse for control flow.
        message_key:
          type: string
          description: |
            Canonical lookup key per ticket
            [I26](https://trello.com/c/rcnqwgI4). See
            `ErrorEnvelope.message_key`.
        locale:
          type: string
          description: BCP 47 locale tag. See `ErrorEnvelope.locale`.
        message_params:
          type: object
          additionalProperties: true
          description: |
            Optional interpolation values for the localised
            `message`. See `ErrorEnvelope.message_params`. Excludes
            cost numbers.
        documentation_url:
          type: string
          format: uri
          description: |
            Optional link to detection-rule documentation
            explaining the wasteful pattern + recommended fix.
      example:
        warning_type: redundant_pre_encode_before_reencode_merge
        severity: advisory
        jobs:
          - "019539ad-3333-7000-8000-000000000001"
          - "019539ad-3333-7000-8000-000000000002"
        message: "Pre-encoding job outputs are re-encoded by the downstream merge; consider removing the compress step or letting merge.re_encode_mode: auto handle it."
        message_key: "warning.redundant_pre_encode_before_reencode_merge"
        locale: "en-GB"
        message_params:
          upstream_count: 2
          merge_re_encode_mode: "always"
        documentation_url: "https://docs.giveitsmaller.com/workflow-warnings/redundant-pre-encode-before-reencode-merge"

    WorkflowPausedDetail:
      type: object
      description: |
        `paused_insufficient_credits` workflow-state surface returned
        on `GET /api/workflows/{id}/status` when the workflow is
        paused. Per ticket
        [I24 `e50uXLcl`](https://trello.com/c/e50uXLcl).

        SDK + frontend gate UI on `required_action` to render the
        appropriate "Top up to resume" / "Upgrade plan to resume" /
        "Wait for renewal" affordance, with `links.resume` enabling
        a one-click POST to `/api/workflows/{id}/resume` once the
        balance is sufficient.
      required:
        - paused_at
        - expires_at
        - required_action
        - links
      properties:
        paused_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          description: |
            ISO-8601 timestamp at which the workflow auto-transitions
            to `expired` if not resumed. Default 7-day TTL from
            `paused_at`.
        required_action:
          $ref: '#/components/schemas/WorkflowPauseRequiredAction'
        message_key:
          type: string
          description: |
            Canonical lookup key per ticket
            [I26](https://trello.com/c/rcnqwgI4). See
            `ErrorEnvelope.message_key`.
        message:
          type: string
          description: |
            Human-readable, optionally localised explanation. See
            `ErrorEnvelope.message`. Never parse for control flow.
        locale:
          type: string
          description: BCP 47 locale tag. See `ErrorEnvelope.locale`.
        message_params:
          type: object
          additionalProperties: true
          description: |
            Optional interpolation values for the localised
            `message`. See `ErrorEnvelope.message_params`. Excludes
            cost numbers per round-13 narrowing.
        links:
          type: object
          description: Caller-actionable URLs.
          properties:
            resume:
              type: string
              format: uri
              description: |
                Deep link to the resume action — typically
                `POST /api/workflows/{id}/resume` exposed via the
                frontend's "resume" UI.
            top_up:
              type: string
              format: uri
              description: "Top-up deep link (when `required_action: add_credits`)."
            upgrade:
              type: string
              format: uri
              description: "Upgrade-plan deep link (when `required_action: upgrade_plan`)."

    # ============================================
    # USER TIER + TIER RESTRICTION ENVELOPES
    # ============================================

    UserTier:
      type: string
      description: |
        Subscription tier. Mirrors the API-side
        `App\Identity\Domain\Enums\UserTier` PHP enum.

        Ordering is `free` < `pro` < `max` < `enterprise` (the
        upgrade-resolver / `isHigherThan` ordinal in `UserTier.php`).
        `max` is the top **self-serve** tier — Pro plus audio + larger
        long-form bands; Enterprise remains the negotiated tier above it.

        Tier capability summary (informational; canonical limits are
        enforced server-side per `UserTier.php`):
        - `free`: 10 MiB max upload; image MIMEs only; 50 monthly credits;
          0 overdraft; 1× rate-limit baseline. No long-form access
          (video/audio are MIME-gated away from Free).
        - `pro`: 5 GiB max upload; image + video + document MIMEs; 1000
          monthly credits; 200 overdraft; 5× rate-limit; up to 2 concurrent
          in-flight long-form jobs.
        - `max`: 50 GiB max upload; image + video + document + audio MIMEs;
          7500 monthly credits; 2500 overdraft; 15× rate-limit; up to 5
          concurrent in-flight long-form jobs.
        - `enterprise`: 100 GiB max upload; image + video + document + audio
          MIMEs; 10000 monthly credits; 5000 overdraft; 20× rate-limit;
          uncapped concurrent long-form jobs.

        **Concurrent long-form jobs** is a hard per-tier ceiling on the
        number of in-flight long-form (Fargate) workflows a caller may
        hold at once — exceeding it returns a typed `429`
        `LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED` (see the `POST /api/workflows`
        429 response). Pro's 2-job cap is enforced as of the Max-tier
        launch.

        The "max upload" figures are the per-file upload cap
        (`UserTier.maxFileSizeBytes`) — the request-level tier quota,
        surfaced override-aware via `GET /api/v2/account/limits`
        (`max_upload_size_bytes`). They are DISTINCT from the per-operation
        processing-class band caps (`processing_class.constraints` in the
        operation schemas; e.g. the 120 GB Enterprise merge combined band).

        Used by `TierRestrictionResponse.current_tier` /
        `TierRestrictionResponse.required_tier` and by
        `FeatureViolation.required_tier`.
      enum:
        - free
        - pro
        - max
        - enterprise

    TierRestrictionKind:
      type: string
      description: |
        Discriminator for `tier_restriction` 403 responses on upload and
        workflow-create endpoints. Mirrors the API-side
        `App\Identity\Domain\Enums\RestrictionKind` PHP enum.

        - `mime_type`: caller's tier does not permit this MIME type
          (e.g. free tier uploading a video).
        - `file_size`: file exceeds caller's tier file-size cap
          (e.g. pro tier uploading a 1 GB file).
      enum:
        - mime_type
        - file_size

    TierRestrictionResponse:
      description: |
        403 response body for request-level tier-quota violations on
        upload + workflow-create endpoints (file size or MIME).

        Distinct from `FeatureTierRestrictedResponse` (also 403): this
        envelope is request-scoped (one restriction per request, e.g.
        the file is too big); `FeatureTierRestrictedResponse` is
        per-feature granularity inside a workflow body (e.g. a workflow
        references multiple operations / options that require a higher
        tier).

        The two envelopes share status 403 and are discriminated by
        `error_type` at the response-schema `oneOf` level — see the
        `oneOf + discriminator` block on each path's 403 response.

        Mirrors the runtime shape produced by
        `compression_api`'s `FileUploadController::tierRestrictionExtras`
        + `WorkflowsController` after PR #179 (API ticket HPfGvPTZ).
      allOf:
        - $ref: '#/components/schemas/ErrorEnvelope'
        - type: object
          required:
            - error_type
            - restriction_kind
            - current_tier
          properties:
            error_type:
              type: string
              enum:
                - tier_restriction
              description: Discriminator for the 403 oneOf. Always `tier_restriction` for this envelope.
            restriction_kind:
              $ref: '#/components/schemas/TierRestrictionKind'
            current_tier:
              $ref: '#/components/schemas/UserTier'
            required_tier:
              description: |
                Tier required to satisfy the request. Optional — may be
                omitted (`null`) when no upgrade path resolves the
                restriction (e.g. enterprise tier already exceeds the
                file-size cap, or the MIME is rejected at every tier).
              oneOf:
                - $ref: '#/components/schemas/UserTier'
                - type: 'null'

    # ============================================
    # FEATURE AVAILABILITY ENVELOPES
    # ============================================

    FeatureViolation:
      type: object
      description: |
        Single feature violation. One or more of these are returned in
        `FeatureNotAvailableResponse.violations[]` (422) and
        `FeatureTierRestrictedResponse.violations[]` (403).

        `feature` is a dotted-path identifier of the unavailable schema
        entry (e.g. `operation.image_watermark`,
        `operation.compress.option.codec.av1`,
        `operation.merge.mime_group.image.option.transition.dissolve`).
        Granularity matches the schema entry that was rejected
        (operation / mime_group / option / per_value).
      required:
        - feature
        - availability
      properties:
        feature:
          type: string
          description: Dotted-path identifier of the rejected schema entry.
        availability:
          $ref: '#/components/schemas/AvailabilityValue'
        eta:
          type: string
          description: |
            Optional ISO-8601 date or quarter (`2026-Q3`) when the feature
            is expected to ship. Only meaningful for
            `availability: planned`. May be omitted when no ETA is
            committed.
        required_tier:
          description: |
            Tier required to access this feature. Set when the violation
            is tier-gated rather than availability-gated. May be omitted.
          oneOf:
            - $ref: '#/components/schemas/UserTier'
            - type: 'null'
        documentation_url:
          type: string
          format: uri
          description: Optional link to feature documentation.
        message_key:
          type: string
          description: |
            Canonical lookup key per ticket
            [I26](https://trello.com/c/rcnqwgI4). See
            `ErrorEnvelope.message_key`.
        message:
          type: string
          description: |
            Human-readable, optionally localised explanation. See
            `ErrorEnvelope.message`. Never parse for control flow.
        locale:
          type: string
          description: BCP 47 locale tag. See `ErrorEnvelope.locale`.
        message_params:
          type: object
          additionalProperties: true
          description: |
            Optional interpolation values for the localised
            `message` (e.g.
            `{ "feature": "audio_overlay", "eta": "2026-Q3" }`).
            See `ErrorEnvelope.message_params`. Excludes cost
            numbers.

    FeatureNotAvailableResponse:
      description: |
        422 response body when a workflow references one or more features
        whose availability is not `stable` (i.e. `planned`,
        `experimental`, or `beta` not enabled for this caller).

        Per ADR-0001 §1.3 Tension 1 rule, the server MUST honour tagged
        availability and MUST NOT silently reject spec-valid features
        with a generic schema error. This envelope is the structured
        rejection.

        The 422 response is delivered alongside `ValidationErrorEnvelope`,
        `ProcessingClassExceedsBandResponse`, and `ProbePendingResponse`
        via a `oneOf` with an explicit `error_type` discriminator (per
        [ADR-0018](../docs/decisions/0018-universal-422-error-type-discriminator.md));
        this branch's discriminator value is `feature_not_available`. See
        the 422 response on `POST /api/workflows`.
      allOf:
        - $ref: '#/components/schemas/ErrorEnvelope'
        - type: object
          required:
            - error_type
            - violations
          properties:
            error_type:
              type: string
              enum:
                - feature_not_available
              description: Discriminator for the 422 oneOf. Always `feature_not_available`.
            violations:
              type: array
              minItems: 1
              description: |
                One entry per unavailable feature referenced by the
                request. Per ADR-0001 §F6 batched-violations rule,
                multiple violations in a single request are ALL returned
                here (fail-all, not fail-fast).
              items:
                $ref: '#/components/schemas/FeatureViolation'

    ProbePendingResponse:
      description: |
        422 response on `POST /api/workflows` when the probe-pending
        gate (API `av1J0rEF`, shipped behind a default-OFF feature flag)
        is enabled and a job references an upload whose server-side
        probe has not yet completed at workflow-create time. Rather than
        silently routing the job as `short_form` (which hard-fails long
        video clips), the server rejects with this envelope so the
        client can recover deterministically.

        **Recovery contract.** Poll `POST /api/uploads/{id}/probe` for
        the pending upload until its `probe_status` is terminal
        (`ok` → re-`POST /api/workflows` the same request; `corrupt` /
        `unsupported_codec` → surface the probe error, do not retry).
        The `Retry-After` response header (when present) carries the
        suggested delay in seconds before the next poll/retry.

        Delivered alongside `ValidationErrorEnvelope`,
        `FeatureNotAvailableResponse`, and
        `ProcessingClassExceedsBandResponse` via the discriminated
        `oneOf` on the 422 response (per
        [ADR-0018](../docs/decisions/0018-universal-422-error-type-discriminator.md)) —
        this branch's `error_type` discriminator value is `probe_pending`;
        it also remains the only branch carrying `job_ref` and neither
        `details` (`ValidationErrorEnvelope`) nor `violations` (the other
        two typed envelopes).
      allOf:
        - $ref: '#/components/schemas/ErrorEnvelope'
        - type: object
          required:
            - error_type
            - job_ref
          properties:
            error_type:
              type: string
              enum:
                - probe_pending
              description: Discriminator for the 422 oneOf. Always `probe_pending`.
            job_ref:
              type: string
              description: |
                Workflow-local identifier of the job whose upload-probe
                has not landed — `JobDefinition.id` if the caller
                supplied one, else the auto-generated `job_N` token.
                Mirrors `ProcessingClassBandViolation.job_ref`; NOT
                `format: uuid` because workflow-create rejects fire
                before server-side UUIDs are assigned.
              example: "job_0"

    ProcessingClassRejectReason:
      type: string
      description: |
        Why a job cannot be classified — input size/duration exceeds
        the `long_form` (or `long_form_re_encode`) ceiling under the
        caller's effective per-tier caps (per
        [ADR-0011](../docs/decisions/0011-per-tier-processing-class-constraints.md)
        `per_tier_constraints`). Distinct from `ProcessingClassReason`
        (which describes WHY a job got its assigned class — success-
        path advisory).

        Reasons are split by the violated constraint key on the
        processing-class block (per `schemas/FORMAT.md`
        §"`processing_class:` block"):

        - `input_size_exceeds_long_form` /
          `input_duration_exceeds_long_form`: a **single input**
          exceeds the per-input ceiling (`max_input_size_bytes` /
          `max_input_duration`). Applies to single-input operations
          (compress, convert, thumbnail, text_watermark,
          audio_watermark) AND per-input checks on multi-input
          operations with per-input caps (image_watermark,
          custom_luma, audio_overlay).
        - `combined_size_exceeds_long_form` /
          `combined_duration_exceeds_long_form`: the **summed** inputs
          exceed the multi-input combined ceiling
          (`max_total_input_size_bytes` / `max_total_duration`).
          Applies to operations whose `processing_class.<class>.
          constraints` carries `max_total_*` — merge today.
      enum:
        - input_size_exceeds_long_form
        - input_duration_exceeds_long_form
        - combined_size_exceeds_long_form
        - combined_duration_exceeds_long_form

    ProcessingClassBandViolation:
      type: object
      description: |
        One band-ceiling overflow on a workflow-create reject. Carries
        the I26 localisation quad on the violation (mirrors
        `FeatureViolation`); envelope-level localisation rides via
        `allOf [ErrorEnvelope]` on `ProcessingClassExceedsBandResponse`.

        `job_ref`, `actual`, `ceiling`, `operation`, and
        `processing_class` are REQUIRED — the server always knows them
        at reject time and consumers rely on them to (a) correlate
        fail-all violations back to the offending job in multi-job
        workflows and (b) render "X exceeded by Y" without re-deriving
        caps from the per-tier overlay.
      required:
        - reason
        - job_ref
        - operation
        - processing_class
        - actual
        - ceiling
      properties:
        reason:
          $ref: '#/components/schemas/ProcessingClassRejectReason'
        job_ref:
          type: string
          description: |
            Workflow-local job identifier — `JobDefinition.id` if the
            caller supplied one, otherwise the auto-generated `job_N`
            positional token assigned by the server during request
            validation (e.g. `job_0`, `job_1`). Workflow-create rejects
            fire BEFORE persisted server UUIDs are assigned; `job_ref`
            is the request-local handle that survives across reject
            and (later) success paths. Per `JobDefinition.id` pattern
            + auto-generation rules.
          example: "stitched"
        input_index:
          type: integer
          minimum: 0
          description: |
            0-based ordinal into `JobDefinition.inputs[]` identifying
            the specific input that violated the per-input ceiling.
            Set ONLY on `input_*_exceeds_long_form` reasons for
            multi-input operations; omitted on single-input
            operations (no positional ambiguity) and on
            `combined_*_exceeds_long_form` reasons (whole-job
            violation across all inputs).
        operation:
          $ref: '#/components/schemas/OperationType'
          description: Operation type that triggered the band-ceiling reject.
        processing_class:
          $ref: '#/components/schemas/ProcessingClass'
          description: |
            The class whose ceiling was exceeded — typically `long_form`
            or `long_form_re_encode`.
        actual:
          type: integer
          minimum: 0
          description: |
            Observed value. Bytes for `*_size_exceeds_long_form`
            reasons; whole seconds for `*_duration_exceeds_long_form`
            reasons.
        ceiling:
          type: integer
          minimum: 0
          description: |
            Effective per-tier ceiling for this caller (same units as
            `actual`). The binding cap after `per_tier_constraints`
            overlay; lets consumers render "X exceeded by Y" without
            re-deriving caps from the per-tier overlay.
        required_tier:
          description: |
            Optional. When a HIGHER tier exists whose
            `per_tier_constraints` would accommodate this request, the
            server SHOULD set `required_tier` to that tier — consumers
            can offer an upgrade nudge. `null` (or omitted) means
            **no tier resolves** the overflow (e.g. enterprise already
            hits the 120 GB hard ceiling per
            [ADR-0011](../docs/decisions/0011-per-tier-processing-class-constraints.md)
            D1–D4).

            Distinct from `TierRestrictionResponse.required_tier`
            which names a tier whose request-level upload cap
            satisfies the request — this field names a tier whose
            long-form processing-class cap satisfies it. Per
            [ADR-0012](../docs/decisions/0012-processing-class-band-reject-envelope.md)
            for the band-vs-tier split.
          oneOf:
            - $ref: '#/components/schemas/UserTier'
            - type: 'null'
        documentation_url:
          type: string
          format: uri
          description: Optional link to processing-class documentation.
        message_key:
          type: string
          description: Per I26. See `ErrorEnvelope.message_key`.
        message:
          type: string
          description: |
            Per I26. Human-readable, localised per `Accept-Language`.
            Never parse for control flow.
        locale:
          type: string
          description: BCP 47 locale tag. See `ErrorEnvelope.locale`.
        message_params:
          type: object
          additionalProperties: true
          description: |
            Per I26. Optional interpolation values for the localised
            `message`. Excludes cost numbers.

    ProcessingClassExceedsBandResponse:
      description: |
        422 response body when one or more jobs cannot be classified
        because input size/duration exceeds the `long_form` (or
        `long_form_re_encode`) ceiling under the caller's effective
        per-tier caps (per
        [ADR-0011](../docs/decisions/0011-per-tier-processing-class-constraints.md)
        `per_tier_constraints`).

        Distinct from `TierRestrictionResponse` (403, request-level
        tier-quota — resolvable by upload-size reduction or quota
        change) and from `FeatureNotAvailableResponse` (422, feature
        tagged not-yet-shipped — resolvable by waiting for the
        availability flip). Resolution path is per-violation:
        `required_tier` on each `ProcessingClassBandViolation` names
        the tier (if any) whose `per_tier_constraints` would
        accommodate the request; `null` means terminal (e.g. enterprise
        already at the 120 GB hard ceiling).

        Per
        [ADR-0012](../docs/decisions/0012-processing-class-band-reject-envelope.md)
        for the band-vs-tier rationale and routing rule (why this
        envelope is 422, not an extension of `TierRestrictionResponse`
        on 403).

        Delivered on `POST /api/workflows` 422 in a discriminated `oneOf`
        alongside `ValidationErrorEnvelope`, `FeatureNotAvailableResponse`,
        and `ProbePendingResponse` (per
        [ADR-0018](../docs/decisions/0018-universal-422-error-type-discriminator.md)) —
        this branch's `error_type` discriminator value is
        `processing_class_exceeds_band`.
      allOf:
        - $ref: '#/components/schemas/ErrorEnvelope'
        - type: object
          required:
            - error_type
            - violations
          properties:
            error_type:
              type: string
              enum:
                - processing_class_exceeds_band
              description: Discriminator for the 422 oneOf. Always `processing_class_exceeds_band` for this envelope.
            violations:
              type: array
              minItems: 1
              description: |
                One entry per offending job/input. Per ADR-0001 §F6
                batched-violations rule, multiple violations in a
                single request are ALL returned here (fail-all, not
                fail-fast).
              items:
                $ref: '#/components/schemas/ProcessingClassBandViolation'

    FeatureTierRestrictedResponse:
      description: |
        403 response body when a workflow references one or more features
        gated by a higher subscription tier than the caller has.

        Distinct from `TierRestrictionResponse` (also 403): this envelope
        is per-feature granularity (operation / mime_group / option /
        per_value); `TierRestrictionResponse` is request-level (file size
        or MIME). The two envelopes are discriminated by `error_type` at
        the response `oneOf` level.
      allOf:
        - $ref: '#/components/schemas/ErrorEnvelope'
        - type: object
          required:
            - error_type
            - violations
          properties:
            error_type:
              type: string
              enum:
                - feature_tier_restricted
              description: Discriminator for the 403 oneOf. Always `feature_tier_restricted`.
            violations:
              type: array
              minItems: 1
              description: |
                One entry per tier-gated feature. Each `FeatureViolation`
                MUST set `required_tier` to the tier that would satisfy
                the request.
              items:
                $ref: '#/components/schemas/FeatureViolation'

    AuthErrorType:
      type: string
      enum:
        - invalid_credentials
        - account_locked
        - account_disabled
        - account_deleted
        - account_deletion_expired
        - authentication_required
        - api_key_invalid
        - api_key_revoked
      description: |
        Machine-readable discriminator for auth HTTP errors (401/403). Distinct
        from rate-limit 429 responses, which carry no `error_type` and use the
        plain `ErrorEnvelope` with a `Retry-After` header.

        Values are emitted by three auth mechanisms on the server:

        - **Login** (`POST /api/auth/login`) — password-based authentication.
        - **`ApiEntryPoint`** — challenge emitted on any protected route
          when no authenticated principal is present.
        - **`ApiKeyAuthenticator`** — validates `Authorization: Bearer <key>`
          headers on the `api` firewall.

        Per-value attribution:

        - `invalid_credentials` (Login): wrong password, OR correct password on
          an unverified account. The two cases are intentionally collapsed to
          avoid leaking account existence on the machine-readable channel;
          this keeps the login path consistent with the backend's registration
          and password-reset flows, both of which are also anti-enumeration
          (no existence leak on duplicate email or unknown account).
        - `account_locked` (Login OR `ApiKeyAuthenticator`): account is in a
          persisted locked state (e.g. set after repeated failures on the
          server side). Can occur on both mechanisms when the underlying
          account is locked, regardless of credential validity.
        - `account_disabled` (Login OR `ApiKeyAuthenticator`): account has been
          disabled by an administrator.
        - `account_deleted` (Login OR `ApiKeyAuthenticator`): account has been
          deleted.
        - `account_deletion_expired` (Login OR `ApiKeyAuthenticator`): grace
          period expired after a user-initiated deletion request.
        - `authentication_required` (`ApiEntryPoint`): unauthenticated
          request reached an endpoint that requires authentication.
          Emitted as a 401 challenge; no credentials were presented or
          the session is absent/expired. (Endpoint-level auth requirements
          are not declared in this spec yet — tracked as a separate
          housekeeping item to wire `security` requirements per route.)
        - `api_key_invalid` (`ApiKeyAuthenticator`): API key not found, or the
          key resolves to a user that does not exist. The two cases are
          intentionally collapsed for anti-enumeration (same reasoning as
          `invalid_credentials`).
        - `api_key_revoked` (`ApiKeyAuthenticator`): API key was found but is
          no longer active (`isActive() === false`).

    AuthErrorResponse:
      description: |
        Error response for auth HTTP failures. Extends `ErrorEnvelope` via
        `allOf` with an `error_type` discriminator. This is the first
        `allOf`-based envelope extension in this spec — subsequent auth-related
        error envelopes should follow the same pattern rather than redefining
        a parallel flat shape.
      allOf:
        - $ref: '#/components/schemas/ErrorEnvelope'
        - type: object
          required:
            - error_type
          properties:
            error_type:
              $ref: '#/components/schemas/AuthErrorType'

    # ============================================
    # STATUS ENUMS
    # ============================================

    WorkflowStatus:
      type: string
      description: |
        Workflow lifecycle status. The `paused_insufficient_credits`,
        `cancelled`, and `expired` states were added per ticket
        [I24 `e50uXLcl`](https://trello.com/c/e50uXLcl) — additive
        widening; V1 clients ignoring unknown enum values continue
        to function.
        - `pending`: Created but no jobs have started.
        - `in_progress`: At least one job is running.
        - `completed`: All jobs completed successfully.
        - `failed`: All jobs finished, at least one failed, none succeeded.
        - `partially_failed`: Some jobs succeeded, some failed.
        - `paused_insufficient_credits`: Workflow paused at a job/
          stage boundary because the next reservation exceeded the
          caller's available credits. Pause occurs at boundaries
          only — never mid-stream within a running operation.
          Resumable via `POST /api/workflows/{id}/resume` once the
          caller tops up; expires after a 7-day TTL by default.
        - `cancelled`: Caller-initiated termination via
          `POST /api/workflows/{id}/cancel`. Idempotent — cancelling
          already-cancelled returns 200 with the same shape.
          `billing_effect` on the cancel response indicates whether
          unspent reservations were released.
        - `expired`: Workflow was in `paused_insufficient_credits`
          past its `expires_at` and was auto-cancelled by the
          server. Equivalent to `cancelled` for downstream
          accounting; distinct status so dashboards can surface the
          natural-expiry vs caller-initiated distinction.
      enum:
        - pending
        - in_progress
        - completed
        - failed
        - partially_failed
        - paused_insufficient_credits
        - cancelled
        - expired

    JobStatus:
      type: string
      description: |
        Job lifecycle status. The `blocked_insufficient_credits`
        state was added per ticket
        [I24 `e50uXLcl`](https://trello.com/c/e50uXLcl) — additive
        widening.
        - `pending`: Created, waiting to be scheduled.
        - `waiting`: Blocked by upstream job dependencies (workflow_edges).
        - `blocked_insufficient_credits`: Workflow has entered
          `paused_insufficient_credits` and this job has not started
          yet. Distinct from `pending` so downstream UI can render
          a "blocked by billing" affordance vs a "waiting in
          queue" affordance.
        - `in_progress`: At least one operation is running.
        - `completed`: All operations completed successfully.
        - `failed`: Job failed (at least one operation failed).
      enum:
        - pending
        - waiting
        - blocked_insufficient_credits
        - in_progress
        - completed
        - failed

    OperationStatus:
      type: string
      description: |
        Operation lifecycle status:
        - pending: Created, waiting to start
        - in_progress: Currently processing
        - completed: Finished successfully
        - failed: Encountered an error
        - retried: Failed and replaced by a new retry operation
      enum:
        - pending
        - in_progress
        - completed
        - failed
        - retried

    # ============================================
    # OPERATION & JOB TYPES
    # ============================================

    OperationType:
      type: string
      description: |
        Available operation types:
        - compress: Reduce file size (images, audio, video, documents)
        - thumbnail: Legacy thumbnail value. Generates a preview image
          for any media type via a single Lambda. Retained as a valid
          routing target during the migration window, but the
          compression_api publisher no longer emits it — it now resolves
          the per-media sub-type value below on the SNS `operation_type`
          attribute. The payload `operation_type` field stays `thumbnail`.
        - thumbnail_image: Image thumbnail sub-type. Backed by a dedicated
          Rust image Lambda. Emitted by the publisher for image inputs.
        - thumbnail_video: Video thumbnail sub-type. Backed by a dedicated
          FFmpeg Lambda. Emitted by the publisher for video inputs.
        - thumbnail_document: PDF/EPUB thumbnail sub-type. Backed by a
          dedicated Ghostscript Lambda. Emitted by the publisher for
          PDF/EPUB inputs.
        - thumbnail_office: Office document (DOCX/XLSX/PPTX/ODT/ODS/ODP)
          thumbnail sub-type. Backed by a dedicated LibreOffice Lambda.
          Emitted by the publisher for office-document inputs.
        - image_watermark: Image overlay onto a base IMAGE asset. Multi-input (Path B, role-based): one `base` + 1–8 `overlay` inputs (`input` min 2 / max 9). A single overlay uses the flat placement options (`anchor` / `margin_x` / `margin_y` / `opacity` / `overlay_width`); 2–8 overlays use the index-aligned `overlays[]` array (multi_overlay_stack, LIVE 2026-07-01) — the two are mutually exclusive. Each input is a `MultiInputSource` (external_import / connection / job_output — no upload-direct); an uploaded base or overlay enters via a `passthrough` source job referenced by `job_output`. Stable for static-image bases (jpeg/png/webp); TIFF (`image_tiff`) and BMP (`image_bmp`) bases are now `stable` too (flipped 2026-06-30, UANjD38A). Animated GIF (`image_gif`) bases stay `planned` via a parallel mime_group — dispatch returns `feature_not_available` (422) until Lambda support ships. Video bases are NOT supported by image_watermark — use the dedicated `video_watermark` operation per ADR-0013. Per ADR-0004 + I4-CONS + I5 (Trello AKZiOXnd).
        - text_watermark: Text overlay rendered onto an image (Liberation Sans). Single-input. Stable for jpeg/png/webp; TIFF (`image_tiff`) and BMP (`image_bmp`) inputs are now `stable` too (flipped 2026-06-30, UANjD38A). Per ADR-0004 + I4-CONS.
        - merge: Concatenate/combine multiple files into one (images, video, audio). Multi-input. Image inputs merge into animated GIF or slideshow video; image collage/grid and PDF concatenation are not supported by the V1 Lambda.
        - archive: Bundle files into ZIP/tar.gz (all types). Multi-input.
        - convert: Change file format (all types)
        - custom_luma: Apply a caller-uploaded luma matte to a base video for a custom luma-matte transition effect. Multi-input (`role: base` + `role: transition_mask`). `availability: planned` + `required_tier: pro`; dispatch returns `feature_not_available` (422) until Lambda ships. Distinct from FFmpeg `xfade=custom` (which is an expression, not an operation). Per ticket I29 (Trello EPUE5Vs1).
        - audio_overlay: Mix a secondary audio asset over a primary audio or video base (DJ tags, podcast intros/outros, station IDs, jingles). Multi-input (`role: base` + `role: overlay`). `availability: planned`; dispatch returns `feature_not_available` (422) until Lambda ships. **NOT** the same as `audio_watermark` — that operation is steganographic (imperceptible identifier embedded for ownership tracking), tracked separately by I20. Per ticket I19 (Trello Xr3Z4GBF).
        - audio_watermark: Embed a steganographic forensic watermark into an audio asset (or a video's audio track) — Cinavia / Resemble PerTh territory. Single-input. `availability: planned` + `required_tier: enterprise`; dispatch returns `feature_not_available` (422) until Lambda ships. Pairs with `POST /api/audio-watermark/decode` for own-watermarks-only extraction. Per ticket I20 (Trello omiCq7Vn).
        - audio_to_video: Produce a video from an audio input plus an OPTIONAL still image overlay. Multi-input role-based with the first OPTIONAL role on the contract (`role: base` audio required, `role: overlay` image 0..1 — see `per_role_cardinality`). When overlay is omitted, the video uses a solid background colour. `availability: beta` (Wave A — Lambda backend live; opt-in, MUST NOT return `feature_not_available`; may change with notice). Per ticket [`SlluxMBN`](https://trello.com/c/SlluxMBN) + ADR-0015 (introduces `per_role_cardinality` vocab).
        - video_watermark: Apply an image overlay onto a base video via FFmpeg's `overlay` filter. Multi-input role-based (`role: base` video + `role: overlay` image, exactly one of each per `per_role_cardinality`). Re-encode required; audio stream-copy passthrough. Distinct from `image_watermark` (pure-Rust/image-only). `availability: beta` (Wave A — Lambda backend live; opt-in, MUST NOT 422; `short_form` is beta, `long_form` stays `planned` — no live Fargate worker; `multi_overlay_stack` stays `planned`). Per ticket [`4NrRPCgh`](https://trello.com/c/4NrRPCgh) + ADR-0013.
        - video_text_watermark: Render a text overlay onto a base video via FFmpeg's `drawtext` filter. Single-input — text and styling in options. Same `watermark_mode` (single/tiled), anchor + margin vocab as `text_watermark`. Re-encode required; audio stream-copy passthrough. `availability: planned`; dispatch returns `feature_not_available` (422) until Lambda ships. Per ticket [`4NrRPCgh`](https://trello.com/c/4NrRPCgh) + ADR-0013.
        - split: Fan one input file into N outputs across GIF / PDF / audio / video MIME families. Single-input per-mime-group catalog (mirrors merge/convert): GIF uses `frame_range` (REQUIRED) + `output_format`; PDF uses `page_range` OR `page_groups` (mutually exclusive); audio + video use a `mode` discriminator (interval/count/cut_points) + numeric-seconds wire format + `precision` flag (fast/exact). 200-output hard cap per ADR-0009 §D5 with per-mode preflight math; output naming `output-001..output-200`. Long-form video routes to a separate `split-video-fargate` worker via `processing_class`. `availability: beta` for the `audio` and `video` mime_groups (workers live on staging — shape-stable + opt-in, MUST NOT 422); video activates BOTH classes (`video.processing_class.short_form: beta` AND `long_form: beta` — `split-video-fargate` deployed + wired on staging but NOT yet proven end-to-end per [`rcwvUKhI`](https://trello.com/c/rcwvUKhI); the first customer-path soak has not completed, the 4GB+ speed-up is unmeasured, and the long-form fan-out is flag-gated dark). The `image_gif` and `document_pdf` mime_groups stay `availability: planned` and dispatch returns `feature_not_available` (422) until their workers ship. Per ticket [`vKI0CFDu`](https://trello.com/c/vKI0CFDu) + ADR-0014.
        - passthrough: Inert lossless source operation. A single-input source job whose SOLE operation is `passthrough` emits its source bytes UNCHANGED — no compression, no Lambda. The API self-completes the job at publish (terminal output = the upload `{bucket, key}` unchanged). Its purpose is to feed an uploaded file into a multi-input operation LOSSLESSLY: because `JobInputV2.source` is narrowed to exclude upload-direct, an upload that must enter a `merge` / `archive` / `image_watermark` op enters via a `passthrough` source job referenced downstream by `{type: job_output, from: <id>}` — preserving billing / DAG / lineage. Distinct from `operations: []` (which keeps its implicit-compress meaning on a single-input upload job); `passthrough` is the EXPLICIT lossless path via the "non-empty `operations[]` without `compress` = compression opt-out" rule. Media-agnostic; no options. `availability: beta` — activated (the inputs[]-narrowing + passthrough self-complete mechanism is deployed API-side); workflow-create accepts `passthrough` source jobs and MUST NOT return `feature_not_available`. **Never published to SNS** — deliberately absent from the AsyncAPI routing enums (API self-completes; no `ops-passthrough` queue). Per ticket [`4som89Uh`](https://trello.com/c/4som89Uh) + ADR-0004 (planned→beta flip).

        Both the legacy `thumbnail` value and the four sub-type values
        are valid routing targets today during the thumbnail migration
        window. See `asyncapi/events.yaml` for the full routing
        vocabulary and the publisher branching rule.

        V1 `watermark` operation removed at V2 cutover (I4-CONS) —
        replaced by `image_watermark` + `text_watermark` per ADR-0004
        §"Greenfield V2.0 cutover".
      enum:
        - compress
        - thumbnail
        - thumbnail_image
        - thumbnail_video
        - thumbnail_document
        - thumbnail_office
        - image_watermark
        - text_watermark
        - render_variants
        - merge
        - archive
        - convert
        - custom_luma
        - audio_overlay
        - audio_watermark
        - audio_to_video
        - video_watermark
        - video_text_watermark
        - split
        - passthrough

    OperationInputModel:
      type: string
      description: |
        Whether the operation accepts a single file or multiple files:
        - single: One input file (compress, thumbnail, thumbnail_image,
          thumbnail_video, thumbnail_document, thumbnail_office,
          text_watermark, convert, audio_watermark,
          video_text_watermark, split, passthrough)
        - multi: Multiple input files (merge, archive, image_watermark, custom_luma, audio_overlay, audio_to_video, video_watermark). audio_to_video is the first role-based op with an OPTIONAL role (min_inputs=1, max_inputs=2 — see `per_role_cardinality`); video_watermark is a fixed base+overlay pair (exactly one each, min_inputs=max_inputs=2) on video bases; image_watermark widened to base + 1–8 overlays (min 2 / max 9) with the multi_overlay_stack flip (2026-07-01).
      enum:
        - single
        - multi

    JobType:
      type: string
      description: |
        Media type category derived from MIME type. Used as the
        `job_type` SNS message attribute on the `job-requests` topic —
        the single filter attribute that routes compression traffic to
        per-media-type compression queues. Not used by any other SNS
        topic; non-compression operations are routed by `operation_type`
        on the separate `operations` topic.

        Derivation:
        - image/* -> image
        - video/* -> video
        - audio/* -> audio
        - document types -> document (PDF, DOCX, XLSX, PPTX, ODT, ODS, ODP, EPUB)
      enum:
        - image
        - video
        - audio
        - document

    # ============================================
    # UPLOAD SCHEMAS
    # ============================================

    SingleUploadRequest:
      type: object
      required:
        - file
      properties:
        file:
          type: string
          format: binary
          description: The file to upload
        filename:
          type: string
          maxLength: 255
          pattern: '^[^/\\]+$'
          description: |
            Original filename with extension (e.g. "photo.jpg").
            Optional — browsers include filename automatically in multipart uploads.
            Must not contain directory separators (/ or \).
          example: "photo.jpg"

    UploadThresholds:
      type: object
      description: |
        Canonical upload threshold constants. Per ticket
        [u0ar7Yye](https://trello.com/c/u0ar7Yye). All values are
        `const:`-pinned so SDK generators emit them as typed binding
        constants — frontend / API / SDKs reference these instead
        of hardcoding magic numbers.

        These are CONTRACT VALUES, not runtime-discoverable settings.
        A future runtime `GET /api/uploads/limits` endpoint may
        overlay per-tier or per-environment overrides on top of
        these baselines (deferred follow-up).
      required:
        - single_shot_max_bytes
        - multipart_chunk_size
        - multipart_concurrency_default
        - multipart_first_chunk_size
      properties:
        single_shot_max_bytes:
          type: integer
          format: int64
          const: 10000000
          description: |
            Maximum file size in bytes for the single-shot upload
            path (`POST /api/uploads`). Files above this size MUST
            use the multipart flow
            (`POST /api/uploads/multipart/initiate` → chunk PUTs →
            `POST /api/uploads/multipart/complete`). 10 MB /
            10,000,000 bytes — chosen to match the ALB-fronted
            single-request body cap. Raising this requires
            infrastructure work (ALB swap or rearchitect; out of
            scope for this ticket).
        multipart_chunk_size:
          type: integer
          format: int64
          const: 16777216
          description: |
            Recommended chunk size in bytes for multipart upload
            chunks (16 MiB / 16,777,216 bytes — 1024-based). Raised
            from 5 MiB by CON-1 (ADR-0011): S3 caps a multipart
            upload at 10,000 parts, and the Enterprise envelope is a
            120 GB hard ceiling. At 16 MiB a 120 GiB object needs
            7,680 parts (≤ 10,000, inside AWS's 16–64 MB recommended
            band); 5 MiB would need 24,576 and 10 MiB 12,288 — both
            exceed the S3 limit. **Wire-incompatible change** — SDKs
            pin this as a compile-time constant, so CON-1 and SDK-2
            ship as a non-independently-mergeable pair. The server's
            `MultipartInitiateResponse` returns a
            `recommended_chunk_size` per file based on first-chunk
            throughput; SDKs SHOULD prefer that runtime value when
            present and fall back to this constant otherwise.
        multipart_concurrency_default:
          type: integer
          const: 4
          minimum: 1
          description: |
            Recommended parallel-upload count for multipart chunks
            (4 simultaneous PUT requests). Matches the existing TS
            SDK default. Tuning above 4 risks throttling at the S3 +
            client-bandwidth layers; below 4 hurts overall
            throughput on typical broadband.
        multipart_first_chunk_size:
          type: integer
          format: int64
          const: 8388608
          description: |
            Fixed size in bytes for the FIRST chunk PUT in a multipart
            upload (8 MiB / 8,388,608 bytes — 1024-based). The server
            uses this chunk for MIME-type detection and throughput
            measurement; the 8 MiB window is assumed by the server's
            container-metadata probe (see
            `MultipartInitiateRequest.metadata_hint`). SDKs MUST send
            exactly this size for chunk index 0; for chunks 1+ they
            SHOULD prefer the runtime
            `MultipartInitiateResponse.recommended_chunk_size` and
            fall back to `multipart_chunk_size` when absent.

    UploadResponse:
      type: object
      description: Upload result data (same shape for single and multipart complete)
      required:
        - file_id
        - original_name
        - mime_type
        - size_bytes
        - constraints_applied
      properties:
        file_id:
          $ref: '#/components/schemas/UuidV7'
          description: Unique file identifier for use in workflow creation
        original_name:
          type: string
          description: Original filename
          example: "photo.jpg"
        mime_type:
          type: string
          description: Detected MIME type
          example: "image/jpeg"
        size_bytes:
          type: integer
          format: int64
          minimum: 1
          description: File size in bytes
          example: 2457600
        constraints_applied:
          $ref: '#/components/schemas/UploadConstraintsApplied'
          description: |
            Tier+MIME-derived constraints the server applied to this
            upload per ticket
            [I15-CONS](https://trello.com/c/YZpBKzOM) F8.1. REQUIRED
            on every successful upload (V2 cutover invariant); lets
            the frontend display tier-specific size + duration caps
            and the server's pre-assignment of `processing_class`
            (used for early UI gating before workflow-create).

    UploadSuccessEnvelope:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/UploadResponse'

    MultipartInitiateRequest:
      type: object
      required:
        - file
        - filename
        - total_size
      properties:
        file:
          type: string
          format: binary
          description: |
            First chunk of the file (fixed 8 MiB; see
            `UploadThresholds.multipart_first_chunk_size`).
            Used for MIME type detection and throughput measurement.
            Stored as S3 multipart upload part 1.
        filename:
          type: string
          maxLength: 255
          pattern: '^[^/\\]+$'
          description: Original filename with extension. Must not contain directory separators.
          example: "photo.jpg"
        total_size:
          type: integer
          format: int64
          minimum: 1
          description: Total file size in bytes (all chunks combined)
        metadata_hint:
          type: object
          description: |
            Optional caller-asserted preflight metadata for routing-
            quality preview per ticket
            [I15-CONS](https://trello.com/c/YZpBKzOM) F8.1.
            **Advisory only** — server probes the first chunk
            regardless and rejects on actual measurement; the hint
            cannot be used to bypass tier caps. Used to populate
            `MultipartInitiateResponse.constraints_applied.processing_class_pre_assignment`
            when the first-chunk probe alone is insufficient (e.g.
            container metadata not present in the first 8 MiB =
            `UploadThresholds.multipart_first_chunk_size`). Single
            uploads (`POST /api/uploads`) do NOT accept this hint —
            the server has the full byte stream.
          properties:
            duration_seconds:
              type: integer
              minimum: 0
              description: Caller's claim about file duration in seconds.
            width:
              type: integer
              minimum: 1
              description: Caller's claim about video/image width in pixels.
            height:
              type: integer
              minimum: 1
              description: Caller's claim about video/image height in pixels.

    MultipartInitiateResponse:
      type: object
      required:
        - upload_id
        - mime_type
        - first_chunk_etag
        - first_chunk_size_bytes
        - total_parts
        - recommended_chunk_size
        - presigned_urls
        - constraints_applied
      properties:
        upload_id:
          $ref: '#/components/schemas/UuidV7'
          description: |
            Multipart upload session identifier. Pass this as `upload_id` in the
            complete request; after completion, pass the same value as `file_id`
            when creating workflows via `POST /api/workflows`.
        mime_type:
          type: string
          description: MIME type detected from the first chunk
          example: "image/jpeg"
        first_chunk_etag:
          type: string
          description: ETag of the first chunk stored as S3 part 1
          example: '"d8e8fca2dc0f896fd7cb4cb0031ba249"'
        first_chunk_size_bytes:
          type: integer
          description: Size of the first chunk received (for client validation)
          example: 8388608
        total_parts:
          type: integer
          minimum: 2
          maximum: 10000
          description: |
            Total number of parts. The client slices the remaining file into
            exactly (total_parts - 1) chunks using recommended_chunk_size.
            The last chunk may be smaller. Maximum raised 500 → 10,000 by
            CON-1 (ADR-0011) — 10,000 is the S3 multipart hard part limit;
            500 contract-capped uploads at ≈ 50 GB, below the 120 GB
            Enterprise envelope.
          example: 5
        recommended_chunk_size:
          type: integer
          minimum: 16777216
          maximum: 104857600
          description: |
            Chunk size in bytes for remaining parts. Calculated from first chunk
            throughput * 5s target, clamped to 16 MiB–100 MiB. The last chunk
            may be smaller than 16 MiB. Minimum raised 5 MiB → 16 MiB by CON-1
            (ADR-0011) so the runtime value never falls below the
            `UploadThresholds.multipart_chunk_size` constant SDKs fall back to
            (the S3 10,000-part limit at the 120 GB Enterprise ceiling).
          example: 16777216
        presigned_urls:
          type: array
          description: |
            Pre-signed S3 PUT URLs for parts 2 through total_parts.
            Each URL accepts a PUT request with raw chunk bytes as body.
            Collect the ETag from each S3 response for the complete request.

            NOTE (CON-1/ADR-0011): with `total_parts` now bounded by the
            S3 hard limit (10,000), a maximal Enterprise upload implies a
            large URL set. The server is NOT required to return every URL
            in one synchronous response — bounded-batch / paginated URL
            delivery and resumable re-fetch are owned by API-2
            (durable upload session) and SDK-3 (presigned batching +
            paginated ListParts). Consumers MUST NOT assume a single
            initiate response carries all `total_parts - 1` URLs at the
            high end of the range; treat this array as the first batch.
          items:
            $ref: '#/components/schemas/PresignedUrlPart'
        constraints_applied:
          $ref: '#/components/schemas/UploadConstraintsApplied'
          description: |
            Tier+MIME-derived constraints the server applied to this
            multipart session per ticket
            [I15-CONS](https://trello.com/c/YZpBKzOM) F8.1. REQUIRED
            on every successful initiate (V2 cutover invariant). The
            same response shape is also emitted on the multipart
            complete endpoint via `UploadResponse.constraints_applied`.

    MultipartInitiateSuccessEnvelope:
      type: object
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/MultipartInitiateResponse'

    PresignedUrlPart:
      type: object
      required:
        - part_number
        - url
        - expires_at
      properties:
        part_number:
          type: integer
          minimum: 2
          description: S3 multipart part number (starts at 2, API handled part 1)
        url:
          type: string
          format: uri
          description: Pre-signed S3 PUT URL. Send PUT with raw binary chunk as body.
        expires_at:
          type: string
          format: date-time
          description: |
            ISO 8601 expiry timestamp. TTL is dynamic: estimated_upload_duration * 2,
            clamped between 900s and 3600s.

    MultipartCompleteRequest:
      type: object
      required:
        - upload_id
        - parts
      properties:
        upload_id:
          $ref: '#/components/schemas/UuidV7'
          description: Multipart upload session identifier from the initiate response
        parts:
          type: array
          description: |
            ETags for parts 2 through total_parts, collected from S3 PUT responses.
            Part 1 ETag is already known from the initiate step.
          minItems: 1
          items:
            type: object
            required:
              - part_number
              - etag
            properties:
              part_number:
                type: integer
                minimum: 2
                description: S3 part number (must match presigned_urls part_number)
              etag:
                type: string
                description: ETag from S3 PUT response header
                example: '"d8e8fca2dc0f896fd7cb4cb0031ba249"'

    MultipartCompleteResponse:
      type: object
      description: |
        Result of finalising a multipart upload. Intentionally narrower than
        `UploadResponse` (single-upload shape) — the server returns only the
        finalised `upload_id` and a completion status. Clients who need file
        metadata (original name, MIME type, size) can use the values captured
        during initiate, or call `GET /api/uploads/{id}/metadata`.
      required:
        - upload_id
        - status
      properties:
        upload_id:
          $ref: '#/components/schemas/UuidV7'
          description: |
            Identifier of the finalised upload. Pass this as `file_id` when
            creating workflows via `POST /api/workflows` — the `file_id`
            parameter on the workflows endpoint accepts any upload identifier.
        status:
          type: string
          enum:
            - completed
          description: Terminal status of the multipart upload session.

    MultipartCompleteSuccessEnvelope:
      type: object
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/MultipartCompleteResponse'

    # ============================================
    # MULTIPART SESSION RESUME SCHEMAS (HxUmVr3Y)
    # ============================================

    MultipartPartListing:
      type: object
      description: |
        One uploaded part as reported by the S3 multipart ListParts
        API. The /status response carries an array of these. Mirrors
        the S3 wire shape verbatim so the response is a 1:1 passthrough
        with no translation layer.
      required:
        - part_number
        - etag
        - size_bytes
        - last_modified
      properties:
        part_number:
          type: integer
          minimum: 1
          maximum: 10000
          description: |
            S3 multipart part number. Part 1 is always present in a
            healthy session (uploaded synchronously at `/initiate`);
            parts 2–N follow.
        etag:
          type: string
          description: |
            ETag returned by S3 for the part. Quoted MD5 hex by default;
            preserve quotes verbatim as S3 emits them.
          example: '"d8e8fca2dc0f896fd7cb4cb0031ba249"'
        size_bytes:
          type: integer
          format: int64
          minimum: 0
          description: Part size in bytes (as reported by S3).
        last_modified:
          type: string
          format: date-time
          description: ISO-8601 timestamp from S3 ListParts `LastModified`.

    MultipartStatusResponse:
      type: object
      description: |
        Resume-state snapshot for an in-flight multipart session. The
        `data` payload on `GET /api/uploads/multipart/{uploadId}/status`.
      required:
        - upload_id
        - multipart_upload_id
        - cloud_key
        - total_parts
        - uploaded_parts
        - next_part_number_marker
        - is_truncated
        - manifest_expires_at
        - recommended_chunk_size
      properties:
        upload_id:
          $ref: '#/components/schemas/UuidV7'
          description: Session identifier (matches the path `uploadId` and the initiate response `upload_id`).
        multipart_upload_id:
          type: string
          description: |
            S3 multipart upload identifier returned by `CreateMultipartUpload`.
            Opaque to API consumers; emitted for diagnostic visibility
            (SDK logs / dashboards correlating against AWS CloudTrail).
        cloud_key:
          type: string
          description: |
            S3 object key under which the assembled object will be
            stored on `multipart/complete`. Opaque to consumers.
        total_parts:
          type: integer
          minimum: 2
          maximum: 10000
          description: |
            Total number of parts the client must upload to complete
            the session. Same value as `MultipartInitiateResponse.total_parts`
            and stable for the lifetime of the session.
        uploaded_parts:
          type: array
          description: |
            Parts already present in S3 for this session, as reported
            by S3 ListParts at request time. May be empty (no parts
            uploaded yet) or partial (resume mid-upload). Order is the
            S3-native part-number ascending.
          items:
            $ref: '#/components/schemas/MultipartPartListing'
        next_part_number_marker:
          description: |
            S3 ListParts cursor — pass this as the next request's
            `cursor` query parameter to fetch the following page.
            `null` on the final page (or when `is_truncated: false`).
            Mirrors the S3 `NextPartNumberMarker` field verbatim.

            Upper bound matches the request `cursor` query parameter
            range (`0..9999`) so SDKs can round-trip the marker
            verbatim — `total_parts` caps at 10,000 and the cursor
            semantically means "list after part N", so the highest
            reachable marker is 9,999 (any cursor of 10,000 would
            return an empty page).
          oneOf:
            - type: integer
              minimum: 0
              maximum: 9999
            - type: 'null'
        is_truncated:
          type: boolean
          description: |
            `true` when more pages remain; `false` on the final page.
            Mirrors the S3 ListParts `IsTruncated` field verbatim.
        manifest_expires_at:
          description: |
            ISO-8601 expiry timestamp of the session manifest. `null`
            when the TTL has lapsed or was not initialised — in that
            case, callers SHOULD invoke
            `POST /api/uploads/multipart/{uploadId}/keepalive` to
            re-establish a 48 h window before re-presigning. The
            manifest TTL is decoupled from individual presigned-URL
            TTLs.
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        recommended_chunk_size:
          type: integer
          minimum: 16777216
          maximum: 104857600
          description: |
            Chunk size (bytes) the server recommends for any remaining
            parts in this session — identical to
            `MultipartInitiateResponse.recommended_chunk_size` (16 MiB
            floor / 100 MiB ceiling per CON-1 / ADR-0011). Returned on
            every page so the client doesn't need to retain the
            initiate response across a resume.

    MultipartStatusSuccessEnvelope:
      type: object
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/MultipartStatusResponse'

    MultipartPresignRequest:
      type: object
      description: |
        Caller-supplied subset of part numbers to re-presign. Request
        body for `POST /api/uploads/multipart/{uploadId}/presign`.
      required:
        - part_numbers
      properties:
        part_numbers:
          type: array
          minItems: 1
          maxItems: 100
          uniqueItems: true
          description: |
            Part numbers to re-presign. Each entry MUST satisfy
            `2 ≤ n ≤ total_parts` from the initiate response —
            **Part 1 is rejected** because the manifest stores its
            ETag from `/initiate` and re-presigning would break the
            eventual `multipart/complete` call. Cap of 100 entries
            per request; SDKs paginate larger resume sets across
            multiple `/presign` calls.
          items:
            type: integer
            minimum: 2
            maximum: 10000

    MultipartPresignResponse:
      type: object
      description: |
        Pre-signed URLs for the parts requested via
        `MultipartPresignRequest.part_numbers`. The `data` payload on
        `POST /api/uploads/multipart/{uploadId}/presign`.
      required:
        - upload_id
        - presigned_urls
      properties:
        upload_id:
          $ref: '#/components/schemas/UuidV7'
          description: Session identifier (echoes the path `uploadId`).
        presigned_urls:
          type: array
          minItems: 1
          maxItems: 100
          description: |
            One pre-signed PUT URL per requested part number, in the
            same order as `MultipartPresignRequest.part_numbers`. Each
            item carries `part_number` + `url` + `expires_at` —
            same shape as `MultipartInitiateResponse.presigned_urls[]`.
          items:
            $ref: '#/components/schemas/PresignedUrlPart'

    MultipartPresignSuccessEnvelope:
      type: object
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/MultipartPresignResponse'

    MultipartKeepaliveResponse:
      type: object
      description: |
        Refreshed manifest TTL for an in-flight multipart session. The
        `data` payload on `POST /api/uploads/multipart/{uploadId}/keepalive`.
      required:
        - upload_id
        - manifest_expires_at
      properties:
        upload_id:
          $ref: '#/components/schemas/UuidV7'
          description: Session identifier (echoes the path `uploadId`).
        manifest_expires_at:
          type: string
          format: date-time
          description: |
            ISO-8601 expiry timestamp of the refreshed manifest. Always
            non-null on a successful 200 response — keepalive
            unconditionally extends the TTL to 48 h ahead of `now`.

    MultipartKeepaliveSuccessEnvelope:
      type: object
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/MultipartKeepaliveResponse'

    # ============================================
    # METADATA SCHEMAS
    # ============================================

    MetadataResponse:
      type: object
      description: |
        File metadata. Fields vary by MIME type. Common fields are always present;
        type-specific fields (dimensions, exif, duration, etc.) are included when
        available for the file type.
      required:
        - file_id
        - original_name
        - mime_type
        - size_bytes
        - created_at
      properties:
        file_id:
          $ref: '#/components/schemas/UuidV7'
        original_name:
          type: string
          example: "photo.jpg"
        mime_type:
          type: string
          example: "image/jpeg"
        size_bytes:
          type: integer
          format: int64
          example: 4521984
        created_at:
          type: string
          format: date-time
          description: |
            ISO-8601 timestamp at which the upload row was committed
            to the database. The API always emits this field; spec
            catches up to behaviour shipping since
            `UploadMetadataController.php:52`. Per ticket
            [CCpf6CD7](https://trello.com/c/CCpf6CD7).
          example: "2026-04-26T14:30:00Z"
        # Image fields
        dimensions:
          type: object
          description: Image/video dimensions (images, video, PDF)
          properties:
            width:
              type: integer
              example: 4032
            height:
              type: integer
              example: 3024
        color_space:
          type: string
          description: Color space (images)
          example: "sRGB"
        dpi:
          type: integer
          description: Dots per inch (images, PDF)
          example: 72
        has_alpha:
          type: boolean
          description: Whether the image has an alpha channel (images)
        exif:
          type: object
          description: EXIF metadata (images)
          properties:
            camera:
              type: string
              example: "iPhone 15 Pro"
            datetime:
              type: string
              format: date-time
            gps:
              type: object
              properties:
                lat:
                  type: number
                  format: double
                lon:
                  type: number
                  format: double
            copyright:
              type:
                - string
                - "null"
        dominant_colors:
          type: array
          description: Dominant colors as hex strings (images)
          items:
            type: string
            pattern: '^#[0-9a-fA-F]{6}$'
          example: ["#2a5c3f", "#8b6f4e", "#d4c5a9"]
        # Video/Audio fields
        duration:
          type: number
          format: double
          description: Duration in seconds (video, audio)
        codec:
          type: string
          description: Video/audio codec
        fps:
          type: number
          format: double
          description: Frames per second (video)
        audio_codec:
          type: string
          description: Audio codec (video)
        bitrate:
          type: integer
          description: Bitrate in bps (video, audio)
        channels:
          type: integer
          description: Audio channels (audio)
        sample_rate:
          type: integer
          description: Sample rate in Hz (audio)
        # Document fields
        page_count:
          type: integer
          description: Number of pages (documents)

    MetadataSuccessEnvelope:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/MetadataResponse'

    # ============================================
    # WORKFLOW REQUEST SCHEMAS
    # ============================================

    WorkflowCreateRequest:
      type: object
      description: |
        Create a workflow. **Two mutually-exclusive request forms:**

        - **Explicit jobs** — `jobs[]` (+ optional `workflow_edges`), the
          full multi-job DAG form. Use for multi-job workflows, role-based
          / multi-input operations, output-as-role-input chains, explicit
          edges, or `delivery.selection.type: explicit`.
        - **Flat (single-job sugar)** — top-level `source` + `operations[]`,
          **exactly equivalent to `jobs: [{ source, operations }]`** (per
          ticket [`D0Gsri8V`](https://trello.com/c/D0Gsri8V)). The server
          lowers it into one `JobDefinition` and validates it as such —
          inheriting every `JobDefinition` guard verbatim — then runs the
          existing single-job canonicalizer. For the file-first
          dump-and-go case: one input + a set of single-input operations
          (a chain plus `base`-derived branches like thumbnail / split).

        The two forms are mutually exclusive (a request supplies exactly
        one — enforced by the `oneOf` below); they are NOT combined.

        Because the flat form lowers to ONE single-input job, its
        `operations[]` admits only **single-input** operation types. The
        multi-input / role operations (`merge`, `archive`,
        `image_watermark`, `custom_luma`, `audio_overlay`,
        `audio_to_video`, `video_watermark` — the enum at the
        `JobDefinition` multi-input guard) require `inputs[]` and MUST use
        the explicit `jobs[]` form; the server rejects them in flat mode
        via that same `JobDefinition` guard after lowering (not a
        separate structural rule). The flat form also does **not** support
        `workflow_edges` or `delivery.selection.type: explicit` — both
        need a named job id the single implicit job has none of; use
        explicit `jobs[]` for those. Delivery defaults and selection
        otherwise behave exactly as for one explicit job.
      oneOf:
        - title: Explicit jobs form
          required: [jobs]
          not:
            anyOf:
              - required: [source]
              - required: [operations]
        - title: Flat single-job form
          required: [source, operations]
          not:
            anyOf:
              - required: [jobs]
              - required: [workflow_edges]
      properties:
        jobs:
          type: array
          description: |
            List of jobs in this workflow (the explicit-jobs form).
            Mutually exclusive with the flat `source` + `operations`
            form — supply exactly one.
          minItems: 1
          items:
            $ref: '#/components/schemas/JobDefinition'
        source:
          $ref: '#/components/schemas/WorkflowSource'
          description: |
            Flat-form single input source. Present only in the flat form
            (with `operations`); equivalent to a single explicit job's
            `source`. Single-input `WorkflowSource` (4-leaf union incl.
            `upload`) — multi-input role-based jobs use explicit
            `jobs[].inputs[]`. See the schema description for the
            flat/explicit mutex.
        operations:
          type: array
          minItems: 1
          description: |
            Flat-form operation set (the unordered operations applied to
            the top-level `source`). Present only in the flat form (with
            `source`); equivalent to a single explicit job's
            `operations[]` and lowered + canonicalized identically.
            Single-input operation types only (see the schema
            description). Each entry is an `OperationDefinition`.
          items:
            $ref: '#/components/schemas/OperationDefinition'
        workflow_edges:
          type: array
          description: |
            DAG dependency edges between jobs. Each edge defines that a downstream
            job depends on an upstream job's output. Jobs with no incoming edges
            start immediately. Jobs with dependencies wait for all upstream jobs.
          items:
            $ref: '#/components/schemas/WorkflowEdge'
        callback_url:
          type:
            - string
            - "null"
          format: uri
          pattern: '^https://'
          description: |
            Webhook URL (HTTPS only). The API POSTs a `WebhookPayload` JSON body to
            this URL when matching events occur. The payload includes event type,
            delivery ID, timestamp, and full workflow state with job results and
            download URLs. Must use HTTPS to prevent credential leakage and SSRF
            against internal endpoints.

            **Signature verification:**
            Each request includes an `X-GIS-Signature` header containing an
            HMAC-SHA256 hex digest of the raw request body, using the per-workflow
            `webhook_secret` (returned in the workflow creation response) as the key.
            Header format: `sha256=<hex(hmac-sha256(webhook_secret, raw_body))>`.
            Consumers MUST verify the signature before processing the payload.
        callback_events:
          type: array
          description: |
            Which events trigger the webhook callback. Defaults to terminal events only.
          items:
            $ref: '#/components/schemas/CallbackEventType'
          default:
            - "workflow.completed"
            - "workflow.failed"
            - "workflow.partially_failed"
        export:
          $ref: '#/components/schemas/ExternalDestination'
          description: |
            Optional V2 export destination. When set, all operation
            outputs are copied to the customer-controlled destination
            in addition to GISL's own S3 storage. Per ADR-0005,
            `ExternalDestination` shares shape family with
            `ExternalSource` but uses write-permission-based scoping.
            Replaces V1 `ExportConfig` (deleted at V2 cutover).
        delivery:
          $ref: '#/components/schemas/Delivery'
          description: |
            Optional workflow-level delivery configuration per
            [ADR-0003](../docs/decisions/0003-delivery-mode.md). When
            absent, the workflow defaults to `mode: individual,
            selection.type: terminal` (current V1 behaviour). Coexists
            with the `archive` operation — `archive` stays first-class
            for power-user cases (subset bundles, multi-bundle workflows,
            archive chaining, explicit folder/naming control).
        processing:
          $ref: '#/components/schemas/WorkflowProcessing'
          description: |
            Optional workflow-level processing hints per ticket
            [I15-CONS](https://trello.com/c/YZpBKzOM). Caller-supplied
            logical-policy hint via `processing.class_hint`
            (`auto` / `short_form_only` / `long_form_allowed` /
            `long_form_preferred`); defaults to `auto` when omitted.
            Server is the final authority on routing — `class_hint`
            is advisory + policy-shaping, NOT a backend selector.

    JobDefinition:
      type: object
      description: |
        V2 job within a workflow. Per [ADR-0004](../docs/decisions/0004-job-shape.md).

        **Source shape (mutually exclusive):**
        - `source` (single-input): a `WorkflowSource` value (4-leaf
          discriminated union: `upload` / `external_import` / `connection` /
          `job_output`). Used by single-input operations (compress, thumbnail,
          text_watermark, convert).
        - `inputs[]` (multi-input): array of `JobInputV2` entries. Each entry
          carries its own `source` plus an optional `role`
          (image_watermark, custom_luma, audio_overlay, audio_to_video,
          video_watermark) and
          `per_input_options`. Used by multi-input operations (merge,
          archive, image_watermark, custom_luma, audio_overlay,
          audio_to_video, video_watermark).

        Exactly one of `source` or `inputs` is required.

        **`id` is optional.** When omitted, the server auto-generates `job_N`
        (positional, 0-indexed). Required only when this job is referenced
        by:
        - Another job's `source.from` (`JobOutputSource.from`) or
          `inputs[].source.from`
        - `workflow_edges[].from` / `.to`
        - `delivery.selection.explicit.refs[].ref` (delivery_plan, ticket I8-CONS)

        User-supplied IDs matching the reserved pattern `^job_\d+$` are
        rejected — this avoids collisions with the auto-generated naming
        scheme.

        **Server computes `depends_on`** from `JobOutputSource.from`
        references at job-level and inputs[]-level. `workflow_edges` is
        rarely needed and is preserved as an optional override for
        non-typical DAG topologies (e.g. side-effect dependencies that
        don't surface as `from` references).

        **Role validation per operation type** (runtime enforcement;
        contract documents the rule):
        - `image_watermark`: exactly one input with `role: base` AND one
          with `role: overlay` (per [I4-CONS](https://trello.com/c/2dZiW1BF)).
        - `custom_luma`: exactly one input with `role: base` AND one
          with `role: transition_mask` (per
          [I29](https://trello.com/c/EPUE5Vs1)).
        - `audio_overlay`: exactly one input with `role: base` AND one
          with `role: overlay` (per
          [I19](https://trello.com/c/Xr3Z4GBF)). The `overlay` role
          label is shared with `image_watermark` — semantics differ
          by operation type.
        - `text_watermark`: single-input via `source` (not `inputs[]`); no
          `role` field.
        - `merge` / `archive`: no `role` field on inputs (all inputs
          share the same role).

        **Greenfield V2.0 cutover.** V1 `JobDefinition` (with required
        `ref` field and three-way `file_id`/`source`/`inputs` mutex) is
        replaced wholesale per ADR-0004 §"Greenfield V2.0 cutover". V1
        `JobSource` and `JobInput` schemas are deleted; their job-output
        and per-input semantics live in `JobOutputSource` and
        `JobInputV2` respectively.
      properties:
        id:
          type: string
          description: |
            Optional local identifier within the workflow. Auto-generated
            `job_N` when omitted. Required when referenced by other jobs,
            workflow_edges, or delivery_plan. Reserved pattern `^job_\d+$`
            — user-supplied IDs matching this pattern are rejected at the
            JSON-Schema layer via the negative lookahead in the `pattern`
            below.
          pattern: '^(?!job_\d+$)[A-Za-z][A-Za-z0-9_-]*$'
          example: "compressed"
        source:
          $ref: '#/components/schemas/WorkflowSource'
        inputs:
          type: array
          description: |
            Multi-input list for `merge`, `archive`, `image_watermark`,
            `custom_luma`, `audio_overlay`, `audio_to_video`, and
            `video_watermark`. Each
            entry is a `JobInputV2` with its own `MultiInputSource` (the
            3-leaf subset of `WorkflowSource` that excludes upload-direct;
            uploads enter via a `passthrough` source job referenced by
            `job_output`).
            Mutually exclusive with `source` — the V2 shape boundary
            stays `source` (single-input) XOR `inputs[]` (multi-input
            role-based) per ADR-0004 / I12.

            **Minimum input count = sum of role minima** declared in
            the operation's `per_role_cardinality` (per ticket
            [`SlluxMBN`](https://trello.com/c/SlluxMBN) ADR-0015).
            `audio_to_video` requires 1 input (`base` audio; `overlay`
            optional, 0..1); all other role-based ops require 2+ today.
            The schema floor here is `minItems: 1` to admit the
            audio_to_video case; per-operation gates enforce the
            actual count via `OperationSchemaDefinition.min_inputs`.
          minItems: 1
          items:
            $ref: '#/components/schemas/JobInputV2'
        operations:
          type: array
          description: |
            Unordered **set** of operations for this job. The API
            canonicalizes them to a deterministic order — submitted order
            does not affect the result (same set, any order → same plan →
            same bytes). The resolved canonical order is echoed in the
            response `composition_plan` (see
            `WorkflowCreateResponse.composition_plan`). Each canonical chain
            operation consumes the previous operation's output; all
            intermediate and final outputs are kept.

            Order-independence is over a *well-formed* set: conflicting or
            duplicate same-stage operations (e.g. two `compress` with
            different options) are resolved or rejected by the
            canonicalization engine — the contract does not enumerate the
            conflict-resolution rules (engine-side, like the opaque
            processing-time estimator). A rejected set surfaces as a
            `validation_error` (422); it is not silently ordered.

            Multi-input jobs (with `inputs[]`) must have exactly one
            operation, and it must be a multi-input type (`merge`,
            `archive`, `image_watermark`, `custom_luma`,
            `audio_overlay`, `audio_to_video`, or `video_watermark`).
          items:
            $ref: '#/components/schemas/OperationDefinition'
        deliver:
          type: boolean
          default: false
          description: |
            Per-job hide-intermediates promotion flag per
            [ADR-0003](../docs/decisions/0003-delivery-mode.md) §"Per-job
            deliver flag". When `true`, this job's output is promoted
            into the deliverable set even if it's an intermediate (not
            a leaf terminal).

            Mutually exclusive with `delivery.selection.type: explicit`
            at the workflow level — the validator rejects a workflow
            that combines explicit selection with any per-job
            `deliver: true`.
      oneOf:
        - required: [source]
        - required: [inputs]
      allOf:
        - if:
            required: [inputs]
          then:
            properties:
              operations:
                minItems: 1
                maxItems: 1
                items:
                  properties:
                    type:
                      enum: [merge, archive, image_watermark, custom_luma, audio_overlay, audio_to_video, video_watermark]
            required: [operations]
            description: |
              Multi-input jobs must have exactly one operation, and it
              must be a multi-input type. The enum lists `merge`,
              `archive`, `image_watermark` (joined via ticket
              [I4-CONS](https://trello.com/c/2dZiW1BF)),
              `custom_luma` (joined via ticket
              [I29](https://trello.com/c/EPUE5Vs1) — `availability:
              planned` so workflow-create returns 422 until Lambda
              ships), `audio_overlay` (joined via ticket
              [I19](https://trello.com/c/Xr3Z4GBF) — same `planned`
              gating), and `audio_to_video` + `video_watermark` (joined
              via Wave A [`c3uthIP4`](https://trello.com/c/c3uthIP4) —
              `availability: beta`, backends live). The multi-input
              shape is contract-defined for all entries.
        - if:
            properties:
              operations:
                contains:
                  type: object
                  properties:
                    type:
                      const: passthrough
                  required: [type]
            required: [operations]
          then:
            properties:
              operations:
                minItems: 1
                maxItems: 1
                items:
                  properties:
                    type:
                      const: passthrough
              source:
                properties:
                  type:
                    const: upload
                required: [type]
            required: [source]
            description: |
              `passthrough` constraint. A `passthrough` operation MUST be
              the SOLE operation of a SINGLE-INPUT job whose `source.type`
              is `upload` (i.e. `operations: [{type: passthrough}]` with
              `source: {type: upload, ...}`): never chained with another
              operation, never on `inputs[]`, never on a non-`upload`
              source. `passthrough` is an inert lossless bridge that the
              API self-completes at publish with the upload's `{bucket,
              key}` as the job output (see the `passthrough` bullet under
              `OperationType` and the `POST /api/workflows` description),
              so it is meaningful only for an uploaded source feeding a
              downstream multi-input op via `job_output`. The shared
              `OperationType` enum makes `passthrough` syntactically
              expressible elsewhere; this guard makes those uses
              schema-invalid rather than deferring rejection to the
              backend.

    JobInputV2:
      type: object
      description: |
        V2 multi-input entry per [ADR-0004](../docs/decisions/0004-job-shape.md)
        §"JobInputV2". Replaces V1 `JobInput`. Each entry carries its own
        `source` (a `MultiInputSource` value — the 3-leaf subset of
        `WorkflowSource` that **excludes upload-direct**), so multi-input
        jobs can mix external imports, vault connections, and upstream job
        outputs within a single inputs[] array. An uploaded file enters a
        multi-input op via a `passthrough` source job (single-input,
        `operations: [{type: passthrough}]`) referenced here as
        `{ type: job_output, from: <id> }` — not as a direct `upload`
        source (per ticket [`4som89Uh`](https://trello.com/c/4som89Uh)).
      required:
        - source
      properties:
        source:
          $ref: '#/components/schemas/MultiInputSource'
        role:
          type: string
          description: |
            Role for role-based multi-input operations:
            - `image_watermark`: REQUIRED; values `base` (the source
              image) or `overlay` (the watermark). Exactly one of each
              per job (per [I4-CONS](https://trello.com/c/2dZiW1BF)).
            - `custom_luma`: REQUIRED; values `base` (the source video)
              or `transition_mask` (the caller-uploaded luma matte).
              Exactly one of each per job (per
              [I29](https://trello.com/c/EPUE5Vs1)).
            - `audio_overlay`: REQUIRED; values `base` (the primary
              audio or video track) or `overlay` (the secondary audio
              asset to mix in). Exactly one of each per job (per
              [I19](https://trello.com/c/Xr3Z4GBF)). Reuses the
              `overlay` value already declared for `image_watermark`
              — semantics differ by operation type, but the same
              role label keeps the enum compact.
            - `audio_to_video`: REQUIRED `base` (audio source); OPTIONAL
              `overlay` (still image, 0..1). FIRST role-based op with
              an OPTIONAL role — see `per_role_cardinality` for the
              machine-readable cardinality declaration (per ticket
              [`SlluxMBN`](https://trello.com/c/SlluxMBN) /
              ADR-0015). Reuses `base` + `overlay` role values
              already declared for `image_watermark` / `audio_overlay`.
            - `video_watermark`: REQUIRED; values `base` (the source
              video) or `overlay` (the watermark image). Exactly one
              of each per job — `per_role_cardinality { base: 1/1,
              overlay: 1/1 }`. First non-introductory adoption of the
              `per_role_cardinality` vocab (per ticket
              [`4NrRPCgh`](https://trello.com/c/4NrRPCgh) /
              ADR-0013). `video_text_watermark` is single-input (no
              `role` field — text comes from options).
            - `merge` / `archive`: omit (all inputs share the same role).

            `text_watermark` is single-input via `JobDefinition.source`
            (not `inputs[]`); `role` does not apply there.

            Future operations may widen this enum.
          enum:
            - base
            - overlay
            - transition_mask
        per_input_options:
          type: object
          description: |
            Per-input option overrides. Preserved from V1 merge semantics
            (e.g. transition override for a specific join point). Keys
            are operation option names; values are the override values.
            The operation schema in `schemas/operations/*.yaml` defines
            which options are overridable per-input via the
            `per_input_options` block.
          additionalProperties: true

    OperationDefinition:
      type: object
      description: Definition of a single operation within a job
      required:
        - type
      properties:
        type:
          $ref: '#/components/schemas/OperationType'
        options:
          type: object
          description: |
            Operation-specific options. The available options and their validation
            rules depend on the operation type and the input file's MIME type.
            See `GET /api/operations/schema` for the full schema.

            Options are validated against the schema using JSON Schema if/then/else
            rules. For example, `crf` is only valid when `encoding_mode: crf`
            for compress video operations.
          additionalProperties: true
        base:
          type: string
          enum:
            - processed_base
            - original
          default: processed_base
          description: |
            For **derived artifacts** (`thumbnail`, `split`): which canonical
            composition node this artifact branches from. Symbolic-only for
            v1 (no per-operation references — caller-visible op ids do not
            exist at submit time).
            - `processed_base` (default): the fully-processed base —
              post-everything, so the artifact carries the watermark + encode.
            - `original`: the untouched source file.
            Ignored for chain operations (compress, convert, watermark, merge,
            audio). The resolved branch point is echoed as
            `CompositionPlanOperation.derived_from` (a `node_id`) in
            `composition_plan`. See the operation-composition model.

    # ============================================
    # WORKFLOW DELIVERY (ticket I8-CONS)
    # ============================================
    #
    # Per ADR-0003. Workflow-level convenience surface for the common
    # "upload N files, give me a zip" case. Coexists with the `archive`
    # operation, which stays first-class for power-user cases (subset
    # bundles, multi-bundle workflows, archive chaining, explicit
    # folder/naming control).

    DeliveryOutputRef:
      type: object
      description: |
        Reference to a specific job's operation output for explicit
        delivery selection. Distinct from `JobInputV2` (which carries
        per-input options for multi-input ops); this shape is dedicated
        per [ADR-0003](../docs/decisions/0003-delivery-mode.md) §"DeliveryOutputRef schema".
      required:
        - ref
      properties:
        ref:
          type: string
          description: Job ref within the workflow (matches `JobOutputSource.from` semantics).
        operation:
          type: string
          description: |
            Optional operation type filter when the job has multiple
            operations. Omit to select the last operation's output.

            **`availability: planned`** — per-operation-output grain is
            not yet honoured: the API currently rejects a non-empty
            `operation` filter with `feature_not_available` (422), even
            though `delivery.selection` `all_outputs`/`explicit` are
            `stable` at JOB grain (per `cse3wt2s` / T5b). Deferred to a
            follow-up; omit it (job-grain selection) for now.

    DeliverySelection:
      type: object
      description: |
        How to pick outputs from a workflow's jobs for delivery.
        Default `terminal` — leaves only (hide intermediates per
        ADR-0003 round-5 default).
      required:
        - type
      properties:
        type:
          type: string
          description: |
            Selection strategy:
            - `terminal` (default): bundle all jobs with no outgoing
              edges (computed from `JobOutputSource.from` references).
            - `all_outputs`: bundle every **job's** output in the
              workflow, including intermediates (job-grain). Per-operation
              output selection within a job is NOT yet available — see the
              `DeliveryOutputRef.operation` filter (`planned`).
            - `explicit`: bundle only the refs listed in `refs[]`.
              Mutually exclusive with per-job `JobDefinition.deliver: true`
              (validator rejects 422 if both are present).
          enum:
            - terminal
            - all_outputs
            - explicit
          per_value_availability:
            terminal:    { availability: stable }
            all_outputs: { availability: stable }
            explicit:    { availability: stable }
          # Availability flipped planned → stable per ticket
          # [`cse3wt2s`](https://trello.com/c/cse3wt2s) (T5b delivery-
          # selection go-live, co-land with API PR #365). The two-gate
          # flip: co0CERtJ downgraded these to `planned` to match the
          # runtime, which 422'd every non-default `selection.type`; the
          # API now ACCEPTS `all_outputs`/`explicit` at JOB grain
          # (`DeliveryPlanComputer` threads the request-level
          # `delivery.selection`), so the contract flips back to `stable`.
          # The `all_outputs` claim is narrowed to job-grain to stay
          # conformant; per-operation-output grain + the
          # `DeliveryOutputRef.operation` filter stay `planned` (the API
          # still 422s the operation filter — deferred follow-up). Terminal
          # stays `stable` (the implicit default).
        refs:
          type: array
          description: |
            Required when `type: explicit`; ignored otherwise. Each
            entry promotes a specific job's output into the
            deliverable set.
          items:
            $ref: '#/components/schemas/DeliveryOutputRef'

    Delivery:
      type: object
      description: |
        Workflow-level delivery configuration per
        [ADR-0003](../docs/decisions/0003-delivery-mode.md). Optional —
        when absent, the workflow defaults to
        `mode: individual, selection.type: terminal` (current V1
        behaviour).

        **Validator rules (runtime-enforced; spec documents):**
        1. If `selection.type: explicit` AND any job in `jobs[]` has
           `deliver: true`, the request is rejected with HTTP 422 —
           these two promotion mechanisms are mutually exclusive.
        2. If `mode != individual`, `bundle_format` is required.
      properties:
        mode:
          type: string
          description: |
            Delivery mode. `individual` is the only committed value
            today (per ticket [`co0CERtJ`](https://trello.com/c/co0CERtJ)).
            `bundle` and `both` are `planned`: the API runtime returns
            `422 feature_not_available` for any non-`individual` value,
            which per the `info.description` availability taxonomy is
            the `planned` behaviour (`stable`/`beta` MUST NOT 422).

            For workflows that need a packaged output today, compose
            a terminal `archive` job whose `sources` are
            `JobOutputSource` refs to the upstream jobs — `archive` is
            the canonical output-bundler per ADR-0017. `delivery.mode:
            bundle` is the deferred post-launch ergonomic sugar that
            will reuse the same `archive` runtime under the hood.
          enum:
            - individual
            - bundle
            - both
          default: individual
          per_value_availability:
            individual: { availability: stable }
            bundle:     { availability: planned }
            both:       { availability: planned }
        bundle_format:
          type: string
          enum:
            - zip
            - tar_gz
          description: |
            Required when `mode != individual`. Format of the bundle
            output. Validator rule encoded in description per repo
            convention; runtime returns 422 if missing.
        bundle_filename:
          type: string
          description: |
            Optional bundle filename (without extension; server adds
            `.zip` / `.tar.gz` as appropriate). Server generates one
            from workflow_id when omitted.
        include_metadata:
          type: boolean
          default: false
          description: |
            When `true`, embed a `manifest.json` inside the bundle
            describing each output (job_id, operation, original
            filename, size, etc.). The manifest's exact wire shape is
            runtime-defined and not pinned in this spec; consumers
            should treat it as an additive JSON object whose schema
            may evolve in minor versions.
        selection:
          $ref: '#/components/schemas/DeliverySelection'
          description: |
            How to pick outputs. Default `terminal` (leaves only).
      example:
        # Spec example uses `bundle` to document the deferred (planned)
        # shape end-to-end — see Delivery.mode per_value_availability
        # for the runtime state. For workflows that need packaged
        # output today, compose a terminal `archive` job whose
        # `sources` are `JobOutputSource` refs (ADR-0017).
        mode: bundle
        bundle_format: zip
        bundle_filename: "compressed-photos"
        include_metadata: true
        selection:
          type: terminal

    DeliveryPlanReason:
      type: string
      description: |
        Why a given output appears in `outputs[]` or `hidden_outputs[]`
        of `DeliveryPlan`:
        - `leaf_terminal`: terminal job output (no outgoing edges).
        - `consumed_by`: intermediate output consumed by a downstream
          job; the consuming job's id is set in `consumed_by_job_id`
          on the same `DeliveryPlanOutput` entry.
        - `deliver_flag`: promoted via per-job `deliver: true`.
        - `explicit_selection`: listed in `delivery.selection.refs[]`.
      enum:
        - leaf_terminal
        - consumed_by
        - deliver_flag
        - explicit_selection

    DeliveryPlanOutput:
      type: object
      description: One entry in `DeliveryPlan.outputs` or `DeliveryPlan.hidden_outputs`.
      required:
        - job_id
        - reason
      properties:
        job_id:
          $ref: '#/components/schemas/UuidV7'
        operation:
          type: string
          description: Operation type within the job (e.g. `compress`, `merge`).
        reason:
          $ref: '#/components/schemas/DeliveryPlanReason'
        consumed_by_job_id:
          $ref: '#/components/schemas/UuidV7'
          description: |
            Set when `reason: consumed_by`. The id of the downstream
            job that consumes this output.
        node_id:
          type: string
          description: |
            Symbolic composition `node_id` correlating this delivered output
            to its canonical node in `composition_plan` (e.g. `encode`,
            `thumbnail`, `processed_base`). Lets consumers label and group
            delivered files by composition role. **Optional** — emitted once
            the canonicalization engine is live (optional-then-promote,
            mirroring `composition_plan`); absent until then. This is the
            additive carrier; the normative
            `/downloads == delivery_plan.outputs[]` rendezvous invariant is
            introduced with the delivery-selection promotion, not here.

    DeliveryPlan:
      type: object
      description: |
        Response-side delivery plan emitted in `WorkflowCreateResponse`.
        Tells callers what they will receive (`outputs[]`) and what
        was hidden (`hidden_outputs[]`) so frontends render the right
        UI before the workflow runs.

        Both `outputs[]` and `hidden_outputs[]` are always present;
        the server emits `[]` rather than omitting the field when a
        category is empty.
      required:
        - mode
        - selection_type
        - outputs
        - hidden_outputs
      properties:
        mode:
          type: string
          enum: [individual, bundle, both]
        selection_type:
          type: string
          enum: [terminal, all_outputs, explicit]
        outputs:
          type: array
          description: Outputs that will be delivered. Empty `[]` is permitted.
          items:
            $ref: '#/components/schemas/DeliveryPlanOutput'
        hidden_outputs:
          type: array
          description: Outputs hidden by the selection rule. Empty `[]` is permitted.
          items:
            $ref: '#/components/schemas/DeliveryPlanOutput'
      example:
        mode: bundle
        selection_type: terminal
        outputs:
          - job_id: "019539ad-3333-7000-8000-000000000010"
            operation: merge
            reason: leaf_terminal
        hidden_outputs:
          - job_id: "019539ad-3333-7000-8000-000000000011"
            operation: compress
            reason: consumed_by
            consumed_by_job_id: "019539ad-3333-7000-8000-000000000010"

    # ============================================
    # PROCESSING CLASS / LONG-FORM TIER (per ticket I15-CONS)
    # ============================================
    # Public contract uses logical processing-class names only —
    # backend selection (Lambda vs Fargate vs ECS) is server-decided
    # and deliberately absent from the contract per plan v5 §F8 round
    # 7. Frontend tier-upgrade UI should switch on `processing_class`
    # (enum), not `execution_pool` (opaque string).

    ProcessingClass:
      type: string
      description: |
        Logical processing-class identifier. Public contract uses
        logical names only — backend selection is server-decided per
        plan v5 §F8 + ticket
        [I15-CONS](https://trello.com/c/YZpBKzOM). `short_form` /
        `long_form` are the canonical pair; `short_form_concat` /
        `long_form_re_encode` are merge-only aliases (the merge
        differentiator is concat-compatibility per ticket I16-CONS,
        not size/duration).
      enum:
        - short_form
        - long_form
        - short_form_concat
        - long_form_re_encode

    ProcessingClassHint:
      type: string
      description: |
        Caller-supplied logical-policy hint on `WorkflowCreateRequest`.
        NOT a backend selector — server is final authority on routing.
        - `auto` (default): server routes safely.
        - `short_form_only`: fail fast (422 with `reason:
          tier_policy`) if any job would require `long_form`.
        - `long_form_allowed`: caller accepts slower queue/runtime
          semantics.
        - `long_form_preferred`: prefer `long_form` pool when
          eligible (test/isolation use cases).
      enum:
        - auto
        - short_form_only
        - long_form_allowed
        - long_form_preferred
      default: auto

    EstimateQuality:
      type: string
      description: |
        How the server arrived at the estimate.
        - `metadata_exact`: full server-side probe.
        - `metadata_partial`: partial probe (e.g. duration only).
        - `client_hint`: caller-supplied `metadata_hint` on multipart
          initiate.
        - `worst_case`: upper bound only — used when no metadata is
          available.
      enum:
        - metadata_exact
        - metadata_partial
        - client_hint
        - worst_case

    EstimateRange:
      type: object
      description: |
        Min / p50 / max integer-seconds range for an estimate. Server
        may emit identical values when uncertainty is low; consumers
        SHOULD treat min == max as a tight estimate rather than an
        invariant violation.
      required: [min, p50, max]
      properties:
        min:
          type: integer
          minimum: 0
        p50:
          type: integer
          minimum: 0
        max:
          type: integer
          minimum: 0

    ProcessingClassReason:
      type: string
      description: |
        Why a job was assigned its `processing_class`.
        - `within_short_form_limits`: emitted on `short_form` jobs
          whose inputs fit within the short_form constraints; the
          benign / no-routing-needed default.
        - `input_size_exceeds_short_form` /
          `input_duration_exceeds_short_form`: input-driven escalation
          to `long_form`.
        - `merge_re_encode_long_form` / `requires_reencode`: merge
          inputs require re-encoding so concat-fast-path is
          unavailable; `requires_reencode` cross-references ticket
          I16-CONS (Trello `7nCZXEru`) — the `merge.video` re-encode
          decision visible on `OperationMetrics.re_encode_decision`
          (operation-result time) is also surfaced here at
          workflow-create time.
        - `tier_policy`: emitted when the caller's
          `processing.class_hint` (e.g. `short_form_only`) forced
          the decision rather than input characteristics.
      enum:
        - within_short_form_limits
        - input_size_exceeds_short_form
        - input_duration_exceeds_short_form
        - merge_re_encode_long_form
        - requires_reencode
        - tier_policy

    ProcessingPlanJob:
      type: object
      description: |
        Per-job entry in the workflow's `processing_plan`. Server
        emits one entry per job in the workflow (including jobs that
        run on `short_form`).
      required:
        - job_id
        - processing_class
        - execution_pool
        - estimated_queue_seconds
        - estimated_processing_seconds
        - estimate_quality
        - reason
      properties:
        job_id:
          type: string
          format: uuid
          description: UUID-v7 of the job. Mirrors `JobDefinition.id` after server resolution.
        processing_class:
          $ref: '#/components/schemas/ProcessingClass'
        execution_pool:
          type: string
          description: |
            Logical pool name. **Opaque string** — not an enum — so
            future pool naming evolves without contract churn. Known
            initial values (informational): `standard`, `priority`,
            `enterprise_isolated`. SDK + frontend gating logic
            should switch on `processing_class` (enum), NOT
            `execution_pool`.
          example: standard
        estimated_queue_seconds:
          $ref: '#/components/schemas/EstimateRange'
        estimated_processing_seconds:
          $ref: '#/components/schemas/EstimateRange'
        estimate_quality:
          $ref: '#/components/schemas/EstimateQuality'
        reason:
          $ref: '#/components/schemas/ProcessingClassReason'

    ProcessingPlan:
      type: object
      description: |
        Response-side processing plan emitted in
        `WorkflowCreateResponse`. Tells callers which jobs will run
        on which logical processing class (with rough queue/processing
        time estimates) so frontends can preview routing decisions
        before the workflow runs.

        `jobs[]` is always present; the server emits `[]` rather than
        omitting the field when a workflow has no compute jobs (e.g.
        archive-only workflows). Per ticket I15-CONS V2-cutover
        invariant — mirrors the `delivery_plan` REQUIRED rule from
        I8-CONS.

        Estimation algorithm is server-side and deliberately opaque
        per plan v5 §F8.2 (mirrors I16-CONS opacity precedent for
        `re_encode_decision`). Contract pins shape only.
      required:
        - jobs
      properties:
        jobs:
          type: array
          description: Per-job processing-plan entries. Empty `[]` is permitted.
          items:
            $ref: '#/components/schemas/ProcessingPlanJob'
        estimated_total_seconds:
          $ref: '#/components/schemas/EstimateRange'
          description: |
            Wall-clock estimate for the whole workflow under the
            server's `concurrency_assumption`. Sum of per-job
            estimates only when the workflow is sequential.
        concurrency_assumption:
          type: object
          additionalProperties: true
          description: |
            Server-asserted concurrency model. Free-form object —
            keys evolve with infra. Initial keys (informational):
            `max_parallel_long_form_jobs`. NOT depended on by SDK
            codegen.
      example:
        jobs:
          - job_id: "019539ad-4444-7000-8000-000000000020"
            processing_class: long_form
            execution_pool: standard
            estimated_queue_seconds: { min: 1, p50: 5, max: 30 }
            estimated_processing_seconds: { min: 10, p50: 25, max: 90 }
            estimate_quality: metadata_exact
            reason: input_duration_exceeds_short_form
        estimated_total_seconds: { min: 11, p50: 30, max: 120 }
        concurrency_assumption:
          max_parallel_long_form_jobs: 2

    WorkflowProcessing:
      type: object
      description: |
        Caller-supplied workflow-level processing hints. Per ticket
        [I15-CONS](https://trello.com/c/YZpBKzOM). Optional — when
        omitted, server defaults `class_hint` to `auto`.
      properties:
        class_hint:
          $ref: '#/components/schemas/ProcessingClassHint'

    # ============================================
    # CANONICAL COMPOSITION PLAN (operation-composition epic)
    # ============================================
    # Response-side single source of truth for operation composition.
    # The API accepts an unordered operation SET (per source) and
    # canonicalizes it to a deterministic DAG; this plan exposes the
    # resolved order, per-op chain group/position, derived-artifact
    # lineage, and image-encode capabilities so FE + SDK read the model
    # instead of hardcoding it. Additive-first: these schemas ship OPEN
    # (no `additionalProperties: false`, no `allOf`) — flat objects to
    # avoid the inherited-field-closure footgun, and OPEN so the
    # canonicalization engine (later epic phases) can add correlation
    # fields before a separate tightening cut. All correlation fields
    # (`node_id`, `operation_id`, `requested_operations`, capability
    # keys) are pinned now so the eventual `additionalProperties: false`
    # cut has nothing to break.

    CompositionPlan:
      type: object
      description: |
        Canonical composition plan for a workflow — emitted on
        `WorkflowCreateResponse.composition_plan`. Tells callers the
        deterministic order the submitted operation set resolved to, the
        per-operation chain group/position, derived-artifact lineage, and
        the image-encode capabilities that gate format/quality choices.

        `canonical_order` is the informational pipeline spine; per-op
        detail lives on each `CompositionPlanOperation`.
      required:
        - canonical_order
        - jobs
        - capabilities
      properties:
        canonical_order:
          type: array
          description: |
            The canonical pipeline spine (single mutated stream), as an
            ordered list of `chain_group` stage names. The known image/AV
            spine is `merge` (fan-in) → `image_watermark` → `text_watermark`
            → `audio` → `encode` (terminal: convert+compress folded) →
            `derived` (fan-out: thumbnail/split). Informational — read per-op
            `chain_group` / `chain_position` for placement. The geometry
            stage is intentionally not exposed as a node for v1 (image
            resize/fit live inside the thumbnail node).

            **Open string, NOT a fixed enum** (mirrors
            `ProcessingPlanJob.execution_pool`): the canonical model still
            covers operation classes not in the image-pipeline spine
            (`archive`, `passthrough`, `audio_to_video`, …) and the engine's
            final stage taxonomy is pinned alongside the canonicalization
            engine (later epic phases). The values are tightened to an enum
            in the same gated cut that adds `additionalProperties: false`,
            once the engine emits the complete set. Consumers MUST treat
            unknown stage names as forward-compatible.
          items:
            type: string
        jobs:
          type: array
          description: Per-job canonical composition. Empty `[]` is permitted.
          items:
            $ref: '#/components/schemas/CompositionPlanJob'
        capabilities:
          $ref: '#/components/schemas/ImageEncodeCapabilities'

    CompositionPlanJob:
      type: object
      description: Per-job entry in a `CompositionPlan` — the canonical operations for one job.
      required:
        - job_id
        - operations
      properties:
        job_id:
          $ref: '#/components/schemas/UuidV7'
        operations:
          type: array
          description: The job's operations in canonical (resolved) order.
          items:
            $ref: '#/components/schemas/CompositionPlanOperation'

    CompositionPlanOperation:
      type: object
      description: |
        The canonical view of one operation within a job — its symbolic
        composition node, resolved chain placement, and (for derived
        artifacts) the node it branches from.
      required:
        - node_id
        - type
        - chain_group
        - chain_position
      properties:
        node_id:
          type: string
          description: |
            Stable **symbolic** canonical node id (e.g. `original`,
            `processed_base`, `encode`, `thumbnail`). The correlation key
            used by `derived_from`, `DeliveryPlanOutput.node_id`, and
            `OperationDownload.node_id` to tie delivered files back to a
            canonical node. **NOT** the operation UUID. (SSE stays
            per-operation by design — it is not a delivery-plan projection
            and does not carry `node_id`.)
          example: encode
        operation_id:
          $ref: '#/components/schemas/UuidV7'
        type:
          $ref: '#/components/schemas/OperationType'
          description: |
            The canonical operation type of this node. For the folded
            terminal `encode` node (`chain_group: encode`) this is the
            encode operation `compress` — `convert` folds INTO it (a single
            terminal encode, never a double-encode), and the folded source
            operations are listed in `requested_operations`. For all other
            nodes `type` is the operation as submitted.
        chain_group:
          type: string
          description: |
            Which canonical stage this operation belongs to. **Open string,
            NOT a fixed enum** (mirrors `ProcessingPlanJob.execution_pool`) —
            the stage taxonomy is pinned alongside the canonicalization
            engine and tightened to an enum in the same gated cut that adds
            `additionalProperties: false`; consumers MUST treat unknown
            stage names as forward-compatible. The watermark stages are
            **media-agnostic overlay stages**, not image-only. Known stages:
            - `merge`: fan-in.
            - `image_watermark`: raster/image-overlay watermark stage —
              covers the `image_watermark` AND `video_watermark` operation
              types.
            - `text_watermark`: text-overlay watermark stage — covers the
              `text_watermark` AND `video_text_watermark` operation types.
            - `audio`: audio operations (e.g. `audio_overlay`,
              `audio_to_video`).
            - `encode`: the folded convert+compress terminal encode stage.
            - `derived`: fan-out artifact (`thumbnail` / `split`).
            Operation classes outside the image/AV spine (e.g. `archive`
            bundling, `passthrough` bridge jobs) carry their own stage names
            once the engine pins them. (The geometry/resize stage is not
            exposed as a node for v1 — image resize lives inside the
            `thumbnail` worker.)
        chain_position:
          type: integer
          minimum: 0
          description: Deterministic resolved position within the canonical chain.
        derived_from:
          type: string
          description: |
            Set only for derived operations (`thumbnail`, `split`): the
            `node_id` this artifact branches from (symbolic; resolved from
            the request-side `OperationDefinition.base`). Absent for chain
            operations.
          example: processed_base
        requested_operations:
          type: array
          description: |
            Image-fold audit trail: the request operation types that were
            absorbed into this single canonical node. For the folded
            `encode` node this is e.g. `[convert, compress]` — the caller
            still submits both, but composition exposes ONE `encode` node
            (no false double-encode). Absent when no fold occurred.
          items:
            $ref: '#/components/schemas/OperationType'

    ImageEncodeCapabilities:
      type: object
      description: |
        Image-encode-stage capability flags that gate format/quality
        choices for the canonical `encode` node. **Image-encode-scoped**
        — these caveats apply to the image encode pass only, not to
        video / audio / document encoding. Surfaced so FE/SDK do not
        promise capabilities the worker cannot honour (e.g. a WebP
        quality slider the convert path silently ignores).
      required:
        - webp_quality_supported
        - background_flatten
      properties:
        webp_quality_supported:
          type: boolean
          description: |
            Whether quality-controlled (lossy) WebP encode is available.
            `false` today: WebP via the convert/encode path is
            lossless-only and the `quality` option is silently ignored
            (quality-controlled WebP is only reachable via `compress`).
            Consumers MUST NOT offer a WebP quality control when this is
            `false`.
          example: false
        background_flatten:
          type: string
          description: |
            Whether alpha→non-alpha background flattening is available at
            encode time. `conditional` today: flatten fires only when an
            alpha source is encoded to a non-alpha output (e.g. PNG→JPEG).
            - `unsupported`: never applied.
            - `conditional`: applied only on alpha→non-alpha transitions.
            - `supported`: always available.
          enum:
            - unsupported
            - conditional
            - supported
          example: conditional

    # ============================================
    # UPLOAD PREFLIGHT PROBE (per ticket I28)
    # ============================================

    UploadProbeStatus:
      type: string
      description: |
        Outcome of a preflight probe.
        - `ok`: file is workflow-ready; pair with
          `processing_class_pre_assignment` to know which pool the
          server would route it to.
        - `corrupt`: file header / container is damaged; caller
          should exclude or re-upload.
        - `unsupported_codec`: container is readable but the codec
          is not in the supported set for any operation. Caller
          should convert before submitting (or use the `convert`
          operation as the first job).
        - `missing_metadata`: file appears valid but the prober
          could not extract enough metadata to make a routing
          decision. Caller may proceed at their own risk; the
          workflow-create routing fallback applies.
      enum:
        - ok
        - corrupt
        - unsupported_codec
        - missing_metadata

    UploadProbeProcessingClass:
      type: string
      description: |
        Tier-aware pre-assignment. Same logic as F8.1 upload-side
        gating (per ticket I15-CONS) — reflects what the server
        would route this file to under the caller's current tier.
        `blocked` is emitted when no tier-permitted pool exists for
        the file (e.g. free-tier caller probing a long-form clip,
        or any caller probing a `corrupt` / `unsupported_codec`
        file).
      enum:
        - short_form
        - long_form
        - blocked

    UploadProbeMediaMetadata:
      type: object
      description: |
        Probe-extracted media metadata. Fields populated according to
        the file's MIME type and what the prober could read; absent
        fields are NOT errors — they reflect "this metadata is not
        applicable / not extractable" rather than "this metadata
        failed validation". `probed_at` is always present.

        Schema covers the **union** of fields needed by the API's
        `MetadataResponse` projection across video / audio /
        document inputs (per the M2 Epic re-scope —
        [`SrHwuvIl`](https://trello.com/c/SrHwuvIl) — which makes
        this probe Lambda the universal non-image metadata source
        feeding `rich_metadata`). The Lambda extractor only
        populates fields it can derive from the actual container;
        unrelated fields are simply omitted.
      required:
        - probed_at
      properties:
        duration_seconds:
          type: integer
          minimum: 0
          description: Container-reported duration (audio + video).
        width:
          type: integer
          format: int64
          minimum: 1
          description: |
            Pixel width (image + video; best-effort document). Declared
            `int64` so Rust SDK generators emit `i64` instead of the
            default `i32`; `i32` is fine for everything ffmpeg/libcaesium
            handles today but the format hint keeps the door open for
            scientific / RAW imagery without future SDK churn (per
            ticket [`0sTmbUsc`](https://trello.com/c/0sTmbUsc)).
        height:
          type: integer
          format: int64
          minimum: 1
          description: |
            Pixel height (image + video; best-effort document). See
            `width` for the `format: int64` rationale.
        codec:
          type: string
          description: |
            Primary codec identifier as reported by the prober. For
            video inputs this is the **video** codec; for audio-only
            inputs this is the audio codec. For video files with
            audio tracks, the audio codec is reported separately as
            `audio_codec`. Examples: `h264`, `h265`, `vp9`, `av1`,
            `aac`, `opus`, `mp3`. Not constrained to an enum — codec
            strings evolve with FFmpeg releases (per ADR-0007 FFmpeg
            pin) and SDKs should string-match.
        audio_codec:
          type: string
          description: |
            Audio codec for video files that carry an audio track.
            Distinct from `codec` (which is the video codec for
            video inputs). Absent for video-only / silent video
            inputs and for audio-only inputs (audio-only inputs put
            their codec under `codec`).
        container:
          type: string
          description: |
            Container format. Examples: `mp4`, `webm`, `mov`,
            `mkv`, `mp3`, `wav`, `flac`. Not constrained to an
            enum.
        fps:
          type: number
          minimum: 0
          description: |
            Container-reported frame rate (video only). Number, not
            integer — frame rates are commonly fractional (e.g.
            `29.97`, `23.976`).
        bitrate_bps:
          type: integer
          format: int64
          minimum: 0
          description: |
            Overall stream bitrate, **bits per second**. Units in
            the field name, mirroring `duration_seconds`, so SDKs
            avoid the kbps-vs-bps guessing trap. Container-reported
            for video + audio. Declared `int64` so Rust SDK
            generators emit `i64` instead of `i32`; `i32` caps at
            ~2.1 Gbps which is tight for future 4K/8K/RAW workloads
            (per ticket [`0sTmbUsc`](https://trello.com/c/0sTmbUsc)).
        audio_layout:
          type: string
          description: |
            Channel layout, human-display form. Examples: `mono`,
            `stereo`, `5.1`, `7.1`. Not constrained. Pairs with the
            numeric `channels` field (which is what API consumers
            project into `rich_metadata`); `audio_layout` is
            preserved on the probe row for UI display ("5.1
            surround") without forcing the UI to derive a label
            from the integer.
        channels:
          type: integer
          minimum: 1
          description: |
            Numeric audio channel count derived by the Lambda from
            ffprobe output. Examples: `1` (mono), `2` (stereo), `6`
            (5.1), `8` (7.1). Pairs with `audio_layout`. Audio +
            video.
        sample_rate_hz:
          type: integer
          minimum: 1
          description: |
            Audio sample rate, **Hertz**. Units in the field name,
            mirroring `duration_seconds` / `bitrate_bps`. Examples:
            `44100`, `48000`. Audio + video-with-audio.
        page_count:
          type: integer
          minimum: 0
          description: |
            Document page count (PDF / EPUB / DOCX / XLSX / PPTX /
            ODT / ODS / ODP). `0` for a document the prober opened
            but found empty.
        dpi:
          type: integer
          minimum: 1
          description: |
            Document resolution, single-axis (dots per inch).
            Single-axis matches the existing `MetadataResponse`
            contract on the OpenAPI side; the prober reports the
            page-1 horizontal DPI when the document declares it,
            otherwise omits the field.
        probed_at:
          type: string
          format: date-time
          description: |
            ISO-8601 timestamp at which the probe ran. Idempotent
            for the same `file_id` — subsequent probe calls return
            the cached `probed_at`.

    UploadProbeResponse:
      type: object
      description: |
        Payload schema carried on the `data` field of
        `UploadProbeSuccessEnvelope` returned by
        `POST /api/uploads/{id}/probe`, per ticket
        [I28 `KbVAnGCm`](https://trello.com/c/KbVAnGCm). Designed
        so SDKs can compile a `client.preflight_clips([file_ids])`
        helper into N parallel probes and aggregate by
        `probe_status`.
      required:
        - file_id
        - probe_status
        - media_metadata
        - processing_class_pre_assignment
      properties:
        file_id:
          $ref: '#/components/schemas/UuidV7'
          description: Mirrors the path-parameter `id` for caller correlation.
        probe_status:
          $ref: '#/components/schemas/UploadProbeStatus'
        media_metadata:
          $ref: '#/components/schemas/UploadProbeMediaMetadata'
        processing_class_pre_assignment:
          $ref: '#/components/schemas/UploadProbeProcessingClass'

    UploadProbeSuccessEnvelope:
      type: object
      additionalProperties: false
      description: |
        Success envelope wrapping `UploadProbeResponse` on
        `POST /api/uploads/{id}/probe` 200 responses, per ticket
        [`9XlWEnZU`](https://trello.com/c/9XlWEnZU). Mirrors the
        spec-wide `*SuccessEnvelope` convention (`{success: true,
        data: <payload>}`) used by every other 2xx endpoint; the
        probe endpoint shipped during the v2.3 declared-but-pending
        phase missing the wrap. The API
        (`ApiResponseTrait::respondSuccess` in `compression_api`)
        already emits this shape — the envelope brings the contract
        in line with the wire.
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/UploadProbeResponse'

    # ============================================
    # UPLOAD-SIDE GATING (per ticket I15-CONS F8.1)
    # ============================================

    UploadConstraintsApplied:
      type: object
      description: |
        Tier+MIME-derived constraints that the server applied to this
        upload. Returned on `UploadResponse` and
        `MultipartInitiateResponse` so callers can see what limits
        their tier permitted (e.g. show the cap in the UI before
        the user picks a workflow).
      required:
        - max_size_bytes
        - processing_class_pre_assignment
      properties:
        max_size_bytes:
          type: integer
          format: int64
          description: |
            Max file size the upload is permitted under the caller's
            current tier + MIME. Same value the server would reject
            with 422 `upload_size_exceeds_tier` if exceeded.
        max_duration_seconds:
          type: [integer, "null"]
          minimum: 0
          description: |
            Tier+MIME-derived duration cap. Null when:
            - The MIME has no duration concept (images, documents).
            - The server could not derive duration from the first
              chunk (multipart-only path; single uploads always
              probe).
        processing_class_pre_assignment:
          type: string
          description: |
            Server's *current* routing decision based on upload
            metadata. NOT binding on workflow-create — workflow
            shape may flip the assignment, especially for multi-input
            merge. `unknown` when metadata is insufficient.
          enum:
            - short_form
            - long_form
            - unknown

    UploadSizeExceedsTierResponse:
      allOf:
        - $ref: '#/components/schemas/ErrorEnvelope'
        - type: object
          required:
            - error_type
            - current_tier
            - max_size_bytes
          properties:
            error_type:
              type: string
              enum: [upload_size_exceeds_tier]
            current_tier:
              $ref: '#/components/schemas/UserTier'
            max_size_bytes:
              type: integer
              format: int64
              description: Max size permitted at the caller's current tier + MIME.
            required_tier:
              description: |
                Tier that would permit this upload size, when known.
                Null when no tier covers the requested size (file is
                over the absolute cap — return 413 instead).
              oneOf:
                - $ref: '#/components/schemas/UserTier'
                - type: 'null'

    UploadDurationExceedsTierResponse:
      allOf:
        - $ref: '#/components/schemas/ErrorEnvelope'
        - type: object
          required:
            - error_type
            - current_tier
            - max_duration_seconds
          properties:
            error_type:
              type: string
              enum: [upload_duration_exceeds_tier]
            current_tier:
              $ref: '#/components/schemas/UserTier'
            max_duration_seconds:
              type: integer
              minimum: 0
              description: Max duration permitted at the caller's current tier + MIME.
            required_tier:
              oneOf:
                - $ref: '#/components/schemas/UserTier'
                - type: 'null'

    WorkflowEdge:
      type: object
      description: |
        Directed edge in the workflow DAG. Defines that the downstream job
        depends on the upstream job completing successfully.
      required:
        - from
        - to
      properties:
        from:
          type: string
          description: Reference label of the upstream job
          example: "compress-intro"
        to:
          type: string
          description: Reference label of the downstream job
          example: "merge-all"

    # ============================================
    # WORKFLOW RESPONSE SCHEMAS
    # ============================================

    WorkflowCreateResponse:
      type: object
      required:
        - workflow_id
        - status
        - jobs
        - delivery_plan
        - processing_plan
        - warnings
        - created_at
      properties:
        workflow_id:
          $ref: '#/components/schemas/UuidV7'
        status:
          $ref: '#/components/schemas/WorkflowStatus'
        created_at:
          type: string
          format: date-time
          description: |
            ISO-8601 timestamp at which the workflow row was
            committed to the database (per ticket
            [Z1GEw5nG](https://trello.com/c/Z1GEw5nG)). The API
            always emits this field; spec catches up to behaviour
            shipping since `WorkflowController.php:147`.
        jobs:
          type: array
          items:
            $ref: '#/components/schemas/JobResponse'
        delivery_plan:
          $ref: '#/components/schemas/DeliveryPlan'
          description: |
            Server-computed delivery plan per
            [ADR-0003](../docs/decisions/0003-delivery-mode.md). Always
            present (V2 cutover invariant); server emits empty
            `outputs: []` / `hidden_outputs: []` when applicable rather
            than omitting the field.
        processing_plan:
          $ref: '#/components/schemas/ProcessingPlan'
          description: |
            Server-computed processing plan per ticket
            [I15-CONS](https://trello.com/c/YZpBKzOM) — F8 long-form
            video tier. Always present (V2 cutover invariant —
            mirrors `delivery_plan`); server emits `jobs: []` when
            the workflow has no compute jobs (e.g. archive-only)
            rather than omitting the field. Per-job entries surface
            `processing_class`, opaque `execution_pool` for display,
            and rough queue/processing time estimates so the
            frontend can preview routing decisions before the
            workflow runs. Estimation algorithm is server-side and
            deliberately opaque per plan v5 §F8.2.
        composition_plan:
          $ref: '#/components/schemas/CompositionPlan'
          description: |
            Server-computed canonical composition plan — the single
            source of truth for how the submitted operation **set** was
            canonicalized into a deterministic DAG (canonical order,
            per-op chain group/position, derived-artifact lineage, and
            image-encode capabilities). Frontend + SDK read this instead
            of hardcoding the pipeline order (which today lives only in
            the API), so click-order never changes the result.

            **OPTIONAL (optional-then-promote).** The canonicalization
            engine that emits it ships in later phases of the
            operation-composition epic; until it is live the server omits
            this field rather than emitting a partial plan. Promoted to
            required once the engine emits reliably (the same
            optional-then-promote path `delivery_plan` / `processing_plan`
            took). Consumers MUST treat it as may-be-absent.
        warnings:
          type: array
          description: |
            Advisory non-blocking warnings detected at
            workflow-create time per ticket
            [I25 `i5yCuSZc`](https://trello.com/c/i5yCuSZc) + plan
            v5 §F11. Always present (V2 cutover invariant — mirrors
            `delivery_plan` / `processing_plan`); server emits
            empty `[]` when no warnings detected. Workflow proceeds
            regardless — warnings do not block dispatch.

            SDKs MUST treat the `warning_type` enum as additive —
            ignore unknown values encountered at runtime
            (forward-compatible convention; same precedent as
            `ProgressStatus`, `ProcessingClassReason`,
            `WorkflowStatus`).
          items:
            $ref: '#/components/schemas/WorkflowWarning'
        webhook_secret:
          type:
            - string
            - "null"
          readOnly: true
          minLength: 64
          maxLength: 64
          pattern: '^[0-9a-f]{64}$'
          description: |
            HMAC-SHA256 signing key for webhook verification. Present only when
            `callback_url` was provided in the request. This is the only time the
            secret is exposed — it does not appear in status queries.

            Use this key to verify the `X-GIS-Signature` header on incoming webhook
            requests: `sha256=<hex(hmac-sha256(webhook_secret, raw_body))>`.
          example: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
        cap:
          type:
            - string
            - "null"
          readOnly: true
          description: |
            Per-workflow capability token (plaintext). Present ONLY for an
            **anonymous (null-owner)** workflow create — it is the bearer that
            authorizes reads of this workflow without a session. ABSENT for
            authenticated creates (the session authorizes those). Like
            `webhook_secret`, this is the only time it is exposed; it does not
            appear in status queries.

            Pass it as the `X-Workflow-Capability` request header on
            `GET /api/workflows/{id}/status` / `/downloads` / `/events`. A wrong
            or missing cap on a null-owner workflow returns **404**
            (`WorkflowNotFound`) — deliberately no existence oracle (not 401/403).
            Per ticket [`YQt88cq2`](https://trello.com/c/YQt88cq2).
          example: "wcap_3f8a1c9e2b7d4f60a5c1e8b2d9f6a0c3"

    WorkflowCreateSuccessEnvelope:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/WorkflowCreateResponse'

    WorkflowStatusResponse:
      type: object
      required:
        - workflow_id
        - status
        - jobs
        - created_at
        - updated_at
      properties:
        workflow_id:
          $ref: '#/components/schemas/UuidV7'
        status:
          $ref: '#/components/schemas/WorkflowStatus'
        created_at:
          type: string
          format: date-time
          description: |
            ISO-8601 timestamp at which the workflow row was
            committed (matches `WorkflowCreateResponse.created_at`
            for the same workflow). Per ticket
            [0HFbi9kh](https://trello.com/c/0HFbi9kh).
        updated_at:
          type: string
          format: date-time
          description: |
            ISO-8601 timestamp at which the workflow row was last
            updated server-side (status transition, terminal-event
            commit, paused/resumed state change, etc.). The API
            always emits this field; spec catches up to behaviour
            shipping since `WorkflowController.php:178-179`.
        jobs:
          type: array
          items:
            $ref: '#/components/schemas/JobResponse'
        paused_detail:
          $ref: '#/components/schemas/WorkflowPausedDetail'
          description: |
            Present only when `status = paused_insufficient_credits`
            (per ticket [I24](https://trello.com/c/e50uXLcl)). Carries
            `paused_at`, `expires_at`, `required_action`, and
            actionable `links` (resume / top_up / upgrade).
        composition_plan:
          $ref: '#/components/schemas/CompositionPlan'
          description: |
            Server-computed canonical composition plan, echoed on the
            status read so a frontend reload / resume can re-derive the
            canonicalized DAG (canonical order, per-op chain
            group/position, derived-artifact lineage, image-encode
            capabilities) without re-submitting. Identical shape and
            semantics to `WorkflowCreateResponse.composition_plan`.

            **OPTIONAL (optional-then-promote).** The canonicalization
            engine that emits it ships in later phases of the
            operation-composition epic; the server omits the field until
            it is live (mirroring the create side). Per ticket
            [`RrxdUBGZ`](https://trello.com/c/RrxdUBGZ) / `i2J8AMy6`.
        credits:
          description: |
            OPTIONAL. Per-workflow credit summary — the net cost of the run
            (`used`). `null` for legacy workflows with no credit record (or
            while a run has no final charge yet). See `WorkflowCreditSummary`.
            Per ticket `7XbqXO1B` (reshaped to `{ used }`-only per `DsHbIlC1`).
          oneOf:
            - $ref: '#/components/schemas/WorkflowCreditSummary'
            - type: 'null'
        codegen_source:
          $ref: '#/components/schemas/CodegenSource'
          description: |
            OPTIONAL, drill-in-only "code for this run" / replay projection — the
            allowlisted, re-submittable shape of the original request (for an SDK
            snippet / "run it again"). Absent until the API populates it. NOT a
            dump of the persisted option bag; see `CodegenSource`. Per ticket
            `LO0R5gzk`.

    WorkflowStatusSuccessEnvelope:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/WorkflowStatusResponse'

    JobMediaClass:
      type: string
      description: |
        The media class of a job (the kind of media it processes), distinct
        from an operation's step `type` (`convert` / `compress` / …). `mixed`
        is a multi-input job spanning more than one class (e.g. an archive or
        a merge of heterogeneous inputs). Used in the lightweight workflows
        list row so a UI can label/group rows by media without drilling into
        per-operation detail.
      enum:
        - image
        - video
        - audio
        - document
        - mixed

    WorkflowSummaryJob:
      type: object
      description: |
        Per-job entry in a `WorkflowSummary` row. Carries `job_type` (media
        class) + status plus a lightweight at-a-glance enrichment set —
        `input_filename`, an input→output byte summary, and the applied
        `operation_types` — so the list view renders an informative row
        (e.g. "vacation.jpg · 1.2 MB → 480 KB · compress, thumbnail")
        WITHOUT an N-row drill-in. Full per-operation detail
        (`result_metadata`, progress, per-output download URLs) remains the
        drill-in `GET /api/workflows/{id}/status` + `GET /{id}/downloads`.
        The enrichment fields are all OPTIONAL (additive): a consumer MUST
        tolerate their absence, and they are naturally absent for jobs whose
        input is an upstream `job_output` rather than a user upload.
      required:
        - job_id
        - ref
        - job_type
        - status
      properties:
        job_id:
          $ref: '#/components/schemas/UuidV7'
        ref:
          type: string
          description: Workflow-local job ref (matches `JobResponse.ref`).
        job_type:
          $ref: '#/components/schemas/JobMediaClass'
        status:
          $ref: '#/components/schemas/JobStatus'
        input_filename:
          type: string
          maxLength: 1024
          description: |
            OPTIONAL. The user's original input file name (e.g.
            `vacation.jpg`) — what the list row labels the job with, rather
            than the FE-assigned `ref`. For multi-input jobs (merge,
            watermark) this is the primary/`base` input's filename. Absent
            for jobs whose input is an upstream `job_output` (no original
            upload name) or when the server has not recorded it.
          example: "vacation.jpg"
        input_size_bytes:
          type: integer
          format: int64
          minimum: 0
          description: |
            OPTIONAL. Original input size in bytes — the left side of the
            list-row savings readout. For multi-input jobs, the combined
            size of all source inputs. Pair with `output_size_bytes` to
            render "1.2 MB → 480 KB (↓60%)" without a drill-in. Absent when
            not yet known or not applicable.
          example: 1258291
        output_size_bytes:
          type: integer
          format: int64
          minimum: 0
          description: |
            OPTIONAL. Final job output size in bytes — the right side of the
            savings readout. The aggregate deliverable size for the job (the
            authoritative per-output sizes remain on
            `GET /{id}/downloads`). Absent until the job completes / when not
            applicable.
          example: 491520
        operation_types:
          type: array
          description: |
            OPTIONAL (nice-to-have). The actual operation types applied in
            this job (e.g. `[compress, thumbnail]`), beyond the media-class
            `job_type` — lets the list row show what was DONE without a
            drill-in. Order follows the job's operation chain. Absent when
            the server does not project it.
          items:
            $ref: '#/components/schemas/OperationType'

    WorkflowSummary:
      type: object
      description: |
        Lightweight summary row for `GET /api/workflows` (list). Carries
        just enough for a list view — id / status / created_at + per-job
        `{ ref, job_type (media class), status }` — and deliberately does
        NOT inline per-operation detail, `result_metadata`, output counts,
        or download URLs (those are the drill-in `GET /{id}/status` +
        `GET /{id}/downloads` reads; keeping rows lean avoids N×M×K payload
        blow-up). Field naming mirrors `WorkflowStatusResponse`
        (`workflow_id`, snake_case).
      required:
        - workflow_id
        - status
        - created_at
        - jobs
      properties:
        workflow_id:
          $ref: '#/components/schemas/UuidV7'
        status:
          $ref: '#/components/schemas/WorkflowStatus'
        created_at:
          type: string
          format: date-time
          description: ISO-8601 workflow-creation timestamp (the list sort key, DESC).
        jobs:
          type: array
          items:
            $ref: '#/components/schemas/WorkflowSummaryJob'
        credits:
          description: |
            OPTIONAL. Per-workflow credit summary — the net cost of the run
            (`used`) so the list row can show what each run cost. `null` for
            legacy workflows with no credit record. Same shape as
            `WorkflowStatusResponse.credits`. Per ticket `7XbqXO1B` (reshaped to
            `{ used }`-only per `DsHbIlC1`).
          oneOf:
            - $ref: '#/components/schemas/WorkflowCreditSummary'
            - type: 'null'

    WorkflowCreditSummary:
      type: object
      additionalProperties: false
      description: |
        Per-workflow credit summary for the workflow reads — the net cost of a
        run. Exposes ONLY `used` (the net credits charged); the internal
        reservation mechanics (`reserved` / `refunded` / lifecycle `state`) are
        deliberately NOT surfaced — a user-facing cost readout needs only the
        net figure, and `reserved` would leak a worst-case COGS estimate. The
        schema is CLOSED (`additionalProperties: false`) so the removed
        reservation fields cannot leak back into the payload.

        The enclosing `credits` field is `null` for legacy workflows with no
        credit record, and may be `null`/absent while a run has no charge yet.
        Per ticket `7XbqXO1B` (reshaped to `{ used }` per `DsHbIlC1` — product
        decision to show net cost only).
      required:
        - used
      properties:
        used:
          type: integer
          minimum: 0
          description: |
            Net credits charged for the run (the value the old `net` field
            carried). AUTHORITATIVE (final) only once the workflow reaches a
            TERMINAL status — while the run is in flight this is a
            current-but-not-yet-final figure. Consumers MUST gate any "final
            cost" display on the workflow's terminal status: the removed `state`
            field previously carried the active-vs-final distinction, so read
            the workflow status instead (do not present `used` as the final
            charge while the run is still in flight).

    CodegenSource:
      type: object
      description: |
        Drill-in-only "code for this run" / replay projection — a DEDICATED,
        ALLOWLISTED mirror of the SAFE, user-authored parts of the original
        workflow-create request, so a consumer can regenerate it (an SDK
        snippet, a "run it again" action).

        **NOT an echo of the persisted option bag.** The create path
        canonicalizes/defaults options and stores internal fan-out +
        orchestration detail — synthetic `passthrough` source jobs,
        derived-artifact nodes, per-output expansion, server-defaulted/
        canonicalized internal option keys — none of which is a stable public
        contract. This type carries ONLY the re-submittable request shape and
        EXCLUDES all of that plus runtime results.

        **Detail-only** — `WorkflowStatusResponse` only, NEVER `WorkflowSummary`
        (payload + the list view doesn't need it). OPTIONAL: the projector ships
        incrementally; the field is absent until the API populates it.

        **Scope: the reproducible JOB graph only.** Workflow-level request
        fields (`delivery` / `export` / `processing`) are deliberately NOT part
        of this projection — a replay re-applies the caller's own delivery /
        export choices. Per ticket `LO0R5gzk`.
      required:
        - jobs
      properties:
        jobs:
          type: array
          description: |
            The reproducible, user-authored jobs in request order. Internal /
            synthetic jobs (e.g. server-injected `passthrough` source jobs) are
            excluded.
          items:
            $ref: '#/components/schemas/CodegenSourceJob'

    CodegenSourceJob:
      type: object
      description: |
        One job's re-submittable shape — the allowlisted mirror of
        `JobDefinition`. `source` (single-input) and `inputs` (role-based
        multi-input) are mutually exclusive, as in the create request.
      required:
        - operations
      properties:
        ref:
          type: string
          description: |
            The job's workflow-local ref (the authored `JobDefinition.id`, or the
            server `job_N` when unauthored) — preserved so chain `from`
            references between jobs round-trip on replay.
        deliver:
          type: boolean
          description: |
            Mirrors `JobDefinition.deliver` — whether this (possibly
            intermediate) job's output was promoted for delivery. Preserved so a
            replay delivers the same set of outputs. Omitted ⇒ default `false`.
        source:
          description: |
            Single-input source (single-input ops). Mutually exclusive with
            `inputs`. A `job_output` / `external_import` / `connection` source
            round-trips verbatim — the same three `MultiInputSource` leaves,
            inlined here as a FLAT union of concrete records rather than
            nesting the `MultiInputSource` `oneOf` as a member
            (`oneOf`-in-`oneOf` is not code-generatable by openapi-generator —
            ticket `H6wwypmO`); a user UPLOAD is represented by a
            `CodegenUploadPlaceholder` (no bytes/keys — the original upload is
            consumed/expired). Distinguished structurally: the three source
            leaves carry `type` (`job_output` / `external_import` /
            `connection`), the placeholder carries `kind: upload_placeholder`.
          oneOf:
            - $ref: '#/components/schemas/JobOutputSource'
            - $ref: '#/components/schemas/ExternalImportToken'
            - $ref: '#/components/schemas/ConnectionSource'
            - $ref: '#/components/schemas/CodegenUploadPlaceholder'
        inputs:
          type: array
          description: |
            Role-based multi-input list. Mutually exclusive with `source`. (A
            multi-input job never references a raw upload directly — an upload
            enters via a `passthrough` source job referenced here as
            `job_output`.)
          items:
            $ref: '#/components/schemas/CodegenSourceInput'
        operations:
          type: array
          items:
            $ref: '#/components/schemas/CodegenSourceOperation'

    CodegenSourceInput:
      type: object
      description: |
        One multi-input entry — the allowlisted mirror of `JobInputV2`: a
        reproducible `MultiInputSource` (`job_output` / `external_import` /
        `connection` — the canonical request leaves, which round-trip
        `from`+`operation` / external handle / `connection_id`+`path` verbatim)
        plus the input `role` and authored `per_input_options`.
      required:
        - source
      properties:
        source:
          $ref: '#/components/schemas/MultiInputSource'
        role:
          type: string
          enum: [base, overlay, transition_mask]
          description: |
            Input role for role-based multi-input ops (omitted where the op has a
            single implicit role). Mirrors the `JobInputRole` request vocabulary;
            keep in sync if that enum widens.
        per_input_options:
          type: object
          description: |
            Per-input options for this input, as authored — public keys only
            (internal/defaulted keys excluded).
          additionalProperties: true

    CodegenUploadPlaceholder:
      type: object
      description: |
        Stand-in for a user UPLOAD source in a replay projection — descriptive
        metadata so a consumer can re-prompt for an equivalent file. NOT the
        bytes or storage key (the original upload is consumed/expired), so it is
        deliberately NOT a re-submittable `UploadSource`.
      required:
        - kind
      properties:
        kind:
          type: string
          enum: [upload_placeholder]
          description: Discriminates this stand-in from a re-submittable `MultiInputSource`.
        filename:
          type: string
          maxLength: 1024
        mime_type:
          type: string
          maxLength: 100
        size_bytes:
          type: integer
          format: int64
          minimum: 0

    CodegenSourceOperation:
      type: object
      description: |
        One operation in re-submittable form: the public operation `type` + the
        user-authored public `options` (allowlisted — server-defaulted /
        canonicalized internal keys are excluded; what's present is what the
        caller would re-send). Option keys are operation-specific — see the
        per-operation schemas for the typed shapes.
      required:
        - type
      properties:
        type:
          $ref: '#/components/schemas/OperationType'
        options:
          type: object
          description: Public, user-authored options for this operation (as submitted).
          additionalProperties: true

    WorkflowListResponse:
      type: object
      description: |
        Cursor-paginated page of workflow summaries (most-recent-first).
        Walk pages by passing `next_cursor` as the next request's `cursor`
        query parameter until `is_truncated: false`. Cursor pagination
        (opaque `(created_at, id)` token) mirrors the multipart-parts
        listing convention — NOT the offset-based `/api/v2/credits/usage`.
      required:
        - workflows
        - is_truncated
      properties:
        workflows:
          type: array
          description: This page of workflow summary rows (most-recent-first).
          items:
            $ref: '#/components/schemas/WorkflowSummary'
        next_cursor:
          description: |
            Opaque cursor — pass as the next request's `cursor` query
            parameter to fetch the following page. `null` on the final page
            (or when `is_truncated: false`). Treat as opaque; do not parse.
          oneOf:
            - type: string
            - type: 'null'
        is_truncated:
          type: boolean
          description: '`true` when more pages remain; `false` on the final page.'

    WorkflowListSuccessEnvelope:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/WorkflowListResponse'

    JobResponse:
      type: object
      description: Job status within a workflow response
      required:
        - ref
        - job_id
        - status
        - depends_on
        - operations
      properties:
        ref:
          type: string
          description: Job reference label
          example: "main"
        job_id:
          $ref: '#/components/schemas/UuidV7'
        status:
          $ref: '#/components/schemas/JobStatus'
        input_filename:
          type: string
          maxLength: 1024
          description: |
            OPTIONAL. The user's original input file name (e.g.
            `vacation.jpg`) for this job — the drill-in mirror of
            `WorkflowSummaryJob.input_filename`. For multi-input jobs
            (merge, watermark) this is the primary/`base` input's filename.
            Absent for jobs whose input is an upstream `job_output`, or
            when the server has not recorded it. Left schema-OPTIONAL (not
            `required`) because `JobResponse` is also embedded in the
            `WebhookPayload.workflow` callback payload, where a required
            addition would break existing webhook consumers (same rationale
            as `processing_class`).
          example: "vacation.jpg"
        input_size_bytes:
          type: integer
          format: int64
          minimum: 0
          description: |
            OPTIONAL. Original input size in bytes for this job (combined
            source size for multi-input jobs). The drill-in mirror of
            `WorkflowSummaryJob.input_size_bytes`; the input side of the
            savings readout (output sizes remain on the per-operation
            `OperationResult.size_bytes` / `GET /{id}/downloads`). OPTIONAL
            for the same webhook-payload reason as `input_filename`.
          example: 1258291
        processing_class:
          $ref: '#/components/schemas/ProcessingClass'
          description: |
            The logical processing class the server resolved this job to.
            The status-poll echo of `WorkflowCreateResponse.processing_plan`
            `.jobs[].processing_class` (`ProcessingPlanJob`) — same enum, same
            per-job granularity — so consumers can observe the resolved
            routing decision (`short_form` vs `long_form`, plus the merge-only
            `*_concat` / `*_re_encode` aliases) after create, not just at
            create time. The server always knows a job's class once the
            workflow is resolved and populates this on every status response
            (mirrors the `ProcessingPlanJob` invariant); it is left
            schema-OPTIONAL rather than `required` only because `JobResponse`
            is also embedded in the `WebhookPayload.workflow` callback payload,
            where a required addition would be a breaking request-contract
            change for existing webhook consumers. Per ticket
            [F3dL0UKz](https://trello.com/c/F3dL0UKz); consumed by api
            [D3yN1SGm](https://trello.com/c/D3yN1SGm).
        depends_on:
          type: array
          description: List of upstream job refs this job depends on
          items:
            type: string
        operations:
          type: array
          items:
            $ref: '#/components/schemas/OperationResponse'

    OperationResponse:
      type: object
      description: Operation status within a job response
      required:
        - id
        - type
        - status
      properties:
        id:
          $ref: '#/components/schemas/UuidV7'
        type:
          $ref: '#/components/schemas/OperationType'
        status:
          $ref: '#/components/schemas/OperationStatus'
        progress:
          type: integer
          minimum: 0
          maximum: 100
          description: Progress percentage (0-100). Present when in_progress or completed.
        result:
          $ref: '#/components/schemas/OperationResult'
        result_metadata:
          $ref: '#/components/schemas/OperationResultMetadata'
          description: |
            Whitelisted operation-level metadata (the read projection — what
            `GET /api/workflows/{id}/status` emits per operation). Optional;
            present once an operation produces a whitelisted key. Distinct
            from `result` (the deliverable output): this carries small
            per-operation metadata, not the file. The `/status` read carries
            ONLY this `result_metadata` for op-level detail — the output
            `result` (`download_url`/`size_bytes`) is read via
            `GET /api/workflows/{id}/downloads` per the rmP7ndhK/ZjVXHkYK
            narrowing. Per ticket `dw048NKk` (A2) / `EurbZLMH` (B1).
        error_code:
          type: string
          description: |
            Machine-readable operation failure code. Present when `status` is
            `failed`; absent otherwise. Mirrors `SseOperationFailedData.error_code`
            (same diagnostic the SSE `operation.failed` event carries) and the
            AsyncAPI `OperationResult.error_code` / `ErrorCode` enum (the source
            of truth). The worker emits a CLOSED, curated set; consumers SHOULD
            map known values to a friendly reason and MUST degrade an unknown
            value to a generic reason (a future contract version MAY add a
            variant). Left `type: string` (not a strict enum) deliberately: this
            same field rides the `WebhookPayload.workflow` callback, where a
            strict enum would read as a request-narrowing, and tolerate-unknown
            is the intended consumer behaviour.

            Distinct from the workflow/API create-time `ErrorEnvelope.error`
            vocabulary — this is the per-operation processing failure.

            **Retry semantics:** the sibling `is_retryable` marks the transient
            codes (`out_of_memory`, `timeout`, `s3_download_failed`,
            `s3_upload_failed`); those are auto-redriven (SQS) and exhausted
            before a failure surfaces, so a reported failure is always terminal —
            `is_retryable` only tells the user whether re-submitting is worthwhile.

            **Codes** (closed set; meaning):
            `invalid_options` (options invalid for this op — most common),
            `invalid_request` (malformed OperationRequest),
            `invalid_format` (input type unsupported),
            `format_mismatch` (declared MIME ≠ content),
            `decode_failed` (input unreadable/corrupt),
            `output_too_large` (output exceeded the size limit),
            `missing_source` (source not found — fail-fast),
            `invalid_key` (storage key invalid),
            `processing_failed` (non-specific processing failure),
            `s3_access_denied` (storage access denied — fail-fast, non-retryable),
            `unknown` (unclassified),
            `out_of_memory` (retryable), `timeout` (retryable),
            `s3_download_failed` (retryable), `s3_upload_failed` (retryable).
          example: "output_too_large"
        error_message:
          type: string
          description: |
            Human-readable failure detail. Present when `status` is `failed`;
            absent otherwise. Mirrors `SseOperationFailedData.error_message`.
          example: "output_too_large: Output (12156489 bytes) is not smaller than input (6187609 bytes)"

    OperationResultMetadata:
      type: object
      additionalProperties: false
      description: |
        Whitelisted, named operation-level result metadata — a **CLOSED**
        set of declared keys. The API serves ONLY these declared keys (a
        whitelist projection), never the raw stored metadata bag, so internal
        diagnostics never leak. New keys are **additive named cuts** (the
        `additionalProperties: false` closure is the point — an unmodelled
        key is a coordinated contract change, not a silent rollout). Twin of
        the AsyncAPI `OperationResultMetadata` (wire ↔ read parity). Distinct
        from `OperationResult` (the deliverable output file): this carries
        small per-operation metadata, not the output. Per `EurbZLMH` (B1).
      properties:
        watermark_id:
          type: string
          format: uuid
          description: |
            UUIDv7 of the watermark embedded by an `audio_watermark`
            operation (for later decode/verify). **Display-only / not yet
            populated** — gated on the audio_watermark Lambda (`EvPl5fkH`,
            B3) + the watermark registry (B2, deferred). The field +
            whitelist ship now (additive) so the result_metadata pipeline +
            read projection land without a later contract bump; absent in
            practice until B3 is live.

    OperationResult:
      type: object
      description: |
        Result of a completed operation. Present only when operation status is `completed`.
      required:
        - download_url
        - size_bytes
      properties:
        download_url:
          type: string
          format: uri
          description: Pre-signed download URL for the operation output
        size_bytes:
          type: integer
          format: int64
          description: Output file size in bytes
        mime_type:
          type: string
          maxLength: 100
          description: |
            OPTIONAL. MIME type of the produced artifact (the REST read-surface
            mirror of the AsyncAPI `OperationResult.output_file_type` wire field —
            the OpenAPI/REST convention names this concept `mime_type`, as on
            `OperationDownload` / the upload-probe response). Present on completion
            only.

            **Purpose — the auto-format savings readout.** For most traffic the
            client already knows the output format (compress is same-format; the
            compress+format facade canonicalizes to a known `convert` target). This
            field exists for the runtime-format-selection path — `compress`
            `output_format: auto`/`smallest` (currently `planned`), where the server
            picks the output format and the client cannot predict it. Combined with
            `size_bytes` (output size) + `metrics.compression_ratio` (savings), it
            lets the client render "AVIF saved 43%" from a single completion payload
            without cross-referencing the downloads endpoint. Landed additively now
            (cf. `output_file_type` / `watermark_id`) so the auto-format path needs
            no later contract bump; the API populates it when that path ships.
          example: "image/webp"
        export_key:
          type: string
          description: Key in the customer's export destination (if export configured)
        metrics:
          type: object
          description: |
            Operation-specific performance metrics, carried on the
            **completion result** surface (SSE `operation.completed`
            result / result-on-poll), NOT on the workflow **status**
            read (confirmed ZjVXHkYK / api). `GET /api/workflows/{id}`
            serialises each operation as `{id, type, status}` plus a
            conditional `error_message`/`error_code` — it does NOT carry
            `result.metrics`, so `re_encode_decision` / `re_encode_reason`
            are not readable from the status poll. Canonical reads for
            those: the per-job `processing_class` echo (on the status
            response) for the route, and `GET /api/workflows/{id}/downloads`
            (per-output `size_bytes`) for the authoritative output size.
            Note `metrics` has no `output_size_bytes` field — output size
            lives on `size_bytes` / the downloads endpoint.
          properties:
            compression_ratio:
              type: number
              format: double
              description: Ratio of output size to input size (e.g. 0.45 = 55% reduction)
            chosen_quality:
              type: integer
              minimum: 1
              maximum: 100
              description: |
                The encoder quality the search settled on — for a `target_size`
                encode OR an `auto_quality` encode (the quality that met the
                `quality_preset` perceptual target). Present for target_size and
                auto_quality operations. Mirrors the AsyncAPI
                `OperationMetrics.chosen_quality`; lets the client show "hit your
                target at quality 66".
            target_size_met:
              type: boolean
              description: |
                Whether the `target_size` encode landed at or under the requested
                `target_size_bytes`. `false` is an honest best-effort outcome (target
                unreachable at min quality), not a failure. Present only for a
                target_size operation. Mirrors `OperationMetrics.target_size_met`.
            measured_quality:
              type: number
              format: double
              minimum: 0
              maximum: 1
              description: |
                The achieved perceptual-quality score (0.0–1.0) for an `auto_quality`
                encode (compress.image jpeg/webp/avif). Present only for an
                auto_quality operation; pairs with `chosen_quality`. Metric-agnostic —
                the producing metric is named in `quality_metric`. Mirrors
                `OperationMetrics.measured_quality`.
            quality_metric:
              type: string
              description: |
                The perceptual metric that produced `measured_quality` — a free-form
                string (not an enum) so it can evolve without contract churn. Present
                only for an auto_quality operation. Current value: `ssim`. Mirrors
                `OperationMetrics.quality_metric`.
            duration_ms:
              type: integer
              description: Processing time in milliseconds
            re_encode_decision:
              # Mirrors AsyncAPI OperationMetrics.re_encode_decision so the
              # REST result surface (SDK customer path) carries the same
              # merge.video stream-copy-vs-re-encode signal as the SSE/message
              # channel. Per ticket zS4e9CI2 (unblocks e2e aDFI1FgT).
              $ref: '#/components/schemas/ReEncodeDecision'
            re_encode_reason:
              type: string
              description: |
                Advisory explanation for `re_encode_decision` (e.g.
                `all_inputs_compatible`, `explicit_always_mode`,
                `input_codec_mismatch`, `input_framerate_mismatch`).
                Free-form string — not an enum — so the Lambda can emit
                human-readable diagnostics that evolve without contract
                changes. Mirrors `OperationMetrics.re_encode_reason`.

    # ============================================
    # WORKFLOW DOWNLOAD SCHEMAS
    # ============================================

    WorkflowDownloadResponse:
      type: object
      required:
        - downloads
      properties:
        downloads:
          type: array
          items:
            $ref: '#/components/schemas/JobDownload'
        bundle:
          $ref: '#/components/schemas/DownloadBundle'

    DownloadBundle:
      type: object
      additionalProperties: false
      description: |
        A single archive (zip) of **every** deliverable output in the workflow
        — the "download all" affordance (ticket [`6TRw40EM`](https://trello.com/c/6TRw40EM)).

        **Presence = readiness.** The field is present (with a `download_url`)
        only when a workflow-level bundle has been produced and is ready. It is
        **absent** when no bundle was requested, or one was requested but is not
        yet finalized — there is no "pending" placeholder. Poll `/downloads`
        (or watch the SSE stream) until `bundle` appears.

        Tied to the `delivery.mode: bundle` / `both` selection (the canonical
        output-bundler, [ADR-0017](../docs/decisions/0017-archive-as-canonical-bundler.md)),
        which is currently `planned`; the field therefore only populates once
        that ships. The bundle artifact is produced by the `archive` operation
        over the workflow's job outputs (modeled via `workflow_edges`) — no new
        request surface is introduced here. A bespoke archive-from-output-keys
        invocation would only be needed if the bundle is produced out-of-band
        (not as a modeled `archive` op); that is a Lambdas/API call, deferred
        until confirmed needed.
      required:
        - download_url
      properties:
        download_url:
          type: string
          format: uri
          description: Pre-signed URL for the zip-all archive of all workflow outputs.
        size_bytes:
          type: integer
          format: int64
          description: |
            Archive size in bytes. Optional — may be absent when the bundle
            size is not computed at response time (e.g. streamed archive).
        expires_at:
          type: string
          format: date-time
          description: |
            Expiry of the pre-signed `download_url` (RFC 3339 / ISO-8601).
            Optional — absent when the URL does not carry a fixed expiry.

    WorkflowDownloadSuccessEnvelope:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/WorkflowDownloadResponse'

    JobDownload:
      type: object
      required:
        - ref
        - job_id
        - files
      properties:
        ref:
          type: string
          description: Job reference label
        job_id:
          $ref: '#/components/schemas/UuidV7'
        files:
          type: array
          items:
            $ref: '#/components/schemas/OperationDownload'

    OperationDownload:
      type: object
      description: |
        A single deliverable output file for an operation. For multi-output
        fan-out (e.g. convert PDF->image emits one file per page), each entry
        carries an indexing field. This is the REST projection of the same
        indexing model the AsyncAPI `OperationResultOutputEntry` defines per
        ADR-0009 §D2 — `page_index` for PDF-page outputs, `position` for
        generic ordinals, mutually exclusive within an entry.
      required:
        - operation
        - operation_id
        - filename
        - size_bytes
        - download_url
      oneOf:
        - title: PageIndexed
          description: PDF-page output (convert PDF->image).
          required: [page_index]
          not: { required: [position] }
        - title: PositionIndexed
          description: Generic ordinal output (frame strip, chapter split).
          required: [position]
          not: { required: [page_index] }
        - title: Unindexed
          description: |
            Output without a page/position indexing field — every legacy
            single-output download AND `render_variants` variant outputs
            (which carry the optional `target_id` correlation field but are
            not page/position indexed).
          not:
            anyOf:
              - required: [page_index]
              - required: [position]
      properties:
        operation:
          type: string
          description: Operation type that produced this file
        operation_id:
          $ref: '#/components/schemas/UuidV7'
        filename:
          type: string
          description: Output filename
          example: "photo.jpg"
        size_bytes:
          type: integer
          format: int64
          description: Output file size in bytes
        chosen_quality:
          type: integer
          minimum: 1
          maximum: 100
          description: |
            The encoder quality the search settled on — for a `target_size` encode
            OR an `auto_quality` encode (the quality that met the `quality_preset`
            perceptual target). Present only for a target_size or auto_quality
            output; absent otherwise. Optional/additive (rides the shared property
            bag — not in any oneOf `required`/`not`). The per-output `/downloads`
            read is the canonical output-properties surface per the ZjVXHkYK
            narrowing (alongside `size_bytes`); mirrors the worker source
            `OperationMetrics.chosen_quality`. Pairs with `target_size_met`
            (target_size) or `measured_quality` (auto_quality).
        target_size_met:
          type: boolean
          description: |
            For a `target_size` encode: whether the output landed at or under the
            requested `target_size_bytes`. `false` is an honest best-effort outcome
            (target unreachable at min quality), NOT a failure. Present only for a
            target_size output. Mirrors `OperationMetrics.target_size_met`.
        measured_quality:
          type: number
          format: double
          minimum: 0
          maximum: 1
          description: |
            For an `auto_quality` encode (`encoding_mode: auto_quality`): the achieved
            perceptual-quality score (0.0–1.0) that met the `quality_preset` target.
            Present only for an auto_quality output; pairs with `chosen_quality`.
            Metric-agnostic — the producing metric is named in `quality_metric`.
            Mirrors `OperationMetrics.measured_quality`.
        quality_metric:
          type: string
          description: |
            For an `auto_quality` encode: the perceptual metric that produced
            `measured_quality` — a free-form string (not an enum) so it can evolve
            without contract churn. Present only for an auto_quality output. Current
            value: `ssim`. Mirrors `OperationMetrics.quality_metric`.
        download_url:
          type: string
          format: uri
          description: Pre-signed download URL
        page_index:
          type: integer
          minimum: 1
          description: |
            1-based page number for PDF-page fan-out outputs (convert
            PDF->image). Gapless within an operation (an N-page conversion
            emits `page_index` 1..N). Mutually exclusive with `position`.
            Absent on non-indexed (single-output) downloads. Mirrors
            `OperationResultOutputEntry.page_index`. Per ADR-0009 §D2.
          example: 1
        position:
          type: integer
          minimum: 0
          description: |
            0-based ordinal for non-PDF multi-output operations (e.g. frame
            strip, chapter split). Mutually exclusive with `page_index`.
            Absent on non-indexed downloads. Forward-looking — not emitted by
            any current operation; declared for parity with
            `OperationResultOutputEntry.position`. Per ADR-0009 §D2.
          example: 0
        target_id:
          type: string
          pattern: '^[A-Za-z0-9._-]+$'
          maxLength: 64
          description: |
            The caller-assigned `id` of the `render_variants` target that
            produced this output, echoed verbatim (the image-fan-out
            addressing contract). Carried on the `Unindexed` branch (variant
            outputs are not page/position indexed); no operation emits
            `target_id` together with `page_index` / `position`. Mirrors
            `OperationResultOutputEntry.target_id`. Per ticket `w3EwzHYd`.
          example: "thumb-2x"
        node_id:
          type: string
          description: |
            Symbolic composition `node_id` correlating this download to its
            canonical node in `WorkflowCreateResponse.composition_plan` (e.g.
            `encode`, `thumbnail`, `processed_base`). Lets consumers label and
            group delivered files by composition role (e.g. sdks `byNode()`,
            FE "Main image" / "Thumbnail" labels). **Optional** — emitted once
            the canonicalization engine is live (optional-then-promote,
            mirroring `composition_plan` and `DeliveryPlanOutput.node_id`);
            absent until then. Additive carrier only; the normative
            `/downloads == delivery_plan.outputs[]` rendezvous invariant
            lands with the delivery-selection promotion, not here.
          example: thumbnail

    # ============================================
    # SSE EVENT SCHEMAS
    # ============================================

    SseEventType:
      type: string
      description: |
        Server-Sent Event types pushed on the /events endpoint:
        - operation.progress: Progress update for an operation
        - operation.completed: Operation finished successfully
        - operation.failed: Operation encountered an error
        - job.completed: All operations in a job completed
        - job.failed: Job failed
        - workflow.completed: All jobs completed successfully
        - workflow.failed: All jobs finished, at least one failed
        - workflow.partially_failed: Some succeeded, some failed
      enum:
        - operation.progress
        - operation.completed
        - operation.failed
        - job.completed
        - job.failed
        - workflow.completed
        - workflow.failed
        - workflow.partially_failed

    SseOperationProgressData:
      type: object
      description: |
        Payload for `operation.progress` events. Mirrors the
        AsyncAPI `OperationProgress` payload shape; the SSE stream
        is the frontend-facing transport for the same Lambda-emitted
        progress data, forwarded through the API.
      required:
        - job_ref
        - operation_id
        - type
        - progress
      properties:
        job_ref:
          type: string
        operation_id:
          $ref: '#/components/schemas/UuidV7'
        type:
          $ref: '#/components/schemas/OperationType'
        status:
          type: string
          description: |
            Mirrors `OperationProgress.status` in AsyncAPI. Optional
            on the SSE wire — when omitted, frontends should default
            their UI to the generic "processing" state. When set,
            values widen the V1 set with long-form-specific phases
            per ticket
            [I27 `1R3K3bsG`](https://trello.com/c/1R3K3bsG).
          enum:
            - started
            - downloading
            - probing
            - decoding
            - processing
            - encoding
            - uploading
        progress:
          type: integer
          minimum: 0
          maximum: 100
        stage:
          type: string
          description: Optional human-readable description of current processing stage.
          example: "Compressing image"
        phase_input_index:
          type: integer
          minimum: 1
          description: |
            1-based index of the input currently being processed in
            this phase. Emitted only when `status` is `probing` /
            `decoding` (and optionally `encoding` for multi-output
            ops). Per ticket I27 — gives long-form-job callers
            "probing input 2/4" visibility without decomposing into
            multiple jobs.
        phase_total_inputs:
          type: integer
          minimum: 1
          description: |
            Total number of inputs expected in this phase. Pairs with
            `phase_input_index`. For single-input operations this is
            `1` during the relevant phase; for multi-input merge /
            archive / image_watermark / custom_luma / audio_overlay /
            audio_to_video / video_watermark,
            it matches the inputs[] count. Per ticket I27.

    SseOperationCompletedData:
      type: object
      description: |
        Payload for `operation.completed` events.

        **`result` is intentionally OPTIONAL** (not in `required`) — the
        API does not always emit it on `operation.completed` (confirmed
        rmP7ndhK / api `Tu78AGpe`):
        - **Multi-output completions** (e.g. `split` → N outputs): the
          flat single-output `result` shape can't carry N outputs, so
          `result` is omitted by design and the outputs are read from
          the downloads endpoint (`GET /api/workflows/{id}/downloads`).
        - **Stale-load race**: a transient frame published before the
          operation resolves against the snapshot may omit `result`;
          it is repaired by a refetch.
        Consumers MUST treat `result` as may-be-absent on
        `operation.completed` and fall back to the downloads endpoint.
      required:
        - job_ref
        - operation_id
        - type
        - status
        - progress
      properties:
        job_ref:
          type: string
        operation_id:
          $ref: '#/components/schemas/UuidV7'
        type:
          $ref: '#/components/schemas/OperationType'
        status:
          type: string
          const: "completed"
        progress:
          type: integer
          const: 100
        result:
          $ref: '#/components/schemas/SseOperationCompletionResult'

    SseOperationCompletionResult:
      description: |
        Result payload carried on the SSE `operation.completed` event
        (`SseOperationCompletedData.result`). A 2-branch `oneOf` mirroring the
        single-output vs multi-output completion split of the AsyncAPI
        `OperationResult` union (per [ADR-0009](../docs/decisions/0009-multi-output-result-envelope.md)),
        projected into the client-facing (download-URL) shape rather than the
        S3-wire shape.

        - **Single-output**: `SseSingleOutputCompletion` (allOf wrap of
          `OperationResult` + a `result_kind: "single"` discriminator field).
          `download_url` + `size_bytes`, optional `export_key` / `metrics`. Every
          operation that produces one canonical output.
        - **Multi-output**: `SseMultiOutputCompletionWithKind` (allOf wrap of
          `SseMultiOutputCompletion` + a `result_kind: "multi"` discriminator
          field). `outputs[]` + `total_output_size_bytes`. Used by convert
          PDF->image and future fan-out operations.

        Failure is **not** a branch here — it is carried by the separate
        `operation.failed` event (`SseOperationFailedData`), unlike the AsyncAPI
        `OperationResult` which folds failure into the same union.

        **Branch dispatch via `result_kind` discriminator.** The branches are
        disambiguated by an explicit `result_kind` field (`"single"` |
        `"multi"`) that the API SSE emitter populates. The wrapper allOf shape
        keeps the underlying `OperationResult` and `SseMultiOutputCompletion`
        schemas unchanged (they continue to be used elsewhere — REST polling,
        AsyncAPI Lambda→API — without the discriminator). The discriminator
        approach replaces the v2.15.1 bare-`$ref` design, which dispatched
        correctly at validation but generated TS deserialization code that
        checked camelCase property names against snake_case wire payloads —
        silent data-loss on every single-output completion. The
        discriminator-based dispatch reads the snake_case wire key directly
        (`switch (json['result_kind'])`), avoiding the case-mismatch bug
        entirely.

        **v2.16.1 attempted PHP-template fix (superseded by v2.16.5).**
        The v2.16.1 cut introduced the shared `SseCompletionBase`
        schema declaring `result_kind: { enum: [single, multi] }` (both
        literal values together) and had the branch wrappers
        allOf-merge it. The intent was to fix the v2.15.3 design where
        `result_kind` was declared only as `enum: [single]` /
        `enum: [multi]` inside each branch's inline `allOf` block —
        which made the openapi-generator PHP template flatten only one
        branch's enum into `getResultKindAllowableValues()`. The
        v2.16.1 verification at the time confirmed PHP emitted both
        values, but against the v2.15.3-generated tree, not against a
        fresh v2.16.1 regen. SDKs later caught (via codex on the
        v2.16.3 regen, surfaced by ChSmslxj) that v2.16.1's shared-base
        fix did NOT actually propagate through the PHP template — the
        template visits each oneOf branch's allOf and only flattens the
        per-branch single-value enum, NOT the shared base's full-union
        enum. PHP root model still emitted `RESULT_KIND_MULTI` only
        (the second branch's value). SseCompletionBase is RETAINED in
        v2.16.5 for documentation + JSON Schema branch identification
        + consumers that traverse the allOf chain, but on its own it
        is NOT load-bearing for PHP correctness.

        **v2.16.5 ChSmslxj PHP-template fix (load-bearing).**
        Declares `result_kind` with the full `enum: [single, multi]`
        directly on the ROOT `SseOperationCompletionResult` schema as
        a sibling of `oneOf` and `discriminator` (with `type: object`
        + `required: [result_kind]`). The openapi-generator PHP
        template flattens this root enum cleanly into
        `getResultKindAllowableValues()`, emitting both
        `RESULT_KIND_SINGLE` and `RESULT_KIND_MULTI`. The triple
        declaration (root + SseCompletionBase + per-branch wrappers)
        is harmless redundancy: the root carries the full union for
        the PHP template; SseCompletionBase carries the full union
        for allOf-chain consumers; per-branch wrappers pin to a
        single literal value for OpenAPI discriminator semantics +
        JSON Schema branch identification. TypeScript dispatch is
        unchanged (`switch (json['result_kind'])` reads the
        snake_case wire key directly — verified on openapi-generator-
        cli v7.21.0).
      type: object
      required:
        - result_kind
      properties:
        result_kind:
          type: string
          enum: [single, multi]
          description: |
            Discriminator field. The full `[single, multi]` enum is
            declared directly on the root (this object) so the
            openapi-generator PHP template flattens both literal
            values into `getResultKindAllowableValues()` per
            ChSmslxj. Also declared on the shared `SseCompletionBase`
            (retained for consumers that traverse the allOf chain)
            and on each per-branch wrapper (single-value enum pinning
            the branch). The triple declaration is harmless redundancy
            necessary for PHP template flattening.
      oneOf:
        - $ref: '#/components/schemas/SseSingleOutputCompletion'
        - $ref: '#/components/schemas/SseMultiOutputCompletionWithKind'
      discriminator:
        propertyName: result_kind
        mapping:
          single: '#/components/schemas/SseSingleOutputCompletion'
          multi: '#/components/schemas/SseMultiOutputCompletionWithKind'

    SseCompletionBase:
      type: object
      description: |
        Shared base schema for the two `SseOperationCompletionResult`
        branches. Declares the `result_kind` discriminator field as a
        `[single, multi]` enum with both literal values, then each branch
        wrapper allOf-merges this base + its body schema.

        **Rationale (v2.16.1):** the v2.15.3 design declared `result_kind`
        as a single-value `enum [single]` / `enum [multi]` inside each
        branch's inline `allOf` block. The openapi-generator PHP template
        for this shape flattens only the FIRST branch's discriminator enum
        into the root `getResultKindAllowableValues()` list, so PHP
        deserialisation rejects the other branch's wire value as invalid.
        Promoting `result_kind` to a shared base with the full union
        `enum [single, multi]` makes PHP accept both values; the
        per-branch `enum [single]` / `enum [multi]` (kept on the wrappers
        for JSON Schema-level branch identification + OpenAPI
        discriminator semantics) does not regress. TypeScript dispatch
        continues to use `switch (json['result_kind'])` — confirmed by
        codegen verification on openapi-generator-cli v7.21.0.
      required:
        - result_kind
      properties:
        result_kind:
          type: string
          enum: [single, multi]
          description: |
            Discriminator field on `SseOperationCompletionResult`. The
            API SSE serializer emits `"single"` for single-output
            completion payloads and `"multi"` for multi-output payloads;
            generated SDK dispatch routes on this value to pick the
            correct branch deserializer.

    SseSingleOutputCompletion:
      description: |
        Single-output branch of `SseOperationCompletionResult` — wraps
        `OperationResult` + the shared `SseCompletionBase` + pins
        `result_kind` to the literal `"single"`. See
        `SseOperationCompletionResult` description for the rationale +
        codegen-quirk context.
      allOf:
        - $ref: '#/components/schemas/OperationResult'
        - $ref: '#/components/schemas/SseCompletionBase'
        - type: object
          properties:
            result_kind:
              type: string
              enum: [single]

    SseMultiOutputCompletionWithKind:
      description: |
        Multi-output branch of `SseOperationCompletionResult` — wraps
        `SseMultiOutputCompletion` + the shared `SseCompletionBase` +
        pins `result_kind` to the literal `"multi"`. See
        `SseOperationCompletionResult` description for the rationale +
        codegen-quirk context.
      allOf:
        - $ref: '#/components/schemas/SseMultiOutputCompletion'
        - $ref: '#/components/schemas/SseCompletionBase'
        - type: object
          properties:
            result_kind:
              type: string
              enum: [multi]

    SseMultiOutputCompletion:
      type: object
      description: |
        Multi-output completion body for the SSE `operation.completed` event.
        Per-output details plus an aggregate size, mirroring the AsyncAPI
        `MultiOutputCompletion` branch of `OperationResult` (per
        [ADR-0009](../docs/decisions/0009-multi-output-result-envelope.md)) in
        the client-facing (download-URL) projection.
      required:
        - outputs
        - total_output_size_bytes
      properties:
        outputs:
          type: array
          minItems: 1
          maxItems: 200
          description: |
            Per-output deliverables for a multi-output operation (e.g. convert
            PDF->image emits one entry per page). Consumers use `outputs.length`
            for the count — per ADR-0009 §D4 a denormalised `output_count` was
            deliberately rejected. `maxItems: 200` mirrors the AsyncAPI
            `OperationResult.outputs` transport bound.
          items:
            $ref: '#/components/schemas/SseMultiOutputResultEntry'
        total_output_size_bytes:
          type: integer
          format: int64
          minimum: 0
          description: |
            Aggregate size of all `outputs[]` in bytes. Equals
            `sum(outputs[].size_bytes)`. Per ADR-0009 §D4 this is denormalised
            against the per-entry sum; consumers SHOULD trust the per-entry sum
            if the two disagree (and log a warning).
          example: 786432
        metrics:
          type: object
          description: Operation-specific performance metrics (aggregate)
          properties:
            compression_ratio:
              type: number
              format: double
              description: Ratio of output size to input size (e.g. 0.45 = 55% reduction)
            duration_ms:
              type: integer
              description: Processing time in milliseconds

    SseMultiOutputResultEntry:
      type: object
      description: |
        A single deliverable output file in an SSE multi-output
        `operation.completed` result (`SseMultiOutputCompletion.outputs[]`).
        This is the SSE-local, client-facing twin of the AsyncAPI
        `OperationResultOutputEntry` (which is S3-wire: `output_key`) and is
        deliberately **decoupled** from the REST `OperationDownload` schema — it
        carries `download_url` + `size_bytes` and the same `page_index` /
        `position` indexing model defined per
        [ADR-0009](../docs/decisions/0009-multi-output-result-envelope.md) §D2
        (`page_index` for PDF-page outputs, `position` for generic ordinals,
        mutually exclusive within an entry).
      required:
        - download_url
        - size_bytes
      oneOf:
        - title: PageIndexed
          description: PDF-page output (convert PDF->image).
          required: [page_index]
          not: { required: [position] }
        - title: PositionIndexed
          description: Generic ordinal output (frame strip, chapter split).
          required: [position]
          not: { required: [page_index] }
        - title: Unindexed
          description: |
            Output without an explicit indexing field. Reserved for future
            operations that index by something other than page/position.
            Schema-valid but not currently emitted by any operation.
          not:
            anyOf:
              - required: [page_index]
              - required: [position]
      properties:
        download_url:
          type: string
          format: uri
          description: Pre-signed download URL for this individual output file.
        size_bytes:
          type: integer
          format: int64
          minimum: 0
          description: |
            Size of this individual output file in bytes. `minimum: 0` mirrors
            the AsyncAPI `OperationResultOutputEntry.output_size_bytes` bound and
            keeps `total_output_size_bytes` (the sum of these) internally
            consistent.
        page_index:
          type: integer
          minimum: 1
          description: |
            1-based page number for PDF-page fan-out outputs (convert
            PDF->image). Gapless within an operation (an N-page conversion
            emits `page_index` 1..N). Mutually exclusive with `position`.
            Absent on non-indexed outputs. Mirrors
            `OperationResultOutputEntry.page_index`. Per ADR-0009 §D2.
          example: 1
        position:
          type: integer
          minimum: 0
          description: |
            0-based ordinal for non-PDF multi-output operations (e.g. frame
            strip, chapter split). Mutually exclusive with `page_index`.
            Absent on non-indexed outputs. Forward-looking — not emitted by any
            current operation; declared for parity with
            `OperationResultOutputEntry.position`. Per ADR-0009 §D2.
          example: 0

    SseOperationFailedData:
      type: object
      description: Payload for `operation.failed` events
      required:
        - job_ref
        - operation_id
        - type
        - status
        - error_code
        - error_message
      properties:
        job_ref:
          type: string
        operation_id:
          $ref: '#/components/schemas/UuidV7'
        type:
          $ref: '#/components/schemas/OperationType'
        status:
          type: string
          const: "failed"
        error_code:
          type: string
          description: |
            Machine-readable operation failure code (failed only). Same closed
            vocabulary as `OperationResponse.error_code` / the AsyncAPI
            `ErrorCode` enum (the source of truth) — see
            `OperationResponse.error_code` for the full code set + meanings +
            retry semantics. Consumers MUST degrade an unknown value to a
            generic reason.
        error_message:
          type: string

    SseJobCompletedData:
      type: object
      description: Payload for `job.completed` events
      required:
        - job_ref
        - job_id
        - status
      properties:
        job_ref:
          type: string
        job_id:
          $ref: '#/components/schemas/UuidV7'
        status:
          type: string
          const: "completed"

    SseJobFailedData:
      type: object
      description: Payload for `job.failed` events
      required:
        - job_ref
        - job_id
        - status
      properties:
        job_ref:
          type: string
        job_id:
          $ref: '#/components/schemas/UuidV7'
        status:
          type: string
          const: "failed"

    SseWorkflowTerminalData:
      type: object
      description: |
        Payload for workflow terminal events
        (workflow.completed, workflow.failed, workflow.partially_failed).
        Optional `reason` field added per ticket
        [I27 `1R3K3bsG`](https://trello.com/c/1R3K3bsG) + plan v5
        round 7 — emitted when the terminal state has a structured
        cause (timeout, tier-quota exhaustion, etc.). Free-form
        string by design — the runtime emits human-readable
        diagnostics that evolve without contract churn (mirrors the
        opacity precedent for `OperationMetrics.re_encode_reason`
        from I16-CONS).
      required:
        - workflow_id
        - status
      properties:
        workflow_id:
          $ref: '#/components/schemas/UuidV7'
        status:
          type: string
          enum:
            - completed
            - failed
            - partially_failed
        reason:
          type: string
          description: |
            Optional advisory reason for the terminal state. Free-form
            string; not an enum. Examples: "all jobs completed
            successfully", "operation exceeded queue timeout",
            "caller tier quota exhausted mid-workflow",
            "feature_not_available rejection on workflow-create
            superseded an earlier dispatch attempt". Per ticket I27
            + plan v5 round 7. Always advisory — the binding terminal
            state is `status`.
          example: "all jobs completed successfully"

    # ============================================
    # OPERATIONS SCHEMA ENDPOINT RESPONSE
    # ============================================

    OperationsSchemaResponse:
      type: object
      description: |
        Operations meta-schema. Describes all available operation types,
        their options, constraints, defaults, MIME type applicability, and
        **availability metadata** (operation/mime_group/option-level via
        `availability:` per ADR-0001 §1.3 + ticket I1; per-enum-value via
        `per_value_availability:` per ADR-0001 §1.4 + ticket I17).

        Each operation defines options with types, constraints, and
        conditional dependencies (via `depends_on`). Clients use this to
        build dynamic forms and validate options before submission.

        **Tier-scoped per caller.** The response varies by the caller's
        subscription tier (`free` / `pro` / `enterprise`) — features gated
        by `required_tier` are rendered as `availability: planned` (or the
        appropriate tier-restriction state) for callers below the gating
        tier. Anonymous (unauthenticated) callers receive the `free` tier
        baseline (`user_tier: null` on the wire).

        **Caching.** Per-tier private caching with ETag-based revalidation
        (per ADR-0002). Public CDN caching is NOT used because the cache
        key includes the caller's `user_tier`. Clients send
        `If-None-Match` to revalidate; the server returns `304 Not
        Modified` when fresh. The content `ETag` is the sole conditional
        validator — `If-Modified-Since` is not honored and `Last-Modified`
        is informational only (per `sUyA9ZXD`).

        Cache-key composition: `user_tier + schema_version + capabilities_version + environment`.
      required:
        - schema_version
        - capabilities_version
        - generated_at
        - operations
      properties:
        schema_version:
          type: string
          description: |
            Schema-format version (semver). Bumps on YAML schema-format
            changes (e.g. new `OptionSchema` fields, structural changes).
            Used as part of the cache key. Pinned to the OpenAPI
            `info.version` value to keep runtime + static sidecar
            (`availability.json`, ticket I3b) consistent.
          example: "2.0.0"
        capabilities_version:
          type: integer
          description: |
            Monotonically-increasing capability matrix version. Bumps
            independently of `schema_version` whenever the underlying
            availability matrix changes (Lambda capability flips, tier
            policy updates, V2-planned op promotions). Used as part of the
            cache key. Per ADR-0002.
          example: 47
        generated_at:
          description: |
            ISO-8601 timestamp when this response was generated. The
            runtime endpoint emits the wall-clock timestamp; the
            committed sidecar (`availability.json`, ticket I3b) emits
            `null` and the value is substituted at deploy/serve time.
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          example: "2026-04-26T09:00:00Z"
        source_commit:
          description: |
            Git commit SHA of the contracts repo at deploy time. Optional;
            null in the committed sidecar snapshot, populated at
            deploy-time substitution. Used for traceability when
            debugging which contracts revision served a given response.
          oneOf:
            - type: string
              pattern: '^[0-9a-f]{7,40}$'
            - type: 'null'
          example: "c4d80fb"
        environment:
          description: |
            Deployment environment identifier (`baseline` for the
            committed sidecar; `staging` / `production` etc. when
            substituted at deploy time per per-environment overlays —
            future ticket).
          oneOf:
            - type: string
            - type: 'null'
          example: "production"
        user_tier:
          description: |
            Tier of the calling user. Anonymous (unauthenticated) callers
            see `null` on the wire and receive the `free` tier baseline
            view. Used by clients to render upgrade prompts on tier-gated
            features.
          oneOf:
            - $ref: '#/components/schemas/UserTier'
            - type: 'null'
        operations:
          type: object
          description: |
            Map of operation type to its schema definition (with
            availability metadata per I1 / I17).
          additionalProperties:
            $ref: '#/components/schemas/OperationSchemaDefinition'
        endpoints:
          type: object
          description: |
            Flat per-endpoint auth/identity projection per
            [ADR-0016](../docs/decisions/0016-per-endpoint-auth-identity-modeling.md)
            §D4. Keys are `<METHOD> <PATH>` literals (e.g.
            `"POST /api/workflows"`); values declare the auth axis +
            identity-scoping + reserved tier/availability slots +
            operation_id for SDK ergonomic-layer consumption.

            Webhook operations are EXCLUDED per ADR-0016 §D5 — outbound
            HMAC-signed callbacks use a separate auth model.

            **Optional in the wire envelope** to keep the runtime
            endpoint's mirroring obligation incremental: the
            committed sidecar (yN309QVb-B3 / v2.17.0+) emits this
            block authoritatively; the runtime `GET /api/operations/
            schema` MAY mirror it once the API team's runtime
            generator catches up. Consumers MUST tolerate absent
            `endpoints` from the runtime endpoint (read the sidecar
            instead) and MUST tolerate present `endpoints` from
            either source.
          additionalProperties:
            $ref: '#/components/schemas/EndpointProjection'
        workflow_features:
          type: object
          description: |
            Workflow-level option availability projection (ticket
            [`1ZQjSm0j`](https://trello.com/c/1ZQjSm0j)). Surfaces
            `per_value_availability` for workflow-level options that live in
            OpenAPI **component** schemas (not the per-operation
            `schemas/operations/*.yaml` that drive the `operations` map),
            keyed by REQUEST-shape path:
            - `delivery.mode.per_value_availability` (from the `Delivery.mode`
              schema)
            - `delivery.selection.type.per_value_availability` (from the
              `DeliverySelection.type` schema)

            Closes the co0CERtJ (v2.19.0) authority-hierarchy gap: those
            `planned` downgrades lived only in the (decorative per ADR-0001
            §1.5) openapi component tags; this surfaces them in the
            authoritative sidecar.

            **Optional in the wire envelope** (same incremental-mirroring
            stance as `endpoints`): the committed sidecar emits this block
            authoritatively; the runtime `GET /api/operations/schema` MAY
            mirror it once the API generator catches up. Consumers MUST
            tolerate its absence from the runtime endpoint (read the sidecar)
            and its presence from either source.
          properties:
            delivery:
              type: object
              properties:
                mode:
                  type: object
                  properties:
                    per_value_availability:
                      $ref: '#/components/schemas/PerValueAvailability'
                selection:
                  type: object
                  properties:
                    type:
                      type: object
                      properties:
                        per_value_availability:
                          $ref: '#/components/schemas/PerValueAvailability'
        image_encode_capabilities:
          description: |
            Pre-flight image-encode capability matrix
            (`webp_quality_supported`, `background_flatten`, …) — **IDENTICAL
            shape to `composition_plan.capabilities`** on the create-workflow
            201, surfaced here so SDK/FE can fail-fast guard
            format/quality/alpha-flatten choices BEFORE submitting a workflow.
            Reuses the same API-side producer so the two can never disagree.

            **Tier-invariant.** This is a fixed codec/hardware capability fact
            (what the image encoders CAN do), NOT an entitlement — identical
            for every tier including anonymous. Deliberately carries no
            `availability` tag, no `per_value_availability`, and does NOT vary
            by `user_tier` (even though the enclosing response is tier-scoped).

            **Optional in the wire envelope** (same incremental-mirroring
            stance as `endpoints` / `workflow_features`): the committed sidecar
            MAY emit it; the runtime `GET /api/operations/schema` emits it once
            the API generator catches up. Consumers MUST tolerate its absence
            and its presence from either source. Per ticket
            [`kybXQe2S`](https://trello.com/c/kybXQe2S).
          $ref: '#/components/schemas/ImageEncodeCapabilities'
        capabilities:
          type: object
          description: |
            Tier-scoped operation-capability matrix (the
            [operation-capability constraint model](../operation-capabilities/operation-capabilities.json),
            ADR-0024) — per operation type: `accepts` (media classes),
            `input` (cardinality), `produces`, `availability`, `sole_op`,
            and the three constraint families (`excludes` / `requires` /
            `option_conflicts`). Consumers use it to pre-validate
            operation composition (compatibility, ordering, output
            propagation) without hardcoding pairwise rules.

            Each `OperationCapability` is left OPEN (no
            `additionalProperties: false`) so future capability families
            ride through untyped — consumers MUST tolerate extra keys.

            **Optional in the wire envelope** (same incremental-mirroring
            stance as `endpoints` / `workflow_features` /
            `image_encode_capabilities`): the runtime
            `GET /api/operations/schema` emits it (API ticket
            `uneE8Yoz`); the committed sidecar MAY mirror it once the
            generator catches up. Consumers MUST tolerate its absence
            and its presence from either source. Per ticket
            [`9E3oqGNK`](https://trello.com/c/9E3oqGNK).
          additionalProperties:
            $ref: '#/components/schemas/OperationCapability'
        output_properties:
          type: object
          description: |
            Output-format property table (`output_format` → media facts)
            backing the capability model's forward output-propagation:
            `hasAudioTrack` (does this format carry an audio stream) and
            `isAnimated` (single-frame vs animated; `"maybe"` for formats
            that can be either, e.g. GIF/WebP). Consumers use it to reason
            about what a chained operation's output can feed downstream.

            **Tier-invariant** — a fixed codec fact, not an entitlement
            (carries no `availability`).

            **Optional in the wire envelope** (same incremental-mirroring
            stance as the sibling blocks above). Per ticket
            [`9E3oqGNK`](https://trello.com/c/9E3oqGNK).
          additionalProperties:
            $ref: '#/components/schemas/OutputProperties'

    OperationCapability:
      type: object
      description: |
        One operation type's capability entry in the `capabilities`
        matrix (ADR-0024 operation-capability constraint model). The
        object is deliberately OPEN (no `additionalProperties: false`)
        so future capability families ride through untyped — consumers
        MUST tolerate extra keys. The per-op `input` cardinality block
        is typed via `CapabilityInputSpec` (added once the API began
        surfacing it, ticket `L7ykrIX3` / API `03Qg3Ms7`).
      properties:
        accepts:
          type: array
          description: |
            Media classes this operation accepts as input (e.g.
            `image`, `video`, `audio`, `document`, or `*` for
            media-agnostic). Coarse media-class tokens, not MIME types —
            per-MIME applicability lives in `operations.*.mime_groups`.
          items:
            type: string
        input:
          $ref: '#/components/schemas/CapabilityInputSpec'
        availability:
          $ref: '#/components/schemas/AvailabilityValue'
        sole_op:
          type: boolean
          description: |
            True when the operation must be the only operation in its job
            (bypasses chain validation; ADR-0025). False/absent ⇒ chainable.
        produces:
          $ref: '#/components/schemas/CapabilityProduces'
        excludes:
          type: array
          description: |
            Hard-incompatibility constraints — when the `when` condition
            holds the operation/option combination is rejected with the
            constraint's `wire_code`.
          items:
            $ref: '#/components/schemas/CapabilityConstraint'
        requires:
          type: array
          description: |
            Co-requirement constraints — when `when` holds, the
            `requires_field` MUST be present (else `wire_code`,
            typically `invalid_options`).
          items:
            $ref: '#/components/schemas/CapabilityConstraint'
        option_conflicts:
          type: array
          description: |
            Mutually-exclusive option constraints within the operation.
          items:
            $ref: '#/components/schemas/CapabilityConstraint'

    CapabilityInputSpec:
      type: object
      additionalProperties: false
      required: [model]
      description: |
        Per-operation input cardinality — the `capabilities.<op>.input`
        block surfaced by the API (ticket `03Qg3Ms7` / PR #477),
        round-tripped verbatim from the operation-capability model. It
        mirrors the operation's `input_model` / `min_inputs` /
        `max_inputs` / `per_role_cardinality` (the `operations.*` map
        carries the same facts under those keys; this is the
        `capabilities` projection). `model` is always present; `min` /
        `max` accompany multi-input ops; `roles` is added only by
        role-based multis. A single-input op emits just
        `{ model: single }`; a role-less multi emits
        `{ model: multi, min, max }`; a role-based multi adds `roles`.
      properties:
        model:
          type: string
          enum: [single, multi]
          description: |
            `single` — one input. `multi` — multiple inputs (with `min` /
            `max`, and `roles` for role-based ops).
        min:
          type: integer
          description: Minimum number of inputs (multi-input ops).
        max:
          type: integer
          description: Maximum number of inputs (multi-input ops).
        roles:
          type: array
          description: |
            Declared input roles for role-based multi-input ops. Omitted
            entirely for role-less multi-input ops — NOT synthesised.
            Mirrors the `JobInputRole` vocabulary (the request `role`
            enum); keep in sync if that enum widens.
          items:
            type: string
            enum: [base, overlay, transition_mask]

    CapabilityProduces:
      description: |
        How an operation derives its output media type, for forward
        output-propagation through a chain. Exactly one form:
        - `same_as_input: true` — output mirrors the input format
          (e.g. watermark, passthrough).
        - `from_option: <option>` — output format is taken from the
          named option's resolved value (e.g. `output_format`,
          `output_type`, `format`).
        - `fixed: <format>` — output is always this concrete format
          (e.g. `audio_to_video` always produces `mp4`).
      oneOf:
        - type: object
          additionalProperties: false
          required: [same_as_input]
          properties:
            same_as_input:
              type: boolean
              enum: [true]
        - type: object
          additionalProperties: false
          required: [from_option]
          properties:
            from_option:
              type: string
        - type: object
          additionalProperties: false
          required: [fixed]
          properties:
            fixed:
              type: string

    CapabilityConstraint:
      type: object
      description: |
        A single capability constraint (member of `excludes` /
        `requires` / `option_conflicts`). The `when` condition is
        evaluated against the resolved operation + sibling-operation
        context; when it holds, the constraint fires.
      required:
        - constraint_id
        - message
        - wire_code
        - when
      properties:
        constraint_id:
          type: string
          description: Stable identifier for the constraint (for logs / docs / tests).
        message:
          type: string
          description: |
            Human-readable rejection message. MAY contain `{placeholder}`
            tokens (e.g. `{output_format}`) the API interpolates at
            evaluation time.
        wire_code:
          type: string
          description: |
            Error code emitted when the constraint fires. Aligns with the
            error envelope `error_code` vocabulary.
          enum:
            - feature_not_available
            - invalid_options
            - INVALID_OPERATION_ORDER
            - OPERATION_NOT_VALID_FOR_OUTPUT
        when:
          $ref: '#/components/schemas/CapabilityCondition'
        availability:
          $ref: '#/components/schemas/AvailabilityValue'
        eval_scope:
          type: string
          description: |
            Where the constraint is evaluated. `api` marks constraints the
            API create-path enforces (vs purely-advisory ones consumers
            mirror for UX). Absent ⇒ unscoped/advisory.
          enum: [api]
        requires_field:
          type: string
          description: |
            Only on `requires` constraints — the field path that must be
            present when `when` holds (e.g. `compress.target_size_bytes`).

    CapabilityCondition:
      description: |
        Recursive condition node (the `Cond` field-token model, ADR-0024
        §2). Exactly ONE form per node — a boolean combinator (`all` /
        `any` / `not`) OR a single leaf test. A leaf tests one `field`
        token with exactly one operator (`equals` / `in` / `isSet`),
        except `opSelected` which is a standalone leaf asserting a
        sibling operation of that type is present in the job. Consumers
        compute field tokens locally from the resolved request (no new
        wire field). Modelled as a `oneOf` of the valid forms (each
        `additionalProperties: false`) so the grammar is enforced rather
        than advertised as a loose bag of optional keys.
      oneOf:
        - type: object
          additionalProperties: false
          required: [all]
          properties:
            all:
              type: array
              description: All sub-conditions must hold (logical AND).
              items:
                $ref: '#/components/schemas/CapabilityCondition'
        - type: object
          additionalProperties: false
          required: [any]
          properties:
            any:
              type: array
              description: At least one sub-condition must hold (logical OR).
              items:
                $ref: '#/components/schemas/CapabilityCondition'
        - type: object
          additionalProperties: false
          required: [not]
          properties:
            not:
              $ref: '#/components/schemas/CapabilityCondition'
        - type: object
          additionalProperties: false
          required: [field, equals]
          properties:
            field:
              type: string
              description: Field token under test (e.g. `output_format`, `compress.encoding_mode`).
            equals:
              description: Leaf test — `field` equals this value (string or boolean).
        - type: object
          additionalProperties: false
          required: [field, in]
          properties:
            field:
              type: string
              description: Field token under test.
            in:
              type: array
              description: Leaf test — `field` is one of these values.
              items: {}
        - type: object
          additionalProperties: false
          required: [field, isSet]
          properties:
            field:
              type: string
              description: Field token under test.
            isSet:
              type: boolean
              enum: [true]
              description: |
                Leaf test — asserts `field` is present/set. Only the
                positive form is used; negate via a wrapping `not` node.
        - type: object
          additionalProperties: false
          required: [opSelected]
          properties:
            opSelected:
              type: string
              description: |
                Standalone leaf — true when a sibling operation of this
                type is selected in the same job (e.g. `compress`,
                `convert`).

    OutputProperties:
      type: object
      description: |
        Media facts for one output format, backing forward
        output-propagation in the capability model.
      required:
        - hasAudioTrack
        - isAnimated
      properties:
        hasAudioTrack:
          type: boolean
          description: Whether this format carries an audio stream.
        isAnimated:
          description: |
            Whether output of this format is animated. `true` / `false`
            for formats that are always one or the other; `"maybe"` for
            formats that can be either (e.g. GIF, WebP).
          oneOf:
            - type: boolean
            - type: string
              enum: [maybe]

    EndpointProjection:
      type: object
      description: |
        Per-endpoint projection entry per ADR-0016 §D4. Five fields;
        `required_tier` and `availability` at endpoint level are
        reserved/null today (operation-level `required_tier` continues
        to flow via `operations.*.required_tier`; every shipped
        endpoint is currently `availability: stable`).
      required:
        - auth
        - identity_scoped
        - required_tier
        - availability
        - operation_id
      properties:
        auth:
          type: string
          enum: [anonymous, optional, required]
          description: |
            3-value projection of the operation's `security:` block:
            `anonymous` (`security: []`), `optional` (`security`
            contains the empty requirement `{}`), `required`
            (otherwise).
        identity_scoped:
          type: boolean
          description: |
            Value of the `x-identity-scoped` vendor extension on the
            operation (default `false`). True iff the operation
            targets an identity-bound resource and cross-identity
            access is rejected — OR acts on the caller's implicit
            identity-scoped data (credits balance, own session).

            The rejection code is usually `403`, but some endpoints
            **existence-mask cross-identity references as `404`** (BOLA/
            IDOR: a 403 would leak that the resource exists). The
            `createWorkflow` upload reference is the canonical case —
            cross-owner uploads return `404 UPLOAD_NOT_FOUND`, never 403
            (per the ADR-0016 amendment). Consumers MUST NOT assume
            `identity_scoped: true` implies a 403 specifically; read the
            per-operation error responses for the exact code.
        required_tier:
          description: |
            Endpoint-level entitlement gate. Reserved/null today —
            operation-level `required_tier` flows via
            `operations.*.required_tier` and is NOT duplicated here.
          oneOf:
            - $ref: '#/components/schemas/UserTier'
            - type: 'null'
        availability:
          type: string
          enum: [stable, beta, experimental, planned, deprecated]
          description: |
            Endpoint-level availability tag. Currently always
            `"stable"` for shipped endpoints. Reserved for future
            `planned` / `deprecated` endpoint-level annotation.
        operation_id:
          type: string
          description: |
            OpenAPI `operationId` for the operation. SDK code
            generators anchor on this for method naming; including
            it in the sidecar saves a round-trip to `openapi/api.yaml`.
            Per the SDK ask at ADR-0016 B1 sign-off.

    OperationSchemaDefinition:
      type: object
      description: Schema for a single operation type
      required:
        - description
        - input_model
      # `options` removed from required per ticket
      # [IXE7QDxe](https://trello.com/c/IXE7QDxe). See the `options`
      # property description for the parser obligation (consumers
      # treat absent / null `options` as `{}`).
      properties:
        description:
          type: string
          description: Human-readable description of what the operation does
        default:
          type: boolean
          description: Whether this is the default operation when none specified
        availability:
          $ref: '#/components/schemas/AvailabilityValue'
          description: |
            Operation-level availability tag. Optional — when absent, the
            operation is treated as `stable` (parser obligation per
            ADR-0001 §1.4 / FORMAT.md §Availability Taxonomy). Runtime
            emission lands with [I3 `eCWIpug8`](https://trello.com/c/eCWIpug8);
            until then the contract declares the shape but the endpoint
            does not yet surface the field.
        required_tier:
          $ref: '#/components/schemas/UserTier'
          description: |
            Operation-level minimum subscription tier. Optional — when
            absent, the operation is available to all callers regardless
            of tier. Per ADR-0001 §1.3 (tier and availability are
            orthogonal axes). First operation-level consumer is
            `custom_luma` (`required_tier: pro`) per
            [I29 `EPUE5Vs1`](https://trello.com/c/EPUE5Vs1). SDK + frontend
            consumers gate UI on this value alongside `availability` to
            hide higher-tier features for lower-tier callers; the
            runtime returns `feature_tier_restricted` (403) on
            `POST /api/workflows` when violated. Same field exists at
            mime-group and option scope (see `MimeGroupSchema` /
            `OptionSchema` `availability` block — `required_tier` may
            also appear there for sub-shape gating).
        input_model:
          $ref: '#/components/schemas/OperationInputModel'
        min_inputs:
          type: integer
          description: |
            Minimum number of inputs (multi-input operations only).
            `audio_to_video` declares `min_inputs: 1` (first
            role-based op with an optional role); all other
            role-based ops declare `min_inputs: 2`. Per
            `per_role_cardinality` semantics (ADR-0015), this value
            equals the sum of role-level minima.
          minimum: 1
        max_inputs:
          type: integer
          description: Maximum number of inputs (multi-input operations only)
        sole_op:
          type: boolean
          description: |
            When `true`, this operation MUST be the only operation in its job —
            it cannot be chained with other operations in the same job's
            `operations[]`. To combine it with another operation (e.g. watermark
            + compress), model them as SEPARATE jobs linked via `workflow_edges`
            (the downstream job's `source` references the upstream via
            `job_output`). Optional — absent means `false` (the operation chains
            normally). Set on the ten ops the API bars from co-bundling
            (`OperationType::bypassesV1ChainValidator()`): the multi-input ops
            (merge, archive, image_watermark, video_watermark, audio_overlay,
            audio_to_video, custom_luma) and the single-input fan-out / leaf ops
            (text_watermark, video_text_watermark, split). SDK + frontend gate on
            this to DISABLE-or-EXPLAIN an invalid op-combination before submit,
            instead of surfacing a workflow-create 422. Per ADR-0025 (interim
            signal ahead of the full operation-compatibility model, ADR-0024).
        per_role_cardinality:
          $ref: '#/components/schemas/PerRoleCardinality'
          description: |
            Optional per-role input-count overlay for role-based
            multi-input operations. Per ticket
            [`SlluxMBN`](https://trello.com/c/SlluxMBN) / ADR-0015.
            Present on all five role-based ops (audio_to_video,
            video_watermark, audio_overlay, image_watermark,
            custom_luma); absent on non-role ops (merge, archive)
            where all inputs share a single role.
        accepts_mixed_types:
          type: boolean
          description: Whether mixed MIME types are allowed (archive only)
        mime_groups:
          type: object
          description: |
            MIME-type-specific option schemas. When present, options are grouped
            by MIME category (image, video, audio, document). Each group lists
            the supported MIME types and group-specific options.
          additionalProperties:
            $ref: '#/components/schemas/MimeGroupSchema'
        options:
          type: object
          description: |
            Global options applicable regardless of MIME type, keyed
            by option name. **Optional** per ticket
            [IXE7QDxe](https://trello.com/c/IXE7QDxe) — operations
            declaring `mime_groups` typically carry their options
            inside the mime_group, so operation-level `options` is
            omitted on those (e.g. `compress`, `thumbnail`,
            `convert`, `image_watermark`). Operation-level `options`
            is present primarily for media-agnostic operations like
            `archive` whose options don't vary by MIME type.

            **Parser obligation.** Consumers MUST treat absent /
            null `options` as `{}` (empty mapping). API runtime
            SHOULD emit `{}` rather than `null` or omitting the
            field, but consumers handle all three cases gracefully
            — same precedent as the absent-equals-stable rule for
            `availability` per ADR-0001 §1.4.
          additionalProperties:
            $ref: '#/components/schemas/OptionSchema'
        per_input_options:
          type: object
          description: |
            Options that can be overridden per-input for multi-input operations,
            keyed by option name. For merge: per-join-point transition overrides.
          additionalProperties:
            $ref: '#/components/schemas/OptionSchema'

    MimeGroupSchema:
      type: object
      description: MIME-group-specific option schema
      required:
        - mimes
        - options
      properties:
        mimes:
          type: array
          description: List of MIME types in this group
          items:
            type: string
          example: ["image/jpeg", "image/png", "image/webp"]
        availability:
          $ref: '#/components/schemas/AvailabilityValue'
          description: |
            Mime-group-level availability tag. Optional — absent ≡
            `stable`. See `OperationSchemaDefinition.availability` for
            the runtime emission timeline (lands with I3).
        required_tier:
          $ref: '#/components/schemas/UserTier'
          description: |
            Mime-group-level minimum subscription tier. Optional —
            absent means no tier restriction at this level. Same
            semantics as `OperationSchemaDefinition.required_tier` but
            scoped to a single MIME group (e.g. an operation may be
            free-tier for `image/jpeg` and `pro`-tier for `image/heic`
            via two parallel mime_groups).
        per_mime_availability:
          $ref: '#/components/schemas/PerMimeAvailability'
          description: |
            Optional per-MIME availability map per ticket
            [`YXYOo6gg`](https://trello.com/c/YXYOo6gg). Keys MUST be
            a subset of `mimes[]` (CI-checked by
            `scripts/check-per-mime-availability.py`); absent keys
            default to `availability: stable`. Use this when
            individual MIMEs within an otherwise-homogeneous mime
            list ship at different availability levels (e.g.
            `image/avif: beta, image/heic: planned` alongside
            `image/jpeg: stable`). Runtime emission lands with
            [I3](https://trello.com/c/eCWIpug8); until then the
            contract advertises the field shape but the endpoint
            does not yet surface the field.
        processing_class:
          type: object
          description: |
            Optional per-mime-group processing-class block (per ticket
            [I15-CONS `YZpBKzOM`](https://trello.com/c/YZpBKzOM) + plan
            v5 §F8 long-form routing). Keyed by `ProcessingClass` enum
            value (`short_form` / `long_form` / `short_form_concat` /
            `long_form_re_encode`); each entry advertises that routing
            class's availability, tier gate, and size/duration caps.

            Present only on mime_groups subject to short-form vs
            long-form routing — typically `video`; per F8 also `audio`
            for `audio_overlay` / `audio_watermark`. Absence means "no
            per-class routing exposed": consumers MUST treat absent as
            "no routing decision exposed" and MUST NOT coerce it to
            `short_form` (FORMAT.md §`processing_class` parser
            obligation 2 / ADR-0001 §1.4).

            The runtime endpoint (`GET /api/operations/schema`) and the
            `availability/availability.json` sidecar emit this block
            verbatim from the source operation YAML (including any
            `per_tier_constraints` overlay — copied through as an
            opaque subtree). SDK + frontend consumers gate UI on the
            per-class `availability` + `required_tier` and surface the
            caller's effective caps as the binding limits. Surfacing
            this on the typed model retires the raw-ops-schema HTTP
            workaround per ticket
            [`yWeBr81O`](https://trello.com/c/yWeBr81O). See
            `schemas/FORMAT.md` §`processing_class:` block.
          additionalProperties:
            $ref: '#/components/schemas/ProcessingClassEntry'
        options:
          type: object
          description: Options specific to this MIME group, keyed by option name
          additionalProperties:
            $ref: '#/components/schemas/OptionSchema'
        per_input_options:
          type: object
          description: Per-input overrides for this MIME group, keyed by option name (multi-input only)
          additionalProperties:
            $ref: '#/components/schemas/OptionSchema'

    OptionSchema:
      type: object
      description: Schema for a single operation option
      required:
        - type
      properties:
        type:
          type: string
          description: Option value type
          enum:
            - integer
            - float
            - boolean
            - enum
            - string
            - array
        description:
          type: string
          description: Human-readable description
        required:
          type: boolean
          description: Whether the option is required
        default:
          description: Default value if not specified
        values:
          type: array
          description: Allowed values (for enum type)
          items: {}
        value_type:
          type: string
          description: |
            Actual type of enum values when not strings (e.g. "integer" for numeric bitrate enums).
            Consumers should parse/display values as this type rather than as strings.
          enum:
            - integer
            - float
        availability:
          $ref: '#/components/schemas/AvailabilityValue'
          description: |
            Option-level availability tag. Optional — absent ≡ `stable`.
            See `OperationSchemaDefinition.availability` for runtime
            emission timeline (lands with I3).
        required_tier:
          $ref: '#/components/schemas/UserTier'
          description: |
            Option-level minimum subscription tier. Optional — absent
            means no tier restriction at the option level. Same
            semantics as `OperationSchemaDefinition.required_tier` but
            scoped to a single option (e.g. an operation may have a
            free-tier base option set with one or two `pro`-tier
            advanced options gated this way).
        per_value_availability:
          $ref: '#/components/schemas/PerValueAvailability'
          description: |
            Per-enum-value availability map, only meaningful when
            `type: enum`. Keys MUST be a subset of `values[]`.
            Untagged values default to `availability: stable`. Verified
            by `make check-per-value-availability` (CI guard, ticket
            [I17 `0gwtwCav`](https://trello.com/c/0gwtwCav)). Runtime
            emission lands with I3.
        per_value_depends_on:
          type: object
          description: |
            Per-enum-value cross-field constraint map, only meaningful
            when `type: enum`. Keys MUST be a subset of `values[]`;
            each entry value is a `depends_on`-shaped condition mapping
            that the named enum value REQUIRES. Distinct from
            option-level `depends_on`, which gates the WHOLE option's
            applicability and IS enforced by the API at workflow-create
            (`OperationOptionsValidator`: a submitted option whose gate is
            unmet is rejected as `invalid_options`; an absent one is
            inactive). `per_value_depends_on` instead constrains a single
            VALUE of an otherwise-active option. ENFORCEMENT NOTE: the API's
            create-time validator does NOT yet evaluate `per_value_depends_on`
            (only option-level `depends_on`); it is shape-checked metadata
            that SDK/FE consume to gate the value, with the worker as the
            reject backstop (a future API version may add create-time
            enforcement, contract-first). When unmet it logically requires
            the request be rejected as `invalid_options` (intent; per the
            enforcement note above this is not yet API-create-enforced), the
            option itself staying active. The KEY/SHAPE (keys are a subset of
            `values[]`; condition cross-refs resolve) is verified by
            `make check-per-value-depends-on` (CI guard, ticket
            [`bsV3FWM5`](https://trello.com/c/bsV3FWM5)) — shape only, NOT
            runtime/worker enforcement. Runtime
            emission ships verbatim alongside other availability
            metadata. See `schemas/FORMAT.md` §per_value_depends_on.
          additionalProperties: true
        min:
          type: number
          description: Minimum value (for integer/float types)
        max:
          type: number
          description: Maximum value (for integer/float types)
        pattern:
          type: string
          description: |
            ECMA-262 regular expression a `type: string` value MUST match
            (the string analogue of `min`/`max`). Consumers pre-validate
            against it before submit, and the server rejects a non-matching
            value as `invalid_options` — so the contract never advertises a
            free-form string for an option whose worker silently drops
            non-conforming input (e.g. `convert.image.background` honours only
            `#RRGGBB`; a CSS named colour was silently dropped to white). Only
            meaningful for `type: string`. See `schemas/FORMAT.md`.
          example: "^#[0-9a-fA-F]{6}$"
        depends_on:
          type: object
          description: |
            Conditional dependency. This option is only applicable when the condition is met.
            Simple: `{ "mode": "lossy" }` — option applies when mode equals lossy.
            Multi-value: `{ "output_format": ["jpeg", "webp"] }` — option applies when output_format is any listed value.
            Set condition: `{ "width": "set", "height": "set", "logic": "or" }` — option applies when width or height is provided.
            The "set" sentinel means the option has any value. "logic" can be "and" (default) or "or".
          additionalProperties: true
        honored_on:
          type: array
          description: |
            Route-applicability tag for the image "Output" facade (per
            `schemas/FORMAT.md` §"Route-aware Output model"). Lists the route(s)
            whose worker actually honours this option. The Output op routes by
            comparing the authored/resolved `output_format` to the input format:
            `same_format` (output_format == input, or `original`) runs the
            optimiser path; `format_change` (output_format != input) runs the
            transcoder path. An option `honored_on: [same_format]` is read only
            on the optimiser path (e.g. JPEG `progressive`); `[format_change]`
            only on the transcoder path (e.g. `background` alpha-flatten to JPEG).
            ABSENT ≡ honored on every route the option's `depends_on` already
            admits (back-compat — existing options need no tag). Orthogonal to
            `availability`: a `planned` option still declares the route it WILL
            be honoured on. Consumers derive option visibility from
            `honored_on` ∩ active-route ∩ (availability != planned) ∩ depends_on.
            Token validity (subset of {same_format, format_change}) is verified
            by `make check-image-output-routes`. The derived per-(media, route,
            output_format) projection lives at
            `accepted-options/image-output-routes.json`.
          items:
            type: string
            enum:
              - same_format
              - format_change

    ProcessingClassConstraints:
      type: object
      additionalProperties: false
      description: |
        Numeric size / duration caps for a single processing class (or
        a per-tier override of those caps). All fields optional; the
        `max_input_*` keys apply to single-input mime_groups while the
        `max_total_*` keys apply to multi-input merge-style mime_groups
        (a merge can have many small inputs whose combined size triggers
        long-form). Byte caps are positive integers; duration caps are
        ISO-8601 duration strings (e.g. `PT5M`). Field set is CI-fixed —
        `scripts/check-per-tier-constraints.py` rejects unknown keys and
        bad value types. See `schemas/FORMAT.md` §`constraints` sub-block.
      properties:
        max_input_duration:
          type: string
          description: |
            Max per-input duration as an ISO-8601 duration string.
            Single-input mime_groups.
          example: "PT5M"
        max_input_size_bytes:
          type: integer
          minimum: 1
          description: Max per-input file size in bytes. Single-input mime_groups.
        max_output_size_bytes:
          type: integer
          minimum: 1
          description: Max output file size in bytes. Any mime_group.
        max_total_duration:
          type: string
          description: |
            Max combined duration across all inputs as an ISO-8601
            duration string. Multi-input (merge) mime_groups.
          example: "PT1H"
        max_total_input_size_bytes:
          type: integer
          minimum: 1
          description: Max combined input size in bytes. Multi-input (merge) mime_groups.

    ProcessingClassEntry:
      type: object
      description: |
        Single processing-class entry within a mime_group's
        `processing_class` map (per ticket
        [I15-CONS `YZpBKzOM`](https://trello.com/c/YZpBKzOM)). Mirrors
        `PerValueAvailabilityEntry` (availability + optional tier / eta /
        documentation_url) plus per-class `constraints` and an optional
        `per_tier_constraints` overlay. Surfaced on the typed getSchema
        model per ticket [`yWeBr81O`](https://trello.com/c/yWeBr81O).
        See `schemas/FORMAT.md` §`processing_class:` block.
      required:
        - availability
      properties:
        availability:
          $ref: '#/components/schemas/AvailabilityValue'
          description: Per-class availability tag.
        required_tier:
          description: |
            Tier required to use this class. Optional — omit when the
            class is gated by readiness rather than subscription.
            `null` is equivalent to omitted (no tier restriction).
          oneOf:
            - $ref: '#/components/schemas/UserTier'
            - type: 'null'
        eta:
          type: string
          description: |
            ISO-8601 date (`2026-09-15`) or quarter (`2026-Q3`) when
            this class is expected to ship. Only meaningful for
            `availability: planned`.
        documentation_url:
          type: string
          format: uri
          description: Optional link to processing-class documentation.
        constraints:
          $ref: '#/components/schemas/ProcessingClassConstraints'
          description: |
            Baseline caps for the lowest tier this class's
            `required_tier` permits. Overlaid per-caller by
            `per_tier_constraints` (see below).
        per_tier_constraints:
          type: object
          description: |
            Optional per-tier numeric-cap override map (per
            [ADR-0011](../docs/decisions/0011-per-tier-processing-class-constraints.md),
            ticket [`z4GDTUMx`](https://trello.com/c/z4GDTUMx)). Keys
            MUST be a subset of the `UserTier` enum and SHOULD name only
            tiers HIGHER than the class baseline (CI-enforced by
            `scripts/check-per-tier-constraints.py`). Each value
            overrides `constraints` field-by-field for callers of that
            tier. A consumer that ignores this map reads `constraints`
            and sees the smallest permitted-tier cap — it can never
            over-promise. `per_tier_constraints` sizes caps, it does
            NOT grant access (eligibility stays governed by
            `availability` + `required_tier`).
          additionalProperties:
            $ref: '#/components/schemas/ProcessingClassConstraints'

    # ============================================
    # RETRY SCHEMAS
    # ============================================

    RetryResponse:
      type: object
      required:
        - operation_id
        - original_operation_id
        - status
      properties:
        operation_id:
          $ref: '#/components/schemas/UuidV7'
          description: New operation ID for the retry
        original_operation_id:
          $ref: '#/components/schemas/UuidV7'
          description: ID of the original failed operation
        status:
          type: string
          enum:
            - pending
          description: Always "pending" for a new retry

    RetrySuccessEnvelope:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/RetryResponse'

    # ============================================
    # CALLBACK & EXPORT CONFIG
    # ============================================

    CallbackEventType:
      type: string
      description: |
        Events that can trigger a webhook callback:
        - workflow.completed: All jobs done successfully
        - workflow.failed: At least one job failed, none in progress
        - workflow.partially_failed: Some succeeded, some failed
        - operation.completed: Individual operation done (opt-in for granular progress)
      enum:
        - workflow.completed
        - workflow.failed
        - workflow.partially_failed
        - operation.completed

    WebhookPayload:
      type: object
      description: |
        Payload POSTed to the `callback_url` when a subscribed event occurs.
        The `workflow` field contains the full current state including all jobs
        and their operation results, matching the `WorkflowStatusResponse` shape.

        For `operation.completed` events, the `operation` field identifies which
        specific operation triggered the callback, so consumers do not need to
        scan the entire workflow to find the change.
      required:
        - event_type
        - delivery_id
        - timestamp
        - workflow
      properties:
        event_type:
          $ref: '#/components/schemas/CallbackEventType'
        delivery_id:
          $ref: '#/components/schemas/UuidV7'
          description: |
            Unique identifier for this event. Stable across retry attempts —
            the same delivery_id is sent if the API retries a failed delivery.
            Consumers should use this for idempotency to avoid processing
            the same event twice.
        timestamp:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the event occurred
          example: "2026-03-13T14:30:00Z"
        workflow:
          $ref: '#/components/schemas/WorkflowStatusResponse'
        operation:
          $ref: '#/components/schemas/WebhookOperationContext'
      allOf:
        - if:
            properties:
              event_type:
                const: operation.completed
          then:
            required: [operation]
            properties:
              operation:
                type: object
            description: operation.completed events must include operation context
          else:
            properties:
              operation:
                type: "null"
            description: Workflow-level events have null operation context

    WebhookOperationContext:
      type:
        - object
        - "null"
      description: |
        Identifies which operation triggered the callback. Present only for
        `operation.completed` events; null for workflow-level events.
      required:
        - job_ref
        - operation_id
      properties:
        job_ref:
          type: string
          description: Reference label of the job containing the operation
          example: "main"
        operation_id:
          $ref: '#/components/schemas/UuidV7'
          description: ID of the operation that completed

    # V1 ExportConfig deleted in V2 cutover (ticket I12 / ADR-0004
    # §"Greenfield V2.0 cutover"). Replaced by ExternalDestination
    # (ticket I10 / ADR-0005) — see the EXTERNAL SOURCES + DESTINATIONS
    # section above.

    # ============================================
    # HEALTH SCHEMAS
    # ============================================

    LivenessResponse:
      type: object
      required:
        - app
      properties:
        app:
          type: boolean
          description: Application is running

    ReadinessResponse:
      type: object
      properties:
        database:
          type: boolean
          description: Database connection is healthy
        cache:
          type: boolean
          description: Cache connection is healthy

    # ============================================
    # CONTACT SCHEMAS
    # ============================================

    ContactSubject:
      type: string
      enum:
        - general_enquiry
        - bug_report
        - suggestion
        - complaint
        - business_enquiry
      description: |
        Subject category:
        - general_enquiry: General questions
        - bug_report: Report a bug or issue
        - suggestion: Feature suggestion or improvement idea
        - complaint: Complaint about the service
        - business_enquiry: Business or partnership enquiry

    ContactRequest:
      type: object
      required:
        - email
        - subject
        - message
      properties:
        name:
          type: string
          maxLength: 100
          description: Sender's name (optional)
          example: "Jane Doe"
        email:
          type: string
          format: email
          maxLength: 254
          description: Sender's email address
          example: "jane@example.com"
        subject:
          $ref: '#/components/schemas/ContactSubject'
        message:
          type: string
          minLength: 1
          maxLength: 1000
          description: Message body
          example: "I have a question about supported file formats."
        website:
          type: string
          maxLength: 255
          description: |
            Honeypot field for bot detection. Hidden from real users via CSS.
            Legitimate submissions must omit this field or send an empty string.
            The API rejects any request where this field is non-empty.

    ContactValidationErrorResponse:
      type: object
      required:
        - errors
      properties:
        errors:
          type: object
          description: |
            Map of field names to arrays of validation error messages.
          additionalProperties:
            type: array
            items:
              type: string
          example:
            email:
              - "This value is not a valid email address."
            subject:
              - "This value is not valid."
