Skip to content

Integrity HTTP API

This is the reference for the integrity-violation triage HTTP surface. It maps each 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 + domain_id pagination idiom is inherited verbatim from the sibling Node listing surface — see ./nodes.md for the design rationale, and the Approvals surface ./approvals.md for the parallel read-list-plus-decision-verb shape this surface mirrors.

An integrity violation is a tamper-evidence row the agents reported — a mismatched agent-binary checksum (binary), a tampered hook payload (hook), or a rotated SSH host key (host_key). Each row is scoped to its owning Domain and moves through a closed triage lifecycle: open on intake, acknowledged once an operator records a reason, and resolved once the underlying divergence is cleared. The surface adds two operations — a cursor-paginated cross-Domain list and a single state-changing acknowledge verb. The carrying OpenAPI tag is integrity.

Both operations authorise against the canonical platform singleton (platform:plexsphere) with the platform read relation, run before the persistence read so an unauthorised caller never observes the existence side-channel of the platform's violation set. The list then layers a second, per-row read check on each row's owning Domain so the items array is the subset the caller is authorised to see.

Operations

MethodPathOperation IDReBAC gateAudit relationBody cap
GET/v1/integrity-violationsListIntegrityViolationsplatform read on platform:plexsphere before the persistence read + per-row read on each row's domain:<uuid>integrity_violation.list (granted)n/a
POST/v1/integrity-violations/{id}/acknowledgeAcknowledgeIntegrityViolationplatform read on platform:plexsphere (after the optional ACR step-up gate, after id validation and body decode)integrity_violation.acknowledge8 KiB
  • body_cap = 8 KiB (MaxIntegrityViolationAcknowledgeBodyBytes in internal/transport/http/v1/integrity/wiring.go) is enforced through http.MaxBytesReader before the JSON decoder reads the acknowledge body; an over-cap body surfaces as 413 request_body_too_large. The list operation carries no request body.
  • ListIntegrityViolations.limit 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.
  • ListIntegrityViolations.cursor is opaque, HMAC-signed by the server and bound to the per-(caller, pepper) pseudonym; a tampered or malformed cursor surfaces as 400 invalid_cursor and a cursor minted by one caller and replayed by another surfaces as 403 cursor_binding_mismatch. The page renders in (reported_at DESC, id DESC) order.
  • The two operations differ in their ReBAC posture beyond the shared platform gate. The list layers a per-row read check on each candidate row's owning Domain, dropping the rows the caller cannot see (fail-closed with observability). The acknowledge verb runs only the single platform read gate — the row's owning Domain is not re-checked at the transport tier — and additionally MAY enforce an ACR step-up policy on this elevated, audited action.
  • Acknowledge is the only state-changing operation. It is legal only from the open state; the application service owns the legal-transition invariant and surfaces a typed sentinel the handler maps onto 409 illegal_transition for any other source state. A blank or whitespace-only reason is rejected at the handler boundary with 400 invalid_acknowledge_reason before any service transaction opens.
  • The acknowledge surface optionally sits behind an ACR step-up gate. When the composition root wires a step-up policy and the caller's session does not satisfy it, the handler returns 401 step_up_required carrying the required acr_values on an RFC 9470 §3 WWW-Authenticate challenge, and no state transition runs — the gate fires before the service call. A build with no step-up policy skips the gate entirely.
  • The list handler stamps its own transport-local granted audit row (the post-filter cohort size is a transport-layer projection). The acknowledge success audit row is emitted by the application service from inside its transaction; the handler does not stamp a duplicate granted row.

Lifecycle and the acknowledge transition

The triage status is a stored value advanced only by the application service's transition rule:

FromVerbToEffect
(agent intake)reportopenThe agent-reported violation lands in the triage backlog.
openacknowledgeacknowledgedRecords the operator-supplied reason, the acknowledging subject, and the acknowledgement timestamp on the triage row.
acknowledgedresolveresolvedThe underlying divergence is cleared. (No transport verb on this surface drives this transition.)
  • Acknowledge is legal only from the open state. A verb attempted from acknowledged or resolved returns 409 illegal_transition.
  • The three lifecycle states (open, acknowledged, resolved) are a closed roster declared by the IntegrityViolationStatus enum.

Path & query parameters

