openapi: 3.0.3 info: title: aurora-orchestration board API version: 0.1.0 description: | Control-plane API for orchestrating fleets of autonomous coding agents. Design rules (see ARCHITECTURE.md and DECISIONS.md): - API-first: this file is the contract; services and clients conform to it. - Every call is authenticated (OAuth2 client credentials for agents, auth code for humans) except health probes. - Fused, turn-frugal agent endpoints (`claims:next`, `complete`) — every agent round-trip is an LLM turn (ADR-0009). - Refusals are RFC 9457 problem documents carrying a machine-actionable `recovery` field: the protocol rides in the responses (ADR-0001). - Claims are leases (ADR-0005): at most one active claim per task, heartbeat extends, expiry makes the task claimable again. servers: - url: http://localhost:8080 description: local development (docker compose) tags: - name: meta - name: orgs - name: projects - name: tasks - name: claims - name: reviews - name: decisions - name: observations - name: proposals - name: tenets - name: adrs - name: prompts - name: sessions - name: events - name: trail - name: attestation security: - bearerAuth: [] paths: /healthz: get: tags: [meta] security: [] summary: Liveness probe responses: "200": description: alive /readyz: get: tags: [meta] security: [] summary: Readiness probe (checks database connectivity) responses: "200": { description: ready } "503": { description: not ready } /.well-known/aurora-board: get: tags: [meta] security: [] summary: Discovery — the mechanical bootstrap (ADR-0014) description: | Unauthenticated, like OIDC discovery; contains no tenant data. One URL plus one client credential is the entire operator-provided configuration: this document supplies the OIDC issuer/audience/ grant, a token-endpoint hint, and the endpoint map. See docs/agent-guide.md for the loop that starts here. responses: "200": description: discovery document content: application/json: schema: { $ref: "#/components/schemas/Discovery" } /v1/me: get: tags: [meta] summary: Introspect the calling principal description: | Returns the identity the server derived from the bearer token plus the grants it holds. Agents call this once at session start to confirm who they are — Aurora's lesson that identity must be verifiable, not assumed (evaluation §3.1). responses: "200": description: the caller content: application/json: schema: { $ref: "#/components/schemas/Principal" } "401": { $ref: "#/components/responses/Unauthorized" } /v1/run-identities: post: tags: [meta] summary: Mint a single-use run identity (F-44, H-final) description: | The stateless run-identity mint. Any validated **rung-0** principal (an operator's bearer — never a fleet identity) may mint a single-use run identity for a spawned agent. The board generates the `sub` itself — `spiffe://platform/fleet/run-` — so requesting someone else's identity is impossible by construction. The requested `scopes` are clamped to the agent allowlist (`tasks:read`, `tasks:claim`, `tasks:file`, `tasks:complete`, `tasks:review`): `board:admin` is unreachable by construction. `ttl_secs` is clamped to 60..=14400 (≤ 4h, the default). **Write-free:** no row, no registry, no GC, no mint event. Identity confers NOTHING — authority still comes only from grant rows, which only project-admins write via the delegation call. The minted token validates as rung 1 (`attestation: "fleet"`), issued by the board's internal `urn:aurora-board:run-identity` issuer (ephemeral per board boot in v1: a restart invalidates in-flight run tokens). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/RunIdentityRequest" } responses: "201": description: the minted run identity content: application/json: schema: { $ref: "#/components/schemas/RunIdentityResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: | forbidden — a fleet-validated (rung-1+) caller attempted to mint (no recursive minting; a spawned agent must never mint identities) content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/orgs: get: tags: [orgs] summary: List organizations visible to the caller responses: "200": description: organizations content: application/json: schema: type: array items: { $ref: "#/components/schemas/Org" } "401": { $ref: "#/components/responses/Unauthorized" } post: tags: [orgs] summary: Create an organization (self-serve, ADR-0069) description: | Orgs are SELF-SERVE (ADR-0069): any ATTESTED HUMAN may create one and is seeded its org-wide `admin`. No scope is required. An attested human is a caller whose canonical `sub` is in the human grammar — `spiffe://platform/github-` (the brokered GitHub identity), `spiffe://local/legacy/` (a Keycloak user id) or `spiffe://local/id/` (a pseudonymized sub) — on a token issued to one of the realm's human login clients (`azp` = `platform-operator` or `aurora-web`, neither of which can mint a client-credentials token). A run identity (`spiffe://platform/fleet/run-*`) and a client-credentials agent are refused 403. A `board:admin` holder (the operator's bootstrap, ADR-0068) may also create, and is outside the rail below. THE RAIL: one org per human per rolling 24 h, counted from the org's recorded creator — a second create inside the window is 429 naming `next_allowed_at` (the earliest in-window org's `created_at` + 24 h). A rail, not a gate (ADR-0059's arithmetic posture). The creator is recorded on the org (`orgs.created_by`, listed by `GET /v1/admin/stats`) and on the `org.created` event (see `OrgCreated`). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/OrgCreate" } responses: "201": description: created; the caller now holds the org-wide `admin` grant content: application/json: schema: { $ref: "#/components/schemas/Org" } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: the caller is not an attested human (a run identity, a client-credentials agent, a token from no human login client) and holds no `board:admin` content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } "429": description: | the one-org-per-human-per-rolling-24h rail holds (ADR-0069). The body names the instant the next create is allowed, and a `Retry-After` header carries the same wait in seconds. Nothing was written. headers: Retry-After: description: seconds until `next_allowed_at` schema: { type: integer, minimum: 1 } content: application/problem+json: schema: { $ref: "#/components/schemas/OrgCreateRateLimited" } /v1/orgs/{org}/projects: parameters: - $ref: "#/components/parameters/org" get: tags: [projects] summary: List projects in an organization responses: "200": description: projects content: application/json: schema: type: array items: { $ref: "#/components/schemas/Project" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [projects] summary: Create a project (org-admin) description: | Requires an org-WIDE `admin` grant on the org (ADR-0031: a project-scoped admin grant never satisfies it). No scope is required (ADR-0069): an org-admin — e.g. the human who created the org — creates projects without `board:admin`, which ADR-0068 made the platform operator's alone. Emits `project.created`. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ProjectCreate" } responses: "201": description: created content: application/json: schema: { $ref: "#/components/schemas/Project" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } /v1/orgs/{org}/projects/{proj}: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" patch: tags: [projects] summary: Amend a project's policy settings (maintainer+) description: | The Project Settings surface's write path (T-388 declared it; the board has served it since the policies were introduced). Requires `tasks:file` and a maintainer-or-above grant on the project. Every field is optional: an omitted field KEEPS its current value, so a body of `{}` is a no-op that returns the project unchanged. Each value is checked against its closed set (422 naming the field and the value sent). POLICY MUST BE TRUE: the RESOLVED combination (sent values over the stored ones) is validated, so `dod_policy = enforced` with `review_policy` other than `required` is refused 422 whether either half was sent in this call or already stored — enforced coverage is meaningless with no review step to enforce at. When any policy actually changes, one `project.settings_changed` event records the `before` and `after` of all four policies (the churn indicator reads these); an unchanged PATCH writes no event. `slug`, `org` and `name` are not amendable here. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ProjectPatch" } responses: "200": description: the project as stored after the amendment content: application/json: schema: { $ref: "#/components/schemas/Project" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/grants: parameters: - $ref: "#/components/parameters/org" get: tags: [orgs] summary: List the org-WIDE standing grants (org-admin) description: | T-548 (ADR-0069). The org's roster of org-wide (`project: null`) standing role grants — who holds org power, since when, granted by whom. Requires an org-WIDE `admin` grant: the org-wide roster is org-level information (ADR-0031), never readable from a project grant. Support grants are not listed here (`GET /support-grants` is their audit query); project-scoped grants are the project surface's. `granted_by` is read from the audit trail (the `grant.added` / `org.created` actor, an org-wide invitation's inviter) and is null when the trail does not say — never invented. responses: "200": description: org-wide grants, oldest first content: application/json: schema: type: array items: { $ref: "#/components/schemas/OrgGrant" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [orgs] summary: Grant a subject an org-wide role (org-admin) description: | T-548 (ADR-0069): how an org-admin adds a co-admin (or any org-wide role) without the operator. Requires an org-WIDE `admin` grant — which is also the mint clamp: `admin` is only ever minted by an org-admin, exactly as on the project surface; a project-admin is refused 403. Upserts the subject's one org-wide standing row (the subject is normalized into the board's subject grammar). Re-granting the org's LAST admin a lower role is refused 409 (it would leave the org with no admin). Emits `grant.added` with `scope: org` on the org-wide chain. requestBody: required: true content: application/json: schema: type: object properties: subject: { type: string, maxLength: 300 } role: type: string enum: [viewer, auditor, worker, curator, maintainer, project-admin, admin, agent] description: the capability; `agent` is the deprecated spelling of `worker` required: [subject, role] responses: "201": description: granted content: application/json: schema: { $ref: "#/components/schemas/OrgGrant" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/grants/{subject}: parameters: - $ref: "#/components/parameters/org" - name: subject in: path required: true description: | The subject, as ONE percent-encoded path segment — a SPIFFE URI travels as `spiffe%3A%2F%2Fplatform%2Fgithub-1`. Normalized exactly as enrollment normalizes it. schema: { type: string, maxLength: 300 } delete: tags: [orgs] summary: Revoke a subject's org-wide grant (org-admin; never the last admin) description: | T-548 (ADR-0069). Removes the subject's org-WIDE standing role row. Requires an org-WIDE `admin` grant. Refuses **409** — naming the subject — to remove the org's LAST org-wide admin; the operator's support grant (ADR-0031) is not an org-admin and does not count toward that number. **Idempotent**: an absent grant is `200` with `removed: false`. A project-scoped grant the subject also holds is untouched (`grants:revoke` on the project surface). Emits `grant.revoked` with `scope: org` only on an actual removal. responses: "200": description: revoked (or already absent — see `removed`) content: application/json: schema: type: object properties: org: { type: string } subject: { type: string, description: the normalized (pseudonymized) subject } role: type: string nullable: true description: the role the removed row held; null when nothing was removed removed: { type: boolean } required: [org, subject, removed] "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/grants: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [projects] summary: List grants on a project responses: "200": description: grants content: application/json: schema: type: array items: { $ref: "#/components/schemas/Grant" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [projects] summary: Grant a subject a role on a project (enrollment) description: | ADR-0031 ACL split. Minting the `admin` (org-admin) role requires an org-WIDE admin grant. Any other role requires a project-admin-or-above grant on this project, and a project-admin can never confer `admin` — a project admin manages this project's grants but cannot replicate org power. This is how a worker (an OAuth2 client's subject) is enrolled: Keycloak proves who the caller is; this table says what they may touch (ADR-0004). The enrolled subject is normalized into the board's SPIFFE-shaped subject grammar. Upserts the role for an existing subject. requestBody: required: true content: application/json: schema: type: object properties: subject: { type: string, maxLength: 300 } role: type: string enum: [viewer, auditor, worker, curator, maintainer, project-admin, admin, agent] description: | The capability. `agent` is the DEPRECATED spelling of `worker` — accepted forever on input (the board stores that rung as `agent`; see the `Grant` schema), never the name to write in new callers. required: [subject, role] responses: "201": description: granted content: application/json: schema: { $ref: "#/components/schemas/Grant" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/grants:revoke: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" post: tags: [projects] summary: Revoke a subject's project-wide role grant (a named act) description: | The revocation half of enrollment (F-13, BRIEF-worker-pool): removes the subject's PROJECT-WIDE (realm-NULL) role row — "revocation is one delete" (the steward doctrine, ADR-0054) made a route. A named `:revoke` act rather than a DELETE on a path because subjects are SPIFFE URIs. **Idempotent**: revoking an absent grant is `200` with `removed: false` — the bridge revokes at every run close and may retry. Gate mirrors enrollment: project-admin-or-above revokes on this project; a project-scoped `admin` row (which enrollment can mint org-admin-gated) needs an org-WIDE admin to revoke. The `grant.removed` event records only an actual removal. Realm-scoped and support grants are managed by their own surfaces, not this one (a realm-scoped grant: `POST /realms/{slug}/grants:revoke`). requestBody: required: true content: application/json: schema: type: object properties: subject: { type: string, maxLength: 300 } required: [subject] responses: "200": description: revoked (or already absent — see `removed`) content: application/json: schema: type: object properties: org: { type: string } project: { type: string } subject: { type: string, description: the normalized (pseudonymized) subject } removed: { type: boolean } required: [org, project, subject, removed] "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/support-grants: parameters: - $ref: "#/components/parameters/org" get: tags: [orgs] summary: List support grants on an org (the audit query) description: | ADR-0031. "Who had support access to our org, when, why." Requires an org-WIDE admin grant — support history is org-level information. This is the tamper-evident record an enterprise customer's auditor asks for. responses: "200": description: support grants, newest first content: application/json: schema: type: array items: { $ref: "#/components/schemas/SupportGrant" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [orgs] summary: Mint a time-boxed, reason-tagged support grant description: | ADR-0031 audited support access. The operator holds NO standing access to customer org data; this is the bounded, audited way in. Requires the `board:admin` scope. The grant carries a mandatory reason (ticket reference) and a TTL, and auto-expires — there is nothing to revoke by hand. The org's `support_access` flag decides whether it activates immediately (`auto`) or mints `pending` until a customer org-admin approves (`approval`). Every act under the grant is attributable to the operator's subject plus this reason on the evidence chain. requestBody: required: true content: application/json: schema: type: object properties: role: type: string enum: [viewer, worker, curator, maintainer, project-admin, admin, agent] description: | The capability the support grant confers. `agent` is the DEPRECATED spelling of `worker`, accepted on input. reason: { type: string, minLength: 1, maxLength: 2000 } ttl_secs: { type: integer, minimum: 900, maximum: 86400 } project: { type: string, nullable: true } required: [role, reason, ttl_secs] responses: "201": description: support grant minted content: application/json: schema: { $ref: "#/components/schemas/SupportGrant" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/support-grants/{id}/approve: parameters: - $ref: "#/components/parameters/org" - name: id in: path required: true schema: { type: string, format: uuid } post: tags: [orgs] summary: Approve a pending support grant (approval mode) description: | ADR-0031. In an org whose `support_access` is `approval`, a support grant mints `pending` and confers no access until a customer org-admin approves it here. Requires an org-WIDE admin grant. responses: "200": description: support grant activated content: application/json: schema: { $ref: "#/components/schemas/SupportGrant" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /v1/orgs/{org}/projects/{proj}/realms: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [realms] summary: List the endeavor's realms description: | ADR-0046/0048: the places the endeavor is pursued (repos, kitchens). Read-broad — any project member sees every realm (the board records and attributes; it never hides). Default lists LIVE realms; pass `status=archived` for the record or `all` for both. Each realm carries its `context_md` (what/why), `remote_ref` (where the work lives), and `guarded` (the open-by-default / guarded-outlier flag). parameters: - name: status in: query schema: { type: string, enum: [live, archived, all], default: live } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - name: cursor in: query schema: { type: string } responses: "200": description: realms (paginated) content: application/json: schema: { $ref: "#/components/schemas/RealmPage" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [realms] summary: Create a realm (maintainer-gated dispose) description: | ADR-0046: the realm lifecycle is a DECISION, so creation is a maintainer-gated dispose — an agent never self-assigns a place to work. An agent proposes a realm via a steering proposal (`create_realm`); a maintainer mints it here. `from_proposal_id` records the attribution when it came through the steering lifecycle. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/RealmCreate" } responses: "201": description: created content: application/json: schema: { $ref: "#/components/schemas/Realm" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/realms/{slug}: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - name: slug in: path required: true description: | the realm's identity — addressed here, never amended (tasks, proposals and grants name a realm by slug over the wire; renaming it would silently re-point every one of them — archive and re-create) schema: { type: string } patch: tags: [realms] summary: Amend a realm — set `guarded`, correct `remote_ref` / `context_md` (maintainer-gated dispose) description: | T-4 (ADR-0046/0048): the lifecycle is create → amend → archive, each behind the SAME dispose gate (maintainer+, `tasks:file` scope; an agent never flips its own realm open or shut — 403). This is the ONE API path that sets the ADR-0048 `guarded` flag, and the only way to correct a realm's `remote_ref` or `context_md` after creation. **What is amendable:** `remote_ref`, `context_md`, `guarded` — all optional, at least one required (a body naming none is `422`). Omitted means unchanged. `""` is the empty state of the prose columns, so `{"remote_ref": ""}` clears the pointer and is a change like any other; JSON `null` reads as omitted. Bounds are exactly creation's: `remote_ref` ≤ 2000, `context_md` ≤ 16384 (`422`). **Not amendable:** `slug` (identity — see the path parameter); `name` and `kind` are set at creation. **Shape before existence:** the empty-body `422` is decided before the lookup, so an unknown realm with an empty body reads `422`, not `404`; a well-formed body aimed at an unknown realm is `404`. **Idempotent:** a PATCH naming only values the realm already holds is `200` with `changed: []` — a retry after a dropped response is never an error — and writes no row and no event. **An ARCHIVED realm IS amendable** (unlike the grant mint, which refuses one): archiving does not re-home the entities that still name the realm, so an archived realm's `guarded` flag still gates every claim on a task filed there. The response notes it. **Audit:** an amend that moved anything emits `realm.amended` with `changed` and a before/after pair for exactly those fields — `guarded` verbatim, prose (`remote_ref`, `context_md`) as a `{len, sha256}` image, never its text (the chain carries references, not content). The overwritten prose comes back to the caller in the response's `previous`, AND is persisted (T-158) as one `realm_prose_revisions` row per prose field that moved, written in the amend's own transaction and naming the `realm.amended` event it is the twin of. Read it back with `GET /realms/{slug}/revisions`. `guarded` has no revision row — the event carries the flag verbatim. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/RealmAmend" } responses: "200": description: | the realm as it now reads, plus what moved and what it means (`changed: []` on an idempotent retry — still a 200) content: application/json: schema: { $ref: "#/components/schemas/RealmAmended" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/realms/{slug}/revisions: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - name: slug in: path required: true schema: { type: string } get: tags: [realms] summary: A realm's prose revision history — recover clobbered `context_md` / `remote_ref` (maintainer-gated) description: | T-158: every amend that moves a prose field persists the text it overwrote as a revision row (a redactable resource row — the event chain keeps only a `{len, sha256}` image, never the text). This lists them, newest first, for live and archived realms alike. **Maintainer+** (`tasks:read` scope), unlike the read-broad realm list: the CURRENT prose is every member's to read, but a revision is often exactly the text someone corrected away, and recovering it is the same dispose seat that wrote it (`403` below maintainer). **Twin check:** each row carries the chain's own before-image for its field (`chain_image`, read from the `realm.amended` event named by `event_id`) and `verified` — true exactly when `prior_text` measures and hashes to that image. `verified: false` is evidence (the stored text is not what the chain says was overwritten); the board reports it and repairs nothing. The table makes the digest recoverable; the digest makes the table tamper-evident. **Pagination:** keyset on the revision id (UUIDv7, newest first); `next_cursor` is set only on a full page. `field` narrows to one prose field; anything else (`guarded` included — a flag, not prose) is `422`, as is a cursor that is not a revision id. parameters: - name: field in: query schema: { type: string, enum: [context_md, remote_ref] } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - name: cursor in: query schema: { type: string, format: uuid } responses: "200": description: the realm's prose revisions, newest first content: application/json: schema: { $ref: "#/components/schemas/RealmRevisionPage" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/realms/{slug}/archive: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - name: slug in: path required: true schema: { type: string } post: tags: [realms] summary: Archive a realm (never delete) description: | ADR-0046: a realm is archived, never deleted — the record is the thing. Maintainer-gated dispose. Archiving retires the place from live lists but keeps every task/grant/attribution against it. Archiving an already-archived realm is `409`. responses: "200": description: archived content: application/json: schema: { $ref: "#/components/schemas/Realm" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } /v1/orgs/{org}/projects/{proj}/realms/{slug}/grants: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - name: slug in: path required: true schema: { type: string } get: tags: [realms] summary: List who holds a realm-scoped grant (project-admin) description: | T-377: the read half of the realm-grant boundary. The mint and the revoke below exist; without this, the amend answered only a COUNT (`realm_grants`) and the exact subject string a revoke must name was read back by no surface. Each row's `subject` is the board's NORMALIZED (pseudonymized) spelling — byte-identical to what the mint stored, so `grants:revoke` with it removes that row. Same floor as the revoke: project-admin, checked before the realm is looked up, so a non-admin learns nothing (`403`, not even whether the realm exists). Archived and OPEN realms list too, mirroring the revoke's asymmetries: an archived realm's admissions still gate its work, and an un-guard's inert rows are what cleanup revokes. Oldest first, subject as the tie-break. An unknown realm is `404`. responses: "200": description: the realm's grant holders, oldest first content: application/json: schema: type: array items: { $ref: "#/components/schemas/RealmGrantRow" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } post: tags: [realms] summary: Mint a realm-scoped grant (admit a worker to a guarded realm) description: | ADR-0046: the ONE way a worker is admitted to a GUARDED realm. Project-admin gated; an agent never self-assigns a guarded realm. Meaningful ONLY on a guarded realm — on an OPEN realm any project member already reaches the work (subsumption), so a realm-grant there records a FALSE implication that it does something; refused 422 (a policy must be true), naming why. An ARCHIVED realm is refused `409` (minting a new admission to a retired place is forward-looking work); an unknown realm is `404`. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/RealmGrantCreate" } responses: "201": description: grant minted content: application/json: schema: { $ref: "#/components/schemas/Grant" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/realms/{slug}/grants:revoke: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - name: slug in: path required: true schema: { type: string } post: tags: [realms] summary: Revoke a realm-scoped grant (withdraw a worker's admission to a guarded realm) description: | T-37: the exit of the admission the mint above opens — the realm- scoped half of "revocation is one delete" (`grants:revoke` removes only the project-wide, realm-NULL row and never this one). Same gate as the mint: project-admin (an agent never withdraws its own admission — 403). A named `:revoke` act rather than a DELETE on a path, exactly like `grants:revoke`: subjects are SPIFFE URIs and live in a body; the subject is pseudonymized exactly as the mint stored it, so the lookup byte-matches. **Idempotent:** revoking an absent grant is `200` with `removed: false` — never an error. `realm.grant_removed` is emitted only on an actual removal. An unknown realm is `404`; a subject outside 1..=300 chars is `422`. **Two deliberate asymmetries with the mint:** an ARCHIVED realm still accepts revocation (its guard still gates the claims of everything filed there, so its admissions can genuinely need withdrawing), and an OPEN realm does too (an un-guard leaves inert grant rows behind — the amend reports them — and deleting one withdraws an admission nobody can use, so cleanup stays reachable). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/RealmGrantRevoke" } responses: "200": description: | the grant is gone (`removed: true`) or was never there (`removed: false`, an idempotent retry) content: application/json: schema: { $ref: "#/components/schemas/RealmGrantRevoked" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/tasks: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [tasks] summary: Board view description: | `view=next` returns the canonical server-side ranking (pins first, then transitive not-done dependents, then wave, then age) — the one derivation rule (ADR-0010). Supports ETag/If-None-Match polling. parameters: - name: status in: query schema: type: string enum: [draft, open, claimed, available, blocked, done, cancelled] - name: view in: query schema: { type: string, enum: [all, next] } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - name: cursor in: query schema: { type: string } responses: "200": description: tasks (paginated) headers: ETag: schema: { type: string } content: application/json: schema: { $ref: "#/components/schemas/TaskPage" } "304": { description: board unchanged since If-None-Match } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [tasks] summary: File a task description: | Requires `tasks:file`. Declares dependencies and resource ownership up front (ADR-0011). Ownership entries are literals, directory prefixes, or glob patterns (`*`/`**`/`?`/`[class]`). Ordering is declared in BOTH directions: `deps` (this task goes BEHIND them) and `precedes` (T-136: this task is born IN FRONT of them — each named task gains a dependency on this one in the same transaction). Ordering is recognized across the whole dependency path (ancestors of `deps` ∪ `precedes` targets and their descendants), not just direct edges. Ownership overlapping an OPEN task with no ordering path between them is PLACED, not refused (T-432b): the board computes the minimal BEHIND set, adds those edges itself (they read back in `deps_placed`), files the row, and says so in the 201's `notes` ("placed behind …"). The one overlap that still REFUSES 409 is a contradiction — a `precedes` target that is upstream of (or is) a key the row would have to be placed behind. That 409 carries `ownership_remedy` and a `recovery` rendered from it — ONE line on the wire, wrapped here: ```text declare the ordering and refile — these keys are the whole answer, computed from the same graph this refusal walked, not a starting point. IN FRONT (fix-first — the named task(s) gain the dependency on you): add {front}. BEHIND: add {behind}. Or narrow ownership (ADR-0011) ``` where `{front}` renders like ``put T-a in `precedes` — CLI: `--precedes T-a` (1 key clears all 3 overlapping rows)`` and `{behind}` the same with `deps` and `--dep`. Several keys are joined with ", " in the values and repeat the flag in the CLI form; the parenthesised cost reads "1 key clears the 1 overlapping row", "1 key clears all N overlapping rows", or "k independent heads — none is reachable from another, so all k must be named; together they clear …". The prose is generated from `ownership_remedy`; consume the field, not the sentence. The 201 body is `TaskFiled`: the task exactly as a detail read returns it, plus `notes` naming what the row was born lacking (T-165). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TaskCreate" } responses: "201": description: filed (with birth `notes` when the row lacks something, or was placed) content: application/json: schema: { $ref: "#/components/schemas/TaskFiled" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/tasks/{key}: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/key" get: tags: [tasks] summary: One task with brief, deps, ownership, claim state description: | The single-task read is `TaskDetail` plus the observations that cite this row (T-195, the `TaskRead` wrapper). The two observation fields ride THIS read only — never a list page, a claim bundle or a write echo — and both are omitted when empty, so a row nothing cites reads byte-identically to a bare `TaskDetail`. responses: "200": description: the task, with the observations citing it content: application/json: schema: { $ref: "#/components/schemas/TaskRead" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } patch: tags: [tasks] summary: Amend a task (optimistic concurrency) — the curator's verb description: | Amending your *own* filing requires `tasks:file`; amending another's task and any **pin/unpin** require a curator-or-above grant (ADR-0023). The curator shuffles the board so working agents pick the right task next: pin the critical path (it tops the derived ranking, ADR-0010), retitle, append to the brief, re-wire deps, narrow ownership. Accepting proposals and answering decisions stay with the maintainer — the curator refines, it never disposes. `If-Match` with the current version is mandatory; amendments become event rows (evaluation §3.4). Mutations that would leave an ownership overlap with an open task that has no ordering path to this one refuse with 409 (ADR-0011). When that 409 was caused by a `deps_remove` of the very row the FRONT direction would put behind this one, the FRONT clause of `recovery` does NOT offer `--dep-add` (a guaranteed 422 — this task still depends on it in the committed graph); it names the atomic move instead: `POST …/tasks/{key}/precedes`, CLI `aurora-agent task-precedes {key} ...` (T-179, T-329). parameters: - name: If-Match in: header required: true schema: { type: string } requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TaskPatch" } responses: "200": description: amended content: application/json: schema: { $ref: "#/components/schemas/TaskDetail" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "412": { description: version mismatch — re-read and retry } /v1/orgs/{org}/projects/{proj}/tasks/{key}/context: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/key" get: tags: [tasks] summary: "A task's pre-claim context — the fused read (ADR-0057, S5)" description: | One call returning the task (exactly as `GET …/tasks/{key}` serves it), the goal(s) it serves (statement/horizon/success_signals), the tenets it cites, and ONE LEVEL of dependents in both directions as `{key, title, status}` — the blast radius a claiming agent peeks BEFORE it burns a lease, not after. Follows the steering:docket fused-read pattern (one call, several facts fused, so the agent never burns a turn fetching its own context). DESCRIPTIVE, never prescriptive: no rank, no recommendation, no "claim this next" — the board records and derives, the agent decides. Specced minimal deliberately: it overlaps the claim bundle's job by design (the bundle is the full starting context ON a claim; this is the cheap pre-claim peek) and no further. responses: "200": description: the task's fused context content: application/json: schema: { $ref: "#/components/schemas/TaskContext" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /v1/orgs/{org}/projects/{proj}/tasks/{key}/precedes: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/key" post: tags: [tasks] summary: "Move an EXISTING task in front of named targets, atomically (T-179)" description: | Requires `tasks:file` AND a curator-or-above grant on the project (ADR-0023): re-ordering existing rows is a pure steering act that changes other rows' ordering. (Filing-time `precedes` on `POST …/tasks` stays agent-tier; this is its after-birth twin.) CLI: `aurora-agent task-precedes {key} ...`. In ONE transaction, under the same project-scoped lock the deps path takes, the board: 1. drops `{key}`'s own dependency on each target, if it had one; 2. adds the reverse edge — each target gains a dependency on `{key}` (cycle-checked AFTER step 1, which is exactly why the hand-run `deps_remove` + `deps_add` sequence cannot do this: against the committed graph each add reports a cycle the completed act does not have); 3. validates ADR-0011 over the WHOLE board, not only the mover — a third-party pair whose only ordering path ran through a removed edge refuses the request (409) instead of being silently stranded. T-569: judged as a DIFFERENCE — only a pair ordered before the edge writes and unordered after them is this move's doing and refuses; a pair that was already unordered (a legacy overlap anywhere on the board) is reported in the 200's `warnings` as "pre-existing unordered overlap, not this move's doing: …" (at most 20 spelled out, the rest counted) and the move takes; 4. writes a `task.amended` event on the mover (`moved_in_front_of`) and on every target (`moved_behind`). Nothing is half-applied: any refusal writes nothing. Duplicate targets are de-duplicated. This is the seam the ownership-overlap 409 of `PATCH …/tasks/{key}` names in its FRONT clause when the caller is removing the very edge the front direction would reverse (there, the plain `--dep-add` act is a guaranteed 422). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/MoveInFront" } responses: "200": description: | moved — the mover exactly as `GET …/tasks/{key}` serves it after the commit (each target now lists it in its `deps`; it no longer depends on any of them), except that `warnings` additionally carries the pre-existing unordered pairs elsewhere on the board that this move did not strand (T-569, step 3) content: application/json: schema: { $ref: "#/components/schemas/TaskDetail" } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: | the caller lacks `tasks:file`, or holds no curator-or-above grant on this project — re-ordering existing rows is a curator act content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "404": description: the mover `{key}` does not exist in this project content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "409": description: | the re-ordering would leave a THIRD-PARTY pair of overlapping open tasks with no ordering path between them (their only path ran through an edge this request removes). Nothing was written. `detail` names every such pair as `A and B (path)` — only pairs THIS move strands, never a pair that was unordered before it (T-569). `recovery` names the call(s) that would take, computed from the graph and REPLAYED by the board on the pre-move graph before it is named: each stranded pair ordered directly in the direction it already ran, grouped by the row that goes first — `aurora-agent task-precedes F X…` (POST …/tasks/F/precedes with {"targets": [X…]}) — after which the refused call takes as sent. Usually ONE call; more only when the stranded pairs have different front rows, and the recovery says so. When no such sequence verifies, it says that instead of guessing. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "422": description: | nothing written; `detail` names the cause — `targets` empty or over 50; a target that is unknown (named, never ignored); a target or the mover that already left the work set (done, completed or cancelled); a target equal to the mover itself; or an edge that would close a cycle (the target is still required by the mover through a path this request does not remove) content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/orgs/{org}/projects/{proj}/claims:next: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" post: tags: [claims] summary: "Fused: pick the best available work — a task claim OR a review assignment" description: | Requires `tasks:claim`. One call replaces list→choose→claim→read-brief (ADR-0009, Aurora's `start` fusion). Server applies the canonical ranking, claims the top available task with the CAS (ADR-0005), and returns task + brief + lease + protocol block. 404 with `recovery: "ask-user"` when nothing is available — Aurora's "if nothing is claimable, ask the user, do not invent work". ADR-0027 fusion: an agent ALSO holding `tasks:review` is first offered the highest-ranked pending independent review (an acceptance the system needs signed), ahead of new work — one funnel, one ranking, so the review queue cannot starve while work is claimed. The response's `kind` tag tells the agent which it got: `work` (a ClaimBundle) or `review` (a Review). A non-review agent's funnel is unchanged. T-361: a task flagged `human_only` is never offered here, no matter how high it ranks (a live goal may legitimately out-rank every agent row); the operator takes those rows from their own queue — see the `human_only` band of `TaskPage` (view=next). A direct claim of one is refused 409 naming the flag and both remedies. requestBody: required: false content: application/json: schema: { $ref: "#/components/schemas/NextClaimRequest" } responses: "201": description: assigned — a work claim OR a review assignment content: application/json: schema: { $ref: "#/components/schemas/NextBundle" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": description: | nothing available. When the funnel deliberately withheld rows (T-367: flagged `human_only`, T-361), the body carries `withheld` — each row's key and the flag that withheld it — so this 404 cannot be read as "the board holds nothing". Absent on a genuinely empty board, whose body stays byte-identical. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "409": description: caller already holds its per-agent claim limit content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/orgs/{org}/projects/{proj}/tasks/{key}/claim: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/key" post: tags: [claims] summary: Claim a specific task description: | Requires `tasks:claim`. The CAS: succeeds only if the task is open, unclaimed (or its claim lease has expired — takeover closes the expired claim in the same transaction, audited), and all dependencies are done. A lost race returns 409 naming the holder — Aurora's "lost race names the winner". requestBody: required: false content: application/json: schema: { $ref: "#/components/schemas/ClaimRequest" } responses: "201": description: claimed content: application/json: schema: { $ref: "#/components/schemas/ClaimBundle" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": description: | refused. Three causes, each named in the detail: a lost claim race (`holder` names the winner); the task is blocked on an open decision (`blocked_by` carries the blocking decision ids; recovery: raise/answer the decision, or claim a different task — ADR-0012); or the task is flagged `human_only` (T-361) — this is a human's row and `claims:next` never offers it; recovery: "a human works it from the board in their own CLI session; OR a curator clears the flag — `aurora-agent task-update --no-human-only`". A human-rung principal (curator and above, the CLI human session's grant) claims the same row freely: the flag is about WHO, not WHETHER. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/claims/{claimId}/heartbeat: parameters: - $ref: "#/components/parameters/claimId" post: tags: [claims] summary: Extend the lease description: Holder only. Returns the new expiry. responses: "200": description: extended content: application/json: schema: { $ref: "#/components/schemas/Claim" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "410": { description: lease already expired or claim closed } /v1/claims/{claimId}/release: parameters: - $ref: "#/components/parameters/claimId" post: tags: [claims] summary: Release without completing description: | Holder only (admin override is audited). The task returns to the available pool; the release reason lands in the event log — Aurora's `release` with the etiquette made mechanical. requestBody: required: false content: application/json: schema: type: object properties: reason: { type: string, maxLength: 2000 } responses: "200": { description: released } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "410": { description: claim already closed } /v1/claims/{claimId}/complete: parameters: - $ref: "#/components/parameters/claimId" post: tags: [claims] summary: "Fused: complete with evidence, optionally file follow-ups" description: | Requires `tasks:complete` and holding the active claim — the server REFUSES a non-holder (Aurora's measured gap: warn-only completion let a mistyped id finish someone else's task, evaluation §3.1). Evidence (commit SHA, gate report) is recorded, the task becomes done, dependents may become available, and follow-up task filings in the same body are filed atomically (ADR-0009, Aurora's `finish`). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/CompleteRequest" } responses: "200": description: completed content: application/json: schema: { $ref: "#/components/schemas/CompleteResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "410": { description: lease expired before completion — re-claim to continue } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/reviews: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [reviews] summary: List reviews (ADR-0027 independent-acceptance queue) parameters: - name: status in: query schema: { type: string, enum: [active, accepted, rejected] } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - name: cursor in: query schema: { type: string } responses: "200": description: reviews (paginated) content: application/json: schema: { $ref: "#/components/schemas/ReviewPage" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /v1/orgs/{org}/projects/{proj}/reviews:next: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" post: tags: [reviews] summary: "Fused: pick + claim the highest-ranked pending review (ADR-0027)" description: | Requires `tasks:review` and a worker grant. The reviewer self-selects from the canonical ranking — never the worker naming its reviewer, and the server REFUSES self-review (subject == the completion's worker). This is the trust-surface made mechanical: assignment is a PULL by an eligible identity against a server-ranked queue, never a push. requestBody: content: application/json: schema: { $ref: "#/components/schemas/ReviewAssignRequest" } responses: "201": description: review assigned content: application/json: schema: { $ref: "#/components/schemas/Review" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: "no pending review for this caller (recovery: ask-user)" } "409": { description: lost race to a concurrent reviewer } /v1/orgs/{org}/projects/{proj}/tasks/{key}/review: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/key" post: tags: [reviews] summary: "Claim the review of a specific completed task (ADR-0027)" requestBody: content: application/json: schema: { $ref: "#/components/schemas/ReviewAssignRequest" } responses: "201": description: review assigned content: application/json: schema: { $ref: "#/components/schemas/Review" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { description: "self-review refused, or no grant" } "404": { $ref: "#/components/responses/NotFound" } "409": { description: "not completed, or already under active review" } /v1/reviews/{reviewId}/heartbeat: parameters: - $ref: "#/components/parameters/reviewId" post: tags: [reviews] summary: Extend a review's lease (reviewer-only) responses: "200": description: lease extended content: application/json: schema: { $ref: "#/components/schemas/Review" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/Invalid" } /v1/attestations: post: tags: [attestation] summary: Report a signed workspace attestation (ADR-0030 β) description: | The witness reports a signed attestation DIRECTLY under its own credential (tasks:attest scope + a worker grant on the project) — the anti-withhold path. The board verifies it against the claim it binds to (the same five properties as the courier path) and stores it idempotently by content-addressed id. The worker's completion then references it via `attestation_id`. requestBody: required: true content: application/json: schema: type: object description: the signed Attestation object (see Evidence.attestation) responses: "201": description: reported (or already present — idempotent by content id) content: application/json: schema: type: object properties: attestation_id: { type: string } witness: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } /v1/orgs/{org}/projects/{proj}/witness-keys: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [projects] summary: List enrolled witness public keys (ADR-0030) responses: "200": description: enrolled witness keys content: application/json: schema: type: array items: { $ref: "#/components/schemas/WitnessKey" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [projects] summary: Enroll a witness public key (ADR-0030) description: | The admin act that makes a witness's workspace attestations verifiable at L2/L3. The board never runs a witness and never holds a private key — it enrolls the PUBLIC half and verifies signatures against it at completion. `hardened` is the operator's L3 assertion that the witness runs in a separate trust domain the agent cannot read; the board trusts the enrollment act, not the machine. INSERT-ONLY: a (project, label) collision is a 409, never a silent overwrite (TOFU). To change a key, use the rotate endpoint. Requires board:admin scope + a project-admin grant. requestBody: required: true content: application/json: schema: type: object properties: label: { type: string, maxLength: 200 } public_key: type: string description: base64 Ed25519 public key (32 bytes) hardened: { type: boolean, default: false } required: [label, public_key] responses: "201": description: enrolled content: application/json: schema: { $ref: "#/components/schemas/WitnessKey" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": { description: "label already enrolled (insert-only); use rotate" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/witness-keys/{label}/rotate: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - name: label in: path required: true schema: { type: string } post: tags: [projects] summary: Rotate an enrolled witness key (ADR-0030) description: | A deliberate, separately-audited key swap. Re-stamps `enrolled_by` and `created_at` (the audit trail names who rotated, never misattributes the swap to the original enrollee) and emits a distinct `witness_key.rotated` event so the change is visible on the evidence chain. Clearing `hardened` (an L3 → L2 downgrade of the isolation evidence) requires an org-WIDE admin grant — a project-admin may rotate the key or raise it to hardened, but may not silently weaken the assurance tier. Requires board:admin scope + a project-admin grant (org-admin for a downgrade). requestBody: required: true content: application/json: schema: type: object properties: public_key: type: string description: base64 Ed25519 public key (32 bytes) hardened: { type: boolean, default: false } required: [public_key] responses: "200": description: rotated content: application/json: schema: { $ref: "#/components/schemas/WitnessKey" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { description: "clearing `hardened` requires an org-admin" } "404": { description: "no key enrolled under this label" } "422": { $ref: "#/components/responses/Invalid" } /v1/reviews/{reviewId}/release: parameters: - $ref: "#/components/parameters/reviewId" post: tags: [reviews] summary: Release a review without a verdict (re-opens for another reviewer) requestBody: content: application/json: schema: type: object properties: reason: { type: string, maxLength: 2000 } responses: "200": { description: released } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /v1/reviews/{reviewId}/accept: parameters: - $ref: "#/components/parameters/reviewId" post: tags: [reviews] summary: "Accept: sign the task done (the ONLY path to done in review-required projects)" description: | Requires `tasks:review`, holding the active review, and a DISTINCT identity from the worker — the non-self-review guarantee is enforced in the acceptance transaction. The reviewer files its OWN gate report (re-run against the candidate, never the worker's) and the SHA it landed. Dependents unblock on acceptance, never on the worker's self-report. IDEMPOTENT against the converged state (ADR-0027 crash recovery): if the merge already landed and the task is done, a recovering reviewer re-issuing accept against the SAME candidate SHA succeeds as a no-op — the merge-first protocol's self-healing half. The landing anchor (T-357, §3.13 steps 2–3): `landed_sha` must equal the candidate under review — the fast-forward landing — OR be a DECLARED rebase of it: `rebased_from` equal to the candidate and `landed_sha` the rebased head (the reviewer rebased the candidate onto the moved tip, proved the patches identical, gated the merge result and landed that). The board records both shas and emits both on `task.accepted`. An undeclared different `landed_sha` is the 409 it always was; a `rebased_from` that is not the candidate is a 409; `rebased_from` without `landed_sha`, or with `landed_sha` equal to the candidate, is a 422. The `landed_sha` spelling rule (T-171, the accept-side twin of T-137's `commit_sha` gate — the same `claims::is_full_object_name`): a present `landed_sha` must be the FULL object name, 40 (SHA-1) or 64 (SHA-256) lowercase hex characters, OR byte-identical to the `candidate_sha` the board recorded. The second arm keeps rows completed before T-137 acceptable with their abbreviated candidate exactly as recorded — no trim, no lowercase, no prefix match. Any other spelling is a 422 naming the remedy (`git rev-parse `); the board holds no git objects and resolves nothing. The rule is deliberately not a schema `pattern`, which would refuse the grandfathered spelling. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/VerdictRequest" } responses: "200": { description: "accepted (or recovered, idempotently)" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { description: "reviewer-only, or self-review refused" } "404": { $ref: "#/components/responses/NotFound" } "409": { description: "landed_sha does not match the candidate under review and no rebase was declared, or rebased_from is not the candidate (T-357)" } "410": { description: "review lease expired" } "422": description: | invalid verdict — among them a `landed_sha` that is neither a full 40/64-lowercase-hex object name nor byte-identical to the recorded `candidate_sha` (T-171), `rebased_from` without a differing `landed_sha` (T-357), or a missing gate report content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/reviews/{reviewId}/reject: parameters: - $ref: "#/components/parameters/reviewId" post: tags: [reviews] summary: "Reject: hand the task back to open with a reason (bounded by escalation)" description: | Hands the task back to `open` for rework, so worker↔reviewer ping-pong is bounded by a mechanism and a human disposes the dispute. The bound counts the TASK's rejected reviews (T-425), this one included — `task_reject_count`, a monotonic cycle number, because review rows are never rewritten. It is NOT the completion's `reject_count`: a reject reopens the task, the only road back is a re-completion, and a re-completion resets that counter, so it never passed 1 and the bound never fired. When `task_reject_count` lands on a multiple of the configured limit (limit, 2·limit, …) the server auto-files ONE ADR-0012 decision blocking the task — once per crossing, never per reject past it. A non-positive limit disables the bound. Both counts ride the 200 body and the chained `task.rejected` event (which carries references, never the reason). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/VerdictRequest" } responses: "200": description: "rejected (task back to open; maybe escalated)" content: application/json: schema: { $ref: "#/components/schemas/ReviewRejected" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { description: "reviewer-only, or self-review refused" } "404": { $ref: "#/components/responses/NotFound" } "410": { description: "review lease expired" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/steering:docket: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [steering] summary: "The steering docket: the committee's fused, read-only entry (ADR-0028)" description: | The whole awaiting-judgment surface in ONE call (turn-frugal, ADR-0009): open decisions, endorsed + open proposals, pending independent reviews, the objective work-ranking frontier, and backlog counts. The board ASSEMBLES the docket, the committee DELIBERATES, the human DISPOSES. Descriptive, never prescriptive: it reports what is waiting with its evidence in plain FIFO/evidence order — no server-computed priority, no "look at this first". The board ranks work (objective) but never ranks ATTENTION; what is worth a human's deliberation stays in human hands. responses: "200": description: the steering docket content: application/json: schema: { $ref: "#/components/schemas/Docket" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /v1/orgs/{org}/projects/{proj}/triggers/{trigger}/acknowledge: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - name: trigger in: path required: true schema: { type: string, enum: [pool-sizing, head-lock, sse-waste] } post: tags: [steering] summary: "Acknowledge a fired trigger with the ADR that disposes it (ADR-0067, T-505)" description: | The human disposition of a fired registry trigger: appends ONE `trigger.acknowledged` event to the org-wide (NULL-project) hash chain, payload `{trigger, adr, note_image, fired_at}`, actor = the caller. The docket reads it back as the trigger's `last_acknowledged_at`; no trigger state is stored anywhere, and nothing in the board acknowledges on its own. Maintainer/admin-gated (`tasks:file` scope AND the maintainer role on the project in the path) — an agent grant carrying the scope is refused, because acknowledging is disposing. The registry is deployment-wide (its instruments are the process's), so the project is the seat that authorizes the act, not a scope on the record. `note` is optional and is NOT chained as text (events carry references, never content): the event and the response carry `note_image` = `{len, sha256}`, so a copy kept elsewhere — the ADR itself — can be matched to this act. Acknowledging again is allowed and appends again; the newest acknowledgement is what the docket shows. A trigger the chain has never recorded as fired is refused with 409: there is nothing to dispose. requestBody: required: true content: application/json: schema: type: object additionalProperties: false properties: adr: type: string pattern: "^\\s*ADR-[0-9]{4,8}\\s*$" description: the ADR that disposes the trigger (build the mechanism, recalibrate, or accept the risk), e.g. ADR-0067 note: type: string maxLength: 4000 description: optional; digested into `note_image`, never stored as text required: [adr] responses: "201": description: the acknowledgement as chained content: application/json: schema: { $ref: "#/components/schemas/TriggerAcknowledgement" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/decisions: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [decisions] summary: List decisions (questions for humans) parameters: - name: status in: query schema: { type: string, enum: [open, answered] } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - name: cursor in: query schema: { type: string } responses: "200": description: decisions (paginated) content: application/json: schema: { $ref: "#/components/schemas/DecisionPage" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [decisions] summary: Raise a decision (a question for humans) description: | Requires `tasks:file`. This is the "ask, don't invent" mechanism (ADR-0012): an agent that is blocked records the question instead of guessing. Linked open tasks are excluded from `available` and refused at claim time until the decision is answered. The answer must come from a human — a caller with a maintainer-or-above grant (never a worker role), which is what keeps blocked-on-a-question from becoming self-service. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/DecisionCreate" } responses: "201": description: raised content: application/json: schema: { $ref: "#/components/schemas/Decision" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "422": description: | validation failure. Realm refusals (ADR-0046, T-35), verbatim, `x` being the slug sent: `unknown realm "x" in this project`; `realm "x" is archived — file into a live realm or project-wide`. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/orgs/{org}/projects/{proj}/decisions/{id}: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/decisionId" get: tags: [decisions] summary: One decision with its blocked tasks responses: "200": description: the decision content: application/json: schema: { $ref: "#/components/schemas/Decision" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /v1/orgs/{org}/projects/{proj}/decisions/{id}/answer: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/decisionId" post: tags: [decisions] summary: Answer a decision (human, maintainer+) description: | Requires `tasks:file` scope and a maintainer-or-above grant; worker grants are refused (403) even if they carry the scope — a blocked task must not unblock itself. Answering re-surfaces the blocked tasks to `available` in the same transaction and records who answered (actor attribution, evaluation §3.11). T-379: the body may carry the chosen option's `record_patch` onto a linked task, applied in the same transaction (see `DecisionRecordPatch`). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/DecisionAnswer" } responses: "200": description: answered content: application/json: schema: { $ref: "#/components/schemas/Decision" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/questions: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [questions] summary: List questions (non-blocking clarification — ADR-0045) description: | Non-blocking clarification an agent asked mid-run. A question NEVER blocks: the agent filed it on an active claim and proceeded on its `stated_default`. Default returns the open queue (the human's "a question is waiting" surface). `?claim_id=` is the agent's own "did my question get answered?" poll. parameters: - name: status in: query schema: { type: string, enum: [open, answered, moot, all], default: open } - name: claim_id in: query schema: { type: string, format: uuid } - name: limit in: query schema: { type: integer, default: 50, maximum: 200 } - name: cursor in: query schema: { type: string } responses: "200": description: a page of questions content: application/json: schema: { $ref: "#/components/schemas/QuestionPage" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [questions] summary: File a question on an active claim (worker+) — ADR-0045 description: | Requires `tasks:file` and a worker-or-above grant, on an ACTIVE claim the filer holds (a question is asked by a run in flight). Filing NEVER blocks: the agent proceeds on `stated_default` immediately. The `stated_default` is REQUIRED — it is the non-blocking mechanism; a question without a safe default is a blocking decision in disguise (raise a decision instead, the 422 says so). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/QuestionCreate" } responses: "201": description: filed (proceed on the stated default — never blocked) content: application/json: schema: { $ref: "#/components/schemas/Question" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/questions/{id}/answer: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/questionId" post: tags: [questions] summary: Answer a question (human, maintainer+) — ADR-0045 description: | Requires `tasks:file` and a maintainer-or-above grant (an agent never answers its own). Flips the question to `answered` and emits the `question.answered` event the filer picks up at its next poll. Answering an already-answered or moot question is a 409. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/QuestionAnswer" } responses: "200": description: answered content: application/json: schema: { $ref: "#/components/schemas/Question" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/tenets: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [tenets] summary: List tenets (judgments-that-guide) description: | Tenets are the project's recorded judgments-that-guide (ADR-0039) — a **guiding light, not a leash**: agents pull them when uncertain, and the board NEVER enforces them. Default returns the `active` set (what currently steers the project). `?status=proposed` is the operator's triage queue (candidates awaiting the dispose seat). `?lineage=` walks the supersession chain both ways and returns the full history oldest-first — "why is it done this way?" parameters: - name: status in: query schema: { type: string, enum: [active, proposed, superseded, reversed, declined, all] } - name: lineage in: query schema: { type: string, format: uuid } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - name: cursor in: query schema: { type: string } responses: "200": description: tenets (paginated; lineage ignores pagination) content: application/json: schema: { $ref: "#/components/schemas/TenetPage" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [tenets] summary: Propose a tenet (any standing, worker+) description: | Requires `tasks:file` and a worker-or-above grant (cumulative — the operator themself can also propose). This is the door by which a committee or a working agent brings a candidate ruling to the operator. The tenet lands `proposed` and **steers no one** until the operator accepts it; `proposed_by` records the caller's attested subject. To replace an existing tenet, pass `supersedes` (the target must be an ACTIVE tenet in this project — you cannot supersede a mere proposal or a retired one). Append-only: a tenet is never edited, only superseded. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TenetPropose" } responses: "201": description: proposed content: application/json: schema: { $ref: "#/components/schemas/Tenet" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": description: | conflict — including decision 01a05e7b's cross-scope supersede refusal: `supersedes` names a tenet whose realm scope differs from this proposal's. The detail names both scopes; `recovery` says to file two tenets (supersede-within-scope, then a separate filing). content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "422": description: | validation failure. Realm refusals (ADR-0046, T-35), verbatim, `x` being the slug sent: `unknown realm "x" in this project`; `realm "x" is archived — file into a live realm or project-wide`. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/orgs/{org}/projects/{proj}/tenets/{id}: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/tenetId" get: tags: [tenets] summary: One tenet with its lineage pointers responses: "200": description: the tenet content: application/json: schema: { $ref: "#/components/schemas/Tenet" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /v1/orgs/{org}/projects/{proj}/tenets/{id}/citations: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/tenetId" get: tags: [tenets] summary: The work that cites a tenet (the rectify story) description: | ADR-0039 citation: when a tenet turns out wrong and is superseded, this surfaces every proposal, task, and completion made in its name — so the work done under a bad ruling is visible and fixable. Citations are self-reported (`tenet_ids` on proposals/tasks/completions), served verbatim; the board records, never judges. responses: "200": description: the citing proposals, tasks, and completions content: application/json: schema: { $ref: "#/components/schemas/TenetCitations" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /v1/orgs/{org}/projects/{proj}/tenets/{id}/accept: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/tenetId" post: tags: [tenets] summary: Accept a tenet into doctrine (the dispose seat, maintainer+) description: | Requires `tasks:file` and a maintainer-or-above grant. The tenet becomes `active` and starts steering; `accepted_by/at` stamp the operator. If it supersedes another tenet, the replaced tenet flips to `superseded` in the same transaction (append-only). An operator accepting their own proposal is the operator doing both halves — faithfully recorded (`proposed_by == accepted_by`). Accepting a tenet that is not `proposed` is a 409 (a tenet is accepted exactly once). The body is OPTIONAL (decision 01a05e97, T-35): a bodiless accept keeps the proposed realm; `TenetAccept.realm` lets the maintainer re-scope once, at disposal. The cross-scope supersede check (decision 01a05e7b) runs here again, against that FINAL realm. requestBody: required: false content: application/json: schema: { $ref: "#/components/schemas/TenetAccept" } responses: "200": description: accepted (active) content: application/json: schema: { $ref: "#/components/schemas/Tenet" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "422": description: | validation failure. Realm refusals (ADR-0046, T-35), verbatim, `x` being the slug sent: `unknown realm "x" in this project`; `realm "x" is archived — file into a live realm or project-wide`. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/orgs/{org}/projects/{proj}/tenets/{id}/decline: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/tenetId" post: tags: [tenets] summary: Decline a proposed tenet (the dispose seat, maintainer+) description: | Requires `tasks:file` and a maintainer-or-above grant. The operator refuses the candidate; it never steered anyone and the row stays as history. Declining a tenet that is not `proposed` is a 409. responses: "200": description: declined content: application/json: schema: { $ref: "#/components/schemas/Tenet" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } /v1/orgs/{org}/projects/{proj}/tenets/{id}/reverse: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/tenetId" post: tags: [tenets] summary: Reverse an active tenet (maintainer+) description: | Requires `tasks:file` and a maintainer-or-above grant. The tenet is withdrawn — nothing replaces it (contrast `supersedes`, which replaces). The row stays (history); it leaves the active set. Append-only: reversal is a status transition, never a delete. Reversing a tenet that is not `active` is a 409. responses: "200": description: reversed content: application/json: schema: { $ref: "#/components/schemas/Tenet" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } /v1/orgs/{org}/projects/{proj}/tenets/{id}/reassert: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/tenetId" post: tags: [tenets] summary: Re-assert a tenet's push (maintainer+) — ADR-0051 description: | Requires a maintainer-or-above grant. The push privilege — not the tenet — has a re-assertion budget (`push_cadence`); when it lapses (`push_lapsed`), the tenet stops riding claim bundles but REMAINS active doctrine (listed, citable, pullable). Re-asserting moves `last_reasserted_at = now()`, restoring the push for another cadence. Only an `active` tenet can be re-asserted (a proposal guides no one) — re-asserting a non-active tenet is a 409. Records a `tenet.reasserted` event (id-only payload). Append-only: this is a recorded act, never an edit to the tenet's content. responses: "200": description: re-asserted — `last_reasserted_at` moved, `push_lapsed` cleared content: application/json: schema: { $ref: "#/components/schemas/Tenet" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } /v1/orgs/{org}/projects/{proj}/tenets/{id}/delivery: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/tenetId" post: tags: [tenets] summary: Set a tenet's delivery state (maintainer+) — ADR-0044 description: | Requires `tasks:file` and a maintainer-or-above grant (a dispose-seat act — moving up the ladder spends every claimant's context budget). `pull` (default): queryable, never pushed. `cited`: pushed when the task/goal cites it. `always`: pushed into every claim bundle. Only an ACTIVE tenet can be pushed (`cited`/`always`); setting push on a non-active tenet is a 409 (a proposal guides no one). `pull` is always allowed (it just stops a push). Opt-in, reversible, event-recorded. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TenetDelivery" } responses: "200": description: delivery updated content: application/json: schema: { $ref: "#/components/schemas/Tenet" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/adrs: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [adrs] summary: List ADRs (the architecture decision register) description: | Architecture Decision Records as first-class board resources (migration 0031, promoted from DECISIONS.md). The lifecycle mirrors the tenet road (ADR-0039): `proposed` → `active` → `superseded` | `reversed` | `declined`; only `active` steers. Requires `tasks:read` and a viewer-or-above grant. Default returns the `active` set (what currently guides the project). `?status=proposed` is the operator's triage queue; `all` drops the filter. Pages are in id order (UUIDv7, ≈ creation order); `next_cursor` is the last id on the page, opaque to callers. `?lineage=` walks the supersession chain BOTH ways from that ADR and returns the whole lineage oldest-first (by `proposed_at`), ignoring `status`, `limit` and `cursor` (`next_cursor` is then always null). An unknown `status` value, a non-uuid `lineage` or a non-uuid `cursor` is a 422. parameters: - name: status in: query schema: { type: string, enum: [active, proposed, superseded, reversed, declined, all] } - name: lineage in: query schema: { type: string, format: uuid } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - name: cursor in: query schema: { type: string } responses: "200": description: ADRs (paginated; lineage ignores pagination) content: application/json: schema: { $ref: "#/components/schemas/AdrPage" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/Invalid" } post: tags: [adrs] summary: Propose an ADR (worker+) description: | Requires `tasks:file` and a worker-or-above grant (cumulative — the operator themself can also propose). The ADR lands `proposed` and **steers no one** until a maintainer accepts it; `proposed_by` records the caller's attested subject. To replace an existing ADR, pass `supersedes`: the target must be an ACTIVE ADR in this project (404 when no such ADR exists here, 409 when it is not active). Append-only: an ADR is never edited, only superseded. Emits `adr.proposed`. `number` is unique within the project across EVERY status (the table's `UNIQUE (project_id, number)`), so a superseding ADR takes a new number. Reusing a number is a 409 naming it, and nothing is written. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/AdrPropose" } responses: "201": description: proposed content: application/json: schema: { $ref: "#/components/schemas/Adr" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/adrs/{id}: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/adrId" get: tags: [adrs] summary: One ADR (any status) with its lineage pointers responses: "200": description: the ADR content: application/json: schema: { $ref: "#/components/schemas/Adr" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /v1/orgs/{org}/projects/{proj}/adrs/by-number/{number}: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - name: number in: path required: true description: the human ADR number, e.g. 33 for ADR-0033 schema: { type: integer, format: int32, minimum: 1 } get: tags: [adrs] summary: The ACTIVE ADR carrying a number description: | Resolves the operator's number-keyed mental model ("show me ADR-0033"). Only an `active` ADR resolves: a number whose ADR is proposed, superseded, reversed or declined is a 404 here — read it by id, or through `GET …/adrs?status=all`. responses: "200": description: the active ADR with that number content: application/json: schema: { $ref: "#/components/schemas/Adr" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /v1/orgs/{org}/projects/{proj}/adrs/{id}/accept: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/adrId" post: tags: [adrs] summary: Accept an ADR into doctrine (the dispose seat, maintainer+) description: | Requires `tasks:file` and a maintainer-or-above grant. No body. The ADR becomes `active`; `accepted_by/at` stamp the operator. If it supersedes another ADR, that ADR flips to `superseded` (with `superseded_by` set) in the same transaction — supersession lands with acceptance, not proposal. Emits `adr.accepted` (and `adr.superseded` for the replaced row). 409 when the id is not a `proposed` ADR in this project (an unknown id included — the board answers 409, not 404, for it), and 409 when the ADR it supersedes is no longer active (already superseded or reversed): the candidate then stays `proposed`. responses: "200": description: accepted (active) content: application/json: schema: { $ref: "#/components/schemas/Adr" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } /v1/orgs/{org}/projects/{proj}/adrs/{id}/decline: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/adrId" post: tags: [adrs] summary: Decline a proposed ADR (the dispose seat, maintainer+) description: | Requires `tasks:file` and a maintainer-or-above grant. No body. The operator refuses the candidate; it never steered anyone and the row stays as history. 409 when the id is not a `proposed` ADR in this project. Emits `adr.declined`. responses: "200": description: declined content: application/json: schema: { $ref: "#/components/schemas/Adr" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } /v1/orgs/{org}/projects/{proj}/adrs/{id}/reverse: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/adrId" post: tags: [adrs] summary: Reverse an active ADR (maintainer+) description: | Requires `tasks:file` and a maintainer-or-above grant. No body. The ADR is withdrawn — nothing replaces it (contrast `supersedes`). The row stays (history) and leaves the active set; reversal is a status transition, never a delete. 409 when the id is not an `active` ADR in this project. Emits `adr.reversed`. responses: "200": description: reversed content: application/json: schema: { $ref: "#/components/schemas/Adr" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } /v1/orgs/{org}/projects/{proj}/prompts: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [prompts] summary: List prompts (the standing-prompt registry) description: | Prompts are the project's registered standing prompts (ADR-0056) — the prompt text a harness injects for a scope, under the tenet treatment: supersession lineage, both actors recorded, a Maintainer+ dispose gate, and **no delivery mechanism**. The board never injects prompts; a harness fetches the active prompt for ITS scope and injects it (execution plane), reporting its `content_ref` as the session's `prompt_ref` (the E-16 join). Default returns the `active` set — at most one per scope. `?status=proposed` is the operator's triage queue; `?scope=stint` filters by persona; `?lineage=` walks the supersession chain and returns the full history oldest-first. parameters: - name: status in: query schema: { type: string, enum: [active, proposed, superseded, reversed, declined, all] } - name: scope in: query schema: { type: string, enum: [stint, committee, triage, curator, reviewer, auditor] } - name: lineage in: query schema: { type: string, format: uuid } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - name: cursor in: query schema: { type: string } responses: "200": description: prompts (paginated; lineage ignores pagination) content: application/json: schema: { $ref: "#/components/schemas/PromptPage" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [prompts] summary: Propose a prompt (any standing, worker+) description: | Requires `tasks:file` and a worker-or-above grant (cumulative — the operator themself can also propose). The prompt lands `proposed` and **injects into nothing** until the operator accepts it; `proposed_by` records the caller's attested subject. `content_ref` is computed SERVER-SIDE at propose as `"sha256:" + hex(sha256(body_md))` — the request body never carries it. To replace an existing prompt, pass `supersedes` (the target must be an ACTIVE prompt in this project). Append-only: a prompt is never edited, only superseded. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/PromptPropose" } responses: "201": description: proposed (content_ref minted server-side) content: application/json: schema: { $ref: "#/components/schemas/Prompt" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/prompts/{id}: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/promptId" get: tags: [prompts] summary: One prompt with its lineage pointers responses: "200": description: the prompt content: application/json: schema: { $ref: "#/components/schemas/Prompt" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /v1/orgs/{org}/projects/{proj}/prompts/{id}/accept: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/promptId" post: tags: [prompts] summary: Accept a prompt into the active set (the dispose seat, maintainer+) description: | Requires `tasks:file` and a maintainer-or-above grant. The prompt becomes `active` — what a harness should fetch for its scope; `accepted_by/at` stamp the operator. In the SAME transaction any currently-active prompt for that scope flips to `superseded` with `superseded_by` set (at most ONE active prompt per (project, scope) — the unique index backstops the transaction, so a concurrent double-accept cannot leave two live prompts). Accepting a prompt that is not `proposed` is a 409 (a prompt is accepted exactly once). responses: "200": description: accepted (active) content: application/json: schema: { $ref: "#/components/schemas/Prompt" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } /v1/orgs/{org}/projects/{proj}/prompts/{id}/decline: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/promptId" post: tags: [prompts] summary: Decline a proposed prompt (the dispose seat, maintainer+) description: | Requires `tasks:file` and a maintainer-or-above grant. The operator refuses the candidate; it never injected anywhere and the row stays as history. Declining a prompt that is not `proposed` is a 409. responses: "200": description: declined content: application/json: schema: { $ref: "#/components/schemas/Prompt" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } /v1/orgs/{org}/projects/{proj}/prompts/{id}/reverse: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/promptId" post: tags: [prompts] summary: Reverse an active prompt (maintainer+) description: | Requires `tasks:file` and a maintainer-or-above grant. The prompt is withdrawn — nothing replaces it (the scope returns to having no active prompt; the harness's file-based default resumes — opt-in means that is a fine state). The row stays (history). Append-only: reversal is a status transition, never a delete. Reversing a prompt that is not `active` is a 409. responses: "200": description: reversed content: application/json: schema: { $ref: "#/components/schemas/Prompt" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } /v1/orgs/{org}/projects/{proj}/goals: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [goals] summary: List goals (durable, decaying intent) description: | Goals are the project's durable, living *intent* (ADR-0042) — what the work is FOR. Default returns the live set banded by derived status: `stale` first (a live goal whose `review_cadence` lapsed — the anti-graveyard lever; re-assert it or dispose it), then `active` (by horizon now/next/later, soonest decay deadline first). Disposed goals (`achieved`/`abandoned`/`superseded`) are history — filter with `?status=`. `stale` is DERIVED at read time, never stored. parameters: - name: status in: query schema: { type: string, enum: [active, stale, achieved, abandoned, superseded, all] } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - name: cursor in: query schema: { type: string } responses: "200": description: goals (paginated) content: application/json: schema: { $ref: "#/components/schemas/GoalPage" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [goals] summary: File a goal (the dispose seat, maintainer+) description: | Requires `tasks:file` and a maintainer-or-above grant — a goal is HUMAN intent made first-class. A worker never files a goal; it files a **proposal** (ADR-0022) and a maintainer accepts it into a goal. The dispose gate holds from birth, not just at dispose. `review_cadence` is REQUIRED — a goal cannot be born stale. `statement_md`, `horizon`, and `success_signals` are IMMUTABLE once filed: a later change is a SUPERSEDE (a new goal with `superseded_by` lineage), never an edit. `success_signals` are observable, agent-checkable *conditions*, NOT metrics — the board stores them verbatim, never judges. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/GoalCreate" } responses: "201": description: filed content: application/json: schema: { $ref: "#/components/schemas/Goal" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "422": description: | validation failure. Realm refusals (ADR-0046, T-35), verbatim, `x` being the slug sent: `unknown realm "x" in this project`; `realm "x" is archived — file into a live realm or project-wide`. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/orgs/{org}/projects/{proj}/goals/{id}: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/goalId" get: tags: [goals] summary: One goal with its lineage pointer responses: "200": description: the goal content: application/json: schema: { $ref: "#/components/schemas/Goal" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } patch: tags: [goals] summary: Amend a live goal's operational knobs (maintainer+, If-Match) description: | Requires `tasks:file` and a maintainer-or-above grant, and an `If-Match` header carrying the goal's current `version` (optimistic concurrency). Only the OPERATIONAL knobs are patchable: `review_cadence` (the decay clock — not intent; a change re-bases the clock) and `tenet_ids` (the ADR-0039 citations it grounds itself in). The CONTENT — `statement_md`, `horizon`, `success_signals` — is IMMUTABLE: a patch naming any of them is refused 422 with a `recovery` naming `POST …/goals/{id}/supersede`. **`realm` is IMMUTABLE content too (ADR-0046, T-35)** and is refused BY NAME: a patch carrying `realm` is a `422` whose detail lists `statement_md, horizon, success_signals, and realm are IMMUTABLE content` and names the recovery, `POST …/goals/{id}/supersede` (file the successor with the new scope), inside `detail` — this refusal is a plain `Invalid`, so the problem document's `recovery` field is unset. Before T-35 `realm` was undeclared, serde discarded it, and the call returned `200` having changed nothing. Contrast `PATCH …/tasks/{key}`, where `realm` IS amendable (T-169). parameters: - name: If-Match in: header required: true schema: { type: string } requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/GoalPatch" } responses: "200": description: amended content: application/json: schema: { $ref: "#/components/schemas/Goal" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "412": { $ref: "#/components/responses/Conflict" } "422": description: | validation failure — including the immutable-content refusal for `statement_md` / `horizon` / `success_signals` / `realm`, naming `POST …/goals/{id}/supersede` as the recovery. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/orgs/{org}/projects/{proj}/goals/{id}/reassert: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/goalId" post: tags: [goals] summary: Re-assert a live goal (maintainer+) — the anti-graveyard act description: | Requires `tasks:file` and a maintainer-or-above grant. "This intent is still live" — resets `last_reasserted_at`, pulling a `stale` goal back to `active`. Deliberately a governance act, not a PATCH. A goal that is not `active` (already disposed) is a 409. responses: "200": description: re-asserted (decay clock reset) content: application/json: schema: { $ref: "#/components/schemas/Goal" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } /v1/orgs/{org}/projects/{proj}/goals/{id}/achieve: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/goalId" post: tags: [goals] summary: Achieve a goal (maintainer+) — TERMINAL description: | Requires `tasks:file` and a maintainer-or-above grant. The intent was met — a fact about the past, so `achieved` is TERMINAL (no reopen; a revival is a NEW goal superseding it). `evidence_completion_ids` (optional) names the completions the maintainer credits with meeting the intent — a soft confirmation of `success_signals`, never a hard gate; each id must name a completion in this project. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/GoalDispose" } responses: "200": description: achieved (terminal) content: application/json: schema: { $ref: "#/components/schemas/Goal" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/goals/{id}/abandon: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/goalId" post: tags: [goals] summary: Abandon a goal (maintainer+) — REVERSIBLE via reactivate description: | Requires `tasks:file` and a maintainer-or-above grant. The intent is given up — a *judgment*, so unlike `achieved` it is REVERSIBLE (`POST …/reactivate`). The `reason` enum is REQUIRED (`no_longer_relevant|superseded_elsewhere|too_costly|other`) so "why do goals die?" becomes measurable; a bare `other` requires a short `note` (≤280). Append-only: a status transition + an event, never an edit. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/GoalDispose" } responses: "200": description: abandoned (reversible via reactivate) content: application/json: schema: { $ref: "#/components/schemas/Goal" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/goals/{id}/reactivate: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/goalId" post: tags: [goals] summary: Reactivate an abandoned goal (maintainer+) — a judgment revisited description: | Requires `tasks:file` and a maintainer-or-above grant. `abandoned → active`. Append-only: reactivation appends a NEW event — it never edits the row. Only `abandoned` is reversible; `achieved`/`superseded` are terminal (409). The `reason` enum is REQUIRED (same shape as abandon); the clock re-bases (`last_reasserted_at = now()`). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/GoalDispose" } responses: "200": description: reactivated (abandoned → active) content: application/json: schema: { $ref: "#/components/schemas/Goal" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/goals/{id}/supersede: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/goalId" post: tags: [goals] summary: Supersede a goal (maintainer+) — the only way to change content description: | Requires `tasks:file` and a maintainer-or-above grant. This goal flips to `superseded`, pointing at `successor_id` (an ACTIVE goal in this project). Because content is immutable, supersession is the ONLY way to change a goal's statement, horizon, or success_signals — the lineage (`superseded_by`) is the reasoning trail. Append-only; terminal. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/GoalSupersede" } responses: "200": description: superseded (terminal; lineage set) content: application/json: schema: { $ref: "#/components/schemas/Goal" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/stats: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [observability] summary: Project observability series (worker+; human-attention and per-filer reject rate are maintainer+) description: | The board-aggregated trust metrics (BRIEF-observability; ADR-0037): the board computes, the agent/UI relays verbatim. Each series is one trend line. FLOW series measure events over time (an answer, a completion) and bucket by event time; SNAPSHOT series measure the state of the world as of now (open decisions, active tenets, chain integrity) and carry the current reading in the latest bucket with `n=1`, earlier buckets null. Aggregate series are Agent+; the per-human `human_attention_minutes` series and the per-filer `reject_rate_by_filer` series are included only for Maintainer+ (the GDPR data-minimization split — attention is derived from the events feed, never a timer). Notable keys (keys are data — this lists the load-bearing ones, not every series): `acceptance_rate`, `decision_latency_minutes`, `first_pass_rate`, `rework_rate`, `agent_attention_minutes`, `human_attention_minutes` (Maintainer+), `cost_task_usd`, `cost_committee_usd`, `tenets_active`, `tenets_stale`, `chain_intact`, `frontier_depth`, `zombie_tasks`, and the §4.1 human-seat gauges (BRIEF-doctrine-evaluation; aggregate-only, descriptive — never rank/route/gate inputs): `decision_answer_latency_h`, `question_answer_latency_h`, `proposal_dispose_latency_h` (median HOURS to answer/dispose, bucketed by answer/dispose time), `dispose_throughput_ratio` (disposed-in-bucket ÷ filed-in-bucket; null when filed = 0), `open_governance_age_h` (SNAPSHOT: age in hours of the oldest open decision / open-or-endorsed proposal / proposed tenet; null when the queue is empty), `governance_queue_depth` (SNAPSHOT: that same pending set, counted). Filing-quality series (E-09 — work-SPECIFICATION quality, read off the review funnel; aggregate Agent+ except the per-filer one). A review "cycle" is the derived count of prior rejects on the task (matching `completions.reject_count` semantics — reset when a rework re-completion replaces the completion row); the cycle's FIRST-review verdict is the earliest closed accepted/rejected review. Keys: `reject_rate_by_origin_direct` / `reject_rate_by_origin_proposal` (percent, FLOW): the first-review reject rate over closed review cycles, split by the task's filing origin — proposal-born = an accepted proposal whose `task_key` names the task (the minted-task link), direct = every other task; bucketed by the first verdict's close time. `rework_latency_h` (ratio, FLOW): median HOURS from a first-verdict reject to the NEXT completion on the same task, bucketed by the next completion's time. `reject_rate_by_filer` (percent, FLOW) is **Maintainer+ only** — the `human_attention_minutes` precedent: a filer may be a human, so the per-human breakdown is included only when the caller holds Maintainer+; one series per filer (the filer subject rides in the label), grouped by the task's `created_by`. parameters: - name: window in: query schema: { type: string, default: 7d, example: "30d" } - name: bucket in: query schema: { type: string, default: day, enum: [day, week] } responses: "200": description: the project's series content: application/json: schema: { $ref: "#/components/schemas/ProjectStats" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /v1/admin/stats: get: tags: [observability] summary: Whole-platform health snapshot (board:admin only) description: | The operator's cross-tenant health read (BRIEF-observability): orgs, projects, task/claim posture, additive cost split, completion assurance rungs, grant surface, committee yield, chain integrity, and a whole-platform time-series. Cross-project AGGREGATE, never a per-project ranking (the privacy line: the operator sees the fleet's trend, never which tenant drives it). `board:admin` scope only. One named exception to the aggregate-only rule (ADR-0069): `org_list` lists every org with its `created_by` (the pseudonymous sub that opened it) and `created_at`, because orgs are self-serve and growth is watched here rather than gated. responses: "200": description: the platform snapshot content: application/json: schema: { $ref: "#/components/schemas/PlatformStats" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /v1/orgs/{org}/erasures: parameters: - $ref: "#/components/parameters/org" post: tags: [privacy] summary: Erase one person's identity mapping (org-admin; GDPR) description: | Per-person erasure (the door on the pseudonymization hatch): anonymizes the `identity_map` row linking a raw subject to its pseudonym, so the append-only event history (which references the pseudonym, never the person) becomes unattributable. Destructive and irreversible — requires `"confirm": true`. Org-admin only; never self-serve by the target in v1. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ErasureRequest" } responses: "200": description: the erased pseudonym (never the raw sub — that link is what was erased) content: application/json: schema: { $ref: "#/components/schemas/ErasureResponse" } "400": { $ref: "#/components/responses/Invalid" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /v1/orgs/{org}/invitations: parameters: - $ref: "#/components/parameters/org" post: tags: [orgs] summary: Invite someone into the org or one project (single-use secret) description: | ADR-0036 (T-7). Mints a single-use secret and returns it EXACTLY ONCE; the board stores only its SHA-256. Delivery is the inviter's (paste the token or an invite link into any channel) — the board sends no email. The `email` is a delivery hint and audit label, NEVER an identity: it is not consulted at redeem, and it is kept out of the hash-chained event. Gated and clamped as `grants` enrollment (ADR-0031): `admin` or any org-wide role requires an org-WIDE admin grant; a project role requires project-admin on that project. The invitation expires after 7 days. requestBody: required: true content: application/json: schema: type: object properties: email: { type: string, minLength: 1, maxLength: 320 } role: type: string enum: [viewer, auditor, worker, curator, maintainer, project-admin, admin, agent] description: | The capability the redeemer is granted. `agent` is the DEPRECATED spelling of `worker`, accepted on input. project: type: string nullable: true description: project slug; absent = an org-wide role required: [email, role] responses: "201": description: the invitation, with its plaintext token (shown once) content: application/json: schema: type: object properties: invitation_id: { type: string, format: uuid } token: { type: string, description: "The single-use secret (base64url, 43 chars). Never retrievable again." } org: { type: string } project: { type: string, nullable: true } role: { type: string } expires_at: { type: string, format: date-time } required: [invitation_id, token, org, role, expires_at] "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/Invalid" } get: tags: [orgs] summary: List the org's invitations (org-admin) description: | ADR-0036. Newest first, at most 200. `status` is the EFFECTIVE status: a `pending` invitation past `expires_at` reads `expired`. Never carries the token or its hash. responses: "200": description: invitations, newest first content: application/json: schema: type: array items: { $ref: "#/components/schemas/Invitation" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /v1/orgs/{org}/invitations/{id}/revoke: parameters: - $ref: "#/components/parameters/org" - name: id in: path required: true schema: { type: string, format: uuid } post: tags: [orgs] summary: Revoke a pending invitation (its inviter or an org-admin) description: | ADR-0036. Only a `pending`, unexpired invitation is revocable. An unknown id, another org's id, a non-pending invitation, or a caller who is neither the inviter nor an org-wide admin all read as 404. A redeemed invitation is not revocable — remove the grant it wrote (`grants:revoke`) instead. responses: "200": description: the revoked invitation content: application/json: schema: { $ref: "#/components/schemas/Invitation" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /v1/invitations:redeem: post: tags: [orgs] summary: Redeem an invitation token (any authenticated principal) description: | ADR-0036. The token proves the invitee received the secret; the login proves who they are. In ONE transaction the invitation flips to `redeemed` (single-use) and a grant is written binding the CALLER'S attested, pseudonymized subject — never the invitation's email — to the invited role at the invited scope (re-inviting an existing member refreshes their role there). A wrong, used, revoked or expired token all read as the same 404. The redemption is attributed: the row and the `invitation.redeemed` event carry both `invited_by` and `redeemed_by`. Recorded deferral: the per-org `iss` trust-domain check is a documented no-op while the board has one trust domain; it lands when orgs gain a per-org realm (docs/BRIEF-invitations.md). requestBody: required: true content: application/json: schema: type: object properties: token: { type: string, minLength: 1, maxLength: 200 } required: [token] responses: "200": description: redeemed; the grant now exists content: application/json: schema: type: object properties: invitation_id: { type: string, format: uuid } org: { type: string } project: { type: string, nullable: true } role: { type: string } subject: { type: string, description: The canonical subject the grant was written for (the caller). } invited_by: { type: string } required: [invitation_id, org, role, subject, invited_by] "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /v1/orgs/{org}/projects/{proj}/observations: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [observations] summary: List observations (append-only, paginated) description: | The qualitative measurement stream (ADR-0020): friction, waste, near-misses, and measurements reported by agents and humans. This is the feed improvement loops consume — pull-based, never pushed; the server stores and indexes, never aggregates (Aurora D76's stance). parameters: - name: kind in: query description: filter by observation kind (e.g. waste, friction) schema: { type: string, maxLength: 100 } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - name: cursor in: query schema: { type: string } responses: "200": description: observations (paginated) content: application/json: schema: { $ref: "#/components/schemas/ObservationPage" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [observations] summary: File an observation description: | Requires `tasks:file` and the **`auditor`** rung or above (ADR-0049 amendment 3, T-2 — decision `01a03b58-98ea-7ce1-9186-56a2ad81dfc8` answered `lower-to-auditor`; the floor was `worker` before). A viewer is refused 403 naming the floor. An observation is a measured fact, neither work nor a dispose, which is why the Auditor's read-all seat may file one: the work verbs (claim, complete, task-file) keep their `worker` floor. One call, one fact, never a dialog (that is what decisions are for). The merit bar is prose (agent-guide §3.10): a named artifact plus a measured or observable cost — not vibes. Filing is cheap and never blocks anything; consumption is what makes it valuable, and consumption is a client concern. **ADR-0064 (T-350) — the "needed twice" trigger, evaluated here.** For a kind in the board's `RECURRENCE_KINDS` (default `friction`, `near-miss`) the response carries `recurrence`: whether this fact was seen before from a DIFFERENT context (claim ?? session ?? actor — a same-context re-file is a `repeat` and never counts), the chain it joined, the precedent it matched, live related rows, and — when the chain has no mechanism yet and the caller may file tasks — a one-time `key` plus `next_step` naming the exact act (`POST …/observation-chains/{id}/task`). The exact-statement floor runs first (SQL); a NOVEL statement then asks the similarity oracle (the vectors sidecar, with the caller's own bearer) — before the transaction, bounded by `RECURRENCE_TIMEOUT_MS`, and ANY failure is reported as `oracle: exact-only` rather than hidden. The field is ABSENT for every other kind: those bodies are byte-identical to before this seam existed. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ObservationCreate" } responses: "201": description: filed (plus the recurrence verdict for an eligible kind) content: application/json: schema: { $ref: "#/components/schemas/ObservationFiled" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/observation-chains: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [observations] summary: List recurrence chains (widest first) description: | ADR-0064: the recurrence of one fact across distinct contexts, rooted at its first occurrence, spawning at most one task. T-317's hand-made clustering, live: which facts recur, across how many contexts, and which already have a mechanism (`task_key`). Descriptive, never prescriptive — the board surfaces, the reader judges. Viewer + `tasks:read`. parameters: - name: status in: query description: "`open` = no task yet and not declined · `mechanized` = task_id set · `declined` = a curator chose not to mechanize it (T-606)" schema: { type: string, enum: [open, mechanized, declined] } - name: kind in: query schema: { type: string, maxLength: 100 } - name: task in: query description: a task key — the chain(s) that spawned it schema: { type: string } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - name: cursor in: query description: "`:` from the previous page" schema: { type: string } responses: "200": description: chains, member count descending then newest content: application/json: schema: { $ref: "#/components/schemas/ObservationChainPage" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/observation-chains/{id}: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - name: id in: path required: true schema: { type: string, format: uuid } get: tags: [observations] summary: One recurrence chain with its members description: | ADR-0064: the chain's members in order of occurrence, each with its context (claim/session/actor), the tasks it was filed under, what it matched and how, and its offer's STATUS (`none` | `live` | `consumed` | `expired`) — never a key or a hash. responses: "200": description: the chain content: application/json: schema: { $ref: "#/components/schemas/ObservationChainDetail" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } patch: tags: [observations] summary: Dispose a chain (curator) — name its existing mechanism, or decline to mechanize it description: | Exactly ONE disposition per call (both, or neither, is **422**): `{task_key}` — the mechanism exists (T-368); `{declined: true, reason}` — this recurs and we choose not to mechanize it (T-606); `{declined: false, reason?}` — reverse a decline. **`task_key` (T-368).** A chain whose fix landed through a NORMAL row — not through a spent key — has no other way to say so, and keeps offering keys for a solved problem. Requires `tasks:file` and the `curator` rung (the ADR-0023 split for foreign-row steering: it changes what OTHER agents are offered); the key path stays at the worker rung, because a key is provenance, never privilege (ADR-0064). One transaction: the chain exists in this project (else **404**), has no task yet (else **409**, the detail in the key path's own words — `observation chain is already mechanized as `), and `task_key` names a task in this project (else **404**). The chain's `task_id`, `mechanized_at` (now) and `mechanized_by` (the caller) are set; every LIVE offer on the chain is expired; one `observation_chain.mechanized` event is emitted, in the key path's shape plus `via: curator` and `keys_expired`. A later `POST …/{id}/task` with one of the expired keys answers **409 naming the task**, never 410 — the agent reads the task. The summary's `mechanized_via` reads `curator`. Naming a mechanism on a DECLINED chain supersedes the decline (the columns clear; the event carries `superseded_decline_image`). **`declined: true` + `reason` (T-606)** — the T-350 §7 j2 trigger: a recurring fact judged not worth a mechanism (a success record, a benign repetition). `reason` is required (1–2000 chars, else **422**): every next observer reads it instead of being offered a key. The chain must be open (mechanized → the 409 above; already declined → **409** naming the standing reason). `declined_at` (now), `declined_by` (the caller) and `declined_reason` are set; every LIVE offer is expired; one `observation_chain.declined` event (`members`, `distinct_contexts`, `keys_expired`, `reason_image`). The chain keeps accruing members, but a filing that joins it answers `recurs` + `declined: ` and mints NO key, whatever its context count; a spend of a key minted before the decline answers **409** naming the decline, never 410. **`declined: false` (+ optional `reason`, its digest recorded on the trail)** reverses a decline: the three columns clear, one `observation_chain.decline_reversed` event (`reason_image`, `reversed_reason_image`), and keys mint again at the next qualifying occurrence. On a chain that is not declined → **409**. Every `*_image` is `{len, sha256}` of the text, never the text: events carry references, not content (T-421); the row holds the standing reason. Unknown body fields are refused (**422**), so a client sending a disposition this board does not know is told rather than ignored. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ObservationChainPatch" } responses: "200": description: the chain, as disposed content: application/json: schema: { $ref: "#/components/schemas/ObservationChainSummary" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": description: the chain is already mechanized (the detail names the task), already declined (it names the reason), or — on a reversal — not declined content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/observation-chains:backfill: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" post: tags: [observations] summary: Replay the recurrence rule over the historical stream (maintainer) description: | ADR-0064. Requires `tasks:file` and the `maintainer` rung: it writes derived state over the whole stream. Walks the project's observations of the given kinds (default: the board's `RECURRENCE_KINDS`) OLDEST FIRST, skipping rows already in a chain, and runs the same rule the live filing runs — exact floor, then the oracle with the caller's bearer, same-context = repeat — with three deliberate differences: only rows OLDER than the one being attached are priors (the index already holds the future, and a chain must root at the FIRST occurrence); no key is minted (an offer is for the agent present at the moment, and nobody is); members are stamped `via: backfill` and ONE `observation_chains.backfilled` event is emitted per call rather than one `observation.recurred` per membership (a membership computed today is not something observed then). Each row commits on its own, so a failure keeps progress and a re-run is idempotent. Bounded per call by `limit` (≤200); page with `after`. Run once after deploying 0036; the next live occurrence of a chained fact is then offered with its whole history as precedent. requestBody: required: false content: application/json: schema: { $ref: "#/components/schemas/ObservationChainBackfillRequest" } responses: "200": description: what this page did content: application/json: schema: { $ref: "#/components/schemas/ObservationChainBackfillReport" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/observation-chains/{id}/task: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - name: id in: path required: true schema: { type: string, format: uuid } post: tags: [observations] summary: Spend a recurrence offer — file the mechanism for a recurring fact description: | ADR-0064 (T-350). Requires `tasks:file` and the `worker` rung — the same floor as `POST /tasks`: the key adds provenance and idempotency, never privilege. `key` is the one-time offer a `POST /observations` returned in `recurrence.key`; `task` is the `TaskCreate` shape. One transaction: the key resolves (else **404**), is unspent and unexpired (else **410**, the detail naming which), names THIS chain (else **422** with the right chain in `recovery`), and the chain has no task yet (else **409** naming it — another offer won). The task is then filed through the ONE filing path (`file_task_tx`, shared with `POST /tasks`, completion follow-ups and proposal-accept): the same ownership-overlap **409** with its `ownership_remedy`, goal-policy 409 and 422s apply, and **a refused filing rolls the whole call back — the key is still live for the repaired retry**. On success the board appends the chain as provenance to `brief_md` (chain id, kind, N across M contexts, first seen, the precedent), cites the observing run's claim as `filed_by_claim` (so `filed_by_task_key` renders), sets the chain's `task_id` once, spends the key, and emits `observation_chain.mechanized`. Disposition lives on the chain and never in `observation_links` (T-317: links are provenance). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ObservationChainTaskRequest" } responses: "201": description: the task exists; the chain is mechanized; the key is spent content: application/json: schema: { $ref: "#/components/schemas/ObservationChainTaskFiled" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": description: | the chain is already mechanized (detail names the task), OR the filing itself was refused — an ADR-0011 ownership overlap (with `ownership_remedy`, exactly as `POST /tasks`) or goal policy. In the second case the key is NOT spent. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "410": description: the key was already used, or expired (the detail names which, and the remedy — observe again) content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/proposals: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [proposals] summary: List proposals (steering output awaiting a human call) description: | The steering-output queue (ADR-0022): agent-filed, not-yet-claimable task shapes a human accepts into the roadmap or rejects. `status=open` is what awaits triage. parameters: - name: status in: query schema: { type: string, enum: [open, endorsed, accepted, rejected] } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - name: cursor in: query schema: { type: string } responses: "200": description: proposals (paginated) content: application/json: schema: { $ref: "#/components/schemas/ProposalPage" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [proposals] summary: File a steering proposal (ADR-0022) description: | Requires `tasks:file`. A steering agent (committee, task-filer) files a ready-to-file task shape plus a required `rationale_md` (the merit bar: a named artifact plus a consequence). The proposal is **not claimable** — a human accepts it into a real task or rejects it. Agents never triage their own proposals. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ProposalCreate" } responses: "201": description: filed content: application/json: schema: { $ref: "#/components/schemas/Proposal" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/proposals/{id}: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - name: id in: path required: true schema: { type: string, format: uuid } get: tags: [proposals] summary: One proposal responses: "200": description: the proposal content: application/json: schema: { $ref: "#/components/schemas/Proposal" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /v1/orgs/{org}/projects/{proj}/proposals/{id}/accept: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - name: id in: path required: true schema: { type: string, format: uuid } post: tags: [proposals] summary: Accept a proposal — mint a real, claimable task (human) description: | The human gate (ADR-0022): requires a maintainer-or-above grant, and a worker grant carrying the scope is refused — agents never triage their own proposals. Mints a real task carrying the proposal's fields in the same transaction that flips the proposal to `accepted`; the task is minted through the same filing pass as `POST …/tasks`, so its validation and ADR-0011 ownership handling apply exactly as if the task were filed directly. `task_key` records what the proposal became. **The request body is OPTIONAL** (T-136). Omitting it entirely — a bodiless POST with no `Content-Type` header, which is how every accept was sent before T-136 — remains valid and mints exactly as before: the server keys off an absent content-type, not an empty object. When a body IS sent it must be `application/json` and parse as `ProposalAccept`; a JSON content-type with an empty or malformed body is refused (400 syntax / 422 shape, the framework's plain-text JSON rejection), never treated as a bodiless accept, and a non-JSON content-type is a 415. The body carries the disposal-time facts a proposal cannot: `precedes` (the minted task is born IN FRONT of those open tasks, validated with the same three 422 refusals as `TaskCreate.precedes`) and `goal_ids` (T-260: a non-empty list REPLACES the proposal's goal set; an unknown or non-active goal is a plain 422 naming it). Board drift since the proposal was filed or endorsed (a dep key that no longer resolves, an archived realm, a goal gone terminal, a contradicting overlap) is re-dressed as **409** with a `recovery` naming what THIS seam can still do. On the ownership-overlap refusal (which also carries `ownership_remedy`) it reads as below — ONE line on the wire, wrapped here; `{when}` is `filed` or `endorsed`: ```text the board moved since this proposal was {when}: accept again with a body naming the overlapping task(s) in `precedes` to mint this work IN FRONT of them (fix-first — they gain the dependency), or reject it and file the task directly with the ordering you want. Endorsing cannot re-scope a proposal (endorse carries endorsement_md + judges_md only) and there is no proposal-amend route (ADR-0011, ADR-0023). ``` followed, when the computed front set is non-empty, by (leading space included, keys from `ownership_remedy.front.keys`): ```text The minimal `precedes` set, computed from the live graph: T-a, T-b. ``` Every other drift reads: ```text the board moved since this proposal was {when} and its shape no longer fits: endorsing cannot re-scope it (endorse carries endorsement_md + judges_md only, and refuses a proposal that is not open) and there is no proposal-amend route — either fix the board state named above, or reject this proposal and file the corrected task directly (ADR-0023) ``` with, when the refusal names a goal and the proposal carries `goal_ids`, this suffix appended: ```text ; or, when the refusal names a goal this proposal carries, accept again with a body naming live `goal_ids` (they replace the proposal's set) ``` The 200 body is `ProposalAccepted`: the proposal plus the minted row's birth `notes` (T-165), at the one moment the maintainer can still act on them. `precedes` is deliberately on ACCEPT and never on `ProposalCreate`: which open tasks a fix must jump ahead of is a fact about the board at disposal time, often weeks after the proposal was written. requestBody: required: false description: | Optional. Omit it entirely (no body, no Content-Type) for a plain accept — still valid, exactly as before T-136. content: application/json: schema: { $ref: "#/components/schemas/ProposalAccept" } responses: "200": description: accepted; the task was minted content: application/json: schema: { $ref: "#/components/schemas/ProposalAccepted" } "400": { description: "a JSON content-type was sent with a body that is not valid JSON (an empty body included)" } "415": { description: "a body was sent with a non-JSON content-type" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/proposals/{id}/endorse: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - name: id in: path required: true schema: { type: string, format: uuid } post: tags: [proposals] summary: Endorse a proposal — the committee chair's mark (ADR-0023) description: | Requires `tasks:file` (worker-role — NOT the dispose). After the three-way gauntlet (members file open proposals, the chair synthesizes, two fresh judges vet), the chair records the synthesis and the judges' verdicts and marks the proposal `endorsed` — decision-ready for a human, still not a task. The board soft-revalidates the deps against the live board (a dep that no longer resolves is a 409 with a re-scope remedy) and stamps `endorsed_by`/`validated_at`. Accept still hard-revalidates at dispose time; endorsement is a freshness hint, not a waiver. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ProposalEndorse" } responses: "200": description: endorsed; revalidated against the live board content: application/json: schema: { $ref: "#/components/schemas/Proposal" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } /v1/orgs/{org}/projects/{proj}/proposals/{id}/reject: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - name: id in: path required: true schema: { type: string, format: uuid } post: tags: [proposals] summary: Reject a proposal — archive with a reason (human) description: | Same human gate as accept (maintainer-or-above; agents refused). Archives the proposal with a reason. Admits `open` and `endorsed` (the dispose gate is not locked by a chair's endorsement); `accepted`/`rejected` are terminal (409). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ProposalReject" } responses: "200": description: rejected content: application/json: schema: { $ref: "#/components/schemas/Proposal" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } /v1/orgs/{org}/projects/{proj}/events: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [events] summary: Audit log (append-only, paginated) parameters: - name: after in: query description: return events with id greater than this schema: { type: integer, format: int64 } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 500, default: 100 } responses: "200": description: events content: application/json: schema: type: array items: { $ref: "#/components/schemas/Event" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [events] summary: Record a pool lifecycle transition (T-429) description: | The worker pool lives in the bridge, so the board cannot see a pool started (the operator's consent), paused, cooled down, resumed, draining or stopped. The bridge reports each transition here and it is appended to the project's hash chain like any other event, so the Stage replay reads the pool sky from `GET …/events`. NOT a generic event write: only the closed `pool.*` set below is admitted (an unknown kind is 422). Requires `tasks:file` and Maintainer — starting a pool is the consent act, reported under the operator's own login. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/PoolEventIn" } responses: "201": description: the recorded event content: application/json: schema: { $ref: "#/components/schemas/Event" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/Invalid" } /v1/orgs/{org}/projects/{proj}/pool-events: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" post: tags: [events] summary: Report one bridge pool/run lifecycle fact (ADR-0070, T-560) description: | A bridge is ONE publisher among N into its project's event stream, keyed by the stable per-machine `bridge_id` it mints — two bridges on one project are two skies, never one flickering one. Control (stop/drain) stays in the bridge; the FACTS land here, appended to the project's hash chain, so `GET …/events` (the replay) and `GET …/events/stream` (the SSE tail) carry them to every viewer, not only to a browser on the bridge's machine. One event per call; the closed kind set and each kind's payload are `PoolEventReport`. The board checks the shape and stores the bridge's values verbatim under the reporter's subject — the event's `payload` is exactly the report minus `kind` (with `at` in one canonical RFC 3339 spelling). It derives no pool state. **Who:** a rung-0 principal — the consenting operator's own login — holding `tasks:file` and `project-admin`. A run identity (ADR-0060, rung 1) is refused 403 whatever its grants. **Idempotent** on `(bridge_id, kind, at)` within the project: a repeat — however `at` is spelled — answers 200 with the event already held and writes nothing (the first report's values stand). **Rail:** more than 10 reports per second from one principal is 429 with `Retry-After: 1`; a pool emits well under one a second, so only a looping bridge meets it. Nothing refused is recorded. Every "why" (`cause`, `error`) is a CODE, never prose: events carry references, not content (ARCHITECTURE.md), and the chain cannot be redacted. (`cause` is the T-429 name for the same thing on `POST …/events`.) requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/PoolEventReport" } responses: "201": description: recorded — the new event content: application/json: schema: { $ref: "#/components/schemas/Event" } "200": description: | a repeat of `(bridge_id, kind, at)` — the event already held; nothing was written content: application/json: schema: { $ref: "#/components/schemas/Event" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "422": description: | the report does not match `PoolEventReport`. An unknown or missing `kind` names the whole set in `recovery`. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "429": description: more than 10 reports per second from this principal; nothing was recorded headers: Retry-After: description: seconds to wait (always 1) schema: { type: integer, minimum: 1 } content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/orgs/{org}/projects/{proj}/sessions: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" post: tags: [sessions] summary: Open a session description: | A session is an agent's working stint; claims reference it and stats attach to it (Aurora D74/D76: claimless sessions are still agents, and cost attribution is structural). requestBody: required: false content: application/json: schema: type: object properties: kind: type: string enum: [task, eval, ideas, menu, user-order, other, review] default: task harness_ref: { type: string, maxLength: 200 } model_class: type: string maxLength: 120 nullable: true description: | Self-reported model label (e.g. the harness's model id) — the measurement axis ADR-0031 names (never the security axis). Stored verbatim; the board never recomputes it (D76). Drives the review-diversity series. prompt_ref: type: string pattern: '^sha256:[0-9a-f]{64}$' nullable: true description: | Content-address of the standing prompt material the harness injected at stint start (ADR-0055, E-16): sha256 over (promptBlock || 0x00 || persona template bytes). Attribution, never content — the board stores the ref verbatim and joins it in the Maintainer+-gated acceptance_rate_by_prompt_ref series. responses: "201": description: opened content: application/json: schema: { $ref: "#/components/schemas/Session" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } get: tags: [sessions] summary: List sessions (newest first) description: | Read surface for the measurement loop (ROADMAP P0-L2): the attention derivation reads sessions (agent-hours) together with observations (human attention) — the server stores, never aggregates (ADR-0020); derivation is a client concern. parameters: - name: cursor in: query schema: { type: string, format: date-time } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } responses: "200": description: a page of sessions content: application/json: schema: type: object required: [items] properties: items: type: array items: { $ref: "#/components/schemas/Session" } next_cursor: { type: string, format: date-time } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /v1/sessions/{sessionId}: parameters: - name: sessionId in: path required: true schema: { type: string, format: uuid } patch: tags: [sessions] summary: Attach stats / back-fill the attribution axes / close the session description: | The session's meters and lineage markers, all harness-reported. `model_class` is set/replace (E-08 — the label may be learned after open). `harness_ref` (the join key to the harness run, ADR-0062 B.9) and `prompt_ref` (ADR-0055) are FILL-ONCE lineage markers: admitted only while NULL, a second PATCH naming a different value is refused 409, and a same-value re-report is idempotent (a re-resumed stint re-sending the values it resolved twice succeeds). The pool path (T-298) uses this: the bridge's `drill` opens the session before any harness exists, so the harness back-fills the axes once it knows them. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/SessionPatch" } responses: "200": description: updated content: application/json: schema: { $ref: "#/components/schemas/Session" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } /v1/sessions/{sessionId}/trail: parameters: - name: sessionId in: path required: true schema: { type: string, format: uuid } post: tags: [trail] summary: Append a command-trail batch, and/or seal the trail description: | ADR-0062 — the harness's continuous report of what it actually ran. Fused (ADR-0009): entries and the optional seal land in ONE transaction. Idempotent by `(session_id, seq)`, so a re-flush after a lost response is a no-op returning `accepted: 0` — that is what lets a failed flush retry the SAME entries rather than reason about what landed. Authorization is the session's own subject, or `board:admin`; `tasks:claim` scope (inside ADR-0060's run-identity clamp). A CLOSED session refuses (409) — a closed stint is immutable history — with exactly ONE exception: the admin reconstruction path, which admits `backfilled=true` entries into a closed and EMPTY session, is unsealed by construction, and is refused (409) if any trail already exists (live trails are unmergeable). Content lives here and NEVER enters the event hash chain; the chain carries exactly one `trail.sealed` reference per session. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TrailBatch" } responses: "200": description: appended (and sealed, if a seal was supplied) content: application/json: schema: { $ref: "#/components/schemas/TrailPostResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": description: | The session is closed (and this is not the admin reconstruction path), the closed session already has a trail, or a DIFFERENT seal was submitted for an already-sealed session. "422": description: | A field, batch or per-session cap was exceeded, the digest shape is wrong, or `backfilled` was used off the reconstruction path. get: tags: [trail] summary: Read a session's command trail description: | The incremental read (UI / audit / backfill verification). Viewer on the session's project, `tasks:read` scope. A pre-trail session reads as an EMPTY list, never an error — nothing already run may break. parameters: - name: after_seq in: query description: | Return entries whose seq is STRICTLY greater than this. The same cursor the SSE stream resumes on via `Last-Event-ID`, so a caught-up reader never re-receives its own last entry. schema: { type: integer, format: int32 } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 500, default: 500 } responses: "200": description: the trail (and its seal, if sealed) content: application/json: schema: { $ref: "#/components/schemas/TrailReadResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /v1/sessions/{sessionId}/trail/{seq}/redact: parameters: - name: sessionId in: path required: true schema: { type: string, format: uuid } - name: seq in: path required: true description: the trail entry's seq (gapless per session) schema: { type: integer, format: int32 } post: tags: [trail] summary: Redact one trail entry's content (the Rule-2 hatch, T-212) description: | The supported, audited way a leaked secret leaves a stored trail (BRIEF-pii-redaction Rule 2, ADR-0062, T-212). - **Gate**: an org-WIDE `admin` grant on the session's org — the erasure bar. Not the session's own subject (a worker cannot edit its own record), and a project grant never reaches this org-level act. - **Act**: the named content columns are overwritten with the fixed tombstone `[redacted]` (distinct from the harness's capture-time `[redacted:pattern]` / `[redacted:env]` markers), so the entry SAYS it was redacted instead of disappearing. seq, time, source, outcome and the rest of the entry's shape are untouched; a NULL column is left NULL. Open or closed session alike. - **Record**: one chained `content.redacted` event carrying references only — `{resource: "command_entry", session, seq, fields, sealed}`, never the old content — written in the same transaction as the overwrite. - **Never re-seals.** A sealed trail's stored digest is left as the harness sealed it and will no longer recompute; the trail read says why (`redactions`, `digest_note`). - **Idempotent and irreversible**: re-redacting tombed (or NULL) columns is a no-op that writes no event (`redacted: []`); there is no un-redact. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TrailRedactRequest" } responses: "200": description: redacted (or an idempotent replay with `redacted` empty) content: application/json: schema: { $ref: "#/components/schemas/TrailRedactResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": description: no such session, or the session has no trail entry at this seq content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } "422": description: | `fields` is empty or names a non-content column (only `name`, `argv`, `detail`, `ref` are redactable), or `confirm` is not `true` — the act is irreversible and must be acknowledged. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } /v1/sessions/{sessionId}/trail/stream: parameters: - name: sessionId in: path required: true schema: { type: string, format: uuid } get: tags: [trail] summary: Live command trail (SSE) description: | ADR-0062 AMENDS ADR-0006 (the owner declared the SSE trigger fired, 2026-09-02). Replays from `after_seq`, then streams entries as they land. Event types: `entry` (a TrailEntry, `id` = its seq) and `sealed` (the seal). `Last-Event-ID` ↔ `after_seq`, because seq is gapless per session — a reconnecting client resumes exactly. Cursor precedence: an explicit `after_seq` query parameter WINS over the `Last-Event-ID` header (the query param is the caller saying where to start; the header is the transport remembering where it stopped). A malformed or out-of-range `Last-Event-ID` is IGNORED and the stream replays in full — never a 4xx, because the caller is a browser's automatic reconnect and a refusal there is a loop it cannot escape. Content IS included: the subscriber explicitly followed this session, and SSE is transport, not storage. A lagged subscriber self-heals by refetching from its cursor rather than receiving a gap. parameters: - name: after_seq in: query schema: { type: integer, format: int32 } - name: Last-Event-ID in: header required: false description: sent automatically by EventSource on reconnect schema: { type: integer, format: int32 } responses: "200": description: an event stream content: text/event-stream: schema: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /v1/orgs/{org}/projects/{proj}/events/stream: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" get: tags: [events] summary: Live project events (SSE), plus ephemeral trail notices description: | The chained events tail (`board` events, the same shape as `GET …/events`) PLUS EPHEMERAL `trail.appended` notices carrying `{session, task_key, from_seq, to_seq}` — references only, NEVER persisted and never part of the hash chain. A client that wants the content subscribes to that session's trail stream. `task_key` is present so a task-live view is a PURE SUBSCRIPTION: connect once, filter on the key, render. Without it a client must keep its own session→task map and refetch `…/tasks/{key}/runs` whenever a notice names a session it does not recognise — which is every unrelated run in the project. It is `null` for a session with no lease on a task (a committee `ideas` session). The association is resolved through whichever lease opened the session (a work claim, or a review), index-served by migration 0034. parameters: - name: after in: query description: | Resume from this event id. Omitted (and with no `Last-Event-ID`) means "from now" — the stream starts at the current head rather than replaying all history. schema: { type: integer, format: int64 } - name: Last-Event-ID in: header required: false description: | Sent automatically by EventSource on reconnect; `after` wins when both are present. Resume matters MORE here than on the trail stream: because the default is "from now", a subscriber that could not resume would silently LOSE the events that landed while it was disconnected — a gap, not a duplicate. schema: { type: integer, format: int64 } responses: "200": description: an event stream content: text/event-stream: schema: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /v1/orgs/{org}/projects/{proj}/tasks/{key}/runs: parameters: - $ref: "#/components/parameters/org" - $ref: "#/components/parameters/proj" - $ref: "#/components/parameters/key" get: tags: [trail] summary: The task's runs (claims ⋈ sessions ⋈ trail) description: | The fused work-sessions read (ADR-0009). The run unit already existed — it is the claim; this composes it with its session, entry count and seal status. No new schema. `seal_status` is one of `sealed` | `unsealed` (a run that ended without a close) | `reconstructed` (backfilled from opencode's own storage) | `none` (the pre-trail era). A reconstruction is never rendered as equal to a live trail. responses: "200": description: the task's runs, oldest first content: application/json: schema: { $ref: "#/components/schemas/TaskRunsResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT description: | JWT issued by the realm's OIDC provider (Keycloak). Agents use the client credentials grant; humans the authorization code flow. Scopes: tasks:read, tasks:claim, tasks:file, tasks:complete, tasks:review, tasks:attest, board:admin. parameters: org: name: org in: path required: true schema: { type: string, pattern: "^[a-z0-9][a-z0-9-]{1,62}$" } proj: name: proj in: path required: true schema: { type: string, pattern: "^[a-z0-9][a-z0-9-]{1,62}$" } key: name: key in: path required: true description: per-project human key, e.g. T-42 schema: { type: string, pattern: "^T-[0-9]+$" } claimId: name: claimId in: path required: true schema: { type: string, format: uuid } decisionId: name: id in: path required: true schema: { type: string, format: uuid } tenetId: name: id in: path required: true schema: { type: string, format: uuid } adrId: name: id in: path required: true schema: { type: string, format: uuid } promptId: name: id in: path required: true schema: { type: string, format: uuid } goalId: name: id in: path required: true schema: { type: string, format: uuid } questionId: name: id in: path required: true schema: { type: string, format: uuid } reviewId: name: reviewId in: path required: true schema: { type: string, format: uuid } responses: Unauthorized: description: missing/invalid token content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } Forbidden: description: authenticated but not allowed (scope or grant missing) content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } NotFound: description: no such resource (or not visible to the caller) content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } Conflict: description: lost a race or violated a uniqueness rule; body names the winner content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } Invalid: description: validation failure; typed limits, never silent truncation content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } schemas: Problem: type: object description: RFC 9457 problem document plus a machine-actionable recovery hint. properties: type: { type: string } title: { type: string } status: { type: integer } detail: { type: string } recovery: type: string description: | What the client should do next, machine-readable. Examples: "retry-after: 1s", "ask-user", "re-read: /v1/...", "re-claim". holder: type: string description: on claim conflicts, the current holder's subject blocked_by: type: array items: { type: string, format: uuid } description: | on decision-blocking claim refusals (409), the open decision ids that must be answered before this task is claimable (ADR-0012) ownership_remedy: $ref: '#/components/schemas/OwnershipRemedy' withheld: type: array description: | On `claims:next`'s 404 (T-367): the rows the funnel deliberately did NOT offer because a flag reserves them for a human (T-361). A FACT about each row, never a verdict about the caller. Present only when non-empty; absent on a genuinely empty board. items: type: object properties: key: { type: string, description: the withheld task's key } flag: type: string enum: [human_only] description: the flag that withheld it — the field's own name, as the filing body and the direct-claim 409 spell it required: [key, flag] required: [title, status] OwnershipRemedy: type: object description: | On ADR-0011 ownership-overlap refusals (409, T-268): the COMPUTED minimal ordering act, beside the human sentence in `recovery`. The board holds the overlap set and walks the dependency DAG to decide the refusal, so it already knows which of the overlapping rows must actually be NAMED. Returning only a category made every caller page the whole board and rebuild the graph client-side to find out — and the prose implied one act per overlapping row, when measured on the live board it was one act for six and one for four. Both directions are offered because both are deliverable; which one fits is the caller's intent, not the board's judgment. properties: overlap: type: array items: { type: string } description: every overlapping open task key with no ordering path, sorted front: { $ref: '#/components/schemas/RemedyDirection' } behind: { $ref: '#/components/schemas/RemedyDirection' } required: [overlap, front, behind] RemedyDirection: type: object description: | One direction out of the overlap. `keys` is MINIMAL: for an overlap set forming a chain of N it holds exactly one key (the root for `front`, the leaf for `behind` — naming either sequences the rest transitively); for k mutually-unreachable heads it holds exactly k. properties: direction: type: string enum: [front, behind] description: | `front` — this work goes first; the named task(s) gain the dependency on it. `behind` — this work follows them. keys: type: array items: { type: string } description: the minimal keys to name, sorted clears: type: integer description: | how many overlapping rows naming `keys` clears — always the whole set. Stated so the refusal cannot overstate its own cost. independent: type: boolean description: | true when `keys` holds more than one member: the overlap set has genuinely independent heads and no single edge covers them, so all of them must be named. acts: type: array items: { $ref: '#/components/schemas/RemedyAct' } description: the acts to run; the array length is this direction's cost required: [direction, keys, clears, independent, acts] RemedyAct: type: object description: | One executable act, stated twice because this refusal has two kinds of caller and neither should have to translate for the other. `field` + `values` are the REQUEST BODY form and are authoritative: the board is an API, and a client that never runs the CLI must still be able to act on the remedy. `args` + `command` are the CLI form, for a human at a terminal. `command` appears only where the board knows the whole line (the PATCH seam, where verb and target are both determined); on the POST seam the rest of the filing belongs to the caller and the board does not invent it. properties: verb: type: string description: the CLI verb this act runs on — `file` (POST) or `task-update` (PATCH) target: type: string description: the task the act is addressed to, when the verb takes one field: type: string enum: [deps, precedes, deps_add] description: the request-body field to put `values` in values: type: array items: { type: string } description: | what goes in `field`. Usually the direction's keys — but on the PATCH front direction it is the amended task's OWN key, because that act is a patch on somebody else's row. args: type: array items: { type: string } description: 'the literal argument list, already split: ["--precedes", "T-155"]' command: type: string description: the whole line, when the board knows all of it required: [verb, field, values, args] Discovery: type: object properties: service: { type: string } api_version: { type: string } oidc: type: object properties: issuer: { type: string } audience: { type: string } grant: { type: string } token_endpoint_hint: { type: string } required: [issuer, audience, grant] endpoints: type: object additionalProperties: { type: string } agent_guide: { type: string } required: [service, api_version, oidc, endpoints] Principal: type: object properties: subject: type: string description: | The caller's identity in the board's SPIFFE-shaped grammar (`spiffe:///`, ADR-0031). Legacy Keycloak client subs are normalized into the `local` trust-domain. client_id: { type: string, nullable: true } display: { type: string } scopes: type: array items: { type: string } grants: type: array items: { $ref: "#/components/schemas/Grant" } identity_rung: type: integer description: | The identity-assurance rung this identity was minted under (ADR-0031): 0 = self-declared (today's static client; attributable, NOT enforced), 1 = fleet-minted, 2 = platform-attested. v1 always yields 0. required: [subject, scopes, grants] RunIdentityRequest: type: object description: | F-44: what a rung-0 principal asks the mint for. The `sub` is NEVER requested — the board generates it. properties: scopes: type: array items: { type: string } description: | The requested scopes, clamped server-side to the agent allowlist (`tasks:read`, `tasks:claim`, `tasks:file`, `tasks:complete`, `tasks:review`). `board:admin` and unknown scopes are silently dropped from the minted token. default: [] ttl_secs: type: integer format: int64 nullable: true description: | Requested lifetime, clamped to 60..=14400 (≤ 4h). Omit for the default (14400). additionalProperties: false RunIdentityResponse: type: object properties: token: type: string description: | The RS256 JWT, `iss = urn:aurora-board:run-identity`, `attestation: "fleet"` (rung 1). Handed to the spawned harness as its single-use credential. sub: type: string description: | The board-generated identity, `spiffe://platform/fleet/run-`. The bridge delegates this sub a per-run grant, then hands the token to the spawn. expires_at: type: string format: date-time required: [token, sub, expires_at] OrgGrant: type: object description: | T-548: one org-WIDE standing role grant (`project: null`, never a support grant) as the org-admin's grants surface presents it. properties: org: { type: string } subject: { type: string, description: the normalized (pseudonymized) subject } role: type: string enum: [viewer, auditor, worker, curator, maintainer, project-admin, admin] description: the capability, presented (`worker`, never the stored `agent`) granted_at: { type: string, format: date-time, description: when the current role row was written } granted_by: type: string nullable: true description: who wrote it, read from the audit trail; null when the trail does not say required: [org, subject, role, granted_at, granted_by] Grant: type: object description: | ADR-0046: a grant is capability × scope. The `role` is the CAPABILITY (viewer | auditor | worker | curator | maintainer | project-admin | admin); the `realm` is the SCOPE. A project-scope grant (`realm: null`) subsumes every realm of the project under the open-by-default posture — a worker is project-level and may claim any task. A realm-scope grant (`realm` set) is meaningful ONLY on a guarded realm, where it is the one way a worker is admitted to write there (subsumption is off for the guarded outlier). The project-level capabilities curator/maintainer/ admin are schema-forbidden from taking a realm scope (a category error — the dispose gate is project-level, never per-place). properties: org: { type: string } project: { type: string, nullable: true } role: type: string enum: [viewer, auditor, worker, curator, maintainer, project-admin, admin, agent] description: | the CAPABILITY. The ladder is cumulative: `viewer < auditor < worker < curator < maintainer < project-admin < admin`. ADR-0049: `auditor` (read-all + file-proposals + file-observations, never work, never dispose) sits between `viewer` and `worker` — a consumer of the light, never a judge. Project-scope only. **`worker` vs `agent`.** The work rung is PRESENTED as `worker` and STORED as `agent` — "agent" is a principal-kind, not a capability, so it never named what the rung grants (decision `01a04011-b5c1-7c13-9998-0fa224633bc4`). This is a read-layer rename by design (migration 0021 §3): the `grants.role` column and its CHECK constraint keep `agent`, so nothing is migrated and no caller breaks. Consequences for a client: - **On input, both are accepted, everywhere and permanently.** Send `worker`; `agent` will not be refused. - **On output, the board emits `worker`, everywhere** — including the grant-admin edges (`POST`/`GET /orgs/{org}/projects/{proj}/grants`), which present the role rather than echoing the stored string. realm: type: string nullable: true description: | the SCOPE. `null` = project-wide (subsumes all open realms). Set = a realm-scoped work-grant, meaningful only on a guarded realm. required: [org, role] SupportGrant: type: object description: | A time-boxed, reason-tagged, audited support grant (ADR-0031). It confers access only while unexpired AND approved (in `auto` orgs it is approved at mint; in `approval` orgs `approved_by` is NULL until a customer org-admin approves). properties: id: { type: string, format: uuid } org: { type: string } project: { type: string, nullable: true } subject: { type: string, description: The operator's subject (who holds the support access). } role: { type: string } reason: { type: string, description: The mandatory ticket/justification. } expires_at: { type: string, format: date-time } approved_by: { type: string, nullable: true } approved_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } required: [id, org, subject, role, reason, expires_at, created_at] Invitation: type: object description: | An invitation (ADR-0036) — the unactivated intent to grant. Never carries the token or its hash. `email` is a delivery label, never an identity. properties: id: { type: string, format: uuid } org: { type: string } project: { type: string, nullable: true } role: { type: string } email: { type: string } status: { type: string, enum: [pending, redeemed, revoked, expired] } invited_by: { type: string } expires_at: { type: string, format: date-time } redeemed_by: { type: string, nullable: true } redeemed_at: { type: string, format: date-time, nullable: true } revoked_by: { type: string, nullable: true } revoked_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } required: [id, org, role, email, status, invited_by, expires_at, created_at] WitnessKey: type: object properties: id: { type: string, format: uuid } label: { type: string } public_key: { type: string, description: base64 Ed25519 public key } hardened: type: boolean description: | L3 assertion (operator-enrolled): the witness key lives in a separate trust domain the agent cannot read. The board trusts the enrollment act, not the machine. enrolled_by: { type: string } created_at: { type: string, format: date-time } Org: type: object properties: slug: { type: string } name: { type: string } created_at: { type: string, format: date-time } required: [slug, name] OrgCreate: type: object properties: slug: { type: string, pattern: "^[a-z0-9][a-z0-9-]{1,62}$" } name: { type: string, maxLength: 200 } required: [slug, name] OrgCreateRateLimited: description: | The 429 body of `POST /v1/orgs` when ADR-0069's rail holds: a problem document plus the machine-readable instant the next create is allowed. allOf: - $ref: "#/components/schemas/Problem" - type: object properties: next_allowed_at: type: string format: date-time description: the earliest in-window org's `created_at` + 24 h — retry at or after it required: [next_allowed_at] OrgCreated: description: | The `org.created` event (ADR-0069), written by `POST /v1/orgs` on the org-wide (project-less) chain. `actor` is the creator's canonical sub and the payload repeats it, so a reader needs no column knowledge. The sub is the opaque pseudonym (ADR-0031), never an email. allOf: - $ref: "#/components/schemas/Event" - type: object properties: kind: { type: string, enum: [org.created] } payload: type: object properties: org: { type: string, description: the org slug } created_by: { type: string, description: the creator's canonical sub (equals `actor`) } required: [org, created_by] Project: type: object properties: slug: { type: string } org: { type: string } name: { type: string } review_policy: type: string enum: [none, required] description: | ADR-0027. `none` (default): a worker's completion is conclusive → done. `required`: every completion routes through an independent review — the worker's completion is only a self-report (`completed`) until a DISTINCT reviewer accepts it (`done`). workspace_policy: type: string enum: [none, declared, witnessed, hardened] description: | ADR-0029/0030 — the four-level workspace-attestation ladder. `none` (L0, default): a claim may declare no workspace, and any declared workspace_ref still gets uniqueness enforced. `declared` (L1): a claim without a workspace_ref is refused. `witnessed` (L2): declared + a witness-signed attestation is required at completion (verified against an enrolled witness key). `hardened` (L3): witnessed, with the witness key enrolled hardened. Each rung requires the evidence of the rung below plus its own. goal_policy: type: string enum: [none, encouraged, required] description: | ADR-0042 — how hard the project nudges work toward intent. `encouraged` (default): goal-less tasks rank below goal-linked at equal topology and the docket reports the unlinked share (friction, never refusal). `required`: a goal-less task filing is refused 409. `none`: the nudge is off. dod_policy: type: string enum: [none, encouraged, enforced] description: | ADR-0043 — how hard the project nudges work toward DECLARED definition-of-done criteria. `none` (default): off. `encouraged`: a review-required task with no dod_items surfaces a hint in the claim bundle (never a refusal). `enforced`: ACCEPT is refused without one verdict per declared item (coverage checked, correctness never). `enforced` requires review_policy = `required` — the board refuses to store a policy that isn't true (422 naming why), because there is no accept step to enforce at in a review-none project. created_at: { type: string, format: date-time } required: [slug, org, name] ProjectCreate: type: object properties: slug: { type: string, pattern: "^[a-z0-9][a-z0-9-]{1,62}$" } name: { type: string, maxLength: 200 } review_policy: type: string enum: [none, required] default: none workspace_policy: type: string enum: [none, declared, witnessed, hardened] default: none goal_policy: type: string enum: [none, encouraged, required] default: encouraged dod_policy: type: string enum: [none, encouraged, enforced] description: | ADR-0043. Default: `encouraged` when review_policy = `required`, else `none`. `enforced` requires review_policy = `required` (422 otherwise — enforced coverage is meaningless with no review step). required: [slug, name] ProjectPatch: type: object description: | The Project Settings amendment (`PATCH /v1/orgs/{org}/projects/{proj}`). Every field is optional; an omitted field keeps its stored value. The RESOLVED combination must be true: `dod_policy = enforced` requires `review_policy = required` (422 otherwise). properties: review_policy: { type: string, enum: [none, required], description: ADR-0027 } workspace_policy: { type: string, enum: [none, declared, witnessed, hardened], description: ADR-0029/0030 } goal_policy: { type: string, enum: [none, encouraged, required], description: ADR-0042 } dod_policy: { type: string, enum: [none, encouraged, enforced], description: ADR-0043 (enforced requires review_policy = required) } TaskSummary: type: object properties: key: { type: string } title: { type: string } status: type: string description: | derived (ADR-0010); `available` = open ∧ unclaimed ∧ deps done ∧ no open blocking decision; `blocked` = open ∧ blocked on a decision (ADR-0012); `open` = open ∧ deps unmet; `completed` = the worker's self-report in a review-required project, NOT yet accepted (ADR-0027) — dependents do NOT unblock on it enum: [draft, open, claimed, available, blocked, completed, done, cancelled] deps: type: array items: { type: string } ownership: type: array items: { type: string } pinned: { type: boolean } human_only: type: boolean description: | T-361: a human's row (the OPS/MANUAL-act convention, guide §3.2b) — claims:next never offers it, an agent's direct claim is refused 409 naming the flag, view=next bands its key. Read back on the row every list, detail and claim bundle carries; omitted when false. completed_at: type: string format: date-time description: | T-501: when the row's CURRENT completion was filed (the latest completion; a re-completion restamps it). Served on list and detail reads only while the stored status is `completed` or `done`, so a row reopened by a reject never carries its rejected cycle's stamp; omitted otherwise, and on the ranked frontier (open rows only) and the completion response itself. Lets a client age a review-queue card without having seen the `task.completed` event. claim: allOf: [{ $ref: "#/components/schemas/ActiveClaimRef" }] description: | T-625: the row's LIVE claim (active, lease unexpired), served on the flat list read (`GET …/tasks` without `view=next`, e.g. `?status=claimed`) so a client renders holder, session and lease without one detail read per claimed row. Omitted when the row is unclaimed, and on the ranked frontier (unclaimed rows only). The detail read (TaskDetail) serves the FULL `Claim` under this same key — a superset of this shape — so one reader handles both. rank: type: integer nullable: true description: position in the canonical derivation (view=next) version: { type: integer } tenet_ids: type: array items: { type: string, format: uuid } description: | ADR-0039 (citation): the tenets this task grounds itself in (provenance — filed directly, or inherited from its proposal at accept). Self-reported, never enforced; omitted when empty. The forward half of the rectify story — browse task → tenet. filed_by_claim_id: type: string format: uuid description: | ADR-0040 (provenance): the task-RUN that spawned this task — the claim being completed when it was filed as a follow-up (POST /claims/{id}/complete { followups }). Self-reported, never enforced; omitted when filed outside a run (direct POST /tasks, or a human accepting a proposal). filed_by_task_key: type: string description: | ADR-0040: the spawning run's task KEY, resolved by the board so a client links straight to the parent task without a second query (ADR-0028). Omitted when filed outside a run. goal_ids: type: array items: { type: string, format: uuid } description: | ADR-0042: the goals this task serves (MANY-TO-MANY via task_goals). Self-reported at filing (or inherited from the parent on a follow-up), never enforced; omitted when empty — a rank fact (goal-less sorts below goal-linked), never a refusal, unless the project's goal_policy is `required`. realm: type: string nullable: true description: | ADR-0046/0048: the realm this task is filed into — "this is `aurora-ui` work". CONTEXT + ATTRIBUTION, never a cage: any project worker may claim it (project-wide subsumption) unless the realm is guarded. `null` = project-wide (filed to no realm). Rides every read (board view, detail, claim bundle) so the worker sees the place the work belongs without a second query. required: [key, title, status, deps, ownership, version] TaskRead: description: | `GET …/tasks/{key}`'s body (T-195): the shared `TaskDetail`, flattened, plus the observations linked to this row via `observation_links`. A WRAPPER rather than a `TaskDetail` field on purpose — `TaskDetail` also rides the claim bundle, the context peek, PATCH and filing responses. allOf: - $ref: "#/components/schemas/TaskDetail" - type: object properties: observations: type: array maxItems: 10 description: newest first, at most 10 (`TASK_READ_OBSERVATIONS_CAP`); omitted when nothing cites the row items: { $ref: "#/components/schemas/CitingObservation" } observations_more: type: integer minimum: 1 description: how many MORE citing observations exist beyond `observations` (the cap's remainder, a count — never silently dropped; list them via the observations endpoint's task filter). Omitted at 0 CitingObservation: type: object description: one observation citing a task (T-195) — the edge only; the observation's own read carries `context_md`/`numbers` properties: id: { type: string, format: uuid } kind: { type: string } statement: { type: string } created_at: { type: string, format: date-time } actor: { type: string } required: [id, kind, statement, created_at, actor] TaskDetail: allOf: - $ref: "#/components/schemas/TaskSummary" - type: object properties: brief_md: { type: string } claim: nullable: true allOf: [{ $ref: "#/components/schemas/Claim" }] warnings: type: array description: | residual ownership-overlap warnings (legacy data or a relaxed per-project policy); the default policy refuses overlaps at filing/amendment time instead (ADR-0011) items: { type: string } decisions: type: array description: | open decisions blocking this task (ADR-0012); while any are open the task is excluded from `available` and refused at claim time items: { $ref: "#/components/schemas/DecisionRef" } bets: type: array description: | ADR-0047: the LIVE proposals this task bets on (the task_proposals soft-link; each was named on TaskCreate's `bet_on`). The flag IS the agent's choice (BRIEF-realms §5): see it → proceed with the fallback (autonomy) OR raise a decision (genuine concern) OR wait. On a bet-on proposal's accept the links resolve into a dep-edge; on reject they are cleared — either way the bet disappears from this field, so a settled proposal can never misdirect. Empty (and omitted) when the task bets on none. items: { $ref: "#/components/schemas/ProposalBet" } created_by: { type: string } created_at: { type: string, format: date-time } goals: type: array items: { $ref: "#/components/schemas/GoalRef" } description: | ADR-0042: the full goal set this task serves (statement/horizon/ signals per goal) so a detail read exposes intent without a second round-trip. Empty when intent-unlinked. dod_items: type: array items: { $ref: "#/components/schemas/DodItem" } description: | ADR-0043: the DECLARED definition-of-done the reviewer checks item-by-item at accept. Rides the detail read — and so the claim bundle — so the criteria are visible BEFORE the work is claimed (an agent reads what done means up front, not as a surprise reject). Empty (and omitted) when undeclared. deps_placed: type: array items: { type: string } description: | T-478: the subset of `deps` the BOARD placed on an ADR-0011 ownership overlap (ORDER) rather than a filer declared (NEED: `deps`, `precedes`, `deps_add`, a proposal's bet). Read-only. A placed edge onto a `draft` row does not block its dependent; a declared one does. A cancelled dependency never blocks, whatever its provenance. `deps_add` on a placed edge upgrades it to declared. Empty (and omitted) when every edge was declared. notes: type: array items: { type: string } description: | T-478: only on a PATCH that changed `status` — names the open dependents whose availability the act FREED (a cancel; a park to `draft` frees rows only its placed edges held) or FROZE (a re-open). Mirrored by a `task.unblocked` / `task.parked_dependents` event carrying `count` and `dependents`. Omitted on plain reads and when nothing moved. (The 201 of `POST …/tasks` reuses this key for BIRTH notes — see `TaskFiled` / `BirthNotes`.) TaskPage: type: object properties: items: type: array items: { $ref: "#/components/schemas/TaskSummary" } next_cursor: { type: string, nullable: true } human_only: type: array items: { type: string } description: | T-361: the human-only band, on `view=next` — the keys of every live `human_only` row in the project, the operator's own queue spelled out so one read answers "what is MY work next". These rows are also still ranked inside `items` (the ADR-0010 ranking is untouched; the flag is WHO takes a row, never where it sorts) — a worker who reaches for one is refused 409 at claim time. An agent reading a band row should hand it to a human, not claim it. Omitted when the project holds no human-only rows, and on every view but `next`. required: [items] TaskContextTaskRef: type: object description: | One neighbor in a task's one-level blast radius (ADR-0057, S5): key/title/status/realm, nothing more — the peek orients, it does not floodlight the whole graph. properties: key: { type: string } title: { type: string } status: type: string description: the presented status (derived, ADR-0010) realm: type: string nullable: true description: | T-571: the neighbour's realm slug (ADR-0046), so a cross-realm dependency edge is visible in the peek without one task read per neighbour. Always present: an explicit `null` means project-wide, distinct from an older board that omits the key entirely. required: [key, title, status, realm] MoveInFront: type: object description: | Body of `POST …/tasks/{key}/precedes` (T-179). properties: targets: type: array minItems: 1 maxItems: 50 items: { type: string } description: | task keys `{key}` must come BEFORE. Each gains a dependency on `{key}`; `{key}`'s own edge to each is dropped if it had one. Every key must name an unfinished task in this project other than `{key}`; duplicates are collapsed. required: [targets] TaskContext: type: object description: | A task's pre-claim context (ADR-0057, S5) — the fused, read-only peek a claiming agent takes BEFORE burning a lease: the task itself, the goal(s) it serves, the tenets it cites, and one level of dependents in both directions. DESCRIPTIVE, never prescriptive — no rank, no recommendation; the board records and derives, the agent decides. properties: task: $ref: "#/components/schemas/TaskDetail" description: the task, exactly as GET …/tasks/{key} serves it goals: type: array items: { $ref: "#/components/schemas/GoalRef" } description: the goal(s) this task serves (statement/horizon/success_signals) tenets: type: array items: { $ref: "#/components/schemas/Tenet" } description: | the tenets this task cites (ADR-0039 provenance), any status, verbatim — the board never enforces a citation dependents: type: array items: { $ref: "#/components/schemas/TaskContextTaskRef" } description: tasks that depend ON this task (what completing it unblocks) dependencies: type: array items: { $ref: "#/components/schemas/TaskContextTaskRef" } description: tasks this task depends on (what must land before it is claimable) realm_context: $ref: "#/components/schemas/RealmContextRef" description: | T-31: the task's realm context when the work is realm-scoped — that ONE realm's recorded fact, delivered with the peek so the context arrives at the moment it matters. Absent for project-wide work (there is no realm to describe). required: [task, goals, tenets, dependents, dependencies] RealmContextRef: type: object description: | T-31: one realm's context, DELIVERED with the work (ADR-0044's delivery principle applied to realms — context_md is "the first thing an agent reads when it encounters a task in an unfamiliar realm", yet the claim bundle used to carry only the slug). Exactly ONE realm's record — the task's own — so the bundle's size stays bounded; never a fan-out over the project's realm map. The FACT only: what the realm's row records, verbatim. There is deliberately no derived signal about the worker here (no "you usually work X" — agents are ephemeral; see routes/claims.rs). properties: slug: type: string description: the realm's slug — the identifier `task.realm` already carries name: { type: string } kind: { type: string } context_md: type: string description: WHAT this realm is and WHY it is separate, verbatim as recorded created_by: type: string nullable: true description: | T-381 (E-21 — the author of board text is shown at the moment of reading): the subject that minted the realm, verbatim from the row. Null when the row records no author — never invented. Absent from boards predating T-381. context_amended_by: type: string nullable: true description: | T-381: the subject whose amend (`PATCH …/realms/{slug}`) last moved `context_md`, read from the chain's newest `realm.amended` event naming it. Null = the prose is still the creator's text. context_amended_at: type: string format: date-time nullable: true description: when that amend happened (null with it) archived_at: type: string format: date-time nullable: true description: | T-381: set when the realm is archived. T-31 delivers archived realms by design; this says the place is retired. required: [slug, name, kind, context_md] Realm: type: object description: | ADR-0046/0048: a PLACE the endeavor is pursued (a repo, the kitchen). A realm is CONTEXT + ATTRIBUTION, never a cage — `context_md` says what it is and why it's separate; `remote_ref` is the opaque pointer to where the work lives (e.g. a git remote). The board records and attributes; it never confines. A realm is OPEN by default: any project worker claims work filed there (project-wide subsumption). The guarded outlier (`guarded: true`) is the one place the write-seam activates — only for that realm, only work, and only with an EXPLICIT realm grant (project-wide subsumption is OFF there, else the guard is theater). Realms are archived, never deleted — the record is the thing. properties: id: { type: string, format: uuid } project_id: { type: string, format: uuid } slug: type: string description: short handle scoped to the project (`aurora-ui`, `backend`) name: { type: string } kind: type: string description: what sort of place — `repo` (default), `kitchen`, … remote_ref: type: string description: opaque pointer to where the work lives (a git remote, …) context_md: type: string description: | WHAT this realm is and WHY it is separate (prose). Optional but encouraged — a realm without context is a label, not a place. T-31: delivered WITH the work for a realm-scoped task (the claim bundle's `realm_context` and `GET /tasks/{key}/context`) — never only an optional fetch an agent must know to make. guarded: type: boolean description: | the guarded outlier: when true, the write-seam activates for THIS realm only and requires an explicit realm grant (subsumption off). Default false (open). archived_at: { type: string, format: date-time, nullable: true } created_by: type: string description: | the OIDC subject that minted the realm (attribution) — a realm is maintainer-minted, so this names WHOSE judgment made the place. Rendered adjacent to `context_md` (E-21: the author of board text is shown at the moment of reading). created_at: { type: string, format: date-time } required: [id, project_id, slug, name, kind, remote_ref, context_md, guarded] RealmCreate: type: object description: | ADR-0046: create a realm — a maintainer-gated dispose (the realm lifecycle is a decision, so it goes through the dispose gate; an agent never self-assigns a place to work). An agent proposes a realm via a steering proposal (`create_realm`); a maintainer mints it here. properties: slug: { type: string, maxLength: 200 } name: { type: string, maxLength: 300 } kind: { type: string, default: repo } remote_ref: type: string maxLength: 2000 description: opaque pointer to where the work lives (optional) context_md: type: string maxLength: 16384 description: what this realm is and why it's separate (optional) from_proposal_id: type: string format: uuid description: | the steering proposal this realm was born from (attribution). Optional — a maintainer may seed a realm directly; the proposal link is the record when it came through the steering lifecycle. required: [slug, name] RealmAmend: type: object description: | T-4: amend a realm (`PATCH /realms/{slug}`) — maintainer-gated, the same dispose gate as create and archive. Every field is optional and omitted means unchanged, but at least ONE must be named (an empty body is refused `422`). JSON `null` reads as omitted. `slug` is the realm's identity and is not amendable; `name` and `kind` are set at creation. properties: remote_ref: type: string maxLength: 2000 description: | correct the opaque pointer to where the realm's state lives — still never resolved or validated by the board. `""` clears it. context_md: type: string maxLength: 16384 description: correct or extend WHAT this realm is and WHY it is separate. `""` clears it. guarded: type: boolean description: | flip the ADR-0048 isolation outlier. `true` activates the realm write-seam for this realm only (subsumption OFF — only an explicit realm grant admits a worker); `false` returns it to the open default. minProperties: 1 RealmAmended: description: | T-4: the amend response — the realm as it now reads (every `Realm` field) plus what actually changed and what that means. `notes` is the board REPORTING a consequence the row alone does not show, never the board acting on it: nothing here is enforcement, the maintainer disposes. allOf: - $ref: "#/components/schemas/Realm" - type: object properties: changed: type: array items: { type: string, enum: [remote_ref, context_md, guarded] } description: | the fields whose stored value actually MOVED. A field set to the value it already held is not a change: an idempotent retry answers `200` with `changed: []` (and writes no event). previous: type: object additionalProperties: true description: | the values the `changed` fields held BEFORE this amend, keyed exactly by `changed` (`{}` when nothing moved). An amend OVERWRITES and the event chain carries only a length+digest of prose; this is the caller's immediate copy. Prose values are ALSO persisted as revision rows (T-158) and stay readable via `GET /realms/{slug}/revisions`; `guarded`'s prior value lives verbatim in the `realm.amended` event. realm_grants: type: integer format: int64 minimum: 0 description: | realm-scoped role grants standing on this realm NOW (read in the amend's own transaction). On a guarded realm this is the admitted population; on an open realm it counts rows that confer nothing. notes: type: array items: { type: string } description: | consequences reported, never acted on: nothing moved (an idempotent retry); the realm is GUARDED and no principal holds a realm grant on it (the guard admits nobody — only an org-wide holder can claim its tasks); the realm is OPEN and N realm grants still name it (an un-guard left inert rows, not admissions); the realm is archived and this amend still governs every task that names it. required: [changed, previous, realm_grants, notes] RealmGrantCreate: type: object description: | ADR-0046 (superseding 0046-original): mint a realm-scoped grant — the ONE way a worker is admitted to a GUARDED realm. Project-admin gated (a project manages its own guarded realms); an agent never self-assigns a guarded realm. Only meaningful on a GUARDED realm: an OPEN realm is reached by any project member (subsumption), so a realm-grant there records a FALSE implication that it does something — refused 422 (a policy must be true), naming why. properties: subject: type: string description: | the enrollee (email/username/sub), pseudonymized exactly as the extractor pseudonymizes a caller, so the stored grant byte-matches role: type: string default: worker description: | the capability within this realm — `worker` (the only work- capability a guarded realm gates); read roles are already project-wide under read-broad required: [subject] RealmGrantRow: type: object description: T-377 — one holder of a realm-scoped grant, as the listing reads it back. properties: subject: type: string description: | the normalized (pseudonymized) subject — the exact string `grants:revoke` must name to withdraw this row role: type: string description: the capability in the read-layer word (`worker`) created_at: type: string format: date-time description: when the current row was written (a re-grant rewrites it) required: [subject, role, created_at] RealmGrantRevoke: type: object description: | T-37: withdraw a realm-scoped grant — project-admin gated, the same seat that mints it. Idempotent (`removed: false` on an absent grant). properties: subject: type: string minLength: 1 maxLength: 300 description: | the enrollee whose realm grant is withdrawn (email/username/sub), pseudonymized exactly as the mint stored it, so the lookup byte-matches the row the mint wrote required: [subject] RealmGrantRevoked: type: object description: T-37 — the outcome of a realm-grant revocation. properties: org: { type: string } project: { type: string } realm: { type: string, description: the realm's slug } subject: type: string description: | the normalized (pseudonymized) subject — the same spelling every other grants edge answers with removed: type: boolean description: | `false` when the realm stands but held no realm grant for the subject — an idempotent retry, never an error; the board never claims an act that didn't happen required: [org, project, realm, subject, removed] RealmRevision: type: object description: | T-158: one persisted before-image — the text a prose field held before the amend at `amended_at` overwrote it — plus its twin check against the event chain. properties: id: { type: string, format: uuid } field: { type: string, enum: [context_md, remote_ref] } prior_text: { type: string } amended_by: type: string description: the (pseudonymized) subject whose amend overwrote this text amended_at: { type: string, format: date-time } event_id: type: integer format: int64 description: the `realm.amended` event this row is the twin of chain_image: type: object nullable: true description: | the before-image the CHAIN recorded for this field, read from the event verbatim — `{len, sha256}` properties: len: { type: integer } sha256: { type: string } verified: type: boolean description: | true exactly when `prior_text` measures and hashes to `chain_image`; false is evidence of a tampered or redacted row required: [id, field, prior_text, amended_by, amended_at, event_id, verified] RealmRevisionPage: type: object properties: realm: { type: string, description: the realm slug } items: type: array items: { $ref: "#/components/schemas/RealmRevision" } next_cursor: { type: string, nullable: true } required: [realm, items] RealmPage: type: object properties: items: type: array items: { $ref: "#/components/schemas/Realm" } next_cursor: { type: string, nullable: true } required: [items] BirthNotes: type: array items: { type: string } description: | T-165: what the minted row was born LACKING and what mechanically follows from each gap, in prose naming the remedy. Omitted entirely when the row was born complete — silence is itself information. Advisory only: filing is never gated on it, and a caller must never gate on it either. Notes that can appear: - no live goal band — the row ranks NULLS LAST in `view=next` and the claims:next funnel and counts toward the docket's intent-dark share (remedy: `goal_ids_add`, or file with `goal_ids`); - realm inferred as `` from the declared ownership (remedy if wrong: PATCH `realm`); - realm-less (and possibly ownership-less) — collides with EVERY realm and gets no realm re-exec (remedy: PATCH `realm`); - no declared definition of done under `review_policy=required` (remedy: PATCH `dod_items` before the work is claimed); - T-432b: "placed behind T-a, … — its ownership overlaps those open row(s) and no ordering was declared, so the board ordered it after them (the ADR-0011 remedy, applied instead of refused)". T-569: a filing that declares `deps` is placed the same way against the overlaps those deps leave uncovered, and the note then reads "… and the declared deps (T-x, …) do not order it against them, …" — so no row is born with an unordered overlap, `deps` or not. The same gaps reach the record as the `task.filed` event's `born_without` / `realm_inferred` keys. A generated client that does not model this field silently drops the one channel that tells a filer their row is invisible to the funnel. TaskFiled: description: | The 201 body of `POST …/tasks` (T-165): the task exactly as a detail read returns it (`TaskDetail`, flattened — every key unchanged), plus the birth `notes`. A complete row's response is byte-identical to a detail read. Here `notes` carries the BIRTH notes, not T-478's PATCH-time dependents notes (a fresh row has none of those). allOf: - $ref: "#/components/schemas/TaskDetail" - type: object properties: notes: allOf: [{ $ref: "#/components/schemas/BirthNotes" }] description: | OPTIONAL, ABSENT ENTIRELY when the row was born complete (`skip_serializing_if = "Vec::is_empty"` — never `[]`). Each entry names a gap AND its consequence, e.g. a row filed with no live goal "sorts NULLS LAST — below every goal-bearing row at any topology" in `view=next` and claims:next, so no worker is offered it while a goal-bearing row is available (remedy: `goal_ids_add`); a realm-less row collides with every realm's ownership; a row whose ownership overlapped open rows was "placed behind" them. A client that drops this key files rows invisible to the funnel and is told nothing. TaskCreate: type: object properties: title: { type: string, maxLength: 300 } brief_md: type: string maxLength: 65536 description: markdown brief — evidence, done-looks-like, must-not-do deps: type: array items: { type: string } description: | task keys this task depends on (DAG, cycle-checked) — the BEHIND direction, "these go first". Mirrored by `precedes`. precedes: type: array maxItems: 50 items: { type: string } description: | T-136 (ADR-0011, fix-first): the mirror of `deps` — the IN FRONT direction, "I go first, these follow". Each named task gains a dependency on the new task in the SAME transaction, under the same cycle-checked pass, so a fix is born ahead of the open work that owns its file in one declaration rather than a sequence of amendments. Duplicates collapse. Counts as an ordering path for the ownership-overlap check (a `precedes` target and its descendants are sequenced after this task). Refused **422**, nothing written, in three cases: - a key that names no task in this project (`unknown precedes task(s): T-a, …`); - a key whose task has left the work set — `done`, `cancelled` or `completed` (`precedes must name unfinished task(s); T-a already left the work set (nothing can be ordered in front of work that is over)`); - a key already reachable UPWARD from this filing's `deps` — declared as a dep or an ancestor of one — which would close a loop (`precedes would create a cycle: this task already depends on T-a (directly or transitively) — it cannot also come before it`). More than 50 entries is also a 422. ownership: type: array items: { type: string, maxLength: 500 } description: >- resource paths this task will touch: literals, directory prefixes, or glob patterns (`*` within a segment, `**` across segments, `?`, `[class]`). Overlap with an open task that has no ordering path to this one is PLACED behind it by the board (T-432b, reported in the 201's `notes`); only a `precedes` that contradicts that placement is refused 409 (ADR-0011). Glob overlap is conservative. pinned: { type: boolean, default: false } tenet_ids: type: array items: { type: string, format: uuid } maxItems: 50 description: | ADR-0039 (citation): the tenets this work exists because of (provenance). Self-reported, never enforced — a tenet is a guiding light, not a leash. Each id must name a tenet in this project (any status), else 422 — a citation that points at nothing is not a citation. goal_ids: type: array items: { type: string, format: uuid } description: | ADR-0042: the goals this work serves (MANY-TO-MANY via task_goals). Each id must name an ACTIVE goal in this project (not stale, achieved, abandoned, or superseded), else 422 — you bind work to intent currently being pursued. Omitted on a follow-up means the task INHERITS its parent's goal set. Goal-less filing is allowed at `encouraged` (ranks lower) and refused 409 at `required` (the project's goal_policy). dod_items: type: array maxItems: 50 items: { $ref: "#/components/schemas/DodItem" } description: | ADR-0043: the DECLARED definition-of-done, checked item-by-item at accept. Optional at filing — dod_policy never refuses a criteria-less filing (structure is enforced at ACCEPTANCE, not filing; ADR-0027). Amendable before completion via TaskPatch under the patch authority; every amendment is recorded as an event with actor + before/after. realm: type: string nullable: true maxLength: 200 description: | ADR-0046/0048: file this task INTO a realm (context + attribution, never a cage). Must name an existing, non-archived realm in this project, else 422. Omitted = project-wide (no realm). Realm is the honest signal the board reads — "this is `aurora-ui` work" — it is never a confinement gate: any project worker claims it unless the realm is guarded. bet_on: type: array maxItems: 50 items: { type: string, format: uuid } description: | ADR-0047: a SOFT-LINK bet that this task settles one or more OPEN steering proposals (task_proposals). Each id must name an OPEN proposal in this project, else 422 — a bet on a settled proposal is meaningless. When a bet-on proposal is ACCEPTED, the board adds a dep-edge to the minted task and emits `task.proposal_bet_resolved`; when REJECTED it clears the links and emits `task.proposal_bet_refused` (with reason) — the task stays available, re-surfaced not dead. human_only: type: boolean default: false description: | T-361: file this task as a HUMAN's work — the OPS/MANUAL-act convention (T-65's, written down at last): the work names the operator as its only legitimate actor (a VPS login, a phone call, a signature). Consequences, all mechanical: `claims:next` never offers the row; a direct agent claim is refused 409 naming the flag and its remedy; the row is surfaced under the `human_only` band of `TaskPage` (view=next). A human claims it from the board in their own CLI session — the flag is about WHO, not WHETHER. Say it with the flag, never by title: an "OPS:" prefix is not detected, the filer declares. required: [title, brief_md] TaskPatch: type: object description: partial amendment; every accepted patch is an event row properties: title: { type: string, maxLength: 300 } brief_append_md: type: string maxLength: 65536 description: append-only brief amendment (dated, attributed) deps_add: type: array items: { type: string } deps_remove: type: array items: { type: string } ownership: type: array items: { type: string } status: type: string enum: [draft, open, cancelled] pinned: { type: boolean } goal_ids_add: type: array items: { type: string, format: uuid } description: | ADR-0042: link the task to these goals (M:N). Each must name an ACTIVE goal in this project, else 422. An agent may LINK a goal to its claimed task; it never disposes goals. goal_ids_remove: type: array items: { type: string, format: uuid } description: ADR-0042 — unlink these goals from the task dod_items: type: array maxItems: 50 items: { $ref: "#/components/schemas/DodItem" } description: | ADR-0043: REPLACE the declared definition-of-done (full-list replace; add/edit/remove ride the same field). Anyone who passed the patch authority (tasks:file rights on this project) may amend — the board is a bookkeeper, not a judge, so it RECORDS the act (a `task.dod_amended` event with actor + before/after) and lets the REVIEWER judge refinement vs bar-lowering. Frozen after completion. human_only: type: boolean description: | T-361: set (true) to hand an existing row to the human queue, or clear (false) — clearing is the claim-refusal's own remedy once an OPS act has become agent-doable. The authority is the patch authority itself: the row's creator at agent role, a foreign row at curator (the same split every TaskPatch field applies). A dedicated `task.human_only_amended` event records actor + before/after — flipping the flag re-points WHO the funnel offers the row to, the same audit shape as a realm move. realm: type: string nullable: true maxLength: 200 description: | ADR-0046 (T-169): re-realm an EXISTING row. Three-valued: absent leaves the realm alone; a slug moves the row into that realm (an existing, non-archived realm of this project, else 422); an explicit `null` moves it back to project-wide. Curator on a foreign row; the ADR-0011 ownership re-check refuses a move that creates a real collision. Absent and explicit `null` are DIFFERENT requests (a generated client that collapses them turns "leave alone" into "clear"): send `null` only to mean "make this project-wide". Refusals (`422`, verbatim, `x` being the slug sent): `unknown realm "x" in this project` and `realm "x" is archived — file into a live realm or project-wide`. The ADR-0011 overlap check re-runs against the realm the row WILL have (on an open row), and the directions are not symmetric: `null → X` only NARROWS (a project-wide row already collides with every realm) and cannot newly conflict, while `X → Y` and `X → null` can be refused `409` with the ownership-overlap message and its `ownership_remedy`. Deliberately OPPOSITE to `GoalPatch.realm`: a task's realm is amendable; a goal's realm is immutable content, refused by name. CLI mapping (T-485) — `aurora-agent task-update ` carries every field of this schema, one flag per field: `--title`, `--brief-append`, `--dep-add`/`--dep-remove`, `--ownership`, `--status`, `--pin`/`--unpin` (pinned), `--goal-add`/`--goal-remove` (goal_ids_add/remove), `--dod-json ''` (dod_items, full replace) or `--dod-add ''` (dod_items: a read-modify-write that appends one item to the row's current list), `--human-only`/`--no-human-only`, and `--realm ` (`--realm ""` sends the explicit null). The CLI fills `If-Match` from a fresh read. The SDK pins its key set against this schema (`TASK_PATCH_WIRE_KEYS`), so a field added here without the client, or there without here, is a red test rather than a silent drop. ClaimRequest: type: object properties: session_id: { type: string, format: uuid, nullable: true } lease_seconds: type: integer minimum: 60 maximum: 86400 default: 3600 workspace_ref: type: string maxLength: 500 nullable: true description: >- ADR-0029: the workspace the agent declares it will work in (a runtime fact — its real checkout path — not config). Required when the project's workspace_policy is `required`; a second active claim on the same declared workspace is refused with a 409 naming the holder. The server compares refs for equality and never resolves or validates the path. NextClaimRequest: allOf: - $ref: "#/components/schemas/ClaimRequest" - type: object properties: ownership_filter: type: array items: { type: string } description: only consider tasks whose ownership matches these globs Claim: type: object properties: id: { type: string, format: uuid } task_key: { type: string } project: { type: string } org: { type: string } holder: { type: string, description: OIDC subject } session_id: { type: string, format: uuid, nullable: true } workspace_ref: type: string nullable: true description: ADR-0029 declared workspace; null when undeclared in a 'none'-policy project state: { type: string, enum: [active, released, completed, expired] } acquired_at: { type: string, format: date-time } lease_expires_at: { type: string, format: date-time } heartbeat_seconds: type: integer description: recommended heartbeat interval (protocol block) required: [id, task_key, holder, state, acquired_at, lease_expires_at] ActiveClaimRef: type: object description: | T-625: a list row's live claim — the projection of `Claim` a list read carries (who holds the row, the session attributing the stint, when the lease lapses). The claim id, workspace and heartbeat stay detail-read facts. properties: holder: { type: string, description: OIDC subject } session_id: type: string format: uuid description: the claim's board session; omitted when none is recorded lease_expires_at: { type: string, format: date-time } required: [holder, lease_expires_at] ClaimBundle: type: object description: | everything an agent needs to start work, in one response (ADR-0009's fusion): `claim` (the lease) + `task` (the work — with its `realm`, the place it belongs, ADR-0046/0048) + `goals` (the why) + `tenets` (the how, global + the task's realm) + `realm_context` (the WHERE/WHY-in-prose, T-31 — one realm's recorded fact) + `protocol` (the rules, arriving with the work). One response; the agent's whole starting context. properties: claim: { $ref: "#/components/schemas/Claim" } task: { $ref: "#/components/schemas/TaskDetail" } protocol: type: object description: the rules that bind THIS claim, machine-readable properties: heartbeat_every_seconds: { type: integer } complete_with: type: string description: endpoint to call on finish on_blocked: type: string description: endpoint to call to release with a reason open_decisions: type: integer description: | open decisions across the project (ADR-0012). Structural re-surfacing, not etiquette: if this is non-zero, there are questions waiting on a human — relay them at session end. active_tenets: type: integer description: | active tenets across the project (ADR-0039) — the judgments-that-guide currently steering it. A count, not the text: the agent PULLS the full tenets when uncertain (a guiding light, not a leash; the board never enforces them). 0 when the project records none (opt-in — the system is unchanged). open_proposals: type: integer description: | open + endorsed proposals across the project — the not-yet-disposed backlog (ADR-0022/0023). If non-zero, there is steering output awaiting a human's accept/reject — surface it rather than letting it rot. endorsed_proposals: type: integer description: | endorsed proposals across the project (ADR-0023): the subset that is chair-vetted and decision-ready. This is what the human should triage first. pending_reviews: type: integer description: | pending independent reviews across the project (ADR-0027) — the third queue whose rot must surface structurally rather than silently (evaluation §3.11). workspace_policy: type: string enum: [none, declared, witnessed, hardened] description: | ADR-0029/0030: the project's workspace ladder rung. The rule arrives with the work, so a compliant agent learns the requirement here — including whether a witness attestation will be due at completion — rather than by bouncing off a 409. workspace_nonce: type: string nullable: true description: | ADR-0030 (L2/L3 only): the one-time nonce the witness binds into its attestation. Present only when workspace_policy is witnessed|hardened; the board stores only its hash. The holder plants the plaintext in the workspace for the local witness. spend: type: object description: | What this bundle's context COSTS, measured and delivered with the response — the tenet-accretion hazard needs a number before it needs a mechanism (BRIEF-doctrine-evaluation §4.2, E-07). All six fields are **bytes, not tokens** — tokens are model-dependent; bytes are the fact. The same six numbers ride the claim.acquired audit event's payload, so the spend is auditable after the fact, not just visible at claim time. properties: brief_bytes: type: integer description: bytes of the task brief (task.brief_md, UTF-8) — bytes, not tokens goals_bytes: type: integer description: bytes of the delivered goal set (statement_md + success_signals, summed) — bytes, not tokens tenets_bytes: type: integer description: bytes of the DELIVERED tenets (title + decision_md + rationale_md + revisit_when_md, summed) — bytes, not tokens realm_context_bytes: type: integer description: | T-31: bytes of the DELIVERED realm context (name + kind + context_md of the task's ONE realm; 0 when the task is project-wide) — bytes, not tokens protocol_bytes: type: integer description: bytes of this protocol block as serialized (including this spend object) — bytes, not tokens bundle_bytes_total: type: integer description: the five parts summed — the claim bundle's whole context cost — bytes, not tokens required: [brief_bytes, goals_bytes, tenets_bytes, realm_context_bytes, protocol_bytes, bundle_bytes_total] expected_cost: type: object nullable: true description: | ADR-0053 (E-12): a DESCRIPTIVE cost band derived from adjudicated history — p50/p90 of the last 90 days' ACCEPTED completions' cost in this task's realm (project-wide when the task has no realm). A second sanctioned read-plane exception under ADR-0041; the boundary is four enforceable rules: (a) never an input to ranking/ordering/gating — claims:next's ORDER BY is unchanged and a test asserts it; (b) always labeled derived, with sample size; (c) omitted below n=5 (honest sparsity, the stats.rs convention); (d) realm-level keying in v1 — ownership-cluster keying is a recorded deferral with its own trigger (first measured complaint of a misleading realm-level band). Descriptive context delivered WITH the response (like spend), never consulted by the board's own rank/route/gate. If the band vastly exceeds your budget, release-with-reason is the correct move (agent-guide §3.1). properties: p50_tokens: type: integer description: median total tokens (tokens_in + tokens_out) over the sample p90_tokens: type: integer description: 90th-percentile total tokens — the "this one ran big" edge of the band p50_wall_s: type: integer nullable: true description: median session wall-clock seconds over the sample's sessions that reported one (absent when none did) n: type: integer description: the sample size — the honest-sparsity number, always carried (rule b) basis: type: string enum: [realm, project] description: what the sample is keyed on — the task's realm, or project-wide when the task has no realm window_days: type: integer description: the lookback window (days) the sample is drawn from required: [p50_tokens, p90_tokens, n, basis, window_days] goals: type: array items: { $ref: "#/components/schemas/GoalRef" } description: | ADR-0042: the claimed task's goal set — the ACTIVE, non-stale goals the work serves, carried WITH the claim so the agent reads the "why" without a second query (ADR-0009's fusion). Empty (and omitted) when the work is intent-unlinked. tenets: type: array items: { $ref: "#/components/schemas/TenetRef" } description: | ADR-0044: the tenets DELIVERED with this claim — the project's `always` core plus the `cited` tenets relevant to this task (task- or goal-cited). Carried WITH the work so the principle arrives at the moment it matters, not as an optional fetch. ADR-0046/0048: doctrine resolves to the project's GLOBAL tenets (realm-less — the undivided doctrine) plus the tenets scoped to the CLAIMED TASK'S realm — never another realm's noise. The board carries the text verbatim and never enforces it. Empty (and omitted) when nothing is pushed. realm_context: $ref: "#/components/schemas/RealmContextRef" description: | T-31: the claimed task's realm context — that ONE realm's recorded fact (slug/name/kind + context_md), delivered WITH the work by ADR-0044's delivery principle. Omitted for project-wide work (there is no realm to describe). The FACT only — never a judgment about the worker, and never a fan-out over every realm. required: [claim, task] NextBundle: description: | The fused next-assignment (ADR-0009 + ADR-0027): a work claim, or — for an agent holding `tasks:review` — an independent review assignment, tagged by `kind`. One funnel, one canonical ranking. oneOf: - type: object properties: kind: { type: string, enum: [work] } claim: { $ref: "#/components/schemas/Claim" } task: { $ref: "#/components/schemas/TaskDetail" } protocol: { type: object } required: [kind, claim, task] - $ref: "#/components/schemas/Review" discriminator: propertyName: kind mapping: work: "#/components/schemas/ClaimBundle" CompleteRequest: type: object properties: evidence: type: object properties: commit_sha: { type: string, maxLength: 64 } gate_report: type: string maxLength: 65536 description: verification output or a digest/URL of it notes: { type: string, maxLength: 8192 } attestation: type: object description: | ADR-0030 (L2/L3, α courier): the witness's signed workspace attestation, carried by the worker. Verified then stored into the content-addressed attestations store. Fields: v, claim_id, workspace_ref, nonce, candidate_sha, gate_sha256, env, witness, attested_at, signature (base64 Ed25519). attestation_id: type: string description: | ADR-0030 (L2/L3, β witness-direct): the content-addressed id of an attestation the WITNESS already reported to /v1/attestations under its own credential. PREFERRED over `attestation` — the anti-withhold proof that it arrived from a party that is not the worker. One of attestation|attestation_id is required when workspace_policy is witnessed|hardened. tenet_ids: type: array items: { type: string, format: uuid } maxItems: 50 description: | ADR-0039 (citation): the tenets this completion honored (compliance) — "I followed tenet X." Self-reported, never enforced. Each id must name a tenet in the project, else 422. followups: type: array description: tasks filed atomically with the completion (ADR-0009) items: { $ref: "#/components/schemas/TaskCreate" } required: [evidence] CompleteResponse: type: object properties: task: { $ref: "#/components/schemas/TaskSummary" } unblocked: type: array description: tasks that became available because of this completion items: { type: string } filed: type: array description: | Keys of follow-ups filed in this call, in filing order. Stays a plain key array (see `filed_notes` for why it is not widened). items: { type: string } filed_notes: type: object additionalProperties: { $ref: "#/components/schemas/BirthNotes" } description: | T-165: what each follow-up filed by THIS completion was born LACKING and what mechanically follows from each gap, keyed by the follow-up's task key — the completion-seam twin of `TaskFiled.notes` and `ProposalAccepted.notes` (each value is a `BirthNotes` list). The consequence is the content: e.g. a follow-up born with no live goal band "sorts NULLS LAST — below every goal-bearing row at any topology" in `view=next` and the claims:next funnel, so no worker is offered it while any goal-bearing row is available, and it counts toward the docket's intent-dark share (remedy: PATCH the task with `goal_ids_add`); a follow-up whose ownership overlapped open rows with no ordering declared reports the keys the board "placed behind" (T-432b). Advisory only: the completion's status is never gated on it. T-656: a follow-up that names NO `goal_ids` inherits the parent's goals FILTERED to the live ones (active, cadence not lapsed). Each inherited goal that is no longer live is DROPPED, not refused, and named here: "parent goal is ; filed without a goal — ranks NULLS LAST; re-home with PATCH goal_ids" when none remained, or "…; not inherited — the row keeps its parent's remaining live goal(s)" when some did. The board never binds the row to a live goal the parent did not serve. The follow-up's `task.filed` event carries `goal_dropped: []` and `goal_inherited: none | partial` (both absent when nothing was dropped). An EXPLICIT `goal_ids` naming a terminal or stale goal is still refused 409, as on `POST .../tasks`; under `goal_policy = required` a follow-up left goal-less by the drop is refused by that policy's 409. OPTIONAL and sparse. Only keys that lack something appear; a follow-up born complete contributes no entry. When EVERY follow-up was born complete — or none was filed — the `filed_notes` key is ABSENT ENTIRELY, never present-and-empty (`{}`): the board serializes it with `skip_serializing_if = "BTreeMap::is_empty"`, pinned by the T-165 twin `t165_a_completion_whose_followups_are_complete_is_unchanged`. Keys are emitted in task-key order (an ordered map), so one completion serializes the same bytes twice. Why a map BESIDE `filed` rather than a replacement for it: the reasoning is recorded verbatim in the `CompleteResponse.filed_notes` doc comment in `services/board/src/routes/claims.rs` (T-165 c6) — in short, `filed` is consumed as a string array today and no layer on either side of the wire declares `deny_unknown_fields`, so objects there would print garbage silently rather than refuse; the map is the additive shape. Read that comment rather than re-deriving it here. followups: type: array items: { $ref: "#/components/schemas/FiledFollowup" } description: | T-486: every follow-up THIS completion's transaction created, in filing order, as a row a reader can act on — named with the request's own word so a seat reading the answer for `followups` finds them and does not file them again. The board always serializes it (`[]` when nothing was filed); it is left out of `required` only so a client stays valid against a board that predates T-486. `filed` stays the key array beside it. awaiting_review: type: boolean description: | Set when the project's review_policy routed this completion to `completed` (pending independent review) instead of `done` (ADR-0027): the task is NOT landed; a distinct reviewer must accept it. Dependents unblock on acceptance, never on this call. review_policy: type: string enum: [none, required] raised_decisions: type: array items: { $ref: "#/components/schemas/RaisedDecision" } description: | T-477: the open decisions this completion's transaction raised — one per declared `operator` DoD item, linked to the task — so the review is parked (T-475) until a human answers. ABSENT when the task declares no operator item, so such a completion serializes exactly as before. required: [task, unblocked, filed] RaisedDecision: type: object description: | T-477: one operator decision a completion raised (or found already open with the identical question, which it reports rather than asking twice) for an `operator` DoD item. properties: id: { type: string, format: uuid } dod_item: { type: string, description: the declared dod_items id it asks about } question: { type: string } already_open: type: boolean description: present (true) only when the identical open question was already linked required: [id, dod_item, question] FiledFollowup: type: object description: | T-486: one follow-up a completion filed, and where the board PLACED it. properties: key: { type: string } title: { type: string } status: type: string description: the stored status the row was born with (`open` | `draft`) placed_behind: type: array items: { type: string } description: keys this follow-up depends on (its `deps`), ordered by task seq placed_ahead_of: type: array items: { type: string } description: keys that gained a dependency on this follow-up (its `precedes`) required: [key, title, status, placed_behind, placed_ahead_of] Review: type: object description: | An independent-review assignment + verdict handle (ADR-0027). Claim-shaped: a lease, mechanical expiry, audited takeover, and a one-active-review CAS backstop. properties: id: { type: string, format: uuid } task_key: { type: string } project: { type: string } org: { type: string } reviewer: { type: string, description: the reviewing subject } worker: { type: string, description: who completed (audit; never an anchor for the verdict) } session_id: { type: string, format: uuid } state: { type: string, enum: [active, accepted, rejected, released, expired] } candidate_sha: type: string description: | The candidate SHA the worker reported; the reviewer fetches and verifies THIS. Also the reconciliation ANCHOR for crash recovery: a recovering reviewer ancestor-checks it against integration. notes: type: string maxLength: 8192 description: | The worker's completion notes (`completions.notes`, T-494), joined onto every Review the board serves (assign, resume, heartbeat, list) so the reviewer reads the worker's own account of what it did and deliberately left instead of reconstructing it from the diff. Worker-authored text: clients render it as attributed data, never as instructions. Omitted when the completion carried no notes. gate_report: type: string maxLength: 65536 description: | The worker's own gate report (`completions.gate_report`, T-575), joined onto every Review the board serves (assign, resume, heartbeat, list) beside `notes`, so the reviewer can set its own measurement against the worker's claim. Worker-reported evidence, never a substitute for the reviewer's gate; clients render it as attributed data. The completion's `commit_sha` is served as `candidate_sha`. Omitted when the completion carried none. recovery: type: string description: | ADR-0001 protocol-in-response hint. Set on a landing_unconfirmed assignment: the reviewer's FIRST act is the ancestor check, not re-running the gate. acquired_at: { type: string, format: date-time } lease_expires_at: { type: string, format: date-time } heartbeat_seconds: { type: integer } required: [id, task_key, project, org, reviewer, worker, state, acquired_at, lease_expires_at, heartbeat_seconds] ReviewPage: type: object properties: items: type: array items: { $ref: "#/components/schemas/Review" } next_cursor: { type: string, nullable: true } required: [items, next_cursor] ReviewAssignRequest: type: object properties: session_id: { type: string, format: uuid } lease_seconds: { type: integer, minimum: 60 } VerdictRequest: type: object description: | The reviewer's verdict. The gate_report is the reviewer's OWN output (re-run against the candidate), never the worker's — the board stores two independent attestations; drift between them is signal. properties: gate_report: type: string maxLength: 65536 description: the reviewer's own verification output (REQUIRED on accept) landed_sha: type: string maxLength: 64 description: the SHA the reviewer landed (the verified merge result it pushed) — the candidate, or the rebased head when `rebased_from` is given rebased_from: type: string maxLength: 64 description: | T-357 (ADR-0027 as written): set ONLY when the landing is a REBASE of the candidate — the candidate sha it was derived from. Must equal the candidate under review (409 otherwise) and requires a `landed_sha` that differs from it (422 otherwise). Absent = `landed_sha` is the candidate itself, the fast-forward landing (the only shape accepted before T-357). dod_verdicts: type: array items: { $ref: "#/components/schemas/DodVerdict" } description: | ADR-0043: one verdict per declared DoD item, referenced by the item's id (NEVER free text — the loop only closes against declared criteria). The board checks COVERAGE (at dod_policy = `enforced`, accept requires every declared item_id exactly once, no unknown ids), never CORRECTNESS — whether a verdict is right is the reviewer's judgment, not the board's. reason: type: string maxLength: 8192 description: why — REQUIRED on reject, optional on accept ReviewRejected: type: object description: "`POST /v1/reviews/{id}/reject`'s 200 body" properties: task: { type: string } status: { type: string, enum: [open] } rejected_by: { type: string } reason: { type: string } reject_count: type: integer description: the COMPLETION's counter — reset by every re-completion, so it does not bound the cycle task_reject_count: type: integer description: T-425 — the TASK's rejected-review count including this one; the cycle number the escalation bound reads escalated_decision: type: string format: uuid nullable: true description: the blocking decision filed when `task_reject_count` reached a multiple of the limit; null otherwise rejected_sha: type: string nullable: true description: the candidate sha rejected — a re-completion is judged against it brief_amended: type: boolean description: T-154 — whether the verdict reached the delivery channel (the brief) required: [task, status, rejected_by, reason, reject_count, task_reject_count, escalated_decision, rejected_sha, brief_amended] DocketReview: type: object properties: review_id: { type: string, format: uuid } task_key: { type: string } worker: { type: string } reviewer: { type: string } candidate_sha: { type: string } reject_count: { type: integer } landing_unconfirmed: { type: boolean } required: [review_id, task_key, worker, reviewer, reject_count, landing_unconfirmed] DocketSectionDecision: type: object description: >- a capped docket section (S7#4) — `overflow: true` + `total` when more items exist than the 50 shown, so the cap is visible, never silent properties: items: type: array items: { $ref: "#/components/schemas/Decision" } overflow: { type: boolean, description: true when total exceeds the items shown } total: { type: integer, description: the section's uncapped count } required: [items, overflow, total] DocketSectionProposal: type: object description: a capped docket section (S7#4) — see DocketSectionDecision properties: items: type: array items: { $ref: "#/components/schemas/Proposal" } overflow: { type: boolean } total: { type: integer } required: [items, overflow, total] DocketSectionReview: type: object description: a capped docket section (S7#4) — see DocketSectionDecision properties: items: type: array items: { $ref: "#/components/schemas/DocketReview" } overflow: { type: boolean } total: { type: integer } required: [items, overflow, total] DocketSectionTenet: type: object description: a capped docket section (S7#4) — see DocketSectionDecision properties: items: type: array items: { $ref: "#/components/schemas/Tenet" } overflow: { type: boolean } total: { type: integer } required: [items, overflow, total] DocketGoal: type: object description: | One live goal on the docket (ADR-0042) — the intent surface: how much OPEN work serves it (zero = intent without work) and when its decay clock runs out. Stale sorts first, then horizon weight. Descriptive, never prescriptive. properties: id: { type: string, format: uuid } statement_md: { type: string } horizon: { type: string, enum: [now, next, later] } status: type: string enum: [active, stale] description: live goals only — `stale` is derived, never stored open_tasks: { type: integer, description: open tasks linked via task_goals; 0 = intent without work } fresh_until: { type: string, format: date-time, description: the decay deadline (last_reasserted_at + review_cadence) } required: [id, statement_md, horizon, status, open_tasks, fresh_until] DocketSectionGoal: type: object description: a capped docket section (S7#4) — see DocketSectionDecision properties: items: type: array items: { $ref: "#/components/schemas/DocketGoal" } overflow: { type: boolean } total: { type: integer } required: [items, overflow, total] DocketOrphanBand: type: object description: | One terminal goal whose orphan cohort is still live (T-323, the missed-the-response leg of T-163). The dispose act answers its CALLER with the cohort; everyone else meets it here, for as long as it lasts. A fact for a human to dispose — re-home each row or let it ride — the board neither re-homes, blocks, nor migrates anything. properties: goal_id: { type: string, format: uuid } statement_md: { type: string } status: type: string enum: [achieved, abandoned, superseded] description: the stored terminal status superseded_by: type: string format: uuid description: the lineage pointer (supersede only) — where the intent went; omitted otherwise rehome_candidate: type: string format: uuid description: supersede only — the successor named as the obvious re-home candidate, exactly as the dispose response names it. Named, never performed; omitted otherwise disposed_at: { type: string, format: date-time, description: when the goal last changed — for a terminal goal, its disposal act } orphaned_count: type: integer description: the FULL cohort size (non-terminal rows whose entire goal set is this goal), read through `goals::orphaned_rows` — the one definition orphaned_rows: type: array description: up to 50 of the cohort, by task key (the dispose response's cap) items: type: object properties: key: { type: string } title: { type: string } required: [key, title] consequence: type: string description: the standing consequence, worded as the maintainer's choice (ranking band ended; completions refuse follow-ups citing the goal; re-home or let ride) required: [goal_id, statement_md, status, disposed_at, orphaned_count, orphaned_rows, consequence] DocketSectionOrphanBand: type: object description: a capped docket section (S7#4) — see DocketSectionDecision properties: items: type: array items: { $ref: "#/components/schemas/DocketOrphanBand" } overflow: { type: boolean } total: { type: integer } required: [items, overflow, total] DocketSectionQuestion: type: object description: a capped docket section (S7#4) — see DocketSectionDecision properties: items: type: array items: { $ref: "#/components/schemas/Question" } overflow: { type: boolean } total: { type: integer } required: [items, overflow, total] Docket: type: object description: | The steering docket (ADR-0028) — the awaiting-judgment surface in one bundle. Descriptive, never prescriptive: no server-computed priority. The board assembles, the committee deliberates, the human disposes. Every capped section reports `overflow`/`total` (S7#4): a section past its cap of 50 reads "50 shown, N total" — a 51st open decision is never silently invisible. properties: open_decisions: $ref: "#/components/schemas/DocketSectionDecision" endorsed_proposals: $ref: "#/components/schemas/DocketSectionProposal" open_proposals: $ref: "#/components/schemas/DocketSectionProposal" pending_reviews: $ref: "#/components/schemas/DocketSectionReview" frontier: type: array description: the objective work-ranking (view=next) — for drift-spotting, NOT an agenda items: { $ref: "#/components/schemas/TaskSummary" } active_tenets: allOf: - $ref: "#/components/schemas/DocketSectionTenet" - description: ADR-0039 — the doctrine the committee HONORS (a guiding light, not a leash) proposed_tenets: allOf: - $ref: "#/components/schemas/DocketSectionTenet" - description: candidates awaiting the operator's dispose — the committee WEIGHS IN (recommend, never dispose), never re-proposes declined_tenets: allOf: - $ref: "#/components/schemas/DocketSectionTenet" - description: candidates the operator refused — the committee does NOT re-litigate goals: allOf: - $ref: "#/components/schemas/DocketSectionGoal" - description: | ADR-0042 — the intent surface: live goals (active + stale) with their open-task counts (zero = intent without work) and decay deadlines. Stale sorts first — neglected intent is what the cadence exists to surface. Descriptive, never prescriptive. orphaned_by_terminal_goals: allOf: - $ref: "#/components/schemas/DocketSectionOrphanBand" - description: | T-323 — terminal goals (achieved, abandoned, superseded) whose orphan cohort is still live: the rows a disposal dropped out of ranking, listed until each is re-homed or goes terminal. Most recent disposal first. An empty cohort is no band. States facts; never acts. open_questions: allOf: - $ref: "#/components/schemas/DocketSectionQuestion" - description: | ADR-0045 — non-blocking questions awaiting an answer. Surfaced so a question in front of a human gets answered rather than rots, but they NEVER block the work (the agent proceeded on its stated_default). Aggregate count only — a single question is a conversation, not a pattern. counts: type: object properties: open_decisions: { type: integer } open_proposals: { type: integer } endorsed_proposals: { type: integer } pending_reviews: { type: integer } open_questions: { type: integer } active_tenets: { type: integer } proposed_tenets: { type: integer } # ADR-0039 ossification measure: active tenets cited before but # unread 90d (doctrine going quiet). A count, never a ranking. stale_tenets: { type: integer } # ADR-0042: live goals whose cadence lapsed — the anti-graveyard # lever. Re-assert or dispose. stale_goals: { type: integer } # ADR-0042: live goals with ZERO open tasks serving them — intent # without work (the dual of intent-dark work). idle_goals: { type: integer } # T-323: non-terminal rows whose ENTIRE goal set is terminal — the # sum of every orphan band's full cohort (beyond the section cap). orphaned_rows: { type: integer } # ADR-0042: percent of OPEN tasks with NO goal link — the # intent-dark share. 0 when there are no open tasks. intent_dark_pct: { type: integer } # ADR-0067: registered triggers whose DERIVED state is `fired` # at this read — the at-a-glance count of `triggers`. fired_triggers: { type: integer } required: [open_decisions, open_proposals, endorsed_proposals, pending_reviews] triggers: type: array description: | ADR-0067 — the Stage-2 trigger registry: every registered ROADMAP deferral trigger with its line, what the Stage-1 instruments measured over the serving replica's window, and its state, DERIVED at this read and never stored. Evaluation is lazy: reading the docket evaluates the registry, and a `fired` reading is chained once per refire window as `trigger.fired` on the org-wide chain with the reader as actor. The registry SURFACES and never acts — no escalation, no filing, no build. Registry order, not an attention ranking. items: { $ref: "#/components/schemas/TriggerReading" } required: [open_decisions, endorsed_proposals, open_proposals, pending_reviews, frontier, counts] TriggerReading: type: object description: | One registered trigger as read (ADR-0067). Thresholds are configuration (`TRIGGER_*` env); state is derived from the process's instruments at read time; `last_fired_at` / `last_acknowledged_at` are the chain's memory, which a restart does not reset. properties: id: { type: string, enum: [pool-sizing, head-lock, sse-waste] } source: { type: string, description: the doc and row the line was written in } instrument: { type: string, description: the /readyz instrument(s) it is judged on } line: { type: string, description: "the line, rendered with the configured numbers" } threshold: { type: object, additionalProperties: true } observed: type: object nullable: true additionalProperties: true description: what the window measured; null when the line was not measurable this read (a rate over a span shorter than TRIGGER_MIN_WINDOW_SECS) — never reported as a zero state: { type: string, enum: [quiet, armed, fired] } window_secs: { type: number, description: "the span the observation covers (0 on a replica's first read)" } last_fired_at: { type: string, format: date-time, nullable: true } last_acknowledged_at: { type: string, format: date-time, nullable: true } required: [id, source, instrument, line, threshold, observed, state, window_secs, last_fired_at, last_acknowledged_at] TriggerAcknowledgement: type: object description: | One `trigger.acknowledged` as chained (ADR-0067, T-505): the human disposition of a fired trigger, referencing the ADR that disposes it. properties: trigger: { type: string, enum: [pool-sizing, head-lock, sse-waste] } adr: { type: string, description: "the trimmed ADR reference (ADR-NNNN)" } acknowledged_by: { type: string, description: the caller's subject — the event's actor } acknowledged_at: { type: string, format: date-time } last_fired_at: { type: string, format: date-time, description: "the newest `trigger.fired` this disposition answers" } note_image: type: object nullable: true description: "`{len, sha256}` of the note; null when none was given. The text is never stored." properties: len: { type: integer } sha256: { type: string } required: [trigger, adr, acknowledged_by, acknowledged_at, last_fired_at, note_image] DecisionRef: type: object description: an open decision blocking a task (id + verbatim question) properties: id: { type: string, format: uuid } question: { type: string } required: [id, question] ProposalBet: type: object description: | ADR-0047: one live proposal a task bets on (the task_proposals soft-link), served on TaskDetail.bets. Id + title + triage status, nothing more — enough for the agent to make the §5 choice, never enough to floodlight the queue. properties: id: { type: string, format: uuid } title: { type: string } status: type: string description: the proposal's triage status (`open` | `endorsed`) — a bet on an `endorsed` proposal is checkpointed, not settled required: [id, title, status] StatPoint: type: object properties: at: { type: string, format: date-time, description: bucket start (UTC-calendar aligned) } value: type: number nullable: true description: the aggregated value (null when no observations — a gap, never a confident 0) n: { type: integer, description: how many observations the value aggregates (1 for a snapshot reading) } required: [at, value, n] StatSeries: type: object description: a named time series (one trend line on one card) properties: key: { type: string, description: stable series key, e.g. acceptance_rate, tenets_active; the §4.1 human-seat gauges are decision_answer_latency_h, question_answer_latency_h, proposal_dispose_latency_h, dispose_throughput_ratio, open_governance_age_h, governance_queue_depth; the §4.2 bundle-spend measures (E-07) are bundle_bytes_p50 and bundle_tenet_share } label: { type: string } unit: { type: string, enum: [percent, count, minutes, usd, ratio, bytes], description: display unit; ratio is overloaded per key — chain_intact is 1/0, the *_h human-seat gauges are HOURS, dispose_throughput_ratio is a plain quotient; bytes is the bundle-spend unit (bundle_bytes_p50 — bytes, not tokens, which are model-dependent) } points: type: array items: { $ref: "#/components/schemas/StatPoint" } required: [key, label, unit, points] ProjectStats: type: object properties: window: { type: string } bucket: { type: string } series: type: array items: { $ref: "#/components/schemas/StatSeries" } required: [window, bucket, series] PlatformStats: type: object description: the operator's whole-platform health snapshot (cross-project aggregate, never a per-tenant ranking) properties: orgs: { type: integer } projects: { type: integer } org_list: type: array description: | ADR-0069: every org with who created it and when, newest first — orgs are self-serve, so the operator SEES growth here rather than gating it. The one per-identity column on this surface. items: { $ref: "#/components/schemas/PlatformOrg" } tasks_open: { type: integer } tasks_done: { type: integer } active_claims: { type: integer } zombie_claims: { type: integer, description: held-but-silent claims past the staleness bar } cost_task_usd: { type: number, nullable: true, description: self-reported agent burn (1.a) } cost_committee_usd: { type: number, nullable: true, description: bridge-reported committee burn (1.b); the two are disjoint and SUM } completions_rung0: { type: integer } completions_rung1: { type: integer } completions_rung2: { type: integer } grants_total: { type: integer } grants_dispose_power: { type: integer, description: grants conferring maintainer+ (the widening-power watch) } committee_accepted: { type: integer } committee_rejected: { type: integer } chains_intact: { type: integer } chains_broken: { type: integer } series: type: array items: { $ref: "#/components/schemas/StatSeries" } required: [orgs, projects, org_list, tasks_open, tasks_done, active_claims, zombie_claims, completions_rung0, completions_rung1, completions_rung2, grants_total, grants_dispose_power, committee_accepted, committee_rejected, chains_intact, chains_broken, series] PlatformOrg: type: object description: one row of `PlatformStats.org_list` (ADR-0069) properties: slug: { type: string } created_by: type: string nullable: true description: | the creator's canonical, pseudonymous sub (ADR-0031). `null` for an org that predates migration 0044 with no recorded `org.created` event — never invented. created_at: { type: string, format: date-time } required: [slug, created_by, created_at] ErasureRequest: type: object properties: raw_subject: type: string description: the subject AS PRESENTED (the email/username) — what identity_map.raw keys on, NOT the pseudonym confirm: type: boolean description: 'destructive-op ack; a bare POST is a 400 without "confirm": true' required: [raw_subject] ErasureResponse: type: object properties: erased_pseudonym: type: string description: the pseudonym anonymized (so the operator can confirm WHICH identity went dark); never the raw sub required: [erased_pseudonym] DecisionOption: type: object description: | ADR-0052: one filer-provided option on a decision — advisory structure on the question, nothing more. The board never picks, never defaults, and never expires an unanswered decision. properties: key: type: string pattern: '^[a-z0-9_-]{1,32}$' description: the slug the answer names in chosen_option label: { type: string, minLength: 1, maxLength: 200 } consequence_md: type: string maxLength: 2000 description: optional "what this branch costs/buys" note required: [key, label] Decision: type: object properties: id: { type: string, format: uuid } question: { type: string, maxLength: 2000 } context_md: { type: string, maxLength: 16384 } status: { type: string, enum: [open, answered] } raised_by: { type: string, description: OIDC subject } created_at: { type: string, format: date-time } answer: { type: string, maxLength: 8192, nullable: true } answered_by: { type: string, nullable: true } answered_at: { type: string, format: date-time, nullable: true } options: type: array maxItems: 6 description: ADR-0052 option set ([] when the fork was filed without one) items: { $ref: "#/components/schemas/DecisionOption" } chosen_option: type: string nullable: true description: ADR-0052 — the option KEY the answer chose (null = names no option) task_keys: type: array description: tasks this decision blocks while open items: { type: string } realm_id: type: string format: uuid nullable: true description: | ADR-0046 (T-35): the realm this question is about, as the realm's ID. `null` = project-wide (the default). The board returns the id ONLY, never a slug: `decisions::COLS` is interpolated into `docket.rs`'s fused reads, which have no `realms` alias to join a slug from. Resolve it via `GET …/realms` if you need the name. (Tasks differ — `Task.realm` IS a slug, because `tasks.rs` owns its query and joins. Each endpoint is described as it sends.) record_patch: allOf: [{ $ref: "#/components/schemas/DecisionRecordPatchOutcome" }] description: | T-379: present ONLY on the answer response, and only when the answer carried a `record_patch`. The durable record is the stamped line on `answer`. required: [id, question, status, raised_by, created_at] DecisionCreate: type: object properties: question: { type: string, maxLength: 2000 } context_md: type: string maxLength: 16384 description: optional supporting detail (what was tried, where) task_keys: type: array maxItems: 100 description: | existing task keys this decision blocks while open; unknown keys are a 422 (they would silently fail to block anything) items: { type: string } options: type: array maxItems: 6 description: | ADR-0052: enumerate the fork (≤6 options, keys unique [a-z0-9_-]{1,32}) so the answer is one tap. Shape violations are a 422 naming the rule. items: { $ref: "#/components/schemas/DecisionOption" } realm: type: string maxLength: 200 description: | ADR-0046 (T-35): the realm SLUG this question is about. Omitted = project-wide, which stays the common case. Resolved to `realm_id` at raise. The board records what the filer declares and never infers it from `task_keys` — a question can block no task and still be about one realm, and linked tasks may span realms. Refusals (`422`, verbatim as the shared resolver emits them, `x` being the slug sent): `unknown realm "x" in this project` and `realm "x" is archived — file into a live realm or project-wide`. required: [question] DecisionAnswer: type: object properties: answer: type: string maxLength: 8192 description: the why — REQUIRED even when chosen_option names the which chosen_option: type: string description: | ADR-0052: the option key the answer chose. Must name a key in the decision's option set — a mismatch is a 422 naming the valid keys. Omit for a free-text answer. record_patch: $ref: "#/components/schemas/DecisionRecordPatch" required: [answer] DecisionRecordPatch: type: object additionalProperties: false description: | T-379: the record half of a ruling — the chosen option CARRIES a patch onto one of THIS decision's linked tasks, applied in the same transaction that records the answer, under the answering maintainer's own grant (no new role class). Requires `chosen_option`: the task's `task.amended` / `task.ownership_amended` events are stamped `decision: {decision_id, chosen_option}` (and `via: decision`). Refusals (422, the whole answer rolls back): a `task_key` not linked to this decision (a decision-scoped executor, not a free-form PATCH); any field outside this slice (`title`, `status`, `pinned`, `realm`, …); no field at all; ownership failing the shared T-78 validation text; no `chosen_option`. A dep cycle is the PATCH's 422 and an ADR-0011 overlap is placed in front exactly as on `PATCH …/tasks/{key}`, or refused 409 with its ordering remedy. A `done`/`completed` row is immutable: the answer still lands, nothing is applied, and the answer text gains a `[record patch unrecordable] …` line saying so. Applied patches gain `[record patch applied] …` the same way, so every reader of the decision sees the act. properties: task_key: { type: string, description: a task linked to this decision } ownership: type: array maxItems: 100 description: REPLACES the task's ownership set (the whole set) items: { type: string, minLength: 1, maxLength: 500 } brief_append_md: type: string minLength: 1 description: a stamped amendment appended to the task's brief deps_add: { type: array, items: { type: string } } deps_remove: { type: array, items: { type: string } } required: [task_key] DecisionRecordPatchOutcome: type: object properties: task_key: { type: string } outcome: { type: string, enum: [applied, unrecordable] } changed: type: array description: fields applied (`ownership`, `brief`, `deps`); empty when unrecordable items: { type: string } detail: { type: string, description: the line also appended to `answer` } required: [task_key, outcome, changed, detail] DecisionPage: type: object properties: items: type: array items: { $ref: "#/components/schemas/Decision" } next_cursor: { type: string, nullable: true } required: [items] Question: type: object description: | A non-blocking question (ADR-0045): a clarification an agent asked mid-run, attached to a claim. Unlike a `Decision` (ADR-0012), it NEVER blocks — the agent proceeded on `stated_default` the moment it filed. `stated_default` is REQUIRED: it is the non-blocking mechanism (the defensible path the agent proceeds on; a question without one is a blocking decision in disguise). Unanswered at completion/release → `moot` (recorded, never lost). properties: id: { type: string, format: uuid } project_id: { type: string, format: uuid } claim_id: { type: string, format: uuid, description: the run asking (anchors the completion that follows) } task_key: { type: string, description: the claim's task, denormalized for read } asked_by: { type: string } question: { type: string, maxLength: 2000 } context_md: { type: string, maxLength: 16384 } stated_default: { type: string, maxLength: 2000, description: the safe path the agent proceeds on (REQUIRED) } status: { type: string, enum: [open, answered, moot] } answer: { type: string, maxLength: 8192, nullable: true } answered_by: { type: string, nullable: true } answered_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } required: [id, project_id, claim_id, task_key, asked_by, question, context_md, stated_default, status, created_at] QuestionCreate: type: object properties: claim_id: { type: string, format: uuid, description: an ACTIVE claim the filer holds } question: { type: string, maxLength: 2000 } context_md: { type: string, maxLength: 16384 } stated_default: { type: string, maxLength: 2000, description: the safe path to proceed on (REQUIRED — else raise a DECISION) } required: [claim_id, question, stated_default] QuestionAnswer: type: object properties: answer: { type: string, maxLength: 8192 } required: [answer] QuestionPage: type: object properties: items: type: array items: { $ref: "#/components/schemas/Question" } next_cursor: { type: string, nullable: true } required: [items] Tenet: type: object description: | A tenet (ADR-0039): a judgment-that-guide — a guiding light, not a leash. The board records it; agents pull it when uncertain; the board NEVER enforces it. Distinct from a `Decision` (ADR-0012), which is a *question-that-blocks* — a tenet never blocks a task. Lifecycle: `proposed` (a candidate, steers no one) → `active` (accepted doctrine) → `superseded` | `reversed` | `declined`. Only `active` steers. Both actors are recorded: `proposed_by` (who brought it — a committee, an agent, or the operator) and `accepted_by` (the operator who disposed it into doctrine). Append-only supersession: a tenet is never edited; change is a NEW active tenet that points back via `supersedes`, and the `superseded_by`/`supersedes` chain is the reasoning lineage. properties: id: { type: string, format: uuid } project_id: { type: string, format: uuid } title: { type: string, maxLength: 300 } decision_md: { type: string, maxLength: 16384, description: the tenet itself — "we choose X over Y" } rationale_md: { type: string, maxLength: 16384, description: why, plus rejected alternatives } revisit_when_md: { type: string, maxLength: 8192, description: what would make us revisit, recorded at decision-time } status: { type: string, enum: [proposed, active, superseded, reversed, declined] } supersedes: { type: string, format: uuid, nullable: true, description: the active tenet this one replaces (on accept) } superseded_by: { type: string, format: uuid, nullable: true, description: the tenet that replaced this one } proposed_by: { type: string, description: OIDC subject of the proposer } proposed_at: { type: string, format: date-time } accepted_by: { type: string, nullable: true, description: the operator who accepted (NULL until active) } accepted_at: { type: string, format: date-time, nullable: true } source_md: { type: string, maxLength: 8192, description: where it came from (a committee run, an ADR, an operator ruling) } created_at: { type: string, format: date-time } delivery: type: string enum: [pull, cited, always] description: | ADR-0044 — how this tenet reaches an agent. `pull` (default): recorded, queryable, never pushed. `cited`: pushed into a claim bundle when the claimed task cites it OR serves a goal that cites it. `always`: pushed into every claim bundle, cited or not (the universal core — an unconditional context spend, set with restraint). Maintainer-gated (a dispose-seat act); only an ACTIVE tenet can be pushed. push_cadence: type: string nullable: true description: | ADR-0051 — the re-assertion budget on the PUSH, as Postgres interval text (e.g. "14 days"). NULL = never lapses (the default; full back-compat). The push PRIVILEGE expires, never the tenet. last_reasserted_at: type: string format: date-time description: the decay clock's base — set at filing, reset at accept, moved by `:reassert` push_lapsed: type: boolean description: | DERIVED, never stored — `push_cadence IS NOT NULL AND now() > last_reasserted_at + push_cadence`. A lapsed tenet is still active doctrine — listed, citable, pullable — it merely stops riding claim bundles until a human re-asserts it. realm_id: type: string format: uuid nullable: true description: | ADR-0046 (T-35): the realm this ruling is scoped to, as the realm's ID. `null` = project-wide (the default — doctrine that holds in one place is the exception). A realm-scoped tenet reaches claim bundles for tasks in THAT realm only (doctrine resolution's `realm_id IS NULL OR realm_id = ` disjunct). The board returns the id ONLY, never a slug: `tenets::COLS` is interpolated into `docket.rs`'s fused reads, which have no `realms` alias to join a slug from. (`Task.realm`, by contrast, is a slug — `tasks.rs` owns its query and joins.) required: [id, project_id, title, decision_md, status, proposed_by, proposed_at, created_at] TenetPropose: type: object properties: title: { type: string, maxLength: 300 } decision_md: { type: string, maxLength: 16384 } rationale_md: { type: string, maxLength: 16384 } revisit_when_md: { type: string, maxLength: 8192 } source_md: { type: string, maxLength: 8192 } supersedes: type: string format: uuid nullable: true description: | an ACTIVE tenet in this project this proposal replaces on accept. Decision 01a05e7b: the successor must carry the SAME realm scope as the tenet it supersedes — a supersede that changes scope (narrowing, widening, or realm → realm) is refused `409`, naming both scopes, with a `recovery` to file it as two tenets (supersede-within-scope, then a separate filing in the other scope). Checked here and again at accept, against the final realm. realm: type: string maxLength: 200 description: | ADR-0046 (T-35): the realm SLUG this ruling is scoped to. Omitted = project-wide, which stays the common case. Resolved to `realm_id` at filing. A maintainer may override it once, at accept (`TenetAccept.realm`); after accept a re-scope is a supersede. Refusals (`422`, verbatim as the shared resolver emits them, `x` being the slug sent): `unknown realm "x" in this project` and `realm "x" is archived — file into a live realm or project-wide`. TenetAccept: type: object description: | Decision 01a05e97 (T-35): the OPTIONAL accept body. `POST …/accept` with no body accepts the realm the proposer named, exactly as before. properties: realm: type: string nullable: true maxLength: 200 description: | The maintainer's one-time realm override at disposal. Three-valued: key ABSENT keeps the proposed realm; explicit `null` accepts the tenet PROJECT-WIDE; a slug re-scopes it to that realm. The supersede scope check (decision 01a05e7b, `409`) runs against this FINAL realm. Refusals (`422`, verbatim as the shared resolver emits them, `x` being the slug sent): `unknown realm "x" in this project` and `realm "x" is archived — file into a live realm or project-wide`. TenetDelivery: type: object properties: delivery: type: string enum: [pull, cited, always] description: | ADR-0044 — the delivery state to set. `pull` (queryable, never pushed), `cited` (pushed when task/goal cites), `always` (pushed into every bundle). push_cadence: type: string nullable: true description: | ADR-0051 — the re-assertion budget on the push, as Postgres interval text (e.g. "14 days"). Set it alongside a push delivery (`cited`/`always`); setting `pull` clears it (a pull-delivery tenet is never pushed, so it has no push budget). NULL = never lapses. required: [delivery] TenetRef: type: object description: | One tenet DELIVERED into a claim bundle (ADR-0044) — the judgment-that- guides carried WITH the work. `delivery` records WHY it arrived ('always' = universal core; 'cited' = the task/goal cited it), so the spend is auditable. The board carries the text verbatim, never enforces. properties: id: { type: string, format: uuid } title: { type: string } decision_md: { type: string } rationale_md: { type: string } revisit_when_md: { type: string } delivery: type: string enum: [always, cited] description: why this tenet is in the bundle required: [id, title, decision_md, rationale_md, revisit_when_md, delivery] TenetPage: type: object properties: items: type: array items: { $ref: "#/components/schemas/Tenet" } next_cursor: { type: string, nullable: true } required: [items] Adr: type: object description: | An Architecture Decision Record as a board resource (migration 0031). Lifecycle: `proposed` (a candidate, steers no one) → `active` (accepted doctrine) → `superseded` | `reversed` | `declined`. Both actors are recorded: `proposed_by` and `accepted_by`. Append-only supersession: an ADR is never edited; change is a NEW ADR that points back via `supersedes`, and the `supersedes`/`superseded_by` chain is the lineage. Every text field is always present (empty string when the proposer sent none). properties: id: { type: string, format: uuid } project_id: { type: string, format: uuid } number: { type: integer, format: int32, minimum: 1, description: "the human number (33 for ADR-0033); unique within the project" } title: { type: string, minLength: 1, maxLength: 300 } context_md: { type: string, maxLength: 16384, description: the context and forces at play } decision_md: { type: string, maxLength: 16384, description: the decision itself — "we choose X" } consequences_md: { type: string, maxLength: 16384, description: "what follows, positive and negative" } alternatives_md: { type: string, maxLength: 16384, description: rejected alternatives and why } revisit_when_md: { type: string, maxLength: 8192, description: "what would make us revisit, recorded at decision-time" } status: { type: string, enum: [proposed, active, superseded, reversed, declined] } supersedes: { type: string, format: uuid, nullable: true, description: the active ADR this one replaces (on accept) } superseded_by: { type: string, format: uuid, nullable: true, description: the ADR that replaced this one } proposed_by: { type: string, description: OIDC subject of the proposer } proposed_at: { type: string, format: date-time } accepted_by: { type: string, nullable: true, description: the operator who accepted (null until active) } accepted_at: { type: string, format: date-time, nullable: true } source_md: { type: string, maxLength: 8192, description: "where it came from (a committee run, a task, an operator ruling)" } created_at: { type: string, format: date-time } required: [id, project_id, number, title, context_md, decision_md, consequences_md, alternatives_md, revisit_when_md, status, supersedes, superseded_by, proposed_by, proposed_at, accepted_by, accepted_at, source_md, created_at] AdrPropose: type: object properties: number: { type: integer, format: int32, minimum: 1 } title: { type: string, minLength: 1, maxLength: 300 } decision_md: { type: string, minLength: 1, maxLength: 16384 } context_md: { type: string, maxLength: 16384, default: "" } consequences_md: { type: string, maxLength: 16384, default: "" } alternatives_md: { type: string, maxLength: 16384, default: "" } revisit_when_md: { type: string, maxLength: 8192, default: "" } source_md: { type: string, maxLength: 8192, default: "" } supersedes: type: string format: uuid nullable: true description: an ACTIVE ADR in this project this proposal replaces on accept required: [number, title, decision_md] AdrPage: type: object properties: items: type: array items: { $ref: "#/components/schemas/Adr" } next_cursor: { type: string, nullable: true } required: [items, next_cursor] Prompt: type: object description: | A prompt (ADR-0056): a registered standing prompt — the prompt text a harness injects for a scope — under the tenet treatment (supersession lineage, both actors recorded, a Maintainer+ dispose gate) with **no delivery mechanism**: the board never injects prompts; a harness fetches the active prompt for its scope and injects it (execution plane), reporting `content_ref` as the session's `prompt_ref`. Lifecycle: `proposed` (a candidate, injected nowhere) → `active` (accepted; at most ONE per (project, scope)) → `superseded` | `reversed` | `declined`. Append-only: a prompt is never edited; change is a NEW active prompt that points back via `supersedes`. properties: id: { type: string, format: uuid } project_id: { type: string, format: uuid } title: { type: string, maxLength: 300, description: the prompt's name in one line } body_md: { type: string, maxLength: 16384, description: the standing prompt text the harness injects for this scope } scope: type: string enum: [stint, committee, triage, curator, reviewer, auditor] description: the persona this prompt drives — a harness asks for the active prompt of ITS scope content_ref: type: string pattern: "^sha256:[0-9a-f]{64}$" description: | The content-address of `body_md` — computed SERVER-SIDE at propose as `"sha256:" + hex(sha256(body_md))`, immutable with the row. The join key to `sessions.prompt_ref` (migration 0028). status: { type: string, enum: [proposed, active, superseded, reversed, declined] } supersedes: { type: string, format: uuid, nullable: true, description: the active prompt this one replaces (on accept) } superseded_by: { type: string, format: uuid, nullable: true, description: the prompt that replaced this one } proposed_by: { type: string, description: OIDC subject of the proposer } proposed_at: { type: string, format: date-time } accepted_by: { type: string, nullable: true, description: the operator who accepted (NULL until active) } accepted_at: { type: string, format: date-time, nullable: true } source_md: { type: string, maxLength: 8192, description: where it came from (a committee run, an ADR, an operator ruling) } created_at: { type: string, format: date-time } required: [id, project_id, title, body_md, scope, content_ref, status, proposed_by, proposed_at, created_at] PromptPropose: type: object properties: title: { type: string, maxLength: 300 } body_md: { type: string, maxLength: 16384 } scope: type: string enum: [stint, committee, triage, curator, reviewer, auditor] source_md: { type: string, maxLength: 8192 } supersedes: type: string format: uuid nullable: true description: an ACTIVE prompt in this project this proposal replaces on accept required: [title, body_md, scope] PromptPage: type: object properties: items: type: array items: { $ref: "#/components/schemas/Prompt" } next_cursor: { type: string, nullable: true } required: [items] TenetCitations: type: object description: | The work that cites a tenet (ADR-0039 citation) — the rectify story: when a tenet turns out wrong, everything made in its name, so it can be fixed. Self-reported citations, served verbatim. properties: tenet_id: { type: string, format: uuid } proposals: type: array items: type: object properties: id: { type: string, format: uuid } title: { type: string } status: { type: string } created_at: type: string format: date-time description: when the citation was made (proposal filed) — feeds the lifetime page's citation histogram tasks: type: array items: type: object properties: key: { type: string } title: { type: string } status: { type: string } created_at: type: string format: date-time description: when the task was filed (the citation landed) — feeds the histogram completions: type: array items: type: object properties: task_id: { type: string, format: uuid } # The task's key — so a client can link the completion to the # task (ADR-0028: the board resolves the join; a UUID is no link). task_key: { type: string } completed_by: { type: string } completed_at: { type: string, format: date-time } required: [tenet_id, proposals, tasks, completions] SuccessSignal: type: object description: | One success signal (ADR-0042): an observable, agent-checkable *condition* — the evidence the intent was met, checkable BY the work. NOT a metric (a number invites gaming; a condition is checkable). The board stores these verbatim and NEVER judges them. properties: id: { type: string, maxLength: 100 } statement: { type: string, maxLength: 2000 } required: [id, statement] Goal: type: object description: | A goal (ADR-0042): durable, living *intent* — what the work is FOR. The board managed work superbly and intent not at all; a goal is the missing object. Tasks link to goals MANY-TO-MANY (`task_goals`) and a goal shapes the canonical rank as its first term (the goal horizon weight — see view=next / claims:next). CONTENT is immutable (`statement_md`, `horizon`, `success_signals`): a change is a SUPERSEDE with `superseded_by` lineage, never an edit. Lifecycle: `active` ⇄ `stale` (DERIVED — `now() > last_reasserted_at + review_cadence`, the anti-graveyard decay; never stored) → `achieved` | `abandoned` | `superseded`. `achieved`/`superseded` are TERMINAL (a fact about the past / a lineage pointer); `abandoned` is REVERSIBLE via `reactivate` (a judgment that can change — append-only, a new event, not an edit). Every act is maintainer-only: a worker files *proposals*, never goals; agents cite/link, humans dispose. properties: id: { type: string, format: uuid } project_id: { type: string, format: uuid } statement_md: { type: string, maxLength: 65536, description: one page of intent (IMMUTABLE) } horizon: type: string enum: [now, next, later] description: the rank weight (now is highest). IMMUTABLE — a horizon change is a supersede success_signals: type: array items: { $ref: "#/components/schemas/SuccessSignal" } description: observable, agent-checkable conditions (OPTIONAL; IMMUTABLE) review_cadence: type: string description: Postgres interval text (e.g. "14 days") — REQUIRED at filing; a goal cannot be born stale status: type: string enum: [active, stale, achieved, abandoned, superseded] description: the PRESENTED status — `stale` is derived at read time, never stored version: { type: integer, format: int64, description: optimistic-concurrency token (If-Match) } superseded_by: { type: string, format: uuid, nullable: true, description: the goal that replaced this one (lineage) } filed_by: { type: string, description: OIDC subject of the maintainer who filed (or accepted the proposal) } last_reasserted_at: { type: string, format: date-time, description: the decay clock's base } fresh_until: type: string format: date-time description: last_reasserted_at + review_cadence — the decay deadline; after it the goal presents `stale` disposed_reason: type: string enum: [no_longer_relevant, superseded_elsewhere, too_costly, other] nullable: true description: why it was disposed/reactivated (NULL until a dispose act) — so "why do goals die?" is measurable disposed_note: { type: string, maxLength: 280, nullable: true, description: optional bounded freetext; required when reason = other } evidence_completion_ids: type: array items: { type: string, format: uuid } description: the achieve act's recorded evidence (references to completions, never content) tenet_ids: type: array items: { type: string, format: uuid } description: ADR-0039 citations — the doctrine this goal grounds itself in (self-reported, never enforced) realm_id: type: string format: uuid nullable: true description: | ADR-0046 (T-35): the realm this intent is scoped to, as the realm's ID. `null` = project-wide (the default, and most goals). IMMUTABLE content: declared at filing, never patched — a re-scope is a supersede. The board returns the id ONLY, never a slug: `goals::COLS` selects the bare `realm_id` column and no goal read joins `realms` — the same shape as the tenet and decision reads, whose COLS are also interpolated into `docket.rs`'s fused reads (no `realms` alias there to join a slug from). (`Task.realm`, by contrast, is a slug — `tasks.rs` owns its query and joins.) created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } required: [id, project_id, statement_md, horizon, success_signals, review_cadence, status, version, filed_by, last_reasserted_at, fresh_until, tenet_ids, created_at, updated_at] GoalCreate: type: object properties: statement_md: { type: string, maxLength: 65536 } horizon: { type: string, enum: [now, next, later] } success_signals: type: array items: { $ref: "#/components/schemas/SuccessSignal" } maxItems: 50 review_cadence: type: string description: REQUIRED — Postgres interval text (e.g. "14 days"); a goal cannot be born stale tenet_ids: type: array items: { type: string, format: uuid } maxItems: 50 realm: type: string maxLength: 200 description: | ADR-0046 (T-35): the realm SLUG this intent belongs to. Omitted = project-wide, which stays the common case — most intent spans the project. Resolved to `realm_id` at filing. IMMUTABLE once filed (see `GoalPatch.realm`). Refusals (`422`, verbatim as the shared resolver emits them, `x` being the slug sent): `unknown realm "x" in this project` and `realm "x" is archived — file into a live realm or project-wide`. required: [statement_md, horizon, review_cadence] GoalPatch: type: object description: | Only the OPERATIONAL knobs are patchable. The CONTENT — `statement_md`, `horizon`, `success_signals` — is IMMUTABLE: naming any of them is refused 422 with a `recovery` naming `POST …/goals/{id}/supersede`. So is `realm` (T-35) — see below. properties: review_cadence: { type: string, description: the decay clock (not intent); a change re-bases it } tenet_ids: type: array items: { type: string, format: uuid } maxItems: 50 realm: type: string description: | NOT patchable — declared ONLY so a re-scope attempt is REFUSED BY NAME. A goal's realm is IMMUTABLE content (ADR-0042/0046): work already bound to the goal was bound against that scope, so moving it silently would re-point every linked task. Naming `realm` here (any string; JSON `null` reads as omitted) is refused `422`, its `detail` naming `realm` among the immutable fields and the recovery, `POST …/goals/{id}/supersede`, to file the successor with the new scope. Before T-35 the field was not declared, serde discarded it, and the call returned `200` having changed nothing — a silent loss this refusal closes. Deliberately OPPOSITE to `TaskPatch.realm`: a TASK's realm is amendable (T-169); a GOAL's is not. Same field name, opposite semantics on the two endpoints. GoalDispose: type: object description: | The body for achieve / abandon / reactivate. `reason` is REQUIRED for abandon and reactivate (the enum, so "why do goals die?" is measurable); optional for achieve (the evidence is the reason). A bare `other` requires a short `note`. properties: reason: type: string enum: [no_longer_relevant, superseded_elsewhere, too_costly, other] note: { type: string, maxLength: 280 } evidence_completion_ids: type: array items: { type: string, format: uuid } description: achieve only — the completions credited with meeting the intent (soft confirm, never a gate) GoalSupersede: type: object properties: successor_id: type: string format: uuid description: an ACTIVE goal in this project that replaces this one (the only way to change immutable content) required: [successor_id] GoalPage: type: object properties: items: type: array items: { $ref: "#/components/schemas/Goal" } next_cursor: { type: string, nullable: true } required: [items] GoalRef: type: object description: | One goal in a claim bundle's goal set (ADR-0042) — the intent the claimed task serves, carried WITH the work so the claiming agent reads the "why" without a second query. Only ACTIVE, non-stale goals ride the bundle (a stale or disposed goal silently stops steering). properties: id: { type: string, format: uuid } statement_md: { type: string } horizon: { type: string, enum: [now, next, later] } success_signals: type: array items: { $ref: "#/components/schemas/SuccessSignal" } required: [id, statement_md, horizon, success_signals] DodItem: type: object description: | One declared definition-of-done criterion (ADR-0043) — an ACCEPTANCE criterion, not a doctrine object. It carries NO tenet/goal citations of its own: the direction is inherited transitively (dod_item → task → goal/tenets). `kind` tells the reader WHO/WHAT can verify it. The board stores the list verbatim and never judges a criterion; it only checks verdict COVERAGE at accept, never correctness. properties: id: type: string maxLength: 64 description: caller-chosen stable id the verdict references by (unique within the task) statement: { type: string, maxLength: 2000 } kind: type: string enum: [gate, check, judgment, operator] description: | gate = mechanically runnable (an agent self-verifies before submitting); check = inspectable; judgment = human taste the board never pretends to verify; operator (T-477) = only a human's reaction satisfies it — at completion the board raises one open decision per operator item, linked to the task, in the same transaction, so the review is parked until a human answers. required: [id, statement, kind] DodVerdict: type: object description: | One per-item verdict on a review (ADR-0043) — references a declared dod_items id. Shape-checked, never judgment-checked. properties: item_id: { type: string, description: must name a declared dod_items id } verdict: { type: string, enum: [pass, fail, na] } note: { type: string, description: optional reviewer context } required: [item_id, verdict] Proposal: type: object description: | A steering proposal (ADR-0022, lifecycle ADR-0023): an agent-filed, not-yet-claimable task shape a human accepts into the roadmap or rejects. Moves `open → endorsed → accepted` (or `rejected`). `open` is raw member output; `endorsed` is chair-vetted and revalidated against the live board (decision-ready, still not a task); `task_key` records what it became on a maintainer's accept. properties: id: { type: string, format: uuid } title: { type: string, maxLength: 300 } brief_md: { type: string, maxLength: 65536 } ownership: type: array description: paths/globs the task would touch (ADR-0011) items: { type: string, maxLength: 500 } deps: type: array description: task keys this would depend on items: { type: string } rationale_md: type: string maxLength: 16384 description: why this proposal, cited — the merit bar (required) status: { type: string, enum: [open, endorsed, accepted, rejected] } raised_by: { type: string, description: OIDC subject } created_at: { type: string, format: date-time } endorsed_by: type: string nullable: true description: the chair who endorsed (ADR-0023) endorsed_at: { type: string, format: date-time, nullable: true } endorsement_md: type: string nullable: true maxLength: 16384 description: the chair's synthesis — majority verdict plus dissent judges_md: type: string nullable: true maxLength: 16384 description: the fresh judges' verdicts, verbatim validated_at: type: string format: date-time nullable: true description: when the deps were last revalidated (at endorse); drift since is re-checked hard at accept resolved_by: { type: string, nullable: true } resolved_at: { type: string, format: date-time, nullable: true } reject_reason: { type: string, nullable: true, maxLength: 8192 } task_key: type: string nullable: true description: the task the proposal became, on accept tenet_ids: type: array items: { type: string, format: uuid } description: | ADR-0039 (citation): the tenets this proposal exists because of (provenance); inherited by the minted task at accept so the chain tenet → proposal → task holds. Self-reported, never enforced. required: [id, title, status, raised_by, created_at] ProposalCreate: type: object properties: title: { type: string, maxLength: 300 } brief_md: { type: string, maxLength: 65536 } ownership: type: array items: { type: string, maxLength: 500 } deps: type: array items: { type: string } rationale_md: { type: string, maxLength: 16384 } tenet_ids: type: array items: { type: string, format: uuid } maxItems: 50 description: | ADR-0039 (citation): the tenets this work exists because of (provenance). Inherited by the task on accept, so the chain tenet → proposal → task doesn't break at the dispose. required: [title, rationale_md] ProposalAccept: type: object description: | T-136: the OPTIONAL body of `POST …/proposals/{id}/accept`. Omitting the body entirely (no Content-Type) remains a valid accept; `{}` is equivalent. Carries the facts a maintainer reads off the LIVE board at disposal time, which the proposal row cannot hold. properties: precedes: type: array maxItems: 50 items: { type: string } description: | Task keys the minted task is born IN FRONT of — the mirror of the proposal's `deps`, exactly `TaskCreate.precedes`: each named task gains a dependency on the minted one in the same transaction. Validated by the shared filing pass, so accept refuses exactly as `POST …/tasks` does — 422 for a key that names no task, for a target that is `done`/`cancelled`/`completed`, and for a target already reachable upward from the proposal's `deps` (a cycle). This is the deliverable fix the accept-path overlap 409 names. Never on `ProposalCreate`: which tasks a fix must precede is a disposal-time fact, not a scoping-time one. goal_ids: type: array items: { type: string, format: uuid } description: | T-260 / ADR-0042: the goal set the minted task serves. Empty or omitted = inherit the proposal's own `goal_ids`; non-empty REPLACES them. Each must name an ACTIVE, non-stale goal in this project, else a plain 422 naming the id. ProposalAccepted: description: | The 200 body of `POST …/proposals/{id}/accept` (T-165): the `Proposal` (flattened — every key unchanged, `task_key` now set) plus the minted row's birth `notes`. Omitted when the minted row was born complete. Since T-260 the goal note fires only when neither the accept body nor the proposal named a goal; when it does, it reads verbatim: "no live goal band: `view=next` and the claims:next funnel both rank first on the most-urgent active, non-stale linked goal, and a row with none sorts NULLS LAST — below every goal-bearing row at any topology — and counts toward the docket's intent-dark share. Remedy: PATCH the task with `goal_ids_add`, or file it with `goal_ids`." allOf: - $ref: "#/components/schemas/Proposal" - type: object properties: notes: allOf: [{ $ref: "#/components/schemas/BirthNotes" }] description: | OPTIONAL, ABSENT ENTIRELY when the minted row was born complete (`skip_serializing_if = "Vec::is_empty"` — never `[]`). Read at the one moment the maintainer can still act: a minted row with no live goal sorts NULLS LAST below every goal-bearing row, so the work just accepted is never offered to a worker while any goal-bearing row is available, until `goal_ids_add` lands. ProposalReject: type: object properties: reason: { type: string, maxLength: 8192 } required: [reason] ProposalEndorse: type: object properties: endorsement_md: type: string maxLength: 16384 description: the chair's synthesis — majority verdict plus dissent judges_md: type: string maxLength: 16384 description: the fresh judges' verdicts, verbatim (name each judge) required: [endorsement_md] ProposalPage: type: object properties: items: type: array items: { $ref: "#/components/schemas/Proposal" } next_cursor: { type: string, nullable: true } required: [items] Observation: type: object description: | A client-reported fact about the world (ADR-0020) — as opposed to an event, which is a server-generated audit fact about board state. Append-only; there is no patch, no answer, no status. properties: id: { type: string, format: uuid } kind: type: string description: | Convention-tagged, not enumerated: `waste`, `friction`, `near-miss`, `measurement` are the base vocabulary; projects coin their own. Free-form by design — the working rule's "needed twice" trigger is evaluated over these, and an enum would preempt the evidence. statement: { type: string, maxLength: 2000 } context_md: { type: string, maxLength: 16384 } numbers: type: object nullable: true additionalProperties: true description: | measured quantities (turns wasted, cost_usd, ms) — structured so improvement loops can aggregate; the server never does (D76) task_keys: type: array items: { type: string } description: tasks this observation is about (links, not blocks) goal_ids: type: array items: { type: string, format: uuid } description: | ADR-0057 (S4): goals this observation cites — evidence accumulating against the doctrine itself (links, not blocks) tenet_ids: type: array items: { type: string, format: uuid } description: | ADR-0057 (S4): tenets this observation cites — evidence accumulating against the doctrine itself (links, not blocks) claim_id: { type: string, format: uuid, nullable: true } session_id: { type: string, format: uuid, nullable: true } actor: { type: string, description: OIDC subject } created_at: { type: string, format: date-time } required: [id, kind, statement, actor, created_at] ObservationCreate: type: object properties: kind: { type: string, minLength: 1, maxLength: 100 } statement: { type: string, minLength: 1, maxLength: 2000 } context_md: { type: string, maxLength: 16384 } numbers: type: object additionalProperties: true task_keys: type: array maxItems: 100 description: | existing task keys this observation is about; unknown keys are a 422 (they would silently fail to link anything) items: { type: string } goal_ids: type: array description: | ADR-0057 (S4): ids of goals IN THIS PROJECT this observation cites; unknown ids are a 422 naming them (the task_keys pattern) items: { type: string, format: uuid } tenet_ids: type: array description: | ADR-0057 (S4): ids of tenets IN THIS PROJECT this observation cites; unknown ids are a 422 naming them (the task_keys pattern) items: { type: string, format: uuid } claim_id: { type: string, format: uuid } session_id: { type: string, format: uuid } required: [kind, statement] ObservationPage: type: object properties: items: type: array items: { $ref: "#/components/schemas/Observation" } next_cursor: { type: string, nullable: true } required: [items] ObservationFiled: description: | ADR-0064: what `POST /observations` answers — the `Observation` as stored plus, for a kind in `RECURRENCE_KINDS`, the board's `recurrence` verdict. ABSENT (not null) for every other kind. allOf: - $ref: "#/components/schemas/Observation" - type: object properties: recurrence: { $ref: "#/components/schemas/Recurrence" } Recurrence: type: object description: | ADR-0064: the board's recurrence verdict on one filing. `status` is a fact the board established; `next_step` is the board saying what follows from it — the exact act, its args, and that doing nothing is a legitimate answer (a recurring SUCCESS record is not a defect). properties: status: type: string enum: [first, repeat, recurs, mechanized] description: | `first` — no prior in another context · `repeat` — a prior in the SAME context only (claim ?? session ?? actor), not counted · `recurs` — a prior in another context; `key` may be attached, or `declined` says why none was · `mechanized` — the chain already spawned `task_key` oracle: type: string enum: [similarity, exact-only] description: | what the caller may believe: `similarity` — the vectors sidecar answered · `exact-only` — it was not asked (no URL configured, or an exact match answered first) or it failed inside the budget; only the exact-statement floor ran, so an absent match is not evidence of a first occurrence index_current: type: boolean description: the sidecar's own freshness verdict, when it answered chain_id: { type: string, format: uuid } occurrences: { type: integer, description: members of the chain including this filing (0 for first/repeat) } distinct_contexts: { type: integer } task_keys: type: array items: { type: string } description: the tasks the chain's members were filed under first_seen_at: { type: string, format: date-time } matched: type: array maxItems: 5 description: the precedent, best first items: type: object properties: observation_id: { type: string, format: uuid } similarity: { type: number } statement: { type: string } task_keys: { type: array, items: { type: string } } created_at: { type: string, format: date-time } required: [observation_id, similarity, statement, created_at] related_work: type: array maxItems: 5 description: LIVE task/proposal rows near the statement — read before filing another items: type: object properties: resource_kind: { type: string } resource_id: { type: string, format: uuid } key: { type: string } title: { type: string } status: { type: string } similarity: { type: number } required: [resource_kind, resource_id, title, similarity] task_key: { type: string, description: on `mechanized` — the chain's task } declined: type: string description: | T-606, on `recurs`: a curator DECLINED to mechanize this chain — the recorded reason. Present means no `key` was minted, by judgment rather than by count; `next_step` names the curator and how a decline is reversed. key: type: string description: | the one-time offer (`ork_…`), returned ONCE — the board stores only its SHA-256. Minted when the chain has no task, is not declined, spans at least `RECURRENCE_MIN_CONTEXTS` contexts and the caller holds the `worker` rung; an Auditor-role filer is told to propose instead. key_expires_at: { type: string, format: date-time } next_step: { type: string } required: [status, oracle, occurrences, distinct_contexts, next_step] ObservationChainSummary: type: object properties: id: { type: string, format: uuid } kind: { type: string } root_observation_id: { type: string, format: uuid, description: the FIRST occurrence } root_statement: { type: string } member_count: { type: integer } distinct_contexts: { type: integer } live_keys: { type: integer, description: offers minted, unconsumed and unexpired right now } task_key: { type: string, nullable: true, description: set once — the chain is mechanized } mechanized_at: { type: string, format: date-time, nullable: true } mechanized_by: { type: string, nullable: true } mechanized_via: type: string nullable: true enum: [key, curator, null] description: | T-368: how the chain was mechanized — `key` (an offer spent through `POST …/{id}/task`) or `curator` (`PATCH …/{id}` named an existing task). Null while the chain is open. declined_at: { type: string, format: date-time, nullable: true, description: "T-606 — when a curator declined to mechanize this chain; null unless declined (never on a mechanized chain)" } declined_by: { type: string, nullable: true, description: T-606 — the curator who declined } declined_reason: { type: string, nullable: true, description: "T-606 — why; the `declined` every next recurrence verdict carries" } created_at: type: string format: date-time description: when the chain ROW was created — for a backfilled chain, the backfill's date, not when the fact was first seen (see `first_seen_at`) first_seen_at: type: string format: date-time description: T-368 — the root observation's `created_at`, when this fact was FIRST seen (the same value the recurrence verdict reports) last_seen_at: { type: string, format: date-time } required: [id, kind, root_observation_id, root_statement, member_count, distinct_contexts, live_keys, created_at, first_seen_at, last_seen_at] ObservationChainPatch: type: object additionalProperties: false description: | The curator's disposition — exactly ONE of `task_key` (T-368, "the mechanism exists") or `declined` (T-606). Unknown fields are refused. properties: task_key: { type: string, description: "the task (e.g. `T-348`) that already is this chain's mechanism" } declined: { type: boolean, description: "`true` — we choose not to mechanize this chain (needs `reason`) · `false` — reverse a decline" } reason: { type: string, minLength: 1, maxLength: 2000, description: "why — required with `declined: true`, optional with `declined: false` (its digest rides the trail), refused with `task_key`" } ObservationChainMember: type: object properties: observation_id: { type: string, format: uuid } statement: { type: string } actor: { type: string } claim_id: { type: string, format: uuid, nullable: true } session_id: { type: string, format: uuid, nullable: true } task_keys: { type: array, items: { type: string } } created_at: { type: string, format: date-time } matched_observation_id: { type: string, format: uuid, nullable: true, description: what it matched when it joined (null for the root) } similarity: { type: number, nullable: true } method: { type: string, nullable: true, enum: [exact, similarity, null] } key_status: type: string enum: [none, live, consumed, expired] description: the offer's STATUS — never the key, never its hash key_expires_at: { type: string, format: date-time, nullable: true } key_minted_to: { type: string, nullable: true } via: type: string enum: [filing, backfill] description: established when the observation was filed, or computed later by the maintainer's backfill required: [observation_id, statement, actor, created_at, key_status, via] ObservationChainDetail: allOf: - $ref: "#/components/schemas/ObservationChainSummary" - type: object properties: members: type: array items: { $ref: "#/components/schemas/ObservationChainMember" } required: [members] ObservationChainPage: type: object properties: items: type: array items: { $ref: "#/components/schemas/ObservationChainSummary" } next_cursor: { type: string, nullable: true, description: "`:`" } required: [items] ObservationChainBackfillRequest: type: object properties: kinds: type: array items: { type: string } description: kinds to walk; omitted = the board's `RECURRENCE_KINDS` limit: { type: integer, minimum: 1, maximum: 200, default: 100 } after: { type: string, format: uuid, description: the previous page's `next_cursor` } ObservationChainBackfillReport: type: object description: per-CALL counts; a client summing pages gets the whole run properties: processed: { type: integer, description: rows examined (eligible kinds, id order) } already_chained: { type: integer, description: skipped — already a member (idempotency) } firsts: { type: integer } repeats: { type: integer } chained: { type: integer, description: joined or rooted a chain this call } chains_created: { type: integer } oracle_unavailable: { type: integer, description: oracle calls that failed inside the budget (those rows took the exact floor only) } next_cursor: { type: string, format: uuid, description: present when more rows remain } required: [processed, already_chained, firsts, repeats, chained, chains_created, oracle_unavailable] ObservationChainTaskRequest: type: object properties: key: { type: string, description: the one-time offer exactly as `POST /observations` returned it } task: { $ref: "#/components/schemas/TaskCreate" } required: [key, task] ObservationChainTaskFiled: type: object description: | ADR-0064: the task exactly as `POST /tasks` answers it (its birth `notes` included) and the chain it mechanized. Nested rather than flattened on purpose — one flatten fewer to collide (T-332). properties: task: { $ref: "#/components/schemas/TaskDetail" } chain: { $ref: "#/components/schemas/ObservationChainSummary" } required: [task, chain] Session: type: object properties: id: { type: string, format: uuid } org: { type: string } project: { type: string } subject: { type: string } kind: type: string enum: [task, eval, ideas, menu, user-order, other, review] description: | What the stint is, as opened (never recomputed). `task` — a worker stint (`aurora-agent next` / `drill`, including the fused funnel when it hands back a review: the kind is chosen before the board decides); `review` — a reviewer stint opened by `aurora-agent review-next` (T-143; accepted server-side since T-463, migration 0042), so review burn and reviewer observations (anchored on `session_id`, with no claim row) are identifiable by kind; `ideas` — a committee/steering run holding no claim; `eval`, `menu`, `user-order`, `other` — the remaining declared kinds. Reviewer sessions opened before T-143 were recorded as `task` and are not backfilled — the `reviews.session_id` join is the retro derivation. harness_ref: { type: string, nullable: true } model_class: type: string maxLength: 120 nullable: true description: | Self-reported model label — the ADR-0031 measurement axis, stored verbatim (D76). prompt_ref: type: string pattern: '^sha256:[0-9a-f]{64}$' nullable: true description: | Content-address of the standing prompt material the harness injected (ADR-0055, E-16) — attribution, never content. opened_at: { type: string, format: date-time } closed_at: { type: string, format: date-time, nullable: true } stats: { type: object, additionalProperties: true, nullable: true } required: [id, subject, kind, opened_at] SessionPatch: type: object properties: close: { type: boolean } model_class: type: string maxLength: 120 nullable: true description: | Set/replace the session's self-reported model label (the ADR-0031 measurement axis — e.g. when it was unknown at open). Stored verbatim; never recomputed (D76). harness_ref: type: string maxLength: 200 nullable: true description: | FILL-ONCE (T-298): the join key to the harness run that produced this session (ADR-0062 B.9). Admitted only while NULL — the pool path's back-fill seam — and a second PATCH naming a different value is refused 409 (a lineage marker is never overwritten; a same-value re-report is idempotent). prompt_ref: type: string pattern: '^sha256:[0-9a-f]{64}$' nullable: true description: | FILL-ONCE (T-298), same discipline as `harness_ref`: the content-address of the standing prompt material the harness injected (ADR-0055, E-16) — attribution, never content. stats: type: object additionalProperties: true description: | harness-derived numbers (tokens, cost, llm/tool/stall/idle split). The server stores, never recomputes (Aurora D76). kind: type: string enum: [task, eval, ideas, menu, user-order, other, review] nullable: true description: | RETAG (T-534). Exactly one transition is admitted: `task` → `review`, and only while the session anchors a review assignment and no work claim — the fused `claims:next` funnel opens its session as `task` before it decides, and a review offer makes the stint a review stint. A same-value re-report is idempotent. Any other transition, or a retag that would be untrue, is refused 409 with the stored kind; an unknown kind is 422. Event: type: object properties: id: { type: integer, format: int64 } at: { type: string, format: date-time } actor: { type: string, description: OIDC subject (or "system") } kind: type: string description: | task.filed, task.amended, claim.acquired, claim.released, claim.expired, claim.takeover, task.completed, decision.raised, decision.answered, grant.added, org.created (see `OrgCreated`), pool.started / pool.paused / pool.cooldown / pool.resumed / pool.draining / pool.stopped (see `PoolEventIn`), the ADR-0070 bridge facts pool.started / pool.adjusted / pool.paused / pool.resumed / pool.cooldown / pool.draining / pool.stopped / run.spawned / run.session / run.exited (see `PoolEventReport`; their payload carries `bridge_id`), ... `claim.acquired` and `review.assigned` payloads carry `session_id` (the run's board session, null when none was named) so a replay keys one figure per run (T-429). Routine heartbeats deliberately do not produce events (Aurora lesson: unbounded telemetry namespaces, evaluation §3.4). task_key: { type: string, nullable: true } claim_id: { type: string, format: uuid, nullable: true } payload: { type: object, additionalProperties: true } required: [id, at, actor, kind] PoolEventIn: type: object additionalProperties: false description: | One pool lifecycle transition as the bridge reports it (T-429). Stored as the event's payload `{cause, pool, until}`. Events carry references, never content (ARCHITECTURE.md), so the why is a CODE (the bridge's classified cause), not prose. required: [kind] properties: kind: type: string enum: [pool.started, pool.paused, pool.cooldown, pool.resumed, pool.draining, pool.stopped] cause: type: string pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$" description: | the transition's cause code (e.g. provider_limit, provider_auth, breaker, rail, operator); REQUIRED on pool.paused and pool.cooldown pool: type: string pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$" description: the bridge's own name for the pool until: type: string format: date-time description: pool.cooldown only — the instant the cooldown ends by itself PoolEventReport: description: | One bridge lifecycle fact (ADR-0070, `POST …/pool-events`). Every kind carries the envelope `{kind, bridge_id, at}`; each kind adds exactly its own fields below and nothing else (an unknown field is 422). The recorded event's `kind` is the report's `kind`; its `payload` is the rest of the report. `bridge_id`, `run_id`, `publish`, `cause` and `error` all match `^[a-z0-9][a-z0-9_.-]{0,63}$` — codes, never prose. discriminator: propertyName: kind mapping: pool.started: "#/components/schemas/PoolStarted" pool.adjusted: "#/components/schemas/PoolAdjusted" pool.paused: "#/components/schemas/PoolPaused" pool.resumed: "#/components/schemas/PoolResumed" pool.cooldown: "#/components/schemas/PoolCooldown" pool.draining: "#/components/schemas/PoolDraining" pool.stopped: "#/components/schemas/PoolStopped" run.spawned: "#/components/schemas/RunSpawned" run.session: "#/components/schemas/RunSession" run.exited: "#/components/schemas/RunExited" oneOf: - $ref: "#/components/schemas/PoolStarted" - $ref: "#/components/schemas/PoolAdjusted" - $ref: "#/components/schemas/PoolPaused" - $ref: "#/components/schemas/PoolResumed" - $ref: "#/components/schemas/PoolCooldown" - $ref: "#/components/schemas/PoolDraining" - $ref: "#/components/schemas/PoolStopped" - $ref: "#/components/schemas/RunSpawned" - $ref: "#/components/schemas/RunSession" - $ref: "#/components/schemas/RunExited" PoolEventKind: type: string description: the closed set `POST …/pool-events` admits (routes/pool_events.rs `POOL_EVENT_KINDS`) enum: - pool.started - pool.adjusted - pool.paused - pool.resumed - pool.cooldown - pool.draining - pool.stopped - run.spawned - run.session - run.exited PoolEventEnvelope: type: object required: [kind, bridge_id, at] properties: kind: { $ref: "#/components/schemas/PoolEventKind" } bridge_id: type: string pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$" description: | the reporting bridge's stable per-machine id, minted by the bridge. Two ids on one project are two publishers. at: type: string format: date-time description: | the bridge's own instant for the fact — the time half of the idempotency key. Stored in one canonical RFC 3339 spelling (UTC, `Z`); the event's top-level `at` is the board's receipt time. PoolStarted: description: "pool.started — the operator's consent: the pool's bounds as started" allOf: - $ref: "#/components/schemas/PoolEventEnvelope" - type: object required: [limit, reviewer_max, publish] properties: kind: { type: string, enum: [pool.started] } limit: { type: integer, minimum: 0, description: concurrent run limit } reviewer_max: { type: integer, minimum: 0, description: concurrent reviewer cap } publish: type: string pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$" description: the bridge's publish level (`level0`, `level1`, …) duration_secs: type: integer minimum: 0 nullable: true description: the pool's bounded run length; absent or null = unbounded PoolAdjusted: description: pool.adjusted — the bounds changed while running allOf: - $ref: "#/components/schemas/PoolEventEnvelope" - type: object required: [limit, reviewer_max] properties: kind: { type: string, enum: [pool.adjusted] } limit: { type: integer, minimum: 0 } reviewer_max: { type: integer, minimum: 0 } PoolPaused: description: pool.paused — spawning stopped until resumed allOf: - $ref: "#/components/schemas/PoolEventEnvelope" - type: object required: [cause] properties: kind: { type: string, enum: [pool.paused] } cause: type: string pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$" description: the bridge's classified cause (provider_auth, breaker, rail, operator, …) PoolResumed: description: pool.resumed — the envelope only allOf: - $ref: "#/components/schemas/PoolEventEnvelope" - type: object properties: kind: { type: string, enum: [pool.resumed] } PoolCooldown: description: pool.cooldown — spawning stopped until `until`, then resumes by itself allOf: - $ref: "#/components/schemas/PoolEventEnvelope" - type: object required: [until, cause] properties: kind: { type: string, enum: [pool.cooldown] } until: { type: string, format: date-time } cause: type: string pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$" description: the provider's classified reason (provider_limit, …) PoolDraining: description: pool.draining — no new spawns; running runs finish allOf: - $ref: "#/components/schemas/PoolEventEnvelope" - type: object required: [cause] properties: kind: { type: string, enum: [pool.draining] } cause: type: string pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$" PoolStopped: description: pool.stopped — the pool's closing totals allOf: - $ref: "#/components/schemas/PoolEventEnvelope" - type: object required: [spawned_total, spent_usd, stranded] properties: kind: { type: string, enum: [pool.stopped] } spawned_total: { type: integer, minimum: 0 } spent_usd: { type: number, minimum: 0 } stranded: { type: integer, minimum: 0, description: runs the stop left un-closed } RunSpawned: description: run.spawned — a run arrived allOf: - $ref: "#/components/schemas/PoolEventEnvelope" - type: object required: [run_id, run_kind] properties: kind: { type: string, enum: [run.spawned] } run_id: type: string pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$" description: the bridge's id for the run run_kind: type: string enum: [worker, reviewer, committee, audit] description: what was spawned (named `run_kind` because `kind` is the event's own) RunSession: description: run.session — the run's board session, once its beat names it allOf: - $ref: "#/components/schemas/PoolEventEnvelope" - type: object required: [run_id, board_session_id] properties: kind: { type: string, enum: [run.session] } run_id: { type: string, pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$" } board_session_id: { type: string, format: uuid } RunExited: description: run.exited — a run left allOf: - $ref: "#/components/schemas/PoolEventEnvelope" - type: object required: [run_id, status] properties: kind: { type: string, enum: [run.exited] } run_id: { type: string, pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$" } status: { type: string, enum: [done, failed, killed] } error: type: string pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$" nullable: true description: the failure's code, when there is one TrailEntryIn: type: object description: | One command as the harness reports it (ADR-0062). `argv` is the full command line for cli/gate/git/bash-class entries; edit-class entries carry paths and sizes, not contents (unless `capture: full`), and NO entry ever carries tool OUTPUT — `ref` points at evidence's real home. required: [seq, at, source, name] properties: seq: type: integer format: int32 minimum: 0 description: | Gapless, per-session ordinal. Also the SSE event id, which is why it may not be negative (the read cursor's "from the start" is -1). at: type: string format: date-time description: harness-reported (D76 — the board stores, never recomputes) source: type: string enum: [cli, tool, gate, git, witness, bridge] name: { type: string, minLength: 1, maxLength: 200 } argv: { type: string, maxLength: 8192, nullable: true } outcome: type: string enum: [ok, refused, error] nullable: true detail: { type: string, maxLength: 200, nullable: true } duration_ms: { type: integer, format: int32, minimum: 0, nullable: true } ref: type: string maxLength: 200 nullable: true description: a pointer to evidence stored elsewhere, never the evidence backfilled: type: boolean default: false description: | Reconstruction, never fabrication. Admissible ONLY on the admin path into a closed, EMPTY session; refused (422) on a live append. TrailEntry: allOf: - $ref: "#/components/schemas/TrailEntryIn" description: An entry as stored and read back. TrailSeal: type: object required: [count, digest] properties: count: { type: integer, format: int32, minimum: 0 } digest: type: string pattern: '^sha256:[0-9a-f]{64}$' description: | The running digest over the canonical entry sequence. A resumed session MUST rebuild this by replaying the stored trail before appending, so a seal can never skip history. TrailBatch: type: object required: [entries] properties: entries: type: array maxItems: 100 items: { $ref: "#/components/schemas/TrailEntryIn" } description: at most 100 entries, serializing to at most 512 KiB claim_id: type: string format: uuid nullable: true description: the claim these entries belong to (a stint's entries share it) seal: allOf: - $ref: "#/components/schemas/TrailSeal" nullable: true description: | Present only on the final batch. Sealing twice with an IDENTICAL seal is a no-op; a DIFFERENT seal is a 409. TrailPostResponse: type: object required: [accepted, sealed] properties: accepted: type: integer format: int64 description: | How many entries were newly stored. Zero means every seq in the batch was already present — an idempotent replay, not a failure. max_seq: { type: integer, format: int32, nullable: true } sealed: { type: boolean } TrailReadResponse: type: object required: [items] properties: items: type: array items: { $ref: "#/components/schemas/TrailEntry" } seal: allOf: - $ref: "#/components/schemas/TrailSeal" nullable: true description: absent while the run is live, or if it ended without a close redactions: type: array description: | Every redaction ever applied to this trail (T-212), oldest first, read from the chained `content.redacted` events — the whole list, not just the page. items: { $ref: "#/components/schemas/TrailRedaction" } digest_note: type: string nullable: true description: | Present exactly when the trail is SEALED and has been redacted: the stored digest no longer recomputes over what is stored now, and that mismatch is redaction, not tampering (ADR-0062). Null when no explanation is owed. TrailRedaction: type: object description: one redaction act, as the chain recorded it (T-212) required: [seq, fields, at, actor] properties: seq: { type: integer, format: int32 } fields: type: array description: the content columns that were tombed, by API name items: { type: string, enum: [name, argv, detail, ref] } at: { type: string, format: date-time } actor: { type: string } TrailRedactRequest: type: object properties: fields: type: array minItems: 1 description: | Which content columns to tomb. Omitted = `argv`, `detail`, `ref` (the free-text columns a secret reaches); `name` must be asked for by name. Every other column is the shape of the run and is never edited. items: { type: string, enum: [name, argv, detail, ref] } confirm: type: boolean description: must be `true` — the explicit ack for an irreversible act (422 otherwise) TrailRedactResponse: type: object required: [seq, redacted, sealed] properties: seq: { type: integer, format: int32 } redacted: type: array description: the columns this call actually tombed, canonical order — empty on an idempotent replay items: { type: string, enum: [name, argv, detail, ref] } sealed: type: boolean description: whether the trail was sealed — if so its stored digest no longer recomputes, deliberately TaskRun: type: object description: | One run of a task. A run is a LEASE on the task, and there are two lease types: a work claim and a review assignment (settled decision #7 — reviews reuse this composition). A task's story is unreadable without the review half: a review has no claim row, so a claims-only read renders the reviewer's work as if it never happened. Runs are ordered by `acquired_at`, which is board-assigned on BOTH lease types — so a client orders runs on ONE clock and orders entries WITHIN a run by `seq`. Nothing needs to sort the harness's self-reported timestamps against the board's, which is what makes a continuous task timeline honest rather than merely plausible. Runs predating ADR-0062 render `seal_status: none`. required: [run_id, kind, holder, state, entry_count, backfilled_entries, seal_status] properties: run_id: type: string format: uuid description: | The lease this run IS — a claim id for `work`, a review id for `review`. The board's unit of "a run" is the lease, never the identity: under the fleet path a fresh sub is minted per run, so `holder` is not a stable agent name in either direction. kind: type: string enum: [work, review] acquired_at: type: string format: date-time nullable: true description: board-assigned on both lease types — the run ordering key claim_id: type: string format: uuid nullable: true description: | The claim, when this run is a claim. NULL for a review — a review lease has no claim row, by construction. holder: type: string description: the claim's holder, or the review's reviewer state: type: string enum: [active, released, completed, expired, accepted, rejected] description: | The lease's state. A `work` run is active | released | completed | expired (the claim); a `review` run is active | accepted | rejected | released | expired (the review row). close_reason: type: string nullable: true description: | Why the LEASE closed (release reason, expiry). Null on an adjudicated review — the verdict's words are `verdict_reason`. verdict_reason: type: string nullable: true description: | T-503. On a `review` run, the reviewer's own words for its accept or reject (`reviews.verdict_reason`) — the WHY behind a `rejected`. Always null on a `work` run, and on a review still active or closed without a verdict. dod_verdicts: type: array nullable: true items: { $ref: "#/components/schemas/DodVerdict" } description: | T-503. On a `review` run, the per-item definition-of-done verdicts verbatim as the reviewer filed them — which item failed, not only that the review did. Null on a `work` run and on a review that filed none. session_id: { type: string, format: uuid, nullable: true } opened_at: { type: string, format: date-time, nullable: true } closed_at: { type: string, format: date-time, nullable: true } entry_count: { type: integer, format: int64 } backfilled_entries: type: integer format: int64 description: | How many of `entry_count` are reconstructions. A DIFFERENT question from `seal_status`, and it has to be, because a trail can be sealed AND partly reconstructed — backfill a claim's session, then let the live run continue into it. `seal_status` reports that as a clean `sealed` (a seal does exist), so provenance read off that field alone would present a partly reconstructed record as wholly trustworthy. Non-zero means the run's record is partial by construction, whatever its seal says. seal_status: type: string enum: [sealed, unsealed, reconstructed, none] seal_count: { type: integer, format: int32, nullable: true } seal_digest: { type: string, nullable: true } review_state: type: string nullable: true description: the verdict a review of THIS claim's completion landed, if any stats: type: object additionalProperties: true nullable: true description: the session's self-reported stats, verbatim (D76) bundle_bytes_total: type: integer format: int32 nullable: true description: E-07's board-measured claim-bundle size TaskRunsResponse: type: object required: [runs] properties: runs: type: array items: { $ref: "#/components/schemas/TaskRun" }