Skip to content

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

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
GET/v1/projects/{project_id}/capabilitiesListCapabilitiestop-level platform#read + per-row domain#read filtercapability.list (page-level row)(none)n/a
  • ListCapabilities.limit query parameter is clamped at the handler to [1, 200] with default 50. An out-of-range value is silently brought into range rather than rejected, so a paging client never breaks on a tuning mismatch.
  • ListCapabilities.cursor is opaque, HMAC-signed by the server and bound to the per-(caller, pepper) pseudonym (the codec is the CursorCodec port wired at the composition root). A tampered or malformed cursor surfaces as 400 invalid_cursor; a cursor minted by one caller and replayed by another surfaces as 403 cursor_binding_mismatch. A nil codec is tolerated in unit-test composition roots and behaves as an identity passthrough.
  • ListCapabilities.node_id is 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.status is 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 CapabilityLister service and the Authorizer, the listing surface returns 501 capabilities_not_provisioned so the surface is either fully wired or fully off.

Path & query parameters

OperationParameterTypeRequiredNotes
ListCapabilitiesproject_id (path)string (uuid)yesOwning Project (UUIDv7). Zero UUID → 400 invalid_project_id.
ListCapabilitiesnode_id (query)string (uuid)noOptional single-Node filter.
ListCapabilitiesstatus (query)CapabilityStatusnoOptional checksum-status filter (match / drift / unknown).
ListCapabilitiescursor (query)stringnoOpaque HMAC-signed continuation bound to the caller. Tampered/malformed → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch.
ListCapabilitieslimit (query)integerno[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.

ValueMeaning
matchReported checksum equals a known-good artifact.
driftReported checksum differs from the known-good artifact.
unknownNo 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.

FieldTypeRequiredNotes
node_idstring (uuid)yesNode the row describes (UUIDv7).
domain_idstring (uuid)yesOwning Domain (UUIDv7) — the residency pivot the per-row visibility filter authorises against.
binary_versionstringyesReported plexd agent version string of the Node's running binary.
binary_checksumstring (byte) (nullable)noSHA-256 digest of the running binary, 32 bytes base64-encoded with standard padding. Absent when the Node has not yet reported a checksum.
statusCapabilityStatusyesChecksum status (match / drift / unknown).
builtin_actionsarray<BuiltinAction>noThe 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_atstring (date-time)yesTimestamp 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.

FieldTypeRequiredNotes
namestringyesAction identifier (for example diagnostics.collect). The value a dispatch puts in DispatchExecutionRequest.action alongside kind: builtin. Unique within one manifest.
descriptionstringnoHuman-readable summary shown beside the action name.
parametersarray<BuiltinActionParameter>noThe 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.

FieldTypeRequiredNotes
namestringyesParameter identifier as the action expects it in a dispatch's parameters object.
typestringnoAgent-reported type name (for example string, int, duration). Free-form.
requiredbooleannoWhether the agent reports the parameter as mandatory. Descriptive only.
defaultstringnoDefault value the agent applies when the parameter is omitted.
descriptionstringnoHuman-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.

FieldTypeRequiredNotes
itemsarray<CapabilityRow>yesCapability rows in the current page (post per-row visibility filter).
next_cursorstring (nullable)noOpaque 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.

OperationGateRelationSubjectObjectOn denial
ListCapabilitiestop-level platform gatereadresolved principal projected onto the subject stringplatform:plexsphere403 PermissionDenied; an audit row is stamped with outcome permission_denied before the response is flushed.
ListCapabilitiesper-row Domain filterreadsame resolved principal subjectdomain:<uuid> for each candidate rowrow 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 statusProblem.codeOriginTrigger
400invalid_project_idhandlerPath project_id was a zero UUID.
400invalid_cursorhandlerList cursor failed CursorCodec.Decode (tampered or malformed continuation token).
401unauthenticatedhandlerNo resolved principal (or authn.KindUnknown).
403cursor_binding_mismatchhandlerList cursor was minted for a different caller than the one presenting it (per-(caller, pepper) HMAC binding rejected the replay).
403(PermissionDenied)transportTop-level platform read gate denial (separate PermissionDenied schema, not Problem). Per-row Domain denials never reach this gate — they are dropped from items instead.
500internalhandler / serviceCapabilityLister.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.
501capabilities_not_provisionedhandlerScaffold 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.

OperationOutcomeAudit relationAudit outcomeOutbox event
ListCapabilitiessuccesscapability.list (page-level row)granted(none)
ListCapabilitiestop-level gate denialcapability.listpermission_denied(none)
ListCapabilities401 / 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 compare count to limit to 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 clean ErrPermissionDenied.

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