Appearance
Capabilities HTTP API
This is the reference for the /v1/projects/{project_id}/capabilities HTTP surface. It maps the ListCapabilities operation to its OpenAPI schema, ReBAC gate, audit emission, and the closed Problem.code taxonomy. The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract. The cursor + limit pagination idiom and the per-row visibility filter are established by the sibling Nodes surface — see ./nodes.md for the design rationale this surface inherits.
The capability inventory is an operator-facing, cross-Domain platform view: each row pairs a Node's reported plexd agent binary with a checksum status — match when the reported checksum equals a known-good artifact, drift when it differs, and unknown when no known-good artifact is on record. The handler runs the platform read gate before the persistence read, then layers a per-row visibility filter on the Domain owning each Node so items is the subset the caller is authorised to see.
Operations
| Method | Path | Operation ID | ReBAC gate | Audit relation | Outbox event | Body cap |
|---|---|---|---|---|---|---|
| GET | /v1/projects/{project_id}/capabilities | ListCapabilities | top-level platform#read + per-row domain#read filter | capability.list (page-level row) | (none) | n/a |
ListCapabilities.limitquery parameter is clamped at the handler to[1, 200]with default50. An out-of-range value is silently brought into range rather than rejected, so a paging client never breaks on a tuning mismatch.ListCapabilities.cursoris opaque, HMAC-signed by the server and bound to the per-(caller, pepper) pseudonym (the codec is theCursorCodecport wired at the composition root). A tampered or malformed cursor surfaces as400 invalid_cursor; a cursor minted by one caller and replayed by another surfaces as403 cursor_binding_mismatch. A nil codec is tolerated in unit-test composition roots and behaves as an identity passthrough.ListCapabilities.node_idis an optional UUID filter scoping the page to a single Node's capability row. Omitting the parameter widens to every Node in the Project the caller is authorised to see.ListCapabilities.statusis an optional checksum-status filter (match,drift,unknown). When present, only rows in the named status are returned.- The handler ships behind a fail-closed scaffold gate — until the composition root wires both the
CapabilityListerservice and theAuthorizer, the listing surface returns501 capabilities_not_provisionedso the surface is either fully wired or fully off.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| ListCapabilities | project_id (path) | string (uuid) | yes | Owning Project (UUIDv7). Zero UUID → 400 invalid_project_id. |
| ListCapabilities | node_id (query) | string (uuid) | no | Optional single-Node filter. |
| ListCapabilities | status (query) | CapabilityStatus | no | Optional checksum-status filter (match / drift / unknown). |
| ListCapabilities | cursor (query) | string | no | Opaque HMAC-signed continuation bound to the caller. Tampered/malformed → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch. |
| ListCapabilities | limit (query) | integer | no | [1, 200], default 50. Out-of-range values are clamped, not rejected. |
Schemas
CapabilityStatus
Closed enum classifying a Node's reported agent binary against the known-good artifact set. The value is computed by the read service when it joins the reported manifest against the known-good artifacts; the transport never reclassifies.
| Value | Meaning |
|---|---|
match | Reported checksum equals a known-good artifact. |
drift | Reported checksum differs from the known-good artifact. |
unknown | No known-good artifact is on record for the reported binary. |
CapabilityRow
One per-Node capability inventory row — a metadata-only projection of the Node's reported capability manifest paired with its checksum status. The shape is returned by ListCapabilities.
| Field | Type | Required | Notes |
|---|---|---|---|
node_id | string (uuid) | yes | Node the row describes (UUIDv7). |
domain_id | string (uuid) | yes | Owning Domain (UUIDv7) — the residency pivot the per-row visibility filter authorises against. |
binary_version | string | yes | Reported plexd agent version string of the Node's running binary. |
binary_checksum | string (byte) (nullable) | no | SHA-256 digest of the running binary, 32 bytes base64-encoded with standard padding. Absent when the Node has not yet reported a checksum. |
status | CapabilityStatus | yes | Checksum status (match / drift / unknown). |
reported_at | string (date-time) | yes | Timestamp at which the capability manifest snapshot was last reported (UTC). |
CapabilityRowList
Page of capability rows returned by ListCapabilities. The window is computed by the persistence layer in node_id-ascending order; per-row visibility is layered on top, so the items array is the subset the caller is authorised to see — len(items) < limit is NOT a reliable end-of-stream signal. Consult next_cursor instead.
| Field | Type | Required | Notes |
|---|---|---|---|
items | array<CapabilityRow> | yes | Capability rows in the current page (post per-row visibility filter). |
next_cursor | string (nullable) | no | Opaque HMAC-signed continuation reflecting the persistence-layer page boundary. Absent or null at end-of-stream. |
ReBAC contract
The surface gates two orthogonal authorisation axes. A top-level gate authorises read on the single platform object before the persistence read so an unauthorised caller never observes the existence side-channel of the Project's capability set. A per-row filter then re-checks read on each row's owning domain:<uuid>, trimming the cohort to the residency the caller may actually see.
| Operation | Gate | Relation | Subject | Object | On denial |
|---|---|---|---|---|---|
| ListCapabilities | top-level platform gate | read | resolved principal projected onto the subject string | platform:plexsphere | 403 PermissionDenied; an audit row is stamped with outcome permission_denied before the response is flushed. |
| ListCapabilities | per-row Domain filter | read | same resolved principal subject | domain:<uuid> for each candidate row | row filtered out of items; per-row denial NOT audited individually (page-level row carries count + authz_errors). |
Both checks flow through the narrow Authorizer port (see ../../../internal/transport/http/v1/capabilities/wiring.go), which takes a canonical (subject, relation, object) tuple. The handler projects the resolved authn.Principal onto the subject string at the boundary so both Check calls are symmetric with the sibling list surfaces.
Per-row authz failure mode is fail-closed with observability: ErrPermissionDenied (or any error wrapping it) drops the row silently; any OTHER error class drops the row, emits a slog.WarnContext breadcrumb, and increments the audit row's authz_errors counter so an operator can see drift between the persistence-layer page and the post-filter response.
Error taxonomy
The closed Problem.code set this surface emits, exactly as defined in ../../../internal/transport/http/v1/capabilities/errors.go. The Origin column names the layer (handler / service / transport) so a future maintainer knows where to grep when the code changes.
| HTTP status | Problem.code | Origin | Trigger |
|---|---|---|---|
| 400 | invalid_project_id | handler | Path project_id was a zero UUID. |
| 400 | invalid_cursor | handler | List cursor failed CursorCodec.Decode (tampered or malformed continuation token). |
| 401 | unauthenticated | handler | No resolved principal (or authn.KindUnknown). |
| 403 | cursor_binding_mismatch | handler | List cursor was minted for a different caller than the one presenting it (per-(caller, pepper) HMAC binding rejected the replay). |
| 403 | (PermissionDenied) | transport | Top-level platform read gate denial (separate PermissionDenied schema, not Problem). Per-row Domain denials never reach this gate — they are dropped from items instead. |
| 500 | internal | handler / service | CapabilityLister.List returned a non-nil error, or CursorCodec.Encode failed on the next-page cursor. The underlying error text is logged via slog.ErrorContext and NEVER leaks to the wire. |
| 501 | capabilities_not_provisioned | handler | Scaffold fail-closed gate: the CapabilityLister service OR the Authorizer is not wired in this build. |
Audit & outbox contract
ListCapabilities is a read-only surface, so it never writes an outbox event. The read service leaves no domain-event trail for the list operation — the post-filter cohort size is a transport-layer projection — so the handler emits exactly one page-level audit row per outcome via the wired AuditSink. Sink errors are NOT propagated to the caller: a flaky audit backend cannot turn a successful read into a 5xx, and the slog.WarnContext breadcrumb emitted on a sink error is the operator's tripwire.
| Operation | Outcome | Audit relation | Audit outcome | Outbox event |
|---|---|---|---|---|
| ListCapabilities | success | capability.list (page-level row) | granted | (none) |
| ListCapabilities | top-level gate denial | capability.list | permission_denied | (none) |
| ListCapabilities | 401 / 400 / 500 / 501 | (none — pre-handler arms exit before the audit emission) | n/a | (none) |
The audit row stamps the canonical platform singleton (platform:plexsphere) as its Object because the list operation is cross-Project and cross-Domain — there is no single aggregate to address. The caveat_context map carries:
count— the number of rows in the response after per-row visibility filtering. A reader can comparecounttolimitto detect heavy filtering.persistence_count— present only when the per-row Domain filter dropped at least one row. Surfaces the pre-filter persistence-page size so an auditor of a suspected over-broad cross-Domain read can see the drop ratio. Absent when no row was withheld.authz_errors— present only when the per-row filter dropped at least one row due to an infrastructural fault (transport flake, schema drift). Absent when every drop was a cleanErrPermissionDenied.
A top-level denial emits its audit row with outcome permission_denied and a missing_relation caveat naming the relation that was missing (read); the row is written before the 403 body is flushed.
Cross-references
./nodes.md— sibling reference that established the cursor + limit pagination pattern, the per-rowreadReBAC filter with fail-closed-plus-observability drop semantics, and the page-level audit row contract this surface inherits../projects.md— owning Project surface; the capability inventory is scoped to Nodes in a single Project.../../contexts/identity/rebac.md— relation graph behindplatform#readanddomain#read../index.md— platform-wide/v1HTTP surface map.../../../api/openapi/plexsphere-v1.yaml— authoritative OpenAPI 3.1 contract; this doc is a map, not a duplicate.../../../internal/transport/http/v1/capabilities/list.go— handler body; authenticated-principal gate, top-level platform read gate, limit clamp / cursor decode arms, per-row Domain visibility filter, page-level audit emission.../../../internal/transport/http/v1/capabilities/errors.go— closedProblem.codetaxonomy, RFC 9457 problem rendering, the audit-firstPermissionDeniedemitter, and theErrPermissionDeniedandErrCursorBindingMismatchsentinels.../../../internal/transport/http/v1/capabilities/wiring.go—CapabilityLister,Authorizer,AuditSink, andCursorCodecport declarations plus theDepsbundle and limit-clamp constants.