openapi: 3.1.0

# Hand-maintained spec for the public /v1 developer API.
# Source of truth for the API *documentation*; the code in
# backend/lib/routes/public_api.dart is the source of truth for behaviour.
# backend/test/openapi_drift_test.dart asserts every route registered in the
# router exists here (and vice versa) — adding/removing a /v1 route without
# updating this file fails `dart test`. It also cross-checks the enums of the
# documented *response* schemas against the Dart symbol each one is served
# from, so a documented vocabulary can no longer disagree with the emitted
# one (#571); a response enum with no such symbol must be registered as a
# waiver in that test, naming what serves it.

info:
  title: easyi18n API
  version: "1.0"
  description: |
    Public developer API for easyi18n (`/v1`).

    Three groups of endpoints:

    - **Translate** — push managed source units, get an instant accept with a
      cost estimate, and poll an async fill job (`POST /translate`,
      `GET /translations?trackingToken=`). See `docs/specs/translation-api.md`.
    - **Files** — pull rendered translation files from the published version
      (`GET /translations`, `GET /meta`).
    - **Delivery** — the runtime SDK's manifest + immutable bundle origin
      (`GET /manifest`, `GET /bundles/...`). Public, cache-friendly, also
      served through the delivery CDN.

    ### Authentication
    Authenticated endpoints take a **project API key** (`eik_...`) in
    `Authorization: Bearer <key>`. Keys are created per project in the
    dashboard and carry explicit scopes. The ones this API checks are `read`,
    `write`, `capture`, `translate` and `publish` (`admin` grants all). Note
    that `write` is distinct from both `capture` and `translate` — it is the
    scope the Content import routes (`POST`/`PUT .../content`) require, so a
    key minted only for string capture cannot import content. Delivery
    endpoints and `/health` are public.

    ### Credits
    All credit amounts (`estimatedCredits`, `creditsCharged`, `balance`) are
    integers in **millicredits**: 1 displayed credit = 1,000 millicredits.
    Exceptions: `/pricing` uses whole credits for monthly grants and packs;
    `/operations` accepts whole credits in `max_credits` and returns millicredits.

    ### Preview flags
    On translate, sync, content run, publish, restore, capture and content
    import/reimport, the flag name must be exactly `dryRun`. Capitalization
    or hyphen/underscore variants (such as `dryrun`, `dry_run` or `dry-run`) return
    `400 invalid_argument`, even alongside a canonical `dryRun` field.
    Capture and content import/reimport do not support previews: any such
    flag in the query or JSON body is rejected before domain writes or quota
    consumption. Query flags are rejected before parsing the body.

    ### Rate limiting
    Requests under `/projects/{projectId}/...` and authenticated `/operations`
    requests share a per-project budget of
    120 requests per fixed 60-second window. Exceeding it returns `429` with
    a `Retry-After: 60` header.

    ### Errors
    Every error is `{"error": "<code>", "message": "<human text>"}` with the
    matching HTTP status.

servers:
  - url: https://api.easyi18n.com/v1
    description: Main API origin.
  - url: https://locales.easyi18n.com/v1
    description: >
      Delivery origin (CDN). Serves the delivery endpoints (`/manifest`,
      `/bundles/...`, `/health`); projects with an active custom domain
      deliver from that domain instead (see `deliveryHost` in `/meta`).

security:
  - apiKey: []

tags:
  - name: translate
    description: Push source units and track async AI fills.
  - name: files
    description: Read rendered translation files and project metadata.
  - name: delivery
    description: Runtime SDK delivery — manifest and immutable bundles. Public.
  - name: publish
    description: Freeze the live state into immutable versions and roll back.
  - name: content
    description: >
      Long-form documents (articles, posts, pages) — push source content and
      pull its published translations. Available only where the Content
      dimension is enabled; every route 404s otherwise.
  - name: webhooks
    description: >
      Self-service management of outbound publish webhooks
      (`strings.published`, `content.published`): register endpoints, rotate
      signing secrets, inspect the delivery log and resend. All routes
      require the `publish` scope. The signing scheme
      (`X-Easyi18n-Signature: t=...,v1=...`, HMAC-SHA256 over
      `"<t>.<body>"`) and the event payloads are documented in
      `docs/specs/strings-publish-webhook.md`.

