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 projection of the Node's reported capability manifest paired with its checksum status. It carries the binary metadata, the checksum verdict, and the built-in action inventory the agent advertised; the hook lists are not projected here. 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). |
builtin_actions | array<BuiltinAction> | no | The built-in actions the Node's agent advertised on its most recent capability manifest, in the order it reported them. Always present; an empty array means the Node reported no inventory. |
reported_at | string (date-time) | yes | Timestamp at which the capability manifest snapshot was last reported (UTC). |
An empty builtin_actions array does NOT mean the Node supports no actions. An agent that predates the field and one that reports an empty list are indistinguishable here, so the absence is not grounds to refuse a dispatch — the Node remains the authority on what it can run. Read the inventory to offer a Node's actual actions on an operator surface rather than a fixed list.
BuiltinAction
One built-in action a Node's agent advertises.
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Action identifier (for example diagnostics.collect). The value a dispatch puts in DispatchExecutionRequest.action alongside kind: builtin. Unique within one manifest. |
description | string | no | Human-readable summary shown beside the action name. |
parameters | array<BuiltinActionParameter> | no | The parameters the action accepts, in the order the agent reported them — which is the order an operator surface should present them in. |
BuiltinActionParameter
One parameter declaration inside a BuiltinAction.
Every field but name is descriptive: plexsphere records what the agent reported and validates nothing against it. type is not a closed enum and required does not gate dispatch — an execution carrying the wrong parameters is refused by the Node that runs it, which is the only place that knows the current truth.
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Parameter identifier as the action expects it in a dispatch's parameters object. |
type | string | no | Agent-reported type name (for example string, int, duration). Free-form. |
required | boolean | no | Whether the agent reports the parameter as mandatory. Descriptive only. |
default | string | no | Default value the agent applies when the parameter is omitted. |
description | string | no | Human-readable summary of the parameter. |
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.