OperationParameterTypeRequiredNotes
AcknowledgeIntegrityViolationid (path)string (uuid)yesIntegrity-violation UUID. A zero UUID → 400 invalid_integrity_violation_id.
ListIntegrityViolationsdomain_id (query)string (uuid)noOptional owning-Domain filter. Composes with the other filters.
ListIntegrityViolationsproject_id (query)string (uuid)noOptional Project filter (violations on Nodes in the named Project).
ListIntegrityViolationsnode_id (query)string (uuid)noOptional reporting-Node filter.
ListIntegrityViolationskind (query)stringnoOne IntegrityViolationKind value (binary, hook, host_key). Composes with the other filters.
ListIntegrityViolationsstatus (query)stringnoOne IntegrityViolationStatus value (open, acknowledged, resolved). Composes with the other filters.
ListIntegrityViolationscursor (query)stringnoOpaque HMAC-signed continuation. Tampered or malformed → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch.
ListIntegrityViolationslimit (query)integerno[1, 200], default 50. Out-of-range values are clamped into range, not rejected.

Request body

AcknowledgeIntegrityViolation requires a JSON body:

  • IntegrityViolationAcknowledgeRequest — a single required reason string (1..1024 characters, additionalProperties: false). The value is the operator-supplied rationale recorded on the triage row as it moves out of open. A whitespace-only reason is rejected with 400 invalid_acknowledge_reason; an unknown field or otherwise unparseable body is rejected with 400 invalid_body; a body over the 8 KiB cap is rejected with 413 request_body_too_large.

ListIntegrityViolations carries no request body.

Schemas

The OpenAPI spec is the authoritative source for field shapes. The schemas this surface uses are:

IntegrityViolationRow

One integrity-violation row — a metadata-only projection scoped to the owning Domain. Returned in the list items array and as the body of a successful acknowledge.

FieldTypeRequiredNotes
idstring (uuid)yesIntegrity-violation identifier (UUIDv7).
node_idstring (uuid)yesNode that reported the violation (UUIDv7).
domain_idstring (uuid)yesOwning Domain (UUIDv7) — the residency pivot the per-row visibility filter authorises against.
kindstringyesOne IntegrityViolationKind value (binary, hook, host_key).
statusstringyesOne IntegrityViolationStatus value (open, acknowledged, resolved).
artifact_idstringyesStable identifier of the affected artifact (hook name, binary path label, or host-key file label).
detected_atstring (date-time)yesTimestamp the agent detected the violation (UTC).
acknowledged_atstring (date-time) (nullable)noTimestamp an operator acknowledged the violation (UTC). null or omitted while still open.
acknowledged_by_subjectstring (nullable)noSubject of the acknowledging operator. null or omitted while still open.
acknowledge_reasonstring (nullable)noFree-text rationale recorded with the acknowledgement. null or omitted while still open.

IntegrityViolationList

Page of rows returned by ListIntegrityViolations. Per-row visibility is layered on the persistence-level page, so items 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<IntegrityViolationRow>yesRows in the current page (post per-row visibility filter).
next_cursorstring (nullable)noOpaque HMAC-signed continuation. Absent or null at end-of-stream. Derived from the persistence keyset, NOT the post-filter cohort.

IntegrityViolationAcknowledgeRequest

Body for the acknowledge operation (see Request body).

Embedded enums

  • IntegrityViolationKind — closed enum: binary (mismatched agent-binary checksum), hook (tampered hook payload), host_key (rotated SSH host key).
  • IntegrityViolationStatus — closed enum: open (on intake), acknowledged (operator recorded a reason), resolved (divergence cleared).

ReBAC contract

OperationRelation evaluatedSubjectObjectOn denial
ListIntegrityViolationsplatform read (top-level gate)resolved principalplatform:plexsphere403 PermissionDenied body; audit row stamped permission_denied.
ListIntegrityViolationsread (per-row filter)resolved principaldomain:<uuid> for each candidate rowrow filtered out of items; per-row denial NOT audited individually.
AcknowledgeIntegrityViolationplatform readresolved principalplatform:plexsphere403 PermissionDenied body; audit row stamped permission_denied.

The top-level gate runs before the persistence read on both operations. The acknowledge gate object is the platform singleton, not the violation's owning Domain — the acknowledge surface is a cross-Domain triage action, so the canonical platform singleton is the gated object (the same posture the list's top-level gate takes).

Per-row authz failure on the list 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.

The 403 body on a top-level denial is the richer PermissionDenied shape (carrying the ReBAC denial reason and request correlation_id); the cursor_binding_mismatch 403 on the list is a plain Problem body.

Audit contract

The transport tier emits the canonical (subject, relation, object, outcome, correlation_id) audit tuple through the wired AuditSink; sink errors are NEVER propagated to the caller — a flaky audit backend cannot turn a successful read into a 5xx, and a slog.WarnContext breadcrumb is the operator's tripwire.

