beads

GitHub

Beads - A memory upgrade for your coding agent

26,266 stars Go #agents#claude-code#coding
Raw JSON API

beads Specification

Located in internal/httpapi/spec/openapi.v0.yaml on branch HEAD

3.x (YAML) YAML 472.6 KB
Raw YAML Specification
openapi: 3.0.3

# Hand-written. This document is the source of truth for the bd serve wire
# contract: the Go types in internal/httpapi/apigen are generated FROM it
# (`make api-gen`), never the other way round.
#
# CODEGEN TRAP, verified against oapi-codegen v2.6.0 and the reason the pinned
# schemas below repeat their property lists instead of composing with `allOf`:
# a component that carries BOTH `allOf` and `x-go-type` silently loses the
# `x-go-type` — the generator merges the allOf members first and then either
# inherits the first member's pin (an `IssueWithCounts` composed from `Issue`
# generated `type IssueWithCounts = types.Issue`, dropping the count fields the
# ready endpoint exists to carry) or emits a hand-written-struct mirror of the
# canonical type. Both outcomes are the "second wire struct" this whole design
# forbids, and both are silent. internal/httpapi/pinning.go holds compile-time
# assertions that fail the build if either ever happens again.
x-bd-source: spec-first

info:
  title: bd serve HTTP API
  version: 0.1.0
  license:
    name: MIT
  description: >-
    Loopback HTTP surface over the same work contract the `bd` CLI serves, for
    automation clients that would otherwise fork a `bd` subprocess per call.


    This document is spec-first and vendor-neutral: it names no product, no
    deployment and no downstream consumer, and the Go types under
    `internal/httpapi/apigen` are generated from it.


    `info.version` is the revision of THIS DOCUMENT. The wire contract version
    is the `/v0` path prefix, reported as `ContextResponse.api_version`.


    ## Media types


    Success bodies are `application/json; charset=utf-8`, with ONE exception:
    `watchEvents` answers `text/event-stream; charset=utf-8`, because its
    response is a stream held open for the life of a connection rather than a
    document. It is the only streaming operation here and the only non-JSON
    success body; its individual events carry JSON, and the schemas they carry
    are the ones the paged read already publishes.


    EVERY non-2xx body, from every route and from the middleware in front of
    them, is `application/problem+json` carrying the `Problem` schema — there is
    exactly one error shape on this surface, and `watchEvents` is no exception:
    it decides every refusal before opening its stream.


    ## Four 400s cut across operations, and are documented here once


    * **Host header.** A request whose `Host` header is not in the server's
      allowlist is refused with `400` / `code: invalid_argument` /
      `param: "Host"` by middleware that runs before any handler.

    * **Unknown query parameter.** Every operation rejects any query parameter
      outside its own parameter table with `400` / `code: invalid_argument` /
      `reason: "unknown_parameter"` and `param` naming the offending key.
      Operations that declare no QUERY parameter at all reject every query key
      the same way. Today that is twenty-five of the forty-one operations
      here — `GET /healthz`, `GET /v0/beads/context`,
      `GET /v0/beads/dependencies/cycles`, `POST /v0/beads/issues`,
      `PATCH /v0/beads/issues/{id}`,
      `POST /v0/beads/issues/{id}/comments`,
      `POST /v0/beads/issues/{id}:claim`,
      `POST /v0/beads/issues/{id}:release`,
      `POST /v0/beads/issues/{id}:close`,
      `POST /v0/beads/issues/{id}:reopen`,
      `POST /v0/beads/issues/{id}:casMetadata`, `POST /v0/beads/issues:sweep`,
      `POST /v0/beads/issues:delete`,
      `POST /v0/beads/issues:batchCreate`,
      `POST /v0/beads/issues:batchApply`,
      `POST /v0/beads/issues:batchClose`, `GET /v0/beads/config`,
      `GET /v0/beads/config/{key}`, `PUT /v0/beads/config/{key}`,
      `DELETE /v0/beads/config/{key}`, `POST /v0/beads/dependencies:add`,
      `POST /v0/beads/dependencies:remove`, `POST /v0/beads/memories`,
      `GET /v0/beads/memories/{key}` and `DELETE /v0/beads/memories/{key}`.
      A path parameter is not a query parameter: several of those carry
      `{id}` or `{key}` and still take no query key. The authority is each
      operation's own `parameters` table, which is what a generated client
      reads; this list is a convenience and, being prose, is the half that can
      go stale — six entries were missing from it before this revision.

    * **Request media type.** Every operation that carries a request body
      requires `Content-Type: application/json` and refuses anything else with
      `400` / `code: invalid_argument` / `param: "Content-Type"` /
      `reason: "invalid_value"`. The refusal is a CSRF control rather than
      pedantry: a JSON content type is not CORS-simple, so a cross-origin write
      always triggers a preflight this server never approves, and accepting
      `text/plain` or a form encoding would let an attacker's page skip that
      preflight and drive a write from any browser on the host. It is
      unreachable for a client generated from this document — every
      `requestBody` here declares `application/json` — and is stated so that
      the one 400 such a client could not otherwise predict is predictable.
      `415` is deliberately not used: it is not in this surface's status
      vocabulary, and adding one for this would be permanent wire surface.

    * **Project-id stamp.** A request MAY carry a `Bd-Project-Id` header naming
      the workspace the client means to address. When the header is present and
      its value is not the project this server serves, the request is refused
      with `400` / `code: invalid_argument` / `param: "Bd-Project-Id"` /
      `reason: "project_mismatch"`, and that refusal — alone on this surface —
      carries the server's own project id in the `server_project_id` member so a
      stamped client can tell a wrong-server refusal from a malformed one. The
      comparison is LITERAL: a server whose own `project_id` is empty refuses
      every non-empty stamp, because it cannot assert it is the workspace the
      client named. An ABSENT header is the backward-compatible path — a client
      that never sends it is served exactly as before, and enforcement triggers
      only when the header arrives — so this is additive wire surface, not a new
      precondition on requests already in the field. The refusal is raised after
      the Host gate and before any database work, so a misdirected request
      mutates nothing. It is skipped on `GET /healthz` and `GET /v0/beads/context`
      alone: liveness must answer whatever workspace a caller believed it
      reached, and the identity handshake is where a client LEARNS the project
      id to stamp with. A client detects that a server performs this check by
      the `project.enforce` capability (see `ContextResponse`).


    The first two are uniform rules reachable on EVERY route including
    `GET /healthz`; the third holds on every body-carrying operation; the fourth
    holds on every route EXCEPT the two exempt reads named above. All four are
    stated here instead of being repeated on every operation. The per-operation
    `responses` below therefore list what an operation produces BEYOND them: a
    generated client must treat `400 invalid_argument` as possible everywhere,
    including on the operations whose documented statuses are only
    `404`/`500`/`503`.


    ## Authentication is a deployment posture


    Bearer authentication is CONFIGURED PER DEPLOYMENT, not implied by this
    document. A server started without a token file requires no credential and
    never emits `401`; a server started with one requires
    `Authorization: Bearer <token>` on every operation that declares the
    `bearerToken` scheme below — which is all of them except `GET /healthz`, so
    that a liveness probe can answer with no credential. `GET
    /v0/beads/context` is NOT exempt: it reveals the repository root, the beads
    directory and the database name.


    The refusal is `401` / `code: unauthenticated`, with `WWW-Authenticate:
    Bearer`. Its `detail` is a fixed string and NEVER echoes the presented
    credential — a missing header, a wrong scheme and an unrecognized token are
    one code deliberately, because distinguishing them would tell an
    unauthenticated caller which guess was closer.


    The `404` for a path this document does not define needs no credential:
    paths are public spec, so refusing them first would disclose nothing and
    hide nothing.


    A token ROTATES by rewriting the token file, with no restart: every
    non-empty line is an accepted token, so an operator writes the new token
    alongside the old, rolls clients over, then removes the old one. Both the
    addition and the removal take effect within about a second.


    There is NO TLS on this surface. A deployment beyond loopback is expected
    to supply confidentiality itself — a service mesh, or a trusted network
    boundary — because otherwise both the token and the issue data travel in
    plaintext.


    The unknown-parameter rule is deliberate and load-bearing: silently
    ignoring an unrecognized FILTER parameter widens the result set, so a
    client one version ahead of the server would receive — and act on — rows it
    believed it had filtered out. Strict rejection is also a client's only
    per-parameter capability probe, since `capabilities` (see
    `ContextResponse`) is operation-level.


    ## Version skew: what a client may gate on


    * `capabilities` gates OPERATION presence, and also advertises server-wide
      BEHAVIOR tokens such as `project.enforce` (per-request `Bd-Project-Id`
      enforcement). It is derived from the server's implemented handlers plus the
      behaviors this build enforces; check the list, never the version string.
    * `bd_version` gates BEHAVIORAL changes tied to a release, and is the only
      field a client may compare as a version.
    * `api_version` gates the path major, and changes only when `/v1` is cut.
    * Parameter presence is PROBED, not versioned: a 400 with
      `reason: "unknown_parameter"` is the machine-attributable answer.
    * `schema_version` is NOT an HTTP gate. It is shared with the CLI's stdout
      JSON envelope, so it can move for CLI-only reasons with no HTTP wire
      change. Report it, log it, never branch on it.
    * Clients MUST default-branch on unknown `code` and `reason` values within
      a status class: both vocabularies grow additively, so an exhaustive
      switch breaks on the first addition.


    ## An `operationId` is not derivable from its path


    A custom method usually reads the same in both — `:claim` is `claimIssue`,
    `:sweep` is `sweepIssues` — and TWO OPERATIONS INVERT IT. Neither is a
    typo and neither will be renamed, because an `operationId` is a generated
    client's method name and a path is wire surface, so both are one-way doors:


    | Path | `operationId` | Capability token |
    |---|---|---|
    | `POST /v0/beads/issues:batchApply` | `applyBatch` | `issues.batchApply` |
    | `POST /v0/beads/issues/{id}:casMetadata` | `compareAndSetMetadata` | `issues.casMetadata` |


    The capability token follows the PATH in both cases, not the
    `operationId`, so a client probing `capabilities` before calling a
    generated method reads two different spellings of one operation. A spec
    author adding an operation should spell them alike; a spec author touching
    these two should leave both spellings exactly as they are.


    ## Schema evolution while `/v0` is the newest path version


    `Issue`, `IssueWithCounts`, `IssueDetails`,
    `IssueWithDependencyMetadata`, `TreeNode`, `Dependency`, `Comment`,
    `BondRef`, `Statistics`, `Cycle`, `CycleMember`, `IssueBlocking` and
    `EventRecord` are
    pinned to the canonical Go structs (`x-go-type`), so the CLI's `--json`
    output and these response bodies are one compatibility domain and cannot
    drift apart. Changes to them are limited to NEW optional fields, and each
    addition must land with its entry in this document in the same change — a
    two-way JSON-tag bijection test, covering all thirteen, fails CI otherwise.
    A breaking change to those shapes requires cutting `/v1`, not editing `/v0`.


    A member that is absent is not set. When reading a RESPONSE, clients MUST
    treat an explicit `null` member identically to an absent member. Producers
    on this surface omit absent members; a profile MAY instead emit `null` for
    members it declares always-present. Explicit `null` in a REQUEST body is
    NOT covered by this rule; its meaning is defined per operation.


    ## Profiles


    A PROFILE is a separate document that re-publishes part of this contract as
    its own surface. Conformance to this core is a checkable relation over
    wire-observable JSON — the profile's value model is subsumed by this one,
    plus the deviations the profile DECLARES — not a claim of shared
    vocabulary. Five rules make it checkable.


    * **Verbatim or absent.** A property emitted under a core name carries the
      core type, format and semantics, and its value is the canonical value
      verbatim or is wholly absent. Nothing is transformed in place: a
      re-spelling, a coercion to another JSON type, or a truncation, published
      under a core name, is non-conformant — and omitting the member is always
      the conformant alternative.

    * **An omission is not a licence to re-spell.** A profile that omits a core
      member MUST NOT publish that member's value under any other name.
      Omission means the value is not on the wire; an extension that carries
      it is a re-spelling of a core member, and is non-conformant however
      carefully the extension itself is declared.

    * **Tightening is bounds-only.** A profile MAY omit optional properties,
      and MAY tighten `maxLength`, `maxItems` and `maxProperties` or close an
      open vocabulary, provided every instance it emits is still valid against
      this document once explicit `null` is read as absence. That list is
      exhaustive and the proviso is a floor, not a grant: a change the list
      does not name is not made a tightening by the fact that its instances
      still validate. Widening a bound, re-typing a member, or extending a
      vocabulary this document closes is not tightening.

      **A narrowed value schema is a declared deviation.** Constraining what a
      member's VALUES may be — admitting only short strings, say, where this
      document admits any JSON value — is a third thing, and it is how a
      profile caps the size of a response without transforming anything: not
      one of the bounds above, and not the re-typing this rule excludes,
      because every value that does go on the wire is still the canonical
      value. It is a DEVIATION, conformant only where the profile DECLARES it,
      by the same machinery that declares an embedded-relation omission, and
      only on these terms. A value that does not satisfy the narrower schema is
      OMITTED ENTIRELY rather than coerced to fit; stringifying a typed value
      in place is a transformation, and "verbatim or absent" already forbids
      it. And the declaration states that the member MAY BE INCOMPLETE, because
      a consumer of the profile cannot distinguish an entry dropped for failing
      the narrower schema from one that was never set: that consequence must be
      visible in the declaration, not left to be discovered.

    * **Required members are retained.** A profile keeps the required members of
      every top-level resource. Dropping a required member of an EMBEDDED
      relation — a nested edge or summary carried inside another resource — is
      legal only where the profile DECLARES that omission and its reason. A
      silent omission never is.

    * **Extensions are declared, and their names ratchet.** A profile MAY add
      members of its own, provided each is marked as an extension in the
      profile's own document and its name is not one this document already
      defines. Because `/v0` grows by ADDING optional members, a later revision
      of this document may define a name a profile is already using: that
      collision is reconciled — the core member adopted, or the extension
      renamed — as part of adopting the revision, never left to resolve by
      itself.

paths:

  /healthz:
    get:
      operationId: health
      summary: Process liveness
      description: >-
        Liveness only: it answers from the process itself and never touches the
        database, so it stays green while the database is unreachable. For
        readiness, probe `GET /v0/beads/ready?limit=1` instead.
      responses:
        '200':
          description: The process is running.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Health'

  /v0/beads/context:
    get:
      operationId: getContext
      summary: Workspace and API identity
      description: >-
        A startup snapshot of the served workspace plus the API's own identity.
        Serves a fixed field allowlist; it does not reflect the server's whole
        configuration, and in particular never carries a sync remote URL (those
        routinely embed credentials). v0 answers from the snapshot without
        touching the database, which is why no 503 is documented here.
      security:
        - bearerToken: []
      responses:
        '200':
          description: Workspace and API identity.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContextResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'

  /v0/beads/ready:
    get:
      operationId: listReadyWork
      summary: List ready work
      description: >-
        Unblocked, open work, ordered by the requested sort policy. Items are
        `IssueWithCounts` — the same element type `bd ready --json` emits — so
        dependency, dependent and comment counts are present from v0 onward.


        The result set is always restricted to `status=open`; there is no
        status parameter. There is no cursor either: the sort policies admit no
        keyset predicate, and the intended usage is snapshot-and-requery.


        SOME TYPES ARE EXCLUDED BY DEFAULT, exactly as `bd ready` excludes them:
        `merge-request`, `gate`, `molecule`, `rig` and the workspace's
        configured infrastructure types, plus anything named in `exclude_type`.
        Setting `type` DROPS THAT ENTIRE EXCLUSION SET (also matching the CLI),
        so `type=molecule` returns records the default view never shows. A
        client scanning the active set with `limit=0` and no `type` therefore
        never sees those classes at all.
      parameters:
        - name: assignee
          in: query
          description: Only issues assigned to this actor.
          schema:
            type: string
        - name: unassigned
          in: query
          description: Only issues with no assignee.
          schema:
            type: boolean
        - name: type
          in: query
          description: >-
            Issue type. The only normalization is shorthand ALIAS expansion,
            exactly what `bd ready --type` does: `mr` → `merge-request`,
            `feat` → `feature`, `mol` → `molecule`, `enhancement` → `feature`,
            `dec`/`adr` → `decision`. Every other value is used as written —
            there is NO plural folding, so `bugs` is not `bug`.


            An unrecognized type is not an error here: the type vocabulary is
            workspace-configurable, and `bd ready` does not validate it either,
            so it simply matches nothing and `items` comes back empty. (The
            list operation differs — `bd list` DOES validate the type, so
            `GET /v0/beads/issues?type=bugs` is a 400.)


            When set, `exclude_type` is ignored, and so are the default type
            exclusions described above.
          schema:
            type: string
        - name: exclude_type
          in: query
          description: >-
            Issue types to exclude. Repeat the parameter, or pass a
            comma-separated list. Ignored when `type` is set.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: label
          in: query
          description: Labels that must ALL be present (AND).
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: label_any
          in: query
          description: Labels of which at least one must be present (OR).
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: exclude_label
          in: query
          description: Labels that must not be present.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: label_pattern
          in: query
          description: Glob matched against labels.
          schema:
            type: string
        - name: label_regex
          in: query
          description: Regular expression matched against labels.
          schema:
            type: string
        - name: priority
          in: query
          description: Exact priority (0 is a real value, not "unset").
          schema:
            type: integer
        - name: parent
          in: query
          description: Restrict to recursive descendants of this issue.
          schema:
            type: string
        - name: metadata_field
          in: query
          description: >-
            Top-level metadata equality filter as `key=value`, split on the
            first `=`. Repeatable. An invalid key is a 400.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: has_metadata_key
          in: query
          description: Only issues carrying this top-level metadata key.
          schema:
            type: string
        - name: include_ephemeral
          in: query
          description: Include ephemeral (non-synced) rows.
          schema:
            type: boolean
            default: false
        - name: include_deferred
          in: query
          description: Include issues whose `defer_until` is still in the future.
          schema:
            type: boolean
            default: false
        - name: sort
          in: query
          description: >-
            Ready-work ordering. `priority` is priority-first; `hybrid` orders
            recent issues by priority and older ones by age; `oldest` is
            creation order. An unrecognized value is a 400.


            The default is the one `bd ready --sort` registers, so a client
            swapping `bd ready --json` for this operation gets the same items in
            the same order. The storage layer treats an EMPTY policy as
            `hybrid`, but that fallback is unreachable from the CLI and is NOT
            this parameter's default: `hybrid` demotes older high-priority work,
            so defaulting to it would change the item SET as soon as `limit`
            truncates — silently, and only for the clients this API exists to
            migrate.
          schema:
            type: string
            enum: [hybrid, priority, oldest]
            default: priority
        - name: limit
          in: query
          description: >-
            Maximum number of items to return. `0` means unlimited, exactly as
            `bd ready --limit 0` does — the two surfaces read the same shared
            default and the same zero semantics, so they cannot diverge. A
            negative value is a 400.


            The default below is the shared ready-work limit constant
            (`workapi.DefaultReadyLimit`, the value `bd ready`'s `--limit` flag
            registers).


            One exception, and it is mode-dependent: when the server was
            started with `--allow-non-loopback`, `limit=0` is refused with 400
            `invalid_argument`, `param: "limit"`, `reason: "invalid_value"` and
            detail "unlimited reads are loopback-only; pass an explicit limit".
            An unlimited read buffers the whole active set and its JSON encoding
            inside one shared process, which must not be reachable by arbitrary
            network peers. The bind mode is deliberately NOT advertised in
            `ContextResponse` — a client that wants an unlimited read asks for
            one and, on that 400, re-issues with an explicit limit; it is a
            client-side fix, never a retry.
          schema:
            type: integer
            minimum: 0
            default: 100
        - name: brief
          in: query
          description: >-
            Omit the free-form text from every item: `description`, `design`,
            `acceptance_criteria`, `notes`, `payload` and `waiters` are not
            selected. Filtering is unaffected, because it selects rows and this
            selects fields. Default false, so the payload is unchanged for a
            client that does not ask.


            The response carries no marker for the omission, so an omitted
            field is indistinguishable from a genuinely empty one: only the
            client that sent this parameter knows the rows are partial. Fetch a
            whole issue with `GET /v0/beads/issues/{id}`.
          schema:
            type: boolean
            default: false
      security:
        - bearerToken: []
      responses:
        '200':
          description: A page of ready work.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReadyPage'
        '400':
          $ref: '#/components/responses/InvalidArgument'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/stats:
    get:
      operationId: getStats
      summary: Workspace summary statistics
      description: >-
        The counts `bd status` prints and its `bd stats` alias reprints: rows by
        status across the durable plane, plus the two DEPENDENCY-AWARE numbers —
        the blocked count and the readiness derived from it — that make this a
        different question from a filtered count.


        It is `stats` rather than `status` in the path deliberately. `status`
        on an HTTP surface reads as the SERVER's condition, which is `/healthz`;
        this operation answers about the WORKSPACE and touches the database to
        do it.


        THE WISP TIER IS NOT INCLUDED in the workspace-wide answer, so a
        workspace whose work lives in ephemeral rows reports zeros here.
        Supplying `assignee` changes that, along with three other definitions —
        see the parameter.


        THERE IS NO PREDICATE and there will not be one. A count of a set the
        caller describes is a different question with a different answer shape;
        this operation is the summary or it is nothing.
      parameters:
        - name: assignee
          in: query
          description: >-
            Answer for ONE ACTOR instead of the workspace. Used as written: no
            trimming, no case folding, no alias expansion. An empty value is a
            400 — the workspace-wide question is asked by OMITTING the
            parameter, and an empty assignee would otherwise select the rows
            with no assignee and report them as one actor's workload.


            IT CHANGES FOUR DEFINITIONS AT ONCE, and they are named on the
            `Statistics` properties rather than here: the set widens to include
            the ephemeral tier, `blocked_issues` counts the `blocked` STATUS
            instead of the dependency flag, `ready_issues` becomes the real
            ready-work count instead of a subtraction, and `pinned_issues` is
            always 0. An actor with no rows is a summary of zeros, not a 404.
          schema:
            type: string
        - name: skip_blocked
          in: query
          description: >-
            Ask for the answer WITHOUT the blocked-set scan, which is the
            expensive half of this query on a large workspace — the same request
            `bd status --no-blocked` makes.


            IT IS A HINT. When it is honored, `blocked_issues` and
            `ready_issues` are both null and `blocked_count_skipped` is true;
            when the backend has no cheaper path the full numbers come back and
            the flag is false. Nothing else in the summary changes either way,
            so a client that reads neither pointer cannot tell the difference.


            IGNORED when `assignee` is set: that answer computes both numbers by
            a route with no fast path, and it is not an error to ask.
          schema:
            type: boolean
            default: false
      security:
        - bearerToken: []
      responses:
        '200':
          description: The workspace summary.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StatsResponse'
        '400':
          $ref: '#/components/responses/InvalidArgument'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/dependencies/cycles:
    get:
      operationId: listDependencyCycles
      summary: List dependency cycles
      description: >-
        Every circular blocking dependency in the workspace, the same sweep
        `bd dep cycles` performs. Items are `Cycle` — the element type that
        command's `--json` emits — so the two surfaces carry one shape.


        THE ANSWER IS CANONICAL: each cycle's members are rotated so the lowest
        id comes first, and the cycles are sorted against each other, so two
        calls against an unchanged workspace return the same bytes. A client may
        diff two snapshots and read a difference as a real change.


        EDGES ARE NARROWER THAN THEY ARE AT WRITE TIME. The walk follows
        `blocks` and `conditional-blocks` only: `waits-for` is gate semantics, so
        a mutual wait is not a deadlock, and `parent-child` is walked by the
        refusal a dependency WRITE performs but not by this report. A workspace
        this operation calls clean can therefore still refuse an edge.


        Both dependency planes — durable and ephemeral — are one graph here, so
        a cycle that runs issue → wisp → issue is reported.


        THERE IS NO `limit` AND NO CURSOR, and `has_more` is therefore always
        false in v0. Truncating would shrink the count, and the count is the
        number an operator acts on; the response is bounded by the number of
        cycles in the workspace, which in a healthy one is zero. `has_more` is
        present so that adding a bound later is additive rather than a new
        envelope.
      security:
        - bearerToken: []
      responses:
        '200':
          description: Every dependency cycle, canonically ordered.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CyclesPage'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/dependencies/tree:
    get:
      operationId: getDependencyTree
      summary: Walk the dependency tree of one issue
      description: >-
        The dependency graph walked recursively from ONE root — the answer
        `bd dep tree` renders, and the same `TreeNode` elements that command's
        `--json` emits.


        THE ANSWER IS FLAT. Each element carries `depth` and `parent_id`, and a
        client rebuilds the shape from those two; the elements are in
        depth-first pre-order, so a subtree is contiguous.


        EVERY NODE APPEARS AT MOST ONCE PER WALK, at the depth and parent of the
        first path that reached it. That single rule is both the cycle policy
        and the diamond policy: a cycle TERMINATES rather than failing the call
        or being reported (`GET /v0/beads/dependencies/cycles` is where a cycle
        is an answer), and a shared subtree is shown under one parent only, with
        no option to show it twice.


        EDGES ARE WIDER THAN THEY ARE ON THE CYCLE REPORT. This walk follows
        every dependency type except `relates-to`, which is symmetric annotation
        — following it would make the "tree" the connected component. The cycle
        report follows `blocks` and `conditional-blocks` only, because it is
        about scheduling deadlock. Note that `related` and `relates-to` are two
        different types and only the second is excluded.


        Both dependency planes — durable and ephemeral — are one graph here, so
        an ephemeral step in the middle of a chain does not end the picture.


        A NODE THIS DATABASE CANNOT DESCRIBE ENDS THAT BRANCH. A `TreeNode` IS
        an issue, so there is no shape for "on the tree and undescribable": an
        edge whose target is an `external:` reference or an id in another
        repository's namespace contributes no node, and nothing in the answer
        says a branch stopped for that reason rather than because it ended. That
        is the one place this operation is less honest than the cycle report,
        whose `CycleMember` can carry a bare id.


        THERE IS NO `limit` AND NO CURSOR. The walk is bounded by `max_depth`
        instead, which bounds the DESCENT rather than truncating the answer;
        `has_more` is therefore always false in v0. There is also no `max_rows`:
        the CLI's defensive cap is a circuit breaker for a caller that would
        rather fail than wait, and refusing a whole answer is not something this
        surface offers a remote client.
      parameters:
        - name: root_id
          in: query
          required: true
          description: >-
            The issue to walk from. It must be an EXACT canonical issue id:
            there is no fuzzy, prefix or substring resolution on this surface,
            for the reason `GET /v0/beads/issues/{id}` gives. An empty value is a
            400 `invalid_argument`; a value that matches no issue and no wisp is
            a 404 `not_found`, because there is one anchor here and no other
            answer to preserve.
          schema:
            type: string
        - name: direction
          in: query
          description: >-
            Which way to follow edges. `down` (the default) walks what the root
            DEPENDS ON; `up` walks what depends ON it; `both` walks each way and
            returns one list.


            For `both` the two walks are independent and the answer is their
            concatenation: every up node except the root, then the whole down
            tree beginning with the root. The root appears once. The two halves
            may repeat a node between them — an issue that both blocks and is
            blocked by something in the other half — so a client aggregating
            `items` must not assume the ids are distinct. Both walks see ONE
            database state.


            Any other value is a 400 `invalid_argument`: the vocabulary is
            closed.
          schema:
            type: string
            enum: [down, up, both]
            default: down
        - name: max_depth
          in: query
          description: >-
            How many LEVELS to descend, counting the root as level one:
            `max_depth=1` is the root alone. A node beyond the bound is ABSENT
            rather than present and flagged.


            Zero and negative values are a 400 `invalid_argument` rather than
            "unbounded": the answer to an unbounded recursive walk on a large
            workspace is the request that takes the database down.
          schema:
            type: integer
            minimum: 1
            default: 50
        - name: status
          in: query
          description: >-
            Prune the walked tree to the nodes carrying this status AND the
            ancestor chain of each survivor, so the answer is still a tree.


            It is a POST-WALK PRUNE, not a filter on the walk, and the difference
            is observable: a matching node BEHIND a non-matching one is still
            reached, and the non-matcher is kept as its ancestor. A prune that
            matches nothing returns NO items at all, root included.


            The value is not checked against the workspace's status vocabulary;
            an unrecognized status simply matches nothing.
          schema:
            type: string
      security:
        - bearerToken: []
      responses:
        '200':
          description: The walked tree, flat, in depth-first pre-order.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DependencyTreePage'
        '400':
          $ref: '#/components/responses/InvalidArgument'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/ready:count:
    get:
      operationId: countReadyWork
      summary: Count ready work
      description: >-
        How many items `GET /v0/beads/ready` would return for the same filters
        with `limit=0`. That is an identity, not an estimate: the two
        operations answer from one role over one predicate
        (`issueops.ReadyCounter`), so a client may page the listing and print
        this number beside it — which is exactly what `bd ready` does when it
        reports "showing 100 of 412".


        THERE IS NO `limit` AND NO `sort` PARAMETER, and both absences are
        deliberate. A cardinality has no page: a limit here would answer "how
        many of the first N", which breaks the identity above, so the role
        refuses one rather than accepting it and dropping it. A cardinality has
        no order either — this operation counts the same set the listing
        returns under ANY sort policy — so there is nothing for a `sort`
        parameter to change and it is not published. Every other parameter of
        the listing is here and means exactly what it means there, including
        the default type exclusions and the way `type` drops them.


        It costs a query of its own. A client that only needs to know whether
        MORE work exists should read `has_more` on the listing instead; this
        operation is for the client that needs the number.
      parameters:
        - name: assignee
          in: query
          description: Only issues assigned to this actor.
          schema:
            type: string
        - name: unassigned
          in: query
          description: Only issues with no assignee.
          schema:
            type: boolean
        - name: type
          in: query
          description: >-
            Issue type, with the shorthand alias expansion and the
            match-nothing-rather-than-fail treatment `GET /v0/beads/ready`
            documents. Setting it drops the default type exclusions and makes
            `exclude_type` ignored, there and here alike.
          schema:
            type: string
        - name: exclude_type
          in: query
          description: >-
            Issue types to exclude. Repeat the parameter, or pass a
            comma-separated list. Ignored when `type` is set.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: label
          in: query
          description: Labels that must ALL be present (AND).
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: label_any
          in: query
          description: Labels of which at least one must be present (OR).
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: exclude_label
          in: query
          description: Labels that must not be present.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: label_pattern
          in: query
          description: Glob matched against labels.
          schema:
            type: string
        - name: label_regex
          in: query
          description: Regular expression matched against labels.
          schema:
            type: string
        - name: priority
          in: query
          description: Exact priority (0 is a real value, not "unset").
          schema:
            type: integer
        - name: parent
          in: query
          description: Restrict to recursive descendants of this issue.
          schema:
            type: string
        - name: metadata_field
          in: query
          description: >-
            Top-level metadata equality filter as `key=value`, split on the
            first `=`. Repeatable. An invalid key is a 400.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: has_metadata_key
          in: query
          description: Only issues carrying this top-level metadata key.
          schema:
            type: string
        - name: include_ephemeral
          in: query
          description: >-
            Include ephemeral (non-synced) rows, which the count merges exactly
            as the listing lists them.
          schema:
            type: boolean
            default: false
        - name: include_deferred
          in: query
          description: Include issues whose `defer_until` is still in the future.
          schema:
            type: boolean
            default: false
      security:
        - bearerToken: []
      responses:
        '200':
          description: The size of the ready set.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReadyCount'
        '400':
          $ref: '#/components/responses/InvalidArgument'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues:
    get:
      operationId: listIssues
      summary: List issues
      description: >-
        Issues under the same default exclusions `bd list` applies (closed and
        custom done/frozen statuses, templates, gates and configured infra
        types), each carrying dependency, dependent and comment counts.


        ORDERING IS FIXED AND DIVERGES FROM `bd list` DELIBERATELY. Every page
        of this operation — cursored or not — is ordered by
        `(created_at DESC, id ASC)`, while `bd list`'s default ordering is
        priority-first. The divergence is the price of a correct cursor: the
        cursor is a keyset position in the created order, so if the first page
        fell back to priority ordering the second page would skip and duplicate
        rows. That is also why v0 exposes no `sort` parameter — the sort order
        is welded to the cursor contract, and a new order needs new surface.


        The item set, and each item's JSON, are identical to `bd list --json`;
        only the order differs.


        EPHEMERAL ROWS ARE OUT OF SCOPE BY DEFAULT and are admitted by
        `include_ephemeral`, which merges that tier IN ADDITION to the durable
        one. The merged page is ordered by the same `(created_at DESC, id ASC)`
        key across both tiers as if they were one table, and `cursor` pages
        across the merge without skipping or repeating a row.


        `include_ephemeral` and `include_infra` are INDEPENDENT and compose:
        the first admits a tier, the second takes the infrastructure TYPE
        exclusions off (and admits the tier those types live in). Ephemeral
        rows of an infrastructure type therefore need `include_infra`.


        The CLI has no `--include-ephemeral` flag for `bd list` yet, so this is
        the one filter on this operation with no `bd list` spelling;
        `--include-infra` is the nearest one and is wider.


        THE ROWS CARRY NO `revision`, AND THE DETAIL READ DOES. A list-then-guard
        loop is a real shape, and the reason the token stops at
        `GET /v0/beads/issues/{id}` is not its size — measured against a 20k-row
        production export the member is 28 bytes on a 1449-byte median row,
        under 2%. It is that `IssueWithCounts` is ALSO THE INTERCHANGE ROW: it
        is the record `bd export` writes to JSONL and the auto-export flushes
        into a git-tracked `issues.jsonl`, and the token is re-minted by every
        write, so publishing it on this element would put a per-write-random
        value into a file whose whole value is that it diffs only when
        something meant something. That is the loss `Issue`'s own storage field
        is withheld from generic serialization to prevent, and a wire member
        cannot opt out of it — the element here and the element there are one
        pinned Go struct. `IssueDetails` has neither problem: nothing
        interchanges it, and it is assembled in exactly one place, which is
        also why its token cannot be silently 0 on some path that forgot to
        set it. A caller that has a list and wants a guard reads the rows it
        actually intends to write, one detail read each — the read it needs
        anyway to decide.


        THE DOOR, IF A LATER REVISION WANTS THE TOKEN HERE: it needs a list
        element that is NOT the interchange element. The exclusion above is a
        consequence of the two being ONE pinned Go struct, not a judgement that
        a per-row token is unwanted, so the way in is to separate them — and
        nothing short of that will do, because any member added to this element
        ships in `bd export`'s JSONL by construction. Until then a client MUST
        model the absent member as ABSENT and never as `0`: zero is a real
        token here (a legacy row backfilled and not mutated since), so a client
        that defaulted a missing `revision` to 0 would compose guards that
        match exactly the rows it is most dangerous to be wrong about.
      parameters:
        - name: status
          in: query
          description: >-
            Status filter. Repeat the parameter, or pass a comma-separated
            list. Custom statuses configured for the workspace are honored.


            Setting this REPLACES the default status exclusions rather than
            fighting with them: `status=closed` on its own returns closed
            issues, and does NOT also need `all=true`. Leave it unset to get
            the default view described above.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: type
          in: query
          description: >-
            Issue type. Shorthand aliases are expanded exactly as
            `bd list --type` expands them (`mr`, `feat`, `mol`, `enhancement`,
            `dec`, `adr`); there is no plural folding. Unlike on `ready`, an
            unknown type is a 400 `invalid_argument` with
            `reason: "invalid_value"`, because `bd list` validates the type
            against the workspace's configured vocabulary and this operation
            keeps that behavior.
          schema:
            type: string
        - name: assignee
          in: query
          description: Only issues assigned to this actor.
          schema:
            type: string
        - name: label
          in: query
          description: Labels that must ALL be present (AND).
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: label_any
          in: query
          description: Labels of which at least one must be present (OR).
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: exclude_label
          in: query
          description: Labels that must not be present.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: parent
          in: query
          description: Restrict to recursive descendants of this issue.
          schema:
            type: string
        - name: all
          in: query
          description: >-
            Drop the default status exclusions (closed plus any custom statuses
            in the done/frozen categories). Redundant when `status` is set,
            which drops them already.
          schema:
            type: boolean
            default: false
        - name: include_templates
          in: query
          description: Include template molecules.
          schema:
            type: boolean
            default: false
        - name: include_gates
          in: query
          description: Include gate issues.
          schema:
            type: boolean
            default: false
        - name: include_infra
          in: query
          description: >-
            Include the workspace's configured infrastructure issue types. This
            also admits the ephemeral plane those types live in, so it is
            strictly wider than `include_ephemeral`.
          schema:
            type: boolean
            default: false
        - name: include_ephemeral
          in: query
          description: >-
            Include the ephemeral tier — ephemeral rows and the non-synced
            rows stored beside them — merged into the same
            `(created_at DESC, id ASC)` order as the durable ones.


            It admits a TIER and takes no TYPE exclusion off, so a row whose
            type this operation already hides stays hidden. That includes the
            configured infrastructure types: ephemeral `agent`, `role` and
            `message` rows need `include_infra` as well as, or instead of,
            this one. What `include_ephemeral` alone reaches is the ephemeral
            rows of the types a listing already shows.
          schema:
            type: boolean
            default: false
        - name: created_before
          in: query
          description: Only issues created strictly before this instant (RFC 3339).
          schema:
            type: string
            format: date-time
        - name: created_after
          in: query
          description: Only issues created strictly after this instant (RFC 3339).
          schema:
            type: string
            format: date-time
        - name: metadata_field
          in: query
          description: >-
            Top-level metadata equality filter as `key=value`, split on the
            first `=`. Repeatable. An invalid key is a 400.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: has_metadata_key
          in: query
          description: Only issues carrying this top-level metadata key.
          schema:
            type: string
        - name: cursor
          in: query
          description: >-
            Opaque keyset position, taken verbatim from a previous response's
            `next_cursor`. Clients MUST NOT construct, parse or mutate it: its
            encoding is server-private and versioned, and an undecodable or
            unknown-version value is refused with 400 `invalid_cursor`. The
            recovery for that refusal is normative: restart paging with no
            `cursor` at all — the position cannot be salvaged, and re-sending
            the same value cannot succeed.


            LIFETIME: a cursor holds a position and a private encoding version,
            and nothing else. The server keeps no state for it, so it does not
            expire, does not become invalid when the server restarts, and is
            not tied to the connection that issued it; the only thing that
            invalidates one is a change to the encoding, which surfaces as
            `invalid_cursor`.


            MISUSE IS NOT DETECTABLE, which is why repeating the filters
            matters. Because the token carries no filters, a page fetched with
            a cursor minted under DIFFERENT filters is not refused: the server
            applies the filters of the current request from the position of the
            old one, silently skipping every row the new filter set would have
            placed before that position. Repeat every filter verbatim for the
            whole traversal, and start a new traversal when they change.
          schema:
            type: string
        - name: limit
          in: query
          description: >-
            Maximum number of items to return. `0` means unlimited, exactly as
            `bd list --limit 0` does — the two surfaces read the same shared
            default and the same zero semantics, so they cannot diverge. An
            unlimited page reports `has_more: false` and carries no
            `next_cursor`. A negative value is a 400.


            The default below is the shared list limit constant
            (`workapi.DefaultListLimit`, the value `bd list`'s `--limit` flag
            registers).


            One exception, and it is mode-dependent: when the server was
            started with `--allow-non-loopback`, `limit=0` is refused with 400
            `invalid_argument`, `param: "limit"`, `reason: "invalid_value"` and
            detail "unlimited reads are loopback-only; pass an explicit limit".
            An unlimited read buffers the whole active set and its JSON encoding
            inside one shared process, which must not be reachable by arbitrary
            network peers. The bind mode is deliberately NOT advertised in
            `ContextResponse` — a client that wants an unlimited read asks for
            one and, on that 400, re-issues with an explicit limit (and pages
            with `cursor`); it is a client-side fix, never a retry.
          schema:
            type: integer
            minimum: 0
            default: 50
        - name: brief
          in: query
          description: >-
            Omit the free-form text from every item: `description`, `design`,
            `acceptance_criteria`, `notes`, `payload` and `waiters` are not
            selected. Filtering is unaffected, because it selects rows and this
            selects fields. Default false, so the payload is unchanged for a
            client that does not ask.


            The response carries no marker for the omission, so an omitted
            field is indistinguishable from a genuinely empty one: only the
            client that sent this parameter knows the rows are partial. Fetch a
            whole issue with `GET /v0/beads/issues/{id}`.
          schema:
            type: boolean
            default: false
      security:
        - bearerToken: []
      responses:
        '200':
          description: A page of issues in `(created_at DESC, id ASC)` order.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IssuesPage'
        '400':
          description: >-
            Invalid request: an unknown query parameter, a malformed parameter
            value, or an undecodable/unknown-version `cursor`.
          x-bd-codes: [invalid_argument, invalid_cursor]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

    post:
      operationId: createIssue
      summary: Create one issue
      description: >-
        Creates one issue, with its parent, its explicit edges and its waits-for
        gate, as ONE transaction. A plain collection `POST` rather than a custom
        method, because creating one member of the collection the path names is
        what `POST` already means — the same argument `memories.remember` makes
        on its own collection.


        ## It publishes the whole create vocabulary


        Every member the role accepts and this surface publishes anywhere is
        here, and that is deliberate: `POST /v0/beads/issues:batchCreate` spells
        nine of them, which is what makes it unusable for a caller composing a
        real row — no `status`, no `sender`, no `metadata`, no `ephemeral`, no
        `no_history`, no `id`. This operation is the single-issue create with
        `ApplyCreateItem`'s vocabulary, and the two agree member for member
        except where a difference is stated below.


        THE EDGES ARE HERE, unlike on `issues:batchApply`. That operation splits
        edges into their own `dep_add` items so a plan's edge order is total;
        one create has no ordering to express, so `parent_id`, `dependencies`
        and `waits_for` ride on the request the way `bd create --parent`,
        `--deps` and `--waits-for` do. Every edge lands in the same transaction
        as the row: an edge this request could not write is a `400` and the
        issue is not created either.


        ## The explicit id


        `id` is CREATE-ONLY. An id that already names a stored row — on either
        plane — is a `409 already_exists` and nothing is written. It is never an
        adoption and never an overwrite: `PATCH /v0/beads/issues/{id}` acts on a
        row that already exists, and `bd import` is the upsert surface, which
        this API does not publish. Absent is the ordinary case and the server
        mints one.


        ## What this operation deliberately cannot set


        `created_at` and `created_by` — a create whose stored creation time and
        author come from the caller makes the row's own timestamp disagree with
        the journal entry that records it, and re-dating history is what an
        import is for. `issues:batchApply`'s create item publishes neither
        either.


        `spec_id`, `await_*`, `mol_type`, `wisp_type`, `work_type`,
        `storage_class`, `source_*`, `pinned`, `is_template` and the event
        quartet (`event_kind`, `actor`, `target`, `payload`) — workflow and
        classification plumbing this surface publishes on no operation, read or
        write. `PATCH /v0/beads/issues/{id}` says the same of `spec_id` and
        `await_id`.


        `comments` and an inline `dependencies` list on the issue itself — the
        role refuses both, because edges belong to the request's own
        `dependencies` member where their direction can be stated.


        ## Planes


        `ephemeral` and `no_history` create the row on the EPHEMERAL plane
        rather than the durable one, exactly as they do for `issues:batchApply`.
        They are mutually exclusive; sending both is a `400`. An edge between
        rows on opposite planes is refused with everything else the request
        asked for.


        ## Hooks and auto-commit


        Hooks do not fire and the per-command auto-commit machinery does not
        run, exactly as for `POST /v0/beads/issues/{id}:claim`. The only durable
        effect is the single storage commit the role makes in its own
        transaction.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateIssueRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            The created issue, hydrated with its labels and the edges this
            request wrote. It is the row as STORED — the minted id, the
            defaulted status and the persisted timestamps — never the request
            reflected back.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Issue'
        '400':
          description: >-
            Invalid request: an unknown query parameter, an unparseable or
            oversized body, an unknown body member at any level, an `actor` that
            is empty after trimming, longer than 256 bytes or carrying control
            characters, a blank or missing `title`, a member carrying the wrong
            JSON type, an explicit `null` on any member, a value outside its
            documented bounds, `ephemeral` together with `no_history` — or a
            value this workspace's own validation refuses, such as an
            `issue_type` or `status` outside its configured vocabulary, an
            OMITTED `issue_type` (see that member), an `id` outside its
            configured prefix, an edge from the new issue to itself, the same
            pair named twice by two members, or a dependency, parent or
            waits-for target that names nothing this workspace holds.


            A DEPENDENCY TARGET THAT NAMES NOTHING IS A `400`, NOT A `404`,
            conforming to `POST /v0/beads/dependencies:add` and
            `POST /v0/beads/issues:batchCreate`: an edge describes a relation
            rather than a resource this request was asked to address, and this
            operation names no id in its path to have missed. Nothing is created
            in any of these cases.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '409':
          description: >-
            The request is well-formed and the STATE refuses it, and NOTHING WAS
            WRITTEN.


            `already_exists` is an explicit `id` that already names a stored row.
            `param` is `id`. There is no force bypass and no upsert: the
            identical body succeeded before the id was taken and would succeed
            against a workspace that never took it, which is why this is a `409`
            rather than a `400` — recovery is to look at the state (adopt the row
            with `PATCH`, choose another id, or stop) rather than to fix a
            malformed request.


            `dependency_cycle` is the graph refusing the edges this request
            asked for, spelled exactly as `POST /v0/beads/dependencies:add`
            spells it and covering both of that operation's refusals: a
            scheduling cycle, and a blocking edge against the new issue's own
            ancestor or descendant. The hierarchy case — and only it —
            additionally carries `issue_id`, `blocker_id` and
            `blocker_is_ancestor`, so member presence is the discriminator.
            Neither has a force bypass.


            BOTH ARMS ARE REACHABLE HERE because this operation writes BLOCKING
            edges and hierarchy in one transaction: `parent_id` places the new
            row under a parent whose ancestry the caller cannot see, and
            `dependencies[]` may name a blocking edge against that ancestry —
            while `dependencies[].reverse` writes an edge INTO the id being
            minted, which is the only way a create can close a scheduling cycle.
            `PATCH /v0/beads/issues/{id}` publishes no blocking-edge member, so
            its `dependency_cycle` never carries the hierarchy members; the
            discriminator is meaningful here and absent there.
          x-bd-codes: [already_exists, dependency_cycle]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues:query:
    get:
      operationId: queryIssues
      summary: Query issues with a boolean expression
      description: >-
        The `bd query` expression language over HTTP: field comparisons
        combined with `AND`, `OR`, `NOT` and parentheses. It is the one
        operation on this surface that takes a DISJUNCTION — every filter
        parameter of `GET /v0/beads/issues` narrows the answer, and no
        combination of them expresses `type=bug OR label=urgent`.


        IT IS NOT A SQL PASSTHROUGH, and the shape of the language is what
        makes that true rather than a promise about validation: there are no
        table names, no joins and no way to name a column the vocabulary does
        not publish. The vocabulary is the one `bd query --help` documents, and
        an expression outside it is a 400 rather than an empty page.


        EVERY MATCH IS IN SCOPE, which has not always been true and is the
        reason this operation exists in the shape it does. An expression the
        storage filter cannot express is answered by evaluating the predicate
        over every candidate row and then cutting the page, so `has_more` means
        what it says. The CLI used to bound that scan at a few hundred rows and
        filter what came back, which silently dropped matches from an `OR`
        query and reported the result as complete. The cost is stated rather
        than hidden: a broad expression over a large workspace is a large read,
        and `limit` bounds the RESPONSE rather than the scan.


        THERE IS NO CURSOR AND NO `offset`. A cursor is a keyset position in a
        database order, and the matching set of a predicate query is assembled
        outside the database, so there is no position to encode; `offset` is
        absent because the two database sources this server can be built on
        disagree about whether they can honor one, and a parameter that works
        under one deployment topology and refuses under another is worse on a
        wire than an absent one. Raise `limit`, or narrow the expression.
      parameters:
        - name: q
          in: query
          required: true
          description: >-
            The query expression, e.g. `status=open AND priority>1` or
            `type=bug OR label=urgent`. Blank, unparseable, or naming a field
            or operator the language does not have is a 400 `invalid_argument`
            with `param: "q"` and `reason: "invalid_value"`.


            Relative date terms (`created>7d`) are resolved against the
            SERVER's clock at the moment the request is served.
          schema:
            type: string
        - name: all
          in: query
          description: >-
            Include closed issues. Without it closed issues are excluded —
            unless the expression itself compares `status`, in which case the
            expression's own opinion stands and this parameter changes nothing.
          schema:
            type: boolean
            default: false
        - name: sort
          in: query
          description: >-
            Display order for the page. Absent leaves the rows in the order the
            query returned them, which is the same thing `bd query` without
            `--sort` does.


            WHAT IT ORDERS is the rows the query bounded, exactly as on the
            CLI: for an expression the database answered under `limit`, the
            page; for a predicate expression, which the database cannot bound,
            the whole matching set.
          schema:
            type: string
            enum: [priority, created, updated, closed, status, id, title, type, assignee]
        - name: reverse
          in: query
          description: Invert the display order. Ignored when `sort` is absent.
          schema:
            type: boolean
            default: false
        - name: limit
          in: query
          description: >-
            Maximum number of items to return. `0` means unlimited, exactly as
            `bd query --limit 0` does — the two surfaces read the same shared
            default (`workapi.DefaultQueryLimit`, the value `bd query --limit`
            registers) and the same zero semantics. A negative value is a 400.


            The same mode-dependent refusal the issue listing carries applies
            here: under `--allow-non-loopback`, `limit=0` is refused with 400
            `invalid_argument`, `param: "limit"`, `reason: "invalid_value"`.
          schema:
            type: integer
            minimum: 0
            default: 50
      security:
        - bearerToken: []
      responses:
        '200':
          description: A page of the issues the expression matched.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QueryPage'
        '400':
          description: >-
            Invalid request: an unknown query parameter, a malformed parameter
            value, or an expression this server cannot parse or evaluate.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues:count:
    get:
      operationId: countIssues
      summary: Count issues matching a predicate
      description: >-
        How many issues match, and — with `group_by` — how many in each bucket.
        It is the operation behind `bd count`, and it answers from
        `issueops.Counter` over the same predicate that command builds.


        ## It is not `GET /v0/beads/issues` with the page taken off


        That is the difference to internalize before using it, because the two
        answer about DIFFERENT SETS by default. A listing hides closed, pinned,
        template and gate rows; a count hides NONE of them. An empty request
        here counts every durable row this workspace holds, closed rows
        included, and it is the ROLE that decides that — the same decision
        `bd count` has always made — not a default this operation applies.


        Nor is it a listing with paging removed at the type level: there is no
        `limit`, no `offset` and no `cursor`, and the role refuses the first two
        rather than accepting them and dropping them. A cardinality is a number
        about a set; bounding the scan would answer "how many of the first N",
        which is the shape that makes a caller believe `limit=10` bounded the
        answer.


        There is no free-text `q` either. The count seam takes one and both of
        this role's front doors have always passed the empty string, so it is
        left off rather than published untested. `title`, `title_contains`,
        `desc_contains` and `notes_contains` are the substring matches that ARE
        reachable.


        ## One operation, two shapes of one answer


        `group_by` selects the bucketed form. It is a parameter rather than a
        second operationId because the role is one role born with two methods,
        for a reason this document inherits rather than re-decides: the two ask
        the SAME predicate of the SAME set and differ only in whether the answer
        is one number or a number per bucket. The grouped response is the scalar
        response PLUS `groups` — the same schema, with one member that appears
        when you ask for it — so there is no second contract here to hide under
        one id.


        That is the opposite of `GET /v0/beads/events:watch`, which is a sibling
        operation rather than a mode of the paged read: those two differ in media
        type, lifetime, limits and capacity, and one operation carrying both
        would have documented two of everything. Here nothing differs but one
        optional response member.


        ## `total` is not the sum of `groups`


        For four of the five dimensions it happens to be. For `label` it is not,
        and that is why the role computes it rather than leaving a client to add
        the buckets up: LABEL BUCKETS OVERLAP. An issue carrying three labels is
        one row in `total` and one row in each of three buckets, so a client that
        summed them would report a workspace three times its size.


        The two numbers are NOT promised to describe one snapshot. The
        store-backed implementation runs the scalar and the grouped query
        separately, so a concurrent write between them can leave them
        disagreeing by that write. Nothing here is transactional across the two.


        ## Planes


        A count is DURABLE-PLANE ONLY unless `include_infra` is set. The wisps
        tier — ephemeral wisps and the `no_history` beads that are durable work
        stored in that tier — is not counted by default, and `include_infra`
        changes FOUR things at once rather than one. See that parameter; it is
        the one place on this operation where a single flag moves the set in
        more than one direction.
      parameters:
        - name: status
          in: query
          description: >-
            One stored status. The empty value and the literal `all` both mean
            EVERY status, which is what makes a bare count answer for closed
            rows as well as open ones.


            IT IS ONE STATUS, NOT A COMMA-SEPARATED SET, and that is the one
            parameter name this operation shares with `GET /v0/beads/issues`
            while meaning something narrower — that one takes a comma-separated
            OR set. It is stated here rather than quietly reconciled, because a
            client that assumed otherwise would read a plausible number instead
            of an error.


            It is NOT validated against this workspace's configured vocabulary.
            An unrecognized name matches nothing and the answer is `0`, not a
            refusal — the shipped behavior of `bd count`, published rather than
            tightened, because a scripted caller counting a status its workspace
            has since dropped currently reads 0 and would otherwise start
            reading an error.
          schema:
            type: string
        - name: type
          in: query
          description: >-
            One issue type, with `status`'s match-nothing-rather-than-fail
            treatment and NO shorthand alias expansion at all — unlike
            `GET /v0/beads/ready`'s `type`, which expands aliases.


            It has a SECOND effect under `include_infra`: a type this workspace
            calls infra routes the count to the ephemeral tier. See that
            parameter.
          schema:
            type: string
        - name: assignee
          in: query
          description: >-
            Only issues assigned to this actor. Sending it beside
            `no_assignee` is not refused: the two are handed to the filter as
            written and answer with the empty intersection, which is `0`.
          schema:
            type: string
        - name: priority
          in: query
          description: >-
            Exact priority. Absent means unfiltered — the distinction matters
            because `0` is a real priority, which is why the role models this
            as a pointer.
          schema:
            type: integer
        - name: priority_min
          in: query
          description: Lowest priority to count, inclusive.
          schema:
            type: integer
        - name: priority_max
          in: query
          description: Highest priority to count, inclusive.
          schema:
            type: integer
        - name: label
          in: query
          description: >-
            Labels that must ALL be present. Repeat the parameter. Entries are
            trimmed and de-duplicated inside the role, and a set whose entries
            are all blank is the same as an unset one.
          schema:
            type: array
            items:
              type: string
        - name: label_any
          in: query
          description: >-
            Labels of which at least ONE must be present. Repeat the parameter;
            same normalization as `label`.
          schema:
            type: array
            items:
              type: string
        - name: title
          in: query
          description: Case-insensitive substring match on the title.
          schema:
            type: string
        - name: id
          in: query
          description: >-
            A comma-separated id set to restrict the count to. Splitting,
            trimming and de-duplication happen inside the role, so a caller
            passes the string it was given rather than a slice it had to
            prepare.
          schema:
            type: string
        - name: title_contains
          in: query
          description: >-
            Substring match on the title, spelled as `GET /v0/beads/issues:query`
            spells it. It overlaps `title` deliberately: both reach the filter,
            and the two names exist because the role carries both fields.
          schema:
            type: string
        - name: desc_contains
          in: query
          description: Substring match on the description.
          schema:
            type: string
        - name: notes_contains
          in: query
          description: Substring match on the notes.
          schema:
            type: string
        - name: created_after
          in: query
          description: RFC 3339. Only issues created strictly after this instant.
          schema:
            type: string
            format: date-time
        - name: created_before
          in: query
          description: RFC 3339. Only issues created strictly before this instant.
          schema:
            type: string
            format: date-time
        - name: updated_after
          in: query
          description: RFC 3339.
          schema:
            type: string
            format: date-time
        - name: updated_before
          in: query
          description: RFC 3339.
          schema:
            type: string
            format: date-time
        - name: closed_after
          in: query
          description: >-
            RFC 3339. Counting closed rows needs no `status` beside it: a bare
            count already includes them.
          schema:
            type: string
            format: date-time
        - name: closed_before
          in: query
          description: RFC 3339.
          schema:
            type: string
            format: date-time
        - name: empty_description
          in: query
          description: Only issues with no description.
          schema:
            type: boolean
            default: false
        - name: no_assignee
          in: query
          description: Only issues with no assignee.
          schema:
            type: boolean
            default: false
        - name: no_labels
          in: query
          description: Only issues carrying no label.
          schema:
            type: boolean
            default: false
        - name: include_infra
          in: query
          description: >-
            Count the cardinality of `bd list --include-infra --all` instead of
            the durable plane. IT CHANGES FOUR THINGS AT ONCE, and they are
            listed rather than summarized because a caller reading "include
            infra" would expect one:


            the ephemeral wisps tier is MERGED IN, picking up both wisps and the
            `no_history` beads that are durable work stored in that tier;
            template molecules are EXCLUDED, which a default count includes;
            gate beads are EXCLUDED unless `type=gate` asks for them by name;
            and a `type` this workspace calls infra ROUTES the count to the
            ephemeral tier instead of the durable one.


            The infra vocabulary is the WORKSPACE's, read from its configuration
            inside the role. A caller does not supply it and cannot — that
            config load is what this role exists to keep off both front doors.


            Unset, the count is durable-plane only and applies none of the four:
            the historical `bd count` answer, kept exactly so a scripted caller
            reads the same number it read yesterday.
          schema:
            type: boolean
            default: false
        - name: group_by
          in: query
          description: >-
            Bucket the count by one dimension and return `groups` beside
            `total`. Absent, the response carries `total` alone.


            The set is CLOSED, and a value outside it is a `400` rather than an
            empty answer: a caller that misspelled a dimension and got zero
            buckets back has no way to tell that from a workspace with nothing
            in it. That is the role's own rule, applied at the edge here so the
            refusal names the parameter.


            Bucket KEYS are normalized and printed unmodified by every front
            door, so the normalization is part of this contract: a priority
            bucket is `P` followed by the number (`P1`); the assignee bucket for
            unassigned rows is `(unassigned)`, never the empty string, which
            would be indistinguishable from a stored empty assignee; the label
            bucket for rows carrying no label at all is `(no labels)`, and it is
            ABSENT rather than zero when every matching row has one; `status`
            and `type` buckets are the stored value verbatim.
          schema:
            type: string
            enum: [status, priority, type, assignee, label]
      security:
        - bearerToken: []
      responses:
        '200':
          description: The size of the matching set, and its buckets when asked for.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IssueCount'
        '400':
          description: >-
            Invalid request: an unknown query parameter, a repeated
            single-valued one, a malformed boolean, integer or RFC 3339 instant,
            or a `group_by` outside the closed set.


            EVERY REFUSAL HERE IS THE TRANSPORT'S. Unlike the listings, this
            operation has no predicate its library surface can turn down: an
            unrecognized `status` or `type` matches nothing and answers `0`, and
            the one refusal the count role does make — an unknown bucketing
            dimension — is refused at the edge above, so it never reaches the
            role at all.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues/{id}:
    get:
      operationId: getIssue
      summary: Get one issue with its labels, dependencies and counts
      description: >-
        Returns a single object, never an array. The id must be the exact
        canonical issue id: there is no fuzzy, prefix or substring resolution
        on this surface, so a lookup can never resolve to a different issue
        than the caller named. Wisp (ephemeral) records are looked up as a
        fallback when no issue matches.


        COMMENT BODIES AND DEPENDENTS ARE ASKED FOR, NEVER VOLUNTEERED. They
        are the two expensive row lists on this read, so each is absent unless
        the request sets the parameter that populates it, and a caller that
        wants only the cardinalities reads `comment_count`, `dependent_count`
        and `comments_omitted` and pays for neither. A request that sets
        neither parameter is answered exactly as this operation answered it
        before the parameters existed.


        IT IS THE TOKEN SOURCE FOR A GUARDED WRITE. The response carries
        `revision`, the row's optimistic-concurrency token, so a
        read-modify-write loop starts HERE: read the row, decide from what it
        says, and send the token back as the next request's `expected_version`.
        Before this member every token on the surface was minted by a WRITE, so
        the first guarded write of a loop had to be preceded by a write the
        caller did not want to make — or guarded on nothing at all, which is
        the lost-update this whole family exists to refuse. The token is
        carried on every 200, including for an ephemeral wisp resolved by the
        fallback lookup.
      parameters:
        - $ref: '#/components/parameters/IssueID'
        - name: include_comments
          in: query
          description: >-
            Populate `comments` with the issue's full comment bodies. When it
            is honored `comments_omitted` is false, so a client is never left
            to guess whether an absent list means "no comments" or "not asked
            for".
          schema:
            type: boolean
            default: false
        - name: include_dependents
          in: query
          description: >-
            Populate `dependents` with the issues that depend on this one, each
            carrying its edge type — the shape `dependencies` already carries.
            Default false, for `include_comments`'s reason.
          schema:
            type: boolean
            default: false
        - name: brief_deps
          in: query
          description: >-
            Reduce each row in `dependencies` to its identity-and-shape fields
            (`id`, `title`, `status`, `issue_type`, `priority`,
            `dependency_type`), dropping `description`, `design`, `notes` and
            `acceptance_criteria`. Default false, so the payload is unchanged
            for a client that does not ask.
          schema:
            type: boolean
            default: false
      security:
        - bearerToken: []
      responses:
        '200':
          description: The issue.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IssueDetails'
        '400':
          $ref: '#/components/responses/InvalidArgument'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

    patch:
      operationId: updateIssue
      summary: Edit the fields of one issue
      description: >-
        Partial update of one named issue. A plain `PATCH` rather than a custom
        method, because partial update of one named resource is what `PATCH`
        already means — exactly as one named resource with no body is what
        `DELETE` means for `forgetMemory`. The custom-method namespace is left
        for the operations that are not CRUD.


        ## Member presence is the signal


        A member PRESENT in `patch` is written; a member ABSENT is left
        untouched. That is the whole partial-update rule, and it is why an
        absent member and a member set to its current value are different
        requests with the same outcome.


        Explicit `null` is defined per member. On the four NULLABLE members —
        `estimated_minutes`, `external_ref`, `due_at`, `defer_until` — it
        CLEARS the value, because `null` is the only wire spelling a clear has.
        On every other member it is a `400` naming the member, so a null is
        never quietly recorded as an empty string.


        An EMPTY `patch` object is a `400`: a write that writes nothing is a
        client bug, the same judgement `issues:batchCreate` makes about an
        empty `items`.


        `labels` is COMPLETE REPLACEMENT. It is the only shape whose result the
        client already knows without a read-back; an additive
        `labels_add`/`labels_remove` pair can join the vocabulary later without
        disturbing it.


        `notes` and `append_notes` are mutually exclusive; sending both is a
        `400`.


        ## Guards, forces and the conflicts they answer


        `expected_version`, `expected_status` and `expected_assignee` are
        compare-and-set preconditions, checked before the patch. A miss refuses
        the WHOLE request with `409 precondition_failed` and writes nothing, so
        recovery is to re-read and recompose rather than to retry the same body.
        They are `ApplyUpdateItem`'s three members with `ApplyUpdateItem`'s
        contract; `expected_version`'s token is the `revision` this operation
        answers with.


        `force_close_policy` and `force_assignee_transfer` bypass exactly one
        refusal each and nothing else. Neither bypasses validation, the
        preconditions above, or the other's guard.


        Three members carry policy, which is where this operation's `409`s come
        from. `status` crossing into the workspace's done category answers to
        close policy (`not_closable`). `assignee` transferring away from a live
        foreign in-progress owner answers to the assignee fence
        (`already_claimed`). `parent_id` is a graph edit and answers to the
        graph (`dependency_cycle`, `dependency_exists`). Each is the SAME
        refusal `issues:batchApply` and `dependencies:add` already publish, not
        a second vocabulary.


        ## What this operation deliberately cannot edit


        `owner` — additive later, and there is no argument against it any more:
        the one it had was `assignee`'s, which this operation now publishes.
        Nothing has asked for it.


        `persistence` — a plane move is an atomic aggregate migration rather
        than a field write, and moving a row between planes mid-patch is a
        different act from editing it.


        `closed_by_session` and `close_reason` — written under first-close-wins
        by `{id}:close`, which a patch write would bypass. A `status` that
        crosses into the done category still answers to close POLICY here; it
        does not acquire the close's semantics, which is why `{id}:close`
        remains the operation to reach for when what you mean is "close this".


        `created_at`, `created_by` — `POST /v0/beads/issues` withholds them for
        the same reason: a caller-chosen creation time makes the row disagree
        with the journal entry that recorded it, and re-dating history is what an
        import is for.


        `spec_id` and `await_id` — workflow plumbing this surface publishes
        nowhere yet.


        ## Planes


        The id resolves across BOTH planes, as the close and reopen do. An
        update whose target is a wisp lands on the unversioned plane and
        records no durable history entry.


        ## Hooks and auto-commit


        Hooks do not fire and the per-command auto-commit machinery does not
        run, exactly as for `POST /v0/beads/issues/{id}:claim`. The only
        durable effect is the single storage commit the role makes in its own
        transaction.
      parameters:
        - $ref: '#/components/parameters/IssueID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateIssueRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            The update was applied. A same-value patch is a 200 with
            `changed: false`, not an error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UpdateIssueResponse'
        '400':
          description: >-
            Invalid request: an unknown query parameter, an unparseable or
            oversized body, an unknown body member at any level, an `actor`
            that is empty after trimming, longer than 256 bytes, or carrying
            control characters, an empty `patch`, a member carrying the wrong
            JSON type, an explicit `null` on a member that is not nullable, a
            value outside its documented bounds, `notes` together with
            `append_notes`, `metadata.replace` together with any other metadata
            edit, `force_assignee_transfer` without `patch.assignee` or beside
            `expected_assignee`, a `patch.parent_id` naming the issue itself or
            naming no issue this workspace holds — or a value this workspace's
            own validation refuses, such as an `issue_type` or `status` outside
            its configured vocabulary.


            A NEW PARENT THAT NAMES NOTHING IS A `400`, NOT A `404`, conforming
            to `POST /v0/beads/dependencies:add`: an edge describes a relation
            rather than the resource this request addresses, and the `404` below
            is reserved for the id in the PATH.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            The request is well-formed and the STATE refuses it, and NOTHING WAS
            WRITTEN. Five codes, each inherited from the operation that already
            publishes it rather than minted here, and described in the four
            paragraphs below — `dependency_cycle` and `dependency_exists` share
            one, because they are one member reaching the graph.


            `precondition_failed` is `expected_version`, `expected_status` or
            `expected_assignee` missing. `param` names the guard member and the
            `expected_*` member echoes what the request asked for; the
            `actual_*` members are absent, because the refusal rolled its
            transaction back and a read afterwards would describe a row the
            refusal never saw. Re-read and recompose; never retry the same body.


            `not_closable` is close policy refusing a `patch.status` that
            crosses into the workspace's done category: open children, or a live
            blocker. `open_children` is attached for the first and withheld for
            the second, so member presence tells them apart.
            `force_close_policy` is the bypass.


            `already_claimed` is the assignee fence: a `patch.assignee` that
            transfers work away from a live foreign in-progress owner.
            `force_assignee_transfer` is the bypass and `expected_assignee` is
            the compare-and-set that replaces the fence outright. The `assignee`
            extension member is attached only when the refusing transaction
            reported the holder — the fence itself refuses without one, so a
            client must treat it as optional and re-read the row.


            `dependency_cycle` and `dependency_exists` are `patch.parent_id`
            reaching the graph: a move under the issue's own descendant, and a
            pair that already carries an edge of a different type. Neither has a
            force bypass.


            `dependency_cycle` ARRIVES WITHOUT THE HIERARCHY MEMBERS HERE, and a
            client must not dispatch on their presence on this operation. The
            hierarchy refusal — `issue_id`, `blocker_id`, `blocker_is_ancestor`
            — is raised only for a BLOCKING edge, and the only edge this
            operation writes is the `parent-child` one `patch.parent_id` names,
            so the discriminator cannot fire. That is the difference from
            `POST /v0/beads/issues` and from `POST /v0/beads/dependencies:add`,
            both of which write blocking edges and do carry it: this operation
            publishes no member that writes one. What you get here is the plain
            scheduling cycle, every time.
          x-bd-codes:
            [already_claimed, dependency_cycle, dependency_exists,
             not_closable, precondition_failed]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues/{id}/related:
    get:
      operationId: listRelatedIssues
      summary: List one issue's neighbors in a named direction
      description: >-
        THE ISSUES ON THE FAR END of this issue's edges, in the direction the
        request names, each carrying the type of the edge that led to it. It is
        the read behind `bd dep list`'s neighbor view, and its elements are the
        same `IssueWithDependencyMetadata` the `dependencies` and `dependents`
        members of `GET /v0/beads/issues/{id}` already carry.


        IT IS A SUB-RESOURCE OF THE ISSUE, not a member of the dependency
        collection, and the two answer different things about the same edges.
        `GET /v0/beads/dependencies` returns the stored edge ROWS — targets
        spelled exactly as stored, nothing looked up — for many anchors at once;
        this one is anchored on ONE issue and answers with HYDRATED ISSUES, so
        an edge whose far end this database holds no row for is not a neighbor
        at all. `issueops.Relations` and `issueops.EdgeReader` state that split
        once; nothing is restated here.


        THE CONSEQUENCE IS WORTH READING BEFORE COUNTING ANYTHING. A dependency
        target may be an `external:` reference or an id belonging to another
        repository, and this database holds no issue for either. Such an edge is
        left out with no placeholder row and no error, so the length of `items`
        is this issue's NEIGHBOR count and not its EDGE count, and the two
        differ by however many of its edges point outside this database. A
        caller that needs the edges themselves reads
        `GET /v0/beads/dependencies`; a caller that needs the number reads
        `GET /v0/beads/dependencies:count`, which counts edges and not
        neighbors.


        AN ID THAT NAMES NEITHER AN ISSUE NOR A WISP IS A 404, and that is the
        difference from every other graph read on this surface. Those are
        batched and report a miss per anchor, because failing the call would
        throw away the answers for the ids that were found; here there is one
        anchor and no other answer to preserve. It matters because an empty
        `items` is the COMMON case — most issues have neighbors in only one
        direction — so a typo answered with an empty list would never surface.


        BOTH PLANES ARE READ, on the anchor and on its neighbors. The anchor is
        resolved against the durable and ephemeral planes together, so a wisp id
        is a legal anchor; and the neighbors are collected from BOTH dependency
        tables and hydrated from both issue tables, so a durable issue's `in`
        neighbors include the wisps that depend on it and a `direction=out`
        answer includes the wisp targets it depends on. Those are the ROLE's
        rules, stated at `issueops.Relations`, and this document cites them
        rather than restating them.


        THE ORDER IS PINNED: ascending by the neighbor's id, with the edge type
        breaking a tie. It is pinned rather than left to the query because the
        rows come from two dependency tables read in sequence, so their natural
        order is an artifact of which plane a neighbor happens to live on —
        stable enough to look deliberate and not stable enough to rely on.


        THE ROWS CARRY NO `revision`, on `GET /v0/beads/issues`'s terms. The
        element here is the pinned Go struct `GET /v0/beads/issues/{id}` carries
        under `dependencies` and `dependents`, so it publishes an issue's own
        serialized fields and nothing this operation invents; the
        optimistic-concurrency token stays on the detail read, which is where a
        guarded write composes its `expected_version` from. A client holding a
        neighbor list and wanting a guard reads the rows it actually intends to
        write, one detail read each.


        THERE IS NO `limit` AND NO CURSOR. One issue's neighbors are unbounded
        here exactly as the `dependencies` member of
        `GET /v0/beads/issues/{id}` already is, and this operation names ONE
        anchor, so there is no question to bound instead — which is what the
        dependency-collection reads bound at 100 `issue_id` values apiece. A
        limit with no cursor behind it would truncate with no way to fetch the
        rest.


        THERE IS NO `both` DIRECTION, for the reason
        `GET /v0/beads/dependencies:count` has none: a caller that wants the
        pair asks twice, and one call answering both would have to say which
        direction each row came from — which is a second member on an element
        this document deliberately shares with the detail read.
      parameters:
        - $ref: '#/components/parameters/IssueID'
        - name: direction
          in: query
          required: true
          description: >-
            Which way this issue's edges are walked. `out` answers the issues it
            DEPENDS ON — the `dependencies` member of
            `GET /v0/beads/issues/{id}`. `in` answers the issues that depend on
            it — that read's `dependents` member.


            IT IS REQUIRED AND HAS NO DEFAULT, on
            `GET /v0/beads/dependencies:count`'s terms and for the reason
            `issueops.RelationDirection` gives: the two answers have the same
            shape and the same member names, so a caller handed the inverse graph
            has nothing to notice. An absent or unrecognized value is a 400
            `invalid_argument` with `param: "direction"`, never a walk in some
            default direction.


            The vocabulary is CLOSED — unlike `type` below — because it is a
            property of the edge's shape rather than of a workspace's
            configuration.
          schema:
            type: string
            enum: [out, in]
        - name: type
          in: query
          description: >-
            Edge types to include. Repeat the parameter. Empty means every
            type.


            `GET /v0/beads/dependencies`'s `type` exactly: the vocabulary is
            OPEN, so an unrecognized value is not an error and simply matches no
            edge, while a value no edge could ever carry — empty, or longer than
            the column — is a 400 `invalid_argument` with `param: "type"`.


            The filter narrows EDGES, never the anchor. An issue whose every
            edge it rejects is answered with an empty `items` and not with a
            404, which is a different fact from an id that names nothing.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
      security:
        - bearerToken: []
      responses:
        '200':
          description: This issue's neighbors, in ascending neighbor id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RelatedIssues'
        '400':
          $ref: '#/components/responses/InvalidArgument'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues/{id}/comments:
    post:
      operationId: addComment
      summary: Append one comment to an issue's thread
      description: >-
        Appends ONE comment to the thread the issue owns, as one atomic
        mutation — the write behind `bd comment`. It leaves every field of the
        issue untouched: a comment is not a patch, which is why it is not a
        member of `PATCH /v0/beads/issues/{id}` and why `issueops.Commenter` is
        its own role rather than a lifecycle verb.


        IT IS A SUB-RESOURCE COLLECTION OF THE ISSUE, and the argument is the
        one `GET /v0/beads/issues/{id}/related` makes, applied to a write: the
        row this creates is the SAME pinned `Comment`
        `GET /v0/beads/issues/{id}?include_comments=true` already carries under
        `comments`, so the operation that appends one belongs on the resource
        whose members it publishes. A PLAIN collection `POST` rather than a
        custom method, for `POST /v0/beads/issues`' reason: creating one member
        of the collection a path names is what `POST` already means, and a
        `{id}:addComment` spelling would in addition need the claim route's
        wildcard contortion for no gain. It collides with nothing — the
        custom-method dispatcher's pattern is one segment shorter, and this
        path's literal final segment is what ServeMux matches whole.


        THIS COLLECTION PUBLISHES NO `GET`, and the absence is a statement
        rather than a gap. No role answers a comment PAGE: reading the thread is
        `GET /v0/beads/issues/{id}?include_comments=true`, which returns the
        bodies in full, and a paged walk is a second question with a cursor of
        its own — `issueops.Commenter` says exactly that, and a `GET` here would
        be this surface inventing the role that does not exist. A `GET` on this
        path is answered `404`, which is what every method mismatch on this
        surface gets; `405` is not in the v0 status vocabulary.


        ## `author` is caller-asserted, and it is not `actor`


        The caller always names the author and the server never infers one, for
        `POST /v0/beads/issues/{id}:claim`'s reason. IT IS NOT THE AUTHENTICATED
        PRINCIPAL even where a bearer is required: the token a deployment
        configures admits a client to the whole surface and names nobody, so it
        can neither confirm nor contradict the name in `author`. A reader of the
        thread is reading a claim the writer made about itself.


        It is spelled `author` rather than the `actor` every issue mutation here
        carries because it is a different thing. An `actor` is the principal a
        mutation is attributed to and is not part of what the mutation wrote;
        this value IS part of the row, echoed back by every read of the thread,
        and it is spelled the way the `Comment` element that carries it back
        spells it. `issueops.AddCommentRequest.Author` states the distinction;
        this member is that field.


        ## Planes


        The id resolves across BOTH planes, as `POST
        /v0/beads/issues/{id}:close` does and unlike
        `POST /v0/beads/issues/{id}:claim`: A WISP IS A LEGAL TARGET. The
        comment lands on the ephemeral thread and reads back from it, and only
        the DURABLE trace is missing — a comment on an ephemeral row records NO
        history entry, none rather than one, because the wisp tables are
        dolt-ignored precisely so ephemeral work never ships. A caller
        reconstructing threads from durable history alone will not see it. An id
        that names neither plane is a `404` and nothing is written.


        ## What this operation does not have


        NO CONFLICT CODE AND NO `expected_version`. A thread is append-only and
        this write touches no field of the issue, so there is no row state for a
        guard to be stale about and no concurrent comment for this one to
        collide with. Two callers commenting at once both succeed, in whatever
        order the database commits them.


        NO IDEMPOTENCY KEY EITHER: a retried request appends a SECOND comment,
        because two identical comments are a legitimate thread and nothing here
        can tell that pair from a retry. A client that must not double-post
        reads the thread.


        Hooks do not fire and the per-command auto-commit machinery does not
        run, as for every write on this surface. The only durable effect is the
        single storage commit the role makes inside its own transaction.
      parameters:
        - $ref: '#/components/parameters/IssueID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddCommentRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            The stored comment, with the id and `created_at` the row actually
            got — the stored value at the column's precision, not the wall clock
            the call happened at, so it is safe to use directly as a
            comment-page cursor when one exists. It is the row as STORED, never
            the request reflected back.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Comment'
        '400':
          description: >-
            Invalid request: an unknown query parameter, an unparseable or
            oversized body, an unknown body member, a member carrying the wrong
            JSON type, an explicit `null` on any member, an `author` that is
            missing, empty after trimming, longer than 256 bytes or carrying
            control characters, or a `text` that is missing or blank after
            trimming. Nothing is written in any of these cases.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues/{id}:claim:
    post:
      operationId: claimIssue
      summary: Claim an issue for an actor
      description: >-
        Compare-and-set claim. The caller always names the actor: the server
        never infers one, because its own identity is meaningless for remote
        callers.


        A re-claim by the CURRENT holder is idempotent — 200 with
        `already_claimed: true` — matching CLI semantics. A claim held by a
        different actor is 409 `already_claimed` and carries the holder in the
        `assignee` extension member, read inside the same transaction; an issue
        in a non-claimable state is 409 `not_claimable` and carries
        `issue_status`. Neither needs the client to parse prose.
      parameters:
        - $ref: '#/components/parameters/IssueID'
      security:
        - bearerToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClaimRequest'
      responses:
        '200':
          description: The issue is now held by `actor`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ClaimResponse'
        '400':
          description: >-
            Invalid request: an unknown query parameter, an unparseable or
            oversized body, an unknown body member, or an `actor` that is empty
            after trimming, longer than 256 bytes, or carrying control
            characters.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            The issue is held by another actor (`already_claimed`, with the
            `assignee` extension member) or is not in a claimable state
            (`not_claimable`, with the `issue_status` extension member).
          x-bd-codes: [already_claimed, not_claimable]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues/{id}:release:
    post:
      operationId: releaseIssue
      summary: Give back the claim on an issue
      description: >-
        The claim's inverse, and what `bd unclaim` spells. It ends the
        OWNERSHIP and leaves the work open for the next taker: assignee
        cleared, status the literal `open`, `started_at` cleared, the lease
        dropped, and `revision` reminted so a concurrent reclaim or close
        conflicts rather than silently merging.


        It is a named lifecycle action rather than a `patch`, for
        `POST /v0/beads/issues/{id}:close`'s reason: an update spells a release
        as three fields at a time, which puts the transition's definition in
        the CALLER, and the lease it drops is the part a patch cannot express
        at all.


        The caller always names the actor, for
        `POST /v0/beads/issues/{id}:claim`'s reason. HERE IT IS ALSO THE
        OWNERSHIP FENCE'S SUBJECT: a release carrying neither
        `expected_assignee` nor `force` succeeds only while `actor` is the
        current holder. That is not authentication — `actor` is caller-asserted
        provenance exactly as it is on the claim, and it is NOT the
        authenticated principal even where a bearer is required: the token a
        deployment configures admits a client to the whole surface and names
        nobody, so it can neither confirm nor contradict the name in `actor`.
        The fence is the same anti-yank guard the claim gets from refusing a
        foreign holder, pointed the other way.


        ## NOT IDEMPOTENT, which is the one thing to read before adopting it


        A release over a row that holds NO claim is `409` / `not_releasable`,
        never a 200. There is no `already_released` member here and there must
        not be one, because the post-state is ANONYMOUS: "I released this
        twice", "a reaper beat me to it" and "nothing ever claimed it" leave
        the identical row — assignee cleared, status open, `started_at` gone —
        so one 200 would report one answer for three situations that want
        different things from a caller. `claimIssue` can afford
        `already_claimed: true` because a claim's post-state NAMES the
        claimant, and this operation has nothing left on the row to name.


        ON `not_releasable`, READ THE ROW. Do NOT treat the code as "already
        released", however ordinary that case is for you — it is the one
        shortcut this operation's taxonomy makes unsafe, and it is unsafe in
        the direction that strands work.


        The code covers TWO conditions and publishes no member telling them
        apart (see the `409` below). One of them is "nothing holds this". The
        other is "the status will not accept a release", which is true of a row
        that is CLOSED and equally true of a row parked in a status this
        workspace configured — and such a row can still be ASSIGNED. A reaper
        that read this code as success would book a claim as dropped while it
        is still held, by an agent that is already gone, with nothing left to
        free it. That is exactly the stranding this operation resolves ids
        across both planes to prevent, reintroduced through the refusal
        vocabulary instead of through the id.


        So the recovery is a read: the row tells you which of the two you got —
        an assignee, or none. That is one extra request on an uncommon path,
        and it is the honest price of one code rather than two. A caller that
        wants the distinction without the read should say so, and it arrives as
        an ADDITION, which the `Problem.code` rules already tell clients to
        tolerate.


        ## The two ways to release a claim you do not hold


        `expected_assignee` is a compare-and-set on the holder and REPLACES the
        ownership fence: a caller that can name the current holder has
        demonstrated the view the fence exists to protect, so `actor` need not
        be that holder. It cannot release a claim that has since moved, which
        is what makes it the safer of the two for a supervisor reaping one
        named agent's abandoned work.


        `force` ignores the holder entirely, and ignores NOTHING ELSE. It does
        not make an unheld row releasable, it does not make a closed one
        releasable, and it may not accompany `expected_assignee` — the two are
        answers to the same question and they disagree, so sending both is a
        400.


        ## Planes


        The id resolves across BOTH planes, unlike
        `POST /v0/beads/issues/{id}:claim` and like
        `POST /v0/beads/issues/{id}:close`. The asymmetry is about which
        direction strands work: a wisp can hold a claim, so an operation that
        refused to release one would leave an ephemeral row owned by an agent
        that is gone with no verb able to free it. A release whose target is a
        wisp records no durable history entry.


        ## Hooks and auto-commit


        Hooks do not fire and the per-command auto-commit machinery does not
        run, exactly as for `POST /v0/beads/issues/{id}:claim`. The only
        durable effect is the single storage commit the role makes inside its
        own transaction, which is what a proxied CLI write does today.
      parameters:
        - $ref: '#/components/parameters/IssueID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReleaseIssueRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            The claim is released. The issue is open and unassigned, and the
            body carries the row the release produced.


            THE SET THIS SUCCEEDS OVER IS WIDER THAN `in_progress`, and a
            client porting a guard needs to know it before it ports one. The
            transition is defined over `open` AND `in_progress` alike, so a row
            already in `open` that STILL HOLDS AN ASSIGNEE — the state a
            partial write or an out-of-band status edit leaves behind — is
            released here. A caller whose own release-if-current guarded on
            `in_progress` did nothing for that row and reported no error; this
            operation writes it. That is a genuine widening rather than a
            restatement, and a client mapping "no error" to "I released it"
            will now be right about rows it was previously never right about —
            which is the point, and which is also why the mapping below is
            written in terms of the CODES rather than in terms of success.


            THE CODE-TO-OUTCOME MAPPING a release-if-current client wants,
            stated here so it is written against a documented fact rather than
            against a reading of the refusals. Four answers mean "no release
            happened, and that is not an error": `not_releasable`,
            `precondition_failed`, `already_claimed`, and `404`. A client
            spelling that as a `(released, err)` pair returns
            `(false, nil)` for all four.


            `(false, nil)` MEANS "NO RELEASE HAPPENED". It does not mean the
            claim is gone, and nothing here licenses that reading — see the
            `not_releasable` paragraph in the operation description, which is
            the one place the two readings come apart and the place a reaper
            gets it wrong. A client that needs "is this row still held" asks a
            READER; this operation answers whether IT released anything.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReleaseIssueResponse'
        '400':
          description: >-
            Invalid request: an unknown query parameter, an unparseable or
            oversized body, an unknown body member, an `actor` refused by the
            rules `ClaimRequest.actor` states, an `expected_assignee` that is
            empty after trimming, or `force` sent beside `expected_assignee`.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            THE ORDER THESE ARE DECIDED IN IS PART OF THE ANSWER, because one
            request can fail several ways at once. Request validation runs
            before anything is read; then existence (the 404); then the row's
            STATUS, because a closed issue is not a claim question at all; then
            whether a claim exists; then the precondition or the fence,
            whichever the request selected. So a CLOSED row guarded on a stale
            `expected_assignee` is `not_releasable`, not
            `precondition_failed` — do not read one refusal as evidence about
            the checks that never ran.


            `not_releasable` is the row refusing to produce a release at all,
            and it covers two conditions: it holds no claim, or its status is
            neither `open` nor `in_progress` — a closed issue, or one parked in
            a status this workspace configured, which plainly can still hold a
            claim. Neither has a bypass: `force` answers "may I release someone
            else's claim" and has no opinion about whether there is a claim
            here to release.


            IT CARRIES NO MEMBER DISTINGUISHING THOSE TWO, and a client must
            not look for one. The OBSERVATIONS the two refusals made — the
            status one saw, the emptiness of the assignee the other saw — travel
            in their messages, and this surface does not scrape its own prose to
            manufacture a typed member. If the distinction ever earns one, it
            arrives as an ADDITION (a new member, or a second code), which the
            `Problem.code` rules already tell clients to tolerate; going the
            other way, merging two codes back into one, is the change that
            could not be made.


            SO THE RECOVERY IS A READ, and it is a correctness rule rather than
            a convenience: one of these two conditions leaves the row STILL
            ASSIGNED, so a client that read this code as "already released"
            books a claim as dropped while it stands. The operation description
            says why that direction strands work. Read the row.


            `already_claimed` is the ownership fence: an unforced, unguarded
            release by an actor that is not the holder. It is `updateIssue`'s
            assignee-fence code unchanged — the same situation, and the same
            two bypasses, spelled `force` and `expected_assignee` here instead
            of `force_assignee_transfer` and `expected_assignee` there. As
            there, the `assignee` extension member is NOT attached: the fence
            refuses without naming the holder, so a client re-reads the row.


            `precondition_failed` is `expected_assignee` missing, INCLUDING
            against a row that holds no claim — that is a mismatch rather than
            `not_releasable`, because the caller asked about a specific holder
            and the answer is that it is not the holder. It carries
            `expected_assignee` (the value the REQUEST sent) and `param`, and
            NO `actual_assignee`: the refusal rolled its transaction back, and
            the role carries the holder it observed in prose only. That is
            `updateIssue`'s rule for the same guard, unchanged.
          x-bd-codes: [already_claimed, not_releasable, precondition_failed]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues/{id}:close:
    post:
      operationId: closeIssue
      summary: Close one issue
      description: >-
        Closes the issue this path names, moving it to the literal `closed`
        status including from a configured done status. It is the half of the
        agent loop — claim, work, close — that this surface did not serve
        before.


        A named lifecycle action rather than a status patch, because the close
        carries semantics a patch has nowhere to put: the reason and session
        under first-close-wins, the done-status normalization, and the close
        POLICY vocabulary below.


        THE FIRST CLOSE WINS. A re-close of an already-closed issue is
        idempotent — 200 with `already_closed: true` — and writes neither
        `reason` nor `session`, so a replayed close cannot rewrite the record of
        why the work ended. The stored pair keeps what the first close gave it
        until a reopen clears both.


        ## Close policy


        An unforced close is refused with `409` / `not_closable` when the issue
        has open children (the refusal carries `open_children`, the count the
        refusing transaction observed) or a live blocker (no such member). The
        MEMBER'S PRESENCE is the discriminator, so telling the two apart never
        requires reading `detail`. Both refusals are the ROLE's, so `force`
        bypasses those two and nothing else — this endpoint cannot skip a guard
        by forgetting one exists.


        ## The guard


        `expected_version` is a compare-and-set precondition on the row's
        revision, checked FIRST — before close policy and before the idempotent
        re-close. A miss refuses the whole request with
        `409 precondition_failed` and writes nothing.


        POLICY AND PRECONDITION ARE DIFFERENT THINGS, and `force` is the bypass
        for exactly one of them. A forced close still answers to the guard: the
        two members say "close it even though the graph objects" and "only if
        this is still the row I read", which are unrelated claims.


        The token travels back on `revision`, so a read-modify-write chain that
        ends in a close composes its expectation from the value the previous
        write answered with — or, where the chain STARTS with a read, from
        `GET /v0/beads/issues/{id}`'s `revision`, which is the same token and
        agrees with this one.


        ## Planes


        The id resolves across BOTH planes, unlike
        `POST /v0/beads/issues/{id}:claim`. A close whose target is a wisp lands
        on the unversioned plane and records no durable history entry.


        ## Hooks and auto-commit


        Hooks do not fire and the per-command auto-commit machinery does not
        run, exactly as for `POST /v0/beads/issues/{id}:claim`. The only durable
        effect is the single storage commit the role makes in its own
        transaction.
      parameters:
        - $ref: '#/components/parameters/IssueID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CloseIssueRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: The issue is closed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CloseIssueResponse'
        '400':
          description: >-
            Invalid request: an unknown query parameter, an unparseable or
            oversized body, an unknown body member, an `actor` that is empty
            after trimming, longer than 256 bytes, or carrying control
            characters, a `reason` or `session` longer than the column holds or
            carrying control characters — or an `expected_version` that is not
            an integer.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            Two refusals share this status and are told apart by `code`.


            `precondition_failed`: `expected_version` did not match the row's
            current revision. `param` is `expected_version` and the refusal
            echoes the value the REQUEST guarded on; there is no `actual_version`
            beside it, because the transaction that saw the mismatch rolled back
            and a read afterwards would describe a row the refusal never saw.
            Re-read and recompose rather than retrying the same body.


            `not_closable`: close policy refused an unforced close — the issue
            has open children, carrying the `open_children` extension member, or
            a live blocker, which carries none. Resend with `force: true` to
            bypass both. `force` does NOT bypass the guard above.


            Nothing was written in either case.
          x-bd-codes: [not_closable, precondition_failed]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues/{id}:reopen:
    post:
      operationId: reopenIssue
      summary: Reopen one issue
      description: >-
        Moves the literal `closed` status and every configured done status back
        to `open`. It is `POST /v0/beads/issues/{id}:close`'s mirror, and it
        completes the lifecycle pair so a recovery flow works end to end over
        this surface.


        A reopen of an issue that is NOT done changes nothing and succeeds —
        200 with `already_open: true`, the re-claim's and the re-close's answer
        to the same question. An agent replaying its own recovery should not
        have to classify an error to learn it already ran.


        Reopening CLEARS `close_reason` and `closed_by_session`, because they
        describe a closure that no longer holds. That is what makes the close's
        first-close-wins rule survivable: the way to write a new reason is to
        reopen and close again, not to re-close.


        ## Where the reason is recorded


        On the `reopened` EVENT this move records, not on a field of the issue,
        and the response does not carry it. A caller that wants it back reads
        the issue's events — the same place the actor attribution for the
        reopen lives. A reopen with no reason still records that entry; it
        simply carries none.


        ## No POLICY conflict to name


        There is still no policy refusal here, and the absence is deliberate:
        close has one — open children, a live blocker — and reopen is the
        direction that removes an issue from the done category rather than
        adding it, so there is nothing for a policy to refuse. `not_closable`
        is not in this operation's vocabulary and never will be.


        The one `409` it does document is a PRECONDITION rather than a policy.
        `expected_version` is a compare-and-set on the row's revision, checked
        FIRST — before the non-done no-op — and a miss refuses the whole request
        with `precondition_failed` and writes nothing. It is the caller's own
        guard rather than a rule of the graph, which is why the operation can
        carry it while carrying no policy conflict at all.


        The token travels back on `revision`, so a reopen-then-re-close recovery
        composes its next expectation from the value this operation answered
        with, and a recovery that starts by reading composes its first from
        `GET /v0/beads/issues/{id}`'s `revision`.


        ## Planes


        The id resolves across BOTH planes, as the close does. A reopen whose
        target is a wisp lands on the unversioned plane and records no durable
        history entry.


        ## Hooks and auto-commit


        Hooks do not fire and the per-command auto-commit machinery does not
        run, exactly as for `POST /v0/beads/issues/{id}:claim`. The only
        durable effect is the single storage commit the role makes in its own
        transaction.
      parameters:
        - $ref: '#/components/parameters/IssueID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReopenIssueRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: The issue is open.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReopenIssueResponse'
        '400':
          description: >-
            Invalid request: an unknown query parameter, an unparseable or
            oversized body, an unknown body member, an `actor` that is empty
            after trimming, longer than 256 bytes, or carrying control
            characters, a `reason` longer than the column holds or carrying
            control characters — or an `expected_version` that is not an
            integer.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            `expected_version` did not match the row's current revision
            (`precondition_failed`). `param` is `expected_version` and the
            refusal echoes the value the REQUEST guarded on, with no
            `actual_version` beside it for the reason
            `POST /v0/beads/issues/{id}:close` gives. Nothing was written.


            This operation's ONLY conflict, and it is a precondition rather than
            a policy: `not_closable` is not in its vocabulary.
          x-bd-codes: [precondition_failed]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues/{id}:casMetadata:
    post:
      operationId: compareAndSetMetadata
      summary: Conditionally set one metadata key on an issue
      description: >-
        Sets `metadata[key]` if and only if it currently holds `expected`, and
        reports what it found either way. It is the conditional write every
        coordination protocol over the metadata plane is built from, and the one
        operation on this surface a client is expected to call in a LOOP.


        THE TRANSITION IS A PAIR. `expected` is the key's value before, `value`
        is its value after, and OMITTING either member means the key is ABSENT
        there. So a first-writer-wins acquire omits `expected`, a release omits
        `value`, and an ordinary hand-off carries both. A member present with
        the JSON value `null` is a real value and does NOT mean absent: a key
        stored holding null exists, and the server can tell the two apart.


        EQUALITY IS CANONICAL. Two JSON values match when their canonical
        encodings match, so insignificant whitespace is ignored and object keys
        compare as a set rather than in the order they were written — a client
        cannot lose a swap to its own serializer.


        NUMBERS COMPARE AS THEIR SOURCE LITERAL, so `1` and `1.0` do not match,
        and that is a CONSTRAINT ON CLIENTS rather than a precision guarantee.
        The metadata store decodes JSON numbers through a float and re-emits
        them, so a number is not always stored as it was sent: `1.0` is stored
        as `1`, an integer past 2^53 is rounded, and `1e300` is stored as three
        hundred and one digits. COMPOSE `expected` FROM A PREVIOUS `current`,
        never from your own spelling of a number — `current` is the value the
        row HOLDS, so a loop that feeds it back converges while a loop that
        re-sends a renormalized literal is refused forever. For a coordination
        token, prefer a STRING: it round-trips byte for byte.


        SERIALIZERS OMIT NULLS BY DEFAULT, AND HERE THAT CHANGES THE REQUEST.
        Omitting `value` is a DELETE, not "leave the value alone" — so a client
        that builds this body from a struct under Jackson's NON_NULL, Pydantic's
        `exclude_none`, or Go's `omitempty` will send a delete when it meant to
        write null. Send the members explicitly, and check what your serializer
        does with a null before you rely on either meaning.


        A REFUSED SWAP IS A 200, not a 409. `swapped` is false and `current`
        carries the value that refused it, which is what lets a client recompute
        and retry without a second read that could itself go stale. A lost race
        is the ordinary path here rather than an exceptional one, and a client
        that treats this operation's non-2xx codes as "the swap did not happen"
        would be wrong in both directions. DISPATCH ON `swapped`.


        A SUCCESSFUL SWAP THAT CHANGES NOTHING WRITES NOTHING: when the
        precondition holds over a value already equal to `value`, `swapped` is
        true, `current` is that value, and no row and no history entry is
        touched. `swapped` answers the PRECONDITION; it does not claim a write
        happened.


        `current` IS ALWAYS THE VALUE THE ROW HOLDS, read inside the transaction
        that decided — on a refusal, on a swap that landed, and on a swap that
        changed nothing. It is not an echo of what was sent, so where the store
        renormalized a value on the way in, `current` reports the store's form.
        A member present holding `null` is a value; an ABSENT `current` member
        means the key is absent.


        Sibling keys survive. The read, the comparison and the write share one
        transaction, so a concurrent write to a DIFFERENT key of the same issue
        is preserved rather than clobbered.


        The id resolves across both planes, so a swap whose target is a wisp
        lands on the unversioned plane and records no durable history entry.


        An issue whose stored metadata is not a JSON object answers 500 rather
        than 400 or 404, and that is deliberate: the request was well-formed and
        the issue exists, so both client-error codes would be something a caller
        could act on. The row is corrupt and no retry converges.
      parameters:
        - $ref: '#/components/parameters/IssueID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CompareAndSetMetadataRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            The comparison ran. `swapped` says whether the transition applied.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompareAndSetMetadataResponse'
        '400':
          description: >-
            Invalid request: an unknown query parameter, an unparseable or
            oversized body, an unknown body member, an `actor` that is empty
            after trimming, longer than 256 bytes, or carrying control
            characters, or a `key` that is empty or outside the metadata-key
            syntax. Nothing is written.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues:sweep:
    post:
      operationId: sweepIssues
      summary: Delete closed beads in bulk
      description: >-
        Bulk clearance of CLOSED beads from ONE tier: the operation behind
        `bd purge` (`tier: ephemeral`) and `bd prune` (`tier: durable`). It is
        one of the two DESTRUCTIVE operations on this surface — the other is
        `issues:delete` — and nothing it deletes comes back.


        A collection-level custom method rather than `DELETE
        /v0/beads/issues?...`: this describes a SET and acts on it, and a
        `DELETE` with a filtering query string is the shape where a dropped
        parameter widens what is erased. Sending a body makes every narrowing
        term a member the server refuses by name if it does not know it.


        ## What it selects, in order


        The closed rows of `tier`, then `pattern`, then the two protections —
        pinned beads are never swept and `protect_referenced` holds back beads
        another live bead cites. The ORDER is part of the answer: `skipped`
        counts what the request actually reached, so a pinned bead the pattern
        excluded is not counted as protected.


        ## The safety gate is the ROLE'S, not this handler's


        A `durable` sweep with neither `closed_before` nor `pattern` is
        refused with `400` / `invalid_argument`. That refusal comes from the
        same library surface `bd prune` calls, not from a check written here,
        which is what makes this endpoint incapable of erasing every closed
        bead in a workspace by omission. A caller that really means everything
        closed sends `pattern: "*"`.


        ## One transaction


        The selection and the deletion share one transaction, so the set the
        response describes IS the set that was deleted. The cost is that a
        sweep is all-or-nothing: one large enough to exceed the backend's write
        timeout fails whole and deletes nothing. Narrow the request rather than
        expecting progress.


        `dry_run: true` answers the same question and changes nothing —
        including history. Ask it first.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SweepRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            The sweep ran (or, under `dry_run`, was costed). A request that
            matched nothing is a 200 with `swept: 0`, not a 404 — an empty set
            of closed beads is the steady state of a swept workspace.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SweepResult'
        '400':
          description: >-
            Invalid request: an unparseable or oversized body, an unknown body
            member, a `tier` outside the enum, a malformed `pattern`, an
            unparseable `closed_before`, an `actor` that is empty after
            trimming or carries control characters — or an unfiltered
            `durable` sweep, which the library surface refuses as a safety
            invariant.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues:delete:
    post:
      operationId: deleteIssues
      summary: Delete named beads
      description: >-
        Erases the beads this request NAMES, and — under `cascade` — everything
        that depends on them. It is the operation behind `bd delete`, the other
        DESTRUCTIVE operation on this surface alongside `issues:sweep`, and
        nothing it deletes comes back.


        It is a different operation from `POST /v0/beads/issues:sweep`, not a
        narrower spelling of it. A sweep DESCRIBES a set and lets the server
        resolve it; this one is handed ids. That difference is what decides
        where each one's safety lives: a description can be accidentally too
        wide, so the sweep carries a require-a-filter refusal, while a list of
        ids cannot be, so this one's guard is about the GRAPH instead.


        A collection-level custom method rather than `DELETE
        /v0/beads/issues/{id}`: this acts on a SET, its behaviour turns on
        three flags, and a `DELETE` carrying those in a query string is the
        shape where a dropped parameter changes what is erased. Sending a body
        makes every one of them a member the server refuses by name if it does
        not know it.


        ## The three modes


        With neither `cascade` nor `force`, a named bead that some bead OUTSIDE
        the request depends on is REFUSED with `400` / `invalid_argument`, and
        nothing is deleted. `force` deletes the named beads and leaves those
        dependents ORPHANED — they keep their rows, lose their edges, and come
        back in `orphaned`. `cascade` deletes the transitive closure instead,
        so nothing is left outside it to orphan; a request carrying both
        behaves as `cascade`.


        The refusal is the LIBRARY's, not this handler's — the same surface
        `bd delete` calls — which is what makes this endpoint incapable of
        orphaning a workspace's graph by omission.


        ## Every id must resolve


        An id naming no stored bead is a `404` and NOTHING is deleted, not even
        the ids beside it that did resolve. There is no prefix matching here:
        ids are exact, because resolving an ambiguous prefix to a bead and then
        deleting it is the one place that convenience is not one.


        ## The guard, and why it takes one id


        `expected_version` is a compare-and-set precondition on the row this
        request NAMES: the deletion proceeds only if that bead's revision still
        equals it, and otherwise the request is `409 precondition_failed` and
        nothing is deleted. It is the guard that matters most on this operation,
        because being wrong about which bead you are looking at is the one
        mistake here that cannot be undone.


        IT REQUIRES A SINGLE-ID REQUEST, and that is a `400` naming
        `expected_version` rather than a fudge: one token cannot describe two
        rows. Duplicates collapse first, so repeating one id is still one bead.


        THE ORDER THE REFUSALS HAPPEN IN IS PART OF THE ANSWER. Request shape
        first, then the existence probe (`404`), then this guard (`409`), then
        the dependents refusal (`400`). A request that is both a typo and a
        graph problem reports the typo, which is the one a caller can fix
        without deciding anything; and a stale guard outranks the dependents
        refusal because a caller whose view has moved should not be asked to
        choose `cascade` or `force` over information that has already changed.


        NEITHER `cascade` NOR `force` BYPASSES IT. They bypass POLICY. Under
        `cascade` the guard covers the named bead alone — the closure is
        resolved inside the deleting transaction, so it may have grown since the
        caller's read.


        ## One transaction


        The guard, the deletion and the rewrite of surviving beads' text
        references all share one transaction, so the set the response describes
        IS the set that was deleted and no bead is left citing an id that no
        longer exists. The cost is that a delete is all-or-nothing: one whose
        neighbourhood is large enough to exceed the backend's write timeout
        fails whole. Split the request rather than expecting progress.


        `dry_run: true` answers the same question and changes nothing —
        including history, and including the two refusals, which a preview
        reports exactly where the real request would. Ask it first.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DeleteIssuesRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            The beads were deleted (or, under `dry_run`, costed).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeleteIssuesResult'
        '400':
          description: >-
            Invalid request: an unparseable or oversized body, an unknown body
            member, an empty or over-long `ids`, a blank id, an `actor` that is
            empty after trimming or carries control characters, an
            `expected_version` beside more than one distinct id — or a named
            bead with a dependent the request did not name and neither
            `cascade` nor `force` to say what to do about it, which the library
            surface refuses as a safety invariant.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          description: >-
            At least one id named no stored bead, and nothing was deleted.
            `detail` is this surface's fixed not-found sentence and does NOT
            name which ids missed: a client able to tell a well-formed miss
            from a malformed id would be probing the id space. `bd delete`
            names them, because it is answering the person who typed them.
          x-bd-codes: [not_found]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '409':
          description: >-
            `expected_version` did not match the named bead's current revision
            (`precondition_failed`), and NOTHING was deleted. `param` is
            `expected_version` and the refusal echoes the value the REQUEST
            guarded on, with no `actual_version` beside it for the reason
            `POST /v0/beads/issues/{id}:close` gives.


            It outranks the dependents refusal and is outranked by the `404`:
            see the operation description.
          x-bd-codes: [precondition_failed]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues:batchCreate:
    post:
      operationId: batchCreateIssues
      summary: Create many issues as one act
      description: >-
        Creates every item in the request, or none of them. There is no partial
        outcome and no per-item status: the whole request is one transaction, so
        a client that gets a 4xx knows nothing was written and can fix its
        payload and resend it unchanged.


        That is the OPPOSITE of what a batch close would do, and it is the right
        default here for one reason: half a created plan cannot be re-sent
        without duplicating the half that landed, and nothing in the response
        would say which half that was.


        THE SERVER ASSIGNS EVERY ID. There is no `id` member on an item, so this
        operation can never adopt or overwrite a stored row — `bd import` is the
        upsert surface and it is not published here. The generated ids come back
        in `items`, in request order, which is the only place a client can learn
        them.


        A dependency target may name an issue this workspace holds, an
        `external:` reference, or an id belonging to another repository. A
        target that is none of those is a `400` and nothing is created — an edge
        silently dropped from a created issue is a relationship the client has
        no way to discover is missing.


        AN ITEM OF THIS REQUEST CANNOT BE A DEPENDENCY TARGET, and this
        operation is the narrow fast path rather than the one to reach for when
        it needs to be. The server assigns every id, an item has no name a later
        item could spell, and only an id a caller already held could ever have
        addressed a row — so a plan whose edges point at its own new issues is
        `POST /v0/beads/issues:batchApply`, where a create item may NAME itself
        and later items address it by that name.


        Hooks do not fire and the per-command auto-commit machinery does not
        run, exactly as for `POST /v0/beads/issues/{id}:claim`. The only durable
        effect is the single storage commit the role makes in its own
        transaction.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BatchCreateRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            Every item was created. `items` carries the stored issues in request
            order.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchCreateResponse'
        '400':
          description: >-
            Invalid request: an unknown query parameter, an unparseable or
            oversized body, an unknown body member at any level, an `actor` that
            is empty after trimming, longer than 256 bytes or carrying control
            characters, an empty or over-long `items` array, an item the
            workspace's own validation refuses, or a dependency target that
            names nothing this workspace holds. Nothing is created in any of
            these cases.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues:batchApply:
    post:
      operationId: applyBatch
      summary: Apply an ordered, heterogeneous plan as one act
      description: >-
        Applies an ORDERED list of creates, updates, closes and dependency edges
        as ONE transaction, or applies none of them. It is the operation for a
        PLAN — create these three issues, wire them to each other, close the
        step that spawned them — which every other write here can only
        approximate as a sequence of calls with a window between each pair.


        A collection-level custom method, spelled the way `issues:sweep` and
        `issues:delete` are: it acts on a set the request DESCRIBES rather than
        on one addressable resource, and every one of its narrowing terms is a
        body member the server refuses by name if it does not know it.


        ## Order is the contract


        Items apply in declaration order and are NEVER reordered. That is the
        difference from `POST /v0/beads/dependencies:add`, which applies
        parent-child edges first so the planned hierarchy is visible before any
        blocking edge is validated against it. Reordering is not available here
        because the items are not all edges: "clear the old blockers, then set
        the new ones" is a sequence, and a server that reordered it would apply
        the clear after the set. What that pass buys there, this operation buys
        with an END GATE instead — after every item has landed, every scheduling
        edge the request added is re-validated against the parent-child closure
        the WHOLE request produced. `skip_per_edge_cycle_check` never drops it.


        ## Names, and which way they reach


        A `create` item may give itself a `key`. Later items address the row it
        minted with a `Ref` carrying that key, and the response's `keys` member
        maps each key to the id it was bound to — the one fact the request
        cannot carry and every caller needs.


        A KEY REACHES BACKWARD ONLY. A ref used to ADDRESS a row — an update's
        or a close's `target`, either endpoint of an edge — may name a key only
        if the create item declaring it appears EARLIER in `items`. That is
        forced by what the items do rather than chosen: an update has to see the
        row it patches. A key declared LATER is a `400` carrying
        `declared_later: true`, which is a different diagnosis from a key
        nothing in the request declares at all — one is an ordering mistake and
        the other is a typo, and a client fixes them differently.


        `create.metadata_refs` IS THE ONE EXCEPTION and may reach forward, or
        name its own item's key. Every id is minted before any splice is
        applied, so direction cannot matter there; the backward-only rule exists
        to make a TARGET ROW exist before an item touches it, which a metadata
        VALUE does not need. The splice is a SECOND WRITE and says so: the row
        is created with the metadata the item spelled, and the resolved ids are
        written after every id exists, so a consumer of the event stream sees a
        create and then an update rather than one create carrying values nothing
        could have known yet.


        ## All or nothing, and what that does to a precondition


        A non-2xx means NOTHING WAS WRITTEN — no id was minted, no edge landed,
        no row was closed — so a client fixes its payload and resends it
        unchanged. There is no per-item status because there is no outcome but
        the request's: an item whose target is a key an earlier item failed to
        create has no outcome that could be reported.


        THAT IS WHY A PRECONDITION MISS IS A `409` HERE and not an answer.
        `POST /v0/beads/issues/{id}:casMetadata` reports a lost compare-and-set
        as a 200, because a retry loop is its designed caller and a miss is the
        ordinary path. Here the guarded item is one step of a graph the caller
        meant to land as a unit, so committing the rest would leave a shape
        nobody asked for. `update.expected_version`, `update.expected_status`,
        `update.expected_assignee` and `close.expected_version` therefore refuse
        the WHOLE request with `precondition_failed`, and the problem names the
        offending item.


        Those guards evaluate AS-MODIFIED: against the row as this request has
        already changed it at that item's position, not against the row as it
        was when the request began. An item guarding on what an earlier item of
        the same request just wrote is asking a coherent question and gets a
        coherent answer. `expected_version` is the exception and it is a `400`:
        the token is server-minted and rewritten by every write, so mid-request
        there is no value a caller COULD send, and guarding on a row an earlier
        item already touched is refused before anything is written rather than
        answered with a mismatch the caller would go looking for a concurrent
        writer to explain.


        ## What lands, and what it costs


        `items` accepts at most 100 entries. The cap bounds how long one request
        may hold a write transaction — it is not a statement about batch
        semantics, and it is the bound the sibling batch operations already run
        under. Split a larger plan; each request is atomic on its own, but note
        that splitting it changes what the end gate can see, since the gate runs
        over one request at a time.


        ONE HISTORY ENTRY IS RECORDED FOR THE WHOLE REQUEST, attributed to
        `actor`, and none at all when nothing durable landed — a request made
        entirely of ephemeral items writes only to unversioned tables.
        `provenance` labels that entry: it changes how the entry READS, never
        whether one is recorded, and an empty one composes a default naming how
        many items of each kind landed rather than every id, since an entry
        listing a hundred is the diff written twice.


        EPHEMERALITY IS PER ITEM, exactly as it is for
        `POST /v0/beads/issues:batchCreate`: one request may create durable
        issues and ephemeral ones together. The two planes hold their edges in
        different tables, so a `dep_add` BETWEEN two rows this request creates
        on opposite planes is refused with everything else the request asked
        for.


        IT IS NOT IDEMPOTENT AND CARRIES NO IDEMPOTENCY KEY. Replaying a request
        applies it again — the creates mint new ids, the edges are idempotent,
        the closes are no-ops. A caller that needs a replay record makes it an
        ITEM of the batch, so the record lands or rolls back with the work it
        describes; a key on the request would be a second, weaker mechanism for
        the same thing.


        Hooks do not fire and the per-command auto-commit machinery does not
        run, exactly as for `POST /v0/beads/issues/{id}:claim`. The only durable
        effect is the single storage commit the role makes in its own
        transaction.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ApplyBatchRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            Every item applied and the transaction committed. `items` carries
            one entry per requested item in request order, and `keys` maps every
            key a create item named to the id it was bound to.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApplyBatchResponse'
        '400':
          description: >-
            Invalid request, and NOTHING WAS WRITTEN. The vocabulary is wide
            because this body is: an unknown query parameter, an unparseable or
            oversized body, an unknown body member at ANY of its levels, an
            `actor` that is empty after trimming, longer than 256 bytes or
            carrying control characters, an empty or over-long `items` array, an
            item whose `kind` is outside the enum, an item carrying no payload
            or a payload its `kind` does not name, a `create` with no `title`, a
            duplicate `key`, a `Ref` with neither `key` nor `id` or with both, a
            `dep_add` with an unstorable `type` or pointing a row at ITSELF, a
            `waits-for` gate outside its two values, an item a workspace's own
            validation refuses, and an edge endpoint that names nothing this
            database can see. An explicit `create.id` that already names a
            stored row is NOT here: it is a 409 `already_exists`, because that
            body is well-formed and only the state refuses it.


            TWO OF THOSE ARE WORTH SPELLING OUT. A ref naming a key declared by
            a LATER item carries `declared_later: true`; a ref naming a key no
            item declares carries `declared_later: false`, and member PRESENCE
            is not the discriminator here — the member is emitted in both
            polarities, because the two refusals are told apart by its VALUE and
            a client that read an absent member as false would misread every
            other 400 on this operation as an ordering mistake.


            An `expected_version` on a row an earlier item of the same request
            already wrote is the other: it is a `400` rather than the `409` a
            real mismatch earns, because it is a request-SHAPE refusal made
            before anything is read. No token the caller could have sent would
            have matched.


            An explicit `create.id` that is already taken is a `400` naming that
            member for `POST /v0/beads/dependencies:add`'s reason: the refusal is
            about the request BODY, and there is no id in the path to have
            missed. The same applies to an edge endpoint this database can see
            the absence of.


            `param` names the offending member in the `items[i].kind.member`
            spelling, so a client learns WHICH item and WHICH member without
            parsing prose.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          description: >-
            A `target` of an `update` or a `close` named no stored row, and
            nothing was written.


            It is a `404` rather than a `400` on `POST /v0/beads/issues:delete`'s
            precedent: those refs NAME rows this request acts on, exactly as
            that operation's `ids` do, so a miss is a resource this request
            failed to address. An EDGE endpoint is the other case and stays a
            `400`, conforming to `POST /v0/beads/dependencies:add` — an edge
            describes a relation rather than acting on a row, and its target may
            legitimately be an `external:` reference or an id in another
            repository.


            `detail` is this surface's fixed not-found sentence and does NOT say
            which ref missed, for the reason `issues:delete` gives.
          x-bd-codes: [not_found]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '409':
          description: >-
            The request is well-formed and the STATE refuses it, and NOTHING WAS
            WRITTEN. Six codes, and every one of them is a statement a caller
            could not have made without reading state it does not hold. Five
            paragraphs cover them: `dependency_cycle` and `dependency_exists`
            share one, because they are the graph's own pair.


            `precondition_failed` is a guard that missed:
            `update.expected_version`, `update.expected_status`,
            `update.expected_assignee` or `close.expected_version` evaluated
            as-modified at its item's position. `param` names the guard member
            and `item_index`, `item_kind`, `item_key` and `item_issue_id` name
            the item, all read from the refusing transaction rather than parsed
            out of prose. The `expected_*` members echo what the request asked
            for; the `actual_*` members are absent on this operation, because
            the all-or-nothing rollback means a read after the fact would report
            a row the refusal never saw and this role's refusals do not carry
            the observed value. A client recovers by re-reading and recomposing,
            never by retrying the same body.


            `not_closable` is close policy refusing a `close` item, or an
            `update` whose `patch.status` crosses into the workspace's done
            category: open children, or a live blocker. `close.force` and
            `update.force_close_policy` are the per-item bypasses, and a forced
            item never earns this.


            `already_claimed` is the assignee fence: an `update` whose
            `patch.assignee` transfers work away from a live foreign in-progress
            owner. `update.force_assignee_transfer` is the bypass, and
            `update.expected_assignee` is the compare-and-set that replaces the
            fence outright.


            `dependency_cycle` and `dependency_exists` are the graph's own two
            refusals, spelled exactly as `POST /v0/beads/dependencies:add`
            spells them, including the hierarchy discriminator: the hierarchy
            case — and only it — additionally carries `issue_id`, `blocker_id`
            and `blocker_is_ancestor`. They may come from the per-edge probe or
            from the END GATE, which is the one place an edge that is legal on
            its own and illegal in the graph this request built is caught.
            Neither has a force bypass.


            `already_exists` is a `create` item whose EXPLICIT `id` already
            names a stored row. `param` is `items[i].create.id` and the item
            members name which item carried it. There is no force bypass and
            there is no upsert here: every create item refuses an occupied id,
            and adopting a row that already exists is `update` — reference it by
            `{"id": …}` instead. It is a 409 rather than a 400 because the body
            is well-formed and stays well-formed: the identical request
            succeeded before the id was taken, so recovery is to look at the
            state (adopt the row, choose another id, or stop) rather than to fix
            a malformed request.
          x-bd-codes:
            [already_claimed, already_exists, dependency_cycle,
             dependency_exists, not_closable, precondition_failed]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues:batchClose:
    post:
      operationId: batchCloseIssues
      summary: Close many issues as one act
      description: >-
        Closes every item it can and commits them together. It is the write side
        of `bd close a b c`, and it is the operation `POST /v0/beads/issues:batchCreate`
        already names as its own opposite.


        THE REQUEST IS THE TRANSACTION BOUNDARY, which is the whole reason this
        is one operation rather than a loop over
        `POST /v0/beads/issues/{id}:close`: closing N issues one call at a time
        is N transactions and N history entries, and a caller that wants them to
        land together has no way to say so.


        ## It is NOT all-or-nothing, and that is the point


        An id this batch refuses is SKIPPED and the survivors commit. An agent
        that finishes four of five steps and mistypes the fifth keeps the four;
        making the batch atomic in the other sense would turn a typo into a
        rollback of finished work.


        SO THE ANSWER IS A 200 CARRYING PER-ITEM OUTCOMES, even when items
        refused. `outcomes` has exactly one entry per requested item, in REQUEST
        ORDER, so a client walks it against its own argument list without
        matching ids back up. A NON-2xx from this operation means the batch
        NEVER RAN — a refused body, or an infrastructure failure — and never
        that some items landed.


        That divides the refusal vocabulary in two, and the division is the
        contract: a refusal OF THE REQUEST is a problem document, and a refusal
        OF AN ITEM is a member of that item's outcome. `code` on an outcome is
        the same vocabulary `Problem.code` publishes, restricted to
        `not_found` and `not_closable`, so a client classifies an item exactly
        as it classifies a request.


        WHAT IS ATOMIC IS EVERYTHING THAT LANDS: one transaction, at most one
        history entry, and none at all when nothing landed. LANDED MEANS
        CHANGED — an idempotent re-close is a per-item success that persisted
        nothing, so a batch of them lands nothing and records no history entry,
        exactly as a batch of typos does.


        ## Duplicates and planes


        A DUPLICATED id is not a request error: `bd close a b a` is a plausible
        typo, not a failure. Items are closed in the order given, so the second
        occurrence finds what the first one did and reports an idempotent
        re-close at ITS OWN index — a success with `already_closed: true`. The
        reason on the row is the first occurrence's, because the second mutated
        nothing and so wrote nothing.


        A WISP ID IS AN ADMISSIBLE ITEM, resolved across both planes exactly as
        `POST /v0/beads/issues/{id}:close` resolves one. What it does not do is
        reach the durable history entry: the entry a mixed batch records is
        composed from its DURABLE landings alone.


        ## What this operation does not publish


        NO COMPOSED CLAIM. The role can claim the next ready issue in the same
        transaction once the closes land, and that member is deliberately not
        published here — expressing it would require a second, BODY-shaped
        spelling of the ready-filter vocabulary that
        `GET /v0/beads/ready` and `POST /v0/beads/issues:claimNext` both express
        as query parameters, and two spellings of one predicate eventually
        disagree. A client that wants both sends the two requests; what it loses
        is the single transaction, which is a real loss and is named here rather
        than papered over. It is additive later, once there is one shape for a
        ready filter in a body.


        NO PER-ITEM PRECONDITION. There is no counterpart here to a
        compare-and-set guard, matching the role, so `force` is a question with
        only two answers: it bypasses blocker and open-child close policy for
        every item, and it never bypasses validation or existence.


        ## Hooks and auto-commit


        Hooks do not fire and the per-command auto-commit machinery does not
        run, exactly as for `POST /v0/beads/issues/{id}:close`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BatchCloseRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            The batch ran. Every requested item has an outcome, including the
            ones that refused.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchCloseResponse'
        '400':
          description: >-
            The batch NEVER RAN. An unknown query parameter, an unparseable or
            oversized body, an unknown body or item member, an `actor` refused
            by the rules `ClaimRequest.actor` states, an `items` that is absent,
            empty or longer than 100, an item that is not an object or carries
            no `id`, or a `reason`/`session` longer than the column holds.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/issues:claimNext:
    post:
      operationId: claimNextIssue
      summary: Claim the next ready issue
      description: >-
        Takes ONE ready issue and hands it back claimed, in a single
        transaction. It is `bd ready --claim`, and it is the operation this
        surface's polling clients have been composing by hand out of
        `GET /v0/beads/ready` and `POST /v0/beads/issues/{id}:claim`.


        THAT COMPOSITION IS A RACE, and retiring it is why this exists. Between
        the listing that offered a row and the claim that asked for it, another
        agent claims it — so the second agent gets `409 already_claimed` for a
        row it was correctly offered, and a fleet polling one queue spends its
        requests losing races rather than doing work. Here the ready predicate,
        the compare-and-set that wins the row, and the hydration of the row that
        was won are ONE transaction, so the row cannot move between being chosen
        and being reported.


        THE CALLER NAMES A QUESTION, NOT A ROW, which is what makes this a
        different operation rather than a mode of the claim. There is no id in
        the path and none in the body: selection is part of the contract.


        ## Nothing eligible is a 200


        An empty ready front is the steady state of a drained queue, not a
        failure, so a request that finds nothing answers `200` with `claimed`
        ABSENT. Its absence is the whole signal and there is no second member
        beside it: a polling agent branches on presence and sleeps. Nothing is
        written and no history entry is recorded.


        This is the one place this operation deliberately differs from
        `POST /v0/beads/issues/{id}:claim`, which 404s an id that names nothing
        — that operation was asked about a ROW, and this one was asked a
        question whose honest answer can be "none".


        ## The filters are the listing's, exactly


        Every parameter below is `GET /v0/beads/ready`'s, means what it means
        there, and is decoded by the same function — including the default type
        exclusions and the way `type` drops them. That is not tidiness: a claim
        that answered a different question than the listing shows would hand an
        agent work the listing never offered it, and two predicates that are
        allowed to differ eventually do.


        THERE IS NO `limit`, and sending one is a 400 rather than a silently
        dropped parameter. A claim delivers exactly the one row it wins no
        matter how large the pool it scanned, and the scan itself must stay
        UNBOUNDED: the implementation walks the ready order and continues past
        rows a racing agent already took, so a bounded window would report
        "nothing to claim" whenever that window happened to be unclaimable while
        plenty of other ready work remained.


        `sort` IS published, unlike on `GET /v0/beads/ready:count`, because
        order decides WHICH row a claim wins where it cannot change a
        cardinality.


        ## Leases, and the one case that has none


        A DURABLE win grants exactly one lease on the row it won — the handle
        heartbeats extend, and the row lease-expiry recovery walks once nothing
        extends it.


        An EPHEMERAL win carries NO LEASE, and that is the sharper consequence
        because it has no expiry to wait out. Ephemeral rows are outside the
        ready set by default; `include_ephemeral` pulls them in for the claim
        exactly as it does for the listing, and such a row IS claimable —
        claiming it moves the ephemeral row itself rather than promoting it.
        But heartbeats refuse an ephemeral row and lease-expiry recovery only
        walks leased durable ones, so nothing reclaims it if its claimant dies:
        it stays in progress under a gone actor until something releases or
        finishes it. A caller handing ephemeral work to agents it does not
        supervise owns that recovery itself. An ephemeral win records no durable
        history entry either, so a caller reconstructing who took what from
        history alone will not see it — read the row.


        ## Hooks and auto-commit


        Hooks do not fire and the per-command auto-commit machinery does not
        run, exactly as for `POST /v0/beads/issues/{id}:claim`.
      parameters:
        - name: assignee
          in: query
          description: Only issues assigned to this actor.
          schema:
            type: string
        - name: unassigned
          in: query
          description: Only issues with no assignee.
          schema:
            type: boolean
        - name: type
          in: query
          description: >-
            Issue type. The only normalization is shorthand ALIAS expansion,
            exactly what `bd ready --type` does: `mr` → `merge-request`,
            `feat` → `feature`, `mol` → `molecule`, `enhancement` → `feature`,
            `dec`/`adr` → `decision`. Every other value is used as written —
            there is NO plural folding, so `bugs` is not `bug`.


            An unrecognized type is not an error here: the type vocabulary is
            workspace-configurable, and `bd ready` does not validate it either,
            so it simply matches nothing and `items` comes back empty. (The
            list operation differs — `bd list` DOES validate the type, so
            `GET /v0/beads/issues?type=bugs` is a 400.)


            When set, `exclude_type` is ignored, and so are the default type
            exclusions described above.
          schema:
            type: string
        - name: exclude_type
          in: query
          description: >-
            Issue types to exclude. Repeat the parameter, or pass a
            comma-separated list. Ignored when `type` is set.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: label
          in: query
          description: Labels that must ALL be present (AND).
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: label_any
          in: query
          description: Labels of which at least one must be present (OR).
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: exclude_label
          in: query
          description: Labels that must not be present.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: label_pattern
          in: query
          description: Glob matched against labels.
          schema:
            type: string
        - name: label_regex
          in: query
          description: Regular expression matched against labels.
          schema:
            type: string
        - name: priority
          in: query
          description: Exact priority (0 is a real value, not "unset").
          schema:
            type: integer
        - name: parent
          in: query
          description: Restrict to recursive descendants of this issue.
          schema:
            type: string
        - name: metadata_field
          in: query
          description: >-
            Top-level metadata equality filter as `key=value`, split on the
            first `=`. Repeatable. An invalid key is a 400.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: has_metadata_key
          in: query
          description: Only issues carrying this top-level metadata key.
          schema:
            type: string
        - name: include_ephemeral
          in: query
          description: Include ephemeral (non-synced) rows.
          schema:
            type: boolean
            default: false
        - name: include_deferred
          in: query
          description: Include issues whose `defer_until` is still in the future.
          schema:
            type: boolean
            default: false
        - name: sort
          in: query
          description: >-
            Ready-work ordering. `priority` is priority-first; `hybrid` orders
            recent issues by priority and older ones by age; `oldest` is
            creation order. An unrecognized value is a 400.


            The default is the one `bd ready --sort` registers, so a client
            swapping `bd ready --json` for this operation gets the same items in
            the same order. The storage layer treats an EMPTY policy as
            `hybrid`, but that fallback is unreachable from the CLI and is NOT
            this parameter's default: `hybrid` demotes older high-priority work,
            so defaulting to it would change the item SET as soon as `limit`
            truncates — silently, and only for the clients this API exists to
            migrate.
          schema:
            type: string
            enum: [hybrid, priority, oldest]
            default: priority
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClaimNextRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            The claim ran. `claimed` carries the row it won, or is ABSENT
            because nothing was eligible.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ClaimNextResponse'
        '400':
          description: >-
            Invalid request: an unknown query parameter, a malformed filter
            value, a `limit` (which this operation refuses rather than drops),
            an unparseable or oversized body, an unknown body member, or an
            `actor` refused by the rules `ClaimRequest.actor` states.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/config:
    get:
      operationId: listSettings
      summary: List the workspace's stored settings
      description: >-
        The SETTINGS stored in the workspace database, which is the plane
        `bd config list` reads. It is not the effective configuration: values
        that reach a running `bd` from `config.yaml`, from environment
        variables or from git config are absent and cannot be here, because
        they are files and variables on the CLIENT's machine and this server
        answers for the database. `bd config show` is the multi-source view and
        has no HTTP operation for the same reason.


        IT IS NOT EVERY ROW OF THAT TABLE. Keys under `kv.` are omitted — the
        generic `bd kv` namespace and the `bd remember` memories nested under
        it. Those rows are USER DATA that rides in the settings table because
        there is one table, not because they are settings, and enumerating them
        here published a workspace's memories, key and value, to anything that
        could reach this port. `bd config list` omits them too: the exclusion is
        in the shared role both doors call, so the two cannot drift.

        `getSetting` still answers a `kv.` key NAMED EXACTLY, and this
        operation is where it stopped being discoverable.


        SETTINGS WHOSE KEY MARKS THEM AS CREDENTIAL-BEARING ARE WITHHELD. Their
        entry is present with `redacted: true` and no `value`, so a client can
        see that the key is configured without the surface handing a secret to
        every process that can reach the port. A CONFIGURED BEARER DOES NOT
        NARROW IT: authentication on this surface is a deployment posture and the
        credential, where there is one, is a single shared token that names
        nobody — it decides WHO may call, never what a caller who is let in may
        read — and there is no TLS either. The rule is the KEY's and is the same
        for every caller. See `Setting`, and `PUT` on this key's own path for the
        write half, which withholds the value in its response for the same
        reason and permits the write.


        The envelope is the paginated one and `has_more` is always false today:
        settings are a keyed namespace a workspace holds tens of, not a
        collection to scan, so the whole plane is returned in one page. The
        envelope is used anyway because entries are ordered by key, which makes
        a keyset cursor expressible later without a breaking change.
      security:
        - bearerToken: []
      responses:
        '200':
          description: The stored settings.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SettingsPage'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/config/{key}:
    get:
      operationId: getSetting
      summary: Get one stored setting
      description: >-
        The value stored for one key, used verbatim: there is no namespace
        completion, no case folding and no dash/underscore equivalence.


        THERE IS NO 404 ON THIS OPERATION, deliberately. A key nothing stored
        and a key stored as the empty string are the same answer here — 200
        with `value` absent — because the storage seam behind it cannot tell
        them apart and neither can `bd config get`, which prints "(not set)"
        for both. Answering 404 for one of them would publish a distinction
        this server would have to invent.
      parameters:
        - $ref: '#/components/parameters/SettingKey'
      security:
        - bearerToken: []
      responses:
        '200':
          description: The setting.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Setting'
        '400':
          description: >-
            Invalid request: an unknown query parameter, or a `key` that is
            empty after trimming or carries a control character.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

    put:
      operationId: setSetting
      summary: Store one setting
      description: >-
        Stores one setting, REPLACING any value already there — the write
        `bd config set` spells, on the durable settings plane this document's
        reads already publish.


        `PUT` RATHER THAN A COLLECTION `POST`, and the method is the whole
        argument: the resource has a canonical URI, the caller names it, and the
        request carries the value that becomes its whole state. That is what
        `PUT` already means, and it is idempotent in the strict sense — the same
        request sent twice leaves the same row.
        `POST /v0/beads/memories` is the operation this is NOT: that one posts to
        the COLLECTION because its key may be derived from the content, so the
        caller cannot always name the resource it is creating. Here the caller
        always can.


        THE KEY IS THE PATH'S and appears nowhere in the body: one anchor, one
        spelling, and no question about what to do when two disagree. It is used
        verbatim — no namespace completion, no case folding, no dash/underscore
        equivalence — and stored UNTRIMMED, because a key differing from another
        only by surrounding space is a key a reader will never match and
        trimming it would produce a write the caller cannot find again.


        ## What this plane refuses, and what it does not


        `issue_prefix` — in either spelling — is a `400` and NOTHING is written.
        The prefix is owned by `bd init --prefix`, `bd bootstrap` and
        `bd rename-prefix`, each of which does work this plane cannot: rewriting
        existing ids, or seeding a workspace that has none. Storing a new one
        here would leave the beads created before the write and the beads created
        after it disagreeing about their own namespace with nothing to reconcile
        them.


        A `status.custom` value that does not PARSE is a `400` and nothing is
        written. That key is not merely stored — it is projected into the
        `custom_statuses` table, which reads consult first, IN THE SAME
        TRANSACTION as the row. `types.custom` is projected into `custom_types`
        the same way. A row without its table is a value that has been stored and
        has no effect for as long as the table holds something else, so the write
        and the projection are one durable act or neither happens.


        A KEY WHOSE NAME MARKS IT CREDENTIAL-BEARING IS WRITABLE, and that is
        deliberate rather than an oversight in the redaction posture. Redaction
        is a rule about DISCLOSURE: it withholds a value from a reader because a
        bearer on this surface is shared and surface-wide and cannot decide that
        one caller may read a credential and another may not. A writer supplies
        the value, so refusing the write protects nothing that is not already in
        the caller's hand — and it would leave a workspace whose credentials can
        be seen to EXIST and never configured. The role refuses no such key
        either; `bd config set`'s own secret guard is about writing a credential
        into a git-tracked `config.yaml`, which is a different plane with a
        different hazard, and it returns clean for every key this operation
        reaches.


        THE RESPONSE THEREFORE WITHHOLDS IT ANYWAY. The body is a `Setting`
        projected by the same rule `GET /v0/beads/config/{key}` projects with, so
        a `redacted: true` key comes back with no `value` — the response to a
        write is byte-identical to the read that follows it. It costs the caller
        nothing: the role promises the stored value equals the value sent for
        every key this plane accepts, so the echo carries no information the
        caller does not already hold, and publishing it would make one schema say
        two different things about `redacted`.


        A key belonging to another SOURCE is not refused here and is worth
        knowing about: `export.*`, `dolt.*`, `federation.*`, `storage-class.*`
        and the rest of the yaml-only list live in `config.yaml`, and
        `beads.role` lives in git config. Written through this operation they
        land a row no reader consults. The role does not police that routing and
        neither can this server — three of the five sources `bd config show`
        reads are files on the CLIENT's machine, which is the same reason that
        command has no operation here.


        Hooks do not fire, as for every write on this surface — and on this plane
        there are none to fire in any case: the workspace hook vocabulary is
        `on_create`/`on_update`/`on_close`, each of which hands a script an
        ISSUE, and a settings write has none to name.
      parameters:
        - $ref: '#/components/parameters/SettingKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetSettingRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            The setting as it now stands, projected exactly as the `GET` beside
            it projects it — including the redaction, which is decided on the KEY
            and never on the caller.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Setting'
        '400':
          description: >-
            Invalid request: an unknown query parameter, an unparseable or
            oversized body, an unknown body member, a `value` that is missing,
            is not a string or is longer than the 65535-byte column, a `key`
            that is empty after trimming, carries a
            control character or is longer than the 255-character column — or a
            value this workspace's own validation refuses, which is `issue_prefix`
            in either spelling and a `status.custom` that does not parse. NOTHING
            IS WRITTEN in any of these cases.


            The role's two refusals carry no `param`, unlike the transport's
            above: telling them apart would take a second copy of the protected-key
            vocabulary on this side of the seam, and `detail` carries the role's
            own sentence, which names what to send instead.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

    delete:
      operationId: unsetSetting
      summary: Remove one stored setting
      description: >-
        Removes the setting stored under one key — the write `bd config unset`
        spells. `DELETE` for `DELETE /v0/beads/memories/{key}`'s reason: it names
        ONE resource by path, carries no body and takes no flags, which is what
        the method already means.


        REMOVING A KEY NOTHING SET SUCCEEDS, and this is where the operation
        parts company with the memory delete beside it. That one answers `404`
        for a key it held nothing under, because its role reports whether a row
        was found and `bd recall` already has an exit-code contract for the
        miss. THIS role reports no such thing: the storage seam discards the
        affected-row count on all three implementations, and an absent key and a
        key stored as the empty string are one answer on this plane — the same
        conflation `GET /v0/beads/config/{key}` has no `404` for. So this
        operation states an INTENDED END STATE rather than an act performed, and
        a caller clearing configuration it is not sure was ever written does not
        have to classify an error to learn it was already absent.


        THERE IS CONSEQUENTLY NO `removed` MEMBER, and there must not be one: it
        would be a value one implementation had to invent. Sending the same
        request twice is `200` and then `200`.


        UNSET DOES NOT UNDO `PUT`'s PROJECTION. Removing `status.custom` or
        `types.custom` deletes the row and LEAVES the normalized table exactly as
        the last write left it, so the custom statuses and types keep applying
        after the key that configured them is gone. All three implementations
        agree, so it is the plane's behavior rather than a divergence, and it is
        stated here so no reader infers a symmetry with `PUT` that the code does
        not have.


        THE PROTECTED KEY IS NOT REFUSED HERE. `issue_prefix` cannot be WRITTEN
        through this plane and can be removed through it — an asymmetry that is
        shipped behavior on all three implementations rather than a decision this
        document makes, recorded as bd-yby99.34. Removing it does not rename
        anything; it leaves the workspace resolving its prefix from `config.yaml`
        or from nothing.


        A CREDENTIAL-BEARING KEY IS REMOVABLE, on `PUT`'s reasoning: redaction
        withholds a value from a READER, and a removal discloses nothing at all.


        Hooks do not fire, and this plane has none to fire; see `PUT`.
      parameters:
        - $ref: '#/components/parameters/SettingKey'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            The key holds nothing now. Whether it held anything before is not
            reported — see the description.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RemovedSetting'
        '400':
          description: >-
            Invalid request: an unknown query parameter, or a `key` that is empty
            after trimming or carries a control character. It is the `GET`
            beside it's refusal exactly, unchanged, because the two take the same
            parameter and judge it the same way. Nothing is removed in either
            case.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/dependencies:
    get:
      operationId: listDependencies
      summary: List the stored dependency edges of several issues
      description: >-
        The STORED EDGE ROWS whose source is each named issue — the answer
        `bd dep list a b c` gives, and the same `Dependency` elements that
        command's `--json` emits.


        THESE ARE ROWS, NOT NEIGHBOURS, and that is the difference from the
        `dependencies` member of `GET /v0/beads/issues/{id}`'s issue body. A
        dependency target may be an `external:` reference or an id belonging to
        another repository, and this database holds no issue for either; such
        an edge is returned here with its target spelled exactly as stored, and
        nothing is looked up on the far end. The `id` member of each element is
        absent: the read behind this operation does not select the row's
        surrogate key, so the `(issue_id, depends_on_id, type)` triple is what
        identifies an edge on this surface.


        THE DIRECTION IS OUTGOING ONLY — what each issue depends on. There is
        no direction parameter: the inbound bulk read is a different read
        against a different key with its own de-duplication rule across the two
        dependency tables, and adding it later is additive.


        A NAMED ISSUE THAT DOES NOT EXIST IS NOT A 404. It is listed in
        `missing`, and the edges of the issues that do exist are still
        returned: a batch that failed on one absent id would throw away the
        answers for the ids that were found. An issue that exists and depends
        on nothing is absent from `missing` and contributes no `items`, which
        is the distinction this operation exists to publish — an empty edge
        list is otherwise indistinguishable from a typo, and the empty list is
        the common case.


        THERE IS NO `limit` AND NO CURSOR. `bd dep list` has no limit either,
        so one here would make the two surfaces answer differently by default;
        instead the QUESTION is bounded — at most 100 `issue_id` values per
        call — which bounds the answer without truncating it. Each issue's
        edges are unbounded, exactly as the `dependencies` member of
        `GET /v0/beads/issues/{id}` already is.
      parameters:
        - name: issue_id
          in: query
          description: >-
            The issues to read edges for. Repeat the parameter; at least one is
            required and at most 100 are accepted, and either bound is a 400
            `invalid_argument` with `param: "issue_id"`, `reason:
            "invalid_value"`.


            Each value must be an EXACT canonical issue id: there is no fuzzy,
            prefix or substring resolution on this surface, for the reason
            `GET /v0/beads/issues/{id}` gives. A value that matches nothing is
            reported in `missing` rather than refused. An empty value is a 400.


            Repeats collapse: an id named twice is one entry in `missing` at
            most once, and its edges appear once.
          style: form
          explode: true
          required: true
          schema:
            type: array
            minItems: 1
            maxItems: 100
            items:
              type: string
        - name: type
          in: query
          description: >-
            Edge types to include. Repeat the parameter. Empty means every
            type.


            The vocabulary is OPEN — a workspace configures its own edge types
            — so an unrecognized value is not an error here: it simply matches
            no edge. What IS refused, with a 400 `invalid_argument`, is a value
            no edge could ever carry: empty, or longer than the column.


            The filter narrows EDGES, never the named issues. An issue whose
            every edge the filter rejects is still not in `missing`.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
      security:
        - bearerToken: []
      responses:
        '200':
          description: The stored edges of the named issues.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DependencyEdges'
        '400':
          $ref: '#/components/responses/InvalidArgument'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/dependencies:count:
    get:
      operationId: countDependencyEdges
      summary: Count the dependency edges around several issues
      description: >-
        HOW MANY EDGES each named issue has in ONE named direction, after the
        type and status filters — the cardinality behind
        `GET /v0/beads/dependencies`, plus the direction that read does not
        take. Nothing is materialized: this is the operation for a caller that
        wants the numbers for issues whose edges it will never print.


        IT IS NOT THE LISTING COUNTED, and the operationId says so rather than
        pretending otherwise. The listing is OUTGOING ONLY and takes no
        direction; this one REQUIRES a direction and answers about either end.
        Naming it `countDependencies` would have promised that it sizes the set
        `listDependencies` returns, which it does only at `direction=out`.


        THE ANSWER IS PER ANCHOR, which is the other difference from the
        listing. That one flattens every issue's edges onto one array because
        the rows carry their own `issue_id`; a number does not, so folding
        these together would produce a total no caller asked for and lose the
        per-issue answer every caller wants. `anchors` carries one entry per
        DISTINCT requested id, in the order the request first named it — an
        array rather than an object, because the request's order is part of the
        answer and a keyed object would have made that ordering this surface's
        own invention.


        A NAMED ISSUE THAT DOES NOT EXIST IS NOT A 404, on
        `GET /v0/beads/dependencies`'s terms and one sharper: its entry reports
        `missing: true` with `count: 0`, and 0 is the COMMON answer here —
        most issues have no edges in at least one direction — so without that
        flag a typo would be indistinguishable from a real zero and would never
        surface. There is no `not_found` on this operation at all.


        THE COUNT SPANS BOTH DEPENDENCY PLANES and is a SUM rather than a
        distinct count of edge rows; a status-narrowed count reads the
        dependent's status from its own plane; and `status` is legal only with
        `direction=in` because an outbound edge's far end may be a row this
        database does not hold. Those are the ROLE's rules, stated once at
        `issueops.GraphCounter` and its `EdgeCountRequest`, and this document
        cites them rather than restating them — a second telling is a second
        thing to keep true.


        THERE IS NO `limit` AND NO CURSOR, for the reason
        `GET /v0/beads/dependencies` has none and `GET /v0/beads/issues:count`
        gives: a cardinality has no page, and bounding the scan would answer
        "how many of the first N". The QUESTION is bounded instead, at 100
        `issue_id` values per call — the same bound, the same number and the
        same constant as the listing on this collection, because the two bound
        the same thing and a client holding both must not have to learn two
        numbers.


        THERE IS NO `both` DIRECTION. A caller that wants the pair asks twice,
        which is what every front door in the tree already does. One call
        answering both would mean two numbers per anchor, and `status` — which
        narrows by a row only the inbound direction has — would then govern one
        of them and silently not the other.
      parameters:
        - name: issue_id
          in: query
          description: >-
            The anchors to count around. Repeat the parameter; at least one is
            required and at most 100 are accepted, and either bound is a 400
            `invalid_argument` with `param: "issue_id"`, `reason:
            "invalid_value"`.


            Each value must be an EXACT canonical issue id, for the reason
            `GET /v0/beads/dependencies` gives. A value that matches nothing is
            reported on its own anchor rather than refused. An EMPTY value is a
            400: the empty string names nothing a caller can have meant, and
            reporting it as a missing anchor would put a nameless row in an
            answer keyed by name.


            Repeats collapse onto the first mention — a second entry carries no
            second fact and would only invite a caller summing the result to
            count the same edges twice.
          style: form
          explode: true
          required: true
          schema:
            type: array
            minItems: 1
            maxItems: 100
            items:
              type: string
        - name: direction
          in: query
          required: true
          description: >-
            Which end of the edge the anchors sit on. `out` counts what each
            anchor DEPENDS ON — the direction `bd dep list` reads and the number
            `bd show` prints as the dependency count. `in` counts what depends
            on it.


            IT IS REQUIRED, and that is the one deliberate unfriendliness on
            this request. The two answers are about DIFFERENT EDGE SETS, and a
            workspace where most issues have edges in only one direction returns
            the same number for both often enough that a caller who meant the
            other one would not notice for a long time. An absent or
            unrecognized value is a 400 `invalid_argument` with
            `param: "direction"`, never a count in some default direction.


            The vocabulary is CLOSED — unlike `type` below — because it is a
            property of the edge's shape rather than of a workspace's
            configuration.
          schema:
            type: string
            enum: [out, in]
        - name: type
          in: query
          description: >-
            Edge types to include. Repeat the parameter. Empty means every
            type.


            `GET /v0/beads/dependencies`'s `type` exactly: the vocabulary is
            OPEN, so an unrecognized value is not an error and simply matches no
            edge, while a value no edge could ever carry — empty, or longer than
            the column — is a 400 `invalid_argument`.


            The filter narrows EDGES, never anchors. An anchor whose every edge
            it rejects comes back present with a count of 0, which is a
            different fact from an anchor that is not there.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: status
          in: query
          description: >-
            Count only edges whose DEPENDENT — the issue at the source end, the
            one doing the depending — is in this stored status. Empty means
            every status.


            IT IS LEGAL ONLY WITH `direction=in`. Sending it beside
            `direction=out` is a 400 `invalid_argument` with `param: "status"`
            and `reason: "invalid_value"`, rather than a filter that is quietly
            ignored. The asymmetry is the substrate's and
            `issueops.EdgeCountRequest.Status` states why: narrowing by status
            joins the far end of the edge to the row holding its status, and an
            OUTBOUND edge's far end may be an `external:` reference or an id
            belonging to another repository — rows this database does not hold
            — so the filter would silently drop every dangling edge.


            It is ONE status, not a comma-separated OR set, and it is NOT
            validated against the workspace vocabulary: an unrecognized name
            matches nothing and counts 0 rather than failing, exactly as
            `GET /v0/beads/issues:count`'s `status` does. A scripted caller
            counting a status its workspace has since dropped reads 0 and
            should keep reading 0.
          schema:
            type: string
      security:
        - bearerToken: []
      responses:
        '200':
          description: One entry per distinct anchor, in the order first named.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EdgeCounts'
        '400':
          $ref: '#/components/responses/InvalidArgument'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/dependencies/blocking:
    get:
      operationId: listBlockingAnnotations
      summary: Read the blocking decoration of several issues
      description: >-
        The DERIVED blocking summary `bd list` prints beside each row —
        `(parent: X, blocked by: Y, blocks: Z)` — for the issues named here.


        IT IS DERIVED, NOT STORED, and that is the whole difference from
        `GET /v0/beads/dependencies` next door. That operation returns the edge
        ROWS: every edge type, targets spelled exactly as stored, nothing looked
        up. This one answers a summary over TWO of those types with a rule
        applied: a `blocks` edge counts only while its blocker is open, and a
        `parent-child` edge becomes `parent` rather than a blocker. A client
        that wants the rows asks the other operation; a client that wants the
        decoration a listing shows asks this one.


        A CLOSED BLOCKER IS NOT A BLOCKER, in either direction. An issue whose
        every blocker is closed comes back with an empty `blocked_by`, and a
        CLOSED issue blocks nothing — its `blocks` is empty even where the edges
        still exist. `parent` follows the same rule: a closed parent is absent.


        A BLOCKER THIS WORKSPACE HOLDS NO ROW FOR STILL BLOCKS. An `external:`
        reference, an id in another repository's namespace and an id whose issue
        was deleted out from under its edges are all statuses this database
        cannot read, and an unreadable status is not `closed`. Hiding such a
        blocker would report work as unblocked on the strength of a row that was
        never found, so it is reported.


        THERE IS NO `missing` MEMBER, unlike `GET /v0/beads/dependencies`. This
        operation runs no existence probe: an id that names nothing and an id
        with no live blocking edges decorate identically, so the probe would be
        a read whose answer no client could act on. A client that needs to know
        whether an id exists calls `GET /v0/beads/issues/{id}`, which answers
        `404`. Every requested id gets an entry here either way.


        THERE IS NO `limit` AND NO CURSOR, for the reason
        `GET /v0/beads/dependencies` has none: the QUESTION is bounded instead,
        at 100 `issue_id` values per call, which bounds the answer without
        truncating it.
      parameters:
        - name: issue_id
          in: query
          description: >-
            The issues to annotate. Repeat the parameter; at least one is
            required and at most 100 are accepted, and either bound is a 400
            `invalid_argument` with `param: "issue_id"`, `reason:
            "invalid_value"`.


            Each value must be an EXACT canonical issue id: there is no fuzzy,
            prefix or substring resolution on this surface, for the reason
            `GET /v0/beads/issues/{id}` gives. A value that matches nothing gets
            a bare entry rather than being refused. An empty value is a 400.


            Repeats collapse: an id named twice is one entry, at the position of
            its first mention.
          style: form
          explode: true
          required: true
          schema:
            type: array
            minItems: 1
            maxItems: 100
            items:
              type: string
      security:
        - bearerToken: []
      responses:
        '200':
          description: The blocking decoration of the named issues.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BlockingAnnotations'
        '400':
          $ref: '#/components/responses/InvalidArgument'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/dependencies:add:
    post:
      operationId: addDependencies
      summary: Assert dependency edges as one act
      description: >-
        Asserts every edge in the request, or none of them.


        IT IS ALL-OR-NOTHING, and unlike a batch close that is not a policy
        choice but the shape of the question. Edges asserted together describe a
        GRAPH, and half a graph is a graph nobody asked for — the cycle a caller
        was refused for is exactly the state a partial commit would leave
        behind. A client that gets a 4xx knows nothing was written, and can fix
        its payload and resend it unchanged. There is no per-edge outcome
        because there is no outcome but the request's.


        AN EDGE IS IDEMPOTENT AT ITS OWN TYPE. A pair that already carries an
        edge of the requested type refuses nothing and is still echoed in
        `added`; a pair that carries a DIFFERENT type is a `409`
        `dependency_exists`, whichever of the two types was stored first.
        Repetition WITHIN one request answers the same way: the second
        occurrence of a pair finds the first already written, and two different
        types for one pair in one request is the same `409`.


        A TARGET NEED NOT BE AN ISSUE THIS DATABASE HOLDS. An `external:`
        reference and an id belonging to another repository are legitimate
        targets — the vocabulary is open — so only an absence this database can
        SEE is refused. A SOURCE has no such latitude: an edge follows its
        source, so a source this database holds no row for has no plane to land
        in.


        Edges are applied parent-child first regardless of request order, so the
        complete planned hierarchy is visible before any blocking edge is
        validated against it, and a whole-graph gate runs once at the end. Both
        hold ACROSS the durable and ephemeral planes: a request may mix them,
        and it is still one transaction.


        A request that wrote no genuinely new durable edge records no history
        entry, and a request made entirely of wisp-sourced edges records none
        either — an edge follows its source, and the wisp plane is not
        versioned. Each genuinely new edge records a `dependency_added` entry on
        its source's event stream, attributed to `actor`.


        Hooks do not fire and the per-command auto-commit machinery does not
        run, exactly as for `POST /v0/beads/issues/{id}:claim`. The only durable
        effect is the single storage commit the role makes in its own
        transaction.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddDependenciesRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            Every edge landed. `added` echoes the request's edges in request
            order.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AddDependenciesResponse'
        '400':
          description: >-
            Invalid request: an unknown query parameter, an unparseable or
            oversized body, an unknown body member at any level, an `actor` that
            is empty after trimming, longer than 256 bytes or carrying control
            characters, an empty or over-long `edges` array, a blank or
            over-long endpoint id, an unstorable `type`, an edge that points an
            issue at ITSELF, or an edge endpoint that names nothing this
            database can see. Nothing is written in any of these cases.


            A self-dependency is a `400` rather than a `409` because it is
            request-intrinsic: it is invalid whatever the graph holds, so it is
            a refusal of a VALUE and not a statement about state. An endpoint
            that names nothing is a `400` rather than a `404` for
            `POST /v0/beads/issues:batchCreate`'s reason: the refusal is about
            the request BODY, and there is no id in the path to have missed.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '409':
          description: >-
            The graph refuses the requested edge set, and NOTHING WAS WRITTEN.


            `dependency_cycle` means the set can never make progress: a
            scheduling cycle, or a blocking edge against the issue's own
            ancestor or descendant. The hierarchy case — and ONLY the hierarchy
            case — additionally carries `issue_id`, `blocker_id` and
            `blocker_is_ancestor`. Member PRESENCE is the discriminator: absent
            means the plain cycle refusal, present means the hierarchy one, and
            the three together are enough to rebuild the refusal whole rather
            than parse it out of prose.


            `dependency_exists` means the pair already carries an edge of a
            DIFFERENT type; `existing_type` and `requested_type` carry both.


            Neither has a force bypass. The recovery for both is to rethink the
            edge, which is why they are one status and not a retry.
          x-bd-codes: [dependency_cycle, dependency_exists]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/dependencies:remove:
    post:
      operationId: removeDependency
      summary: Remove one dependency edge
      description: >-
        Removes exactly the edge the request names — source, target — and at
        most that one edge.


        IT IS IDEMPOTENT, and `removed: false` is a SUCCESS rather than a
        refusal. Removing an edge twice leaves the same graph as removing it
        once, so an agent replaying its own teardown does not have to classify
        an error to discover it already ran. Nothing is written for it and no
        event is recorded.


        THERE IS NO `404` ON THIS OPERATION, deliberately. An edge that is not
        there is `removed: false`, and an endpoint id that names nothing holds
        no edge either, so this operation probes no id's existence and has
        nothing it could report a miss on.


        It is a collection-level custom method rather than a `DELETE`: an edge
        is named by TWO endpoints, so there is no single-segment resource path
        for a `DELETE` to address, and a `DELETE` carrying a body is the shape
        proxies mangle. `DELETE /v0/beads/memories/{key}` is a `DELETE` for the
        opposite reason — one named resource, no body.


        A removal that found its edge records a `dependency_removed` entry on
        the source's event stream, attributed to `actor`. An edge FOLLOWS ITS
        SOURCE, so a wisp-sourced edge is removed from the unversioned plane
        and leaves no durable history entry.


        Hooks do not fire and the per-command auto-commit machinery does not
        run, exactly as for `POST /v0/beads/issues/{id}:claim`. The only
        durable effect is the single storage commit the role makes in its own
        transaction.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RemoveDependencyRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            The removal ran. `removed` says whether an edge was there to
            remove.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RemoveDependencyResponse'
        '400':
          description: >-
            Invalid request: an unknown query parameter, an unparseable or
            oversized body, an unknown body member, a missing or non-string
            member, an `actor` that is empty after trimming, longer than 256
            bytes or carrying control characters, or an endpoint id that is
            empty or longer than storage holds. Nothing is removed.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/memories:
    get:
      operationId: listMemories
      summary: List the workspace's stored memories
      description: >-
        Every memory this workspace holds, optionally narrowed by `search`. It
        is the plane `bd memories` reads.


        ONLY THE MEMORY PLANE. Memories ride in the same database table as the
        workspace's settings and as the generic `bd kv` namespace, and neither
        of those appears here, whatever it contains — including a memory whose
        key SHADOWS a settings name: a memory called `issue_prefix` is a memory,
        and the workspace's real issue prefix is not one. That separation is the
        role's, pinned by its conformance contract, not a filter applied to this
        answer.


        MEMORY CONTENT IS SERVED IN FULL, and authentication does not narrow
        that. Every entry carries its value verbatim; there is no `redacted`
        member and nothing is withheld. A bearer decides WHO may call, not what
        a caller who is let in may read, so a token-protected server serves
        every memory to every holder of the token. See
        `POST /v0/beads/memories` for why a key-name heuristic would be worse
        here than no promise at all. This is the operation that makes stored
        memories DISCOVERABLE rather than merely readable by a caller who
        already knows a key — an operator binding beyond loopback is accepting
        exactly that for everyone the token admits.


        A memory stored as the EMPTY STRING is enumerated here, because its key
        exists, while `GET /v0/beads/memories/{key}` answers `404` for it. That
        asymmetry is the one way a client can tell a row stored empty from a row
        that is not there, and it is the storage seam's conflation showing
        through rather than a rule this surface invented.


        The envelope is the paginated one and `has_more` is ALWAYS false:
        memories are a keyed namespace a workspace holds tens of, not a
        collection to scan, so the whole plane comes back in one page. There is
        no `limit` and no cursor. The envelope is used anyway because entries
        are ordered by key, which makes a keyset cursor expressible later
        without a breaking change.
      parameters:
        - name: search
          in: query
          description: >-
            Narrows the answer to memories that MATCH: a memory matches when the
            lowercase of its key, or the lowercase of its value, contains the
            lowercase of this term. Absent or empty means everything, and a term
            nothing matches is a `200` with an empty `items`.


            IT IS A SUBSTRING MATCH, NOT THE `issues:query` EXPRESSION LANGUAGE,
            and it is spelled `search` rather than `q` FOR THAT REASON. On `GET
            /v0/beads/issues:query`, `q` is a boolean expression over issue
            fields that is refused when it does not parse; here there is nothing
            to parse, no vocabulary and no refusal — every string is a legal
            search term, `status=open` included, and it is matched literally.
            Two names because two questions: a client that sent this operation
            the other `q` would otherwise get a literal substring search back
            instead of an error.


            The term reaches the role UNFOLDED. Case folding is the role's, so
            that this surface and `bd memories` cannot come to disagree about
            what matching means; a client sends what its user typed.
          schema:
            type: string
      security:
        - bearerToken: []
      responses:
        '200':
          description: The stored memories, ordered by key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MemoriesPage'
        '400':
          description: >-
            Invalid request: an unknown query parameter, or a repeated
            `search`. `search` is the only parameter this operation accepts —
            `q` is NOT one of them, and is refused as unknown rather than
            treated as a search term.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'
    post:
      operationId: rememberMemory
      summary: Store one memory
      description: >-
        Stores one memory in the workspace's persistent memory plane — the
        operation behind `bd remember`. It is an UPSERT: a key that already
        holds a memory is overwritten, and `replaced` in the response reports
        which of the two happened.


        `key` is OPTIONAL, and omitting it is the normal case. The server then
        derives the key from `content` using the one derivation `bd remember`
        has used since it shipped, and the response's `key` is where the caller
        learns what to recall. A `key` that IS supplied is used verbatim: no
        trimming, no slugging, no charset restriction, because a stored key has
        to stay recallable under the exact bytes the caller used.


        MEMORY CONTENT IS SERVED IN FULL, here and on every other operation of
        this plane, and a bearer does not change that — it gates WHO may call,
        never what a caller who is let in may read. There is no `redacted`
        member and no value withholding: the settings surface's redaction is a
        heuristic over the KEY NAME, and memory keys are derived from the
        content, so the same rule would withhold a memory ABOUT tokens while
        serving one that CONTAINS a token under an innocuous slug. A promise
        that cannot be kept is not made. Do not store credentials in this plane.


        There is no `201`/`200` split. `replaced` already says whether a row
        existed, saying it twice in a second vocabulary would add nothing, and
        an upsert whose key the server may derive has no stable `Location` to
        point at.


        Hooks do not fire and the per-command auto-commit machinery does not
        run, exactly as for the other write operations on this surface. The
        only durable effect is the single storage commit the role makes in its
        own transaction — which also observes whether a previous value existed,
        so `replaced` is a statement about the row this request wrote rather
        than about a row some earlier read happened to see.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RememberRequest'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            The memory was stored. `key` is where it lives, derived or verbatim.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RememberedMemory'
        '400':
          description: >-
            Invalid request: an unknown query parameter, an unparseable or
            oversized body, an unknown body member, a missing or non-string
            `content`, a `content` that is empty after trimming, or a `content`
            from which no key can be derived when none was supplied — the last
            two the ROLE's refusals, reaching the wire with its own sentences.
            Nothing is stored in any of these cases.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/memories/{key}:
    get:
      operationId: getMemory
      summary: Get one stored memory
      description: >-
        The memory stored under one key, used verbatim: no namespace completion,
        no case folding, no dash/underscore equivalence. It is the operation
        behind `bd recall`.


        THIS OPERATION HAS A REAL 404, and it is the one place this surface
        diverges from `GET /v0/beads/config/{key}`, which deliberately has none.
        The planes genuinely differ. On the settings plane a key nothing stored
        and a key stored empty are one answer that `bd config get` prints
        identically, so a 404 would publish a distinction the server would have
        to invent. On this plane `bd recall` ALREADY distinguishes a miss, by
        exit code, and the role answers a miss as a result rather than a value
        — so a 404 reports a distinction that exists rather than minting one.


        A MEMORY STORED AS THE EMPTY STRING IS A 404 TOO. No front door can
        create one — storing empty content is refused — but an out-of-band write
        to the config table can, and the role does not invent a distinction the
        storage seam beneath it cannot see. `GET /v0/beads/memories` DOES
        enumerate such a row, because its key exists, and that asymmetry is the
        one way a client can tell the two apart.


        MEMORY CONTENT IS SERVED IN FULL, and a configured bearer does not
        narrow it: `Memory` carries no `redacted` member and withholds nothing
        from any caller the token admits. See `POST /v0/beads/memories` for why
        a key-name heuristic would be worse than no promise at all.


        KEYS THIS OPERATION CANNOT REACH. `bd remember --key` accepts any
        string, so a stored key may carry a control character — and `key` is one
        path segment, percent-decoded once, so such a key would arrive here as a
        decoded control character in a path. This operation refuses it with a
        `400` rather than looking it up, exactly as `getSetting` does. The ROLE
        stays verbatim: breaking `bd recall` of an odd key someone already
        stored, to tidy a wire rule, would be the tail wagging the dog. Such a
        memory is reachable from the CLI and from `GET /v0/beads/memories`, and
        not by path.
      parameters:
        - $ref: '#/components/parameters/MemoryKey'
      security:
        - bearerToken: []
      responses:
        '200':
          description: The stored memory.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Memory'
        '400':
          description: >-
            Invalid request: an unknown query parameter, or a `key` that is
            empty after trimming or carries a control character.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          description: >-
            This workspace holds no memory under that key — or holds one stored
            as the empty string, which answers the same way. `detail` says which
            plane was asked, not which of those two it was.
          x-bd-codes: [not_found]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'
    delete:
      operationId: forgetMemory
      summary: Forget one stored memory
      description: >-
        Removes the memory stored under one key and answers with what it held —
        the operation behind `bd forget`. IT IS DESTRUCTIVE and nothing it
        removes comes back, on a surface that has NO AUTHENTICATION: every
        process that can reach the port can erase any memory whose key it knows,
        exactly as `issues:sweep` and `issues:delete` state for beads. The
        recovery is the workspace's version control, not this API.


        THE FIRST `DELETE` METHOD ON THIS SURFACE, and the shape is why. The two
        destructive issue operations are collection-level custom methods because
        they act on a SET the request describes and carry flags that change what
        is erased — a query string is exactly where a dropped parameter widens a
        deletion. This one names ONE resource by path, carries no body and takes
        no flags, which is what the `DELETE` method already means. The
        alternative spelling `POST /v0/beads/memories/{key}:forget` would in
        addition recreate the claim route's wildcard contortion, since
        `{key}:forget` is not a router pattern, for no gain.


        REMOVING EXACTLY THE NAMED ROW is the ROLE's promise, pinned by its
        conformance contract rather than restated here: the memory plane shares
        one table with the workspace's settings and with the generic `bd kv`
        namespace, and a memory called `issue_prefix` is not the workspace's
        issue prefix.


        Forgetting a key nothing stored is a `404` and removes nothing.
        Forgetting the same key twice is therefore a `200` and then a `404`,
        which is what a retrying client actually sees; the second answer is not
        a failure to act, it is the same fact reported after the act.


        The same keys are unreachable here as on the `GET` beside it: a stored
        key carrying a control character is refused with a `400` rather than
        looked up, while the role stays verbatim, so such a memory can be
        forgotten from the CLI and not through this operation.


        Hooks do not fire, as for every write on this surface. There is no
        `dry_run`: one named row is not a set to cost first.
      parameters:
        - $ref: '#/components/parameters/MemoryKey'
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            The memory was removed. The body carries what it held, read in the
            same transaction as the deletion — so it is what was actually
            removed rather than what an earlier read happened to see.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Memory'
        '400':
          description: >-
            Invalid request: an unknown query parameter, or a `key` that is
            empty after trimming or carries a control character. Nothing is
            removed in either case.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          description: >-
            This workspace holds no memory under that key — or holds one stored
            as the empty string, which answers the same way — and NOTHING WAS
            REMOVED.
          x-bd-codes: [not_found]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/events:
    get:
      operationId: listEvents
      summary: Read the durable events journal
      description: >-
        The workspace's append-only record of every committed issue mutation,
        paged from a caller-held checkpoint. It is the HTTP form of
        `bd events tail --since`, and it exists so a hosted consumer can mirror
        or replay a workspace without shelling out to the CLI.


        THE RECORDS ARE THE CLI'S RECORDS, byte for byte. `records[]` elements
        are the same `EventRecord` objects `bd events tail` and
        `bd events export` print one per line, produced by the same projection
        and covered by the same committed golden fixture. A consumer may
        reconcile an HTTP mirror against a CLI export without a translation
        layer.


        `since` IS THE CURSOR, and it is not one this server minted. It is a
        sequence number the journal itself assigned, gapless and strictly
        increasing in commit order, so a consumer's position is durable across
        restarts on both sides and means the same thing to the CLI. Read with
        `since` set to the highest `seq` you have DURABLY PROCESSED — not the
        highest you have received — and advance it only after your own write
        lands, because this server keeps no per-consumer state and cannot
        redeliver.


        THIS OPERATION POLLS; there is no long poll and no `follow` parameter.
        `head` is what makes polling cheap to pace: when the last record's `seq`
        equals `head` you are caught up and can back off until your next
        interval. A caught-up read is a 200 with an EMPTY `records` array, never
        a 404 — "nothing new yet" is an ordinary answer about a log.


        FOR A PUSH FEED USE `watchEvents`, the sibling operation at
        `GET /v0/beads/events:watch`, which streams the same records from the
        same `since` as `text/event-stream`. It is a separate operation rather
        than a mode of this one because the two differ in media type, lifetime
        and capacity; a client that streams still needs this operation, because
        this is the one that is never refused for capacity and the one a stream
        falls back to.


        SCOPE IS PER REPLICA AND PER BRANCH, and this is the part that most
        often surprises. The journal records what THIS clone mutated on the
        branch its writer commits to. Rows arrive by direct write, never by
        merge, so `bd dolt pull` and the changes a merge settles into this
        workspace are NOT journaled — they arrived as data, and nothing here
        wrote them through the mutation seam. Each replica also has its OWN seq
        space, counted from its own first mutation: a checkpoint taken against
        one server is meaningless against another, where the same number names a
        different record and a number above that replica's head reads as
        "caught up" and stalls forever. Track a checkpoint per server URL, and
        re-baseline (a fresh export or full re-read) after a sync rather than
        carrying one across.


        NOT EVERY MUTATION IS COVERED. Raw DML through `bd sql` bypasses the
        mutation seam and is not journaled; nor are the schema migrations and
        version reconciliation that run while a store is being opened, which
        touch no bead. Dependency records are not symmetric either: `dep_add` is
        emitted for an idempotent same-type re-add that only refreshes edge
        metadata, so treat it as an upsert of the edge rather than proof the
        edge is new, and a `dep_remove` naming an edge that is already gone
        emits nothing at all.


        THIS OPERATION NEVER DELETES. Retention is the workspace's decision,
        made by `bd events prune` and by the automatic bounding that keeps an
        enabled journal inside its floors; no prune is reachable over HTTP, and
        reading a record does not acknowledge or release it.


        IT ALSO DEPENDS ON WORKSPACE STATE, alone among the operations here.
        `events.list` in `ContextResponse.capabilities` says this BUILD serves
        the operation; it does not say this workspace has a journal, because the
        journal is a per-workspace setting that is off by default. A server that
        advertises the capability and answers 409 `events_journal_disabled` to
        every request is behaving correctly. Handle that 409 as "not on this
        workspace" rather than as a fault, and do not read the capability as a
        promise that records will arrive.


        A workspace that HAS enabled the journal on a storage backend with no
        journal seam does not reach this operation at all: `bd serve` refuses to
        start, matching the refusal that opening such a workspace already
        produces. Either the server is running and this operation can answer, or
        the operator saw the failure at startup.
      parameters:
        - name: since
          in: query
          required: true
          description: >-
            Return records with `seq` strictly greater than this value. Pass `0`
            to read from the beginning of the retained journal.


            REQUIRED, and deliberately not defaulted to zero. A consumer that
            omitted its checkpoint by mistake would be served the whole retained
            window, which reads as a flood of duplicate records rather than as
            an error. A negative value is a 400 `invalid_argument` for the same
            reason `bd events tail --since` refuses one: it is almost always
            arithmetic on an empty cursor, and `seq > -5` would quietly serve
            everything as though it were a legitimate resume.


            A value at or above `head` is not an error — it is the caught-up
            case, a 200 with an empty `records` array.
          schema:
            type: integer
            format: int64
            minimum: 0
        - name: limit
          in: query
          description: >-
            Maximum number of records to return, from 1 to 10000. A value
            outside that range — `0` included — is a 400 `invalid_argument`.


            THERE IS NO UNLIMITED READ HERE, and `0` does NOT mean unlimited as
            it does on `GET /v0/beads/issues`. A caller resuming from an old
            checkpoint would otherwise ask one process to buffer the entire
            retained window — a hundred thousand records under the shipped
            `events-journal-retain-rows` floor — and encode it into a single
            response. The ceiling is unconditional and does not depend on the
            bind mode.


            The default of 1000 is deliberately much larger than the issue
            listings' 50: a journal consumer is a machine draining a backlog in
            order rather than a person reading a page, and the number that
            matters to it is round trips to catch up.


            A FULL PAGE DOES NOT MEAN THERE IS MORE, and a short one does not
            mean there is not. Compare the last record's `seq` against `head`;
            that is the only correct test, and it is why this envelope carries
            no `has_more`.
          schema:
            type: integer
            minimum: 1
            maximum: 10000
            default: 1000
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            A page of journal records in ascending `seq` order, with the
            journal head.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventsPage'
        '400':
          description: >-
            Invalid request: `since` absent, negative or unparseable, a `limit`
            outside 1..10000, or an unknown query parameter.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '409':
          description: >-
            The durable events journal is NOT ENABLED on this workspace, so it
            records nothing and never will until an operator turns it on
            (`events-journal true`, or `BD_EVENTS_JOURNAL=1` in the server's
            environment) and restarts the server.


            This refusal exists because the honest alternative does not: a
            disabled journal presents as zero rows and a head of zero, which is
            byte-identical to an enabled journal nothing has written to yet. A
            consumer given that answer would poll a workspace that will never
            produce a record and report itself caught up indefinitely. An EMPTY
            journal is the 200 — `records: []` with `head: 0` — and the two are
            distinguishable only here.


            A 409 rather than a 404 because the operation and the resource both
            exist: this is a statement about the workspace's current
            configuration, which the same request stops earning the moment it
            changes. It is a server-side fix, never a retry and never a
            client-side one.
          x-bd-codes: [events_journal_disabled]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '410':
          description: >-
            The checkpoint in `since` has fallen BELOW the retained window: the
            records that came next were pruned, and this server cannot serve
            them. Retrying the same `since` can never succeed.


            The read FAILS rather than returning an empty success or silently
            skipping ahead, and that is the whole point of the code. `WHERE seq
            > since` cannot itself distinguish "nothing new" from "your prefix
            is gone", so a consumer resuming past a prune would either stall
            forever or jump to the current floor and lose every record in
            between — both silent data loss.


            The response carries `since`, `floor` and `head`: `floor` is the
            lowest seq still retained, `head` the highest ever assigned. The
            recovery is a decision the CONSUMER makes and this server will not
            make for it — resume from `floor - 1` and accept a known gap, or
            rebuild from a full export.


            `since` IN THE RESPONSE IS NOT ALWAYS THE VALUE YOU SENT, and the
            case where it differs has a third recovery. On an interior hole in
            the retained window — a restored, hand-edited or half-copied journal
            table; nothing bd does produces one — the response `since` is the
            last seq that could be served CONTIGUOUSLY from your checkpoint, and
            everything between your checkpoint and it is intact and servable.
            This refusal does not hand it over, because a read that returned rows
            and a gap would be the silent loss the code exists to prevent. Drain
            it explicitly first: re-request with the SAME `since` you sent and
            `limit` set to `response.since - request.since`, which stops the page
            exactly at the hole and succeeds. Then resume from `floor - 1` and
            take the gap, or re-baseline. A consumer that skips straight to
            `floor - 1` loses records it could have had.


            `since` is never BELOW what you sent in any of these cases, so
            echoing it back can never re-deliver records you already have.


            Sizing the floors — `events-journal-retain-days` and
            `events-journal-retain-rows` — is how an operator decides the
            longest consumer outage the workspace can survive. They are time and
            count based and are NOT a consumer watermark: nothing here knows how
            far any consumer has got.
          x-bd-codes: [events_journal_truncated]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v0/beads/events:watch:
    get:
      operationId: watchEvents
      summary: Stream the durable events journal
      description: >-
        The same journal as `listEvents`, PUSHED: a held-open
        `text/event-stream` response that emits each committed mutation as it
        lands, so a consumer learns about a write when it happens rather than
        on its next interval.


        THE CURSOR IS STILL THE CONTRACT. This is not a subscription — the
        server keeps no per-consumer state, remembers nothing between
        connections and cannot redeliver. A stream is the reads you would have
        performed yourself, performed on your behalf, and every event carries
        `id:` set to the record's `seq`: the same number `since` takes, the same
        number `bd events tail --since` takes. Advance your own checkpoint only
        after your write lands, exactly as on the paged read.


        RECONNECTION IS THE NORMAL CASE and it is free. When a stream drops, a
        client re-requests this operation with the standard `Last-Event-ID`
        header carrying the last `seq` it processed; that header WINS over the
        `since` query parameter, which is what makes a browser's `EventSource`
        correct without any code — it re-sends the original URL, and its
        original `since` would otherwise replay everything since the consumer
        started. `since` is still required on every connect, because the header
        is absent on the first one.


        WATCH OR POLL is a real choice and the answer is usually poll. A stream
        costs a connection and a goroutine for its whole life, and this server
        holds a bounded number of them; a poller holds nothing between requests
        and can never be refused for capacity. Stream when the delay between a
        mutation and your reaction is the point — a live mirror, an agent
        waiting on a gate — and poll for anything that can afford its interval.
        A backlog is drained at read speed either way, so a stream is not a
        faster way to catch up, only a shorter wait once you have.


        THE STREAM ONLY OPENS ON A SERVABLE CURSOR. Every refusal below is an
        ordinary `application/problem+json` response with its documented status,
        decided BEFORE any stream byte — including the 410 for a checkpoint that
        has been pruned past, which is the same body `listEvents` returns for
        the same condition. There is exactly one failure a client can meet after
        the status is spent: see the `truncated` event.


        EVERYTHING THE PAGED READ SAYS ABOUT THE RECORDS APPLIES UNCHANGED —
        they are the same `EventRecord` objects from the same projection; scope
        is per replica and per branch, so a checkpoint is meaningful only
        against the server that issued it; merges and `bd sql` are not
        journaled; and `events.watch` in `ContextResponse.capabilities` says
        this BUILD serves the operation, not that this workspace has a journal.
      parameters:
        - name: since
          in: query
          required: true
          description: >-
            Emit records with `seq` strictly greater than this value. Pass `0`
            to stream from the beginning of the retained journal.


            REQUIRED on every connect, including a reconnect that also carries
            `Last-Event-ID`, and refused when negative — both for the reasons
            `listEvents` gives. When the header is present this value is
            ignored, but it is still validated: one rule, one spelling, whether
            or not the client is a browser.
          schema:
            type: integer
            format: int64
            minimum: 0
        - name: Last-Event-ID
          in: header
          required: false
          description: >-
            The last `seq` this client processed, as emitted in the `id:` field
            of a previous event. Present, it REPLACES `since` as the resume
            point.


            This is the standard SSE reconnection header and browsers attach it
            automatically, which is the whole reason it outranks the query
            parameter: an `EventSource` reconnects to the URL it was built with,
            so honoring `since` there would re-deliver every record since the
            consumer started on every reconnect.


            A NONEMPTY value that is not a non-negative 64-bit integer is a 400
            `invalid_argument` naming this header, rather than a silent fallback
            to `since`: a client that invented its own id has a broken
            checkpoint, and a stream that quietly started somewhere else would
            look correct and lose records.


            An EMPTY value is treated exactly as an absent one — `since`
            decides — because it says the same thing: no id yet. A client or
            intermediary that always sets the header sends it empty on the first
            connect, and refusing that would break the one request this header
            exists to make work.
          schema:
            type: integer
            format: int64
            minimum: 0
      security:
        - bearerToken: []
      responses:
        '200':
          description: >-
            An open event stream. It ends when the client disconnects, when the
            server shuts down, or with the `truncated` event below; there is no
            end-of-stream marker otherwise, and a client is expected to
            reconnect.


            FRAMES, in the order a client meets them:


            `retry: 3000` — the reconnection delay, stated once at the top so it
            does not depend on the client's default.


            `id: <seq>` + `data: <EventRecord>` — one record, as an UNNAMED
            (default `message`) event, so a bare `onmessage` receives it. The
            `data` payload is exactly one line: a single JSON object identical
            to an element of `listEvents`'s `records` array
            (`#/components/schemas/EventRecord`).


            `: heartbeat` — a comment line every ~20 seconds of silence. It
            carries no information and exists to keep idle connections alive
            through intermediaries; clients ignore comments by construction.


            `event: truncated` + `data: <Problem>` — the ONE in-band failure,
            emitted when a prune removes the records this stream was about to
            send. Its data is a `#/components/schemas/Problem` object with code
            `events_journal_truncated`, carrying the same `since` / `floor` /
            `head` window and the same `detail` a 410 for that condition
            carries. It is the 410's body in the same encoding, on one line
            (an SSE `data:` field cannot contain the newline an HTTP body ends
            with); `request_id` is this STREAM's id, not a reconnect's, since no
            second request has happened. Whatever parses the 410 parses this
            unchanged. The stream closes immediately after it, behind a raised
            `retry`.


            TREAT `truncated` AS STOP-AND-RE-BASELINE. The recovery is the
            410's: resume from `floor - 1` and accept a known gap, or rebuild
            from a full export. It is deliberately a NAMED event, so a client
            that registered only `onmessage` never mistakes it for a record —
            but such a client will simply reconnect and then meet a
            connect-time 410 on every attempt, which is why the delay is raised
            to a minute first. A consumer that ignores this event does not lose
            records silently; it stalls loudly.
          content:
            text/event-stream:
              schema:
                type: string
        '400':
          description: >-
            Invalid request: `since` absent, negative or unparseable, a
            `Last-Event-ID` header that is not a non-negative sequence number,
            or an unknown query parameter.
          x-bd-codes: [invalid_argument]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '409':
          description: >-
            The durable events journal is NOT ENABLED on this workspace, so
            there is nothing to stream and never will be until an operator turns
            it on (`events-journal true`, or `BD_EVENTS_JOURNAL=1` in the
            server's environment) and restarts the server. Identical in every
            respect to `listEvents`'s 409, and refused before the stream opens.
          x-bd-codes: [events_journal_disabled]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '410':
          description: >-
            The resume point — `Last-Event-ID` if present, otherwise `since` —
            has fallen BELOW the retained window, so the records that came next
            were pruned and this server cannot serve them. The body is
            `listEvents`'s 410 exactly, carrying the same `since` / `floor` /
            `head` window and the same recoveries, and no stream is opened.


            A CLIENT THAT RECONNECTS BLINDLY LOOPS HERE. This is the status a
            consumer earns after ignoring a `truncated` event, and retrying with
            the same id can never succeed; re-baseline instead.
          x-bd-codes: [events_journal_truncated]
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: >-
            The stream could not be opened right now and may be retried:
            `events_watch_saturated` (this server is already holding as many
            concurrent streams as it will), `db_unavailable`, or `busy`.


            `events_watch_saturated` is the one code here that is about
            CONNECTIONS rather than data, and the one with a recovery that is
            not waiting: `GET /v0/beads/events` answers the same records from
            the same checkpoint and is never refused for this reason. The cap
            exists because a stream is the only request on this server that can
            last hours, and streams are held until their consumers leave — so
            `Retry-After` here is a human-scale hint, not a request-scale one.
          x-bd-codes: [busy, db_unavailable, events_watch_saturated]
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'

components:

  securitySchemes:
    bearerToken:
      type: http
      scheme: bearer
      description: >-
        A shared bearer token from the server's token file. It carries no
        identity and no scopes: presenting an accepted token grants every
        operation. Which token is presented is never observable in a response.

  parameters:
    SettingKey:
      name: key
      in: path
      required: true
      description: >-
        Exact setting key, used verbatim. Keys routinely contain dots
        (`status.custom`); they occupy one path segment and are
        percent-decoded once.
      schema:
        type: string
        minLength: 1
    MemoryKey:
      name: key
      in: path
      required: true
      description: >-
        Exact memory key, used verbatim. It occupies one path segment and is
        percent-decoded once. Keys may contain spaces, dots and unicode — the
        plane stores what `bd remember --key` was given — and a key carrying a
        CONTROL character is refused here rather than looked up; see the
        operation description.
      schema:
        type: string
        minLength: 1
    IssueID:
      name: id
      in: path
      required: true
      description: >-
        Exact canonical issue id. No fuzzy, prefix or substring resolution.
      schema:
        type: string

  responses:
    Unauthenticated:
      description: >-
        The bearer credential was missing, malformed, or is not one this server
        accepts. Emitted only by a server configured with a token file; one
        without never emits it. `detail` is a fixed string, which is what
        guarantees the presented credential is never echoed back to the client
        or into any log between here and the caller.
      x-bd-codes: [unauthenticated]
      headers:
        WWW-Authenticate:
          description: The challenge, always `Bearer`.
          schema:
            type: string
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    InvalidArgument:
      description: >-
        Invalid request: an unknown query parameter, or a malformed parameter
        value.
      x-bd-codes: [invalid_argument]
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    NotFound:
      description: No issue or wisp with that id.
      x-bd-codes: [not_found]
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    InternalError:
      description: >-
        Unexpected server-side failure. `detail` is a fixed string per code:
        the underlying error goes to the server log only, because driver and
        dial errors routinely embed the database DSN, user and host:port.
      x-bd-codes: [internal]
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Unavailable:
      description: >-
        The request could not be served right now and may be retried:
        `db_unavailable` (the database is unreachable) or `busy` (write
        contention exhausted the transaction retry budget, or the in-flight
        request limit was saturated). `detail` is a fixed string per code, for
        the same reason as 500.
      x-bd-codes: [busy, db_unavailable]
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'

  schemas:

    # ---------------------------------------------------------------------
    # Pinned schemas.
    #
    # Each of the thirteen below is `x-go-type`-pinned to the canonical Go struct,
    # so the generator emits a type ALIAS and no mirror struct exists anywhere.
    # `types.Issue`'s JSON encoding IS this contract; the CLI's `--json`, the
    # JSONL interchange and these bodies all marshal the same struct.
    #
    # They therefore repeat each other's properties rather than composing with
    # `allOf` — see the codegen trap documented at the top of this file. The
    # repetition is machine-checked in both directions by
    # TestWireTagBijection (internal/httpapi/wire_bijection_test.go): a field
    # added to the Go struct without an entry here fails CI, and an entry here
    # with no Go field fails CI.
    #
    # Nine of the thirteen are pinned to `internal/types`. The other four are
    # pinned where their canonical struct actually lives: `CycleMember`,
    # `Cycle` and `IssueBlocking`, which appear after `Statistics`, to the role
    # package `issueops`; `EventRecord`, last of the group, to
    # `internal/eventsjournal`. TestWireTagBijection's `pinnedSchemas` table is
    # the enumeration that is machine-checked; the counts here are prose and go
    # stale in silence, so a schema added to that table updates them too.
    # ---------------------------------------------------------------------

    Issue:
      type: object
      description: >-
        A tracked work item. Property semantics documented here apply to every
        schema that repeats them below.
      x-go-type: types.Issue
      x-go-type-import:
        name: types
        path: github.com/steveyegge/beads/internal/types
      required: [id, title, priority, created_at, updated_at]
      properties:
        id:
          type: string
          description: Canonical issue id, e.g. `bd-abc`.
        title:
          type: string
        description:
          type: string
        design:
          type: string
        acceptance_criteria:
          type: string
        notes:
          type: string
        spec_id:
          type: string
        status:
          type: string
          description: >-
            `open`, `in_progress`, `blocked`, `closed`, or any custom status
            configured for the workspace. NOT a closed vocabulary: clients must
            treat an unrecognized value as "some status I do not know".
        priority:
          type: integer
          description: 0 (most urgent) through 4. Always present; 0 is a real value.
        issue_type:
          type: string
          description: >-
            `bug`, `feature`, `task`, `epic`, `chore`, `gate`, and workspace
            configured types. Not a closed vocabulary.
        is_blocked:
          type: boolean
          description: >-
            The persisted readiness projection — true when an open blocking
            dependency keeps this issue out of the ready set. Derived from the
            dependency graph; never set by a client.


            NOT POPULATED by the /v0 read endpoints today: they hydrate issues
            without it, so the field is absent from their responses and its
            absence says nothing about whether an issue is blocked. Use
            `/v0/beads/ready` or the dependency endpoints to answer that. The
            field is carried by events-journal snapshots, which is where it is
            read back, and it is documented here because those snapshots
            serialize the same type this schema is pinned to.
        assignee:
          type: string
          description: The actor currently holding the issue, if any.
        owner:
          type: string
        estimated_minutes:
          type: integer
        created_at:
          type: string
          format: date-time
        created_by:
          type: string
        updated_at:
          type: string
          format: date-time
        started_at:
          type: string
          format: date-time
        closed_at:
          type: string
          format: date-time
        close_reason:
          type: string
        closed_by_session:
          type: string
        lease_expires_at:
          type: string
          format: date-time
          description: Expiry of the current claim's lease, when one is active.
        heartbeat_at:
          type: string
          format: date-time
        lease_granted_node:
          type: string
          description: >-
            The replica that granted the lease. Empty means the provenance is
            unknown, which is treated as local.
        due_at:
          type: string
          format: date-time
        defer_until:
          type: string
          format: date-time
        external_ref:
          type: string
        source_system:
          type: string
        metadata:
          type: object
          additionalProperties: true
          description: >-
            Arbitrary caller-supplied JSON object. VALUES MAY BE OF ANY JSON
            TYPE — string, number, boolean, array or nested object — because
            typed values enter through the explicit JSON metadata path and
            persist in older rows. Clients MUST NOT decode this into a
            string-to-string map; a strict decode fails on the first typed
            value and takes the whole response with it. That prohibition is on
            assuming a narrower type when reading THIS document: a profile MAY
            declare a narrower value schema under the rules in **Profiles**,
            and a client of that profile may rely on the profile's declaration.


            The object-at-top-level shape is the contract every producer and
            every metadata filter (`metadata_field`, `has_metadata_key`)
            assumes, but be aware the store validates only that the value is
            WELL-FORMED JSON: a row created with a non-object (e.g.
            `bd create --metadata '[1,2]'`) can carry an array or a scalar
            here. That is a storage-side gap, not licence for a producer to
            emit one — but a tolerant client should skip such a row rather than
            fail the whole response.
        compaction_level:
          type: integer
        compacted_at:
          type: string
          format: date-time
        compacted_at_commit:
          type: string
        original_size:
          type: integer
        labels:
          type: array
          items:
            type: string
        dependencies:
          type: array
          description: >-
            Dependency edges. Populated in export/interchange contexts; the
            detail endpoint returns richer entries — see `IssueDetails`.
          items:
            $ref: '#/components/schemas/Dependency'
        comments:
          type: array
          items:
            $ref: '#/components/schemas/Comment'
        sender:
          type: string
        ephemeral:
          type: boolean
          description: Ephemeral records are not synced.
        no_history:
          type: boolean
        wisp_type:
          type: string
        storage_class:
          type: string
          description: >-
            The record's history/replication contract. Empty means unset, which
            resolves to ephemeral for wisp-plane records and versioned
            otherwise.
        pinned:
          type: boolean
        is_template:
          type: boolean
        bonded_from:
          type: array
          items:
            $ref: '#/components/schemas/BondRef'
        await_type:
          type: string
          description: Gate condition type.
        await_id:
          type: string
        timeout:
          type: integer
          format: int64
          description: >-
            NANOSECONDS. This is a Go `time.Duration`, which encodes as an
            int64 nanosecond count — a pre-existing wire fact, not a choice.
        waiters:
          type: array
          items:
            type: string
        source_formula:
          type: string
        source_location:
          type: string
        mol_type:
          type: string
        work_type:
          type: string
        event_kind:
          type: string
        actor:
          type: string
          description: >-
            For event records, the entity that caused the event. Unrelated to
            the claiming actor in `ClaimRequest`.
        target:
          type: string
        payload:
          type: string

    IssueWithCounts:
      type: object
      description: >-
        An `Issue` plus relationship cardinalities. This is the element type of
        both `/v0/beads/ready` and `/v0/beads/issues`, matching what
        `bd ready --json` and `bd list --json` emit. Property semantics are
        documented on `Issue`.
      x-go-type: types.IssueWithCounts
      x-go-type-import:
        name: types
        path: github.com/steveyegge/beads/internal/types
      required:
        [id, title, priority, created_at, updated_at, dependency_count, dependent_count, comment_count]
      properties:
        # --- repeated from Issue (see the pinned-schema note above) ---
        id: { type: string }
        title: { type: string }
        description: { type: string }
        design: { type: string }
        acceptance_criteria: { type: string }
        notes: { type: string }
        spec_id: { type: string }
        status: { type: string }
        priority: { type: integer }
        issue_type: { type: string }
        is_blocked: { type: boolean }
        assignee: { type: string }
        owner: { type: string }
        estimated_minutes: { type: integer }
        created_at: { type: string, format: date-time }
        created_by: { type: string }
        updated_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time }
        closed_at: { type: string, format: date-time }
        close_reason: { type: string }
        closed_by_session: { type: string }
        lease_expires_at: { type: string, format: date-time }
        heartbeat_at: { type: string, format: date-time }
        lease_granted_node: { type: string }
        due_at: { type: string, format: date-time }
        defer_until: { type: string, format: date-time }
        external_ref: { type: string }
        source_system: { type: string }
        metadata: { type: object, additionalProperties: true }
        compaction_level: { type: integer }
        compacted_at: { type: string, format: date-time }
        compacted_at_commit: { type: string }
        original_size: { type: integer }
        labels: { type: array, items: { type: string } }
        dependencies:
          type: array
          items: { $ref: '#/components/schemas/Dependency' }
        comments:
          type: array
          items: { $ref: '#/components/schemas/Comment' }
        sender: { type: string }
        ephemeral: { type: boolean }
        no_history: { type: boolean }
        wisp_type: { type: string }
        storage_class: { type: string }
        pinned: { type: boolean }
        is_template: { type: boolean }
        bonded_from:
          type: array
          items: { $ref: '#/components/schemas/BondRef' }
        await_type: { type: string }
        await_id: { type: string }
        timeout: { type: integer, format: int64 }
        waiters: { type: array, items: { type: string } }
        source_formula: { type: string }
        source_location: { type: string }
        mol_type: { type: string }
        work_type: { type: string }
        event_kind: { type: string }
        actor: { type: string }
        target: { type: string }
        payload: { type: string }
        # --- added by IssueWithCounts ---
        dependency_count:
          type: integer
          description: Number of issues this one depends on.
        dependent_count:
          type: integer
          description: Number of issues that depend on this one.
        comment_count:
          type: integer
        parent:
          type: string
          description: Parent issue id, computed from the parent-child edge.

    IssueDetails:
      type: object
      description: >-
        An `Issue` with its labels, dependency edges and cardinalities — the
        body of `GET /v0/beads/issues/{id}`. `dependencies` and `dependents`
        carry FULL issue objects plus the edge type, not bare edges. Property
        semantics are documented on `Issue`.
      x-go-type: types.IssueDetails
      x-go-type-import:
        name: types
        path: github.com/steveyegge/beads/internal/types
      required: [id, title, priority, created_at, updated_at, revision]
      properties:
        # --- repeated from Issue (see the pinned-schema note above) ---
        id: { type: string }
        title: { type: string }
        description: { type: string }
        design: { type: string }
        acceptance_criteria: { type: string }
        notes: { type: string }
        spec_id: { type: string }
        status: { type: string }
        priority: { type: integer }
        issue_type: { type: string }
        is_blocked: { type: boolean }
        assignee: { type: string }
        owner: { type: string }
        estimated_minutes: { type: integer }
        created_at: { type: string, format: date-time }
        created_by: { type: string }
        updated_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time }
        closed_at: { type: string, format: date-time }
        close_reason: { type: string }
        closed_by_session: { type: string }
        lease_expires_at: { type: string, format: date-time }
        heartbeat_at: { type: string, format: date-time }
        lease_granted_node: { type: string }
        due_at: { type: string, format: date-time }
        defer_until: { type: string, format: date-time }
        external_ref: { type: string }
        source_system: { type: string }
        metadata: { type: object, additionalProperties: true }
        compaction_level: { type: integer }
        compacted_at: { type: string, format: date-time }
        compacted_at_commit: { type: string }
        original_size: { type: integer }
        sender: { type: string }
        ephemeral: { type: boolean }
        no_history: { type: boolean }
        wisp_type: { type: string }
        storage_class: { type: string }
        pinned: { type: boolean }
        is_template: { type: boolean }
        bonded_from:
          type: array
          items: { $ref: '#/components/schemas/BondRef' }
        await_type: { type: string }
        await_id: { type: string }
        timeout: { type: integer, format: int64 }
        waiters: { type: array, items: { type: string } }
        source_formula: { type: string }
        source_location: { type: string }
        mol_type: { type: string }
        work_type: { type: string }
        event_kind: { type: string }
        actor: { type: string }
        target: { type: string }
        payload: { type: string }
        # --- shape of these three differs from Issue's ---
        labels:
          type: array
          items: { type: string }
        dependencies:
          type: array
          description: Issues this one depends on, each carrying its edge type.
          items: { $ref: '#/components/schemas/IssueWithDependencyMetadata' }
        comments:
          type: array
          description: >-
            The issue's comment bodies. Populated only when the request sets
            `getIssue`'s `include_comments`; absent otherwise, which is what
            `comment_count` and `comments_omitted` report about.
          items: { $ref: '#/components/schemas/Comment' }
        # --- added by IssueDetails ---
        dependents:
          type: array
          description: >-
            Issues that depend on this one, each carrying its edge type.
            Populated only when the request sets `getIssue`'s
            `include_dependents`; absent otherwise, where `dependent_count` is
            the cardinality.
          items: { $ref: '#/components/schemas/IssueWithDependencyMetadata' }
        parent:
          type: string
          description: Parent issue id, computed from the parent-child edge.
        dependency_count:
          type: integer
          format: int64
        dependent_count:
          type: integer
          format: int64
        comment_count:
          type: integer
          format: int64
        comments_omitted:
          type: boolean
          description: >-
            True when `comment_count` is nonzero and `comments` was left out —
            every such issue on a request that did not set `include_comments`.
            Without it, an absent `comments` key is ambiguous between "no
            comments" and "comments not included in this response".
        epic_total_children:
          type: integer
        epic_closed_children:
          type: integer
        epic_closeable:
          type: boolean
        revision:
          type: integer
          format: int64
          description: >-
            The row's optimistic-concurrency token, and THE READ THAT SOURCES A
            GUARD. Every `expected_version` on this surface is composed from a
            token some response carried; until this member existed the only
            responses that carried one were WRITES, so a caller's first guarded
            write had to be preceded by a write it did not want to make. This
            is that member, and it is the one the write responses' "when a read
            publishes one, this member is what it will agree with" was written
            against.


            IT IS EQUALITY-ONLY: compare it, never order or interpret it. A
            change signals the row was mutated since you read it, and nothing
            more — it is a random value the engine rewrites, not a counter.
            `ApplyItemResult.revision` states the full rule and this member
            carries it verbatim.


            ITS COVERAGE IS PARTIAL, on the same inherited terms: the token is
            rewritten by claim, close, unclaim and the generic update path, and
            NOT by the direct-update paths that rewrite text without touching
            it. A client needing complete change detection combines it with
            `updated_at`, `status` and the label set.


            It is ALWAYS PRESENT, including as 0 — a legacy row backfilled and
            not mutated since — because an absent member would be ambiguous
            between a legacy-zero row and a server that does not publish the
            token.


            DECODE IT AS A 64-BIT INTEGER, for the reason
            `UpdateIssueRequest.expected_version` spells out: an IEEE-754-double
            parser corrupts it silently, and the corruption only shows up as a
            `precondition_failed` on the NEXT request.


            IT IS THE ANCHOR ROW'S ALONE. The issues nested under
            `dependencies` and `dependents` are `IssueWithDependencyMetadata`
            and carry no token: a caller that means to guard a NEIGHBOUR reads
            that neighbour with its own `GET /v0/beads/issues/{id}`, and a
            token lifted off an embedded relation would be one the response
            never promised was fresh.


            IT IS NOT ON THE LIST ROWS, and that is a decision rather than an
            oversight — see `GET /v0/beads/issues`.

    IssueWithDependencyMetadata:
      type: object
      description: >-
        A full issue plus the type of the dependency edge that led to it.
        Property semantics are documented on `Issue`.
      x-go-type: types.IssueWithDependencyMetadata
      x-go-type-import:
        name: types
        path: github.com/steveyegge/beads/internal/types
      required: [id, title, priority, created_at, updated_at, dependency_type]
      properties:
        # --- repeated from Issue (see the pinned-schema note above) ---
        id: { type: string }
        title: { type: string }
        description: { type: string }
        design: { type: string }
        acceptance_criteria: { type: string }
        notes: { type: string }
        spec_id: { type: string }
        status: { type: string }
        priority: { type: integer }
        issue_type: { type: string }
        is_blocked: { type: boolean }
        assignee: { type: string }
        owner: { type: string }
        estimated_minutes: { type: integer }
        created_at: { type: string, format: date-time }
        created_by: { type: string }
        updated_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time }
        closed_at: { type: string, format: date-time }
        close_reason: { type: string }
        closed_by_session: { type: string }
        lease_expires_at: { type: string, format: date-time }
        heartbeat_at: { type: string, format: date-time }
        lease_granted_node: { type: string }
        due_at: { type: string, format: date-time }
        defer_until: { type: string, format: date-time }
        external_ref: { type: string }
        source_system: { type: string }
        metadata: { type: object, additionalProperties: true }
        compaction_level: { type: integer }
        compacted_at: { type: string, format: date-time }
        compacted_at_commit: { type: string }
        original_size: { type: integer }
        labels: { type: array, items: { type: string } }
        dependencies:
          type: array
          items: { $ref: '#/components/schemas/Dependency' }
        comments:
          type: array
          items: { $ref: '#/components/schemas/Comment' }
        sender: { type: string }
        ephemeral: { type: boolean }
        no_history: { type: boolean }
        wisp_type: { type: string }
        storage_class: { type: string }
        pinned: { type: boolean }
        is_template: { type: boolean }
        bonded_from:
          type: array
          items: { $ref: '#/components/schemas/BondRef' }
        await_type: { type: string }
        await_id: { type: string }
        timeout: { type: integer, format: int64 }
        waiters: { type: array, items: { type: string } }
        source_formula: { type: string }
        source_location: { type: string }
        mol_type: { type: string }
        work_type: { type: string }
        event_kind: { type: string }
        actor: { type: string }
        target: { type: string }
        payload: { type: string }
        # --- added by IssueWithDependencyMetadata ---
        dependency_type:
          type: string
          description: >-
            The edge type, e.g. `blocks`, `parent-child`, `related`,
            `discovered-from`. Not a closed vocabulary.

    TreeNode:
      type: object
      description: >-
        One node of a walked dependency tree: a full issue plus where the walk
        reached it. Property semantics for the issue members are documented on
        `Issue`.


        The tree is FLAT. A node's place in it is read from `depth` and
        `parent_id`, not from nesting, and a subtree is contiguous in `items`.
      x-go-type: types.TreeNode
      x-go-type-import:
        name: types
        path: github.com/steveyegge/beads/internal/types
      required: [id, title, priority, created_at, updated_at, depth, parent_id, truncated]
      properties:
        # --- repeated from Issue (see the pinned-schema note above) ---
        id: { type: string }
        title: { type: string }
        description: { type: string }
        design: { type: string }
        acceptance_criteria: { type: string }
        notes: { type: string }
        spec_id: { type: string }
        status: { type: string }
        priority: { type: integer }
        issue_type: { type: string }
        is_blocked: { type: boolean }
        assignee: { type: string }
        owner: { type: string }
        estimated_minutes: { type: integer }
        created_at: { type: string, format: date-time }
        created_by: { type: string }
        updated_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time }
        closed_at: { type: string, format: date-time }
        close_reason: { type: string }
        closed_by_session: { type: string }
        lease_expires_at: { type: string, format: date-time }
        heartbeat_at: { type: string, format: date-time }
        lease_granted_node: { type: string }
        due_at: { type: string, format: date-time }
        defer_until: { type: string, format: date-time }
        external_ref: { type: string }
        source_system: { type: string }
        metadata: { type: object, additionalProperties: true }
        compaction_level: { type: integer }
        compacted_at: { type: string, format: date-time }
        compacted_at_commit: { type: string }
        original_size: { type: integer }
        labels: { type: array, items: { type: string } }
        dependencies:
          type: array
          items: { $ref: '#/components/schemas/Dependency' }
        comments:
          type: array
          items: { $ref: '#/components/schemas/Comment' }
        sender: { type: string }
        ephemeral: { type: boolean }
        no_history: { type: boolean }
        wisp_type: { type: string }
        storage_class: { type: string }
        pinned: { type: boolean }
        is_template: { type: boolean }
        bonded_from:
          type: array
          items: { $ref: '#/components/schemas/BondRef' }
        await_type: { type: string }
        await_id: { type: string }
        timeout: { type: integer, format: int64 }
        waiters: { type: array, items: { type: string } }
        source_formula: { type: string }
        source_location: { type: string }
        mol_type: { type: string }
        work_type: { type: string }
        event_kind: { type: string }
        actor: { type: string }
        target: { type: string }
        payload: { type: string }
        # --- added by TreeNode ---
        depth:
          type: integer
          description: >-
            How far the walk was from the root when it reached this node. The
            root is 0.


            For a `both` walk each half measures its own depth from the root, so
            a depth does not say which half a node came from and nothing else
            on the node does either.
        parent_id:
          type: string
          description: >-
            The node this one was reached FROM, empty on the root. A node is
            reached ONCE per walk, by the first path that got there, so a shared
            child of two parents names only the first of them here and the
            second has no visible edge to it.
        edge_from_parent:
          type: string
          description: >-
            The dependency type of the edge that led here, e.g. `blocks`,
            `parent-child`, `discovered-from`. Absent on the root. Not a closed
            vocabulary.
        truncated:
          type: boolean
          description: >-
            ALWAYS FALSE. No implementation sets it: a node beyond `max_depth`
            is ABSENT from the answer rather than present and flagged, so a
            client cannot tell a tree that ended from one that was cut. It is
            documented rather than removed because it is on the wire today.

    Dependency:
      type: object
      description: A dependency edge between two issues.
      x-go-type: types.Dependency
      x-go-type-import:
        name: types
        path: github.com/steveyegge/beads/internal/types
      required: [issue_id, depends_on_id, type, created_at]
      properties:
        id: { type: string }
        issue_id: { type: string }
        depends_on_id: { type: string }
        type:
          type: string
          description: Edge type. Not a closed vocabulary.
        created_at: { type: string, format: date-time }
        created_by: { type: string }
        metadata:
          type: string
          description: Free-form edge annotation. A STRING, unlike `Issue.metadata`.
        thread_id: { type: string }

    Comment:
      type: object
      x-go-type: types.Comment
      x-go-type-import:
        name: types
        path: github.com/steveyegge/beads/internal/types
      required: [id, issue_id, author, text, created_at]
      properties:
        id: { type: string }
        issue_id: { type: string }
        author: { type: string }
        text: { type: string }
        created_at: { type: string, format: date-time }

    AddCommentRequest:
      type: object
      additionalProperties: false
      required: [author, text]
      description: >-
        One comment to append. The issue is named by the path, so it is not a
        member here: a body carrying it too would give one request two spellings
        of one anchor and a question about what to do when they disagree.
      properties:
        author:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Who is signing the comment. CALLER-ASSERTED, and not the
            authenticated principal — see the operation description.


            Trimmed of surrounding space, then refused when the result is empty,
            when it exceeds 256 bytes or 255 characters (the storage column),
            or when it carries a control character. The bounds and the character
            rule are `actor`'s, unchanged, because the value lands in a column
            of the same width that every renderer of the thread prints, where an
            unfiltered C1 introducer is an escape-sequence payload.
        text:
          type: string
          description: >-
            The comment body, stored VERBATIM: newlines, surrounding space and
            unicode all survive, and nothing trims the value that lands in the
            row.


            NO LENGTH BOUND AND NO CHARACTER RULE, unlike `author` beside it,
            and both absences are the column: this one is `LONGTEXT` rather than
            a 255-character field, and a comment that is a stack trace or a diff
            is an ordinary comment. The only cap is the 1 MiB every body on this
            surface shares.


            BOTH PLANES AGREE ABOUT THAT, which is worth stating because they
            did not. `wisp_comments.text` was left `TEXT` — 65535 bytes — when
            the durable column was widened, so a comment past that limit wrote
            fine against an issue and failed against a wisp, on an operation
            that resolves its anchor across both planes deliberately. A caller
            therefore could not know which side of the bound it was on until the
            write failed. The ephemeral column is widened to match, so this
            member's bound is one number rather than two.


            Blank after trimming is a `400` — a comment of nothing but
            whitespace carries no information and is almost always a shell
            quoting accident — and blankness is judged on a TRIMMED COPY while
            the stored value is untrimmed, so a comment that merely begins with
            a newline is a comment.

    BondRef:
      type: object
      description: A constituent of a compound molecule.
      x-go-type: types.BondRef
      x-go-type-import:
        name: types
        path: github.com/steveyegge/beads/internal/types
      required: [source_id, bond_type]
      properties:
        source_id: { type: string }
        bond_type: { type: string }
        bond_point: { type: string }

    Statistics:
      type: object
      description: >-
        Workspace summary counts. Two of them are DEPENDENCY-AWARE and two are
        structurally always zero; both facts are stated on the properties
        themselves, because every number here is the same JSON type and nothing
        else on the wire distinguishes them.


        This is the struct `bd status --json` marshals under `summary`, pinned
        so the two surfaces are one compatibility domain.
      x-go-type: types.Statistics
      x-go-type-import:
        name: types
        path: github.com/steveyegge/beads/internal/types
      required:
        - total_issues
        - open_issues
        - in_progress_issues
        - closed_issues
        - blocked_issues
        - deferred_issues
        - ready_issues
        - pinned_issues
        - epics_eligible_for_closure
        - average_lead_time_hours
      properties:
        total_issues:
          type: integer
          description: >-
            Every row in the workspace-wide answer's scan, INCLUDING closed and
            pinned ones. The status counts below do not sum to it: a row whose
            status is none of the four falls into no bucket, and `pinned_issues`
            counts a flag that overlaps all of them.
        open_issues:
          type: integer
        in_progress_issues:
          type: integer
        closed_issues:
          type: integer
        deferred_issues:
          type: integer
        blocked_issues:
          type: integer
          nullable: true
          description: >-
            Rows the dependency graph blocks — the transitive is_blocked flag,
            NOT the count of rows whose status is `blocked`. An open row with an
            unfinished blocker is counted here and its status is still `open`.


            NULL when `skip_blocked` was honored, always together with
            `ready_issues`. The two are nil together or populated together;
            there is no state in which one is knowable and the other is not.


            When `assignee` is set this is a different number: the count of that
            actor's rows whose STATUS is `blocked`, and never null.
        ready_issues:
          type: integer
          nullable: true
          description: >-
            ARITHMETIC, not a query: `open_issues` minus `blocked_issues`,
            clamped at zero. It is NOT the cardinality of `GET /v0/beads/ready`,
            which applies type exclusions, the deferral window and a limit that
            none of this touches.


            NULL under the same conditions as `blocked_issues`.


            When `assignee` is set this is the real ready-work count for that
            actor, and never null.
        pinned_issues:
          type: integer
          description: >-
            Rows carrying the pinned flag, overlapping every status bucket.
            Always 0 when `assignee` is set: that answer tallies the five
            statuses and nothing else.
        epics_eligible_for_closure:
          type: integer
          description: >-
            ALWAYS 0. No implementation computes it, on any backend or either
            surface. It is documented rather than dropped because this schema is
            pinned to the struct both surfaces marshal, and a caller reading a 0
            here is reading an absent computation rather than an answer.
        average_lead_time_hours:
          type: number
          description: ALWAYS 0, for the reason above.
    # The two cycle schemas are pinned like the seven above, and to the same end,
    # but to the canonical types in `issueops` rather than in `internal/types`:
    # the role's own result IS the CLI's `--json` element, so `bd dep cycles`
    # and this operation cannot drift apart. TestWireTagBijection covers them on
    # the same terms and reads each schema's declared import path.

    CycleMember:
      type: object
      description: >-
        One node on a dependency cycle.


        `id` is always present, and its presence is what proves the node is on
        the cycle. `issue` is the row behind it, and is ABSENT — never null —
        when this workspace holds no record for that id: a target in another
        repository's namespace, an `external:` reference, or a row whose edges
        outlived it. That absence means the node cannot be DESCRIBED here, never
        that it is not really on the cycle.


        `issue` is spelled as a bare `$ref` with no sibling keywords, following
        the codegen note at the top of this document.
      x-go-type: issueops.CycleMember
      x-go-type-import:
        name: issueops
        path: github.com/steveyegge/beads/issueops
      required: [id]
      properties:
        id:
          type: string
        issue:
          $ref: '#/components/schemas/Issue'

    Cycle:
      type: object
      description: >-
        One circular blocking dependency: its members in EDGE ORDER, so
        `members[i]` blocks on `members[i+1]` and the last member blocks on the
        first. The closing edge is implied and is not repeated as a final member.


        The rotation is canonical — the lowest id comes first — which is what
        makes two snapshots of an unchanged workspace comparable.
      x-go-type: issueops.Cycle
      x-go-type-import:
        name: issueops
        path: github.com/steveyegge/beads/issueops
      required: [members, partial]
      properties:
        members:
          type: array
          description: The nodes, in edge order, starting at the lowest id. Never empty.
          items:
            $ref: '#/components/schemas/CycleMember'
        partial:
          type: boolean
          description: >-
            True when at least one member has no `issue`. It is always present,
            including when false: a consumer must be able to read "this path is
            complete" from the answer rather than from the absence of a key.


            `members` is complete either way. This flag says the DESCRIPTIONS
            beside the ids are not.

    # Pinned to `issueops` for the reason the cycle pair is: the role's own
    # result IS what the CLI renders, so `bd list`'s decoration and this
    # operation cannot drift apart. TestWireTagBijection covers it on the same
    # terms and reads this schema's declared import path.
    IssueBlocking:
      type: object
      description: >-
        One issue's derived blocking decoration.


        `blocked_by` and `blocks` are ASCENDING BY ID with repeats collapsed,
        and both are always present — an empty array, never null and never
        absent, so a client reads "nothing blocks this" from the answer rather
        than from a missing key. `parent` is absent when the issue has none and
        when the parent it has is closed.
      x-go-type: issueops.IssueBlocking
      x-go-type-import:
        name: issueops
        path: github.com/steveyegge/beads/issueops
      required: [id, blocked_by, blocks]
      properties:
        id:
          type: string
          description: The annotated id, spelled exactly as the request spelled it.
        blocked_by:
          type: array
          description: >-
            The OPEN issues this one is blocked by: the targets of its `blocks`
            edges whose own status is not closed.
          items:
            type: string
        blocks:
          type: array
          description: >-
            The issues this one blocks. Empty when this issue is itself closed,
            which is the same rule `blocked_by` applies from the other end.
          items:
            type: string
        parent:
          type: string
          description: >-
            This issue's parent id. AT MOST ONE is reported; where an issue
            carries several `parent-child` edges, which one appears here is not
            specified. A client that needs every structural edge reads them from
            `GET /v0/beads/dependencies`.

    # ---------------------------------------------------------------------
    # Envelope schemas. Almost all of these have no canonical Go type behind
    # them: they are new wire surface, generated normally, and the generated
    # type is the only implementation — so there is nothing for them to drift
    # against.
    #
    # `EventRecord`, further down this region, is the exception and is pinned
    # to `eventsjournal.Record`: the journal's published envelope is shared with
    # `bd events tail`, so it HAS a canonical implementation to drift against.
    # TestWireTagBijection's `pinnedSchemas` is the authority on which schemas
    # are pinned; do not read this region's position as the answer.
    # ---------------------------------------------------------------------

    Health:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ok]

    ContextResponse:
      type: object
      description: >-
        The server's identity handshake. Every member is a deliberate,
        permanent choice; the field set is an allowlist frozen by a test that
        checks it against BOTH this document and the generated Go struct, so a
        field cannot arrive here as a side effect of the server's configuration
        growing one. In particular the workspace's sync remote is EXCLUDED, in
        this and every future version, because remote URLs routinely embed
        credentials — as are the database bind host/port (advertising them
        invites clients to bypass this API and dial the database directly) and
        the loopback/non-loopback bind mode.
      required:
        [api_version, bd_version, schema_version, backend, dolt_mode, database,
         beads_dir, repo_root, project_id, capabilities]
      properties:
        api_version:
          type: string
          description: The path major this server serves. `v0` for this document.
        bd_version:
          type: string
          description: >-
            The release version of the serving binary. The only field a client
            may compare as a version, and only for behavioral changes tied to
            a release.
        schema_version:
          type: integer
          description: >-
            The shared JSON schema version — the same constant the CLI's stdout
            JSON envelope reports. Diagnostic only: it can move for CLI-only
            reasons with no HTTP wire change, so clients MUST NOT branch on it.
        backend:
          type: string
          description: Storage backend name.
        dolt_mode:
          type: string
          description: Which storage mode this workspace is served from.
        database:
          type: string
          description: Logical database name (not a host or a DSN).
        beads_dir:
          type: string
          description: >-
            Absolute path of the served workspace's `.beads` directory. A host
            path, kept because it is the single-workspace server's only
            workspace-identity handshake; disclosing it to network peers is
            part of what an operator accepts when binding beyond loopback.
        repo_root:
          type: string
          description: Absolute path of the served repository root. See `beads_dir`.
        project_id:
          type: string
          description: Logical project identifier.
        capabilities:
          type: array
          description: >-
            The tokens this server advertises: the OPERATIONS it implements,
            derived from its route table, and the server-wide BEHAVIORS it
            enforces. v0's operation vocabulary is `ready.list`, `ready.count`,
            `issues.list`, `issues.query`, `issues.count`, `issues.get`,
            `issues.related`, `issues.create`, `issues.addComment`,
            `issues.batchClose`,
            `issues.claim`, `issues.claimNext`, `issues.release`,
            `issues.close`, `issues.reopen`, `issues.update`,
            `issues.sweep`, `issues.delete`, `issues.batchCreate`,
            `issues.batchApply`,
            `stats.get`, `config.list`, `config.get`, `config.set`,
            `config.unset`, `dependencies.cycles`,
            `dependencies.list`, `dependencies.count`,
            `dependencies.blocking`, `dependencies.tree`,
            `dependencies.add`, `dependencies.remove`,
            `memories.list`, `memories.get`, `memories.remember`,
            `memories.forget`, `events.list`, `events.watch`,
            `issues.casMetadata`; the one behavior token is `project.enforce`,
            which announces that a `Bd-Project-Id` stamp for the wrong workspace
            is refused here rather than silently ignored. The list grows
            additively, and an operation never appears here unless it is fully
            implemented. This is how a client checks for an operation or a
            behavior — never the version string.


            THIS LIST IS BUILD-LEVEL, NOT WORKSPACE-LEVEL. It says which
            operations this binary serves, and for every entry but two that is
            the whole answer. `events.list` and `events.watch` are the
            exceptions: the durable events journal is a per-workspace setting
            that is OFF by default, so a server that advertises them may still
            refuse every request to both with 409 `events_journal_disabled` —
            correctly, because the operations exist and the workspace has no
            journal. A consumer of either MUST treat the capability as "this
            server speaks it" and the 409 as "not on this workspace", and must
            not read the capability as a promise that records will arrive.
          items:
            type: string

    IssueCount:
      type: object
      required: [total]
      description: >-
        The size of a matching set, and its buckets when `group_by` asked for
        them. It carries no items and no cursor: this is a number about a set,
        and the operations that return rows are `GET /v0/beads/issues` and
        `GET /v0/beads/issues:query`.


        ONE SCHEMA FOR BOTH SHAPES, because the grouped answer is the scalar
        answer plus one member rather than a different answer. See the
        operation's own description for why that is one operation and not two.


        It is NOT `x-go-type`-pinned, for `ReadyCount`'s reason: there is no
        canonical Go struct whose JSON encoding is this contract.
      properties:
        total:
          type: integer
          format: int64
          description: >-
            How many issues match. Never negative; `0` when nothing matches,
            which is a 200 rather than a 404 — a question about a set has an
            answer even when the set is empty, and a client polling for work
            would otherwise have to classify an error to read a zero.


            Under `group_by` this is still the cardinality of the WHOLE matching
            set and NOT the sum of `groups`. The two differ for `label`, whose
            buckets overlap; see the operation description.
        groups:
          type: object
          additionalProperties:
            type: integer
          description: >-
            Bucket key to cardinality, PRESENT exactly when the request carried
            `group_by` and ABSENT otherwise. That absence is the answer to "you
            did not ask for buckets"; an empty OBJECT is the answer to "nothing
            matched", and the two are deliberately different — a client must be
            able to tell a scalar count from a grouped count of an empty set
            without re-reading its own request.


            Buckets with no rows are absent rather than present at zero. The
            dimensions are open-ended — any assignee, any label, any custom
            status — so there is no closed set of keys to enumerate and a client
            reads an absent key as zero. The KEY normalization is part of the
            contract and is documented on `group_by`.

    ReadyCount:
      type: object
      required: [total]
      properties:
        total:
          type: integer
          format: int64
          description: >-
            How many items `GET /v0/beads/ready` would return for these filters
            with `limit=0`. Never negative; 0 when nothing is ready, which is a
            200 rather than a 404 — a question about a set has an answer even
            when the set is empty.
      description: >-
        The size of a ready set. It carries no items, no `has_more` and no
        cursor: this is a number about a set, and the operation that returns
        rows is `GET /v0/beads/ready`.


        It is NOT `x-go-type`-pinned, unlike the seven schemas above, and that
        is a decision rather than an omission: those seven are pinned because a
        canonical Go struct's JSON encoding IS the contract and a second wire
        struct would let the CLI's `--json` drift from these bodies. There is
        no canonical struct here — the CLI publishes this number inside its own
        stdout envelope's `pagination` member, which is not a wire type — so
        pinning would weld this body to a CLI presentation type instead of
        preventing a drift.

    DeleteIssuesRequest:
      type: object
      additionalProperties: false
      required: [ids]
      description: >-
        Which beads to erase, and what to do about the beads that point at
        them. There is no predicate here — no status, no cutoff, no glob — and
        that absence is the reason this operation needs no require-a-filter
        gate: a caller cannot spell "everything" without typing every id.


        `additionalProperties: false`, so an unknown member is a `400` naming
        the member. On this operation a silently ignored member is the
        difference between orphaning a dependent and deleting it.
      properties:
        ids:
          type: array
          minItems: 1
          maxItems: 1000
          description: >-
            The beads to delete, exact ids, in either plane. DUPLICATES
            COLLAPSE. An empty array is a `400` rather than a no-op — a caller
            whose id list came out empty because its own construction broke
            would read "deleted 0" and conclude the workspace was already
            clean.


            The cap is on the REQUEST rather than on what a cascade expands to:
            the whole delete is one transaction, so the practical bound is the
            backend's write timeout and no number here can promise it.
          items:
            type: string
        actor:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Caller-asserted attribution, under the same rules as
            `SweepRequest`'s `actor`: trimmed, refused when empty after
            trimming, over 256 BYTES, or carrying any control character.
            Optional — a deleted bead leaves no row to attribute the deletion
            on — but it does reach the SURVIVING beads whose text this
            operation rewrites, so a workspace that cares who rewrote a
            description sends one.
        cascade:
          type: boolean
          default: false
          description: >-
            Also delete the transitive closure of everything that depends on
            the named beads. With `cascade` there is nothing left outside the
            set to orphan, so it makes `force` moot rather than conflicting
            with it: a request carrying both behaves as `cascade` and
            `orphaned` comes back empty.
        force:
          type: boolean
          default: false
          description: >-
            Delete the named beads and leave their dependents ORPHANED,
            reported in `orphaned`. Without it and without `cascade`, a named
            bead with a dependent the request did not name is refused.


            It defaults FALSE, which is the guarded mode, and the default is
            the protection. Authentication here is a deployment posture, and
            where it is configured it is a single shared bearer that admits a
            client to the WHOLE surface — it names no principal this operation
            could weigh and grants no narrower right — so an omitted member
            must not silently choose the answer that changes another bead's
            graph.
        dry_run:
          type: boolean
          default: false
          description: >-
            Report what the deletion WOULD do and change nothing. The counts
            and BOTH refusals are the ones the real request would produce,
            computed against the same snapshot, and nothing is recorded in
            history either.
        expected_version:
          type: integer
          format: int64
          description: >-
            Requires the named bead's revision to equal this value before
            anything is erased. A miss refuses the whole request with
            `409 precondition_failed` and deletes NOTHING —
            `UpdateIssueRequest.expected_version`'s contract, on the operation
            where being wrong about which row you are looking at cannot be
            undone.


            IT REQUIRES A SINGLE-ID REQUEST. Sending it beside more than one
            DISTINCT id is a `400` naming this member, refused before anything
            is read. One token cannot describe two rows: the version space is
            per-row, so checking one number against a list would pass by
            coincidence for a list of never-written rows — every one of them
            holds 0 — and fail forever for a list whose rows have since
            diverged. A guard that passes by coincidence and a guard nobody can
            satisfy are one defect seen from two sides. Delete one bead per
            guarded request; the per-id shape a batch would need is a token PER
            id, which is a different request type.


            DUPLICATES COLLAPSE FIRST, so `{"ids":["be-1","be-1"],
            "expected_version":N}` names one bead and is legal. The refusal
            counts DISTINCT ids, not mentions, exactly as the library surface
            does.


            NEITHER `cascade` NOR `force` BYPASSES IT. Both bypass POLICY — the
            dependents guard — and never a precondition. Under `cascade` the
            guard still covers only the NAMED bead: the closure is resolved
            inside the deleting transaction, so a matching token promises the
            row is the one you read and promises nothing about how far the
            closure has grown since. A caller that needs the closure itself
            pinned wants `dry_run` first.


            IT GUARDS LIFECYCLE STATE, NOT THE GRAPH. The token is reminted by
            status, assignee and started-at writes and deliberately not by
            label, dependency or rename writes, so a match does not promise the
            bead's edges are the ones you saw.


            The token is the `revision` a lifecycle write answers with, and the
            one `GET /v0/beads/issues/{id}` publishes — which is where a delete
            guard should seed itself, since reading the bead before erasing it
            is the only way to be sure it is the bead you meant. DECODE IT AS A
            64-BIT INTEGER, for the reason
            `UpdateIssueRequest.expected_version` spells out.

    DeleteIssuesResult:
      type: object
      required: [dry_run, deleted, dependencies, labels, events, references_updated]
      description: >-
        What one delete did. Every number describes the SAME snapshot, because
        the guard, the deletion and the reference rewrite ran in one
        transaction.


        It is NOT `x-go-type`-pinned, for the reason `SweepResult` is not:
        there is no canonical Go struct whose JSON encoding is this contract.
        `bd delete --json` publishes these numbers under its own per-shape keys
        (`deleted_count`, `dependencies_removed`, and a scalar `deleted` on the
        single-id form), which are a stdout presentation rather than a wire
        type, so pinning would weld this body to one of them.
      properties:
        dry_run:
          type: boolean
          description: >-
            Echoes the request, so a result carries whether its numbers
            describe beads that are gone or beads that would go.
        deleted:
          type: integer
          description: >-
            How many beads were deleted, or under `dry_run` would be. Under
            `cascade` this counts the whole closure, so it is normally larger
            than `ids` and it — not the request length — is the number to show.
        dependencies:
          type: integer
          description: >-
            Dependency edge rows removed with them, in either direction.
            Reported because a delete's visible effect is much larger than its
            bead count.
        labels:
          type: integer
          description: Label rows removed with the deleted beads.
        events:
          type: integer
          description: Event rows removed with the deleted beads.
        references_updated:
          type: integer
          description: >-
            How many SURVIVING beads had their text rewritten — beads, not
            occurrences. Always 0 under `dry_run`, because a preview rewrites
            nothing.
        orphaned:
          type: array
          description: >-
            The surviving beads that depended on something this request
            deleted, in ascending id order. Present exactly when the request
            carried `force` without `cascade`, which is the only mode in which
            orphaning is possible; absent otherwise.


            DIRECT dependents only. A bead two edges away lost no edge.
          items:
            type: string

    SweepRequest:
      type: object
      additionalProperties: false
      required: [tier]
      description: >-
        Which closed beads to clear. The predicate is FIXED at "closed beads of
        one tier" and the two narrowing members only narrow it: there is no
        status, no assignee, no label and no free-text query here, because
        every one of those would be another way to spell a destructive
        selection that a caller could get subtly wrong.


        `additionalProperties: false`, so an unknown member is a `400` naming
        the member — the same posture the query-parameter rule takes, and for
        the same reason: on this operation a silently ignored narrowing term
        widens what is erased.
      properties:
        tier:
          type: string
          enum: [ephemeral, durable]
          description: >-
            Which plane to clear. `ephemeral` is the wisp tier (`bd purge`) and
            `durable` is the issue tier (`bd prune`). The two are DISJOINT: a
            sweep of one can never touch a bead of the other. Required, with no
            default — a caller handed the wrong tier has nothing to notice
            until the beads are gone.
        actor:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Caller-asserted attribution for wherever the backend records it,
            under the same rules and for the same reasons as `ClaimRequest`'s
            `actor`: trimmed, refused when empty after trimming, over 256
            BYTES, or carrying any control character. Optional — a deleted bead
            leaves no row to attribute the deletion on.
        closed_before:
          type: string
          format: date-time
          description: >-
            Keep only beads closed STRICTLY BEFORE this instant (RFC 3339). A
            bead closed exactly at it is kept, which is the half-open interval
            every other time bound on this surface uses. `bd prune
            --older-than 30d` resolves the duration itself and sends the
            resulting instant.
        pattern:
          type: string
          description: >-
            Keep only beads whose id matches this shell glob (`*`, `?`,
            `[...]`, `\` escapes; `*` also crosses `-` and `.`, since an id is
            not a path). Absent matches every bead in the tier. A MALFORMED
            glob is a `400`, never a pattern that matches nothing.
        protect_referenced:
          type: boolean
          default: true
          description: >-
            Skip candidates whose id is CITED — as a literal, at word
            boundaries — in the description, notes or comments of any bead that
            is not done, so a decision trail a live bead still points at is not
            deleted out from under it.


            It DEFAULTS ON here, unlike the library default, and that is
            deliberate. This is the only destructive operation on the surface,
            and the bearer a deployment may configure is not a per-caller
            right: one shared token admits a client to everything published
            here, so being authenticated says nothing about whether this
            particular deletion was meant. A caller that omits the member must
            therefore not get weaker protection than the operator typing `bd
            prune`, which protects unless `--ignore-references`. The inverse —
            opt OUT locally, opt IN remotely — is the shape that lets a stray
            request delete a decision trail nothing brings back.


            It costs a full scan of the not-done set and its comments. Sending
            `protect_referenced: false` buys the cheaper sweep, and asking for
            it explicitly is the point: that is the request that should be the
            deliberate one.
        dry_run:
          type: boolean
          default: false
          description: >-
            Report what the sweep WOULD do and delete nothing. The counts, the
            skips and the refusals are the same ones the real sweep would
            produce, computed against the same snapshot — and nothing is
            recorded in history either.

    SweepResult:
      type: object
      required: [dry_run, swept, dependencies, labels, events, skipped]
      description: >-
        What one sweep did. Every number describes the SAME snapshot, because
        the selection and the deletion ran in one transaction.


        It is NOT `x-go-type`-pinned, for the reason `ReadyCount` is not: there
        is no canonical Go struct whose JSON encoding is this contract. The CLI
        publishes these numbers under its own per-command keys (`purged_count`,
        `pruned_count`), which are a stdout presentation rather than a wire
        type, so pinning would weld this body to one of them.
      properties:
        dry_run:
          type: boolean
          description: >-
            Echoes the request, so a result carries whether its numbers
            describe beads that are gone or beads that would go.
        swept:
          type: integer
          description: How many beads were deleted, or under `dry_run` would be.
        dependencies:
          type: integer
          description: >-
            Dependency edge rows removed with them. Reported because a sweep's
            visible effect is much larger than its bead count.
        labels:
          type: integer
          description: Label rows removed with the swept beads.
        events:
          type: integer
          description: Event rows removed with the swept beads.
        skipped:
          $ref: '#/components/schemas/SweepSkips'
        referenced_ids:
          type: array
          description: >-
            A BOUNDED SAMPLE of the ids `skipped.referenced` counts — at most
            100, in the order the candidate query returned them. It is a
            sample, not the set: compare its length against 100 to tell a
            truncated one from a complete one. Absent when nothing was
            protected.
          items:
            type: string

    SweepSkips:
      type: object
      required:
        - pinned
        - referenced
        - not_closed
        - unknown_closed_at
        - closed_at_or_after_cutoff
        - unreadable
      description: >-
        The candidates a sweep declined to delete, bucketed by WHY. They are
        separate counters rather than one number because they mean different
        things: the first two are PROTECTIONS, and the last four are the sweep
        declining to trust its own input.
      properties:
        pinned:
          type: integer
          description: >-
            Candidates protected by the pinned flag. No request member
            overrides it — a caller who wants a pinned bead gone unpins it
            first.
        referenced:
          type: integer
          description: >-
            Candidates protected by `protect_referenced`. Always 0 when that
            member is false or absent, so a 0 read without having asked says
            nothing about whether beads are cited.
        not_closed:
          type: integer
          description: >-
            Candidates the tier query returned that the recheck found were not
            closed. A NON-ZERO VALUE HERE IS A DEFENSE FIRING, not a normal
            outcome: the query asked for exactly the beads this excludes.
        unknown_closed_at:
          type: integer
          description: Closed candidates carrying no close timestamp. See `not_closed`.
        closed_at_or_after_cutoff:
          type: integer
          description: >-
            Candidates whose close timestamp did not satisfy `closed_before`.
            See `not_closed`.
        unreadable:
          type: integer
          description: >-
            Rows the tier query returned as nothing at all. A defense of the
            same kind, on a shape rather than a value.

    ReadyPage:
      type: object
      required: [items, has_more]
      properties:
        items:
          type: array
          description: Empty array (never null) when nothing is ready.
          items:
            $ref: '#/components/schemas/IssueWithCounts'
        has_more:
          type: boolean
          description: >-
            True when the limit truncated the result. There is no cursor on
            this operation; narrow the filters or raise the limit.

    CyclesPage:
      type: object
      required: [items, has_more]
      properties:
        items:
          type: array
          description: >-
            Empty array (never null) when the workspace has no cycles. Its
            LENGTH is the total: a cycle whose members this workspace cannot
            describe is still counted here, so the number cannot shrink because
            a row went missing.
          items:
            $ref: '#/components/schemas/Cycle'
        has_more:
          type: boolean
          description: >-
            Always false in v0: this operation takes no limit, so the report is
            never truncated. Present so that adding a bound later is additive.

    DependencyTreePage:
      type: object
      required: [items, has_more]
      properties:
        items:
          type: array
          description: >-
            The walked nodes in DEPTH-FIRST PRE-ORDER: a node appears before
            every node it led to, and a subtree is contiguous. Never null.


            It is empty only when a `status` filter matched nothing — the root
            is kept in a filtered answer solely as an ancestor of a match, never
            for its own sake. Without `status` the root is always the first
            element, which is what lets a client tell "this issue depends on
            nothing" from "this issue is not there" (a 404).
          items:
            $ref: '#/components/schemas/TreeNode'
        has_more:
          type: boolean
          description: >-
            Always false in v0: this operation takes no limit, so the walk is
            bounded by `max_depth` rather than truncated after the fact. Present
            so that adding a bound later is additive.

    IssuesPage:
      type: object
      required: [items, has_more]
      properties:
        items:
          type: array
          description: Empty array (never null) when nothing matches.
          items:
            $ref: '#/components/schemas/IssueWithCounts'
        has_more:
          type: boolean
        next_cursor:
          type: string
          description: >-
            Present if and only if `has_more` is true. Pass it back verbatim as
            `cursor` to fetch the next page. Opaque and server-private.

    EventRecord:
      type: object
      description: >-
        One record of the durable events journal: a single committed issue
        mutation, as a replaying consumer receives it.


        THIS IS THE CLI'S RECORD. It is pinned to the same Go struct
        `bd events tail` and `bd events export` marshal one per line, so the
        JSONL a consumer reads from stdout and the elements of an
        `EventsPage.records` array are the same bytes for the same row. A
        committed golden fixture pins that encoding field by field.


        `issue` is the full issue state AFTER the mutation and is ALWAYS
        PRESENT, carrying the literal `null` on a delete — where there is no
        surviving row to describe. That is the one place this document's general
        "treat null as absent" rule does not apply to a member's meaning: a
        consumer must be able to tell a delete from a payload the server failed
        to record, so the member is emitted rather than omitted. `dep` and
        `comment` are the opposite: they are ABSENT on the ops that have no such
        half, because their absence says the op has none, not that one was
        empty.
      x-go-type: eventsjournal.Record
      x-go-type-import:
        name: eventsjournal
        path: github.com/steveyegge/beads/internal/eventsjournal
      required: [seq, ts, op, issue_id, issue]
      properties:
        seq:
          type: integer
          format: int64
          description: >-
            Counter-assigned inside the mutation's own transaction: gapless,
            strictly increasing in commit order, never reused and never reset.
            This is the value to pass back as `since`. It is scoped to ONE
            replica — see the operation description.
        ts:
          type: string
          description: >-
            UTC insert time, stamped inside the committing transaction and
            normalized to RFC 3339. It is NOT monotone in `seq`: two writers
            against one SQL server, or a clock stepped back by NTP, can commit
            an earlier `seq` with a later timestamp. Order by `seq`, never by
            this.
        op:
          type: string
          description: >-
            What was done: `create`, `update`, `close`, `delete`, `dep_add`,
            `dep_remove` or `comment`. The set is closed in v0; a client MUST
            default-branch on an unknown value rather than fail, so that adding
            one stays additive.
        issue_id:
          type: string
          description: The mutated issue's canonical id.
        issue:
          type: object
          nullable: true
          description: >-
            The full issue state after the mutation — the same object shape
            `Issue` describes — or `null` on a delete. Always present.
        dep:
          type: object
          description: >-
            On `dep_add` and `dep_remove` only: `{"kind","target","metadata"}`
            for the edge. Absent on every other op.


            `metadata` differs in PROVENANCE between the two: on `dep_add` it is
            the value being written as the caller supplied it, on `dep_remove`
            it is the stored column read back just before the delete. The two
            can differ byte for byte while meaning the same thing, so compare
            parsed values rather than strings.
        comment:
          type: object
          description: >-
            On `comment` only: `{"id","author","text","created_at","source"}`.
            Absent on every other op.

    EventsPage:
      type: object
      required: [records, head]
      description: >-
        One page of the journal plus the position of its end.


        THERE IS NO `has_more`, and that is deliberate rather than an omission.
        Every other page on this surface reports truncation with a boolean
        because its ordering is a query's; here the answer is a number the
        client already needs for its next request. Compare the last record's
        `seq` with `head`: equal means caught up, lower means keep reading. A
        full page proves nothing either way, and a `has_more` computed from the
        limit would be a second, weaker way to ask the same question.
      properties:
        records:
          type: array
          description: >-
            Records with `seq` strictly greater than the requested `since`, in
            ASCENDING `seq` order and contiguous — a gap in the retained window
            is a 410, never a quietly shortened list. Empty array (never null)
            when the caller is caught up.
          items:
            $ref: '#/components/schemas/EventRecord'
        head:
          type: integer
          format: int64
          description: >-
            The highest `seq` this journal has ever assigned, read in the same
            transaction as the records above.


            It is the journal's HISTORY, not its contents: pruning deletes rows
            and never touches the counter, so a fully pruned journal still
            reports the head it reached. `0` means no mutation has ever been
            journaled here — which, given that a disabled journal is refused
            with 409 rather than answered, means an enabled journal on a
            workspace that has not been written to yet.


            Because it is read after the rows within one transaction, it is
            always greater than or equal to the last record's `seq`; it may be
            greater simply because a mutation committed while the page was being
            read, which is the ordinary signal to poll again.

    QueryPage:
      type: object
      required: [items, has_more]
      properties:
        items:
          type: array
          description: Empty array (never null) when the expression matched nothing.
          items:
            $ref: '#/components/schemas/IssueWithCounts'
        has_more:
          type: boolean
          description: >-
            True when `limit` truncated the result. It is exact for every
            expression, including the ones evaluated outside the database:
            those are matched against every candidate row, so the count of
            matches is known before the page is cut. There is no cursor —
            raise `limit` or narrow the expression.
      description: >-
        A page of query results. It is `ReadyPage`'s shape rather than
        `IssuesPage`'s, and the missing member is the point: a page of this
        operation carries no `next_cursor`, because a cursor is a keyset
        position in a database order and a predicate query's matching set is
        assembled outside the database.

    Setting:
      type: object
      description: >-
        One entry of the workspace's stored settings plane.


        THIS SCHEMA IS DELIBERATELY NOT `x-go-type`-PINNED, and it is one of
        the two ROW schemas on this surface that is not — `Memory` is the
        other. (Envelopes and page wrappers are unpinned as a class, for a
        different reason: they are new wire surface with no prior
        implementation. `TestWireTagBijection`'s `pinnedSchemas` is the
        authority on what is pinned.) The thirteen pinned schemas above are
        pinned because a canonical Go struct already IS the contract —
        `types.Issue`'s JSON encoding is what `bd show --json` emits. A setting
        has no such struct: the CLI marshals an ad-hoc
        `map[string]string` per verb, so there is nothing to pin TO, and
        minting a type to pin to would mean changing what `bd config get
        --json` prints in order to satisfy a rule about not changing it.


        The two surfaces are still one shape where they overlap — `key` and
        `value` are spelled as the CLI spells them — and they diverge in
        exactly one deliberate place, `redacted`, which exists because a bearer
        on this surface is optional, shared and surface-wide — it cannot decide
        that one caller may read a credential and another may not — while the
        CLI requires access to the database anyway.
      required: [key, redacted]
      properties:
        key:
          type: string
          description: The setting's key, echoed verbatim.
        value:
          type: string
          description: >-
            The stored value, verbatim.


            ABSENT MEANS ONE OF TWO THINGS, and `redacted` says which. With
            `redacted: false` the workspace stores nothing for this key OR
            stores the empty string; those are indistinguishable through this
            surface and through the CLI. With `redacted: true` a value may well
            be stored and is withheld.


            It is never emitted as an empty string and never transformed: a
            value that is not the stored value is omitted rather than masked,
            so a client can never mistake a placeholder for configuration.
        redacted:
          type: boolean
          description: >-
            True when `value` is withheld because the KEY marks the setting as
            credential-bearing — the name contains `token`, `secret`,
            `password`, or an API-key spelling. It is a decision about the key
            alone: no value is inspected, so a credential stored under an
            innocuous name is NOT protected by this and must not be stored in
            this plane at all.


            Always present, including when false, so a client never has to
            infer redaction from an absent member.

    Memory:
      type: object
      required: [key, value]
      description: >-
        One entry of the workspace's persistent memory plane.


        NOT `x-go-type`-PINNED, for the reason `Setting` is not: the CLI
        marshals an ad-hoc map per verb, so there is no canonical Go struct
        whose JSON encoding is this contract, and minting one to pin to would
        mean changing what `bd recall --json` prints in order to satisfy a rule
        about not changing it.


        IT HAS NO `redacted` MEMBER, and that is the deliberate difference from
        `Setting`. Redaction there is a decision about the KEY NAME, which works
        because settings keys are configured names; memory keys are derived from
        the content, so the same rule would withhold a memory about credentials
        and serve one containing a credential under an innocuous slug. A
        configured bearer would not close that either — it admits a client to
        the whole surface rather than to particular keys — so this schema
        states the exposure rather than implying a protection it does not have.
      properties:
        key:
          type: string
          description: The memory's key, echoed verbatim.
        value:
          type: string
          description: >-
            The stored content, verbatim: newlines, surrounding space and
            unicode as stored, never truncated and never withheld.


            Always present. It is the empty string only where a row was written
            out of band with an empty value, which
            `GET /v0/beads/memories/{key}` answers as a `404` and
            `GET /v0/beads/memories` enumerates.

    MemoriesPage:
      type: object
      required: [items, has_more]
      properties:
        items:
          type: array
          description: >-
            The stored memories, ordered by key. Empty array (never null) when
            the workspace holds none, or when `q` matched none.
          items:
            $ref: '#/components/schemas/Memory'
        has_more:
          type: boolean
          description: >-
            Always false in v0: the whole plane is returned in one page. It is
            present so that a later revision can page this collection without
            changing the response shape.
        next_cursor:
          type: string
          description: >-
            Present if and only if `has_more` is true, which is never in v0.

    RememberRequest:
      type: object
      additionalProperties: false
      required: [content]
      description: >-
        What to remember, and optionally under what key.
      properties:
        content:
          type: string
          description: >-
            The memory itself, stored VERBATIM: newlines, surrounding space and
            unicode all survive. Flattening it to one line is what a front door
            does when it prints, not what this plane does when it stores.


            Empty after trimming is a `400`. So is content from which no key can
            be derived when `key` is omitted — `"!!!"` derives to nothing — and
            the recovery for that one is to send a `key`.
        key:
          type: string
          description: >-
            The key to store under. OMIT IT to have the server derive one from
            `content`; the response's `key` is then how the caller learns where
            the memory landed.


            Supplied, it is used verbatim — no trimming, no slugging, no charset
            restriction. A key carrying a control character is storable this way
            and by `bd remember --key`, and is then unreachable through
            `GET`/`DELETE /v0/beads/memories/{key}`, which refuse one: see those
            operations.

    RememberedMemory:
      type: object
      required: [key, value, replaced]
      description: >-
        One stored memory, plus whether storing it overwrote a previous value.


        It is `Memory`'s shape with `replaced` added rather than a composition
        of it, because this document repeats property lists instead of using
        `allOf` (see the note at the top of the file).
      properties:
        key:
          type: string
          description: >-
            The key the memory now lives under: the one the request supplied, or
            the one derived from `content`. Recall it under exactly these bytes.
        value:
          type: string
          description: >-
            The stored content, echoed verbatim. Always present, and never
            withheld — this plane has no redaction; see the operation
            description.
        replaced:
          type: boolean
          description: >-
            True when a previous value existed under `key` and this request
            overwrote it; false when the key was new. It is observed in the same
            transaction as the write, so it describes the row this request
            wrote.


            A previous value that was the EMPTY STRING reports true: the ROW
            existed, even though `GET /v0/beads/memories/{key}` would have
            answered `404` for it. That divergence is the storage seam's
            conflation showing through, and it is stated rather than smoothed
            over, because smoothing it would mean this member reporting
            "nothing was there" about a write that overwrote something.

    SetSettingRequest:
      type: object
      additionalProperties: false
      required: [value]
      description: >-
        What to store under the key the path names. The key is not a member
        here: it has one spelling, and a body carrying it too would give one
        request two anchors and a question about what to do when they disagree.


        THERE IS NO `actor`, unlike every issue mutation on this surface, and no
        guard member either. This plane records no history entry to attribute a
        write on and holds no row version to compare, so both would be members
        with nothing behind them.
      properties:
        value:
          type: string
          description: >-
            The value to store, VERBATIM. It is not trimmed and not
            character-filtered: two of the keys this plane holds carry
            structured configuration a filter would corrupt.


            IT IS BOUNDED AT 65535 BYTES, which is the storage column, and the
            refusal is a `400` naming this member rather than the `500` the
            column would otherwise produce for a request the caller could have
            fixed. BYTES rather than characters, because that is how the column
            counts: 40000 multi-byte characters overflow it and 65000 ASCII ones
            do not. The 1 MiB body cap every operation shares still applies above
            this and is never the binding limit here.


            The bound is NOT the one `addComment`'s `text` carries, and the
            difference is what the two members are for. A comment is a document —
            a stack trace, a diff, a captured transcript — so its column is
            `LONGTEXT`. A setting is a value: nothing this plane holds is a
            megabyte of configuration, so the narrow bound is the honest
            description rather than a limitation to widen later.


            The empty string is a legal value and is stored. Read back it is
            INDISTINGUISHABLE from a key nothing ever set — `Setting.value` is
            absent for both — which is this plane's shipped conflation rather
            than something this operation introduces. A caller that means
            "remove it" sends `DELETE`.


            What comes back is this value, for every key this plane accepts:
            the one stored key with a normalization step is `issue_prefix`,
            which is also the one key this plane refuses, so no write through
            this door is transformed on its way in. The one thing the response
            may not repeat is a value the KEY marks credential-bearing; see the
            operation.

    RemovedSetting:
      type: object
      required: [key]
      description: >-
        The outcome of removing one setting.


        IT CARRIES THE KEY AND NOTHING ELSE, and the absence is the contract
        rather than an unfinished shape. There is no `removed` flag because the
        storage seam discards the affected-row count on every implementation, so
        the member would be a value one of them had to invent — and no `value`,
        because reporting what was there would publish, on the one operation that
        withholds nothing, exactly the credential `GET /v0/beads/config/{key}`
        redacts.
      properties:
        key:
          type: string
          description: The key that now holds nothing, echoed verbatim.

    SettingsPage:
      type: object
      required: [items, has_more]
      properties:
        items:
          type: array
          description: >-
            The stored settings, ordered by key. Empty array (never null) when
            the workspace stores none.
          items:
            $ref: '#/components/schemas/Setting'
        has_more:
          type: boolean
          description: >-
            Always false in v0: the whole plane is returned in one page. It is
            present so that a later revision can page this collection without
            changing the response shape.
        next_cursor:
          type: string
          description: >-
            Present if and only if `has_more` is true, which is never in v0.
    StatsResponse:
      type: object
      required: [summary, blocked_count_skipped]
      description: >-
        The same envelope `bd status --json` prints, minus one member: the CLI
        also carries `recent_activity`, which every shipped code path leaves
        absent, so it is not published here.
      properties:
        summary:
          $ref: '#/components/schemas/Statistics'
        blocked_count_skipped:
          type: boolean
          description: >-
            True when the summary came back without the blocked-set scan, which
            is exactly `summary.blocked_issues == null`. It is DERIVED from the
            answer rather than echoed from the request: `skip_blocked` is a
            hint, and a backend with no cheaper path answers with the full
            numbers and this flag false.
    DependencyEdges:
      type: object
      description: >-
        The stored edges of the named issues, plus the ids that named nothing.
        It is NOT a page: this operation has no limit and no cursor, because
        the number of issues asked about is what bounds it.
      required: [items, missing]
      properties:
        items:
          type: array
          description: >-
            Every matching edge, flattened across the named issues rather than
            keyed by source — the same flat array `bd dep list a b c --json`
            emits, so the two surfaces are one compatibility domain. Group by
            `issue_id` to recover the per-source view. Empty array (never null)
            when nothing matches.


            The order is by the named issues in the order the request named
            them, and within each issue by target id.
          items:
            $ref: '#/components/schemas/Dependency'
        missing:
          type: array
          description: >-
            The requested `issue_id` values that name neither an issue nor a
            wisp, in the order they were named. Empty array (never null) when
            every named issue exists.


            An id here contributes no `items`, and the absence of an id here is
            NOT a claim that it has edges — an issue that exists and depends on
            nothing is in neither list.
          items:
            type: string

    EdgeCounts:
      type: object
      description: >-
        Each anchor's edge cardinality — the body of
        `GET /v0/beads/dependencies:count`. It is NOT a page and carries no
        total: the answer is per anchor, and a sum across anchors would
        double-count every edge whose two ends were both named.


        It is NOT `x-go-type`-pinned, for `IssueCount`'s reason: there is no
        canonical Go struct whose JSON encoding is this contract. The role
        answers with `issueops.EdgeCountResult`, whose members carry no JSON
        tags at all because nothing marshals it.
      required: [anchors]
      properties:
        anchors:
          type: array
          description: >-
            One entry per DISTINCT requested `issue_id`, in the order the
            request first named it. Empty array (never null) when the request
            named no anchors this server accepted — which it cannot, since
            `issue_id` is required and bounded below at one.


            A repeated id appears ONCE. The collapse happens before anything is
            counted, so a caller that summed the entries would not count the
            same edges twice.
          items:
            $ref: '#/components/schemas/AnchorEdgeCount'

    AnchorEdgeCount:
      type: object
      description: One anchor's edge cardinality, or the report that it is not there.
      required: [id, count, missing]
      properties:
        id:
          type: string
          description: >-
            The anchor, spelled exactly as the request spelled it. It is not
            re-canonicalized: there is no fuzzy, prefix or substring resolution
            on this surface, so what comes back is what went out.
        count:
          type: integer
          format: int64
          description: >-
            How many stored edges match, in the requested direction, after the
            type and status filters. Never negative.


            It SPANS BOTH DEPENDENCY PLANES and is a SUM over them rather than a
            distinct count of edge rows — a durable issue's dependent count
            includes the wisps that depend on it, and a wisp's dependency count
            includes the durable issues it depends on.
            `issueops.AnchorEdgeCount.Count` states the rule, the one state that
            can make the sum differ from a distinct count, and why this role
            answers with the sum. Nothing is restated here.


            ALWAYS PRESENT, including as 0, and 0 is the COMMON answer: most
            issues have no edges in at least one direction. It is 0 for a
            missing anchor too, which is exactly why `missing` is beside it.


            DECODE IT AS A 64-BIT INTEGER. The member is `format: int64` and a
            workspace's graph is not bounded by 2^53; a lossy parser would
            answer a number NEAR the count, which on a cardinality is worse
            than an error because nothing downstream can tell.
        missing:
          type: boolean
          description: >-
            True when no issue and no wisp carries this id.


            ALWAYS PRESENT, including as `false`. It is the member that keeps
            this from being a question a caller cannot tell it got wrong: a
            count of 0 is otherwise indistinguishable from a typo, and an
            absent boolean would be ambiguous between "present" and "this
            producer does not report misses".


            A missing anchor counts 0 whatever rows are still keyed to it — a
            dependency row whose source has been deleted is orphaned data, and
            counting it would contradict this flag.


            DANGLING EDGES ARE NOT MISSING ANCHORS. This is about the ANCHOR. An
            edge whose OTHER end names nothing is counted like any other edge,
            and nothing here reports on it.

    BlockingAnnotations:
      type: object
      description: >-
        The blocking decoration of the named issues. It is NOT a page: this
        operation has no limit and no cursor, because the number of issues asked
        about is what bounds it.
      required: [items]
      properties:
        items:
          type: array
          description: >-
            One entry per DISTINCT requested id, in the order the request first
            named it — so a client can zip this against the ids it sent. Empty
            array (never null) when the request named none.


            There is no `missing` beside it, unlike `DependencyEdges`: this
            operation probes no id's existence, so every requested id has an
            entry and an id that names nothing is simply bare.
          items:
            $ref: '#/components/schemas/IssueBlocking'

    RelatedIssues:
      type: object
      description: >-
        One issue's neighbors — the body of
        `GET /v0/beads/issues/{id}/related`. It is NOT a page: this operation
        has no limit and no cursor, and it names ONE anchor, so there is nothing
        for a `has_more` to be about.


        There is no `missing` beside `items`, unlike `DependencyEdges`. That
        member exists because a batch cannot report an absent anchor any other
        way without discarding the anchors it did find; here the single absent
        anchor is a 404.


        It is NOT `x-go-type`-pinned, for `EdgeCounts`' reason: there is no
        canonical Go struct whose JSON encoding is this envelope. The role
        answers with a bare slice of `issueops.RelatedIssue`, and THAT element
        is pinned — see `IssueWithDependencyMetadata`.
      required: [items]
      properties:
        items:
          type: array
          description: >-
            The neighbors, ascending by id with the edge type breaking a tie.
            Empty array (never null) when this issue has none in the requested
            direction, or when the `type` filter rejected every edge.


            Each element is a full issue plus `dependency_type`, the type of the
            edge that led to it — the same element
            `GET /v0/beads/issues/{id}` carries under `dependencies` and
            `dependents`, so the two surfaces are one compatibility domain.


            AN EDGE WITH NO FAR END IN THIS DATABASE CONTRIBUTES NOTHING here:
            an `external:` reference, an id in another repository's namespace
            and an id whose issue was deleted out from under its edges are all
            silently absent, with no placeholder row and no error. The length of
            this array is a NEIGHBOR count, never an edge count.
          items:
            $ref: '#/components/schemas/IssueWithDependencyMetadata'

    BatchCreateRequest:
      type: object
      additionalProperties: false
      required: [actor, items]
      properties:
        actor:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Who is creating the issues, under `ClaimRequest.actor`'s rules and
            for the same reasons: the server trims it, refuses an empty result,
            anything longer than 256 BYTES, and any control character including
            newline. It is attributed to every item and interpolated into the
            storage commit message.
        items:
          type: array
          minItems: 1
          maxItems: 100
          description: >-
            The issues to create, in order. An empty array is a `400` rather
            than a successful no-op: a write request that writes nothing is a
            client bug, and answering it with a cheerful empty success is how a
            client whose own list filtered to nothing silently stops creating
            anything.


            The 100-item cap is a bound on how long one request may hold a write
            transaction, not a statement about batch semantics. Split a larger
            plan; each request is atomic on its own.
          items:
            $ref: '#/components/schemas/BatchCreateItem'

    BatchCreateItem:
      type: object
      additionalProperties: false
      required: [title]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
        description:
          type: string
        design:
          type: string
        acceptance_criteria:
          type: string
        priority:
          type: integer
          minimum: 0
          maximum: 4
          description: 0 is P0/critical. Absent means the workspace default.
        issue_type:
          type: string
          description: >-
            Issue type. Spelled `issue_type` rather than `type`, matching the
            member `Issue` carries, and validated against the built-ins plus the
            workspace's configured custom types — an unknown one is a `400`.
        assignee:
          type: string
          maxLength: 255
        labels:
          type: array
          items:
            type: string
            maxLength: 255
        dependencies:
          type: array
          maxItems: 100
          description: >-
            The edges this issue is created carrying. They are written in the
            same transaction as the issue, so this operation never publishes an
            issue whose declared relationships are not there yet.
          items:
            $ref: '#/components/schemas/BatchCreateDependency'

    BatchCreateDependency:
      type: object
      additionalProperties: false
      required: [target_id, type]
      properties:
        target_id:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            The far end of the edge: an issue this workspace holds, an
            `external:` reference, or an id whose prefix belongs to another
            repository. Anything else is a `400` and nothing is created.


            NOT AN ITEM OF THIS REQUEST. The server assigns every id and an item
            carries no name, so there is nothing here a caller could write to
            address one; see the operation's description for the operation that
            can.
        type:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            The edge type, from the same OPEN vocabulary `Dependency.type`
            carries. It is spelled `type` because that is the member an edge
            carries everywhere else on this surface.

    BatchCreateResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          description: >-
            One entry per requested item, in REQUEST ORDER, each the stored
            issue with its generated id and its labels. Never null and never
            shorter than the request: a partial outcome does not exist on this
            operation.


            There is no `has_more` and no `next_cursor`. This is not a page —
            the client already knows how many items it sent — and publishing a
            paging envelope over a fixed-length answer would invite a client to
            look for a second page that can never exist.
          items:
            $ref: '#/components/schemas/Issue'

    ApplyBatchRequest:
      type: object
      additionalProperties: false
      required: [actor, items]
      properties:
        actor:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Who is applying the plan, under `ClaimRequest.actor`'s rules and for
            the same reasons: the server trims it, refuses an empty result,
            anything longer than 256 BYTES (the `maxLength` above counts
            characters — the byte limit is the binding one), and any control
            character including newline.


            It is attributed to every item and to the ONE history entry the
            request records, because a batch is one act by one caller.
        items:
          type: array
          minItems: 1
          maxItems: 100
          description: >-
            The items to apply, IN THE ORDER THEY ARE TO BE APPLIED. An empty
            array is a `400` rather than a successful no-op: a write request that
            writes nothing is a client bug, and answering it cheerfully is how a
            client whose own plan filtered to nothing silently stops writing.


            The 100-item cap bounds how long one request may hold a write
            transaction, not batch semantics. Split a larger plan; each request
            is atomic on its own — but splitting it changes what the end gate
            can see, since the gate runs over one request at a time.


            A per-item refusal names its offender as `items[i].kind.member`.
          items:
            $ref: '#/components/schemas/ApplyItem'
        provenance:
          type: string
          maxLength: 255
          description: >-
            Labels the version-control history entry this request records, under
            `updateIssue`'s rule: it changes how the entry READS, never whether
            one is recorded. Empty composes a default naming how many items of
            each kind landed and no ids.
        force_id_prefix:
          type: boolean
          default: false
          description: >-
            Permits an explicit `create.id` outside the workspace's configured
            issue prefix, for EVERY create item in the request. Without it such
            an id is refused by the role and arrives as a `400`.
        skip_per_edge_cycle_check:
          type: boolean
          default: false
          description: >-
            Drops the PER-EDGE cycle probe for a caller wiring a large graph,
            exactly as it does on `POST /v0/beads/dependencies:add`.


            IT NEVER DROPS THE END GATE, which runs once after every item and
            re-validates the whole graph this request built, and it never drops
            the self-dependency refusal. It trades per-edge attribution for
            speed, not validation for speed.

    ApplyItem:
      type: object
      additionalProperties: false
      required: [kind]
      description: >-
        One item of a plan: a `kind` naming what it does, plus exactly one
        payload member matching it.


        IT IS A TAGGED SINGLE-SHAPE OBJECT rather than a polymorphic one, and
        the spelling is deliberate. This document uses no `oneOf`, `anyOf` or
        `allOf` anywhere: a component carrying a composition keyword alongside
        the `x-go-type` pins the response schemas depend on silently loses the
        pin, and the generated result is a second wire struct that drifts from
        the canonical one. So the union is carried as four OPTIONAL members with
        a required tag rather than as a schema alternation.


        WHAT A CLIENT MUST DO, since no validator can enforce it from this
        schema alone: send `kind`, send the ONE member `kind` names, and send no
        other. An item carrying no payload does nothing; an item carrying a
        payload its `kind` does not name has two halves that disagree; an item
        carrying two payloads cannot say which it meant. All three are a `400`
        and nothing in the request is written. A generated client's type will
        make all four members constructible at once — that is the cost of the
        spelling, and checking it is the client's.


        READING one is the same rule from the other side: dispatch on `kind` and
        read only that member. The other three are absent.
      properties:
        kind:
          type: string
          enum: [create, update, close, dep_add]
          description: >-
            Which member below is read. A CLOSED set, unlike a dependency
            `type`: every value here is a verb this operation implements, and an
            unknown one is a request the server cannot execute rather than a
            workspace's own vocabulary.
        create:
          $ref: '#/components/schemas/ApplyCreateItem'
        update:
          $ref: '#/components/schemas/ApplyUpdateItem'
        close:
          $ref: '#/components/schemas/ApplyCloseItem'
        dep_add:
          $ref: '#/components/schemas/ApplyDepAddItem'

    Ref:
      type: object
      additionalProperties: false
      description: >-
        Names ONE issue, either by an id that already exists or by the `key` a
        create item earlier in the same request gave itself.


        EXACTLY ONE OF THE TWO IS SET, and both cases the schema cannot express
        are a `400`: both members set is a caller that cannot say which it
        meant, and neither set is a reference to nothing. (Spelling that as a
        schema alternation would need `oneOf`, which this document does not
        use — see `ApplyItem`.)


        A KEY REACHES BACKWARD ONLY where the ref ADDRESSES a row — an
        `update.target`, a `close.target`, either endpoint of a `dep_add`. The
        one exception is `create.metadata_refs`, whose values may reach forward
        or name their own item's key; the operation's description says why.
      properties:
        key:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            The `key` a create item in THIS REQUEST gave itself. It is not an
            id, it is not stored anywhere, and it is resolved to the id the
            request minted — which the response's `keys` member reports.
        id:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            An id that already exists, EXACTLY. There is no fuzzy, prefix or
            cross-repo resolution on this surface.

    ApplyCreateItem:
      type: object
      additionalProperties: false
      required: [title]
      description: >-
        Creates one issue and optionally NAMES it, so later items can reach the
        row without knowing an id the request has not minted yet.


        It publishes the whole create vocabulary rather than
        `POST /v0/beads/issues:batchCreate`'s narrow one, and the additions are
        the point: `status`, `sender`, `metadata`, `ephemeral` and `no_history`
        are the members whose absence there makes that operation unusable for a
        caller composing a real plan.


        THE EDGES ARE NOT HERE. An issue's dependencies and its parent are
        `dep_add` ITEMS, so the order of every edge in the request is total and
        there is exactly one spelling for an edge. An item carrying comments or
        dependencies on the issue is a `400`.


        `metadata` is the issue's own metadata document and must be a JSON
        OBJECT where it is present at all. It is stored as sent; the resolved
        ids `metadata_refs` splices are written over its top-level keys after
        every id in the request exists.
      properties:
        key:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            This item's name inside the request. OPTIONAL — an item nothing
            refers to needs no name — and unique across the request's create
            items; a repeat is a `400`. It is what a later `Ref.key` resolves
            to, and the response's `keys` member is where the id it was bound to
            is read.
        id:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            An explicit id for the new row, CREATE-ONLY: an id that already
            names a stored row is a `409` `already_exists` and the whole request
            is refused — never an adoption and never an overwrite. To act on a
            row that already exists, send an `update` item referencing it by
            `{"id": …}`. The id is checked against the workspace's configured
            issue prefix unless the request sets `force_id_prefix`.


            Absent is the ordinary case and the server mints one. This is the
            member `POST /v0/beads/issues:batchCreate` deliberately does not
            publish, which is why that operation can never adopt or overwrite a
            stored row and this one can be refused for trying.
        title:
          type: string
          minLength: 1
          maxLength: 255
        description:
          type: string
        design:
          type: string
        acceptance_criteria:
          type: string
        notes:
          type: string
        issue_type:
          type: string
          maxLength: 255
          description: >-
            Issue type. Spelled `issue_type` rather than `type`, matching the
            member `Issue` carries, and validated against the built-ins plus the
            workspace's configured custom types by the ROLE — this server cannot
            read that vocabulary without a transaction, so it checks only what
            this schema declares and an unknown one arrives as a `400`.
        status:
          type: string
          maxLength: 255
          description: >-
            The status the issue is created in, from this workspace's own
            configured vocabulary. Absent means the workspace default.
        priority:
          type: integer
          minimum: 0
          maximum: 4
          description: 0 is P0/critical. Absent means the workspace default.
        assignee:
          type: string
          maxLength: 255
        owner:
          type: string
          maxLength: 255
          description: >-
            The human owner, which is a different member from `assignee`: the
            assignee is who is working it now, the owner is who it is attributed
            to.
        labels:
          type: array
          items:
            type: string
            maxLength: 255
          description: >-
            The complete label set the issue is created with. Authoritative, not
            a patch — a create has nothing to add to.
        estimated_minutes:
          type: integer
          description: An estimate in minutes. Absent leaves it unset.
        external_ref:
          type: string
          maxLength: 255
        due_at:
          type: string
          format: date-time
          description: RFC 3339.
        defer_until:
          type: string
          format: date-time
          description: RFC 3339. The issue is hidden from ready work until then.
        sender:
          type: string
          maxLength: 255
          description: >-
            Who sent this, for the message-shaped rows a plan creates. Stored
            verbatim and interpreted by nothing on this surface.
        metadata:
          $ref: '#/components/schemas/MetadataValue'
        ephemeral:
          type: boolean
          default: false
          description: >-
            Creates the issue on the EPHEMERAL plane rather than the durable
            one. Per item, exactly as it is for
            `POST /v0/beads/issues:batchCreate`, so one request may create
            durable issues and ephemeral ones together.


            The two planes hold their edges in different tables, so a `dep_add`
            between two rows this request creates on OPPOSITE planes is refused
            with everything else the request asked for. Mutually exclusive with
            `no_history`.
        no_history:
          type: boolean
          default: false
          description: >-
            Creates the issue on the ephemeral plane WITHOUT history, and
            without the garbage collection an ordinary ephemeral row is eligible
            for. Mutually exclusive with `ephemeral`.
        metadata_refs:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/Ref'
          description: >-
            Splices resolved ids into this issue's metadata: each entry writes
            the id its `Ref` resolves to as the WHOLE VALUE of one top-level
            metadata key.


            IT IS THE ONE PLACE A KEY MAY REACH FORWARD, or name this item's own
            `key` — see the operation's description. A ref here that names a key
            NO item declares is still a `400`.


            IT IS A TYPED MAP, NOT TEMPLATING. A `${key}` placeholder inside a
            JSON string would have no escape for a literal dollar-brace, would
            collide with every other templating language a caller's own values
            might carry, and could not be type-checked at all. This is one key,
            one whole value, one level deep.


            The splice is applied AFTER the row is created, so a consumer of the
            event stream sees a create and then an update on the spliced row.

    ApplyUpdateItem:
      type: object
      additionalProperties: false
      required: [target, patch]
      description: >-
        Patches one existing issue, under `PATCH /v0/beads/issues/{id}`'s rules.


        The two carry the same preconditions and the same force flags; what is
        this operation's alone is that its guards evaluate AS-MODIFIED — against
        the row as earlier items of this same request have already changed it —
        and that a miss takes the whole plan down rather than one write.
      properties:
        target:
          $ref: '#/components/schemas/Ref'
        patch:
          $ref: '#/components/schemas/ApplyPatchBody'
        expected_version:
          type: integer
          format: int64
          description: >-
            Requires the row's `revision` to equal this value before the patch.
            A miss refuses the WHOLE request with `409 precondition_failed`.


            IT IS A `400`, NOT A `409`, ON A ROW THIS REQUEST HAS ALREADY
            WRITTEN — including one an earlier item created. The token is minted
            by the write, so mid-request there is no value a caller could send:
            the pre-request token is stale by construction and a row this
            request just created never had one the caller could read. Refusing
            statically says so; answering with a mismatch would send the caller
            looking for a concurrent writer that does not exist.


            `expected_status` and `expected_assignee` carry no such rule,
            because a caller CAN know what its own earlier item set them to.


            DECODE IT AS A 64-BIT INTEGER, for the reason
            `UpdateIssueRequest.expected_version` spells out. It bites harder
            here than anywhere else on the surface: a corrupted token refuses
            the WHOLE plan rather than one write, so a client with a lossy
            parser loses every item of every batch it guards.
        expected_status:
          type: string
          maxLength: 255
          description: >-
            Requires the issue's status to equal this value, evaluated
            AS-MODIFIED — against the row as this request has already changed it
            at this item's position. A miss refuses the whole request with
            `409 precondition_failed`.
        expected_assignee:
          type: string
          maxLength: 255
          description: >-
            Requires the issue's assignee to equal this value, evaluated
            as-modified. A match AUTHORIZES the requested `patch.assignee`
            transfer: this compare-and-set replaces the ordinary anti-steal
            fence, so it must not be combined with `force_assignee_transfer`. A
            miss refuses the whole request with `409 precondition_failed`.
        force_close_policy:
          type: boolean
          default: false
          description: >-
            Bypasses ONLY close policy — the open-children refusal and the live
            blocker refusal — for a `patch.status` that crosses into the
            workspace's done category. It has no effect without such a status
            change, and it never bypasses validation, the preconditions above,
            or the assignee fence.
        force_assignee_transfer:
          type: boolean
          default: false
          description: >-
            Bypasses ONLY a genuine transfer away from a live foreign
            in-progress owner. Reasserting the exact current assignee is
            idempotent and needs no force. It requires `patch.assignee` — a
            request setting it without one is a `400` — and it must be false
            when `expected_assignee` is sent.

    ApplyPatchBody:
      type: object
      additionalProperties: false
      description: >-
        The fields an `update` item writes. Every member is optional and
        PRESENCE is the signal: a member present is written, a member absent is
        untouched. An empty object is a `400` — a write that writes nothing is a
        client bug.


        It mirrors `IssuePatchBody` member for member and diverges in exactly
        two places now that `PATCH /v0/beads/issues/{id}` publishes `status`,
        `assignee` and the same `metadata` algebra.


        `owner` is published here and not there, which is an accident of order
        rather than a decision: nothing has asked for it on the single patch.


        `labels` is a full patch rather than a complete replacement, and that
        one is a real difference: a plan has to be able to REMOVE one label
        without knowing the rest of the set, because it edits a set it did not
        compose. A caller patching one row it just read already knows the set.


        `parent_id` is deliberately absent, and its absence is this operation's
        one-edge-one-spelling rule: a parent is a `dep_add` item of type
        `parent-child`, so the order of every edge in the request stays total.
        The single patch has no ordering to express and publishes it directly.
        `persistence` is absent from both — moving a row between planes mid-plan
        is a different act from writing its fields, and nothing has asked for it
        here.
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            Must not be blank after trimming; the length bound is what the
            column holds.
        description:
          type: string
        design:
          type: string
        acceptance_criteria:
          type: string
        notes:
          type: string
          description: >-
            Replaces the notes. Mutually exclusive with `append_notes`; sending
            both is a `400`.
        append_notes:
          type: string
          description: >-
            Appends to the notes rather than replacing them. Mutually exclusive
            with `notes`.
        priority:
          type: integer
          minimum: 0
          maximum: 4
        issue_type:
          type: string
          maxLength: 255
          description: >-
            The issue type, from this workspace's own configured vocabulary. A
            type outside it is refused by the ROLE and reaches the client as a
            `400`.
        status:
          type: string
          maxLength: 255
          description: >-
            The issue's status, from this workspace's own configured vocabulary.


            A STATUS THAT CROSSES INTO THE DONE CATEGORY ANSWERS TO CLOSE
            POLICY: the item is refused with `409 not_closable` for open
            children or a live blocker unless `force_close_policy` is set. A
            done-to-done change and a move OUT of the done category are
            unaffected — which is how a plan reopens a row, since there is no
            reopen item.
        assignee:
          type: string
          maxLength: 255
          description: >-
            The assignee. A transfer away from a live foreign in-progress owner
            is refused with `409 already_claimed` unless
            `force_assignee_transfer` is set or `expected_assignee` matched.
        owner:
          type: string
          maxLength: 255
        labels:
          $ref: '#/components/schemas/ApplyLabelPatch'
        estimated_minutes:
          type: integer
          nullable: true
          description: 'Explicit `null` CLEARS the estimate.'
        external_ref:
          type: string
          nullable: true
          maxLength: 255
          description: 'Explicit `null` CLEARS the reference.'
        due_at:
          type: string
          format: date-time
          nullable: true
          description: 'RFC 3339. Explicit `null` CLEARS the due date.'
        defer_until:
          type: string
          format: date-time
          nullable: true
          description: 'RFC 3339. Explicit `null` CLEARS the deferral.'
        metadata:
          $ref: '#/components/schemas/ApplyMetadataPatch'

    ApplyLabelPatch:
      type: object
      additionalProperties: false
      description: >-
        An ordered label edit: `replace` first, then `add`, then `remove`, so
        REMOVAL WINS when the same label appears in more than one member.


        It is the full patch rather than `IssuePatchBody.labels`' complete
        replacement because a plan edits a set it did not compose: replacing
        would mean reading the labels back first, and the read this operation
        exists to avoid is exactly that one.


        Repetition is free in both directions — a label named twice in one
        member is applied once, and removing a label the issue does not carry is
        a no-op. An EMPTY-STRING entry is dropped rather than refused: a label
        row holding "" renders as nothing and matches nothing, so refusing the
        whole request for one stray entry would fail an otherwise-good edit.
      properties:
        replace:
          type: array
          items:
            type: string
            maxLength: 255
          description: >-
            The complete starting label set. An empty array CLEARS every label;
            omitting the member leaves the current set as the starting point.
        add:
          type: array
          items:
            type: string
            maxLength: 255
          description: Labels to add after any replacement.
        remove:
          type: array
          items:
            type: string
            maxLength: 255
          description: Labels to remove after replacement and addition.

    ApplyMetadataPatch:
      type: object
      additionalProperties: false
      description: >-
        A metadata edit. `replace` is mutually exclusive with the other three;
        without it the edits apply as `merge`, then `set` in key order, then
        `unset`, so UNSETTING A KEY WINS over setting or merging it. Sending
        `replace` beside any of the others is a `400`.


        `replace` replaces the whole document. Present holding `null`, `{}` or
        an empty value CLEARS metadata — and clearing STORES THE EMPTY JSON
        DOCUMENT rather than SQL null, so "created with no metadata" and "given
        metadata and then cleared" are the same stored value; a reader must
        treat absent, empty and `{}` as one value on the way out. `merge` must
        be a nonempty JSON OBJECT and is merged into the current document.
      properties:
        replace:
          $ref: '#/components/schemas/MetadataValue'
        merge:
          $ref: '#/components/schemas/MetadataValue'
        set:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/MetadataValue'
          description: >-
            Individual top-level keys to write, in deterministic key order. A
            value present holding `null` writes JSON null; a key is removed with
            `unset`, never by sending a null here.
        unset:
          type: array
          items:
            type: string
            maxLength: 255
          description: Top-level keys to remove, applied after every other edit.

    ApplyCloseItem:
      type: object
      additionalProperties: false
      required: [target]
      description: >-
        Closes one existing issue, under `POST /v0/beads/issues/{id}:close`'s
        rules including first-close-wins.
      properties:
        target:
          $ref: '#/components/schemas/Ref'
        reason:
          type: string
          maxLength: 255
          description: >-
            Why the issue is closed, stored and read back as `close_reason`. THE
            FIRST CLOSE WINS: an idempotent re-close writes neither this nor
            `session`.
        session:
          type: string
          maxLength: 255
          description: >-
            The working session that closed the issue, stored and read back as
            `closed_by_session`, under the same first-close-wins rule.
        force:
          type: boolean
          default: false
          description: >-
            Bypasses close policy — the open-children refusal and the
            live-blocker refusal — and nothing else.


            CLOSE POLICY EVALUATES AT THIS ITEM, against the row as this request
            has already changed it. A LATER item that gives a closed parent an
            open child is NOT refused: the policy is a gate on the closing act,
            not an invariant the store maintains.
        expected_version:
          type: integer
          format: int64
          description: >-
            Requires the row's `revision` to equal this value, evaluated
            as-modified and checked before the idempotent close. A miss refuses
            the whole request with `409 precondition_failed`, and
            `ApplyUpdateItem.expected_version`'s already-written rule applies
            here identically.


            THERE IS DELIBERATELY NO `expected_status` HERE. A close is
            idempotent — re-closing a closed issue is `changed: false` — so a
            guard spelled to refuse an already-closed row is asking for a
            REFUSAL where this verb answers with a no-op. That belongs on an
            `update` item whose `patch.status` crosses into the done category.


            DECODE IT AS A 64-BIT INTEGER, on
            `ApplyUpdateItem.expected_version`'s terms, including its note that
            a corrupted token here costs the whole plan.

    ApplyDepAddItem:
      type: object
      additionalProperties: false
      required: [source, target, type]
      description: >-
        Asserts ONE dependency edge, under `POST /v0/beads/dependencies:add`'s
        rules. An edge from a row to itself is a `400`.


        A TARGET NEED NOT BE A ROW THIS DATABASE HOLDS: an `external:` reference
        and an id belonging to another repository are legitimate targets, so
        only an absence this database can SEE is refused. A SOURCE has no such
        latitude — an edge follows its source, so a source this database holds
        no row for has no plane to land in.


        `metadata` is the edge's type-specific JSON blob, and an OBJECT where it
        is present at all. Most edge types carry none.


        A WAITS-FOR EDGE IS NORMALIZED RATHER THAN STORED AS ASKED. An absent,
        empty or `{}` `metadata` on a `waits-for` edge is STORED as
        `{"gate":"all-children"}`, because a stored waits-for row must be
        self-describing: readers predating the gate's introduction do not
        default a missing one, so an empty gate is a row those readers get
        wrong. A metadata that names a gate keeps it, along with the spawner and
        also-blocks members a caller may carry, and a gate that is neither
        `all-children` nor `any-children` is a `400`. Nothing else about that
        member is interpreted.


        THERE IS NO TYPED `waits_for` MEMBER, and that is the shape rather than
        an omission: every measured caller already carries the gate as metadata,
        a typed spelling lowers to these same bytes, and the blob carries
        members a two-field typed member could not express. One spelling, and it
        is this one.
      properties:
        source:
          $ref: '#/components/schemas/Ref'
        target:
          $ref: '#/components/schemas/Ref'
        type:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            The edge type, from the same OPEN vocabulary `Dependency.type`
            carries: checked for BEING a storable value, never for membership of
            a known-types list, so a workspace's own type passes.
        metadata:
          $ref: '#/components/schemas/MetadataValue'

    ApplyBatchResponse:
      type: object
      required: [keys, items]
      properties:
        keys:
          type: object
          additionalProperties:
            type: string
          description: >-
            Each create item's `key` mapped to the id it was bound to. It is the
            one fact the request cannot carry and every caller needs.


            It carries only the keys the request NAMED: an unnamed create item
            is in `items` and not here. A request whose create items named
            nothing answers with an empty object, never `null`.
        items:
          type: array
          description: >-
            One entry per requested item, in REQUEST ORDER. Never null and never
            shorter than the request: a batch that could not apply every item
            applied none, so there is no index with nothing to put at it.


            There is no `has_more` and no `next_cursor`. This is not a page — the
            client already knows how many items it sent.
          items:
            $ref: '#/components/schemas/ApplyItemResult'

    ApplyItemResult:
      type: object
      required: [kind, issue_id, changed, revision]
      description: >-
        What ONE item did, at the index the item occupied.


        IT IS LEAN, AND CARRIES NO ISSUE. Every other write on this surface
        answers with the stored row; this one answers with ids and a revision,
        and a client that wants the rows reads them back. A hundred hydrated
        issues with their labels and edges is a response an order of magnitude
        larger than the request that produced it, and no client needs all of
        them: the ids are what a plan's next step is composed from. The library
        contract behind this operation DOES carry a post-item snapshot, because
        its completion hooks hand a script the row it is being told about — and
        hooks never fire on this surface at all, which is exactly why the
        snapshot stops here.
      properties:
        kind:
          type: string
          enum: [create, update, close, dep_add]
          description: >-
            Echoes the item's kind, so a caller walking the results does not
            have to walk the request alongside them.
        issue_id:
          type: string
          description: >-
            The row the item acted on: the minted or explicit id for a `create`,
            the resolved target for an `update` or a `close`, and the edge's
            SOURCE for a `dep_add`.
        depends_on_id:
          type: string
          description: >-
            The edge's target. Present for `dep_add` and ABSENT for every other
            kind, which act on a row rather than on a pair.
        changed:
          type: boolean
          description: >-
            Whether this item persisted a semantic mutation. A `create` is
            always true. An `update` and a `close` follow their own operations'
            `changed`/`already_closed` answers, and a `dep_add` is false for an
            idempotent re-add of an edge that already existed with the same
            type.
        revision:
          type: integer
          format: int64
          description: >-
            The row's optimistic-concurrency token AFTER the item, and the value
            an `expected_version` guard is composed from. The same member the
            CLI's detail view publishes under this name; it is not a new word.


            IT IS EQUALITY-ONLY: compare it, never order or interpret it. A
            change signals the row was mutated since you read it, and nothing
            more — it is a random value the engine rewrites, not a counter.


            ITS COVERAGE IS PARTIAL, and the partiality is inherited rather than
            introduced: the token is rewritten by claim, close, unclaim and the
            generic update path, and NOT by the direct-update paths that rewrite
            text without touching it. A client needing complete change detection
            combines it with `updated_at`, `status` and the label set.


            It is ALWAYS PRESENT, including as 0. Zero is a real value — a
            legacy row backfilled and not mutated since — and a `dep_add` is 0
            too, because an edge acts on no single row's version. An absent
            member would be ambiguous between the two.


            DECODE IT AS A 64-BIT INTEGER, for the reason
            `UpdateIssueRequest.expected_version` spells out: an IEEE-754-double
            parser corrupts it silently, and the corruption only shows up as a
            `precondition_failed` on the NEXT request. This member is the one
            every other `revision` on the surface is spelled against, so the
            warning belongs here most of all — a client that reads it through a
            lossy parser here carries the damage into every guard it composes.

    AddDependenciesRequest:
      type: object
      additionalProperties: false
      required: [actor, edges]
      properties:
        actor:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Who is asserting the edges, under `ClaimRequest.actor`'s rules and
            for the same reasons: the server trims it, refuses an empty result,
            anything longer than 256 BYTES, and any control character including
            newline. It is attributed on each `dependency_added` event a
            genuinely new edge records, and interpolated into the storage commit
            message.
        edges:
          type: array
          minItems: 1
          maxItems: 100
          description: >-
            The edges to assert, in the caller's order. An empty array is a
            `400` rather than a successful no-op: a write request that writes
            nothing is a client bug, and answering it cheerfully is how a client
            whose own list filtered to nothing silently stops wiring anything.


            The 100-edge cap is a bound on how long one request may hold a write
            transaction, not a statement about batch semantics. Split a larger
            graph; each request is atomic on its own — but note that splitting
            it changes what the cycle gate can see, since the gate runs over one
            request at a time.


            A per-edge refusal names its offender as `edges[i].member`.
          items:
            $ref: '#/components/schemas/DependencyEdge'

    DependencyEdge:
      type: object
      additionalProperties: false
      required: [issue_id, depends_on_id, type]
      description: >-
        One directed edge, as a REQUEST names it. It is not `Dependency`, which
        is the stored row `GET /v0/beads/dependencies` returns and carries the
        columns storage assigned; this is the three members a caller supplies.
      properties:
        issue_id:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            The edge's SOURCE — the issue that depends on the other end. An
            EXACT canonical id, and one this database holds: an edge follows its
            source, so a source that names nothing is a `400`.
        depends_on_id:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            The edge's TARGET — the issue depended upon. An exact canonical id,
            an `external:` reference, or an id belonging to another repository.
            Only an absence this database can SEE is refused. It must differ
            from `issue_id`.
        type:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            The edge type, from the same OPEN vocabulary `Dependency.type`
            carries: checked for being a storable value, never for membership of
            a known-types list, so a workspace's own type passes.

    AddDependenciesResponse:
      type: object
      required: [added]
      properties:
        added:
          type: array
          description: >-
            The request's edges, in REQUEST ORDER. It echoes the request because
            all-or-nothing means it is either every edge or the call failed, so
            a caller reporting what landed reads the result and never has to
            know which of the two it is safe to read. An idempotent same-type
            re-add is echoed like any other edge; the response does not say
            which edges were genuinely new, because nothing a client does
            depends on that.


            Never null and never shorter than the request: a partial outcome
            does not exist on this operation.
          items:
            $ref: '#/components/schemas/DependencyEdge'

    RemoveDependencyRequest:
      type: object
      additionalProperties: false
      required: [actor, issue_id, depends_on_id]
      properties:
        actor:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Who is removing the edge, under `ClaimRequest.actor`'s rules and
            for the same reasons: the server trims it, refuses an empty result,
            anything longer than 256 BYTES, and any control character including
            newline. It is attributed on the `dependency_removed` event a real
            removal records, and interpolated into the storage commit message.
        issue_id:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            The edge's SOURCE — the issue that depends on the other end. An
            EXACT canonical id: there is no fuzzy, prefix or substring
            resolution on this surface.
        depends_on_id:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            The edge's TARGET — the issue depended upon. An exact canonical id,
            under `issue_id`'s rule.

    RemoveDependencyResponse:
      type: object
      required: [removed]
      properties:
        removed:
          type: boolean
          description: >-
            True when an edge was there and is now gone. FALSE IS A SUCCESS,
            not a refusal: it says this pair carried no such edge, which is the
            same graph a second removal leaves. Nothing was written for it.

    ClaimRequest:
      type: object
      additionalProperties: false
      required: [actor]
      properties:
        actor:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Who is claiming the issue. The server trims it, then refuses an
            empty result, anything longer than 256 BYTES (the `maxLength` above
            counts characters — the byte limit is the binding one), and any
            control character including newline: Unicode category Cc — C0, DEL
            and the C1 block — plus the U+2028/U+2029 line separators, which is
            the set the `pattern` above spells.


            The value is persisted as the assignee and interpolated into the
            storage commit message, so an unvalidated newline would forge
            audit-trail lines. C1 is refused for that same reason and not for
            tidiness: U+0085 is a line break on a VT-conformant terminal, and
            U+009B is the one-byte CSI introducer, which would make an actor an
            escape-sequence payload in anything that prints an assignee.

    ClaimResponse:
      type: object
      required: [issue, already_claimed]
      properties:
        issue:
          $ref: '#/components/schemas/Issue'
        already_claimed:
          type: boolean
          description: >-
            True when the caller already held the issue and this call changed
            nothing — the idempotent re-claim. A claim held by a DIFFERENT
            actor is a 409, not a 200 with this flag.

    ReleaseIssueRequest:
      type: object
      additionalProperties: false
      required: [actor]
      properties:
        actor:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Who is releasing the claim. `ClaimRequest.actor`'s rules exactly:
            the server trims it, then refuses an empty result, anything longer
            than 256 BYTES (the `maxLength` above counts characters — the byte
            limit is the binding one), and any control character including
            newline. The value reaches the event the release records and the
            storage commit message, so an unvalidated newline would forge
            audit-trail lines.


            It is REQUIRED, and for one reason beyond the audit trail: a
            release is the moment work stops being owned, and the one question
            asked of its history entry afterwards is who let it go. On the
            unconditional path it is ALSO the ownership fence's subject — see
            the operation description.
        expected_assignee:
          type: string
          description: >-
            Compare-and-set on the holder: the release proceeds only while the
            issue is still assigned to this actor, and otherwise refuses with
            `409` / `precondition_failed` naming this value, having written
            nothing.


            A MATCH REPLACES THE OWNERSHIP FENCE, so `actor` need not be the
            holder. Sending it beside `force` is a 400: the two are answers to
            the same question and they disagree.


            THE COMPARISON IS SEPARATOR-INSENSITIVE AND NOTHING ELSE.
            A run of `.`, `_` or `-` matches any other such run, so `agent-a`,
            `agent_a` and `agent.a` are one holder — that is deliberate, so a
            caller naming the holder under a different layer's spelling is a
            match rather than a mismatch. NOTHING ELSE IS FORGIVEN: the value
            is not trimmed and not case-folded, so `" agent-a"` and `Agent-a`
            are both refusals. The server trims only far enough to tell a blank
            expectation from a real one and never sends the trimmed form on, so
            a caller that pads its expectation loses EVERY time rather than
            intermittently. Compose it from a holder a read gave you.


            THE EMPTY STRING IS A 400, and this is the one place this member
            disagrees with `UpdateIssueRequest.expected_assignee`, where an
            empty string is a real guard meaning "expected unassigned". Here
            "release a row nobody holds" describes no release at all; a caller
            that wants to assert a row is unheld is asking a READER a question,
            not asking this operation to do nothing. Absent, and only absent,
            selects the unconditional path.


            IT IS NOT LENGTH- OR PATTERN-BOUNDED the way `actor` is, and the
            asymmetry is deliberate: this value is COMPARED and never stored,
            so a value no assignee column could hold simply cannot match, and
            refusing it at the edge would be a refusal the role does not have.
        force:
          type: boolean
          default: false
          description: >-
            Bypass the ownership fence, so an actor that is not the holder may
            release the claim. It is the escape hatch `bd unclaim --force`
            spells, for an abandoned claim whose holder crashed.


            IT BYPASSES THE FENCE AND NOTHING ELSE. It does not make an unheld
            row releasable, it does not make a closed one releasable, and it
            never bypasses a precondition — sending it beside
            `expected_assignee` is a 400 rather than a silent win for either.

    ReleaseIssueResponse:
      type: object
      required: [issue, changed, revision]
      properties:
        issue:
          $ref: '#/components/schemas/Issue'
        changed:
          type: boolean
          description: >-
            Whether the release WROTE the row. It is TRUE on every 200 this
            operation returns, because every shape that would not write is
            refused above it — an unheld row is a 409, not an idempotent no-op.
            DO NOT WRITE A `changed: false` BRANCH: no request reaches one.


            It is published rather than omitted because `claimIssue` and
            `updateIssue` publish the same fact, and a caller holding all three
            should not have to read them two ways. It is also the negative
            space that answers "where is the `already_released` member" —
            there is none, and the operation description says why.
        revision:
          type: integer
          format: int64
          description: >-
            The row's optimistic-concurrency token AFTER the release, spelled
            the way `UpdateIssueResponse.revision` spells it and carrying the
            same promise: a read-modify-write loop composes its next
            `expected_version` from THIS value, never from a number it
            incremented itself.


            A release REMINTS the token by design, so a caller that guarded a
            following write on a version it read BEFORE the release will miss.
            That is the point — a concurrent reclaim or close conflicts rather
            than silently merging — and this member is how the caller stays in
            step.


            DECODE IT AS A 64-BIT INTEGER, for the reason
            `UpdateIssueRequest.expected_version` spells out: an IEEE-754-double
            parser corrupts it silently, and the corruption only shows up as a
            `precondition_failed` on the NEXT request.

    ClaimNextRequest:
      type: object
      additionalProperties: false
      required: [actor]
      properties:
        actor:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Who is claiming. `ClaimRequest.actor`'s rules exactly: the server
            trims it, then refuses an empty result, anything longer than 256
            BYTES (the `maxLength` above counts characters — the byte limit is
            the binding one), and any control character including newline. The
            value is persisted as the assignee and interpolated into the storage
            commit message, so an unvalidated newline would forge audit-trail
            lines.


            IT IS THE ONLY BODY MEMBER, and the FILTER travels in the query
            string instead. That split is deliberate: the filter vocabulary is
            `GET /v0/beads/ready`'s and is decoded by the same function, so
            re-spelling it as a body object would create a second expression of
            one predicate — and two spellings of one predicate eventually
            disagree. The actor cannot go the same way: it is provenance that
            lands in a column, and this surface has always carried that in a
            body.

    ClaimNextResponse:
      type: object
      properties:
        claimed:
          $ref: '#/components/schemas/IssueWithCounts'
      description: >-
        The outcome of one atomic take of ready work.


        `claimed` IS ABSENT WHEN NOTHING WAS ELIGIBLE, and its absence is the
        whole signal — there is no boolean beside it, because a second member
        carrying the same fact is a second member that can disagree with the
        first. A polling client branches on presence.


        IT IS ALSO THE ONLY MEMBER, deliberately. A count of what was scanned,
        or how many rows a racing agent had already taken, would describe a
        moment inside a transaction that has committed and is not a fact about
        the claim.


        The row is `IssueWithCounts` — the element type `GET /v0/beads/ready`
        returns and `bd ready --json` emits — hydrated INSIDE the transaction
        that committed the claim, so its counts describe the state the claim
        produced rather than a later one. It is not an `Issue` because the
        listing this replaces answers with counts, and a client swapping the
        composed pair for this operation should not lose a field doing it.

    BatchCloseRequest:
      type: object
      additionalProperties: false
      required: [actor, items]
      properties:
        actor:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Who is closing. `ClaimRequest.actor`'s rules exactly, and the value
            is recorded against every item.
        items:
          type: array
          minItems: 1
          maxItems: 100
          items:
            $ref: '#/components/schemas/BatchCloseItem'
          description: >-
            The issues to close, in the order the caller asked for them. Every
            item appears in `outcomes` at the same index.


            An EMPTY array is a `400` rather than an empty answer, and the cap
            is `batchCreateIssues`' cap for its reason: it bounds how long one
            request may hold a write transaction.
        session:
          type: string
          maxLength: 255
          description: >-
            The working session, recorded against every item that closes, under
            `CloseIssueRequest.session`'s first-close-wins rule and bounds.
        force:
          type: boolean
          default: false
          description: >-
            Bypass close policy — the open-children refusal and the live-blocker
            refusal — for EVERY item, and nothing else. It never bypasses
            validation and it never bypasses existence: an id that names nothing
            refuses whether or not this is set. It is request-wide because the
            flag that spells it is.

    BatchCloseItem:
      type: object
      additionalProperties: false
      required: [id]
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            Exact canonical issue id, resolved across BOTH planes. No fuzzy,
            prefix or substring resolution — `IssueID`'s rule.


            A DUPLICATE is admissible; see the operation description.
        reason:
          type: string
          maxLength: 255
          description: >-
            Why THIS issue is closed. It is per item rather than per request
            because `bd close a b c --reason x --reason y --reason z` has always
            mapped them positionally, and one request-wide reason could not
            express it. `CloseIssueRequest.reason`'s rules and first-close-wins.

    BatchCloseResponse:
      type: object
      required: [outcomes]
      properties:
        outcomes:
          type: array
          items:
            $ref: '#/components/schemas/CloseOutcome'
          description: >-
            Exactly one entry per requested item, in REQUEST ORDER — including
            for items that refused, so a client walks this against its own
            argument list without matching ids back up.

    CloseOutcome:
      type: object
      required: [issue_id]
      description: >-
        What happened to ONE requested item.


        `code` IS THE DISCRIMINATOR. Present means the item REFUSED and nothing
        was written for it; absent means it succeeded, and `issue`,
        `already_closed` and `open_children` are all present. A client branches
        on `code` first and reads nothing else until it has.
      properties:
        issue_id:
          type: string
          description: >-
            The id the caller asked for, echoed verbatim so an outcome can be
            read without indexing back into the request.
        issue:
          $ref: '#/components/schemas/Issue'
        already_closed:
          type: boolean
          description: >-
            True when the issue was already closed and this item changed
            nothing — the idempotent re-close, spelled the way
            `CloseIssueResponse.already_closed` spells it. Present only on a
            successful item.


            A BATCH WHOSE ITEMS ARE ALL `true` LANDED NOTHING, and records no
            history entry: a per-item success that changed nothing is not work
            the caller did.
        open_children:
          type: integer
          description: >-
            How many open children the transaction observed for this item.


            ITS MEANING FOLLOWS `code`, and both readings are the ones the
            single close already publishes. On a SUCCESSFUL item it is always
            present and is `CloseIssueResponse.open_children` — the number a
            forced close bypassed, reported even for an idempotent re-close, and
            `0` for an unforced close that got that far. On a REFUSED item its
            PRESENCE is the discriminator between the two `not_closable`
            refusals, exactly as it is on a problem document: present means open
            children, absent means a live blocker.
        code:
          type: string
          description: >-
            This item's refusal, from `Problem.code`'s vocabulary and restricted
            to `not_found` (the id names no row in either plane) and
            `not_closable` (close policy refused it: open children, or a live
            blocker — see `open_children`). ABSENT means the item succeeded.


            It is the problem vocabulary rather than a second one because an
            item refusal and a request refusal are the same question asked at
            two scopes, and a client that had to learn two vocabularies to
            classify one condition would be classifying the SCOPE rather than
            the condition.
        detail:
          type: string
          description: >-
            Prose for a refusal, never load-bearing and present only with
            `code`. It reflects the request and this server's own words rather
            than the role's message, for the reason `Problem.detail` gives.

    CloseIssueRequest:
      type: object
      additionalProperties: false
      required: [actor]
      properties:
        actor:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Who is closing the issue. `ClaimRequest.actor`'s rules exactly: the
            server trims it, then refuses an empty result, anything longer than
            256 BYTES (the `maxLength` above counts characters — the byte limit
            is the binding one), and any control character including newline.
            The value reaches stored columns, event-stream attribution and the
            storage commit message, so an unvalidated newline would forge
            audit-trail lines.
        reason:
          type: string
          maxLength: 255
          description: >-
            Why the issue is closed. Stored on the issue and read back as
            `close_reason`. THE FIRST CLOSE WINS: an idempotent re-close writes
            neither this nor `session`, so a replayed close cannot rewrite the
            record of why the work ended. Refused for control characters, and
            bounded by what the column holds rather than by the number above.
        session:
          type: string
          maxLength: 255
          description: >-
            The working session that closed the issue, stored and read back as
            `closed_by_session`, under the same first-close-wins rule and the
            same bounds as `reason`.
        force:
          type: boolean
          default: false
          description: >-
            Bypass close policy — the open-children refusal and the
            live-blocker refusal — and nothing else. The refusals are the
            ROLE's, so this endpoint cannot skip a guard by forgetting one
            exists. A forced close still reports `open_children`.


            IT BYPASSES POLICY, NEVER A PRECONDITION. `expected_version` is
            still checked with it set, for the reason
            `issueops.CloseRequest.Force` gives: a caller saying "close it
            anyway" has said nothing about whether the row is still the one it
            read.
        expected_version:
          type: integer
          format: int64
          description: >-
            Requires the row's revision to equal this value BEFORE the close. A
            miss refuses the whole request with `409 precondition_failed` and
            writes nothing — `UpdateIssueRequest.expected_version`'s contract,
            on the operation that closes one row.


            IT IS CHECKED BEFORE THE IDEMPOTENT RE-CLOSE, which is the one place
            this guard differs from the update's. A re-close of a row somebody
            else has moved since the caller read it is a `409` and not the
            200-with-`already_closed` the same body earns without a guard: a
            replay whose premise has expired is a refusal the caller wants to
            see, and it is the only way `already_closed` can be trusted as
            "nothing has happened here since".


            The token is the `revision` this operation's own response carries.
            Compose the next expectation from the value a write ANSWERED with,
            never from a number the client incremented itself: the token is
            OPAQUE and compared for equality alone, so it has no predecessor a
            client can compute. A first guarded close seeds itself from
            `GET /v0/beads/issues/{id}`'s `revision` — the read that sources a
            guard — or, for a chain already mid-flight, from an unguarded
            lifecycle write or `POST /v0/beads/issues:batchApply`'s
            `ApplyItemResult.revision`.


            DECODE IT AS A 64-BIT INTEGER, for the reason
            `UpdateIssueRequest.expected_version` spells out: an IEEE-754-double
            parser corrupts it silently, and the corruption only surfaces as a
            `precondition_failed` on the NEXT request.

    CloseIssueResponse:
      type: object
      required: [issue, already_closed, open_children, revision]
      properties:
        issue:
          $ref: '#/components/schemas/Issue'
        already_closed:
          type: boolean
          description: >-
            True when the issue was already closed and this call changed
            nothing — the idempotent re-close, mirroring
            `ClaimResponse.already_claimed`. The response still carries the
            row, and `reason`/`session` were not rewritten.
        open_children:
          type: integer
          description: >-
            How many open children the close observed. Reported by a FORCED
            close — including an idempotent re-close — because a caller that
            bypassed the guard is exactly the caller that wants the number. An
            unforced close that got this far had none, so it reports 0.
        revision:
          type: integer
          format: int64
          description: >-
            The row's optimistic-concurrency token AFTER this close, spelled the
            way `UpdateIssueResponse.revision` spells it.


            It is here because `expected_version` is: a guard whose token no
            response carries is a guard a caller cannot fill, and a
            close-then-reopen or close-then-delete chain has to compose its next
            expectation from the value the close ANSWERED with. An idempotent
            re-close carries one too — the row still has a version, and a caller
            that guarded a replay needs the token whether or not the replay
            wrote.


            DECODE IT AS A 64-BIT INTEGER, for the reason
            `UpdateIssueResponse.revision` spells out.

    ReopenIssueRequest:
      type: object
      additionalProperties: false
      required: [actor]
      properties:
        actor:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Who is reopening the issue. `ClaimRequest.actor`'s rules exactly:
            the server trims it, then refuses an empty result, anything longer
            than 256 BYTES (the `maxLength` above counts characters — the byte
            limit is the binding one), and any control character including
            newline. The value reaches the `reopened` event's attribution and
            the storage commit message, so an unvalidated newline would forge
            audit-trail lines.
        reason:
          type: string
          maxLength: 255
          description: >-
            Why the issue is being reopened. Recorded on the `reopened` EVENT
            this move records — not on a field of the issue, and not carried in
            the response, so a caller that wants it back reads the issue's
            events. Refused for control characters, and bounded by what the
            column holds rather than by the number above.
        expected_version:
          type: integer
          format: int64
          description: >-
            Requires the row's revision to equal this value BEFORE the reopen. A
            miss refuses the whole request with `409 precondition_failed` and
            writes nothing — `CloseIssueRequest.expected_version`'s contract, on
            the close's mirror.


            IT IS CHECKED BEFORE THE NON-DONE NO-OP, the mirror of the close's
            check-before-the-idempotent-re-close, and for the same reason: a
            reopen of a row somebody else has moved is a `409` rather than the
            200-with-`already_open` the same body earns unguarded, which is what
            lets `already_open` be read as "nothing has happened here since".


            The token is the `revision` this operation's own response carries;
            compose the next expectation from a value a write ANSWERED with and
            never from one the client computed. DECODE IT AS A 64-BIT INTEGER,
            for the reason `UpdateIssueRequest.expected_version` spells out.

    ReopenIssueResponse:
      type: object
      required: [issue, already_open, revision]
      properties:
        issue:
          $ref: '#/components/schemas/Issue'
        already_open:
          type: boolean
          description: >-
            True when the issue was not in a done status and this call changed
            nothing — idempotent, mirroring `CloseIssueResponse.already_closed`
            and `ClaimResponse.already_claimed`. The response still carries the
            row.
        revision:
          type: integer
          format: int64
          description: >-
            The row's optimistic-concurrency token AFTER this reopen, spelled
            the way `CloseIssueResponse.revision` spells it and here for the same
            reason: a recovery flow that reopens and then re-closes composes its
            next `expected_version` from this value. DECODE IT AS A 64-BIT
            INTEGER.

    MetadataValue:
      # No x-go-type-import: the generator already imports encoding/json
      # unconditionally, and naming it here emits the import a second time.
      x-go-type: json.RawMessage
      # nullable, because `null` is a REAL VALUE of this type rather than the
      # absence of one — the whole point of the member. It is declared for the
      # validating gateway in front of somebody's server, which would otherwise
      # be entitled to reject a legitimate `"expected": null`.
      nullable: true
      # skip-optional-pointer, and this one is a CORRECTNESS fix rather than
      # ergonomics. A *json.RawMessage cannot READ a present null: encoding/json
      # handles a JSON null against a pointer by setting the pointer to nil
      # before any UnmarshalJSON runs, so a generated client decoding
      # {"swapped":false,"current":null} gets Current==nil — byte-identical to an
      # omitted member, which on this operation means the opposite thing. A
      # retry loop against a null-valued key would read "absent", swap with
      # `expected` omitted, mismatch, and never converge. A bare json.RawMessage
      # is an Unmarshaler in its own right, so it receives the literal `null` and
      # stores it, while an omitted member leaves it nil and `omitempty` still
      # omits it on the way out — the wire is unchanged in both directions.
      x-go-type-skip-optional-pointer: true
      description: >-
        One metadata value: ANY JSON value — string, number, boolean, null,
        array or object — because typed values enter through the explicit JSON
        metadata path and persist in older rows. It is not a string, and a
        client must not decode it as one.


        Where a member of this type is OMITTED, the key is absent; where it is
        present holding `null`, the key exists and holds null. Those are
        different states and this surface reports both.

    CompareAndSetMetadataRequest:
      type: object
      additionalProperties: false
      required: [actor, key]
      properties:
        actor:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Who is performing the swap. `ClaimRequest.actor`'s rules exactly:
            the server trims it, then refuses an empty result, anything longer
            than 256 BYTES (the `maxLength` above counts characters — the byte
            limit is the binding one), and any control character including
            newline. It reaches the update event's attribution and the storage
            commit message, so an unvalidated newline would forge audit-trail
            lines.


            It is REQUIRED here rather than optional, because a swap is a
            coordination write between racing callers and the one question
            asked of its history entry afterwards is which of them won.
        key:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            The single metadata key to read and write. It must match the
            workspace's metadata-key syntax — a letter or underscore, then
            letters, digits, underscores, dots and slashes — so a key the query
            layer could not later spell is refused rather than written.


            ONE KEY, NOT A PATH: a dotted key like `gc.lease` names a top-level
            key spelled with a dot, not a nested field. The metadata object's
            nesting is VALUE structure, and this operation swaps whole values.
        expected:
          $ref: '#/components/schemas/MetadataValue'
        value:
          $ref: '#/components/schemas/MetadataValue'

    CompareAndSetMetadataResponse:
      type: object
      required: [swapped]
      properties:
        swapped:
          type: boolean
          description: >-
            Whether the precondition held and the transition applied. THIS IS
            THE VERDICT and the only member to dispatch on. False is a lost
            race — an answer, not a failure — and the response is still a 200.
        current:
          $ref: '#/components/schemas/MetadataValue'

    UpdateIssueRequest:
      type: object
      additionalProperties: false
      required: [actor, patch]
      properties:
        actor:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Who is editing the issue. `ClaimRequest.actor`'s rules exactly: the
            server trims it, then refuses an empty result, anything longer than
            256 BYTES (the `maxLength` above counts characters — the byte limit
            is the binding one), and any control character including newline.
            The value reaches the history entry's attribution and the storage
            commit message, so an unvalidated newline would forge audit-trail
            lines.
        patch:
          $ref: '#/components/schemas/IssuePatchBody'
        expected_version:
          type: integer
          format: int64
          description: >-
            Requires the row's revision to equal this value before the patch. A
            miss refuses the WHOLE request with `409 precondition_failed` and
            writes nothing — `ApplyUpdateItem.expected_version`'s contract, on
            the operation that patches one row.


            The token is the `revision` this operation's own response carries,
            and the same one `GET /v0/beads/issues/{id}` publishes — which is
            where a first guarded write seeds itself, rather than from an
            unguarded one or from `POST /v0/beads/issues:batchApply`'s
            `ApplyItemResult.revision`.
            Compose the next expectation from the value the write ANSWERED with,
            never from a number the client incremented itself: the token is
            OPAQUE and compared for equality alone, so it has no predecessor a
            client can compute.


            DECODE IT AS A 64-BIT INTEGER. Live tokens run past 5e17, where an
            IEEE-754 double's ulp is already 64, so a parser that decodes JSON
            numbers as doubles — JavaScript's `JSON.parse`, Go's `any`, Python's
            `float` — hands back a value NEAR the token that is not it, and the
            guard is refused against a row nothing else touched.
        expected_status:
          type: string
          maxLength: 255
          description: >-
            Requires the issue's status to equal this value before the patch. A
            miss refuses the whole request with `409 precondition_failed`.


            Unlike `expected_version` this one is readable: `Issue.status` is on
            every read of this surface, so a caller can guard a status
            transition without any token at all.
        expected_assignee:
          type: string
          maxLength: 255
          description: >-
            Requires the issue's assignee to equal this value before the patch.
            A match AUTHORIZES the requested `patch.assignee` transfer: this
            compare-and-set replaces the ordinary anti-steal fence, so it must
            not be combined with `force_assignee_transfer`. A miss refuses the
            whole request with `409 precondition_failed`.
        force_close_policy:
          type: boolean
          default: false
          description: >-
            Bypasses ONLY close policy — the open-children refusal and the live
            blocker refusal — for a `patch.status` that crosses into the
            workspace's done category. It has no effect without such a status
            change, and it never bypasses validation, the preconditions above,
            or the assignee fence.
        force_assignee_transfer:
          type: boolean
          default: false
          description: >-
            Bypasses ONLY a genuine transfer away from a live foreign
            in-progress owner. Reasserting the exact current assignee is
            idempotent and needs no force. It requires `patch.assignee` — a
            request setting it without one is a `400` — and it must be false
            when `expected_assignee` is sent.

    IssuePatchBody:
      type: object
      additionalProperties: false
      description: >-
        The fields to write. Every member is optional and PRESENCE is the
        signal: a member present is written, a member absent is untouched. An
        empty object is a `400` — a write that writes nothing is a client bug.


        This is a deliberate SUBSET of the fields an issue carries; the members
        it does not spell are future surface rather than oversights, and
        `updateIssue`'s own description says which and why.


        It now agrees with `ApplyPatchBody` on every member it publishes, and
        the two differ only in the SHAPE of two of them: `labels` is complete
        replacement here and an ordered add/remove/replace patch there, because
        that operation edits a set it did not compose. Everything else — down to
        the `metadata` algebra and the four nullable members — is one
        definition, so a caller cannot get a different answer for the same edit
        depending on which operation it sent.
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            The issue's title. Must not be blank after trimming; the length
            bound is what the column holds.
        description:
          type: string
        design:
          type: string
        acceptance_criteria:
          type: string
        notes:
          type: string
          description: >-
            Replaces the notes. Mutually exclusive with `append_notes`; sending
            both is a `400`.
        append_notes:
          type: string
          description: >-
            Appends to the notes rather than replacing them. Mutually exclusive
            with `notes`.
        priority:
          type: integer
          minimum: 0
          maximum: 4
        issue_type:
          type: string
          maxLength: 255
          description: >-
            The issue type, from this workspace's own configured vocabulary. A
            type outside it is refused by the ROLE and reaches the client as a
            `400` — this server cannot read the vocabulary without a
            transaction, so it checks only what this schema declares.
        status:
          type: string
          maxLength: 255
          description: >-
            The issue's status, from this workspace's own configured vocabulary.


            A STATUS THAT CROSSES INTO THE DONE CATEGORY ANSWERS TO CLOSE
            POLICY: the update is refused with `409 not_closable` for open
            children or a live blocker unless `force_close_policy` is set. A
            done-to-done change and a move OUT of the done category are
            unaffected.


            IT IS NOT A SECOND SPELLING OF `{id}:close` AND `{id}:reopen`. Those
            two carry semantics a status write has nowhere to put — the reason
            and session under first-close-wins, the done-status normalization,
            the `already_closed`/`already_open` idempotence flags — and they
            remain the operations to reach for when what you mean is "close
            this". This member is for the edit that moves a status ALONGSIDE
            other fields in one transaction, which is the thing two calls cannot
            do. `ApplyPatchBody.status` has meant exactly this since
            `issues:batchApply` landed.
        assignee:
          type: string
          maxLength: 255
          description: >-
            The assignee. A transfer away from a live foreign in-progress owner
            is refused with `409 already_claimed` unless
            `force_assignee_transfer` is set or `expected_assignee` matched.
            Setting it to the empty string unassigns.


            `{id}:claim` remains the operation that ACQUIRES work: it carries
            its own eligibility rules and sets the status with the assignee in
            one act. This member is the raw write, fenced.
        parent_id:
          type: string
          maxLength: 255
          description: >-
            Replaces the issue's parents atomically: a nonempty value makes THAT
            issue the only parent, and an EMPTY STRING removes every parent-child
            edge the issue has. Labels are not inherited — that is a create-time
            choice (`CreateIssueRequest.inherit_labels_from_parent`) and a
            reparent does not re-run it.


            IT IS A GRAPH EDIT, and it earns the graph's refusals: a new parent
            this workspace holds no row for is a `400`, a pair that already
            carries an edge of another type is `409 dependency_exists`, and a
            move under the issue's own descendant is `409 dependency_cycle` —
            the PLAIN one, carrying no `issue_id`/`blocker_id`/
            `blocker_is_ancestor`, because the hierarchy refusal answers only to
            blocking edges and this member writes a `parent-child` edge.
            Naming the issue itself is a `400`. One call rather than a
            remove-then-add pair, which is the whole reason it is here: the
            two-call spelling leaves the issue parentless if the second call
            fails.
        labels:
          type: array
          items:
            type: string
            maxLength: 255
          description: >-
            COMPLETE REPLACEMENT of the label set. An empty array clears every
            label.


            It is the REPLACE half of the same ordered edit `ApplyPatchBody`
            spells as `labels.replace`, and `add_labels`/`remove_labels` are the
            other two. All three may travel together and are applied in that
            order — replace, then add, then remove — so REMOVAL WINS when one
            label appears in more than one of them. That is the role's own
            algebra, not this operation's arrangement of it.


            THE SHAPE DIFFERS FROM `ApplyPatchBody`'s, which nests the three
            under one `labels` object, and the difference is historical rather
            than meaningful. This member shipped as a bare array; nesting it now
            would RE-TYPE a published member, which is the one kind of change
            this document has no additive route for. Two flat siblings is the
            shape that could be added — and it is the shape `notes` and
            `append_notes` already use for the same replace/increment pair.
        add_labels:
          type: array
          items:
            type: string
            maxLength: 255
          description: >-
            Labels to add, applied AFTER any `labels` replacement.


            IT IS NOT MUTUALLY EXCLUSIVE WITH `labels`, and that is the
            difference from `append_notes`, which is. The role defines an order
            over all three label edits, so sending a replacement and an addition
            together has a defined result; notes have no such algebra, so there
            the two are a contradiction and are refused.


            IT IS WHY THIS PAIR EXISTS. A caller that reads a row, adds one
            label and writes the whole set back silently drops any label another
            writer added in between — and `bd label add` and every agent that
            tags work concurrently are exactly that caller. A replacement can
            only be composed safely by a writer that knows it is alone.


            Repetition is free: a label named twice is applied once, and adding
            one the issue already carries changes no labels. (Whether the
            RESPONSE reports `changed: false` is a fact about the whole patch —
            see `remove_labels`.) An EMPTY-STRING entry is DROPPED rather than
            refused — a label row carrying `""` renders as nothing and matches
            nothing, so writing one would only store junk, and refusing the
            whole update would let one stray entry fail an otherwise-good edit.
        remove_labels:
          type: array
          items:
            type: string
            maxLength: 255
          description: >-
            Labels to remove, applied AFTER `labels` and `add_labels`, so
            REMOVAL WINS over both.


            Removing a label the issue does not carry CHANGES NO LABELS; it is
            not a `404` and not a conflict. Whether the RESPONSE reports
            `changed: false` is a fact about the whole patch, not about this
            member — a request that also moved a title changed the row. The same
            repetition and empty-string rules as `add_labels` apply, and a value
            longer than the column is refused here as it is there — the length
            rule is about what a label may BE, not about whether this particular
            row happens to carry one.
        estimated_minutes:
          type: integer
          nullable: true
          description: 'Explicit `null` CLEARS the estimate.'
        external_ref:
          type: string
          nullable: true
          maxLength: 255
          description: 'Explicit `null` CLEARS the reference.'
        due_at:
          type: string
          format: date-time
          nullable: true
          description: 'RFC 3339. Explicit `null` CLEARS the due date.'
        defer_until:
          type: string
          format: date-time
          nullable: true
          description: 'RFC 3339. Explicit `null` CLEARS the deferral.'
        metadata:
          $ref: '#/components/schemas/ApplyMetadataPatch'

    UpdateIssueResponse:
      type: object
      required: [issue, changed, revision]
      properties:
        issue:
          $ref: '#/components/schemas/Issue'
        changed:
          type: boolean
          description: >-
            Whether the request persisted a semantic mutation. A same-value
            patch is a 200 with `changed: false` rather than an error —
            idempotent, like every replay answer on this surface.
        revision:
          type: integer
          format: int64
          description: >-
            The row's optimistic-concurrency token AFTER this write, spelled the
            way `ApplyItemResult.revision` spells it.


            It is here because `expected_version` is: a guard whose token no
            response carries is a guard a caller cannot fill. A read-modify-write
            loop composes its next expectation from THIS value and never from a
            number it incremented itself, for the reason
            `compareAndSetMetadata` gives about a value the store renormalizes.
            `GET /v0/beads/issues/{id}`'s `revision` is the read that publishes
            the same token, and this member agrees with it.


            DECODE IT AS A 64-BIT INTEGER, for the reason
            `UpdateIssueRequest.expected_version` spells out: an IEEE-754-double
            parser corrupts it silently, and the corruption only shows up as a
            `precondition_failed` on the NEXT request.

    CreateIssueRequest:
      type: object
      additionalProperties: false
      required: [actor, title]
      description: >-
        One issue, its parent, its explicit edges and its waits-for gate,
        created as one act.


        It is FLAT rather than nesting the issue's fields under an `issue`
        member, unlike `UpdateIssueRequest`'s `patch`: a patch has to distinguish
        a member that is absent from one set to its zero value, and a create has
        no such distinction to make — an absent member is the workspace default,
        which is the same answer a nested object would have given.


        The issue members mirror `ApplyCreateItem` exactly, minus that schema's
        two plan-only members (`key` and `metadata_refs`, which name items of a
        request this operation has only one of). What this adds is the edge
        vocabulary that operation moves into `dep_add` items: `parent_id`,
        `inherit_labels_from_parent`, `dependencies` and `waits_for`.
      properties:
        actor:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[^\u0000-\u001F\u007F-\u009F\u2028\u2029]+$'
          description: >-
            Who is creating the issue. `ClaimRequest.actor`'s rules exactly: the
            server trims it, then refuses an empty result, anything longer than
            256 BYTES (the `maxLength` above counts characters — the byte limit
            is the binding one), and any control character including newline.
            The value reaches the created edges' author column, the history
            entry's attribution and the storage commit message, so an
            unvalidated newline would forge audit-trail lines.


            It is NOT the issue's `created_by`, which this operation does not
            publish: this is the caller-asserted provenance of the ACT, and the
            row's own author column is left to the implementation.
        id:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            An explicit id for the new row, CREATE-ONLY: an id that already
            names a stored row is a `409` `already_exists` and nothing is
            written — never an adoption and never an overwrite. It is checked
            against the workspace's configured issue prefix unless
            `force_id_prefix` is set. Absent is the ordinary case and the server
            mints one.
        title:
          type: string
          minLength: 1
          maxLength: 255
          description: The issue's title. Must not be blank after trimming.
        description:
          type: string
        design:
          type: string
        acceptance_criteria:
          type: string
        notes:
          type: string
        issue_type:
          type: string
          maxLength: 255
          description: >-
            Issue type. Spelled `issue_type` rather than `type`, matching the
            member `Issue` carries, and validated against the built-ins plus the
            workspace's configured custom types by the ROLE — this server cannot
            read that vocabulary without a transaction, so it checks only what
            this schema declares and an unknown one arrives as a `400`.


            SEND ONE. The member is optional in this schema and the role
            validates the EMPTY type against the same vocabulary as any other,
            where it is neither a built-in nor a configured type — so an omitted
            `issue_type` is refused with everything else the request asked for.
            It stays optional because the vocabulary belongs to the workspace and
            a deployment may configure a default this server cannot read, but it
            is not optional in practice on any workspace shipped today.
            `POST /v0/beads/issues:batchCreate` has the same property and does
            not say so, which is why this member does.
        status:
          type: string
          maxLength: 255
          description: >-
            The status the issue is created in, from this workspace's own
            configured vocabulary. Absent means the workspace's own default,
            which is `open` today — unlike `issue_type`, the role fills this one
            in before it validates.
        priority:
          type: integer
          minimum: 0
          maximum: 4
          description: 0 is P0/critical. Absent means the workspace default.
        assignee:
          type: string
          maxLength: 255
        owner:
          type: string
          maxLength: 255
          description: >-
            The human owner, which is a different member from `assignee`: the
            assignee is who is working it now, the owner is who it is attributed
            to.
        labels:
          type: array
          items:
            type: string
            maxLength: 255
          description: >-
            The complete label set the issue is created with. Authoritative, not
            a patch — a create has nothing to add to. `inherit_labels_from_parent`
            adds the parent's labels on top of it.
        estimated_minutes:
          type: integer
          description: >-
            An estimate in minutes. Absent leaves it unset. NOT nullable, unlike
            `IssuePatchBody.estimated_minutes`: a create has nothing to clear, so
            `null` here would be a second spelling of omission and is a `400`.
        external_ref:
          type: string
          maxLength: 255
          description: 'e.g. `gh-9`. Not nullable, for `estimated_minutes`'' reason.'
        due_at:
          type: string
          format: date-time
          description: 'RFC 3339. Not nullable, for `estimated_minutes`'' reason.'
        defer_until:
          type: string
          format: date-time
          description: >-
            RFC 3339. The issue is hidden from ready work until then. Not
            nullable, for `estimated_minutes`' reason.
        sender:
          type: string
          maxLength: 255
          description: >-
            Who sent this, for the message-shaped rows an orchestrator creates.
            Stored verbatim and interpreted by nothing on this surface.
        metadata:
          $ref: '#/components/schemas/MetadataValue'
        ephemeral:
          type: boolean
          default: false
          description: >-
            Creates the issue on the EPHEMERAL plane rather than the durable one,
            exactly as it does for `POST /v0/beads/issues:batchApply`. Mutually
            exclusive with `no_history`.
        no_history:
          type: boolean
          default: false
          description: >-
            Creates the issue on the ephemeral plane WITHOUT history, and without
            the garbage collection an ordinary ephemeral row is eligible for.
            Mutually exclusive with `ephemeral`.
        parent_id:
          type: string
          maxLength: 255
          description: >-
            Creates a typed `parent-child` edge from the new issue to this
            target. It must not duplicate an edge `dependencies` already spells;
            naming the same pair twice with two types is a `400`.
        inherit_labels_from_parent:
          type: boolean
          default: false
          description: >-
            Copies the parent's labels onto the new issue at creation, on top of
            `labels`. It has no effect without `parent_id`.


            The DEFAULT IS FALSE and diverges from `bd create --parent`, whose
            default is to inherit. A wire caller sends what it means: this
            operation has no `--no-inherit-labels` to turn off, and a create that
            silently acquired labels the request never named would be a set the
            caller has to read back to learn.
        dependencies:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/CreateIssueDependency'
          description: >-
            The complete set of explicit edges created with the issue.
            Authoritative, not a patch. Every edge is written in the same
            transaction as the row, so an edge this request cannot write means no
            issue either.


            A TARGET NEED NOT BE A ROW THIS DATABASE HOLDS: an `external:`
            reference and an id belonging to another repository are legitimate
            targets, so only an absence this database can SEE is refused —
            `ApplyDepAddItem`'s rule, unchanged.
        waits_for:
          $ref: '#/components/schemas/CreateIssueWaitsFor'
        force_id_prefix:
          type: boolean
          default: false
          description: >-
            Permits an explicit `id` outside the workspace's configured issue
            prefix. It bypasses ONLY that check: it is not a force on the
            create-only guard, so an occupied id is still a `409`.

    CreateIssueDependency:
      type: object
      additionalProperties: false
      required: [target_id, type]
      description: >-
        One edge created with the issue. It carries `reverse` where
        `BatchCreateDependency` does not, because that operation's items have no
        id a target could point back at and this one's issue does.
      properties:
        target_id:
          type: string
          minLength: 1
          maxLength: 255
          description: The other endpoint of the edge.
        type:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            The edge type, from the same OPEN vocabulary `Dependency.type`
            carries: checked for BEING a storable value, never for membership of
            a known-types list, so a workspace's own type passes.
        reverse:
          type: boolean
          default: false
          description: >-
            Writes the edge from `target_id` TO the new issue rather than from
            it. It is what lets a create declare an edge that points INTO the row
            being minted — the id no caller could have spelled beforehand — and
            it is the member that makes `dependency_cycle` reachable on this
            operation at all.
        metadata:
          $ref: '#/components/schemas/MetadataValue'

    CreateIssueWaitsFor:
      type: object
      additionalProperties: false
      required: [spawner_id]
      description: >-
        A typed `waits-for` edge from the new issue to a spawner whose children
        gate it. It records a readiness primitive; it does not define scheduling
        or execution policy.


        IT IS A TYPED MEMBER HERE AND A METADATA BLOB ON
        `POST /v0/beads/issues:batchApply`, and the difference follows the ROLE
        rather than taste: `CreateRequest.WaitsFor` is a typed field that gets
        the gate defaulted and the "must not duplicate an explicit edge" check,
        while that operation's `dep_add` item is one generic edge with no typed
        field to reach. One spelling per operation, and each is its role's.
      properties:
        spawner_id:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            The dependency target whose children are observed. It must not
            duplicate an edge `dependencies` or `parent_id` already spells.
        gate:
          type: string
          maxLength: 255
          description: >-
            The readiness condition: `all-children` or `any-children`. Absent or
            empty defaults to `all-children`. A value that is neither is refused
            by the ROLE and reaches the client as a `400`.

    Problem:
      type: object
      description: >-
        RFC 9457 problem detail. This is the only error shape on this surface.
        The core declares `type`; this server never emits it, so `about:blank`
        is implied throughout.
      required: [status, title, code, request_id]
      properties:
        type:
          type: string
          format: uri
          description: >-
            RFC 9457 problem type. This server never emits it, so `about:blank`
            is implied. A deployment that hosts problem documentation MAY supply
            it: one stable URI per status+code pair, dereferencing to
            documentation for that pair. It restates identity that `code`
            already carries, so a client MUST NOT dispatch on it and a server
            MUST NOT use it to subdivide a code.
        status:
          type: integer
          description: The HTTP status code, repeated in the body.
        title:
          type: string
          description: The status phrase. Human-facing; never dispatch on it.
        code:
          type: string
          description: >-
            The stable machine-readable reason, and the ONLY member a client
            may dispatch on. v0's vocabulary: `invalid_argument` (400, also
            emitted by the Host-header middleware on any route),
            `invalid_cursor` (400), `unauthenticated` (401, only on a server
            configured with a token file), `not_found` (404),
            `already_claimed` (409), `not_claimable` (409),
            `not_closable` (409), `not_releasable` (409),
            `dependency_cycle` (409), `dependency_exists` (409),
            `already_exists` (409), `precondition_failed` (409),
            `events_journal_disabled` (409), `events_journal_truncated` (410),
            `busy` (503),
            `db_unavailable` (503), `events_watch_saturated` (503),
            `internal` (500). Renaming or removing a status+code pair is a
            breaking change; ADDING one is not, so clients MUST default-branch
            on unknown values and fall back to the status class (unknown 4xx →
            client bug, fail loud; unknown 503 → retry per `Retry-After`; other
            unknown 5xx → server fault).
        detail:
          type: string
          description: >-
            Optional prose, never load-bearing. For 5xx codes it is a FIXED
            string per code and carries nothing about the underlying failure:
            driver and dial errors routinely embed the DSN, database user and
            host:port, and this API supports binding beyond loopback. It is
            fixed for `unauthenticated` too, and for the mirror-image reason:
            the caller's own input there is a credential, so echoing it would
            write the token into every client log and proxy trace on the way
            back. Other 4xx details reflect the caller's own input back and are
            specific.
        param:
          type: string
          description: >-
            With `invalid_argument`: the offending query parameter, body member
            or header name. Present on every 400 except a body that fails to
            parse at all.


            With `precondition_failed`: the body member carrying the guard that
            missed. It is the same spelling a 400 on the same operation would
            use, so a client reads one member to find the offending input
            whichever way the request was refused.
        reason:
          type: string
          description: >-
            With `invalid_argument`: `unknown_parameter` (this server does not
            know that parameter — version skew; degrade or fall back),
            `invalid_value` (the value is not one this server will act on:
            malformed, out of vocabulary, or — for `limit=0` under
            `--allow-non-loopback` — legal but refused in this server's
            configuration; `detail` says which), or `project_mismatch` (the
            `Bd-Project-Id` header named a project this server does not serve —
            a document-level refusal like the Host-header 400, raised on every
            enforced route, and the one that carries `server_project_id`; see
            the document-level rule). Either way the recovery is to send
            something different, never to retry the same request. The set may
            grow; default-branch on unknown values.
        assignee:
          type: string
          description: >-
            With `already_claimed`: the actor currently holding the issue, read
            inside the transaction that refused.


            IT IS OPTIONAL ON EVERY OPERATION BUT THE CLAIM. `POST
            /v0/beads/issues/{id}:claim` always carries it, because its
            conflict path reads the row it lost to. `PATCH
            /v0/beads/issues/{id}` and `POST /v0/beads/issues:batchApply` carry
            it only when the refusing transaction reported a holder, and `POST
            /v0/beads/issues/{id}:release` never does — the ownership fence
            refuses without naming anyone. An absent member means "this refusal
            could not name the holder", never "nobody holds it"; re-read the
            row.
        issue_status:
          type: string
          description: >-
            With `already_claimed` or `not_claimable`: the issue's status at
            the moment of refusal.
        open_children:
          type: integer
          description: >-
            With `not_closable`: how many open children the transaction that
            refused the close observed, read inside that transaction rather
            than parsed out of `detail`.


            PRESENT ONLY for the open-children refusal. The other
            `not_closable` refusal is a live blocker and carries no such
            member, so member presence — not prose — is how a client tells the
            two apart. Both are bypassed by `force`.
        existing_type:
          type: string
          description: >-
            With `dependency_exists`: the type of the edge the pair already
            carries, read inside the refusing transaction.
        requested_type:
          type: string
          description: >-
            With `dependency_exists`: the type the request asked for. Together
            with `existing_type` it is the whole refusal, so a client never
            parses either out of `detail`.
        issue_id:
          type: string
          description: >-
            With `dependency_cycle`, and ONLY on the hierarchy refusal: the
            issue the requested blocking edge would have gated. Its PRESENCE is
            the discriminator — absent means a plain scheduling cycle, present
            means the edge pointed at the issue's own ancestor or descendant.


            The conflicting hierarchy may exist only inside the rolled-back
            batch, so no read after the fact can recover it: the refusing
            transaction is the only place this member can come from.
        blocker_id:
          type: string
          description: >-
            With `dependency_cycle`, hierarchy refusal only: the ancestor or
            descendant the edge named as blocker. See `issue_id`.
        blocker_is_ancestor:
          type: boolean
          description: >-
            With `dependency_cycle`, hierarchy refusal only: true when
            `blocker_id` is an ANCESTOR of `issue_id` (which cannot close until
            its descendants finish, so the gate would never clear), false when
            it is a DESCENDANT (blocked status cascades, so it would inherit the
            block and never close). Both polarities are reported; this member is
            never omitted to mean false. See `issue_id`.
        expected_version:
          type: integer
          format: int64
          description: >-
            With `precondition_failed`: the row `revision` the request guarded
            on, echoed from the request itself.


            THE EXPECTED/ACTUAL PAIRS ARE SPLIT BY TYPE rather than carried as
            one polymorphic `expected`/`actual`, and the reason is this
            document's: a member that is "a version or a status or an assignee"
            is a schema alternation, and no composition keyword is available to
            spell one here (see `ApplyItem`). Three typed pairs cost three
            member names and are readable by a generated client without a cast.
        actual_version:
          type: integer
          format: int64
          description: >-
            With `precondition_failed`: the `revision` the row was found
            holding, read inside the transaction that refused the guard.


            PRESENT ONLY WHERE THE REFUSING OPERATION CAN REPORT IT. An
            all-or-nothing operation rolls its transaction back, so a value read
            after the fact would describe a row the refusal never saw; where the
            role behind an operation does not carry the observed value, this
            member is omitted rather than reconstructed. Its absence therefore
            means "this server cannot tell you what it found", never "it found
            zero".


            NO v0 OPERATION EMITS IT TODAY, nor `actual_status` or
            `actual_assignee`. Every operation that publishes a guard refuses
            all-or-nothing, and none of the roles behind them carries the
            observed value out of the rolled-back transaction. The three
            members are declared so that an operation whose role CAN report
            what it found is an addition rather than a wire change — a client
            must not wait for them, and must never read their absence as a
            value.
        expected_status:
          type: string
          description: >-
            With `precondition_failed`: the status the request guarded on,
            echoed from the request. See `expected_version`.
        actual_status:
          type: string
          description: >-
            With `precondition_failed`: the status the row was found holding.
            Present under `actual_version`'s rule.
        expected_assignee:
          type: string
          description: >-
            With `precondition_failed`: the assignee the request guarded on,
            echoed from the request. See `expected_version`.
        actual_assignee:
          type: string
          description: >-
            With `precondition_failed`: the assignee the row was found holding.
            Present under `actual_version`'s rule.
        item_index:
          type: integer
          description: >-
            On a batch operation whose items are heterogeneous: the position in
            `items` of the item that earned the refusal, read from the role's own
            typed error rather than parsed out of `detail`.


            The request is all or nothing, so there is no per-item result array
            for a client to find the offender in — these four `item_*` members
            are the only place it exists.
        item_kind:
          type: string
          description: >-
            The `kind` of the item at `item_index`, so a client can dispatch on
            what the item was doing without walking its own request back.
        item_key:
          type: string
          description: >-
            The refused item's own `key`, or the key its target ref named.
            ABSENT when the item named nothing symbolically, which is a real
            state rather than a gap: not every item has a key.
        item_issue_id:
          type: string
          description: >-
            The id the refused item was acting on, where one had been resolved
            before the refusal. ABSENT when the refusal happened before
            resolution — a create whose id was never minted, or a ref that
            resolved to nothing.


            IT IS NOT `issue_id`, and the divergence is load-bearing rather than
            verbose: `issue_id` is a PRESENCE-DISCRIMINATING member of the
            `dependency_cycle` hierarchy refusal, so a batch operation reusing it
            would make that discriminator fire on refusals it says nothing about.
        declared_later:
          type: boolean
          description: >-
            With `invalid_argument` on a batch operation whose items may name
            each other: whether the unresolvable key IS declared by the request,
            at a LATER index.


            True is an ORDERING mistake — a key reaches backward only — and false
            is a key nothing in the request declares, which is a typo or a
            missing item. A client acts differently on each. Both polarities are
            emitted and the member is never omitted to mean false: an absent
            member says the refusal was not about a key at all.
        since:
          type: integer
          format: int64
          description: >-
            With `events_journal_truncated`: the checkpoint the reported window
            begins after.


            It is NOT always the value the request sent. In the ordinary case —
            the prefix you asked for was pruned — it IS your checkpoint. When
            the prefix is intact but the retained window has an interior hole,
            it is instead the last seq the server could serve contiguously from
            your checkpoint, and `floor` is where the next intact stretch
            begins. It never reports a value BELOW what you sent, so echoing it
            back can never re-deliver records you already hold.
        floor:
          type: integer
          format: int64
          description: >-
            With `events_journal_truncated`: the lowest seq still retained, or
            `head + 1` when the journal retains nothing at all. Resuming from
            `floor - 1` continues with a known, explicit gap.
        head:
          type: integer
          format: int64
          description: >-
            With `events_journal_truncated`: the highest seq this journal has
            ever assigned. It never decreases under a prune, so `floor > head`
            means the journal was pruned empty and the caller is at the end of
            its history. Emitted even when zero.
        request_id:
          type: string
          description: >-
            Opaque correlation id for this request, echoed in the server's
            request log line. Never a dispatch key and never a retry key.
            (This server mints per-process ids that do not survive a restart;
            a deployment may substitute any identifier with the same
            log-correlation property, such as an edge trace id.)
        server_project_id:
          type: string
          description: >-
            With `invalid_argument` / `reason: "project_mismatch"` ONLY: the
            project id this server actually serves, so a client that stamped a
            `Bd-Project-Id` can tell a wrong-server refusal from a malformed one
            without parsing `detail`. It is set on that refusal and on no other,
            and never on a refusal raised before the stamp is checked — the Host
            gate, or a deployment's authentication layer — so its PRESENCE is the
            signal that this specific check fired.