openapi: 3.1.0
info:
  title: FuzeFront Selection List Service API
  version: 4.2.0
  summary: Org-scoped, translatable selection lists (reference data) for the Fuze family.
  description: >-
    **FROZEN CONTRACT — FFRNT-187 (S1).** This file is the single source of truth
    for the `selection-list-service` HTTP surface and for the generated/derived
    client `@fuzeone/selection-list-client`. Nothing downstream (backend routes,
    migrations, UI, acceptance tests, docs) may diverge from it; a change to the
    API means amending **this file first**, bumping `info.version`, re-linting,
    and re-issuing the client (see the `api-contract-first` skill and
    `governance/`).


    ### What the service is

    A selection list is a named, org-scoped set of ordered, translatable choices
    (`countries`, `industries`, `ticket-priorities`, ...) that consuming
    applications render in dropdowns/pickers and persist by **item id**. The
    service owns three things and nothing else: the list/item structure, the
    per-locale translations of both, and the per-list access grants.


    ### The two identifiers, and why there are two

    Every item carries both an `id` and a `code`, and they are not
    interchangeable:

    * **`id`** — the service-minted, permanently immutable identifier consumers
      **persist** in their own rows. It is opaque and never displayed. Consumers
      store this and nothing else, which is what makes an item's label and its
      `code` safe to change later.
    * **`code`** — a human-meaningful interop key (`US`, `HIGH`) that is
      **immutable after create** and unique within its list. It exists for
      integrations that must map to an external system's vocabulary. It is a
      key, never a label: it is not translated and not shown to end users.

    Persisting a `code` instead of an `id` is the classic reference-data defect —
    it welds a display/interop concern to a foreign key. The contract makes the
    right choice the easy one: `POST /v1/resolve` takes ids.


    ### Archive is the default; purge is explicit

    Reference data is referenced. Deleting a row that thousands of consumer rows
    point at turns their stored id into a dangling reference, so the default
    destructive operation is **archive** (`status: archived`): the item stops
    being offered for new selections but still **resolves**, so existing rows
    keep rendering a real label. `DELETE` therefore archives unless the caller
    passes `?purge=true`, which is irreversible and permitted only to a
    `list-owner` (it requires `delete` on the list, in addition to the
    operation's own action — see *Authorization*). `POST /v1/resolve` deliberately resolves archived ids.


    ### Translations and the locale fallback

    List `name`/`description` and item `label`/`description` live in
    `*_translations` rows keyed by locale, never on the entity itself. Every
    representation of a list or an item therefore carries the resolved text plus
    `resolved_locale` (the locale the text actually came from) and `is_machine`
    (whether that text was machine-translated). The resolution order is fixed:

      1. the `locale` query parameter, if supplied and supported;
      2. the first supported language in the `Accept-Language` header;
      3. the list's `source_locale`;
      4. `en`.

    **A label is never `null`.** A missing translation falls through the chain
    rather than returning an empty string — a picker rendering blank rows is a
    worse failure than one rendering the source-locale text.

    `source_hash` on a translation row is the hash of the **source-locale** text
    the translation was produced from; when the source text changes the hash no
    longer matches and the translation is known-stale, which is what lets
    `POST /v1/selection-lists/{listId}/translations/{locale}/autofill` refresh
    only what actually drifted.


    ### Field casing

    Resource representations use `snake_case`, mirroring storage. The pagination
    envelope (`page.nextCursor`, `page.hasMore`, `page.total`) is `camelCase`
    because it is fixed verbatim by the family pagination standard
    (`governance/pagination-standard.md` §1) and is identical across every Fuze
    service. This split is intentional and load-bearing: the envelope is shared
    infrastructure, the payload is this service's own.


    ### Identifiers (governance/identifier-standard.md)

    Ids are **TypeID**-shaped — a registered type prefix, an underscore, and a
    UUIDv7 in base32 — minted exclusively by this service via `mintId()` from
    `@izzywdev/fuzefront-identity`. No create body accepts an `id`, and every
    create schema sets `additionalProperties: false`, so a client-supplied id
    cannot bind. On the wire ids are `type: string` and **opaque past the
    prefix**: validate the prefix, never parse further, never assume a length.

    | Entity | Prefix | Example |
    |---|---|---|
    | selection list | `front_sl_` | `front_sl_01h455vb4pex5vsknk084sn02q` |
    | selection list item | `front_sli_` | `front_sli_01h455vb4pex5vsknk084sn02q` |

    Referenced spine ids keep their own prefixes (`org_`, `usr_`).


    **Authorship fields are not always users.** `created_by` (lists, items)
    and `granted_by` (access grants) are an `AuthorPrincipal`: a `usr_` user
    id, a `system:<service>` principal (rows written by seeding carry
    `system:selection-list-service`), or the literal `[deleted-user]`
    sentinel (the author was deleted and anonymized). The forms are disjoint
    by prefix, so a client decides which one it holds from the string alone
    and must do so before treating the value as a user id.


    ### Seed provenance

    Lists and items created by **seeding** (platform default packs, or an
    app's `selection-lists.seed.requested` — see
    `docs/planning/selection-lists-events.md`) carry a read-only `seed`
    object (`source`, `pack_key`, `pack_version`, `user_modified`). The
    property is **always present**: `null` on a user-authored row, an object
    on a seeded one. It is never accepted in a request body. `user_modified`
    flips to `true` (permanently) once a human edits the seeded content, after
    which seeding upgrades leave the row alone.

    ### Visibility, shared lists and forks (4.1.0)

    Design record: `docs/planning/selection-lists-shared-and-fork.md`.

    Every list has a `visibility` that says who may **read** it *without* an
    instance grant. Visibility only ever widens **reading** (fetching the
    list and its items/translations, picking a value, resolving it); it never
    confers a mutation. Mutations stay gated by the per-list instance roles
    exactly as before.

    | `visibility` | Owned by | Readable without an instance grant by |
    |---|---|---|
    | `private` (default) | an organization | nobody — today's behaviour; only instance-role holders |
    | `org` | an organization | every member of the **owning** organization who holds `SelectionListCatalog:read_shared` (every customer tenant role) |
    | `platform` | the **platform organization** (FuzeFront's root organization) | every authenticated caller, in **any** organization, who holds `SelectionListCatalog:read_shared` |

    A `platform` list is a **common list**: one instance, shared by every
    tenant, immutable for them. It is owned by the platform organization (so
    `organization_id` is always an `org_` id — never `null`) and only a
    **platform operator** may create one or promote a list to it (see
    *Authorization*). Visibility `platform` is **one-way**: a `platform`
    list can be archived but never demoted, because demoting it would turn
    every tenant's stored item ids into `missing`.

    **Copy-on-write (fork).** A tenant that wants to change a common list
    forks it: `POST /v1/selection-lists/{listId}/fork` copies the list, its
    items (all non-purged, with their status and order) and every
    translation into the caller's organization as an ordinary, editable
    list with the **same `key`**, `forked_from` provenance, and the caller as
    `list-owner`. Each copied item records the source item it came from in
    `origin_item_id`. At most one fork of a given source exists per
    organization; forking again returns the existing fork (`200`).

    Attempting to **mutate** a `platform` list you hold no instance role on
    returns `409 CONFLICT` with `reason: fork_required`, a `source_list_id`
    and a same-origin-relative `fork_url` — when you could fork (you hold
    `SelectionListCatalog:create`). Without `create` it is a plain `403`.

    **Which list a key means (precedence).** For a caller in organization X
    asking for key K (`GET /v1/selection-lists?key=K&include_shared=true`),
    the effective list is the first of: (1) X's own **active** list with key
    K that the caller can read (a fork, an `org` list, or a list it holds a
    grant on); (2) the **active** `platform` list with key K. Keys are
    unique per organization, so each step has at most one candidate. Because
    a fork keeps the source's key, forking automatically shadows the common
    list for the forking organization; archiving the fork reverts to it.

    **Stored values keep working across a fork.** Forked items get **new**
    service-minted ids (the copy is a different entity in a different
    organization). Ids already stored that point at the common list's items
    keep resolving; for a caller in an organization holding an active
    `org`-visible fork, `POST /v1/resolve` follows `origin_item_id` and
    returns the fork's label/status for them, naming the fork item in
    `effective_item_id` so a consumer can migrate the stored value lazily.

    **Is a fork behind its source? (4.2.0)** Every list carries a read-only
    `revision` — the same per-list counter the event contract publishes as
    `listRevision`, bumped once per change event committed about the list
    or anything inside it. A fork's `forked_from.revision` is the source's revision at
    the moment of copying and never changes; read the source (one
    `getSelectionList` of `forked_from.list_id`) and compare: greater means
    the source changed since the fork was made, equal means it has not.
    This is informational only — there is no merge or sync operation. When
    either side's revision is absent the answer is *unknown*; never infer it
    from `updated_at`.


    **Registry note (S3 amendment):** prefixes `front_sl` and `front_sli` are
    registered in `packages/identity/src/registry.ts` (keys `selectionList` and
    `selectionListItem`). They are product-local types namespaced as `front_*`
    per the namespace gate (`gate_identifier.py --namespace`).


    ### Authorization

    Authorization is decided by FuzeFront's Security API (backend/security's
    `/api/v1/security/authz/*`, called via `@fuzefront/auth`'s `AuthzClient`
    — this service embeds no policy-vendor SDK). Every operation declares the
    resource type it is checked against in `x-permit-resource` and the action
    it requires in `x-permit-action`. There are **two** resource types, and
    they answer different questions:

    * **`SelectionListCatalog`** — the organization's collection of lists, as
      a **tenant-level** resource (no instance key; the tenant is the caller's
      organization). It answers "may this caller work with selection lists in
      this org at all?". Actions: `list`, `create`, `read_quota`, `resolve`,
      and (4.1.0) `read_shared`, `publish_platform`. It is granted through
      **tenant roles**:

      | tenant role | list | create | read_quota | resolve | read_shared | publish_platform |
      |---|---|---|---|---|---|---|
      | `admin`     | x | x | x | x | x | x |
      | `editor`    | x | x |   | x | x |   |
      | `viewer`    | x |   |   | x | x |   |
      | `developer` |   |   |   |   |   |   |

      `read_shared` is held by every **customer** tenant role (a plain
      member is `editor`). It is the membership proof behind visibility:
      the service grants a shared read only when Permit says the caller holds
      `read_shared` **in its own tenant** — the token's org claim alone is
      never trusted as membership. `developer` (the root-org developer-portal
      role) deliberately holds none of these actions.

      `publish_platform` is only **effective in the platform organization's
      tenant**: the service evaluates it there, and additionally requires the
      caller to be acting in the platform organization. A customer-org
      `admin` therefore cannot publish a common list.

    * **`SelectionList`** — one list **instance** (key = the `front_sl_` id).
      It answers "what may this caller do to *this* list?". Actions: `read`,
      `add_value`, `update_value`, `remove_value`, `translate`, `update`,
      `delete`, `manage_access`. These actions are **instance-only**: they are
      conferred exclusively by ReBAC resource-instance roles on the list
      (mirroring `Organization.roles['org-admin']` in
      `backend/src/permit/schema.ts`), and **tenant roles confer zero
      `SelectionList:*` actions** — being an org `admin` does not by itself
      let anyone read, edit or delete a list it holds no instance role on.

      | role | read | add_value | update_value | remove_value | translate | update | delete | manage_access |
      |---|---|---|---|---|---|---|---|---|
      | `list-owner`       | x | x | x | x | x | x | x | x |
      | `list-editor`      | x | x | x | x | x | x |   |   |
      | `list-contributor` | x | x | x |   | x |   |   |   |
      | `list-translator`  | x |   |   |   | x |   |   |   |
      | `list-viewer`      | x |   |   |   |   |   |   |   |

    How the two meet:

    * **Reading a list (shared read, 4.1.0).** Every read operation on one
      list (`getSelectionList`, `listSelectionListItems`,
      `listSelectionListTranslations`, `listSelectionListItemTranslations`,
      and the source read of `forkSelectionList`) is satisfied by
      `SelectionList:read` on the instance **or**, when the list's
      `visibility` is not `private`, by the *shared read*:
      `SelectionListCatalog:read_shared` in the caller's tenant **and** the
      service-owned data predicate (`org`: the list belongs to the caller's
      organization; `platform`: always). Declared per operation in
      `x-permit-shared-read`. Permit stays the authority on *who* is a member
      (the tenant role); the service is the authority on the list's
      `visibility`, which it owns. Shared read never confers any other
      `SelectionList:*` action, so the invariant "tenant roles confer zero
      instance actions" holds.
    * **Listing** (`GET /v1/selection-lists`) requires `SelectionListCatalog:list`,
      and the returned rows are **still filtered per list** by
      `SelectionList:read` — catalog access lets a caller ask, it does not
      widen what comes back. With `include_shared=true` (4.1.0) the filter
      also admits rows the caller can read through the shared read above.
    * **Forking** (`POST /v1/selection-lists/{listId}/fork`) requires
      `SelectionListCatalog:create` (the fork is a new list in the caller's
      org, subject to the same quotas) **and** read on the source (grant or
      shared read). The service grants the caller `list-owner` on the fork
      with its machine identity, exactly as on create.
    * **Visibility changes** (`PATCH` with `visibility`) require
      `SelectionList:manage_access` (owner-only: who may read a list is an
      access decision); promoting to `platform` additionally requires
      `SelectionListCatalog:publish_platform` and a list owned by the
      platform organization. **Creating** a list with `visibility: platform`
      requires `publish_platform` likewise.
    * **Purging a `platform` list or one of its items** additionally
      requires `SelectionListCatalog:publish_platform`: it turns stored ids
      in every tenant into `missing`.
    * **Creating** (`POST /v1/selection-lists`) requires
      `SelectionListCatalog:create`. The service then grants the creator
      `list-owner` on the new instance, using **its own machine identity**
      (the creator holds no `manage_access` on a list that did not exist a
      moment ago, so the grant cannot be made with the caller's token).
    * **Purging an item** (`DELETE .../items/{itemId}?purge=true`) requires
      `SelectionList:remove_value` **and** `SelectionList:delete` on the
      parent list — i.e. `list-owner` only. Archiving an item needs only
      `remove_value`. The extra requirement is declared in
      `x-permit-additional-actions` on that operation.
    * **Archiving a list through PATCH** (`PATCH /v1/selection-lists/{listId}`
      with `"status": "archived"`) requires `SelectionList:update` **and**
      `SelectionList:delete` — the same `delete` that `DELETE` and
      `POST .../archive` require, so PATCH is not a way around it (review
      finding L-1). Declared in `x-permit-additional-actions` on
      `updateSelectionList`.
    * **Resolving** (`POST /v1/resolve`) requires `SelectionListCatalog:resolve`
      and does **not** check `SelectionList:read` on each id's list. This is an
      accepted trade-off (review finding L-4): resolve is the hot path, it is
      org-scoped, and it returns only a label, a locale and a status — never a
      list key or anything else an id alone should not unlock. A caller who
      holds an item id (because a consumer row it can see stores it) can
      render its label without a grant on the list. Per-id list checks would
      turn one round trip into N authorization decisions.

    **Support path for tenant administrators.** A tenant `admin` holds
    `Organization:manage`, which the Security API's grant gate
    (`authorizeGrantMutation`) treats as authority to grant any role in the
    tenant — so an admin who needs to administer a list it holds no role on
    grants itself `list-owner` on that instance via `POST /authz/grants`. That
    is an explicit, audited act, not an implicit inheritance. There is **no**
    automatic `org-admin`/`admin` → `list-owner` derivation; whether there
    should be is an open design question
    (`docs/planning/selection-lists-permit-actions.md`).

    **An id is never a capability** — knowing a list id grants nothing; every
    route re-checks the caller against the Security API, and a read the caller
    is not entitled to returns `404`, not `403`, so the API is not an
    existence oracle across orgs.


    ### Changelog

    * **4.2.0** — `SelectionList.revision` (the fork "behind source"
      comparison; `docs/planning/selection-lists-shared-and-fork.md` §13).
      (1) `SelectionList` gains read-only `revision` (integer, `>= 1`):
      the list's current revision, the same counter as the event
      contract's `listRevision` — `1` at creation, increased once per
      change event committed about the list or anything inside it, never
      decreasing, not changed by access grants. It equals the `listRevision` of the
      latest event the service committed for the list. Compared with a
      fork's `forked_from.revision` (the source's revision at copy time,
      unchanged since 4.1.0) it tells a client whether a fork is behind
      its source. (2) `revision` is **optional** (not in `required`) at
      4.2.0, for the same reason the 4.1.0 fields were: the contract
      freezes ahead of the implementation and the running service does not
      emit it yet. **Absent means unknown** — a client shows "comparison
      unavailable" and never infers a revision from `updated_at`. The
      implementation makes the service always emit it (independently of
      the `fuzefront.selection-lists.shared-lists` flag: the counter
      predates that feature) and a later MINOR adds it to `required`.
      No new fork-time field is needed: `SelectionListForkProvenance`
      already captures the source revision at copy time (`revision`).
      MINOR: one optional read-only response property; no request, path,
      parameter, status, enum or authorization changes. Clients:
      `@fuzeone/selection-list-client` 2.2.0,
      `fuzefront-selection-list-client` (Python) 2.2.0 (each adds a
      `revision` field and a fork-vs-source comparison helper). Events:
      unchanged (`listRevision` already exists on every revisioned event).
      (3) Documentation only, no wire change: `listSelectionLists` and
      `Page` now state that a page may be shorter than `limit` — even
      empty — while `hasMore` is `true`, because the per-row readability
      filter runs after the page is cut. Callers must follow `nextCursor`.
      The 2.2.0 clients' `getEffectiveList` / `get_effective_list` now
      follow the same query's cursor until a row for the key is found or
      the last page, bounded to 10 pages (`EffectiveLookupIncompleteError`
      when exhausted — never "no list"); 2.1.x read only the first page
      and could report no effective list when one existed.

    * **4.1.0** — shared lists, common (`platform`) lists and copy-on-write
      forks (`docs/planning/selection-lists-shared-and-fork.md`; owner
      requirement "members can pick from the app's common lists without
      owning them; ACL with copy-on-write").
      (1) `SelectionList` gains read-only `visibility`
      (`private|org|platform`), `forked_from` (nullable
      `SelectionListForkProvenance`) and `editable` (caller-relative hint);
      `SelectionListItem` gains read-only, nullable `origin_item_id`.
      All four are **optional** (not in `required`) at 4.1.0: the
      contract freezes ahead of the implementation, which ships behind the
      default-OFF flag `fuzefront.selection-lists.shared-lists`, and a
      service still running 4.0.0 behaviour omits them. Absent means
      exactly the 4.0.0 state — `visibility` absent == `private`,
      `forked_from` absent == `null` (not a fork), `origin_item_id`
      absent == `null` (not a forked item), `editable` absent == unknown
      (no hint; a client falls back to 4.0.0 behaviour and lets each
      mutation's own authorization decide). The implementation PR makes
      the service always emit them and a later MINOR tightens them to
      required (adding a property to a response's `required` is additive
      for readers).
      (2) `SelectionListCreate` accepts optional `visibility` (default
      `private`); `SelectionListUpdate` accepts `visibility` (owner-only;
      `platform` operator-only and one-way).
      (3) New operation `forkSelectionList`
      (`POST /v1/selection-lists/{listId}/fork`).
      (4) `listSelectionLists` gains `include_shared` (default `false` =
      the 4.0.0 result set, unchanged) and a `visibility` filter; with
      `include_shared=true` and `key`, it returns the *effective* list for
      the key (precedence above).
      (5) `ResolvedSelectionListItem` gains optional `effective_item_id`;
      resolve also resolves items of `platform` lists and follows
      `origin_item_id` into the caller org's `org`-visible fork.
      (6) `Error` gains optional `reason` (`fork_required`, `fork_exists`,
      `fork_not_applicable`, `visibility_locked`), `fork_url` and
      `source_list_id`; `409` is declared on every per-list mutation that
      can answer `fork_required`.
      (7) `SelectionListCatalog` gains `read_shared` (every customer tenant
      role) and `publish_platform` (`admin`, effective only in the platform
      organization); new extension `x-permit-shared-read` on read
      operations; new `x-permit-additional-actions` entries on
      `createSelectionList`, `updateSelectionList`, `deleteSelectionList`,
      `deleteSelectionListItem`.
      (8) `servers` example port corrected `3011` → `3008` (the service's
      default and the chart's `selectionListService.port`).
      MINOR: no existing request changes its response or its authorization
      for existing data. Every list that exists at 4.0.0 is `private`, so it
      reads, lists, resolves and authorizes exactly as before; the default
      listing is unchanged; the widened behaviour is reachable only through
      new opt-in parameters, the new operation, or lists created/changed
      with a non-`private` visibility, which cannot exist before 4.1.0.
      Response objects gain properties (additive, as `seed` was).
      Clients: `@fuzeone/selection-list-client` 2.1.0,
      `fuzefront-selection-list-client` (Python) 2.1.0; events:
      `@fuzefront/shared` 1.3.0 (`selection-lists.list.forked`,
      `selection-lists.visibility.changed`).

    * **4.0.0** — authorship and seed provenance (prerequisite for seeding,
      `docs/planning/selection-lists-events.md` §13.1), plus review finding
      L-1.
      (1) `SelectionList.created_by`, `SelectionListItem.created_by` and
      `SelectionListAccessGrant.granted_by` widen from `UserId` to
      `AuthorPrincipal` = `oneOf: [UserId, SystemPrincipal,
      DeletedUserSentinel]`. `SystemPrincipal` is `^system:[a-z0-9-]+$`
      (seeded rows carry `system:selection-list-service`);
      `DeletedUserSentinel` is the exact string `[deleted-user]`, which
      `src/events/user-deleted.handler.ts` has written into all three columns
      since it shipped — so 3.0.0 responses could already violate the
      `^usr_` pattern; 4.0.0 makes the contract describe the service.
      (2) `SelectionList` and `SelectionListItem` gain a required, read-only,
      nullable `seed` (`SeedProvenance`: `source`, `pack_key`,
      `pack_version`, `user_modified`), `null` for user-authored rows.
      (3) `updateSelectionList` with body `status: archived` additionally
      requires `delete` on the list (`x-permit-additional-actions`), matching
      `deleteSelectionList` / `archiveSelectionList`; a `list-editor` can no
      longer archive a list through PATCH.
      MAJOR because a client that validated or relied on `created_by` /
      `granted_by` being a `usr_` id (user lookup, profile link) now receives
      values that are not users, and because `list-editor` loses the PATCH
      archive path. Wire-additive otherwise: no request body, path or
      parameter changes. Clients: `@fuzeone/selection-list-client` 2.0.0,
      `fuzefront-selection-list-client` (Python) 2.0.0.

    * **3.0.0** — authorization model split across two resources (see
      *Authorization*). `x-permit-resource` is now declared on every operation.
      `listSelectionLists`, `createSelectionList`, `getSelectionListQuota` and
      `resolveSelectionListItems` are checked against the tenant-level
      `SelectionListCatalog` (`list`, `create`, `read_quota`, `resolve`)
      instead of `SelectionList` (`read`/`add_value`); every per-list action is
      instance-only and tenant roles confer none of them. Item **purge** now
      also requires `delete` on the parent list (owner-only, as the prose
      always said — review finding M-1), withdrawing purge from `list-editor`.
      `403` is declared on `listSelectionLists` and `getSelectionListQuota`.
      The wire shapes are unchanged. MAJOR because existing grants stop
      satisfying operations they used to: a tenant grant of `read`/`add_value`
      on `SelectionList` no longer authorizes list, create, quota or resolve,
      and a `list-editor` can no longer purge items. As with 2.0.0,
      `oasdiff breaking` does not flag this (it reports only the two added
      `403` responses, as info) because it does not model `x-permit-*`
      extensions — the break is in who is authorized, not in the wire shape.
      Migration notes: `docs/planning/selection-lists-permit-actions.md`.
    * **2.0.0** — `POST /v1/resolve` requires a Bearer token carrying an
      organization claim, like every other operation; the optional
      unauthenticated "trusted in-cluster caller" mode is removed and `403` is
      declared. MAJOR because it withdraws a documented calling mode, even
      though `oasdiff breaking` does not flag the removed `{}` security
      alternative and the service has always answered such calls `401`.
    * **1.0.0** — initial frozen contract (FFRNT-187).
  contact:
    name: FuzeFront Platform Team
    url: https://github.com/izzywdev/FuzeFront
  license:
    name: UNLICENSED

servers:
  - url: '{baseUrl}'
    description: >-
      Cluster-internal base URL. In the browser the shell always calls this
      service on a **same-origin** API base through the ingress — never a
      hard-coded absolute host (see CLAUDE.md).
    variables:
      baseUrl:
        default: http://fuzefront-selection-list-service:3008
        description: Base URL of the selection-list-service.

security:
  - bearerAuth: []

tags:
  - name: lists
    description: Selection lists — create, read, update, archive, purge.
  - name: items
    description: Items within a list — create, update, archive, purge, reorder.
  - name: translations
    description: Per-locale translations of lists and items, including machine autofill.
  - name: access
    description: Per-list access grants (ReBAC roles on the list instance).
  - name: quota
    description: Current quota usage and ceilings for the caller's organization.
  - name: resolve
    description: Hot path — cacheable bulk resolution of item ids to labels.

paths:
  /v1/selection-lists:
    get:
      operationId: listSelectionLists
      summary: List selection lists in the caller's organization
      description: >-
        Cursor-paginated. Requires `list` on the tenant-level
        `SelectionListCatalog` (`403` without it). Scoped to the caller's
        organization and further filtered per row to the lists the caller can
        `read` (`SelectionList:read`, an instance-only action); a list the
        caller has no instance grant on is simply absent, never a `403`. Each
        row carries its source-locale-resolved (or `locale`-resolved) `name`.


        **Shared lists (4.1.0).** By default (`include_shared=false`) the
        result set is exactly the 4.0.0 one: lists of the caller's
        organization that it holds an instance role on. With
        `include_shared=true` the per-row filter also admits lists the caller
        can read through the *shared read* (`org` lists of its own
        organization and `platform` common lists, given
        `SelectionListCatalog:read_shared`; a caller without it simply gets
        no extra rows). Unless a `visibility` filter is given, a `platform`
        list that the caller's organization **shadows** (its own active list
        with the same `key`, e.g. a fork, is readable by the caller) is
        omitted, so a picker sees each key once. With `key` and
        `include_shared=true` the page holds at most one row: the
        **effective** list for that key (see *Visibility, shared lists and
        forks*). Pagination is unchanged.


        **Pages may be short — even empty — while `hasMore` is `true`.** The
        service pages over the organization's rows first and applies the
        per-row readability filter to each page afterwards, so rows the
        caller cannot read are dropped *from* a page rather than replaced:
        a page can hold fewer than `limit` items, or none, and still carry a
        `nextCursor`. The walk stays gap- and duplicate-free. Callers must
        follow `nextCursor` until `hasMore` is `false` and must never read an
        empty page as "no more rows". This matters for the key lookup too:
        with `key`, `include_shared=true` and `limit=1`, a newer list with
        that key that the caller cannot read (e.g. another member's
        `private` draft) yields an empty first page, and the effective list
        is on a later page of the **same** query. Both clients'
        `getEffectiveList` / `get_effective_list` (2.2.0) follow the cursor
        for this, bounded to 10 pages, and report an exhausted walk as an
        error rather than as "no list".
      tags: [lists]
      x-permit-resource: SelectionListCatalog
      x-permit-action: list
      x-permit-shared-read:
        when: include_shared=true
        resource: SelectionListCatalog
        action: read_shared
        predicate: >-
          Admits rows with `visibility: org` owned by the caller's
          organization and rows with `visibility: platform`, in addition to
          the rows admitted by `SelectionList:read`.
      parameters:
        - name: limit
          in: query
          required: false
          description: >-
            Maximum rows to return. Clamped to `maximum` server-side — an
            over-max request is never honoured unbounded.
          schema:
            type: integer
            description: Page size.
            minimum: 1
            default: 50
            maximum: 200
        - name: cursor
          in: query
          required: false
          description: >-
            Opaque, server-issued cursor from the previous page's
            `page.nextCursor`. Echo it back unmodified; never construct or parse
            one. A malformed or expired cursor is a `400 VALIDATION_ERROR`,
            never a silent reset to page 1.
          schema:
            type: string
            description: Opaque pagination cursor.
            maxLength: 2048
        - name: status
          in: query
          required: false
          description: Filter lists by lifecycle status. Defaults to `active`.
          schema:
            type: string
            description: Lifecycle status filter.
            enum: [active, archived, all]
            default: active
        - name: locale
          in: query
          required: false
          description: >-
            Preferred locale for resolved text. Highest priority in the fallback
            chain; an unsupported value is a `400 VALIDATION_ERROR` rather than a
            silent fallback, so a client bug does not masquerade as missing
            translations.
          schema:
            $ref: '#/components/schemas/Locale'
        - name: key
          in: query
          required: false
          description: >-
            Exact-match filter on the org-unique list `key`. Returns a page of at
            most one row; present so a consumer can look a list up by its stable
            slug without knowing its id. With `include_shared=true` the row is
            the **effective** list for the key: the caller organization's own
            readable active list with that key if there is one, else the
            active `platform` list with that key.
          schema:
            $ref: '#/components/schemas/SelectionListKey'
        - name: include_shared
          in: query
          required: false
          description: >-
            Also return lists the caller can read through visibility (`org`
            lists of its own organization, `platform` common lists), not only
            lists it holds an instance role on. Defaults to `false`, which is
            the 4.0.0 result set. A picker passes `true`.
          schema:
            type: boolean
            description: Include lists readable through visibility.
            default: false
        - name: visibility
          in: query
          required: false
          description: >-
            Return only lists with this visibility. Supplying it also turns off
            platform-list shadowing, so an administrator can see the common
            list behind its organization's fork. A caller outside the platform
            organization sees `platform` rows only with `include_shared=true`.
          schema:
            $ref: '#/components/schemas/SelectionListVisibility'
      responses:
        '200':
          description: A page of selection lists.
          content:
            application/json:
              schema:
                type: object
                description: A cursor-paginated page of selection lists.
                required: [items, page]
                additionalProperties: false
                properties:
                  items:
                    type: array
                    description: The selection lists on this page, newest first.
                    items:
                      $ref: '#/components/schemas/SelectionList'
                  page:
                    $ref: '#/components/schemas/Page'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      operationId: createSelectionList
      summary: Create a selection list
      description: >-
        Mints a `front_sl_`-prefixed id server-side; the body must not contain one.
        `key` must be unique within the organization (`409 CONFLICT` otherwise)
        and `name` seeds the `source_locale` translation. Requires `create` on
        the tenant-level `SelectionListCatalog`. The service then grants the
        caller `list-owner` on the new list instance using its own machine
        identity (the caller cannot hold `manage_access` on a list that did not
        exist yet). Subject to the `org_lists` and `user_lists` quotas.


        `visibility` defaults to `private`. `org` needs nothing extra (the
        creator owns the list). `platform` creates a **common** list and
        additionally requires `SelectionListCatalog:publish_platform`
        **and** that the caller is acting in the platform organization; a
        caller in any other organization gets `403` (the list would have to
        be owned by the platform organization).
      tags: [lists]
      x-permit-resource: SelectionListCatalog
      x-permit-action: create
      x-permit-additional-actions:
        - when: body.visibility=platform
          resource: SelectionListCatalog
          action: publish_platform
          reason: >-
            A common list is readable by every tenant and immutable for them;
            only a platform operator acting in the platform organization may
            publish one. Evaluated in the platform organization's tenant.
      requestBody:
        required: true
        description: The list to create.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SelectionListCreate'
      responses:
        '201':
          description: The created selection list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'

  /v1/selection-lists/{listId}:
    parameters:
      - $ref: '#/components/parameters/ListIdParam'
    get:
      operationId: getSelectionList
      summary: Get one selection list
      description: >-
        Returns the list with its translation resolved through the locale
        fallback chain. Returns `404` — never `403` — when the caller cannot
        `read` it, so the endpoint is not a cross-org existence oracle.
        Readable through an instance grant or, for an `org`/`platform` list,
        the shared read (`x-permit-shared-read`) — so any tenant member can
        fetch a common list by id.
      tags: [lists]
      x-permit-resource: SelectionList
      x-permit-action: read
      x-permit-shared-read:
        when: list.visibility!=private
        resource: SelectionListCatalog
        action: read_shared
        predicate: >-
          `org`: the list's organization is the caller's organization.
          `platform`: always. Satisfies this operation's `read` without an
          instance grant; confers no other action.
      parameters:
        - name: locale
          in: query
          required: false
          description: >-
            Preferred locale for resolved text. Highest priority in the fallback
            chain; an unsupported value is a `400 VALIDATION_ERROR` rather than a
            silent fallback, so a client bug does not masquerade as missing
            translations.
          schema:
            $ref: '#/components/schemas/Locale'
      responses:
        '200':
          description: The selection list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      operationId: updateSelectionList
      summary: Update a selection list
      description: >-
        Partial update. Supplying `name`/`description` upserts the
        **`source_locale`** translation (marked `is_machine: false`) and
        invalidates the `source_hash` of every other locale, marking those
        translations stale for the next autofill. Changing `source_locale` does
        not re-translate anything.


        **Archiving through PATCH is not a back door.** A body with
        `"status": "archived"` reaches the same outcome as `DELETE` (archive)
        and `POST .../archive`, which both require `delete`; so it requires
        `delete` on the list **in addition to** `update`
        (`x-permit-additional-actions`, review finding L-1). A `list-editor`
        (holds `update`, not `delete`) gets `403` for that body and can still
        PATCH every other field. The check applies whenever the body carries
        `status: archived`, even if the list is already archived — the
        decision depends on the request, not on stored state.


        **Changing `visibility` (4.1.0)** is an access decision, so a body
        carrying `visibility` additionally requires `manage_access`
        (`list-owner`). Allowed transitions: `private` ↔ `org`, and
        `private`/`org` → `platform`. Promoting to `platform` additionally
        requires `SelectionListCatalog:publish_platform` and a list owned by
        the platform organization (`403` otherwise). A `platform` list can
        never be demoted: any other `visibility` on it is
        `409 CONFLICT` with `reason: visibility_locked` (archive it instead —
        archived items keep resolving). Sending the current visibility is a
        no-op that still requires `manage_access`. A change emits
        `selection-lists.visibility.changed`.


        **Shared lists.** A caller that can read a `platform` list through
        the shared read but holds no instance role on it gets
        `409 CONFLICT` / `reason: fork_required` (with `fork_url`) if it holds
        `SelectionListCatalog:create`, else `403`. The same holds for every
        per-list mutation below.
      tags: [lists]
      x-permit-resource: SelectionList
      x-permit-action: update
      x-permit-additional-actions:
        - when: body.status=archived
          resource: SelectionList
          action: delete
          reason: >-
            Archiving is a `delete`-level outcome everywhere else
            (`deleteSelectionList`, `archiveSelectionList`). `update` alone
            (held by `list-editor`) must not reach it through PATCH; the
            archive outcome additionally requires `delete` on the list.
        - when: body.visibility
          resource: SelectionList
          action: manage_access
          reason: >-
            Visibility decides who may read the list without a grant — an
            access decision, reserved to the holder of `manage_access`
            (`list-owner`), not to `update` (held by `list-editor`).
        - when: body.visibility=platform
          resource: SelectionListCatalog
          action: publish_platform
          reason: >-
            Publishing a common list to every tenant is a platform-operator
            act, evaluated in the platform organization's tenant; the list
            must be owned by the platform organization.
      requestBody:
        required: true
        description: The fields to change. Omitted fields are left untouched.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SelectionListUpdate'
      responses:
        '200':
          description: The updated selection list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
    delete:
      operationId: deleteSelectionList
      summary: Archive (default) or purge a selection list
      description: >-
        Archives the list unless `purge=true`. Archiving is idempotent and
        reversible via `PATCH { "status": "active" }`; its items keep resolving.
        **Purging is irreversible**, cascades to every item, translation and
        access grant, and permanently breaks any consumer row still holding one
        of the purged item ids — which is why it requires `delete` (owner only)
        and an explicit opt-in rather than being the default verb. Purging a
        `platform` list breaks stored ids in **every** tenant, so it
        additionally requires `SelectionListCatalog:publish_platform` (4.1.0).
        Forks of a purged list are untouched; their `forked_from` keeps
        naming the purged id.
      tags: [lists]
      x-permit-resource: SelectionList
      x-permit-action: delete
      x-permit-additional-actions:
        - when: purge=true AND list.visibility=platform
          resource: SelectionListCatalog
          action: publish_platform
          reason: >-
            A common list's item ids are stored by every tenant; purging it is
            a platform-operator decision, not only the list owner's.
      parameters:
        - name: purge
          in: query
          required: false
          description: >-
            When `true`, permanently delete instead of archiving. Irreversible;
            breaks every consumer reference to the purged ids.
          schema:
            type: boolean
            description: Permanently delete rather than archive.
            default: false
      responses:
        '200':
          description: The archived list (only returned when `purge` is false/absent).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionList'
        '204':
          description: The list was permanently purged. No body.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /v1/selection-lists/{listId}/archive:
    parameters:
      - $ref: '#/components/parameters/ListIdParam'
    post:
      operationId: archiveSelectionList
      summary: Archive a selection list
      description: >-
        Sets `status` to `archived`. Idempotent — archiving an already-archived
        list returns `200` with the unchanged resource. The explicit verb exists
        so a UI need never call `DELETE` for the non-destructive outcome.
      tags: [lists]
      x-permit-resource: SelectionList
      x-permit-action: delete
      responses:
        '200':
          description: The archived selection list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /v1/selection-lists/{listId}/fork:
    parameters:
      - $ref: '#/components/parameters/ListIdParam'
    post:
      operationId: forkSelectionList
      summary: Fork a common list into the caller's organization (copy-on-write)
      description: >-
        Copies a `platform` (common) list into the caller's organization as an
        ordinary list the caller owns, so the organization can change it. The
        copy carries:


        * the **same `key`** — so it shadows the common list for this
          organization in key lookups (archiving the fork reverts to the
          common list);
        * every non-purged item with its `code`, `status`, `sort_order`,
          source-locale text and **every translation** (with their
          `is_machine` flags), each item under a **new, service-minted id**
          and with `origin_item_id` naming the source item;
        * the list's source-locale text and every translation;
        * `forked_from: {list_id, organization_id, revision, forked_at}`
          (the source's revision at the moment of copying), `seed: null`,
          `created_by` = the caller, and `visibility` from the body
          (default `org`, so the organization's members keep seeing the
          values; `private` for a draft only grant-holders see).


        The service mints every id — the body carries none and rejects
        unknown properties (`governance/identifier-standard.md` §1). The
        caller is granted `list-owner` with the service's machine identity,
        exactly as on create. Subject to the `org_lists`, `user_lists` and
        `list_items` quotas (`403 QUOTA_EXCEEDED`). A source with more than
        5000 non-purged items is refused with `400 VALIDATION_ERROR` (the
        bound of the `list.forked` item map).


        **Idempotent per (organization, source).** If the caller's
        organization already holds a fork of this source (active or
        archived), nothing is copied: the existing fork is returned with
        `200` when the caller can read it, and `409 CONFLICT` /
        `reason: fork_exists` when it cannot (ask its owner for a
        role). A different list in the organization already using the key
        (one that is not a fork of this source) is `409 CONFLICT`.


        **Only lists of another organization are forkable** — in practice
        `platform` lists. Forking a list of the caller's own organization is
        `409 CONFLICT` / `reason: fork_not_applicable` (ask the owner for a
        role instead); an unreadable source is `404`.


        Emits, in one transaction: the `list.created` / `item.created` /
        `translation.upserted` events for the copied content,
        `selection-lists.list.forked` (provenance + the source→fork item id
        map), and `access.granted` for the owner.
      tags: [lists]
      x-permit-resource: SelectionListCatalog
      x-permit-action: create
      x-permit-additional-actions:
        - when: always
          resource: SelectionList
          action: read
          reason: >-
            The source must be readable by the caller — through an instance
            grant or through the shared read (`x-permit-shared-read`). A
            source the caller cannot read is `404`.
      x-permit-shared-read:
        when: list.visibility!=private
        resource: SelectionListCatalog
        action: read_shared
        predicate: >-
          Satisfies the source `read` above without an instance grant
          (`platform`: always; `org`: same organization, which is then
          refused as `fork_not_applicable`).
      requestBody:
        required: false
        description: Optional fork settings. An absent body means all defaults.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SelectionListForkRequest'
      responses:
        '200':
          description: >-
            The organization already held a fork of this source; it is
            returned unchanged and nothing was copied.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionList'
        '201':
          description: The new fork.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /v1/selection-lists/{listId}/items:
    parameters:
      - $ref: '#/components/parameters/ListIdParam'
    get:
      operationId: listSelectionListItems
      summary: List the items of a selection list
      description: >-
        Cursor-paginated, ordered by `sort_order` then `id`. Defaults to
        `status=active` — the set a picker should offer — so rendering a
        dropdown never accidentally offers archived choices. Pass
        `status=archived` or `status=all` to see the rest.
      tags: [items]
      x-permit-resource: SelectionList
      x-permit-action: read
      x-permit-shared-read:
        when: list.visibility!=private
        resource: SelectionListCatalog
        action: read_shared
        predicate: >-
          `org`: the list's organization is the caller's organization.
          `platform`: always. Satisfies this operation's `read` without an
          instance grant; confers no other action.
      parameters:
        - name: limit
          in: query
          required: false
          description: >-
            Maximum rows to return. Clamped to `maximum` server-side — an
            over-max request is never honoured unbounded.
          schema:
            type: integer
            description: Page size.
            minimum: 1
            default: 50
            maximum: 200
        - name: cursor
          in: query
          required: false
          description: >-
            Opaque, server-issued cursor from the previous page's
            `page.nextCursor`. Echo it back unmodified; never construct or parse
            one. A malformed or expired cursor is a `400 VALIDATION_ERROR`,
            never a silent reset to page 1.
          schema:
            type: string
            description: Opaque pagination cursor.
            maxLength: 2048
        - name: status
          in: query
          required: false
          description: >-
            Filter items by lifecycle status. Defaults to `active` so a picker
            never offers a retired choice by accident.
          schema:
            type: string
            description: Lifecycle status filter.
            enum: [active, archived, all]
            default: active
        - name: locale
          in: query
          required: false
          description: >-
            Preferred locale for resolved text. Highest priority in the fallback
            chain; an unsupported value is a `400 VALIDATION_ERROR` rather than a
            silent fallback, so a client bug does not masquerade as missing
            translations.
          schema:
            $ref: '#/components/schemas/Locale'
      responses:
        '200':
          description: A page of items, in `sort_order`.
          content:
            application/json:
              schema:
                type: object
                description: A cursor-paginated page of selection-list items.
                required: [items, page]
                additionalProperties: false
                properties:
                  items:
                    type: array
                    description: The items on this page, ordered by `sort_order`.
                    items:
                      $ref: '#/components/schemas/SelectionListItem'
                  page:
                    $ref: '#/components/schemas/Page'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      operationId: createSelectionListItem
      summary: Add an item to a selection list
      description: >-
        Mints a `front_sli_`-prefixed id server-side. `code` must be unique within the
        list and is **immutable afterwards** (`409 CONFLICT` on a duplicate).
        `label` seeds the `source_locale` translation. Omitting `sort_order`
        appends the item at `max(sort_order) + 100`. Subject to the `list_items`
        quota.
      tags: [items]
      x-permit-resource: SelectionList
      x-permit-action: add_value
      requestBody:
        required: true
        description: The item to add.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SelectionListItemCreate'
      responses:
        '201':
          description: The created item.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionListItem'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /v1/selection-lists/{listId}/items/reorder:
    parameters:
      - $ref: '#/components/parameters/ListIdParam'
    put:
      operationId: reorderSelectionListItems
      summary: Set the order of every item in a list
      description: >-
        Whole-collection replace: `item_ids` must contain **every** non-archived
        item of the list exactly once, and nothing else (`400 VALIDATION_ERROR`
        otherwise). The server rewrites `sort_order` to a gapped sequence
        (100, 200, 300, ...) so a later single-item insert needs no rewrite of
        its neighbours. Requiring the complete set — rather than accepting a
        sparse patch — is what makes the operation a single atomic statement
        with no ambiguous interleaving when two editors reorder concurrently.
      tags: [items]
      x-permit-resource: SelectionList
      x-permit-action: update_value
      requestBody:
        required: true
        description: The complete, ordered list of non-archived item ids.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SelectionListItemReorder'
      responses:
        '200':
          description: The full reordered set of non-archived items.
          content:
            application/json:
              schema:
                type: object
                description: The reordered items, in their new order.
                required: [items]
                additionalProperties: false
                properties:
                  items:
                    type: array
                    description: Every non-archived item, in its new `sort_order`.
                    items:
                      $ref: '#/components/schemas/SelectionListItem'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /v1/selection-lists/{listId}/items/{itemId}:
    parameters:
      - $ref: '#/components/parameters/ListIdParam'
      - $ref: '#/components/parameters/ItemIdParam'
    patch:
      operationId: updateSelectionListItem
      summary: Update an item
      description: >-
        Partial update. `code` is **absent from this schema by design** — it is
        immutable after create because integrations key off it; sending it is
        rejected by `additionalProperties: false` as a `400 VALIDATION_ERROR`.
        Supplying `label`/`description` upserts the `source_locale` translation
        and marks every other locale's translation stale.
      tags: [items]
      x-permit-resource: SelectionList
      x-permit-action: update_value
      requestBody:
        required: true
        description: The fields to change. Omitted fields are left untouched.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SelectionListItemUpdate'
      responses:
        '200':
          description: The updated item.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionListItem'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
    delete:
      operationId: deleteSelectionListItem
      summary: Archive (default) or purge an item
      description: >-
        Archives the item unless `purge=true`; an archived item stops being
        offered but keeps resolving through `POST /v1/resolve`, so consumer rows
        holding its id still render a label. **Purging is irreversible** and
        turns every stored reference to this id into a `missing` entry on
        resolve — hence an explicit opt-in **and** owner-level authority:
        archiving needs `remove_value`, purging needs `remove_value` plus
        `delete` on the parent list (`x-permit-additional-actions`), so only a
        `list-owner` can purge. A `list-editor` passing `purge=true` gets `403`.
      tags: [items]
      x-permit-resource: SelectionList
      x-permit-action: remove_value
      x-permit-additional-actions:
        - when: purge=true
          resource: SelectionList
          action: delete
          reason: >-
            Purge is irreversible and owner-only. `remove_value` alone (held
            by `list-editor`) authorizes the archive outcome; the purge
            outcome additionally requires `delete` on the parent list.
        - when: purge=true AND list.visibility=platform
          resource: SelectionListCatalog
          action: publish_platform
          reason: >-
            An item of a common list is stored by every tenant; purging it is
            a platform-operator decision (4.1.0).
      parameters:
        - name: purge
          in: query
          required: false
          description: >-
            When `true`, permanently delete instead of archiving. Irreversible;
            breaks every consumer reference to the purged ids.
          schema:
            type: boolean
            description: Permanently delete rather than archive.
            default: false
      responses:
        '200':
          description: The archived item (only returned when `purge` is false/absent).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionListItem'
        '204':
          description: The item was permanently purged. No body.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /v1/selection-lists/{listId}/items/{itemId}/archive:
    parameters:
      - $ref: '#/components/parameters/ListIdParam'
      - $ref: '#/components/parameters/ItemIdParam'
    post:
      operationId: archiveSelectionListItem
      summary: Archive an item
      description: >-
        Sets the item's `status` to `archived`. Idempotent. Archived items keep
        their `sort_order` so un-archiving restores their original position.
      tags: [items]
      x-permit-resource: SelectionList
      x-permit-action: remove_value
      responses:
        '200':
          description: The archived item.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionListItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /v1/selection-lists/{listId}/translations:
    parameters:
      - $ref: '#/components/parameters/ListIdParam'
    get:
      operationId: listSelectionListTranslations
      summary: List all translated locales for a selection list
      description: >-
        Returns one status entry per locale that has a list-level translation,
        including completeness (list + items), machine-translation status, and
        whether the translation is stale relative to the current source text
        (`source_changed`). The source locale itself is never returned here —
        its text lives on `PATCH /v1/selection-lists/{listId}`.

        This endpoint is the primary read for the translation workbench UI.
      tags: [translations]
      x-permit-resource: SelectionList
      x-permit-action: read
      x-permit-shared-read:
        when: list.visibility!=private
        resource: SelectionListCatalog
        action: read_shared
        predicate: >-
          `org`: the list's organization is the caller's organization.
          `platform`: always. Satisfies this operation's `read` without an
          instance grant; confers no other action.
      x-pagination: exempt
      x-pagination-reason: >-
        Bounded by the supported locale count (max 11). Not a user-data
        collection; no cursor required (pagination-standard.md §3).
      responses:
        '200':
          description: Translation status per locale.
          content:
            application/json:
              schema:
                type: array
                description: >-
                  One entry per locale that has a list-level translation
                  (excluding source_locale).
                items:
                  $ref: '#/components/schemas/TranslationLocaleStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/selection-lists/{listId}/translations/{locale}:
    parameters:
      - $ref: '#/components/parameters/ListIdParam'
      - $ref: '#/components/parameters/LocalePathParam'
    put:
      operationId: upsertSelectionListTranslation
      summary: Create or replace a list's translation for one locale
      description: >-
        Idempotent upsert of the list's `name`/`description` in `locale`. A
        human edit through this route always sets `is_machine: false` and stamps
        `source_hash` from the current `source_locale` text, which is what marks
        the translation fresh and protects it from being overwritten by a later
        autofill. Writing the `source_locale` itself is equivalent to
        `PATCH /v1/selection-lists/{listId}` with `name`/`description`. Subject
        to the `list_locales` quota.
      tags: [translations]
      x-permit-resource: SelectionList
      x-permit-action: translate
      requestBody:
        required: true
        description: The translated list text for this locale.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SelectionListTranslationUpsert'
      responses:
        '200':
          description: The stored translation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionListTranslation'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
    delete:
      operationId: deleteSelectionListTranslation
      summary: Remove a list's translation for one locale
      description: >-
        Deletes the list-level translation row for `locale`. The source locale
        (`source_locale` on the list) cannot be deleted — a `400
        VALIDATION_ERROR` is returned instead. Item-level translations in this
        locale are not touched; delete them individually if needed. Idempotent:
        deleting a translation that does not exist returns `204`.
      tags: [translations]
      x-permit-resource: SelectionList
      x-permit-action: translate
      responses:
        '204':
          description: Translation deleted (or never existed). No body.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /v1/selection-lists/{listId}/items/{itemId}/translations:
    parameters:
      - $ref: '#/components/parameters/ListIdParam'
      - $ref: '#/components/parameters/ItemIdParam'
    get:
      operationId: listSelectionListItemTranslations
      summary: List all translated locales for one item
      description: >-
        Returns one status entry per locale that has an item-level translation,
        with machine-translation status and staleness relative to the current
        source text. The source locale itself is excluded.
      tags: [translations]
      x-permit-resource: SelectionList
      x-permit-action: read
      x-permit-shared-read:
        when: list.visibility!=private
        resource: SelectionListCatalog
        action: read_shared
        predicate: >-
          `org`: the list's organization is the caller's organization.
          `platform`: always. Satisfies this operation's `read` without an
          instance grant; confers no other action.
      x-pagination: exempt
      x-pagination-reason: >-
        Bounded by the supported locale count (max 11).
      responses:
        '200':
          description: Item translation status per locale.
          content:
            application/json:
              schema:
                type: array
                description: >-
                  One entry per locale that has an item-level translation
                  (excluding source_locale).
                items:
                  $ref: '#/components/schemas/ItemTranslationLocaleStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/selection-lists/{listId}/items/{itemId}/translations/{locale}:
    parameters:
      - $ref: '#/components/parameters/ListIdParam'
      - $ref: '#/components/parameters/ItemIdParam'
      - $ref: '#/components/parameters/LocalePathParam'
    put:
      operationId: upsertSelectionListItemTranslation
      summary: Create or replace an item's translation for one locale
      description: >-
        Idempotent upsert of the item's `label`/`description` in `locale`, with
        the same `is_machine: false` / `source_hash` semantics as the list-level
        route. Does not consume `list_locales` quota beyond the locale already
        being open on the parent list.
      tags: [translations]
      x-permit-resource: SelectionList
      x-permit-action: translate
      requestBody:
        required: true
        description: The translated item text for this locale.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SelectionListItemTranslationUpsert'
      responses:
        '200':
          description: The stored translation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionListItemTranslation'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
    delete:
      operationId: deleteSelectionListItemTranslation
      summary: Remove an item's translation for one locale
      description: >-
        Deletes the item-level translation row for `locale`. The source locale
        cannot be deleted. Idempotent: deleting a translation that does not
        exist returns `204`.
      tags: [translations]
      x-permit-resource: SelectionList
      x-permit-action: translate
      responses:
        '204':
          description: Translation deleted (or never existed). No body.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /v1/selection-lists/{listId}/translations/{locale}/autofill:
    parameters:
      - $ref: '#/components/parameters/ListIdParam'
      - $ref: '#/components/parameters/LocalePathParam'
    post:
      operationId: autofillSelectionListTranslations
      summary: Machine-translate every missing or stale translation for one locale
      description: >-
        Translates the list and all of its items from `source_locale` into
        `locale` and stores the results with `is_machine: true`.

        **Human translations are never overwritten by default.** Only rows that
        are missing, or whose `source_hash` no longer matches the current source
        text, are filled; a fresh human translation is skipped. Pass
        `overwrite_machine: true` to additionally refresh existing *machine*
        rows — it still never touches `is_machine: false` rows, because the one
        thing an autofill must not do is silently discard work a human did.

        Requires the `translate` action; `locale` must differ from the list's
        `source_locale` (`400 VALIDATION_ERROR` otherwise). Synchronous;
        responds `200` once every row is written.
      tags: [translations]
      x-permit-resource: SelectionList
      x-permit-action: translate
      requestBody:
        required: false
        description: Optional scoping and overwrite behaviour for the autofill run.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SelectionListAutofillRequest'
      responses:
        '200':
          description: Summary of what the autofill run wrote and skipped.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionListAutofillResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /v1/selection-lists/{listId}/access:
    parameters:
      - $ref: '#/components/parameters/ListIdParam'
    get:
      operationId: listSelectionListAccess
      summary: List the access grants on a selection list
      description: >-
        Cursor-paginated. Grants are ReBAC role assignments on this list
        instance; the list's creator appears as `list-owner`. Requires
        `manage_access` — who can see a list is itself sensitive.
      tags: [access]
      x-permit-resource: SelectionList
      x-permit-action: manage_access
      parameters:
        - name: limit
          in: query
          required: false
          description: >-
            Maximum rows to return. Clamped to `maximum` server-side — an
            over-max request is never honoured unbounded.
          schema:
            type: integer
            description: Page size.
            minimum: 1
            default: 50
            maximum: 200
        - name: cursor
          in: query
          required: false
          description: >-
            Opaque, server-issued cursor from the previous page's
            `page.nextCursor`. Echo it back unmodified; never construct or parse
            one. A malformed or expired cursor is a `400 VALIDATION_ERROR`,
            never a silent reset to page 1.
          schema:
            type: string
            description: Opaque pagination cursor.
            maxLength: 2048
      responses:
        '200':
          description: A page of access grants.
          content:
            application/json:
              schema:
                type: object
                description: A cursor-paginated page of access grants.
                required: [items, page]
                additionalProperties: false
                properties:
                  items:
                    type: array
                    description: The access grants on this page.
                    items:
                      $ref: '#/components/schemas/SelectionListAccessGrant'
                  page:
                    $ref: '#/components/schemas/Page'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/selection-lists/{listId}/access/{userId}:
    parameters:
      - $ref: '#/components/parameters/ListIdParam'
      - $ref: '#/components/parameters/UserIdParam'
    put:
      operationId: setSelectionListAccess
      summary: Grant or change a user's role on a selection list
      description: >-
        Idempotent upsert of exactly one role for one user — roles do not stack.
        The user must already be a member of the list's organization
        (`400 VALIDATION_ERROR` otherwise). **Demoting the last `list-owner` is
        refused with `409 CONFLICT`**, so a list can never become unadministrable.
      tags: [access]
      x-permit-resource: SelectionList
      x-permit-action: manage_access
      requestBody:
        required: true
        description: The role to grant.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SelectionListAccessUpsert'
      responses:
        '200':
          description: The stored access grant.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionListAccessGrant'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
    delete:
      operationId: revokeSelectionListAccess
      summary: Revoke a user's access to a selection list
      description: >-
        Idempotent — revoking a grant that does not exist returns `204`.
        Revoking the **last** `list-owner` is refused with `409 CONFLICT`, for
        the same reason demotion is.
      tags: [access]
      x-permit-resource: SelectionList
      x-permit-action: manage_access
      responses:
        '204':
          description: The grant is gone (or never existed). No body.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /v1/selection-lists/quota:
    get:
      operationId: getSelectionListQuota
      summary: Get quota usage and ceilings for the caller's organization
      description: >-
        Returns a fixed, closed set of four quota scopes with the caller's
        current usage against each ceiling. Intended for a UI to warn *before* a
        create fails, rather than surfacing `403 QUOTA_EXCEEDED` as a surprise.
        Requires `read_quota` on the tenant-level `SelectionListCatalog`
        (tenant `admin` only); a caller without it gets `403`, and a UI should
        treat that as "no quota banner", not as an error.
      tags: [quota]
      x-permit-resource: SelectionListCatalog
      x-permit-action: read_quota
      x-pagination: exempt
      x-pagination-reason: >-
        Aggregate/scalar response — a fixed, closed set of four quota scopes for
        one organization. It does not grow with user data, so there is nothing
        to page through (pagination-standard.md §3).
      responses:
        '200':
          description: Quota usage and ceilings.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectionListQuotaStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /v1/resolve:
    post:
      operationId: resolveSelectionListItems
      summary: Bulk-resolve item ids to their labels
      description: >-
        **The hot path.** Consumers persist item ids; this turns a batch of them
        back into display labels in one round trip, so rendering a table of 200
        rows is one call rather than 200.

        It is `POST` because the id batch does not fit a URL, not because it
        mutates anything — the operation is read-only, side-effect-free and
        **cacheable**. Responses carry `Cache-Control` and an `ETag`; the
        response varies by `locale`/`Accept-Language`, so caches must key on
        those.

        **Archived items resolve normally** (with `status: "archived"`), which is
        the entire reason archive is the default destructive verb — a consumer
        row pointing at a retired choice still renders real text, and the caller
        can style it as retired using `status`. Only *purged* or never-existent
        ids come back in `missing`; ids are never silently dropped, so a caller
        can always tell "no label" from "no such item".

        **Authenticated, like every other route** (amended in 2.0.0). A Bearer
        token is required, and it must carry an organization claim: resolution
        is scoped to the caller's organization, so an id belonging to another
        org lands in `missing` exactly as a never-existent id does — never a
        `403`/`404` that would turn this endpoint into a cross-org existence
        oracle. A call with no token, an invalid token, or a token with no
        organization claim is answered `401` and resolves nothing. There is no
        anonymous or "trusted in-cluster" path: an in-cluster caller resolves on
        behalf of its end user by forwarding that user's token. (1.0.0 declared
        an optional, unauthenticated mode; the service never implemented it,
        and it would have made a bare item id a capability, contradicting the
        identifier standard.)

        The response is still deliberately minimal — it returns a label, a
        locale and a status, and never reveals a list key, an organization, or
        anything else an id alone should not unlock.

        **Authorization (amended in 3.0.0):** requires `resolve` on the
        tenant-level `SelectionListCatalog` (tenant `admin`, `editor`,
        `viewer`); `403` without it. It deliberately does **not** check
        `SelectionList:read` on each id's parent list — an accepted trade-off
        (review finding L-4): within the caller's own organization, a caller
        holding an item id can render that item's label even without an
        instance grant on its list. The minimal response above is what bounds
        that exposure; a per-id list check would turn this one round trip into
        N authorization decisions on the hottest path in the service.


        **Common lists and forks (4.1.0).** The resolvable set is the
        caller's organization's items **plus** the items of every `platform`
        list — a common list is readable by every tenant, so its ids resolve
        everywhere (any other organization's ids still land in `missing`).
        When an id belongs to a `platform` list and the caller's
        organization holds an **active, `org`-visible fork** of that list
        containing a non-purged item whose `origin_item_id` is that id, the
        result is the **fork item's** label/locale/status and carries
        `effective_item_id` = the fork item's id; a consumer may rewrite its
        stored value to it at leisure (both keep resolving). A `private` fork
        never redirects (it is a draft, not the organization's choice).
        `effective_item_id` is absent whenever no redirect happened.
      tags: [resolve]
      x-permit-resource: SelectionListCatalog
      x-permit-action: resolve
      security:
        - bearerAuth: []
      requestBody:
        required: true
        description: The item ids to resolve, and the locale to resolve them in.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResolveRequest'
      responses:
        '200':
          description: Resolved labels, plus the ids that could not be resolved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ResolveResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >-
        Authentik-issued JWT. `organization_id` and the acting user are derived
        from the token claims and **never** from the request body or a query
        parameter, so no caller can act outside its own org by editing a payload.

  parameters:
    ListIdParam:
      name: listId
      in: path
      required: true
      description: The `front_sl_`-prefixed selection-list id.
      schema:
        $ref: '#/components/schemas/SelectionListId'
    ItemIdParam:
      name: itemId
      in: path
      required: true
      description: The `front_sli_`-prefixed selection-list-item id.
      schema:
        $ref: '#/components/schemas/SelectionListItemId'
    UserIdParam:
      name: userId
      in: path
      required: true
      description: The `usr_`-prefixed id of the user whose grant is addressed.
      schema:
        $ref: '#/components/schemas/UserId'
    LocalePathParam:
      name: locale
      in: path
      required: true
      description: The target locale, from the supported set.
      schema:
        $ref: '#/components/schemas/Locale'

  schemas:
    SelectionListId:
      type: string
      description: >-
        TypeID for a selection list — the `front_sl_` prefix plus an opaque,
        service-minted suffix. Validate the prefix; never parse past it and never
        assume a length.
      pattern: '^front_sl_[0-9a-z]+$'
      maxLength: 255
      examples: ['front_sl_01h455vb4pex5vsknk084sn02q']
    SelectionListItemId:
      type: string
      description: >-
        TypeID for a selection-list item — the `front_sli_` prefix plus an opaque,
        service-minted suffix. This is the value consumers persist.
      pattern: '^front_sli_[0-9a-z]+$'
      maxLength: 255
      examples: ['front_sli_01h455vb4pex5vsknk084sn02q']
    OrganizationId:
      type: string
      description: TypeID of the owning organization (spine prefix `org_`).
      pattern: '^org_[0-9a-z]+$'
      maxLength: 255
      examples: ['org_01h455vb4pex5vsknk084sn02q']
    UserId:
      type: string
      description: TypeID of a user (spine prefix `usr_`).
      pattern: '^usr_[0-9a-z]+$'
      maxLength: 255
      examples: ['usr_01h455vb4pex5vsknk084sn02q']
    SystemPrincipal:
      type: string
      description: >-
        A non-human principal that wrote the row — `system:` plus a lowercase
        service slug. Today the only value the service writes is
        `system:selection-list-service`, on rows created by **seeding**
        (`docs/planning/selection-lists-events.md` §9.2). It names a service,
        not a user: never look it up in the identity service, and never treat
        it as a grantee.
      pattern: '^system:[a-z0-9-]+$'
      maxLength: 255
      examples: ['system:selection-list-service']
    DeletedUserSentinel:
      type: string
      description: >-
        The exact sentinel `[deleted-user]` that replaces a user id in
        authorship fields when that user is deleted (`identity.user.deleted`,
        `src/events/user-deleted.handler.ts`). The audit trail keeps the row;
        it no longer says who. It is not an id and resolves to nobody — render
        it as "deleted user", never as a link.
      const: '[deleted-user]'
      examples: ['[deleted-user]']
    AuthorPrincipal:
      description: >-
        Who wrote a row (`created_by`, `granted_by`). One of: a user
        (`usr_…`), a system principal (`system:<service>`, e.g. rows written by
        seeding), or the deleted-user sentinel (`[deleted-user]`, after the
        author was deleted). The three alternatives are disjoint by their
        prefix / literal value, so the type is decidable **offline from the
        string alone** — that prefix is the discriminator
        (`governance/identifier-standard.md` §3); no lookup ever resolves a
        bare value. Only a `usr_` value names a user; a client must branch on
        the form before treating the value as one (e.g. before a user
        lookup or rendering a profile link).
      type: string
      maxLength: 255
      oneOf:
        - $ref: '#/components/schemas/UserId'
        - $ref: '#/components/schemas/SystemPrincipal'
        - $ref: '#/components/schemas/DeletedUserSentinel'
      examples:
        - 'usr_01h455vb4pex5vsknk084sn02q'
        - 'system:selection-list-service'
        - '[deleted-user]'
    SeedSlug:
      type: string
      description: >-
        A seed source (app slug, or `platform`) or seed-pack key. Same
        alphabet and bounds as a list `key`; mirrors `slSlugV1` in
        `@fuzefront/shared` `selection-lists.common.ts`.
      pattern: '^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$'
      minLength: 2
      maxLength: 64
      examples: ['platform']
    SeedProvenance:
      type: object
      description: >-
        **Read-only.** Provenance of a row created by seeding
        (`docs/planning/selection-lists-events.md` §9). Present (non-null) only
        on seeded rows; a user-authored row carries `seed: null`. Mirrors the
        event contract's `slSeedProvenanceV1` field-for-field, in this API's
        snake_case. No request body accepts it — seeding happens only through
        the Kafka contract, never over HTTP.
      required: [source, pack_key, pack_version, user_modified]
      additionalProperties: false
      readOnly: true
      properties:
        source:
          $ref: '#/components/schemas/SeedSlug'
        pack_key:
          $ref: '#/components/schemas/SeedSlug'
        pack_version:
          type: integer
          description: Version of the seed pack that last wrote this row.
          minimum: 1
          examples: [1]
        user_modified:
          type: boolean
          description: >-
            `true` once a human has changed the seeded content (detected by
            content hash, not by a flag a route must remember to set). Sticky:
            it never returns to `false`, and seeding upgrades never overwrite a
            row where it is `true`.
          examples: [false]

    SelectionListVisibility:
      type: string
      description: >-
        Who may **read** a list without an instance grant (4.1.0). `private`
        — nobody (only instance-role holders; the default and every 4.0.0
        list). `org` — every member of the owning organization holding
        `SelectionListCatalog:read_shared`. `platform` — a common list owned
        by the platform organization, readable by every authenticated
        caller in any organization holding `read_shared`; one-way (never
        demoted). Visibility never confers a mutation.
      enum: [private, org, platform]
      examples: ['org']

    SelectionListForkProvenance:
      type: object
      description: >-
        **Read-only.** Where a forked list was copied from (4.1.0). The
        source may since have changed, been archived or been purged — this
        records the moment of copying and is never updated; `list_id` is a
        provenance reference, not a capability, and may answer `404`.
      required: [list_id, organization_id, revision, forked_at]
      additionalProperties: false
      readOnly: true
      properties:
        list_id:
          $ref: '#/components/schemas/SelectionListId'
        organization_id:
          $ref: '#/components/schemas/OrganizationId'
        revision:
          type: integer
          description: >-
            The source list's revision (the event contract's `listRevision`)
            at the moment of copying — the revision of exactly the content
            that was copied. Never updated. Compare it with the source's
            current `SelectionList.revision` (4.2.0, read with one
            `getSelectionList` of `list_id`): greater there means the fork is
            behind its source, equal means up to date; a source response
            without `revision` means the comparison is unknown.
          minimum: 1
          examples: [3]
        forked_at:
          type: string
          format: date-time
          description: When the fork was made (RFC 3339, UTC).
          examples: ['2026-10-05T12:00:00Z']

    SelectionListForkRequest:
      type: object
      description: >-
        Optional settings for `POST /v1/selection-lists/{listId}/fork`.
        Carries **no `id`** and no `key` — the service mints every id and the
        fork keeps the source's key — and rejects unknown properties
        (`governance/identifier-standard.md` §1).
      additionalProperties: false
      properties:
        visibility:
          type: string
          description: >-
            Visibility of the fork. `org` (default) makes it the
            organization's version of the list for every member; `private`
            keeps it a draft only grant-holders see (it then neither shadows
            the common list for others nor redirects resolve). A fork can
            never be `platform`.
          enum: [private, org]
          default: org
          examples: ['org']

    Locale:
      type: string
      description: >-
        A supported BCP-47 language code. The set is fixed by
        `i18n.languages.json` / `packages/i18n/src/languages.ts`; adding a
        language there is what widens this enum, and the two must not drift.
      enum: [en, es, fr, de, pt, ru, zh, ja, hi, ar, he]
      examples: ['en']

    LifecycleStatus:
      type: string
      description: >-
        Lifecycle state. `archived` rows are hidden from pickers but still
        resolve, so existing consumer references keep rendering a label.
      enum: [active, archived]
      examples: ['active']

    SelectionListKey:
      type: string
      description: >-
        Org-unique, stable slug identifying the list to integrations
        (`countries`, `ticket-priorities`). Lowercase alphanumerics and hyphens.
      pattern: '^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$'
      minLength: 2
      maxLength: 64
      examples: ['countries']

    SelectionListItemCode:
      type: string
      description: >-
        List-unique interop key (`US`, `HIGH`). **Immutable after create** and
        never translated — it is a key, not a label, and must never be rendered
        to an end user.
      pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$'
      minLength: 1
      maxLength: 63
      examples: ['US']

    Page:
      type: object
      description: >-
        Cursor pagination envelope, identical across every Fuze service
        (`governance/pagination-standard.md` §1). A page may hold fewer
        items than `limit` — even none — while `hasMore` is `true` (rows the
        caller may not read are filtered out of a page after it is cut);
        follow `nextCursor` until `hasMore` is `false`.
      required: [nextCursor, hasMore]
      additionalProperties: false
      properties:
        nextCursor:
          type: [string, 'null']
          description: >-
            Opaque cursor for the next page, or `null` when this is the last
            page. Pass it back as the `cursor` query parameter.
          maxLength: 2048
          examples: ['eyJsYXN0SWQiOiJzbGlfMDFoNDU1In0']
        hasMore:
          type: boolean
          description: Whether a further page exists.
          examples: [false]
        total:
          type: integer
          description: >-
            Total rows matching the filter across all pages. Optional — omitted
            when counting the full set would be expensive.
          minimum: 0
          examples: [12]

    SelectionList:
      type: object
      description: >-
        A selection list with its text resolved for one locale. The resolved
        text is not stored on this row — it comes from
        `selection_list_translations` through the fallback chain.
      required:
        - id
        - organization_id
        - key
        - source_locale
        - status
        - name
        - resolved_locale
        - is_machine
        - seed
        - created_by
        - created_at
        - updated_at
      additionalProperties: false
      properties:
        id:
          $ref: '#/components/schemas/SelectionListId'
        organization_id:
          description: >-
            The owning organization. For a `platform` list this is the
            platform organization, not the caller's — never `null`.
          $ref: '#/components/schemas/OrganizationId'
        key:
          $ref: '#/components/schemas/SelectionListKey'
        source_locale:
          $ref: '#/components/schemas/Locale'
        status:
          $ref: '#/components/schemas/LifecycleStatus'
        name:
          type: string
          description: >-
            Human-readable list name in the resolved locale. **Never `null`** —
            the fallback chain always yields text.
          minLength: 1
          maxLength: 200
          examples: ['Countries']
        description:
          type: [string, 'null']
          description: Optional longer description in the resolved locale.
          maxLength: 2000
          examples: ['ISO 3166-1 countries offered in the address picker.']
        resolved_locale:
          $ref: '#/components/schemas/Locale'
        is_machine:
          type: boolean
          description: >-
            Whether the resolved `name`/`description` was machine-translated. A
            UI should badge machine text so a reviewer can find what still needs
            a human pass.
          examples: [false]
        item_count:
          type: integer
          description: Number of non-archived items in the list.
          minimum: 0
          examples: [249]
        seed:
          description: >-
            **Read-only, always present.** Seed provenance when the list was
            created by seeding; `null` for a user-authored list.
          readOnly: true
          oneOf:
            - $ref: '#/components/schemas/SeedProvenance'
            - type: 'null'
              description: Not a seeded list.
        visibility:
          description: >-
            **Read-only (4.1.0), optional until the implementation ships.**
            Who may read this list without an instance grant. **Absent means
            `private`** — a service still running 4.0.0 behaviour (the
            implementation is behind the default-OFF flag
            `fuzefront.selection-lists.shared-lists`) omits it, and every
            such list is `private`. The implementation PR makes it always
            present; a later MINOR adds it to `required`.
          readOnly: true
          $ref: '#/components/schemas/SelectionListVisibility'
        forked_from:
          description: >-
            **Read-only (4.1.0), optional until the implementation ships.**
            Fork provenance when this list was made by `forkSelectionList`;
            `null` otherwise. **Absent means `null`** (not a fork) — a
            service still running 4.0.0 behaviour omits it. The
            implementation PR makes it always present; a later MINOR adds it
            to `required`.
          readOnly: true
          oneOf:
            - $ref: '#/components/schemas/SelectionListForkProvenance'
            - type: 'null'
              description: Not a fork.
        editable:
          type: boolean
          readOnly: true
          description: >-
            **Caller-relative hint**, not an authorization grant: `true` when
            the caller holds at least one write action on this list through
            an instance role (by the role table, exactly the roles other than
            `list-viewer`; the service decides it with one bulk check of
            `SelectionList:translate`, which every such role holds). `false`
            for a list read only through visibility — a UI shows it read-only
            and, for a `platform` list, offers "fork to edit". Every mutation
            is still authorized on its own. **Optional until the
            implementation ships (4.1.0): absent means unknown** — no hint;
            a service still running 4.0.0 behaviour (implementation behind
            the default-OFF flag `fuzefront.selection-lists.shared-lists`)
            omits it, and a client behaves exactly as at 4.0.0 (offers the
            edit affordances its other signals allow and lets each
            mutation's own authorization decide). Never infer `false` from
            absence. The implementation PR makes it always present; a later
            MINOR adds it to `required`.
          examples: [false]
        revision:
          type: integer
          readOnly: true
          minimum: 1
          description: >-
            **Read-only (4.2.0), optional until the implementation ships.**
            The list's current revision: the same per-list counter the event
            contract carries as `listRevision` on every `selection-lists.*`
            event about this list. It is `1` when the list is created and the
            service increases it by one for every change event it commits
            about the list **or anything inside it** — list fields, `status`,
            `visibility`, items (create, update, reorder, archive, purge) and
            list or item translations, whether made by a caller, by seeding
            or by autofill. It never decreases and a value is never reused
            for the same list. Access-grant changes do not change it, and
            neither does housekeeping that publishes no list event
            (anonymising a deleted user's authorship; an organization-deletion
            cascade). It equals the `listRevision` of the latest revisioned
            event the service has committed for this list (a mutation's
            response carries a revision at least as new as the mutation's own
            last event). A greater value means
            "changed since", not that any particular field or item differs.
            **Fork comparison:** a fork's `forked_from.revision` is the
            source's revision at copy time; the source's current `revision`
            greater than it means the source changed after the fork was made
            (informational — no merge or sync operation exists), equal means
            unchanged. It is not an optimistic-concurrency token: no request
            accepts it. **Absent means unknown** — a service that does not yet
            emit it omits it; a client then reports the comparison as
            unavailable and never infers a revision from `updated_at` or from
            absence. The implementation makes it always present (regardless
            of the `fuzefront.selection-lists.shared-lists` flag); a later
            MINOR adds it to `required`.
          examples: [7]
        created_by:
          $ref: '#/components/schemas/AuthorPrincipal'
        created_at:
          type: string
          format: date-time
          description: When the list was created (RFC 3339, UTC).
          examples: ['2026-08-10T12:00:00Z']
        updated_at:
          type: string
          format: date-time
          description: When the list row was last modified (RFC 3339, UTC).
          examples: ['2026-08-10T12:00:00Z']

    SelectionListCreate:
      type: object
      description: >-
        Body for creating a list. Carries **no `id`** — the service mints it —
        and rejects unknown properties so a client-supplied id cannot bind
        (`governance/identifier-standard.md` §1).
      required: [key, name]
      additionalProperties: false
      properties:
        key:
          $ref: '#/components/schemas/SelectionListKey'
        source_locale:
          $ref: '#/components/schemas/Locale'
        name:
          type: string
          description: The list name in `source_locale`. Seeds the source translation.
          minLength: 1
          maxLength: 200
          examples: ['Countries']
        description:
          type: string
          description: Optional list description in `source_locale`.
          maxLength: 2000
          examples: ['ISO 3166-1 countries offered in the address picker.']
        visibility:
          allOf:
            - $ref: '#/components/schemas/SelectionListVisibility'
          description: >-
            Defaults to `private`. `platform` requires
            `SelectionListCatalog:publish_platform` and a caller acting in the
            platform organization.
          default: private

    SelectionListUpdate:
      type: object
      description: >-
        Partial update of a list. Every field is optional; an empty body is a
        `400 VALIDATION_ERROR`.
      additionalProperties: false
      minProperties: 1
      properties:
        key:
          $ref: '#/components/schemas/SelectionListKey'
        source_locale:
          $ref: '#/components/schemas/Locale'
        status:
          $ref: '#/components/schemas/LifecycleStatus'
        name:
          type: string
          description: New list name, written to the `source_locale` translation.
          minLength: 1
          maxLength: 200
          examples: ['Countries (EMEA)']
        description:
          type: [string, 'null']
          description: >-
            New description for the `source_locale` translation; `null` clears
            it.
          maxLength: 2000
          examples: ['Countries offered in the EMEA address picker.']
        visibility:
          allOf:
            - $ref: '#/components/schemas/SelectionListVisibility'
          description: >-
            New visibility (4.1.0). Requires `manage_access` on the list;
            `platform` additionally requires `publish_platform` and a list
            owned by the platform organization. A `platform` list cannot be
            given any other visibility (`409`, `reason: visibility_locked`).

    SelectionListItem:
      type: object
      description: >-
        One item of a selection list, with its text resolved for one locale.
      required:
        - id
        - list_id
        - code
        - sort_order
        - status
        - label
        - resolved_locale
        - is_machine
        - seed
        - created_by
        - created_at
        - updated_at
      additionalProperties: false
      properties:
        id:
          $ref: '#/components/schemas/SelectionListItemId'
        list_id:
          $ref: '#/components/schemas/SelectionListId'
        code:
          $ref: '#/components/schemas/SelectionListItemCode'
        sort_order:
          type: integer
          description: >-
            Display position within the list, ascending. Gapped by 100 so a
            single insert does not rewrite its neighbours.
          minimum: 0
          examples: [100]
        status:
          $ref: '#/components/schemas/LifecycleStatus'
        label:
          type: string
          description: >-
            The display text in the resolved locale. **Never `null`** — the
            fallback chain always yields text.
          minLength: 1
          maxLength: 200
          examples: ['United States']
        description:
          type: [string, 'null']
          description: Optional helper text for the item, in the resolved locale.
          maxLength: 2000
          examples: ['United States of America']
        resolved_locale:
          $ref: '#/components/schemas/Locale'
        is_machine:
          type: boolean
          description: Whether the resolved text was machine-translated.
          examples: [false]
        seed:
          description: >-
            **Read-only, always present.** Seed provenance when the item was
            created by seeding; `null` for a user-authored item (including a
            user-added item on a seeded list).
          readOnly: true
          oneOf:
            - $ref: '#/components/schemas/SeedProvenance'
            - type: 'null'
              description: Not a seeded item.
        origin_item_id:
          description: >-
            **Read-only (4.1.0), optional until the implementation ships.**
            On an item copied by `forkSelectionList`, the id of the source
            item it was copied from — the mapping that lets stored values
            pointing at the common list be migrated to the fork (and that
            `POST /v1/resolve` follows). `null` on every other item,
            including items added to a fork after it was made. **Absent
            means `null`** (not a forked item) — a service still running
            4.0.0 behaviour (implementation behind the default-OFF flag
            `fuzefront.selection-lists.shared-lists`) omits it. The
            implementation PR makes it always present; a later MINOR adds it
            to `required`. A provenance reference, not a capability.
          readOnly: true
          oneOf:
            - $ref: '#/components/schemas/SelectionListItemId'
            - type: 'null'
              description: Not a forked item.
        created_by:
          $ref: '#/components/schemas/AuthorPrincipal'
        created_at:
          type: string
          format: date-time
          description: When the item was created (RFC 3339, UTC).
          examples: ['2026-08-10T12:00:00Z']
        updated_at:
          type: string
          format: date-time
          description: When the item row was last modified (RFC 3339, UTC).
          examples: ['2026-08-10T12:00:00Z']

    SelectionListItemCreate:
      type: object
      description: >-
        Body for adding an item. Carries **no `id`** — the service mints it —
        and rejects unknown properties.
      required: [code, label]
      additionalProperties: false
      properties:
        code:
          $ref: '#/components/schemas/SelectionListItemCode'
        label:
          type: string
          description: The item label in the list's `source_locale`.
          minLength: 1
          maxLength: 200
          examples: ['United States']
        description:
          type: string
          description: Optional helper text in the list's `source_locale`.
          maxLength: 2000
          examples: ['United States of America']
        sort_order:
          type: integer
          description: >-
            Explicit position. Omit to append at `max(sort_order) + 100`.
          minimum: 0
          examples: [100]

    SelectionListItemUpdate:
      type: object
      description: >-
        Partial update of an item. `code` is deliberately **not** a member —
        it is immutable after create, and `additionalProperties: false` turns an
        attempt to change it into a `400` instead of a silent no-op.
      additionalProperties: false
      minProperties: 1
      properties:
        label:
          type: string
          description: New label, written to the `source_locale` translation.
          minLength: 1
          maxLength: 200
          examples: ['United States of America']
        description:
          type: [string, 'null']
          description: New helper text for the `source_locale`; `null` clears it.
          maxLength: 2000
          examples: ['Includes overseas territories.']
        sort_order:
          type: integer
          description: New display position. Prefer the reorder route for bulk moves.
          minimum: 0
          examples: [250]
        status:
          $ref: '#/components/schemas/LifecycleStatus'

    SelectionListItemReorder:
      type: object
      description: >-
        Whole-collection reorder. `item_ids` must be a permutation of exactly the
        list's non-archived item ids.
      required: [item_ids]
      additionalProperties: false
      properties:
        item_ids:
          type: array
          description: >-
            Every non-archived item id of the list, exactly once, in the desired
            display order.
          minItems: 1
          maxItems: 5000
          uniqueItems: true
          items:
            $ref: '#/components/schemas/SelectionListItemId'

    TranslationLocaleStatus:
      type: object
      description: >-
        Translation workbench summary for one locale of a selection list.
        Returned by `GET /v1/selection-lists/{listId}/translations`.
      required: [locale, completeness_pct, machine_translated, source_changed]
      additionalProperties: false
      properties:
        locale:
          $ref: '#/components/schemas/Locale'
        completeness_pct:
          type: integer
          description: >-
            Percentage of translatable entities (list + active items) that have
            a translation in this locale. 0–100.
          minimum: 0
          maximum: 100
          examples: [75]
        machine_translated:
          type: boolean
          description: Whether the list-level translation in this locale was machine-produced.
          examples: [true]
        source_changed:
          type: boolean
          description: >-
            Whether the source-locale text has changed since this translation
            was written (i.e. the stored `source_hash` no longer matches the
            current source). A `true` value means the translation is stale and
            should be reviewed or regenerated.
          examples: [false]

    ItemTranslationLocaleStatus:
      type: object
      description: >-
        Translation workbench summary for one locale of a selection-list item.
        Returned by `GET /v1/selection-lists/{listId}/items/{itemId}/translations`.
      required: [locale, machine_translated, source_changed]
      additionalProperties: false
      properties:
        locale:
          $ref: '#/components/schemas/Locale'
        machine_translated:
          type: boolean
          description: Whether this item's translation in this locale was machine-produced.
          examples: [true]
        source_changed:
          type: boolean
          description: >-
            Whether the source-locale text for this item has changed since the
            translation was written.
          examples: [false]

    SelectionListTranslation:
      type: object
      description: A stored list translation for one locale.
      required: [list_id, locale, name, is_machine, updated_at]
      additionalProperties: false
      properties:
        list_id:
          $ref: '#/components/schemas/SelectionListId'
        locale:
          $ref: '#/components/schemas/Locale'
        name:
          type: string
          description: The translated list name.
          minLength: 1
          maxLength: 200
          examples: ['Pays']
        description:
          type: [string, 'null']
          description: The translated list description, if any.
          maxLength: 2000
          examples: ['Pays proposés dans le sélecteur d''adresse.']
        source_hash:
          type: [string, 'null']
          description: >-
            Hash of the `source_locale` text this translation was produced from.
            When it no longer matches the current source text the translation is
            stale and eligible for autofill. `null` for the source locale itself.
          maxLength: 128
          examples: ['9f2c1a7b5e3d4088']
        is_machine:
          type: boolean
          description: Whether this row was machine-translated.
          examples: [true]
        updated_at:
          type: string
          format: date-time
          description: When this translation was last written (RFC 3339, UTC).
          examples: ['2026-08-10T12:00:00Z']

    SelectionListItemTranslation:
      type: object
      description: A stored item translation for one locale.
      required: [item_id, locale, label, is_machine, updated_at]
      additionalProperties: false
      properties:
        item_id:
          $ref: '#/components/schemas/SelectionListItemId'
        locale:
          $ref: '#/components/schemas/Locale'
        label:
          type: string
          description: The translated item label.
          minLength: 1
          maxLength: 200
          examples: ['États-Unis']
        description:
          type: [string, 'null']
          description: The translated item helper text, if any.
          maxLength: 2000
          examples: ['États-Unis d''Amérique']
        source_hash:
          type: [string, 'null']
          description: >-
            Hash of the source-locale label this translation came from; a
            mismatch marks it stale.
          maxLength: 128
          examples: ['9f2c1a7b5e3d4088']
        is_machine:
          type: boolean
          description: Whether this row was machine-translated.
          examples: [true]
        updated_at:
          type: string
          format: date-time
          description: When this translation was last written (RFC 3339, UTC).
          examples: ['2026-08-10T12:00:00Z']

    SelectionListTranslationUpsert:
      type: object
      description: Human-authored list text for one locale.
      required: [name]
      additionalProperties: false
      properties:
        name:
          type: string
          description: The translated list name.
          minLength: 1
          maxLength: 200
          examples: ['Pays']
        description:
          type: [string, 'null']
          description: The translated description; `null` clears it.
          maxLength: 2000
          examples: ['Pays proposés dans le sélecteur d''adresse.']

    SelectionListItemTranslationUpsert:
      type: object
      description: Human-authored item text for one locale.
      required: [label]
      additionalProperties: false
      properties:
        label:
          type: string
          description: The translated item label.
          minLength: 1
          maxLength: 200
          examples: ['États-Unis']
        description:
          type: [string, 'null']
          description: The translated helper text; `null` clears it.
          maxLength: 2000
          examples: ['États-Unis d''Amérique']

    SelectionListAutofillRequest:
      type: object
      description: Optional scoping for a machine-translation run.
      additionalProperties: false
      properties:
        overwrite_machine:
          type: boolean
          description: >-
            Also refresh translations that already exist and were themselves
            machine-produced. Human translations (`is_machine: false`) are never
            overwritten regardless of this flag.
          default: false
          examples: [false]
        item_ids:
          type: array
          description: >-
            Restrict the run to these items. Omit to cover the list and all of
            its non-archived items.
          maxItems: 5000
          uniqueItems: true
          items:
            $ref: '#/components/schemas/SelectionListItemId'

    SelectionListAutofillResult:
      type: object
      description: What a machine-translation run wrote and what it left alone.
      required:
        - locale
        - source_locale
        - list_translated
        - items_translated
        - items_skipped
      additionalProperties: false
      properties:
        locale:
          $ref: '#/components/schemas/Locale'
        source_locale:
          $ref: '#/components/schemas/Locale'
        list_translated:
          type: boolean
          description: Whether the list's own name/description was written.
          examples: [true]
        items_translated:
          type: integer
          description: Count of item translations written by this run.
          minimum: 0
          examples: [248]
        items_skipped:
          type: integer
          description: >-
            Count of items left untouched because a fresh human translation
            already existed.
          minimum: 0
          examples: [1]

    SelectionListAccessRole:
      type: string
      description: >-
        A ReBAC role held on one selection-list instance. Roles do not stack — a
        user holds exactly one per list.
      enum:
        - list-owner
        - list-editor
        - list-contributor
        - list-translator
        - list-viewer
      examples: ['list-editor']

    SelectionListAccessGrant:
      type: object
      description: One user's role on one selection list.
      required: [list_id, user_id, role, granted_by, granted_at, updated_at]
      additionalProperties: false
      properties:
        list_id:
          $ref: '#/components/schemas/SelectionListId'
        user_id:
          $ref: '#/components/schemas/UserId'
        role:
          $ref: '#/components/schemas/SelectionListAccessRole'
        granted_by:
          $ref: '#/components/schemas/AuthorPrincipal'
        granted_at:
          type: string
          format: date-time
          description: When the grant was first created (RFC 3339, UTC).
          examples: ['2026-08-10T12:00:00Z']
        updated_at:
          type: string
          format: date-time
          description: When the role was last changed (RFC 3339, UTC).
          examples: ['2026-08-10T12:00:00Z']

    SelectionListAccessUpsert:
      type: object
      description: The single role to grant a user on this list.
      required: [role]
      additionalProperties: false
      properties:
        role:
          $ref: '#/components/schemas/SelectionListAccessRole'

    QuotaScope:
      type: string
      description: >-
        The ceiling a quota decision was made against. `org_lists` and
        `user_lists` cap how many lists exist; `list_items` and `list_locales`
        cap the size of one list.
      enum: [org_lists, user_lists, list_items, list_locales]
      examples: ['org_lists']

    SelectionListQuotaEntry:
      type: object
      description: Current usage against one ceiling.
      required: [scope, applies_to, limit, current]
      additionalProperties: false
      properties:
        scope:
          $ref: '#/components/schemas/QuotaScope'
        applies_to:
          type: string
          description: What the ceiling is counted per.
          enum: [organization, user, list]
          examples: ['organization']
        limit:
          type: integer
          description: The ceiling. A create at or above this is refused `403`.
          minimum: 0
          examples: [100]
        current:
          type: [integer, 'null']
          description: >-
            Current usage. `null` for per-list ceilings, where usage depends on
            which list you ask about rather than on the org as a whole.
          minimum: 0
          examples: [12]

    SelectionListQuotaStatus:
      type: object
      description: Quota ceilings and usage for the caller's organization.
      required: [organization_id, quotas]
      additionalProperties: false
      properties:
        organization_id:
          $ref: '#/components/schemas/OrganizationId'
        quotas:
          type: array
          description: >-
            One entry per quota scope. Fixed set of four; not paginated.
          minItems: 4
          maxItems: 4
          items:
            $ref: '#/components/schemas/SelectionListQuotaEntry'

    ResolveRequest:
      type: object
      description: A batch of item ids to resolve to labels.
      required: [ids]
      additionalProperties: false
      properties:
        ids:
          type: array
          description: >-
            The item ids to resolve. Bounded at 500 per call — the cap is what
            keeps this route a fixed-cost hot path rather than an unbounded
            fan-out.
          minItems: 1
          maxItems: 500
          uniqueItems: true
          items:
            $ref: '#/components/schemas/SelectionListItemId'
        locale:
          $ref: '#/components/schemas/Locale'

    ResolvedSelectionListItem:
      type: object
      description: >-
        The minimal resolution of one item id. Deliberately narrow: an id alone
        buys a label, a locale and a status, and nothing else.
      required: [label, locale, is_machine, status]
      additionalProperties: false
      properties:
        label:
          type: string
          description: Display text in the resolved locale. Never `null`.
          minLength: 1
          maxLength: 200
          examples: ['United States']
        locale:
          $ref: '#/components/schemas/Locale'
        is_machine:
          type: boolean
          description: Whether the label was machine-translated.
          examples: [false]
        status:
          $ref: '#/components/schemas/LifecycleStatus'
        effective_item_id:
          description: >-
            Present only when the requested id belongs to a `platform` list
            and the result was taken from the caller organization's
            `org`-visible fork (4.1.0): the fork item's id. Absent otherwise.
          $ref: '#/components/schemas/SelectionListItemId'

    ResolveResponse:
      type: object
      description: >-
        Resolutions keyed by the id that was asked for, plus every id that could
        not be resolved. The two together always account for the whole request —
        an id is never silently dropped.
      required: [results, missing]
      additionalProperties: false
      properties:
        results:
          type: object
          description: >-
            Map of item id to its resolution. Keys are exactly the requested ids
            that resolved.
          propertyNames:
            $ref: '#/components/schemas/SelectionListItemId'
          additionalProperties:
            $ref: '#/components/schemas/ResolvedSelectionListItem'
        missing:
          type: array
          description: >-
            Requested ids that do not exist (never created, or purged). Distinct
            from an archived id, which resolves normally.
          uniqueItems: true
          items:
            $ref: '#/components/schemas/SelectionListItemId'

    ErrorCode:
      type: string
      description: >-
        Stable, machine-readable error code. Clients branch on this, never on
        `message`, which is human-facing and may change without a version bump.
      enum:
        - VALIDATION_ERROR
        - UNAUTHENTICATED
        - FORBIDDEN
        - NOT_FOUND
        - CONFLICT
        - QUOTA_EXCEEDED
      examples: ['NOT_FOUND']

    Error:
      type: object
      description: >-
        The single error shape for every non-2xx response. `scope`, `limit` and
        `current` are populated only for `QUOTA_EXCEEDED`, where the client
        needs to know *which* ceiling it hit to say anything useful.
      required: [code, message]
      additionalProperties: false
      properties:
        code:
          $ref: '#/components/schemas/ErrorCode'
        message:
          type: string
          description: Human-readable explanation. Not for programmatic branching.
          examples: ['Organization list quota exhausted.']
        scope:
          $ref: '#/components/schemas/QuotaScope'
        limit:
          type: integer
          description: The ceiling that was hit. Present only on `QUOTA_EXCEEDED`.
          minimum: 0
          examples: [100]
        current:
          type: integer
          description: Usage at the time of refusal. Present only on `QUOTA_EXCEEDED`.
          minimum: 0
          examples: [100]
        details:
          type: array
          description: >-
            Per-field validation problems. Present only on `VALIDATION_ERROR`.
          items:
            $ref: '#/components/schemas/ErrorDetail'
        reason:
          $ref: '#/components/schemas/ErrorReason'
        fork_url:
          type: string
          description: >-
            Present only with `reason: fork_required`: the **same-origin
            relative** path of the fork operation for the list the caller
            tried to change (`/v1/selection-lists/{listId}/fork`). Prefix it
            with the same base the client already uses; never an absolute
            host.
          pattern: '^/v1/selection-lists/front_sl_[0-9a-z]+/fork$'
          maxLength: 300
          examples: ['/v1/selection-lists/front_sl_01h455vb4pex5vsknk084sn02q/fork']
        source_list_id:
          description: >-
            Present only with `reason: fork_required`: the common list the
            caller tried to change (the list `fork_url` forks).
          $ref: '#/components/schemas/SelectionListId'

    ErrorReason:
      type: string
      description: >-
        Refines a `CONFLICT` (4.1.0) so a client can act on it without parsing
        `message`. Absent on every other error. `fork_required` — the caller
        tried to change a `platform` list it holds no instance role on, and
        may fork it (`fork_url`). `fork_exists` — the organization already
        holds a fork of this source the caller cannot read. `fork_not_applicable`
        — the source belongs to the caller's own organization. `visibility_locked`
        — a `platform` list cannot be demoted. Treat an unknown value as a
        plain `CONFLICT`.
      enum: [fork_required, fork_exists, fork_not_applicable, visibility_locked]
      examples: ['fork_required']

    ErrorDetail:
      type: object
      description: One field-level validation problem.
      required: [field, message]
      additionalProperties: false
      properties:
        field:
          type: string
          description: JSON pointer to the offending property.
          examples: ['/code']
        message:
          type: string
          description: What is wrong with it.
          examples: ['code is immutable after create']

  responses:
    BadRequest:
      description: >-
        `VALIDATION_ERROR` — malformed body, unknown property, unsupported
        locale, or a bad cursor.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: '`UNAUTHENTICATED` — missing, expired or invalid Bearer token.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: >-
        `FORBIDDEN` — the caller is authenticated but lacks the required Permit
        action — or `QUOTA_EXCEEDED`, which shares this status because both are
        "you may not, and retrying identically will not help".
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: >-
        `NOT_FOUND` — no such list/item, **or** the caller may not `read` it.
        The two are deliberately indistinguishable so the API is not a
        cross-org existence oracle.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Conflict:
      description: >-
        `CONFLICT` — duplicate list `key`, duplicate item `code`, or an
        operation that would remove the last `list-owner`; and (4.1.0), with
        a `reason`, a change attempted on a common list the caller must fork
        first (`fork_required`, with `fork_url` and `source_list_id`), a
        fork that cannot be made (`fork_exists`, `fork_not_applicable`), or a
        `platform` list demotion (`visibility_locked`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