paths:
  /operations:
    post:
      operationId: requestOperation
      tags: [publish]
      security: [{apiKey: []}]
      summary: Request human approval for a scoped action
      description: >
        Requires a project key with translate scope for translate, publish scope
        for publish or rollback. Creates pending_approval with zero authorized
        budget. Equivalent concurrent requests reuse one pending operation, or
        an approved operation with fewer than three attempts and
        authorized - charged - reserved at or above the requested budget
        (publish and rollback request zero). An approved translate that already
        charged or reserved anything is not reused: the request creates a new
        pending_approval operation. Reposting does not extend expiry.
        max_credits is a whole-credit request, not an authorization.
        Shares the existing per-project rate budget after authentication.
        The approval URL opens the dashboard approval screen. Approval and
        denial use the internal API; execution requires a separate request
        under the approved operation. This endpoint does not start work or
        reserve credits.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OperationRequest'
      responses:
        '201':
          description: New or reused operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OperationStatus'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
        '404':
          $ref: '#/components/responses/NotFound'
        '503':
          description: Concurrent state changed; retry the identical request
  /operations/{operationId}:
    get:
      operationId: getOperation
      tags: [publish]
      security: [{apiKey: []}]
      summary: Read an operation with its owning project key
      description: >
        Unknown operations, invalid or foreign credentials and deleted projects
        return the same 404 operation_not_found envelope. Pending requests expire
        exactly 30 minutes after creation. An approved publish or rollback
        reads as expired 30 minutes after its approval. An approved translate
        expires after 30 minutes without use and with no live job of its own;
        a background sweep releases its hold and stores the expired state, so
        this read never writes. Ledger counters are returned as stored.
      parameters:
        - name: operationId
          in: path
          required: true
          schema: {type: string}
      responses:
        '200':
          description: Current operation and ledger
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OperationStatus'
        '429':
          $ref: '#/components/responses/RateLimited'
        '404':
          description: Unknown or malformed operation ID, invalid or foreign credentials, or a deleted project (`operation_not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /projects/{projectId}/credential:
    get:
      operationId: getCredential
      tags: [publish]
      summary: Describe the key in use
      description: >
        The scopes of the calling key and whether it is an Agent key
        (`requiresOperationApproval`), which spends, publishes and rolls back
        only under an approved operation. Any valid key for the project may
        read it; it never describes another key. `doctor` in the MCP server
        warns when the answer is false.
      parameters:
        - $ref: '#/components/parameters/projectId'
      responses:
        '200':
          description: The calling key.
          content:
            application/json:
              schema:
                type: object
                required: [scopes, requires_operation_approval]
                properties:
                  scopes:
                    type: array
                    items: {type: string}
                  requires_operation_approval:
                    type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /pricing:
    get:
      operationId: getPricing
      summary: Read the public compiled pricing catalogue
      security: []
      description: >
        Catalogue amounts in USD cents, not configured Stripe prices or checkout
        quotes. Grants and packs use whole credits. Unpriced paid plans are
        omitted. No application request-rate limit; constant pre-encoded body
        with no per-request I/O. CDN caching must be configured before relying
        on edge protection. See docs/specs/public-pricing.md.
      responses:
        '200':
          description: Public catalogue, cacheable for 300 seconds
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicPricing'
    head:
      operationId: headPricing
      summary: Read pricing response headers without a body
      security: []
      responses:
        '200':
          description: Same headers as GET, including its UTF-8 Content-Length
          headers:
            Content-Length:
              schema:
                type: integer
                minimum: 0
  /projects/{projectId}/translate:
    post:
      tags: [translate]
      operationId: translate
      summary: Push source units for translation
      description: |
        Registers managed source units (deduplicated by content token), marks
        the missing target languages as pending, and enqueues one durable
        async fill job. Responds instantly with per-unit state, a cost
        estimate and a `trackingToken` to poll via
        `GET /translations?trackingToken=`.

        Idempotent: re-pushing already-translated units reports them as
        `translated` and enqueues nothing (`trackingToken: null`). A unit with
        `previousToken`/`previousSource` carries over an existing key
        (copy-edit) instead of minting a new one.

        With `?dryRun=true` nothing is written or enqueued — the response is a
        read-only cost preview (powers `easyi18n push --dry-run`).

        **Lost responses.** A request that carries a `requestId` is recorded
        before it writes anything else. Sending the same `requestId` with the
        same body again answers what the first run answered, `trackingToken`
        included, and writes nothing. `GET /requests/{requestId}` reads the
        same record without sending the body again.

        **Auto-verify (Pro).** When the project has auto-verify on, a unit
        whose source is not verified yet is **skipped, not translated**: it
        comes back with `gated: true`, the response carries `skippedGated: n`,
        and no credits are spent on it. No *new* languages are queued for it —
        `pending` only ever lists languages already in flight from an earlier
        push (those are reported, not re-queued, so a rapid re-push can't
        double-charge). The key is still created/matched. This path is
        headless, so it never blocks the push — verify the sources in the
        dashboard and re-push (idempotent). Dry-run reports the same, and
        excludes those units from the estimate.

        **Disabled keys.** A key switched off in the dashboard is excluded from
        published output, so it is never translated: the unit comes back with
        `disabled: true`, the response carries `skippedDisabled: n`, and no
        credits are spent on it. Pushing a source that maps to a disabled key
        does **not** switch it back on — matching a key never changes its
        settings. Re-enable it in the dashboard and re-push. As with the
        verify gate, dry-run reports the same and excludes those units from
        the estimate.

        Limits: at most 500 units per request, 5,000 characters per source,
        1 MiB request body. Requires the `translate` scope.

        An explicit `unit.key` must be a canonical key name (see the field):
        anything else is `400 invalid_key` — the whole request is rejected
        before any key is minted and before any credits are spent.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: dryRun
          in: query
          required: false
          schema:
            type: string
            enum: ["true", "false"]
          description: >-
            Estimate-only — classify and price the push without writing
            anything.
            The literal `true` or `false`, as a string. Omitting the
            parameter means a real run. Any other spelling — `True`, `1`,
            `yes`, or a present-but-empty `?dryRun=` — is a `400
            invalid_argument`: this flag guards the branch that writes and
            charges, so a value the door cannot read is refused rather
            than read as "not a dry run" (#1250).
            **In the query string, not the body** — the content run door
            takes it in the body, so a `dryRun` sent in this route's body is
            a `400` rather than a silently ignored key on a charged run
            (#1345).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TranslateRequest'
      responses:
        '200':
          description: Accepted (or dry-run preview).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/TranslateAccepted'
                  - $ref: '#/components/schemas/TranslateDryRun'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: >
            `insufficient_credits` — the estimate exceeds the workspace
            balance (pending markers stay; the next push retries them once
            topped up); carries `availableCredits`, what a new job may spend.
            `spend_cap_exceeded` — the monthly spending cap refused it; carries
            `capRemaining` (what is left under the cap, and the number the
            refusal was decided against) plus `spendingCap` for context. Or
            `plan_limit` — the push would exceed the plan's key cap; carries
            `details: [{feature, limit}]`. If concurrent writes exhaust capacity
            partway through the batch, the detail also includes `appliedKeys`
            for entries already applied; retrying them is safe.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: >
            `forbidden`: the credential lacks the required scope.
            `authorization_required`: the key is an Agent key
            (`requiresOperationApproval`) and the request would queue billable
            work without `operationId`; carries `estimatedCredits`, the number
            to request with `POST /operations`. Work that queues nothing
            billable needs no operation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >
            `operation_not_approved` — `operationId` names an operation that
            is not approved yet; `budget_exhausted` — it has no budget left
            (`charged + reserved` reached `authorized`); `scope_changed`: the
            operation was approved for another action, or carries no batch
            manifest. `batch_out_of_range` — `batchIndex` is outside the
            manifest; `batch_mismatch` — the body does not hash to
            `batches[batchIndex].sha256`, or `batchSha256` is not the body's
            hash; `attempts_exhausted` — the operation already ran three
            `attemptId`s; `batch_replayed` — another `requestId` holds this
            `(attemptId, batchIndex)` and has not failed. `request_mismatch`:
            `requestId` was already used for a different action or body.
            `request_in_progress` — another handler still owns `requestId`;
            carries `leaseUntil`, after which
            `GET /requests/{requestId}` is worth reading again.
            `request_expired` — `requestId` was resolved at least 24 hours
            ago and its response is no longer kept. Read its `stateWas` and
            `trackingToken` with GET /requests/{requestId}; do not automatically
            resend. Completed work needs no replay; failed work may use a new id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: >
            `unavailable` — the translation engine is not configured on this
            deployment, or the datastore refused a write; the message says
            which and, for a refusal, what the request left behind (re-run
            the push or translate call, never replay the request). It names
            a credit hold only when the attempt could not release one, in
            which case the sweep clears it within about 35 minutes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /projects/{projectId}/requests/{requestId}:
    get:
      tags: [translate]
      operationId: getRequest
      summary: What happened to a request sent with a requestId
      description: >
        Reads the record `POST /translate` or `POST /sync` keeps for a
        `requestId`, for the key that sent it. `unknown` means no record exists
        under this key. For an intent less than 24 hours old, the request had
        no effect because the record is its first write; sending it again with
        the same `requestId` is safe. Older intents must not be resent: their
        record may have been deleted. A record under another key or another project
        is `unknown` too. `processing` carries `leaseUntil`; wait until then
        and read again rather than re-sending. `done` carries the response
        the request answered, `failed` the status and body of its refusal. A
        request that failed is re-sent under a new `requestId`. An expired
        recoverable lease is taken by this GET or the periodic sweep. An
        enqueued request recovers the response captured with its enqueue;
        otherwise partial holds and markers are compensated before `failed`
        with `orphaned`. Legacy records without `recoveryVersion` are not
        automatically taken. `expired` means the request was resolved at least
        24 hours ago and its response is no longer kept: `stateWas`
        says how it ended and `trackingToken` names the job it enqueued, if
        any. The record is kept until 90 days after `closedAt`; after that the id reads
        `unknown` again, which is why an intent older than 24 hours is never
        re-sent. Any scope.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: requestId
          in: path
          required: true
          schema:
            type: string
            pattern: '^(?!__.*__$)[A-Za-z0-9_-]{1,128}$'
      responses:
        '200':
          description: The state of the request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RequestStatus'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /projects/{projectId}/sync:
    post:
      tags: [translate]
      operationId: sync
      summary: Sync a whole translation file (file-first CI)
      description: |
        The file-first counterpart of `POST /translate` (`easyi18n sync`,
        `docs/specs/ci-sync.md`). One request carries the entire translation
        file, in one of two shapes. An Apple String Catalog (`xcstrings`)
        holds every language in one file. A `json` or `nested_json` catalog
        is **base-only**: the file is the project's base language and
        nothing else, the shape Transloco, ngx-translate and i18next keep
        one file per locale of. `angular_json` is base-only too: the file
        `ng extract-i18n --format=json` writes, a root of exactly `locale`
        and `translations`, where each id under `translations` is a key and
        `locale` declares the language. Any other detected format, a single
        target-language file included, is rejected with
        `format_not_syncable`. The server parses it with the
        same parser as the web import, diffs it against the project by slug
        (a pure function of each source phrase) and content hash, applies
        only what changed, disables keys the file no longer carries
        (re-enabling them if they come back), and queues an AI fill for
        (key, language) pairs that have no value anywhere. Translations the
        file already carries are stored verbatim as imported overrides —
        never re-translated, never re-charged.

        A stored value the project produced (a person's edit, an AI fill, a
        memory hit) or a person signed off (`reviewed`/`approved`) is never
        reverted to the file's in silence: by default it is **kept** and the
        pair is listed under `conflicts`; `onConflict: file` writes the
        file's value over it, and still lists the pair. An imported value is
        the file's own lineage and the file wins over it as before.

        The base language gets the same protection when the request carries
        `pulledAt`: a source the project edited after that instant comes
        back as a `conflicts` row with `lang` = the base language, kept or
        overwritten under the same `onConflict`. Despite the name, what a
        current client sends there is the `snapshotAt` of the version it
        pulled — the instant its file is not behind — not the moment it ran
        the pull.

        `?dryRun=true` runs the full parse + classification + cost estimate
        with zero writes — the CLI's cost preview. `conflicts` is reported
        there too, so CI can gate before applying.

        A base-only sync registers the file's keys and source values and
        queues the fill for the project's target languages. It carries no
        target value, so `updatedTranslations` and `unchangedTranslations`
        are `0` and `conflicts` holds no target-language row. The
        base-language guard is unchanged: with `pulledAt`, a source the
        project edited after that instant is kept and reported. Each
        fillable target language keeps its `LANG_NOT_IN_FILE` row. The file
        is the project's whole catalog, as on every sync: a key it does not
        carry is disabled. Values are
        non-empty strings; an empty or non-string leaf rejects the catalog
        with `400 invalid_argument` before any content write. String values use
        ICU: `{name}` placeholders and `{count, plural, …}`. The project
        stores each key under a canonical name (`welcomeMessage` is stored
        as `welcome.message`) and keeps the spelling of the file beside it.
        The rows of the response name the stored one; `pull` renders the
        `json`, `nested_json` and `angular_json` formats under the spelling
        of the file. An Angular digest id (19 digits) is stored with a
        leading `k` and written back as the digest.

        Requires the `translate` scope. The language the file declares must
        match the project's base language (comparing language + script) or
        the request fails with `source_language_mismatch` — in CI a silent
        warning would store the wrong language forever. A String Catalog
        declares it in `sourceLanguage`. A base-only catalog declares it in
        a root `@@locale`, or in the `locale` of a file whose root holds
        exactly `locale` and `translations`; neither is read as a key, and
        a file that declares nothing is taken as the base language.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: dryRun
          in: query
          required: false
          schema:
            type: string
            enum: ["true", "false"]
          description: >-
            Classify and estimate without writing anything.
            The literal `true` or `false`, as a string. Omitting the
            parameter means a real run. Any other spelling — `True`, `1`,
            `yes`, or a present-but-empty `?dryRun=` — is a `400
            invalid_argument`: this flag guards the branch that writes and
            charges, so a value the door cannot read is refused rather
            than read as "not a dry run" (#1250).
            **In the query string, not the body** — the content run door
            takes it in the body, so a `dryRun` sent in this route's body is
            a `400` rather than a silently ignored key on a charged run
            (#1345).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contentBase64]
              properties:
                format:
                  type: string
                  description: >
                    Optional declared format id; a mismatch with the detected
                    format fails with `format_mismatch`.
                fileName:
                  type: string
                  description: Original file name (format detection hint).
                contentBase64:
                  type: string
                  description: >
                    The whole file, base64-encoded. Decoded size is capped at
                    2 MiB; the request body at 4 MiB.
                onConflict:
                  type: string
                  enum: [server, file]
                  default: server
                  description: >
                    What to do with a (key, language) whose file value
                    differs from a stored value the project produced or a
                    person signed: `server` keeps the stored value and only
                    reports the pair; `file` overwrites it — and still
                    reports the pair. Anything else is `invalid_argument`.
                requestId:
                  type: string
                  pattern: '^(?!__.*__$)[A-Za-z0-9_-]{1,128}$'
                  description: >
                    A caller-minted id for this HTTP request (a UUID fits).
                    Optional, and the same contract as `/translate`: the
                    record is the sync's first write, the same id with the
                    same action and body replays the recorded response and writes
                    nothing for 24 hours and is `409 request_expired` after
                    that, another action or body is `409 request_mismatch`, and
                    `GET /requests/{requestId}` reads the record. Scoped to
                    the API key. Ignored by `?dryRun=true` beyond its shape
                    check.
                attemptId:
                  type: string
                  pattern: '^(?!__.*__$)[A-Za-z0-9_-]{1,128}$'
                  description: >
                    Required with `operationId`. One ingest: every batch of it
                    carries the same id, and a re-ingest mints a new one. An
                    operation runs at most three (`409 attempts_exhausted`).
                batchIndex:
                  type: integer
                  description: >
                    Required with `operationId`: this batch's `index` in the
                    operation's manifest. At most one request per
                    `(attemptId, batchIndex)` that has not failed
                    (`409 batch_replayed`); after a `failed` one, the batch is re-sent
                    under a new `requestId`, same attempt and index.
                batchSha256:
                  type: string
                  description: >
                    Required with `operationId`: the hash the preview returned for
                    this body. The server hashes the body itself; a value that is
                    not that hash is `409 batch_mismatch`.
                operationId:
                  type: string
                  description: >
                    The approved AI operation this sync's fill spends under
                    (same contract as `/translate`: `operation_not_found`,
                    `operation_not_approved`, `budget_exhausted`,
                    `scope_changed`, and required for an Agent key when the
                    catalog has billable pairs; ignored by `?dryRun=true`,
                    whose `accepted` reflects the workspace balance only).
                pulledAt:
                  type: string
                  format: date-time
                  description: >
                    What this catalog's content is NOT behind. Send the
                    `snapshotAt` of the version you pulled: a pull renders
                    the published snapshot, not the live keys, so the moment
                    you ran it over-claims by however long ago that publish
                    was — days under `?version=`. The field keeps its name
                    for the clients that already send it; one that has no
                    `snapshotAt` (an older server, an older CLI) may still
                    send its own clock, which is the pre-#898 behavior.
                    Arms the base-language guard: a key whose source
                    the project edited AFTER this instant is reported under
                    `conflicts` with `lang` = the base language, and
                    `onConflict` decides whether the project's source is
                    kept (default) or overwritten. Omitted = no guard (the
                    pre-#671 behavior). Must name its zone (`Z` or
                    `±hh:mm`) — a bare local timestamp names a different
                    instant on every server and is refused. Malformed is
                    `invalid_argument`, never treated as absent.
                    A key whose source is kept counts in neither
                    `updatedSources` nor `matchedSources`, and any cell the
                    file carried in the OTHER shape (plural vs plain) than
                    the kept source is withheld, with a
                    `SOURCE_SHAPE_KEPT` diagnostic naming the language.
      responses:
        '200':
          description: Sync applied (or dry-run classified).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SyncResult'
        '400':
          description: >
            `source_language_mismatch`, `format_mismatch`,
            `format_not_syncable`, `too_many_entries`, `invalid_base64` or
            `invalid_argument`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: >
            `plan_limit` (key cap; details carry `feature`, `limit`, and
            `appliedKeys` if entries were applied before a concurrent refusal),
            `insufficient_credits` (the fill hold was
            refused for balance; carries `availableCredits`) or
            `spend_cap_exceeded` (refused by the monthly cap; carries
            `capRemaining` and `spendingCap`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: >
            `forbidden`: the credential lacks the required scope.
            `authorization_required`: the key is an Agent key
            (`requiresOperationApproval`) and the request would queue billable
            work without `operationId`; carries `estimatedCredits`, the number
            to request with `POST /operations`. Work that queues nothing
            billable needs no operation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >
            `operation_not_approved` — `operationId` names an operation that
            is not approved yet; `budget_exhausted` — it has no budget left
            (`charged + reserved` reached `authorized`); `scope_changed`: the
            operation was approved for another action, or carries no batch
            manifest. `batch_out_of_range` — `batchIndex` is outside the
            manifest; `batch_mismatch` — the body does not hash to
            `batches[batchIndex].sha256`, or `batchSha256` is not the body's
            hash; `attempts_exhausted` — the operation already ran three
            `attemptId`s; `batch_replayed` — another `requestId` holds this
            `(attemptId, batchIndex)` and has not failed. `request_mismatch`:
            `requestId` was already used for a different action or body.
            `request_in_progress` — another handler still owns `requestId`;
            carries `leaseUntil`, after which
            `GET /requests/{requestId}` is worth reading again.
            `request_expired` — `requestId` was resolved at least 24 hours
            ago and its response is no longer kept. Read its `stateWas` and
            `trackingToken` with GET /requests/{requestId}; do not automatically
            resend. Completed work needs no replay; failed work may use a new id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: File or request body over its size cap.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: >
            `unavailable` (no AI provider configured, or the datastore
            refused a write — the message says which; re-run the sync,
            never replay the request, and it names a credit hold only when
            the attempt could not release one, cleared by the sweep within
            about 35 minutes) or `contended` (transient balance contention
            — retry).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /projects/{projectId}/output-formats:
    post:
      tags: [projects]
      operationId: addOutputFormat
      summary: Add or adopt an output format atomically
      description: |
        Requires a project API key (`eik_`) with `write` scope. No operation
        approval is needed. Rejects `dryRun` in either body or query.
        `add` preserves configured formats and adds the requested ID. `adopt`
        only permits an empty effective list or an already effective ID.
        If configuration is empty, the effective list is the ordered union of
        published formats from every retained version (document ID order).
        Once materialized, this configuration applies to every version. It can
        expand exports from pinned versions; a pull without a format can then
        return 409 `export_collision` if an added format has key collisions.
        Legacy IDs are preserved. The candidate may contain at most two IDs.
        An already configured ID is a no-op, even in a legacy list over cap.
        The project CAS rechecks live state, plan lock and legacy versions on
        retry. Configuration and one `project.updated` event are atomic;
        no-op and losing requests leave neither project changes nor events.
        Authentication retains its ordinary API-key `lastUsedAt` update.
      security:
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/projectId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [format]
              properties:
                format:
                  type: string
                  enum: [json, nested_json, angular_json, ios_strings, xcstrings, android_xml, arb, po]
                mode:
                  type: string
                  enum: [add, adopt]
                  default: add
      responses:
        '200':
          description: Persisted formats; created is true only when this call materialized or changed configuration.
          content:
            application/json:
              schema:
                type: object
                required: [formats, created]
                properties:
                  formats:
                    type: array
                    items: {type: string}
                    description: Stored IDs, including legacy IDs; not a closed catalog.
                  created: {type: boolean}
        '400':
          description: invalid_argument (body, types, mode or dryRun); invalid_format (unknown requested ID). Internal replacement rejects too_many_formats with 400; this additive operation uses 409.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '401':
          description: Missing or invalid API key.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '402':
          description: plan_limit; details[0].feature is project_locked.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '403':
          description: API key lacks write scope.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '404':
          description: Missing or deleted project; checked before scope.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '409':
          description: too_many_formats (candidate exceeds two), output_format_conflict (different adoption), or output_config_conflict (CAS retries exhausted).
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '500':
          description: Indeterminate failure; the write may have committed. Explicitly rerunning the command is safe; no automatic request replay is implied.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '503':
          description: Datastore refused a write; no output configuration changed. Send the output-format request again.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}

  /projects/{projectId}/languages:
    post:
      tags: [projects]
      operationId: addLanguage
      summary: Add a target language to the project
      description: |
        Requires a project API key (`eik_`) with `write` scope. No operation
        approval is needed: adding a language charges nothing and runs no AI.
        Rejects `dryRun` in either body or query. The code is normalized
        (`PT_BR` becomes `pt-br`) and validated as a BCP 47 locale.
        Idempotent: re-adding a removed language revives it with `fillMode`
        (default `ai`). Re-adding a live one keeps its mode and writes nothing
        unless `fillMode` names a different mode; then only the mode changes.
        Languages have no plan cap. The response is the same object the
        dashboard receives and carries the mode the language has now.
      security:
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/projectId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code:
                  type: string
                  description: Locale code, e.g. `fr` or `pt-BR`.
                fillMode:
                  type: string
                  enum: [ai, manual]
                  default: ai
                  description: '`manual` excludes the language from AI fills.'
      responses:
        '201':
          description: The language is live on the project.
          content:
            application/json:
              schema:
                type: object
                required: [code, isBase, fillMode]
                properties:
                  code:
                    type: string
                    description: The normalized code that was stored.
                  isBase:
                    type: boolean
                    description: Always false; the base language is set when the project is created.
                  fillMode:
                    type: string
                    enum: [ai, manual]
        '400':
          description: invalid_argument (body, code, fillMode, dryRun, or the project's base language).
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '401':
          description: Missing or invalid API key.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '402':
          description: plan_limit; details[0].feature is project_locked.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '403':
          description: API key lacks write scope; checked before the body.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '404':
          description: Missing or deleted project; checked before scope.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '409':
          description: conflict; the language was removed while the request ran. Nothing was written. Send the request again.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '500':
          description: Indeterminate failure; the language may have been added. Sending the request again is safe.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '503':
          description: Datastore refused the write; no language was added. Send the request again.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}

  /projects/{projectId}/publish:
    post:
      tags: [publish]
      operationId: publish
      summary: Publish the project as a new immutable version
      description: |
        Freezes the project's live keys + translations into a new immutable
        `YYYY.MM.DD.N` version and points delivery (`/manifest`) at it. This
        is what `easyi18n push --publish` calls once the fill completes.

        This operation does **not** support `dryRun`, in the query or in the
        body. Sending it is a `400 invalid_argument` rather than a silent
        no-op: it used to be dropped unread, and the caller who asked for a
        preview got a published version and webhook delivery behind a 201
        (#1368).

        `incompleteMode` controls keys whose target-language value is still
        missing: `source` bakes the base-language text, `skip` omits the key
        from that locale, and `hold` is accepted for symmetry with the Content
        dimension but degrades to `skip` here. When omitted, the server
        resolves it from the project's integration mode: `skip` for a TMS
        project (`mode: a` — baking source text into pulled files permanently
        silences the customer's own untranslated report), `source` otherwise
        (runtime-SDK or undeclared). `approvedOnly: true` additionally treats
        unapproved translations as missing.

        Requires the `publish` scope. Rate-capped per project (30/day via
        API keys). A 409 `export_collision` means two keys collapse to the
        same name in a lossy configured format — rename one and retry.

        An Agent key (`requiresOperationApproval`) publishes only under an
        approved `publish` operation whose scope equals this body's
        `approvedOnly`, `require_complete` and `incompleteMode` (an omitted
        `incompleteMode` matches none). The operation is single use: the
        publish takes the use before it writes, and gives it back when it is
        refused without minting (`fill_incomplete`, `export_collision`, the
        output-format conflicts). An approval lapses 30 minutes after it was
        given.
      parameters:
        - $ref: '#/components/parameters/projectId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  description: Optional human label stored on the version.
                incompleteMode:
                  type: string
                  enum: [source, skip, hold]
                  description: >
                    Defaults to the project's integration-mode default:
                    `skip` for `mode: a` (TMS), `source` otherwise. `hold` is
                    accepted for symmetry with the Content dimension and
                    degrades to `skip` here: a strings publish is one
                    monolithic project snapshot, with no per-key served
                    version to hold.
                approvedOnly:
                  type: boolean
                  default: false
                  description: >-
                    Publish only cells on the approved rung. A real JSON
                    boolean — a string `"true"` is a `400 invalid_argument`,
                    not a wider publish. This flag NARROWS what ships, so an
                    unreadable value read as `false` would publish MORE
                    (unapproved translations, to the served pointer) on a 201
                    describing a successful publish (#1322). A workspace with
                    `requireApproval` on publishes approved cells only, and
                    `false` here does not lower it (#1677).
                require_complete:
                  type: boolean
                  default: false
                  description: >
                    Refuse with 409 fill_incomplete before minting or returning
                    unchanged if the snapshot has incomplete cells or exhausted
                    fill markers. Uses the effective approval policy and the
                    same ICU/markup gates as publication. Source fallback and
                    skip do not satisfy completeness. Explicit null and other
                    non-boolean values are 400 invalid_argument.
                requiredOutputFormat:
                  type: string
                  enum: [json, nested_json, angular_json, ios_strings, xcstrings, android_xml, arb, po]
                  description: >
                    Optional requirement, checked before snapshot construction
                    and skipIfIdentical. Must be configured; otherwise 409
                    output_format_conflict. A change to the ordered formats
                    during commit gives 409 output_config_conflict without
                    minting a version or event. All configured formats retain
                    collision checks. Before returning unchanged, the project
                    is reread and changed formats are rejected with
                    output_config_conflict (a missing/deleted project is 404).
                    Minting requires an updateTime CAS token on every attempt;
                    its absence also gives output_config_conflict. This does
                    not reserve configuration until a subsequent pull.
                    Omission preserves historical behavior.
                skipIfIdentical:
                  type: boolean
                  default: false
                  description: >
                    When true and the would-be snapshot's digest equals the
                    current version's, nothing is minted: the response is 200
                    with the CURRENT versionId and `unchanged: true`. Lets a
                    CI loop publish on every run while only real changes
                    consume versions (and the 30/day cap). A real JSON boolean —
                    a string `"true"` is a `400 invalid_argument` (#1322).
                operationId:
                  type: string
                  description: >
                    The approved `publish` operation this publish uses.
                    Required for an Agent key; optional for any other key,
                    which is then held to the same checks.
      responses:
        '200':
          description: >
            `skipIfIdentical` matched the current version's snapshot digest —
            nothing was minted; `versionId` is the current version.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublishResult'
        '201':
          description: Published.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublishResult'
        '400':
          description: >
            invalid_argument for dryRun in the query or body, malformed JSON,
            a non-object body, invalid incompleteMode, non-boolean values for
            approvedOnly or skipIfIdentical (null uses the default), non-boolean
            require_complete (including null), or non-string requiredOutputFormat
            (including null); invalid_format for an unknown or empty format ID.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >
            `forbidden`: the credential lacks the `publish` scope.
            `authorization_required`: an Agent key sent no `operationId`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >
            `not_found`: the project does not exist. `operation_not_found`:
            `operationId` names no operation of this key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: >
            `operation_not_approved`: the operation is pending, denied,
            expired or `exhausted` (already used). `scope_changed`: it was approved for
            another action or other filters.
            `export_collision` — two keys collapse to one name in a lossy
            configured format; the payload itemizes the colliding groups.
            output_format_conflict means the required format is not configured.
            output_config_conflict means formats changed during the commit;
            neither conflict mints a version, event or publication counter.
            fill_incomplete means require_complete rejected the snapshot;
            pending counts incomplete base and target cells before fallback,
            exhausted lists fill markers, and refused explains ICU/markup gates.
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: '#/components/schemas/Error'
                  - $ref: '#/components/schemas/PublishIncompleteError'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: >
            `unavailable` — the datastore refused the write; no version was
            published by this request, so publish again.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /projects/{projectId}/versions/{versionId}/restore:
    post:
      tags: [publish]
      operationId: restoreVersion
      summary: Roll back by restoring a prior version as a new version
      description: |
        Copies a prior version's snapshot into a NEW `YYYY.MM.DD.N` version
        (restore-as-new-version) and points delivery at it; history is never
        rewritten.
        The snapshot is copied verbatim and never filtered. In a workspace
        with `requireApproval`, a version holding any non-base cell whose
        status at its publish was not `approved` is refused whole with
        `409 version_not_approved`, and nothing is written (#1703). The check
        reads each cell's status as that version recorded it: a cell approved
        since still counts as unapproved there, and publishing again is how
        the approved translations go live.

        This operation does **not** support `dryRun`, in the query or in the
        body. Sending it is a `400 invalid_argument`, not a preview: it used to
        be discarded and the rollback moved every served pointer behind a 201
        describing a successful write (#1368). The optional JSON body carries
        `operationId`. Malformed JSON or a non-object body returns
        `400 invalid_argument`. An empty body is allowed; Agent keys still
        require `operationId`.

        `versionId` may be the literal `previous` to mean "the newest version
        older than the current one" — what `easyi18n rollback` sends.

        Requires the `publish` scope. Rate-capped like publish.

        An Agent key (`requiresOperationApproval`) rolls back only under an
        approved `rollback` operation whose `from_version` is still the
        current version and whose `to_version` is the version this request
        resolves to (`previous` included). Single use and a 30-minute lapse,
        as on publish.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: versionId
          in: path
          required: true
          schema:
            type: string
          description: A `YYYY.MM.DD.N` version id, or the literal `previous`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                operationId:
                  type: string
                  description: >
                    The approved `rollback` operation this restore uses.
                    Required for an Agent key; optional for any other key,
                    which is then held to the same checks.
      responses:
        '201':
          description: Restored as a new version.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PublishResult'
                  - type: object
                    properties:
                      restoredFrom:
                        type: string
                        description: The version id that was restored.
                      icuRejected:
                        type: array
                        items:
                          type: string
                        description: >
                          On a restore these carry the OPPOSITE verdict to a
                          publish: the snapshot is copied verbatim, so the
                          listed cells are PRESENT in the restored version —
                          nothing was dropped — and today's publish gates
                          would refuse them. Treat them as a warning about
                          what the restored version serves, never as cells
                          that were removed. Same cap and companion total as
                          on a publish.
                      markupRejected:
                        type: array
                        items:
                          type: string
                        description: >
                          Same restore reading as `icuRejected` — present and
                          warned, not dropped — for the markup parity gate.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >
            `forbidden`: the credential lacks the `publish` scope.
            `authorization_required`: an Agent key sent no `operationId`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >
            `not_found`: the project or version does not exist.
            `operation_not_found`: `operationId` names no operation of this
            key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: >
            `operation_not_approved`: the operation is pending, denied,
            expired or `exhausted` (already used). `scope_changed`: it was approved for
            another action, from a version that is no longer current, or to
            another version.
            `version_not_approved`: the workspace requires approval and the
            version holds cells published without it; `unapproved` lists the
            first 50 as `key [lang]` and `unapproved_total` counts them all.
            The operation stays `approved` and the daily restore cap is not
            used.
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: '#/components/schemas/Error'
                  - $ref: '#/components/schemas/VersionNotApprovedError'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'

  /projects/{projectId}/capture:
    post:
      tags: [translate]
      operationId: capture
      summary: Register untranslated sources seen at runtime
      description: |
        Debug-only auto-capture: the runtime SDK reports the `tr()` sources it
        had to render raw. Unknown sources are registered as **captured**
        keys — untranslated, flagged for dashboard review, never translated
        or charged. Idempotent: already-known sources are skipped.

        Requires the minimal `capture` scope (a debug-only dev token, never
        shipped in release builds). At most 200 units per batch.
      parameters:
        - $ref: '#/components/parameters/projectId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [units]
              properties:
                units:
                  type: array
                  minItems: 1
                  maxItems: 200
                  items:
                    type: object
                    properties:
                      source:
                        type: string
                        maxLength: 5000
                        description: >
                          The raw source string rendered untranslated.
                          Exactly one of `source` / `forms` per unit; invalid
                          units are skipped (capture is best-effort).
                      forms:
                        type: object
                        additionalProperties:
                          type: string
                          maxLength: 5000
                        description: >
                          Plural capture (gettext `_n()`): source plural forms
                          keyed by CLDR category, `other` mandatory. Mutually
                          exclusive with `source`.
                      ctx:
                        type: string
                        description: Optional disambiguation context.
      responses:
        '200':
          description: Batch processed.
          content:
            application/json:
              schema:
                type: object
                required: [captured, skipped]
                properties:
                  captured:
                    type: integer
                    description: New captured keys created.
                  skipped:
                    type: integer
                    description: Sources already registered as live keys.
                  contended:
                    type: integer
                    description: >
                      Sources whose key could not be admitted because
                      concurrent writers kept winning the project's key
                      counter or the name it probed. Nothing was written for
                      them; capture them again. Absent when zero.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: >-
            `plan_limit` — the batch would exceed the plan's key cap; carries
            `details: [{feature, limit}]`, with `appliedKeys` when a concurrent
            capacity change refused the batch after some keys were created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'

  /projects/{projectId}/translations:
    get:
      tags: [files]
      operationId: getTranslations
      summary: Pull rendered files, poll a fill job, or read one key
      description: |
        Three read modes on one path, selected by query parameter:

        1. **Published files** (no `trackingToken`/`ref`): renders the
           project's published version through its configured output
           formatters and returns the files inline. Filter with `version`
           (calver or `latest`), `format`, `lang`. Requires the `read` scope.
        2. **Fill-job summary** (`?trackingToken=`): per-language progress of
           a `POST /translate` job — counts of translated / pending / failed
           (key, lang) pairs and the settled `creditsCharged`. Requires
           `read` or `translate`.
        3. **Single key** (`?ref=KEY[&lang=]`): the current translations of
           one managed key. Requires `read` or `translate`.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: trackingToken
          in: query
          required: false
          schema:
            type: string
          description: Poll the fill job accepted by `POST /translate`.
        - name: ref
          in: query
          required: false
          schema:
            type: string
          description: Read the current translations of one managed key (its slug).
        - name: version
          in: query
          required: false
          schema:
            type: string
            default: latest
          description: 'Published-files mode: a calver version id (`YYYY.MM.DD`) or `latest`.'
        - name: format
          in: query
          required: false
          schema:
            type: string
          description: 'Published-files mode: restrict to one configured output format.'
        - name: lang
          in: query
          required: false
          schema:
            type: string
          description: Restrict to one language (published-files and single-key modes).
      responses:
        '200':
          description: Depends on the mode (see description).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/PublishedFiles'
                  - $ref: '#/components/schemas/FillJobSummary'
                  - $ref: '#/components/schemas/KeyTranslations'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: >
            `not_found` (project, tracking token or key),
            `not_published`, `version_not_found` or `lang_not_found`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: >
            `export_collision` (published-files mode): two distinct canonical
            keys collapse to the same identifier in a lossy target format
            (e.g. camelCase `.arb`), so serving would silently drop one. Rename
            one of the keys and re-publish, or request a path-preserving format
            (`?format=json`). A `json` or `nested_json` export collides only
            when two keys carry the same file spelling, or one spelling is a
            parent path of another in `nested_json`; disable or delete one
            of them and re-publish.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExportCollisionError'
        '429':
          $ref: '#/components/responses/RateLimited'

  /projects/{projectId}/meta:
    get:
      tags: [files]
      operationId: getMeta
      summary: Project configuration for API consumers
      description: >
        The project's live configuration: base language, the language
        catalogue as configured today (with `fillMode` and whether each
        language made the current published version), configured output
        formats, publish pointers for the Strings and Content dimensions, and
        the delivery host the runtime SDK should target (the default CDN host,
        or the project's active custom domain). Answers 200 on a project with
        no published version yet (`currentVersionId: null`) — integrators can
        read the language list before the first publish. Note the deliberate
        asymmetry with the manifest: a language deleted after a publish
        disappears from this catalogue but keeps being served from the
        immutable version snapshot until the next publish. Requires the `read`
        scope.
      parameters:
        - $ref: '#/components/parameters/projectId'
      responses:
        '200':
          description: Project configuration.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Meta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /projects/{projectId}/pending-code-changes:
    get:
      tags: [files]
      operationId: getPendingCodeChanges
      summary: Open source-verify code changes
      description: >
        The unresolved `tr('old') → tr('new')` rewrites a source review applied
        to SDK-managed keys (`source-verify.md` §5). `easyi18n apply` fetches
        this list to rewrite the literals in the dev's repo; a subsequent push
        of the corrected literal resolves each entry automatically. Requires the
        `read` scope.
      parameters:
        - $ref: '#/components/parameters/projectId'
      responses:
        '200':
          description: Open pending code changes.
          content:
            application/json:
              schema:
                type: object
                required: [changes]
                properties:
                  changes:
                    type: array
                    items:
                      type: object
                      required: [key, oldSource, newSource]
                      properties:
                        key:
                          type: string
                          description: The resulting key (the winner in a merge).
                        ctx:
                          type: string
                          nullable: true
                        oldSource:
                          type: string
                          description: The literal currently in the dev's code.
                        newSource:
                          type: string
                          description: The corrected literal to rewrite it to.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: '`not_found`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /projects/{projectId}/manifest:
    get:
      tags: [delivery]
      operationId: getManifest
      summary: Delivery manifest for the published version
      description: |
        The runtime SDK's entry point: one manifest per project listing, per
        locale, the current content-addressed bundle (hash + root-relative
        URL). Public — published bundles ship inside apps anyway.

        Conditional fetch: the response carries a strong `ETag`
        (sha256 of the canonical JSON) and `Cache-Control: no-cache`, so a
        client revalidates on every request; a matching `If-None-Match`
        returns `304`. Bundle URLs are
        root-relative, so bundles are always pulled from the same host the
        manifest came from (default CDN or a custom domain).
      security: []
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: channel
          in: query
          required: false
          schema:
            type: string
            enum: [production]
            default: production
          description: Publishing channel. Only `production` exists today.
        - name: version
          in: query
          required: false
          schema:
            type: string
            enum: [latest]
            default: latest
          description: Version pinning is not supported yet — only `latest`.
      responses:
        '200':
          description: The delivery manifest.
          headers:
            ETag:
              schema:
                type: string
              description: Strong validator — sha256 of the canonical manifest JSON.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Manifest'
        '304':
          description: Not modified (`If-None-Match` matched).
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          description: '`not_found`, `not_published`, `channel_not_found` or `version_not_found`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /projects/{projectId}/bundles/{locale}/{file}:
    get:
      tags: [delivery]
      operationId: getBundle
      summary: One immutable, content-addressed locale bundle
      description: |
        Serves the bundle advertised by the manifest. `{file}` is
        `{bundleHash}.json`; the content is immutable by construction
        (`Cache-Control: public, max-age=31536000, immutable`). A stale hash
        is never re-served from the origin — the SDK re-fetches the manifest
        instead. Public, no auth.
      security: []
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: locale
          in: path
          required: true
          schema:
            type: string
          description: Locale code (e.g. `es`, `pt-BR`).
        - name: file
          in: path
          required: true
          schema:
            type: string
            pattern: '^[a-f0-9]+\.json$'
          description: '`{bundleHash}.json` — the hash from the manifest.'
      responses:
        '200':
          description: The bundle, canonical JSON.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Bundle'
        '404':
          description: '`not_found` — unknown locale, stale hash, or unpublished project.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /@{handle}/{slug}/manifest:
    get:
      tags: [delivery]
      operationId: getManifestByHandle
      summary: Delivery manifest by workspace handle + project slug
      description: >
        Same manifest as `GET /projects/{projectId}/manifest`, addressed by the
        human-readable `@handle/slug` pair (mirrors the dashboard URL) so a
        shipped app bakes stable identifiers instead of the opaque id. Additive
        — the id route still works. Bundle URLs it advertises point at the
        canonical `/projects/{id}/bundles/...` path (single cached origin).
        Public, no auth.
      security: []
      parameters:
        - $ref: '#/components/parameters/handle'
        - $ref: '#/components/parameters/slug'
        - name: channel
          in: query
          required: false
          schema:
            type: string
            enum: [production]
            default: production
          description: Publishing channel. Only `production` exists today.
        - name: version
          in: query
          required: false
          schema:
            type: string
            enum: [latest]
            default: latest
          description: Version pinning is not supported yet — only `latest`.
      responses:
        '200':
          description: The delivery manifest.
          headers:
            ETag:
              schema:
                type: string
              description: Strong validator — sha256 of the canonical manifest JSON.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Manifest'
        '304':
          description: Not modified (`If-None-Match` matched).
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          description: >
            `not_found` (unknown handle/slug, unclaimed handle, or trashed
            project), `not_published`, `channel_not_found` or `version_not_found`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /@{handle}/{slug}/meta:
    get:
      tags: [files]
      operationId: getMetaByHandle
      summary: Project configuration by workspace handle + project slug
      description: >
        Same payload as `GET /projects/{projectId}/meta`, addressed by the
        `@handle/slug` pair. Requires the `read` scope.
      parameters:
        - $ref: '#/components/parameters/handle'
        - $ref: '#/components/parameters/slug'
      responses:
        '200':
          description: Project configuration.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Meta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /projects/resolve:
    get:
      tags: [files]
      operationId: resolveProject
      summary: Resolve a workspace handle + project slug to the opaque ids
      description: >
        Maps the human-readable `workspace` handle + `project` slug pair (the
        CLI's `easyi18n.yaml` ref) to `{projectId, workspaceId}`. Any valid key
        for the resolved project is accepted — resolve is a prerequisite for
        read, translate and publish alike, so it imposes no scope of its own; a
        key bound to another project is rejected. Works pre-publish — it
        resolves the project doc, not a published version.
      parameters:
        - name: workspace
          in: query
          required: true
          schema:
            type: string
          description: The claimed workspace handle (the `@handle` segment).
        - name: project
          in: query
          required: true
          schema:
            type: string
          description: The project slug (unique within its workspace).
      responses:
        '200':
          description: The resolved identifiers.
          content:
            application/json:
              schema:
                type: object
                required: [projectId, workspaceId]
                properties:
                  projectId:
                    type: string
                  workspaceId:
                    type: string
        '400':
          description: '`invalid_argument` — missing `workspace` or `project`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: >
            `not_found` — unknown or unclaimed handle, unknown slug, or trashed
            project.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /health:
    get:
      tags: [delivery]
      operationId: health
      summary: Delivery health probe
      description: >
        Project-independent probe served through any delivery host (default
        or custom domain). Used by the custom-domain verifier to confirm
        end-to-end delivery before activating a domain. Public, uncached.
      security: []
      responses:
        '200':
          description: The delivery origin is healthy.
          content:
            application/json:
              schema:
                type: object
                required: [ok, service]
                properties:
                  ok:
                    type: boolean
                    const: true
                  service:
                    type: string
                    const: easyi18n-delivery

  /waitlist:
    post:
      tags: [delivery]
      operationId: waitlist
      summary: Marketing waitlist signup
      description: >
        Temporary endpoint backing the `easyi18n.com` coming-soon page.
        Stores the email and notifies the team. Public, unauthenticated,
        idempotent per email. Not part of the stable developer API.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        '200':
          description: Email accepted (newly subscribed or already on the list).
          content:
            application/json:
              schema:
                type: object
                required: [ok, status]
                properties:
                  ok:
                    type: boolean
                    const: true
                  status:
                    type: string
                    enum: [subscribed, already_subscribed]
        '400':
          description: Missing or malformed email.
        '413':
          description: Request body over 8 KiB (`payload_too_large`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Too many signups; retry shortly.

  /projects/{projectId}/content:
    get:
      tags: [content]
      operationId: listContent
      summary: List published documents
      description: |
        Every published, visible document with the version it currently serves
        and which languages that version carries. Requires the `read` scope.

        `langs[].source` is `translated` when that language was published on
        its own merits, or `fallback` when the base language is being served
        in its place. It is derived live from the served version, so a rollback
        is reflected on the next call.

        Carries an `ETag`; send it back as `If-None-Match` and an unchanged
        project answers `304`. Intended to be polled on a schedule — a CMS
        adapter compares `versionId` per document and pulls only what moved.

        Capped at 200 documents (pagination is not yet available). `truncated`
        reports whether the list is **known to be complete**, and is always
        present on both outcomes — `false` is the server asserting completeness,
        so an absent key means only that the server predates the field. It is
        `true` when the ceiling cut the list, and also when any published
        document had to be left out for another reason (its served version
        already reclaimed, a row the server could not decode). Never reconcile
        (delete, re-push, prune a local map) against a response with
        `truncated: true`: the documents that are not listed are missing, not
        gone.

        **Above 200 published documents reconciliation is currently
        impossible.** Plans without a document cap can legitimately exceed the
        ceiling; such a project gets `truncated: true` on every poll, and the
        200-document window is ordered by an opaque key, so the full set cannot
        be accumulated across polls either. Until pagination lands, a client at
        that size must keep its own `externalId → docId` map safe rather than
        rely on rebuilding it from here.

        Each entry echoes back the `externalId` the document was pushed with,
        when it had one, so a client that lost its `externalId → docId` map can
        rebuild it from one poll rather than re-pushing and creating duplicates.
        Omitted for documents created in the app or imported before clients sent
        the field. **Not unique**: nothing reserves an `externalId`, and the very
        accident this field exists to repair — a lost map followed by a re-push
        — produces two live documents sharing one. A client rebuilding its map
        must handle collisions (for example prefer the newest `publishedAt` and
        surface the rest for cleanup) rather than collapse them onto an
        arbitrary winner and leave the twin consuming the plan cap forever.
      parameters:
        - $ref: '#/components/parameters/projectId'
      responses:
        '200':
          description: The published documents.
          content:
            application/json:
              schema:
                type: object
                required: [project, baseLang, truncated, documents]
                properties:
                  project: {type: string}
                  baseLang: {type: string}
                  truncated:
                    type: boolean
                    description: >
                      `true` when the list is not known to be complete: the
                      200-document ceiling (or the scan-pass ceiling) cut it,
                      or a published document had to be skipped (served version
                      reclaimed, undecodable row). Always present: `false` is an
                      explicit assertion that every published document is here.
                  documents:
                    type: array
                    items:
                      type: object
                      required: [docId, title, format, versionId, publishedAt, langs]
                      properties:
                        docId: {type: string}
                        title: {type: string}
                        format:
                          type: string
                          enum: [markdown, html, plaintext]
                        baseLang: {type: string}
                        externalId:
                          type: string
                          description: >
                            The correlation id this document was last pushed
                            with (`externalId` on `POST`/`PUT .../content` — a
                            PUT against an existing document updates it).
                            **Omitted** when the document has none — created in
                            the app, or never pushed with one. **Not unique**
                            across documents; see the endpoint description.
                        versionId: {type: string}
                        publishedAt: {type: string, format: date-time}
                        langs:
                          type: array
                          items:
                            type: object
                            required: [code, source]
                            properties:
                              code: {type: string}
                              source:
                                type: string
                                enum: [translated, fallback]
                              stage:
                                type: string
                                enum: [translated, reviewed, approved]
                                description: >
                                  The human sign-off frozen into this snapshot
                                  at publish time. **Omitted when unknown** —
                                  on the base language, under
                                  `source: fallback`, and on snapshots
                                  published before this field existed. Absent
                                  is not "unsigned". `confirmedRevisionId` is
                                  deliberately not here: it only means
                                  something against the revision the document
                                  pull returns.
                              title:
                                type: string
                                description: >
                                  The translated document title frozen into
                                  this snapshot, so menus and archive cards can
                                  be labelled from the listing alone. **Omitted
                                  when unknown** — on the base language (the
                                  top-level `title` is the source title), under
                                  `source: fallback`, when the title was never
                                  translated, and on snapshots published before
                                  this field existed. Absent = keep the source
                                  title.
        '304':
          description: Unchanged since the supplied `If-None-Match`.
        '404':
          description: >
            No such project, or the Content dimension is not enabled for it.
    post:
      tags: [content]
      operationId: createContent
      summary: Import a document
      description: |
        Creates a document from raw text. Requires the `write` scope.

        Import **only** stores the source — it never starts a translation and
        so never spends credits. Running the pipeline is a separate, explicit
        action.

        Send an `Idempotency-Key` header to make a retried push safe: the
        reservation shares the document's commit, so a repeat returns the
        original `docId`/`revisionId` with `replayed: true` rather than
        creating a second document.

        Capped at 256 KB per body and 500 import writes per project per day.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: Idempotency-Key
          in: header
          required: false
          schema: {type: string}
          description: Opaque per-push token; a retry with the same value is deduplicated.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, text]
              properties:
                title: {type: string}
                text: {type: string}
                format:
                  type: string
                  enum: [markdown, html, plaintext]
                  description: Detected from the content when omitted. Immutable afterwards.
                baseLang:
                  type: string
                  description: Defaults to the project's base language.
                externalId:
                  type: string
                  description: >
                    The id this document has in the calling system (e.g. a
                    WordPress post id). Stored as provenance; not indexed and
                    not required to be unique — the caller owns the mapping.
                    Must be a string: an integer post id has to be sent as
                    `"42"`, not `42` (`400 invalid_argument`).
                origin:
                  type: string
                  description: Free-form label for where the push came from.
                fields:
                  type: object
                  additionalProperties: {type: string}
                  description: >
                    Named translatable units of the document (an excerpt, a
                    hand-written SEO title), by key. Keys match
                    `^[a-z][a-zA-Z0-9_]{0,39}$`; `title` is reserved. At most
                    16 keys, each value at most 4096 bytes of UTF-8, and short
                    enough for one translation call into every target language
                    of the project that is not on manual fill (the `400` names
                    the language and the longest length it takes). An empty
                    value is ignored on create.
      responses:
        '201':
          description: The document was created.
          content:
            application/json:
              schema:
                type: object
                required: [docId, revisionId]
                properties:
                  docId: {type: string}
                  revisionId: {type: string}
        '200':
          description: >
            A replay of an earlier push with the same `Idempotency-Key`;
            carries `replayed: true` and the original ids.
        '400':
          description: >
            Missing `title`/`text`, an unrecognised `format`, or a `fields`
            map that breaks one of its rules.
        '402':
          description: The project is read-only on its current plan.
        '413':
          description: >
            `payload_too_large` — body over 256 KB. `document_too_large` — the
            body is under the cap but the document does not fit once stored:
            each block of the text adds a record of about 140 bytes beside it,
            so tens of thousands of very short blocks outweigh the text
            itself, and named fields count against the same document as its
            title. The message carries the stored size and the limit. Content
            state, including deletion markers, is unchanged; no import quota
            is consumed. Authentication may still record API-key usage.
        '415':
          description: Content-Type is not `application/json`.
        '429':
          description: Daily import limit reached.

  /projects/{projectId}/content/{docId}:
    get:
      tags: [content]
      operationId: getContentDocument
      summary: Pull a published document
      description: |
        The published text of one document. Requires the `read` scope.

        Serves the version the document currently points at, unless `version`
        pins a retained one. Visibility is evaluated live: a document that was
        hidden or deleted answers `404` even though a published version still
        exists for it.

        Carries an `ETag`, like the listing.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: docId
          in: path
          required: true
          schema: {type: string}
        - name: lang
          in: query
          required: false
          schema: {type: string}
          description: >
            Required for `shape=raw`; omitting it answers 400 with the
            available languages so the caller need not re-read the listing.
            Optional for `shape=blocks`, which otherwise returns every
            language.
        - name: shape
          in: query
          required: false
          schema:
            type: string
            enum: [raw, blocks]
            default: raw
          description: >
            `raw` returns the document reassembled in its original format,
            ready to publish as-is. `blocks` returns the structural blocks for
            callers that sync per block.
        - name: version
          in: query
          required: false
          schema: {type: string}
          description: Pin a retained version instead of the one being served.
        - name: include
          in: query
          required: false
          schema: {type: string, enum: [reports]}
          description: >
            Attach the annotate reports (SEO, MQM). These are the target's
            latest run and are **not** anchored to the requested version —
            the response flags this with `reportsAnchored: false`.
      responses:
        '200':
          description: The published content.
          content:
            application/json:
              schema:
                type: object
                required: [project, docId, versionId, format, baseLang, sourceFields, content]
                properties:
                  project: {type: string}
                  docId: {type: string}
                  externalId:
                    type: string
                    description: >
                      The correlation id this document was last pushed with.
                      Same field, same omission rule and same non-uniqueness
                      caveat as the listing — present only when the document
                      carries one.
                  versionId: {type: string}
                  format: {type: string, enum: [markdown, html, plaintext]}
                  baseLang: {type: string}
                  sourceFields:
                    type: object
                    additionalProperties: {type: string}
                    description: >
                      The document's named fields in the source language as
                      they are NOW, `{}` when it has none. Not anchored to
                      `version`: compare against it, do not rebuild an old
                      source from it.
                  content:
                    type: object
                    additionalProperties:
                      type: object
                      properties:
                        source: {type: string, enum: [translated, fallback]}
                        stage:
                          type: string
                          enum: [translated, reviewed, approved]
                          description: >
                            The human sign-off FROZEN at publish time, never
                            read from the live target — an explicit `version`
                            serves older text, and the target may have moved on
                            since. **Omitted when unknown**: on the base
                            language, under `source: fallback` (the bytes
                            served are the base document, so the target's
                            signature does not describe them), and on snapshots
                            published before this field existed. Absent means
                            unknown, NOT "nobody signed it".
                        confirmedRevisionId:
                          type: string
                          description: >
                            The base revision a human vouched for, frozen with
                            `stage`. Equal to the document's `baseRevisionId`
                            when the sign-off was made against the source being
                            served. Omitted under the same conditions as
                            `stage`.
                        title:
                          type: string
                          description: >
                            The translated document title, frozen into the
                            snapshot with the text it describes. Omitted under
                            the same conditions as `stage`, and when the title
                            was never translated. Absent means unknown — keep
                            the source title.
                        fields:
                          type: object
                          additionalProperties: {type: string}
                          description: >
                            Translated named fields by key, frozen with the
                            text. Same omission rules as `title`; omitted when
                            no key was translated. An absent key means keep
                            its source text, never an empty value.
                        text:
                          type: string
                          description: Present for `shape=raw`.
                        blocks:
                          type: array
                          description: Present for `shape=blocks`.
                          items:
                            type: object
                            properties:
                              id: {type: string}
                              kind: {type: string}
                              text: {type: string}
                  reports:
                    type: object
                    description: Present only with `include=reports`.
                  reportsAnchored:
                    type: boolean
                    const: false
        '304':
          description: Unchanged since the supplied `If-None-Match`.
        '400':
          description: >
            `shape=raw` without `lang` (the body lists the available
            languages), or an unrecognised `shape`.
        '404':
          description: >
            No such document, it is hidden or deleted, it has never been
            published, the pinned version does not exist, or the requested
            language is not in that version.
    put:
      tags: [content]
      operationId: upsertContent
      summary: Replace a document's source content, creating it if absent
      description: |
        Stores new base content for the document, aligning it against the
        previous revision so unchanged blocks keep their translations and
        modified ones are flagged for re-translation. Requires the `write`
        scope.

        **Upsert:** when nothing lives at `docId` the push creates the
        document there and answers `201` with `created: true` — a client may
        map its own stable ids (e.g. `wp-post-7`) straight onto document ids
        and push without tracking create-vs-update. A client-chosen id must be
        1–64 characters of `[A-Za-z0-9_-]` (and not `__reserved__`), and
        `title` is required on the create. A document that was deleted is
        revived in place by the same push (`201`, same id, prior translations
        of unchanged blocks survive). No `Idempotency-Key` is needed: the id
        itself makes a replayed create land as `changed: false` on its winner.

        Re-pushing identical text is free and answers `changed: false` without
        writing — a CMS may safely re-send on every sync tick.

        A hidden (`disabled`) document still accepts updates: hiding controls
        delivery, not authoring. The document's `format` cannot change.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: docId
          in: path
          required: true
          schema: {type: string}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: >
                `text` is required unless the push carries `fields` for a live
                document: that push updates the named fields alone, writes no
                revision, takes no import quota and answers `changed: false`.
              properties:
                text: {type: string}
                title:
                  type: string
                  description: Optional rename on update; required when the push creates the document.
                format:
                  type: string
                  enum: [markdown, html, plaintext]
                  description: Only honoured when the push creates the document (detected from the text when omitted); immutable afterwards.
                baseLang:
                  type: string
                  description: Only honoured when the push creates the document; defaults to the project base language.
                externalId:
                  type: string
                  description: >
                    Provenance. Recorded on create, and **updated** on an
                    existing document when the push carries a new value — so a
                    document created in the app can acquire an id, and a
                    migrated id (`wp-42` → `wp-907`) is echoed correctly
                    afterwards. A push that omits it (or sends a blank) leaves
                    the stored id untouched. Not indexed and not required to be
                    unique — the caller owns the mapping. Must be a string
                    (`400 invalid_argument` otherwise).
                origin:
                  type: string
                  description: >
                    Provenance. Same rule as `externalId` above: recorded on
                    create, updated on an existing document when the push
                    carries a new value, left untouched when the push omits it.
                fields:
                  type: object
                  additionalProperties: {type: string}
                  description: >
                    A delta over the document's named fields, same key and
                    value rules as on `POST`. A key the push omits keeps its
                    value, `{}` changes nothing, and `""` deletes that key; the
                    next publish stops serving its translation. The 16-key cap
                    counts the map the push leaves, so adding a key to a
                    document that holds 16 is refused. The per-language length
                    check applies to values this push changes.
      responses:
        '200':
          description: The document's current revision.
          content:
            application/json:
              schema:
                type: object
                required: [docId, revisionId, changed]
                properties:
                  docId: {type: string}
                  revisionId:
                    type: string
                    description: >
                      The base revision: the hex `sha256` of the format name, a
                      newline and the text. A document re-cut by a newer block
                      parser over the same bytes gets that hash plus
                      `-p<version>`.
                  changed:
                    type: boolean
                    description: >
                      False when the text was byte-identical. Nothing is
                      charged then, but a document whose blocks an older parser
                      cut is re-cut and its health re-checked, so `revisionId`
                      can still move.
        '201':
          description: >
            The push created (or revived) the document at `docId`. Same body
            as `200` plus `created: true`.
        '400':
          description: >
            Missing `text` (it may be omitted only with `fields` on a live
            document), an attempt to change `format`, a `fields` map that
            breaks one of its rules, or — on a create — a missing `title` or an
            id outside the allowed shape.
        '402':
          description: >
            The project is read-only on its current plan, or the create would
            exceed the plan's document cap.
        '404':
          description: Content is not enabled, or the project does not exist.
        '409':
          description: >
            `edit_conflict` — another writer kept winning the document while
            this push was being applied. Pushes to one document are serialized
            server-side by a compare-and-swap; after a few lost rounds the
            request is refused rather than silently overwriting. Retry, or
            serialize your own pushes per document.
        '413':
          description: >
            `payload_too_large` — body over 256 KB. `document_too_large` — the
            body is under the cap but the document does not fit once stored:
            each block of the text adds a record of about 140 bytes beside it,
            so tens of thousands of very short blocks outweigh the text
            itself, and named fields count against the same document as its
            title. The message carries the stored size and the limit. Content
            state, including deletion markers, is unchanged; no import quota
            is consumed. Authentication may still record API-key usage.
        '415':
          description: Content-Type is not `application/json`.
        '429':
          description: Daily import limit reached.

  /projects/{projectId}/content/{docId}/run:
    post:
      tags: [content]
      operationId: runContentDocument
      summary: Price or start a pipeline run
      description: |
        Prices a translation run, or starts it. Requires the **`translate`**
        scope — the only Content route that does, and the only route on this
        API that can spend credits.

        With `dryRun: true` nothing is enqueued, nothing is reserved and
        nothing is charged; the response carries the estimate for the run that
        *would* happen. It is produced by the same estimator the run itself is
        gated on, so the number cannot drift from what you are billed against.

        Without `dryRun`, one job is enqueued per target language. Languages
        already running answer `409`; languages a `fill` cannot touch (free-form
        targets) or that need no work are reported under `skipped` rather than
        failing the request.

        A language may also come back as `skipped` with reason
        `run_in_progress`: a run on `(document, language)` is claimed
        atomically, and this is what the loser of a genuine race is told —
        another request claimed that language in the same instant, after the
        up-front check found it free. The request's other languages still run.

        If the project sets a cost-warning threshold and the estimate exceeds
        it, the request answers `409 cost_warning` with the estimate until you
        retry with `confirm: true`.

        The model resolves through the project's offered-models gate; an
        off-list `model` is a `400`. An API key is never a platform admin, so
        admin-gated models are unreachable here regardless of what is asked for.

        **The call is all-or-nothing across languages.** Every code in `langs`
        is checked against the project's configured languages first, so a single
        unrecognised code fails the whole request with `400 unknown_language`
        and starts *nothing* — including the languages that were valid. Send
        only codes the project actually has. The base language is the one
        exception: it is silently dropped rather than rejected.

        **A run reserves credits before it starts**, so either mode is refused
        with `402` when the workspace cannot fund it. The reservation is a
        ceiling (~2x the pre-flight estimate) and its unused remainder is
        released when the run ends, so a refusal quotes a number larger than
        the `dryRun` estimate for the same request. `mode: overwrite` deletes
        the existing translations before it rebuilds them, so its check runs
        before the delete and nothing is destroyed by a refusal.

        The reservation is **per language**, so a request can enqueue the first
        and be refused for the second. That is a `200`, not a `402`: `jobs`
        carries the languages that started and `skipped` carries the refused
        ones with `reason: insufficient_credits` / `spend_cap_exceeded` /
        `billing_contended`. `402` is reserved for a run that started
        **nothing**.

        Retry with exactly the languages `skipped` names. Re-sending the whole
        list instead gets `409 run_in_progress` for as long as the languages
        that did start are still running, so it cannot make progress until they
        finish.

        **Retries:** send an `Idempotency-Key` header. A repeat of the same key
        for the same document replays the original job ids with
        `replayed: true` instead of starting a second run. Without it, a client
        that times out and retries can enqueue twice and be charged twice.

        Once a key has an outcome it keeps it permanently, and that includes a
        run that started nothing: a `fill` with no work left replays as
        `{"replayed": true, "jobs": []}` forever, never as a fresh run.

        A key is only ever reusable when its request died before recording an
        outcome — a crash, or an instance killed mid-flight. Those are held for
        **two minutes** (a retry inside that window gets `409
        run_in_progress`), after which the key is released and the next request
        carrying it runs normally. A `402` refusal releases it immediately,
        since nothing was written: top up and retry the same key at once.
      security: [{apiKey: []}]
      parameters:
        - {name: projectId, in: path, required: true, schema: {type: string}}
        - {name: docId, in: path, required: true, schema: {type: string}}
        - name: Idempotency-Key
          in: header
          required: false
          schema: {type: string}
          description: >
            Makes a retry safe. Scoped to this document: the same key replays
            the run it started rather than beginning a new one.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                langs:
                  type: array
                  items: {type: string}
                  description: >
                    Target languages. Omit for every non-base language of the
                    project. A language on manual fill mode is a `400`.
                lang:
                  type: string
                  description: Convenience for a single language.
                mode:
                  type: string
                  enum: [fill, overwrite]
                  default: fill
                  description: >
                    `fill` translates what is missing or stale and leaves
                    settled work alone. `overwrite` re-translates everything,
                    approved included.
                model: {type: string}
                force:
                  type: boolean
                  description: >
                    Retry the blocks a previous fill gave up on. Never implied
                    by `overwrite`. Typed like `dryRun`: a non-boolean is a
                    `400 invalid_argument`. It fails safe on its own — a
                    string leaves it false and nothing retries — but a caller
                    who spelled it wrong is told.
                dryRun:
                  type: boolean
                  description: >-
                    Price the run without starting it. **In the body, not the
                    query string** — the sibling `/v1` doors (`translate`,
                    `sync`) take this flag as `?dryRun=`, and a `?dryRun` sent
                    here is a `400 invalid_argument` rather than a silently
                    ignored parameter on a charged run (#1321).
                    A real JSON boolean —
                    a string `"true"` is a `400 invalid_argument`, not a real
                    run: this flag guards the branch that reserves credits and
                    enqueues jobs, so a value the door cannot read is refused
                    rather than read as "not a dry run" (#1260).
                confirm:
                  type: boolean
                  description: Acknowledge a `cost_warning` and proceed.
      responses:
        '200':
          description: Jobs enqueued, or the estimate when `dryRun` is set.
          content:
            application/json:
              schema:
                type: object
                properties:
                  dryRun: {type: boolean}
                  replayed:
                    type: boolean
                    description: >
                      True when an `Idempotency-Key` matched an earlier run;
                      `jobs` then carries that run's ids and nothing new started.
                  estimatedMc:
                    type: integer
                    description: Millicredits. Present only for `dryRun`.
                  model: {type: string}
                  langs:
                    type: array
                    items: {type: string}
                  jobs:
                    type: array
                    items:
                      type: object
                      properties:
                        lang: {type: string}
                        jobId: {type: string}
                  skipped:
                    type: array
                    items:
                      type: object
                      properties:
                        lang: {type: string}
                        reason:
                          type: string
                          description: >
                            Why this language was left alone. The run-flow
                            half: `freeform` (a `fill` cannot touch a
                            free-form target), `up_to_date` (nothing to
                            translate), `field_unfittable` (the only work
                            left is a named field no single call can carry
                            into this language; `fields` lists each one with
                            `chars` and the longest value the language
                            takes, `maxChars`, and only the writer can
                            shorten it), `run_in_progress` (another request
                            claimed this language in the same instant — see
                            the description above). The credit half, for a
                            per-language reservation this workspace could not
                            fund: `insufficient_credits`, `spend_cap_exceeded`
                            and `billing_contended` (the ledger was busy;
                            nothing was charged and a retry works). A run
                            whose **every** language was refused for credit
                            answers `402` instead — or `503` when that refusal
                            is `billing_contended`, which is transient and
                            retryable. A run with nothing left to do reports
                            each language here and answers `200` with an empty
                            `jobs`.
                          enum:
                            - freeform
                            - up_to_date
                            - field_unfittable
                            - run_in_progress
                            - insufficient_credits
                            - spend_cap_exceeded
                            - billing_contended
                        fields:
                          type: array
                          description: >
                            Only with `field_unfittable`: the named fields
                            the run dropped for this language.
                          items:
                            type: object
                            properties:
                              key: {type: string}
                              chars: {type: integer}
                              maxChars: {type: integer}
        '400':
          description: >
            `unknown_language` — a code in `langs` is not configured on this
            project, or `langs`/`lang` was sent but held nothing usable (the
            whole request fails; nothing runs); `fill_mode_manual` — a requested
            language is set to manual translation; `model_not_offered` — the
            `model` is outside the project's list; `invalid_argument` — an
            unknown `mode`, a non-string `mode`/`model`, or no target languages
            at all; `empty_document` — the document parsed to no content.
        '401':
          description: Missing or unrecognised API key.
        '403':
          description: >
            The key does not carry the `translate` scope. `read`, `write` and
            `capture` cannot start or price a run. `authorization_required`:
            the key is an Agent key: no operation action covers a content run,
            so it cannot start or price one.
        '402':
          description: >
            The project is read-only on its current plan, or the workspace
            cannot fund the run's credit reservation and **no** language
            started (`insufficient_credits` / `spend_cap_exceeded`). A run that
            started some languages and was refused for others answers `200`
            and names both sets. Both modes are gated; an overwrite is checked
            before it deletes anything, so a refusal never destroys work it
            cannot rebuild.
        '404':
          description: No such document, or Content is not enabled.
        '409':
          description: >
            `run_in_progress` — a run is already in flight for a requested
            language, or an identical `Idempotency-Key` is still being
            processed (retry after the two-minute hold described above, which
            is the longest this can last);
            `health_blocked` — the document failed its health gate (a
            document whose blocks an older parser cut is re-cut and re-checked
            first, except on a dry run);
            `revision_missing` — the document's base revision is unavailable,
            so it must be re-imported before it can run; `cost_warning` — the
            estimate exceeds the project threshold, retry with `confirm`.
        '413':
          description: Body over 8 KB.
        '429':
          description: Daily run limit reached.
        '503':
          description: >
            `ai_unavailable` — no AI provider is configured on this deployment;
            `billing_contended` — the workspace ledger was too busy to place
            the run's reservation. Nothing was charged and nothing reserved:
            retry.

  /projects/{projectId}/webhooks:
    get:
      tags: [webhooks]
      operationId: listWebhooks
      summary: List the project's webhooks
      description: >
        Every configured endpoint, enabled or not. The signing secret is never
        echoed — only its 4-character tail (`secretHint`).
      parameters:
        - $ref: '#/components/parameters/projectId'
      responses:
        '200':
          description: The webhook list.
          content:
            application/json:
              schema:
                type: object
                required: [webhooks]
                properties:
                  webhooks:
                    type: array
                    items:
                      $ref: '#/components/schemas/Webhook'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [webhooks]
      operationId: createWebhook
      summary: Register a webhook endpoint
      description: >
        Registers an HTTPS endpoint for the given events. The response is the
        ONLY time the signing secret (`whsec_...`) is shown — store it. The
        URL is SSRF-checked at save time (https only, no credentials, no
        private/loopback/metadata targets) and re-checked at every delivery.
        Unlike the dashboard, `events` is required here: an API consumer
        states what it subscribes to. Capped at 5 webhooks per project.
      parameters:
        - $ref: '#/components/parameters/projectId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookCreateRequest'
      responses:
        '201':
          description: Created — carries the literal secret, shown once.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookCreated'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: '`plan_limit` — the project is read-only on the Free plan.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`webhook_limit` — the project already has 5 webhooks.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'

  /projects/{projectId}/webhooks/{webhookId}:
    patch:
      tags: [webhooks]
      operationId: updateWebhook
      summary: Update a webhook's url, events or enabled flag
      description: >
        Field-masked update; at least one of `url`, `events`, `enabled` is
        required. Re-enabling a disabled endpoint also resets its failure
        streak, so a fixed endpoint starts from a clean slate.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/webhookId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPatchRequest'
      responses:
        '200':
          description: The updated webhook.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Webhook'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
    delete:
      tags: [webhooks]
      operationId: deleteWebhook
      summary: Delete a webhook
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/webhookId'
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                required: [deleted]
                properties:
                  deleted:
                    type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /projects/{projectId}/webhooks/{webhookId}/rotate-secret:
    post:
      tags: [webhooks]
      operationId: rotateWebhookSecret
      summary: Rotate the signing secret
      description: >
        Mints a new secret and keeps the previous one verifying for a 24h
        grace window (deliveries carry a `v1=` signature for BOTH secrets
        while it lasts), so a rotation never breaks in-flight retries or a
        client that has not redeployed yet. The new secret is shown once.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/webhookId'
      responses:
        '200':
          description: The new secret — shown once — and the grace deadline.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookRotated'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /projects/{projectId}/webhooks/{webhookId}/deliveries:
    get:
      tags: [webhooks]
      operationId: listWebhookDeliveries
      summary: Recent deliveries of one webhook
      description: >
        The delivery log, newest first (max 50): per attempt state
        (`pending`/`delivering`/`delivered`/`failed`), attempt count, last
        HTTP status and error. The debugging surface for "my webhook is not
        arriving".
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/webhookId'
      responses:
        '200':
          description: The delivery log.
          content:
            application/json:
              schema:
                type: object
                required: [deliveries]
                properties:
                  deliveries:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookDelivery'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /projects/{projectId}/webhooks/{webhookId}/deliveries/{deliveryId}/resend:
    post:
      tags: [webhooks]
      operationId: resendWebhookDelivery
      summary: Re-queue one delivery
      description: >
        Re-queues a delivery — even a dead-lettered one — under the SAME
        `eventId` (it is a retry of the event, not a new event; dedup by
        `(webhookId, eventId)` still holds). Refused with 409 while a
        delivery attempt is in flight.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/webhookId'
        - name: deliveryId
          in: path
          required: true
          schema:
            type: string
          description: The delivery id (equals the publish/version id).
      responses:
        '200':
          description: Queued for redelivery.
          content:
            application/json:
              schema:
                type: object
                required: [deliveryId, queued]
                properties:
                  deliveryId:
                    type: string
                  queued:
                    type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`delivery_in_flight` — an attempt is happening right now.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: >
        Project API key (`eik_...`) created in the dashboard, sent as
        `Authorization: Bearer <key>`. Scopes checked by this API: `read`,
        `write`, `capture`, `translate`, `publish` (`admin` grants everything).
        They are checked exactly — holding one never implies another — so pick
        every scope the integration needs when you mint the key. In particular
        `write` (Content import) is separate from `capture` (auto-registering
        source strings).

  parameters:
    projectId:
      name: projectId
      in: path
      required: true
      schema:
        type: string
      description: The project id (shown in the dashboard's API panel).
    webhookId:
      name: webhookId
      in: path
      required: true
      schema:
        type: string
      description: The webhook id returned at creation.
    handle:
      name: handle
      in: path
      required: true
      schema:
        type: string
      description: >
        The workspace handle (globally unique, the `@handle` segment of the
        dashboard URL). Together with `slug` it names a project without the
        opaque id.
    slug:
      name: slug
      in: path
      required: true
      schema:
        type: string
      description: The project slug (unique within its workspace).

  responses:
    BadRequest:
      description: >
        Malformed request — `invalid_argument`, `too_many_units`,
        `source_too_long`, `no_target_langs`, `no_formats` or
        `ambiguous_previous_source`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing or invalid bearer credential (`unauthorized`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: The credential lacks the required scope (`forbidden`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Project not found (`not_found`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    PayloadTooLarge:
      description: Request body over 1 MiB (`payload_too_large`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: >
        Per-project request budget exhausted (`rate_limited`). Retry after
        the `Retry-After` header (seconds).
      headers:
        Retry-After:
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    OperationAction:
      type: string
      enum: [translate, publish, rollback]
    OperationRequest:
      type: object
      additionalProperties: false
      required: [action, scope]
      properties:
        action:
          $ref: '#/components/schemas/OperationAction'
        max_credits:
          type: integer
          minimum: 0
          maximum: 9007199254740
          description: Whole credits; required and positive for translate, otherwise omitted or zero.
        scope:
          oneOf:
            - $ref: '#/components/schemas/OperationTranslateScope'
            - $ref: '#/components/schemas/OperationPublishScope'
            - $ref: '#/components/schemas/OperationRollbackScope'
      allOf:
        - if:
            properties: {action: {const: translate}}
          then:
            required: [max_credits]
            properties:
              max_credits: {minimum: 1}
              scope: {$ref: '#/components/schemas/OperationTranslateScope'}
        - if:
            properties: {action: {const: publish}}
          then:
            properties:
              max_credits: {const: 0}
              scope: {$ref: '#/components/schemas/OperationPublishScope'}
        - if:
            properties: {action: {const: rollback}}
          then:
            properties:
              max_credits: {const: 0}
              scope: {$ref: '#/components/schemas/OperationRollbackScope'}
    OperationProjectId:
      type: string
      pattern: '^(?!__.*__$)[A-Za-z0-9_-]{1,128}$'
    OperationTranslateScope:
      type: object
      additionalProperties: false
      required: [project_id, langs, unit_count, batches, payload_sha256]
      properties:
        project_id:
          $ref: '#/components/schemas/OperationProjectId'
        langs:
          type: array
          minItems: 1
          uniqueItems: true
          items: {type: string}
          description: Valid locale codes of active target languages. Sorted in the stored scope.
        unit_count:
          type: integer
          minimum: 1
          maximum: 9007199254740991
          description: The sum of the batches' `unit_count`.
        batches:
          type: array
          minItems: 1
          maxItems: 1000
          description: >
            The manifest: the batches the ingest will send, in order. Each
            `sha256` is the `batchSha256` the `?dryRun=true` preview of that
            exact batch returned. A request under this operation must be one
            of these batches (`batch_out_of_range`, `batch_mismatch`).
          items:
            type: object
            additionalProperties: false
            required: [index, sha256, unit_count]
            properties:
              index: {type: integer, minimum: 0, description: The batch's position in the array.}
              sha256: {type: string, pattern: '^[a-f0-9]{64}$'}
              unit_count: {type: integer, minimum: 1}
        payload_sha256:
          type: string
          pattern: '^[a-f0-9]{64}$'
          description: >
            sha256 of `{"batches": [...]}` with every object's keys sorted,
            serialized without whitespace; anything else is
            `invalid_argument`. Shown to the approver.
    OperationPublishScope:
      type: object
      additionalProperties: false
      required: [project_id, approved_only, require_complete, incomplete_mode, expected_pending]
      properties:
        project_id:
          $ref: '#/components/schemas/OperationProjectId'
        approved_only: {type: boolean}
        require_complete: {type: boolean}
        incomplete_mode: {type: string, enum: [skip, source]}
        expected_pending: {type: integer, const: 0}
    OperationRollbackScope:
      type: object
      additionalProperties: false
      required: [project_id, from_version, to_version]
      properties:
        project_id:
          $ref: '#/components/schemas/OperationProjectId'
        from_version: {type: string, pattern: '^(?!\.{1,2}$)[A-Za-z0-9_.-]{1,128}$'}
        to_version: {type: string, pattern: '^(?!\.{1,2}$)[A-Za-z0-9_.-]{1,128}$'}
    OperationStatus:
      type: object
      additionalProperties: false
      required: [operation_id, action, state, scope, requested_mc, authorized_mc, reserved_mc, charged_mc, absorbed_mc, approval_url, expires_at, attempts]
      properties:
        operation_id: {type: string}
        action:
          $ref: '#/components/schemas/OperationAction'
        state:
          type: string
          enum: [pending_approval, approved, denied, expired, exhausted]
        scope:
          oneOf:
            - $ref: '#/components/schemas/OperationTranslateScope'
            - $ref: '#/components/schemas/OperationPublishScope'
            - $ref: '#/components/schemas/OperationRollbackScope'
        requested_mc: {type: integer, minimum: 0, description: Requested budget in millicredits, not yet authorized.}
        authorized_mc: {type: integer, minimum: 0}
        reserved_mc: {type: integer, minimum: 0}
        charged_mc: {type: integer, minimum: 0}
        absorbed_mc: {type: integer, minimum: 0}
        approval_url: {type: string, format: uri}
        expires_at: {type: string, format: date-time}
        attempts:
          type: array
          description: >
            The ingests run under this operation, ordered by `id`; empty for
            publish and rollback. At most three per operation.
          items:
            $ref: '#/components/schemas/OperationAttempt'
    OperationAttempt:
      type: object
      additionalProperties: false
      required: [id, requests]
      properties:
        id: {type: string, description: The `attemptId` the requests named.}
        requests:
          type: array
          description: >
            The request seated on each batch of the manifest, ordered by
            `batch_index`. A batch re-sent after its request failed lists only
            the latest request. `state` is read from the request record without
            reconciling it: a `processing` request whose lease lapsed is
            resolved by `GET /projects/{projectId}/requests/{requestId}`. A
            record past its response window reads `expired`, as that route
            answers it, whether or not the sweep has compacted it yet.
          items:
            type: object
            additionalProperties: false
            required: [id, batch_index, state]
            properties:
              id: {type: string, description: The `requestId`.}
              batch_index: {type: integer, minimum: 0}
              state:
                type: string
                enum: [unknown, processing, done, failed, expired]
              state_was:
                type: string
                enum: [done, failed]
                description: Present when `expired`, as in `RequestStatus.stateWas`.
    PublicPricing:
      type: object
      additionalProperties: false
      required: [schemaVersion, currency, priceSource, plans, packs]
      properties:
        schemaVersion:
          type: integer
          const: 1
        currency:
          type: string
          const: USD
        priceSource:
          type: string
          const: catalog
        plans:
          type: array
          items:
            $ref: '#/components/schemas/PublicPricingPlan'
        packs:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [id, priceCents, credits]
            properties:
              id:
                type: string
              priceCents:
                type: integer
                minimum: 0
              credits:
                type: integer
                minimum: 0
                description: Whole credits, purchased once and non-expiring
    PublicPricingPlan:
      type: object
      additionalProperties: false
      required: [id, offer, prices, monthlyCredits, limits]
      properties:
        id:
          type: string
        offer:
          type: string
          enum: [free, subscription, contact]
        prices:
          type: object
          additionalProperties: false
          description: USD cents per full billing period; empty for free/contact
          properties:
            monthly:
              type: integer
              minimum: 0
            annual:
              type: integer
              minimum: 0
        monthlyCredits:
          type: [integer, 'null']
          minimum: 0
          description: Whole monthly credits; null means negotiated for contact
        limits:
          type: [object, 'null']
          additionalProperties: false
          description: >
            Entire object null means negotiated contact terms. Each numeric null
            means unlimited; zero means none. Keys are a soft developer-API cap;
            documents include soft-deleted entries; API keys exclude revoked keys.
          required: [projectsPerWorkspace, administratorsPerWorkspace, keysPerProject, documentsPerProject, apiKeysPerProject]
          properties:
            projectsPerWorkspace:
              type: [integer, 'null']
              minimum: 0
            administratorsPerWorkspace:
              type: [integer, 'null']
              minimum: 0
              description: Includes owner and pending admin invitations
            keysPerProject:
              type: [integer, 'null']
              minimum: 0
            documentsPerProject:
              type: [integer, 'null']
              minimum: 0
            apiKeysPerProject:
              type: [integer, 'null']
              minimum: 0
    Error:
      type: object
      required: [error, message]
      properties:
        error:
          type: string
          description: Machine-readable error code.
        message:
          type: string
          description: Human-readable detail.
        details:
          type: array
          description: Structured error context; plan limits use feature and limit.
          items:
            type: object
            properties:
              feature:
                type: string
              limit:
                type: integer
              appliedKeys:
                type: array
                description: Keys with confirmed writes before a partial capacity refusal; later steps may still have failed.
                items:
                  type: string
              failedEntries:
                type: array
                description: Sync entry failures; a key may also appear in appliedKeys if creation succeeded before a later patch failed. Capacity-refused syncs skip fill and orphan disabling.
                items:
                  type: object
                  properties:
                    key:
                      type: string
                    code:
                      type: string
                    reason:
                      type: string

    TranslationUnitInput:
      type: object
      properties:
        source:
          type: string
          maxLength: 5000
          description: >
            The source-language text to translate. Exactly one of `source` /
            `forms` is required.
        forms:
          type: object
          additionalProperties:
            type: string
            maxLength: 5000
          description: >
            Plural push (gettext `_n()`): source plural forms keyed by CLDR
            category (`zero`/`one`/`two`/`few`/`many`/`other`; `other` is
            mandatory). Mutually exclusive with `source`. Identity is the
            canonical plural serialization's message token, so re-pushing the
            same forms is idempotent.
        ctx:
          type: string
          description: >
            Optional disambiguation context. Two identical sources with
            different `ctx` are distinct units.
        key:
          type: string
          pattern: '^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$'
          maxLength: 200
          description: >
            Optional preferred key name for a **newly minted** key (ignored
            when the unit matches or carries over an existing one — those keep
            the name they already have).

            Must be a **canonical key name**: `.`-separated segments of
            `[A-Za-z0-9_]`, the first segment not starting with a digit, at
            most 200 characters. This is the one form keys are stored in; every
            export target reshapes it deterministically (`.arb` → camelCase,
            Android → snake_case, …). Anything else is `400 invalid_key`, with
            the canonical form the server would have suggested in the message.

            In particular a `/` is **not** a separator here — use `.`
            (`questions.n400_q1.title`).

            The rule binds the **mint**: a name that already belongs to a key
            in the project is accepted as sent, whatever its shape, so a
            re-push that echoes back a key minted before this rule (or under
            the old `-2` collision suffix) still resolves. A name Firestore
            cannot store — one containing `/` above all — is refused either
            way.

            Omitted (or an empty string) means no preference: the key is
            auto-named from the source.
        previousToken:
          type: string
          description: >
            Copy-edit carry-over — the message token of the previous source.
            The unit re-uses that key instead of minting a new one.

            Carry-over is **one-to-one** and RETIRES the previous source: a
            previous value may be claimed by at most one unit per request, and
            not at all if ANOTHER unit in the same request still sends that
            source. (Naming your own source as `previousSource` is not a
            claim — it is an ordinary idempotent re-push, and is accepted.)
            Either offending shape is `400 ambiguous_previous_source` —
            it describes a split (or a rename of something still in use),
            which the carry-over model cannot express, and which `?dryRun=true`
            could only price by simulating the request's own writes.
        previousSource:
          type: string
          maxLength: 5000
          description: >
            Alternative to `previousToken` for clients that don't tokenize:
            the previous raw source (with `previousCtx`), tokenized
            server-side.
        previousCtx:
          type: string
          description: The `ctx` the previous source was registered with.

    RequestStatus:
      type: object
      required: [requestId, state]
      properties:
        requestId:
          type: string
        state:
          type: string
          enum: [unknown, processing, done, failed, expired]
        steps:
          type: object
          description: >
            The durable effects the request has recorded so far.
            `jobEnqueued` is the id of the fill job, the same value as the
            response's `trackingToken`. Other recorded steps are boolean
            progress flags; the internal response snapshot is not exposed.
          properties:
            jobEnqueued:
              type: string
        stateWas:
          type: string
          enum: [done, failed]
          description: >
            Present when `expired`: how the request ended, before its
            response stopped being kept.
        trackingToken:
          type: string
          description: >
            Present when `expired` and the request enqueued a fill: the job
            id its response carried.
        leaseUntil:
          type: string
          format: date-time
          description: Present while `processing`.
        response:
          $ref: '#/components/schemas/TranslateAccepted'
        error:
          type: object
          description: Present when `failed`.
          properties:
            status:
              type: integer
            body:
              $ref: '#/components/schemas/Error'

    TranslateRequest:
      type: object
      required: [units]
      properties:
        requestId:
          type: string
          pattern: '^(?!__.*__$)[A-Za-z0-9_-]{1,128}$'
          description: >
            A caller-minted id for this HTTP request (a UUID fits). Optional.
            The same id with the same action and body replays the recorded response
            for 24 hours and is `409 request_expired` after that; another
            action or body is `409 request_mismatch`. Scoped to the API key.
            Ignored by `?dryRun=true` beyond its shape check.
        units:
          type: array
          minItems: 1
          maxItems: 500
          items:
            $ref: '#/components/schemas/TranslationUnitInput'
        attemptId:
          type: string
          pattern: '^(?!__.*__$)[A-Za-z0-9_-]{1,128}$'
          description: >
            Required with `operationId`. One ingest: every batch of it
            carries the same id, and a re-ingest mints a new one. An
            operation runs at most three (`409 attempts_exhausted`).
        batchIndex:
          type: integer
          description: >
            Required with `operationId`: this batch's `index` in the
            operation's manifest. At most one request per
            `(attemptId, batchIndex)` that has not failed
            (`409 batch_replayed`); after a `failed` one, the batch is re-sent
            under a new `requestId`, same attempt and index.
        batchSha256:
          type: string
          description: >
            Required with `operationId`: the hash the preview returned for
            this body. The server hashes the body itself; a value that is
            not that hash is `409 batch_mismatch`.
        operationId:
          type: string
          description: >
            The approved AI operation this push spends under: every work unit
            of the fill reserves its quote against the operation's budget
            before the provider is called, and what the operation charges
            never exceeds what was authorized (the rest is absorbed). Optional
            today; an unknown or foreign id is `404 operation_not_found`, one
            not yet approved `409 operation_not_approved`, one with no budget
            left `409 budget_exhausted`, one approved for another action
            `409 scope_changed`. Required for an Agent key whenever the push
            would queue billable work (`403 authorization_required`
            otherwise). Ignored by `?dryRun=true`: a preview
            reserves nothing, and its `accepted` reflects the workspace
            balance only — whether the operation can still cover the run is
            judged by the real request (and, for a client spending under
            one, by the operation's own ledger).
        langs:
          type: array
          items:
            type: string
          description: >
            Optional whitelist of target languages, intersected with the
            project's non-base languages. Omitted → all of them. Languages
            set to manual fill mode are excluded either way — they are
            user-translated, never AI targets.

    UnitResult:
      type: object
      required: [token, outcome]
      properties:
        key:
          type: string
          description: >
            The managed key name this unit resolved to. Canonical (the charset
            `TranslationUnitInput.key` documents) for anything minted since
            that rule landed — deliberately **not** constrained here, because a
            key minted earlier keeps the name it was given: a client's own
            legacy name, or the `-2` suffix the collision probe used to add.
            Treat it as an opaque identifier and send it back verbatim.
        token:
          type: string
          description: The unit's content-derived message token.
        outcome:
          type: string
          enum: [created, matched, carriedOver, contended]
          description: >
            `created` = new key minted, `matched` = idempotent re-push of a
            known source, `carriedOver` = copy-edit re-using the previous key,
            `contended` = concurrent writers kept winning the key's admission,
            or the write of a copy-edit on the previous key, so nothing was
            written; the unit carries no `key`, is flagged `unresolved`, and
            resolves on a re-push.
        duplicate:
          type: boolean
          description: >
            This unit resolved to the same key as an earlier unit in the
            batch; only the first occurrence was processed.
        translated:
          type: array
          items:
            type: string
          description: Target languages already translated.
        pending:
          type: array
          items:
            type: string
          description: Target languages queued (or already in flight).
        budgetExhausted:
          type: array
          items:
            type: string
          description: >
            Target languages the previous run stopped short of because its
            operation's budget could not cover the next unit. Also listed in
            `pending`: this push re-queues them. A client that spends under
            an operation reads this as "re-authorize", not "retry".
        gated:
          type: boolean
          description: >
            The project has auto-verify on (a Pro setting) and this unit's
            source is not verified yet, so it was **skipped, not translated**
            — no new fill was queued for it and no credits were spent. The key
            itself is still created/matched as usual. Note `pending` may still
            be non-empty: it reports languages already in flight from an
            earlier push, which are neither re-queued nor re-charged. Verify
            the source in the dashboard (Dimensions → Keys → Key sources),
            then re-push: the unit is idempotent and will translate then.
            Absent when the unit was not gated.
        disabled:
          type: boolean
          description: >
            The key is switched off in the dashboard, so it was **skipped, not
            translated** — no new fill was queued for it and no credits were
            spent. Distinct from `gated`: this is a deliberate setting on the
            key, not a pending review. Pushing does not re-enable it. As with
            `gated`, `pending` may still list languages already in flight from
            an earlier push. Absent when the key is enabled.
        unresolved:
          type: boolean
          description: >
            Identity resolution lost a race and the key this unit would have
            claimed was never written, so **nothing was queued and no credits
            were held** — `pending` is empty. Rare and self-correcting: re-push
            and the unit resolves normally (it is idempotent). Reported rather
            than silently retried so a push never reads as complete for a unit
            that got no key. Absent on every normal outcome.

    SyncResult:
      type: object
      required:
        [dryRun, format, baseCode, targetLangs, created, updatedSources,
         matchedSources, updatedTranslations, unchangedTranslations,
         disabledOrphans, reenabled, failedEntries, exhausted, budgetExhausted,
         onConflict, conflicts, conflictsTotal, diagnostics, diagnosticsTotal,
         diagnosticsOmitted, estimatedCredits,
         balance, accepted]
      properties:
        dryRun: {type: boolean}
        batchSha256:
          type: string
          description: >
            Only on `?dryRun=true`: this body's entry for an operation
            manifest, as on the translate preview.
        format: {type: string, example: xcstrings}
        baseCode: {type: string, example: es}
        targetLangs:
          type: array
          items: {type: string}
        created:
          type: integer
          description: New keys minted from the file's source strings.
        updatedSources:
          type: integer
          description: Existing keys whose source text or comment changed.
        matchedSources:
          type: integer
          description: Existing keys the file matches byte-for-byte.
        updatedTranslations:
          type: integer
          description: >
            (key, language) values the file carries that differ from the
            stored ones — written as imported overrides. Identical values are
            never touched (a re-sync of the pulled catalog is a no-op). A
            pair listed under `conflicts` is counted here only when it was
            actually overwritten (`onConflict: file`).
        unchangedTranslations: {type: integer}
        disabledOrphans:
          type: integer
          description: >
            Live keys the file no longer carries, transitioned to
            `disabled` (excluded from publish + fill; never deleted).
        reenabled:
          type: integer
          description: Previously disabled keys whose phrase is back.
        failedEntries:
          type: array
          description: Per-entry failures; one bad entry never fails the sync.
          items:
            type: object
            properties:
              key: {type: string}
              lang: {type: string}
              code: {type: string}
              reason: {type: string}
        exhausted:
          type: array
          description: >
            (key, language) pairs whose AI retry budget is spent for the
            current source + model. NOT re-queued by further syncs — they
            need a human (or a source/model change) in the dashboard.
            Reported separately so a CI retry loop does not spin on them.
          items:
            type: object
            properties:
              key: {type: string}
              lang: {type: string}
        budgetExhausted:
          type: array
          description: >
            (key, language) pairs the previous run stopped short of because
            its operation's budget could not cover the next unit. Re-queued
            by this sync; listed so a client spending under an operation
            knows to re-authorize rather than retry.
          items:
            type: object
            properties:
              key: {type: string}
              lang: {type: string}
        onConflict:
          type: string
          enum: [server, file]
          description: The conflict policy this run applied (echo of the request).
        conflicts:
          type: array
          description: >
            (key, language) pairs whose stored value the file would revert:
            the project produced it (`origin` is `human`, `machine` or
            `memory`) or a person signed it (`stage` is `reviewed` or
            `approved`), and the file carries a different value. Reported
            under both policies; `resolution` says what happened to the
            stored value in THIS run — `kept`, or `overwritten` (only under
            `onConflict: file`, and only when the key's write landed).
            Capped at the first 50 rows — `conflictsTotal` carries the real
            count. The run is also logged server-side as ONE aggregated
            `sync_conflicts` WARNING per request (`projectId`, `actor`,
            `count`, and the same capped `conflicts` array).
          items:
            type: object
            required: [key, lang, resolution]
            properties:
              key: {type: string}
              lang: {type: string}
              origin:
                type: string
                nullable: true
                enum: [human, machine, memory, import, null]
              stage:
                type: string
                nullable: true
                enum: [translated, reviewed, approved, null]
              updatedAt:
                type: string
                format: date-time
                nullable: true
                description: When the stored value was last written.
              resolution:
                type: string
                enum: [kept, overwritten]
        conflictsTotal:
          type: integer
          description: >
            How many (key, language) pairs conflicted in total. Equal to
            `conflicts.length` until the cap bites, above it the only honest
            count.
        diagnostics:
          type: array
          description: >-
            The parsers' findings, forwarded verbatim. Rows raised by a
            TARGET-language pass carry `lang` (#726); rows from the base
            pass and the membership rows (`LANG_*`) do not.
          items:
            type: object
            properties:
              code: {type: string}
              severity: {type: string}
              message: {type: string}
              field: {type: string}
              lang: {type: string}
              alsoSpelled:
                type: array
                items: {type: string}
                description: >-
                  On a `LANG_NOT_IN_FILE` row only: the spellings the catalog
                  already uses for that same language (`zh-Hans` for a
                  configured `zh-hans`). The project and the file disagree
                  about one language, and re-spelling either side is the
                  operator's call — see `ci-sync.md`.
              plannedPairs:
                type: integer
                description: >-
                  On a `LANG_NOT_IN_FILE` row of a dry run only: how many
                  (key, language) pairs of that language the plan marks for
                  translation, which are the pairs `estimatedCredits` is
                  priced on. `0` means the run translates nothing for that
                  language and it adds nothing to the estimate (the project
                  already holds it, or its pairs are held by another fill,
                  exhausted, or waiting on an unverified source). Absent on
                  a real run, where `estimatedCredits` is what was reserved
                  after the mint dropped or deferred pairs.
        diagnosticsTotal:
          type: integer
          description: >
            How many diagnostic rows the run raised. `diagnostics` lists at
            most 10 per (code, lang), so past that this is the only whole
            count (#1247).
        diagnosticsOmitted:
          type: array
          description: >
            One entry per (code, lang) group the listing was cut in, with
            the rows left out. Empty when `diagnostics` is whole. The
            `LANG_NOT_IN_FILE` and `LANG_NOT_FILLABLE` rows are never cut
            (one per configured target); `LANG_NOT_CONFIGURED` is one per
            locale the file declares and is cut like any other code.
          items:
            type: object
            required: [code, count]
            properties:
              code: {type: string}
              lang:
                type: string
                description: Absent for rows no target-language pass raised.
              count: {type: integer}
        trackingToken:
          type: string
          nullable: true
          description: >
            Job id of the queued fill (poll via
            `GET /translations?trackingToken=`), or null when nothing needed
            translating.
        estimatedCredits: {type: integer}
        balance: {type: integer}
        accepted:
          type: boolean
          description: >
            dryRun only — whether the balance would cover the estimate and
            the key cap allows the new keys.
        skippedGated:
          type: integer
          description: >
            Keys excluded from the fill because source review has not
            approved their source yet (Pro source-verify gate).
        wouldExceedKeyCap:
          type: boolean
          description: dryRun only — creating the new keys would pass the plan's key cap.

    PublishIncompleteError:
      type: object
      required: [error, message, pending, exhausted, exhausted_total, refused]
      properties:
        error:
          type: string
          description: Always fill_incomplete.
        message:
          type: string
        pending:
          type: integer
          description: Incomplete live base/target cells before source fallback or skip.
        exhausted:
          type: array
          maxItems: 50
          description: Live target cells with fill=exhausted, even with a usable prior value.
          items:
            type: object
            required: [key, lang]
            properties:
              key:
                type: string
              lang:
                type: string
        exhausted_total:
          type: integer
          description: Total exhausted cells before truncation; may overlap pending.
        refused:
          type: object
          required: [icu, icu_total, markup, markup_total]
          properties:
            icu:
              type: array
              maxItems: 50
              items:
                type: string
            icu_total:
              type: integer
            markup:
              type: array
              maxItems: 50
              items:
                type: string
            markup_total:
              type: integer

    VersionNotApprovedError:
      type: object
      required: [error, message, unapproved, unapproved_total]
      properties:
        error:
          type: string
          description: Always version_not_approved.
        message:
          type: string
        unapproved:
          type: array
          maxItems: 50
          description: >
            Cells the version published without approval, as `key [lang]`.
            An alias is reported under its target key.
          items:
            type: string
        unapproved_total:
          type: integer
          description: Total unapproved cells before truncation.

    PublishResult:
      type: object
      required: [versionId, keyCount]
      properties:
        versionId:
          type: string
          description: The new `YYYY.MM.DD.N` version id.
          example: "2026.07.09.3"
        keyCount:
          type: integer
          description: Number of keys frozen into the version.
        unchanged:
          type: boolean
          description: >
            Present (true) only on the 200 `skipIfIdentical` outcome: the
            snapshot digest matched the current version, nothing was minted,
            and `versionId` names the CURRENT version.
        icuRejected:
          type: array
          items:
            type: string
          description: >
            `key [lang]: problem` for each target cell the ICU gate refused
            to bake (malformed ICU or placeholder-parity break, or a stored
            value that does not decode). Those cells fell back to the
            incomplete-mode axis instead of shipping a value that renders
            raw. One line per key may name the BASE language instead: a
            pooled source that exists but does not decode (`stored source
            value does not decode`) — the base cell was not baked, the key
            kept its decodable targets, and the repair is that one source
            document. Absent when every cell passed; capped at 50
            entries — read `icuRejectedTotal` for the real count. On the
            RESTORE endpoint this field means the opposite — see the restore
            201 schema: cells present in the verbatim copy that today's
            gates would refuse.
        icuRejectedTotal:
          type: integer
          description: >
            Uncapped count of cells the ICU gate refused. Present only
            alongside `icuRejected`; exceeds that array's length whenever
            more than 50 cells were refused, so count from this field —
            never from the capped array.
        markupRejected:
          type: array
          items:
            type: string
          description: >
            `key [lang]: problem` for each target cell the markup gate refused
            to bake — a `markdown`/`html` key whose translation dropped, added
            or broke the source's markup. Same consequence as `icuRejected`
            (the cell falls to the incomplete-mode axis), reported separately
            because the remedy differs. Absent when every cell passed; capped
            at 50 entries — read `markupRejectedTotal` for the real count.
        markupRejectedTotal:
          type: integer
          description: >
            Uncapped count of cells the markup gate refused. Present only
            alongside `markupRejected`; exceeds that array's length whenever
            more than 50 cells were refused, so count from this field —
            never from the capped array.

    TranslateAccepted:
      type: object
      required: [trackingToken, estimatedCredits, balance, baseCode, targetLangs, units]
      properties:
        trackingToken:
          type: [string, 'null']
          description: >
            Poll handle for `GET /translations?trackingToken=`. `null` when
            nothing needed filling (fully idempotent push).
        estimatedCredits:
          type: integer
          description: Estimated cost in millicredits (0 when nothing to fill).
        balance:
          type: integer
          description: Workspace balance in millicredits after reserving the estimate.
        baseCode:
          type: string
          description: The project's base language.
        targetLangs:
          type: array
          items:
            type: string
        units:
          type: array
          items:
            $ref: '#/components/schemas/UnitResult'
        skippedGated:
          type: integer
          description: >
            How many units were skipped because their source is not verified
            (see `gated` on `UnitResult`). Absent when none were. Headless
            callers should surface this: the push otherwise looks successful
            while those units silently went untranslated.
        skippedDisabled:
          type: integer
          description: >
            How many units were skipped because their key is switched off
            (see `disabled` on `UnitResult`). Absent when none were. Surface
            it for the same reason as `skippedGated`.

    TranslateDryRun:
      type: object
      required: [dryRun, batchSha256, trackingToken, estimatedCredits, balance, accepted, wouldExceedKeyCap, baseCode, targetLangs, units]
      properties:
        dryRun:
          type: boolean
          const: true
        batchSha256:
          type: string
          description: >
            This body's entry for an operation manifest: sha256 of the body
            without `requestId`, `operationId`, `attemptId`, `batchIndex` and
            `batchSha256`, keys sorted.
        trackingToken:
          type: 'null'
        estimatedCredits:
          type: integer
          description: What the real push would cost, in millicredits.
        balance:
          type: integer
          description: Current workspace balance in millicredits.
        accepted:
          type: boolean
          description: Whether the real push would be accepted (balance + key cap).
        wouldExceedKeyCap:
          type: boolean
          description: The push would exceed the plan's per-project key cap.
        baseCode:
          type: string
        targetLangs:
          type: array
          items:
            type: string
        units:
          type: array
          items:
            $ref: '#/components/schemas/UnitResult'
        skippedGated:
          type: integer
          description: >
            How many units the real push would skip as unverified. They are
            excluded from `estimatedCredits`, so the preview does not price
            work that would not run. Absent when none.
        skippedDisabled:
          type: integer
          description: >
            How many units the real push would skip as disabled. Excluded from
            `estimatedCredits` for the same reason. Absent when none.

    PublishedFiles:
      type: object
      required:
        [project, version, baseCode, snapshotAt, formats, files, coverage,
         diagnostics]
      properties:
        project:
          type: string
        version:
          type: string
          description: The calver version rendered.
        baseCode:
          type: string
        snapshotAt:
          type: string
          format: date-time
          description: >
            When the content in `files` stopped being live — the instant
            these bytes are not behind, always UTC with a `Z`. NOT the time
            of this response: the route renders the published snapshot, not
            the live keys, so a dashboard edit made after that publish is not
            in here. Usually the version's publish instant; a version created
            by a restore reports the ORIGINAL publish it re-mints, not its own
            creation. Send it back as `pulledAt` on `POST /sync` — dating the
            file by your own clock instead claims it already carried every
            edit made since, and the base-language guard stays silent while
            one is reverted.
        formats:
          type: array
          items:
            type: string
          description: The output formats rendered.
        files:
          type: object
          additionalProperties:
            type: string
          description: >
            Relative file path → rendered file content. Paths use each
            platform's canonical locale form (e.g. `arb/app_zh_Hant.arb`,
            `android_xml/values-b+zh+Hant/strings.xml`). `xcstrings` is the
            exception: it renders a single multi-language file
            (`xcstrings/Localizable.xcstrings`) whose locales live inside the
            JSON, so combining it with `lang` yields a catalog Xcode cannot
            merge back — request it without a language filter.
        coverage:
          type: object
          additionalProperties:
            type: object
            required: [translated, total]
            properties:
              translated:
                type: integer
              total:
                type: integer
          description: >
            Per target language (the base is omitted — its cell is the source):
            how many of the version's `total` keys carry a value in that
            language, counting only cells that render as non-empty text.
            `total` counts alias entries too — they render as their own key,
            so a consumer short one is short a file entry — which makes it
            larger than the version's `keyCount` on a project with renames.
            The
            files cannot say it: a Mode A publish skips the cells it has no
            translation for, so an incomplete locale renders as a shorter
            file. `easyi18n pull --require-complete` fails on any language
            with `translated < total` (#1052). With `lang`, only that
            language's entry is reported (empty when `lang` is the base).
            Read it together with `coverageBasis`.
        coverageBasis:
          type: string
          enum: [skip, source]
          description: >
            What an untranslated cell meant when this version was published —
            the `incompleteMode` the publish ran under. Under `source` the
            base text was baked into every missing cell, so `coverage` counts
            the fallback as a value and always reports complete: a client
            gating on completeness must refuse to judge rather than pass.
            Omitted on versions published before this field existed; absent
            means unknown, which a gate must also refuse. `hold` is not in
            this set: a strings publish degrades it to `skip` before stamping
            the version (`publish.dart`), so no version can carry it.
        warnings:
          type: array
          items:
            type: string
          description: >
            Best-effort key-name warnings (e.g. a verbatim key heuristically
            segmented for a snake_case target). Omitted when empty.
        diagnostics:
          type: array
          description: >-
            The renderer's findings for this export. Always present, `[]`
            included: absence of the key is not a signal, and a client should
            not have to branch on it to tell "no findings" from an older
            backend (#1243). It was emitted and documented nowhere until then,
            which made #492's distinction — a key name segmented
            heuristically vs a key the file does not carry — undiscoverable
            from this spec.

            Same ROW SHAPE as `SyncResult.diagnostics`, not the same rows: a
            client can cast either door's array once and read `code`,
            `severity`, `message` and `field` off both. What it must not carry
            across is the content. This door's rows come from the renderer and
            never carry `lang`; sync's come from the parsers plus membership
            rows the route synthesizes, so its vocabulary of `code` is a
            different set and its target-language rows add `lang` (#726).
          items:
            type: object
            properties:
              code: {type: string}
              severity: {type: string}
              message: {type: string}
              field: {type: string}

    ExportCollisionError:
      type: object
      required: [error, message, collisions]
      properties:
        error:
          type: string
          enum: [export_collision]
        message:
          type: string
          description: Human-readable summary naming the first colliding group.
        collisions:
          type: array
          description: Every colliding group, so a caller can rename precisely.
          items:
            type: object
            required: [format, renderedName, keys]
            properties:
              format:
                type: string
                description: The output format id the collision occurs in.
              renderedName:
                type: string
                description: The single name the keys collapse to.
              keys:
                type: array
                items:
                  type: string
                description: >
                  The canonical key names that collide. Rename one of them (or,
                  if one is a retired-name export alias, retire the alias) and
                  re-publish.

    FillJobSummary:
      type: object
      required: [trackingToken, status, estimatedCredits, creditsCharged, summary]
      properties:
        trackingToken:
          type: string
        status:
          type: string
          enum: [queued, running, done, failed, aborted_no_credits, cancelled]
          description: >
            The fill job's lifecycle status, served verbatim from the job
            store (the `JobStatus` vocabulary — note `done`, not `succeeded`,
            and `cancelled` with two `l`s). `queued` and `running` mean keep
            polling; the other four are terminal. `done` is the normal
            completion — it does NOT mean every pair succeeded, read
            `summary.failed` for that. `aborted_no_credits` means the
            workspace ran out of credits mid-run: the pairs it could not fill
            stay counted as `pending`, but the job is already terminal and
            nothing resumes it — top up the balance and push those keys again.
            The new job fills them under a new `trackingToken`; polling this
            one will never move.
        estimatedCredits:
          type: integer
          description: The accept-time estimate, in millicredits.
        creditsCharged:
          type: integer
          description: Actually settled cost so far, in millicredits.
        summary:
          type: object
          required: [total, translated, pending, failed]
          description: >
            Counts of (key, language) pairs by state, over the pairs THIS RUN
            PRICED — not over every language in `targetLangs` (#773). A push
            excludes pairs from its own plan per key (a peer already owns the
            marker, the retry budget is spent, the mint lease deferred them),
            the credit hold is priced on exactly what survives, and since then
            so are these counts. Every excluded pair was reported to you by the
            response that returned this `trackingToken` — as `pendingSkipped`
            and `exhausted[]` on `/v1/sync`, and as `fillDeferred` plus
            `units[].pending` on `/v1/translate`.
          properties:
            total:
              type: integer
              description: >
                `translated + pending + failed`. Before #773 this was the whole
                key set times the whole language set; on a contended run it is
                now smaller. Add `excluded` to recover the old figure.
            translated:
              type: integer
            pending:
              type: integer
              description: >
                Pairs this run priced and has not delivered. The completeness
                axis `failed` cannot answer: a no-credits abort's pairs are
                reported here on purpose, since they are recoverable rather
                than a terminal failure.
            failed:
              type: integer
            excluded:
              type: integer
              description: >
                Pairs of `targetLangs` on the counted keys that this run did
                NOT price, and therefore does not report on. Additive (#773):
                `total + excluded` recovers the pre-#773 `total` for any job
                whose payload names languages, so a client that stored a
                progress fraction can still reconstruct it. Two caveats, both
                narrow: a job enqueued in-app names no languages at all (its
                pre-#773 `total` was `0` by defect and there is nothing to
                reconstruct), and a project configuring the same language code
                twice makes the sum over-count by the duplicate. Absent from a
                pre-#773 server, where `0` is the honest default.

                A job whose plan cannot be reconstructed at all — an
                undecodable chunk, or no language set to count a key against —
                answers `500 summary_unavailable` rather than a summary of
                zeros: `0` would read as "covered everything" over pairs
                nobody filled. Unreachable through a correct enqueue; retry,
                and if it persists re-queue the pairs with a new push.

    KeyTranslations:
      type: object
      required: [key, translations]
      properties:
        key:
          type: string
          description: The managed key slug.
        translations:
          type: object
          additionalProperties:
            type: object
            required: [status]
            properties:
              status:
                type: string
                enum: [translated, pending, failed, untranslated]
              value:
                type: string
                description: The translated text (present only when `translated`).
          description: Language code → current state (and value when translated).

    Meta:
      type: object
      required:
        [
          baseCode,
          mode,
          currentVersionId,
          lastPublishedAt,
          deliveryHost,
          languages,
          formats,
          outputFormatsApiVersion,
          content,
        ]
      properties:
        outputFormatsApiVersion:
          type: integer
          const: 1
          description: >
            Supports atomic output-format add/adopt and the publish
            requiredOutputFormat contract. Clients must check this marker
            before using those workflows; a successful meta response alone
            does not establish support. Formats remain live configuration.
        baseCode:
          type: string
          description: The project's base language.
        mode:
          type: [string, 'null']
          enum: [a, b, null]
          description: >
            Integration mode: `a` (TMS — the customer keeps their own
            `gen_l10n`/ARB setup) or `b` (runtime SDK). `null` when the
            project has never declared one, which behaves as `b` for every
            default that predates the field. It decides what a publish does
            with untranslated cells when `incompleteMode` is omitted: `skip`
            for `a`, `source` for `b` and for `null`.
        currentVersionId:
          type: [string, 'null']
          description: >
            The currently published calver version, or null when the project
            has not published yet.
        lastPublishedAt:
          type: [string, 'null']
          format: date-time
          description: When the current version was published (null if never).
        deliveryHost:
          type: string
          description: >
            Host the runtime SDK should target — the default delivery CDN, or
            the project's active custom domain.
        languages:
          description: >
            The language catalogue as configured today (live, not the
            published snapshot). Base language first, then alphabetical.
          type: array
          items:
            type: object
            required: [code, name, isBase, fillMode, inCurrentVersion]
            properties:
              code:
                type: string
              name:
                type: string
                description: English display name (CLDR), e.g. "Spanish".
              isBase:
                type: boolean
              fillMode:
                type: string
                enum: [ai, manual]
                description: >
                  `manual` languages are excluded from AI fills — a
                  `POST /translate` targeting one answers 400
                  `fill_mode_manual`.
              inCurrentVersion:
                type: boolean
                description: >
                  Whether the language is part of the current published
                  version's snapshot. False for a language added after the
                  last publish (or before the first one). Being in the
                  snapshot does not guarantee it carries translations.
        formats:
          type: array
          items:
            type: string
          description: Output formats configured for the project.
        content:
          type: object
          required: [enabled, currentVersionId, lastPublishedAt]
          description: The Content dimension's availability and publish pointer.
          properties:
            enabled:
              type: boolean
              description: >
                Whether the Content API answers for this project. When false,
                the `/content` routes are unreachable (404).
            currentVersionId:
              type: [string, 'null']
              description: >
                The currently published Content version, or null when Content
                has not published yet.
            lastPublishedAt:
              type: [string, 'null']
              format: date-time

    Manifest:
      type: object
      required: [project, channel, revision, locales]
      properties:
        project:
          type: string
        channel:
          type: string
          description: Publishing channel (`production`).
        revision:
          type: string
          description: The published calver version.
        locales:
          type: object
          additionalProperties:
            type: object
            required: [version, bundleHash, url]
            properties:
              version:
                type: string
              bundleHash:
                type: string
                description: sha256 content hash of the bundle.
              url:
                type: string
                description: >
                  Root-relative bundle path on the same host as the manifest.
          description: Locale code → current bundle pointer.

    Bundle:
      type: object
      description: >
        One locale's content-addressed translation bundle, canonical JSON.
        The runtime SDK verifies the content against the manifest's
        `bundleHash` before use. The internal layout is defined by the
        runtime SDK contract and may gain fields over time.
      additionalProperties: true

    WebhookEvent:
      type: string
      enum: [strings.published, content.published]
      description: >
        `strings.published` fires on every Keys publish/restore;
        `content.published` on every Content publish that moves a
        document, and on every per-document rollback.

    Webhook:
      type: object
      required: [webhookId, url, events, enabled, consecutiveFailures]
      properties:
        webhookId:
          type: string
        url:
          type: string
        events:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEvent'
        enabled:
          type: boolean
        consecutiveFailures:
          type: integer
          description: >
            Consecutive dead-lettered deliveries; at 3 the endpoint is
            auto-disabled (`disabledReason: auto_failures`). Reset on any
            successful delivery or on re-enabling.
        disabledReason:
          type: [string, 'null']
        secretHint:
          type: string
          description: The signing secret's 4-character tail — never the secret.

    WebhookCreateRequest:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          description: HTTPS endpoint; SSRF-checked at save and at delivery.
        events:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/WebhookEvent'

    WebhookPatchRequest:
      type: object
      description: At least one field is required.
      properties:
        url:
          type: string
        events:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/WebhookEvent'
        enabled:
          type: boolean

    WebhookCreated:
      type: object
      required: [webhookId, url, events, enabled, secret]
      properties:
        webhookId:
          type: string
        url:
          type: string
        events:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEvent'
        enabled:
          type: boolean
        secret:
          type: string
          description: The signing secret (`whsec_...`) — shown ONLY here.

    WebhookRotated:
      type: object
      required: [webhookId, secret, previousSecretExpiresAt]
      properties:
        webhookId:
          type: string
        secret:
          type: string
          description: The new signing secret — shown ONLY here.
        previousSecretExpiresAt:
          type: string
          format: date-time
          description: Until when the previous secret keeps verifying.

    WebhookDelivery:
      type: object
      required: [deliveryId, eventId, eventType, publishId, status, attempts]
      properties:
        deliveryId:
          type: string
        eventId:
          type: string
        eventType:
          $ref: '#/components/schemas/WebhookEvent'
        publishId:
          type: string
          description: The content publishId or Strings versionId that fired.
        status:
          type: string
          enum: [pending, delivering, delivered, failed]
        attempts:
          type: integer
        nextAttemptAt:
          type: [string, 'null']
          format: date-time
        lastStatusCode:
          type: [integer, 'null']
        lastError:
          type: [string, 'null']
