openapi: 3.1.0
info:
  title: FuzeFront Configuration Service API
  version: 1.2.0
  summary: Namespaced, hierarchical key/value configuration with typed key metadata and provenance-carrying resolution.
  description: >-
    **FROZEN CONTRACT — FFRNT-153 (FF-EPIC-17-S1), amended for FF-EPIC-18.**
    This file is the single source of truth for the `config-service` HTTP
    surface and for every artefact derived from it: the Node client
    `@fuzefront/config-client`, the Python client (FFRNT-259), and the served
    Swagger UI (FFRNT-258). Nothing downstream — backend routes, migrations,
    the settings UI, acceptance tests, docs — may diverge from it. Changing
    the API means amending **this file first**, bumping `info.version`,
    re-linting, and re-issuing **both** clients.


    ### Changelog


    **1.1.0 (FF-EPIC-18, FFRNT-280 frames `secret-audit` flow — 08-secret-input.html,
    09-audit-history.html).** Additive, no breaking changes to any 1.0.0 shape.

      - **`POST /v1/config/secrets/reveal`** — reveal-once for an `isSecret`
        value: a separately authorized, individually audited action, never a
        field on `EffectiveConfigEntry` or any list/read response.
      - **`GET /v1/config/history`** — the append-only change trail for one
        key at one scope (`set`/`unset`/`lock`/`unlock`/`reveal`), cursor
        paginated like every other list in this file. Secret history is
        redacted on both sides — `oldValue`/`newValue` are never populated for
        an `isSecret` key.
      - **Revert** is deliberately **not** a new endpoint. `ConfigOperation`
        gained an optional `revertOf` (a `ConfigHistoryEntryId` reference) so
        a revert is expressed as an ordinary `set`/`unset` through the
        existing `PUT /v1/config`, and is therefore itself written to
        history as a new entry — never a rewrite of the one it restores.
      - **No new `ErrorCode` values.** `SECRET_UNAVAILABLE` (already reserved,
        previously unused by any operation) now answers reveal with `409`;
        `RATE_LIMITED` (already reserved, previously unused) now answers
        reveal with `429`. Both alternatives considered and rejected: reusing
        `FORBIDDEN` for "temporarily undecryptable" would make it
        indistinguishable from an authorization denial, which is exactly the
        confusion the frames call out by name; reusing `NOT_FOUND` would read
        as "no secret was ever set", inviting an operator to overwrite a
        working credential.


    ### What the service is

    A generic place to store settings. Today every feature that needs one invents
    its own table, so there is no shared notion of a typed key, a declared
    default, an org-level override, or a setting a tenant may see but not change.
    This service owns exactly two things: a **catalog of key definitions**
    (metadata, shipped by the owning app) and a **sparse set of values**
    (overrides per scope). It owns nothing else.


    ### Two tables, not one — and why the split is load-bearing

    The catalog/value split is what makes `isSystem`, `isHidden`, `isSecret` and
    validation expressible at all. A plain key/value store cannot say *"this key
    exists, is a boolean, and only the platform may write it."* Definitions
    describe what a key **is**; values record what somebody **set** it to, at one
    scope, sparsely — the absence of a row means "inherit", not "empty".


    ### The scope chain

    A value resolves down `default → platform → portal → org → user`, where
    `default` comes from the key definition itself and is therefore always
    present. Each tier is optional and sparse. Removing an override at one tier
    leaves every other tier untouched, which is why `unset` and "set to the
    parent's current value" are different operations: the first keeps tracking
    the parent, the second pins a copy that stops following it.


    ### Resolution returns provenance, not a bare value

    Every resolved entry carries `source` (which scope supplied it), `locked`,
    and `editable`. This is not decoration. Without it the settings editor cannot
    render "inherited from Organization", cannot decide whether to disable an
    input, and cannot explain why a value it is showing cannot be changed. A
    client that reads only `value` will produce a form that looks correct and
    misrepresents where its values came from.


    ### `locked` — inheritance with non-edit permission

    A value written with `locked: true` beats every value beneath it in the chain
    **and** resolves with `editable: false`. This is what lets a portal
    (super-tenant) set values for the orgs inside it that they cannot modify. It
    is enforced server-side: a write beneath an active lock is refused with
    **409 `LOCKED_BY_ANCESTOR`**, and the error names the locking scope so the UI
    can say *which* ancestor locked it rather than showing a generic denial.

    Locking is deliberately **not** the same mechanism as `precedence`. Locking
    hard-pins one value; precedence is the ordering rule for the whole chain.


    ### `precedence` — the reverse-order hook

    Each key definition carries `precedence`: `most-specific-wins` (the default —
    the user's value beats the org's) or `least-specific-wins` (the org's value
    beats the user's). Storing the ordering rule **per key** means changing that
    behaviour later is a value change, not a migration plus a resolver rewrite.
    Both directions are implemented from day one; the response shape is identical
    either way, so no consumer branches on it.


    ### This is NOT the feature-flag system

    Unleash owns feature flags. The split: **configuration** is durable, typed,
    user- and tenant-authored settings; **flags** are rollout, targeting,
    kill-switches and experiments, authored by engineering. Stating this in the
    contract is load-bearing — without it the key catalog accretes flag-shaped
    keys and the family ends up with two flag systems and no clear owner.


    ### Identifiers

    Ids are TypeIDs (`prefix_` + base32 UUIDv7), opaque past the prefix:
    `cns_` namespace, `ckd_` key definition, `cvl_` value, `cvh_` history entry.
    The service mints them; **no create body accepts an id for the resource being
    created**, and every create body is `additionalProperties: false`. Scope
    references are polymorphic and therefore always carry their type
    (`scopeType` + `scopeId`) — a bare id is never resolved. An id is never a
    capability: authorization comes from the token and FuzeFront's Security
    API, never from the caller having known an id.


    ### Authorization

    Every operation is gated by a policy decision from FuzeFront's Security
    API (the acting principal's own token is forwarded for the decision; the
    policy engine behind that API is a server-side deployment detail this
    contract does not name). Reads are scoped —
    a caller reading a scope they have no authority over gets **403** and learns
    nothing about whether that scope exists. Writes additionally distinguish
    *write* authority from *lock* authority: being able to set a value at your
    own scope does not imply being able to lock it against the scopes beneath.
  contact:
    name: FuzeFront Platform Team
    url: https://github.com/izzywdev/FuzeFront
  license:
    name: UNLICENSED
servers:
  - url: /api/config
    description: >-
      Same-origin path behind the FuzeFront ingress. Browser callers MUST use a
      same-origin base — an absolute host breaks under TLS with a mixed-content
      block the moment the environment changes.
tags:
  - name: Namespaces
    description: Registering and listing the configuration namespaces an app owns.
  - name: Key definitions
    description: The catalog — what a key is, how it validates, and who may set it.
  - name: Effective configuration
    description: Reading resolved values, with provenance, for a scope.
  - name: Values
    description: Writing, resetting, locking and unlocking values at a scope.
security:
  - bearerAuth: []
paths:
  /v1/namespaces:
    get:
      operationId: listNamespaces
      summary: List configuration namespaces
      description: >-
        Returns the namespaces the caller may see, newest first. Cursor
        paginated per the platform pagination standard.
      tags: [Namespaces]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: A page of namespaces.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PagedNamespaces'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      operationId: createNamespace
      summary: Register a configuration namespace
      description: >-
        Registers a namespace for an owning application. Idempotent on
        `namespace`: re-registering an existing namespace updates its
        presentation metadata and returns 200 rather than failing, so an app can
        register unconditionally at startup.
      tags: [Namespaces]
      requestBody:
        required: true
        description: The namespace to register.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NamespaceCreate'
      responses:
        '200':
          description: The namespace already existed and its metadata was updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Namespace'
        '201':
          description: The namespace was created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Namespace'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
  /v1/namespaces/{namespace}/keys:
    parameters:
      - $ref: '#/components/parameters/NamespacePath'
    get:
      operationId: listKeyDefinitions
      summary: List the key definitions in a namespace
      description: >-
        The catalog view. Returns key definitions with their full metadata,
        cursor paginated and optionally filtered by a free-text search over
        display name and description.


        Keys marked `isHidden` are **omitted** for ordinary callers and included
        only for a platform administrator who passes `includeHidden=true`. A
        hidden key that reaches an ordinary caller is not hidden, so this is a
        server-side omission and never a client-side filter.
      tags: [Key definitions]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - name: search
          in: query
          required: false
          description: Free-text filter matched against `displayName` and `description`.
          schema:
            type: string
            minLength: 1
            maxLength: 200
        - name: category
          in: query
          required: false
          description: Restrict to one presentation category.
          schema:
            type: string
            maxLength: 100
        - name: includeHidden
          in: query
          required: false
          description: >-
            Include `isHidden` keys. Permitted only for platform administrators;
            any other caller passing `true` is refused with 403 rather than
            silently receiving a filtered list.
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: A page of key definitions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PagedKeyDefinitions'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      operationId: registerKeyDefinitions
      summary: Register (upsert) the key definitions for a namespace
      description: >-
        Declarative registration: an app publishes the definitions it owns and
        the service reconciles them. Idempotent — re-registering an unchanged
        manifest is a no-op.


        Metadata updates are applied while existing stored **values** for the key
        are preserved. A key present in the catalog but absent from a manifest
        that declares itself `complete` is marked **deprecated, never deleted**:
        deleting it would silently destroy every tenant's value for that key.


        A change that is incompatible with values already stored (for example a
        `valueType` change that existing values cannot satisfy) is refused as a
        whole, with the conflicting values reported, rather than leaving the
        catalog and the data inconsistent.
      tags: [Key definitions]
      requestBody:
        required: true
        description: The key definitions this namespace declares.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/KeyDefinitionManifest'
      responses:
        '200':
          description: The catalog after reconciliation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KeyDefinitionManifestResult'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            The manifest conflicts with values already stored. Nothing was
            written.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
  /v1/namespaces/{namespace}/keys/{key}:
    parameters:
      - $ref: '#/components/parameters/NamespacePath'
      - $ref: '#/components/parameters/KeyPath'
    get:
      operationId: getKeyDefinition
      summary: Get one key definition
      description: >-
        Returns a single key's metadata. Hidden keys are 404 for ordinary
        callers — the same response an absent key produces, so the endpoint does
        not confirm the existence of keys the caller may not see.
      tags: [Key definitions]
      responses:
        '200':
          description: The key definition.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KeyDefinition'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/config:
    get:
      operationId: getEffectiveConfig
      summary: Read a scope's fully-resolved configuration
      description: >-
        The hot path. Resolves every visible key in a namespace for the given
        scope down the chain `default → platform → portal → org → user`,
        honouring each key's `precedence` and short-circuiting at the outermost
        active lock, and returns one entry per key **with its provenance**.


        Returns an `ETag`. A caller that sends `If-None-Match` with an unchanged
        namespace/scope gets **304** and no body. The version behind that ETag
        reflects the **resolved view**, so a change made at an ancestor scope
        also changes it — a version that tracked only rows owned by the exact
        scope would let inherited changes go undetected.


        Keys marked `isHidden` are absent from the response entirely.
      tags: [Effective configuration]
      parameters:
        - $ref: '#/components/parameters/NamespaceQuery'
        - $ref: '#/components/parameters/ScopeTypeQuery'
        - $ref: '#/components/parameters/ScopeIdQuery'
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: The resolved configuration for the scope.
          headers:
            ETag:
              description: Version of the resolved view; pass back as `If-None-Match`.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EffectiveConfig'
        '304':
          description: The resolved view is unchanged since the supplied `ETag`.
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      operationId: writeConfigValues
      summary: Apply a batch of value operations to one scope, atomically
      description: >-
        Applies `set`, `unset`, `lock` and `unlock` operations to a single scope
        as **one transaction**. If any operation fails — validation, an ancestor
        lock, missing authority — **nothing is written** and the response names
        every failing operation. A settings page that saves twenty keys must
        never half-save, which is why this is a batch endpoint rather than a
        per-key one.


        `unset` removes the override at this scope so the key resolves from its
        parent again. That is deliberately different from setting the parent's
        current value, which would pin a copy that stops following the parent.


        Supply `expectedVersion` (the `ETag` from a prior read) for optimistic
        concurrency: if the resolved view moved underneath the caller, the write
        is refused with **409 `VERSION_CONFLICT`** rather than silently
        overwriting a concurrent editor's change.


        **Revert is not a separate endpoint.** An operation that replays an
        earlier value from `GET /v1/config/history` sets that operation's
        `revertOf` to the id of the history entry being restored. The write
        is otherwise an ordinary `set` or `unset` — reverting a `set` entry is
        `{op: 'set', value: <that entry's newValue>, revertOf: <its id>}`;
        reverting an `unset` entry is `{op: 'unset', revertOf: <its id>}`,
        which restores inheritance rather than pinning whatever the key
        happened to resolve to at the time. Either way this call produces a
        **new** history entry naming what it reverted; nothing already in
        history is rewritten or removed. A `revertOf` that does not name a
        history entry for this exact key and scope is rejected as a whole
        batch with `VALIDATION_ERROR`, same as any other malformed operation.
      tags: [Values]
      requestBody:
        required: true
        description: The scope to write to and the operations to apply.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConfigWriteRequest'
      responses:
        '200':
          description: All operations applied. Returns the scope's new version.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConfigWriteResult'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            Refused without writing anything. Either a key is locked by an
            ancestor scope (`LOCKED_BY_ANCESTOR`, with `lockedBy` naming that
            scope) or the resolved view moved since `expectedVersion`
            (`VERSION_CONFLICT`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
        '422':
          description: >-
            A key was addressed at a scope its `allowedScopes` excludes. Nothing
            was written.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
  /v1/config/secrets/reveal:
    post:
      operationId: revealSecret
      summary: Reveal a stored secret value once
      description: >-
        Returns the plaintext of an `isSecret` value **exactly once**. This is
        deliberately not a field on `EffectiveConfigEntry` or any GET: reveal
        is its own high-privilege action, separately authorized from both the
        ordinary read grant and the write grant (an operator who may `Replace`
        a credential does not automatically get to `Reveal` it), and every
        call — success or not — writes its own `reveal` entry to
        `GET /v1/config/history` against the caller. Nothing about the
        response is cached or re-servable; a second look is a second call
        with its own audit entry.


        Refused with **404** if no value is currently stored at this exact
        scope (`isSet: false` — there is nothing to reveal), and with **409
        `SECRET_UNAVAILABLE`** if a value is stored but its encryption key is
        currently unavailable. The two are deliberately distinct: the first
        invites setting a value, the second must not, because a value **is**
        stored and consumers may still be using it.
      tags: [Values]
      requestBody:
        required: true
        description: The secret to reveal, and why.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RevealSecretRequest'
      responses:
        '200':
          description: >-
            The plaintext, returned once. Responses to this operation MUST
            NOT be cached or stored by any intermediary.
          headers:
            Cache-Control:
              description: Always `no-store` — this response is never cacheable.
              schema:
                type: string
                enum: ['no-store']
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RevealSecretResult'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: >-
            No namespace/key/scope by that description, or no value is
            currently stored for this key at this exact scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
        '409':
          description: >-
            The key is `isSecret` and a value is stored, but it cannot be
            decrypted right now (`SECRET_UNAVAILABLE`). The value has **not**
            been deleted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
        '429':
          description: >-
            Too many reveal attempts for this credential in the current
            window (`RATE_LIMITED`). Reveal is deliberately throttled harder
            than ordinary reads because a successful call discloses a live
            credential.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
  /v1/config/history:
    get:
      operationId: listConfigHistory
      summary: List the append-only change trail for one key at one scope
      description: >-
        Every `set`, `unset`, `lock`, `unlock` and `reveal` recorded against
        one key at one exact scope, newest first, cursor paginated per the
        platform pagination standard. Nothing is ever deleted from this trail
        and no entry is ever mutated in place — a revert (see
        `writeConfigValues`) and a reveal (see `revealSecret`) each add a new
        entry rather than changing an existing one.


        For an `isSecret` key every entry's `oldValue`/`newValue` are `null`
        and `redacted` is `true` — the trail records **that** the value
        changed or was revealed, never **what** it was or is. There is
        deliberately no way to recover a past secret value from this
        endpoint; that is what makes `revealSecret` a distinct, individually
        audited operation rather than a formality.


        An empty page is not an error: it means this key has never been
        changed at this exact scope, and its value is inherited or default.
      tags: [Effective configuration]
      parameters:
        - $ref: '#/components/parameters/NamespaceQuery'
        - $ref: '#/components/parameters/ScopeTypeQuery'
        - $ref: '#/components/parameters/ScopeIdQuery'
        - $ref: '#/components/parameters/KeyQuery'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: A page of history entries, newest first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PagedHistoryEntries'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          description: >-
            The principal has no **audit** grant over the requested scope.
            Distinct from the write/read grants on the value itself — being
            able to see or change a setting does not imply being able to see
            who changed it and why.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
        '404':
          $ref: '#/components/responses/NotFound'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >-
        The acting principal's access token. Authorization is decided by
        FuzeFront's Security API against this principal; knowing an id never
        grants access.
  parameters:
    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque cursor from a previous page's `nextCursor`. Omit for the first page.
      schema:
        type: string
    Limit:
      name: limit
      in: query
      required: false
      description: Maximum items to return in this page.
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 50
    NamespacePath:
      name: namespace
      in: path
      required: true
      description: Dotted namespace identifier, for example `fuzefront.chat`.
      schema:
        $ref: '#/components/schemas/NamespaceName'
    KeyPath:
      name: key
      in: path
      required: true
      description: Dotted key path within the namespace, for example `ui.theme.density`.
      schema:
        $ref: '#/components/schemas/KeyName'
    NamespaceQuery:
      name: namespace
      in: query
      required: true
      description: The namespace whose configuration to resolve.
      schema:
        $ref: '#/components/schemas/NamespaceName'
    ScopeTypeQuery:
      name: scopeType
      in: query
      required: true
      description: Which tier of the chain to resolve for.
      schema:
        $ref: '#/components/schemas/ScopeType'
    ScopeIdQuery:
      name: scopeId
      in: query
      required: false
      description: >-
        The portal, organization or user to resolve for. Omitted (and rejected if
        supplied) when `scopeType` is `platform`, which is a singleton tier.
      schema:
        type: string
    KeyQuery:
      name: key
      in: query
      required: true
      description: The key whose change trail to list, within `namespace`.
      schema:
        $ref: '#/components/schemas/KeyName'
    IfNoneMatch:
      name: If-None-Match
      in: header
      required: false
      description: >-
        A previously returned `ETag`. An unchanged resolved view answers 304. An
        unrecognised or malformed token is treated as absent and answered with a
        full response, so a stale cached token degrades to a normal read rather
        than an error.
      schema:
        type: string
  responses:
    Unauthenticated:
      description: No credential, or a credential that is not valid.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    Forbidden:
      description: >-
        The principal has no authority over the requested scope. The response
        does not reveal whether that scope exists.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    NotFound:
      description: >-
        No such namespace or key — or one the caller may not see, which answers
        identically by design.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    ValidationError:
      description: The request was malformed or a value failed its key's schema.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
  schemas:
    NamespaceId:
      type: string
      description: TypeID of a namespace, prefixed `cns_`. Opaque past the prefix.
      pattern: '^cns_[0-9a-hjkmnp-tv-z]{26}$'
      examples: ['cns_01h455vb4pex5vsknk084sn02q']
    KeyDefinitionId:
      type: string
      description: TypeID of a key definition, prefixed `ckd_`. Opaque past the prefix.
      pattern: '^ckd_[0-9a-hjkmnp-tv-z]{26}$'
      examples: ['ckd_01h455vb4pex5vsknk084sn02q']
    ConfigHistoryEntryId:
      type: string
      description: >-
        TypeID of one append-only change-history entry, prefixed `cvh_`.
        Opaque past the prefix. Referenced by `ConfigOperation.revertOf` to
        express a revert as a replayed `set`/`unset` rather than a rewrite of
        the entry it restores.
      pattern: '^cvh_[0-9a-hjkmnp-tv-z]{26}$'
      examples: ['cvh_01h455vb4pex5vsknk084sn02q']
    NamespaceName:
      type: string
      description: >-
        Dotted, lowercase namespace name owned by one application, for example
        `fuzefront.chat`. Unique across the service.
      pattern: '^[a-z0-9]+(\.[a-z0-9-]+)*$'
      maxLength: 200
      examples: ['fuzefront.chat']
    KeyName:
      type: string
      description: >-
        Dotted, lowercase key path, unique within its namespace, for example
        `ui.theme.density`.
      pattern: '^[a-z0-9]+(\.[a-z0-9-]+)*$'
      maxLength: 200
      examples: ['ui.theme.density']
    ScopeType:
      type: string
      description: >-
        A tier of the resolution chain. `platform` is a singleton and carries no
        `scopeId`; the others identify a portal, organization or user.
      enum: [platform, portal, org, user]
    ValueType:
      type: string
      description: >-
        The kind of value a key holds. Drives both server-side validation and the
        input the settings editor renders.
      enum:
        - string
        - number
        - boolean
        - enum
        - json
        - duration
        - url
        - email
        - color
        - secret
    Precedence:
      type: string
      description: >-
        Which end of the chain wins. `most-specific-wins` (default) lets the
        user's value beat the org's; `least-specific-wins` reverses that so an
        org-level setting overrides a user-specific one. The response shape is
        identical either way.
      enum: [most-specific-wins, least-specific-wins]
      default: most-specific-wins
    Scope:
      type: object
      description: A point in the resolution chain. Polymorphic references always carry their type.
      additionalProperties: false
      required: [scopeType]
      properties:
        scopeType:
          $ref: '#/components/schemas/ScopeType'
        scopeId:
          type: [string, 'null']
          description: >-
            The portal, organization or user. Null exactly when `scopeType` is
            `platform`.
    Namespace:
      type: object
      description: A configuration namespace owned by one application.
      required: [id, namespace, displayName, createdAt]
      properties:
        id:
          $ref: '#/components/schemas/NamespaceId'
        namespace:
          $ref: '#/components/schemas/NamespaceName'
        displayName:
          type: string
          description: Human-facing name shown as the section heading in the editor.
        description:
          type: [string, 'null']
          description: What this namespace's settings govern.
        ownerAppId:
          type: [string, 'null']
          description: The application that owns this namespace, in the app registry.
        createdAt:
          type: string
          format: date-time
          description: When the namespace was first registered.
    NamespaceCreate:
      type: object
      description: >-
        Registration body. Carries no `id` — the service mints it — and rejects
        unknown properties.
      additionalProperties: false
      required: [namespace, displayName]
      properties:
        namespace:
          $ref: '#/components/schemas/NamespaceName'
        displayName:
          type: string
          description: Human-facing name shown as the section heading in the editor.
          minLength: 1
          maxLength: 200
        description:
          type: string
          description: What this namespace's settings govern.
          maxLength: 2000
        ownerAppId:
          type: string
          description: The owning application in the app registry.
    KeyDefinition:
      type: object
      description: >-
        What a key **is**: how it is presented, how it validates, where it may be
        set, and who may change it.
      required:
        - id
        - key
        - displayName
        - valueType
        - defaultValue
        - allowedScopes
        - isSystem
        - isHidden
        - isSecret
        - isReadonly
        - precedence
        - requiresRestart
      properties:
        id:
          $ref: '#/components/schemas/KeyDefinitionId'
        key:
          $ref: '#/components/schemas/KeyName'
        displayName:
          type: string
          description: Label shown next to the input in the editor.
        description:
          type: [string, 'null']
          description: Help text explaining what the setting does.
        helpUrl:
          type: [string, 'null']
          description: Link to fuller documentation for this setting.
        category:
          type: [string, 'null']
          description: Grouping heading in the editor.
        sortOrder:
          type: integer
          description: Position within its category. Lower sorts first.
        tags:
          type: array
          description: Free-form tags used for search and filtering.
          items:
            type: string
        valueType:
          $ref: '#/components/schemas/ValueType'
        schema:
          type: [object, 'null']
          description: >-
            JSON Schema the value must satisfy, intersected with the base schema
            implied by `valueType`. Deriving the base from `valueType` rather
            than trusting the two to agree is what stops a `number` key carrying
            a string schema.
        enumValues:
          type: [array, 'null']
          description: >-
            The permitted values when `valueType` is `enum`. A rejection names
            these, so the caller learns what was allowed.
          items: {}
        defaultValue:
          description: >-
            The bottom of the resolution chain. Always present, so every key
            resolves to something even with no overrides anywhere. Validated
            against this key's own schema at write time, so an unsatisfiable key
            cannot enter the catalog.
        allowedScopes:
          type: array
          description: >-
            The tiers at which this key may be set. A write at an excluded tier
            is refused; the key cannot acquire a row there by any path.
          items:
            $ref: '#/components/schemas/ScopeType'
        isSystem:
          type: boolean
          description: >-
            Platform-owned. Its metadata is immutable to everyone else and only
            the platform may write a value.
        isHidden:
          type: boolean
          description: >-
            Never rendered in the editor and omitted from ordinary reads
            entirely. Visible only in the platform-admin catalog view.
        isSecret:
          type: boolean
          description: >-
            Encrypted at rest, masked on read, excluded from export. The value is
            never returned; `isSet` reports only whether one exists.
        isReadonly:
          type: boolean
          description: Displayed but not editable at any tier.
        precedence:
          $ref: '#/components/schemas/Precedence'
        requiresRestart:
          type: boolean
          description: >-
            Changing it does not take effect in running consumers until they
            restart; the editor warns accordingly.
        deprecatedAt:
          type: [string, 'null']
          format: date-time
          description: When the key was deprecated. Deprecated keys still resolve.
        replacedBy:
          type: [string, 'null']
          description: The key that supersedes this one, if any.
    KeyDefinitionInput:
      type: object
      description: >-
        One key as declared by an owning application. Carries no `id` — the
        service mints it — and rejects unknown properties.
      additionalProperties: false
      required: [key, displayName, valueType, defaultValue, allowedScopes]
      properties:
        key:
          $ref: '#/components/schemas/KeyName'
        displayName:
          type: string
          description: Label shown next to the input in the editor.
          minLength: 1
          maxLength: 200
        description:
          type: string
          description: Help text explaining what the setting does.
          maxLength: 2000
        helpUrl:
          type: string
          description: Link to fuller documentation for this setting.
          format: uri
        category:
          type: string
          description: Grouping heading in the editor.
          maxLength: 100
        sortOrder:
          type: integer
          description: Position within its category. Lower sorts first.
        tags:
          type: array
          description: Free-form tags used for search and filtering.
          items:
            type: string
        valueType:
          $ref: '#/components/schemas/ValueType'
        schema:
          type: object
          description: JSON Schema the value must satisfy, intersected with the `valueType` base.
        enumValues:
          type: array
          description: The permitted values when `valueType` is `enum`.
          items: {}
        defaultValue:
          description: The bottom of the chain. Must satisfy this key's own schema.
        allowedScopes:
          type: array
          description: The tiers at which this key may be set.
          minItems: 1
          items:
            $ref: '#/components/schemas/ScopeType'
        isSystem:
          type: boolean
          description: Platform-owned; metadata immutable and platform-only writes.
          default: false
        isHidden:
          type: boolean
          description: Omitted from ordinary reads entirely.
          default: false
        isSecret:
          type: boolean
          description: Encrypted at rest, masked on read, excluded from export.
          default: false
        isReadonly:
          type: boolean
          description: Displayed but not editable at any tier.
          default: false
        precedence:
          $ref: '#/components/schemas/Precedence'
        requiresRestart:
          type: boolean
          description: Warn that the change is not live until consumers restart.
          default: false
        replacedBy:
          type: string
          description: The key that supersedes this one, if any.
    KeyDefinitionManifest:
      type: object
      description: The set of key definitions an application declares for one namespace.
      additionalProperties: false
      required: [keys]
      properties:
        keys:
          type: array
          description: Every key this manifest declares.
          items:
            $ref: '#/components/schemas/KeyDefinitionInput'
        complete:
          type: boolean
          description: >-
            Whether this manifest is the **whole** catalog for the namespace.
            Only when true does an omitted key get deprecated. Requiring the
            manifest to say so, rather than inferring deletion from absence,
            is what stops a truncated or partially-generated manifest from
            deprecating half a namespace.
          default: false
    KeyDefinitionManifestResult:
      type: object
      description: What reconciling a manifest changed.
      required: [created, updated, deprecated, unchanged]
      properties:
        created:
          type: array
          description: Keys that did not previously exist.
          items:
            $ref: '#/components/schemas/KeyName'
        updated:
          type: array
          description: Keys whose metadata changed. Their stored values are untouched.
          items:
            $ref: '#/components/schemas/KeyName'
        deprecated:
          type: array
          description: >-
            Keys absent from a `complete` manifest. Marked deprecated, never
            deleted — deleting would destroy every tenant's value for them.
          items:
            $ref: '#/components/schemas/KeyName'
        unchanged:
          type: array
          description: Keys the manifest matched exactly. Re-registering is a no-op.
          items:
            $ref: '#/components/schemas/KeyName'
    EffectiveConfigEntry:
      type: object
      description: >-
        One resolved setting. The provenance fields are the point: a consumer
        that reads only `value` cannot tell a value set here from one inherited
        or locked, and will render a form that misrepresents its own contents.
      required: [key, value, source, locked, editable, definition]
      properties:
        key:
          $ref: '#/components/schemas/KeyName'
        value:
          description: >-
            The resolved value. For an `isSecret` key this is always null — see
            `isSet`.
        isSet:
          type: boolean
          description: >-
            Whether a secret has a stored value. Present only for `isSecret`
            keys, whose `value` is never returned.
        source:
          $ref: '#/components/schemas/Scope'
        locked:
          type: boolean
          description: >-
            An ancestor scope pinned this value. It wins over anything beneath
            it, and writes beneath it are refused server-side.
        lockedBy:
          oneOf:
            - $ref: '#/components/schemas/Scope'
            - type: 'null'
          description: >-
            Which scope holds the lock. Present exactly when `locked` is true, so
            the editor can name the ancestor rather than showing a generic
            denial.
        lockReason:
          type: [string, 'null']
          description: Why the ancestor locked it, if a reason was recorded.
        editable:
          type: boolean
          description: >-
            Whether **this caller** may change it at the scope being read,
            accounting for the lock, `isReadonly`, `allowedScopes` and the
            Security API's authorization decision. A disabled input is a
            courtesy; the server refuses the write regardless.
        warning:
          type: [string, 'null']
          description: >-
            Set when the stored value no longer satisfies its definition — the
            schema changed after the value was written. The **default** is
            returned and this explains why, rather than failing the request: one
            stale value must not break a consumer's boot.
        definition:
          $ref: '#/components/schemas/KeyDefinition'
    EffectiveConfig:
      type: object
      description: A scope's fully-resolved configuration for one namespace.
      required: [namespace, scope, version, entries]
      properties:
        namespace:
          $ref: '#/components/schemas/NamespaceName'
        scope:
          $ref: '#/components/schemas/Scope'
        version:
          type: string
          description: >-
            Monotonic version of the **resolved view**, matching the `ETag`. It
            changes when an ancestor changes too, so inherited changes are
            detectable.
        entries:
          type: array
          description: One entry per visible key. Hidden keys are absent entirely.
          items:
            $ref: '#/components/schemas/EffectiveConfigEntry'
    ConfigOperationType:
      type: string
      description: >-
        What to do with one key at the target scope. `unset` removes this scope's
        override so the key resolves from its parent again — not the same as
        setting the parent's current value, which pins a copy.
      enum: [set, unset, lock, unlock]
    ConfigOperation:
      type: object
      description: One operation against one key at the request's scope.
      additionalProperties: false
      required: [key, op]
      properties:
        key:
          $ref: '#/components/schemas/KeyName'
        op:
          $ref: '#/components/schemas/ConfigOperationType'
        value:
          description: The new value. Required for `set` and `lock`; rejected otherwise.
        lockReason:
          type: string
          description: >-
            Why the value is being locked, surfaced to the scopes beneath. Only
            meaningful with `lock`.
          maxLength: 500
        revertOf:
          $ref: '#/components/schemas/ConfigHistoryEntryId'
    ConfigWriteRequest:
      type: object
      description: >-
        A batch of operations against one scope, applied as a single
        transaction.
      additionalProperties: false
      required: [namespace, scope, operations]
      properties:
        namespace:
          $ref: '#/components/schemas/NamespaceName'
        scope:
          $ref: '#/components/schemas/Scope'
        operations:
          type: array
          description: >-
            The operations to apply. All succeed or none do; a settings page
            must never half-save.
          minItems: 1
          maxItems: 500
          items:
            $ref: '#/components/schemas/ConfigOperation'
        expectedVersion:
          type: string
          description: >-
            The `version`/`ETag` the caller last read. When supplied and the
            resolved view has moved, the write is refused with
            `VERSION_CONFLICT` instead of overwriting a concurrent edit.
        reason:
          type: string
          description: Why the change is being made. Recorded in the audit trail.
          maxLength: 500
    ConfigWriteResult:
      type: object
      description: The outcome of an applied batch.
      required: [namespace, scope, version, applied]
      properties:
        namespace:
          $ref: '#/components/schemas/NamespaceName'
        scope:
          $ref: '#/components/schemas/Scope'
        version:
          type: string
          description: The scope's new resolved-view version, usable as the next `expectedVersion`.
        applied:
          type: array
          description: The keys changed, in request order.
          items:
            $ref: '#/components/schemas/KeyName'
    PageInfo:
      type: object
      description: Cursor pagination envelope.
      required: [hasNextPage]
      properties:
        hasNextPage:
          type: boolean
          description: Whether a further page exists.
        nextCursor:
          type: [string, 'null']
          description: Cursor for the next page. Null on the last page.
    PagedNamespaces:
      type: object
      description: A page of namespaces.
      required: [items, pageInfo]
      properties:
        items:
          type: array
          description: The namespaces in this page.
          items:
            $ref: '#/components/schemas/Namespace'
        pageInfo:
          $ref: '#/components/schemas/PageInfo'
    PagedKeyDefinitions:
      type: object
      description: A page of key definitions.
      required: [items, pageInfo]
      properties:
        items:
          type: array
          description: The key definitions in this page.
          items:
            $ref: '#/components/schemas/KeyDefinition'
        pageInfo:
          $ref: '#/components/schemas/PageInfo'
    ActorType:
      type: string
      description: >-
        What kind of principal performed a change or reveal. Polymorphic —
        `Actor` always carries this alongside `actorId` — because not every
        history entry is attributable to a human caller, and treating
        `actorId` as always a user id would silently mis-resolve a
        system-initiated one.
      enum: [user, system]
    Actor:
      type: object
      description: >-
        Who performed one recorded change or reveal, from the token that
        authorized it. Never a bare id — always paired with its type.
      additionalProperties: false
      required: [actorType, actorRedacted]
      properties:
        actorType:
          $ref: '#/components/schemas/ActorType'
        actorId:
          type: [string, 'null']
          description: >-
            The acting user. Null when `actorType` is `system` (for example a
            platform-owned key reconciled by an automated process), or when
            `actorRedacted` is true.
        actorRedacted:
          type: boolean
          description: >-
            True when the acting user was hard-deleted and their id erased from
            this entry under right-to-erasure; `actorType` stays `user`, so a
            redacted human action remains distinguishable from a `system` one
            even though both have a null `actorId`. False for every live or
            system actor.
    ConfigHistoryAction:
      type: string
      description: >-
        What a history entry recorded. `reveal` is a read-time action — it
        never changes the resolved value — included so a compliance reviewer
        sees every disclosure of a secret alongside every change to it.
      enum: [set, unset, lock, unlock, reveal]
    ConfigHistoryEntry:
      type: object
      description: >-
        One append-only row in a key's change trail at one exact scope.
        Entries are never edited or deleted — a revert or a reveal always
        adds a new entry rather than touching an existing one.
      required:
        - id
        - namespace
        - key
        - scope
        - action
        - redacted
        - actor
        - occurredAt
      properties:
        id:
          $ref: '#/components/schemas/ConfigHistoryEntryId'
        namespace:
          $ref: '#/components/schemas/NamespaceName'
        key:
          $ref: '#/components/schemas/KeyName'
        scope:
          $ref: '#/components/schemas/Scope'
        action:
          $ref: '#/components/schemas/ConfigHistoryAction'
        oldValue:
          description: >-
            The value before this entry, for `set`/`unset`. Always `null`
            when `redacted` is `true`, when this is the first entry for the
            key at this scope, or when `action` is `reveal` (a reveal does
            not change the value).
        newValue:
          description: >-
            The value after this entry, for `set`/`lock`. Always `null` when
            `redacted` is `true`, `action` is `unset` (there is no override
            any more), or `action` is `reveal`.
        redacted:
          type: boolean
          description: >-
            True when the key is `isSecret`. `oldValue`/`newValue` are then
            always `null` regardless of `action` — the trail records **that**
            the value changed or was revealed, never **what** it was. An
            audit log holding secret plaintext would be a secret store with
            worse access control than the one it is auditing.
        actor:
          $ref: '#/components/schemas/Actor'
        reason:
          type: [string, 'null']
          description: >-
            Why the change or reveal was made, from the triggering request's
            `reason` (`ConfigWriteRequest.reason` or `RevealSecretRequest.reason`).
            Null if none was supplied on a `set`/`unset`/`lock`/`unlock`
            entry; `reveal` entries always carry one, since it is required on
            `revealSecret`.
        revertOf:
          oneOf:
            - $ref: '#/components/schemas/ConfigHistoryEntryId'
            - type: 'null'
          description: >-
            Present when this entry was written by reverting an earlier one —
            the id of the entry that was replayed. Null for every entry that
            was not a revert. The replayed entry is untouched; reverting only
            ever appends.
        occurredAt:
          type: string
          format: date-time
          description: When this entry was recorded.
    PagedHistoryEntries:
      type: object
      description: A page of change-history entries, newest first.
      required: [items, pageInfo]
      properties:
        items:
          type: array
          description: The history entries in this page.
          items:
            $ref: '#/components/schemas/ConfigHistoryEntry'
        pageInfo:
          $ref: '#/components/schemas/PageInfo'
    RevealSecretRequest:
      type: object
      description: >-
        Identifies the stored secret to reveal and why. Addressed by
        namespace + scope + key, like every other value operation — carries
        no id of its own — and rejects unknown properties.
      additionalProperties: false
      required: [namespace, scope, key, reason]
      properties:
        namespace:
          $ref: '#/components/schemas/NamespaceName'
        scope:
          $ref: '#/components/schemas/Scope'
        key:
          $ref: '#/components/schemas/KeyName'
        reason:
          type: string
          description: >-
            Why the caller needs to see this credential. Required, not
            optional — unlike `ConfigWriteRequest.reason` — because a reveal
            is read access to a live secret, not a change a reviewer can
            reconstruct from a diff. Recorded verbatim on the `reveal` history
            entry this call creates.
          minLength: 1
          maxLength: 500
    RevealSecretResult:
      type: object
      description: >-
        The plaintext, returned exactly once. The service does not cache or
        re-serve it — a second look is a new `revealSecret` call with its own
        authorization and its own `historyEntryId`.
      required: [namespace, scope, key, value, revealedAt, historyEntryId]
      properties:
        namespace:
          $ref: '#/components/schemas/NamespaceName'
        scope:
          $ref: '#/components/schemas/Scope'
        key:
          $ref: '#/components/schemas/KeyName'
        value:
          type: string
          description: >-
            The stored plaintext. Present only in this response — never on
            `EffectiveConfigEntry` or any list/read endpoint.
        revealedAt:
          type: string
          format: date-time
          description: When this reveal was authorized and recorded.
        historyEntryId:
          $ref: '#/components/schemas/ConfigHistoryEntryId'
    ErrorCode:
      type: string
      description: >-
        Machine-readable failure reason. Callers branch on this, not on
        `message`, which is human-facing and may change without a contract
        version bump.
      enum:
        - VALIDATION_ERROR
        - UNAUTHENTICATED
        - FORBIDDEN
        - NOT_FOUND
        - LOCKED_BY_ANCESTOR
        - VERSION_CONFLICT
        - SCOPE_NOT_ALLOWED
        - INCOMPATIBLE_DEFINITION
        - SECRET_UNAVAILABLE
        - RATE_LIMITED
    ErrorDetail:
      type: object
      description: One field- or key-level problem within a failed request.
      required: [message]
      properties:
        key:
          type: [string, 'null']
          description: The configuration key this problem concerns, if key-specific.
        field:
          type: [string, 'null']
          description: The request field this problem concerns, if field-specific.
        message:
          type: string
          description: What is wrong, in human-facing terms.
        allowedValues:
          type: [array, 'null']
          description: >-
            The values that would have been accepted. Populated for enum
            rejections so the caller learns what was allowed.
          items: {}
    ErrorBody:
      type: object
      description: The error envelope returned by every non-2xx response.
      required: [code, message]
      properties:
        code:
          $ref: '#/components/schemas/ErrorCode'
        message:
          type: string
          description: Human-facing explanation. Not for branching.
        lockedBy:
          oneOf:
            - $ref: '#/components/schemas/Scope'
            - type: 'null'
          description: >-
            The scope holding the lock. Present on `LOCKED_BY_ANCESTOR`, so the
            UI can say which ancestor refused the write rather than showing a
            generic denial.
        currentVersion:
          type: [string, 'null']
          description: >-
            The resolved view's actual version. Present on `VERSION_CONFLICT` so
            the caller can re-read and retry.
        details:
          type: [array, 'null']
          description: Per-key or per-field problems. Present on validation failures.
          items:
            $ref: '#/components/schemas/ErrorDetail'
