# Narad HTTP API, OpenAPI 3.1.
#
# Written by hand from internal/transport/httpserver/router.go and the
# request and response types under internal/transport/httpserver/handlers.
# This is the only spec: the docs site publishes it as-is.
# docs/reference/http-api.md is edited by hand alongside this file: a
# change to an operation, field, parameter or status code goes into both
# in the same pull request. The Go contract test
# (internal/transport/httpserver/openapi_contract_test.go) fails when the
# routes in router.go and the operations here disagree, and when a status
# code here is one the handler cannot answer. Examples are pasted from
# real runs, never typed.
#
# Extensions:
#   x-narad-lead      first paragraph of docs/reference/http-api.md
#   x-narad-grant     the grant or role an operation needs
#   x-narad-since     the release that added it ("v3.1.0"), or "unreleased"
#                     for anything merged since the latest release
#   x-narad-examples  curl requests and their real responses
#   x-narad-origin    "forwarding" on a status the cluster router writes
#                     for a request it forwarded to another node
openapi: 3.1.0
info:
  title: Narad HTTP API
  version: v1
  summary: Produce, consume and manage topics on a Narad cluster over HTTP.
  license:
    name: Apache-2.0
    identifier: Apache-2.0
  x-narad-lead: |
    Look up every Narad HTTP endpoint: its parameters, request body, the
    grant it needs, the status codes it returns and a real example.
  x-narad-lead-example:
    request: |
      curl -i -u "$AUTH" "$NARAD/v1/topics/orders?partition=1"
    response: |
      HTTP/1.1 200 OK
      Content-Length: 456
      Content-Type: application/json
      Date: Mon, 28 Sep 2026 19:31:43 GMT

      {
        "name": "orders",
        "id": "681a429c0b683b2d",
        "partitions": 3,
        "retention_ms": 172800000,
        "visibility_timeout_ms": 30000,
        "max_in_flight_per_partition": 1024,
        "max_acked_ahead_per_partition": 1024,
        "created_at": 1790623903,
        "owner": "admin",
        "role": "parent",
        "children": ["orders-audit"],
        "schema_version": 0,
        "partition_stats": [
          {
            "index": 1,
            "segments": 1,
            "oldest_offset": 0,
            "next_offset": 2,
            "high_watermark": 2,
            "size_bytes": 166,
            "oldest_segment_at": 1790623903,
            "owner_node": "narad-0"
          }
        ]
      }
  x-narad-placeholders: |
    - `$NARAD` is the base URL of any node or of the load balancer in front
      of the cluster, for example `http://127.0.0.1:7942`.
    - `$AUTH` is `username:password` of a user with the grant the endpoint
      needs. On a node started with `narad server start --dev`, security is
      off and `-u "$AUTH"` can be left out.

    The same API as an OpenAPI 3.1 file:
    [openapi.yaml](openapi.yaml). Every example on this page was run
    against a single node built from `master`. Long JSON bodies are shown
    indented; the node sends each one on a single line, which is what
    `Content-Length` counts.
  description: |
    ### Base URL

    Send requests to any node's API port (`7942` by default) or to the load
    balancer in front of the cluster. Every node serves every route and
    forwards work to the node that owns the partition, so a client never
    needs to know where a partition lives. API routes live under `/v1`;
    `/healthz`, `/readyz` and `/metrics` do not. What a `202` promises, and
    when a message can arrive twice, is in the
    [delivery contract](../understand/delivery-contract.md).

    ### Authentication

    With security on (the default), every `/v1` route needs HTTP Basic
    credentials, and what the user may do is set by its
    [grants](access-model.md). Missing or wrong credentials get
    [`401`](status-codes.md#status-401) with
    `WWW-Authenticate: Basic realm="narad"`. After 5 wrong passwords for an
    existing user, a node answers [`429`](status-codes.md#status-429) for
    that user and allows one more attempt every 12 seconds. `/healthz` and `/readyz` never need
    credentials. Narad expects TLS to end in front of it, at an ingress or a
    load balancer. With security off (`narad server start --dev`), no
    request needs credentials and every grant check passes. How a client
    gets its credentials is in
    [Connect and authenticate](../build/connect.md#credentials).

    ### Required headers

    `POST`, `PUT` and `PATCH` requests must send one of these headers, or
    they get [`415`](status-codes.md#status-415):

    - `Content-Type: application/json`
    - `Content-Type: application/octet-stream`
    - `X-Narad-Client`, with any value

    A `charset` or other parameter on the content type is fine. The rule
    holds for a request with no body too, such as an ack. `GET` and
    `DELETE` are not checked, except a [batch consume](#consume)
    (`max`), which must send `X-Narad-Client` or gets
    [`400`](status-codes.md#status-400), as a `GET` or a `HEAD`. The rule
    stops a web page in a browser from sending state-changing requests
    with an operator's cached credentials.

    ### Limits

    | Limit | Value | Over it |
    |---|---|---|
    | Request body | 1 MiB (1,048,576 bytes) | [`413`](status-codes.md#status-413); `400` on the user and attach-child routes |
    | Batch produce body (v3.2.0) | 16 MiB, each message's payload at most 1 MiB | [`413`](status-codes.md#status-413) |
    | Batch ack body (v3.1.0) | 64 KiB | [`413`](status-codes.md#status-413) |
    | Remotes request body (v3.2.0) | 128 KiB | [`413`](status-codes.md#status-413) |
    | Request headers | 64 KiB by default | [`431`](status-codes.md#status-431) |
    | Messages per batch produce | 1,000 (from v3.2.0; 100 in v3.1.0) | [`400`](status-codes.md#status-400) |
    | Records per batch consume, handles per batch ack (v3.1.0) | 100 | [`400`](status-codes.md#status-400) |
    | Consume `wait` | 10 s by default | clamped, with an `X-Narad-Wait-Clamped` response header |
    | Concurrent consumes per user (or per client IP with security off), per node | 1024 by default | [`429`](status-codes.md#status-429) |
    | Concurrent produces per user, per node (v3.1.0) | off by default | [`429`](status-codes.md#status-429) |
    | Batch produce bodies over 1 MiB being read at once, per node (v3.2.0) | 256 MiB by default | [`503`](status-codes.md#status-503) with `Retry-After: 1` |

    The limits marked "by default" are settings: `http.max_header_bytes`,
    `http.max_consume_wait`, `http.max_consume_in_flight_per_identity`,
    `http.max_produce_in_flight_per_identity` (from v3.1.0) and
    `http.max_batch_body_bytes_in_flight` (from v3.2.0), in that order,
    all in the [Configuration reference](configuration.md#http).

    ### Errors

    An error answer carries a JSON body with one field:

    ```json
    {"error": "topic not found"}
    ```

    A few answers are plain text instead: a route or method the server does
    not know (`404`, `405`), a header block that is too large (`431`), and
    some answers to a request forwarded to another node. Those are `502`,
    `503` when a partition owner is down or the cluster leader cannot be
    reached, and some `500` and `400` answers to a consume.
    Read the status code first and treat the body as a message for people.

    Each endpoint below lists the codes it answers. On top of those, any
    request with credentials can get [`429`](status-codes.md#status-429)
    after too many wrong passwords, and any request can get
    [`500`](status-codes.md#status-500) when the node fails, for example
    `authentication unavailable` when it cannot read its user store.
    [Status codes and errors](status-codes.md) lists every code and
    whether to retry.

    ### Request and response bodies

    Request bodies are strict: a field the endpoint does not know gets
    [`400`](status-codes.md#status-400). Responses can gain fields within
    `/v1`, so ignore fields you do not know
    ([API stability](api-stability.md)). Topic `created_at` and message
    `timestamp` are Unix seconds; user `created_at_ms` and `updated_at_ms`
    are Unix milliseconds.

    ### Pagination

    Only [list topics](#list-topics) pages. Pass `limit` (default 100, at
    most 1000) and the `page_token` from the previous answer, and stop when
    `next_page_token` is empty. A page can hold fewer topics than `limit`,
    or none, while `next_page_token` is still set, because topics you
    cannot read are removed after the page is cut.

    ### Release markers

    This page describes `master`. Anything v3.2.0 added is marked
    **New in v3.2.0**, and what v3.1.0 added is marked
    **New in v3.1.0**. Where an older node answers a request that uses
    it differently, its entry says how. Anything merged since the latest
    release, v3.2.2, is marked **Unreleased** until it ships. See
    [Which release these docs describe](api-stability.md#docs-version).
servers:
  - url: http://127.0.0.1:7942
    description: A local node started with narad server start --dev
security:
  - basicAuth: []
tags:
  - name: Topics
    description: |
      Create, read, change and delete topics. Changes to topics are
      written through the cluster's Raft log, so they need a quorum of
      nodes and answer [`503`](status-codes.md#status-503) while there is
      no leader. Task guide: [Manage topics](../build/topics.md).
  - name: Fan-out children
    description: |
      Link a topic to a parent so that it receives a copy of every message
      produced to the parent from the moment of the link. Task guide:
      [Fan out and delay messages](../build/fanout-and-delay.md).

      **New in v3.2.0:** a child can also be a
      [remote child](glossary.md#remote-child), whose copy goes to a topic
      on another Narad cluster. Its attach, pause, resume and skip need the
      `admin` grant with security on. Task guide:
      [Replicate a topic to another cluster](../build/remote-children.md).
  - name: Messages
    description: |
      Produce, consume and settle messages. Task guides:
      [Produce messages](../build/producing.md),
      [Consume and acknowledge](../build/consuming.md) and
      [Replay messages](../build/replay.md).
  - name: Users
    description: |
      Manage users and their grants. Every route needs the `admin` grant,
      except a user changing their own password. Task guide:
      [Manage users and grants](../operate/users.md).
  - name: Cluster
    description: |
      See cluster members and partition moves, and drain a node before
      removing it. Every route needs the `admin` grant, reads included.
      Task guide: [Scale out and in](../operate/scaling.md).
  - name: Remotes
    description: |
      **New in v3.2.0.** Register the other Narad clusters this one may send
      [remote children](glossary.md#remote-child) to. Every route needs the
      `admin` grant and a node with security on; with security off each
      one answers [`403`](status-codes.md#status-403) (`remotes require
      security`). Answers carry `Cache-Control: no-store`, never contain a
      password, and every request writes one `component=audit` line on the
      node that took it. A write also needs every member to run this
      release ([`412`](status-codes.md#status-412) until then), and a
      create or a password change needs the node to attest an encrypted
      API hop (`remotes.api_hop_encrypted`). Task guide:
      [Manage remotes](../operate/remotes.md).
  - name: Health and metrics
    description: |
      Probes and the Prometheus exposition. Where each is served, and which
      port the Helm chart probes, is in
      [Helm values reference](helm-values.md#ports-and-probes).
paths:
  /v1/topics:
    post:
      operationId: createTopic
      tags: [Topics]
      summary: Create a topic
      description: |
        Creates a topic and, with `parent`, links it as a fan-out child of
        an existing topic in the same call. The caller becomes the topic's
        [owner](glossary.md#owner). A field left out, or sent as `0`, takes
        the server default, except `retention_ms`, where `0` keeps messages
        forever and only leaving it out takes the default.
      x-narad-grant: |
        `create` on the topic name. With `parent`, also ownership of the
        parent or `admin`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTopicRequest'
      responses:
        '201':
          description: Created. The body is the new topic.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Topic'
        '400':
          description: A field is invalid, the name is not allowed, or the schema cannot be registered (from v3.1.0, that includes a schema whose validation would cost too much).
        '401':
          description: Missing or wrong credentials.
        '403':
          description: No `create` grant on the name, or no right to manage `parent`, as the node that answers or the cluster leader sees it.
        '404':
          description: '`parent` does not exist, also after the answering node caught up with the leader.'
        '409':
          description: The topic exists, a topic exists whose name differs only in letter case (the error names it), `parent` cannot take this child (role, child limit, schema, or a delay the parent's retention cannot hold), or (from v3.1.0) the schema, or the parent's schema history a child adopts, would pass a schema byte budget, or `parent` was recreated under the request twice in a row (`topic changed since it was read`).
        '413':
          description: The body is over 1 MiB.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '503':
          description: The cluster has no leader to write the topic, the answering node could not reach the leader to confirm `parent`, or (from v3.1.0) every live node is being decommissioned, so no node can take the new partitions; nothing was written.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X POST "$NARAD/v1/topics" \
              -H "Content-Type: application/json" \
              -d '{"name": "orders", "partitions": 3}'
          response: |
            HTTP/1.1 201 Created
            Content-Length: 224
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:43 GMT

            {"name":"orders","id":"681a429c0b683b2d","partitions":3,"retention_ms":604800000,"visibility_timeout_ms":30000,"max_in_flight_per_partition":1024,"max_acked_ahead_per_partition":1024,"created_at":1790623903,"owner":"admin"}
    get:
      operationId: listTopics
      tags: [Topics]
      summary: List topics
      description: |
        Lists topics in name order, one page at a time. See
        [Pagination](#pagination).
      x-narad-grant: |
        Any user. The list holds only topics the caller could read with
        [get a topic](#get-topic).
      parameters:
        - name: limit
          in: query
          description: Page size. Values above 1000 are treated as 1000.
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 100
        - name: page_token
          in: query
          description: The `next_page_token` of the previous page. Leave it out for the first page.
          schema:
            type: string
      responses:
        '200':
          description: A page of topics.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TopicPage'
        '400':
          description: '`limit` is not a positive integer.'
        '401':
          description: Missing or wrong credentials.
        '500':
          description: The node could not read its topic list.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" "$NARAD/v1/topics?limit=2"
          response: |
            HTTP/1.1 200 OK
            Content-Length: 540
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:43 GMT

            {"next_page_token":"orders-audit","topics":[{"name":"orders","id":"681a429c0b683b2d","partitions":3,"retention_ms":604800000,"visibility_timeout_ms":30000,"max_in_flight_per_partition":1024,"max_acked_ahead_per_partition":1024,"created_at":1790623903,"owner":"admin","role":"standalone"},{"name":"orders-audit","id":"91ad229a6dd04bcf","partitions":3,"retention_ms":604800000,"visibility_timeout_ms":30000,"max_in_flight_per_partition":1024,"max_acked_ahead_per_partition":1024,"created_at":1790623903,"owner":"admin","role":"standalone"}]}
  /v1/topics/{topic}:
    parameters:
      - $ref: '#/components/parameters/TopicPath'
    get:
      operationId: getTopic
      tags: [Topics]
      summary: Get a topic
      description: |
        Returns the topic, its current schema and statistics for each
        partition. The node that answers asks every partition's owner for
        its statistics.

        **New in v3.1.0:** when some partitions' owners are down, the answer
        is still `200`. The partitions that could be read carry their
        statistics and `status` `ok`; each other one is a placeholder with
        zero statistics, `status` `owner_unavailable` and the owner's
        `owner_liveness`, and the body carries `partial: true`. Leave the
        placeholders out of any total. An owner that does not answer within
        2 seconds counts as `unreachable`. A v3.0.1 node answers `421`
        instead.

        **New in v3.2.0:** a [remote child](glossary.md#remote-child)'s stub
        has `partitions: 0` and a `remote` object that names the remote and
        the topic there. Its `created_by` and `paused_by` are shown to
        admins only.
      x-narad-grant: |
        Any grant that matches the topic name, ownership, or `admin`.
      parameters:
        - name: partition
          in: query
          description: Return statistics for this partition only.
          schema:
            type: integer
            minimum: 0
      responses:
        '200':
          description: The topic with partition statistics.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TopicDetails'
        '400':
          description: '`partition` is not a partition of the topic.'
        '401':
          description: Missing or wrong credentials.
        '403':
          description: No grant on the topic.
        '404':
          description: The topic does not exist.
        '421':
          description: From a v3.0.1 node, the owner of one of the topic's partitions could not be found or refused to answer. An upgraded node answers `200` with `partial` instead.
        '503':
          description: The answering node could not read its own copy of the cluster metadata (for example while it catches up after a restart). Retry.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" "$NARAD/v1/topics/orders?partition=1"
          response: |
            HTTP/1.1 200 OK
            Content-Length: 470
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:43 GMT

            {"name":"orders","id":"681a429c0b683b2d","partitions":3,"retention_ms":172800000,"visibility_timeout_ms":30000,"max_in_flight_per_partition":1024,"max_acked_ahead_per_partition":1024,"created_at":1790623903,"owner":"admin","role":"parent","children":["orders-audit"],"schema_version":0,"partition_stats":[{"index":1,"segments":1,"oldest_offset":0,"next_offset":2,"high_watermark":2,"size_bytes":166,"oldest_segment_at":1790623903,"owner_node":"narad-0","status":"ok"}]}
    patch:
      operationId: alterTopic
      tags: [Topics]
      summary: Change a topic
      description: |
        Changes retention, the per-partition caps or the partition count, or
        registers a new schema version. Send at least one field.
        `visibility_timeout_ms` is fixed when the topic is created.

        Fields are applied one group at a time in this order: retention,
        then the caps, then partitions, then schema. There is no
        transaction across them: the first failure answers its error and
        the groups before it stay applied. Send one field per request when
        you need all or nothing.
      x-narad-grant: |
        Ownership of the topic, or `admin`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AlterTopicRequest'
      responses:
        '200':
          description: Changed. The body is the topic after the change.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Topic'
        '400':
          description: No field given, a value is invalid, `partitions` is not larger than the current count, or the new schema is not compatible.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not the owner and not `admin`, as the node that answers or the cluster leader sees the topic.
        '404':
          description: The topic does not exist, also after the answering node caught up with the leader.
        '409':
          description: '`schema_base_version` is not the current version, the history holds 1000 versions or (from v3.1.0) the new version would take it past 4 MiB or the cluster''s schemas past 256 MiB, the topic is a child whose schema its parent manages, the new retention is too short for a delay child, or (from v3.1.0) the topic was deleted and recreated, or grew, under the request twice in a row (`topic changed since it was read`). New in v3.2.0: the topic is a remote child''s stub, which takes no change, or the new retention is below 24 hours on a parent with remote children (a retention already below it may still grow).'
        '413':
          description: The body is over 1 MiB.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '503':
          description: The cluster has no leader to write the change, the answering node could not reach the leader to confirm a topic it does not have, or (from v3.1.0) a partition increase found every live node being decommissioned, so no node can take the new partitions; nothing was changed.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X PATCH "$NARAD/v1/topics/orders" \
              -H "Content-Type: application/json" \
              -d '{"retention_ms": 172800000}'
          response: |
            HTTP/1.1 200 OK
            Content-Length: 244
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:43 GMT

            {"name":"orders","id":"681a429c0b683b2d","partitions":3,"retention_ms":172800000,"visibility_timeout_ms":30000,"max_in_flight_per_partition":1024,"max_acked_ahead_per_partition":1024,"created_at":1790623903,"owner":"admin","role":"standalone"}
    delete:
      operationId: deleteTopic
      tags: [Topics]
      summary: Delete a topic
      description: |
        Deletes the topic's metadata and its data on every node, and
        removes its fan-out links. There is no undo. Once the metadata
        delete is committed the answer is `204`, even if a node that is
        down could not remove its files yet; that node removes them when
        it next starts.

        **New in v3.2.0:** deleting a parent that has
        [remote children](glossary.md#remote-child) deletes their stubs in
        the same entry, and deleting a remote child's stub deletes the link.
        Either is refused with `409` while any record of the parent is not
        yet on the remote, unless `force=true` abandons them; the leader
        checks every member's ingress backlog and every cursor first. The
        same refusal, with its body, is in
        [Detach a child](#detach-child).
      x-narad-grant: |
        Ownership of the topic, or `admin`. New in v3.2.0: a remote child's
        stub has no owner; the owner of its parent, or an `admin`, deletes
        it, with security on.
      parameters:
        - name: force
          in: query
          x-narad-since: v3.2.0
          description: |
            `true` deletes a parent with remote children, or a remote
            child's stub, even while records are unshipped, abandoning
            them. Ignored for any other topic. `narad topic rm --force`
            never sends it: there `--force` only skips the prompt.
          schema:
            type: boolean
            default: false
      responses:
        '204':
          description: Deleted.
        '400':
          description: New in v3.2.0. `force` is not `true` or `false`.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not the owner and not `admin`, as the node that answers or the cluster leader sees the topic. For a remote child's stub (from v3.2.0), the caller is neither an `admin` nor the parent's owner, or security is off. For a parent with remote children (from v3.2.0), security is off (`remotes require security`).
        '404':
          description: The topic does not exist, also after the answering node caught up with the leader.
        '409':
          description: New in v3.1.0. The topic was deleted and recreated under the request twice in a row (`topic changed since it was read`); nothing was deleted. Read the topic again before deleting it. From v3.2.0, for a parent with remote children or a stub without `force`, records of the parent are not yet on the remote (the body carries `lag_messages`, `lag_complete` and `dispatch_backlog`), or the leader found a remote child the answering node did not know of yet; retry.
        '412':
          description: New in v3.2.0. The cluster leader runs a release without remote children; finish the upgrade.
        '429':
          description: New in v3.2.0. The leader ran an unshipped check for this parent less than 10 seconds ago; retry after `Retry-After`.
          x-narad-origin: forwarding
        '501':
          description: New in v3.2.0. The answering node has no remote plane, so it cannot delete a remote-linked topic.
        '503':
          description: 'The cluster has no leader to write the delete, or the answering node could not reach the leader to confirm a topic it does not have. From v3.2.0, for a remote-linked topic, also when the unshipped check could not run. For a remote-linked topic, when the leader committed the change but the answering node could not confirm that its own copy applied it, `503` with `Retry-After: 2` and an error that says the change is committed: read it back after the delay, or on another node, and do not send it again.'
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X DELETE "$NARAD/v1/topics/orders-audit"
          response: |
            HTTP/1.1 204 No Content
            Date: Mon, 28 Sep 2026 19:31:43 GMT
  /v1/topics/{topic}/schema:
    parameters:
      - $ref: '#/components/parameters/TopicPath'
    get:
      operationId: getSchemaHistory
      tags: [Topics]
      summary: Get a topic's schema history
      description: |
        Returns every schema version of the topic, oldest first, and the
        number of the current one. A topic without a schema has version `0`
        and an empty list. The rules for schemas are in
        [Schema validation rules](schema-rules.md).
      x-narad-grant: |
        Any grant that matches the topic name, ownership, or `admin`.
      responses:
        '200':
          description: The schema history.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SchemaHistory'
        '401':
          description: Missing or wrong credentials.
        '403':
          description: No grant on the topic.
        '404':
          description: The topic does not exist.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" "$NARAD/v1/topics/payments/schema"
          response: |
            HTTP/1.1 200 OK
            Content-Length: 105
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:43 GMT

            {"topic":"payments","version":1,"versions":[{"version":1,"schema":{"type":"object","required":["id"]}}]}
  /v1/topics/{parent}/children:
    parameters:
      - $ref: '#/components/parameters/ParentPath'
    post:
      operationId: attachChild
      tags: [Fan-out children]
      summary: Attach a child
      description: |
        Links an existing topic as a [fan-out child](glossary.md#fan-out-child)
        of `parent`. The child receives every message produced to the parent
        from the [attach point](glossary.md#attach-point) on; nothing older
        is copied. A child without a schema adopts the parent's schema
        history.

        The delay field here is `delay_ms`. Creating a child in one call
        with [create a topic](#create-topic) takes `fanout_delay_ms`
        instead.

        **New in v3.2.0:** with `remote`, the request creates `child` as a
        [remote child](glossary.md#remote-child) instead: a stub with no
        partitions whose copies go, through the remote's batch produce, to
        `remote_topic` on that cluster. The child must not exist yet. The
        leader runs the attach checks from every member against the target
        (it answers `401` without credentials, the topic exists, is no
        delay child or stub and has no remote children, the schemas match,
        it takes batch produce, the credential is not an admin there),
        resolves the start offsets, and writes one Raft entry. With
        `dry_run` it stops before the entry and answers what it found.
        Without `remotes.allowed_hosts` on the leader, a dry
        run checks from the leader alone, its reports carry only `node`,
        `result`, `class` and this cluster's own `credential_version`,
        `posture` and an empty `warnings` (nothing the target answered: no
        `target_id`, `target_serves_ids`, times or certificate expiry),
        the top-level `warnings` of a dry run or a `201` holds only the
        parent retention warning, a dry run has no `capabilities`,
        and a failed attach or resume answers the
        `class` and the failing `members` instead of each member's report.
        The
        fields `remote_topic`, `from`, `lanes` and `dry_run` without
        `remote` get `400`. Task guide:
        [Replicate a topic to another cluster](../build/remote-children.md).
      x-narad-grant: |
        Ownership of both topics, or `admin`. New in v3.2.0: with `remote`,
        `admin` and a node with security on; ownership is not enough.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AttachChildRequest'
      responses:
        '200':
          description: 'Attached. The body is the parent topic. From v3.2.0, for a remote `dry_run`: every check passed and nothing was written, and the body is a report instead, with `dry_run`, `attach_offsets` (the start offset per parent partition), `checks` (each member''s report), `warnings` and, when the leader could probe the target and `remotes.allowed_hosts` is set, `capabilities` (`max_messages` per batch and `zstd`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Topic'
        '201':
          description: New in v3.2.0. The remote child was created. The body is its stub, plus `warnings` when there are any (a parent retention below 72 hours; with `remotes.allowed_hosts`, also a target that serves no topic IDs, a target whose batch produce takes at most 1 MiB of body, a certificate that expires within 14 days).
          x-narad-origin: forwarding
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Topic'
        '400':
          description: '`child` is missing, the two names are the same, or `delay_ms` is out of range. New in v3.2.0: a remote field without `remote`, a name that is not a remote''s or a topic''s, `lanes` outside 1 to 8, `from` other than `attach`, `unconsumed` or `earliest`, or a remote that does not exist.'
        '401':
          description: Missing or wrong credentials.
        '403':
          description: The caller does not manage both topics, as the node that answers or the cluster leader sees them. From v3.2.0, with `remote`, the caller is not an `admin`, or security is off (`remotes require security`).
        '404':
          description: The parent or the child does not exist, also after the answering node caught up with the leader.
        '409':
          description: 'The link breaks a fan-out rule (a child has one parent and no children), the parent has 108 children, the schemas differ, the delay is longer than the parent''s retention can hold, or (from v3.1.0) the copy of the parent''s schema history the child adopts would pass the cluster''s schema byte budget, or either topic was recreated under the request twice in a row (`topic changed since it was read`). From v3.2.0, with `remote`: a topic named `child` exists, the parent has 16 remote children, this cluster already links to that remote topic, the parent''s retention is below 24 hours, or a check found the target unusable (the body names the `class` and carries each member''s report in `checks`).'
        '412':
          description: 'New in v3.2.0, with `remote`. A member does not apply the remote Raft entry types, did not answer, or reports a posture that forbids remotes (security off or legacy cluster auth on), and the body names it in `members`; a member holds a stale or unreadable credential; the members disagree about the target''s ID; or the leader runs an older release.'
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '429':
          description: New in v3.2.0, with `remote`. This node took 60 remote child writes in the last minute, or a check of this remote ran less than 5 seconds ago on a member; retry after `Retry-After`.
        '501':
          description: New in v3.2.0, with `remote`. The answering node, or the leader, has no remote plane.
        '502':
          description: New in v3.2.0, with `remote`. Something in front of the target answered instead of it (a load balancer or a proxy), or the target answered with a redirect, which is never followed.
          x-narad-origin: forwarding
        '503':
          description: 'No leader, the answering node could not reach the leader to confirm a topic it does not have, or the parent''s partition owners could not be asked for the attach point. Nothing was linked; retry. From v3.2.0, with `remote`, also when the target or a member was unavailable during the checks. With `remote`, when the leader committed the change but the answering node could not confirm that its own copy applied it, `503` with `Retry-After: 2` and an error that says the change is committed: read it back after the delay, or on another node, and do not send it again. For an attach, the body also carries the leader''s `warnings`, which no read shows again.'
      x-narad-examples:
        - title: a local child
          request: |
            curl -i -u "$AUTH" -X POST "$NARAD/v1/topics/orders/children" \
              -H "Content-Type: application/json" \
              -d '{"child": "orders-audit"}'
          response: |
            HTTP/1.1 200 OK
            Content-Length: 268
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:43 GMT

            {"name":"orders","id":"681a429c0b683b2d","partitions":3,"retention_ms":172800000,"visibility_timeout_ms":30000,"max_in_flight_per_partition":1024,"max_acked_ahead_per_partition":1024,"created_at":1790623903,"owner":"admin","role":"parent","children":["orders-audit"]}
        - title: a remote child, checks only (v3.2.0)
          request: |
            curl -i -u "$AUTH" -X POST "$NARAD/v1/topics/orders/children" \
              -H "Content-Type: application/json" \
              -d '{"child": "orders-to-b", "remote": "b", "remote_topic": "orders",
                   "from": "unconsumed", "dry_run": true}'
          response: |
            HTTP/1.1 200 OK
            Cache-Control: no-store
            Content-Type: application/json
            X-Content-Type-Options: nosniff
            Date: Tue, 06 Oct 2026 13:06:41 GMT
            Content-Length: 435

            {"attach_offsets":[0,0,0],"capabilities":{"max_messages":1000,"zstd":true},"checks":[{"node":"narad-0","result":"pass","credential_version":1,"target_id":"128e63dd156ff568","target_serves_ids":true,"rtt_ms":0,"lane_capacity_per_s":20000,"server_cert_not_after":"2026-11-05T13:04:25Z","warnings":[],"posture":{"security_enabled":true,"legacy_cluster_auth":false,"raft_tls":true,"api_hop_encrypted":true}}],"dry_run":true,"warnings":[]}
        - title: a remote child (v3.2.0)
          request: |
            curl -i -u "$AUTH" -X POST "$NARAD/v1/topics/orders/children" \
              -H "Content-Type: application/json" \
              -d '{"child": "orders-to-b", "remote": "b", "remote_topic": "orders",
                   "from": "unconsumed"}'
          response: |
            HTTP/1.1 201 Created
            Cache-Control: no-store
            Content-Type: application/json
            X-Content-Type-Options: nosniff
            Date: Tue, 06 Oct 2026 13:06:59 GMT
            Content-Length: 408

            {"name":"orders-to-b","id":"d38a4383503cf4ac","partitions":0,"retention_ms":0,"visibility_timeout_ms":0,"max_in_flight_per_partition":0,"max_acked_ahead_per_partition":0,"created_at":1791292019,"role":"child","parent":"orders","attach_epoch":"341e6b8a37a9688a","attach_offsets":[0,0,0],"remote":{"name":"b","topic":"orders","target_id":"128e63dd156ff568","from":"unconsumed","lanes":1,"created_by":"admin"}}
    get:
      operationId: listChildren
      tags: [Fan-out children]
      summary: List a parent's children
      description: |
        Lists the parent's children with their delay and how many messages
        each is behind.

        **New in v3.2.0:** a [remote child](glossary.md#remote-child) also
        carries its link: the remote and the topic there, its state (the
        worst of its partitions'), its recovery point (`lag_seconds`), the
        retention left before drop-behind, the record a cursor is stuck on,
        and when the target was last verified. The listing names the
        remote, never its URL or credential, and a failure as a state,
        never text from the target. The answer also carries `parent_id`,
        which another cluster's remote child reads to notice a recreated
        target. The states are listed in
        [Remotes and remote children](remote-children.md#link-states).
      x-narad-grant: |
        Any grant that matches the parent's name, ownership, or `admin`.
      parameters:
        - name: partitions
          in: query
          x-narad-since: v3.2.0
          description: '`true` adds one row per parent partition to each remote child (`partitions`).'
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: The children.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChildList'
        '400':
          description: New in v3.2.0. `partitions` is not `true` or `false`.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: No grant on the parent.
        '404':
          description: The parent does not exist.
      x-narad-examples:
        - title: local children
          request: |
            curl -i -u "$AUTH" "$NARAD/v1/topics/orders/children"
          response: |
            HTTP/1.1 200 OK
            Content-Length: 108
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:43 GMT

            {"parent":"orders","children":[{"name":"orders-audit","delay_ms":0,"lag_messages":0,"lag_complete":false}]}
        - title: a remote child (v3.2.0)
          request: |
            curl -i -u "$AUTH" "$NARAD/v1/topics/orders/children"
          response: |
            HTTP/1.1 200 OK
            Content-Length: 469
            Content-Type: application/json
            Date: Tue, 06 Oct 2026 13:07:03 GMT

            {"parent":"orders","parent_id":"4be76b543f9c4091","children":[{"name":"orders-to-b","delay_ms":0,"lag_messages":0,"lag_complete":true,"remote":{"name":"b","topic":"orders","target_id":"128e63dd156ff568","from":"unconsumed","lanes":1,"created_by":"admin"},"paused":false,"state":"running","lag_seconds":0,"retention_headroom_seconds":259200,"source_drained":false,"blocked_at":null,"target_verified_at":"2026-10-06T13:07:00Z","last_success_at":"2026-10-06T13:07:00Z"}]}
  /v1/topics/{parent}/children/{child}:
    parameters:
      - $ref: '#/components/parameters/ParentPath'
      - name: child
        in: path
        required: true
        description: Name of the child topic.
        schema:
          type: string
    delete:
      operationId: detachChild
      tags: [Fan-out children]
      summary: Detach a child
      description: |
        Removes the link. The child keeps the messages and the schema
        history it already has and becomes a standalone topic again.

        **New in v3.2.0:** detaching a [remote child](glossary.md#remote-child)
        deletes its stub. It travels to the leader, which first checks that
        every record of the parent is on the remote: no cursor lag, every
        partition owner reporting, and no member holding records of the
        parent it answered `202` for and has not committed yet. While any
        is left it answers `409` with the counts, unless `force=true`
        abandons them; the leader's audit line records what a forced delete
        abandoned. The check is a point in time, so stop the producers
        first. A new check for one parent runs at most every 10 seconds.
      x-narad-grant: |
        Ownership of either topic, or `admin`. New in v3.2.0: for a remote
        child, the owner of the parent, or an `admin`, with security on.
      parameters:
        - name: force
          in: query
          x-narad-since: v3.2.0
          description: '`true` deletes a remote child even while records of its parent are unshipped, abandoning them. Ignored for a local child.'
          schema:
            type: boolean
            default: false
      responses:
        '204':
          description: Detached. For a remote child (from v3.2.0), its stub is deleted.
        '400':
          description: New in v3.2.0. `force` is not `true` or `false`.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: The caller manages neither topic, as the node that answers or the cluster leader sees them. For a remote child (from v3.2.0), the caller is neither an `admin` nor the parent's owner, or security is off.
        '404':
          description: Neither topic exists after the answering node caught up with the leader, or the two are not linked.
        '409':
          description: 'New in v3.1.0. Either topic was deleted and recreated under the request twice in a row (`topic changed since it was read`); nothing was changed. From v3.2.0, for a remote child without `force`: records of the parent are not yet on the remote, with `lag_messages`, `lag_complete` and `dispatch_backlog` (records each member holds, by node) in the body, and `not_answering` or `backlog_over_scan_limit` naming members that could not be counted; or the child turned out to be remote, or local, on the leader; retry.'
        '412':
          description: New in v3.2.0. The cluster leader runs a release without remote children; finish the upgrade.
        '429':
          description: New in v3.2.0. The leader ran an unshipped check for this parent less than 10 seconds ago; retry after `Retry-After`.
          x-narad-origin: forwarding
        '501':
          description: New in v3.2.0. The answering node has no remote plane, so it cannot detach a remote child.
        '503':
          description: 'The cluster has no leader to write the change, or the answering node could not reach the leader to confirm the topics. From v3.2.0, for a remote child, also when the unshipped check could not run. For a remote child, when the leader committed the change but the answering node could not confirm that its own copy applied it, `503` with `Retry-After: 2` and an error that says the change is committed: read it back after the delay, or on another node, and do not send it again.'
      x-narad-examples:
        - title: a local child
          request: |
            curl -i -u "$AUTH" -X DELETE \
              "$NARAD/v1/topics/orders/children/orders-audit"
          response: |
            HTTP/1.1 204 No Content
            Date: Mon, 28 Sep 2026 19:31:43 GMT
        - title: a remote child with records not yet on its remote (v3.2.0)
          request: |
            curl -i -u "$AUTH" -X DELETE \
              "$NARAD/v1/topics/orders/children/orders-to-b"
          response: |
            HTTP/1.1 409 Conflict
            Cache-Control: no-store
            Content-Type: application/json
            X-Content-Type-Options: nosniff
            Date: Tue, 06 Oct 2026 13:07:18 GMT
            Content-Length: 122

            {"dispatch_backlog":{},"error":"remote child \"orders-to-b\" has unshipped records","lag_complete":true,"lag_messages":4}
  /v1/topics/{parent}/children/{child}/pause:
    parameters:
      - $ref: '#/components/parameters/ParentPath'
      - $ref: '#/components/parameters/ChildPath'
    post:
      operationId: pauseRemoteChild
      tags: [Fan-out children]
      summary: Pause a remote child
      x-narad-since: v3.2.0
      description: |
        Stops a [remote child](glossary.md#remote-child) sending. Its
        cursors keep their positions and the parent's retention clock keeps
        running, so a pause longer than the retention headroom loses records
        to drop-behind. The leader checks only that every member applies
        the remote Raft entry types, from the member records, so a pause
        works while a member is down. Pausing a paused child records the new
        reason.
      x-narad-grant: |
        `admin`, with security on.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PauseRemoteChildRequest'
      responses:
        '200':
          description: Paused. The body is the stub, with `remote.paused`, `pause_reason`, `paused_by` and `paused_at_ms`.
          x-narad-origin: forwarding
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Topic'
        '400':
          description: A name is not a topic name, or `reason` is over 256 bytes or holds a control character.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not an `admin`, or security is off (`remotes require security`).
        '404':
          description: No remote child of that name under that parent.
          x-narad-origin: forwarding
        '409':
          description: The child was detached and attached again under the request; read it and retry.
          x-narad-origin: forwarding
        '412':
          description: A member does not apply the remote Raft entry types (the body names it), or the leader runs an older release.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '429':
          description: This node took 60 remote child writes in the last minute; retry after `Retry-After`.
        '501':
          description: The answering node has no remote plane.
        '503':
          description: 'The leader could not be reached or could not write the change; retry. A leader that could not be reached answers with `Retry-After: 2`. When the leader committed the change but the answering node could not confirm that its own copy applied it, `503` with `Retry-After: 2` and an error that says the change is committed: read it back after the delay, or on another node, and do not send it again.'
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X POST \
              "$NARAD/v1/topics/orders/children/orders-to-b/pause" \
              -H "Content-Type: application/json" \
              -d '{"reason": "target maintenance, CHG-4211"}'
          response: |
            HTTP/1.1 200 OK
            Cache-Control: no-store
            Content-Type: application/json
            X-Content-Type-Options: nosniff
            Date: Tue, 06 Oct 2026 13:07:15 GMT
            Content-Length: 517

            {"name":"orders-to-b","id":"d38a4383503cf4ac","partitions":0,"retention_ms":0,"visibility_timeout_ms":0,"max_in_flight_per_partition":0,"max_acked_ahead_per_partition":0,"created_at":1791292019,"role":"child","parent":"orders","attach_epoch":"341e6b8a37a9688a","attach_offsets":[0,0,0],"remote":{"name":"b","topic":"orders","target_id":"128e63dd156ff568","from":"unconsumed","lanes":1,"paused":true,"pause_reason":"target maintenance, CHG-4211","paused_by":"admin","paused_at_ms":1791292035849,"created_by":"admin"}}
  /v1/topics/{parent}/children/{child}/resume:
    parameters:
      - $ref: '#/components/parameters/ParentPath'
      - $ref: '#/components/parameters/ChildPath'
    post:
      operationId: resumeRemoteChild
      tags: [Fan-out children]
      summary: Resume a remote child
      x-narad-since: v3.2.0
      description: |
        Runs the attach checks from every member again, then lets a paused
        [remote child](glossary.md#remote-child) send. A target topic that
        was deleted and recreated since the attach (its ID changed) is
        refused with `409` (`target_replaced`) before anything is sent to
        it; `accept_target` records the new ID and resumes. Resuming a
        running child re-runs the checks and changes nothing else. Unlike
        pause, resume needs every member to answer.
      x-narad-grant: |
        `admin`, with security on.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResumeRemoteChildRequest'
      responses:
        '200':
          description: Resumed. The body is the stub.
          x-narad-origin: forwarding
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Topic'
        '400':
          description: A name is not a topic name, or the body is malformed.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not an `admin`, or security is off (`remotes require security`).
        '404':
          description: No remote child of that name under that parent.
          x-narad-origin: forwarding
        '409':
          description: The remote no longer exists (create it again first), the target topic was replaced (resume with `accept_target`), a check found the target unusable (the body names the `class` and carries each member's report), or the child was detached and attached again under the request.
          x-narad-origin: forwarding
        '412':
          description: A member does not apply the remote Raft entry types, did not answer, or reports a posture that forbids remotes (the body names it); a member holds a stale or unreadable credential; or the leader runs an older release.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '429':
          description: This node took 60 remote child writes in the last minute, or a check of this remote ran less than 5 seconds ago on a member; retry after `Retry-After`.
        '501':
          description: The answering node has no remote plane.
        '502':
          description: Something in front of the target answered instead of it, or the target answered with a redirect.
          x-narad-origin: forwarding
        '503':
          description: 'The leader could not be reached, or the target or a member was unavailable during the checks; retry. A leader that could not be reached answers with `Retry-After: 2`. When the leader committed the change but the answering node could not confirm that its own copy applied it, `503` with `Retry-After: 2` and an error that says the change is committed: read it back after the delay, or on another node, and do not send it again.'
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X POST \
              "$NARAD/v1/topics/orders/children/orders-to-b/resume" \
              -H "Content-Type: application/json"
          response: |
            HTTP/1.1 200 OK
            Cache-Control: no-store
            Content-Type: application/json
            X-Content-Type-Options: nosniff
            Date: Tue, 06 Oct 2026 13:07:33 GMT
            Content-Length: 423

            {"name":"orders-to-b","id":"d38a4383503cf4ac","partitions":0,"retention_ms":0,"visibility_timeout_ms":0,"max_in_flight_per_partition":0,"max_acked_ahead_per_partition":0,"created_at":1791292019,"role":"child","parent":"orders","attach_epoch":"341e6b8a37a9688a","attach_offsets":[0,0,0],"remote":{"name":"b","topic":"orders","target_id":"128e63dd156ff568","from":"unconsumed","lanes":1,"skip":{"0":[2]},"created_by":"admin"}}
  /v1/topics/{parent}/children/{child}/skip:
    parameters:
      - $ref: '#/components/parameters/ParentPath'
      - $ref: '#/components/parameters/ChildPath'
    post:
      operationId: skipRemoteChildRecord
      tags: [Fan-out children]
      summary: Skip a record a remote child cannot ship
      x-narad-since: v3.2.0
      description: |
        Records that one parent record may be dropped from a
        [remote child](glossary.md#remote-child). The leader asks the
        partition's owner first and accepts the skip only while the
        link's cursor is stuck on exactly that partition and offset in
        `rejected_record` or `record_too_large` (the listing's
        `blocked_at`); any other record is refused with `409`, so a
        mistyped partition or offset is never stored. With several lanes
        stuck, the owner reports the lowest record: skip them in order.
        The record stays in the parent's log for its retention. Each
        dropped record counts on
        `narad_fanout_remote_skipped_records_total` and is logged by the
        node that drops it. The child's `remote.skip` keeps skipped
        offsets per partition, ascending, at most 4000 of them (one
        full slab), always including the one just skipped, so a slab read again (after a
        restart or a partition move) drops each of them again.
      x-narad-grant: |
        `admin`, with security on.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SkipRemoteRecordRequest'
      responses:
        '200':
          description: Recorded. The body is the stub, with `remote.skip`.
          x-narad-origin: forwarding
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Topic'
        '400':
          description: '`partition` or `offset` is missing or negative, `partition` is not a partition of the parent, or a name is not a topic name.'
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not an `admin`, or security is off (`remotes require security`).
        '404':
          description: No remote child of that name under that parent, or the parent is gone.
          x-narad-origin: forwarding
        '409':
          description: The link's cursor of that partition is not stuck on that offset in `rejected_record` or `record_too_large` (the body names the record it is stuck on, as `blocked_at`, if any), or the child was detached and attached again under the request; read it and retry.
          x-narad-origin: forwarding
        '412':
          description: A member does not apply the remote Raft entry types (the body names it), or the leader runs an older release.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '429':
          description: This node took 60 remote child writes in the last minute; retry after `Retry-After`.
        '501':
          description: The answering node has no remote plane.
        '503':
          description: 'The leader could not be reached, could not ask the partition''s owner, or could not write the change; retry. A leader that could not be reached answers with `Retry-After: 2`. When the leader committed the change but the answering node could not confirm that its own copy applied it, `503` with `Retry-After: 2` and an error that says the change is committed: read it back after the delay, or on another node, and do not send it again.'
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X POST \
              "$NARAD/v1/topics/orders/children/orders-to-b/skip" \
              -H "Content-Type: application/json" \
              -d '{"partition": 0, "offset": 2}'
          response: |
            HTTP/1.1 200 OK
            Cache-Control: no-store
            Content-Type: application/json
            X-Content-Type-Options: nosniff
            Date: Tue, 06 Oct 2026 13:07:33 GMT
            Content-Length: 532

            {"name":"orders-to-b","id":"d38a4383503cf4ac","partitions":0,"retention_ms":0,"visibility_timeout_ms":0,"max_in_flight_per_partition":0,"max_acked_ahead_per_partition":0,"created_at":1791292019,"role":"child","parent":"orders","attach_epoch":"341e6b8a37a9688a","attach_offsets":[0,0,0],"remote":{"name":"b","topic":"orders","target_id":"128e63dd156ff568","from":"unconsumed","lanes":1,"paused":true,"pause_reason":"target maintenance, CHG-4211","paused_by":"admin","paused_at_ms":1791292035849,"skip":{"0":[2]},"created_by":"admin"}}
  /v1/topics/{topic}/produce:
    parameters:
      - $ref: '#/components/parameters/TopicPath'
    post:
      operationId: produce
      tags: [Messages]
      summary: Produce a message
      description: |
        Stores the request body as one message. A
        [`202`](status-codes.md#status-202) means the node that answered has
        written the message to its [ingress WAL](glossary.md#ingress-wal)
        and synced it to disk; it then moves the message to the partition's
        owner in the background. What a `202` promises is in the
        [delivery contract](../understand/delivery-contract.md#what-202-means).

        The body is stored byte for byte. Send JSON with
        `Content-Type: application/json` and anything else with
        `Content-Type: application/octet-stream`. On a topic with a schema,
        the body must be one JSON text that the current schema accepts
        ([Schema validation rules](schema-rules.md#validation)).
      x-narad-grant: |
        `produce` on the topic.
      parameters:
        - name: key
          in: query
          description: |
            Routes the message: messages with the same key go to the same
            partition while the partition count is unchanged and its owner
            is up. This is not an ordering guarantee. Without a key, messages
            are spread round-robin.
          schema:
            type: string
        - name: partition
          in: query
          description: Pins the message to this partition. Wins over `key`.
          schema:
            type: integer
            minimum: 0
      requestBody:
        required: true
        description: The message, 1 byte to 1 MiB.
        content:
          application/json:
            schema: {}
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        '202':
          description: Accepted and synced to disk. The body is empty.
        '400':
          description: Empty body, `partition` out of range, `key` or `partition` given twice, or the body fails the topic's schema (from v3.1.0, that includes a body nested deeper than 256 levels).
        '401':
          description: Missing or wrong credentials.
        '403':
          description: No `produce` grant on the topic.
        '404':
          description: The topic does not exist.
        '409':
          description: 'The topic is a delay child, which only its parent can feed. New in v3.2.0: the topic is a remote child''s stub, whose messages live on its remote (`remote child "<name>" lives on remote <remote>; consume it there`).'
        '413':
          description: The body is over 1 MiB.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '429':
          description: Too many produces in flight for this user on this node, only when the operator set a produce cap ([Configuration reference](configuration.md#http)).
        '500':
          description: The node could not write to its ingress WAL. It answers every produce this way until it restarts.
        '503':
          description: 'New in v3.1.0. Nothing was checked or stored; retry through another node. Either this node is being decommissioned and takes no new produce (with `Retry-After: 1`), or the topic has a schema and every schema validation slot on the node stayed busy for 5 seconds.'
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X POST \
              "$NARAD/v1/topics/orders/produce?key=customer-42" \
              -H "Content-Type: application/json" \
              -d '{"order_id": "ord_123", "amount": 1250}'
          response: |
            HTTP/1.1 202 Accepted
            Date: Mon, 28 Sep 2026 19:31:43 GMT
            Content-Length: 0
  /v1/topics/{topic}/produce/batch:
    parameters:
      - $ref: '#/components/parameters/TopicPath'
    post:
      operationId: produceBatch
      tags: [Messages]
      summary: Produce a batch
      x-narad-since: v3.1.0
      description: |
        Stores 1 to 1,000 messages in one request, all or none (1 to 100
        in v3.1.0). Every message
        is checked as a single produce would check it before any is stored.
        If one fails, the request gets the status a single produce of that
        message would get, its error starts with `message <index>: `, and
        nothing is stored. The `202` comes once every message is synced to
        the ingress WAL, and carries the same promise as a single `202` for
        each of them.

        `key` and `partition` belong to each message; as query parameters
        they get `400`. A node on v3.0.1 or earlier answers `404`: fall back
        to single produces.

        **New in v3.2.0:** the body may be up to 16 MiB, with each message's
        decoded payload at most 1 MiB, the single-produce cap, so anything a
        single produce accepts fits in a batch. It may be sent compressed,
        `Content-Encoding: zstd` or `gzip`, decoded under the same cap. A
        body over 1 MiB first takes its share of the node's batch body
        budget (`http.max_batch_body_bytes_in_flight`, 256 MiB by default)
        and is answered `503` with `Retry-After: 1` when the budget is
        full; bodies of 1 MiB or less never touch it. v3.1.0 answers a
        batch over 100 messages `400` and a body over 1 MiB `413`, and does
        not decode a compressed body. This is the route a
        [remote child](glossary.md#remote-child) sends to.
      x-narad-grant: |
        `produce` on the topic.
      requestBody:
        required: true
        description: At most 16 MiB in total, each payload at most 1 MiB (from v3.2.0; 1 MiB in total in v3.1.0), optionally zstd or gzip compressed.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProduceBatchRequest'
      responses:
        '202':
          description: Every message accepted and synced to disk.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProduceBatchAccepted'
        '400':
          description: No messages, more than 1,000 (more than 100 in v3.1.0), a bad encoding, an empty payload, a message the schema refuses (one nested deeper than 256 levels included), `key` or `partition` in the query, or (from v3.2.0) a compressed body that does not decode.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: No `produce` grant on the topic.
        '404':
          description: The topic does not exist, or the node runs v3.0.1 or earlier.
        '409':
          description: 'The topic is a delay child. New in v3.2.0: the topic is a remote child''s stub, whose messages live on its remote (`remote child "<name>" lives on remote <remote>; consume it there`).'
        '413':
          description: 'The body is over 16 MiB, decoded or as sent (1 MiB in v3.1.0), or (from v3.2.0) one message''s payload is over 1 MiB (`message <i>: message too large`).'
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header, or (from v3.2.0) a `Content-Encoding` other than `zstd`, `gzip` or none.
        '429':
          description: The batch does not fit this user's produce cap on this node (only when the cap is set). A batch counts as its message count, clamped to the cap.
        '500':
          description: The node could not write to its ingress WAL.
        '503':
          description: 'Nothing was checked or stored; retry through another node. Either this node is being decommissioned and takes no new produce (with `Retry-After: 1`), or the topic has a schema and every schema validation slot on the node stayed busy for 5 seconds, or (from v3.2.0) the body is over 1 MiB and the node''s batch body budget is full (with `Retry-After: 1`).'
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X POST "$NARAD/v1/topics/orders/produce/batch" \
              -H "Content-Type: application/json" \
              -d '{"messages": [
                    {"key": "customer-42", "payload": {"order_id": "ord_124"}},
                    {"key": "customer-7", "payload": "aGVsbG8=",
                     "payload_encoding": "base64"}
                  ]}'
          response: |
            HTTP/1.1 202 Accepted
            Content-Length: 15
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:43 GMT

            {"accepted":2}
  /v1/topics/{topic}/consume:
    parameters:
      - $ref: '#/components/parameters/TopicPath'
    get:
      operationId: consume
      tags: [Messages]
      summary: Consume messages
      description: |
        Without `offset`, takes the next available message and gives the
        caller a [lease](glossary.md#lease) on it for the topic's
        `visibility_timeout_ms`. Settle it with [ack](#ack) before the lease
        runs out, or the message is delivered again. The request can reach
        any node; the node gathers a message from whichever partition owner
        has one.

        With `partition` and `offset`, reads the record at that offset
        without taking a lease: a [replay](../build/replay.md). The answer
        has no `receipt_handle` and nothing needs acking.
      x-narad-grant: |
        `consume` on the topic.
      parameters:
        - name: wait
          in: query
          description: |
            How long to wait for a message before answering `204`, as a Go
            duration such as `500ms` or `10s`. Without it the answer is
            immediate. Values above the server's maximum (10 s by default,
            [Configuration reference](configuration.md#http)) are cut to it,
            and the response then carries `X-Narad-Wait-Clamped` with the
            value used.
          schema:
            type: string
            default: 0s
        - name: partition
          in: query
          description: Take messages from this partition only. Required with `offset`.
          schema:
            type: integer
            minimum: 0
        - name: offset
          in: query
          description: |
            Replay the record at this offset of `partition`. An offset past
            the end of the partition answers `204`; one that aged out of
            retention answers `410`.
          schema:
            type: integer
            minimum: 0
        - name: max
          in: query
          x-narad-since: v3.1.0
          description: |
            Take up to this many messages in one answer,
            `{"messages": [...]}`, each with its own lease. The request does
            not wait to fill `max`. Requires an `X-Narad-Client` header
            ([Required headers](#required-headers)). It counts as `max`,
            clamped to the cap, against the per-user consume cap. Cannot be combined with `offset`. A v3.0.1
            node ignores it and answers with one message in the
            single-message shape.
          schema:
            type: integer
            minimum: 1
            maximum: 100
      responses:
        '200':
          description: 'One message, or `{"messages": [...]}` with `max`.'
          headers:
            X-Narad-Wait-Clamped:
              description: Present when `wait` was cut to the server's maximum; the value used.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Message'
        '204':
          description: No message arrived within `wait`, or `offset` is past the end of the partition.
        '400':
          description: A parameter is invalid, `partition` is out of range, `offset` came without `partition`, `max` came with `offset`, or `max` came without an `X-Narad-Client` header.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: No `consume` grant on the topic.
        '404':
          description: The topic does not exist.
        '409':
          description: 'New in v3.2.0. The topic is a remote child''s stub, whose messages live on its remote (`remote child "<name>" lives on remote <remote>; consume it there`).'
        '410':
          description: Replay only. The record at `offset` aged out of retention or cannot be read.
        '421':
          description: The partition moved to another node while the request was served. Retry.
        '429':
          description: Too many consumes in flight for this user on this node.
        '500':
          description: The node could not read the partition, for example after a disk error.
        '502':
          description: With `partition`, the node forwarded the request to the partition's owner and got no answer.
          x-narad-origin: forwarding
        '503':
          description: With `partition`, the partition's owner is down. Retry after `Retry-After`. Releases up to v3.2.1 sent no `Retry-After`.
          headers:
            Retry-After:
              description: Seconds to wait before retrying, `1`.
              schema:
                type: string
      x-narad-examples:
        - title: one message
          request: |
            curl -i -u "$AUTH" "$NARAD/v1/topics/orders/consume?wait=5s"
          response: |
            HTTP/1.1 200 OK
            Content-Length: 179
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:43 GMT

            {"topic":"orders","partition":1,"offset":0,"key":"customer-42","payload":{"order_id": "ord_123", "amount": 1250},"timestamp":1790623903,"receipt_handle":"1:0:181499699661178901"}
        - title: a batch (v3.1.0)
          request: |
            curl -i -u "$AUTH" -H 'X-Narad-Client: curl' \
              "$NARAD/v1/topics/orders/consume?max=10&wait=5s"
          response: |
            HTTP/1.1 200 OK
            Content-Length: 326
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:43 GMT

            {"messages":[{"topic":"orders","partition":1,"offset":1,"key":"customer-42","payload":{"order_id": "ord_124"},"timestamp":1790623903,"receipt_handle":"1:1:6130356697557286029"},{"topic":"orders","partition":0,"offset":0,"key":"customer-7","payload":"hello","timestamp":1790623903,"receipt_handle":"0:0:2681459915124828679"}]}
  /v1/topics/{topic}/ack:
    parameters:
      - $ref: '#/components/parameters/TopicPath'
    post:
      operationId: ack
      tags: [Messages]
      summary: Ack, extend or nack a message
      description: |
        Settles the lease a [receipt handle](glossary.md#receipt-handle)
        names. By default it acks: the message is done and is not delivered
        again. `extend=true` renews the lease for a full
        `visibility_timeout_ms` from now, for a handler that needs longer.
        `extend=0` is a [nack](glossary.md#nack): the lease ends now and the
        message can be delivered again at once. A handle whose lease ran out,
        or that was already settled, gets `410`.

        Without a `receipt_handle` parameter, a JSON body
        `{"receipt_handles": [...]}` settles 1 to 100 handles in one
        request (**v3.1.0**). Each handle is settled on its own with the
        mode `extend` selects, and the answer is `200` with one result per
        handle, in request order. A v3.0.1 node answers such a request `400`.

        The request is a `POST`, so it needs the `Content-Type` or
        `X-Narad-Client` header even without a body.
      x-narad-grant: |
        `consume` on the topic.
      parameters:
        - name: receipt_handle
          in: query
          description: The `receipt_handle` from the consume answer. Required unless the body lists handles.
          schema:
            type: string
        - name: extend
          in: query
          description: |
            Leave it out (or `false`) to ack, `true` or `1` to extend the
            lease, `0` to nack. Any other value gets `400`.
          schema:
            type: string
            enum: ['false', 'true', '1', '0']
      requestBody:
        required: false
        description: Only for a batch ack, at most 64 KiB.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AckBatchRequest'
      responses:
        '204':
          description: Settled.
        '200':
          description: A batch ack. One result per handle, each the status a single ack of that handle would have answered.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AckBatchResults'
        '400':
          description: No `receipt_handle`, a handle that cannot be decoded, a bad `extend`, or more than 100 handles.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: No `consume` grant on the topic.
        '404':
          description: The topic does not exist.
        '409':
          description: 'New in v3.2.0. The topic is a remote child''s stub, whose messages live on its remote (`remote child "<name>" lives on remote <remote>; consume it there`).'
        '410':
          description: The lease ran out, or the message was already settled or delivered again under a new handle. A handle from another topic, or for a partition this topic does not have, also answers `410`.
        '413':
          description: A batch body over 64 KiB.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '421':
          description: The partition moved to another node while the request was served. Retry.
        '502':
          description: The node forwarded the request to the partition's owner and got no answer in time, so the ack may have been applied. Retry; an ack that already landed answers `410` the second time. The plain-text body names neither the owner nor the transport error.
          x-narad-origin: forwarding
        '503':
          description: The partition's owner is down, or the node could not get the forwarded request to the owner in time (no connection, or 2 seconds without a free slot to the owner). Nothing was applied. Retry after `Retry-After`; a `410` on the retry means the lease is gone. Releases up to v3.2.1 answered a forward that never left with `502`, and sent no `Retry-After`.
          headers:
            Retry-After:
              description: Seconds to wait before retrying, `1`.
              schema:
                type: string
          x-narad-origin: forwarding
      x-narad-examples:
        - title: one handle
          request: |
            curl -i -u "$AUTH" -X POST \
              "$NARAD/v1/topics/orders/ack?receipt_handle=1:0:181499699661178901" \
              -H "Content-Type: application/json"
          response: |
            HTTP/1.1 204 No Content
            Date: Mon, 28 Sep 2026 19:31:43 GMT
        - title: a batch (v3.1.0)
          request: |
            curl -i -u "$AUTH" -X POST "$NARAD/v1/topics/orders/ack" \
              -H "Content-Type: application/json" \
              -d '{"receipt_handles": [
                    "1:1:6130356697557286029",
                    "0:0:2681459915124828679",
                    "0:0:1"
                  ]}'
          response: |
            HTTP/1.1 200 OK
            Content-Length: 124
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:43 GMT

            {"results":[{"status":204},{"status":204},{"status":410,"error":"receipt handle no longer matches an active reservation"}]}
  /v1/users:
    post:
      operationId: createUser
      tags: [Users]
      summary: Create a user
      description: |
        Creates a user with a password and a list of
        [grants](access-model.md#actions).
      x-narad-grant: |
        `admin`. Only the root admin can give the `admin` grant.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '400':
          description: Invalid username, password or grant, or a body over 1 MiB.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not `admin`, or giving `admin` without being the root admin.
        '409':
          description: The user exists.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '503':
          description: The cluster has no leader to write the user.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X POST "$NARAD/v1/users" \
              -H "Content-Type: application/json" \
              -d '{
                "username": "billing-service",
                "password": "example-only-7Kq2",
                "grants": [
                  {"action": "produce", "patterns": ["invoices.*"]},
                  {"action": "consume", "patterns": ["payments.*"]}
                ]
              }'
          response: |
            HTTP/1.1 201 Created
            Content-Length: 196
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:44 GMT

            {"username":"billing-service","grants":[{"action":"produce","patterns":["invoices.*"]},{"action":"consume","patterns":["payments.*"]}],"created_at_ms":1790623904012,"updated_at_ms":1790623904012}
    get:
      operationId: listUsers
      tags: [Users]
      summary: List users
      description: Lists every user in name order. Password hashes are never returned.
      x-narad-grant: |
        `admin`.
      responses:
        '200':
          description: The users.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/User'
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not `admin`.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" "$NARAD/v1/users"
          response: |
            HTTP/1.1 200 OK
            Content-Length: 291
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:44 GMT

            [{"username":"admin","root":true,"created_at_ms":1790623900693,"updated_at_ms":1790623900693},{"username":"billing-service","grants":[{"action":"produce","patterns":["invoices.*"]},{"action":"consume","patterns":["payments.*"]}],"created_at_ms":1790623904012,"updated_at_ms":1790623904012}]
  /v1/users/{username}:
    parameters:
      - $ref: '#/components/parameters/UsernamePath'
    get:
      operationId: getUser
      tags: [Users]
      summary: Get a user
      description: Returns one user and its grants.
      x-narad-grant: |
        `admin`.
      responses:
        '200':
          description: The user.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not `admin`.
        '404':
          description: The user does not exist.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" "$NARAD/v1/users/billing-service"
          response: |
            HTTP/1.1 200 OK
            Content-Length: 196
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:44 GMT

            {"username":"billing-service","grants":[{"action":"produce","patterns":["invoices.*"]},{"action":"consume","patterns":["payments.*"]}],"created_at_ms":1790623904012,"updated_at_ms":1790623904012}
    delete:
      operationId: deleteUser
      tags: [Users]
      summary: Delete a user
      description: Deletes a user. The root admin and the caller's own account cannot be deleted.
      x-narad-grant: |
        `admin`.
      responses:
        '204':
          description: Deleted.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not `admin`, or the user is the root admin or the caller.
        '404':
          description: The user does not exist.
        '503':
          description: The cluster has no leader to write the delete.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X DELETE "$NARAD/v1/users/billing-service"
          response: |
            HTTP/1.1 204 No Content
            Date: Mon, 28 Sep 2026 19:31:44 GMT
  /v1/users/{username}/grants:
    parameters:
      - $ref: '#/components/parameters/UsernamePath'
    put:
      operationId: updateGrants
      tags: [Users]
      summary: Replace a user's grants
      description: |
        Replaces all of the user's grants with the list sent. The password
        is not touched. The root admin's grants cannot change, and no one
        can change their own grants.
      x-narad-grant: |
        `admin`. Only the root admin can give the `admin` grant.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateGrantsRequest'
      responses:
        '200':
          description: Changed. The body is the user after the change.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '400':
          description: A grant is invalid, or the body is over 1 MiB.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not `admin`, the caller's own grants, the root admin's grants, or `admin` given by someone other than the root admin.
        '404':
          description: The user does not exist.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '503':
          description: The cluster has no leader to write the change.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X PUT "$NARAD/v1/users/billing-service/grants" \
              -H "Content-Type: application/json" \
              -d '{"grants": [{"action": "produce", "patterns": ["invoices.*"]}]}'
          response: |
            HTTP/1.1 200 OK
            Content-Length: 149
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:44 GMT

            {"username":"billing-service","grants":[{"action":"produce","patterns":["invoices.*"]}],"created_at_ms":1790623904012,"updated_at_ms":1790623904073}
  /v1/users/{username}/password:
    parameters:
      - $ref: '#/components/parameters/UsernamePath'
    put:
      operationId: updatePassword
      tags: [Users]
      summary: Change a password
      description: |
        Sets a new password. A user who is not `admin` can change their
        own password by sending `current_password`. An `admin` can reset
        any password without it, except the root admin's, which only the
        root admin can change. Grants are not touched.
      x-narad-grant: |
        `admin`, or the user themself.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdatePasswordRequest'
      responses:
        '204':
          description: Changed.
        '400':
          description: '`new_password` is empty or over 72 bytes, or the body is over 1 MiB.'
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not `admin` and not the user, `current_password` is wrong, or someone other than the root admin changing the root admin's password.
        '404':
          description: The user does not exist.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '503':
          description: The cluster has no leader to write the change.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X PUT \
              "$NARAD/v1/users/billing-service/password" \
              -H "Content-Type: application/json" \
              -d '{"new_password": "example-only-9Wd4"}'
          response: |
            HTTP/1.1 204 No Content
            Date: Mon, 28 Sep 2026 19:31:44 GMT
  /v1/cluster/members:
    get:
      operationId: listMembers
      tags: [Cluster]
      summary: List cluster members
      description: |
        Lists every node the cluster knows, alive or dead, with how many
        partitions it owns and how many are moving off it, whether it is a
        Raft voter or the leader, how long ago its last heartbeat was
        stamped, and why a decommission in progress is blocked. Answered
        from the receiving node's replica without asking any other node,
        unless `detail` is set.
      x-narad-grant: |
        `admin`.
      parameters:
        - name: detail
          in: query
          x-narad-since: v3.1.0
          description: |
            `true` also asks every member for its own status (dispatch
            backlog, quarantined copies, move workers), all at once and
            within 2 seconds in total. A member that cannot answer gets a
            `status_error` instead; a v3.0.1 node is reported as an older
            release that cannot report its status.
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: The members, in ID order.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MemberList'
        '401':
          description: Missing or wrong credentials.
        '400':
          description: '`detail` is not `true` or `false`.'
        '403':
          description: Not `admin`.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" "$NARAD/v1/cluster/members"
          response: |
            HTTP/1.1 200 OK
            Content-Length: 183
            Content-Type: application/json
            Date: Mon, 05 Oct 2026 19:14:29 GMT

            {"members":[{"id":"narad-0","addr":"127.0.0.1:17970","status":"alive","draining":false,"owned_partitions":4,"outbound_moves":0,"voter":true,"leader":true,"heartbeat_age_seconds":2}]}
  /v1/cluster/moves:
    get:
      operationId: listMoves
      tags: [Cluster]
      summary: List partition moves
      description: |
        Lists every partition that is moving between nodes right now, with
        each side's liveness and why a move is blocked. Abort one with
        `POST /v1/cluster/moves/{topic}/{partition}/abort`.
      x-narad-grant: |
        `admin`.
      parameters:
        - name: detail
          in: query
          x-narad-since: v3.1.0
          description: |
            `true` also asks each move's destination for its move worker's
            own report: phase, copy attempts, copied bytes, last error and
            why it is blocked. Within 2 seconds in total.
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: The moves, by topic and partition.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MoveList'
        '401':
          description: Missing or wrong credentials.
        '400':
          description: '`detail` is not `true` or `false`.'
        '403':
          description: Not `admin`.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" "$NARAD/v1/cluster/moves"
          response: |
            HTTP/1.1 200 OK
            Content-Length: 119
            Content-Type: application/json
            Date: Mon, 05 Oct 2026 19:15:09 GMT

            {"moves":[{"topic":"orders","partition":3,"from":"narad-0","to":"narad-2","from_status":"alive","to_status":"alive"}]}
  /v1/cluster/members/{id}/decommission:
    parameters:
      - name: id
        in: path
        required: true
        description: The node ID, as `GET /v1/cluster/members` lists it (the pod name under the Helm chart).
        schema:
          type: string
    post:
      operationId: decommissionMember
      tags: [Cluster]
      summary: Decommission a node
      description: |
        Marks the node as draining. The cluster moves every partition it
        owns onto the other nodes and, once it owns none and its ingress WAL
        has handed every accepted message to its owner, removes it from
        the Raft voters. Remove the node only after that; the steps are in
        [Scale out and in](../operate/scaling.md#decommission). While it
        drains, the node answers produce with `503`.

        The request is checked first, on the node that receives it and
        again on the leader. A decommission that could never complete
        safely is refused with `409` and every reason, and nothing changes:
        `below_min_voters` (fewer than three voters would remain),
        `no_healthy_majority` (the voters left alive would not be a
        majority), `no_receivers` (no other alive node can take its
        partitions) or `owner_dead` (the node is dead and owns partitions).
      x-narad-grant: |
        `admin`.
      parameters:
        - name: dry_run
          in: query
          x-narad-since: v3.1.0
          description: |
            `true` answers `200` with what a decommission would do and
            changes nothing. Answered by the receiving node, never
            forwarded.
          schema:
            type: boolean
            default: false
      responses:
        '200':
          x-narad-since: v3.1.0
          description: '`dry_run=true`: the verdict. Nothing changed.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DecommissionDryRun'
        '204':
          description: Marked as draining.
        '400':
          description: '`dry_run` is not `true` or `false`.'
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not `admin`.
        '404':
          description: No member has this ID.
        '409':
          x-narad-since: v3.1.0
          description: The decommission could never complete safely. The body names every reason; nothing changed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DecommissionRefusal'
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '503':
          description: The cluster has no leader to write the change.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X POST \
              "$NARAD/v1/cluster/members/narad-0/decommission" \
              -H "Content-Type: application/json"
          response: |
            HTTP/1.1 204 No Content
            Date: Mon, 28 Sep 2026 19:31:44 GMT
    delete:
      operationId: cancelDecommission
      tags: [Cluster]
      summary: Cancel a decommission
      description: |
        Clears the draining mark. Partitions that already moved stay where
        they are; the node keeps the rest.
      x-narad-grant: |
        `admin`.
      responses:
        '204':
          description: No longer draining.
        '400':
          description: '`dry_run` was given: a cancel cannot be dry-run.'
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not `admin`.
        '404':
          description: No member has this ID.
        '503':
          description: The cluster has no leader to write the change.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X DELETE \
              "$NARAD/v1/cluster/members/narad-0/decommission"
          response: |
            HTTP/1.1 204 No Content
            Date: Mon, 28 Sep 2026 19:31:44 GMT
  /v1/cluster/moves/{topic}/{partition}/abort:
    parameters:
      - $ref: '#/components/parameters/TopicPath'
      - name: partition
        in: path
        required: true
        description: The partition number.
        schema:
          type: integer
          minimum: 0
    post:
      operationId: abortMove
      tags: [Cluster]
      summary: Abort a partition move
      x-narad-since: v3.1.0
      description: |
        Clears the move's target, so the partition stays with its owner
        and keeps serving there. The destination discards its copy. The
        abort is a compare-and-set on the leader: a move re-planned to
        another node in the meantime is left alone. The answer is read
        back from the leader's assignment after the abort, so a move whose
        flip committed before the abort reached the leader is refused
        with `409`, never reported as aborted. The cluster may plan a move
        for the partition again later. An abort that took effect is
        audited as `cluster.move.abort`; one whose outcome could not be
        read back is audited with `outcome unknown`.
      x-narad-grant: |
        `admin`.
      parameters:
        - name: target
          in: query
          description: |
            The destination you mean, as `GET /v1/cluster/moves` showed
            it. When the move now targets another node, the request is
            refused with `409` and nothing changes.
          schema:
            type: string
      responses:
        '202':
          description: The leader cleared the target; the partition stays with its owner.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MoveAbort'
        '400':
          description: The partition is not a number.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not `admin`.
        '404':
          description: The partition has no assignment.
        '409':
          description: No move is in flight for the partition, or it targets another node than `target`, or the leader's assignment after the abort shows the move finished first (another node owns the partition now) or still in flight; the message names the owner and target. Nothing was aborted.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '503':
          description: The cluster has no leader to write the change, or the abort reached the leader but whether it cleared the target could not be read back; list the moves to see.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X POST \
              "$NARAD/v1/cluster/moves/orders/3/abort?target=narad-2" \
              -H "Content-Type: application/json"
          response: |
            HTTP/1.1 202 Accepted
            Content-Length: 240
            Content-Type: application/json
            Date: Mon, 05 Oct 2026 19:15:09 GMT

            {"move":{"topic":"orders","partition":3,"from":"narad-0","to":"narad-2","from_status":"alive","to_status":"alive"},"note":"the move's target was cleared; the partition stays with its owner, and the controller may plan a move for it again"}
  /v1/cluster/members/{id}/forget:
    parameters:
      - name: id
        in: path
        required: true
        description: >-
          The Raft server ID. The leader's warnings name a server with no
          member record as `raft server "<id>" has no member record`; under
          the Helm chart a node's Raft ID is its pod name, such as `narad-3`.
        schema:
          type: string
    post:
      operationId: forgetServer
      tags: [Cluster]
      summary: Forget a Raft server with no member record
      x-narad-since: v3.1.0
      description: |
        Removes a Raft voter or non-voter that has no member record, such as
        a joiner a 3.0.x leader admitted that never registered. Such a
        server counts against quorum as a voter, and holds back new Raft
        entry types whatever its suffrage, and decommission cannot reach
        it. Forget moves and deletes no data: it refuses a server with a
        member record, alive, dead or draining (decommission it instead),
        and one a partition assignment names. It also refuses a voter
        unless the leader and the other voters it reaches make a majority
        of the voters left after the removal: Raft commits the removal
        under the new configuration, so a cluster left without that
        majority loses its leader and cannot undo the change. It runs on
        the leader; followers forward it. The steps are in
        [Troubleshooting](../operate/troubleshooting.md#raft-server-no-member-record).
      x-narad-grant: |
        `admin`.
      responses:
        '200':
          description: Removed from the Raft configuration.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ForgottenServer'
        '400':
          description: The ID names the leader itself.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not `admin`.
        '404':
          description: No Raft server has this ID.
        '409':
          description: The server has a member record (decommission it instead), a partition assignment names it as owner or move target, or it is a voter and the voters left could lack a quorum (the leader's Raft heartbeats to too many of them are failing, or it has led for less than 12 s). The message says which.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '501':
          description: The leader runs a release before forget. Upgrade it first.
          x-narad-origin: forwarding
        '503':
          description: The cluster has no leader to write the change, or the leader could not be reached. The server may have been removed; read the leader's log or retry.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X POST \
              "$NARAD/v1/cluster/members/narad-3/forget" \
              -H "Content-Type: application/json"
          response: |
            HTTP/1.1 200 OK
            Content-Length: 31
            Content-Type: application/json
            Date: Mon, 05 Oct 2026 14:20:04 GMT

            {"id":"narad-3","voter":false}
  /v1/remotes:
    post:
      operationId: createRemote
      tags: [Remotes]
      summary: Register a remote
      x-narad-since: v3.2.0
      description: |
        Registers another Narad cluster this one may send remote children
        to. The node that takes the request checks every field, resolves
        the URL's host and checks it against the address guard, and seals
        the password with AES-256-GCM under a key derived from the cluster
        secret; only the ciphertext travels to the leader and into Raft.
        The password is never shown again: answers carry a keyed
        `fingerprint` instead. Nothing is sent to the remote; run
        [test a remote](#test-remote) next.

        The URL must be `https`, on a port in `remotes.allowed_ports`
        (443 by default), on a host in `remotes.allowed_hosts` when that is
        set, with no user, query or fragment. A node takes at most 10
        remote writes a minute, and a cluster holds at most 64 remotes.
      x-narad-grant: |
        `admin`, with security on.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateRemoteRequest'
      responses:
        '201':
          description: Registered. The body is the remote.
          x-narad-origin: forwarding
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Remote'
        '400':
          description: A field is missing, unknown or invalid (the message names the field, never its value), or the URL's host resolves to an address the guard refuses.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not an `admin`, or security is off (`remotes require security`).
        '409':
          description: A remote of that name exists, the cluster holds 64 remotes, or two first creates raced (retry).
          x-narad-origin: forwarding
        '412':
          description: 'Nothing was sealed or stored: this node does not attest an encrypted API hop (`remotes.api_hop_encrypted`); the cluster secret is missing or decodes to fewer than 32 bytes; the current key''s seal budget is spent (rotate the cluster secret); a member does not apply the remote Raft entry types, did not answer, or reports a posture that forbids remotes (the body names it in `members`); or the leader runs an older release.'
        '413':
          description: The body is over 128 KiB.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '429':
          description: 'This node took 10 remote writes in the last minute; retry after `Retry-After`, the seconds until the oldest of them leaves the minute.'
        '500':
          description: The node failed to seal the password; logged on the node.
        '501':
          description: The answering node has no remote plane.
        '503':
          description: 'The leader could not be reached, or could not write the change; the request ID in the node''s audit line joins it to the leader''s. Read the remote back before retrying. A leader that could not be reached answers with `Retry-After: 2`. When the leader committed the change but the answering node could not confirm that its own copy applied it, `503` with `Retry-After: 2` and an error that says the change is committed: read it back after the delay, or on another node, and do not send it again.'
      x-narad-examples:
        - request: |
            jq -n --rawfile pw repl-password --rawfile ca narad-b-ca.pem \
              '{name: "b", url: "https://localhost:8443",
                username: "repl-from-a-7f3k9q",
                password: ($pw | rtrimstr("\n")), ca_pem: $ca}' |
            curl -i -u "$AUTH" -X POST "$NARAD/v1/remotes" \
              -H "Content-Type: application/json" -d @-
          response: |
            HTTP/1.1 201 Created
            Cache-Control: no-store
            Content-Type: application/json
            X-Content-Type-Options: nosniff
            Date: Tue, 06 Oct 2026 13:06:22 GMT
            Content-Length: 622

            {"name":"b","id":"8ccffc20e36f644c","url":"https://localhost:8443","username":"repl-from-a-7f3k9q","password":{"fingerprint":"ced1ea5d18d1","set_at":"2026-10-06T13:06:22Z","set_by":"admin","key_version":"b51c9412df29325d"},"credential_version":1,"ca_pem_sha512":"a85bc5f06c550eb18ff2a2a29187fe3a290b1fc74c76d0ef2781531bd4df4f84187980295b5484a6e363a2ff24c52fd9cec309bd74405beb5c815fb48b1e80b0","limits":{"max_in_flight":16,"request_timeout_ms":30000,"idle_conn_timeout_ms":30000,"conn_max_age_ms":300000,"check_interval_ms":60000,"compression":"none"},"revision":1,"created_at":"2026-10-06T13:06:22Z","created_by":"admin"}
    get:
      operationId: listRemotes
      tags: [Remotes]
      summary: List remotes
      x-narad-since: v3.2.0
      description: |
        Lists every remote, the remote children that use it, and what each
        member's credential cache holds for it: its state, the credential
        and key versions it decrypted, when it last reached the remote, and
        the server certificate's expiry. `lingering` lists deleted remotes
        some member still holds (it has not applied the delete) and the
        members that did not answer; `not_answering` names every member
        that did not answer, even when no answering member holds a deleted
        remote. `key` is the current encryption key's
        version and age, absent before the first remote.
      x-narad-grant: |
        `admin`, with security on.
      parameters:
        - name: nodes
          in: query
          description: '`false` skips asking the members for their caches; the answer then has no `nodes`, no `lingering` and no `not_answering`.'
          schema:
            type: boolean
            default: true
      responses:
        '200':
          description: The remotes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RemoteList'
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not an `admin`, or security is off (`remotes require security`).
        '501':
          description: The answering node has no remote plane.
        '503':
          description: The node could not read its copy of the remotes; retry.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" "$NARAD/v1/remotes"
          response: |
            HTTP/1.1 200 OK
            Cache-Control: no-store
            Content-Length: 1012
            Content-Type: application/json
            Date: Tue, 06 Oct 2026 13:06:32 GMT

            {"allowlist":"set","key":{"key_version":"b51c9412df29325d","first_sealed_at":"2026-10-06T13:06:22Z","age_seconds":10},"remotes":[{"name":"b","id":"8ccffc20e36f644c","url":"https://localhost:8443","username":"repl-from-a-7f3k9q","password":{"fingerprint":"ced1ea5d18d1","set_at":"2026-10-06T13:06:22Z","set_by":"admin","key_version":"b51c9412df29325d"},"credential_version":1,"ca_pem_sha512":"a85bc5f06c550eb18ff2a2a29187fe3a290b1fc74c76d0ef2781531bd4df4f84187980295b5484a6e363a2ff24c52fd9cec309bd74405beb5c815fb48b1e80b0","limits":{"max_in_flight":16,"request_timeout_ms":30000,"idle_conn_timeout_ms":30000,"conn_max_age_ms":300000,"check_interval_ms":60000,"compression":"none"},"revision":1,"created_at":"2026-10-06T13:06:22Z","created_by":"admin","nodes":[{"node":"narad-0","state":"ready","credential_version":1,"key_version":"b51c9412df29325d","fingerprint":"ced1ea5d18d1","last_ok_at":"2026-10-06T13:06:32Z","last_error":"none","server_cert_not_after":"2026-11-05T13:04:25Z","rtt_ms":0}]}],"lingering":[],"not_answering":[]}
  /v1/remotes/{name}:
    parameters:
      - $ref: '#/components/parameters/RemoteNamePath'
    get:
      operationId: getRemote
      tags: [Remotes]
      summary: Get a remote
      x-narad-since: v3.2.0
      description: |
        Returns one remote, with what each member's credential cache holds
        for it, as [list remotes](#list-remotes) does.
      x-narad-grant: |
        `admin`, with security on.
      parameters:
        - name: nodes
          in: query
          description: '`false` skips asking the members for their caches.'
          schema:
            type: boolean
            default: true
      responses:
        '200':
          description: The remote.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Remote'
        '400':
          description: '`name` is not a remote''s name.'
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not an `admin`, or security is off (`remotes require security`).
        '404':
          description: No remote of that name.
        '501':
          description: The answering node has no remote plane.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" "$NARAD/v1/remotes/b?nodes=false"
          response: |
            HTTP/1.1 200 OK
            Cache-Control: no-store
            Content-Length: 622
            Content-Type: application/json
            Date: Tue, 06 Oct 2026 13:06:32 GMT

            {"name":"b","id":"8ccffc20e36f644c","url":"https://localhost:8443","username":"repl-from-a-7f3k9q","password":{"fingerprint":"ced1ea5d18d1","set_at":"2026-10-06T13:06:22Z","set_by":"admin","key_version":"b51c9412df29325d"},"credential_version":1,"ca_pem_sha512":"a85bc5f06c550eb18ff2a2a29187fe3a290b1fc74c76d0ef2781531bd4df4f84187980295b5484a6e363a2ff24c52fd9cec309bd74405beb5c815fb48b1e80b0","limits":{"max_in_flight":16,"request_timeout_ms":30000,"idle_conn_timeout_ms":30000,"conn_max_age_ms":300000,"check_interval_ms":60000,"compression":"none"},"revision":1,"created_at":"2026-10-06T13:06:22Z","created_by":"admin"}
    patch:
      operationId: updateRemote
      tags: [Remotes]
      summary: Change a remote
      x-narad-since: v3.2.0
      description: |
        Changes the fields named and nothing else. A new `url`, `username`
        or `ca_pem` needs `password` in the same request: a stored password
        is only ever sent to the URL and user it was entered with, verified
        against the CA it was entered with. `ca_pem: ""` drops the CA and
        uses the system roots. A new password is sealed on this node, as on
        a create, and the links pick it up without a restart. `limits`
        change live; a limit named with `0` or `""` is refused.
      x-narad-grant: |
        `admin`, with security on.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateRemoteRequest'
      responses:
        '200':
          description: Changed. The body is the remote.
          x-narad-origin: forwarding
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Remote'
        '400':
          description: A field is unknown or invalid, nothing to change, or a new `url`, `username` or `ca_pem` came without `password`.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not an `admin`, or security is off (`remotes require security`).
        '404':
          description: No remote of that name.
        '409':
          description: The remote changed since this node read it (retry), or it was created again under the same name.
          x-narad-origin: forwarding
        '412':
          description: As for [register a remote](#create-remote); the hop and secret rules apply only to a change that carries a password.
        '413':
          description: The body is over 128 KiB.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '429':
          description: 'This node took 10 remote writes in the last minute; retry after `Retry-After`, the seconds until the oldest of them leaves the minute.'
        '500':
          description: The node failed to seal the password; logged on the node.
        '501':
          description: The answering node has no remote plane.
        '503':
          description: 'The leader could not be reached, or could not write the change. Read the remote back before retrying. A leader that could not be reached answers with `Retry-After: 2`. When the leader committed the change but the answering node could not confirm that its own copy applied it, `503` with `Retry-After: 2` and an error that says the change is committed: read it back after the delay, or on another node, and do not send it again.'
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X PATCH "$NARAD/v1/remotes/b" \
              -H "Content-Type: application/json" \
              -d '{"limits": {"max_in_flight": 32, "compression": "zstd"}}'
          response: |
            HTTP/1.1 200 OK
            Cache-Control: no-store
            Content-Type: application/json
            X-Content-Type-Options: nosniff
            Date: Tue, 06 Oct 2026 13:06:32 GMT
            Content-Length: 622

            {"name":"b","id":"8ccffc20e36f644c","url":"https://localhost:8443","username":"repl-from-a-7f3k9q","password":{"fingerprint":"ced1ea5d18d1","set_at":"2026-10-06T13:06:22Z","set_by":"admin","key_version":"b51c9412df29325d"},"credential_version":1,"ca_pem_sha512":"a85bc5f06c550eb18ff2a2a29187fe3a290b1fc74c76d0ef2781531bd4df4f84187980295b5484a6e363a2ff24c52fd9cec309bd74405beb5c815fb48b1e80b0","limits":{"max_in_flight":32,"request_timeout_ms":30000,"idle_conn_timeout_ms":30000,"conn_max_age_ms":300000,"check_interval_ms":60000,"compression":"zstd"},"revision":2,"created_at":"2026-10-06T13:06:22Z","created_by":"admin"}
    delete:
      operationId: deleteRemote
      tags: [Remotes]
      summary: Delete a remote
      x-narad-since: v3.2.0
      description: |
        Deletes the remote and its stored password. Refused while remote
        children name it, unless `force=true`: they then hold, without
        loss while the parent's retention lasts, in state
        `remote_missing` until a remote of that name exists again. Each
        member drops its cached credential and closes its connections when
        it applies the delete; [list remotes](#list-remotes) shows the
        members that have not under `lingering`. The leader checks only
        the member records, so a delete works while a member is down. In
        an emergency, revoke the user on the target first
        ([Manage remotes](../operate/remotes.md#emergency-revocation)).
      x-narad-grant: |
        `admin`, with security on.
      parameters:
        - name: force
          in: query
          description: '`true` deletes the remote even while remote children use it.'
          schema:
            type: boolean
            default: false
      responses:
        '204':
          description: Deleted.
          x-narad-origin: forwarding
        '400':
          description: '`name` is not a remote''s name, or `force` is not `true` or `false`.'
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not an `admin`, or security is off (`remotes require security`).
        '404':
          description: No remote of that name.
          x-narad-origin: forwarding
        '409':
          description: Remote children use the remote; the body lists them in `links`.
          x-narad-origin: forwarding
        '412':
          description: A member does not apply the remote Raft entry types (the body names it), or the leader runs an older release.
        '429':
          description: 'This node took 10 remote writes in the last minute; retry after `Retry-After`, the seconds until the oldest of them leaves the minute.'
        '501':
          description: The answering node has no remote plane.
        '503':
          description: 'The leader could not be reached, or could not write the change. Read the remotes back before retrying. A leader that could not be reached answers with `Retry-After: 2`. When the leader committed the change but the answering node could not confirm that its own copy applied it, `503` with `Retry-After: 2` and an error that says the change is committed: read it back after the delay, or on another node, and do not send it again.'
      x-narad-examples:
        - title: in use
          request: |
            curl -i -u "$AUTH" -X DELETE "$NARAD/v1/remotes/b"
          response: |
            HTTP/1.1 409 Conflict
            Cache-Control: no-store
            Content-Type: application/json
            X-Content-Type-Options: nosniff
            Date: Tue, 06 Oct 2026 13:07:49 GMT
            Content-Length: 79

            {"error":"remote is used by 1 remote children","links":["orders/orders-to-b"]}
        - title: once no remote child uses it
          request: |
            curl -i -u "$AUTH" -X DELETE "$NARAD/v1/remotes/b"
          response: |
            HTTP/1.1 204 No Content
            Cache-Control: no-store
            Date: Tue, 06 Oct 2026 13:07:49 GMT
  /v1/remotes/{name}/test:
    parameters:
      - $ref: '#/components/parameters/RemoteNamePath'
    post:
      operationId: testRemote
      tags: [Remotes]
      summary: Test a remote
      x-narad-since: v3.2.0
      description: |
        Runs the attach checks against `topic` on the remote, writing
        nothing on either cluster: the dial passes the address guard, TLS
        verifies against the remote's CA (or the system roots), the target
        answers `401` without credentials, the topic exists and is no delay
        child or remote child stub, its children include no remote child,
        the schemas match (with `source`), an empty batch produce is
        answered as a target that takes batch produce answers it, and the
        credential is not an admin there. With `remotes.allowed_hosts` set,
        every member runs the checks and reports the connect time
        (`rtt_ms`) and an estimate of one lane's capacity; without it only
        this node runs them, and its report carries only `node`, `result`,
        `class` and this cluster's own fields: no time, no target ID, no
        `target_serves_ids`, no certificate expiry, no warnings drawn from
        the target's answers.

        The answer is `200` whether or not the checks pass: read `result`.
        `narad remote test` exits non-zero unless it is `pass`.
      x-narad-grant: |
        `admin`, with security on.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TestRemoteRequest'
      responses:
        '200':
          description: The checks ran. `result` is `pass` only when every member passed at the remote's current credential version; otherwise `class` names the most important failure.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RemoteTestResult'
        '400':
          description: '`topic` is missing or not a topic name, or `source` is not one.'
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not an `admin`, or security is off (`remotes require security`).
        '404':
          description: No remote of that name, or no `source` topic of that name.
        '413':
          description: The body is over 128 KiB.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '429':
          description: A check of this remote ran less than 5 seconds ago on this node (or, with an allowlist, on a member); retry after `Retry-After`.
        '501':
          description: The answering node has no remote plane.
        '503':
          description: The cluster's members could not be listed; retry.
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X POST "$NARAD/v1/remotes/b/test" \
              -H "Content-Type: application/json" \
              -d '{"topic": "orders", "source": "orders"}'
          response: |
            HTTP/1.1 200 OK
            Cache-Control: no-store
            Content-Length: 361
            Content-Type: application/json
            Date: Tue, 06 Oct 2026 13:06:32 GMT

            {"remote":"b","result":"pass","checks":[{"node":"narad-0","result":"pass","credential_version":1,"target_id":"128e63dd156ff568","target_serves_ids":true,"rtt_ms":0,"lane_capacity_per_s":20000,"server_cert_not_after":"2026-11-05T13:04:25Z","warnings":[],"posture":{"security_enabled":true,"legacy_cluster_auth":false,"raft_tls":true,"api_hop_encrypted":true}}]}
  /v1/cluster/reencrypt-remotes:
    post:
      operationId: reencryptRemotes
      tags: [Remotes]
      summary: Re-encrypt remote passwords
      x-narad-since: v3.2.0
      description: |
        After a cluster secret rotation, re-seals every stored remote
        password that is still under the previous key: the leader opens it
        with `NARAD_CLUSTER_SECRET_PREVIOUS` and seals it under the current
        secret, bound to the same remote, URL, username and CA. A remote
        changed in between keeps its newer ciphertext. Safe to repeat.
        Steps: [Rotate the cluster secret](../operate/remotes.md#rotate-cluster-secret).
      x-narad-grant: |
        `admin`, with security on.
      requestBody:
        required: false
        description: Empty, or `{}`.
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        '200':
          description: 'Done. `reencrypted` names the remotes moved to the current key, `already_current` the ones that were, and `failed` the ones the leader could not open (`key_unknown`: sealed under a key neither secret derives, as when `NARAD_CLUSTER_SECRET_PREVIOUS` is not set on the leader; `open_failed`) or write, each with a `reason`.'
          x-narad-origin: forwarding
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReencryptResult'
        '400':
          description: The body is not empty or `{}`.
        '401':
          description: Missing or wrong credentials.
        '403':
          description: Not an `admin`, or security is off (`remotes require security`).
        '412':
          description: The leader's cluster secret is missing or decodes to fewer than 32 bytes, a member does not apply the remote Raft entry types, did not answer, or reports a posture that forbids remotes (the body names it in `members`), or the leader runs an older release. Nothing was re-sealed.
        '413':
          description: The body is over 128 KiB.
        '415':
          description: No accepted `Content-Type` and no `X-Narad-Client` header.
        '429':
          description: 'This node took 10 remote writes in the last minute; retry after `Retry-After`, the seconds until the oldest of them leaves the minute.'
        '501':
          description: The answering node has no remote plane.
        '503':
          description: 'The leader could not be reached; it is safe to repeat. A leader that could not be reached answers with `Retry-After: 2`. When the leader committed the change but the answering node could not confirm that its own copy applied it, `503` with `Retry-After: 2` and an error that says the change is committed: read it back after the delay, or on another node, and do not send it again.'
      x-narad-examples:
        - request: |
            curl -i -u "$AUTH" -X POST "$NARAD/v1/cluster/reencrypt-remotes" \
              -H "Content-Type: application/json"
          response: |
            HTTP/1.1 200 OK
            Cache-Control: no-store
            Content-Type: application/json
            X-Content-Type-Options: nosniff
            Date: Tue, 06 Oct 2026 13:07:49 GMT
            Content-Length: 88

            {"key_version":"b51c9412df29325d","reencrypted":[],"already_current":["b"],"failed":[]}
  /healthz:
    get:
      operationId: healthz
      tags: [Health and metrics]
      summary: Check liveness
      security: []
      description: |
        Answers `200` while the process runs, from the moment it starts,
        and `503` once a graceful shutdown has begun. It never needs
        credentials. It is also served on the metrics listener when
        `http.metrics_addr` is set.
      x-narad-grant: |
        None.
      responses:
        '200':
          description: The process is up.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Status'
        '503':
          description: The node is shutting down.
      x-narad-examples:
        - request: |
            curl -i "$NARAD/healthz"
          response: |
            HTTP/1.1 200 OK
            Content-Length: 16
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:44 GMT

            {"status":"ok"}
  /readyz:
    get:
      operationId: readyz
      tags: [Health and metrics]
      summary: Check readiness
      security: []
      description: |
        Answers `200` only while the node should receive traffic: its
        startup work is done, it has a Raft leader in view that it heard
        from within the last 5 seconds (or it is the leader), and its copy
        of the cluster metadata has caught up with the leader since it
        started. Otherwise it answers `503` with the reason in `error`.
        The check runs on every request. It never needs credentials.

        A `200` can list conditions under `degraded` (from v3.1.0): an
        expired Raft TLS certificate or CA bundle. They do not make the
        node unready, because one certificate usually serves every node
        and expires on all of them at once, and failing readiness would
        take every pod out of its Services. See
        [Raft TLS certificates](../operate/raft-tls.md#expiry).
      x-narad-grant: |
        None.
      responses:
        '200':
          description: Ready for traffic.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Readiness'
        '503':
          description: Not ready. The `error` field says why.
      x-narad-examples:
        - request: |
            curl -i "$NARAD/readyz"
          response: |
            HTTP/1.1 200 OK
            Content-Length: 19
            Content-Type: application/json
            Date: Mon, 28 Sep 2026 19:31:44 GMT

            {"status":"ready"}
  /metrics:
    get:
      operationId: metrics
      tags: [Health and metrics]
      summary: Scrape metrics
      description: |
        The Prometheus text exposition; every series is in the
        [Metrics reference](metrics.md). Where it is served depends on
        `http.metrics_addr`:

        - Not set (the binary's default): on the API port, and it needs
          the same credentials as the API unless
          `http.metrics_unauthenticated` is `true`.
        - Set (the Helm chart sets `:9100`): on that listener without
          credentials, and not on the API port, where a request gets
          `404` (or `401` without credentials).
      x-narad-grant: |
        Any valid credentials on the API port; none on the metrics
        listener.
      responses:
        '200':
          description: The exposition.
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Missing or wrong credentials on the API port.
      x-narad-examples:
        - request: |
            curl -sS -u "$AUTH" "$NARAD/metrics" | grep '^narad_topics_total'
          response: |
            narad_topics_total 2
components:
  securitySchemes:
    basicAuth:
      type: http
      scheme: basic
  parameters:
    TopicPath:
      name: topic
      in: path
      required: true
      description: Name of the topic.
      schema:
        type: string
    ParentPath:
      name: parent
      in: path
      required: true
      description: Name of the parent topic.
      schema:
        type: string
    ChildPath:
      name: child
      in: path
      required: true
      description: Name of the child topic.
      schema:
        type: string
    RemoteNamePath:
      name: name
      in: path
      required: true
      description: The remote's name.
      schema:
        type: string
    UsernamePath:
      name: username
      in: path
      required: true
      description: The username.
      schema:
        type: string
  schemas:
    CreateTopicRequest:
      type: object
      required: [name]
      additionalProperties: false
      properties:
        name:
          type: string
          maxLength: 200
          description: |
            1 to 200 characters from `A-Z a-z 0-9 . _ -`, not `.` or `..`,
            and not differing from an existing topic's name only in letter
            case (`409`). Topics an earlier release created with names up to
            255 characters keep working.
        partitions:
          type: integer
          minimum: 3
          description: |
            At least 3 and at most the server's maximum (108 by default).
            Defaults to the server default (3), or to the parent's count
            when `parent` is set. It can grow later, never shrink. Both
            server settings are in the
            [Configuration reference](configuration.md#topic-defaults).
        retention_ms:
          type: integer
          description: |
            How long a message is kept after it is written, whether or not
            it was consumed. At least 3,600,000 (1 hour), or `0` to keep
            messages forever; a negative value gets `400`. Left out, it
            takes the server default
            ([Configuration reference](configuration.md#topic-defaults)):
            7 days in the binary, 12 hours in the Helm chart.
        visibility_timeout_ms:
          type: integer
          default: 30000
          description: How long a consumer's lease lasts before the message is delivered again. Defaults to the server default ([Configuration reference](configuration.md#topic-defaults)). Fixed after creation.
        max_in_flight_per_partition:
          type: integer
          default: 1024
          description: Most messages of one partition leased at once. At the cap, consumes find nothing new in that partition until a lease ends. Defaults to the server default ([Configuration reference](configuration.md#topic-defaults)).
        max_acked_ahead_per_partition:
          type: integer
          default: 1024
          description: |
            Most acks one partition holds above its oldest unacked message.
            At the cap the partition delivers no new messages until that
            message is acked. Defaults to the server default
            ([Configuration reference](configuration.md#topic-defaults)).
        schema:
          description: A JSON Schema every message must match; registered as version 1. A JSON object or `true`.
        parent:
          type: string
          description: |
            Create the topic as a fan-out child of this existing topic. Its
            partitions are placed on other nodes than the parent's where the
            cluster allows, which is what makes a
            [replica child](../operate/backups.md#replica-children).
        fanout_delay_ms:
          type: integer
          description: |
            With `parent`, make the topic a delay child that receives each
            message this long after the parent committed it. At most
            31,536,000,000 (one year). Attaching an existing topic takes
            `delay_ms` instead.
        owner:
          type: string
          description: Ignored. The server sets the owner to the caller.
    AlterTopicRequest:
      type: object
      additionalProperties: false
      properties:
        retention_ms:
          type: integer
          description: New retention. At least 3,600,000 (1 hour), or `0` to keep messages forever; a negative value gets `400`. To return to the server default, send its value.
        max_in_flight_per_partition:
          type: integer
          description: New in-flight cap; `0` sets the server default. A cap left out keeps its value.
        max_acked_ahead_per_partition:
          type: integer
          description: New acked-ahead cap; `0` sets the server default. A cap left out keeps its value.
        partitions:
          type: integer
          description: New partition count, larger than the current one and at most the server's maximum (108 by default). New keys may then map to other partitions.
        schema:
          description: |
            A new schema version, checked for compatibility with the current
            one. Sending the current schema again changes nothing and
            answers `200`. A schema cannot be removed (`null` gets `400`).
        schema_base_version:
          type: integer
          description: With `schema`, apply it only if the current version is exactly this number; `409` otherwise.
    Topic:
      type: object
      properties:
        name:
          type: string
          description: Topic name.
        id:
          type: string
          description: The [incarnation](glossary.md#incarnation) ID, 16 hex characters. A topic deleted and created again under the same name gets a new one. Absent on a topic created before v2.2.0.
        partitions:
          type: integer
          description: Partition count.
        retention_ms:
          type: integer
          description: Retention in milliseconds; `0` keeps messages forever.
        visibility_timeout_ms:
          type: integer
          description: Lease length in milliseconds.
        max_in_flight_per_partition:
          type: integer
          description: In-flight cap per partition.
        max_acked_ahead_per_partition:
          type: integer
          description: Acked-ahead cap per partition.
        created_at:
          type: integer
          description: Creation time, Unix seconds.
        owner:
          type: string
          description: The user that created the topic. Absent when security was off.
        role:
          type: string
          enum: [standalone, parent, child]
          description: Fan-out role. Absent in the answer to a create without `parent`, which means `standalone`.
        children:
          type: array
          items:
            type: string
          description: A parent's children, in attach order.
        parent:
          type: string
          description: A child's parent.
        attach_epoch:
          type: string
          description: A child's current attachment; it changes on every attach.
        fanout_delay_ms:
          type: integer
          description: A delay child's delay in milliseconds.
        attach_offsets:
          type: array
          items:
            type: integer
          description: A child's attach point, one offset per parent partition.
        remote:
          $ref: '#/components/schemas/RemoteLink'
    RemoteLink:
      type: object
      x-narad-since: v3.2.0
      description: A [remote child](glossary.md#remote-child)'s link. Present only on a remote child's stub, which has `partitions` `0` and no owner.
      properties:
        name:
          type: string
          description: The remote the copies go to.
        topic:
          type: string
          description: The topic on the remote.
        target_id:
          type: string
          description: The target topic's ID as the attach, or the last resume with `accept_target`, saw it. A target on v3.1.0 serves it in its describe answer, so recreate detection works there too. Empty for a target topic created before topic IDs (v2.1 and earlier); such a link stops in `target_replaced` if the target later reports an ID, because a topic gains one only by being recreated.
        from:
          type: string
          enum: [attach, unconsumed, earliest]
          description: Where the link started on each parent partition.
        lanes:
          type: integer
          description: Ordered streams per parent partition, 1 to 8.
        paused:
          type: boolean
          description: '`true` while paused. Absent otherwise.'
        pause_reason:
          type: string
          description: The reason given to pause.
        paused_by:
          type: string
          description: The admin who paused it; shown to admins only.
        paused_at_ms:
          type: integer
          description: When it was paused, Unix milliseconds.
        skip:
          type: object
          additionalProperties:
            type: array
            items:
              type: integer
          description: Per parent partition, the offsets an admin accepted to lose, ascending, at most 4000. A cursor drops a record only while it is stuck on exactly one of them.
        created_by:
          type: string
          description: The admin who attached it; shown to admins only.
    TopicDetails:
      allOf:
        - $ref: '#/components/schemas/Topic'
        - type: object
          properties:
            schema_version:
              type: integer
              description: Current schema version, `0` without a schema.
            schema:
              description: The current schema document. Absent without a schema.
            partition_stats:
              type: array
              description: One entry per partition, or only the one `partition` asked for.
              items:
                $ref: '#/components/schemas/PartitionStats'
            partial:
              type: boolean
              x-narad-since: v3.1.0
              description: '`true` when some entries are placeholders whose owner could not report (`status` `owner_unavailable`). Absent otherwise.'
    PartitionStats:
      type: object
      properties:
        index:
          type: integer
          description: Partition number.
        segments:
          type: integer
          description: Segment files on disk.
        oldest_offset:
          type: integer
          description: Lowest offset still kept.
        next_offset:
          type: integer
          description: Offset the next record will get. It can lead `high_watermark` while a commit runs.
        high_watermark:
          type: integer
          description: One past the last offset consumers can see.
        size_bytes:
          type: integer
          description: Bytes on disk.
        oldest_segment_at:
          type: integer
          description: Time of the oldest segment, Unix seconds. Absent when unknown.
        owner_node:
          type: string
          description: ID of the node that owns the partition.
        status:
          type: string
          enum: [ok, owner_unavailable]
          x-narad-since: v3.1.0
          description: '`ok` when the statistics are the owner''s; `owner_unavailable` for a placeholder with zero statistics, because the owner could not report them. Never add a placeholder''s numbers to a total.'
        owner_liveness:
          type: string
          enum: [dead, unreachable, unknown, unassigned]
          x-narad-since: v3.1.0
          description: 'Why an `owner_unavailable` partition''s owner could not report: `dead` (marked dead), `unreachable` (alive, but its statistics did not come back within 2 seconds), `unknown` (no member with an address), `unassigned` (no owner yet). Absent for `ok`.'
    TopicPage:
      type: object
      properties:
        topics:
          type: array
          description: Topics the caller can read.
          items:
            $ref: '#/components/schemas/Topic'
        next_page_token:
          type: string
          description: Pass as `page_token` for the next page; empty when there are no more.
    SchemaHistory:
      type: object
      properties:
        topic:
          type: string
          description: Topic name.
        version:
          type: integer
          description: Current version, `0` without a schema.
        versions:
          type: array
          description: Every version, oldest first.
          items:
            type: object
            properties:
              version:
                type: integer
                description: Version number, from 1.
              schema:
                description: The schema document.
    AttachChildRequest:
      type: object
      required: [child]
      additionalProperties: false
      properties:
        child:
          type: string
          description: Name of the existing topic to attach.
        delay_ms:
          type: integer
          description: Make the child a delay child that receives each message this long after the parent committed it. At most one year. Fixed while attached.
        remote:
          type: string
          x-narad-since: v3.2.0
          description: Create `child` as a remote child that sends to this remote. `child` must not exist yet.
        remote_topic:
          type: string
          x-narad-since: v3.2.0
          description: With `remote`, the topic on the remote. Defaults to the parent's name.
        from:
          type: string
          enum: [attach, unconsumed, earliest]
          default: attach
          x-narad-since: v3.2.0
          description: With `remote`, where the link starts on each parent partition. `attach` at the parent's committed high watermark, as a local child; `unconsumed` at the parent's consumer ack frontier, so everything not yet acked here is sent; `earliest` at the oldest retained record.
        lanes:
          type: integer
          minimum: 1
          maximum: 8
          default: 1
          x-narad-since: v3.2.0
          description: With `remote`, ordered streams per parent partition. A key always uses one lane; more lanes help a link with a long round trip.
        dry_run:
          type: boolean
          default: false
          x-narad-since: v3.2.0
          description: With `remote`, run every check and resolve the start offsets, and write nothing.
    ChildList:
      type: object
      properties:
        parent:
          type: string
          description: Parent topic name.
        parent_id:
          type: string
          x-narad-since: v3.2.0
          description: The parent's incarnation ID, empty for a topic created before topic IDs (v2.1 and earlier). A remote child on another cluster that sends to this topic reads it to notice a recreate.
        children:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
                description: Child topic name.
              delay_ms:
                type: integer
                description: The child's delay, `0` for an immediate child.
              lag_messages:
                type: integer
                description: Parent messages not yet copied into the child, summed over partitions. For a remote child, not yet accepted by the remote.
              lag_complete:
                type: boolean
                description: '`false` while some partitions have not reported, so `lag_messages` is a lower bound.'
              remote:
                $ref: '#/components/schemas/RemoteLink'
              paused:
                type: boolean
                x-narad-since: v3.2.0
                description: A remote child only. `true` while it is paused.
              state:
                type: string
                x-narad-since: v3.2.0
                description: A remote child only. Its worst partition's [link state](remote-children.md#link-states), `running` or `paused` when healthy; `unknown` when a partition owner did not report, or when its owner is still stuck on a record this node shows as skipped. `running` and `paused` follow the pause flag as the node answering has applied it, so a read straight after a pause or resume agrees with it even while an owner has not caught up.
              lag_seconds:
                type: number
                x-narad-since: v3.2.0
                description: 'A remote child only. Age, on the owners'' clocks, of the oldest parent record not yet accepted by the remote, the worst partition''s: the link''s live recovery point.'
              retention_headroom_seconds:
                type: number
                x-narad-since: v3.2.0
                description: A remote child only. The parent's retention minus `lag_seconds`, the time left before drop-behind. Absent when the parent keeps messages forever.
              source_drained:
                type: boolean
                x-narad-since: v3.2.0
                description: A remote child only. `true` once the parent's consumers have acked past the link's start offset on every partition.
              blocked_at:
                type: object
                x-narad-since: v3.2.0
                description: A remote child only. The one record a cursor is stuck on, as `partition`, `offset` and `state` (`rejected_record` or `record_too_large`); `null` when none is.
              target_verified_at:
                type: string
                x-narad-since: v3.2.0
                description: A remote child only. The oldest of the cursors' last successful target checks, RFC 3339; `null` while any cursor has had none (a node whose checks keep failing is not hidden behind another node's success).
              unverified:
                type: boolean
                x-narad-since: v3.2.0
                description: A remote child only. `true` for a running link with a cursor whose node has had no successful target check in the last 10 minutes (counted from when the node began checking, for a cursor that never had one).
              last_success_at:
                type: string
                x-narad-since: v3.2.0
                description: A remote child only. When the remote last accepted a chunk, RFC 3339.
              partitions:
                type: array
                x-narad-since: v3.2.0
                description: A remote child only, with `partitions=true`. One row per parent partition.
                items:
                  type: object
                  properties:
                    partition:
                      type: integer
                      description: Parent partition.
                    node:
                      type: string
                      description: The owner that reported the cursor.
                    start_offset:
                      type: integer
                      description: Where the link started on this partition.
                    next_offset:
                      type: integer
                      description: The cursor's next offset; `null` when the owner did not report.
                    high_watermark:
                      type: integer
                      description: The partition's high watermark; `null` when the owner did not report.
                    ack_frontier:
                      type: integer
                      description: The parent's consumer ack frontier on this partition; `null` when unknown.
                    state:
                      type: string
                      description: This partition's link state.
                    last_success_at:
                      type: string
                      description: When the remote last accepted a chunk from this partition.
                    blocked_at:
                      type: object
                      description: The record this partition's cursor is stuck on, if any.
    Message:
      type: object
      description: 'One message. A batch consume answers `{"messages": [...]}` with one of these per message.'
      properties:
        topic:
          type: string
          description: Topic name.
        partition:
          type: integer
          description: Partition the message is stored in.
        offset:
          type: integer
          description: Position in the partition.
        key:
          type: string
          description: The produce key. Absent for a message produced without one.
        key_encoding:
          type: string
          enum: [base64]
          x-narad-since: v3.1.0
          description: '`base64` when the key is not valid UTF-8 and `key` holds it in base64.'
        payload:
          description: |
            The message as it was produced. Valid JSON comes back as JSON,
            other UTF-8 text as a JSON string, and anything else as a base64
            string with `payload_encoding`.
        payload_encoding:
          type: string
          enum: [base64]
          description: '`base64` when `payload` holds binary data in base64.'
        timestamp:
          type: integer
          description: When the message was committed to its partition, Unix seconds.
        receipt_handle:
          type: string
          description: The lease to settle with [ack](#ack), `partition:offset:nonce`. Absent on a replay.
    ProduceBatchRequest:
      type: object
      required: [messages]
      additionalProperties: false
      properties:
        messages:
          type: array
          description: 1 to 1,000 messages (from v3.2.0; 1 to 100 in v3.1.0), stored in this order.
          items:
            type: object
            required: [payload]
            additionalProperties: false
            properties:
              payload:
                description: |
                  A JSON value, stored exactly as written (a JSON string
                  keeps its quotes). With `payload_encoding`, a base64
                  string of any bytes.
              payload_encoding:
                type: string
                enum: [base64]
                description: Set to `base64` for a payload that is not JSON.
              key:
                type: string
                description: The message's key. Absent or empty means no key.
              key_encoding:
                type: string
                enum: [base64]
                description: Set to `base64` for a key that is not valid UTF-8.
              partition:
                type: integer
                minimum: 0
                description: Pin the message to this partition.
    PauseRemoteChildRequest:
      type: object
      additionalProperties: false
      properties:
        reason:
          type: string
          description: Why, shown in the listing and the audit line. At most 256 bytes of printable text.
    ResumeRemoteChildRequest:
      type: object
      additionalProperties: false
      properties:
        accept_target:
          type: boolean
          default: false
          description: Accept a target topic that was deleted and recreated since the attach (state `target_replaced`), and send to it.
    SkipRemoteRecordRequest:
      type: object
      required: [partition, offset]
      additionalProperties: false
      properties:
        partition:
          type: integer
          minimum: 0
          description: The parent partition of the stuck record.
        offset:
          type: integer
          minimum: 0
          description: Its offset, as `blocked_at` shows it.
    RemoteLimits:
      type: object
      additionalProperties: false
      description: A remote's limits. Each applies per node and changes live; a field left out keeps its value (its default on a create).
      properties:
        max_in_flight:
          type: integer
          minimum: 1
          maximum: 256
          default: 16
          description: Requests to the remote in flight at once on each node, shared by every cursor that sends to it.
        request_timeout_ms:
          type: integer
          minimum: 5000
          maximum: 120000
          default: 30000
          description: Timeout of one request to the remote.
        idle_conn_timeout_ms:
          type: integer
          minimum: 1000
          maximum: 300000
          default: 30000
          description: How long an idle connection to the remote is kept.
        conn_max_age_ms:
          type: integer
          minimum: 10000
          maximum: 3600000
          default: 300000
          description: How often the connections are replaced, busy ones included (each closes once its request ends), so a DNS change or a load balancer scale-out is picked up.
        check_interval_ms:
          type: integer
          minimum: 10000
          maximum: 3600000
          default: 60000
          description: How often each node re-checks the target of each link, with records to send or not (with 20% jitter).
        compression:
          type: string
          enum: [none, zstd]
          default: none
          description: '`zstd` compresses a chunk when that saves at least 10% and the target decodes zstd; otherwise it goes uncompressed.'
    CreateRemoteRequest:
      type: object
      required: [name, url, username, password]
      additionalProperties: false
      properties:
        name:
          type: string
          description: A lowercase letter, then up to 62 lowercase letters, digits or `-`.
        url:
          type: string
          description: The remote cluster's `https` URL, such as its ingress. Stored in a canonical form.
        username:
          type: string
          description: The replicator user on the remote. Give it `produce` on the replicated topics only; an admin credential fails the checks.
        password:
          type: string
          writeOnly: true
          description: That user's password, 24 to 72 bytes. Write-only.
        ca_pem:
          type: string
          description: 1 to 16 PEM certificates, at most 64 KiB, that alone verify the remote. Left out, the system roots do.
        limits:
          $ref: '#/components/schemas/RemoteLimits'
    UpdateRemoteRequest:
      type: object
      additionalProperties: false
      description: Name at least one field. A new `url`, `username` or `ca_pem` needs `password` too.
      properties:
        url:
          type: string
          description: A new `https` URL.
        username:
          type: string
          description: A new replicator user.
        password:
          type: string
          writeOnly: true
          description: A new password, 24 to 72 bytes.
        ca_pem:
          type: string
          description: A new CA bundle, or `""` for the system roots.
        limits:
          $ref: '#/components/schemas/RemoteLimits'
    Remote:
      type: object
      description: A remote as the API shows it. It never holds the password.
      properties:
        name:
          type: string
          description: The remote's name.
        id:
          type: string
          description: An ID minted at create; a remote created again under the same name gets a new one.
        url:
          type: string
          description: The canonical URL.
        username:
          type: string
          description: The replicator user on the remote.
        password:
          type: object
          description: 'What can be said about the password: `fingerprint` (keyed, so it reveals nothing without the cluster secret), `set_at`, `set_by` and `key_version`, the key it is sealed under.'
        credential_version:
          type: integer
          description: Moves on every new password or re-encrypt.
        ca_pem_sha512:
          type: string
          description: SHA-512 of the CA bundle. Absent when the system roots verify the remote.
        limits:
          $ref: '#/components/schemas/RemoteLimits'
        revision:
          type: integer
          description: Moves on every change.
        created_at:
          type: string
          description: RFC 3339.
        created_by:
          type: string
          description: The admin who created it.
        links:
          type: array
          items:
            type: string
          description: The remote children that use it, as `parent/child`. Absent in a write's answer.
        nodes:
          type: array
          description: What each member's credential cache holds for the remote. Absent with `nodes=false` and in a write's answer.
          items:
            type: object
            properties:
              node:
                type: string
                description: Member ID.
              state:
                type: string
                enum: [ready, stale, credential_unreadable, node_insecure, missing, unknown]
                description: '`ready`; `stale` while it holds an older credential version than the record; `credential_unreadable` when it cannot open the password (a secret it does not have); `node_insecure` when its posture forbids remotes; `missing` when it holds no entry yet; `unknown` when it did not answer, with `last_error` `unreachable` or `old_release`.'
              credential_version:
                type: integer
                description: The credential version it decrypted.
              key_version:
                type: string
                description: The key that version was sealed under.
              fingerprint:
                type: string
                description: The fingerprint of the password it holds.
              last_ok_at:
                type: string
                description: When it last reached the remote successfully.
              last_error:
                type: string
                description: The class of its last failure toward the remote, `none` when there was none. Never text from the remote.
              server_cert_not_after:
                type: string
                description: When the remote's certificate expires, as the last check saw it; only with `remotes.allowed_hosts` set.
              rtt_ms:
                type: integer
                description: Connect time to the remote; only with `remotes.allowed_hosts` set.
    RemoteList:
      type: object
      properties:
        allowlist:
          type: string
          enum: [set, none]
          description: Whether the answering node has `remotes.allowed_hosts` set.
        key:
          type: object
          description: The current encryption key's `key_version`, `first_sealed_at` and `age_seconds`. Absent before the first remote.
        remotes:
          type: array
          items:
            $ref: '#/components/schemas/Remote'
        lingering:
          type: array
          description: Deleted remotes some member still holds, with the members `holding` them and the ones `not_answering`.
          items:
            type: object
        not_answering:
          type: array
          description: Every member that was asked and did not answer, whether or not an answering member still holds a deleted remote. Such a member may still hold one.
          items:
            type: string
    TestRemoteRequest:
      type: object
      required: [topic]
      additionalProperties: false
      properties:
        topic:
          type: string
          description: The topic on the remote.
        source:
          type: string
          description: The parent topic on this cluster, for the schema, source and loop checks.
    RemoteTestResult:
      type: object
      properties:
        remote:
          type: string
          description: The remote.
        result:
          type: string
          enum: [pass, fail]
          description: '`pass` only when every member passed.'
        class:
          type: string
          description: When `result` is `fail`, the most important failure ([check classes](remote-children.md#check-classes)).
        checks:
          type: array
          description: One report per member that ran the checks.
          items:
            type: object
            properties:
              node:
                type: string
                description: Member ID.
              result:
                type: string
                enum: [pass, fail]
                description: This member's verdict.
              class:
                type: string
                description: Why it failed.
              credential_version:
                type: integer
                description: The credential version it checked with.
              target_id:
                type: string
                description: The target topic's ID.
              target_serves_ids:
                type: boolean
                description: '`false` for a target whose children listing serves no `parent_id` and no `remote` objects (v3.1.0): it cannot hold a remote child, so loop detection starts once it is upgraded; recreate detection reads the topic id from its describe answer. Absent without `remotes.allowed_hosts` (a blind report) and when the checks stopped before they read the target''s children listing.'
              rtt_ms:
                type: integer
                description: TCP connect time, with `remotes.allowed_hosts` set.
              lane_capacity_per_s:
                type: integer
                description: An estimate of one lane's records per second at that round trip, with `remotes.allowed_hosts` set.
              server_cert_not_after:
                type: string
                description: When the target's certificate expires, with `remotes.allowed_hosts` set.
              warnings:
                type: array
                items:
                  type: string
                description: Advisories, such as a certificate that expires within 14 days.
              posture:
                type: object
                description: The member's `security_enabled`, `legacy_cluster_auth`, `raft_tls` and `api_hop_encrypted`.
    ReencryptResult:
      type: object
      properties:
        key_version:
          type: string
          description: The current key.
        reencrypted:
          type: array
          items:
            type: string
          description: Remotes moved to the current key.
        already_current:
          type: array
          items:
            type: string
          description: Remotes already under it.
        failed:
          type: array
          description: Remotes not moved, each with `name` and `reason`.
          items:
            type: object
    ProduceBatchAccepted:
      type: object
      properties:
        accepted:
          type: integer
          description: How many messages were stored.
    AckBatchRequest:
      type: object
      required: [receipt_handles]
      additionalProperties: false
      properties:
        receipt_handles:
          type: array
          x-narad-since: v3.1.0
          description: 1 to 100 receipt handles of this topic.
          items:
            type: string
    AckBatchResults:
      type: object
      properties:
        results:
          type: array
          description: One result per handle, in request order.
          items:
            type: object
            properties:
              status:
                type: integer
                description: The status a single ack of this handle would have answered.
              error:
                type: string
                description: The error message, for a failed handle.
    Grant:
      type: object
      required: [action]
      properties:
        action:
          type: string
          enum: [produce, consume, create, admin]
          description: What the grant allows; see [Access model and grants](access-model.md#actions).
        patterns:
          type: array
          items:
            type: string
          description: Topic names or prefix wildcards such as `invoices.*`. Required for every action but `admin`, which takes none.
    CreateUserRequest:
      type: object
      required: [username, password]
      additionalProperties: false
      properties:
        username:
          type: string
          description: 1 to 64 characters from `A-Z a-z 0-9 . _ -`, not `.` or `..`.
        password:
          type: string
          description: 1 to 72 bytes. A character outside ASCII counts as more than one byte.
        grants:
          type: array
          description: What the user may do. Leave it out for a user with no grants.
          items:
            $ref: '#/components/schemas/Grant'
    UpdateGrantsRequest:
      type: object
      required: [grants]
      additionalProperties: false
      properties:
        grants:
          type: array
          description: The complete new list; it replaces the old one. An empty list removes every grant.
          items:
            $ref: '#/components/schemas/Grant'
    UpdatePasswordRequest:
      type: object
      required: [new_password]
      additionalProperties: false
      properties:
        new_password:
          type: string
          description: 1 to 72 bytes.
        current_password:
          type: string
          description: The user's current password. Required when a user who is not `admin` changes their own.
    User:
      type: object
      properties:
        username:
          type: string
          description: The username.
        grants:
          type: array
          description: The user's grants. Absent when it has none.
          items:
            $ref: '#/components/schemas/Grant'
        root:
          type: boolean
          description: '`true` for the root admin, which holds every right and cannot be deleted. Absent otherwise.'
        created_at_ms:
          type: integer
          description: Creation time, Unix milliseconds.
        updated_at_ms:
          type: integer
          description: Time of the last change, Unix milliseconds.
    MemberList:
      type: object
      properties:
        members:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                description: Node ID.
              addr:
                type: string
                description: The node's API address.
              status:
                type: string
                enum: [alive, dead]
                description: '`dead` once the node stopped sending heartbeats.'
              draining:
                type: boolean
                description: '`true` while the node is being decommissioned.'
              owned_partitions:
                type: integer
                description: Partitions the node owns.
              outbound_moves:
                type: integer
                description: Partitions moving off the node.
              voter:
                type: boolean
                x-narad-since: v3.1.0
                description: '`true` when the node is a Raft voter.'
              leader:
                type: boolean
                x-narad-since: v3.1.0
                description: '`true` for the Raft leader.'
              heartbeat_age_seconds:
                type: integer
                x-narad-since: v3.1.0
                description: Seconds since the leader last stamped the node's heartbeat, by the answering node's clock.
              decommission_blocked:
                type: array
                x-narad-since: v3.1.0
                description: For a draining node, every reason its decommission cannot progress that the cluster metadata shows. Absent when none. The leader also logs each reason and exports `narad_decommission_blocked`.
                items:
                  $ref: '#/components/schemas/DecommissionReason'
              node_status:
                $ref: '#/components/schemas/NodeStatus'
              status_error:
                type: string
                x-narad-since: v3.1.0
                description: With `detail=true`, why the node's own status could not be read.
    NodeStatus:
      type: object
      x-narad-since: v3.1.0
      description: With `detail=true`, the node's own report about itself.
      properties:
        node:
          type: string
          description: Node ID.
        draining:
          type: boolean
          description: The node's own view of its draining mark.
        produce_in_flight:
          type: integer
          description: Client produce requests the node admitted and has not answered yet. Once it is draining it admits none, and a decommission waits for 0.
        dispatch_backlog:
          type: integer
          description: Messages its ingress WAL accepted and has not yet handed to their partition owners. A decommission waits for 0.
        quarantine:
          type: object
          description: Partition copies the node set aside instead of deleting ([Troubleshooting](../operate/troubleshooting.md#quarantined-copies)).
          properties:
            copies:
              type: integer
              description: Every set-aside copy.
            bytes:
              type: integer
              description: Their total size.
            list:
              type: array
              description: The first 100 copies.
              items:
                type: object
                properties:
                  kind:
                    type: string
                    description: '`partition`, `topic_incarnation` or `staging`.'
                  topic:
                    type: string
                    description: Topic name.
                  partition:
                    type: integer
                    description: Partition number, `-1` for a whole topic directory.
                  dir:
                    type: string
                    description: The copy's directory on the node.
                  bytes:
                    type: integer
                    description: Its size.
                  mod_time:
                    type: string
                    description: When it was last modified, RFC 3339.
        moves:
          type: array
          description: The moves this node runs as the destination.
          items:
            $ref: '#/components/schemas/MoveWorker'
    MoveWorker:
      type: object
      x-narad-since: v3.1.0
      description: A destination's own report of one move.
      properties:
        topic:
          type: string
          description: Topic name.
        partition:
          type: integer
          description: Partition number.
        source:
          type: string
          description: The node the copy comes from.
        target:
          type: string
          description: This node.
        started_at:
          type: string
          description: When the worker started, RFC 3339.
        phase:
          type: string
          enum: [copying, frozen, flip_pending, waiting_for_source, blocked]
          description: What the worker is doing.
        attempts:
          type: integer
          description: Copy attempts against a live source.
        last_error:
          type: string
          description: The last thing that failed.
        copied_bytes:
          type: integer
          description: Bytes the current copy fetched.
        blocked:
          type: string
          enum: [copy_unverifiable, source_dead_copy_behind]
          description: Why the move cannot finish on its own. Absent while it can.
    DecommissionReason:
      type: object
      x-narad-since: v3.1.0
      properties:
        code:
          type: string
          enum: [below_min_voters, no_healthy_majority, move_target, dispatch_backlog, node_status_unavailable, no_receivers, owner_dead, move_budget_full, leader_transfer]
          description: The reason, as `narad_decommission_blocked` labels it ([Troubleshooting](../operate/troubleshooting.md#decommission-blocked)).
        message:
          type: string
          description: What it means here and what to do.
    DecommissionRefusal:
      type: object
      x-narad-since: v3.1.0
      properties:
        error:
          type: string
          description: The first reason, in a sentence.
        reasons:
          type: array
          items:
            $ref: '#/components/schemas/DecommissionReason'
    DecommissionDryRun:
      type: object
      x-narad-since: v3.1.0
      properties:
        member:
          type: string
          description: Node ID.
        would_decommission:
          type: boolean
          description: '`true` when a decommission would be accepted.'
        reasons:
          type: array
          description: Why it would be refused; empty when it would not.
          items:
            $ref: '#/components/schemas/DecommissionReason'
        voter:
          type: boolean
          description: '`true` when the node is a Raft voter.'
        owned_partitions:
          type: integer
          description: Partitions the node owns, all of which would move off it.
        inbound_moves:
          type: integer
          description: Moves aimed at the node; a decommission clears them.
    MoveList:
      type: object
      properties:
        moves:
          type: array
          items:
            $ref: '#/components/schemas/Move'
    Move:
      type: object
      properties:
        topic:
          type: string
          description: Topic name.
        partition:
          type: integer
          description: Partition number.
        from:
          type: string
          description: Node that owns the partition now.
        to:
          type: string
          description: Node the partition is moving to.
        from_status:
          type: string
          enum: [alive, dead, draining, not_a_member]
          x-narad-since: v3.1.0
          description: The owner's liveness.
        to_status:
          type: string
          enum: [alive, dead, draining, not_a_member]
          x-narad-since: v3.1.0
          description: The destination's liveness.
        blocked:
          type: string
          enum: [source_dead, target_dead, target_not_member]
          x-narad-since: v3.1.0
          description: Why the move cannot progress as things stand. Absent while it can. A dead source finishes only if the destination can force-promote a complete copy; the leader clears a move to a dead destination after two minutes.
        worker:
          $ref: '#/components/schemas/MoveWorker'
        worker_error:
          type: string
          x-narad-since: v3.1.0
          description: With `detail=true`, why the destination's report could not be read.
    MoveAbort:
      type: object
      x-narad-since: v3.1.0
      properties:
        move:
          $ref: '#/components/schemas/Move'
        note:
          type: string
          description: What happens next.
    ForgottenServer:
      type: object
      properties:
        id:
          type: string
          description: The Raft server ID that was removed.
        voter:
          type: boolean
          description: '`true` when it was a voter, `false` for a non-voter.'
    Status:
      type: object
      properties:
        status:
          type: string
          description: '`ok` for `/healthz`, `ready` for `/readyz`.'
    Readiness:
      type: object
      properties:
        status:
          type: string
          description: Always `ready`.
        degraded:
          type: array
          x-narad-since: v3.1.0
          items:
            type: string
            enum: [raft_tls_certificate_expired, raft_tls_ca_expired]
          description: |
            Present only when something is wrong that does not make the
            node unready: `raft_tls_certificate_expired` when the node's
            Raft TLS certificate has expired, `raft_tls_ca_expired` when
            every CA in its Raft CA bundle has. Peers refuse new Raft
            connections until the node restarts with renewed files.
