Appearance
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
| Method | Path | Operation ID | ReBAC gate | Audit relation | Body cap |
|---|---|---|---|---|---|
| GET | /v1/integrity-violations | ListIntegrityViolations | platform 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}/acknowledge | AcknowledgeIntegrityViolation | platform read on platform:plexsphere (after the optional ACR step-up gate, after id validation and body decode) | integrity_violation.acknowledge | 8 KiB |
body_cap = 8 KiB(MaxIntegrityViolationAcknowledgeBodyBytesininternal/transport/http/v1/integrity/wiring.go) is enforced throughhttp.MaxBytesReaderbefore the JSON decoder reads the acknowledge body; an over-cap body surfaces as413 request_body_too_large. The list operation carries no request body.ListIntegrityViolations.limitis 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.ListIntegrityViolations.cursoris opaque, HMAC-signed by the server and bound to the per-(caller, pepper) pseudonym; a tampered or malformed cursor surfaces as400 invalid_cursorand a cursor minted by one caller and replayed by another surfaces as403 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
readcheck 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 platformreadgate — 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
openstate; the application service owns the legal-transition invariant and surfaces a typed sentinel the handler maps onto409 illegal_transitionfor any other source state. A blank or whitespace-onlyreasonis rejected at the handler boundary with400 invalid_acknowledge_reasonbefore 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_requiredcarrying the requiredacr_valueson an RFC 9470 §3WWW-Authenticatechallenge, 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:
| From | Verb | To | Effect |
|---|---|---|---|
| (agent intake) | report | open | The agent-reported violation lands in the triage backlog. |
open | acknowledge | acknowledged | Records the operator-supplied reason, the acknowledging subject, and the acknowledgement timestamp on the triage row. |
acknowledged | resolve | resolved | The underlying divergence is cleared. (No transport verb on this surface drives this transition.) |
- Acknowledge is legal only from the
openstate. A verb attempted fromacknowledgedorresolvedreturns409 illegal_transition. - The three lifecycle states (
open,acknowledged,resolved) are a closed roster declared by theIntegrityViolationStatusenum.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| AcknowledgeIntegrityViolation | id (path) | string (uuid) | yes | Integrity-violation UUID. A zero UUID → 400 invalid_integrity_violation_id. |
| ListIntegrityViolations | domain_id (query) | string (uuid) | no | Optional owning-Domain filter. Composes with the other filters. |
| ListIntegrityViolations | project_id (query) | string (uuid) | no | Optional Project filter (violations on Nodes in the named Project). |
| ListIntegrityViolations | node_id (query) | string (uuid) | no | Optional reporting-Node filter. |
| ListIntegrityViolations | kind (query) | string | no | One IntegrityViolationKind value (binary, hook, host_key). Composes with the other filters. |
| ListIntegrityViolations | status (query) | string | no | One IntegrityViolationStatus value (open, acknowledged, resolved). Composes with the other filters. |
| ListIntegrityViolations | cursor (query) | string | no | Opaque HMAC-signed continuation. Tampered or malformed → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch. |
| ListIntegrityViolations | limit (query) | integer | no | [1, 200], default 50. Out-of-range values are clamped into range, not rejected. |
Request body
AcknowledgeIntegrityViolation requires a JSON body:
IntegrityViolationAcknowledgeRequest— a single requiredreasonstring (1..1024characters,additionalProperties: false). The value is the operator-supplied rationale recorded on the triage row as it moves out ofopen. A whitespace-onlyreasonis rejected with400 invalid_acknowledge_reason; an unknown field or otherwise unparseable body is rejected with400 invalid_body; a body over the 8 KiB cap is rejected with413 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.
| Field | Type | Required | Notes |
|---|---|---|---|
id | string (uuid) | yes | Integrity-violation identifier (UUIDv7). |
node_id | string (uuid) | yes | Node that reported the violation (UUIDv7). |
domain_id | string (uuid) | yes | Owning Domain (UUIDv7) — the residency pivot the per-row visibility filter authorises against. |
kind | string | yes | One IntegrityViolationKind value (binary, hook, host_key). |
status | string | yes | One IntegrityViolationStatus value (open, acknowledged, resolved). |
artifact_id | string | yes | Stable identifier of the affected artifact (hook name, binary path label, or host-key file label). |
detected_at | string (date-time) | yes | Timestamp the agent detected the violation (UTC). |
acknowledged_at | string (date-time) (nullable) | no | Timestamp an operator acknowledged the violation (UTC). null or omitted while still open. |
acknowledged_by_subject | string (nullable) | no | Subject of the acknowledging operator. null or omitted while still open. |
acknowledge_reason | string (nullable) | no | Free-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.
| Field | Type | Required | Notes |
|---|---|---|---|
items | array<IntegrityViolationRow> | yes | Rows in the current page (post per-row visibility filter). |
next_cursor | string (nullable) | no | Opaque 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
| Operation | Relation evaluated | Subject | Object | On denial |
|---|---|---|---|---|
| ListIntegrityViolations | platform read (top-level gate) | resolved principal | platform:plexsphere | 403 PermissionDenied body; audit row stamped permission_denied. |
| ListIntegrityViolations | read (per-row filter) | resolved principal | domain:<uuid> for each candidate row | row filtered out of items; per-row denial NOT audited individually. |
| AcknowledgeIntegrityViolation | platform read | resolved principal | platform:plexsphere | 403 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.
| Operation | Outcome | Audit relation | Audit outcome |
|---|---|---|---|
| ListIntegrityViolations | success | integrity_violation.list | granted |
| ListIntegrityViolations | 403 denial | integrity_violation.list | permission_denied |
| AcknowledgeIntegrityViolation | 403 denial | integrity_violation.acknowledge | permission_denied |
| AcknowledgeIntegrityViolation | 4xx body / lifecycle rejection | integrity_violation.acknowledge | invariant_violation |
| AcknowledgeIntegrityViolation | success | (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.
| Code | Status | Where | Meaning |
|---|---|---|---|
invalid_cursor | 400 | List | Cursor failed HMAC verification or structural decode. |
invalid_integrity_violation_id | 400 | Acknowledge | Path {id} was a zero UUID. |
invalid_body | 400 | Acknowledge | Body could not be decoded as IntegrityViolationAcknowledgeRequest (unknown field or malformed JSON). |
invalid_acknowledge_reason | 400 | Acknowledge | reason was empty or whitespace-only. |
unauthenticated | 401 | List / Acknowledge | Request carries no authenticated principal. |
step_up_required | 401 | Acknowledge | The 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_mismatch | 403 | List | Cursor was minted for a different caller (per-(caller, pepper) HMAC binding rejected the replay). Plain Problem body. |
permission_denied | 403 | List / Acknowledge | The caller lacks the platform read relation. Emitted via the PermissionDenied schema. |
integrity_violation_not_found | 404 | Acknowledge | No integrity violation with the given {id}. |
illegal_transition | 409 | Acknowledge | The violation is not in a state (open) from which acknowledgement is legal. |
request_body_too_large | 413 | Acknowledge | Body exceeded the 8 KiB acknowledge ceiling. |
internal | 500 | List / Acknowledge | Server-side failure path. The underlying error text is logged via slog.ErrorContext and NEVER leaks to the wire. |
integrity_violations_not_provisioned | 501 | List / Acknowledge | Fail-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; theListIntegrityViolationsandAcknowledgeIntegrityViolationoperations and theIntegrityViolationRow/IntegrityViolationList/IntegrityViolationAcknowledgeRequest/IntegrityViolationKind/IntegrityViolationStatusschemas. This doc is a map, not a duplicate.../../../internal/transport/http/v1/integrity/— the transport-tier implementation: the two handlers, the closedProblem.codetaxonomy, 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-rowreadReBAC 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, and409 illegal_transitionsemantics)../authz.md— thePermissionDeniedshape returned on 403.../../contexts/authz/index.md— the ReBAC relation graph behind the platformreadgate.../../contexts/audit/index.md— the audit chain the(subject, relation, object, outcome, correlation_id)tuple lands on../index.md— platform-wide/v1HTTP surface map.