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 metadata-only projection of the Node's reported capability manifest paired with its checksum status. 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).
reported_atstring (date-time)yesTimestamp 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.

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