beads
GitHubBeads - A memory upgrade for your coding agent
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.