OperationOutcomeAudit relationAudit outcome
ListIntegrityViolationssuccessintegrity_violation.listgranted
ListIntegrityViolations403 denialintegrity_violation.listpermission_denied
AcknowledgeIntegrityViolation403 denialintegrity_violation.acknowledgepermission_denied
AcknowledgeIntegrityViolation4xx body / lifecycle rejectionintegrity_violation.acknowledgeinvariant_violation
AcknowledgeIntegrityViolationsuccess(emitted by the application service inside its transaction)(service-owned)

The list audit row stamps the canonical platform singleton (platform:plexsphere) as its Object because the operation is cross-Domain — there is no single aggregate to address. Its caveat_context carries:

  • count — the number of rows in the response after per-row visibility filtering.
  • persistence_count — the pre-filter persistence-page size, present only when the per-row filter dropped at least one row, so an auditor of a suspected over-broad cross-Domain read can see the drop ratio.
  • 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 permission denial.

The acknowledge denial and invariant-rejection rows stamp the concrete integrity_violation:<uuid> object so the audit trail joins on the row being triaged.

Error taxonomy

All error responses use the shared Problem envelope (application/problem+json), except the top-level ReBAC denial, which uses the richer PermissionDenied shape. The closed Problem.code set this surface emits is pinned in internal/transport/http/v1/integrity/errors.go.

CodeStatusWhereMeaning
invalid_cursor400ListCursor failed HMAC verification or structural decode.
invalid_integrity_violation_id400AcknowledgePath {id} was a zero UUID.
invalid_body400AcknowledgeBody could not be decoded as IntegrityViolationAcknowledgeRequest (unknown field or malformed JSON).
invalid_acknowledge_reason400Acknowledgereason was empty or whitespace-only.
unauthenticated401List / AcknowledgeRequest carries no authenticated principal.
step_up_required401AcknowledgeThe caller's session does not satisfy the required ACR for this elevated action; the required acr_values are carried on the RFC 9470 §3 WWW-Authenticate challenge and no state transition runs.
cursor_binding_mismatch403ListCursor was minted for a different caller (per-(caller, pepper) HMAC binding rejected the replay). Plain Problem body.
permission_denied403List / AcknowledgeThe caller lacks the platform read relation. Emitted via the PermissionDenied schema.
integrity_violation_not_found404AcknowledgeNo integrity violation with the given {id}.
illegal_transition409AcknowledgeThe violation is not in a state (open) from which acknowledgement is legal.
request_body_too_large413AcknowledgeBody exceeded the 8 KiB acknowledge ceiling.
internal500List / AcknowledgeServer-side failure path. The underlying error text is logged via slog.ErrorContext and NEVER leaks to the wire.
integrity_violations_not_provisioned501List / AcknowledgeFail-closed scaffold gate: the composition root has not wired the integrity dependency bundle (Service and Authz) yet.

Every Problem.type is derived from the code by kebab-casing it under the https://plexsphere.dev/errors/ prefix. A 403 permission-denied response carries the extended PermissionDenied fields documented in authz.md.

Cross-references

  • ../../../api/openapi/plexsphere-v1.yaml — authoritative OpenAPI 3.1 contract; the ListIntegrityViolations and AcknowledgeIntegrityViolation operations and the IntegrityViolationRow / IntegrityViolationList / IntegrityViolationAcknowledgeRequest / IntegrityViolationKind / IntegrityViolationStatus schemas. This doc is a map, not a duplicate.
  • ../../../internal/transport/http/v1/integrity/ — the transport-tier implementation: the two handlers, the closed Problem.code taxonomy, the ReBAC relation and audit-relation constants, the body-cap and limit-clamp constants, the HMAC cursor codec seam, the optional ACR step-up gate, and the per-row visibility filter on the list path.
  • ./nodes.md — sibling listing surface that established the cursor + limit + domain_id pagination pattern, the per-row read ReBAC filter, and the page-level audit row contract this surface inherits.
  • ./approvals.md — the parallel list-plus-decision-verb surface; the acknowledge verb mirrors how Approvals documents a state-changing action alongside a list (distinct gate, request body, lifecycle transition, and 409 illegal_transition semantics).
  • ./authz.md — the PermissionDenied shape returned on 403.
  • ../../contexts/authz/index.md — the ReBAC relation graph behind the platform read gate.
  • ../../contexts/audit/index.md — the audit chain the (subject, relation, object, outcome, correlation_id) tuple lands on.
  • ./index.md — platform-wide /v1 HTTP surface map.