yuzu

REST API

This document covers every HTTP endpoint exposed by the Yuzu server. Endpoints are grouped by functional area.


API Versioning

The Yuzu REST API uses path-based versioning. Understanding the distinction between versioned and legacy endpoints is important for building stable integrations.

Formal policy: the binding cross-surface versioning & deprecation policy — covering REST and MCP tools, the additive-vs-breaking test, the deprecation cycle (announcement channels + minimum window), and MCP tool naming on breaking changes — is docs/api-versioning-policy.md. This section is the REST summary.

Versioned API (/api/v1/)

All endpoints under the /api/v1/ prefix are the stable, versioned API. These endpoints:

Legacy API (/api/ without version prefix)

Endpoints under /api/ without the v1 prefix are legacy endpoints that predate the versioned API. These endpoints:

Migration Guidance

When writing new integrations, always use /api/v1/ endpoints. If you have existing scripts using legacy endpoints, plan to migrate them to v1 equivalents. The Legacy API Endpoints section below documents each legacy endpoint and notes where a v1 replacement exists.

Response Headers

Every API response (versioned and legacy) carries the standard Yuzu HTTP security response headers: Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and Strict-Transport-Security (HTTPS only). These headers add roughly 700–900 bytes per response — JSON consumers should expect this overhead. See HTTP Security Response Headers for details, the full header list, and how to extend the CSP via --csp-extra-sources for browser dashboards that integrate with the Yuzu API.

Sec-Audit-Failed and the behavioural-PII audit posture

Routes that serve per-person behavioural / compliance PII (the dex.device.view, dex.device.app_perf.view, guardian.device.view, and dex.signal.view verbs) audit the access before serving. If that audit row cannot durably persist (audit DB locked/full, or an allocation failure), the gap is surfaced — never silently swallowed — but the posture differs by surface, by design:

This is intentional cross-surface behaviour: during an audit-store blip a browsing operator sees data while a wired REST integration receives 503. Alert on Sec-Audit-Failed: true (or audit_persisted: false) from any surface as a SOC 2 CC7.2 evidence-gap signal. (Mutating routes such as token/session revoke use the related audit_emitted body field — see those endpoints.)

A separate, narrower shape applies to ordinary mutation routes that audit a change after it already durably committed (not the behavioural-PII read routes above): POST /api/v1/software-packages, POST /api/v1/software-deployments, and its .../start, .../rollback, .../cancel actions. These always set-and-proceed — the mutation cannot be un-committed, so the response stays 2xx and only Sec-Audit-Failed: true signals a dropped audit row, with no audit_emitted/audit_persisted body field. The Integrations writes share the shape for their OUTCOME row: POST/DELETE /api/webhooks, POST/DELETE /api/v1/offload-targets, POST /api/directory/sync and PUT /api/directory/group-mappings write a result=attempted row first (a failure there is a 503 and nothing changes), and if the later outcome row cannot be stored they keep their 2xx and set Sec-Audit-Failed: true, which means the change WAS made and only the attempted row stands — do not retry the create. PUT and DELETE /api/v1/dex/score-policy share the shape too: a 200 that carries the header means the outcome row is missing and only the attempted row stands: for a PUT, or a DELETE that removed a policy, the change was applied and a retry with the old If-Match answers 409, while a DELETE of a policy that was never written changed nothing and may be repeated. Treat the header the same way as the behavioural-PII surfaces: a CC7.2 evidence-gap signal, not an error.


Table of Contents


Authentication

Every request must be authenticated using one of three methods:

MethodHeader / CookieExample
Session cookieyuzu_session cookieSet automatically after POST /login
Bearer tokenAuthorization: Bearer <token>Authorization: Bearer yzt_a1b2c3d4...
X-Yuzu-Token headerX-Yuzu-Token: <token>X-Yuzu-Token: yzt_a1b2c3d4...

Unauthenticated browser requests are redirected to /home (the sign-in landing page; /login forwards there). Unauthenticated API requests receive a 401 Unauthorized response.

API tokens are created via POST /api/v1/tokens and can be scoped to the creating user’s permissions. The raw token value is returned exactly once at creation time.

State-changing examples (POST, PUT, DELETE) that authenticate with a session cookie send a same-site Origin header (the scheme, host and port of the server you are calling); API-token requests do not need it.

Cookie-session writes. Every request other than GET, HEAD or OPTIONS that is authorised by the yuzu_session cookie must carry an Origin or Referer header naming this site (or an entry from --csrf-trusted-origin). Otherwise it is refused 403 cross-site request refused (the standard error envelope, with a remediation and no permission field) and audited as csrf.denied. One check, applied centrally, decides this after authentication and before step-up evaluation, challenge emission and route-handler effects, and before the request body is read. A request that presents a valid session cookie is treated as a cookie-session request even if it also carries a token; a request authenticated only by an API, MCP or engine token is not subject to this check, except on the routes that keep a copy of their own that is not token-exempt. Browsers send these headers on every write (the dashboard needs no change); a script that reuses a login cookie sends -H "Origin: https://yuzu.example.com" or uses an API token. Behind a reverse proxy, declare the public origin with its scheme (--csrf-trusted-origin https://yuzu.example.com; required if the proxy rewrites Host, and a plain-http:// origin for that host is then refused) and do not configure the proxy to inject or rewrite Origin or Referer. A server that terminates TLS itself requires an https:// Origin or Referer. The audit row’s result is denied, its target_id is the sanitised request method and path (the request path is truncated at 200 bytes) and its detail is fixed text plus reason=<token>; each refusal increments yuzu_server_csrf_denied_total{path_class,reason} (see Metrics). Cross-site checks described per route elsewhere in this reference are defence in depth: for a cookie-session caller the central check answers first and writes the csrf.denied row, so the per-route target_id values documented below are written only by a copy that is actually reached, which in practice means a token caller of a route whose copy is not token-exempt. Cookie-session requests that dispatch work, persist state or force a refresh of server state (the guarded reads) are held to the same rule whichever method they use, and the exact path, never a query flag, selects the gate. For them a Sec-Fetch-Site header that is present decides alone: exactly one header with value same-origin or none is admitted, any other value or a repeated header is refused with origin_mismatch, and Origin and Referer are not consulted. Only a request without Sec-Fetch-Site falls back to the Origin or Referer rule. So a script that reuses a login cookie for such a request either sends no Sec-Fetch-Site and a same-site Origin or Referer, or sends Sec-Fetch-Site once with value same-origin or none; or it uses an API token. Token callers are not gated. The audit row’s detail text for such a refusal is the fixed write wording (see CSRF protection).

Reserved headers — do not send. Five header names are reserved for future server-verifiable delegation and are rejected on every endpoint (sole exception: the four unauthenticated health-probe paths, which ignore them — see docs/auth-architecture.md) with 403 + the standard error envelope (ADR-1005): On-Behalf-Of, X-On-Behalf-Of, X-Yuzu-On-Behalf-Of, X-Yuzu-Delegated-Operator, X-Yuzu-Delegation-Artifact (case-insensitive). A client must never assert that it acts on another principal’s behalf via a header; see docs/auth-architecture.md (“On-behalf-of assertions rejected”) for rationale and the evolution path.


JSON Envelope

All REST API v1 responses use a standard JSON envelope.

Success (single object):

{
  "data": { ... },
  "meta": { "api_version": "v1" }
}

Success (list):

{
  "data": [ ... ],
  "pagination": {
    "total": 42,
    "start": 0,
    "page_size": 50
  },
  "meta": { "api_version": "v1" }
}

Error (A4 envelope):

{
  "error": {
    "code": 403,
    "message": "human-readable message",
    "correlation_id": "req-18f2a1c3d-2a",
    "retry_after_ms": null,
    "permission": "GuaranteedState:Read"
  },
  "meta": { "api_version": "v1" }
}

HTTP status codes follow standard conventions: 200 for success, 201 for resource creation, 400 for bad requests, 401 for unauthenticated, 403 for forbidden, 404 for not found, 503 for service unavailable. Every /api/v1/* error response uses the structured A4 error envelope (per docs/agentic-first-principle.md §A4), with the code field matching the HTTP status. The envelope’s fields:

FieldAlways presentMeaning
codeyesHTTP status echoed into the body for self-describing frames.
messageyesOne-sentence human-readable summary.
correlation_idyesA req-<hex-ms>-<hex-seq> token, also echoed on the X-Correlation-Id response header — join the response to server logs / audit rows by grepping this token.
retry_after_msyes (nullable)null unless the condition is retryable, in which case it advises how many milliseconds to back off (e.g. a 503 warm-up returns 5000).
remediationwhen a hint existsNatural-language self-recovery hint. Key is omitted when there is no hint (absence carries the same “no recovery available” meaning).
permissionon permission denialsThe "SecurableType:Operation" the caller was denied (e.g. "Tag:Write") — the §A4 kPermissionDenied specialisation. Absent on whole-route admin gates that are not tied to a single securable, and absent on most service-scoped-token confinement denials even where the route IS tied to one — the caller is denied regardless of grant, so naming one would be a false self-remediation claim (docs/adr/1006-service-scope-default-deny.md). One documented exception: a confinement denial that fires after the route’s own permission gate already confirmed the caller holds that exact grant still names it — there .permission is informational, not a remediation hint (.claude/routed-concerns-access-control.md, “Service-scoped API token confinement” clause 5(a)). GET /api/v1/inventory/software no longer illustrates this — its after-gate deny was retired (#3290, provably dead: it fired after perm_fn, and the route migrated onto require_fleet_read entirely). No live example currently exists: an exhaustive check of every remaining deny_fleet_wide_service_scoped call site (20 in rest_api_v1.cpp, 5 in mcp_server.cpp as of #3290 Phase 2 bucket 1a) found none currently match this exception’s shape (.claude/routed-concerns-access-control.md, “Service-scoped API token confinement” clause 5) — deny_service_scoped_schedule and the one MCP site that did fire after its gate (get_dex_group_app_perf) were retired outright, not left as non-matching candidates. The exception clause still governs the next one that appears.
approval_id + status_urlreservedThe §A4 kApprovalRequired specialisation. Reserved for the Phase-2 approval re-dispatch flow; not populated by current denials (an approval-gated operation is denied with permission + remediation today, because no pollable approval exists yet). status_url points at GET /api/v1/approvals/{id}.

Service-scoped API tokens and 503. A service-scoped API token is checked against the ITServiceOwner role’s permissions (its authority ceiling). On a route that can succeed on a retry, meaning one gated through the fleet-read gate or the scoped-permission gate, a token whose ceiling cannot be read (the permission store is degraded) receives 503 with retry_after_ms 5000 and no permission field: an outage is not a missing grant, and the request is still refused. On a route behind the plain permission gate a service-scoped token is refused by the default-deny allow-list whatever the ceiling read returns, so no retry hint is given there. A definitive deny, such as the role not holding the permission, is 403 on every gate.

The R2 A4 completion (2026-07) routed the RBAC/tier denial gates (require_admin, require_permission, and the service-scope denials in the auth layer) and the ~156 legacy error_json sites in rest_api_v1.cpp through this one envelope. The #1552 sweep PR-1 (2026-09) converted 8 more route files — auth_routes.cpp (including its MFA-flow branches), workflow_routes.cpp, webhook_routes.cpp, settings_routes.cpp, file_retrieval_routes.cpp, viz_routes.cpp, offload_routes.cpp, and sle_routes.cpp (audited, already compliant via its own local builder) — so it does not yet cover literally every path, but the remaining gap is now precisely enumerated rather than open-ended: 7 more route files (compliance_routes.cpp, discovery_routes.cpp, command_routes.cpp, dashboard_api_routes.cpp, notification_routes.cpp, schedule_routes.cpp, nvd_routes.cpp, tracked as #1552’s PR-2), roughly 86 hand-rolled sites still inline in server.cpp itself (not yet decomposed into a *_routes.cpp file), and the pre-routing chokepoint’s own “no session” 401 (server.cpp, {"error":{"code","message"},"meta"} with no correlation_id — every unauthenticated /api/v1/* request hits this before any route’s own A4 gate runs; tracked as #2003). Automation crossing surfaces should treat the enrichment fields as present-when-available until all three close.

Per-principal quota cap (PR 4.4, ADR-1005 class engine principals). REST traffic from an engine principal session (stored principal_kind engine or an engine_token authentication source, username engine:<slug>) is subject to a per-principal cap enforced at the server’s single pre-routing chokepoint — before the request reaches any route handler. Two independent dimensions are checked: an in-flight concurrency cap and a per-principal token-bucket rate cap (see docs/user-manual/engine-principals.md for the operator-facing tuning guide). Exhausting either dimension returns 429 with the standard A4 envelope plus an HTTP Retry-After header (whole seconds, rounded up from retry_after_ms):

{
  "error": {
    "code": 429,
    "message": "per-principal rate limit exceeded",
    "correlation_id": "req-18f2a1c3d-2a",
    "retry_after_ms": 150,
    "remediation": "retry after retry_after_ms; reduce request rate"
  },
  "meta": { "api_version": "v1" }
}

retry_after_ms is honest per dimension: a rate rejection carries the token bucket’s actual refill time, a concurrency rejection carries a fixed short backoff (250ms) since there is no natural “when will a slot free up” estimate. Streaming/SSE endpoints (GET /api/v1/events, GET /events, GET /sse/executions/{id}) take a concurrency slot held for the stream’s lifetime (released when the stream ends), plus the rate debit — the same two caps as any other engine request (UP-1; an earlier revision of this primitive released the slot early at routing hand-off and treated streaming as rate-only, which left an engine principal able to open an unbounded number of concurrent streams — that gap is closed). This gate applies only to engine-principal sessions; human, device-agent, and anonymous traffic is unaffected. It is enforced per server process — a multi-replica deployment’s effective ceiling is configured_cap x replica_count, not the configured cap alone (each replica enforces independently; not a fleet-wide cap). A quota rejection is metric-only (yuzu_server_principal_quota_exhausted_total{side,limit}, see docs/user-manual/metrics.md) — it does not write an audit row (see docs/user-manual/audit-log.md); every admitted request also increments the companion yuzu_server_principal_quota_admits_total{side} counter.

⚠ Breaking wire-shape change (upgrade note). Many of those legacy /api/v1 errors were previously emitted as {"error":"<string>"} (the single-arg error_json) or the older nested {"error":{"code","message"}}. They are now uniformly the nested A4 object above. A client that read error as a string (String(body.error), body.error.startsWith(...)) will break — error is always an object on these paths now. Migrate to body.error.code / body.error.message. See docs/user-manual/upgrading.md.


Numeric Query Parameters (#4644)

A numeric query parameter must be one complete base-10 integer, on the routes that read it this way, or the route answers 400 with the A4 message invalid numeric query parameter (the message names the parameter on several routes, for example window, the software inventory limit, the event stream’s since and the delivery-list limit; the wording otherwise varies by route family). They were read leniently, stopping at the first non-digit, so ?limit=100abc was read as 100, ?since=1e9 as 1 and ?since=junk was swallowed into the default (“from the beginning”): a malformed filter silently answered a different question than the one asked. Rejected now: trailing characters, hex and exponent forms, decimals, whitespace, a leading + and a value too large for its type (an empty value and an overflow were already refused on most of these routes). Zero padding (007) is accepted. Omitting a parameter still means its default, and each route’s existing clamping and sentinel values (for example limit clamped to a ceiling) are unchanged. The epoch bounds since/until (and from/to on the auth-sample export) are presence-tracked: omitting one leaves that side unbounded, since=0 is accepted as a harmless literal bound that matches exactly what omitting it matches, and until=0 (or to=0), like any negative bound, is 400: 0 is not an “unbounded” sentinel on the upper side, so a computed window that collapsed to 0 fails instead of silently returning everything.

Routes that parse strictly (the response routes are listed in Command/Instruction Responses and Responses below):

FamilyRoutes and parameters
Execution statisticsGET /api/v1/execution-statistics/agents and /definitions: since, limit (a negative since is 400 too, and so is a limit below 1, which the MCP twins also reject with -32602)
ExecutionsGET /api/executions, GET /api/v1/executions: limit; GET /api/v1/executions/{id}/visualization: index
AuditGET /api/v1/audit: since, until, limit, offset; GET /api/audit: since, until, limit, offset; GET /api/analytics/recent: limit (a malformed value used to be ignored)
Instructions, product packs, workflowsGET /api/instructions, GET /api/v1/instructions, GET /api/v1/product-packs, GET /api/product-packs, GET /api/workflows, GET /api/v1/workflows: limit
ComplianceGET /api/policy-fragments, GET /api/policies, GET /api/v1/policy-fragments, GET /api/v1/policies: limit. On the two legacy routes (/api/policy-fragments, /api/policies) a negative limit is 400 too: it used to reach Postgres as LIMIT -1, fail, and answer 503. The /api/v1/ routes clamp limit to 1..1000 as before
PatchesGET /api/patches, GET /api/patches/deployments: limit (a malformed value used to be ignored; a negative limit is 400 too, where the store answered an empty 200)
InventoryGET /api/v1/inventory/software and /software/catalogue: limit (trailing characters were read as their numeric prefix; an EMPTY limit= means the default on these routes, see the note below the table); conditions[].value on POST /api/v1/inventory/evaluate and POST /api/v1/result-sets/from-inventory-query (a JSON number became the empty string, see the condition table under inventory/evaluate)
DEX windowGET /api/v1/dex/signals, /dex/scope and /dex/signals/{obs_type}: window must be 24h, 7d, 30d or all (omitted = 7d; an empty ?window= is 400, like on every other DEX route); 90d, 30, junk or an empty value used to be answered with 7 days of data
Fleet visualizationGET /api/v1/viz/fleet/topology: machines_max
Verificationthe app-performance compare route: window (a malformed value used to fall back to 7 days). This dashboard fragment keeps its own rejection style: an honest 200 note (“The window must be a whole number of days.”), because the dashboard’s htmx config drops 4xx bodies; nothing is computed or audited. The other dashboard fragments in this table (results, hardware, DEX perf, network quality) still answer a JSON 400 that an operator in the dashboard never sees: that is a known UX gap, tracked in issue #5637, not a deliberate inconsistency
Dashboard fragmentsGET /fragments/results: page, per_page; the three software inventory fragments (/fragments/inventory/software, /software/devices, /software/versions): a malformed limit answers 200 with an inv-caveat note, not a JSON 400 (htmx drops 4xx bodies), and the store is not read; a well-formed limit is validated and then ignored, because these fragments use a fixed page size; the DEX perf devices fragment and the network quality devices fragment: limit; the hardware list fragment and GET /api/v1/hardware: offset, limit (a negative value is refused too, it used to be clamped to 0); the hardware sync poll: await_since (a malformed freshness bound used to read as “no bound”, so a stale snapshot could be served as fresh); GET /fragments/echo/process-tree/detail: node (stoul read -1 as the largest node id and 5x as 5)
Delivery and list routesGET /api/notifications: limit, offset (a negative value is 400 too, where the store answered an empty 200); GET /api/webhooks/{id}/deliveries and GET /api/v1/offload-targets/{id}/deliveries: limit; GET /api/v1/ca/issued: limit, offset; GET /api/v1/preflight/runs: limit; the pre-flight run form: min_gib and window (a malformed value is 400; a negative min_gib is 400 too) and the version thresholds app_min, app_max and os_min (empty, or a dotted-decimal version with an optional -beta / +build suffix; anything else is 400, see Pre-flight readiness checks); GET /api/v1/result-sets and GET /api/v1/result-sets/{id}/members: limit (trailing characters are 400, not their numeric prefix; an integer outside the route’s page range still uses the default page size); GET /api/v1/dex/signals/{obs_type}, GET /api/v1/dex/perf/devices and GET /api/v1/network/devices: limit; GET /api/v1/guaranteed-state/events: limit and offset (a negative value is 400 too; both are parsed before the dex.device.view audit row is written, so a 400 leaves no access row)
Event streamsGET /api/v1/events: since (a non-negative integer; a malformed value is 400, answered before the stream budget is touched). A malformed Last-Event-ID header on GET /api/v1/events or the workflow execution stream is served live only: nothing buffered is replayed (never the whole ring buffer) and it is not a 400, because the header is browser-supplied inference and a 400 would fail a reconnect. The cursor is parsed strictly, so -1 and 5abc are never accepted as a position (5abc used to replay from position 5; -1 wrapped past every event and also replayed nothing, so that input’s outcome is unchanged); valid ids replay exactly as before. A rejected ?since leaves no success audit row, because it is parsed before the audit write
Settings formsPOST /api/settings/dex-alerts/blast (min_devices, window_seconds, cooldown_seconds): a malformed value is 400 and nothing is saved (it used to persist the clamped minimum); POST /api/settings/enrollment-tokens and /batch (max_uses, ttl_hours, count): 0x5 used to read as 0, and max_uses 0 means unlimited, so a mistyped limit minted an unlimited token (only a value that parses to a literal 0 such as 0, -0 or 00 means unlimited or no expiry; an empty field takes the default of 100 uses and 72 hours, and on /batch an empty max_uses defaults to 1 and an empty ttl_hours to 72 hours; a negative ttl_hours is refused with 400 because it would compute 0, a token that never expires); the API-token create form ttl_hours (the same 0x5 read as a token that never expires; it is refused with the form’s usual 200 feedback span, Invalid TTL value., a negative value too, and nothing is minted); the OTA package rollout_pct on upload and on the rollout route (an out-of-range integer on upload is clamped to 0..100 as before): a malformed value used to become 100, a full-fleet rollout. Behaviour change on the rollout change route only: an empty rollout_pct and a value above 100 are also 400 there (an empty value used to mean 100 and a larger one was clamped to 100), while a negative value still clamps to 0 (the least exposure); the upload route is unchanged. Each enrollment and OTA 400 also sets an error toast (HX-Trigger), because the dashboard drops 4xx bodies
JSON request bodies (security-gating numbers)POST /api/v1/tokens expires_at (a negative value or one above 9223372036854775807 minted a token that never expires, 5.5 or 1e3 an already-expired one, and a wrong type threw a bare 500; now 400, and 0 or omitted still means never expires); POST /api/v1/tokens/{id}/rotate and POST /api/v1/engine-principals/{id}/credentials/rotate overlap_secs (a truncated or non-object body used to rotate with the 7-day default; now 400, and an empty body still takes the default); POST /api/v1/guaranteed-state/rules version (5.5 was stored as 5 and a wrong type threw; now 400); POST /api/v1/upload-grants ttl_secs (a wrong type, 0 or a negative value was ignored and the grant got the 900 s maximum; now 400); POST /api/settings/enrollment-tokens/batch count, max_uses, ttl_hours (a JSON number was silently ignored, so the documented example minted single-use tokens that never expire; now a JSON integer and a numeric string are both read, an empty string keeps the default, and a float, boolean, null, overflow or a truncated or non-object body is 400 and mints nothing). The ip_subnet auto-approve rule value is validated when it is added (POST /api/settings/auto-approve, 400 for anything but an IPv4 address or a.b.c.d/0 to /32)

On the query-parameter routes in the table above, a parameter that is present but empty (?limit=) is malformed, not absent (GET /api/v1/audit limit included). Two exceptions. The software list and catalogue routes (GET /api/v1/inventory/software and /software/catalogue, and the three software fragments) share one parser in which an EMPTY limit= means the default; a non-empty malformed or overflowing limit is still refused (400 on the REST routes, a 200 inv-caveat note on the fragments). And the auth-sample export: a present but empty from or to on GET /api/v1/audit/auth-sample is accepted as unbounded on that side. The admin forms are different: an EMPTY form field means that field’s default.

Not covered by this change, and still lenient (the list is not exhaustive): the n retry-attempt counter on the live-snapshot poll fragments (the deployment, device, hardware, DEX, pre-flight and Echo process-tree polls), which is clamped to a fixed range per poll (1 to 100000 on the deployment and pre-flight polls, at most 50 on the others); and values that are parsed from stored rows, agent output or server configuration rather than from a request. Numeric path segments matched by a (\d+) pattern (/api/notifications/{id}/read, /dismiss and the webhook routes) answer 404 for an id that does not fit a signed 64-bit integer (the auto-approve rule index answers 200 with an audited denied row and changes nothing); they used to throw and answer 500.


Pre-Auth Request Body Caps (#2407)

Every request is checked against a per-route body-size cap before authentication runs and before the body is read. A single pre-routing chokepoint (server.cpp) resolves the caller’s (method, path) against the policy table in body_cap_policy.hpp and rejects an oversized or unmeasurable body without buffering it. The four health-probe paths (/livez, /readyz, /health, /api/health) are NOT exempt from this gate. The cap is a single lambda, enforce_pre_auth_body_cap in server.cpp’s pre-routing handler, invoked from exactly two sites: once INSIDE the probe branch, before its early return, and once after the on-behalf-of guard and the rate limiter for every other route. One definition, two call sites; the probes cannot drift out of coverage without deleting a call. The plain-HTTP redirect listener (#5906) also serves these four paths, but it admits no body at all: a probe declaring Content-Length or Transfer-Encoding is redirected before any read (is_plaintext_probe_request, health_routes.hpp), so no second cap table exists. tests/unit/server/test_body_cap_policy.cpp’s “POST /health … is rejected (D3)” case exercises that DECISION on a real socket, but against a test server that reproduces the gate rather than invoking the production one, so it does not pin the wiring itself. The probe-branch call is pinned separately by the source-text wiring case in tests/unit/server/test_health_routes.cpp, which fails if that call is deleted or moved out of the probe branch. The non-probe call site (after the on-behalf-of guard and the rate limiter) is not pinned: deleting it would leave the suite green, so treat that call as verified by review, not by test, until that gap is closed. Before this fix the probes’ early-return sat above the cap entirely, making them the server’s last unauthenticated 100 MiB buffer; closing that was the point. The probes remain exempt from the on-behalf-of guard that runs further below (a header-stamping proxy or orchestrator must not be able to crash-loop the server by tripping it on its own liveness check). That exemption is unchanged; only the body cap now also reaches them. /livez and /readyz are also exempt from the rate limiter. /health and /api/health requests that carry a credential header are rate limited per source address in a bucket of their own, checked before the body cap; requests with no credential header are not charged (see the /health section below). Three status codes:

StatusMeaning
413The body’s declared Content-Length exceeds the resolved cap for this route’s class.
411The body cannot be measured in advance by framing — chunked Transfer-Encoding, or a POST/PUT/PATCH with no Content-Length at all — and this route’s class refuses that. Four classes refuse an unmeasurable body today: /mcp/, upload_session (PUT /api/v1/uploads/{id}/chunk and its sibling agent-facing upload routes), hardware and dex_score_policy — every other class below falls through and is admitted up to httplib’s own 100 MiB backstop, because chunked request bodies are legal HTTP and this repo does not control every client population talking to public REST/SCIM/upload routes. This is a deliberate per-class opt-in (requires_measurable), not a blanket rule. upload_session opts in for the same reason /mcp/ does: its session gate lives in the route HANDLER, after httplib would otherwise buffer the body, so the pre-routing bound is what actually stops an unauthenticated caller forcing a ~100 MiB buffer per connection — the agent uploader always sends an identity-encoded body with a Content-Length (the protocol’s Content-Range header requires knowing it), so this costs a conforming client nothing. A non-identity Content-Encoding is judged separately — see 415 below, not this row.
415The request carries a Content-Encoding header set to anything other than identity (case-insensitive). Refused unconditionally, on every route class, regardless of requires_measurable — unlike the framing rule above, this does not depend on a per-class opt-in. Why: this build compiles CPPHTTPLIB_BROTLI_SUPPORT, httplib decompresses transparently and enforces only its 100 MiB global limit against the decompressed size, so Content-Length on a compressed body measures the wrong thing and a sub-cap compressed body can expand to ~100 MiB before anything downstream sees it. No Yuzu route accepts a compressed request body today, so this costs a conforming client nothing — send the body identity-encoded by omitting Content-Encoding entirely. Do not send Content-Encoding: identity explicitly: has_non_identity_content_encoding correctly admits it past this gate (it isn’t a compressed encoding), but httplib’s own body reader then calls create_decompressor("identity") — which matches none of gzip/deflate/br/zstd, returns null, and httplib answers a bare 415 itself (httplib.h’s prepare_content_receiver), before this gate, any route handler, or the A4/SCIM envelope code ever runs. That response carries none of the structure this section promises — no A4 envelope, no correlation_id, no yuzu_body_cap_rejected_total increment. Until that gap is closed at the code level, the only conforming way to send an identity-encoded body is to omit the header.

All three responses use the standard A4 error envelope with a remediation hint (no permission field — the request is rejected before any principal is resolved) — except the scim class, which publishes SCIM’s own RFC 7644 §3.12 error shape (schemas/status/detail) on application/scim+json instead of the generic envelope (scim::error(), scim_json.hpp:192; wired into all three status codes above in server.cpp’s pre-routing handler). This gate is separate from, and runs before, any route-local body check a handler may also carry (e.g. the SCIM/response-template 64 KiB checks below) — those still exist for defense-in-depth if a future edit widens this table’s entry, but on the current table this pre-routing gate rejects first. This is not purely a timing change for every affected class. For most (response templates, CA import), an earlier rejection at the same byte count is the entire effect. SCIM is no longer an exception to that either (fixed as part of this change, D7): the pre-routing rejection’s SCIM shape matches the wording of the handler’s own (now-superseded, and unreachable — the pre-routing cap and the handler check the identical 64 KiB threshold, so the pre-routing gate always wins first) 413 check exactly, and extends the same shape to the 411/415 cases the handler-level check never covered.

Post-Read Backstop (#2407)

The pre-routing gate above is what stops an oversized body from being buffered at all — but it can only act on a declared size. Its structural limit, recorded in body_cap_policy.hpp’s KNOWN LIMITATION paragraph: on the 28 of 32 classes (distinct path_class labels, the catch-all default included) below that don’t set requires_measurable, a genuine chunked (or otherwise undeclared) body is not size-checked by the pre-routing gate at all — it is admitted, up to httplib’s own 100 MiB backstop, without that table’s cap being consulted.

A second stage, wired at httplib’s Server::set_pre_request_handler in server.cpp, closes that gap from the other side. It runs inside httplib’s dispatch_request — after read_content has consumed the body off the socket into req.body, and after the route has matched, but before the route’s own handler runs. At that point the body’s actual size is known, so this stage resolves the SAME kBodyCapTable/resolve_body_cap the pre-routing gate uses (no forked table) and, if the now-fully-read body is over the class’s cap, refuses with the same 413 A4/SCIM envelope shape and the same 1-in-100 per-reason log throttle as the pre-routing gate (tagged [#2407 post-read] in the journal rather than [#2407]). The rejection reason is a new value, over_cap_post_read, on yuzu_body_cap_rejected_total{path_class,reason} — pre-seeded at 0 for every class, unconditionally, unlike unmeasurable; see docs/user-manual/metrics.md’s yuzu_body_cap_rejected_total row for the full description.

What an operator observes. A chunked (or otherwise undeclared) body that exceeds its route class’s cap is now refused — where it previously reached the route handler uncapped, bounded only by httplib’s 100 MiB backstop. That is the change: the 28-of-32-class gap the pre-routing gate leaves open is now closed for a body that turns out, once read, to be over cap.

The two stages are mutually exclusive per request, not stacked. httplib’s routing() returns as soon as the pre-routing handler answers Handled, so it never calls dispatch_request for a request that gate already refused — a measurable over-cap body (a declared Content-Length above the cap) is still rejected by the pre-routing gate, before anything is buffered, exactly as before this stage existed. Do not describe this as “the cap now runs twice” — in practice over_cap_post_read fires almost exclusively for the chunked/unmeasurable case the pre-routing gate deliberately admits.

Limitations — read before relying on this as complete coverage:

Why not one global cap? httplib’s own Server::set_payload_max_length is a single server-wide knob shared by every route on the same listener, including the ~70 MiB live-query bundle route and the OTA agent-binary upload — a single small value would break those outright, and a single large value (httplib’s 100 MiB default) leaves every small JSON/form route able to buffer up to 100 MiB from an unauthenticated caller. The table below exists so each route class gets a cap sized to what it actually needs.

Per-class caps

Derived directly from server/core/src/body_cap_policy.hpp’s kBodyCapTable (longest matching {method, path prefix} wins; do not hand-copy these numbers elsewhere — that header is the source of truth):

MethodPath prefixCappath_classNotes
any/mcp/4 MiBmcpOne of four classes with requires_measurable=true — see the 411 row above.
POST/api/v1/bundles70 MiBbundlesSized to the live-query bundle route’s own computed 64 MiB parameter-byte floor plus JSON overhead headroom.
any/api/v1/uploads8 MiBupload_sessionThe second requires_measurable=true class. Exact mirror of the PR1.6a chunked-receive protocol’s own kDefaultChunkMaxBytes (upload_grant_parsers.hpp), bound to it by a static_assert in file_retrieval_routes.cpp so the two cannot drift. Any method — the session-open POST, chunk PUT, status GET, commit POST, and cancel DELETE all share this class; only the chunk PUT carries a large body, but scoping to PUT alone would silently drop the sibling verbs to the 4 MiB catch-all.
any/api/v1/plugin-config256 KiBplugin_config† The store’s own grammar caps a config value at 8 KiB and a secret plaintext at 64 KiB (plugin_config_parsers.hpp); 256 KiB covers the larger of the two plus JSON framing/escaping headroom, while keeping an unauthenticated flood against this surface two orders of magnitude under the 4 MiB catch-all. Any method — PUT config/secret writes, DELETE carries no body, GET/list are bodyless.
POST/api/settings/updates/upload100 MiBota_uploadKept at httplib’s own backstop deliberately, not squeezed — OTA agent binaries are legitimately multi-ten-MB.
POST/api/export/json-to-csv100 MiBjson_to_csv_exportUnbounded by design — converts a full exported dataset to CSV, and its size follows directly from the caller’s own prior query. Kept at httplib’s backstop, same treatment as ota_upload, rather than squeezed or given an arbitrary MiB judgment-call number.
POST/api/nvd/match8 MiBnvd_match‡ No aggregate contract; reasoned to this content’s own realistic scale (one device’s software census, generously overestimated at ~200 bytes/item ⇒ 40000+ items) rather than borrowed from a sibling class.
POST/api/v1/ca/import-chain256 KiBca_import_chainMirrors the handler’s own 256 KiB bound exactly.
POST/api/v1/ca/issue-code-signing64 KiBca_issue_code_signingCode-signing leaf issuance via CSR custody (gap-matrix #10); mirrors the handler’s own kMaxIssueCodeSigningBody exactly.
POST/api/v1/ca/revoke64 KiBca_revokeMirrors the handler’s own kMaxRevokeBody exactly.
POST/api/v1/secrets/kek/64 KiBkek_opsCovers both /rotate and /rewrap — both take zero body fields and share the handler’s own kMaxKekBody.
POST/api/settings/ca/import-chain512 KiBca_import_chain_dashboard† 2× the REST JSON twin’s cap — reasoned headroom for form-encoding overhead, not a measured worst case.
POST/api/settings/plugin-signing/upload512 KiBplugin_trust_bundle† 2× the handler’s 256 KiB parsed-content check — same reasoned multipart-framing headroom, not measured.
any/scim/v2/64 KiBscimMatches the handler’s own kMaxBodyBytes; moves an existing after-buffer check to before it, but the rejection’s error SHAPE changes, see the SCIM caveat above. Any method, as in kBodyCapTable: one row, not one per verb.
POST/saml/acs1 MiBsaml_acsA legitimate signed SAMLResponse is well under 64 KiB; headroom against amplification, not judgment.
POST/api/v1/definitions/64 KiBresponse_templatesWidest literal prefix the segment-boundary matcher can key on for the regex-captured {id}/{template_id} routes underneath; a future mutation route added elsewhere under this prefix inherits it unless it gets its own, more specific entry.
PUT/api/v1/definitions/64 KiBresponse_templatesSame.
POST/api/dashboard/echo-execute16 KiBecho_dashboard_sql† The handler checks the FORM-DECODED sql field at 4096 chars, not the raw body this gate sees — 3× worst-case percent-encoding of that 4096-char value, plus 4 KiB flat headroom for field-name framing and the companion scope field. The as-shipped 4 KiB cap matched the raw body to the decoded-field number directly and rejected legitimate queries (measured: a 4085-char query produced a 5078-byte body, 24% over that cap) — fixed here.
POST/api/v1/result-sets/from-echo-query200 KiBecho_result_set_sql† The handler checks the JSON-PARSED sql field at 100000 chars; 2× that for JSON-escaping headroom plus the request’s other keys. The as-shipped 100 KiB cap left only ~2400 bytes of margin over the 100000-char check, which JSON escaping alone can exceed — fixed here. The handler answers 400 for its own condition; this gate answers 413 for a raw body over 200 KiB — these are two different checks on two different values, not one check enforced twice.
POST/api/v1/guaranteed-state/rules16 MiBguardian_rule_authoring‡ Judgment call, not a measurement — no aggregate Guardian-rule-size contract exists yet anywhere in the codebase.
PUT/api/v1/guaranteed-state/rules/16 MiBguardian_rule_authoring‡ The sibling update route — same class, same bound. (As shipped, this route fell through to the 4 MiB catch-all, so a rule created up to 16 MiB could never be edited back through the same-sized route; fixed here.)
POST/api/workflows16 MiBworkflow_yaml‡ Judgment call, same reasoning — arbitrary YAML/legacy yaml_source bodies with no size contract yet. No PUT/PATCH update route exists for this class today.
POST/api/product-packs16 MiBproduct_pack_yaml‡ Judgment call, same reasoning — a pack can hold multiple YAML documents, so it needs at least the same headroom as workflow authoring. No PUT/PATCH update route exists for this class today.
POST/api/instructions/import16 MiBinstruction_import‡ No aggregate contract — the embedded yaml_source field IS hard-capped at 1 MiB, but an optional responseTemplates array supplied as native JSON has no size limit at all, so the whole-request bound is still a judgment call. Same 16 MiB peer bound as the three classes above.
POST/api/instructions/yaml3076 KiBinstruction_yaml† The handler checks the FORM-DECODED yaml_source field at 1048576 chars (instruction_yaml.cpp:165); 3× worst-case percent-encoding of that value, plus 4 KiB flat headroom for field-name framing and the id field (save only).
POST/api/instructions/validate-yaml3076 KiBinstruction_yaml† Same check, same margin — see above.
POST/fragments/instructions/yaml-preview3076 KiBinstruction_yaml† Same check, same margin — see above.
any/api/v1/hardware4 KiBhardwareThe third requires_measurable=true class. Only POST .../sync and POST /api/v1/hardware/sync carry a body (a short source enum token, and for the fan-out a scope token or an expression of at most 1,024 bytes); the bodyless GET /api/v1/hardware and GET /api/v1/hardware/{id} list/record routes share the class rather than falling to the 4 MiB catch-all.
any/api/v1/rbac/roles/4 KiBrbac_role_assignmentA2 global human role assignment — see “Fleet-Wide Role Assignment” below. POST .../assignments body is {principal_type, principal_id}, two short strings; DELETE .../assignments/{principal_id} carries none. Any method — the DELETE sibling shares this literal prefix once past the role-name path segment.
any/api/v1/rbac/enforcement1 KiBrbac_enforcementA1 enforcement enable/disable toggle. Body is {"enabled": true}, under 64 bytes even pretty-printed; 1 KiB is 16× headroom. A segment-boundary-distinct prefix from rbac_role_assignment above, so it needs its own row or it would fall to the 4 MiB catch-all.
any/api/v1/guaranteed-state/policy-generation1 KiBguardian_policy_generationThe Guardian policy-generation raise (POST .../raise). Body is {"target_generation": <integer>, "acknowledge_rule_set_replaced": true}, under 128 bytes even pretty-printed; 1 KiB is 8× headroom. The row is any-method, so the read-only GET .../policy-generation resolves to the same class; it carries no body. A segment-boundary-distinct prefix from guardian_rule_authoring (/api/v1/guaranteed-state/rules), so it needs its own row or it would fall to the 4 MiB catch-all.
any/api/v1/users16 KiBusersThe local-account user subtree: POST .../me/password and POST .../{name}/password (#5342) plus the bodyless .../unlock and the small .../elevation-eligibility. The largest legitimate body is two passwords at the 1024-byte policy maximum; worst-case JSON escaping puts that near 12.3 KiB, so 16 KiB admits every policy-conformant request. Any method, because the bodyless siblings share the prefix.
any/api/v1/dex/score-policy320 KiBdex_score_policyThe fourth requires_measurable=true class. PUT’s body IS the score policy document, which the handler caps at 256 KiB (kDexScorePolicyMaxBytes); 320 KiB leaves framing headroom, so a legitimate document is never refused here. GET and DELETE are bodyless siblings sharing the class. The permission gate runs in the handler, after the body is buffered (a cookie session’s same-site gate runs earlier, pre-routing), so an unmeasurable (chunked) body is refused up front with 411.
any/api/v1/crypto16 KiBcryptoThe /crypto REST v1 subtree. The only body-bearing route is PUT /api/v1/crypto/dashboard/layout (at most 64 catalogue ids, about 4.4 KiB even fully escaped); 16 KiB mirrors the handler’s own kLayoutBodyMaxBytes exactly. Any method, because every sibling is a bodyless GET and shares the prefix.
any/api/v1/config16 KiBconfigThe runtime-configuration REST v1 subtree. The only body-bearing route is PUT /api/v1/config/{key} ({"value": ...}, one scalar: an integer, a flag, a log level, a tag key, an OIDC client secret or a string holding a short JSON array); 16 KiB is far above any legal value. Any method, because the sibling GET /api/v1/config is bodyless and shares the prefix. The legacy PUT /api/config/{key} stays on the catch-all.
any (catch-all)(empty prefix — matches everything not listed above)4 MiBdefaultApplies to ordinary JSON/form mutation routes not called out individually.

† = a reasoned margin over a real, cited handler-level check — the pre-routing gate sees the RAW body while the handler checks a DECODED/PARSED value (form-decoded, JSON-unescaped, or multipart-extracted), so the two numbers are never expected to match exactly. Reasoned headroom, not a measured worst case; getting the margin wrong rejects legitimate traffic (the echo_dashboard_sql/echo_result_set_sql history above is a shipped example). ‡ = a generous, explicit, judgment-call bound because no aggregate size contract exists for that class yet, reasoned against that class’s OWN realistic scale rather than copy-pasted from a sibling — not a fixed multiple below httplib’s 100 MiB backstop: it ranges from ~12.5× for the 8 MiB nvd_match entry (just over one order of magnitude) down to 6.25× for the five 16 MiB entries (under one order of magnitude) — none of the ‡ entries reach two orders of magnitude. Do not read either footnote as license to invent a number for a different route — see the header block of body_cap_policy.hpp.

Counting by table ROW (one BodyCapEntry struct in kBodyCapTable = one row): 8 rows carry † (plugin_config, ca_import_chain_dashboard, plugin_trust_bundle, echo_dashboard_sql, echo_result_set_sql, and all three instruction_yaml rows) and 6 rows carry ‡ (nvd_match, both guardian_rule_authoring rows, workflow_yaml, product_pack_yaml, instruction_import) — 14 rows total, out of 36 rows in the table overall (A2 added rbac_role_assignment, A1 rbac_enforcement, the policy-generation raise guardian_policy_generation, #5342 users, and then crypto, config and dex_score_policy, all unmarked — an exact reasoned-from-real-shape bound, not a measured mirror or a judgment call against an arbitrary-content class). Counting by CLASS (path_class; several classes span multiple rows) that collapses to 6 † classes and 5 ‡ classes — 11 classes total, out of 32 classes overall (the catch-all default included). Every other row/class mirrors a cited, decoded-equals-raw byte count exactly (this now includes upload_session, added alongside plugin_config in this table — an exact protocol-constant mirror, not a reasoned margin, so it carries neither footnote). A third, unmarked category (ota_upload, json_to_csv_export) is pinned at httplib’s own 100 MiB backstop as an explicit, reviewed decision rather than squeezed or given a judgment-call number — neither is “reasoned” in the † /‡ sense, since there is no smaller number to reason toward.

Raising a cap. Edit the table in body_cap_policy.hpp (with review) and update this table to match — never reach for Server::set_payload_max_length, which is global across every route on the listener (see “Why not one global cap?” above). See also docs/user-manual/server-admin.md’s upgrade note for this change and docs/user-manual/metrics.md’s yuzu_body_cap_rejected_total row for observing rejections.


REST API v1 Endpoints

All endpoints are under the /api/v1/ prefix.


Current User

GET /api/v1/me

Returns the authenticated caller’s identity, legacy role and session context (not an authority summary: see role below). The MCP twin is get_me.

Permission: Authenticated session (no specific RBAC permission required). The route is session-only self-description and writes no audit row. A service-scoped token may call it and describe itself; the MCP twin get_me refuses service-scoped tokens (a documented asymmetry, tracked in #6432).

Response:

{
  "data": {
    "username": "alice",
    "display_name": "Alice",
    "role": "user",
    "principal_kind": "human",
    "auth_source": "local",
    "mcp_tier": "",
    "scope_service": "",
    "rbac_enabled": false,
    "rbac_role": "Viewer",
    "rbac_roles": [],
    "rbac_roles_resolved": true,
    "effective_role": "user",
    "elevated": false,
    "can_access_settings": false
  },
  "meta": { "api_version": "v1" }
}

While a just-in-time elevation is active, effective_role is admin, elevated is true, elevated_until (ISO-8601 UTC) is added and can_access_settings is true for a local session.

FieldMeaning
usernameThe stable authorization principal (for an SSO session an opaque oidc:<iss>#<sub> id).
display_nameA readable label; falls back to username.
roleThe persisted legacy role (admin or user). Never the elevated view. This and effective_role are the legacy session view, not a summary of authority: with RBAC on, authority is decided per permission (use POST /api/v1/rbac/check), and an engine principal’s real authority is its rbac_roles.
principal_kindhuman or engine, and nothing else. This is the classification the server applies: engine when the session records that kind or was authenticated by an engine token, human otherwise. An engine principal is looked up under RBAC principal type engine.
auth_sourceAlways present. How the session authenticated: local, oidc, saml, api_token, mcp_token or engine_token.
mcp_tierThe MCP authorization tier of an MCP token; an empty string for any other session.
scope_serviceThe service tag of a service-scoped token; an empty string when the session is not service-scoped.
rbac_enabledThe server’s cached view of the RBAC switch. It can lag a change made on another replica, and it does not report the degraded state in which enforcement fails closed (tracked in #6033).
rbac_roleDeprecated display field: the first DIRECT role assignment in role-name order. Omitted for an engine principal while RBAC is off, for an engine principal with no grant while RBAC is on, and when the role-store read failed. For a human with no direct role it shows the legacy label Administrator or Viewer, including while RBAC is on; that label is not a grant and not authority. Read rbac_roles instead.
rbac_rolesThe principal’s DIRECT role assignments only; roles held through a group grant are not listed. It is not access-review evidence: the access-review export (AccessReview:Read) is. With RBAC off it is []. Omitted when the role-store read failed.
rbac_roles_resolvedfalse only when the role-store read failed; it is true otherwise, including with RBAC off (no read is made then, so true is not proof that enforcement is off). When false the route still answers 200 and omits rbac_role and rbac_roles; treat an absent list as unknown, not as “no roles”.
effective_roleThe effective legacy session role: admin while a just-in-time elevation is active, otherwise role. Like role, it is not a summary of authority.
elevatedtrue while a just-in-time elevation is active. Always false for API and MCP token sessions.
elevated_untilEnd of the elevation window, ISO-8601 UTC. Present only when elevated is true.
can_access_settingstrue when GET /settings would render for this session. Always false for an MCP-tier or service-scoped token, because the admin gate refuses either before it tests the role; for an MCP token effective_role therefore equals role. A display hint only: every route is still gated server-side.

Keys appear in the order shown; do not depend on it. The response is sent with Cache-Control: no-store, private. During a rolling upgrade an older replica omits the newer keys (principal_kind, auth_source, mcp_tier, scope_service, rbac_roles, rbac_roles_resolved and the session fields); clients should treat a missing can_access_settings as false. GET /api/me returns the same keys without the data envelope.


Devices

The fleet device list and single-device identity read (#4033, #2146 API-parity Batch A) — the REST twins of the /devices dashboard’s list_agents/get_agent_details-shaped MCP tools. Both routes are gated via AuthRoutes::require_fleet_read (the admit-then-filter chokepoint), not a bare permission check — a management-group-confined operator and a correctly-confined service-scoped token both see a real, filtered subset of the fleet rather than either the whole fleet or an outright denial. Neither route is audited on a successful read: device identity (hostname/OS/arch/version) is machine metadata, not behavioural PII — matching the /fragments/devices/list, /fragments/device/page, and /fragments/device/info dashboard fragments’ own unaudited posture.

GET /api/v1/devices

List every device visible to the caller.

Permission: Infrastructure:Read, via require_fleet_read.

Response:

{
  "data": {
    "devices": [
      {
        "agent_id": "a1b2c3d4e5f6",
        "hostname": "web-01",
        "os": "linux",
        "arch": "x86_64",
        "agent_version": "0.13.1"
      }
    ],
    "count": 1,
    "devices_omitted": 0
  },
  "pagination": { "total": 1, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

devices_omitted counts agents dropped by the caller’s management-group/service-scope confinement (0 when unfiltered or nothing was dropped) — the same “out of my scope” vs. “not present” signal GET /api/v1/inventory/software’s devices_omitted field used to carry (on that route it now counts only devices hidden by the post-filter and is 0 under SQL push-down — see its section).


GET /api/v1/devices/{id}

Fetch one device’s identity.

Permission: Infrastructure:Read, via require_fleet_read.

Response:

{
  "data": {
    "agent_id": "a1b2c3d4e5f6",
    "hostname": "web-01",
    "os": "linux",
    "arch": "x86_64",
    "agent_version": "0.13.1",
    "tags": [
      { "key": "environment", "value": "production", "source": "server" }
    ]
  },
  "meta": { "api_version": "v1" }
}

tags is always present ([] when the device has no tags, or in the never-hit-in-production case of an unwired TagStore). A device outside the caller’s fleet-read scope returns the SAME 404 as a genuinely nonexistent agent_id — an out-of-scope match is never distinguishable from “does not exist” in the response body (matches the pre-existing MCP get_agent_details tool’s existence-oracle closure exactly).


Hardware

The Hardware CI list and single-device CI record: the two read routes of the OpenAPI tag Hardware, followed by short entries for the on-demand syncs POST /api/v1/hardware/{id}/sync (one device) and POST /api/v1/hardware/sync (a scope or the fleet). Both read routes are audited on a served read, and with result=failure when the device roster could not be read.

GET /api/v1/hardware

The Hardware list: the device roster (identity fields as for /devices) plus device-CI columns, a per-page DEX score, claimed IPs and tags.

Permission: Inventory:Read, via require_fleet_read (admit-then-filter). MCP twin: list_hardware_ci (strict argument typing and a lowercase-only os/status/sort/dir; see docs/user-manual/mcp.md).

Query parameters: q, os, status, sort, dir, tag, offset, limit. An unrecognised sort, os or status token, or an invalid tag key, is a 400; any dir other than desc sorts ascending.

Response: { "data": { "devices": [...], "count", "total_matching", "devices_omitted", "ci_degraded", "tags_degraded", "kpis", "query" }, "pagination", "meta" }.

StatusMeaning
200The roster was read. ci_degraded / tags_degraded are true when the CI or tag store could not be read, so those columns are blank rather than genuinely absent. At most the 100,000 most recently seen endpoints within the 30-day window are served, with no truncation signal yet. While tags_degraded is true, a tag filter (or a tag match in q) sees no tags and answers an empty list.
400Unrecognised sort, os or status token, or an invalid tag key.
403Caller lacks Inventory:Read.
503Route misconfigured (roster provider unwired), the device roster could not be read (retry_after_ms set), the audit row could not be persisted, or the authorization store is unavailable (retry_after_ms 5000). A failed roster read is never a 200 with an empty list.

Audit: inventory.devices; result=success on a served page, result=failure (detail hardware roster read failed) when the roster could not be read. A persist failure on the success row fails the request closed with 503.


GET /api/v1/hardware/{id}

One device’s CI record: identity, device-CI blob, installed software and tags, each read independently so a degraded read is distinguishable from an absent one.

Permission: Inventory:Read, via require_fleet_read. An id outside the caller’s fleet-read scope returns the same 404 as a nonexistent id. MCP twin: get_hardware_ci (see docs/user-manual/mcp.md).

Response: { "data": { "agent_id", "hostname", "os", "online", "last_seen", "identity_state", "ci", "ci_state", "software", "tags", ... }, "meta" }. identity_state is found, degraded (the device roster could not be read) or absent; ci_state is found, absent or degraded. agent_id, hostname, os and last_seen are null (and online is false) unless identity_state is found.

StatusMeaning
200The record. A degraded CI store alongside a degraded roster is still a 200 carrying identity_state and ci_state as degraded.
400Device id longer than 256 bytes or containing a byte below 0x20. Checked after the Inventory:Read check and before the scope check. Registration applies the same 256-byte limit and refuses an id with a byte below 0x20 (through its enrollment check), so any id that agent registration accepts passes and a registered device is not affected.
403Caller lacks Inventory:Read.
404Not found, or outside the caller’s fleet-read scope (indistinguishable by design).
503Route misconfigured, audit row not persisted, the authorization store unavailable (retry_after_ms 5000), or the device roster could not be read (retry_after_ms set) and the device was not otherwise found (CI record readable-and-absent, no software, no tags). Never a 404.

Audit: inventory.device.ci; result=failure when the roster read failed (detail state=roster unreadable on the 503, state=<...> identity=roster unreadable on a served 200).


POST /api/v1/hardware/sync

Requests an on-demand software-inventory sync for a device, a scope or the whole fleet (the Software page’s Sync now). Permission: Execution:Execute, checked as a global grant: an operator whose Execution:Execute is limited to management groups is refused with 403 for every scope kind. The route also confines its targets to the caller’s own Execution:Execute visible set and refuses the whole fleet ("scope":"__all__") unless that set is unfiltered (403); with a global grant neither changes the outcome today, and both are defence in depth for when the gate becomes scope-aware. The body names the targets with exactly one of scope (__all__, group:<id> or a device id, up to 256 bytes) or scope_expression (a scope expression, up to 1,024 bytes); source may only be installed_software.

A run reaches agents attached to this server process only. skipped_offline counts the devices this server process did not reach that are not too old: it has no live session for them (a disconnected device, or one attached to another server), or the send was refused by the live stream (for example a gateway-attached agent whose gateway lacks the dispatch capability). Which devices are candidates depends on the scope: a group or a single named device lists its disconnected members too, but an __all__ or scope-expression run lists only this server’s own sessions plus the shared presence record, so a device that is offline everywhere is never counted, and if the presence read is degraded an __all__ run lists fewer devices (those attached to other servers are not listed) while a scope-expression run is refused with 503 (the scope could not be evaluated) and sends nothing. requested and skipped_offline therefore describe what this server could reach; they are not a census of the fleet. Agents below 0.13.1 are skipped and counted in skipped_version. At most 500 devices are reached per request (413). A group, scope or fleet run is limited to one per operator per 60 seconds (429, Retry-After) (an attempt refused for exceeding 500 devices, or with no live target, counts as a run; a send every device refused does not) and one at a time (409; the slot is a 120 s lease a later run can take over, but only while fewer than three stalled runs are alive, after which 409 persists until one of them unwinds or the server restarts), both per server process rather than cluster-wide (with several active servers each allows its own run, still bounded by the 500-device cap); a single named device is exempt. The 202 body carries counts only (requested, agents_reached, skipped_offline, skipped_version) plus command_id, never device ids. A server that is missing a required component answers 500 and writes a result=failure audit row. A browser (session-cookie) request must carry an Origin or Referer naming this site, otherwise it is refused with 403 and a csrf.denied audit row; API-token and MCP-token callers are exempt. Who is reached is decided at send time by the dispatch_confined_arms system-reserved arm, which intersects the target list with the caller’s own Execution:Execute visible set. The MCP tool sync_hardware_software is the twin of this route and shares its cooldown and its single in-flight run. The request, statuses and bounds are in the OpenAPI document. Audit: inventory.sync.request, written before dispatch (see audit-log.md).


POST /api/v1/hardware/{id}/sync

Requests an on-demand agent sync for one device. Permission: Execution:Execute scoped to the device. A browser (session-cookie) request must carry an Origin or Referer naming this site (403 otherwise; token callers are exempt). The body, the 202 and the 409 are described in the OpenAPI document. A device id longer than 256 bytes or containing a byte below 0x20 is a 400, answered before the permission check and with no audit row (the same id rule as GET /api/v1/hardware/{id}, including the registration note there). Audit: inventory.sync.request (see audit-log.md).


Management Groups

Management groups organize agents into a hierarchy for access scoping. Groups can be static (manually assigned members) or dynamic (membership determined by a scope expression evaluated against agent tags and properties).

GET /api/v1/management-groups

List all management groups.

Permission: ManagementGroup:Read

Response:

{
  "data": [
    {
      "id": "a1b2c3d4e5f6",
      "name": "EU Production Servers",
      "description": "All production endpoints in EU datacenters",
      "parent_id": "",
      "membership_type": "static",
      "scope_expression": "",
      "created_by": "admin",
      "created_at": 1710849600,
      "updated_at": 1710849600
    },
    {
      "id": "f6e5d4c3b2a1",
      "name": "Windows Desktops",
      "description": "Dynamic group for all Windows endpoints",
      "parent_id": "",
      "membership_type": "dynamic",
      "scope_expression": "os = 'windows'",
      "created_by": "admin",
      "created_at": 1710850000,
      "updated_at": 1710850000
    }
  ],
  "pagination": { "total": 2, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

POST /api/v1/management-groups

Create a new management group.

Permission: ManagementGroup:Write

Request body:

{
  "name": "EU Production Servers",
  "description": "All production endpoints in EU datacenters",
  "parent_id": "",
  "membership_type": "static",
  "scope_expression": ""
}
FieldTypeRequiredDescription
namestringYesUnique group name
descriptionstringNoHuman-readable description
parent_idstringNoID of parent group (empty for root)
membership_typestringNo"static" (default) or "dynamic"
scope_expressionstringNoScope engine expression for dynamic groups

Response (201):

{
  "data": { "id": "a1b2c3d4e5f6" },
  "meta": { "api_version": "v1" }
}

GET /api/v1/management-groups/{id}

Get a single group’s details including its current members.

Permission: ManagementGroup:Read

Response:

{
  "data": {
    "id": "a1b2c3d4e5f6",
    "name": "EU Production Servers",
    "description": "All production endpoints in EU datacenters",
    "parent_id": "",
    "membership_type": "static",
    "scope_expression": "",
    "created_by": "admin",
    "created_at": 1710849600,
    "updated_at": 1710849600,
    "members": [
      {
        "agent_id": "agent-eu-prod-01",
        "source": "static",
        "added_at": 1710850000
      },
      {
        "agent_id": "agent-eu-prod-02",
        "source": "static",
        "added_at": 1710850100
      }
    ]
  },
  "meta": { "api_version": "v1" }
}

The group row is read through ManagementGroupStore::get_group_checked and its members through get_members_checked (#1762). A store-not-open / pool-acquire-timeout / query-error degrade on EITHER read returns 503 (A4 envelope, retry_after_ms: 2000) rather than a 404 or an authoritative empty members: []; 404 now always means the group does not exist. The MCP twin get_management_group fails the same way.


PUT /api/v1/management-groups/{id}

Update a management group. Only the fields provided in the request body are changed; omitted fields retain their current values.

Permission: ManagementGroup:Write

Request body:

{
  "name": "EU Production Servers (Renamed)",
  "description": "Updated description",
  "parent_id": "f6e5d4c3b2a1",
  "membership_type": "dynamic",
  "scope_expression": "os = 'linux' AND tag:environment = 'production'"
}
FieldTypeRequiredDescription
namestringNoNew group name (must be unique)
descriptionstringNoUpdated description
parent_idstringNoNew parent group ID (empty string for root-level)
membership_typestringNo"static" or "dynamic"
scope_expressionstringNoScope engine expression for dynamic groups

Validation rules:

All validation runs at the ManagementGroupStore layer as well as the REST handler, so non-REST administrative callers cannot bypass cycle or depth checks.

Response:

{
  "data": { "updated": true },
  "meta": { "api_version": "v1" }
}

Error (400) — cycle detected:

{
  "error": "re-parenting would create a cycle",
  "meta": { "api_version": "v1" }
}

Error (400) — self-parent:

{
  "error": "group cannot be its own parent",
  "meta": { "api_version": "v1" }
}

Error (400) — depth exceeded:

{
  "error": "maximum hierarchy depth (5) exceeded",
  "meta": { "api_version": "v1" }
}

Error (400) — root group re-parent:

{
  "error": "cannot re-parent root group",
  "meta": { "api_version": "v1" }
}

Error (503) — store unavailable:

Cycle and depth validation walk the hierarchy via recursive ancestor/descendant reads against the management_group_store Postgres substrate (ADR-0042). If those reads degrade (store not open, connection-pool acquire timeout, or a query error) the re-parent cannot be validated safely, so it fails closed with 503 rather than risk applying an unvalidated move — the caller did nothing wrong, the store did. This is distinct from the 400 validation errors above (a genuinely bad request); retry a 503 once the substrate recovers.

{
  "error": "management group store unavailable",
  "meta": { "api_version": "v1" }
}

DELETE /api/v1/management-groups/{id}

Delete a management group. The root group (“All Devices”, ID 000000000000) cannot be deleted and returns 403 Forbidden. Deleting a group cascade-deletes all child groups, their membership records, and their role assignments.

Permission: ManagementGroup:Delete

Response:

{
  "data": { "deleted": true },
  "meta": { "api_version": "v1" }
}

Error (403) — root group:

{
  "error": "cannot delete root group",
  "meta": { "api_version": "v1" }
}

POST /api/v1/management-groups/{id}/members

Add an agent to a static management group.

Permission: ManagementGroup:Write

Request body:

{
  "agent_id": "agent-eu-prod-03"
}

Response (201):

{
  "data": { "added": true },
  "meta": { "api_version": "v1" }
}

DELETE /api/v1/management-groups/{id}/members/{agent_id}

Remove an agent from a management group.

Permission: ManagementGroup:Write

Response:

{
  "data": { "removed": true },
  "meta": { "api_version": "v1" }
}

GET /api/v1/management-groups/{id}/roles

List role assignments scoped to this management group.

Permission: ManagementGroup:Read. This response is authorization topology (who holds what role), so the fleet-wide arm actually authorizing it is the floored UserManagement:Read (#2376), not ManagementGroup:Read itself — see the authorization topology floor. A caller lacking that also succeeds if they are ITServiceOwner of this specific group (a scoped fallback, not the fleet-wide grant) — but a service-scoped API token cannot use that fallback (guardian-confinement-2298 PR 3): the fallback checks group membership only, never the token’s own service-tag scope, and a service-scoped token is denied outright rather than risk it reaching a group outside its own service.

Response:

{
  "data": [
    {
      "group_id": "a1b2c3d4e5f6",
      "principal_type": "user",
      "principal_id": "jane.ops",
      "role_name": "Operator"
    },
    {
      "group_id": "a1b2c3d4e5f6",
      "principal_type": "user",
      "principal_id": "bob.viewer",
      "role_name": "Viewer"
    }
  ],
  "meta": { "api_version": "v1" }
}

POST /api/v1/management-groups/{id}/roles

Assign a role to a principal within this management group. Only the Operator and Viewer roles can be delegated. The caller must be a global Administrator or hold the ITServiceOwner role on this group.

Permission: Global ManagementGroup:Write or ITServiceOwner on this group. A service-scoped API token is denied outright, even one whose minter holds ITServiceOwner on this exact group (guardian-confinement-2298 PR 3) — the group-scoped fallback checks membership only, never the token’s own service-tag scope.

Request body:

{
  "principal_type": "user",
  "principal_id": "jane.ops",
  "role_name": "Operator"
}
FieldTypeRequiredDescription
principal_typestringNo"user" (default) or "group"
principal_idstringYesUsername or group name
role_namestringYes"Operator" or "Viewer" only

Response (201):

{
  "data": { "assigned": true },
  "meta": { "api_version": "v1" }
}

Error (403) — non-delegatable role:

{
  "error": "only Operator and Viewer roles can be delegated",
  "meta": { "api_version": "v1" }
}

DELETE /api/v1/management-groups/{id}/roles

Remove a role assignment from this management group.

Permission: Global ManagementGroup:Write or ITServiceOwner on this group. Same service-scoped-token denial as POST above.

Request body:

{
  "principal_type": "user",
  "principal_id": "jane.ops",
  "role_name": "Operator"
}

Response:

{
  "data": { "unassigned": true },
  "meta": { "api_version": "v1" }
}

GET /api/v1/management-groups/agent-count-preview

Preview the number of currently-visible agents that would match a would-be management group’s filter criteria, before creating it (#4033, #2146 API-parity Batch A). REST/MCP/dashboard-fragment twin of /fragments/create-group-form’s own live count. This route and its MCP twin call the same shared builder (group_agent_count_preview.hpp), so those two cannot drift from each other; the dashboard fragment keeps its own separate, behaviourally-equivalent inline implementation (DashboardRoutes::parse_filters), except that for an over-bound filter it hides the count and shows a static sentence (#5623). For a filter within the bound, the number it shows and this route’s agent_count are expected to agree today, but are not structurally guaranteed to. MCP twin: preview_management_group_agent_count.

Permission: ManagementGroup:Write — matching the fragment’s own gate exactly (only an operator who could create the group may preview it), not ManagementGroup:Read, even though this route performs no mutation.

Query parameters:

ParameterDescription
command_idThe response set’s instruction/command id to count against.
pluginPlugin name whose response columns the filter keys are matched against.
<filter keys>Any other query param is treated as a filter, keyed by the SAME mangling the dashboard fragment’s f_<column> params apply to a column name — lowercase, spaces/dashes replaced with underscores (e.g. Local Addr → local_addr). Unlike the fragment, this route’s keys carry no f_ prefix — supply local_addr, not f_local_addr. An unrecognised key is silently ignored.

An empty filter set (no recognised filter keys supplied) returns a genuine 0 — no scoped count to report, and no store read is made.

Response:

{
  "data": { "agent_count": 42, "unindexed_filter_keys": [] },
  "meta": { "api_version": "v1" }
}

unindexed_filter_keys is always present (additive, #5623): the mangled keys, in a stable order (sorted by key) and without duplicates, of any filter whose value is longer than 2048 bytes (measured as ingest stores it, so a NUL or invalid UTF-8 byte counts as 3), and an empty array when none is. Such a value is not indexed in newly stored responses, so when the array is not empty agent_count can be 0 or an undercount. The array names keys only, never the values. agent_count and every status code are unchanged, including the 503 when the response store is degraded.

Forensics-tier rows (#4990). Rows written by a Forensics-tier plugin are counted only for a caller who also holds Forensics:Read (see “Forensics-tier rows” under Command/Instruction Responses). For any other caller they are absent from the count with no marker, so a low or zero agent_count does not prove that no matching data exists. If the caller’s Forensics authority cannot be determined the route answers a retryable 503 with retry_after_ms instead of a partial count. The route writes no audit row of its own.


API Tokens

API tokens provide non-interactive authentication for scripts and automation. Tokens are scoped to the creating user’s permissions. The raw token string is returned exactly once at creation time and cannot be retrieved afterward.

Storage failure: if the server’s token store database failed to open at startup (bad data directory, permissions), all three token endpoints return 503 with message service unavailable rather than 404 or an empty list, so automation can distinguish a server-side outage from a missing token. Note that Bearer-token authentication against an unavailable token store deliberately fails closed with 401, not 503 — an automation client seeing unexpected 401s during an outage should check server health (/readyz reports api_token_store) before rotating credentials.

GET /api/v1/tokens

List the current user’s API tokens. Raw token values are never returned.

Permission: ApiToken:Read

Response:

{
  "data": [
    {
      "token_id": "a1b2c3d4",
      "name": "CI Pipeline Token",
      "principal_id": "admin",
      "created_at": 1710849600,
      "expires_at": 1742385600,
      "last_used_at": 1710936000,
      "revoked": false
    }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

Rotation fields (P2 #11): rotation_group, supersedes_token_id, overlap_expires_at, and confirmed_at appear on an item only while a rotation is (or was) in flight for that token — a token that has never been rotated omits all four. rotation_group is present on both the predecessor and the successor row of an in-flight pair (it links them); supersedes_token_id is present only on the successor and equals the predecessor’s token_id; overlap_expires_at is present on the predecessor while the overlap window is open (the epoch it is auto-revoked); confirmed_at is present once an explicit credentials/confirm-style confirmation (below) has closed the rotation. A caller that only checks for the absence of these fields on a fresh token needs no code change — they were not serialized before P2 #11 either.


POST /api/v1/tokens

Create a new API token. The raw token is returned in the response and is never shown again. Store it immediately.

Permission: ApiToken:Write

Request body:

{
  "name": "CI Pipeline Token",
  "expires_at": 1794355200,
  "mcp_tier": "readonly"
}

The expires_at above is a placeholder epoch: with mcp_tier set it must be in the future and at most 90 days out, so compute it at call time, for example $(date -d '+30 days' +%s) (macOS: date -v+30d +%s).

FieldTypeRequiredDescription
namestringYesHuman-readable label
expires_atintegerNoUnix epoch seconds, in the future. 0 or omitted = never expires. Required for MCP tokens (max 90 days). Must be one JSON integer: a string, float, boolean, null, a negative or past value, or one above 9223372036854775807 is 400 and mints nothing.
mcp_tierstringNoMCP authorization tier: "readonly", "operator", or "supervised". Omit for standard API tokens. When set, expires_at is mandatory.

mcp_tier is honored at mint time (a value outside readonly/operator/supervised is rejected). A tiered (mcp_tier set) or service-scoped token also requires a valid expires_at; the 90-day cap applies to tiered (mcp_tier set) tokens only, so a service-scoped token without a tier may expire later than that. Setting scope_service requires RBAC to be enabled and either a global ManagementGroup:Write grant or ITServiceOwner on the "Service: <scope_service>" management group — see Service-Scoped Tokens.

Validation errors (400):

ConditionResponse
mcp_tier is not one of readonly / operator / supervised400 — invalid mcp_tier: must be one of readonly, operator, supervised (or omitted)
mcp_tier set (or scope_service set) but expires_at omitted or 0400: expires_at is required for an MCP-tier or service-scoped token
mcp_tier set and expires_at is more than 90 days out400 — MCP token TTL cannot exceed 90 days
The body is not a JSON object (truncated, empty, array, string, number, null), or name, scope_service or mcp_tier is not a string400: request body must be a JSON object / <field> must be a string
expires_at is not a JSON integer ("7", 5.5, 1e3, true, null, 18446744073709551615)400: expires_at must be a JSON integer (unix epoch seconds)
expires_at is negative, or a positive epoch that is not in the future400: expires_at must be 0 (never expires) or a unix epoch second in the future

Response (201):

{
  "data": {
    "token": "yzt_a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef12345678",
    "name": "CI Pipeline Token"
  },
  "meta": { "api_version": "v1" }
}

Warning: Copy the token value immediately. It cannot be retrieved after this response.

The response is sent Cache-Control: no-store, no-cache, must-revalidate and Pragma: no-cache, because the body carries a raw one-time secret (the same headers as the rotate and engine mint routes).


DELETE /api/v1/tokens/{token_id}

Revoke an API token. The token becomes immediately unusable.

Permission: ApiToken:Delete

Ownership constraint: A caller with ApiToken:Delete may only revoke tokens they created. The global admin role, or a session with an active just-in-time admin elevation, is the only bypass and may revoke any token. Attempting to revoke another user’s token returns 404 token not found — identical to the response for a token that does not exist — so the endpoint cannot be used as an enumeration oracle by a non-owner with ApiToken:Delete. Denied attempts are recorded in the audit log with action=api_token.revoke, result=denied, and detail=owner=<real owner> so forensics can distinguish a probe from a legitimate self-revoke.

The same ownership constraint applies to the HTMX dashboard path DELETE /api/settings/api-tokens/{token_id}. The same bypass applies there: the global admin role or a just-in-time-elevated session may revoke any token.

Response:

{
  "data": { "revoked": true },
  "meta": { "api_version": "v1" }
}

Error (404) — token not found or not owned by caller:

{
  "error": {
    "code": 404,
    "message": "token not found"
  },
  "meta": { "api_version": "v1" }
}

POST /api/v1/tokens/{token_id}/rotate

Self-service overlap-pair rotation of a human-owned API token (P2 #11, SOC 2 CC6.3): mints a successor token while the existing (predecessor) token stays valid for an overlap window — at most two active tokens in the rotation group during the overlap. {token_id} in the path is the predecessor’s id. The raw successor secret is returned exactly once per reveal (see the grace-window re-serve note below), the same discipline the engine-principal credentials/rotate route above uses. MFA step-up runs on every call to this route, including an idempotent re-serve.

Permission: ApiToken:Rotate — a DISTINCT operation from ApiToken:Write (round-4 security finding): ApiToken:Write also gates POST /api/v1/tokens (mint) and its settings twin, so a shared op would let an operator-tier MCP token mint a brand-new, caller-chosen-tier token — a privilege escalation, not a parity fix. See mcp_policy.hpp’s tier_allows() operator-tier comment for the full analysis. Under the default RBAC-off configuration, this permission is reachable by any non-admin owner (#2963 legacy self-service allowlist) — ownership, not role, is what actually gates who can succeed; see Rotating a Token for the operator-facing explanation.

Ownership constraint — self-service only, no admin bypass: unlike DELETE /api/v1/tokens/{token_id} above, there is no admin override here. A human token’s raw successor secret authenticates as that user, so an admin rotating or confirming someone else’s token would hand out (or complete the cutover of) a credential that impersonates them — identity takeover, not a permission gap an admin role could legitimately cross. An admin who needs to act on another user’s token still has DELETE (revoke). Attempting to rotate a token you do not own returns 404 token not found — identical to the response for a token that does not exist, closing the same enumeration-oracle gap the DELETE route closes. Denied attempts are recorded in the audit log with action=api_token.rotate, result=denied, and detail=owner=<real owner>.

Request body (optional):

{ "overlap_secs": 604800 }

overlap_secs defaults to 7 days (604800), with the same 24-hour floor / 10-year ceiling as the engine-principal rotate route — a value outside that range is rejected (400), never silently clamped.

Lifetime-neutral by design (SOC 2 CC6.3): the successor token always inherits the predecessor’s expires_at verbatim — a perpetual (never-expiring) token stays perpetual, a 30-day token stays a 30-day token measured from its own original grant, never recomputed as “90 days from now”. There is no request field to override this; the store-level successor_expires_at override that exists internally for the engine-principal arm is deliberately not exposed on this route. Rotating a credential must never silently extend its authorization lifetime — an auditor reviewing rotation evidence for CC6.3 must be able to trust that rotation and grant renewal are two separate, independently-audited actions, never one fused into the other. A caller that genuinely needs a longer-lived replacement token should mint a fresh one via POST /api/v1/tokens instead.

Response: same Cache-Control: no-store, no-cache, must-revalidate + Pragma: no-cache headers as POST /api/v1/tokens, since the body carries a raw one-time secret.

{
  "data": {
    "token": "yzt_...",
    "token_id": "...",
    "expires_at": 1742385600,
    "overlap_expires_at": 1710936000
  },
  "meta": { "api_version": "v1" }
}

token_id and expires_at describe the successor, found structurally — the active row whose supersedes_token_id equals {token_id} (the predecessor being rotated), never merely “any linked row of this principal’s active set” and never by “most recently created” (which is second-resolution and can tie). A human principal routinely holds several unrelated tokens with independent in-flight rotations, so the match must be scoped to the exact predecessor in the path — a broader match can return a different rotation’s successor.

overlap_expires_at, by contrast, describes the predecessor — consistent with the GET /api/v1/tokens field of the same name above (§“Rotation fields”): it is stamped on the predecessor row, not the successor, and is echoed here purely for the caller’s convenience (the epoch at which the predecessor this call just rotated is auto-revoked). There is no separate overlap_expires_at on the successor row itself; do not expect one from GET /api/v1/tokens until the successor is itself rotated in turn.

Success audit: action=api_token.reveal — the reveal itself is the success event, mirroring the engine-principal route’s engine_principal.credential.reveal; a grace-window re-serve gets its own row too, since every time the raw secret leaves the server is independently on the audit chain.

Errors:

ConditionResponse
No such token, or the token exists but is not owned by the caller404 — token not found (identical body; not an enumeration oracle)
The token exists and IS owned by the caller, but the caller’s OWN current mcp_tier/scope_service (from their authenticated session) does not equal the token’s own (the authority-inheritance guard), AND the caller holds SOME non-empty mcp_tier/scope_service of its own — a caller holding NO standing tier/scope at all (an untiered dashboard/cookie session) is instead admitted regardless of the token’s own tier/scope (#2963; the successor still inherits the token’s own tier/scope, never the caller’s)400 — no such token to rotate (store-level; distinct from the route’s own 404 pre-check above, and worded identically to the absent/not-owned case on purpose, so this is not an authority-probing oracle either — see Rotating a Token for the operator-facing explanation)
overlap_secs present in the body but not a JSON integer (a string, float, boolean, null, or a value above 9223372036854775807)400: overlap_secs must be an integer (seconds)
A non-blank body that is not a JSON object (truncated, array, string, number, null); an empty or whitespace-only body keeps the default400: request body must be a JSON object
Overlap window below the 24h floor, or above the 10-year ceiling400
Overlap window would outlive the predecessor’s or the successor’s own expiry — by design, a token within 24h of its own expiry cannot be rotated (rotation is lifetime-neutral, so there is no way to shrink the overlap below the 24h floor to fit); mint a new token instead400
The token is not a human-owned credential (an engine-principal credential somehow reached this route)400 — token is not a human-owned credential
The token is revoked or already expired400 — credential is not currently active — nothing to rotate
Two active tokens in the rotation group exist but are not a recognized predecessor/successor pair400 — resolve via revoke, not rotate
More than two active tokens in the rotation group400 — resolve manually before rotating
A rotation already in flight, initiated by a different operator409
Grace window elapsed with no confirm409 — grace window elapsed; confirm or revoke
No active credential found to rotate503 — deliberately conflated with a transient read failure, same rationale as the engine-principal rotate route above
The rotation itself succeeded (a successor token now exists) but the follow-up read that locates it for the response came back empty503 — fails CLOSED rather than return a raw one-time secret with no token_id to ever confirm it against; retry, or check GET /api/v1/tokens
Advisory-lock acquire failure, CSPRNG failure, or a mint/stamp write that did not persist503 — retryable store failure
MFA step-up not satisfied401
Missing ApiToken:Rotate, or the caller’s own session is engine-classed (structural deny belt)403

POST /api/v1/tokens/{token_id}/confirm

Explicit maker-checker confirmation that a rotation’s successor secret has been received and installed. {token_id} in the path is the successor’s id — the value the rotate response above returned. On success, the predecessor token is revoked and the successor becomes the sole active token in the rotation group.

Permission: ApiToken:Rotate (same distinct-operation rationale as rotate above)

Proof of possession (#3015, SOC 2 CC6.3) — BREAKING: unlike before, a request body is now required — {"secret": "<raw successor secret>"}, the raw successor secret rotate returned. Confirm revokes the predecessor immediately, so it must never proceed on token_id alone — a caller who merely learned or guessed the (non-secret) successor token_id could otherwise force an immediate cutover without ever having received the new credential. A caller that previously confirmed with only the {token_id} path parameter now gets 400. The check is performed immediately after auth/step-up, before the ownership/existence lookup below, so a missing secret is 400 even against an unowned or nonexistent token_id — it is never an enumeration oracle either way, since neither response discloses whether the token exists.

Request body (required):

{ "secret": "yzt_..." }

secret is verified last, strictly after the ownership check and every store-side admission check (pair-state, the successor pin, the authority-inheritance guard) have passed — via a constant-time hash comparison against the successor’s stored hash, read fresh inside the same advisory-locked transaction as the rest of this call. It is never persisted, logged, or echoed into an audit/error string.

Ownership constraint: the same self-service-only posture as rotate above — no admin bypass, 404 token not found for both a nonexistent successor id and one owned by someone else, and the same action=api_token.confirm/result=denied/detail=owner=<real owner> audit row on a denied attempt.

Response:

{
  "data": { "confirmed": true },
  "meta": { "api_version": "v1" }
}

Errors: the same state matrix as the engine-principal credentials/confirm route above (replay-after-success is a terminal 409, an ambiguous empty/malformed-pair read is a retryable 503, unresolved rotation metadata on the sole survivor is a terminal 409), substituting token is not a human-owned credential / principal has a non-human active credential for the engine-kind equivalents. 401/403 follow the same step-up and permission rules as rotate. The same authority-inheritance 400 — no such token to confirm also applies here, as defence-in-depth only (see the rotate error matrix row above, including the #2963 empty-tier/scope exception). This is not a same-session pin — the caller confirming does not have to be the same session that called rotate; ownership and the raw successor secret (proof of possession) are the actual gates on who may confirm. The check re-verifies that the CALLER’S CURRENT mcp_tier/scope_service equals the successor’s own (or the caller holds no standing tier/scope at all, #2963) — since the successor’s tier/scope are fixed at mint time and cannot legitimately diverge from the predecessor’s, this defense-in-depth check is not reachable as a live path today for a caller presenting the correct current authority, outside a future bypass of rotate’s own guard.

secret missing, empty, or not a string is 400 — secret required (checked before the 404 ownership belt, see above). A wrong secret is 403 — rotation secret mismatch — the presented secret does not verify against the pending successor — distinct from every other outcome, reachable only after every other admission check has already passed. If the authoritative successor row cannot be re-read to verify the secret (should not happen under the lock already held), that’s a fail-closed 503 — failed to verify rotation secret, folded into the general store-failure 503 row above.


Engine Principals

Engine principals are the durable identities behind autonomous use-case-engine modules (ADR-1005 item 2b) — a distinct principal class from human users and human-created API tokens, with a named responsible human owner, a grant justification captured at creation, and a required internal/external classification. Design reference: docs/auth-engine-principals-design.md. Every credential minted against an engine principal is hard-locked to MCP tier readonly and can never be granted the admin/Administrator role or any built-in role (assignment refuses them). A custom role that covers every permission is not refused at assignment (maintainer ruling 2026-10-23, #2485); it is detected afterwards by the pull-only auditor route below.

Every mutating route is admin + MFA-step-up gated. The read routes (GET/list, GET /{id}, and GET /audit/no-admin) are admin + RBAC gated (EnginePrincipal:Read for the two engine-principal reads, AuditLog:Read for /audit/no-admin) but do not require a fresh MFA step-up. Beyond that, every route, reads included, structurally denies a caller whose own session is engine-classed (principal_kind="engine" or auth_source="engine_token"), with one exception: GET /api/v1/engine-principals/{id}/roles carries no engine-session check and relies on its EnginePrincipal:Read gate alone. The two role mutations (POST and DELETE) are step-up-gated like every other mutating route here, so they refuse an engine-classed session at the central check. An engine-classed session can therefore not list, read or mutate engine principals through the other routes, not even its own. A denied engine-classed caller gets 403. On the read routes the handler issues it and the corresponding audit verb is recorded with result=denied. On the mutating routes the 403 is issued by the central step-up check, before the handler runs and before any role grant is consulted, so the handler’s own audit verb is never written; the only audit row is the step-up refusal mfa.step_up.required (result=error) whose detail carries reason=engine_principal_denied and stage=pre_authz.

Storage failure: if the engine-principal store failed to open at startup (no PostgreSQL configured, or a migration failure), every route on this surface returns 503 service unavailable.

Audit-persist failure (fail closed on mutations): these routes emit a privileged-action audit row, and a mutation that cannot record it must never look like a clean success. If the audit-store write itself fails, every mutating route (create, revoke, transfer-owner, credentials/credentials/rotate/credentials/confirm, and the role assign/unassign routes documented later) fails closed — it returns 503 with a Sec-Audit-Failed: true header instead of its normal 2xx (ADR-1005 “mutations fail closed on audit failure”). The mutation may already have taken effect (the audit is emitted after the store write), so treat such a 503 as unconfirmed and reconcile via a read. This is a stricter posture than the software-package/deployment mutations elsewhere in this API, which set-and-proceed (stay 2xx with Sec-Audit-Failed) on the same audit-drop: engine-principal management is a privileged-identity surface, so ADR-1005 holds it to fail-closed rather than proceed-and-signal.

The credential mint and rotate routes additionally withhold the one-time secret on this path — nothing is placed in the 503 body. Recovery differs by route: a mint whose audit dropped leaves a credential that exists but is unusable (no one holds its secret) — GET the principal to see it, then rotate it (mints an audited successor you can then confirm) to obtain a usable secret; a rotate whose reveal-audit dropped can be rotated again within the grace window (about two minutes, by the same operator, against the same server replica; after a restart, on another replica or once the window ends the retry is 409) to re-serve the same successor secret (an audited re-serve, not a new credential). If the window has passed, find the stuck successor with GET /api/v1/engine-principals/{id} (in active_credentials, the credential whose overlap_expires_at is 0 while another credential of the same rotation_group has a non-zero value; that read fails closed with 503, so a 200 list is authoritative) and revoke it with DELETE /api/v1/tokens/{token_id} (REST only; it needs ApiToken:Delete, a fresh MFA step-up and an effective administrator, since the credential’s owner is the engine principal and any other caller gets 404), then rotate again from the remaining sole credential. Do not revoke only the predecessor, which leaves a credential nobody holds, and do not revoke the principal itself: that is terminal. Note the compounding case under a sustained audit outage: repeated mint/rotate failures can leave two unconfirmed credentials, at which point the store’s ≤2-active guard blocks further rotation — recover once the audit store is healthy by revoking the stuck successor as described above and rotating again from the remaining credential (a second mint is refused while any active credential exists).

The read routes (GET/list, GET /{id}, GET /audit/no-admin, GET /{id}/roles) and the engine-session denial above instead set the same Sec-Audit-Failed: true header but still serve their response (except the 503 failure paths of the list and GET /{id} routes below, which serve nothing) — matching the MCP twins’ audit_persisted:false body field. Reads proceed (rather than fail closed like the behavioural-PII device/network reads) because they commit no state and disclose only authorization topology metadata — principal ids, owners, role grants — not per-person behavioural PII; the fail-closed-on-read posture exists specifically to keep individual-identifying data off an unaudited response, which does not apply here. Alert on Sec-Audit-Failed: true from this surface as a SOC 2 CC7.2 evidence-gap signal (the same alert that covers the behavioural-PII routes fires on it), while noting the serve behaviour differs: these reads still return data, those withhold it.

{id} convention: on every route below (GET /{id}, DELETE /{id}, the credentials/credentials/rotate/credentials/confirm/transfer-owner sub-resources), {id} is the full principal_id, i.e. engine:<slug> (e.g. GET /api/v1/engine-principals/engine:vuln-uce) — none of these routes prepend the engine: prefix themselves. This is the opposite convention from the .../{id}/roles role-assignment sub-resource documented later in this file, where {id} is the bare slug and the server reconstructs the full id internally — don’t assume the two sections share one convention.

POST /api/v1/engine-principals

Create a new engine-principal identity. principal_id is derived server-side as "engine:" + slug — the reserved engine: namespace.

Permission: Security:Write

Request body:

{
  "slug": "vuln-uce",
  "display_name": "Vuln UCE",
  "owner_username": "alice",
  "classification": "internal",
  "justification": "First-party vulnerability-management use-case engine module"
}
FieldTypeRequiredDescription
slugstringYes1-128 chars; becomes principal_id = "engine:" + slug.
display_namestringNoUI/audit label.
owner_usernamestringYesMust reference an existing user (FK-validated before the row is written).
classificationstringYesExactly "internal" or "external" — required at creation, no default and no silent fallback.
justificationstringYesGrant justification, feeds periodic access review. Rejected empty (store-level check, 400 — justification cannot be empty).

display_name is accepted but not actually enforced non-empty by the REST route or the store — an empty/omitted display_name is persisted as-is; contrast with the create_engine_principal MCP tool, which additionally rejects an empty display_name at the tool layer (see docs/user-manual/mcp.md).

Validation errors (400):

ConditionResponse
slug empty400 — slug is required
slug longer than 128 chars400 — invalid_input_length: slug exceeds 128 chars
slug contains characters outside [a-z0-9._-]400 — store validation error (principal_id slug may only contain lowercase letters, digits, '.', '_', '-')
owner_username empty or does not reference an existing user400 — owner_username must reference an existing user
classification missing or not internal/external400 — store validation error
justification empty400 — store validation error (justification cannot be empty)
The create audit row could not persist503 + Sec-Audit-Failed: true — the principal may already have been created (audit is emitted after the store write); reconcile via GET (see Audit-persist failure above)

Response (201):

{
  "data": { "principal_id": "engine:vuln-uce" },
  "meta": { "api_version": "v1" }
}

GET /api/v1/engine-principals

List every engine principal (all lifecycle states), with each principal’s active-credential count.

Permission: EnginePrincipal:Read

Response:

{
  "data": [
    {
      "principal_id": "engine:vuln-uce",
      "display_name": "Vuln UCE",
      "owner_username": "alice",
      "classification": "internal",
      "lifecycle_state": "active",
      "created_at": 1710849600,
      "active_credential_count": 1
    }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 1 },
  "meta": { "api_version": "v1" }
}

Errors: 503 — engine-principal store not open (see Storage failure above). 503 with Retry-After: 2, retry_after_ms: 2000 and an engine_principal.list audit row with result=failure — the engine-principal list or the token store’s credential counts could not be read (the two stores share one connection pool, so pool exhaustion shows here), so no list is served: an empty list or a count of 0 is never shown when the read did not answer.


GET /api/v1/engine-principals/{id}

Get one engine principal’s full identity row plus its active credentials (token id, name, timestamps, rotation group, overlap-expiry — never the raw secret; token_hash stays masked). active_credentials here is an array of credential objects — contrast with the list route above (active_credential_count, an integer) and with the MCP get_engine_principal twin, whose active_credentials field is an integer count under the same field name (see docs/user-manual/mcp.md).

Permission: EnginePrincipal:Read

Errors: 404 — engine principal not found. 503 — engine-principal store unavailable (not open, or the read itself failed). 503 with Retry-After: 2, retry_after_ms: 2000 and an engine_principal.get audit row with result=failure — the token store could not be read, so no active_credentials list is served (an empty list would read as “no credential”).


DELETE /api/v1/engine-principals/{id}

Terminal, irreversible revoke. Every active credential is revoked first, then the identity’s lifecycle_state flips to revoked — a caller can never observe a revoked identity with a still-valid credential. Idempotent in the sense that calling this on an already-revoked principal still returns 200 success (never an error) — but it is not audit-idempotent: a repeat call re-emits a fresh engine_principal.revoke success audit row each time, recording the repeat operator action (with credentials_revoked=0 on the repeat, since there is nothing left to revoke), not just the original revoke.

Permission: Security:Write

Request body (optional):

{ "superseded_by": "engine:vuln-uce-2" }

superseded_by is an optional successor-principal id recorded on this row for audit-trail continuity — recovery from a compromised or retired principal mints a fresh successor principal rather than un-revoking this one.

Response:

{
  "data": { "revoked": true, "credentials_revoked": 1 },
  "meta": { "api_version": "v1" }
}

Errors: 404 — engine principal not found. 503 — a genuine store failure at any step of the sequence (the principal lookup, the credential-store revoke, or the identity revoke itself) — never an idempotent no-op; credentials are revoked strictly before the identity, so a 503 here never leaves the identity flipped to revoked while a credential is still live. 503 + Sec-Audit-Failed: true — the revoke audit row could not persist (see Audit-persist failure above); the revoke may already have taken effect, so reconcile via GET.


POST /api/v1/engine-principals/{id}/credentials

Mint the first credential for an engine principal. The raw secret is returned exactly once in this response — capture it immediately; it cannot be retrieved again, only rotated (below) or revoked. A second call against a principal that already has an active credential returns 409 — use the rotate route once a credential exists.

Permission: Security:Write

Request body (optional):

{ "ttl_days": 90 }

The body is optional only when it is empty or whitespace-only (the credential then lasts 90 days). Any other body must be a JSON object: a truncated document, an array, a string, a number or null is 400 and mints nothing. Within an object, ttl_days defaults to 90 only when it is omitted. It must be a JSON integer between 1 and 90 (400 otherwise, including a string, float, boolean or null — the engine-credential ceiling matches the tiered-token 90-day cap elsewhere on this surface).

Response (201): headers carry Cache-Control: no-store, no-cache, must-revalidate and Pragma: no-cache so a shared proxy or browser history never retains the secret.

{
  "data": { "token": "yzt_...", "token_id": "...", "expires_at": 1760000000 },
  "meta": { "api_version": "v1" }
}

token is the raw secret (shown once); token_id and expires_at let a pure-REST caller correlate the credential it just minted without a follow-up GET.

Errors: 404 — engine principal not found. 409 — engine principal is not active (revoked), or the principal already has an active credential (mint is single-shot; use credentials/rotate instead). 400 — ttl_days wrong type or out of range, or a non-empty body that is not a JSON object. 503 — CSPRNG failure or any other store failure (a CSPRNG failure also sets Retry-After: 5 and increments yuzu_secure_random_failure_total{reason="prng_failure",site="engine_principal"}). 503 with Retry-After: 2 when the check that the principal has no active credential could not read the token store; nothing is minted, and the audit row is engine_principal.credential.mint with result=failure. Retry once the store answers. 503 with the secret withheld and the audit row engine_principal.credential.mint with result=partial when the credential was created but the read that locates it for the response could not answer: the credential exists but no one holds its secret, so do not retry the mint (that is 409); rotate it. The same 503, withheld secret and result=partial row apply when the read answers but finds no active credential (the new credential was revoked out of band between the insert and the read-back; the detail says so): list the principal’s credentials and rotate only if one is listed. 503 + Sec-Audit-Failed: true — the mint audit row could not persist: the credential was created but its one-time secret is withheld (never in the body), so the credential exists but is unusable — see Audit-persist failure above for recovery.


POST /api/v1/engine-principals/{id}/credentials/rotate

Overlap-pair rotation (design doc §7): mints a successor credential while the existing (predecessor) credential stays valid for an overlap window — at most two active credentials per principal during the overlap. The raw successor secret is returned exactly once per reveal (see below for the grace-window exception). MFA step-up runs on every call to this route, including an idempotent re-serve — a rotate call is never exempt from step-up just because it returns a secret the caller already saw.

Permission: Security:Write

Request body (optional):

{ "overlap_secs": 604800 }

overlap_secs defaults to 7 days (604800). The overlap window has a 24-hour floor and a 10-year ceiling — a value below the floor (or above the ceiling) is rejected outright (400), never silently truncated up to the floor. The window must also end before the predecessor expires and before the successor does, and the successor expires 90 days after the rotation, so in practice the window is shorter than 90 days; a longer one is 400 as well. overlap_secs must be a JSON integer when present, and a non-blank body must be a JSON object: a string, float, boolean or null value, or a truncated or non-object body, is 400 and rotates nothing (an empty or whitespace-only body keeps the default).

Grace-window re-serve: a same-caller retry within a ~120-second grace window after the original mint re-serves the same successor secret (rather than erroring or minting a second successor). Each successful return — the original reveal or a grace-window re-serve — is independently recorded under its own engine_principal.credential.reveal audit row, since every time the raw secret leaves the server is independently on the audit chain, whether or not the bytes are new. Once the grace window lapses, a further rotate call while a rotation is already in flight errors — the caller must fall back to credentials/confirm (below) or the principal-level revoke-and-replace runbook, never an indefinite retry loop.

Response: same no-store headers as the mint route; the data object carries token, token_id, expires_at and overlap_expires_at. expires_at is 90 days from the rotation; it is not inherited from the predecessor. overlap_expires_at is the successor row’s own stamp and is always 0, because only the predecessor is stamped; read the predecessor’s stamp on its entry in active_credentials of GET /api/v1/engine-principals/{id}.

No 404 path: unlike credentials (mint) and transfer-owner, this route never looks up {id} via a get() call before acting — it drives the rotation state machine straight off the principal’s active credential rows. An unknown or revoked {id} therefore does not 404.

Errors:

ConditionResponse
Overlap window below the 24h floor, or above the 10-year ceiling400
Overlap window would outlive the predecessor’s or the successor’s own expiry400
Exactly one active credential exists, but the engine-principal referent it would mint a successor against is unknown or revoked400 — engine principal not found or revoked
Two active credentials exist but are not a recognized rotation pair (predecessor/successor linkage broken)400 — resolve via revoke, not rotate
More than two active credentials exist for this principal400 — resolve manually before rotating
A rotation already in flight, initiated by a different operator409 — a second operator cannot touch an in-flight rotation they didn’t start
Grace window elapsed with no confirm409 — grace window elapsed; confirm or revoke
No active credential found for this principal to rotate503 — not 400/404. Deliberately conflated with a transient read failure: the internal read that finds “no active credential” cannot be told apart from a silently-failed read, so a genuine “nothing to rotate” is classified as retryable (503) rather than a definitive client error — treating it as 400 could otherwise mislead a caller into minting a redundant second credential during what was actually a transient outage.
A non-engine-kind active credential is present for this principal (defensive check)503
The rotation itself succeeded (a successor now exists) but the follow-up read that locates it for the response could not answer503 with Retry-After: 2. The one-time secret is withheld, and the audit row is engine_principal.credential.rotate with result=partial (a successor exists, so it is never failure) and no engine_principal.credential.reveal row. The same operator’s retry within the ~120-second grace window, against the same server replica, re-serves the same successor secret; after the window, after a restart or on another replica the retry is 409 and the pair is resolved by revoking the successor (not the predecessor) as described under Audit-persist failure above, then rotating from the sole credential
Advisory-lock acquire failure, CSPRNG failure, the engine-referent check itself being unreachable, or a mint/stamp write that did not persist503 — retryable store failure
The reveal audit row could not persist503 + Sec-Audit-Failed: true — the successor was minted but its one-time secret is withheld; rotate again within the grace window (about two minutes, same operator, same replica) to re-serve the same audited successor secret; after it the retry is 409 (see Audit-persist failure above)
The rotation succeeded and the follow-up read answered, but no active credential links back to a predecessor (the successor was revoked or confirmed out of band in between)503 with the secret withheld and audit engine_principal.credential.rotate with result=partial, and no Retry-After or retry_after_ms (no pending pair remains, so a retry would perform a new rotation rather than re-serve). The message reads “A successor may exist; inspect the principal’s credentials (GET /api/v1/engine-principals/{id}), then rotate again if needed”
MFA step-up not satisfied401
Missing Security:Write, or the caller’s own session is engine-classed (structural deny belt)403

POST /api/v1/engine-principals/{id}/credentials/confirm

Explicit maker-checker confirmation that a rotation’s successor secret has been received and installed by its consumer. This is a separate attestation from the rotate reveal above — it is never inferred from a successful rotate call. On success, the predecessor credential is revoked and the successor becomes the principal’s sole active credential (closing the overlap window early, ahead of the auto-revoke sweep).

Permission: Security:Write

Proof of possession (#3015, SOC 2 CC6.3) — BREAKING: the request body now also requires secret, the raw successor secret rotate returned. Confirm revokes the predecessor immediately, so it must never proceed on token_id alone — a caller who merely learned or guessed the (non-secret) successor token_id could otherwise force an immediate cutover without ever having received the new credential. A caller that previously confirmed with only token_id now gets 400. The presented secret is verified last, strictly after every other admission check (ownership, pair-state, the token_id pin, and the initiator binding below) has passed — so a wrong secret from a caller who fails an earlier check gets that check’s own non-disclosing error, never a secret-specific one; only a caller who has already cleared every other gate can reach the mismatch outcome.

Request body:

{ "token_id": "a1b2c3d4e5f60718293a4b5c", "secret": "eng_..." }

token_id (required) is the successor’s token id returned by the rotate response above — it pins this confirm to that exact rotation. A stale or mismatched id (for example a blind retry of an old confirm after a second rotation has started) is rejected with 409 and no state change, so a replayed confirm can never resolve a later rotation early. The success audit row records the confirmed id (token_id=<id>).

secret (required) is the raw successor secret from the rotate response — never persisted, logged, or echoed into an audit/error string. Missing, empty, or non-string is 400 — secret required. Verified via a constant-time hash comparison against the successor’s stored hash, read fresh inside the same advisory-locked transaction as the rest of this call; a wrong secret is 403 — rotation secret mismatch — the presented secret does not verify against the pending successor — distinct from every other outcome below, since it is reachable only after every other gate has already passed.

Replaying a confirm after its own success (a network-dropped 200, a double-submit) is a terminal 409 conflict, not a retryable 503: once the rotation has resolved the successor is the sole active credential, and the confirm returns an explicit already confirmed / already resolved answer with no state change. Treat it as done and stop retrying (rotate again only if you genuinely need a fresh rotation). See the error table below for the full state matrix.

Response:

{
  "data": { "confirmed": true },
  "meta": { "api_version": "v1" }
}

Like credentials/rotate above, this route never looks up {id} via a get() call before acting — there is no 404 path here either.

Errors:

ConditionResponse
token_id missing from the body, empty, not a string, or the body is malformed/non-object JSON400 — token_id required
secret missing from the body, empty, or not a string (#3015)400 — secret required — checked immediately after the token_id presence check, before the store is ever reached
The supplied token_id is not the pending rotation’s successor (stale id from an earlier rotation, or the predecessor’s id passed by mistake)409 — token_id does not match the pending rotation successor; pass the token_id returned by rotate. No state change.
Confirm attempted by a different operator than the one who initiated the rotation409 — rotation in progress by a different operator
The initiating operator cannot be resolved from either source — the in-memory grace-cache entry is gone (different replica, or this replica restarted) and the durable rotation_initiator column on the successor row is empty (the pair started rotating before the durable-binding migration shipped) — or the two sources are both present but disagree409 — rotation confirmation unavailable — fall back to revoke. A plain same-replica restart mid-overlap no longer triggers this: the successor row’s own rotation_initiator, stamped durably at mint time, resolves the identity check when the in-memory grace cache is gone.
Replay after the rotation already resolved — the successor is now the principal’s sole active credential and the supplied token_id matches it409, terminal, do not retry: rotation already confirmed - the supplied token_id is the sole active credential; nothing to confirm (an explicit confirm resolved it) or no rotation in flight - the supplied token_id is already the sole active credential; nothing to confirm (never rotated, or resolved by the auto-revoke sweep or a manual revoke)
One credential is active with its rotation state clear, but the supplied token_id is a different id (the pinned rotation has moved on)409, terminal — no rotation in flight for the supplied token_id - the rotation was resolved (confirmed, revoked, or cut over); rotate again if a new rotation is needed
One credential is active but still carries unresolved rotation metadata (a best-effort pair-resolve failed, or its partner expired before cleanup while the sweep was down)409 — one active credential with unresolved rotation metadata - inspect the credential state and do not rotate; revoke only if it is confirmed stale. Rotating from here would strand a malformed pair; inspect before revoking — in the sweep-outage case this sole credential is the good survivor.
More than two active credentials for this principal (one minted outside the rotation path)400 — more than two active credentials for this principal - resolve manually before confirming
No active credentials, or exactly two that aren’t a recognized rotation pair503 — not 400. Ambiguity-avoidance: an empty read can’t be distinguished from a silently-failed read, and a malformed pair is kept conservative, so these stay retryable rather than a definitive client error.
A non-engine-kind active credential is present for this principal (defensive check)400 — principal has a non-engine active credential
The presented secret does not hash-match the pending successor’s stored secret (#3015 proof of possession) — reached only after ownership/pair-state/the token_id pin/the initiator binding above have all passed403 — rotation secret mismatch — the presented secret does not verify against the pending successor
The confirm audit row could not persist503 + Sec-Audit-Failed: true — the rotation was confirmed (predecessor retired) but its audit row did not persist (see Audit-persist failure above); a retried confirm returns the terminal 409 (the rotation is already resolved — the replay row above), which itself confirms the mutation took effect
The authoritative successor row could not be re-read to verify the secret (should not happen under the lock already held; fails closed rather than assume a match)503 — failed to verify rotation secret
Advisory-lock acquire failure, or the confirm/predecessor-revoke/successor-clear write did not persist503 — retryable store failure
MFA step-up not satisfied401
Missing Security:Write, or the caller’s own session is engine-classed403

POST /api/v1/engine-principals/{id}/transfer-owner

Reassign the named responsible owner of an active engine principal. Admin-forced — deliberately does not depend on the outgoing owner’s cooperation (an account under termination-for-cause cannot use engine-principal ownership as a lever to stall its own deprovisioning).

Permission: Security:Write

Request body:

{ "new_owner": "bob" }

new_owner must reference an existing user (FK-validated).

Response:

{
  "data": { "transferred": true },
  "meta": { "api_version": "v1" }
}

Errors: 400 — new_owner must reference an existing user. 404 — engine principal not found. 409 — engine principal is not active. 503 — the transfer call itself failed. 503 + Sec-Audit-Failed: true — the transfer audit row could not persist (see Audit-persist failure above); the transfer may already have taken effect, so reconcile via GET.


GET /api/v1/engine-principals/audit/no-admin

Auditor-runnable check of the “no admin” rule for every engine principal. Reads every grant held by an engine: id from the grant table, with each role’s permissions, against the live RBAC reference tables and reports each detected grant: a literal admin/Administrator role (admin_role), any role flagged is_system (system_role), or a wildcard_grant (a role, or with an empty role the union of a principal’s roles, that allows every securable × operation pair of the grid as it is at run time). Assignment refuses the admin names and built-in roles but not a custom full-grid role (maintainer ruling 2026-10-23, #2485); this route is the detective control for that case. It is point-in-time and pull-only: nothing schedules it or alerts on a result, and role permissions can change after assignment.

Permission: AuditLog:Read — deliberately not Security, since this is a read-only evidentiary query, not part of the write-path bar it verifies; a read-only auditor role can run it without holding engine-principal write access. With RBAC disabled the route is admin-only like every other audit read (#5771).

Response:

{
  "data": { "ok": true, "violations": [] },
  "meta": { "api_version": "v1" }
}

ok:true means no role held by an engine principal, and no engine principal’s union of roles, allows every pair of the current grid at the moment of the run. A role that grants every pair but one is not reported, and deny rows are ignored. Each entry of a non-empty violations array carries principal_id, role (the role name; empty for a union-only wildcard) and reason (admin_role / system_role / wildcard_grant; the MCP twin’s include_revoked=false can also return orphan_grant, see below). Grants are read by principal id whatever their principal type, so a user-typed grant on an engine: id is reported too. A row with reason admin_role or system_role means the assignment check was bypassed (data corruption, direct DB write, or a code regression) and should be treated as a security incident. A wildcard_grant is a custom role that covers the whole grid, which assignment does not refuse: review whether that grant is intended. The success audit row engine_principal.audit.no_admin carries violations=N scope=all and up to ten principal_id:reason pairs; those rows are the evidence the control operated. A dropped success-row write is signalled only by Sec-Audit-Failed on this route (the MCP twin gives no signal). The Tokens page also calls this route incidentally, in the browser, when a viewer who holds AuditLog:Read loads the principals panel; that is not a schedule and not a control, so a period with no rows is not evidence that the check ran or that it passed. The grants, roles and role permissions are three separate reads, not one snapshot.

Errors (503): rbac resolution unavailable - cannot verify when the grants, roles or role permissions could not be read, and rbac reference data unavailable — cannot verify when the RBAC reference tables (securable_types/operations) came back empty. Both carry Retry-After: 2 and retry_after_ms: 2000 (the MCP twin uses the same 2000 ms hint). Both are fail-closed: there is no partial violations list, the audit row is engine_principal.audit.no_admin with result=failure and no success row is written. Treat them as “unable to verify,” never as “clean.”


Sessions

Operator and dashboard sessions are the cookie-based sessions issued by POST /login. The endpoints below let an admin force-log-out another user from every device, and let any authenticated principal sign out of every browser at once. They are the SOC 2 CC6.3 (revocation) and CC6.8 (termination) evidence path; both endpoints emit auditable actions distinguishable by SIEM correlation.

The DB primitive (AuthDB::invalidate_all_sessions) and the in-memory counterpart already fire when a user is removed (DELETE /api/settings/users/{username}) or when their role changes; these REST endpoints expose the same primitive standalone for incident response and operator self-service.

DELETE /api/v1/sessions?username=<name>

Revoke every active cookie session for a named user. The user remains valid (no role change, no account disable); they simply have to authenticate again to obtain a new session cookie. API tokens belonging to the user are deliberately NOT revoked — operators force-logging out a leaked cookie session typically want to leave CI/CD and automation tokens running. Use DELETE /api/v1/tokens/{token_id} (or the user’s own DELETE /api/v1/sessions/me) to revoke those.

Permission: UserManagement:Write

Self-target behaviour. An admin invoking this with their own username is permitted (signing yourself out of every device is recoverable — re-authenticate and you are back), but the audit row is recorded as session.revoke_all.self instead of session.revoke_all so SIEM rules can split operator self-service from a sibling-admin force-logout. This is a deliberately weaker guard than the #397/#403 self-target guard on DELETE /api/settings/users/{username}, which exists to prevent admin-role self-lockout (an unrecoverable state).

Example:

curl -s -X DELETE \
  -H "Authorization: Bearer $TOKEN" \
  "https://yuzu.example.com/api/v1/sessions?username=alice"

Response (200):

{
  "data": {
    "username": "alice",
    "revoked": 2,
    "db_persisted": true,
    "audit_emitted": true
  },
  "meta": { "api_version": "v1" }
}

revoked is the number of sessions wiped (the fleet-wide durable count on a Postgres-backed deployment, or the local cookie count in the config-file-only fallback). db_persisted reports whether the durable session delete succeeded — since HA WS-1/1a this goes through the durable SessionStore (invalidate_user), never AuthDB. When false, the audit row is recorded with result="partial" and detail carries db_error=true. Remediation: retry — the durable delete is idempotent, so re-issuing the request converges. A server restart does NOT revoke the sessions: durable sessions survive a restart by design (ADR-2002 §4), so a session that failed to be revoked stays valid until its absolute 8-hour expiry, not “resurrected” by restart. The local cache is wiped on this replica regardless (the operator’s “stop NOW” intent is honoured there immediately), but the durable rows — and any other replica’s cache — are only cleared once the delete actually lands.

audit_emitted reports whether the SOC 2 CC6.6 audit row landed in the audit store. When false the response also sets the Sec-Audit-Failed: true header — SREs scraping for evidence-integrity gaps should alert on either signal. The revoke side-effect still completes when audit_emitted=false (operator’s “stop NOW” intent is honoured); only the SOC 2 evidence chain is degraded for that request. This split was introduced in PR #883 (HIGH-2) to close a silent-failure window where a locked audit DB or disk-full condition produced a 200 OK that masqueraded as full evidence.

Audit: successful cross-user invocations emit session.revoke_all with target_type=User, target_id=<username>, and detail=count=<N> (or count=<N> db_error=true on partial failure). When the caller’s own username is supplied, the action is session.revoke_all.self instead.

The admin route emits two distinct 400 bodies — operators scripting the endpoint should distinguish them.

Error (400) — missing username query parameter:

{
  "error": { "code": 400, "message": "username query parameter required" },
  "meta": { "api_version": "v1" }
}

Error (400) — malformed username value:

{
  "error": { "code": 400, "message": "invalid username format" },
  "meta": { "api_version": "v1" }
}

The username parameter accepts either a strict local username OR a durable SSO principal (is_valid_principal, #1852) — an OIDC principal (oidc:<iss>#<sub>) or, since ADR-2001 PR4a, a SAML principal (saml:<entity_id>#<NameID>), so an admin can force-log-out an SSO operator authenticated via either protocol. Local usernames stay on the strict alphanumeric/._- charset; an SSO principal permits the : # / . _ - @ ~ % | alphabet a real IdP issuer URL and opaque subject need. NUL bytes, control characters, newlines, and shell/SQL metacharacters (;, =, \, quotes, backtick, space) are rejected in both cases — passing them through to the SQL bind would silently truncate/diverge from the audited target string (sec-H1). A 400 with the invalid username format message indicates the client has malformed input; retrying with the same value will not succeed.

SAML sessions are now force-loggable (ADR-2001 PR4a). A SAML session’s Session::username is the stable principal saml:<entity_id>#<NameID> (saml_principal_id, mirroring the OIDC shape exactly), not the raw NameID — create_saml_session mints it via the saml: reserved prefix, which is_valid_principal accepts on the same wider SSO charset as oidc:. An admin can therefore force-log-out a SAML operator with:

curl -s -X DELETE \
  -H "Authorization: Bearer $TOKEN" \
  "https://yuzu.example.com/api/v1/sessions?username=saml%3Aentity-id%23NameID"

(URL-encode the : and #.) SAML sessions are also revoked automatically as part of SCIM deprovision — see “SCIM ↔ SAML identity linkage” in docs/user-manual/scim-provisioning.md, which covers the NameID-Format precondition for that automatic linkage. This closes the gap previously tracked as #1859/#1860.

Error (403) — caller lacks UserManagement:Write:

{
  "error": { "code": 403, "message": "forbidden" },
  "meta": { "api_version": "v1" }
}

DELETE /api/v1/sessions/me

Self-revoke “Sign out everywhere”. Wipes every cookie session belonging to the authenticated caller AND revokes every API token they own. This is intended as the lost-device recovery flow: every credential bearing the caller’s identity is killed in one call.

Permission: Any authenticated caller: a cookie session or a plain API token (one with no mcp_tier and no service scope) is accepted. MCP-tier tokens and service-scoped automation tokens are explicitly rejected with 403 — those credential classes have no other write privilege and accepting them here would create a novel DoS surface against the human owner. Use the dashboard, a fresh password-authenticated session or a plain API token.

Example:

curl -s -X DELETE \
  -H "Origin: https://yuzu.example.com" \
  -H "Cookie: yuzu_session=$COOKIE" \
  "https://yuzu.example.com/api/v1/sessions/me"

Response (200):

{
  "data": {
    "revoked": 3,
    "api_tokens_revoked": 2,
    "db_persisted": true,
    "audit_emitted": true
  },
  "meta": { "api_version": "v1" }
}

The response sets Set-Cookie: yuzu_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0 so the client side completes the revocation by deleting the cookie from the browser jar. audit_emitted and the Sec-Audit-Failed: true header have the same semantics as on the admin route above — false means the revoke completed but the audit row was lost (locked DB / disk full / pipeline exception), and the SOC 2 CC6.6 evidence chain is degraded for that request.

Error (403) — non-interactive credential:

The caller authenticated with an MCP-tier token (X-Yuzu-Token carrying a non-empty mcp_tier) or a service-scoped token. The denial is audited as session.revoke_all.self with result=denied.

{
  "error": {
    "code": 403,
    "message": "self-revoke requires an interactive session, not an MCP-tier or service-scoped token"
  },
  "meta": { "api_version": "v1" }
}

Audit: every successful invocation emits session.revoke_all.self with target_type=User, target_id=<caller>, detail=count=<N> api_tokens_revoked=<M> (with db_error=true appended on partial failure). The dashboard’s “Sign out everywhere” button on the operator’s own row in Settings → Users uses this endpoint and follows up with a redirect to /login.


Users

POST /api/v1/users/{username}/unlock

Clear a user’s account-lockout counter. After --auth-lockout-threshold consecutive failed local-password logins an account is temporarily locked (SOC 2 CC6.3 — see the account-lockout flags in the server-admin guide). The lock already auto-expires after --auth-lockout-window-secs; this endpoint is the operability path for an admin who needs to restore access immediately (e.g. a locked-out VIP who can’t wait out the window).

Permission: UserManagement:Write (plus MFA step-up when MFA is enrolled — parity with DELETE /api/v1/sessions).

Self-target behaviour. An admin invoking this with their own username is permitted — clearing your own lockout is recoverable. This is the same weaker-than-#397/#403 guard reasoning as DELETE /api/v1/sessions.

Body: none.

Example:

curl -s -X POST \
  -H "Origin: https://yuzu.example.com" \
  -H "Cookie: yuzu_session=$COOKIE" \
  "https://yuzu.example.com/api/v1/users/alice/unlock"

Response (200):

{
  "data": {
    "username": "alice",
    "unlocked": true,
    "audit_emitted": true
  },
  "meta": { "api_version": "v1" }
}

audit_emitted and the Sec-Audit-Failed: true header have the same semantics as the session-revoke routes above — false means the unlock completed but the audit row was lost, degrading the CC6.3 evidence chain for that request.

Errors: 400: username empty or malformed (e.g. contains a reserved :); 401: MFA step-up required (stale or absent proof; see meta.challenge_url); 403: caller lacks UserManagement:Write, or a SAML session (step-up is not available for SAML sessions, unless --mfa-step-up-window-secs is 0 or less, which lets SAML sessions through on the 37 step-up-gated routes); 500: the AuthDB (Postgres) write failed (a best-effort auth.lockout.cleared/error audit is still attempted); 503: the lockout subsystem is not wired (no AuthDB).

Audit: a successful unlock emits auth.lockout.cleared with result=ok, target_type=User, target_id=<username>, detail=admin_unlock. A failed write emits the same verb with result=error. Note that a lockout cleared automatically (no operator action) emits auth.lockout.cleared with result=ok and detail=reset_on_successful_login when the user next logs in successfully; the threshold crossing itself emits auth.lockout.applied. These two verbs are the durable CC6.3 evidence; blocked-while-locked attempts are tracked only via the yuzu_auth_lockout_blocked_total metric (no per-attempt audit row or analytics event) to avoid amplification under a sustained brute-force.


POST /api/v1/users/me/password

Change the caller’s own local-account password (#5342). The caller proves the current password; the new one must be 12–1024 bytes of UTF-8 (bytes, not characters — a character outside ASCII takes 2–4 bytes). The dashboard’s Settings → User Management → Change password button uses this route.

Who can call it: an interactive dashboard session whose account is local (auth_source=local). There is no extra permission check — every local user may change their own password. Under --auth-mode=sso-only only the configured break-glass account, and only while it is armed, may use it (see the 403 sso_only_local_disabled row below). Non-admin users have no dashboard page for it yet: the button is on the admin-only User Management page, so a non-admin signs in and uses Access → Users and roles → Your account → Change password, or calls the route directly.

Order of checks. Each step runs only if every earlier one passed. For a cookie session the server-wide same-site check (see step 3) answers first, before any step-up evaluation. The central MFA step-up check (step 4) then runs, before the handler’s own steps 2 and 3, and the handler repeats it from the same per-request verdict (a token caller skips the same-site check). An over-cap request body whose size is declared in Content-Length is refused 413 before any of these, because the pre-routing body cap runs first. This route is in a body class that does not require a measurable body, so a chunked over-cap body from a cookie session with no same-site Origin/Referer is answered by the same-site 403 before the post-read 413 (which runs after the body is read, just before the step-up check):

  1. Authentication (401 without a session).

  2. Session class: an API, MCP or engine token, or a service-scoped token, is 403 token_session; an OIDC or SAML session is 409 not_local (change it at the identity provider).

  3. Same-site request (this route’s own copy): Origin/Referer must be the dashboard’s own origin (or a --csrf-trusted-origin entry), and a request carrying neither header is refused with 403 csrf. Content-Type must be application/json, else 415 (not audited). A cookie-session caller never reaches the csrf refusal: the server-wide check (see Authentication) answers first, with message cross-site request refused and no csrf code, and this copy is defence in depth.

  4. MFA step-up, when the account is enrolled and its proof is stale — 401 step-up envelope (meta.mfa_step_up_required, meta.challenge_url).

  5. Body and length policy — 400, not audited (nothing security-relevant was attempted, and a policy rejection never records the length). The body is never echoed, because it may hold a password.

  6. The current password, through the same lockout-accounted check as POST /login: a wrong password is 403 wrong_current and counts toward account lockout; a locked account gets the same 403 and message (account_locked in the audit row only — no lock-state oracle). A password that could not be verified — the auth store could not be read, or the stored password changed while it was being checked — is a retryable 503 verify_transient and never counts. If the lockout counter cannot be recorded the answer is 503 store_unavailable (fail closed: an uncounted guess is never answered as an ordinary wrong password). A correct current password clears a non-zero failure counter (auth.lockout.cleared, detail=reset_on_password_change).

  7. One transaction. Under the account’s row lock, the server re-checks that the stored password is still exactly the one step 6 verified and that the account is still an active local account, then — together, or not at all — writes the new password, deletes every session of the account (including the caller’s own, durably and on every replica), wipes a provisional (not yet enrolled) TOTP secret, and writes the user.password_change audit row. A refusal here changes nothing (your session stays valid): 409 conflict (the password changed concurrently, e.g. an admin reset), 409 not_local (the account became SCIM-managed in between), 404 not_found (it was deactivated). If the audit row cannot be written the change is not made: 503 audit_unavailable with Sec-Audit-Failed: true. Any other store failure is 503 store_unavailable. A 503 body says what is true: nothing was changed unless the server lost contact with the database at commit; a successful sign-in with the new password confirms the change, which is audited either way.

Body: {"current_password": "<string>", "new_password": "<string>"}. Only new_password is length-checked at step 5. current_password is not: one longer than 1024 bytes is answered exactly like a wrong password (403 wrong_current, counts toward lockout) without being hashed, as /login does. A new_password equal to the current one is accepted — it is stored with a fresh salt and every session is signed out, as for any change.

Example:

curl -s -X POST \
  -H "Cookie: yuzu_session=$COOKIE" \
  -H "Origin: https://yuzu.example.com" \
  -H "Content-Type: application/json" \
  -d '{"current_password":"old-password-123","new_password":"a-new-long-password"}' \
  "https://yuzu.example.com/api/v1/users/me/password"

Response (200) — with a Set-Cookie header that clears the session cookie:

{
  "data": {
    "username": "alice",
    "password_changed": true,
    "sessions_revoked": 3,
    "session_reissued": false,
    "provisional_mfa_cleared": false,
    "audit_emitted": true
  },
  "meta": { "api_version": "v1" }
}

No replacement session is issued: the caller is signed out and signs in again with the new password. sessions_revoked counts the sessions deleted at step 7 (the caller’s included). provisional_mfa_cleared is true when an un-enrolled TOTP secret was wiped (an enrolled second factor is never touched). session_reissued is always false and audit_emitted always true — a 200 means the user.password_change row committed with the change. API tokens are not touched. Only a 200 clears the cookie; a refusal leaves it alone.

Errors:

StatusAudit detailMeaning
400(not audited)Malformed body, or new password outside 12–1024 bytes
401—No session, or MFA step-up required
403token_session / csrfSteps 2 and 3 (csrf is this route’s own copy; a cookie-session caller gets cross-site request refused from the server-wide check instead)
403wrong_currentThe current password is wrong. Counts toward lockout exactly like a failed /login
403account_lockedThe account is locked. Same message as a wrong password (“current password is incorrect”)
403sso_only_local_disabled--auth-mode=sso-only and this is not an armed break-glass account. No password was evaluated and nothing counts toward lockout
404not_foundThe account was deactivated in between (nothing changed)
409not_localOIDC/SAML session, or the account is SSO/SCIM-managed (nothing changed)
409conflictThe stored password changed between the check and the write (nothing changed). Sign in with the current password and retry
415(not audited)Content-Type is not application/json
503verify_transientThe current password could not be verified (store read failure, or a concurrent change). Retry
503audit_unavailableThe audit row could not be written, so the change was not made (Sec-Audit-Failed: true). Retry
503store_unavailableThe auth store is unavailable (retry_after_ms: 2000), or the server runs without the Postgres auth store (retry_after_ms: 5000)

Every 503 carries retry_after_ms and a Retry-After header.

Audit: user.password_change, target_type=User, target_id=<caller>, the caller as principal on every row. Success is result=ok, detail=self_service sessions_revoked=<N> provisional_mfa_cleared=<true|false>, written in the same database transaction as the change — the change and its evidence commit or abort together, so there is no rollback step and no “changed but unrecorded” state; there is no separate session.revoke_all row. Refusals are result=denied (error for 503) with the detail tokens above. The password, its hash and its length never appear in any audit row or log line.

Metric: yuzu_auth_password_changes_total{kind="self", result="ok"|"denied"|"error"} (the not-audited 400/415 count as denied).


POST /api/v1/users/{username}/password

Reset another user’s local-account password (#5342) without knowing the current one. The dashboard’s Settings → User Management → Reset password button uses this route.

Who can call it: a durable Administrator — not an ordinary permission check. The route uses the same is_rbac_administrator predicate as the RBAC role-assignment routes, then re-checks it against the enforcement regime in effect right now, and applies the same rule whatever the target’s role:

A JIT elevation, an IdP-group-derived session role (an OIDC session whose admin role comes only from --oidc-admin-group), a group-held Administrator grant, or a custom role holding UserManagement:Write is refused 403 durable_admin_required. If the stores cannot confirm the caller’s authority the answer is 503 admin_gate_unavailable (retryable; never downgraded to a 403). The caller must use an interactive dashboard session; API, MCP, engine and service-scoped tokens are refused 403 token_session. A SAML session is refused 403 by the central MFA step-up check before the administrator check is reached, because there is no SAML MFA attestation in this release.

Order of checks: authentication → for a cookie session, the server-wide same-site check, then the central MFA step-up check (the handler’s own step-up below repeats it from the same verdict) → username format (400, not audited) → session class (403 token_session) → same-site Origin/Referer (403 csrf; this route’s own copy, defence in depth, since the server-wide check answers a cookie-session caller first with cross-site request refused) and Content-Type: application/json (415, not audited) → the durable-Administrator check (403 durable_admin_required / 503 admin_gate_unavailable) → not yourself (403 self_target) → not the break-glass account (403 break_glass_target) → MFA step-up (when the caller is enrolled) → body and the 12 to 1024 byte policy (400, not audited) → one transaction (below).

Body: {"new_password": "<string>"}.

Example:

curl -s -X POST \
  -H "Cookie: yuzu_session=$COOKIE" \
  -H "Origin: https://yuzu.example.com" \
  -H "Content-Type: application/json" \
  -d '{"new_password":"a-temporary-long-password"}' \
  "https://yuzu.example.com/api/v1/users/bob/password"

Response (200):

{
  "data": {
    "username": "bob",
    "password_reset": true,
    "sessions_revoked": 2,
    "lockout_cleared": true,
    "provisional_mfa_cleared": false,
    "api_tokens_active": 1,
    "remediation": "the account's API tokens were NOT revoked; if it may be compromised, revoke them (DELETE /api/v1/tokens/{id})"
  },
  "meta": { "api_version": "v1" }
}

Errors:

StatusAudit detailMeaning
400(not audited)Malformed username or body, or new password outside 12–1024 bytes
401—No session (including a request whose principal changed after the step-up check passed), or MFA step-up required
403durable_admin_requiredThe caller is not a durable Administrator (see above)
403token_session / csrf / self_target / break_glass_targetSee above (csrf is this route’s own copy; a cookie-session caller gets cross-site request refused from the server-wide check instead)
403—A SAML session at the step-up gate
404not_foundNo active account by that name (nothing changed)
409not_localSSO/SCIM account (nothing changed)
415(not audited)Content-Type is not application/json
503admin_gate_unavailableThe RBAC/auth stores could not confirm the caller’s authority
503audit_unavailableThe audit row could not be written, so the reset was not made (Sec-Audit-Failed: true)
503store_unavailableThe auth store is unavailable, or not configured

Audit: user.password_reset, target_type=User, target_id=<target>, principal = the caller on every row. Success result=ok, detail=admin_reset target_role=<admin|user> sessions_revoked=<N> provisional_mfa_cleared=<true|false>, plus — only when a lockout existed — auth.lockout.cleared (result=ok, detail=password_reset); both are written in the same transaction as the reset (no rollback step, no unrecorded state). Refusals as for the self route.

Metric: yuzu_auth_password_changes_total{kind="admin", result=...}.

Rate limit: both password routes share the per-IP /login rate-limit bucket (login_rate_limit_per_ip), not the general API bucket, because the self route verifies a password. Over the limit: 429 with Retry-After: 1.

No MCP twin (parity exception). Neither password route has an MCP tool, by design, recorded as exception: rows in docs/api-parity-ledger.md. Self-service change is permanently excluded: an MCP caller is a token, not the human, and a token that could change its owner’s password would turn a token leak into an account takeover. Admin reset is deferred: UserManagement:Write is approval-gated at the supervised MCP tier, and an approval ticket stores the tool’s arguments in plain text and shows them to approvers, so a password argument would be persisted and displayed. The planned tool takes no password argument — the server generates a temporary password and forces a change at next sign-in (#5357).


POST /api/v1/users/{username}/elevation-eligibility

Grant or revoke a user’s JIT-admin-elevation eligibility — who may activate a time-boxed admin elevation via POST /api/v1/elevate (SOC 2 CC6.3/CC6.6). This is the per-user users.elevation_eligible flag, distinct from holding standing admin and enumerable for access reviews. See JIT Admin Elevation below and docs/auth-architecture.md “JIT admin elevation”.

Permission: admin + MFA step-up (an active elevation suffices only to revoke). Self-grant is blocked — an operator cannot set their own eligibility (another admin must). Granting (eligible=true) needs the caller’s own administrator role: a session that is administrator only through an elevation gets 403 (audit user.elevation_eligibility.set / denied / elevated_session_eligibility_grant_blocked). Revoking is still open to an elevated session.

Body: {"eligible": <bool>}.

Side effect: setting eligible=false immediately terminates any in-flight elevation for that user.

Two route forms, same handler:

This is an UPDATE-only operation against an existing users row, never an INSERT — an SSO principal only has a row once the operator has logged in at least once (first login auto-provisions it). Granting eligibility against a principal with no row yet returns 404, which for an SSO principal specifically means “this operator has never signed in” rather than “no such user was ever created”.

curl -s -X POST -H "Cookie: yuzu_session=$COOKIE" \
  -H "Origin: https://yuzu.example.com" \
  -H "Content-Type: application/json" -d '{"eligible":true}' \
  "https://yuzu.example.com/api/v1/users/alice/elevation-eligibility"

# SSO principal — MUST use the query form; note the URL-encoded '#' (%23):
curl -s -X POST -H "Cookie: yuzu_session=$ADMIN_COOKIE" \
  -H "Origin: https://yuzu.example.com" \
  -H "Content-Type: application/json" -d '{"eligible":true}' \
  'https://yuzu.example.com/api/v1/users/elevation-eligibility?username=oidc:https://idp.example.com/%23sub-4821'

Response (200): {"status":"ok"}.

Errors: 400: invalid username/principal or non-boolean body; 401: not authenticated, or MFA step-up required (stale or absent proof; see meta.challenge_url); 403: not admin, self-grant, a grant (eligible=true) from a session that is administrator only through an elevation, or a SAML session (step-up is not available for SAML sessions; this holds even when --mfa-step-up-window-secs is 0 or less, because the elevation routes own their gate and substitute a 300 s window); 404: user not found (for an SSO principal: the operator has never logged in); 503: AuthDB unavailable (--postgres-dsn unset/unreachable).

Audit: user.elevation_eligibility.set, result in {ok, denied, error}, detail=eligible=<bool> (plus elevations_cleared=<N> when a revoke dropped active windows; self_grant_blocked or elevated_session_eligibility_grant_blocked on a 403).

POST /api/v1/users/elevation-eligibility

The query-parameter form of the route above. It uses the same handler, permission, body ({"eligible": <bool>}), response (200 {"status":"ok"}), errors and audit. The target is passed as ?username=<principal> instead of a path segment. This form is required for an SSO principal (oidc:<iss>#<sub>; URL-encode the # as %23), because a path segment cannot carry that principal’s / and #. A local username may use either form. A missing or empty username returns 400. See the section above for the full permission rules (admin plus MFA step-up, self-grant blocked, granting needs the caller’s own administrator role). Revoking with eligible:false also ends any active elevation. If that clean-up fails, the route returns 503 even though the flag change was saved; retry to finish the revocation.


Quarantine

Quarantine isolates a device from receiving commands or participating in normal operations. Quarantined devices remain connected but are blocked from instruction execution.

Scoped per-target authorization (#1788). All three routes below authorize per-device, matching the MCP quarantine_device tool: a management-group-confined operator holding Security:Execute/Security:Read only through a group can act on and see devices inside their own group(s), and is refused (403) for devices outside them. On GET and DELETE, a global grant (or an unfiltered/legacy-RBAC-disabled deployment) still reaches every device, unchanged; POST additionally applies the dispatch approval check described below. If the per-target scope gate is ever left unconfigured, every request on these routes fails closed with a 500 rather than silently widening to a fleet-wide check.

GET /api/v1/quarantine

List currently quarantined devices visible to the caller — admit-then-filter (ADR-0017): the route makes one Security:Read list-gate decision per request and applies the resulting scope in the store query, so a device outside the caller’s management-group scope is omitted from the list entirely, not merely hidden from write access, and an out-of-scope record costs neither a per-record permission check nor an audit row.

Permission: Security:Read, resolved once through the fleet-read list gate. MCP twin: list_quarantined_devices (capped at 500 records per call; this route returns the complete list; see docs/user-manual/mcp.md).

Fail-closed on a degraded read (ADR-0047). A store/pool/query failure returns 503 ("quarantine list unavailable — try again"), never a silently-empty list — a device could still be actively contained while the caller would otherwise see “nothing quarantined”. Each failure increments yuzu_server_quarantine_read_degrade_total — see docs/user-manual/upgrading.md and docs/user-manual/metrics.md. If the list gate itself cannot decide (an unavailable authorization store, or a service-scoped API token whose ITServiceOwner ceiling cannot be read) it answers with its own 401/403/503 and the store is not read. An unwired gate answers 500, never an unfiltered list.

Response:

{
  "data": [
    {
      "agent_id": "agent-compromised-01",
      "status": "active",
      "quarantined_by": "admin",
      "quarantined_at": 1710849600,
      "whitelist": "10.0.1.50,10.0.1.51",
      "reason": "Suspicious network activity detected",
      "last_applied_at": 1710849660,
      "last_confirmed_at": 1710849675
    }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

last_applied_at/last_confirmed_at (#3425), both 0 = never. Endpoint-containment confirmation state written by QuarantineContainmentReconciler, the background component that re-applies a device’s own firewall on reconnect. last_applied_at means a system re-dispatch of the stored whitelist was accepted (agents_reached > 0) — NOT proof of containment (a gateway-attached agent’s send_to only queues the frame). last_confirmed_at is set only after a follow-up quarantine.status read reports state|active — but that read is the target agent’s own self-report (cert-bound to its identity, not independently corroborated by any network-side signal), so treat it as strong operational evidence of containment, not proof. See docs/user-manual/security-hardening.md “Reconnect re-application (#3425)” for the full trust boundary.


POST /api/v1/quarantine

Quarantine a device.

Permission: Security:Execute, scoped per-device (see the note above)

Audit-before-change (ADR-0033 §9). After authorisation and validation the route writes a quarantine.enable row with result=attempt before it changes the record. If that row cannot be written the request is refused with 503 (Sec-Audit-Failed: true) and containment is unchanged. The change is then recorded as success or failure; a lost outcome row is flagged with the same header on the response.

Supervised MCP tokens are approval-gated here too. A bearer token minted with mcp_tier: "supervised" gets 403 on this route (and on the DELETE release below): supervised Security:Execute requires an approval ticket, and the ticket-then-recall flow exists only on the MCP transport (quarantine_device). The REST route mirrors the denial so switching transports cannot bypass the approval gate (#520).

Dispatch approval check (#6065). After the scoped Security:Execute gate and whitelist validation, and before the attempt audit row or any record is written, the route runs the shared dispatch check for quarantine.quarantine, the same one MCP quarantine_device runs. REST has no approval-ticket path today, so the check admits only an administrator: here that means an account whose account role is admin (a live temporary JIT elevation counts as that role). Holding the RBAC Administrator role or a global Security:Execute grant on an account whose role is user does not make the account an administrator for this check.

With RBAC off (the default) nothing changes for non-administrators: the scoped gate already refuses Security:Execute without the admin account role (a JIT elevation counts), and the dispatch check adds no refusal. With RBAC on, a caller must be an administrator in that sense and hold Security:Execute globally in their own right; a live JIT elevation supplies the first condition, not the second.

Statuses these routes can answer beyond the usual 400 (body) and 503 (store). The table is grouped by cause, not ordered by when a request meets them: the 500 for an unwired audit identity writer comes first, right after authentication; the 401 for a changed principal comes at the second look-up, after the scoped gate and whitelist validation, and before the dispatch check:

StatusWhenReason label (audit detail, metric)
403RBAC on: the caller holds Security:Execute globally but is not an administrator (account role user, no live JIT elevation)approval_required
403RBAC on: the caller’s own Security:Execute grant is not global (for example it is held only through a management group, or the caller is an administrator only through a JIT elevation), or the RBAC store could not be read (a transient failure reads the same as a denial)forbidden
400The quarantine.quarantine pair cannot be classifiedunclassified or ambiguous
403Other chokepoint refusals map the same way (kill_switched, anonymous_operator); neither is reachable for this pair today, because quarantine is exempt from the per-action kill switch and an unauthenticated request ends at the 401kill_switched, anonymous_operator
401The request’s second session look-up named a different principal than the first (for example a session cookie lapsed and a bearer token for another account is on the same request); written as a quarantine.enable row with result=denied and detail authenticated principal changed during the request now=<second account> (the account name has control bytes, spaces, commas, = and ; replaced by _ and is cut at 128 bytes), naming the first account as the actornone
500The dispatch check is not wired on this server (also written as a quarantine.enable row with result=failure)none
500The audit identity writer is not wired on this server (misconfiguration; written as a quarantine.enable row with result=failure and detail audit identity writer not configured)none

Every refusal carries error.message and error.remediation. For approval_required the message states that the action needs an administrator caller or a redeemed approval ticket, and the remediation names the MCP supervised flow; REST serves that shared MCP wording until #6065 PR-C gives REST its own. For forbidden the message is permission denied: Security:Execute and the remediation says the caller lacks the grant. That remediation does not help a group-only holder, a JIT-elevated administrator with no grant of their own, or a transient RBAC read failure: for the last, check yuzu_server_rbac_read_degrade_total before treating the refusal as permanent. The 403 bodies never carry a permission field: holding that permission would not admit the caller, so naming it would be a false remediation hint. A refused request writes no quarantine record and no attempt audit row. It writes a quarantine.enable row with result=denied and the reason in detail, and increments yuzu_server_dispatch_denied_total{reason}. A session that expires between the first authorisation check and the request’s second look-up now ends at the 401 instead of writing a record attributed to system. The second look-up must also name the same principal as the first: a request whose two look-ups resolve to different principals (for example a session cookie that lapsed and a bearer token for another account on the same request) ends at 401 with a denied row naming the first, and writes no record. Every audit row the handler writes after its first look-up names the session the route resolved, not a fresh look-up of the request, so the attempt and outcome rows name the same account as the record’s quarantined_by; rows written by the scoped permission gate itself, and the failure row for an unwired writer, still come from a look-up of the request. A server whose audit identity writer is not configured answers 500 on both routes and changes nothing. The denied rows are best-effort, as for a dispatch-check refusal: a lost write does not change the status.

Request body:

{
  "agent_id": "agent-compromised-01",
  "reason": "Suspicious network activity detected"
}
FieldTypeRequiredDescription
agent_idstringYesTarget device ID
reasonstringNoHuman-readable reason for quarantine
whiteliststringNoComma-separated IPs still allowed to communicate. Validated server-side (#3425): ≤512 characters total, each token ≤45 characters and drawn from [0-9A-Fa-f.:] — the same charset the reconciler and MCP’s quarantine_device retry path require before ever dispatching a stored whitelist. Rejected with 400, not written.

Response (201):

{
  "data": { "quarantined": true },
  "meta": { "api_version": "v1" }
}

400 vs 503 (ADR-0047). A 400 means a business/state error (a missing agent_id, the device is already quarantined, or whitelist fails server-edge validation) — retrying the identical request will not succeed, and error.retry_after_ms is null. A 503 means a genuine store/pool failure — retrying is reasonable, and error.retry_after_ms carries a concrete 5000 hint (REST’s envelope has no nested data object — that’s MCP’s JSON-RPC shape; REST matches the MCP quarantine_device twin’s A5 behavior, not its exact field path).

This route records; it does NOT itself dispatch — and the twins have diverged on the already-quarantined case (#3127). POST /api/v1/quarantine writes the quarantine record only, synchronously. The live plugin isolation is dispatched by the MCP quarantine_device tool, which has no REST twin. Two consequences for a client that treats the two transports as interchangeable:

  • A 201 here means the record was written, not that the device’s firewall is enforcing anything yet. To isolate a device immediately, dispatch quarantine.quarantine through the normal execution routes as well — see Security Hardening. As of #3425, “yet” is load-bearing: this route no longer leaves a record permanently unenforced. QuarantineContainmentReconciler reconciles every active record regardless of which surface created it — a record written here for a device that is (or later becomes) connected is automatically dispatched the stored whitelist typically within one reconciler tick (~20s) of the device being connected, without a second call — a brand-new record isn’t in the heartbeat fast path’s cache until the next periodic tick populates it, so a heartbeat arriving in that narrow window doesn’t shortcut the wait. A record created here for a reachable device does not stay dormant — the one exception is a stored whitelist the re-dispatch validator refuses. As of #3425 (d1f71c58f) this route validates whitelist at write time (400 instead of 201 for a malformed value), the same rule MCP’s quarantine_device already enforced — so a fresh write here can no longer land in this state. A validation_failed reconcile now points at a record written via this route before d1f71c58f shipped, or — on a fleet with pre-#3623 history — one migrated from a legacy quarantine.db by the since-retired migrate_from_sqlite backfill (ADR-0047, which copied whitelist verbatim with no validation), not a fresh call. Either way, every reconcile attempt then counts validation_failed and nothing is ever dispatched. That failure is loud, not silent — the device stays in yuzu_server_quarantine_endpoint_unconfirmed{reachability="connected"} and trips YuzuQuarantineEndpointUnconfirmed after 15 minutes — but it is a real way for a record to go permanently unenforced through this route. If record-only-without-live-isolation is the intent (e.g. flagging for review), enforce it at the caller/workflow level; do not rely on this route’s absence of its own dispatch.
  • The MCP tool now treats an already-active record as a retryable re-dispatch, not a terminal error; this route still answers 400, because with no dispatch of its own there is nothing for it to re-drive. The 400-vs-503 split above is unchanged and still mirrors the twin; the already-quarantined outcome no longer does.

A quarantined device is refused at dispatch (#881). Once a device is quarantined, every dispatch route below — POST /api/command, POST /api/instructions/{id}/execute, and the scope/group/broadcast arms — drops that device before the command reaches the agent, increments yuzu_server_dispatch_target_rejected_total{reason="quarantined"}, and writes a quarantine.dispatch_denied audit row (target_type=Security; target_id is the device, or * on a fail-closed denial and on the summary row that follows a capped per-device fan-out — see Audit Log).

POST /api/command reports what it withheld. The success body carries withheld_quarantined, (#3424/#3511) withheld_unknown_plugin and (#5294) withheld_kill_switched_os — all always present, 0 on a clean dispatch — so agents_reached: 97 on a 100-device group is distinguishable from three devices being offline, and a MIXED partial dispatch (some reached, some plugin-absent) is never silently invisible in the response. The success body also carries audit_emitted (#2557), the same field every denial arm that calls emit_behavioral_audit on this route already carries (the body-type, targeting-shape, Destructive-untargeted, Destructive-no-visible-target, no-target-named and classification-denial 400/403/404 arms) — NOT the scope-parse 400 (an unrelated JSON shape with no audit call at all) or the sent==0 503 family, neither of which ever carried it — present and false only when the paired command.dispatch result=success audit row failed to persist (the response also sets Sec-Audit-Failed: true in that case), omitted entirely when no audit store is configured. Before #2557 the success path’s audit call was unguarded against a throwing sink and never reported this field at all — the one response shape most likely to be read as “everything is fine” was also the one place a silent audit-persist failure was invisible to the caller. The dashboard’s own execute console (/api/dashboard/execute, a separate HTML-fragment handler, not this route) now names the same containment/quarantine/plugin-absence causes on a zero-agents-reached dispatch; it does not yet surface withheld counts on a MIXED partial dispatch the way this route’s success body does — tracked as a follow-up.

Its 503 now names the cause. Several conditions previously shared one body (“failed to send command to any agent”), and two of them are fleet-wide/request-wide states rather than a transport failure:

error.reasonMeaningretry_after_ms
containment_unreadableThe gate is failing closed: containment state cannot be read, so every target on every dispatch is refused. A server condition, not a device one.5000
quarantinedEvery target named is contained. The dispatch was withheld, not attempted.null — retrying will not help until the device is released
plugin_not_found (#3511)The dispatched plugin is absent from every target’s reported inventory — a command guaranteed to fail, withheld before dispatch rather than reported as a false success. Permanent for the current plugin name. If the plugin name and spelling are correct and it genuinely is installed on the target, the agent’s REPORTED inventory is stale: it is populated once at registration and does not refresh until the agent’s management connection next re-registers (a reconnect — daemon restart, or any dropped/re-established connection) — this reason can persist until then even after installation completes.null — retrying will not help; check the plugin name/spelling instead, or trigger a reconnect on the target agent if the plugin was only just installed
kill_switched_os (#5294)Every reachable target runs an OS for which this plugin action’s per-OS kill switch is OFF. The dispatch was withheld, not attempted.null — retrying will not help until the switch is turned back on for that OS
os_gate_unreadable (#5294)Agent presence could not be read while a per-OS kill switch is set, so the dispatch failed closed rather than reaching only this replica’s local agents. Transient.5000
(absent)Genuinely no agent reachable — the pre-existing meaning.(absent)

plugin_not_found, kill_switched_os and os_gate_unreadable are never produced for a protective action (the quarantine plugin’s four actions and __guard__.push_rules); see Upgrading.

reason is a top-level key on the error object, not part of the A4 error.data envelope. POST /api/instructions/{id}/execute carries the same reason 503 split as POST /api/command above (containment_unreadable / quarantined / plugin_not_found / kill_switched_os / os_gate_unreadable, same retry_after_ms values) — closed alongside the architect finding that its response-building code was reading dispatch_outcome.command_id/.sent only, discarding the richer fields already available on the same struct.

A fourth, structurally distinct condition — invalid_scope — is 400, not part of the 503 table above. This route (unlike POST /api/command) accepts a caller-supplied scope expression; a malformed one is a client mistake, not a fleet-state fact, so it reports error.reason: "invalid_scope" at 400 with retry_after_ms: null, checked before the 503 cascade so a bad expression is never shadowed by containment_unreadable. Its generic catch-all (no reason key, matching the (absent) row above) additionally covers ADR-1007’s per-device concurrency-claim exclusion, unique to this route since it — unlike /api/command — accepts a concurrency_mode: for a per-device definition, every named target may have been excluded by an already-open claim rather than being unreachable at all; the message points at yuzu_server_dispatch_concurrency_skipped_total. The bundle and result-set producers still do not carry this split — their own response-building code was not extended to surface the richer per-arm result their shared dispatch closure now carries (a separate, smaller follow-up per route). The quarantine plugin’s own four actions (quarantine, unquarantine, status, whitelist) are exempt so that release stays reachable, and so are four server-internal pushes that are not operator dispatch — echo.fleet_snapshot, __guard__.push_rules, asset_tags.sync and __sync__.now, a closed set counted (not per-event audited) by yuzu_server_system_reserved_push_total. Nothing else is. If containment state becomes unreadable for longer than a 60-second last-known-good window, dispatch fails closed and refuses every target fleet-wide — alert on yuzu_server_quarantine_gate_total{outcome="fail_closed"}, which is an outage signal rather than a quarantine one (see Metrics).


DELETE /api/v1/quarantine/{agent_id}

Release a device from quarantine. This ends the server’s quarantine record only: it does not remove the device’s firewall rules. To lift those, run the quarantine plugin’s release instruction (security.quarantine.release, “Release Device from Quarantine”) from the Instructions page or through execute_instruction.

Permission: Security:Execute, scoped per-device (see the note above)

Like POST, the release writes a quarantine.disable row with result=attempt before the change and answers 503 (Sec-Audit-Failed: true) without releasing the device if that row cannot be written.

Dispatch approval check (#6065). After the scoped gate, and before the attempt audit row and the release, the route runs the same dispatch approval check as POST, for the quarantine.unquarantine pair: a caller who cannot create a record cannot end one. The answers, statuses and reason labels are the table under POST, with the pair read as quarantine.unquarantine and the audit verb as quarantine.disable; a refusal leaves the record active, writes a quarantine.disable audit row with result=denied and detail reason=<label> securable=<name> (no attempt row), and increments yuzu_server_dispatch_denied_total{reason}. The remediation text names the MCP supervised flow, which has no release tool, so a non-administrator has no path to release through REST, the dashboard or MCP; an administrator releases it. Running the plugin’s unquarantine without ending the record does not release the device from the server’s side: the record stays active, the lens can keep reading the earlier confirmation, and containment is re-applied at the next session change, so an administrator should end the record first.

The 401 for a changed principal, the 500 for an unwired audit identity writer and the session-stamped audit rows are exactly as for POST above, with the audit verb quarantine.disable.

Response:

{
  "data": { "released": true },
  "meta": { "api_version": "v1" }
}

400 vs 503 (ADR-0047). Same distinction as POST above — 400 means the device is not currently quarantined (retrying will not help); 503 means a genuine store/pool failure (retrying is reasonable).


Internal CA

The server’s built-in certificate authority (see docs/pki-architecture.md) can be managed via the Settings → Certificates and secrets page (or the Settings → Internal CA panel on the Settings page) or the REST endpoints below. root and crl are public (clients and browsers need them to establish trust / check revocation before they have a session); the inventory and revoke endpoints are gated by the Security securable.

Note on Security operations: certificate revocation uses Security:Delete (a destructive operation on an issued cert), while device quarantine uses Security:Execute. A custom role scoped to security operations needs both.

GET /api/v1/ca/root

Download the CA root certificate (PEM) and add it to an OS/browser trust store. Public — no authentication. Returns Content-Type: application/x-pem-file, Content-Disposition: attachment; filename="yuzu-ca.pem", Cache-Control: public, max-age=86400. 404 if no CA root exists; 503 if the CA store is unavailable (a genuine database error, distinct from no-root).

curl https://yuzu.example.com/api/v1/ca/root -o yuzu-ca.pem
openssl x509 -in yuzu-ca.pem -noout -fingerprint -sha256

GET /api/v1/ca/crl

Download the current certificate revocation list (DER). Public — no authentication. Returns Content-Type: application/pkix-crl, Cache-Control: no-cache, must-revalidate. 503 if no CRL has been published yet (transient — the server pre-publishes at startup and republishes on each revocation).

curl https://yuzu.example.com/api/v1/ca/crl -o yuzu.crl
openssl crl -inform DER -in yuzu.crl -noout -text

GET /api/v1/ca/issued

List certificates issued by the internal CA. Permission: Security:Read. Query params limit (1–1000, default 200) and offset (default 0). The full certificate PEM and enrollment reference are intentionally omitted. 503 if the CA store is unavailable.

The inventory is fleet-wide data about every enrolled agent (subject, SAN), so it is held to the same rules as the filtered list below. A caller whose view of the fleet is confined (management-group confinement), or who holds no Inventory:Read grant, is refused 403 with no permission named, and the refusal is written as a denied crypto.ca.certificates.view row (target inventory, detail cid=<cid> ws=crypto_widget_windows_v1 withheld=confined or withheld=refused). A served page is written as a success row (target inventory, detail cid=<cid> inventory rows=<n> limit=<n> offset=<n> has_more=<bool> standing=available), set-and-proceed: if the row cannot be written the page is still served, with Sec-Audit-Failed: true and audit_persisted: false in the body. The confinement check reporting itself unavailable is 503 + retry_after_ms: 5000 (access check unavailable, also written as a failure row), a store that cannot be read is 503

Who is served. The list is served only to a caller whose Inventory:Read is granted without a management-group restriction (a global grant). A caller whose Inventory:Read is granted only for management groups is confined and is refused (403, a denied row ending withheld=confined). A caller holding Security:Read and no Inventory:Read grant at all is refused too (403, withheld=refused): holding no grant is not an affirmative admission to a fleet-wide list. Granting a global Inventory:Read removes the restriction, and removing a caller’s last scoped Inventory:Read grant never widens what it can see. With RBAC enforcement off nothing changes: every Security:Read session is served, as before.

A read failure inside the access check is a retryable 503, never the list. If a store behind the check (permissions, management groups) cannot be read, the route answers 503 access check unavailable with retry_after_ms: 5000 and writes a failure row. The same holds for the filtered list below and the /crypto drill (a banner with Retry). The audit row can be missing during the same outage (correlate with yuzu_server_audit_emit_failed_total).

meta.has_more is true when more rows exist beyond the current page; when it is, meta.next_offset carries the offset to pass for the next page. Iterate until has_more is false (do not infer end-of-list from count < limit).

meta also carries the revocation-list figures, so a client can judge revocation freshness without downloading the DER list: crl_version, crl_published_at and crl_next_update (epoch seconds), crl_revoked_count and crl_stale. The first four are null when no list is published, and crl_revoked_count is also null (never a fabricated 0) for a list published before entry counts were recorded; 0 means a published list with no revoked entries. crl_stale is true when the latest list was not built from the current revoked set (a revocation is missing from it, no list is published, or its entry count was not recorded), false when it covers every revocation, and null when the comparison read failed. It does not reflect expiry: compare crl_next_update with the current time. The MCP list_issued_certs tool returns the same five fields at the top level of its result.

{
  "items": [
    {
      "serial_hex": "3A4B5C6D...",
      "subject": "agent-prod-01",
      "san": "URI:yuzu://<ca-fp>/agent/agent-prod-01",
      "purpose": "agent",
      "status": "active",
      "not_after": 1780000000,
      "issued_at": 1710849600,
      "issued_by": "agent:agent-prod-01"
    }
  ],
  "count": 1,
  "meta": { "api_version": "v1", "limit": 200, "offset": 0, "has_more": false,
            "crl_version": 14, "crl_published_at": 1710849600, "crl_next_update": 1711454400,
            "crl_revoked_count": 2, "crl_stale": false }
}

The MCP list_issued_certs tool mirrors this contract (same has_more / next_offset).

Filtered: the certificates behind one /crypto count (ADR-0070 R2, R8). When any of kind, days, week, bucket, as_of or cursor is present, the route lists exactly the certificates behind one Yuzu-issued count of GET /api/v1/crypto/ca/stats, with the same predicate. Permission: Security:Read, and a caller whose view of the fleet is confined, or who holds no Inventory:Read grant, is refused 403 with no permission field (Yuzu-issued certificate figures are not available to this account: ...), whatever the parameters. The input is strict, and each malformed parameter is a 400 whose message names it:

ParameterTypeDescription
kindstringRequired in this form: active, expiring, expired, revoked, issued, issued_week or revoked_week
daysintegerRequired for expiring (3, 7, 14, 21, 30, 60), expired and revoked (7, 30, 60, 90) and issued (7); refused on other kinds
weekstringISO week YYYY-Www (for example 2026-W43); required for issued_week and revoked_week, refused on other kinds. A week that does not exist (2026-W00, 2027-W53) is refused
bucketstringsuperseded, unpublished or other; optional, revoked and revoked_week only. Omitted lists every reason
as_ofintegerEpoch seconds to measure the first page against (for example a /crypto/ca/stats count’s as_of). Accepted from 7 days before the database clock up to 5 minutes after it, checked in the read’s own transaction; omitted = the database clock now. A continuation page carries its as_of in cursor; an as_of sent beside a cursor must equal it. See Pinned as_of
limitinteger1 to 500, default 100 (refused outside the range, not clamped)
cursorstringThe previous page’s next_cursor (opaque, at most 512 bytes). Bound to kind, days, week and bucket: a cursor that is malformed, edited or issued for another filter is a 400 naming cursor

A parameter repeated with differing values is a 400. offset is refused in this form (400 naming it): the filtered list pages by keyset cursor only. limit or offset alone does not select this form (the unfiltered list above keeps its offset paging).

curl -s -H "Authorization: Bearer $TOKEN" \
  "https://yuzu.example.com/api/v1/ca/issued?kind=revoked&days=30&bucket=superseded"
{
  "items": [
    {
      "serial_hex": "3A4B5C6D...",
      "subject": "agent-prod-01",
      "san": "URI:yuzu://<ca-fp>/agent/agent-prod-01",
      "purpose": "agent",
      "status": "revoked",
      "not_after": 1821657600,
      "issued_at": 1790121600,
      "revoked_at": 1791590400,
      "revocation_reason": "superseded",
      "issued_by": "agent:agent-prod-01",
      "issuer_key_id": "ab12..."
    }
  ],
  "count": 1,
  "filter": {
    "kind": "revoked", "days": 30, "week": null, "week_start": null, "bucket": "superseded",
    "rule_id": "yuzu_ca_revoked_superseded",
    "criterion": "Revoked after as_of - 30 days and on or before as_of, reason recorded as superseded (replaced by a same-key successor)",
    "rule_set_version": "crypto_widget_windows_v1",
    "as_of": 1792396800, "as_of_iso": "2026-10-19T08:00:00Z", "total": 1,
    "scope_note": "Counts certificates Yuzu's own CA issued only (agent, server, gateway, HTTPS and code-signing); certificates on your devices are not covered."
  },
  "next_cursor": null,
  "meta": { "api_version": "v1", "limit": 100, "has_more": false }
}

filter.total counts every match at filter.as_of; when meta.has_more is true, next_cursor is set: request the next page with cursor=<next_cursor> (and the same kind, days, week, bucket). The cursor carries the position of the last certificate served and the walk’s as_of, so every page is measured against the same time and starts strictly after that certificate: a certificate revoked between pages never makes the walk skip another (an offset would). next_cursor is null on the last page. Order: expiring soonest first, expired and revoked most recent first, the rest newest-issued first. Every filtered call is audited crypto.ca.certificates.view, as is every unfiltered call (set-and-proceed: audit_persisted: false in the body and Sec-Audit-Failed: true when the row is lost; a confined refusal is a denied row, and an unavailable access check a failure row). Errors: 400 (each malformed parameter, named), 401, 403 (no Security:Read, a confined caller, or no Inventory:Read grant), 503 + retry_after_ms: 5000 (Yuzu CA read degraded, crypto read capacity in use; retry, or access check unavailable; never an empty list), and 503 without retry_after_ms (filtered certificate list unavailable: the server was built without the /crypto seam, a wiring fault that lasts until a restart). MCP twin: list_issued_certs with the same arguments.

POST /api/v1/ca/revoke

Revoke a certificate by serial. Permission: Security:Delete. Revocation takes effect server-side immediately (the mTLS accept gate reads ca_store, not the CRL); the CRL is then republished. Request body (max 64 KB):

{ "serial_hex": "3A4B5C6D...", "reason": "key compromise" }

serial_hex is required (1–64 hex digits, case-insensitive). Unknown JSON fields are rejected. A reason equal (trimmed, case-insensitive) to one of the system markers superseded or unpublished is stored as operator-supplied: <text> (the revoke still succeeds), so an operator-typed reason can never be mistaken for the bookkeeping revocations the server writes itself. Response:

{ "revoked": true, "serial_hex": "3A4B5C6D...", "crl_republished": true, "meta": { "api_version": "v1" } }

crl_republished: false means the revocation stands (the agent is refused on its next gRPC call) but the public CRL could not be rebuilt — external CRL consumers will not see it until the next successful publish. The server retries on its own: the leader’s freshness pass republishes on its next 15-second tick once the cause clears (up to about 5 minutes later if that attempt fails too). Repeating the POST is not a retry — it returns 404 because the serial is already revoked. Errors: 400 (missing/invalid serial, unknown field, bad JSON), 403 (missing Security:Delete), 404 (serial not found or already revoked), 413 (body too large), 503 (CA unavailable).

POST /api/v1/ca/issue-code-signing (gap-matrix #10)

Issue a code-signing leaf certificate via CSR custody — the operator holds the private key and submits only a PKCS#10 CSR; the server never sees the key. See docs/pki-architecture.md “Code-signing certificate issuance” and docs/user-manual/agent-plugins.md “Plugin Signing” for the full workflow. Permission: Security:Write. MCP twin: issue_code_signing_cert (same schema, supervised-tier + approval-gated like every other Security:Write MCP tool). Request body (max 64 KiB):

{
  "csr_pem": "-----BEGIN CERTIFICATE REQUEST-----\n...",
  "label": "plugin-signer-2026",
  "validity_days": 365
}

csr_pem is required. label is required and becomes the leaf’s subject CN — must match ^[A-Za-z0-9._-]{1,64}$ (never an agent-style yuzu://…/agent/… URI SAN). validity_days is optional, [1, 730], default 365, refused outright (not silently clamped) if requested out of range; always further clamped so the leaf cannot outlive the issuing CA. Unknown JSON fields are rejected. Usage is hard-pinned to codeSigning only — never clientAuth/serverAuth — which is what makes this route safe to expose while the general POST /api/v1/ca/issue stays deferred (see pki-architecture.md). Response:

{
  "certificate_pem": "-----BEGIN CERTIFICATE-----\n...",
  "chain_pem": "-----BEGIN CERTIFICATE-----\n...",
  "serial_hex": "3A4B5C6D...",
  "not_after": "2027-09-10T00:00:00Z",
  "purpose": "code-signing",
  "meta": { "api_version": "v1" }
}

chain_pem is the issuing cert plus, in subordinate-CA mode, the parent chain above it — feed both certificate_pem and chain_pem to openssl cms -sign -certfile to build a signature an agent can chain to its trust anchor. Errors use the A4 envelope: 400 (missing/invalid csr_pem, invalid label, validity_days out of range, a signing key below the strength floor — the accepted set is RSA 2048-16384 bits or EC P-256/P-384/P-521; Ed25519 and Ed448 are rejected, since openssl cms -sign cannot use them — unknown field, bad JSON), 403 (missing Security:Write), 409 (no CA root — generate default certs first), 413 (body too large), 503 (CA unavailable). Audited as ca.cert.issued (target_type=CodeSigningCertificate, target_id=<label or serial>, detail carries purpose=code-signing) — the same action name an agent-leaf issuance uses, distinguished by target_type (CodeSigningCertificate vs AgentCertificate), with purpose as a secondary discriminator. Revoking the issued leaf uses the existing POST /api/v1/ca/revoke above; revocation does not yet reach the agent-side plugin-load verifier via the CRL — see docs/pki-architecture.md’s caveat before relying on this operationally.

Subordinate-CA (root your CA in an enterprise PKI)

By default Yuzu’s CA is a self-signed install root (trust mode: built-in). An enterprise can instead make Yuzu’s issuing CA a subordinate of its own root, so every Yuzu-issued certificate chains to the corporate trust anchor. The issuing key never changes — the enterprise signs Yuzu’s existing public key — so certificates already issued keep validating across the switch.

Workflow:

  1. Export Yuzu’s CA signing request and have your enterprise root sign it as a subordinate CA (the signed cert must carry basicConstraints: CA:TRUE, keyUsage: keyCertSign, cRLSign).
  2. Import the signed intermediate plus the parent chain (enterprise root and any intermediates above Yuzu’s).

This is also available in Settings → Internal CA → “Subordinate this CA…”.

GET /api/v1/ca/root-csr

Export the install CA’s PKCS#10 CSR (PEM), over its existing key, for an enterprise root to sign. Permission: Security:Read. The CSR carries only the CA’s public key (already public via GET /api/v1/ca/root) and subject — no secret. Audited (ca.root_csr.exported).

curl -H "Authorization: Bearer $TOKEN" \
  https://yuzu.example.com/api/v1/ca/root-csr -o yuzu-ca.csr
# → hand yuzu-ca.csr to your enterprise CA; it returns a signed intermediate.

Errors: 403 (missing Security:Read), 500 (CSR generation failed), 503 (CA unavailable).

POST /api/v1/ca/import-chain

Import the enterprise-signed intermediate + parent chain, switching the issuing identity to subordinate mode. Permission: Security:Write. Request body (max 256 KB):

{
  "intermediate_pem": "-----BEGIN CERTIFICATE-----\n... (Yuzu's key, signed by the enterprise) ...\n-----END CERTIFICATE-----\n",
  "chain_pem": "-----BEGIN CERTIFICATE-----\n... (enterprise root [+ intermediates]) ...\n-----END CERTIFICATE-----\n"
}

The server validates that the intermediate (a) is a CA certificate, (b) carries this install’s CA public key (proof the enterprise signed the CSR you exported), and (c) verifies up to the supplied parent chain — then swaps the issuing cert and re-publishes the CRL under the new issuer. Response:

{ "imported": true, "mode": "subordinate", "crl_republished": true, "meta": { "api_version": "v1" } }

Errors: 400 (bad JSON / missing field / unparseable intermediate), 403 (missing Security:Write), 409 (no existing CA to subordinate — generate default certs first), 422 (intermediate is not a CA / does not carry this CA’s key / does not verify to the chain), 413 (body too large), 500 (validated but persistence failed), 503 (CA unavailable). A rejected import is audited ca.subordinate.imported with result=denied; success with result=success.

Bring-your-own-leaf (orthogonal): any surface given an explicit --cert/--key/--ca-cert (or --https-cert/--https-key) flag bypasses the internal CA for that surface entirely — unchanged behaviour, supported and independent of subordinate mode.


KEK Rotation

Rotate the secrets key-encryption-key (KEK) that wraps every envelope-encrypted secret column (ADR-0010). See docs/user-manual/server-admin.md “Key management (secrets KEK)” for the full runbook (half-committed recovery, rotation runaway control, stuck-lock diagnosis, DR drill, retirement preconditions). Each route has an MCP twin sharing the same failure classification, remediation wording, and — for get_kek_status — the identical live_versions/lock_held/lock_holder_pid fields added by #2530 (REST/MCP twin parity, ADR-1005): rotate_kek, rewrap_secrets, get_kek_status.

POST /api/v1/secrets/kek/rotate

Mint a new KEK version (secrets-kek-v<N+1>) and re-wrap every registered secret row’s wrapped-DEK header under it (payloads untouched). Permission: Security:Write. No request body (empty or {}).

{ "new_version": 2, "rotation_complete": true, "meta": { "api_version": "v1" } }

rotation_complete is the honest completion signal (ADR-0010 §3) — the response deliberately omits a rewrapped-row count; call /rewrap for one. Errors: 400/413 (non-empty/oversized body), 403 (missing Security:Write), 409 (two distinct causes — see below), 429 (rotation cooldown, below), 503 (three distinct causes — see below).

Two different 409s. "another KEK operation is in progress" means another rotation or re-wrap holds the cluster-wide secrets_kek_op advisory lock — wait for it to finish and retry. "the live KEK version ceiling has been reached" (#2530) means --kek-max-live-versions has been hit — waiting never resolves this one; it needs an operator to explicitly raise the ceiling (documented as a deliberate, audited risk acceptance pending #2525 — see server-admin.md). Distinguish them from the message text, not the status code alone.

Three different 503s. "KEK service unavailable" (codec/Postgres not yet ready — retry once the server reports ready) is the original one. #2530 added two more, neither retryable on a fixed delay: "a KEK query was canceled or exceeded its statement timeout" (SQLSTATE 57014 — deliberately worded that way, not “timed out”: Postgres also raises this for an administrator’s pg_cancel_backend, and it is not necessarily transient — check statement_timeout, database load, cancellation activity, and the registered-column scan size before retrying), and "the KEK rotation clock is untrustworthy" (the newest secrets.kek_meta row is future-dated relative to the database server’s own clock — investigate the database server’s clock/NTP, not the KEK subsystem). The response body and server log report the observed skew magnitude in seconds, because the two possible skew directions behave differently: a backward skew self-clears once the clock reads sanely again, but a forward skew (the row was minted while the clock was already ahead — bad NTP, a VM restore, a failover to a host that’s ahead) does not self-clear — it persists until real time catches up to the stored timestamp, blocking every /rotate for that whole duration with no configuration bypass at all. See server-admin.md “Clock-anomaly guard” for the full diagnostic procedure. None of the three carry retry_after_ms.

500 half-committed — do NOT retry this route. If rotation registers the new version but fails to finish re-wrapping every row, this returns 500 with a remediation telling you to call POST /api/v1/secrets/kek/rewrap to resume. Retrying /rotate instead would mint a second, spurious version on top of the half-rotated state. This classification wins over a query_canceled cause if the cancellation happened after the mint committed — you still get told to call /rewrap, never /rotate.

429 rotation cooldown — durable and cluster-wide (#2530; #2530 G7-S9 removed a weaker second tier). An earlier cut of this hardening pass added a cheap process-local pre-check (5 minutes, in-memory) alongside the durable control, but it was removed: it was never the authoritative control (it was per-process and a restart cleared it), its hardcoded window could not be configured below 5 minutes, and it never populated an honest retry_after_ms. The sole authoritative rate limit today is read from secrets.kek_meta.created_at on the database server’s own clock, cluster-wide and restart-persistent: --kek-min-rotate-interval (seconds, default 3600 = 1h). A fresh install’s first rotate attempt is refused for up to this interval too — KEK v1 is minted at boot with created_at = now(). A failed attempt consumes the budget too — deliberately, because a failed rotation is exactly when you must not immediately rotate again: automation that retries /rotate on a 500 would otherwise mint a fresh version on every retry, and a KEK version can never be retired (#2525). /rewrap is NOT rate-limited and is the correct way to finish a half-committed rotation. Unlike the 409/503 cases above, this 429’s retry_after_ms is honest — computed from the same durable database-clock timestamp, so waiting genuinely resolves it (the field is a uint32 millisecond count that saturates at its maximum on an oversized configured interval rather than wrapping, so it can only come out over-long, never falsely short). Cluster-wide correctness (as opposed to rate limiting) still comes from the advisory lock, which returns 409, not this.

--kek-min-rotate-interval is a runaway/abuse guard, not a rotation schedule — do NOT set it to your rotation cadence. It exists to stop a looping caller (buggy automation, a compromised token) hammering /rotate, not to express how often you intend to rotate; most operators should never change the default. Raising it directly delays emergency re-rotation after a suspected KEK compromise, with no bypass (/rewrap only resumes an in-progress rotation, never mints a new version) — e.g. setting it to 90 days to “match” a quarterly policy means a routine rotation followed by a compromise the next day leaves you refused for the next three months, with no escape short of a restart at a lower value mid-incident. The 365-day upper bound is a fat-finger sanity ceiling, not a recommendation.

POST /api/v1/secrets/kek/rewrap

Idempotent resume: re-wrap every row still on a non-active KEK version under the current active one. Safe to call repeatedly, including when there is nothing left to do. Permission: Security:Write. No request body.

{ "rows_rewrapped": 3, "meta": { "api_version": "v1" } }

rows_rewrapped: 0 is a normal outcome, not an error. Errors: 400/413, 403 (missing Security:Write), 409 (advisory lock held), 503 (codec/ Postgres unavailable, or "a KEK query was canceled or exceeded its statement timeout" — same #2530 SQLSTATE 57014 classification and non-transient caveat as /rotate above; /rewrap never returns the ceiling or clock-anomaly failures, those are rotate-only preconditions).

GET /api/v1/secrets/kek/status

Current KEK rotation status. Permission: Security:Read.

{
  "active_version": 2,
  "oldest_in_use": 2,
  "rotation_complete": true,
  "live_versions": 2,
  "lock_held": false,
  "lock_holder_pid": null,
  "meta": { "api_version": "v1" }
}

oldest_in_use is null when no secret rows exist at all (trivially complete). Read-only, not audited, and — unlike /rotate//rewrap — this route deliberately never takes the secrets_kek_op advisory lock, so it never returns 409 for lock contention. Errors: 403 (missing Security:Read), 503 (codec/Postgres unavailable, or the same query_canceled classification as above while reading the oldest-referenced version).

live_versions, lock_held, lock_holder_pid (#2530). Diagnostic fields for diagnosing a wedged secrets_kek_op lock — before these existed, a stuck holder made every KEK operation 409 forever with no way to see why. lock_held/lock_holder_pid report the lock in this server’s own database (the underlying query is filtered by current_database(), not cluster-wide — a same-named advisory lock held by an unrelated tenant database sharing the same Postgres instance is never reported here). They are lock-free snapshots read at possibly different instants, not one coordinated read — never derive a “safe to retire” conclusion from any combination of them (#2525), and never treat lock_holder_pid alone as sufficient grounds to terminate a backend: a healthy long-running re-wrap looks identical to a wedge from this field alone. The full diagnosis-and-remedy procedure (corroborate against pg_stat_activity before any DBA action) is in docs/user-manual/server-admin.md § “Diagnosing a stuck KEK op lock”.

live_versions and lock_held are null — never a fabricated 0/ false — when the underlying query could not be determined. The key is always present; null means “could not be determined”, not a truthful negative. This matters most for lock_held: a null here must never be read as “no lock is held” — it means the lock state is UNKNOWN. Corroborate via pg_stat_activity before concluding anything, exactly as you would for a genuinely-held lock.

lock_holder_pid is null in THREE distinct situations — always check lock_held first to tell them apart: (1) lock_held: false → genuinely unheld, nothing to report; (2) lock_held: null → undetermined, the holder query itself failed, the pid is meaningless (not “unheld”); (3) lock_held: true with lock_holder_pid: null → the lock is held, but the holder’s backend pid could not be read from pg_locks at query time — there is no pid to corroborate against pg_stat_activity; instead query pg_locks directly for any granted holder of secrets_kek_op in the current database (see docs/user-manual/server-admin.md’s runbook for the exact query) and, if it also comes back empty or unreadable, escalate to a DBA inspection rather than waiting on a pid that may never arrive.

No retire endpoint — by design. Yuzu does not expose a way to destroy an old KEK version, even though the codec’s internal retire_kek exists and is tested. SecretCodec::encrypt() snapshots the active version and releases its lock before the caller persists the blob, so a retirement could pass its “zero references” check and delete a key an in-flight write is about to use — a permanent, unrecoverable loss (#2525). Old KEK files are expected to accumulate; they are required to restore older backups and must not be deleted by hand.


Engine Principals

Fleet-wide RBAC role-assignment authoring surface for the engine principal class — the durable identity behind an autonomous use-case-engine (UCE) module (design doc docs/auth-engine-principals-design.md, ADR-1005 item 2b). This is PR 4.2’s “assignment authoring” deliverable (design §4.1): without it, RbacStore::assign_role had no production caller for principal_type="engine", so a would-be grant could never take effect. There is no CRUD for minting or revoking engine principals themselves yet — that lands in PR 4.3; these three endpoints only manage the roles held by an existing engine principal.

{id} in every route below is the bare slug, without the engine: prefix (mirrors /management-groups/{id} taking a bare group id) — the server reconstructs the full engine:<slug> principal id internally. Grants are always fleet-wide in this release; scoped (management-group) engine role assignment is rejected pending the Phase-5 authorize_list_read extension (design §4.3).

Structural constraint (design §4.2, “no admin, ever”): an engine principal cannot hold the admin/Administrator role or any other built-in system role. POST .../roles rejects such a request outright (400) — it is never silently narrowed to something safer. A custom role that grants every securable and operation is not refused at assignment; GET /api/v1/engine-principals/audit/no-admin reports it afterwards as wildcard_grant.

GET /api/v1/engine-principals/{id}/roles

List the fleet-wide roles currently assigned to an engine principal.

Permission: EnginePrincipal:Read

Response:

{
  "data": [
    { "principal_id": "engine:vuln-viewer", "role": "Viewer" }
  ],
  "meta": { "api_version": "v1" }
}

An unknown or revoked {id} is not distinguished here — it simply returns an empty data array (no RBAC row can exist for a principal that was never granted one). Errors: 403 (missing EnginePrincipal:Read), 503 (RBAC store unavailable).


POST /api/v1/engine-principals/{id}/roles

Assign a fleet-wide role to an engine principal.

Permission: Security:Write, plus admin + MFA step-up (same gate class as POST /api/v1/ca/import-chain — this route grants further RBAC authority to a principal).

Request body:

{ "role": "Viewer" }
FieldTypeRequiredDescription
rolestringYesAn existing RBAC role name. admin/Administrator/any built-in system role is rejected.

Response (201):

{
  "data": { "assigned": true, "principal_id": "engine:vuln-viewer", "role": "Viewer" },
  "meta": { "api_version": "v1" }
}

Errors: 400 — invalid JSON, missing role, unknown role name, or the role is admin/built-in/otherwise rejected by validate_assignment (design §4.2 — always a 4xx, never a 500); 401/403 — not authenticated, missing Security:Write, an engine session, or MFA step-up required/failed; 404 — no active (live, non-revoked) engine principal exists at {id}; 503 — the RBAC or engine-principal store is unavailable. A rejected assignment is audited engine_principal.role.assigned with result=denied; success with result=success. 503 + Sec-Audit-Failed: true — the assignment audit row could not persist (see Audit-persist failure above): the grant may already be in effect, so reconcile via GET /{id}/roles.


DELETE /api/v1/engine-principals/{id}/roles/{role}

Unassign a fleet-wide role from an engine principal.

Permission: Security:Write, plus admin + MFA step-up.

Response:

{
  "data": { "unassigned": true },
  "meta": { "api_version": "v1" }
}

Errors: the call is idempotent: a role that was not held also answers 200 (removed=false appears only in the audit detail). 401 or 403: not authenticated, missing Security:Write, an engine session, or MFA step-up required or failed. 503: the RBAC store is unavailable or the query failed. Audited engine_principal.role.unassigned on success. 503 + Sec-Audit-Failed: true — the unassign audit row could not persist (see Audit-persist failure above): the grant may already be removed, so reconcile via GET /{id}/roles.


RBAC

Role-Based Access Control endpoints for inspecting roles, permissions, and authorization decisions.

GET /api/v1/rbac/roles

List all defined roles.

Permission: UserManagement:Read

Response:

{
  "data": [
    {
      "name": "Administrator",
      "description": "Full system access",
      "is_system": true,
      "created_at": 1710849600
    },
    {
      "name": "Operator",
      "description": "Can execute instructions and manage devices",
      "is_system": true,
      "created_at": 1710849600
    },
    {
      "name": "Viewer",
      "description": "Read-only access",
      "is_system": true,
      "created_at": 1710849600
    },
    {
      "name": "ITServiceOwner",
      "description": "Can delegate Operator and Viewer roles within their management groups",
      "is_system": true,
      "created_at": 1710849600
    }
  ],
  "pagination": { "total": 4, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

Errors:

ConditionResponse
Caller lacks UserManagement:Read403
The RBAC store is closed, its connection pool timed out, or the read failed503 with retry_after_ms 5000 and a fixed message. Never an empty 200 list: an empty list would read as “no roles exist”.

GET /api/v1/rbac/roles/{role_name}/permissions

List all permissions granted to a role.

Permission: UserManagement:Read

Response:

{
  "data": [
    {
      "securable_type": "ManagementGroup",
      "operation": "Read",
      "effect": "allow"
    },
    {
      "securable_type": "ManagementGroup",
      "operation": "Write",
      "effect": "allow"
    },
    {
      "securable_type": "Tag",
      "operation": "Read",
      "effect": "allow"
    }
  ],
  "meta": { "api_version": "v1" }
}

Errors:

ConditionResponse
Caller lacks UserManagement:Read403
{role_name} is not in the roles table (matched exactly, case-sensitive)404 — role not found. Not an empty 200.
The RBAC store is closed, its connection pool timed out, or either read failed503 with retry_after_ms 5000 and a fixed message. A degrade is never reported as 404 or as an empty permission set.

POST /api/v1/rbac/check

Check whether the current user has a specific permission.

Permission: Authenticated session (no specific RBAC permission required).

Request body:

{
  "securable_type": "ManagementGroup",
  "operation": "Write"
}
FieldTypeRequiredDescription
securable_typestringYesThe resource type to check
operationstringYesThe operation to check (Read, Write, Delete, Execute)

Response:

{
  "data": { "allowed": true },
  "meta": { "api_version": "v1" }
}

Securable types: ManagementGroup, ApiToken, Security, UserManagement, Tag, InstructionDefinition, AuditLog

Operations: Read, Write, Delete, Execute


GET /api/v1/rbac/roles/assignments

Fleet-wide “who currently holds which RBAC role”: every (principal_type, principal_id, role_name) grant row on record, across all three principal types (user/group/engine), in one bulk read. This reuses the same RbacStore::list_all_principal_roles_checked() bulk read the SOC 2 CC6.2 access-review export (GET /api/v1/access-reviews/export) is built on — it is the complete, ungated grant table, not a management-group-confined slice.

Permission: AccessReview:Read — the SAME dedicated securable the access-review export uses, deliberately not UserManagement:Read like the plain role catalog above: a confinement-filtered view would be incomplete evidence, so this route needs the same bar as the export. Also subject to the RBAC-off authorization-topology floor (docs/auth-architecture.md “The authorization topology floor (#2376)”), which applies only in the legacy RBAC-off fallback: with RBAC disabled, the floor additionally requires an effective local admin role; with RBAC enabled, any principal actually granted AccessReview:Read — including the seeded, non-admin Reviewer role — is admitted, exactly as intended (#2324 seeded Reviewer specifically to reach this class of surface without full Administrator authority).

No pagination: list_all_principal_roles_checked() is a single bulk read with no limit/offset, and the response carries no pagination block at all (matching GET /api/v1/access-reviews, the route this one mirrors) — a data array of the complete grant table plus meta only.

Self-audited as rbac.assignments.list.

Response:

{
  "data": [
    {
      "principal_type": "user",
      "principal_id": "alice",
      "role_name": "Administrator"
    },
    {
      "principal_type": "engine",
      "principal_id": "engine:nvd-sync",
      "role_name": "Viewer"
    }
  ],
  "meta": { "api_version": "v1" }
}

Errors:

StatusMeaning
403Requires AccessReview:Read
503RBAC store unavailable, or a genuine read failure

POST /api/v1/rbac/roles/{name}/assignments

Grant one of the 7 fleet-wide-assignable built-in RBAC roles (Administrator, PlatformEngineer, Operator, ApiTokenManager, Viewer, Reviewer, ForensicsAnalyst) to a human user, fleet-wide. See “Fleet-Wide Role Assignment (Built-in Roles)” in docs/user-manual/rbac.md for the full narrative (key constraints, examples, and why this is a separate surface from POST /api/v1/rbac/check’s perm_fn-gated siblings above).

Permission: Gated on a dedicated is_rbac_administrator check — a durable Administrator role held by the caller, re-read fresh from the store (never the session’s cached role or a JIT POST /api/v1/elevate elevation) — instead of, not in addition to, an ordinary RBAC-securable permission check — plus MFA step-up. A service-scoped API token, an engine session, and an MCP-tier bearer token of ANY tier (including supervised) are all structurally denied before any store read — an MCP-tiered credential must use the assign_rbac_role MCP tool instead, which carries its own supervised-tier + approval-ticket gate (#520).

Cross-site writes: a session-cookie call must carry an Origin or Referer naming this site (or a --csrf-trusted-origin entry), else it is refused 403 cross-site request refused and audited as csrf.denied. API-token callers are exempt, so a script that signs in with a cookie must send the header (for example -H "Origin: https://yuzu.example.com") or use a token. The server-wide same-site check (see Authentication) answers first; this route also keeps its own copy, which is defence in depth.

Request body:

{ "principal_type": "user", "principal_id": "jane.doe" }
FieldTypeRequiredDescription
principal_typestringYesMust be "user" — group-scoped fleet-wide assignment is not supported yet.
principal_idstringYesThe target username. Charset-constrained (alphanumeric + ._-, 1–64 chars) — same set the DELETE twin’s URL-path segment is limited to. A username with no existing auth.users row is accepted (pre-provisioning).

Response (201):

{
  "data": {
    "assigned": true,
    "principal_type": "user",
    "principal_id": "jane.doe",
    "role": "Operator",
    "target_provisioned": "true"
  },
  "meta": { "api_version": "v1" }
}

target_provisioned is a three-state string ("true" / "false" / "unknown"), never a bare boolean: "true" means principal_id has an auth.users row that is currently active (can authenticate right now), "false" means it does not — either a genuine pre-provisioned grant (no account has ever existed at this username) or a grant landing on a currently-deactivated account; the two are not distinguished by this field — and "unknown" means the AuthDB read itself degraded and could not confirm either way — the third state exists specifically so a degraded read is never misreported as “no active account.”

Errors: 400 — invalid JSON, a non-object top-level body (e.g. a JSON array or scalar), missing principal_id, a principal_type/principal_id field present with the wrong JSON type (degrades to “missing”/“invalid”, never an uncaught exception), principal_type other than "user", a malformed principal_id, {name} is not one of the 7 assignable roles (ITServiceOwner and a genuinely unknown/custom role name return the identical client-facing message — no role-catalog oracle; the specific reason is audited server-side only; use POST /api/v1/management-groups/{id}/roles for ITServiceOwner instead), or the store rejects the grant for a genuine client-validation reason (a malformed or reserved-namespace principal_id) — this branch is defensive and not reachable via this route today, since principal_type is hardcoded "user" and principal_id has already passed the same charset check the DELETE route enforces, but stays classified 400 (not 503) for if that ever changes; 401 — not authenticated, or MFA step-up not satisfied; 403 — the caller does not hold a durable Administrator role, does not hold authority under the regime that is durably true right now, is a service-scoped/engine session, presented an MCP-tier bearer token of any tier (use the assign_rbac_role MCP tool instead), or made a cross-site cookie-session call (cross-site request refused); 409 — a role that is not a built-in role holds the name of the built-in role (a name collision; nothing is assigned, and the upgrade guide, “Role name collision”, has the remedy); 503 — the RBAC or AuthDB store is unavailable (including when the admin gate itself cannot confirm durable authority — now logged and audited as a denied row, not silently invisible), a genuine store/query fault while writing the grant (as opposed to a client-validation rejection, which stays 400 — classified via an allow-list of known validation-error shapes, never a denylist, so an unrecognized future error defaults to the safer 503), the defense-in-depth role lookup finding an already-validated role name missing from the store (a tampered/hand-edited store — a store-integrity fault, never a client error), or (on an otherwise-successful assignment) its audit row could not persist — treat the grant as unconfirmed and reconcile via a read. The “does not hold authority under the regime…” refusal (A1) has TWO distinct causes, not one: a cross-replica cache-staleness re-check (the durable-Administrator check above reads a replica-local cached view that can lag a real commit), and a SEPARATE, structural exclusion — a genuine SAML-authenticated Administrator has no auth.users row at all (SAML does not sync to auth.users, unlike OIDC’s provision_sso_identity), so the fresh check’s auth.users-joined authority set never contains them, and this route is refused unconditionally regardless of any staleness. This is a real, fail-safe (over-restrictive, never permissive) side effect, not a bug — see #5182, which also tracks the identical SAML gap in the last-Administrator guard. Audited rbac.role.assigned — see docs/user-manual/audit-log.md.


DELETE /api/v1/rbac/roles/{name}/assignments/{principal_id}

Revoke a fleet-wide RBAC role grant from a human user. Idempotent — unassigning a role the principal did not hold still returns success.

Permission: Same dedicated is_rbac_administrator check as the POST above (including the MCP-tier-token structural denial), plus MFA step-up.

Cross-site writes: a session-cookie call must carry an Origin or Referer naming this site (or a --csrf-trusted-origin entry), else it is refused 403 cross-site request refused and audited as csrf.denied. API-token callers are exempt, so a script that signs in with a cookie must send the header (for example -H "Origin: https://yuzu.example.com") or use a token. The server-wide same-site check (see Authentication) answers first; this route also keeps its own copy, which is defence in depth.

Response:

{ "data": { "unassigned": true }, "meta": { "api_version": "v1" } }

Errors: 401 — not authenticated, or MFA step-up not satisfied; 403 — the caller does not hold a durable Administrator role, does not hold authority under the regime that is durably true right now (A1: same fresh re-check as the POST route above, including its SAML #5182 structural exclusion), is a service-scoped/engine session, presented an MCP-tier bearer token of any tier (use the unassign_rbac_role MCP tool instead), made a cross-site cookie-session call (cross-site request refused), or ({name}=="Administrator" only) is attempting to remove their own Administrator assignment (self-lockout guard — a caller can never revoke their own standing Administrator authority through this route, even to hand it to someone else first); 409 — ({name}=="Administrator" only) removing this grant would leave the fleet with zero authenticatable Administrators (here: an active auth.users row, which is necessary for login but not sufficient) — the guard counts a grant only when its principal_id names an active auth.users row, so a grant naming a nonexistent, deactivated, soft-deleted, or group-held (not creatable via this surface) principal is never counted as a surviving administrator (the guard has known residual gaps, see “Fleet-Wide Role Assignment” in rbac.md); 503 — the RBAC store is unavailable (including when the admin gate itself cannot confirm durable authority — logged and audited as a denied row), a genuine store/query fault removing the grant, or (on an otherwise-successful unassignment) its audit row could not persist — treat the removal as unconfirmed and reconcile via a read. Audited rbac.role.unassigned.

MCP twins: assign_rbac_role / unassign_rbac_role — see docs/user-manual/mcp.md.


PUT /api/v1/rbac/enforcement

Enable or disable RBAC enforcement fleet-wide (durable; every replica converges within ~1s of the write). See “Enabling RBAC” in docs/user-manual/rbac.md for the full narrative (the caller-survives rule, the recommended onboarding order, and the SSO/break-glass note).

Permission: Same dedicated is_rbac_administrator check as the two routes above, plus MFA step-up — except an api_token/mcp_token bearer session, which is exempt from step-up entirely (mfa_step_up.cpp), the same accepted deviation docs/user-manual/rbac.md’s SSO/break-glass callout notes for the equivalent case. A service-scoped API token and an engine session are both structurally denied before any store read.

Cross-site writes: a session-cookie call must carry an Origin or Referer naming this site (or a --csrf-trusted-origin entry), else it is refused 403 cross-site request refused and audited as csrf.denied. API-token callers are exempt, so a script that signs in with a cookie must send the header (for example -H "Origin: https://yuzu.example.com") or use a token. The server-wide same-site check (see Authentication) answers first; this route also keeps its own copy, which is defence in depth.

Request body:

{ "enabled": true }

enabled is required and must be a JSON boolean (true or false). The integers 0 and 1, the strings "true" and "false", and any other value are 400 and change nothing.

Response (200):

{
  "data": {
    "enabled": true,
    "previous_enabled": false,
    "changed": true,
    "post_transition_administrators": 1
  },
  "meta": { "api_version": "v1" }
}

post_transition_administrators reports, after enabling, the number of fleet-wide Administrator grants held by currently-active local accounts; after disabling, the number of active local accounts holding the admin role. Requesting the current state is idempotent: changed is false and neither the guard nor a write runs.

Errors: 400 — invalid/missing JSON body, or enabled missing or not a JSON boolean; 401 — MFA step-up not satisfied (see the exemption above); 403 — the caller does not hold a durable Administrator role, is a service-scoped/engine session, carries a non-empty mcp_tier bearer token (denied before the admin check even runs; REST has no tier/approval concept to legitimize a tiered bearer token the way MCP’s own dispatch guard does), makes a cross-site cookie-session call (cross-site request refused), or (on a real transition) does not hold authority under the regime that is durably true right now — enabling requires the caller’s own local admin role, disabling requires the caller’s own fleet-wide Administrator grant (this closes a cross-replica cache-staleness gap in the admin gate itself: the same requirement the outer check is supposed to enforce, re-verified fresh under the row lock this route itself holds) — the response’s remediation field names the exact follow-up call; 409 — the caller would not remain a durable administrator after the switch (enabling: no fleet-wide Administrator grant; disabling: local account role is not admin) — the response’s remediation field names the exact follow-up call; 503 — the RBAC or AuthDB store is unavailable, or (on an otherwise-successful toggle) its audit row could not persist: treat the change as unconfirmed and check it with the access-review export’s rbac_enforcement label (GET /api/v1/access-reviews/export, AccessReview:Read). GET /api/v1/me (rbac_enabled) is only the cached view; it can lag on another replica and does not report the unconfirmed (degraded) state (#6033). Audited rbac.enforcement_changed — see docs/user-manual/audit-log.md.

There is no GET twin: this route’s own response echoes the authoritative post-write value, and the access-review export’s rbac_enforcement label (enabled, disabled or degraded; GET /api/v1/access-reviews/export, which also writes an access_review.exported audit row) is the strongest available read. It is still the serving replica’s view (see the degrade-versus-outage note in docs/user-manual/rbac.md). GET /api/v1/me (rbac_enabled) is the cached view only: it can lag on another replica and does not report the degraded state (#6033).

MCP twin: set_rbac_enforcement — see docs/user-manual/mcp.md.


Tags

Tags are key-value pairs assigned to agents for categorization, scoping, and compliance tracking. Four structured categories are enforced: role, environment, location, and service. Custom keys are also supported.

Tag key constraints: max 64 characters, pattern [a-zA-Z0-9_.:-]. Tag value constraints: max 448 bytes.

GET /api/v1/tag-categories

List the structured tag categories and their allowed values.

Permission: Tag:Read

Response:

{
  "data": [
    {
      "key": "role",
      "display_name": "Device Role",
      "allowed_values": ["server", "desktop", "kiosk", "virtual"]
    },
    {
      "key": "environment",
      "display_name": "Environment",
      "allowed_values": ["production", "staging", "development", "test"]
    },
    {
      "key": "location",
      "display_name": "Location",
      "allowed_values": []
    },
    {
      "key": "service",
      "display_name": "Service",
      "allowed_values": []
    }
  ],
  "meta": { "api_version": "v1" }
}

Categories with an empty allowed_values array accept free-form values.


GET /api/v1/tag-compliance

Identify agents missing required structured tag categories.

Permission: Tag:Read

Response:

{
  "data": [
    {
      "agent_id": "agent-untagged-01",
      "missing_tags": ["role", "environment", "location", "service"]
    },
    {
      "agent_id": "agent-partial-02",
      "missing_tags": ["service"]
    }
  ],
  "meta": { "api_version": "v1" }
}

GET /api/v1/tags?agent_id=X

Get all tags for a specific agent.

Permission: Tag:Read

Query parameters:

ParameterTypeRequiredDescription
agent_idstringYesThe agent whose tags to retrieve

Response:

{
  "data": {
    "role": "server",
    "environment": "production",
    "location": "eu-west-1",
    "service": "web-frontend",
    "custom.team": "platform-eng"
  },
  "meta": { "api_version": "v1" }
}

PUT /api/v1/tags

Set a tag on an agent. Creates the tag if it does not exist, or updates the value if it does. If the key matches a structured category with allowed values, the value is validated.

Permission: Tag:Write

Request body:

{
  "agent_id": "agent-eu-prod-01",
  "key": "environment",
  "value": "production"
}
FieldTypeRequiredDescription
agent_idstringYesTarget agent
keystringYesTag key (max 64 chars, [a-zA-Z0-9_.:-])
valuestringYesTag value (max 448 bytes)

Response:

{
  "data": { "set": true },
  "meta": { "api_version": "v1" }
}

Error (400) — invalid value for structured category:

{
  "error": "invalid value 'bogus' for category 'role'; allowed: server, desktop, kiosk, virtual",
  "meta": { "api_version": "v1" }
}

Service-scoped tokens: a service-scoped token can never set the service key on any agent, in or out of its own scope, regardless of the value being written — 403, no Tag:Write grant admits it (#3289). The service tag defines the token’s own confinement boundary; see Service-Scoped Tokens.


DELETE /api/v1/tags/{agent_id}/{key}

Delete a tag from an agent.

Permission: Tag:Delete

Service-scoped tokens: cannot delete the service key on any agent, for the same reason as PUT above — 403, no Tag:Delete grant admits it (#3289).

Response:

{
  "data": { "deleted": true },
  "meta": { "api_version": "v1" }
}

Storage failure (all tag endpoints that read or write stored tags — GET /api/v1/tag-categories is compile-time static and exempt): a degraded tag store returns 503 tag store unavailable (A4 envelope, retry_after_ms: 5000) — never an empty tag map, a 404, or a false success. Tags feed scope resolution and dispatch targeting, so a silently-empty answer would mis-resolve decisions built on them (ADR-0050); retry a 503 once /readyz reports tag_store healthy. DELETE returns 404 tag not found only after a successful read found no such tag. Failed and not-found mutations leave tag.set/tag.delete audit rows (failure/not_found), matching the legacy and MCP twins.


Definitions

Instruction definitions describe the schema and execution spec for operations that can be sent to agents.

GET /api/v1/definitions

List all instruction definitions.

Permission: InstructionDefinition:Read

Response:

{
  "data": [
    {
      "id": 1,
      "name": "hardware.cpu-info",
      "description": "Retrieve CPU model, core count, and architecture",
      "plugin": "hardware",
      "action": "cpu-info",
      "version": "1.0.0",
      "created_at": "2025-03-19T12:00:00Z"
    },
    {
      "id": 2,
      "name": "network.interfaces",
      "description": "List all network interfaces with IP addresses",
      "plugin": "network",
      "action": "interfaces",
      "version": "1.0.0",
      "created_at": "2025-03-19T12:00:00Z"
    }
  ],
  "pagination": { "total": 2, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

GET /api/v1/instructions

(#4029, api-parity Batch A) Twin of the legacy GET /api/instructions — the full filter set (name/plugin/type/set_id/enabled_only/limit), reconciled onto the same shared builder MCP’s list_definitions tool calls (instruction_definition_row_json, instruction_definition_model.hpp) so REST and MCP cannot drift from each other by construction. A distinct route from GET /api/v1/definitions above (that route predates this issue and is unaffected by it).

Permission: InstructionDefinition:Read

Query parameters: name, plugin, type (question|action), set_id, enabled_only (bool), limit (default 100).

Response:

{
  "data": [
    {
      "id": "def-abc123",
      "name": "hardware.cpu-info",
      "version": "1.0.0",
      "type": "question",
      "plugin": "hardware",
      "action": "cpu-info",
      "description": "Retrieve CPU model, core count, and architecture",
      "enabled": true,
      "instruction_set_id": "",
      "created_at": 1742385600,
      "updated_at": 1742385600
    }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

503 if the instruction store is unavailable or the read fails (A4 envelope, retry_after_ms: 5000).


GET /api/v1/instructions/{id}

(#4029) Single-definition detail — the RECONCILED SUPERSET of the legacy GET /api/instructions/{id} fragment’s fields (gather_ttl_seconds/response_ttl_days/created_by/timestamps) and MCP get_definition’s pre-existing fields (approval_mode/parameter_schema/result_schema/yaml_source) — both surfaces call the same builder (instruction_definition_detail_json) now, closing the field-set drift that existed between them before this issue. The detail also carries secret_params, the parameter names a client must render as write-only inputs (today grant_secret and grant_id, the server’s one shared list); it is a client hint and is not part of the export.

Permission: InstructionDefinition:Read

Response:

{
  "data": {
    "id": "def-abc123",
    "name": "hardware.cpu-info",
    "version": "1.0.0",
    "type": "question",
    "plugin": "hardware",
    "action": "cpu-info",
    "description": "Retrieve CPU model, core count, and architecture",
    "enabled": true,
    "instruction_set_id": "",
    "created_at": 1742385600,
    "updated_at": 1742385600,
    "gather_ttl_seconds": 300,
    "response_ttl_days": 90,
    "created_by": "operator@example.com",
    "approval_mode": "auto",
    "parameter_schema": "{}",
    "result_schema": "{}",
    "yaml_source": "apiVersion: yuzu.io/v1alpha1\nkind: InstructionDefinition\n..."
  },
  "meta": { "api_version": "v1" }
}

404 if no definition has that id. 503 if the instruction store is unavailable or the read fails (A4 envelope, retry_after_ms: 5000).


GET /api/v1/instructions/{id}/export

(#4029) Export a single instruction definition as its full JSON document (every field, including the operator-authoring-only fields concurrency_mode/platforms/min_agent_version/required_plugins/readable_payload/visualization_spec/response_templates_spec). Twin of the legacy GET /api/instructions/{id}/export route and the new MCP export_definition tool — all three now call the same builder (instruction_definition_export_json), which InstructionStore::export_definition_json itself delegates to as well. Diverges from the legacy route on an unknown id: the legacy route returns 200 "{}" (a store-export quirk, never fixed since the field it checks was designed around “found but empty”); this route resolves the definition first and returns a real 404.

Permission: InstructionDefinition:Read

404 if no definition has that id. 503 if the instruction store is unavailable or the read fails (A4 envelope, retry_after_ms: 5000).


Product Packs

(#4029, api-parity Batch A) A product pack is a bundle of InstructionDefinition/PolicyFragment/Policy/Workflow documents installed together (POST /api/product-packs, legacy — unversioned, unaffected by this issue). These two routes are read-only twins of the legacy GET /api/product-packs[/{id}] routes and of new MCP list_product_packs/get_product_pack tools, all three sharing one builder per shape (product_pack_model.hpp) so they cannot drift from each other by construction.

Prerequisite fix (#4029): ProductPack is used as the RBAC securable string gating every one of these routes, but was never seeded into RBAC’s securable-types catalogue before this issue — no role, not even Administrator, could be granted ProductPack:* while RBAC was enabled. Fixed as part of this issue: ProductPack is now seeded with full CRUD granted to Administrator, and Read additionally granted to Operator/PlatformEngineer/Viewer (the same population that already holds InstructionDefinition:Read).

GET /api/v1/product-packs

List installed product packs.

Permission: ProductPack:Read

Query parameters: name, limit (default 100).

503 on a genuine DB/lease fault (A4 envelope, retry_after_ms: 5000) — a validation/business-rule error is 400, never retryable.

Response:

{
  "data": [
    {
      "id": "pack-abc123",
      "name": "windows-baseline",
      "version": "1.0.0",
      "description": "Baseline Windows fleet content",
      "item_count": 3,
      "items": [
        { "kind": "InstructionDefinition", "item_id": "def-1", "name": "hardware.cpu-info" }
      ],
      "installed_at": 1742385600,
      "verified": true
    }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

GET /api/v1/product-packs/{id}

Get a single installed product pack’s detail, including the pack’s own yaml_source (the full multi-document bundle) and each item’s own yaml_source.

Permission: ProductPack:Read

503 on a genuine DB/lease fault (A4 envelope, retry_after_ms: 5000) — a validation/business-rule error is 400, never retryable.

Response:

{
  "data": {
    "id": "pack-abc123",
    "name": "windows-baseline",
    "version": "1.0.0",
    "description": "Baseline Windows fleet content",
    "yaml_source": "apiVersion: yuzu.io/v1alpha1\nkind: ProductPack\n...",
    "items": [
      {
        "kind": "InstructionDefinition",
        "item_id": "def-1",
        "name": "hardware.cpu-info",
        "yaml_source": "apiVersion: yuzu.io/v1alpha1\nkind: InstructionDefinition\n..."
      }
    ],
    "installed_at": 1742385600,
    "verified": true
  },
  "meta": { "api_version": "v1" }
}

404 if no product pack has that id.


Response Templates

Named response-view configurations attached to an InstructionDefinition — column subset, sort order, and filter presets the dashboard’s filter-bar dropdown surfaces (issue #254, Phase 8.2). Storage is the response_templates_spec JSON array column on instruction_definitions; the __default__ template is synthesised on read from spec.result.columns (or the plugin’s column schema) and never persists.

GET /api/v1/definitions/{id}/response-templates

List all response templates for the definition. The synthesised __default__ is auto-prepended when no operator template is marked default.

Permission: InstructionDefinition:Read

Response:

{
  "data": [
    {
      "id": "__default__",
      "name": "Default",
      "description": "Auto-generated default view derived from the result schema.",
      "columns": ["PID", "Name", "Path", "SHA-1"],
      "filters": [],
      "default": true
    }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

GET /api/v1/definitions/{id}/response-templates/{template_id}

Fetch a single template. The reserved id __default__ always returns the synthesised default (even when an operator default exists).

Permission: InstructionDefinition:Read

POST /api/v1/definitions/{id}/response-templates

Create a new template. Like PUT/DELETE below, it is applied only to the definition version it read: a concurrent edit of the definition returns 409. A browser (session-cookie) caller must send a same-site Origin or Referer (403 otherwise); token callers are exempt. Returns the canonicalised template (with auto-assigned id when omitted) and 201.

Permission: InstructionDefinition:Write

Body:

{
  "name": "Failures only",
  "columns": ["Severity", "Title"],
  "sort": {"column": "Severity", "dir": "desc"},
  "filters": [{"column": "Severity", "op": "equals", "value": "high"}],
  "default": false
}
FieldTypeRequiredDescription
idstringNoAuto-generated if omitted. The reserved id __default__ is rejected.
namestringYesOperator-facing label. Max 200 characters.
descriptionstringNoOptional longer description.
columnsarray of stringsNoSubset of plugin column names. Empty / omitted means “show all”.
sortobjectNo{column: <name>, dir: asc|desc}. dir requires column.
filtersarray of objectsNo{column, op, value} clauses. op ∈ equals, not_equals, contains, starts_with, ends_with.
defaultbooleanNoAt most one operator template may be marked default per definition.

Body size cap. POST and PUT bodies are capped at 64 KiB. Larger bodies receive a 413 response and a failure-audit emission with reason=body_too_large before parsing — preventing operator-tier JSON-bomb DoS against the single-process server.

Errors:

StatusReason
400invalid JSON / missing name / unknown filter op / id collision / multiple default templates / __default__ as authored id
404Definition not found
413Body exceeds 64 KiB cap
500Persist failure (rare; see server logs)
503Service unavailable

PUT /api/v1/definitions/{id}/response-templates/{template_id}

Replace the named template in place.

Permission: InstructionDefinition:Write

Returns 400 when template_id is __default__ (the synthesised default cannot be overwritten). Same 64 KiB body cap as POST.

Errors:

StatusReason
400malformed id / __default__ reserved / invalid JSON / validation failure
404Definition or template not found
413Body exceeds 64 KiB cap
500Persist failure
503Service unavailable

DELETE /api/v1/definitions/{id}/response-templates/{template_id}

Remove a template. Returns 400 when template_id is __default__.

Permission: InstructionDefinition:Write

Errors:

StatusReason
400malformed id / __default__ reserved
404Definition or template not found
500Persist failure
503Service unavailable

Audit events emitted: response_template.create, response_template.update, response_template.delete — target type InstructionDefinition, target id = definition id, detail = template id (success) or reason=<r> (audit-path failure). 4xx branches emit result=denied; 500 persist failures emit result=failure; the 503 store-unavailable branch emits result=error (an infra degrade is not an operator denial — ADR-0058). See audit-log.md for the full reason vocabulary.


Audit Log

Query the server audit trail. All state-changing operations are recorded with the acting principal, action, target, and result.

Reads deny on store degrade (ADR-0040). The audit trail is the SOC 2 evidence chain, so the audit query endpoints (GET /api/v1/audit, GET /api/v1/audit/auth-sample, and the legacy GET /api/audit) return 503 when the underlying store or connection pool is unavailable — never an empty 200. A reviewer, SIEM, or CMDB integration can therefore never mistake an infrastructure blip for “no audit activity”; treat a 503 here as a transient, retryable evidence-availability gap, not as an empty result set. This is the read-side counterpart to the fail-hard write posture (503 + Sec-Audit-Failed on the behavioural-PII routes).

Who may read the trail. Every audit read surface — GET /api/v1/audit, GET /api/v1/audit/auth-sample, the legacy GET /api/audit, GET /api/v1/engine-principals/audit/no-admin, and the MCP query_audit_log / audit_engine_no_admin tools and yuzu://audit/recent resource — is gated on AuditLog:Read. With RBAC disabled (the shipped default) only an admin-role session may read the trail (#5771): AuditLog:Read is in the authorization topology floor, so any other authenticated session is refused with 403 and the denial is audited with a detail beginning topology floor: non-admin role denied AuditLog:Read (an MCP-tier token’s row appends (mcp_tier=<tier>), so match on the prefix). With RBAC enabled the permission is an ordinary grant: the seeded Administrator, PlatformEngineer, Operator, ITServiceOwner and Viewer roles hold it, and any custom role can be granted it. A holder reads every row; the audit surfaces are not confined to a management group.

GET /api/v1/audit

Query audit events.

Permission: AuditLog:Read

Query parameters:

ParameterTypeRequiredDescription
limitintegerNoMax events to return (default 100, max 1000)
offsetintegerNoRows to skip, 0..10000 (default 0)
principalstringNoFilter by acting user
actionstringNoFilter by action name
target_typestringNoFilter by target type (exact match)
target_idstringNoFilter by target id (exact match); requires target_type
sinceintegerNoInclusive lower bound, epoch seconds. Omit for no bound; 0 is accepted and matches the same rows as omitting it. Negative is 400
untilintegerNoInclusive upper bound, epoch seconds. Omit for no bound; 0 or negative is 400

Response:

{
  "data": [
    {
      "id": 4101,
      "timestamp": 1710849600,
      "principal": "admin",
      "action": "management_group.create",
      "result": "success",
      "target_type": "ManagementGroup",
      "target_id": "a1b2c3d4e5f6",
      "detail": "EU Production Servers"
    },
    {
      "id": 4102,
      "timestamp": 1710849700,
      "principal": "admin",
      "action": "tag.set",
      "result": "success",
      "target_type": "Tag",
      "target_id": "agent-eu-prod-01:environment",
      "detail": "production"
    },
    {
      "id": 4103,
      "timestamp": 1710849800,
      "principal": "jane.ops",
      "action": "quarantine.enable",
      "result": "success",
      "target_type": "Security",
      "target_id": "agent-compromised-01",
      "detail": "Suspicious network activity detected"
    }
  ],
  "pagination": { "total": 3, "start": 0, "page_size": 100 },
  "meta": { "api_version": "v1" }
}

Paging. total is the number of rows in this response’s data array, not the number of rows that matched; start echoes offset and page_size echoes limit. A page shorter than limit is the last page. Rows are ordered newest first, and rows with an identical timestamp break by id (newest first). On an unchanging result set offset paging neither repeats nor skips rows; the log is live, though, so a new event (or a retention delete) between two page fetches shifts later pages. Pin the window with a fixed until in the past and use id to de-duplicate across pages. offset is capped at 10000 and is never clamped: a larger value is a 400. To read past that depth, set until to the oldest timestamp already seen (it is inclusive), restart at offset=0, and de-duplicate on id.

Rows carry id, timestamp, principal, action, result, target_type, target_id and detail. principal_role and source_ip are returned only by the legacy GET /api/audit; the MCP query_audit_log tool returns the same row set as this route.

Errors: 403 — caller lacks AuditLog:Read; 400 — limit is not one base-10 integer or is less than 1, since is negative or not an integer, until is 0, negative or not an integer, since is greater than until, offset is not an integer in 0..10000, a string filter holds a NUL or invalid UTF-8, or target_id is given without target_type (the index leads with target_type, so a bare target_id would scan the table); 503 — the audit store is unavailable or the query degraded (ADR-0040 deny-on-degrade — see the note above this section; never a false-empty 200).

GET /api/v1/audit/auth-sample

Pseudo-random sample of authentication-surface audit events over an optional time window — for SOC 2 CC7.2 sampled-evidence export (auditor pulls a representative sample of auth activity rather than the full log). Scoped to the auth., mfa., session., and engine_principal. action prefixes (logins, MFA, session revocation, lockout, admin-gate denials, and every engine-principal lifecycle/credential/audit event, since engine-principal identities are themselves authentication-surface — a distinct principal class alongside human users, not general application activity). Rows are returned in random order so a bounded limit is a sample across the window, not just the latest N.

Sampling caveat (read before using as formal evidence). The sample is drawn from at most the 10000 most-recent matching events in the window. When the window holds more than that, the sample is recency-biased — it is a uniform sample of the newest 10000 events, not of the full window. The response sampling object reports this: candidates_considered (pool size the sample was drawn from), scan_cap (10000), and recency_capped (true when the pool hit the cap). For a uniform sample of a high-volume period, narrow the from/to window so it holds ≤ 10000 events. Samples are also non-reproducible (no seed) — re-running yields a different draw; the audited audit.auth_sample.exported row is the chain-of-custody record of what was pulled.

Permission: AuditLog:Read — with RBAC enabled this is deliberately not admin-only, so a dedicated read-only auditor role can pull evidence without full admin (separation of duties). With RBAC disabled it is admin-only (#5771; see the note at the top of this section).

The export is itself audited as audit.auth_sample.exported (who pulled which window, and how many rows) so evidence access stays on the audit chain.

Query parameters:

ParameterTypeRequiredDescription
fromintegerNoWindow start, epoch seconds, digits only (omit for unbounded; 0 is accepted and matches the same rows as omitting it)
tointegerNoWindow end, epoch seconds, digits only (omit for unbounded; to=0 is 400, 0 is not a way to say unbounded)
limitintegerNoSample size (default 100, max 1000)
curl -s -G \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "from=1717200000" \
  --data-urlencode "to=1719792000" \
  --data-urlencode "limit=50" \
  "https://yuzu.example.com/api/v1/audit/auth-sample" | jq .

Responses: 200 sampled list — same envelope and event field set as GET /api/v1/audit (timestamp, principal, action, result, target_type, target_id, detail); the sample deliberately does not widen what AuditLog:Read discloses (no session_id / source_ip). The envelope additionally carries a sampling object (candidates_considered, scan_cap, recency_capped). Errors: 403 — caller lacks AuditLog:Read; 400 — from/to are not non-negative digits, from > to, or limit is non-integer (unlike the sibling GET /api/v1/audit, a non-numeric limit here IS a 400, not a silent fallback); 503 — the audit store is closed (checked up front, before any query parameter is even parsed) or the query itself degraded mid-request. If the export’s own audit row fails to persist, the response carries a Sec-Audit-Failed: true header (the export still returns).

Audit action names:

ActionDescription
audit.auth_sample.exportedAuth evidence sample pulled via GET /api/v1/audit/auth-sample. target_type=AuditLog, target_id=auth-sample; detail carries from=<epoch-or-0> to=<epoch-or-0> limit=<N> returned=<N>. result=success.
management_group.createGroup created
management_group.updateGroup updated (rename, re-parent, membership type change)
management_group.deleteGroup deleted
management_group.add_memberAgent added to group
management_group.remove_memberAgent removed from group
management_group.assign_roleRole assigned on group
management_group.unassign_roleRole removed from group
api_token.createAPI token created
api_token.revokeAPI token revoked. Can carry result=success (token was revoked) or result=denied (a non-owner without the admin role attempted a cross-user revoke). Denied events include detail=owner=<real owner> so forensics can tell a legitimate self-revoke from an enumeration probe.
user.createLocal account created. result ∈ {attempted, success, denied}; attempted is written first and its failure refuses the create (503, Sec-Audit-Failed). Denied detail values: duplicate_username (409 — attempted create on an existing name), weak_password (400 — fewer than 12 bytes), too_long (400 — more than 1024 bytes, #5342), create_refused (409 — a policy-valid password was refused by the store: the name still belongs to a removed account, or the user store did not answer), invalid_username (400 — the name fails the allowlist or is made only of dots). The role field is ignored on create — new users always land as user. To change role, use the dedicated POST /api/settings/users/{username}/role endpoint (audit action user.role_change).
user.role_changeLocal account role changed via POST /api/settings/users/{username}/role. result ∈ {attempted, success, denied, no_op, failure, partial}. Denied detail values: content_type_not_json (415 — the body is not application/json), self_role_change_blocked (403), elevated_session_role_change_blocked (403), invalid_username (400), invalid_json (400), missing_role (400), invalid_role (400), user_not_found (404), last_administrator_blocked (409), inactive_promotion_refused (409). result=failure with detail store_failure (503, no retry hint) when the guarded write did not complete (this replaces the former denied / db_failure 500), and result=partial with detail role_changed session_wipe_failed=true old_role=<role> new_role=<role> (503, no retry hint) when the role changed but session cleanup failed. inactive_promotion_refused is a store-level rule: the route answers user_not_found (404) for an inactive account, so the 409 can occur only when the account is deactivated between the route’s lookup and the write. no_op detail format same_role={admin|user} (200) when the requested role equals the current role - recorded so compliance review can distinguish operator intent from inaction. Success detail format old_role=user,new_role=admin.
user.deleteLocal account deleted. result ∈ {attempted, success, denied, failure, partial}. Denied detail values: self_delete_blocked (403), invalid_username (400), user_not_found (404), owns_active_engine_principal (409), last_administrator_blocked (409, nothing revoked: the early form). The LATE form is result=partial (credentials were revoked and the account stays active), detail last_administrator_blocked credentials_revoked_before_refusal=true account_remains_active=true followed by the revoke summary. failure detail store_failure ... (503), or identity_link_resolution_failed / api_token_store_unavailable; partial detail account_deactivated session_wipe_failed=true ... (503) or the revoke summary with api_tokens_db_error=true (500).
user.password_changeLocal account changed its own password via POST /api/v1/users/me/password (#5342). target_type=User, target_id=<caller>; the principal is the caller on every row, including those written after its own session was revoked. result ∈ {ok, denied, error}. Success detail self_service sessions_revoked=<N> provisional_mfa_cleared=<true|false>, written in the same transaction as the change (no separate session.revoke_all row). Denied/error detail tokens: token_session, not_local, csrf, wrong_current, account_locked, sso_only_local_disabled, verify_transient, not_found, conflict, store_unavailable, audit_unavailable. A malformed body, a password outside 12–1024 bytes or a wrong Content-Type is not audited. Never carries the password or its length.
user.password_resetAdmin reset another local account’s password via POST /api/v1/users/{name}/password (#5342). target_type=User, target_id=<target>, principal = the caller. result ∈ {ok, denied, error}. Success detail admin_reset target_role=<admin|user> sessions_revoked=<N> provisional_mfa_cleared=<true|false> (no separate session.revoke_all row), plus — only when a lockout existed — an auth.lockout.cleared row (result=ok, detail=password_reset), both in the same transaction as the reset. Denied/error detail tokens: token_session, csrf, durable_admin_required, admin_gate_unavailable, self_target, break_glass_target, not_found, not_local, store_unavailable, audit_unavailable. A malformed username or body, a password outside 12–1024 bytes or a wrong Content-Type is not audited.
auth.admin_requiredCentralised denial event emitted by AuthRoutes::require_admin on every privileged-endpoint 403. target_type=endpoint, target_id={req.path}. SOC 2 CC7.2 evidence chain — captures rejected attempts that previously surfaced only in the request log. Exception: an interactive cookie session (local, OIDC or SAML) refused on an auto-loading dashboard panel fragment (the Access Users panels, and the three enrolment fragments GET /fragments/settings/tokens, /pending and /auto-approve on Settings and on Groups and enrolment) writes no row; token and engine callers always do, and so does every refused write.
auth.lockout.appliedAccount locked after --auth-lockout-threshold consecutive failed local-password logins (SOC 2 CC6.3). Emitted once at the threshold crossing — not once per blocked attempt (those are tracked only by yuzu_auth_lockout_blocked_total to avoid audit flooding). result=ok (the lock was applied; the warning severity is carried by the metric + analytics event, not the audit result), target_type=User, detail=threshold=<N> window_secs=<S>.
auth.lockout.clearedAccount-lockout counter reset. result ∈ {ok, error}, target_type=User. detail=admin_unlock for POST /api/v1/users/{name}/unlock, reset_on_successful_login when the user’s next successful login clears a non-zero counter, reset_on_password_change when POST /api/v1/users/me/password verifies the current password, or password_reset when POST /api/v1/users/{name}/password clears an existing lockout (written in the same transaction as the reset; never written when there was nothing to clear).
execution.detail.fetchGET /api/v1/executions/{id} (REST v1 single-execution lookup). result=denied only (#1634) on the BARE route (no include) — emitted on the 404 branch, execution genuinely nonexistent OR out of the caller’s management-group scope, non-distinguishing detail text; there is no result=success row on the bare route’s 200 path. As of #4030, this no longer holds for the whole URL pattern: ?include=agents on this same route, and the sibling GET /api/v1/executions/{id}/responses, both DO write result=success rows under this identical verb name on their 200 path (see docs/user-manual/audit-log.md’s execution.detail.fetch row for the full, current three-way split).
execution.live_subscribeServer-Sent Events subscribe to /sse/executions/{id}. result ∈ {success, denied}. success emitted on every successful subscribe (no per-session-per-execution dedup currently — see #700). denied emitted (#1634) on the 404 branch — execution genuinely nonexistent OR out of the caller’s management-group scope, using non-distinguishing detail text so the two cases stay audit-indistinguishable. The forensic-grade audit on first-load remains on /fragments/executions/{id}/detail’s execution.detail.view.
api.v1.events.subscribeAgentic-first SSE subscribe to /api/v1/events?execution_id=<id> (sprint W5.1). result ∈ {success, denied}. success detail format: correlation_id=req-<hex-ms>-<hex-seq> so SIEM rules can join the audit row to the response’s X-Correlation-Id header. Deliberately separated from execution.live_subscribe so the SIEM can distinguish browser-tier vs agentic-worker consumers. Same no-dedup policy (#700). denied emitted (#1634) on the 404 branch (unknown execution OR out of scope), using non-distinguishing detail text — corrects an earlier version of this row, which predated #1634 and said this 404 branch never audits.
instruction.createInstruction definition created. result ∈ {success, denied, error}. Denied detail value: duplicate_id (409, explicit id already exists). error detail db_error on a genuine InstructionStore DB/lease failure (503, ADR-0058) — distinct from denied, since an infra degrade is not an operator denial.
instruction.updateInstruction definition updated via PUT /api/instructions/{id}. result ∈ {success, denied, error}. Denied detail value: not_found (404, unknown id). error detail db_error on a genuine DB/lease failure (503). A plain 400 validation error is not audited (matches instruction.create’s equivalent branch).
instruction.deleteInstruction definition deleted via DELETE /api/instructions/{id}. result ∈ {success, denied, error}. Denied detail value: not_found (404, unknown id). error detail db_error on a genuine DB/lease failure (503).
instruction_set.createInstruction set created via POST /api/instruction-sets. Every create first writes result=attempted (before the change; if that row cannot be stored the request is refused with 503 and Sec-Audit-Failed: true and no set is created), then the outcome. result ∈ {attempted, success, denied, error}. success carries target_id=<set id> and the set name as detail. Denied detail values: conflict (409, duplicate id), invalid (400, rejected input). error detail db_error on a genuine DB/lease failure (503). A failed audit write sets Sec-Audit-Failed: true on the response.
instruction_set.deleteInstruction set deleted via DELETE /api/instruction-sets/{id}. Every delete first writes result=attempted (target_id = the set id) and is refused with 503 and Sec-Audit-Failed: true, with nothing deleted, when that row cannot be stored. result ∈ {attempted, success, denied, error}. Denied detail value: not_found (404, unknown id). error detail db_error on a genuine DB/lease failure (503). A failed audit write on success sets Sec-Audit-Failed: true.
instruction.scope_resolution_failedEmitted at dispatch when a from_result_set: reference in the scope cannot be resolved (set absent, TTL-expired, or not owned by the dispatching principal). result=failure. Detail format: INSTRUCTION_SCOPE_RESOLUTION_FAILED command=<command_id> ref=<id-or-alias> reason=.... Fires on all scoped dispatch paths (generic REST, tracked, MCP) and increments the yuzu_scope_resolution_failed_total metric; as of governance M1 (2026-07-29) the entire dispatch is aborted — no devices are targeted, including from other scope atoms — recorded by a paired scope.evaluation_aborted row with reason=owner_check_failed.
scope.evaluation_abortedEmitted when a scoped dispatch is aborted fail-closed before any device is targeted. result=failure. Reasons: db_degraded (a from_result_set:<id> alias/owner/membership read against the result-set store could not answer — ADR-0036 — or a props.<key>/tag:<key> bulk preload against the custom-properties/tag store could not answer — ADR-0045; all abort the same way and share this reason value), owner_check_failed (a referenced set is absent, expired, or not owned — paired with per-ref instruction.scope_resolution_failed rows; as of #4981 PR-1 this ALSO fires, with its own paired per-ref row, when the set was deleted in the window between the pre-dispatch owner-check gate and the registry’s own later membership read — a TOCTOU case the pre-#4981 code could not detect at all, see ADR-0036’s 2026-09-26 Update), principal_unresolved (a tracked/MCP dispatch could not recover the dispatching operator), presence_degraded (#4981 PR-1 — the cross-replica presence read backing fleet-wide scope evaluation could not answer), unresolvable (a from_result_set:/props.<key> atom referenced a store that was never wired at this call site — unreachable on the real production dispatch ladder, both stores are always wired there, but a defensively-labelled case rather than a misleading db_degraded). target_type on the audit row is result_set for owner_check_failed (the only reason actually about a specific result set) and scope for every other reason. Fires on all three scoped dispatch paths, plus the no-principal tracked-closure guard (principal_unresolved only).
bundle.dispatchLive-query bundle dispatched via POST /api/v1/bundles (ADR-0011). target_type=Execution. result=success (target_id=<bundle-… correlation id>, detail agent=<id> steps=<n>) or result=failure (dispatch threw — target_id empty, detail agent=<id> error=<…>).
bundle.<plugin>.<action>One step of a live-query bundle, emitted per step at dispatch — the device-access lens. target_type=Agent, target_id=<agent_id>. result=dispatched (reached the agent) or result=no_agents (reached zero agents → dispatch_failed on collate). A bundle of N steps emits N of these, so it is exactly as auditable as N separate executions (works-council parity). Emitted on both the REST and MCP surfaces (the per-step verb is transport-agnostic; the MCP tool-call envelope additionally audits as mcp.execute_bundle).
bundle.collateLive-query bundle collated via GET /api/v1/bundles/{id}. target_type=Execution, target_id=<correlation id>. result=success (detail complete=0|1), result=denied (not found or not owned — the 404 covers both an unknown id and a non-owner, so the audit row is where the real reason is recorded), or result=failure (response store degraded — a 503, distinct from denied: the bundle WAS found and owned, the read just could not be served; retryable, retry_after_ms:5000). A collation that serves a Forensics-tier step’s output also writes response.forensics.read with surface=bundle (see response.forensics.read in the Audit log guide).
policy_fragment.createPolicy fragment created. result ∈ {success, denied}. Denied detail value: duplicate_name (409, fragment with the same name already exists).
policy.evaluateCompliance evaluation forced for a policy via POST /api/policies/{id}/evaluate. result ∈ {success, error}. Success detail format execution_id=<id>. Note: the 409 rejection (no check instruction / no matching agents) returns without emitting an audit row; the 503 degraded-evaluation case (policy_evaluator_->evaluate_now returning an error, e.g. an InstructionStore DB/lease failure per ADR-0058, or #4981 PR-1’s scope-evaluation abort) DOES audit, result=error, detail = the specific underlying-cause string (not a fixed literal — see the route’s own 503 documentation) — matches policy.remediate’s own error-vs-denied convention: an infra degrade is not an operator denial.
policy.remediateManual remediation triggered via POST /api/policies/{id}/remediate. result ∈ {success, denied, error}. Success detail execution_id=<id> agents=<n> (agents = delivered count, see the route doc); denied detail carries the reason (e.g. fragment defines no fix instruction, no non-compliant agents, a target is already claimed for remediation, or a target has reached its fix-retry cap for this policy); error is a genuine store/evaluator degrade, distinct from denied.
quarantine.enableDevice quarantined (attempt, success or failure; denied when the dispatch approval check refuses the request, with reason=<label> securable=<name> in the detail, or when the request’s two session look-ups named different principals, with authenticated principal changed during the request now=<account> in the detail)
quarantine.disableDevice released from quarantine (attempt, success or failure; denied in the same two cases as quarantine.enable)
ca.cert.issuedInternal CA signed a per-agent client certificate at enrollment. target_type=AgentCertificate, target_id=<serial>, result=success. Also emitted by POST /api/v1/ca/issue-code-signing (gap-matrix #10) / MCP issue_code_signing_cert: target_type=CodeSigningCertificate, target_id=<label> on a rejected request or <serial_hex> on success, detail carries purpose=code-signing; result=success, result=denied (no CA root, or a bad/unparseable CSR), or result=failure (a genuine 5xx — key-load, signing, or ca_store write failure).
ca.cert.revokedCertificate revoked via POST /api/v1/ca/revoke. target_type is derived from the revoked cert’s purpose — AgentCertificate for an agent leaf, CodeSigningCertificate for a code-signing leaf (gap-matrix #10), or the neutral Certificate when the serial is not found or the ca_store read fails; target_id=<serial>. result=success; result=denied with detail="serial not found or already revoked" for an unknown/already-revoked serial (reject without state change, matches every destructive sibling); result=failure (ADR-0053) for a genuine ca_store DB/lease error — kept distinct from denied so a database outage is never audited as a rejected revoke attempt.
ca.crl.publishedtarget_type=Security. Written on every internal-CA CRL (re)publish — an operator revoke, a subordinate-CA import (REST/MCP/dashboard), or one of three system-initiated triggers (startup, freshness, self-heal); target_id and principal vary by trigger. result=success, or result=failure when the CRL could not be rebuilt/recorded (the revocation still stands; the public CRL is momentarily stale). See docs/user-manual/audit-log.md’s ca.crl.published row for the full trigger/target_id/reason= breakdown (#4829).
ca.root_csr.exportedThe install CA’s CSR was exported via GET /api/v1/ca/root-csr (subordinate-CA setup). target_type=CaRoot, target_id=root. result=success, or result=failure if generation failed.
ca.subordinate.importedAn enterprise-signed intermediate was imported via POST /api/v1/ca/import-chain (or the dashboard wrapper). target_type=CaRoot, target_id=root. result=success on a validated switch to subordinate mode; result=denied when the uploaded material is rejected (not a CA / wrong key / does not chain); result=failure on a server-side persistence error. detail carries reason=... on rejection and via=dashboard for the panel path.
tag.setTag created or updated
tag.deleteTag deleted

Access Reviews

Periodic access reviews (SOC 2 CC6.2) — a fleet-wide export of every principal’s current role grants, plus a durable attestation-campaign workflow so a reviewer (manager/security) can sign off on each grant or flag it for revocation. Covers all three RBAC principal types: user, group, and engine.

#2225 chokepoint note. These routes are deliberately gated on a GLOBAL AccessReview:Read/AccessReview:Attest, not the ADR-0017 authorize_list_read admit-then-filter confinement chokepoint every new per-agent list read must use. A management-group-confined slice of the grant population would be useless as CC6.2 evidence — a reviewer certifying the fleet must see the entire grant population, not one operator’s confined view of it. Access review is intentionally admin/auditor-only and unconfined by design, not an oversight. See docs/auth-architecture.md “Periodic access reviews” for the full rationale. AccessReview is a dedicated securable type (not a reuse of AuditLog) seeded only to Administrator and the Reviewer role — an earlier round of this feature gated it on AuditLog:Read/AuditLog:Attest instead, which over-disclosed the full grant population to the Operator and PlatformEngineer roles (both seeded AuditLog:Read for unrelated reasons, neither seeded UserManagement:Read/EnginePrincipal:Read, the permissions gating the equivalent-sensitivity /rbac/roles and /engine-principals/{id}/roles routes — the latter moved off the over-broad Security:Read in #2376). See docs/security-reviews/access-reviews-2026-07-21.md “#2225 round 2” for the finding and fix.

Engine-session denial, every route including reads. Every route on this surface — GET/list, GET .../export, GET .../{id}, and every mutating route — structurally denies a caller whose own session is engine-classed (principal_kind="engine" or auth_source="engine_token", the same deny_engine_session belt the engine-principals surface uses): an engine principal can never enumerate, read, or mutate any access-review campaign or pull the export, not even one it opened itself via an automation flow. A denied engine-classed caller gets 403; the corresponding audit verb is recorded with result=denied and detail="engine_session_denied".

Grant-table-driven completeness. The export and every campaign freeze are driven by the grant table, not a roster walk — a principal with zero role grants produces no row (the export answers “who currently has access”, not “who exists”), while a grant belonging to a principal that matches nothing in any roster (a since-deleted user, a stale IdP row, an OIDC/SSO principal minted outside every roster) is surfaced as a row with source="orphan", lifecycle_state="unknown", display_name equal to the raw principal_id — never silently dropped. See docs/auth-architecture.md “Periodic access reviews” → “Grant-table-driven completeness” for the full rationale and the non-atomic cross-substrate read caveat.

GET /api/v1/access-reviews/export

Stateless cross-principal grant export — every user/group/engine-principal’s direct role grants right now (user rows do not include group-inherited access; that access appears on the group’s own row). ?format=json|csv (default json).

Permission: AccessReview:Read.

The export is itself audited as access_review.exported.

Query parameters:

ParameterTypeRequiredDescription
formatstringNojson or csv (default json)

Responses: 200 — JSON: data[] of {principal_type, principal_id, display_name, owner_or_email, roles[], effective_permission_count, last_activity_ms, last_activity_kind, classification, lifecycle_state, source}, plus a top-level rbac_enforcement field ("enabled" | "disabled" | "degraded" — see below). CSV: the same row fields with a Content-Disposition: attachment header, plus an unconditional leading metadata line — # rbac_enforcement=<enabled|disabled|degraded> — before the header row. This line is present even when the grant population is empty, so “no grants” is never ambiguous with “the stamp was omitted”; a consumer treating the CSV as strict single-header tabular data skips this first line before the real header. The CSV is the retained, offline evidence artifact an auditor pulls for a hand-off — the stamp travels with the file itself, not only this route’s JSON form. 400 if format is not json/csv. 403 without AccessReview:Read (or an engine-classed caller — see the note above). 503 — fail-loud: a read across users, groups, engine-principals, or tokens failed. This endpoint never returns a silent partial export — an empty 200 always means “no grants”, never “the read partially failed”. If the export’s own audit row fails to persist, the response carries a Sec-Audit-Failed: true header (the export still returns).

rbac_enforcement. Whether RBAC actually governs the grant population above, right now — without this, the export can certify a fleet-wide grant listing that has no bearing on real access control (RBAC ships off by default). Three values, derived from rbac_enforcement_label() (rbac_store.hpp), which mirrors the fail-closed rbac_enforcement_in_effect predicate’s own branch order exactly:

Precedence: a cached-enabled read always wins, even over a stale view. The store checks “is RBAC enabled” first; only when that reads OFF does it go on to ask whether the OFF reading itself is fresh. So a store that is cached ON but whose refresh has gone stale still reports enabled here — it never falls through to degraded merely because the view is old (gates deny in both cases either way, so this costs nothing on the authorization side; it only affects which of these three labels an evidence export shows).

ValueMeaning
enabledRBAC reads as ON right now (a cached ON always reports this, live or stale) — grants above are enforced.
disabledRBAC reads as OFF, and that OFF reading is confirmed fresh (not stale) — grants above are not enforced; any authenticated session gets the legacy fallback.
degradedThe store could not confirm a genuine OFF: it’s unreachable/unopened, or it reads OFF from a STALE cached view — gates deny defensively in this state, same as enabled, but this is not an administrator’s deliberate choice to turn RBAC on. Never misread degraded as “ungoverned”.

The frozen campaign row (POST /api/v1/access-reviews below) carries the same field, stamped once at open — never re-derived on a later read, and (per this feature’s no-prune retention) permanently — a degraded or stale-cached-enabled reading recorded during a partition is not self-correcting on a closed campaign the way the live export is on its next pull. To infer whether a given reading was actually fresh, correlate against yuzu_server_rbac_read_degrade_total{reason=~"generation_refresh_failed.*"} around the pull/open time. Full discussion, including why this never affects actual authorization (gates deny on the identical degraded view independently of this stamp): rbac.md → “The access-review export’s rbac_enforcement stamp inherits this same degrade-vs-outage ambiguity”.

CSV formula-injection neutralization. Every CSV data-row field is RFC-4180 escaped, and any field whose first byte is one Excel/Sheets treats as a formula trigger (=, +, -, @, tab, CR) is additionally prefixed with a literal ' before the RFC-4180 quoting pass (CWE-1236). Several fields on this export are influenceable by an external identity provider (SCIM userName, an engine principal’s display_name) — this is not a theoretical input, and the neutralization applies unconditionally to every row, orphan rows included. The leading # rbac_enforcement=... metadata line is exempt — its value is always one of the three fixed literals above, never externally influenced.

Known scoping notes (accurate as shipped, not defects to “fix” reflexively): last_activity_kind is "n/a" for every user row today — AuthDB has no read accessor for last-login yet (a noted follow-up); source="scim" for users is forward-compat and not yet populated by any write path; an orphan row (grant with no matching roster entry — see “Grant-table-driven completeness” above) always carries last_activity_kind="n/a" and classification="" too, since there is no roster row to enrich it from. A disabled user who still holds a live grant is a normal, roster-matched row — source="user", lifecycle_state="disabled" — never confused with an orphan row; it is exactly the kind of finding a periodic access review exists to surface.

GET /api/v1/access-reviews

List every review campaign’s metadata (not its attestations — use GET /api/v1/access-reviews/{id} for those), newest-first, capped at the most recent 500. The surface an auditor needs to prove reviews ran on cadence without already knowing a campaign_id out-of-band.

Permission: AccessReview:Read.

Responses: 200 — {data: [{campaign_id, title, status, created_by, created_at_ms, closed_by, closed_at_ms, rbac_enforcement}], meta}. rbac_enforcement ("enabled" | "disabled" | "degraded" — see GET .../export above) is the fleet’s RBAC state at open, frozen with the rest of the campaign; "" for a campaign opened before this field existed. 403 without AccessReview:Read (or an engine-classed caller). 503 — the access-review store is unavailable, or a genuine read failure.

Audited as access_review.list.

POST /api/v1/access-reviews

Open a review campaign — freeze the current cross-principal grant population (the same rows GET .../export returns, expanded to one row per (principal, role) grant) into a new, durable campaign. A grant created after this call returns is out of scope for this campaign (review it in the next one); a grant revoked afterward stays reviewable — the frozen row is not re-derived from live state. This freeze-at-open property is what makes “every grant that existed when the campaign opened has a reviewable row” provable rather than assumed. The fleet’s RBAC enforcement state (enabled/disabled/degraded, see GET .../export above) is stamped onto the campaign at this same moment — see GET /api/v1/access-reviews/{id} below.

Permission: AccessReview:Attest.

Request body:

{ "title": "Q3 2026 Access Review" }
FieldTypeRequiredDescription
titlestringYesHuman-readable campaign name

Responses: 201 — {campaign_id, grant_count}. 400 — invalid JSON, or title missing/empty. 403 without AccessReview:Attest (or an engine-classed caller), or for a browser (session-cookie) request that carries no Origin/Referer naming this site (audited as csrf.denied; API-token callers are exempt). 415 — Content-Type is not application/json. 503 — the access-review store is unavailable, or the grant-population read failed.

Audited as access_review.campaign_opened (detail carries grants=<N> rbac_enforcement=<value> on success).

GET /api/v1/access-reviews/{id}

Full evidentiary state of one review campaign: metadata plus every frozen attestation row (pending/attested/flagged_revoke) plus pending_count (the review-completeness number).

Permission: AccessReview:Read.

Responses: 200 —

{
  "campaign": {
    "campaign_id": "...", "title": "...", "status": "open",
    "created_by": "alice", "created_at_ms": 0,
    "closed_by": "", "closed_at_ms": 0,
    "rbac_enforcement": "enabled"
  },
  "attestations": [
    {
      "principal_type": "user", "principal_id": "bob", "role_name": "Operator",
      "decision": "pending", "reviewer": "", "decided_at_ms": 0,
      "justification": "", "grant_snapshot": { "display_name": "bob", "...": "..." }
    }
  ],
  "pending_count": 1
}

403 without AccessReview:Read (or an engine-classed caller). 404 — no campaign with that id. 503 — the access-review store is unavailable, or a genuine read failure.

Audited as access_review.get.

POST /api/v1/access-reviews/{id}/attestations

Record one reviewer decision against a grant frozen into an open campaign.

Permission: AccessReview:Attest.

Request body:

{
  "principal_type": "user",
  "principal_id": "bob",
  "role_name": "Operator",
  "decision": "attested",
  "justification": "Confirmed still required for on-call rotation."
}
FieldTypeRequiredDescription
principal_typestringYesuser | group | engine
principal_idstringYesPrincipal identifier
role_namestringYesRole being reviewed
decisionstringYesattested | flagged_revoke
justificationstringNoFree-text reviewer note

flagged_revoke records evidence ONLY — it never itself revokes the grant. No RBAC/EnginePrincipal mutation happens on this path; acting on the flag (unassigning the role, revoking the engine principal, etc.) is a separate, explicit operator action performed after reading this evidence. flag ≠ revoke.

Responses: 200 — {recorded: true}. 400 — principal_type, principal_id, or role_name missing, or decision is neither attested nor flagged_revoke. 403 without AccessReview:Attest (or an engine-classed caller), or for a browser (session-cookie) request that carries no Origin/Referer naming this site (audited as csrf.denied; API-token callers are exempt). 404 — no campaign with that id, no such frozen grant in it, or the campaign is already closed. 415 — Content-Type is not application/json. 503 — the access-review store is unavailable, or a genuine write failure.

This route is an UPSERT — a second call for the same (campaign_id, principal_type, principal_id, role_name) overwrites the prior reviewer’s decision, reviewer, and justification. The earlier decision is not retained (mirrors record_attestation’s MCP destructiveHint:true, see docs/mcp-server.md).

Audited as access_review.attested (decision attested) or access_review.flagged (decision flagged_revoke) — the two outcomes are separately countable/alertable, not one verb with a buried field.

POST /api/v1/access-reviews/{id}/close

Close an open review campaign. Does not require every attestation to be decided first — a campaign closed with pending rows still outstanding is itself evidence (an incomplete review), not something this route silently forces to completion.

Permission: AccessReview:Attest.

Responses: 200 — {closed: true}. 403 without AccessReview:Attest (or an engine-classed caller), or for a browser (session-cookie) request that carries no Origin/Referer naming this site (audited as csrf.denied; API-token callers are exempt). 404 — no campaign with that id, or already closed. 503 — the access-review store is unavailable, or a genuine write failure. The route reads no body, so it has no Content-Type requirement.

Audited as access_review.closed.

Audit action names:

ActionDescription
access_review.exportedCross-principal grant export pulled via GET /api/v1/access-reviews/export. target_type=AccessReview, detail=format=<json|csv|view> rows=<N> rbac_enforcement=<value> (view is the dashboard’s Current grants panel, GET /fragments/access/reviews/grants). result=success.
access_review.listCampaign list pulled via GET /api/v1/access-reviews or the dashboard fragment GET /fragments/access/reviews/campaigns. target_type=AccessReview. result=success.
access_review.campaign_openedReview campaign opened via POST /api/v1/access-reviews. target_id=<campaign_id>, detail=grants=<N> rbac_enforcement=<value> on success. result ∈ {success, failure}.
access_review.attestedReviewer recorded decision=attested via POST /api/v1/access-reviews/{id}/attestations. target_id=<campaign_id>:<principal_type>:<principal_id>:<role_name>. result ∈ {success, failure}.
access_review.flaggedReviewer recorded decision=flagged_revoke via the same endpoint. Same target/result shape as access_review.attested — a separate verb so flagged grants are separately countable/alertable. Evidence only — does not revoke.
access_review.closedCampaign closed via POST /api/v1/access-reviews/{id}/close. target_id=<campaign_id>. result ∈ {success, failure}.
access_review.getCampaign evidentiary state read via GET /api/v1/access-reviews/{id} or the dashboard fragment GET /fragments/access/reviews/campaign. target_id=<campaign_id>. result=success.

Every access_review.* verb above additionally records result=denied (detail="engine_session_denied") when the caller’s own session is engine-classed — see the “Engine-session denial” note above. access_review.campaign_opened and access_review.attested also record result=denied, detail=content_type_not_json (empty target_id) when a request is refused with 415.

The three mutating routes (POST /api/v1/access-reviews, POST /api/v1/access-reviews/{id}/attestations and POST /api/v1/access-reviews/{id}/close) are called by the dashboard with a session cookie, so a cookie-authenticated request must carry an Origin (or Referer) naming this site, or it is refused with 403 before the body is read and a csrf.denied row is written (target_type=Endpoint, target_id is the route as written above). A request that authenticates with an API token is not riding a cookie and is not gated. Behind a proxy that rewrites Host, add the dashboard origin to --csrf-trusted-origin.

MCP twins (JSON only — the REST ?format=csv export path has no MCP equivalent): export_access_review, open_access_review, record_attestation, get_access_review, list_access_reviews, close_access_review. See docs/mcp-server.md.


Policy Fragments

Policy fragments are reusable check/fix/postCheck patterns that define compliance checks and auto-remediation actions.

GET /api/policy-fragments

List all policy fragments.

Permission: Policy:Read

Query parameters:

ParameterTypeRequiredDescription
namestringNoFilter by fragment name (substring match)
limitintegerNoMaximum results (default 100)

Response:

{
  "fragments": [
    {
      "id": "frag-abc123",
      "name": "ensure-defender-enabled",
      "description": "Verify Windows Defender is active",
      "check_instruction": "security.defender-status",
      "fix_instruction": "security.enable-defender",
      "post_check_instruction": "",
      "created_at": 1710849600
    }
  ]
}

POST /api/policy-fragments

Create a new policy fragment from YAML.

Permission: Policy:Write

Request body:

{
  "yaml_source": "apiVersion: yuzu.io/v1alpha1\nkind: PolicyFragment\n..."
}

Response (200):

{
  "id": "frag-abc123",
  "status": "created"
}

Response (400): YAML missing required fields, oversized payload, invalid CEL compliance expression, or an explicit id that is longer than 128 characters, holds a character other than letters, digits, ., _ and -, or consists only of dots. Body is {"error": "<reason>"}.

Response (409): Returned when a fragment with the same name already exists. Body is {"error": "policy fragment named '<name>' already exists"}. Audit event recorded as policy_fragment.create / denied / duplicate_name. Choose a different name (existing fragments are immutable on rename).

Response (503): Policy store not yet initialized, or a genuine internal store failure on the write (safe to retry — distinct from the 400/409 rejections above, and the internal error string is never included in the body).


DELETE /api/policy-fragments/{id}

Delete a policy fragment.

Permission: Policy:Delete

Response:

{
  "deleted": true
}

GET /api/v1/policy-fragments

REST v1 twin of GET /api/policy-fragments above (api-parity #4034) — same query parameters, same row shape, A4-enveloped. MCP twin: list_policy_fragments.

Permission: Policy:Read

Response:

{
  "data": [
    {
      "id": "frag-abc123",
      "name": "ensure-defender-enabled",
      "description": "Verify Windows Defender is active",
      "check_instruction": "security.defender-status",
      "check_compliance": "result.enabled == true",
      "fix_instruction": "security.enable-defender",
      "post_check_instruction": "",
      "created_at": 1710849600,
      "updated_at": 1710849600
    }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

Response (503): {"error":{"code":503,"message":"policy store not available"},"meta":{"api_version":"v1"}}


Policies

Policies bind fragments to devices via scope expressions, triggers, and management group bindings.

GET /api/policies

List all policies.

Permission: Policy:Read

Query parameters:

ParameterTypeRequiredDescription
namestringNoFilter by policy name (substring match)
fragment_idstringNoFilter by fragment ID
enabled_onlybooleanNoReturn only enabled policies
limitintegerNoMaximum results (default 100)

Response:

{
  "policies": [
    {
      "id": "pol-xyz789",
      "name": "baseline-security",
      "fragment_id": "frag-abc123",
      "scope_expression": "tag:environment = 'production'",
      "enabled": true,
      "management_groups": ["eu-production"],
      "triggers": [{"type": "interval", "config": {"interval_seconds": 300}}],
      "created_at": 1710849600,
      "updated_at": 1710849600
    }
  ]
}

POST /api/policies

Create a new policy from YAML.

Permission: Policy:Write

Request body:

{
  "yaml_source": "apiVersion: yuzu.io/v1alpha1\nkind: Policy\n..."
}

Response (200):

{
  "id": "pol-xyz789",
  "status": "created"
}

Response (400): YAML missing required fields, unknown fragment_id, invalid scope expression, or an explicit id that is longer than 128 characters, holds a character other than letters, digits, ., _ and -, or consists only of dots. Body is {"error": "<reason>"}.

Response (503): A genuine internal store failure on the write — safe to retry, and the internal error string is never included in the body.


GET /api/policies/{id}

Get policy detail including compliance summary.

Permission: Policy:Read

Response:

{
  "policy": {
    "id": "pol-xyz789",
    "name": "baseline-security",
    "fragment_id": "frag-abc123",
    "scope_expression": "tag:environment = 'production'",
    "enabled": true,
    "remediation_available": true,
    "management_groups": ["eu-production"],
    "triggers": [{"type": "interval", "config": {"interval_seconds": 300}}],
    "inputs": [{"key": "severity", "value": "high"}],
    "created_at": 1710849600,
    "updated_at": 1710849600
  },
  "compliance": {
    "compliant": 42,
    "non_compliant": 3,
    "unknown": 5,
    "fixing": 1,
    "error": 0,
    "total": 51
  }
}

DELETE /api/policies/{id}

Delete a policy and all associated compliance data.

Permission: Policy:Delete

Response:

{
  "deleted": true
}

POST /api/policies/{id}/enable

Enable a previously disabled policy.

Permission: Policy:Write

Response:

{
  "status": "ok"
}

Response (400): policy not found. Response (503): a genuine internal store failure on the write — safe to retry, internal error string never included in the body.


POST /api/policies/{id}/disable

Disable a policy, pausing compliance checks.

Permission: Policy:Write

Response:

{
  "status": "ok"
}

Response (400): policy not found. Response (503): a genuine internal store failure on the write — safe to retry, internal error string never included in the body.


POST /api/policies/{id}/invalidate

Invalidate agent-side compliance cache for a specific policy. Resets all agent statuses to pending, forcing re-evaluation.

Permission: Policy:Execute

Response:

{
  "status": "ok",
  "agents_invalidated": 42
}

Response (503): a genuine internal store failure on the write — safe to retry, internal error string never included in the body.


POST /api/policies/invalidate-all

Invalidate compliance cache for all policies across all agents.

Permission: Policy:Execute

Response:

{
  "status": "ok",
  "total_invalidated": 210
}

Response (503): a genuine internal store failure on the write — safe to retry, internal error string never included in the body.


POST /api/policies/{id}/evaluate

Force an immediate compliance evaluation of a policy, ignoring its interval. The server dispatches the bound fragment’s check instruction to the policy’s scope and, once responses arrive (within a short grace window), evaluates the CEL check_compliance per agent and writes compliant / non_compliant / unknown / error to each agent’s status — which is what GET /api/compliance and GET /api/policies/{id} then report. Evaluation is asynchronous: this returns immediately with the dispatch execution_id; the verdicts appear a few seconds later.

Permission: Policy:Execute

Response (202):

{
  "status": "dispatched",
  "execution_id": "polchk-a1b2c3d4e5f60718"
}

Response (404): policy not found. Response (409): the policy’s fragment has no check instruction, the policy matches no agents, or a check for this policy is already in flight. Response (503): either the policy evaluator isn’t wired, a genuine internal store failure occurred while reading the policy or fragment, recording the dispatch claim, or (ADR-0058) resolving the check instruction against InstructionStore, or (#4981 PR-1) the policy’s scope expression could not be evaluated — a result-set reference that failed its ownership check due to a race with a concurrent delete, or a presence-store degradation on a cross-replica scope. The response’s message field carries the specific underlying cause (e.g. “policy store not wired”, “degraded policy read for <id>”, “dispatch claim failed for <id>: <reason>”, or “kickoff_check degraded for <id>: scope evaluation degraded: <reason>”) — it is not a fixed literal, so do not pattern-match an exact string; treat any 503 on this route as safe to retry. One case is deliberately generic rather than specific: a genuine InstructionStore DB/lease failure reports as “…service unavailable” rather than the raw database error text, which can carry connection/schema detail — the raw text is logged server-side instead. A transient failure of any of these kinds is never reported as a 409 (a genuine “matches no agents” result is distinguished from “could not determine whether it matches”).

Audit: policy.evaluate — including on the 503 degraded-evaluation case above (result=error, detail = the same specific cause string as the response message).


POST /api/policies/{id}/remediate

Manually remediate a policy’s non-compliant agents. Only available when the bound fragment defines a fix instruction (the remediation_available flag on the policy detail) — this is the operator-gated “would you like to remediate this?” action; remediation is never automatic. The server marks the targets fixing, dispatches the fix instruction, then runs the postCheck (falling back to check) and writes the verified post-fix verdict.

Permission: Policy:Execute

Request body (optional):

{
  "agent_ids": ["agent-123", "agent-456"]
}

If agent_ids is omitted, every agent currently non_compliant for the policy is remediated.

Response (202):

{
  "status": "remediating",
  "execution_id": "polchk-9f8e7d6c5b4a3021",
  "agents": 2
}

The agents field counts targets the fix was actually dispatched to (delivered). A claimed-but-undelivered target (offline / quarantined / plugin absent) releases its claim without consuming a retry attempt and is excluded from the count, so agents may be smaller than the number of agent_ids requested.

Response (404): policy not found. Response (409): the fragment defines no fix instruction, there are no non-compliant agents to remediate, or a target could not be claimed — either it is already claimed for remediation (a durable per-(policy, agent) claim in PolicyStore, safe across replicas, HA WS-3 3.4) or it has exhausted its fix-retry cap for this policy. The response body message is "remediation already in flight or retry cap reached for this policy". Response (503): either the policy evaluator isn’t wired (“policy evaluation not available”), or a genuine internal store failure occurred while resolving the policy or its remediation targets, including (ADR-0058) InstructionStore resolving the fix instruction (“policy store degraded” / “policy store unavailable”) — safe to retry, and distinguished from the 400/409 business rejections above. When agent_ids is supplied (#4981 PR-1), a 503 is also returned if the policy’s own scope expression — used to confirm each requested agent is actually in-scope before remediating it — could not be evaluated (the same result-set-ownership-race or presence-degradation causes as /evaluate above); the message is "could not determine the policy's scope (\<reason>) — remediation target resolution aborted, not evaluated as empty", distinguishing this from the ordinary 409 “no in-scope agents” case where the scope evaluated fine and genuinely excluded the requested agents. This path does not apply when agent_ids is omitted (the “remediate every non-compliant agent” case reads compliance status directly, not the scope expression).

Audit: policy.remediate (result ∈ {success, denied, error} — error is a store degrade, never a business rejection).


GET /api/v1/policies

REST v1 twin of GET /api/policies above (api-parity #4034) — same query parameters, same row shape, A4-enveloped. MCP twin: list_policies (a narrower 5-field subset of this row shape — id/name/description/enabled/scope_expression only).

Permission: Policy:Read

Response: same data/pagination/meta envelope shape as GET /api/v1/policy-fragments above, wrapping the same per-policy row GET /api/policies documents.


GET /api/v1/policies/{id}

REST v1 twin of GET /api/policies/{id} above (api-parity #4034) — flat top-level fields (not nested under a "policy" key), A4-enveloped. MCP twin: get_policy.

Permission: Policy:Read

Response:

{
  "data": {
    "id": "pol-xyz789",
    "name": "baseline-security",
    "description": "",
    "yaml_source": "apiVersion: yuzu.io/v1alpha1\nkind: Policy\n...",
    "fragment_id": "frag-abc123",
    "scope_expression": "tag:environment = 'production'",
    "enabled": true,
    "remediation_available": true,
    "inputs": { "severity": "high" },
    "triggers": [{ "id": 1, "type": "interval", "config": { "interval_seconds": 300 } }],
    "management_groups": ["eu-production"],
    "created_at": 1710849600,
    "updated_at": 1710849600,
    "compliance": {
      "compliant": 42,
      "non_compliant": 3,
      "unknown": 5,
      "fixing": 1,
      "error": 0,
      "total": 51
    }
  },
  "meta": { "api_version": "v1" }
}

Response (404): {"error":{"code":404,"message":"policy not found"},"meta":{"api_version":"v1"}}


Compliance

Fleet and per-policy compliance status endpoints.

GET /api/compliance

Fleet compliance summary across all active policies.

Permission: Policy:Read

Response (200):

{
  "compliance_pct": 92.5,
  "total_checks": 200,
  "compliant": 185,
  "non_compliant": 8,
  "unknown": 5,
  "fixing": 2,
  "error": 0
}

Response (503): policy store degraded — a genuine internal store failure, distinct from a genuine 0% (no policies enabled yet reads as all-zero counts with 200, never a 503).


GET /api/compliance/{policy_id}

Per-policy compliance detail with per-agent statuses.

Permission: Policy:Read

Response (200):

{
  "summary": {
    "compliant": 42,
    "non_compliant": 3,
    "unknown": 5,
    "fixing": 1,
    "error": 0,
    "total": 51
  },
  "agents": [
    {
      "agent_id": "agent-01",
      "status": "compliant",
      "last_check_at": 1710936000,
      "last_fix_at": 0,
      "check_result": "{\"realtime_protection\": true}"
    }
  ]
}

Response (503): policy store degraded, on either the summary or the per-agent-status read.


GET /api/v1/compliance

REST v1 twin of GET /api/compliance above (api-parity #4034), A4-enveloped. MCP twin: get_fleet_compliance.

Permission: Policy:Read

Response:

{
  "data": {
    "compliance_pct": 92.5,
    "total_checks": 200,
    "compliant": 185,
    "non_compliant": 8,
    "unknown": 5,
    "fixing": 2,
    "error": 0
  },
  "meta": { "api_version": "v1" }
}

GET /api/v1/compliance/{policy_id}

REST v1 twin of GET /api/compliance/{policy_id} above (api-parity #4034), A4-enveloped, plus a policy_id sibling field the legacy route also returns (the legacy route’s own doc example above omits it — pre-existing doc drift, not introduced by this route). Authorization is require_fleet_read (Policy:Read), not a bare permission check — the per-agent agents array is a fan-out read of per-agent data (routed-concerns RBAC row), so a management-group- or service-scope-confined caller sees only the agents visible to it, and summary is tallied from exactly that filtered set, never the store’s unfiltered fleet-wide aggregate. MCP twin: get_policy_agent_statuses (same shape) — get_compliance_summary covers the summary-only half.

Permission: Policy:Read (fleet-read gate)

Response:

{
  "data": {
    "policy_id": "pol-xyz789",
    "summary": {
      "compliant": 42,
      "non_compliant": 3,
      "unknown": 5,
      "fixing": 1,
      "error": 0,
      "total": 51
    },
    "agents": [
      {
        "agent_id": "agent-01",
        "status": "compliant",
        "last_check_at": 1710936000,
        "last_fix_at": 0,
        "check_result": "{\"realtime_protection\": true}"
      }
    ]
  },
  "meta": { "api_version": "v1" }
}

Audit: compliance.agent_statuses.view — fail-closed (503 + Sec-Audit-Failed: true, never a 2xx on an audit-persist miss), matching this API’s general behavioural-PII posture (see Sec-Audit-Failed and the behavioural-PII audit posture above). Unlike the five sibling routes above it, this route’s check_result field carries the output of whatever instruction the bound fragment’s check_instruction names — free-form and operator-authored at fragment-creation time, not a fixed machine-scope shape — so it cannot make the same “not behavioural PII” claim GET /api/v1/inventory/software genuinely can. The MCP twin (get_policy_agent_statuses) is unaffected: it follows MCP’s own convention of surfacing the same gap via an audit_persisted:false body field rather than a header/status code.

Forensics-tier checks. The one case where check_result is not the check’s output: when the check instruction runs a Forensics-tier plugin (app_usage, browser_inventory, execution_artifacts, privacy_permissions, remote_access_audit), the agent’s status is unknown and check_result is empty, because the evaluator does not read or store that plugin’s output. unknown there says nothing about the device. Check output of such a policy is never stored. See “Forensics-tier rows” in the Command/Instruction Responses section.


Runtime Configuration

Runtime configuration endpoints read and update a predefined set of server settings. Every accepted write is persisted immediately; whether it also takes effect immediately depends on the key - see When a change takes effect. In particular, writing an OIDC key through this API does not re-initialise the running OIDC provider.

GET /api/config

Returns current configuration values and any active runtime overrides.

Permission: Infrastructure:Read

Scoped to the caller. The response shows only the overrides the caller could write with PUT /api/config/{key}: for every key the caller would be refused (see Administrator-only keys and the oidc_* rule under PUT /api/config/:key), the stored override is left out, and so is the effective audit_retention_days. An administrator account’s response is the original body, key for key, apart from config.log_level reading warn where it used to read warning, so the Settings pages read it unchanged. An MCP-tier token, a non-administrator role holding Infrastructure:Read, or (for the oidc_* keys) an elevated-only session does not receive the administrator-only overrides, including oidc_admin_group. Unlike GET /api/v1/config, this route does not list what it withheld, so a missing key reads as “not set”; use the v1 route to see withheld_keys. Before this change the route returned every override to every Infrastructure:Read holder.

Replaying this response. Read with a caller that is not an administrator account, the response omits the administrator-only overrides (see Scoped to the caller), so replaying it writes only the keys that caller may write and silently loses the rest; read and write with an administrator account instead. A write of an administrator-only key by a caller that is not an administrator account stops with 403; see Replaying GET /api/config.

Secret values are never returned. For an override whose key holds a credential (today: oidc_client_secret) the value field is omitted entirely and replaced by "is_set": true|false. It is not a placeholder string: a placeholder is a legal value, so a config-as-code or backup-restore client that read this and wrote it back would silently overwrite the real secret. updated_by and updated_at are still returned. PUT /api/config/oidc_client_secret sets it, and its 200 response omits the value too.

Error (503) - runtime config store unavailable. If the store is closed, failed to open, or a read against an otherwise-open store fails (a transient database error), this returns 503 rather than an empty overrides, so a degraded store is never read as “nothing is configured”. This route never decrypts oidc_client_secret to answer GET — is_set is derived from whether a secrets-table row exists for the key, not from its (never-fetched) value — so a corrupted or undecryptable secret cannot 503 this route; that failure mode is confined to server boot instead (see docs/adr/0060-runtime-config-store-postgres-migration.md). Key presence and is_set are still the only way to answer “is the OIDC secret set on this server?” - the question Security hardening sends operators to before deciding whether to rotate.

{ "error": { "code": 503, "message": "runtime config store unavailable" }, "meta": { "api_version": "v1" } }

Response (shape corrected - the handler returns config, overrides, allowed_keys and read_only_keys, not a data/meta envelope):

{
  "config": { "heartbeat_timeout": 120, "log_level": "info" },
  "overrides": {
    "log_level":          { "value": "debug", "updated_by": "admin", "updated_at": 1754150400 },
    "oidc_client_secret": { "is_set": true,   "updated_by": "admin", "updated_at": 1754150400 }
  },
  "allowed_keys": ["heartbeat_timeout", "log_level", "oidc_client_secret"],
  "read_only_keys": ["dex_score_policy"]
}

allowed_keys above is abridged for readability; the real response lists every key in the allowed-keys table. read_only_keys lists the allowed keys this route does not write (see the note under that table). config reports effective values and overrides only the keys with a stored override, so a key can appear in config without appearing in overrides.


PUT /api/config/:key

Update a single runtime configuration value. The key must be one of the allowed runtime-configurable keys.

Permission: Infrastructure:Write, plus a caller rule for two groups of keys. A caller the rule refuses gets 403 (A4 error envelope, with a remediation field and no permission field) and the audit row config.update / denied, with nothing stored and no preceding attempted row. The refusal happens before the runtime-config store is opened and before the request body is parsed, so a refused caller gets 403 even when the store is unavailable or the body is malformed.

Every other key stays writable under Infrastructure:Write alone, including response_retention_days, guardian_event_retention_days, log_level, heartbeat_timeout and auto_approve_enabled, and that includes a supervised-tier MCP-tier token whose creator is an administrator. That is deliberate for now, pending a maintainer ruling.

In production the permission gate runs first. A service-scoped token, and a readonly or operator tier MCP token, are refused by that gate with the audit row auth.permission_required and never reach this rule, so they do not produce a config.update / denied row. The callers that do reach it are a supervised-tier MCP token (even one minted by an administrator) and, with RBAC on, a non-administrator role that holds Infrastructure:Write.

Replaying GET /api/config. A caller that is not an administrator account does not receive the administrator-only overrides (GET /api/config leaves them out; GET /api/v1/config lists them in withheld_keys, which a replay client must treat as indeterminate, not unset). Writing one of those keys with such a caller stops with 403 at the first one it reaches, so keys written before that point stay applied and the rest are not (a half-applied configuration). overrides is a name-sorted object, so which keys come first depends on the order the client replays them in. A config-as-code client should read and replay with an administrator API token that is not MCP-tier, or write the seven keys separately (the Settings pages for the six that have one, PUT /api/config/audit_retention_days with an administrator account for the seventh). Keys listed in read_only_keys (today dex_score_policy) appear in overrides but are refused by both generic write routes with the route-owned 400 for every caller, an administrator included: skip them when replaying, and write the policy through PUT /api/v1/dex/score-policy.

Audit: audit-first (ADR-0033 section 9, no audit row, no mutation). An attempted config.update row is written before anything changes; if it cannot be stored the call answers 503 with Sec-Audit-Failed: true and the key is neither stored nor applied. A success row follows a change, or a failure row a store refusal. If only that outcome row is lost the change stays applied and the 200 carries Sec-Audit-Failed: true (see Sec-Audit-Failed). Each accepted call therefore leaves two rows.

Twins. GET /api/v1/config and PUT /api/v1/config/{param} (below) and the MCP tools get_runtime_config and set_runtime_config read and write the same store. The decisions are one implementation (config_write.hpp): the write class (RuntimeConfigStore::write_class), the caller rule (authz::admin_gate_verdict), the integer-key checks, the live apply, the caller-scoped view and the redacted audit detail. Each surface keeps its own gate call, audit call and envelope, so a further surface that writes these keys must call the shared core, or it reopens the gap this section closes. They differ in more than the envelope: the v1 PUT is MFA step-up gated for a cookie session and this legacy route is not; the v1 PUT and the MCP tool report an effect field and this route does not; the v1 GET and the MCP get_runtime_config also list withheld_keys; and the MCP tool takes string values only, with a 64-character key and a 256-byte value limit.

Dangling attempted row. An attempted config.update row with no success or failure row after it means the outcome is unknown (the server stopped between the two, or the outcome row was lost); read GET /api/config to see what is in force. A failure row for a database error records store error; outcome unknown, because the write may have committed before the acknowledgement was lost.

Cross-site writes. A session-cookie call must carry an Origin or Referer naming this site (or a --csrf-trusted-origin entry), else it is refused 403 cross-site request refused and audited as csrf.denied. API, MCP and engine token callers are exempt. This is the server-wide rule (see Authentication); this route also keeps its own check, which is defence in depth.

Allowed keys (the full set; source of truth is the kKeySpecs table in server/core/src/runtime_config_store.cpp, which pairs every key with its write class, that is, who may write it through this route; see Permission above):

KeyTypeTakes effectDescription
heartbeat_timeoutintegerimmediatelySeconds before an agent is considered offline. Minimum 30 (the default agent heartbeat interval) and maximum 86400 (one day); a value outside that is refused 400 on every surface, because a longer window leaves a dead agent reading online. 30 is a floor, not a recommendation: keep it well above the agents’ own --heartbeat. A value stored outside 30 to 86400 before the bounds existed is moved to the nearest bound at server start (logged), while GET /api/config keeps listing the stored override until you replace it
response_retention_daysintegerstored only, not enforced - see noteDays to retain command response data
audit_retention_daysintegerstored only, not enforced - see noteDays to retain audit log entries. Administrator-only (see Permission); no Settings page writes it
guardian_event_retention_daysintegerstored only, not enforced - see noteDays to retain guaranteed-state events
log_levelstringimmediatelyServer log verbosity (trace, debug, info, warn, error)
auto_approve_enabledbooleannever - see noteAccepted and stored, but no consumer reads it back
plugin_signing_requiredbooleanrecords intent only - see noteServer-side record of the Settings “Require signed plugins” toggle
oidc_issuerstringsee noteOIDC issuer URL
oidc_client_idstringsee noteOIDC client ID
oidc_client_secretstringsee noteOIDC client secret (write-only through this API)
oidc_redirect_uristringsee noteOIDC redirect URI
oidc_admin_groupstringsee noteOIDC admin group ID
oidc_skip_tls_verifybooleansee noteSkip TLS verification to the IdP (test only)
dex_alert_routingstring (JSON array)persisted only - see noteRouted DEX observation types
dex_blast_min_devicesintegerpersisted only - see noteBlast-radius alert threshold (clamped on apply)
dex_blast_window_secondsintegerpersisted only - see noteBlast-radius window
dex_blast_cooldown_secondsintegerpersisted only - see noteBlast-radius cooldown
dex_cohort_export_keystringpersisted only - see noteCohort export tag key; "" disables export
dex_score_policystring (JSON document)typically within 5 secondsThe customer score policy (tier edges and names, deduction weights, scoring model parameters, per-application overrides). Read-only here: a PUT to this route is refused, see below. Write it with PUT /api/v1/dex/score-policy

GET /api/config also returns read_only_keys: the allowed keys that this route lists but does not write. It currently holds dex_score_policy. PUT /api/config/dex_score_policy is refused with a 400 (A4 envelope, no audit row) naming PUT /api/v1/dex/score-policy: the policy is one document that is validated whole by its own route, and a generic single-value write could leave it half-edited. Write the policy through PUT /api/v1/dex/score-policy (and remove it with DELETE).

When a change takes effect

Persisting a value and applying it are separate steps, and this endpoint only does the first for most keys. PUT /api/v1/config/{key} and the MCP tool set_runtime_config report the outcome as effect (immediate, deferred, stored_only) in their answer; the groups below are those three:

Request body:

{
  "value": 180
}

Response (flat, not a data/meta envelope). applied means persisted and accepted - see When a change takes effect for whether the running server is using it yet:

{ "key": "heartbeat_timeout", "value": 180, "applied": true }

For a secret-valued key the response omits value, for the same reason GET /api/config does:

{ "key": "oidc_client_secret", "applied": true }

Removing a stored secret. Send an empty value — {"value": ""} — and GET /api/config then reports "is_set": false for that key. Two things to know before relying on this. It clears only the stored override: an empty stored value is skipped at startup, so if --oidc-client-secret or YUZU_OIDC_CLIENT_SECRET is still set, that value takes effect again on the next restart. And like every write to this route, it does not reach the running OIDC provider — the process keeps using whatever secret it started with until it restarts. Clearing the Settings → OIDC form field does not remove the secret either: a blank field there means “leave the stored value unchanged”.

After clearing, do not save the Settings → OIDC form with the secret field blank. That form falls back to the secret the process is currently holding, rebuilds the live provider with it, and — because the field was blank — does not write the secret back to the store (the form’s other OIDC fields are persisted as normal). The result is the previously loaded secret live in the running process while GET /api/config still reports "is_set": false. If you have cleared the secret and need to change another OIDC field, restart first so the process is not holding the old value.

Two different error sources, two different shapes. The shared authorization gate in front of this route does emit the A4 envelope: a 401, a 403 permission denial, or a 503 authorization-store failure carries correlation_id (echoed on X-Correlation-Id) like any other gated route. Most bodies the handler itself emits do not (the A4 refusals named at the end of this note are the exception) — /api/config is a legacy non-/api/v1 route, and its own errors are either a bare error string or a nested {"error":{"code","message"},"meta":{"api_version"}} object with no correlation_id and no retry_after_ms. Besides those shown below, the handler emits nested bodies for 400 “missing ‘value’ in request body”, 400 “invalid JSON body”, 400 “request body nests too deeply” (over 32 levels, kMcpMaxJsonDepth), and a 503 “runtime config store unavailable” when the runtime-config store is unavailable (GET and PUT now share the identical message; an earlier GET-side wording of “runtime configuration store unavailable” was a drift, not a deliberate distinction, and has been unified). Note 503 and 403 are emitted by both sources, so status alone does not tell you which shape you have: test for error.correlation_id rather than assuming it, on every status. These refusals use the A4 envelope (correlation_id present): 403 cross-site request refused, the 403 for a refused administrator-class write (see Permission; it carries a remediation and no permission field), 503 audit log unavailable; the change was not made (retry_after_ms: 5000, a remediation hint, header Sec-Audit-Failed: true) and the 400 for dex_score_policy (written only through its own route, PUT /api/v1/dex/score-policy; no audit row). An unknown key is a bare 400 and writes no audit row.

Error (503) - runtime config store unavailable. A genuine DB/crypto write failure, distinguished from caller-input validation at the seam so it never returns a 400-shaped body instead:

{ "error": { "code": 503, "message": "runtime config store unavailable" }, "meta": { "api_version": "v1" } }

Error (400) - key not configurable. This one is a bare error string with no envelope:

{ "error": "key 'foo' is not a configurable runtime setting" }

Error (400) - bad value for an integer key, which uses a nested error body:

{
  "error": { "code": 400, "message": "value must be a non-negative integer" },
  "meta": { "api_version": "v1" }
}

A value of 0 for heartbeat_timeout, response_retention_days or audit_retention_days passes the route’s >= 0 pre-check and is then rejected by the store’s own > 0 check, so it returns the bare shape with a different message: { "error": "value must be a positive integer" }. A heartbeat_timeout of 1 to 29 is refused the same way with { "error": "heartbeat_timeout must be at least 30 seconds (the default agent heartbeat interval)" }: a window shorter than the agent heartbeat interval would mark every directly connected agent offline between two heartbeats. The floor equals the default heartbeat interval and leaves no margin for a late heartbeat, so choose a value well above the interval your agents run with.

Error (400) - the redaction placeholder submitted as a secret. GET /api/config omits a secret’s value and the startup log prints <redacted> in its place. Any value containing that literal is refused for a secret-valued key, ignoring surrounding whitespace and control bytes, so copying it back cannot destroy the stored credential. The rule is deliberately broad: a paste can carry an invisible code point (a BOM, a zero-width space) that no trim list catches exhaustively, and the two failure directions are not symmetric - refusing an implausible secret costs an error message, while accepting a padded placeholder silently destroys a live credential. If your real client secret contains <redacted>, rotate it at the IdP to a value that does not. Bare shape:

{ "error": "value is the redaction placeholder, not a credential; send the real secret, or omit the key to leave it unchanged" }

Audit: a write emits config.update / RuntimeConfig with target_id = the key: attempted (empty detail) before the change, then success (detail is value=<new value>, redacted for a secret key) or failure (detail is the store’s refusal text, or store error; outcome unknown for a database error). A write refused by the caller rule (see Permission) emits config.update / denied on RuntimeConfig with target_id = the key, and detail of elevated_session_oidc_setting_blocked, admin_role_required_oidc_setting_blocked or token_session_oidc_setting_blocked for an oidc_* key, token_session_admin_setting_blocked or admin_role_required_setting_blocked for the other administrator-only keys. It stores nothing and is not preceded by an attempted row. The Settings routes audit the equivalent denial as auth.admin_required, so a SIEM rule keyed on auth.admin_required does not see a refusal on this route. See Audit log for the detail contract, which differs for secret-valued keys.


GET /api/v1/config

The v1 twin of GET /api/config: the effective values, the stored overrides, allowed_keys and read_only_keys, in the A4 envelope ({"data": {...}, "meta": {"api_version": "v1"}}, Cache-Control: no-store). The data object is the legacy response body, key for key, plus withheld_keys, and a secret key reports is_set and never its value. Scoped to the caller: the stored override of every key the caller could not write with PUT /api/v1/config/{key} is left out, and so is the effective audit_retention_days, so an MCP-tier token or a non-administrator does not read the oidc_*, DEX alert or plugin-signing overrides (a service-scoped token is refused at the permission gate). A stored row whose key has no write class (stale or renamed) is shown only to an effective administrator (an elevated session counts). withheld_keys lists the allowed keys withheld from this caller (sorted; empty for an account whose own role is admin; an elevated-only session is still withheld the oidc_* keys), so a missing key means “not shown to you”, not “not set”; a client replaying configuration must treat a withheld key as indeterminate. The legacy GET /api/config is scoped the same way but does not declare withheld_keys. The values shown are what is stored: the three retention values, audit_retention_days included, are not the enforced windows, which come from the command-line flags and are shown as enforced by GET /api/v1/settings/data-retention. dex_score_policy (route-owned) is listed to every reader. Permission: Infrastructure:Read. Not audited. A store that is closed or cannot be read answers 503 (A4 error with retry_after_ms), never an empty list. MCP: get_runtime_config.

PUT /api/v1/config/{param}

The v1 twin of PUT /api/config/:key; the path parameter ({param} in the route pattern, {key} elsewhere in this manual) is the key. Body {"value": ...}, at most 16 KiB (413 above that). Same permission (Infrastructure:Write), same caller rule for the seven administrator-only keys and the oidc_* keys, same same-site check for a cookie session, same audit-first rows (config.update: denied, or attempted then success or failure), same value checks. What changes is the answer:

MCP: set_runtime_config, with the arguments {"key": "...", "value": "..."} (strings; key 1 to 64 characters from a-z and _, value at most 256 bytes). The readonly and operator tiers cannot call it. A supervised-tier token reaches only the five keys that are not administrator-only (heartbeat_timeout, response_retention_days, guardian_event_retention_days, auto_approve_enabled, log_level); the rest answer a permission error and the config.update / denied audit row, and dex_score_policy answers invalid params. An untiered administrator API token is admitted for every key, as on REST. It is not approval-gated at the supervised tier: the write is audit-first, and an MCP-tier token cannot reach the administrator-only keys, including every sign-in setting. The retention values are stored but not enforced, even after a restart (see When a change takes effect).

Settings

#4028 (api-parity programme #2146). REST v1 read-twins for the eight /fragments/settings/* dashboard sub-areas (TLS, HTTPS, gateway, server-config, MCP, data-retention, analytics, plugin-signing) — previously gated by a whole-endpoint admin role check (require_admin) with no RBAC securable and no REST twin at all. All eight routes are read-only and Administrator-only today (see the per-route Permission line); broadening to another role is a separate, explicit decision. Every builder is shared verbatim with the corresponding HTML fragment renderer (docs/api-twin-recipe.md §1) — the REST and dashboard views cannot drift from each other.

MCP. None of these eight routes has an MCP twin, deliberately, and no MCP token can reach any of them via REST either — at any tier, including readonly. Issue #520 established the rule via require_admin’s own comment: “MCP tokens are for fleet management (queries, instruction execution) and must not be used to administer the server itself (settings, users, TLS, OIDC)”. Seven of these eight routes are new and were never gated by require_admin; the eighth (/api/v2/agent/plugin-policy — its predecessor /api/v1/agent/plugin-policy is deprecated, frozen on require_admin; see below) is hardened off it onto require_permission. Gating via require_permission alone is not sufficient to preserve #520’s blanket MCP exclusion — an admin-owned MCP token (any tier) satisfies require_permission’s topology-floor legacy-role check the same way an interactive admin session does. #4028 closes that gap at the actual chokepoint: TlsConfig, PluginSigning, ServerConfig, and AnalyticsConfig are excluded from every MCP tier in mcp_policy.hpp’s tier_allows(), so an MCP token is denied before it ever reaches the role-based fallback, on every transport that consults tier_allows() (REST today; MCP JSON-RPC too, if a future tool ever names one of these securables). #4028 ships these eight sub-areas REST-only rather than silently widening #520 — a future MCP carve-out for read-only settings visibility would need its own security-guardian-reviewed amendment, not a side effect of this twin work. See MCP Server for the full #520 policy.

GET /api/v1/settings/tls

TLS/mTLS listener settings: enabled state, server cert/key/CA file paths, insecure_skip_client_verify, and the management-listener TLS override paths.

Permission: TlsConfig:Read. Audit: settings.tls.read (fail-closed — TLS/mTLS posture and cert paths are reconnaissance value for an attacker learning the mTLS enforcement posture).

{
  "data": {
    "enabled": true,
    "server_cert_path": "/etc/yuzu/certs/server.pem",
    "server_key_path": "/etc/yuzu/certs/server.key",
    "ca_cert_path": "/etc/yuzu/certs/ca.pem",
    "insecure_skip_client_verify": false,
    "mgmt_server_cert_path": "",
    "mgmt_server_key_path": "",
    "mgmt_ca_cert_path": ""
  },
  "meta": { "api_version": "v1" }
}

GET /api/v1/settings/https

HTTPS listener settings: enabled state, port, cert/key file paths, HTTP-redirect state.

Permission: TlsConfig:Read (same securable as /settings/tls). Audit: settings.https.read (fail-closed).

{
  "data": {
    "enabled": true,
    "port": 8443,
    "cert_path": "/etc/yuzu/certs/https.pem",
    "key_path": "/etc/yuzu/certs/https.key",
    "redirect": true
  },
  "meta": { "api_version": "v1" }
}

GET /api/v1/agent/plugin-policy

Deprecated. Use GET /api/v2/agent/plugin-policy. This v1 route is frozen at its original shape and stays live for the deprecation window (Upgrading). It returns the plugin code-signing trust bundle and the require-signature flag, so operators can distribute them to agent hosts out of band (--plugin-trust-bundle). Agents do not call it.

Permission: administrator only (require_admin, a whole-route admin check rather than an RBAC securable). Service-scoped and MCP tokens are refused with 403 whatever their creator’s role. This route writes no read audit and uses no data envelope.

{
  "enabled": true,
  "required": false,
  "trust_bundle_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
  "cert_count": 2,
  "sha256": "a1b2c3..."
}

If no bundle has been uploaded, the route still answers 200 with {"enabled": false, "required": <bool>, "trust_bundle_pem": ""}.

Errors: 401, 403 (not an administrator, or a service-scoped or MCP token), 500 (a bundle is on disk but cannot be read).

GET /api/v2/agent/plugin-policy

Plugin code-signing trust bundle — this is plugin-signing’s REST twin, not a separate /api/v1/settings/plugin-signing route (deliberate: the acceptance criteria for #4028 hardens this pre-existing, previously off-ledger route onto the A4 envelope + OpenAPI + the shared builder rather than adding a parallel one). Superset of the /fragments/settings/plugin-signing fragment’s data — this route alone also returns the raw trust_bundle_pem bytes, for out-of-band agent-config distribution (--plugin-trust-bundle).

v1 is deprecated. GET /api/v1/agent/plugin-policy predates #4028 and is FROZEN at its original flat-body shape (require_admin gate, no data envelope, no audit, no A4 error shape) rather than reshaped in place — #4144 caught that the original #4028 round did exactly that and violated docs/api-versioning-policy.md. v1 keeps working through the announced deprecation window (see server-admin.md’s vNEXT note); new integrations should target v2 below.

Permission: PluginSigning:Read (dedicated securable, deliberately distinct from the unrelated PluginConfig securable, which gates per-plugin runtime kill-switch config — a different domain). No MCP token, at any tier, satisfies this permission (see “MCP” above). Audit: settings.plugin_signing.read (fail-closed) — CC6.1 least-privilege: a non-admin token holder learning when the trust anchor rotates (sha256 changes) is useful reconnaissance for a supply-chain attacker.

{
  "data": {
    "enabled": true,
    "required": false,
    "cert_count": 2,
    "sha256": "a1b2c3...",
    "subjects": ["CN=Yuzu Plugin Signer, O=Example Corp"],
    "bundle_unreadable": false,
    "trust_bundle_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
  },
  "meta": { "api_version": "v1" }
}

No bundle uploaded (still 200, not 404 — a normal operational state): enabled: false, cert_count: 0, sha256: "", subjects: [], trust_bundle_pem: "". A bundle present on disk but unreadable returns 500 (A4 error envelope) instead.

Errors: 503 (A4 envelope, retry_after_ms set) in two cases distinct from the audit fail-closed 503 above — the required flag’s backing runtime_config_store read failed (the response would otherwise be unable to distinguish a genuine outage from a healthy “not required”), or the trust bundle was concurrently uploaded/cleared between this request’s existence check and its PEM re-read. Both are retryable, and neither affects real plugin-signature enforcement — see When a change takes effect above: plugin_signing_required “records intent only,” is read in exactly two places (this route and its dashboard fragment twin), and no server or agent code consumes it to require signatures. Agent-side enforcement, where configured, is that agent’s own --plugin-require-signature/--plugin-trust-bundle flags, independent of this route and its backing store.

GET /api/v1/settings/gateway

Erlang gateway upstream status: enabled state, listen address, gateway mode, live session count.

Permission: ServerConfig:Read. Audit: none — operational status, nothing secret.

{
  "data": { "enabled": true, "listen_address": "0.0.0.0:50053", "gateway_mode": false, "active_sessions": 3 },
  "meta": { "api_version": "v1" }
}

listen_address/gateway_mode/active_sessions are defaulted (""/false/0) when enabled is false.

GET /api/v1/settings/server-config

Core server configuration: gRPC/web addresses and ports, session timeout, max agents, rate limits, OTA/gRPC tuning knobs. Nothing secret.

session_timeout_seconds is the agent heartbeat timeout: the number of seconds without a heartbeat after which this server drops a directly connected agent. It is not the dashboard sign-in expiry. The sign-in limits are session_inactivity_seconds (the --session-inactivity-secs idle timeout; 0 means off) and session_absolute_lifetime_seconds (the fixed sign-in lifetime, 8 hours). The heartbeat timeout can be changed at runtime with PUT /api/config/heartbeat_timeout; the two sign-in limits cannot.

Permission: ServerConfig:Read. Audit: none.

{
  "data": {
    "agent_grpc_address": "0.0.0.0:50051",
    "management_grpc_address": "0.0.0.0:50052",
    "web_address": "127.0.0.1",
    "web_port": 8080,
    "session_timeout_seconds": 90,
    "session_inactivity_seconds": 0,
    "session_absolute_lifetime_seconds": 28800,
    "max_agents": 10000,
    "auth_config_path": "/etc/yuzu/yuzu-server.cfg",
    "rate_limit_per_ip": 100,
    "login_rate_limit_per_ip": 10,
    "ota_max_concurrent_per_peer": 2,
    "ota_rate_refill_per_min": 1.0,
    "ota_rate_capacity": 20.0,
    "ota_max_concurrent_total": 64,
    "ota_max_peers_tracked": 50000,
    "ota_transfer_deadline_secs": 900,
    "ota_chunk_write_deadline_secs": 30,
    "grpc_max_concurrent_streams": 128,
    "grpc_max_resource_memory_mb": 512
  },
  "meta": { "api_version": "v1" }
}

max_agents is deprecated and has no effect: it was never enforced and is kept only for compatibility (#6165).

GET /api/v1/settings/mcp

MCP server status and the client-connection info shown on the dashboard (enabled/read-only state, the /mcp/v1/ endpoint URL).

Permission: ServerConfig:Read. Audit: none — low sensitivity; an MCP client with a valid token already knows this endpoint exists. This route describes the MCP surface but is itself REST-only — see the #520 note above.

{
  "data": { "enabled": true, "read_only": false, "endpoint_url": "https://localhost:8443/mcp/v1/" },
  "meta": { "api_version": "v1" }
}

GET /api/v1/settings/data-retention

Response, audit and Guardian-event data retention windows, in days.

Permission: ServerConfig:Read. Audit: none — lowest sensitivity of the eight (integers and fixed registry strings).

The top-level fields are the configured/stored values. enforced holds the window each store is actually applying, in days (0 or less means rows never expire); a value is null when the store is absent or not open, which is “unknown”, not zero. The two differ after a runtime PUT /api/config/<key>, which is stored but not applied (see When a change takes effect).

echo publishes the Echo warehouse retention tiers: tick_seconds (the agent’s prune tick, 900) and tiers, one row per source and tier (live, hourly, daily, monthly, boot). Each row has limit_kind (rows or seconds), limit, bucket_seconds (the roll-up bucket: 0 for live/boot, 3600, 86400, or 2678400 for a month, the 31-day worst case) and guaranteed_depth_seconds. Roll-up rows age from the start of their bucket, so a time-based tier is guaranteed for limit - bucket_seconds (a 365-day monthly tier is guaranteed for 334 days). It is null for rows tiers, whose time depth depends on how fast a device writes. guaranteed_depth_seconds is a floor on how long a record is kept, not where it sits: a record is still in its finer tier until the coarser roll-up closes. It is a floor on rows that exist, not on coverage: after an agent is offline longer than a tier’s roll-up catch-up window (2 h hourly, 2 d daily, about 62 d monthly) the missed span is a gap in that tier, not a deeper tier. The usage/live row is a backstop, not an operative bound: the usage fold bounds its own open set (20,000 runs) before this limit can apply.

These are the registry defaults an agent of this server’s release applies (agents on other releases may differ), not per-device readings: Echo prunes on the agent, so the server has no live value. Every source’s tiers are declared on every OS; where a source does not run on the agent’s OS (see the OS compatibility matrix in the Echo manual) its tier applies to an empty table. A paused or errored source is never pruned (per-device paused sources: GET /api/v1/echo/retention-paused); while the agent is running, rows may outlive their limit by up to tick_seconds (an offline agent or a disabled plugin prunes nothing), longer when the retention clock guard declines a prune, shorter by any plausible forward skew of the endpoint clock, which the guard does not decline (the depth is measured on the agent’s own clock), and longer still while a backlog drains through the per-pass delete cap (5,000 rows per table per tick). echo is null only if the server’s embedded table cannot be read. A server older than this release omits echo; treat absent like null.

The order of tiers is unspecified; match rows by (source, tier). Clients must tolerate unknown tier and limit_kind values: a new tier is an additive change. A per-device or server-enforced reading, if one ever ships, lands under a new key, never by redefining tiers.

{
  "data": {
    "response_retention_days": 90,
    "audit_retention_days": 365,
    "guardian_event_retention_days": 30,
    "enforced": {
      "response_retention_days": 90,
      "audit_retention_days": 365,
      "guardian_event_retention_days": 30
    },
    "echo": {
      "tick_seconds": 900,
      "tiers": [
        { "source": "process", "tier": "live", "limit_kind": "rows", "limit": 100000,
          "bucket_seconds": 0, "guaranteed_depth_seconds": null },
        { "source": "process", "tier": "daily", "limit_kind": "seconds", "limit": 2678400,
          "bucket_seconds": 86400, "guaranteed_depth_seconds": 2592000 }
      ]
    }
  },
  "meta": { "api_version": "v1" }
}

GET /api/v1/settings/analytics

Analytics drain configuration and ClickHouse integration settings.

Permission: AnalyticsConfig:Read. Audit: settings.analytics.read (fail-closed) — mixed, leans high (embedded-credential risk).

clickhouse_url is sanitized of embedded userinfo credentials (a URL of the form clickhouse://user:pass@host:9000/db has the user:pass@ segment stripped) before being returned — this closes a gap where the sibling HTML fragment previously rendered the URL verbatim, masking only the separate clickhouse_password field. The raw password is never returned at all; only clickhouse_password_set (a bool) is.

{
  "data": {
    "enabled": true,
    "drain_interval_seconds": 10,
    "batch_size": 100,
    "clickhouse_configured": true,
    "clickhouse_url": "clickhouse://host:9000/yuzu",
    "clickhouse_database": "yuzu",
    "clickhouse_table": "yuzu_events",
    "clickhouse_username": "default",
    "clickhouse_password_set": true,
    "jsonl_export_path": ""
  },
  "meta": { "api_version": "v1" }
}

Custom Properties

Custom properties are operator-defined key-value pairs on agents, separate from tags. Properties can have schemas that enforce a value type (string/int/bool/datetime) and an optional regex-pattern validation rule. Properties are available for use in scope expressions via the props.<key> prefix.

GET /api/agents/:id/properties

List all custom properties for a specific agent.

Permission: Infrastructure:Read, scoped to the agent (a global grant or a role assigned on the agent’s management group / an ancestor, or a service-scoped API token whose ITServiceOwner role is admitted on agents matching its own service tag; RBAC-off legacy behavior is unchanged). A degraded confinement check (management-group store unavailable) denies with 403, distinct from the 503 below for a degraded properties-store read.

Error (503) — store degraded: custom_properties_store is a migrated Postgres store (authoritative posture); a transient database read failure returns 503 rather than an empty/partial property list, so automation never mistakes a degraded read for “this agent has no properties.”

{"error":{"code":503,"message":"custom properties store degraded"},"meta":{"api_version":"v1"}}

Response: a flat, unwrapped body — this legacy route family predates the data/meta v1 envelope and has not been migrated onto it.

{
  "agent_id": "agent-042",
  "properties": [
    {
      "key": "department",
      "value": "Engineering",
      "type": "string",
      "updated_at": "2026-08-01T12:00:00Z"
    },
    {
      "key": "cost_center",
      "value": "CC-4200",
      "type": "string",
      "updated_at": "2026-07-15T09:30:00Z"
    }
  ]
}

Invalid agent id: after the permission and store-availability checks, an agent id longer than 256 bytes or containing a NUL byte answers 200 with an empty properties list (for a valid-UTF-8 id; a non-UTF-8 byte in the id makes the response body fail to serialise).


PUT /api/agents/:id/properties/:key

Set or update a custom property value on an agent. If a property schema exists for the key, the value is validated against it.

Permission: Infrastructure:Write, scoped to the agent (a global grant or a role assigned on the agent’s management group / an ancestor, or a service-scoped API token whose ITServiceOwner role is admitted on agents matching its own service tag; RBAC-off legacy behavior is unchanged). A degraded confinement check (management-group store unavailable) denies with 403, distinct from the 503 below for a degraded properties-store write.

Request body: type is optional (default "string") and IS persisted, but a schema on this key (below) takes precedence — if one exists, its own type overrides whatever the request sent before the write, so the stored (and later GET-returned) type reflects the caller’s value only when no schema is registered for the key.

{
  "value": "Engineering",
  "type": "string"
}

Response: flat, unwrapped (same family caveat as GET above).

{
  "agent_id": "agent-042",
  "key": "department",
  "value": "Engineering",
  "type": "string"
}

Error (400) — schema validation failure: the message depends on which schema constraint failed — a type mismatch (int/bool) or a validation_regex mismatch (GET /api/property-schemas below):

{"error": "value does not match validation pattern for 'department'"}

Error (503) — store outage:

{"error":{"code":503,"message":"custom properties store unavailable"},"meta":{"api_version":"v1"}}

Note on database degrade: a transient database failure during this write — store not open, a pool/query failure on the property INSERT, or a failure on the schema-validation lookup itself — now surfaces as this distinguishable 503, matching GET /api/agents/:id/properties above. Caller-input validation failures (invalid key/value shape, or a value rejected against an existing schema) stay 400. This closes a gap where every failure, including a genuine store outage, collapsed to the same 400 a caller couldn’t distinguish from their own bad input (gov Gate 8 finding, fjarvis re-review of PR #3065). A transient failure on the schema-validation lookup used to be silently treated by the SQLite original as “no schema, accept any value” — that fail-open was already closed by the original Postgres migration (it correctly rejects the write instead of accepting it unvalidated); this change only affects which status code that rejection now uses.

Invalid agent id: after the permission and store-availability checks and once the request body is accepted, an agent id longer than 256 bytes or containing a NUL byte is refused with 400 and the body {"error":"invalid agent id (1-256 bytes, no NUL)"}.


DELETE /api/agents/:id/properties/:key

Delete a custom property from an agent.

Permission: Infrastructure:Write, scoped to the agent (a global grant or a role assigned on the agent’s management group / an ancestor, or a service-scoped API token whose ITServiceOwner role is admitted on agents matching its own service tag; RBAC-off legacy behavior is unchanged). A degraded confinement check (management-group store unavailable) denies with 403 — distinct from the 404 degrade conflation noted below, which is a property-store issue on the delete path itself, not the authorization check.

Response: flat, unwrapped (same family caveat as GET/PUT above).

{
  "deleted": true,
  "key": "department"
}

Note on database degrade: a transient database failure during this delete currently surfaces as the same 404 (“property not found”) a genuine miss would return — not yet type-widened, predates the Postgres migration. Unlike PUT/POST above (fixed to a distinguishable 503), this route’s underlying delete_property was deliberately left unwidened — it’s an admin/operator-driven delete, not a scope/dispatch-feeding read, matching custom_properties_store.hpp’s documented posture — so this stays a tracked gap rather than a fixed one.

Invalid agent id: after the permission and store-availability checks, an agent id longer than 256 bytes or containing a NUL byte answers 404 (“property not found”), and the audit record is not_found.


GET /api/property-schemas

List all property schemas. Schemas define the allowed keys, types, and validation constraints for custom properties.

Permission: Infrastructure:Read

Note on database degrade: a transient database failure during this list currently surfaces as a 200 with an empty schemas array — indistinguishable from “no schemas configured.” Not yet type-widened, predates the Postgres migration. Unlike PUT/POST above (fixed to a distinguishable 503), this route’s underlying list_schemas was deliberately left unwidened — it’s an admin-surface read, not scope/dispatch-feeding, matching custom_properties_store.hpp’s documented posture — so this stays a tracked gap rather than a fixed one.

Response: flat, unwrapped (same family caveat as the agent-properties routes above).

{
  "schemas": [
    {
      "key": "department",
      "display_name": "Department",
      "type": "string",
      "description": "The employee's department",
      "validation_regex": ""
    },
    {
      "key": "cost_center",
      "display_name": "Cost Center",
      "type": "string",
      "description": "",
      "validation_regex": "^CC-[0-9]{4}$"
    }
  ]
}

type is one of string, int, bool, datetime — int/bool values are type-checked on write; string/datetime accept any text. A non-empty validation_regex (RE2 syntax, max 256 characters) is additionally applied via RE2::FullMatch regardless of type. An empty validation_regex accepts any value of the declared type.


POST /api/property-schemas

Create or update a property schema. If a schema with the given key already exists, it is replaced.

Permission: Infrastructure:Write

Request body:

{
  "key": "cost_center",
  "display_name": "Cost Center",
  "type": "string",
  "description": "The agent's billing cost center",
  "validation_regex": "^CC-[0-9]{4}$"
}
FieldTypeRequiredDescription
keystringYesProperty key (unique identifier)
display_namestringNoHuman-readable label
typestringNoValue type: string (default), int, bool, datetime
descriptionstringNoFree-text description
validation_regexstringNoRE2 pattern (max 256 chars) values must fully match; empty = no pattern constraint

Response (201): flat, unwrapped (same family caveat as the routes above) — echoes the stored schema back, including any server-applied default (type defaults to "string" if omitted).

{
  "key": "cost_center",
  "display_name": "Cost Center",
  "type": "string",
  "description": "The agent's billing cost center",
  "validation_regex": "^CC-[0-9]{4}$"
}

Error (503) — store outage:

{"error":{"code":503,"message":"custom properties store unavailable"},"meta":{"api_version":"v1"}}

Note on database degrade: a transient database failure during this write (store not open, a pool lease timeout, or a query failure) surfaces as this distinguishable 503. Caller-input validation failures (invalid key, unrecognized type, an invalid/oversized validation regex) stay 400 (gov Gate 8 finding, fjarvis re-review of PR #3065 — the same fix as PUT /api/agents/:id/properties/:key above).


Webhooks

Webhooks deliver real-time HTTP POST notifications to external systems when events occur in Yuzu (e.g., agent registration, a command response arriving, a DEX signal).

GET /api/webhooks

List all configured webhooks.

Response:

{
  "webhooks": [
    {
      "id": 1,
      "url": "https://example.com/hooks/yuzu",
      "event_types": ["agent.registered", "execution.completed"],
      "has_secret": true,
      "enabled": true,
      "created_at": 1710849600
    }
  ]
}

has_secret reports whether a signing secret is configured — the secret itself is never returned by this or any other endpoint, encrypted or otherwise.

POST /api/webhooks

Create a new webhook subscription.

Request body:

{
  "url": "https://example.com/hooks/yuzu",
  "event_types": ["agent.registered", "execution.completed", "dex.signal"],
  "secret": "optional-hmac-secret"
}
FieldTypeRequiredDescription
urlstringYesEndpoint to receive POST notifications (http:// or https://; see the cleartext-HTTP warning below)
event_typesarrayYesEvents to subscribe to (see the table below)
secretstringNoHMAC-SHA256 secret for payload signing

Event types (the event_types filter matches these literal strings):

Event typeFired whenNotable payload fields
agent.registeredAn agent enrolls or re-enrollsagent_id, hostname, os, arch, agent_version
execution.completedOnce per agent response to a command, not once per instruction: each target agent that answers produces its own eventcommand_id, agent_id, status, exit_code (and error when the response carries one)
dex.blast_radiusN distinct devices report the same DEX signal (obs_type, subject) within the window — thresholds are operator-tunable on the DEX → Alerts tab (defaults 5 devices / 15 min; see DEX fleet incident alerts)obs_type, subject, device_count, window_seconds
dex.signalA device reports a DEX signal type the operator routed to alerts (DEX → Alerts tab; once per device per hour — see Routing signals to alerts)obs_type, subject, agent_id

These four (agent.registered, execution.completed, dex.blast_radius, dex.signal) are the only event types the server fires; no other event is ever sent.

agent.registered fires on every gRPC reconnect, not only first enrollment — a server restart, a network blip, or a gateway bounce that strands and reconnects a fleet produces one delivery per reconnecting agent, not one per genuinely new device. A target wired to a low-tolerance channel (e.g. a Slack alert intended for “new device joined the fleet”) should filter or debounce on the receiving end if reconnect noise matters; the dashboard’s own “Agent Enrolled” notification does not have this problem (it fires once, on first enrollment only).

If a secret is provided, each delivery includes an X-Yuzu-Signature header containing the HMAC-SHA256 hex digest of the request body.

Security — secret storage, and a legacy file’s disposal. A configured signing secret is envelope-encrypted at rest (AES-256-GCM, ADR-0010) — a stolen database backup alone cannot recover it. There is no rotation endpoint today: to change a secret, delete the webhook and recreate it. No production fleet ever ran a pre-Postgres build of this store (#3623), so a legacy SQLite webhooks.db genuinely holding real data should not exist — but if one is somehow found on disk at boot (hardened to 0600, main file and any -wal/-shm/-journal sidecars, and warned about in the log), it still holds any signing secrets in plaintext and is never automatically deleted or rotated. If you find one and don’t need it, delete it yourself; if any webhook it names might still be relying on that secret, rotate (delete-and-recreate) the corresponding webhook’s secret via this store first.

Cleartext HTTP warning. When url is http:// (not https://), the delivered event payload is transmitted in cleartext. Production deployments should use https:// only. The store accepts http:// for development convenience — same posture as Offload Targets below.

URL rules. A URL with embedded credentials (https://user:pass@host) or with control bytes (any byte below 0x20, including tab, CR, LF and NUL, and DEL) in the URL or in event_types is refused with 400 and nothing is stored. The webhook.created analytics event and the audit rows carry the scheme and host (plus, in the audit rows, a short url_fp fingerprint) only: a destination URL is a bearer secret and its path and query are never recorded. A URL with a query but no path (https://hooks.example?token=x) is delivered to the host with the query intact. Destinations are not yet restricted by address (loopback, link-local and private ranges are accepted); an opt-in guard is tracked in #4816. The same hooks are managed from Settings > Integrations, which calls this route.

Errors. 400 for an empty/missing url, invalid JSON, a URL that isn’t http:///https://, or a URL with embedded credentials or control bytes. 503 for a store/database degradation. Every error response is audited (webhook.create, result failure, a distinct detail string per cause). Create and delete write a result=attempted audit row before they change anything; if that row cannot be written the call answers 503 with Sec-Audit-Failed: true and nothing changes. A browser (cookie-session) call whose Origin/Referer names another site, or carries neither, is refused 403; token callers are not gated.

DELETE /api/webhooks/{id}

Delete a webhook by numeric ID. 200 on success, 404 if no webhook has that id, 503 on a store/database error. Every outcome (including 404, and a well-formed id too large for a 64-bit integer) is audited as webhook.delete; a successful delete’s audit detail carries the webhook’s scheme, host and URL fingerprint (captured just before deletion), so the record of where a webhook pointed survives its removal. Same audit-first and same-site rules as create.

GET /api/webhooks/{id}/deliveries

List recent delivery attempts for a webhook, newest first. Includes HTTP status code, response time, and any error message for failed deliveries. ?limit= defaults to 50 (any non-positive value also falls back to 50) and is capped at 10000; no offset/pagination parameter exists. A degraded read renders an empty list rather than a 503 — delivery history is audit convenience, not a decision surface. An id too large for a 64-bit integer names no webhook and answers 404.

Usage guide:

  1. Create a webhook targeting your SIEM, Slack, or automation endpoint.
  2. Subscribe to the event types relevant to your workflow.
  3. Optionally set an HMAC secret and verify the X-Yuzu-Signature header on receipt.
  4. Monitor delivery history via GET /api/webhooks/{id}/deliveries to detect failures.

Offload Targets

Response-offload control plane (issue #255, Phase 8.3). Targets are named external HTTP endpoints that receive a copy of the same events webhooks do (see the event-types table in the Webhooks section above) as they fire — heavier-duty than webhooks: typed auth (none / bearer / basic / hmac) and server-side batching for SIEM / data-warehouse ingestion that prefers fewer, larger requests.

A target is identified by a unique name so a definition can reference it via spec.offload.targets in YAML (see yaml-dsl-spec.md).

agent.registered fires on every gRPC reconnect, not only first enrollment — same caveat as the Webhooks section above, and the same event-type table, since both sinks fire off the identical set of events. A target wired to a low-tolerance channel should filter or debounce on the receiving end if reconnect noise matters.

All five endpoints require the Infrastructure securable type — Read for GET, Write for POST/DELETE. The auth_credential is never returned in any response (redacted from list() and from get(), not even as the encrypted-at-rest blob) — a has_credential boolean instead reports whether one is configured. Audit events: offload_target.create (success or denied) and offload_target.delete. The create row’s detail is name=<name> url=<scheme>://<host>[:<port>] url_fp=<12 hex>, the same shape as the delete row (the URL’s path and query are never recorded), and it follows a result=attempted row written before the target is stored. A url with embedded user:password@ is refused with 400. These targets are also managed from Settings > Integrations, which calls these routes.

GET /api/v1/offload-targets

List all configured offload targets. 503 on a degraded read (distinguishable from a genuine empty list).

Response:

{
  "offload_targets": [
    {
      "id": 1,
      "name": "siem-primary",
      "url": "https://siem.example.com/ingest",
      "auth_type": "bearer",
      "has_credential": true,
      "event_types": "execution.completed",
      "batch_size": 50,
      "enabled": true,
      "created_at": 1714501234
    }
  ]
}

GET /api/v1/offload-targets/{id}

Fetch a single target by numeric id. auth_credential is redacted (has_credential reports whether one is configured). 404 when no such id exists, 503 on a degraded read.

POST /api/v1/offload-targets

Create a new offload target. Returns 201 + {id, status} on success, 400 when validation fails (invalid URL scheme, empty name, batch_size outside 1..10000, duplicate name, a control byte in a free-text field, a present-but-wrong-typed field such as batch_size sent as a string, or an unrecognized auth_type).

FieldTypeRequiredDescription
namestringYesUnique stable identifier referenced from spec.offload.targets.
urlstringYeshttp:// or https:// POST endpoint.
auth_typestringNo (none)One of none, bearer, basic, hmac.
auth_credentialstringNoBearer token (Bearer), user:pass (Basic), shared secret (Hmac). Never returned by any endpoint.
event_typesstringNo (*)Comma-separated list of event types or * for all. Same semantics as webhooks.
batch_sizeintNo (1)Accumulate up to N events into a single POST body. 1 = no batching. Must be 1..10000 (read as a 64-bit value, so an oversized number is rejected rather than wrapped; a float such as 5.5, a string, a boolean or null is 400 invalid_field_type, where a float used to be truncated); the per-target buffer flushes only when it reaches this size and at graceful server shutdown (there is no timer flush, so a partly filled buffer waits for more events and is dropped on a crash or when the target is deleted) and has no other cap, so the store refuses a larger value (the same range MCP create_offload_target enforces).
enabledboolNo (true)When false, no events are dispatched until re-enabled.

Auth headers (set per auth_type):

Every delivery also carries X-Yuzu-Event and X-Yuzu-Event-Count headers; batched bodies are JSON of shape {"events":[…]}.

DELETE /api/v1/offload-targets/{id}

Delete a target. Cascades on offload_deliveries. Pending events buffered for batching are dropped — operator who deletes the target asked for it.

Audit and same-site rules (create and delete). The audit rows (offload_target.create/.delete) record name, the destination’s scheme and host, and a short url_fp fingerprint of the URL, never its path or query (the URL is a bearer secret in practice). Each call writes a result=attempted row before it changes anything; if that row cannot be written the call answers 503 with Sec-Audit-Failed: true and nothing changes. A browser (cookie-session) call whose Origin/Referer names another site, or carries neither, is refused 403; token callers are not gated. A URL with a query but no path (https://hooks.example?token=x) is delivered to the host with the query intact.

GET /api/v1/offload-targets/{id}/deliveries

Recent delivery attempts for a target (default 50, override via ?limit=N). Each row records event_type, event_count, payload, status_code, delivered_at (epoch seconds), and error (set on connection failure or exception).

Usage guide:

  1. Register a target — e.g. a generic webhook collector, Datadog Logs HTTP endpoint, Elastic Common Schema ingest URL, or any in-house aggregator that accepts JSON over HTTP(S).
  2. Set event_types to the events you actually need; execution.completed is the typical analytics feed, agent.registered for inventory hydration.
  3. Tune batch_size to your downstream ingestion preference. SIEMs commonly prefer 50–500 per POST; real-time alerting wants batch_size=1.
  4. Monitor delivery via GET /api/v1/offload-targets/{id}/deliveries. Repeated connection_failed errors mean the receiver is down or the URL/auth is wrong.

Validating a new target. There is no synthetic-test endpoint in this revision. To validate, set batch_size=1, run any instruction that produces an execution.completed event, then poll GET /api/v1/offload-targets/{id}/deliveries for the resulting row.

Authentication interop — known limitations.

Cleartext HTTP warning. When url is http:// (not https://), the entire JSON payload — including potentially sensitive instruction response data (file paths, registry values, software inventory, security findings) — is transmitted in cleartext. Production deployments containing customer endpoint data should use https:// only. The store accepts http:// for development convenience and to maintain parity with the webhook precedent.

Operator trust model. Any principal with Infrastructure:Write can register an offload target pointing at any URL the server can resolve, including RFC1918 / loopback / link-local destinations. There is no URL allowlist or network-egress mitigation in this revision; the trust model is “Infrastructure:Write operators are trusted to choose where data goes.” For multi-tenant managed deployments this is a known limitation tracked as a roadmap follow-up.


Directory Sync

AD/Entra directory integration (Phase 7). Backed by DirectorySync (directory_sync.{hpp,cpp}).

POST /api/directory/sync

Trigger a directory sync. Requires Directory:Write. Also available from Settings > Integrations > Directory sync, which sends the tenant, client and secret for that one request and never stores them. A 400 (unparseable body, invalid_json; or missing credentials, missing_credentials) and a 500 (sync failed, sync_failed) are audited as directory.sync with result failure, and a 409 as denied; every detail is a fixed token, never the upstream error text or a credential.

Request (provider: "entra"):

{
  "provider": "entra",
  "tenant_id": "...",
  "client_id": "...",
  "client_secret": "..."
}

tenant_id/client_id/client_secret are all required for entra; a provider: "ldap" request instead takes server/port/base_dn/bind_dn/bind_password/use_ssl, but sync_ldap is not yet implemented and always returns 501. Any other provider value is 400.

Response (200): {"status": "completed", "provider": "entra", "user_count": <n>, "group_count": <n>}

Response (409, ADR-1007): {"error": "sync already in progress"} — sync_entra holds a single in-process re-entrancy guard for the duration of one sync call; a second POST that arrives while a sync is already running is rejected immediately rather than racing it. See “Diagnosing a stuck directory sync” in docs/user-manual/server-admin.md for how long a legitimate sync can hold the guard and what a stuck one looks like. Not queued or retried automatically — the caller decides when to retry.

Response (400): malformed body, or a missing required Entra field.

Response (403): a browser (cookie-session) call whose Origin/Referer names another site, or carries neither (token callers are not gated).

Response (503): directory_sync is unavailable (store not open), or the result=attempted audit row written before the sync could not be persisted (Sec-Audit-Failed: true; no upstream call is made).

Response (500): a genuine sync failure (e.g. Graph API error) — the error message is passed through from sync_entra’s result.

GET /api/directory/users

List directory-synced users from the last successful sync. Requires Directory:Read. Optional ?group_id= query param filters to members of one synced group.

Response (200): {"users": [{"id","display_name","email","upn","enabled","groups":[...],"synced_at"}], "count": <n>}

Response (503): directory_sync unavailable.

GET /api/directory/status

Sync status plus the last-synced group list (there is no separate GET /api/directory/groups route — groups are embedded here). Requires Directory:Read.

Response (200): {"provider","status","last_sync_at","user_count","group_count","last_error","groups":[{"id","display_name","description","mapped_role","synced_at"}]}

Response (503): directory_sync unavailable.

PUT /api/directory/group-mappings

Record group-to-role mappings (the RBAC role intended for a synced group’s members; mappings are recorded for reference and nothing applies them at sign-in today). Requires Directory:Write.

Request: {"mappings": [{"group_id": "...", "role_name": "..."}]}. role_name is NOT required per entry — an entry with an empty/omitted role_name REMOVES that group’s mapping rather than being rejected; a non-empty role_name configures (creates or overwrites) it. An entry with an empty group_id is silently skipped and not counted.

Response (200): {"mappings": [{"group_id","role_name"}], "count": <n>} — the FULL current mapping set after applying this request’s changes (not the size of the batch just submitted).

Response (400): malformed body, a missing mappings array, or an entry that is not an object or whose group_id/role_name is not a string. The whole request is checked before anything is stored, so a refused request changes nothing.

Response (403): a cross-site or header-less cookie-session write (token callers are not gated).

Response (503): directory_sync unavailable, or the result=attempted audit row written before the first mapping change could not be persisted (Sec-Audit-Failed: true; nothing stored).

Audit: directory.group_mapping.update records the pairs (N mappings updated: <group_id>=<role>, <group_id>=(removed)), with control bytes replaced; a long list is split across several rows so every pair is recorded. Mappings are recorded for reference; nothing reads them at sign-in. The write helpers swallow store failures and the route still answers 200, so a client that needs certainty compares the echoed set with what it sent (the Integrations page does). The route reads the stored set back before auditing: stored pairs get a result=success row and pairs the store did not take get a result=failure row (N mappings not confirmed by read-back: ...); a failed read-back confirms nothing, removals included.

GET /api/v1/directory/users

REST v1 twin of GET /api/directory/users above (#4031) — same underlying data, the versioned A4 envelope, and a real MCP twin (list_directory_users). Optional ?group_id= query param filters to members of one synced group.

Permission: Directory:Read

Every call is audited (directory.users.view) — this closes a real pre-existing gap: the legacy route above returned this same email/UPN/group-membership PII with no audit call at all until this change. The route fails closed (503) if the audit row cannot be persisted, per the Sec-Audit-Failed behavioural-PII posture above.

Response:

{
  "data": [
    {
      "id": "8f3c...",
      "display_name": "Alice Chen",
      "email": "alice.chen@example.com",
      "upn": "alice.chen@example.com",
      "enabled": true,
      "groups": ["g-engineering"],
      "synced_at": 1735689600
    }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

Response (503): directory sync not available, or the audit subsystem is unavailable (Sec-Audit-Failed: true).

GET /api/v1/directory/status

REST v1 twin of GET /api/directory/status above (#4031), with a real MCP twin (get_directory_status). No per-person PII (counts + synced-group metadata only) — unaudited, matching the legacy route’s own posture.

Permission: Directory:Read

Response:

{
  "data": {
    "provider": "entra",
    "status": "completed",
    "last_sync_at": 1735689600,
    "user_count": 42,
    "group_count": 5,
    "last_error": "",
    "groups": [
      { "id": "g-engineering", "display_name": "Engineering", "description": "", "mapped_role": "Operator", "synced_at": 1735689600 }
    ]
  },
  "meta": { "api_version": "v1" }
}

Response (503): directory sync not available.

Prerequisite fix (#4031): Directory was used as an RBAC securable on every route in this section but was never seeded into RbacStore’s securable-type catalogue — under RBAC-enabled deployments, no role (not even Administrator) could actually be granted Directory:Read/Write. This is now seeded (Administrator via the CRUD grant loop, Viewer for read — the same population that reads UserManagement) and mirrored into mcp_server.cpp’s RBAC securable catalogue.


Enrollment

Auto-approve rules (agent-enrollment bypass criteria) and the pending/denied agent-enrollment queue. Both were previously reachable only via admin_fn_-gated HTMX dashboard fragments (GET /fragments/settings/auto-approve, GET /fragments/settings/pending) with no REST v1 or MCP surface (#4031). Backed by auth::AutoApproveEngine and auth::AuthManager::list_pending_agents().

MCP: REST-only — no MCP twin. admin_fn_ (the gate these fragments used, and the new Enrollment securable’s sole initial grant) explicitly excludes MCP tokens from administering server settings/enrollment policy (auth_routes.cpp, #520); this is the same reviewed exception the sibling settings-fragment issue #4028 records for its own admin_fn_-gated fragments. See MCP (Model Context Protocol) for the general MCP-tier policy.

RBAC-off note: the new Enrollment securable is Administrator-only today, and — because the underlying gate moved from a role check (admin_fn_, always admin-only) to an RBAC permission check — the (Enrollment, Read) pair is in authz_topology_floor.hpp’s floor set so an RBAC-off deployment (the default) cannot silently widen this from admin-only to any authenticated user.

GET /api/v1/enrollment/auto-approve-rules

Permission: Enrollment:Read (Administrator only)

Audited (enrollment.auto_approve.view, non-blocking) — the rule set is auto-enrollment bypass criteria, reconnaissance-valuable to an attacker but not per-person data, so the posture is a plain audit log entry rather than the fail-closed behavioural-PII posture above.

Response:

{
  "data": {
    "rules": [
      { "index": 0, "type": "hostname_glob", "value": "*.prod.example.com", "label": "Production hosts", "enabled": true }
    ],
    "require_all": false
  },
  "meta": { "api_version": "v1" }
}

index is the rule’s position in the engine’s list — the same handle the existing toggle/delete mutation routes (POST/DELETE /api/settings/auto-approve/{index}) key on (there is no separate rule id).

GET /api/v1/enrollment/pending-agents

Permission: Enrollment:Read (Administrator only in the default seed). A service-scoped API token is refused 403 (service-scoped token does not grant Enrollment:Read (the ITServiceOwner role does not hold it), no permission field), whoever minted it, because the ITServiceOwner role does not hold Enrollment:Read; use an Administrator-minted non-service token. If the read of the ITServiceOwner role itself fails, the route answers a retryable 503 (retry_after_ms 5000) instead.

Do not conflate with the unrelated existing MCP tool list_pending_approvals, which serves ApprovalManager’s maker-checker action-approval queue — a different domain entirely. Audited (enrollment.pending_agents.view, non-blocking) — device-identity fingerprint data, a lighter version of the device_ci GDPR-personal-data-adjacent class the agent daily-sync framework already flags for serial/UUID/MAC.

Confinement (ADR-0017): unlike every other route in this section, each row here carries genuine per-agent identity (agent_id plus hostname/os/arch/agent_version), so this route gates on the admit-then-filter chokepoint (AuthRoutes::require_fleet_read), not a bare permission check. A holder of a management-group-scoped Enrollment:Read grant (rather than a global one) is admitted and gets the real visible-agent intersection — typically the empty list, since a pending (not-yet-approved) agent normally has no management-group membership yet, but this is a workflow expectation, not a data-model guarantee: an agent pre-assigned to a group before approval yields a non-empty, correctly-confined result instead. This closes a defect where such a grant was previously denied outright (403) instead of admitted with its correct, confined result — see docs/auth-architecture.md’s ADR-0017 migration list.

Returns 503 if the Postgres enrollment store (auth.pending_agents) is degraded, rather than an empty list or a 200 — a degraded read is never laundered into “no pending agents” (WS-6 6.2).

DELETE /api/settings/pending-agents/{id} (documented in server-admin.md’s Settings API Reference) now returns 409 if the target row is no longer pending — removal only succeeds against a genuinely pending row; an approved or denied row is refused rather than silently reversed (WS-6 6.2 governance hardening).

Response:

{
  "data": [
    { "agent_id": "a1b2c3d4", "hostname": "host1.example.com", "os": "linux", "arch": "x86_64", "agent_version": "1.4.2", "requested_at": 1735689600, "status": "pending" }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

Network Discovery

Network-discovered devices (Issue 7.18) — raw scan results agents report before an operator promotes a device to a managed agent. Backed by discovery_store on the shared PostgreSQL substrate (ADR-0044). Not to be confused with Discovery (A2) above, which is the unrelated agentic self-discovery family (/api/v1/discover/*).

POST /api/discovery/scan

Store discovery scan results. Requires Infrastructure:Write. Upserts one row per device by ip_address — mac_address/last_seen/subnet always refresh on a re-scan; hostname refreshes only when the new value is non-empty; a re-scan never touches an existing row’s managed/agent_id/discovered_at/discovered_by.

Request:

{
  "subnet": "10.0.0.0/24",
  "discovered_by": "agent-scanner-01",
  "devices": [
    {"ip_address": "10.0.0.5", "mac_address": "aa:bb:cc:dd:ee:ff", "hostname": "workstation-05"}
  ]
}

devices[].ip_address is required per entry; an entry with an empty ip_address is silently skipped and not counted.

Response (200, all or partial success):

{"status": "ok", "devices_stored": 1, "devices_failed": 0}

A partial batch failure (e.g. under a degraded Postgres pool) still returns 200 but "status": "partial" with devices_failed > 0, and audits discovery.scan as "partial":

{"status": "partial", "devices_stored": 1, "devices_failed": 1}

Response (503, every attempted device failed):

{"error":{"code":503,"message":"discovery scan storage degraded"},"meta":{"api_version":"v1"}}

Returned only when every device the request attempted to persist failed — an entry skipped for an empty ip_address is not counted as an attempt, so an all-skip batch still returns 200/"ok". Audits discovery.scan as "failure".

Re-sending the exact same request body after a 503 or a "partial" response is safe: upsert_device is idempotent per ip_address (see the upsert semantics above), so a byte-identical retry does not double-count or corrupt already-stored devices. This is narrower than “safe to re-scan”: a fresh scan is not a replay, and mac_address/subnet overwrite unconditionally on every upsert (unlike hostname, which only refreshes on a non-empty value) — a re-scan that this time fails to resolve a device’s MAC (e.g. a transient ARP miss) will blank a previously known-good mac_address, not merely retry the failed write.

There is currently no scan-path-specific Prometheus degrade signal (unlike GET /api/discovery/results, which increments yuzu_server_discovery_read_degrade_total) — devices_failed in the response body, and the audit trail’s "partial"/"failure" outcomes, are the per-request evidence today; the shared yuzu_pg_* connection-pool metrics are the closest backend-health proxy. A write-path degrade counter is tracked as a follow-up.

GET /api/discovery/results

List discovered devices. Requires Infrastructure:Read. Optional ?subnet= query param filters to one subnet; omitted returns every discovered device, newest-last_seen-first.

Response (200):

{
  "devices": [
    {
      "id": 1,
      "ip_address": "10.0.0.5",
      "mac_address": "aa:bb:cc:dd:ee:ff",
      "hostname": "workstation-05",
      "managed": false,
      "agent_id": "",
      "discovered_by": "agent-scanner-01",
      "discovered_at": 1714501234,
      "last_seen": 1714501234,
      "subnet": "10.0.0.0/24"
    }
  ],
  "total": 1
}

Response (503, degraded read): {"error":{"code":503,"message":"discovery read degraded"},"meta":{"api_version":"v1"}}. discovery_store is an authoritative store (ADR-0012 §1) — a query/pool failure surfaces as 503, never as a silently-empty device list. See yuzu_server_discovery_read_degrade_total{reason} (store_not_open/pool_acquire_timeout/query_error).

mark_managed and clear_results exist on the underlying store but have no REST route today — dead surface, noted in ADR-0044’s Follow-ups.


Workflows

Workflows define multi-step instruction sequences that execute in order against a set of agents. Each step references an instruction definition and can include parameter overrides.

GET /api/workflows

List all workflows. Supports ?name=<search> for a substring name filter and ?limit=<n> to cap the number of rows returned (default 100; limit must be a positive integer — 0 or negative is a 400, matching the existing non-numeric-limit 400). A soft-deleted workflow (see DELETE below) never appears in this list.

Response (400): {"error":{"code":400,"message":"limit must be a positive integer"},"meta":{"api_version":"v1"}} — non-numeric limit returns the sibling "invalid numeric query parameter" 400 instead.

Response (503, degraded read): {"error":{"code":503,"message":"service unavailable"},"meta":{"api_version":"v1"}} — workflow_engine is a migrated Postgres store (ADR-0064); a genuine pool/query failure surfaces as 503, never a silently-empty list.

Response:

{
  "workflows": [
    {
      "id": "abc123",
      "name": "Patch and Reboot",
      "description": "Install patches then reboot if required",
      "steps": [
        { "instruction": "windows-update-install", "parameters": {} },
        { "instruction": "system-reboot", "parameters": { "delay": "60" } }
      ],
      "created_at": 1710849600
    }
  ]
}

POST /api/workflows

Create a workflow from YAML. The request body is the raw YAML text with Content-Type: text/x-yaml or application/json with a yaml_source field.

GET /api/workflows/{id}

Get a single workflow by ID, including its full step definitions. 404 for an unknown or soft-deleted id; 503 on a genuine store degrade (ADR-0064).

DELETE /api/workflows/{id}

Delete a workflow by ID. Soft-delete (ADR-0064): the workflow is retired, not physically removed — its execution history stays intact and remains readable via GET /api/workflow-executions/{id} indefinitely, and a deleted workflow’s id can never be re-used. Response body is unchanged: {"deleted": true} on the first delete of an existing workflow, {"deleted": false} (still 200) for an unknown id or a repeat delete of an already-deleted one. A genuine store-unavailable condition now returns 503 instead of the pre-migration {"deleted": false}.

POST /api/workflows/{id}/execute

Execute a workflow against targeted agents. Fails (workflow-not-found, 400) if the workflow does not exist or was soft-deleted; a genuine store degrade returns 503 (ADR-0064).

Request body:

{
  "scope": "os = 'windows' AND tag:environment = 'production'",
  "parameters": {}
}
FieldTypeRequiredDescription
scopestringYesScope expression selecting target agents
parametersobjectNoOverride parameters for workflow steps

GET /api/workflow-executions/{id}

Get the status of a running or completed workflow execution, including per-step and per-agent results. 404 for an unknown execution id; 503 on a genuine store degrade (ADR-0064). Execution history survives its workflow being deleted — this route stays readable for an execution whose workflow was later soft-deleted.

Permission: Workflow:Read, fleet-read confined as of #4030 — same record-level gate and agent_ids/agents_reached narrowing as the v1 twin below (a caller with no agent visible in the execution’s agent_ids gets the same 404 as a nonexistent id). Unlike the v1 twin and the MCP tool, this legacy route writes no audit row on any path (pre-existing; out of scope for the #4030 confinement fix).

Usage guide:

  1. Define a workflow with ordered steps (each step maps to an instruction definition).
  2. Execute the workflow against a scope expression to target specific agents.
  3. Monitor execution progress via GET /api/workflow-executions/{id}.
  4. Each step runs sequentially; if a step fails on an agent, subsequent steps for that agent are skipped.

Workflows, Executions & Schedules — v1 read twins (#4030)

Versioned REST + MCP twins of the legacy /api/workflows* routes above, the /fragments/executions and /fragments/executions/{id}/detail dashboard fragments, and the /fragments/schedules fragment — closing the last gaps in that domain’s docs/api-parity-ledger.md row set. Every route here shares its JSON row builder with its MCP tool twin (docs/api-twin-recipe.md Rule 1), so the two surfaces cannot drift on JSON shape independently. This does not by itself guarantee the two surfaces admit the same result set for a given caller: GET /api/v1/executions’s confined branch (below) is materially broader than the pre-existing MCP list_executions confinement (own-dispatches-only) — a disclosed, pre-#4030 difference, not a silent one (docs/user-manual/mcp.md).

GET /fragments/executions also takes view=page (the Executions page list) and status, an allow-list of this fragment’s own (running, succeeded, completed, cancelled, pending), not the v1 twin’s enum. An empty status is no filter; an unknown status or a malformed definition_id answers 200 with a data-invalid note, never an empty list.

Prerequisite fix bundled with this PR: Workflow was used as an RBAC securable throughout workflow_routes.cpp but was never seeded into RbacStore’s securable-types catalogue — meaning no role, including Administrator, could ever be granted Workflow:Read while RBAC was enabled. This PR seeds it and grants Read to Administrator (via the standard CRUD seed), PlatformEngineer, Operator, ITServiceOwner, and Viewer — the same role footprint Schedule:Read already has. This applies automatically on upgrade (no migration step, no opt-out) — an existing RBAC-enabled deployment picks up these four grants on its next restart; review your role assignments first if you’ve already narrowed them.

GET /api/v1/workflows

Permission: Workflow:Read. List workflows — the v1 twin of GET /api/workflows above (same name/limit query parameters), MCP twin list_workflows.

Response:

{
  "data": [
    { "id": "abc123", "name": "Patch and Reboot", "description": "...",
      "steps": [ { "index": 0, "instruction_id": "windows-update-install", "condition": "",
                   "retry_count": 0, "retry_delay_seconds": 5, "foreach": "", "label": "",
                   "on_failure": "abort" } ],
      "step_count": 1, "created_at": 1710849600, "updated_at": 1710849600 }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

GET /api/v1/workflows/{id}

Permission: Workflow:Read. Fetch one workflow’s full detail, including yaml_source. The v1 twin of GET /api/workflows/{id} above, MCP twin get_workflow. 404 for an unknown or soft-deleted id.

GET /api/v1/workflow-executions

Permission: Workflow:Read, gated on the fleet-read chokepoint (ADR-0017), the same sole gate as the detail route below. Lists workflow runs (WorkflowEngine’s own multi-step run records, a different data model from the instruction Executions GET /api/v1/executions lists; a workflow run never appears there). MCP twin list_workflow_executions; there is no legacy unversioned list.

Rows carry metadata only: id, workflow_id, workflow_name, workflow_deleted (true when the workflow was soft-deleted; its history is kept), status, current_step, started_at, completed_at. Never agent ids, step results or agents_reached (use the detail route for those). Newest first by (started_at, id).

A confined caller (management-group or service-scoped) sees only runs with at least one agent in its scope. The scope is applied in the database before ordering and the page limit, so a run the caller cannot see never takes a page slot, next_cursor and the page length depend only on runs the caller may see, and a hand-made cursor naming a hidden run reveals nothing. A run whose stored agent list is malformed, empty or not an array is invisible to a confined caller. pagination.total is the number of rows in this page, never a fleet-wide count.

ParameterMeaning
workflow_idExact workflow filter (1-128 characters of A-Z a-z 0-9 _ -).
statusExact filter: pending, running, completed, failed, cancelled.
since / untilEpoch seconds, started_at >= since and started_at < until; 0 to 2^53; since must not be after until.
limitPage size, default 50. Below 1 is a 400; above 500 is clamped to 500.
cursorThe previous page’s next_cursor. A position only: it does not bind the other filters.
latest_per_workflowtrue returns at most one run per workflow: its newest run among those passing the other filters and the caller’s visibility (so a confined caller gets its newest visible run, never a blanked newest one), ordered by workflow_id, with no cursor. result_truncated_by_cap is true when more workflows matched than limit.

Input is strict: an unknown or repeated parameter, a value outside the vocabulary or bounds above, or cursor together with latest_per_workflow is a 400 (A4 envelope) rather than being ignored, so a mistyped filter can never read as “all runs”. No audit row is written for a successful list (run metadata, not operator-supplied step parameters); a refusal is audited by the gate as auth.fleet_read_required. A store failure or the 1.5 s statement timeout is a 503 with retry_after_ms, never an empty 200.

Scale. Only workflow_id is indexed (idx_wf_exec_workflow). since, until and status narrow the result but not the scan, and there is no started_at index, so a large run table is scanned and sorted on every call. A confined caller also pays to check the stored agent list of every candidate run, so a confined caller on a large table should always pass workflow_id. A read that does not finish inside the 1.5 s budget answers an honest 503 (never a short or empty page): narrow by workflow_id and retry.

Response:

{
  "data": [
    { "id": "exec-wf-9", "workflow_id": "wf-1", "workflow_name": "Patch and Reboot",
      "workflow_deleted": false, "status": "failed", "current_step": 1,
      "started_at": 1735693200, "completed_at": 1735693260 }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 50, "next_cursor": null },
  "meta": { "api_version": "v1" }
}

GET /api/v1/workflow-executions/{id}

Permission: Workflow:Read, gated on the fleet-read chokepoint (the legacy route now shares this same gate — see below). Record-level confinement: a caller with no agent visible in WorkflowExecution.agent_ids gets 404, byte-identical to a genuinely nonexistent id (never a narrower-but-still-present record) — not merely a field-level agent_ids filter. An admitted confined caller’s agent_ids array is narrowed to their visible agents, and each step’s result has its agents_reached count stripped (it’s the step’s full target-agent count, not the caller’s visible subset — emitting it verbatim would disclose the true out-of-scope agent count by simple arithmetic). The v1 twin of GET /api/workflow-executions/{id} above (which now applies the same confinement + agent_ids/agents_reached narrowing, previously unfiltered), MCP twin get_workflow_execution. Audited as workflow_execution.detail.fetch, set-and-proceed (not REST’s usual fail-closed-on-audit-failure posture — this file’s AuditFn predates the checked-bool try_persist_audit/emit_behavioral_audit helpers the Executions family below uses).

Response:

{
  "data": {
    "id": "exec-wf-1", "workflow_id": "wf-1", "status": "completed",
    "agent_ids": ["agent-A"], "current_step": 1,
    "started_at": 1735689600, "completed_at": 1735689620,
    "steps": [
      { "step_index": 0, "instruction_id": "windows-update-install", "status": "success",
        "result": { "status": "dispatched", "command_id": "cmd-1" },
        "started_at": 1735689600, "completed_at": 1735689610, "attempt": 1 }
    ]
  },
  "meta": { "api_version": "v1" }
}

For an unconfined (global-grant) caller, each step’s result also carries agents_reached (the step’s dispatch-time target-agent count) — stripped above for a confined caller, as described.

GET /api/v1/executions

Permission: Execution:Read, gated on the fleet-read chokepoint (ADR-0017), as is GET /fragments/executions (which used the plain permission check until #3526; its admission change is in docs/user-manual/upgrading.md; unlike this route, the fragment renders a gate 403/503 as an HTTP 200 note, and this route keeps its real 403/503); a confined caller sees only executions involving at least one visible agent (or that they dispatched; the fragment, unlike this route, does not apply the dispatched-by rule to service-scoped tokens, see #5557). The v1 twin of GET /fragments/executions, MCP twin list_executions (widened by this PR to the same field set). Accepts definition_id, status, and limit (capped at 500) query parameters.

Response:

{
  "data": [
    { "id": "exec-1", "definition_id": "def-1", "definition_name": "Collect OS Info",
      "status": "completed", "dispatched_by": "alice", "dispatched_at": 1735689600,
      "agents_targeted": 2, "agents_responded": 2, "agents_success": 1, "agents_failure": 1,
      "completed_at": 1735689620, "rerun_of": "", "error_preview": "boom" }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

GET /api/v1/executions/{id}?include=agents

Permission: Execution:Read (same gate as the bare route, docs/executions-history-ladder.md). ?include=agents is the #4030 per-agent status/duration expansion decided for this row: a query parameter on the existing detail route rather than a new one, so the fragment’s aggregate + per-agent capability stays a single genuine REST+MCP twin (MCP twin: get_execution_status with "include":["agents"]). Adds a confined agents array (agent_id, status, dispatched_at, first_response_at, completed_at, exit_code, error_detail) and a kpi object (total, succeeded, failed, p50_ms, p95_ms — null percentiles when no agent has a usable duration yet). Unlike the bare request, this expansion is audited (execution.detail.fetch, REST fail-closed 503 on an audit-persist failure) because it discloses raw agent identities. Response bodies are a separate route (below), not part of this expansion.

GET /api/v1/executions/{id}/responses

Permission: Response:Read, gated on the fleet-read chokepoint — deliberately a different securable than the detail route’s Execution:Read, so folding this into the detail-route expansion above would have over-disclosed response bodies to a caller who holds Execution:Read but not Response:Read. The v1 twin of MCP query_responses’ execution_id-scoped filter (no new MCP tool — query_responses already covers this shape). Accepts agent_id, status, since, until, limit (capped at 1000) query parameters. Does not accept offset — matching query_responses exactly: the result set orders by a non-unique, actively-growing timestamp while an execution is non-terminal, so offset-based paging would silently skip or duplicate rows; a caller-supplied offset is rejected with 400, not silently ignored. Scope pushdown mirrors query_responses exactly: the caller’s visible agent set is resolved and pushed into the store query before limit (ADR-0017 INV-3) — never filtered after the fact. Audited as execution.detail.fetch. Row shape does NOT mirror query_responses — this route returns the narrower 5-field shape (agent_id/execution_id/status/output/timestamp); query_responses was widened to 10 fields by #2146 A2-R2 on both its instruction_id and execution_id paths, but this REST route was not retrofitted onto the wider shared builder (tracked as #4647). The “twin”/“mirrors” language above is about query-parameter and scope-pushdown semantics only.

GET /api/v1/executions/{id}/children (#2146 A2-R1)

Permission: Execution:Read, gated on the fleet-read chokepoint - same gate, same confinement rules, and the same execution_child_row_json shared builder as the legacy GET /api/executions/{id}/children route documented above (docs/api-twin-recipe.md Rule 1). MCP twin: get_execution_children. An invisible or nonexistent parent returns 404 with no existence oracle; under a confined grant, each child is checked against the caller’s visibility independently of the parent’s own visibility - a visible parent does not by itself disclose a child dispatched by, or targeting, someone else (#3789). Not audited on a successful read (matches the legacy route’s own posture); a confined denial is audited as execution.read. The underlying query is hard-capped at 100 rows (governance Gate 8 re-review fix, #2146 A2-R1; no caller-visible limit/cursor), with the cap now pushed down TOGETHER WITH the caller’s own visibility scope, before LIMIT - a confined caller’s cap applies to their own visible children, not the fleet-wide raw row set, so an invisible sibling can no longer displace a visible child out of the capped window; data.result_truncated_by_cap: true means that scoped row set exceeded the cap.

Response:

{
  "data": {
    "children": [
      { "id": "exec-2", "status": "completed", "dispatched_at": 1735689700 }
    ]
  },
  "meta": { "api_version": "v1" }
}

When the 100-row (scoped) cap drops rows, data.result_truncated_by_cap is added (true):

{
  "data": {
    "children": [ { "id": "exec-2", "status": "completed", "dispatched_at": 1735689700 } ],
    "result_truncated_by_cap": true
  },
  "meta": { "api_version": "v1" }
}

GET /api/v1/schedules

Permission: same two-stage gate as GET /fragments/schedules: a service-scoped API token is denied the fleet-wide list outright (schedules carry no per-agent axis for fleet_read_fn to confine against), then Schedule:Read. The v1 twin of GET /fragments/schedules, MCP twin list_schedules (widened by this PR to include execution_count). Accepts definition_id and enabled_only query parameters (#2146 A2-R1). enabled_only is a real boolean here (true/1 or false/0; any other value 400s), matching the #4034 precedent already set on GET /api/v1/policies and MCP list_schedules - not the legacy unversioned GET /api/schedules route’s parsing, where any presence of enabled_only is treated as true regardless of value. This route remains a distinct, separately-ledgered twin of GET /fragments/schedules, not of GET /api/schedules documented above.

GET /fragments/schedules also takes view=page (the Workflows page’s Schedules panel) behind the same two-stage gate and the same read; a refused caller gets 200 with a data-denied note and a failed read 200 with data-degraded (the dashboard drops 4xx bodies). The page view adds the cadence the scheduler really applies (the time-of-day, weekday and day-of-month fields are stored and ignored), Count and Last run, and an owner flag computed from the session. A schedule the caller did not create shows only All devices or Targeted: the scope expression, interval, approval flag, parameters and creator are in no read that carries another user’s schedule (REST v1, MCP) and are not rendered for one. The page view also carries data-can-write, data-can-enable and data-can-delete on its list root (render hints probed once per render from Schedule:Write, Execution:Execute and Schedule:Delete, never a gate; each probe refused writes one auth.permission_required audit row) and per-row Enable/Disable and Delete buttons, disabled for a row the caller did not create. The default view is unchanged.

GET /fragments/workflows

HTML fragment behind the Workflows page (/workflows). Requires Workflow:Read through the same gate as GET /api/v1/workflows (a management-group-scoped-only caller is refused, as there). Lists the 100 newest definitions with their steps (label, instruction id, condition, retries, foreach, failure policy) and marks a cut list with data-truncated="1". A refused caller gets 200 with data-denied="true", a failed store read 200 with data-degraded="true"; neither is an empty list. Step rows carry the workflow’s own labels and instruction ids; an instruction’s name is looked up and shown only when the caller also holds InstructionDefinition:Read (a separate securable that Workflow:Read does not imply). The list root carries data-can-run, data-can-write and data-can-delete (0 or 1): render hints probed once per render from Workflow:Execute, Workflow:Write and Workflow:Delete, deciding which controls the page offers. They are not an authorization decision; the run, create and delete routes check their own grant. Each refused probe writes one auth.permission_required audit row. Twin: GET /api/v1/workflows, MCP list_workflows.

GET /fragments/workflow-runs

HTML fragment of workflow run rows for the Workflows page: each workflow’s newest run (latest_per_workflow=true&limit=500) or one workflow’s recent runs (workflow_id=<id>&limit=10). The sole gate is the fleet-read gate for Workflow:Read, the one GET /api/v1/workflow-executions uses; its scope narrows the rows in the store before ordering and limiting, so a confined caller’s rows are exactly the runs with a device in its scope. A latest_per_workflow list cut off by the limit is marked data-truncated="1", and the page then shows Unknown rather than Not run for a workflow the list did not include. Only workflow_id, status, limit and latest_per_workflow are accepted; any other parameter, a repeated one or an out-of-vocabulary value answers 200 with data-invalid="true" and a fixed note (a mistyped filter never reads as all runs). A refused caller gets 200 with data-denied="true", a failed read 200 with data-degraded="true"; an empty list says “No runs yet.” or, for a confined caller, “No runs include devices in your scope.” No audit row on a list. Twin: GET /api/v1/workflow-executions, MCP list_workflow_executions.

GET /fragments/workflow-runs/{id}

HTML fragment of one run: status, workflow, start, duration, device count (the count of the caller’s in-scope target devices when confined) and one row per step. Same sole gate and same record-level visibility rule as GET /api/v1/workflow-executions/{id}. A run the caller may not see and an unknown id both answer 404 with the identical body, so the route is not an existence oracle. Step titles are the workflow definition’s labels, falling back to the step’s instruction id. Error text is rendered only when the stored error is a plain string (never a nested object), skipped steps only from the three reasons the engine writes, and a confined caller sees “Dispatched (run-wide)” with no per-step device count. Audited as workflow_execution.detail.fetch (success, or denied for a hidden run), like the v1 twin. Twin: GET /api/v1/workflow-executions/{id}, MCP get_workflow_execution.

Response:

{
  "data": [
    { "id": "sched-1", "name": "nightly-scan", "frequency_type": "interval", "enabled": true }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

The underlying query is hard-capped at kScheduleListCap (100) rows with no limit/cursor parameter on this route (ADR-0031 WS-A4 schedule seam). When the cap drops rows, pagination.result_truncated_by_cap is added (true) — note this sits under pagination, not nested inside data the way the /executions/{id}/children cap flag above does; the two routes chose different envelope placements and a caller should not assume one shape from the other:

{
  "data": [ { "id": "sched-1", "name": "nightly-scan", "frequency_type": "interval", "enabled": true } ],
  "pagination": { "total": 100, "start": 0, "page_size": 50, "result_truncated_by_cap": true },
  "meta": { "api_version": "v1" }
}

The dashboard fragment (GET /fragments/schedules) and the MCP twin (list_schedules) share this same cap and the same underlying query, and each surfaces the truncation in its own shape: the fragment renders a partial-list HTML notice, MCP’s structuredContent carries its own result_truncated_by_cap field. See Dashboard UI — Schedules tab for the fragment’s rendered behaviour.


Command/Instruction Responses — v1 read twins (#2146 A2-R2)

Versioned REST + MCP twins of the legacy, unversioned GET /api/responses/{id} family (command/instruction-ID-keyed — not the execution-ID-keyed GET /api/v1/executions/{id}/responses documented above, a different, already-shipped capability). The legacy routes (response_routes.cpp) do not call the shared row builders; the query/aggregate/export semantics below mirror them, and since #4644/#4703 they share the strict numeric parser, the export row cap and the export byte cap. GET /api/v1/responses/{id} and MCP query_responses share one JSON row builder (response_query_row_json, docs/api-twin-recipe.md Rule 1); GET /api/v1/responses/{id}/aggregate and MCP aggregate_responses share another (response_aggregate_row_json).

Forensics-tier rows (#4990). Rows written by Forensics-tier plugins (app_usage, browser_inventory, execution_artifacts, privacy_permissions, remote_access_audit; the set is derived from the capability catalogue) are served only to callers who hold Forensics:Read in addition to Response:Read. This applies to every route that reads stored responses: the legacy GET /api/responses/{id}[/aggregate|/export], the v1 twins above, GET /api/v1/executions/{id}/responses, GET /api/v1/executions/{id}/visualization, GET /api/v1/bundles/{id} (a Forensics step stays pending with an empty output for a caller without a global grant, so that bundle never reports complete=true to that caller), and the GET /api/v1/management-groups/agent-count-preview count. To any other caller those rows are absent, not marked: there is no extra field or flag, and the row cap, the truncation flags, the aggregate counts and the agent counts are all computed over the rows the caller may read, so an empty or short result does not imply that no data exists. The one error is the unresolved case: if the server cannot determine whether the caller may read Forensics rows (the authorization store is unavailable; this applies to sessions, API tokens, MCP tokens and engine principals (an engine principal once its cached RBAC view is older than 5 seconds: an explicit grant is then admitted and a missing one is 403; the authorization model reference has the condition), while a service-scoped token is default-denied with a definitive 403), the route answers a retryable A4 503 (retry_after_ms 5000, no permission field) instead of a result with those rows silently missing; that shape is the Forensics check’s own, and when a whole-store outage fails the route’s earlier Response:Read gate first, that gate’s own answer is returned in its own shape (it can name a permission). yuzu_server_rbac_read_degrade_total (alert YuzuRbacReadDegraded) is the correlated signal. Forensics:Read is a global grant only: there is no management-group scoped read tier on these stored-response routes, and a hand-authored scoped grant reads nothing from them (the per-device application-usage read is a separate data set and keeps its own scoped gate). Service-scoped tokens, MCP-tier tokens and engine principals need the global grant like everyone else. With RBAC enforcement off, Forensics:Read is administrator-only, so a non-administrator session does not receive these rows. The built-in ForensicsAnalyst role is the delegable read-only holder (see rbac.md). A served read that includes at least one such row also writes a response.forensics.read audit row (detail=forensics_rows=<n> plugins=<names> surface=<s>, surface being rest for the v1 routes and legacy for the unversioned ones), fail-closed like the neighboring response.read row (503 + Sec-Audit-Failed: true, no data); aggregate, facet-option and count reads write none. For an export the forensics_rows count is taken before the byte cap trims the file, so it can exceed the rows the file holds. When the response.forensics.read row cannot persist and the route answers 503, the attempt has already left its neighboring success row (response.read or execution.detail.fetch, written first), and a retry leaves a second one. A caller using an API token, an MCP token or an engine principal that does not hold a global Forensics:Read also leaves one auth.permission_required denied audit row per read attempt (an interactive browser sign-in does not), so expect denial rows from automation that polls stored responses. The filter is per plugin, because only the plugin name is stored with a row, and it is a rule about these plugins’ rows, not about a kind of data: other plugins can return comparable information under their own securables, and their stored rows stay under Response:Read. The same filter is applied to the server’s own consumers of these rows: policy check output (such a policy scores unknown), result set membership (a set sourced from such a command materializes empty) and the live output stream, which publishes no row of a Forensics-tier plugin on GET /events, GET /sse/executions/{id} or GET /api/v1/events. (Such a frame publishes nothing to the live output stream, not a row and not a notice, and nothing is published there when the plugin set is unwired or the frame carries no output. The timing and command-status events, which carry no plugin output, still go out, and a dashboard dispatch still resets the Console to “Waiting for results…”; the Console’s results table therefore stays on that placeholder for such an action, so open the execution results instead.) The per-agent error_detail of a Forensics-tier command (execution routes, include=agents) is a short machine token (scope:cause[:detail]) or the fixed value forensics_error; status and exit code are unchanged. The failure notification, the analytics event and the webhook and offload payload carry the same reduced text. A response is labelled with the plugin the server recorded when it dispatched the command, not with the command id the agent echoes; a response with no server record is labelled ~unverified, which is part of the Forensics set, so it is withheld from a caller without Forensics:Read like any other Forensics row (see “Provenance” in docs/authz-model.md).

Numeric query parameters are parsed strictly (#4644), on these routes AND the legacy GET /api/responses/{id}[/aggregate|/export] routes. status, since, until, limit (and the legacy catch-all route’s offset) must each be one complete base-10 integer, or the route answers 400 invalid numeric query parameter. These were read by std::stoi/std::stoll, which stop at the first non-digit and accept a leading + or whitespace, so a malformed value became a different, valid-looking filter. Newly rejected: trailing characters (100abc was 100), hex and exponent forms (0x1 was 0, 1e9 was 1), decimals (1.5 was 1), whitespace and a leading +. Already 400 before: an empty value (?limit=) and a value too large for its type; there is no wraparound. Zero-padding (status=007) is still a valid 7. status has one extra rule: -1 is the documented “any status” sentinel and stays accepted, but any value below -1 (for example status=-5) is rejected, because the store only applies the filter for status >= 0, so it previously behaved exactly like “no filter” while looking like one. A script that sends a fractional epoch (date +%s.%N) must send integer seconds. since and until are each optional, and omitting one leaves that side unbounded. A supplied since must be 0 or more (since=0 is accepted and matches exactly what omitting it matches, because every stored timestamp is positive). A supplied until must be 1 or more: until=0 is 400, as is any negative value of either. Both used to be read as “unbounded”, so a window computed as now - n that went past the epoch silently returned the whole result; 0 is no longer an unbounded sentinel on the upper side, so a computed window that collapses to 0 fails loudly instead. Both bounds are inclusive. GET /api/v1/executions/{id}/responses shares the parser. MCP query_responses applies the same rule to its JSON arguments, with its own error codes (see its entry in the MCP tool reference). Each rejection increments yuzu_server_response_param_rejected_total{surface} (Metrics); a rejected request is not audited.

Differences between the v1 and the legacy routes:

status is the response status enum (1=SUCCESS, 2=FAILURE; 0 is an in-flight RUNNING frame), same enum as the bundle-steps status field documented above.

A caller cannot tell a complete result from a capped one from row count alone. GET /api/v1/responses/{id} sets pagination.result_truncated_by_cap: true when the served row count equals limit, matching MCP query_responses’ own hit_cap convention (it can be set when the result is exactly limit rows long). GET /api/v1/executions/{id}/responses clamps limit to 1000 silently and carries no such flag at all. The two export routes signal exactly: they report a cut only when matching rows were actually left out, by the row cap or the byte cap below. There is no cursor on these routes and offset is rejected (see above), so to read past a cap either pull once per agent_id, or move the window with until set to the oldest timestamp you received. until is inclusive (timestamp <= until), so rows tied at that second come back again: de-duplicate on id. A window in which more than limit rows share a single timestamp cannot be split this way. The list routes order ties by timestamp alone, so rows tied at the boundary can also arrive in a different order on each call; the export routes order ties by id descending.

Export body size cap (#4703). Both export routes (this family’s GET /api/v1/responses/{id}/export and the legacy GET /api/responses/{id}/export) also stop once the rows served carry 50 MiB of payload (output plus error_detail, as stored), on top of the row-count cap: each response’s output/error_detail is cut to only 2 MiB of raw bytes at ingest (including the error_detail a terminal frame writes), so a row-count cap alone still let a 10,000-row export serialize to tens of GB. The cut is made inside the store query: the database keeps rows while the payload of the rows before them is under the cap, so the fetch holds about 50 MiB of payload plus one final row, not every row up to limit. The cap is on whole rows, so the last row kept can run past it by up to its own size. Each of output and error_detail is cut to 2 MiB of raw bytes at ingest, and only afterwards is each invalid byte or NUL replaced by the 3-byte U+FFFD, so a row is about 4 MiB for text output and can reach about 12 MiB for output dense in invalid bytes or NULs. That holds for the error_detail written when a terminal frame closes a running row too. At least one row is always served, so a single row larger than the cap is still returned. A second check while serializing counts what each format builds (CSV quoting and JSON framing make the serialized row larger than its raw payload): for JSON it is the serialized size of each row object, which excludes the commas between rows, the envelope and the legacy route’s pretty-print whitespace; for CSV it is the whole body so far, header, quoting and commas included. So the body is approximately, not exactly, bounded, and a result under 50 MiB of raw payload can still be cut and flagged byte_cap when its escaped form crosses the cap. One measurement, 400 rows of 512 KiB: the store query’s peak resident memory rose by 99 MiB with this bounded fetch, against 398 MiB with the unbounded query() fetch; other row shapes were not measured, and the serialized body built afterwards is additional. These named routes are not covered by the byte cap: the plain list routes (GET /api/v1/responses/{id}, the legacy GET /api/responses/{id}, MCP query_responses and GET /api/v1/executions/{id}/responses) are capped by row count only (at most 1000 rows of up to 2 MiB of raw bytes per field, before the U+FFFD growth described above); the dashboard results fragment’s FILTERED branch (it reads by response id, not through query_bounded) is not bounded either. Bounded by the same 50 MiB cap in SQL (#4644): the execution visualization route (GET /api/v1/executions/{id}/visualization), the dashboard results fragment’s unfiltered read and the Echo retention-paused scan page’s read, each up to 10,000 rows; a cut there is signalled (see those routes), never silent. Other internal reads also bypass it and take their limit from something other than a request parameter; for example (not an exhaustive list), the fleet visualization snapshot’s collect poll sizes it from the number of agents it dispatched to (dispatched.size() + 16), the deployment poll uses a fixed 50,000, the pre-flight per-check read uses a fixed 50,000, a bundle execution’s result read uses 1000 and an execution-detail page read uses 500. The cap is not operator-tunable.

Detecting a cut export. A cut is marked on both export routes and both formats. JSON carries pagination.result_truncated_by_cap: true (v1) or a top-level result_truncated_by_cap: true (legacy envelope, new there). CSV carries an in-band trailer record, the one signal that reaches every consumer: after the data rows, one extra record whose first field is # result_truncated_by_cap cause=row_cap (or cause=byte_cap) padded with empty fields to the width of the header (7 columns on the legacy route, 10 on v1). A strict CSV parser that expects a number in the id column fails on it; that is the intent, since a cut file must not be read as complete. An uncut export never has it and is byte-identical to an export without the feature. A quoted cell can legitimately contain text that looks like the trailer (agent output is arbitrary), so parse the file as CSV and read the final record; do not regex-match lines. Two out-of-body signals accompany a cut: the X-Result-Truncated-By-Cap: true response header (CSV only; the JSON formats carry the field instead) and the download name (both formats), responses-<id>-truncated.json or .csv instead of responses-<id>.json or .csv in Content-Disposition. Any character of <id> outside [A-Za-z0-9._-] is written as _ there (only the legacy route’s id pattern, [^/]+, can carry one; the v1 routes admit [A-Za-z0-9_-] only), so a legacy export of an id such as cmd:1 is downloaded as responses-cmd_1.csv. Those two are lost by a plain curl -o out.csv ..., which names the file itself and discards the headers; curl -sS -D - -o out.csv ... prints the headers alongside the file, and curl -OJ keeps the server’s file name. A body that merely ends on the row that crosses the cap is not truncated. Each cut also increments yuzu_server_response_export_truncated_total{surface,cause} with cause row_cap or byte_cap.

Audit posture: all three routes below emit a response.read audit event, REST fail-closed (503 on an audit-persist failure, docs/api-twin-recipe.md §4) — a deliberate addition vs. the legacy routes before #4644; the legacy routes now audit successful reads the same way, fail-closed. A scope-drop still emits its own distinct denied row (CC7.2 evidence), as the legacy routes do.

GET /api/v1/responses/{id}

Permission: Response:Read, gated on the fleet-read chokepoint (ADR-0017) — resolve-then-scope: the caller’s visible agent set is resolved and pushed into the store query before limit, never filtered after the fact. The v1 twin of GET /api/responses/{id} above, MCP twin query_responses’s instruction_id path. Accepts agent_id, status, since, until, limit (clamped to [1,1000], default 100) query parameters. Does not accept offset (see above).

Response:

{
  "data": [
    { "id": 42, "instruction_id": "instr-1", "agent_id": "agent-A", "execution_id": "exec-1",
      "status": 1, "output": "ok", "error_detail": "", "timestamp": 1735689600,
      "plugin": "shellexec", "received_at_ms": 1735689600123 }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

GET /api/v1/responses/{id}/aggregate

Permission: Response:Read, same resolve-then-scope confinement (filter-before-aggregate) as the query route above. The v1 twin of GET /api/responses/{id}/aggregate above, MCP twin aggregate_responses. group_by is status or agent_id; op is one of count/sum/avg/ min/max (default count); op_column (only meaningful for sum/avg/min/max) is one of timestamp/status/id, defaulting to id when omitted. An invalid group_by/op_column is a 400, validated against ResponseStore’s own allow-list before the aggregate query runs — never mapped to a 503 (a client typo must not read as store degradation). A 503 (with retry_after_ms) means the response store is degraded or not initialized, the response.read audit row could not persist, or the caller’s Forensics authority could not be determined (retryable). Rows of Forensics-tier plugins are absent from every group and total for a caller without Forensics:Read, with no marker, so a small or zero total does not prove that no data exists; an aggregate writes no response.forensics.read row (see “Forensics-tier rows” above).

Response:

{
  "data": {
    "instruction_id": "instr-1",
    "groups": [ { "group_value": "0", "count": 2, "aggregate_value": 1735689700.0 } ],
    "total_groups": 1,
    "total_rows": 2
  },
  "meta": { "api_version": "v1" }
}

GET /api/v1/responses/{id}/export

Permission: Response:Read, same resolve-then-scope confinement as the routes above. The v1 twin of GET /api/responses/{id}/export above (no MCP twin yet). format is json (default) or csv — an unrecognised value falls through to json, matching the legacy route’s own behavior exactly (neither route rejects an unknown format with 400); limit is clamped to [1,10000] on both bounds (see the differences above). Does not accept offset (see above) — rejected with 400, same non-unique-timestamp-ordering rationale as GET /api/v1/responses/{id}. Unlike the legacy export’s narrower 7-column CSV, both formats here carry the same field set as GET /api/v1/responses/{id} (id, instruction_id, agent_id, execution_id, status, output, error_detail, timestamp, plugin, received_at_ms) — this is a brand-new endpoint with no positional-column consumer to keep compatible. Both formats set Content-Disposition: attachment. The JSON format’s envelope is the standard v1 {data, pagination, meta} shape (same as GET /api/v1/responses/{id} above) - distinct from the legacy export’s bespoke {instruction_id, count, responses} body. A cut is most consequential here since bulk export is this route’s whole purpose: see “Detecting a cut export” above (row cap or the 50 MiB payload cap; the download is renamed -truncated); there is no cursor to page past 10,000 rows; narrow with agent_id/status, or move the window with until as described above (inclusive, so de-duplicate on id).

Response (format=json):

{
  "data": [
    { "id": 42, "instruction_id": "instr-1", "agent_id": "agent-A", "execution_id": "exec-1",
      "status": 0, "output": "ok", "error_detail": "", "timestamp": 1735689600,
      "plugin": "shellexec", "received_at_ms": 1735689600123 }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

When the export was cut, pagination also carries "result_truncated_by_cap": true and the Content-Disposition file name ends -truncated.json:

"pagination": { "total": 10000, "start": 0, "page_size": 50, "result_truncated_by_cap": true }

OpenAPI Spec

GET /api/v1/openapi.json

Returns the OpenAPI/Swagger specification for the v1 API as JSON.

Permission: Infrastructure:Read (#2057 — was unauthenticated; now matches the MCP yuzu://openapi resource twin’s gate, mcp_server.cpp).

Response: OpenAPI 3.x JSON document describing all v1 endpoints, schemas, and authentication methods.

Query parameter: guides (default off). By default the document leaves out the per-group guides, the x-yuzu-guide text on the top-level tags, which are written for people reading the /swagger page. The parameter turns them on only when its value is exactly true or 1, in lower case; any other value, such as TRUE or yes, returns the default form. If the parameter is repeated, only the first value is read. The two forms are not the same text with a filter applied: ?guides=true returns the document exactly as it is written in the server, while the default form is parsed and written out again without the guides, so key order and whitespace can differ although the content is the same apart from the guides. The MCP resource yuzu://openapi and the discover tools always serve the default form, so an agentic worker does not read the guide text.

Caching. Each form carries its own strong ETag (a hash of the bytes served) and Cache-Control: no-cache, private, so a client stores the document but asks the server before each reuse, and a shared cache never stores a document that only an authorised caller may read. Send the ETag back in If-None-Match and a document that has not changed answers 304 with no body, which spares the server re-sending and compressing a large document. The comparison is exact: If-None-Match must equal the ETag of the form requested, byte for byte. A weak validator (W/"..."), a list of validators, *, or the ETag of the other form is answered with the full document and a 200, so a proxy that weakens or merges validators costs the saving, not correctness. The permission check runs first, so a caller with no session gets 401, and a caller without Infrastructure:Read gets the 403, whatever it sends in If-None-Match. Building the response is not free: the server compresses the document with brotli for a client that accepts it, once per request that is not answered 304, and does not cache the compressed bytes (see Upgrading).

Extensions and identifiers in the document. Every operation has an operationId, unique across the document. It is the lower-case method followed by the path in PascalCase, with each path parameter written as By and the parameter name, and the /api/v1 base left out: GET /hardware/{id} is getHardwareById, POST /hardware/{id}/sync is postHardwareByIdSync, and DELETE /upload-grants/{grant_id} is deleteUploadGrantsByGrantId. An operation that the running server does not serve, because the store behind it is not started, carries an x-yuzu-unavailable string explaining that it is documented but not registered; 14 operations carry it today. Group guides live under x-yuzu-guide on the top-level tags, as described above.

GET /swagger (dashboard)

A browsable reference for the same document, in the dashboard. It lists every operation grouped by tag, with a search box and filters for method, group and whether the operation declares a typed response schema. Each operation expands to show its description, a curl request (with -g so the braces of a path parameter are not globbed, and for POST, PUT and PATCH a JSON content type and a placeholder -d '{}' body), its parameters, its request body and its responses, with response schemas laid out property by property. A link such as /swagger#GET%20%2Fhardware opens one operation directly.

Each group opens with a one-sentence summary and a Guide button. The guide says what the group is for, how its data gets there where there is a flow, what it holds, which permissions and audit records apply, and what a caller should watch for, followed by the data model as expandable field tables. Selecting a group in the filter opens its guide, and /swagger#g-Hardware links straight to one.

The page loads /api/v1/openapi.json?guides=true with your own session, so it shows exactly what that route returns. It takes the same Infrastructure:Read gate; an operator without it gets a 403. An unauthenticated visitor is redirected by the server’s pre-routing session check to the sign-in landing page, /home (/swagger is not a screen the landing page can return to, so the redirect carries no next key). It has no sidebar entry, and a successful load writes no audit row. The Developer page links to it for administrators only, but the page is not admin-only: anyone with Infrastructure:Read can open /swagger by its address, because it shows nothing the same holder cannot already read from /api/v1/openapi.json.

When the page cannot load. The permission check runs before the page is sent, so an operator without Infrastructure:Read never sees the page shell: the browser tab shows the raw 403 JSON error body from the server. Once the page is open, a failed fetch of the document replaces the loading text with a message that names the HTTP status, plus the correlation id when the server sent one (from the error body, or from the X-Correlation-Id header when the body is not JSON), and a Retry button that fetches again. A 401 adds a note that the session may have ended, and a 403 adds a note naming the Infrastructure:Read permission. The fetch is abandoned after 30 seconds and the page then reports that the description took too long to load, again with Retry.

Operations the running server does not serve, because the store behind them is not wired in (the License Management, Device Tokens and Software Deployment groups today), are marked unavailable and say so at the top of their detail view. Operations whose success response has only a prose description are marked untyped.


Discovery (A2)

Agentic-first discovery family (roadmap Issue 17.1, docs/agentic-first-principle.md §A2): “an agentic worker should be able to learn what is possible from the live server alone, without a side-channel doc fetch.” Like GET /api/v1/openapi.json above, every endpoint here is authenticated and gates Infrastructure:Read (/discover/instructions gates InstructionDefinition:Read instead). Each response body IS the catalog object directly — no data/meta envelope wrapper, matching the GET /api/v1/guaranteed-state/schemas discovery precedent this family is modeled on.

All seven share the same revalidation contract: a content-derived ETag header; send If-None-Match: <etag> to get a cheap 304 Not Modified instead of re-downloading. instructions, routes, scope-kinds, plugin-docs and plugin-docs/{name} are Cache-Control: public, max-age=300; permissions and plugins are private with a Vary header because their bodies depend on the caller. Five are mirrored as read-only MCP tools of the same name (discover_permissions, discover_instructions, discover_routes, discover_scope_kinds, discover_plugins); /discover/plugin-docs is mirrored as the MCP resource yuzu://plugin-docs, and /discover/plugin-docs/{name} as the MCP resource template yuzu://plugin-docs/{name} (resources/templates/list). REST and MCP share the same builder functions internally, so they cannot drift from each other.

GET /api/v1/discover/permissions

RBAC permission catalog. The response has two halves with different permissions (#2376):

A caller holding Infrastructure:Read but not UserManagement:Read still gets 200 and the full taxonomy; the grid is replaced by "roles_omitted": true plus a roles_omitted_reason. The omission is declared, never silent — roles absent with no roles_omitted flag would mean “no roles exist”, which is a different fact.

Permission: Infrastructure:Read (taxonomy) · UserManagement:Read (role grid)

Caching: because the body varies with the caller’s grants, this route responds Cache-Control: private, max-age=300 with Vary: Authorization, Cookie — never public. A shared cache must not store one caller’s representation and serve it to another. GET /api/v1/discover/plugins is private for the same reason (its parameter_schema enrichment is gated on InstructionDefinition:Read); the caller-independent catalogues (instructions, routes, scope-kinds) remain public, max-age=300. Vary names all three credential channels this server accepts — Cookie, Authorization and X-Yuzu-Token — because a caller-local cache keyed on only some of them can serve one API-token caller’s representation to another.

Response:

{
  "version": 1,
  "description": "RBAC permission catalog: ...",
  "securable_types": ["Infrastructure", "InstructionDefinition", "Execution", "..."],
  "operations": ["Read", "Write", "Execute", "Delete", "Approve", "Push", "Attest", "Rotate"],
  "roles": [
    {
      "name": "Administrator",
      "description": "...",
      "is_system": true,
      "permissions": [
        {"securable_type": "Infrastructure", "operation": "Read", "effect": "allow"}
      ]
    }
  ]
}

Without UserManagement:Read the roles key is replaced by:

{
  "roles_omitted": true,
  "roles_omitted_reason": "requires UserManagement:Read (#2376 authorization-topology floor); the securable_types and operations taxonomy above is unaffected"
}

503 ({"error":"service unavailable"}) when the RBAC store is unavailable.

GET /api/v1/discover/instructions

Published (enabled_only=true) InstructionDefinition catalog, the commands an agentic worker may dispatch via execute_instruction (MCP) or POST /api/instructions/{id}/execute (REST). A disabled definition is excluded; there is no flag distinguishing “excluded because disabled” from “never existed.”

Permission: InstructionDefinition:Read

Response:

{
  "version": 1,
  "description": "Published (enabled) InstructionDefinition catalog: ...",
  "count": 42,
  "truncated": false,
  "instructions": [
    {
      "id": "def-abc123",
      "name": "Get Hostname",
      "plugin": "system_info",
      "action": "query",
      "description": "...",
      "parameter_schema": {"type": "object", "properties": {}},
      "input_schema": {"type": "object", "properties": {}, "additionalProperties": false},
      "input_schema_error": null,
      "platforms": "windows,linux,darwin",
      "approval_mode": "auto"
    }
  ]
}

input_schema is the canonical JSON Schema the server enforces on POST /api/instructions/{id}/execute: build params from it. It is produced by the same canonicaliser as that route’s validator (DSL types such as int32 become integer with the int32 range, displayName and other DSL-only keys are dropped, an inline required is hoisted to the root, and the root carries additionalProperties: false, so an undeclared parameter is refused). It is null in three cases that input_schema_error tells apart: input_schema_error is null when the definition declares no parameter schema (stored empty or {}: nothing is enforced); the fixed token parameter_schema_not_canonicalisable when a stored schema cannot be canonicalised (the execute route refuses such a definition with a 500); and the fixed token input_schema_budget_exceeded when canonicalising THIS schema would have taken the request past 4 MiB (kDiscoveryCanonicalBudgetBytes) of stored schema text in total. The test is per definition: definitions are visited in name order (ties broken by id), one that does not fit is skipped, and a smaller later one can still get its input_schema. Which definitions are affected depends on the catalogue order and the schema sizes; for an unchanged catalogue the outcome is the same on every request, so retrying does not change it. The raw parameter_schema is still published, and the shipped catalogue uses about 2% of the budget. Canonical schemas are not cached; the budget bounds canonicalisation work only. Each request still parses and depth-scans every stored parameter_schema (at most 5000 definitions, and the store’s write gate caps a new schema at 256 KiB, though a legacy row can be larger), and the response size grows with the catalogue. This route is served with Cache-Control: public, max-age=300, so a client can hold an input_schema up to five minutes older than an edit to the definition: after editing one, re-fetch with a conditional request (If-None-Match carrying the last ETag). The canonicaliser does not compile regular-expression patterns, so input_schema can be non-null while the execute route still refuses the stored schema, for any reason prepare_param_validator rejects that canonicalisation does not check: a pattern that does not compile or exceeds the RE2 program budget, a default that violates its own constraints, and any rejection by the shared schema compiler (for example non-numeric bounds, inverted bounds, or a keyword on the wrong type). The store’s write gate blocks new rows like that; only a legacy row can carry one. parameter_schema is unchanged and kept for existing readers; prefer input_schema.

How a client should read input_schema:

parameter_schema is the stored definition schema as a nested object (not a string; the raw DSL form, not a standard JSON Schema: int32, displayName) when the stored value parses as JSON and is itself a JSON object; null when the stored value fails to parse (the authoring path always stores at least {}, so this case needs a non-standard write to reach), or when it parses to something other than an object, e.g. an array or string (only a legacy or non-standard write can store one: the store refuses a non-object schema, and the only REST route that can supply a parameter_schema at all is POST /api/instructions/import). Same rule GET /api/v1/discover/plugins already follows for its inline parameter_schema.

GET /api/v1/discover/routes

REST route catalog — a subset of the SAME OpenAPI document GET /api/v1/openapi.json serves (they read the identical compiled-in spec, so they cannot disagree), reshaped to {method, path, summary, tags, description} rows. An operation that is documented but not registered on a running server (see the x-yuzu-unavailable extension under GET /api/v1/openapi.json) also carries an unavailable string with the same text; the field is absent on every other row. Do not call a row that has it.

Permission: Infrastructure:Read

Response:

{
  "version": 1,
  "source": "openapi",
  "description": "REST route catalog, subset of the SAME document GET /api/v1/openapi.json serves...",
  "caveat": "This catalog is derived from the hand-maintained OpenAPI document, NOT generated from the live route table. A route that exists but was never documented in the OpenAPI spec will be under-reported here too. ...",
  "count": 210,
  "routes": [
    {"method": "GET", "path": "/api/v1/discover/routes", "summary": "REST route catalog (A2 discovery)", "tags": ["Discovery"], "description": "..."}
  ]
}

Read the caveat field: this is a snapshot of a hand-maintained document, not a generated live route table — treat it as informative, not exhaustive.

GET /api/v1/discover/scope-kinds

Scope DSL kinds, operators, and syntax the Scope Engine (server/core/src/scope_engine.hpp) and AgentRegistry::evaluate_scope accept when building a scope expression for a dispatch, policy, or Baseline. Fully static — answers even when every store is unavailable.

Permission: Infrastructure:Read

Response:

{
  "version": 1,
  "description": "Scope DSL kinds and operators recognized by ...",
  "ground_kinds": [
    {"kind": "__all__", "syntax": "__all__", "example": "__all__", "description": "Every enrolled agent."},
    {"kind": "group:<name>", "syntax": "group:<name>", "example": "group:finance-laptops", "description": "..."}
  ],
  "attribute_kinds": [
    {"kind": "tag:<key>", "syntax": "tag:<key> <op> <value>", "example": "tag:department == \"finance\"", "description": "..."}
  ],
  "operators": [
    {"token": "==", "name": "Eq", "description": "Case-insensitive equality."}
  ],
  "extended_forms": [
    {"form": "LEN(<attr>) <op> <value>", "example": "LEN(hostname) > 5", "description": "..."}
  ],
  "combinators": ["AND", "OR", "NOT"]
}

See also docs/scope-walking-design.md and docs/asset-tagging-guide.md for the fuller narrative treatment of the DSL.

GET /api/v1/discover/plugins

Plugin/action catalog observed across currently-connected agents (deduplicated by plugin name; the richest reported action list wins). Not a build-time manifest of every plugin that could ever load — a plugin no currently-connected agent reports is absent.

Permission: Infrastructure:Read

Response:

{
  "version": 3,
  "description": "Plugin/action catalog observed across currently-connected agents ...",
  "limitation": "An action carries an inline parameter_schema only when it has a published InstructionDefinition (matched on plugin+action) AND the caller holds InstructionDefinition:Read; otherwise name+description only. GET /api/v1/discover/instructions is the full schema-bearing catalog.",
  "actions_enriched_with_schema": 1,
  "plugins": [
    {"name": "processes", "version": "1.0", "description": "...", "actions": [
      {"name": "list", "description": "...", "parameter_schema": {"type": "object", "properties": {}}}
    ]}
  ],
  "commands": ["processes", "processes list", "..."]
}

An action carries an inline parameter_schema only when it has a published InstructionDefinition (matched on plugin + action) and the caller holds InstructionDefinition:Read; a caller with only Infrastructure:Read gets each action’s name + description and no schema. The top-level actions_enriched_with_schema counts how many actions were enriched. The inline parameter_schema here is the raw stored DSL form (int32, displayName), not a canonical JSON Schema; the canonical form is input_schema in GET /api/v1/discover/instructions, which is the complete schema-bearing catalog.

Consumer note: this catalog is now "version": 3 (was 1; v2 added the inline parameter_schema and top-level actions_enriched_with_schema fields). The revision is additive; treat version as a minimum (>= 1), not == 1, so future additive revisions do not break your client.

Each plugin entry also carries docs: a build-embedded documentation summary {summary, kind, platforms, readme, resource} (kind = {collector, mutating, gathered}) when the plugin has adopted the README standard (docs/plugin-readme-standard.md), or an explicit null when it has not (catalog version 2 → 3). resource names the per-plugin endpoint below (or the MCP resource template of the same shape). The full manifest is that endpoint, or the whole-catalog one above it.

GET /api/v1/discover/plugin-docs

Per-plugin documentation as data: one manifest per agent plugin that has adopted the README standard, generated by tools/plugin-doc-gen from agents/plugins/<name>/README.md and embedded at build time (docs/plugin-readme-standard.md rule 10). Each manifest carries how the plugin works, per-OS support/rung/mechanism per action (from the CI-verified capability matrix), privileges, inputs, output columns with vocabularies and examples, sample rows per OS, caveats and source paths. Fully static — compiled-in content only, never fleet-derived — so it answers during warmup like /discover/scope-kinds, and it is byte-identical to the MCP resource yuzu://plugin-docs. A plugin absent here has not adopted the standard yet.

Permission: Infrastructure:Read

Response:

{
  "catalog": "plugin-docs",
  "version": 1,
  "source": "build-embedded",
  "description": "Per-plugin documentation as data ...",
  "plugin_count": 2,
  "skipped_invalid": 0,
  "plugins": [
    {
      "manifest_version": 1,
      "name": "disk_actions",
      "version": "1.0.0",
      "description": "Reports physical drive health and the mapping between drives and the logical volumes they back",
      "kind": {"collector": true, "mutating": false, "gathered": false},
      "platforms": {"windows": "supported", "macos": "constrained", "linux": "unsupported"},
      "security": [{"action": "smart", "securable": "Inventory", "operation": "Read", "risk_tier": "Low", "dispatch_class": "ReadOnly", "mutability": "None", "execute_gate": "None"}],
      "actions": [{"action": "smart", "definition_ids": ["crossplatform.storage.smart"], "legs": {"windows": {"support": "supported", "rung": "1", "mechanism": "IOCTL_STORAGE_QUERY_PROPERTY ...", "fallback": "..."}}}],
      "definitions": [{"id": "crossplatform.storage.smart", "display_name": "Drive Health", "platforms": ["windows", "darwin"], "approval_mode": "auto", "execute_roles": ["endpoint-admin", "endpoint-operator"]}],
      "inputs": [],
      "outputs": [{"definition_id": "crossplatform.storage.smart", "columns": [{"name": "health", "type": "string", "values": ["ok", "warning", "failing", "unknown", "unsupported"], "example": "ok", "platforms": ["windows"]}]}],
      "how_it_works": "Both actions are reads. ...",
      "outputs_note": "Pipe-delimited rows, one per drive or volume. Field 0 is a literal discriminator ...",
      "privileges": [{"os": "Windows", "runs_as": "...", "grant": "None. ...", "measured": "...", "if_refused": "..."}],
      "result_status": [{"status": "`CONSTRAINED`", "completeness": "partial", "provenance": "`macos:iokit:health_unread`", "when": "..."}],
      "where_the_data_goes": ["**Instruction result only.** ..."],
      "samples": {"macos": {"stamp": {"os": "macos", "os_version": "macOS 26.6.2 arm64", "host_class": "bare-metal", "date": "2026-09-07", "privilege": "euid 501", "leg_hash": "f062fb9a3dfd"}, "actions": [{"action": "smart", "rows": ["smart|disk0|APPLE SSD AP0512Z|nvme|ssd|unknown|-|-|..."], "row_count": 2, "result_status": {"status": "CONSTRAINED", "completeness": "PARTIAL", "provenance": "macos:iokit:health_unread"}}]}},
      "caveats": ["**Health is NVMe-only on Windows.** ..."],
      "source": {"plugin": ["agents/plugins/disk_actions/src/disk_actions_plugin.cpp"], "tests": ["tests/unit/test_disk_actions_local_dispatcher.cpp"]},
      "readme": "agents/plugins/disk_actions/README.md",
      "leg_hash": "f062fb9a3dfd"
    }
  ]
}

Same ETag / Cache-Control: public, max-age=300 / If-None-Match → 304 contract as /discover/scope-kinds (the body is identical for every caller). inputs[] entries carry {definition_id, name, type, required, default, constraints, description}, where constraints is the parameter’s DSL validation object or null; the key set of each manifest is the “Manifest schema” table in docs/plugin-readme-standard.md, which the docs suite binds to the generator.

GET /api/v1/discover/plugin-docs/{name}

One plugin’s manifest, narrower than the whole-catalog route above (#4108) — the exact same object the catalog carries at plugins[] for that name, byte-identical. Same manifest_by_name builder as the catalog and as the MCP resource template yuzu://plugin-docs/{name}, so all three cannot drift from each other. The permission gate runs BEFORE the name lookup, so a caller without Infrastructure:Read learns nothing about which plugin names exist.

Permission: Infrastructure:Read

Response: the single manifest object shown above for disk_actions (same key set, no wrapping envelope — no catalog/version/plugins[]).

Errors: 404 (A4 envelope) when no manifest documents that name.

Same ETag / Cache-Control: public, max-age=300 / If-None-Match → 304 contract as the catalog route.


Developer and API page

Settings > Developer and API (GET /settings/developer, administrators only; others are redirected to the dashboard) is a front for the discovery routes above, plus a load generator control. It adds no REST route and no MCP tool: its four read panels are GET /fragments/settings/developer/api, .../routes, .../permissions and .../plugins (plus .../plugins/observed, loaded on request), which call the same builders in process. Every fragment is administrators only, like the page, and then takes the same Infrastructure:Read gate as the REST twins. The role grid in the permissions panel needs UserManagement:Read and is otherwise replaced by a note; that check is a draw-time decision and writes no denial row for a dashboard session (a token caller keeps its denial row).

The process data fetch control is not part of this page.

Inventory

Inventory data is structured per-plugin telemetry collected from agents and stored server-side. The generic store (InventoryStore, ADR-0037) is Postgres-backed with an AUTHORITATIVE read posture — a store/pool/query degrade returns 503, never a silent empty result.

The generic store never returns the typed daily-sync sources (installed_software, app_perf, device_ci, software_licensing, app_usage and the reserved certificate_inventory; ADR-0070 D12). They are absent from the table list and from query results, and a GET of one for an agent is 404, as for any source the agent has not reported. Each has its own typed route and gate, for example the software inventory and app-usage routes; certificate_inventory is reserved, and its route arrives with its store.

GET /api/v1/inventory/tables

List all inventory tables (one per plugin that reports inventory data).

Permission: Inventory:Read

Response:

{
  "data": [
    {
      "plugin": "hardware",
      "agent_count": 142,
      "last_collected": 1711900800
    }
  ],
  "pagination": { "total": 12 },
  "meta": { "api_version": "v1" }
}

Errors:

StatusReason
503Inventory store unavailable or degraded

POST /api/v1/result-sets/from-inventory-query

Creates an owner-scoped result set from an inventory query (scope-walking producer; see docs/scope-walking-design.md for the full result-set surface). One behavior worth calling out here: when the underlying inventory read hits the server row cap or 8 MiB aggregate payload cap, the route returns 503 (“inventory query truncated … refusing to materialise a partial result set”) rather than persisting a silently-incomplete set — a fleet-targeting set is never silently narrowed. (#4496) A candidate inventory record excluded by the JSON depth guard (a poisoned/over-nested data_json row; the exclusion check runs before condition matching, so a record’s plugin/fields need not relate to the query’s conditions to trigger it; see json-dump-depth-guard below) gets the identical treatment: the route returns 503 (“inventory record(s) excluded for nesting too deeply … refusing to materialise a result set narrower than the true match set”) rather than silently dropping the poisoned agent from membership: this is a DELIBERATE choice, unlike the read-only POST /api/v1/inventory/evaluate and POST /api/inventory/query routes below, which surface the same exclusion as a results_excluded_by_poison count field, this route materialises its match set into a durable result set other operators/dispatches consume later, so a flag on this response would never reach them. (#4496 follow-up) A candidate inventory record excluded because its data_json failed to parse as JSON at all gets the SAME 503 treatment (“inventory record(s) excluded for failing to parse as JSON … refusing to materialise a result set narrower than the true match set”), as a distinctly-named sibling refusal, checked in a fixed sequence AFTER the depth-guard one above: a candidate set carrying both problems reports only the depth-guard (poison_excluded) refusal on that call, and the parse-error refusal surfaces on a subsequent retry once the poisoned record is fixed - so a caller is always told which cause remains, never that both have cleared at once.

Permission: Inventory:Read (guardian-confinement-2298 PR 3 — this route had NO authorization check of any kind before this fix, CWE-862: any authenticated session could query up to 5000 fleet-wide inventory records with zero scoping. Unlike the async producers below, it is a synchronous read, not a dispatch, so it gates on the same securable as GET /api/v1/inventory/software rather than Execution:Execute. A service-scoped token is denied outright here too (#4980), same as its result-set siblings — before #4980, under RBAC-on, this route admitted-and-confined a service-scoped token via fleet_read_fn instead of denying it, a tracked cross-service-reach gap (#4307); fleet_read_fn still hard-denied a service-scoped token under RBAC-off (the default), same as the require_permission gate it briefly replaced (see the “Result Sets” section below for the full history). The owner-scoped result-set row it creates is only readable/mutable by its own creator through the routes below, which — like their HTMX dashboard twins — also deny a service-scoped token outright: session->username is the minting principal’s identity, not the token’s own service tag, so without this a service token could reach any other token the same minter held.

POST /api/v1/result-sets/from-echo-query
POST /api/v1/result-sets/from-instruction-result
POST /api/v1/result-sets/{id}/re-eval

The three asynchronous result-set producers. Unlike from-inventory-query, which answers from server-side inventory, these dispatch a command to agents and materialise the set once the responses land — so they are operator dispatch surfaces, not reads. from-echo-query runs SQL on Echo agents; from-instruction-result runs an InstructionDefinition and filters responders by matcher; {id}/re-eval re-runs a set’s own source query and creates a sibling (same parent, new id).

Permission: Execution:Execute, confined per device

Per-device confinement (#1788). These routes admit on a global Execution:Execute grant and then reach the fleet by scope (parent_id) or __all__ broadcast, so the caller’s derived visible set is their only per-device authorization. A service-scoped token reaches only agents tagged with its own service; devices outside the caller’s reach are dropped from the send set rather than refused individually. A global administrator or a JIT-elevated session keeps full-fleet reach — that is their actual authority.

Targeting: omit parent_id to dispatch to __all__ deliberately. A supplied parent_id that is empty, non-string, or null is refused with 400 RESULT_SET_BAD_PARENT rather than silently widening to the fleet.

{id}/re-eval field bounds (#4373). The original set’s re-run fields are rechecked against the same bound values used elsewhere, because the original row may have been minted through POST /api/v1/result-sets directly (which carries no source_kind allowlist) rather than through from-echo-query/from-instruction-result, and so may never have been validated at all: sql (echo_query) at 100 KiB, the bound from-echo-query itself already enforces at creation time; and instruction_id (instruction_result) at 256 bytes plus params at 32 keys / 256-byte keys / 64 KiB values, the same bounds REST’s own from-instruction-result route now also enforces at creation time (#4373), matching the MCP tool create_result_set_from_instruction_result. A params that is present but not a JSON object (a string, array, or number) is refused outright rather than silently dispatching with an empty params map. A type-mismatched sql/instruction_id value (not a JSON string) is treated as absent, taking the existing missing-field 400 path, rather than surfacing as an uncaught exception (#4406, fixed on both from-echo-query and from-instruction-result in the same change).

{id}/re-eval is refused, never broadcast, when the recorded parent is gone (#4306). re-eval synthesises the sibling’s dispatch scope from the original’s live parent_id FK, which is nulled (ON DELETE SET NULL) if the parent set is later deleted. An absent parent_id reaching dispatch synthesis normally means “broadcast to __all__” — the same rule the parent_id-empty guard above enforces for a caller-supplied empty string — so letting that happen here would silently turn “re-ask the same narrow question” into “ask the whole visible fleet”. If the live parent is gone and the original’s stored source_payload shows it was narrowed at creation time (a non-empty scope_input_id — recorded by every result-set create route that accepts parent_id — from-echo-query/ from-instruction-result/from-inventory-query (which always build a well-formed object payload) and the generic POST /api/v1/result-sets (#4306 follow-up), except when the generic route’s caller-supplied source_payload is itself not a JSON object, in which case re-eval’s own sql/instruction_id-presence check independently refuses such a row before dispatch regardless), the call is refused rather than re-resolved (the recorded value may be an alias that has since been re-bound to a different, newer set) or silently broadcast. A genuinely parentless original (no parent_id was ever supplied, by any creation path) still broadcasts on re-eval, unchanged.

Errors:

StatusReason
400RESULT_SET_BAD_PARENT — parent_id supplied but names no parent set; or missing sql / instruction_id. parent_id exceeding 64 bytes also returns 400, but without this code prefix (bare “parent_id must be at most 64 bytes”)
400RESULT_SET_BAD_REQUEST: on from-instruction-result or re-eval, instruction_id exceeds 256 bytes, params exceeds 32 keys / a key exceeds 256 bytes / a value exceeds 64 KiB, or params is present but not a JSON object. On re-eval only, the original’s sql may also exceed 100 KiB (#4373)
400RESULT_SET_BAD_REQUEST: on re-eval only, the original’s live parent set was deleted and its persisted scope_input_id shows it was narrowed at creation (#4306) — audited result_set.create|denied, reason=parent_gone
400RESULT_SET_BAD_REQUEST: on from-instruction-result, a matcher with "kind":"echo_rows_ge" whose n is present but not a JSON integer from 1 to 2147483647 (a wrong type or a wrapped value used to throw in the maintenance tick, which re-threw every 2 seconds and starved the result-set GC; an n or kind already stored that is malformed, or an n below 1, now matches no device). A matcher that is present but not a JSON object, or a kind that is not a string, is refused too, and so is a column matcher (no kind) whose column, op or value is present but not a string or whose value_set is present but not an array ({"column":5,"op":"eq"} used to qualify every responder; a stored one now matches no device, while a MISSING key keeps the accept-all default so a typo cannot silently empty the set); it used to be dropped, which meant every responder qualified (omit matcher for that default). Nothing is dispatched (#4644)
400RESULT_SET_BAD_REQUEST: on from-instruction-result, a matcher that carries declared_columns, plugin or row_discriminator. These three keys are server-authored: when the matcher’s column is a column the instruction definition declares in its result_schema, the server stores them (declared_columns = field names in output order, plugin = the definition’s plugin) so a column matcher works on any plugin’s pipe-row output, not only Echo or JSON. The header row and short status rows (status|not_found, not_available|<reason>) are skipped. A column the definition does not declare stores nothing extra
400sql/instruction_id/name present but not a JSON string (a clean 400 rather than an uncaught exception, #4406); name over 256 bytes on from-echo-query or from-instruction-result
404Unknown instruction_id, unknown parent set, or (on re-eval) a set the caller does not own
429RESULT_SET_QUOTA_EXCEEDED — owner is at the per-owner set cap
500RESULT_SET_GATE_UNCONFIGURED — the server’s dispatch-visibility gate is not wired. Fails closed: nothing is dispatched, and the refusal is audited. An operator seeing this has a server misconfiguration, not an authorization problem
500RESULT_SET_STORE_FAULT_AFTER_DISPATCH — a real command already dispatched to agents, but the store fault persisting the pending result-set row afterward (#4306; previously 400, corrected — this is a server fault, not a client error, once a command has already been sent). Do not re-send; poll GET /api/v1/executions/{id} for the dispatched command’s outcome instead
503RESULT_SET_NO_AGENTS — no agents were reached in the target scope. Deliberately indistinguishable from “every resolved target was outside your reach”: a distinct status would disclose devices the caller may not see
503RESULT_SET_STORE_UNAVAILABLE (pre-dispatch) — the per-owner quota could not be verified before dispatch (Postgres degraded, #4306). Nothing was dispatched; safe to retry (Retry-After header present)
503RESULT_SET_DISPATCH_UNAVAILABLE / RESULT_SET_DISPATCH_FAILED — dispatch not wired, or the dispatch itself raised

GET /api/v1/inventory/{agent_id}/{plugin}

Get inventory data for a specific agent and plugin.

Permission: Inventory:Read

Response:

{
  "data": {
    "agent_id": "agent-001",
    "plugin": "hardware",
    "data": { "cpu_count": 8, "ram_gb": 32 },
    "collected_at": 1711900800
  },
  "meta": { "api_version": "v1" }
}

The data field contains the plugin-specific structured inventory blob as parsed JSON (or a string if the blob is not valid JSON).

Errors:

StatusReason
404No inventory data found for this agent+plugin
503Inventory store unavailable or degraded

POST /api/v1/inventory/query

Query inventory data across agents with filters.

Permission: Inventory:Read

Request body:

FieldTypeRequiredDescription
agent_idstringNoFilter by agent ID
pluginstringNoFilter by plugin name
sinceintegerNoUnix timestamp, inclusive lower bound on collection time. Omit for no lower bound; 0 is accepted and matches the same rows as omitting it; a negative value or a non-integer is 400
untilintegerNoUnix timestamp, inclusive upper bound on collection time. Omit for no upper bound; 0, a negative value or a non-integer is 400 (0 is not a way to say unbounded)
limitintegerNoMax results (default 100, max 1000; a larger integer is clamped to 1000, a value below 1 or a non-integer is 400)

Response: List of inventory records matching the query, plus top-level result_truncated_by_cap. When that flag is true, the server’s row or 8 MiB aggregate payload budget was reached and the returned fleet result is incomplete; callers must not interpret omitted rows as absence.

Errors:

StatusReason
503Inventory store unavailable or degraded

POST /api/v1/inventory/evaluate

Evaluate inventory conditions across agents. Returns agents whose inventory data matches the specified conditions.

Permission: Inventory:Read

Request body:

FieldTypeRequiredDescription
agent_idstringNoScope to a single agent
combinestringNo"all" (default) or "any": how to combine multiple conditions; any other value means all
conditionsarrayYesList of condition objects (at most 500; more is 400)

Each condition object:

FieldTypeDescription
pluginstringPlugin name to query
fieldstringJSON field path within the inventory data
opstringOperator: ==, !=, >, >=, <, <=, contains, exists, version_gte, version_lte. An unrecognised operator matches nothing
valuestringValue to compare against. A JSON number is accepted with a comparison operator (==, !=, >, >=, <, <=) and a boolean with == or != (true / false). A number is compared as a number, not as text, under all six operators: two integers compare exactly (a stored 9007199254740993 does not equal 9007199254740992 and is greater than it), an integer against a decimal compares exactly, == and != with a number are exact for JSON integers; every JSON float (a number written with a decimal point or exponent, such as 5.0, 1e3, 5.5, 22359.09411, or an integer outside -9223372036854775808 to 18446744073709551615, which arrives as a float) is 400 with a hint, because its original text is lost once parsed (5.00000000000000001 arrives as the double 5.0); send an integer, or send the decimal as a string; a decimal is sent as a JSON string under >, >=, <, <= and is accepted when it is a whole number written in digits (an all-zero fraction is allowed) or as an integer-valued exponent form ("1e16", "564141138995e7") within int64/uint64, which is read exactly, or has at most 15 significant digits (digits after leading and trailing zeros), a magnitude below 2^53, a value that is zero or at least the smallest normal double (a subnormal such as "1e-320" is refused), and a value within the range of a double; otherwise it is 400, because a double rounds a longer decimal and a comparison against a stored integer could then flip ("5.00000000000000001" would equal 5), and a non-integer at or above 2^53 such as "9007199254740992.5" is refused for the same reason; a decimal string compares as a double (so >= "5.5" and <= "5.5" both match a stored 5.5), but == and != with a string stay a text compare, so a non-integer decimal cannot be matched for equality numerically; a plain-decimal string outside the double range such as "1e400" is 400 too, instead of ordering lexically: write a whole number as an integer up to 18446744073709551615, or as a string of digits (a string such as "9007199254740993.0" whose fraction is all zeros is read as that exact integer), a stored string that parses in full as a number counts as that number (a stored "10.0" equals 10, a stored "10" is greater than 9), and any other stored value never equals it and never satisfies >, >=, < or <= against it (a stored "5 GB" or "N/A" used to be read as 5 or compared as text, so "N/A" > 5 selected the device); a string operand with == / != stays an exact text compare ("1.10" and "1.1" differ), while under >, >=, <, <= a string operand that is a plain decimal number in full ("4", "-5.5", "2e-7") is compared exactly against a stored number or fully numeric string (a stored integer within int64/uint64, including an integer-valued exponent spelling that fits (such as "564141138995e7"), and a stored decimal with a fraction of at most 15 significant digits and a magnitude below 2^53 compare exactly; any other stored decimal, one of more digits or a non-integer at or above 2^53, or a stored negative integer below -9223372036854775808, is compared as the nearest double), a stored string that merely starts with a number (a dotted version such as "1.2.3", or "5 GB") has its leading number read and compared the same way ("1.2.3" reads as 1.2, "9007199254740993 B" as that exact integer, so "1.2.3" < "2.0" still matches; use version_gte / version_lte for dotted versions), and any other stored value ("N/A", "", "v2.1", " 5", a boolean) never matches, never as text; a JSON number operand is stricter and never matches a stored string that is not a number in full; only a non-numeric string operand ("abc", "1.2.3", "v2", " 5", "5 GB") keeps the legacy numeric-prefix-then-text ordering. Any other type, or a number or boolean with another operator, is 400 (conditions[].value must be a JSON string ...); a non-string plugin, field or op is 400 too (#4644). A numeric 5 used to become the empty string, and with > that selected every device that had the field. The same rule holds on POST /api/v1/result-sets/from-inventory-query and the MCP tool create_result_set_from_inventory_query (-32602)

Response:

{
  "data": [
    {
      "agent_id": "agent-001",
      "match": true,
      "matched_value": "8",
      "plugin": "hardware",
      "collected_at": 1711900800
    }
  ],
  "meta": { "api_version": "v1" }
}

result_truncated_by_cap (bool, optional) — emitted at the top level of the envelope (alongside data), present and true when the underlying inventory read hit its row cap (5,000 for this route) or 8 MiB aggregate payload cap: absent devices may simply not have been read rather than not matching. (The typed software route carries the same flag inside data — placement alignment is tracked with #2633.)

results_excluded_by_poison (integer, optional, #4496): emitted at the same top level, present and non-zero when one or more candidate inventory records were excluded because their stored data_json nested past the JSON depth guard (a poisoned/over-nested row; the exclusion check runs before condition matching, so a record’s plugin/fields need not relate to the query’s conditions to trigger it; see json-dump-depth-guard below). The returned matches may be missing some the caller cannot detect any other way. Distinct from result_truncated_by_cap (a row/byte cap on the underlying read, not a per-record exclusion); either, both, or neither may be present on a given response.

results_excluded_by_parse_error (integer, optional, #4496 follow-up): emitted at the same top level, present and non-zero when one or more candidate inventory records were excluded because their stored data_json failed to parse as JSON at all (a syntax defect, not over-nesting). A distinctly-named sibling of results_excluded_by_poison above, kept separate so a caller can tell WHICH guard excluded a record - the two causes are different (malformed JSON vs. over-nested JSON) and both, either, or neither may be present on a given response alongside result_truncated_by_cap.

Errors:

StatusReason
503Inventory store unavailable or degraded

GET /api/v1/inventory/software

Fleet-wide read of the typed installed-software inventory (ADR-0016, SoftwareInventoryStore). Distinct from the generic /inventory/* routes above, which read the generic per-source blob store (InventoryStore, also Postgres-backed as of ADR-0037, but a separate schema/table). This is the REST sibling of the query_installed_software MCP tool — same data, same scope contract.

Permission: Inventory:Read, gated SOLELY by AuthRoutes::require_fleet_read (#3290 Phase 2 — never stacked with a separate permission check). A service-scoped API token (bound to one IT service’s agents) is no longer denied outright: it gets a real filtered 200 scoped to its service-tagged agents, intersected with any management-group confinement that also applies (meet(management-group, service-scope)). A management-group-confined operator (no global grant) also now gets a genuinely filtered 200 instead of the fleet-wide/denied split of earlier releases.

Query parameters:

ParameterTypeRequiredDescription
namestringNoExact software-name filter
agent_idstringNoExact agent filter
versionstringNoExact version filter. A present-empty value (?version=) selects only the rows with an empty version; omit the parameter for any version
qstringNoCase-insensitive literal substring over name, publisher, ecosystem and source (%/_ are not wildcards; clamped to 128 bytes)
kind / ecosystemstringNoExact filters; an empty value means no filter
sourcestringNoExact source filter. source is an open set (<plugin>.<action>), never an enum
limitintegerNoMax rows (default 100, min 1, max 1000). In export mode it is the total cap (default and max 10000)
cursorstringNoOpaque next_cursor from the previous page (see below)
formatjson | csvNoSelects export mode: a single response of up to 10000 rows

q, kind, ecosystem and source are REST-only on this route: its MCP twin query_installed_software takes name, agent_id, version, limit and cursor only (see mcp.md).

Omit name and agent_id for a fleet-wide scan. A parameter that is not valid UTF-8 or contains a NUL byte, an agent_id/kind/ecosystem/source over 256 bytes, a name/version over 1024 bytes, a non-integer limit, an unknown format or a refused cursor is a 400 (never a retryable 503). An unknown kind or ecosystem value is not refused: it matches nothing and returns an empty 200 (the OpenAPI enum is descriptive, not enforced).

Response (200):

{
  "data": {
    "software": [
      {"agent_id": "agent-001", "name": "bash", "version": "5.2.21", "publisher": "Fedora Project",
       "install_date": "2026-01-15", "kind": "package", "ecosystem": "rpm", "epoch": "0",
       "release": "3.fc40", "arch": "x86_64", "signature_status": "signed",
       "distro_id": "fedora", "distro_version": "40", "source": "pkg_inventory.packages"},
      {"agent_id": "agent-002", "name": "Google Chrome", "version": "124.0", "publisher": "Google LLC",
       "install_date": "2026-01-15", "kind": "app", "ecosystem": "windows", "epoch": "",
       "release": "", "arch": "", "signature_status": "", "distro_id": "", "distro_version": "",
       "source": "installed_apps.list_inventory"}
    ],
    "count": 2,
    "next_cursor": "d2:0f3a9c1d5b7e2a64:MzpnaXQfOTphZ2VudC0wMDIfMTo1HzE6MB8",
    "devices_omitted": 0,
    "result_truncated_by_cap": true
  },
  "meta": { "api_version": "v1" }
}

Every row carries the blob-v2 package fields (kind, ecosystem, epoch, release, arch, signature_status, distro_id, distro_version) alongside the original four. A field the source ecosystem does not store is "" — never synthesised (a Windows/macOS app row is honestly empty on every NEVRA/signature/distro field, as in the second example above). These 8 keys are always present in every row (never absent) — integration code should test for an empty-string value, not key absence. See docs/user-manual/inventory.md for the full per-ecosystem availability matrix.

Every row carries source — the <plugin>.<action> that produced it (an open set; "" for rows ingested before the field existed). Rows therefore have 14 fields.

Pagination (keyset). next_cursor is non-null when more rows may exist (result_truncated_by_cap: true is set at the same moment — also in export mode); pass it back as cursor to continue. The cursor is opaque — never parse it. It is bound to the filter set: a malformed cursor, or one issued for a different filter set, is a 400 (invalid cursor / cursor was issued for a different filter set), never a silent restart. Under push-down the cursor is the last row of the page. When the page was post-filtered (below), a page can be short or empty while next_cursor is non-null — keep paging until next_cursor is null; that cursor never names a row you were not served (see the Scope paragraph).

Scope (devices_omitted — re-specified). The gate’s composed meet(management-group, service-scope) visibility set is pushed into SQL (agent_id = ANY(...)) for up to 10 000 visible devices, so a confined caller (management-group-confined operator or a correctly-confined service-scoped token) gets complete pages of its own devices and nothing is hidden. devices_omitted is the count of distinct devices hidden by the in-process post-filter used only for a scope wider than that bound. It is 0 under push-down, so there an empty page means no matching software in your scope. Above 10 000 visible devices it is a distinct-device count within the scanned window(s) and still signals that out-of-scope matches exist (bounded and audited). A denied audit row is written when it is greater than 0, or when the request names an agent_id outside the caller’s scope (the response is then empty with devices_omitted unchanged; the row says agent_id outside the caller's scope). On a wide scope the page is refilled with at most four bounded store reads, so a page can be empty with a non-null next_cursor; the cursor then re-anchors at the last row you were served (or at the start) and carries a count of the rows scanned since, which the next request passes over — no identifier, name or version of an out-of-scope row is ever in it. A row inserted inside that skipped span during a walk can be missed or repeated, the same class as the install_id churn already noted for concurrent resyncs; a wide-scope walk is best-effort under concurrent change, because the count is of positions: deleting an out-of-scope row inside the skipped span between pages, or widening the caller’s scope between pages, can make the next page skip or repeat a row. A long run of out-of-scope rows costs proportionally more to resume, and a statement timeout there degrades the page to a 503 (tracked in #5698). An export on a wide scope reads at most four bounded windows, so it can hold fewer than limit rows with X-Result-Truncated-By-Cap set. result_truncated_by_cap is present only when more rows may exist (push-down: count == limit; wide scope: the page filled early or the read bound was reached). audit_persisted: false is present only when the audit row could not be persisted (set-and-proceed posture — the data is still served, the lost-evidence flag is surfaced honestly).

Export mode. format=csv returns text/csv; charset=utf-8 with Content-Disposition: attachment; filename="software-installs.csv" and the header row agent_id,name,version,publisher,install_date,kind,ecosystem,epoch,release,arch,signature_status,distro_id,distro_version,source; format=json returns the usual object. limit is the total row cap (default and maximum 10000); when more rows may exist the response carries X-Result-Truncated-By-Cap: true and X-Next-Cursor: <cursor> (CSV, whose body then ends with a # result_truncated_by_cap cause=row_cap trailer record of 14 fields) or result_truncated_by_cap: true and next_cursor (JSON); pass the cursor back as cursor with the same format to read on. Exports are audited as inventory.software.export and are REST-only (an agent pages the same data through the MCP cursor). Service-scoped tokens stay admitted — confined — on this route, including export, unlike the two catalogue routes below.

Audit. Every call that is served writes a success row — inventory.software.query, or inventory.software.export in export mode (continuation pages carry page=continuation in the detail), with the target fleet / agent=<id> / name=<n> (name=<n>;agent=<id> when both are set) plus the active filters; a 400 writes nothing. Because the cursor is opaque but unauthenticated (a hand-made well-formed cursor is accepted), per-call audit is what keeps a forged cursor from walking the fleet unrecorded.

Behaviour changes in this release. limit is now parsed with a strict full-consume integer parse: limit=12abc is a 400 (it was previously accepted as 12), and an empty limit= now means the default (it was a 400). A parameter holding a NUL byte or invalid UTF-8, or an over-long name/agent_id/version/cursor, is now a 400 (it was a retryable 503). A denied audit row is now also written for an explicit agent_id outside the caller’s scope. Repeated query parameters keep the first value on this route (unchanged); the two catalogue routes below reject a repeated parameter with 400 instead.

Error responses:

StatusCondition
400limit is not a valid integer, format is not json/csv, or the cursor is malformed / was issued for a different filter set; a parameter that is not valid UTF-8 or contains a NUL byte; or an over-long value (agent_id/kind/ecosystem/source over 256 bytes, name/version over 1024 bytes)
401Unauthenticated
403No management-group grant for Inventory:Read; or a service-scoped token whose RBAC/ITServiceOwner grant is missing, or whose RBAC enforcement is disabled fleet-wide (a service-scoped token always hard-denies when RBAC is off)
503The gate’s own RBAC/tag-store lookup is unavailable or degraded (including a failed read of the ITServiceOwner ceiling for a service-scoped token), the software inventory store is unavailable or degraded, or the require_fleet_read gate itself is unwired (server misconfiguration) — all A4 envelope with correlation_id, retry_after_ms: 5000 where retryable — never an empty 200

On a 503 the store (or the confinement check itself) could not be read; do not treat it as “not installed anywhere” (ADR-0016 §7 authoritative reads). A genuine empty result is 200 with count: 0.

GET /api/v1/inventory/software/catalogue

The fleet software title catalogue: one row per title rolled up across the whole fleet from the precomputed catalogue rollup (refreshed hourly), ordered by installs descending then name. MCP twin: query_software_catalogue — both are shaped by one shared model, so the JSON cannot drift.

Permission: a global Inventory:Read (via require_fleet_read). The rollup is fleet-wide and cannot be scoped, so a management-group-confined caller gets 403 (permission: "Inventory:Read", audited denied) on the fleet view — but may pass agent_id for a per-device view of an in-scope device (see below). Service-scoped API tokens are denied (403, no permission field — no grant would admit them), unlike the raw GET /api/v1/inventory/software above, which keeps admitting them confined: the asymmetry is deliberate.

Query parameters:

ParameterTypeDescription
qstringCase-insensitive literal substring over title name, publisher, ecosystems and sources (clamped to 128 bytes)
kind, ecosystemstringExact filters (closed vocabulary — see inventory.md); empty means no filter
sourcestringExact source filter (open set, <plugin>.<action>); empty means no filter
agent_idstringPer-device view: that device’s titles (installs are 1)
versions_minintegerOnly titles with at least this many distinct versions (0–100000)
limitintegerPage size (default 100, 1–1000, clamped); with format=csv it is the total export cap (default and max 10000)
cursorstringOpaque next_cursor from the previous page
formatjson | csvcsv exports the titles (REST-only)

A repeated parameter, a parameter that is not valid UTF-8 or contains a NUL byte, a kind/ecosystem/source/agent_id over 256 bytes, a non-integer limit/versions_min, an unknown format, a cursor over 2048 bytes, or a cursor sent with format=csv (an export is one whole walk and takes no continuation) is a 400. An empty kind/ecosystem/source means “no filter”, so those values cannot be selected as ''.

Response (200):

{
  "data": {
    "titles": [
      {"name": "git", "publisher": "Git SCM", "installs": 3, "versions": 3,
       "ecosystems": ["brew", "macos_pkgutil", "rpm"], "kinds": ["package", "pkg"],
       "newest_version": "2.41"}
    ],
    "count": 1,
    "next_cursor": "c1:9a1c0e5d7b3f2a68:1790000000:Mx9naXQ",
    "totals": {"titles": 2, "installs": 4},
    "kpis": {
      "titles": 2, "publishers": 2, "installs": 4,
      "installs_by_os": {"windows": 0, "macos": 3, "linux": 1, "other": 0},
      "os_pct": {"windows": 0, "macos": 75, "linux": 25, "other": 0},
      "stay_current": {"current_installs": 2, "total_installs": 4, "titles": 2, "pct": 50},
      "sprawl": {"titles": 1, "pct": 50.0},
      "rpm": {"total": 1, "unsigned": 0},
      "coverage": {"reporting": 3, "connected": 5, "stale": 0},
      "avg_installs_per_device": 1,
      "refreshed_at": 1790000000,
      "building": false
    },
    "devices_omitted": 0
  },
  "meta": { "api_version": "v1" }
}

Error responses (A4 envelope with correlation_id):

StatusCondition
400Repeated/malformed parameter, unknown format, a cursor with format=csv, or a refused cursor (see above)
401Unauthenticated
403Confined caller on the fleet view (permission: "Inventory:Read"), or a service-scoped token
503A degraded rows, totals or refresh-metadata read, or (fleet format=csv) a catalogue not yet built — retry_after_ms: 5000; never an empty 200. A degraded totals read fails the whole page. An absent store or an unwired gate (server misconfiguration) is a 503 without retry_after_ms

GET /api/v1/inventory/software/catalogue/versions

Installs per version for one title: distinct devices per version, most-installed first. MCP twin: get_software_versions.

Permission and posture: identical to the catalogue — a global Inventory:Read for the fleet view (403 for a confined caller), the per-device view for an in-scope device (newest is false on every row and newest_omitted: true for a confined caller; an out-of-scope device returns an empty list with devices_omitted: 1 and a denied audit row), service-scoped tokens denied.

ParameterTypeDescription
namestringRequired exact title name, at most 1024 bytes (400 name is required)
kind, ecosystem, source, agent_idstringSame filters as the catalogue
limitintegerDefault 200, 1–1000 (clamped)
{"data": {"name": "git", "versions": [{"version": "2.40", "installs": 1, "newest": false},
                                      {"version": "2.41", "installs": 1, "newest": true}],
          "count": 2, "truncated": false, "devices_omitted": 0}, "meta": {"api_version": "v1"}}

newest marks the title’s fleet-wide newest version; truncated is true when the limit was reached; devices_omitted is 1 when the per-device view named a device outside the caller’s scope (the empty list is then “hidden”, not “no installs”), else 0. Every call that serves a page is audited as inventory.software.versions (success); an out-of-scope per-device read writes a denied row only, a 400 writes none, a confined or service-scoped refusal writes denied, and the Inventory:Read gate’s own refusal is audited as auth.fleet_read_required. The unfiltered read (no kind, ecosystem, source or agent_id) serves the precomputed version rollup, so before the first catalogue refresh it is a 503 with retry_after_ms and a failure audit row rather than an empty list; filtered and per-device reads do not depend on the rollup for their rows and counts (newest is false until the first refresh). Errors: 400 (missing name, repeated parameter, an over-long value, invalid UTF-8 or a NUL byte), 401, 403, 503 (retry_after_ms: 5000 on a degraded or not-yet-built read, never an empty 200; no hint where the store or gate is absent).


Crypto

Which cryptographic libraries are installed across the fleet, derived from the daily-synced installed-software inventory (ADR-0016) by matching package names against a curated, versioned table (see crypto.md). The machine-readable siblings of the /crypto dashboard fragments; the page, these routes and the MCP tools get_crypto_libraries / list_crypto_library_devices all shape their output from one model, so the JSON cannot drift. Presence and versions only: nothing here judges a library or a version.

Permission (both routes): Inventory:Read, as the sole gate (via the fleet-read gate, ADR-0017). A management-group-confined caller is admitted and every figure is computed over its visible devices. A service-scoped API token is refused with a 403 on both routes while RBAC enforcement is off (the default); with RBAC enforcement on it is admitted within the ITServiceOwner ceiling and sees only the devices tagged with its IT service. Both routes answer Cache-Control: no-store, private and Vary: Authorization, Cookie, X-Yuzu-Token (per-caller, scope-filtered data, keyed on every credential a caller can present). A gate-level denial (no grant, authorization store unavailable) is a 401/403/503 written by the gate and audited as auth.fleet_read_required.

GET /api/v1/crypto/libraries

Family summary: devices per family, version buckets and the coverage denominator.

ParameterTypeDescription
familystringRestrict to one family id (openssl, libressl, boringssl, gnutls, nss, mbedtls, wolfssl, libgcrypt, libsodium, nettle, botan, bouncycastle); empty means every family
ecosystemstringExact package-ecosystem filter, at most 1,024 bytes. The published ecosystems are rpm, deb, apk, pacman, snap, flatpak, windows, macos, macos_pkgutil, brew, optional_feature and msi; REST does not refuse another value, which matches no rows (the MCP twin refuses it). A present but empty value selects rows that carry no ecosystem; omit for any

A parameter repeated with differing values, an unknown family, a value that is not valid UTF-8 or contains a NUL byte, or an over-long value is a 400. Unknown query keys are ignored.

Response (200):

{
  "data": {
    "table_version": 3,
    "names_source": "catalogue",
    "names_as_of": 1760000000,
    "families": [
      {
        "id": "openssl",
        "display": "OpenSSL",
        "device_count": 41,
        "versions": [
          {"version": "3.0.13", "ecosystem": "deb", "device_count": 29, "package_names": ["libssl3", "openssl"], "package_names_truncated": false},
          {"version": "1.1.1k", "ecosystem": "rpm", "device_count": 12, "package_names": ["openssl-libs"], "package_names_truncated": false}
        ],
        "versions_truncated": false
      },
      {
        "id": "gnutls",
        "display": "GnuTLS",
        "device_count": 29,
        "versions": [
          {"version": "3.8.3", "ecosystem": "deb", "device_count": 29, "package_names": ["libgnutls30"], "package_names_truncated": false}
        ],
        "versions_truncated": false
      }
    ],
    "coverage": {"devices_reporting_inventory": 118, "devices_with_crypto_library": 41},
    "result_truncated": false
  },
  "meta": {"api_version": "v1"}
}

Error responses (A4 envelope with correlation_id):

StatusCondition
400Parameter repeated with differing values, malformed parameter, unknown family, invalid UTF-8, over-long value
401Unauthenticated
403Caller lacks Inventory:Read (written by the gate); while RBAC enforcement is off, service-scoped tokens are refused 403
503crypto library read degraded (the inventory read failed or ran past its 5 s bound) or crypto read capacity in use; retry (the server bounds concurrent reads per process): retry_after_ms: 5000 either way; never an empty 200. An absent inventory store or an unwired gate (server misconfiguration) is a 503 without retry_after_ms. A server whose Postgres pool is smaller than 2 connections refuses these reads with a 503 and no retry_after_ms (crypto library reads (and the CBOM export) refused: the server's Postgres pool is smaller than 2 connections; restart the server with --postgres-pool-size 2 or more): a retry fails the same way until that restart.

GET /api/v1/crypto/libraries/devices

The devices (and matching package rows) behind one family, optionally one version bucket. A device with several matching packages appears once per package.

ParameterTypeDescription
familystringRequired family id (as above); 400 family is required if missing
versionstringExact version bucket, at most 1,024 bytes (the ingest bound). Present but empty selects rows with no version; omit for every version
ecosystemstringExact package-ecosystem filter, at most 1,024 bytes, free text as on the summary (an unpublished value matches no rows). Present but empty selects rows with no ecosystem; omit for any
cursorstringOpaque next_cursor from the previous page, at most 2048 bytes; bound to the family/version/ecosystem filter and the table_version
limitintegerPage size, default 100, clamped into 1 to 1000; a value that is not a plain decimal integer (non-numeric, out of range, signed such as +5) is a 400

Response (200):

{
  "data": {
    "table_version": 3,
    "names_source": "catalogue",
    "names_as_of": 1760000000,
    "family": "openssl",
    "version": "3.0.13",
    "ecosystem": null,
    "devices": [
      {"agent_id": "a1b2c3d4", "hostname": "build-07", "package_name": "libssl3",
       "version": "3.0.13", "release": "0ubuntu3.4", "ecosystem": "deb", "kind": "package"},
      {"agent_id": "a1b2c3d4", "hostname": "build-07", "package_name": "openssl",
       "version": "3.0.13", "release": "0ubuntu3.4", "ecosystem": "deb", "kind": "package"}
    ],
    "next_cursor": "k1:5f0c9a7e2b1d4c36:MTpiOjE",
    "count": 2
  },
  "meta": {"api_version": "v1"}
}

Error responses (A4 envelope with correlation_id):

StatusCondition
400family missing or unknown, parameter repeated with differing values, malformed parameter, invalid limit, over-long or non-UTF-8 value, or a refused cursor (including one from another table_version, or a cursor issued on live names when the catalogue is now in use)
401Unauthenticated
403Caller lacks Inventory:Read (written by the gate); while RBAC enforcement is off, service-scoped tokens are refused 403
503crypto library read degraded or crypto read capacity in use; retry: retry_after_ms: 5000 either way; never an empty 200. An absent store or an unwired gate is a 503 without retry_after_ms. A server whose Postgres pool is smaller than 2 connections refuses these reads with a 503 and no retry_after_ms (crypto library reads (and the CBOM export) refused: the server's Postgres pool is smaller than 2 connections; restart the server with --postgres-pool-size 2 or more): a retry fails the same way until that restart.

GET /api/v1/crypto/cbom

A CycloneDX 1.7 inventory of the cryptographic library packages installed across the fleet (library components only; no algorithms, certificates, keys or protocols yet), exported as a JSON bill of materials (a CBOM). Same gate, confinement, read-capacity bound and degrade behaviour as GET /api/v1/crypto/libraries; the response is the BOM itself (no data/meta envelope) with Content-Type: application/vnd.cyclonedx+json; version=1.7, Cache-Control: no-store, private and Vary: Authorization, Cookie, X-Yuzu-Token. MCP twin: export_crypto_cbom. The document model, the yuzu:crypto: properties and the truncation flags are described in crypto.md.

ParameterMeaning
familyOne family id from the curated table; omitted or empty means every family. An unknown id is a 400.
ecosystemExact package ecosystem (at most 1,024 bytes; the catalogue’s published set is rpm, deb, apk, pacman, snap, flatpak, windows, macos, macos_pkgutil, brew, optional_feature, msi); present-but-empty selects rows with no ecosystem. An unpublished value is not refused: it matches no rows.
{
  "bomFormat": "CycloneDX",
  "specVersion": "1.7",
  "serialNumber": "urn:uuid:5b2f6a3e-8c1d-4e07-9a55-0c3d7e1b4f92",
  "version": 1,
  "metadata": {
    "timestamp": "2026-10-18T09:30:00Z",
    "tools": {"components": [{"type": "application", "name": "Yuzu", "version": "0.14.1"}]},
    "properties": [
      {"name": "yuzu:crypto:table_version", "value": "3"},
      {"name": "yuzu:crypto:names_source", "value": "catalogue"},
      {"name": "yuzu:crypto:result_truncated", "value": "false"},
      {"name": "yuzu:crypto:devices_reporting", "value": "118"},
      {"name": "yuzu:crypto:scope", "value": "fleet"}
    ]
  },
  "components": [
    {"type": "library", "bom-ref": "yuzu-crypto-lib/openssl/3.0.13/deb", "name": "OpenSSL", "version": "3.0.13",
     "properties": [
       {"name": "yuzu:crypto:family_id", "value": "openssl"},
       {"name": "yuzu:crypto:ecosystem", "value": "deb"},
       {"name": "yuzu:crypto:device_count", "value": "31"},
       {"name": "yuzu:crypto:versions_truncated", "value": "false"},
       {"name": "yuzu:crypto:package_name", "value": "libssl3"}
     ]}
  ],
  "compositions": [{"aggregate": "incomplete"}]
}

(Properties are abbreviated above; the full set is listed in crypto.md.)

StatusMeaning
200The BOM
400Repeated parameter with differing values, unknown family, invalid UTF-8 or over-long value
401Unauthenticated
403Caller lacks Inventory:Read (written by the gate); while RBAC enforcement is off, service-scoped tokens are refused 403
503crypto library read degraded or crypto read capacity in use; retry: retry_after_ms: 5000 either way; never an empty BOM. An absent store, an unwired gate or a failure minting the serial number is a 503 too; the serial is minted before the inventory read, so that last failure takes no read slot and writes no audit row. A server whose Postgres pool is smaller than 2 connections refuses these reads with a 503 and no retry_after_ms (crypto library reads (and the CBOM export) refused: the server's Postgres pool is smaller than 2 connections; restart the server with --postgres-pool-size 2 or more): a retry fails the same way until that restart.

Dashboard widgets (ADR-0070 D13)

The /crypto page is a per-operator widget dashboard (see crypto.md). These routes serve the widget catalogue, the caller’s own layout and the Yuzu-issued certificate counts; MCP twins list_crypto_widgets, get_crypto_dashboard_layout, set_crypto_dashboard_layout and get_crypto_ca_stats. The certificates behind a count are served by the filtered GET /api/v1/ca/issued (MCP list_issued_certs). Every route answers Cache-Control: no-store, private and Vary: Authorization, Cookie, X-Yuzu-Token. The Yuzu-issued figures read Yuzu’s own internal CA only, never certificates found on fleet devices (ADR-0070 D6). Facts only: the only built-in checks are the D13 named windows, each returned with its criterion and computed at read time against as_of, the database clock (ADR-0070 R4, R5), over the closed window set crypto_widget_windows_v1 (active; expiring 3/7/14/21/30/60 days; expired 7/30/60/90; revoked 7/30/60/90 in three reason buckets; issued 7; 52 ISO weeks, the current partial week included); nothing grades a certificate.

GET /api/v1/crypto/widgets

The widget catalogue. Authentication only; not audited. data: window_set, default_layout (ids), max_layout_widgets (64), scope_note and widgets[] with id, title, kind (number, bar, line, table), group, gate (fleet_read: library widgets, answered over the caller’s scope; fleet_wide_only: Yuzu-issued counts, withheld from a confined caller), securable, operation (Inventory/Read for every widget), criterion (what the widget counts, in words), drill, available and availability (available, not_available, confined or access_check_unavailable) and, for Yuzu-issued widgets, ca_kind, days, drill_securable/drill_operation (Security/Read) and drill_available. available and drill_available decide what to draw only: they come from the same non-auditing check the data routes would apply, and grant nothing. Errors: 401, 503 (server misconfiguration).

GET /api/v1/crypto/dashboard/layout

The caller’s OWN layout, keyed by the session user name (never another operator’s). Authentication only; not audited. A service-scoped API token is refused 403 (service-scoped tokens may not read or change a dashboard layout, no permission field; audited crypto.dashboard.layout.access_denied), and an MCP-tier token is held to its tier’s Infrastructure:Read. data: widgets (catalogue ids this server knows, in display order), is_default (true when the widgets are the default and no other-version ids are kept), version (the stored version, 0 when nothing is stored), preserved_unknown (stored ids this server version does not know: kept on every save, not listed) and max_layout_widgets.

curl -s -H "Authorization: Bearer $TOKEN" https://yuzu.example.com/api/v1/crypto/dashboard/layout
{
  "data": {
    "widgets": ["lib.devices_reporting", "ca.active", "ca.expiring_30d"],
    "is_default": false,
    "version": 7,
    "preserved_unknown": 0,
    "max_layout_widgets": 64
  },
  "meta": { "api_version": "v1" }
}

Errors: 401, 403, 422 (the stored layout is damaged: not retryable, the X-Crypto-Layout-Version header carries its version; a PUT with expected_version set to that version replaces it), 503 + retry_after_ms: 5000 (dashboard layout read degraded; never the default served in its place), and 503 without retry_after_ms when the layout store did not open at server start (dashboard layouts unavailable: the layout store did not open at server start; saved layouts return after a restart with the store fixed).

PUT /api/v1/crypto/dashboard/layout

Replace the caller’s own layout. Body: widgets (at most 64 catalogue ids, no repeats; an empty list is valid) and optional expected_version (an integer, 0 or more); no other key. With expected_version the save is refused 409 unless the stored version is still that value (0 = only if nothing is stored). Omit it only for a first save: without it the save succeeds only when nothing is stored, and over a stored layout (a damaged one included) it is the same 409 with X-Crypto-Layout-Version carrying the stored version. Ids stored by another server version are kept after yours (see preserved_unknown). Same caller rules as the GET (service-scoped token 403, audited as a crypto.dashboard.layout.update denied row; MCP-tier token held to Infrastructure:Write). A cookie-authenticated browser write must name this site (Origin/Referer), else 403 and a csrf.denied row. Audited crypto.dashboard.layout.update: an attempted row first (if it cannot be written the layout is not changed: 503, Sec-Audit-Failed: true), then success or failure.

curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"widgets":["lib.devices_reporting","ca.active","ca.expiring_30d"],"expected_version":7}' \
  https://yuzu.example.com/api/v1/crypto/dashboard/layout
{
  "data": {
    "widgets": ["lib.devices_reporting", "ca.active", "ca.expiring_30d"],
    "is_default": false,
    "version": 8,
    "preserved_unknown": 0,
    "max_layout_widgets": 64
  },
  "meta": { "api_version": "v1" }
}

data carries audit_persisted: false (and the response Sec-Audit-Failed: true) when the outcome row could not be written. The body cap is 16 KiB, enforced before the body is read; an over-cap body is refused with the pre-auth cap’s 413 envelope:

{
  "error": {
    "code": 413,
    "message": "request body exceeds this route's size cap",
    "correlation_id": "req-18f2a1c3d-2a",
    "retry_after_ms": null,
    "remediation": "reduce the request body size; see docs/user-manual/rest-api.md for the per-route limit"
  },
  "meta": { "api_version": "v1" }
}
StatusCondition
400Malformed body, unknown or repeated id, too many ids, a bad expected_version, or another key (the message names the position, never the value)
401Unauthenticated
403Service-scoped token, MCP tier below Infrastructure:Write, or a cross-site cookie write
409expected_version is stale, or omitted while a layout is stored (the layout changed since it was loaded (another tab or client saved first); reload it and save again). X-Crypto-Layout-Version carries the stored version (0 when another save landed during this one). Not retryable as sent: read the layout again, then save
413Body over 16 KiB
503retry_after_ms: 5000: dashboard layout save failed (store degraded), or save outcome unknown — reload to check (the connection dropped after the save was sent; the failure audit row says outcome=unknown; read the layout to see what was stored). Without retry_after_ms: the layout store did not open at server start, or the audit attempted row could not be written
GET /api/v1/crypto/ca/stats

Every Yuzu-issued count against one as_of. Permission: Inventory:Read, and only for an unconfined view of the fleet (ADR-0070 R2): a management-group-confined caller (or one whose service-scoped token confines it) is refused 403 with Yuzu-issued certificate figures are not available to this account: its view of the fleet is confined, and these are fleet-wide figures. and no permission field (no grant would admit it), audited as a crypto.ca.stats.view denied row. Every call reads at request time (never cached), bounded by the /crypto read admission limit (see crypto.md). Audited crypto.ca.stats.view on every call (set-and-proceed: audit_persisted: false + Sec-Audit-Failed: true when the row is lost).

curl -s -H "Authorization: Bearer $TOKEN" https://yuzu.example.com/api/v1/crypto/ca/stats

Response (trimmed: one entry of each array shown):

{
  "data": {
    "scope": "yuzu_issued",
    "scope_note": "Counts certificates Yuzu's own CA issued only (agent, server, gateway, HTTPS and code-signing); certificates on your devices are not covered.",
    "window_edge_note": "as_of is the database clock; issued and revoked times are recorded from the issuing server's clock, so a window edge is accurate only to the servers' clock synchronisation.",
    "rule_set_version": "crypto_widget_windows_v1",
    "as_of": 1792396800,
    "as_of_iso": "2026-10-19T08:00:00Z",
    "active": {"rule_id": "yuzu_ca_active", "criterion": "Status active, issued on or before as_of, notAfter on or after as_of (valid through notAfter, inclusive)", "rule_set_version": "crypto_widget_windows_v1", "as_of": 1792396800, "count": 412},
    "expiring_within_days": [
      {"rule_id": "yuzu_ca_expiring", "criterion": "Status active, notAfter from as_of through as_of + 30 days (valid through notAfter, inclusive)", "rule_set_version": "crypto_widget_windows_v1", "as_of": 1792396800, "count": 9, "threshold": 30}
    ],
    "expired_within_days": [
      {"rule_id": "yuzu_ca_expired", "criterion": "Status active, notAfter from as_of - 7 days up to, not including, as_of", "rule_set_version": "crypto_widget_windows_v1", "as_of": 1792396800, "count": 0, "threshold": 7}
    ],
    "revoked_within_days": [
      {"rule_id": "yuzu_ca_revoked_superseded", "criterion": "Revoked after as_of - 30 days and on or before as_of, reason recorded as superseded (replaced by a same-key successor)", "rule_set_version": "crypto_widget_windows_v1", "as_of": 1792396800, "count": 31, "threshold": 30, "bucket": "superseded"}
    ],
    "issued_within_days": {"rule_id": "yuzu_ca_issued", "criterion": "Issued after as_of - 7 days and on or before as_of, excluding unpublished (recorded, never delivered)", "rule_set_version": "crypto_widget_windows_v1", "as_of": 1792396800, "count": 14, "threshold": 7},
    "week_basis": "last 52 ISO weeks (UTC, Monday start); the current week is partial",
    "weeks": [
      {"week": "2026-W43", "week_start": 1792368000, "partial": true,
       "issued": {"rule_id": "yuzu_ca_issued_week", "criterion": "Issued in ISO week 2026-W43 (Monday 00:00 UTC start) up to as_of, excluding unpublished (recorded, never delivered)", "rule_set_version": "crypto_widget_windows_v1", "as_of": 1792396800, "count": 3, "week": "2026-W43"},
       "revoked": [
         {"rule_id": "yuzu_ca_revoked_week_superseded", "criterion": "Revoked in ISO week 2026-W43 (Monday 00:00 UTC start) up to as_of, reason recorded as superseded (replaced by a same-key successor)", "rule_set_version": "crypto_widget_windows_v1", "as_of": 1792396800, "count": 2, "week": "2026-W43", "bucket": "superseded"}
       ]}
    ]
  },
  "meta": { "api_version": "v1" }
}

Errors: 401, 403 (no Inventory:Read, written by the gate, or a confined caller, above), 503 + retry_after_ms: 5000 (Yuzu CA read degraded or crypto read capacity in use; retry; never zeros), and 503 with no retry_after_ms when the server’s Postgres pool is smaller than 4 connections (Yuzu-issued certificate reads refused: ... restart the server with --postgres-pool-size 4 or more; a retry fails the same way until that restart). The filtered GET /api/v1/ca/issued answers the same refusal the same way.

The /fragments/crypto/dashboard, /fragments/crypto/widget/{id} and /fragments/crypto/ca/certificates fragments render the same data for the page (always HTTP 200, a note on refusal and a banner on degrade) and write the same audit verbs; for a service-scoped token, or an MCP token whose tier refuses the layout read, /fragments/crypto/dashboard draws the default layout read-only and never reads the minting operator’s saved layout (the layout routes refuse the same tokens); the dashboard fragment writes one row per source per draw (see crypto.md). /fragments/crypto/ca/certificates is the page’s twin of the filtered GET /api/v1/ca/issued and needs Security:Read.


Result Sets

The result-set lifecycle routes (list/create/inspect/pin/delete). See scope-walking-design.md for the full design and the four producer routes documented above under Inventory (POST /api/v1/result-sets/from-inventory-query, from-echo-query, from-instruction-result, {id}/re-eval). ResultSetStore (ADR-0036) is always constructed in a running server (Postgres is mandatory; a construction failure halts startup rather than degrading serving, ADR-0012 §1) — these routes are always registered.

JSON nesting depth bound (json-dump-depth-guard fix), all four producers plus re-eval. nlohmann::json::dump() is unboundedly recursive; the MCP transport’s 32-level guard (#2437) checked only the live /mcp/ request body, leaving a gap on REST. POST /api/v1/result-sets, /from-inventory-query, /from-echo-query, and /from-instruction-result now reject (400 RESULT_SET_BAD_REQUEST) a request body nesting deeper than 32 levels before it is parsed, reusing the same kMcpMaxJsonDepth constant MCP enforces so the two surfaces cannot drift apart. POST /api/v1/result-sets/{id}/re-eval applies the same check to the row’s stored source_payload before parsing it, since the table is shared with MCP’s reevaluate_result_set and a row poisoned by any write path (including one predating this fix) would otherwise be re-dumped on a later read. Heal on reject (#4493). This specific re-eval attempt still fails with 400 RESULT_SET_BAD_REQUEST, but the route now also discards the poisoned source_payload in place (status-agnostic - materialized and failed rows are healed too, not just pending) before returning, so every subsequent read of the row is safe instead of re-detecting the same poison forever; the row’s status and members are never touched. The response body says “…and has been discarded…” only when the heal write actually committed - a rare heal-write failure returns a differently-worded 400 and leaves the row unchanged for the next retry.

MCP twins (#2146 Batch B2): every one of these 12 REST v1 operations has an MCP tool twin (list_result_sets, create_result_set, create_result_set_from_inventory_query, create_result_set_from_echo_query, create_result_set_from_instruction_result, reevaluate_result_set, get_result_set, get_result_set_members, get_result_set_lineage, pin_result_set, unpin_result_set, delete_result_set) — see mcp-server.md’s “Result sets” tool family. The three async producer tools share the exact same Execution:Execute + per-device confined-dispatch gate (#1788) as their REST twins below; all 9 of the remaining tools/routes are owner-scoped exactly like the REST routes (a service-scoped API token is denied outright). create_result_set_from_inventory_query/POST /api/v1/result-sets/from-inventory-query joined them in #4980: both gate via the admit-then-filter fleet_read_fn/fleet_read_fn_ chokepoint for scope confinement, but a service-scoped token is now hard-denied structurally BEFORE that chokepoint ever runs on either transport — MCP already enforced this via its C8 generic-tier gate’s structural ServiceScopeClass::denied default (empirically verified during #4980, never actually reachable there); the REST route lacked the equivalent and gated purely via fleet_read_fn, whose service-scope branch admits-and-confines a service-scoped token rather than denying it outright — since the created result set is still owner-scoped to the minting token’s principal, a service token could mint a set the minter’s other tokens/session could then read. This REST-side gap was originally tracked as #4307 and is closed by #4980’s deny_fleet_wide_service_scoped call on the route, matching its 8 siblings.

ResultSet is not a seeded RBAC securable; every route below is session-authenticated and owner-scoped instead (a result set is only readable/mutable by the principal that created it). A service-scoped API token is denied outright on every route (403): ownership keys on session->username, which for a service token is the minting operator’s identity, not the token’s own service tag — without this deny, any other service token the same operator holds could reach the same owner-scoped result sets.

Example ResultSet object (returned by several routes below):

{
  "id": "rs_a1b2c3d4e5f6",
  "name": "Ubuntu fleet, patched",
  "owner_principal": "alice",
  "created_at": 1711900800,
  "ttl_at": 1712505600,
  "last_used_at": 1711900800,
  "pinned": false,
  "parent_id": "",
  "source_kind": "manual_curate",
  "status": "materialized",
  "source_execution_id": "",
  "device_count": 42
}

parent_id is "" when the set has no parent. source_kind is one of manual_curate (direct POST /result-sets), inventory_query, echo_query, or instruction_result. status is pending until an asynchronous producer’s dispatch responses land, then materialized (or failed if the dispatch could not be materialized).

GET /api/v1/result-sets

List the caller’s owned result sets, most recently used first (last_used_at then created_at, both descending — not creation order).

Permission: Session-authenticated (owner-scoped; no RBAC permission).

Query parameters:

ParamTypeDescription
cursorstringOpaque pagination cursor from a previous response
limitintegerMax results (default 50, max 500). A value that is not one base-10 integer (10abc, empty, too large for int) is 400; an integer outside 1..500 uses the default of 50

Response:

{
  "data": { "result_sets": [ { "...": "ResultSet objects, see above" } ], "next_cursor": "" },
  "meta": { "api_version": "v1" }
}

Errors:

StatusReason
400limit is not one base-10 integer
403Service-scoped API token — result-set listing cannot be confined to the token’s service
503RESULT_SET_STORE_UNAVAILABLE — a store-level read failure while listing

A store-level read failure now answers 503 RESULT_SET_STORE_UNAVAILABLE (fail-closed, #4306) rather than a silent empty 200 array.

POST /api/v1/result-sets

Create a result set directly from a pre-computed device-id list (e.g. an operator with a CSV of device ids). Synchronous — lands materialized immediately, no dispatch involved.

Permission: Session-authenticated (owner-scoped). No RBAC securable — see “Not RBAC-gated: per-operator result sets” for what that does and doesn’t mean. An MCP-tiered bearer token additionally passes through the same tier/approval belt its create_result_set MCP twin enforces (#5047) — this is the cross-transport parity check, not an RBAC grant. When device_ids is non-empty, the route additionally gates that field through the admit-then-filter Infrastructure:Read chokepoint (fleet_read_fn, ADR-0017, #4983) — a request that omits device_ids (or supplies an empty array) is completely unaffected by this gate and pays no extra cost.

Request body:

FieldTypeRequiredDescription
namestringNoHuman-readable name
source_kindstringNoDefaults to manual_curate
source_payloadobjectNoStored as JSON; defaults to {} — if parent_id is also supplied AND source_payload is itself a JSON object, a scope_input_id key recording it is merged in (overwriting any caller-supplied key of that name), #4306
parent_idstringNoMust reference a set the caller owns (else 404)
device_idsarray of stringNoThe initial member set. Each entry is capped at 256 bytes (400 RESULT_SET_DEVICE_ID_TOO_LONG), checked before the existence/scope lookup below. #4983: every entry must name a device that both exists (checked against the presence-merged fleet, not just this replica’s local connections — HA WS-5) and is visible to the caller’s own scope (management-group confinement, service-scope confinement); a nonexistent id and a real-but-out-of-scope id are indistinguishable in the response, matching GET /api/v1/devices/{id}’s own oracle-safety posture. On any invalid STRING entry the WHOLE request is rejected (400) — never a silent drop or a partial create for a string entry that fails the check. (A non-string array entry, e.g. {"device_ids":["dev-1",7]}, is silently skipped during parsing rather than rejected — a pre-existing, separate gap, not introduced or closed by #4983.)

Response (201): A ResultSet object (see above).

Errors:

StatusReason
400RESULT_SET_TOO_MANY_MEMBERS (device_ids exceeds the per-set cap), or another ResultSetError (every non-quota create_materialized failure — including a store-level error — maps to 400, not 503)
400name/source_kind present but not a JSON string, or over the MCP-matching length cap (name 256 bytes, source_kind 64 bytes) - checked before create_materialized is ever called, not a ResultSetError (#4373)
400RESULT_SET_BAD_PARENT — parent_id supplied but empty/non-string. parent_id exceeding 64 bytes also returns 400, but without this code prefix (bare “parent_id must be at most 64 bytes”)
400RESULT_SET_UNKNOWN_DEVICE_ID (#4983) — a non-empty device_ids contains an id that does not exist, or exists but is outside the caller’s own scope; the offending id(s) are named in the error message (the caller’s own submitted list, so echoing them back is not a disclosure) — checked after the size cap and before create_materialized, so an oversized array is rejected for that reason first
400RESULT_SET_DEVICE_ID_TOO_LONG (#4983) — a device_ids entry exceeds 256 bytes; checked before both the per-set member-count cap and the existence/scope lookup above
403Service-scoped API token
403MCP token tier does not allow Infrastructure:Write — an MCP-tiered bearer whose tier disallows this operation (readonly/operator; #5047). The caller’s actual tier is not echoed in this message; it is recorded in the audit row only.
403(#4983) the caller lacks Infrastructure:Read — only reachable when device_ids is non-empty; a request without it never consults this gate
404parent_id supplied but not owned/found
429RESULT_SET_QUOTA — owner is at the per-owner set cap
503RESULT_SET_TIER_POLICY_UNAVAILABLE — the tier/approval check is misconfigured (server-side wiring fault, not a caller error; unreachable in a correctly-configured deployment)
503(#4983) The device_ids existence/scope check itself is unwired or its backing agent registry is unavailable — only reachable when device_ids is non-empty; a request without it is unaffected

GET /api/v1/result-sets/{id}

Fetch one result set by id.

Permission: Session-authenticated (owner-scoped, load_owned).

Response (200): A ResultSet object (see above).

Errors:

StatusReason
403Service-scoped API token
404RESULT_SET_NOT_FOUND — no such set, or not owned by the caller (identical body either way — no enumeration oracle)
503Result-set store unavailable

DELETE /api/v1/result-sets/{id}

Delete a result set.

Permission: Session-authenticated (owner-scoped, load_owned). No RBAC securable — see “Not RBAC-gated: per-operator result sets”. An MCP-tiered bearer additionally passes through the same tier/approval belt its delete_result_set MCP twin enforces (#5047); Infrastructure:Delete is approval-gated for the supervised tier on every transport except /mcp/v1/ itself, so a supervised-tier bearer must use the MCP ticket-then-recall flow to delete — this REST route cannot approve one.

Response: {"data": {"deleted": true}, "meta": {"api_version": "v1"}}

Errors:

StatusReason
403Service-scoped API token
403MCP token tier does not allow Infrastructure:Delete — an MCP-tiered bearer whose tier disallows delete outright (readonly/operator; #5047). The caller’s actual tier is not echoed in this message; it is recorded in the audit row only.
403Supervised-tier bearer, approval required — "operation requires approval for this MCP tier on this transport", with a remediation pointing at the MCP ticket-then-recall flow (#5047)
404No such set, or not owned by the caller. Also returned if the delete itself fails after ownership is confirmed (e.g. a store write error) — that failure is not currently distinguished from not-found.
409RESULT_SET_PINNED — unpin the set before deleting it
503Result-set store unavailable (ownership lookup only), or RESULT_SET_TIER_POLICY_UNAVAILABLE (tier/approval check misconfigured; server-side wiring fault, unreachable in a correctly-configured deployment)

GET /api/v1/result-sets/{id}/members

Page through a result set’s member device ids.

Permission: Session-authenticated (owner-scoped, load_owned), and Infrastructure:Read through the admit-then-filter fleet-read gate. Ownership says whose set it is, not which devices the owner can still see: the owner’s current management-group scope is applied in the query before the page limit, so a device they can no longer see is not listed, does not use up a page slot, and is never reached by the cursor. A set whose devices have all left the caller’s scope answers {"device_ids": [], "next_cursor": ""}. The dashboard fragment GET /fragments/result-sets/{id}/members and the MCP tool get_result_set_members apply the same gate.

Query parameters:

ParamTypeDescription
cursorstringOpaque pagination cursor
limitintegerMax results (default 1000, max 10000). A value that is not one base-10 integer is 400; an integer outside 1..10000 uses the default of 1000

Response: {"data": {"device_ids": [...], "next_cursor": ""}, "meta": {"api_version": "v1"}}

Errors:

StatusReason
400limit is not one base-10 integer
403Service-scoped API token, or the caller lacks Infrastructure:Read
404No such set, or not owned by the caller
503Result-set store unavailable, or the visibility check is unavailable

GET /api/v1/result-sets/{id}/lineage

Read a result set’s ancestor chain (walks parent_id links up to the root).

Permission: Session-authenticated (owner-scoped, load_owned).

Response: {"data": {"chain": [{"id": "...", "name": "...", "source_kind": "...", "device_count": 0}]}, "meta": {"api_version": "v1"}}

Errors:

StatusReason
403Service-scoped API token
404No such set, or not owned by the caller
503Result-set store unavailable

POST /api/v1/result-sets/{id}/pin
POST /api/v1/result-sets/{id}/unpin

Pin (exempt from TTL expiry) or unpin a result set.

Permission: Session-authenticated (owner-scoped, load_owned). No RBAC securable — see “Not RBAC-gated: per-operator result sets”. An MCP-tiered bearer additionally passes through the same tier/approval belt its pin_result_set/unpin_result_set MCP twins enforce (#5047) — this is the cross-transport parity check, not an RBAC grant.

Response (200): The updated ResultSet object (pinned flipped).

Errors:

StatusReason
403Service-scoped API token
403MCP token tier does not allow Infrastructure:Write — an MCP-tiered bearer whose tier disallows this operation (readonly/operator; #5047). The caller’s actual tier is not echoed in this message; it is recorded in the audit row only.
404No such set, or not owned by the caller. Also returned if the pin/unpin write itself fails after ownership is confirmed (e.g. a store write error) — that failure is not currently distinguished from not-found.
409PIN_LIMIT — owner is at the per-owner pin cap (pin only)
503Result-set store unavailable (ownership lookup only), or RESULT_SET_TIER_POLICY_UNAVAILABLE (tier/approval check misconfigured; server-side wiring fault, unreachable in a correctly-configured deployment)

Forensics

The per-agent app-usage discovery surface (Wave 7 PR7.2, ADR-0016 §5). Data is derived on the agent from Echo’s usage_daily fold via the read-only app_usage plugin and synced daily into AppUsageStore, projecting each executable’s retained-window first/last-seen plus a trailing-30-day run-count/total-seconds window. Forensics is held by default only by Administrator and the read-only ForensicsAnalyst role — deliberately absent from the seeded Viewer read-list.

GET /api/v1/forensics/agents/{agent_id}/app-usage

One device’s per-executable last-used projection — the per-agent drill. Because it renders behavioural data, every open is audited fail-closed: if the app_usage.agent.view audit row cannot persist, the request is refused (503 + Sec-Audit-Failed) and no data is served.

Permission: Forensics:Read — per-device scoped (management-group confinement; 403 for an in-fleet device outside your scope).

Response (200):

{
  "data": {
    "agent_id": "agent-001",
    "apps": [
      {"exe_key": "chrome.exe", "first_seen": 1751000000, "last_seen": 1751600000,
       "run_count_30d": 42, "total_seconds_30d": 86400}
    ],
    "collected_at": 1751600000
  },
  "meta": { "api_version": "v1" }
}

first_seen/last_seen are within Echo’s retained usage window (31 days default, operator-tunable) — never a value spanning the executable’s full run history. An executable absent from apps has not run inside that retained window; it does not mean the executable has never run. An agent that genuinely reported no rows for the window is 200 with apps: [].

Error responses:

StatusCondition
401Unauthenticated
403Caller lacks per-device-scoped Forensics:Read for this agent
503 + Sec-Audit-FailedThe per-open audit row could not persist — fail-closed, no data served
503Store unavailable or degraded (A4 envelope with correlation_id, retry_after_ms: 5000) — never an empty 200

Decommissioning the agent erases this store’s rows too — see Software Licensing (SLE)‘s DELETE /api/v1/sle/agents/{agent_id} (the eight-store cascade).


Software Licensing (SLE)

The detected software-licence discovery surface (ADR-0024; per its “Placement under ADR-1005” only the discovery mechanism is in-server — the compliance/entitlement/posture reads ship with the SAM use-case-engine module). Data is collected by the agent license_scan plugin and synced daily into SoftwareLicensingStore; see Software licence detection for what is collected and the per-user privacy carve-out. Not Yuzu’s own product licence (that is License Management).

GET /api/v1/sle/agents/{agent_id}

One device’s detected licences — the per-agent drill. This is the only surface that serves the per-user user_scope/user_ref fields (ADR-0024 Decision 11 personal data); its MCP twin query_software_licenses returns machine-scope facts with those fields omitted. Because it renders personal data, every open is audited fail-closed: if the sle.agent.view audit row cannot persist, the request is refused (503 + Sec-Audit-Failed) and no licence data is served.

Permission: SoftwareLicensing:Read — per-device scoped (management-group confinement; 403 for an in-fleet device outside your scope).

Response (200):

{
  "data": {
    "agent_id": "agent-001",
    "licenses": [
      {"product": "Office 365 ProPlus", "vendor": "Microsoft", "version": "16.0.1",
       "license_type": "subscription", "state": "subscription_active", "expiry_at": 1893456000,
       "channel": "KMS", "key_hint": "XXXXX-B7GJQ", "detector": "wmi_slp",
       "confidence": "probable", "exe_hints": "winword.exe;excel.exe",
       "user_scope": "machine", "user_ref": "",
       "collected_at": 1751000000, "first_seen": 1751000000, "last_seen": 1751600000}
    ],
    "count": 1
  },
  "meta": { "api_version": "v1" }
}

key_hint is an OS-provided partial key — full key material is never collected, stored, or served. expiry_at is epoch seconds (0 = none). On a per-user row, user_scope is "user" and user_ref carries the profile identifier as minimised on-device (hash mode default — a per-agent keyed-HMAC pseudonym; see the privacy limits). first_seen/last_seen are server receipt times.

Error responses:

StatusCondition
401Unauthenticated
403Caller lacks per-device-scoped SoftwareLicensing:Read for this agent
503 + Sec-Audit-FailedThe per-open audit row could not persist — fail-closed, no data served
503Store unavailable or degraded (A4 envelope with correlation_id, retry_after_ms: 5000) — never an empty 200

A genuinely licence-free (or unknown) device is 200 with count: 0; a 503 means the store could not be read — do not treat it as “nothing licensed”.

DELETE /api/v1/sle/agents/{agent_id}

Destructive. The audited whole-device erasure trigger: fans delete_agent across the eight stores in the cascade (generic inventory, installed-software, device-CI, app-perf, detected-licence, app-usage, the endpoint_state presence row that holds the device’s last-known hostname, OS, agent version and architecture, and the fleet certificate inventory), durably erasing the decommissioned device’s rows — including the Decision-11 user_ref personal data. This is the wired whole-device erasure path for those eight stores (GDPR Art. 17); per-agent stores outside the cascade are not erased by it, and row-level / per-subject DSAR erasure is a stated gap (#1666). Deliberately REST-only — no MCP twin (recorded ADR-1005 exception, #2102).

Permission: a single per-device-scoped Decommission:Delete securable (ADR-0024 Decision 9, amended Wave 7 PR7.2) — one grant authorizing for the cascade’s whole blast radius, replacing the earlier per-store conjunction. A role lacking it is 403’d, naming Decommission:Delete (the seeded Administrator/ITServiceOwner roles hold it by default; a custom role that had assembled the old per-store Delete grants must be granted this securable too — the three old grants no longer suffice).

Two durable audit events: sle.agent.decommission|attempt is written before the erasure and fails closed — if it cannot persist, nothing is erased (503 + Sec-Audit-Failed); the outcome row (success/partial) follows with the per-store breakdown.

Response (200) — every configured store’s delete committed:

{
  "data": {
    "agent_id": "agent-001",
    "decommissioned": true,
    "stores": {"inventory": "deleted", "software_inventory": "deleted",
               "app_perf_daily": "deleted", "device_inventory": "skipped",
               "software_licensing": "deleted", "app_usage": "deleted",
               "offline_endpoint": "deleted",
               "certificate_inventory": "deleted"},
    "deleted": 7,
    "skipped": 1,
    "failed": 0
  },
  "meta": { "api_version": "v1" }
}

Per-store outcomes: deleted (the DELETE committed), skipped (store not configured on this deployment), failed (rolled back or threw — the delete did not happen). Each store reports its true commit status, so a rolled-back erasure is never reported as done.

Error responses:

StatusCondition
401Unauthenticated
403Caller lacks the per-device-scoped Decommission:Delete permission
500One or more stores failed — the A4 body carries the per-store breakdown in error.details.stores; the cascade is idempotent, re-issue the DELETE to retry the failures. The server log line AgentDecommission: store '<name>' delete_agent reported failure for agent=<id> (delete did not commit — recorded Failed, not Deleted) names the failing store; the most frequent root cause is PostgreSQL availability (see the yuzu_pg_* metrics). A store that throws instead logs AgentDecommission: store '<name>' delete_agent threw for agent=<id>: <what>
503 + Sec-Audit-FailedThe attempt audit row could not persist — fail-closed, nothing was erased
503Scope gate or cascade not configured (A4 envelope)

A 200 with decommissioned: true is confirmed erasure for the stores in the cascade of the replica that answered (a store reported skipped was not erased); deleting an unknown agent_id is a no-op 200 (nothing to erase). The deleted count reaches 8 (7 before the certificate inventory store joined the cascade, 6 before offline_endpoint) when every store is configured. During a rolling upgrade, a DELETE served by a replica that has not been upgraded runs the old cascade and returns 200 without erasing the presence row: a response whose stores map has no offline_endpoint key did not erase it, and the sle.agent.decommission success audit row that replica writes lists its own stores (no offline_endpoint), so it is not evidence that the presence row was erased. Re-issue the DELETE once every replica runs this version. The same applies to the eighth store: a DELETE served by a replica older than the certificate inventory store returns 200 without a certificate_inventory key in stores, and did not erase the device’s certificate rows (those exist only once a certificate-collecting agent reports, so the window is empty until then). Re-issue it after the rollout.

Erasure does not unenroll the agent: a still-enrolled device re-syncs on its next daily cycle and re-creates its offline_endpoint presence row on its next heartbeat. Erasure is therefore point-in-time; an offline_endpoint=deleted entry in the success audit row’s store breakdown proves the row was deleted at commit, not that it stayed deleted. Stop or uninstall the agent first: revoking its certificate does not cut off an agent connected through a gateway (#5578), which must be disconnected at the gateway layer as well (see Device Management: Per-agent mTLS auto-provisioning). If you also revoke its certificate, look up the serial with GET /api/v1/ca/issued (needs Security:Read and an unconfined view; a management-group-confined account is refused), call POST /api/v1/ca/revoke (Security:Delete), and wait about 15 s for the next revocation sweep to drop a directly connected agent’s stream before you issue the DELETE. Revocation cannot be undone through the API: the server refuses to issue a new certificate for that agent id while the revoked certificate is unexpired (audit ca.cert.reissue_blocked), so do not revoke a device you intend to re-enrol.

To confirm the erasure, wait at least one heartbeat interval (30 s) with the agent stopped and then read /hardware; a still-running agent re-creates the row on its next heartbeat, in which case re-issue the DELETE (it is idempotent). /hardware reads the table directly (no cache), but it lists only rows seen in the last 30 days (for a device last seen more than 30 days ago /hardware cannot confirm erasure, because the row is hidden both before and after the DELETE, so the response’s offline_endpoint: "deleted" entry is the only confirmation) and needs fleet-scoped Inventory:Read; a management-group-confined operator cannot tell “erased” from “out of scope”, so confirm with a fleet-scoped reader. Each replica also keeps a liveness cache of about 3 s (used for scope evaluation and dispatch targeting, not by /hardware) that the decommission does not invalidate on any replica, including the one that served the DELETE, so the device may still count as present in scope evaluation for up to about 3 s. The endpoint_state row holds a hostname, which can identify a person on a personally-assigned device, so treat its erasure as personal-data erasure.

This guarantee is scoped to the stores the cascade actually fans across: a leftover legacy inventory.db on disk (only possible outside ADR-0009’s “no fleet ever ran pre-Postgres” assumption) was never part of it, because InventoryStore::delete_agent() stopped scrubbing that file when its one-time backfill was retired (#3623), since the erasure branch existed only to serve the backfill.


Execution Statistics

Fleet-wide and per-entity execution metrics.

GET /api/v1/execution-statistics

Get fleet-wide execution summary.

Permission: Execution:Read

Degraded store (this route, /agents, /definitions and GET /api/v1/statistics): a statistics read that fails because the execution store is not open, the connection pool timed out or a query failed answers 503 with retry_after_ms: 2000. It used to answer 200 with an empty list (or an all-zero summary), which reads as “no executions”. A scope or filter that genuinely matches nothing is still 200 with an empty data array.

Response:

{
  "data": {
    "total_executions": 15420,
    "executions_today": 230,
    "active_agents": 142,
    "overall_success_rate": 97.3,
    "avg_duration_seconds": 4.2
  },
  "meta": { "api_version": "v1" }
}

GET /api/v1/execution-statistics/agents

Get per-agent execution statistics.

Permission: Execution:Read, through the fleet-read chokepoint (ADR-0017 admit-then-filter, #3526): a management-group-confined caller is admitted and sees only the rows of the agents it can see (filtered in SQL before the limit), while a global grant sees every agent. A caller holding only a management-group-scoped grant, refused with 403 before, is admitted as a confined view. A service-scoped API token, also refused before, is admitted as a confined view only when RBAC enforcement is on (it must clear the ITServiceOwner authority ceiling, which holds Execution:Read, and the rows are then intersected with the token’s service tag scope); with RBAC off it is refused with 403. With RBAC off (the shipped default) the rollup is unfiltered for every authenticated non-service, non-engine caller. The sibling /definitions route and the fleet summary carry no agent identity and keep the plain Execution:Read gate. A malformed since or limit is 400, and so is a negative since.

Query parameters:

ParamTypeDescription
agent_idstringFilter to a single agent
sinceintegerUnix timestamp, inclusive lower bound on dispatch time. Omit for no bound; 0 is accepted and matches the same rows as omitting it; a negative value is 400 (it used to read as no bound)
limitintegerMax results (default 50, max 1000). A value below 1 or one that is not one base-10 integer is 400; the MCP twin rejects a limit below 1 with -32602 too

Response:

{
  "data": [
    {
      "agent_id": "agent-001",
      "total_executions": 324,
      "success_count": 310,
      "failure_count": 14,
      "success_rate": 95.7,
      "avg_duration_seconds": 3.8,
      "last_execution_at": 1711900800
    }
  ],
  "meta": { "api_version": "v1" }
}

GET /api/v1/execution-statistics/definitions

Get per-definition execution statistics.

Permission: Execution:Read

Query parameters:

ParamTypeDescription
definition_idstringFilter to a single definition
sinceintegerUnix timestamp, inclusive lower bound on dispatch time. Omit for no bound; 0 is accepted and matches the same rows as omitting it; a negative value is 400 (it used to read as no bound)
limitintegerMax results (default 50, max 1000). A value below 1 or one that is not one base-10 integer is 400; the MCP twin rejects a limit below 1 with -32602 too

Response:

{
  "data": [
    {
      "definition_id": "os-info-query",
      "total_executions": 5200,
      "total_agents": 142,
      "success_rate": 99.5,
      "avg_duration_seconds": 1.2
    }
  ],
  "meta": { "api_version": "v1" }
}

Execution Visualization

Render an execution’s response set as chart-ready JSON, using the spec.visualization block configured on the associated InstructionDefinition (issue #253). The endpoint is the data source for the dashboard’s chart cards and is also suitable for external consumers (Grafana scripted-panel datasources, custom dashboards) that can read JSON over HTTP.

GET /api/v1/executions/{id}/visualization

Permission: require_fleet_read("Response","Read") (ADR-0017 admit-then-filter — #1634, replaced the flat Response:Read gate)

Confined (#1634). A management-group-confined operator is admitted and sees only their in-scope agents’ rows in the rendered chart — real cross-operator isolation, not the earlier inert per-row filter. The visible-agent set is resolved and pushed into the underlying SQL query before the row cap (ADR-0017 INV-3), so rows_capped (below) reflects the CALLER’s own scoped cap hit, not a raw-then-filtered one that could fire entirely inside another operator’s rows. On a corrupt or unavailable RBAC store the endpoint still fails closed (403/503) rather than exposing the whole fleet. When rows are dropped, a SEPARATE result=denied audit row fires (detail carries scope_dropped=<N>), paired with the result=success row for the same request — this fires under ordinary operation for a confined caller whose execution spans agents outside their groups, not only on RBAC-store corruption.

Bounded read (#4644). The response read behind the chart is capped at 10,000 rows AND 50 MiB of row payload (output plus error_detail), applied in SQL on whole rows with at least one row always read (the same fetch as the export routes, see Command/Instruction Responses). rows_capped:true with rows_cap:10000 now means more matching rows existed beyond the row cap (it used to be set on a read of exactly 10,000 rows too). When either cap cut the read the payload also carries result_truncated_by_cap:true and truncation_cause (row_cap or byte_cap): a chart built from a cut read is a wrong picture, not a smaller one, so a client must show it as partial. The cut is logged but not counted on the export-cut counter, whose surface label is a closed set naming the export routes.

Path parameters:

ParamDescription
idThe response-store key (the command_id returned at dispatch time).

Query parameters:

ParamTypeRequiredDescription
definition_idstringYesID of the InstructionDefinition that holds the spec.visualization (or spec.visualizations) block. Required because Yuzu keys responses by command_id, not by executions.id — the caller supplies the link. Must match [A-Za-z0-9._-]+.
indexintegerNoChart index when the definition declares multiple visualizations. Default 0. Returns 404 if out of range.

Response (200):

{
  "data": {
    "chart_type": "pie",
    "title": "Service States",
    "labels": ["running", "stopped", "paused"],
    "series": [{ "name": "Count", "data": [42, 7, 1] }],
    "meta": {
      "responses_total": 50,
      "responses_succeeded": 50,
      "responses_failed": 0
    },
    "chart_index": 0,
    "chart_count": 1
  },
  "meta": { "api_version": "v1" }
}

chart_index and chart_count (issue #587) let clients iterate when the definition declares multiple charts. For datetime_series charts, labels is replaced by x (epoch-seconds array) and "x_axis": "datetime" is added.

When the underlying response set exceeds the per-request row cap (10000), the response payload includes "rows_capped": true and "rows_cap": 10000 so the client can surface a “showing first N rows” banner. rows_capped is the row cap only: a read cut by the 50 MiB payload cap sets result_truncated_by_cap: true with truncation_cause: "byte_cap" and not rows_capped, so a client must test result_truncated_by_cap to catch both. The dashboard chart adapter does (it shows a “Partial result” notice in the chart card).

Errors:

StatusCause
400definition_id query parameter not provided, or index is not a non-negative integer.
404Definition not found, has no spec.visualization configured, or the requested index is out of range.
500The visualization spec parses but cannot be applied (invalid processor / invalid chart type).
503Response store or instruction store unavailable, or the response store’s read degraded (transient Postgres outage — retryable, retry_after_ms:5000 in the A4 body; audited as reason=response_store_degraded).

Audit: every successful and failed render emits an execution.visualization.fetch audit event with target_type=execution, target_id=<execution_id>, detail=<definition_id> index=<N> on success, or <definition_id> reason=<r> on the failure path. When out-of-scope agents’ rows were dropped, a SEPARATE result=denied row on the same action fires alongside the success row, detail=<definition_id> scope_dropped=<N> — not appended to the success detail.

Example:

curl -s -H "Authorization: Bearer $TOKEN" \
  "https://yuzu.example.com/api/v1/executions/cmd-os_info-abc123/visualization?definition_id=crossplatform.service.inspect"

See docs/yaml-dsl-spec.md § spec.visualization for the full configuration schema.


Live-Query Bundles

Fan one instruction out into several plugin actions on one device and collate the results — server-side async fan-out (ADR-0011). Use this instead of issuing N separate execute_instruction / POST /api/instructions/{id}/execute calls when you need to refresh a single device (e.g. a ServiceNow CI sync): N round-trips collapse to one dispatch plus one (or a few) polls.

The server expands the bundle into N ordinary plugin commands, each dispatched under one shared bundle-… correlation id. The agent is unchanged — it never sees a “bundle”, only ordinary commands. Dispatch is async: it returns immediately, and a slow plugin step never withholds the others. The MCP tools execute_bundle / get_bundle_result are the exact parity of these two endpoints.

POST /api/v1/bundles

Dispatch a bundle. Returns the correlation id immediately; poll GET /api/v1/bundles/{id} for the collated result.

Permission: Execution:Execute, scoped per-device (management-group-confined operators are admitted for devices in their own group(s)). The caller’s derived visibility is also enforced a second time at dispatch (#1788, defense-in-depth) — a target outside it is treated as unreached (result=no_agents, state=dispatch_failed on collate) rather than reaching the device.

Request body:

FieldTypeRequiredDescription
agent_idstringYesThe single target device — a bundle targets one device.
stepsarrayYes1–32 step objects, executed in request order. Duplicate (plugin, action) is allowed (e.g. two registry/get_value reads with different params) — each step gets its own command_id and collates back in order.
steps[].pluginstringYesPlugin name, lowercased [a-z0-9_], ≤ 64 chars.
steps[].actionstringYesAction name, same charset / length rule.
steps[].paramsobjectNoString→string parameter map (non-string values are coerced to strings).

Response (202 Accepted):

{
  "data": { "bundle_id": "bundle-1a2b3c4d5e6f7a8b", "agent_id": "agent-001", "expected": 2 },
  "meta": { "api_version": "v1" }
}

bundle_id is the bundle-… correlation id — pass it to GET /api/v1/bundles/{id} to collate. It is not an ExecutionTracker execution_id: a bundle is deliberately not a tracked execution, so feeding bundle_id into GET /api/v1/events / execution-status endpoints returns 404. expected is the number of steps dispatched.

Errors:

StatusCause
400Invalid JSON, missing/empty agent_id, missing/empty steps, more than 32 steps, an unsafe plugin/action identifier, a param key/value over the size cap (key ≤ 256 B, value ≤ 64 KiB, ≤ 32 params/step), or a request body nesting deeper than 32 levels (kMcpMaxJsonDepth).
500The authenticated session resolved to an empty principal (a bundle must be attributable to its dispatcher).
503Command dispatch or response store unavailable.

Audit: each step emits a bundle.<plugin>.<action> event with target_type=Agent, target_id=<agent_id>, result=dispatched (the step reached the agent) or result=no_agents (the step reached zero agents — it will read dispatch_failed on collate) — the device-access lens, so a bundle is exactly as auditable as the N executions it replaces. One bundle.dispatch (target_type=Execution) envelope records the dispatch itself (result=success, or result=failure with the error detail if dispatch threw).

GET /api/v1/bundles/{id}

Collate a dispatched bundle into a server-grouped result.

Permission: Response:Read

Path parameters:

ParamDescription
idThe bundle-… correlation id returned by POST /api/v1/bundles.

Response (200):

{
  "data": {
    "complete": true,
    "received": 2,
    "succeeded": 2,
    "expected": 2,
    "steps": [
      { "plugin": "os_info", "action": "uptime",  "state": "responded", "status": 1, "output": "up 3 days" },
      { "plugin": "os_info", "action": "os_name", "state": "responded", "status": 1, "output": "Windows 11" }
    ]
  },
  "meta": { "api_version": "v1" }
}

Steps are returned in request order (not arrival order), so duplicate or same-plugin steps stay unambiguous. state is one of pending (no response yet), responded (a response landed), or dispatch_failed (the step reached no agent — terminal; it does not hold the bundle open). status is the response status enum (1=SUCCESS, 2=FAILURE; 0 is an in-flight RUNNING frame — only meaningful when state=responded). received counts steps with any response; succeeded counts only steps that responded with status SUCCESS. Plugin output is transcoded to UTF-8 — any bytes that are not valid UTF-8 (e.g. binary or a non-UTF-8 codepage) are replaced with U+FFFD so the JSON always serializes.

complete is terminal, NOT success. A bundle to an offline device completes (complete=true) with received=0, succeeded=0, and every step dispatch_failed. A caller deciding “did the refresh work” must check succeeded == expected (or inspect each steps[].state/status) — never complete alone. Polling complete to mark a record refreshed would silently treat an unreachable device as a success.

Errors:

StatusCause
404Bundle not found, or the caller did not dispatch it and is not an admin. The two are deliberately indistinguishable (no enumeration oracle); the real reason is recorded in the audit log.
503The bundle manifest WAS found and owned, but the response store could not be read (transient Postgres degrade) — distinct from a 404, and retryable: retry_after_ms:5000 in the A4 body. Poll again after the hint rather than treating this as terminal.

Audit: bundle.collate (target_type=Execution, target_id=<id>), result=success (carrying complete=0|1), result=denied (not found / not owned), or result=failure (response store degraded, the 503 case above). When the collation serves the output of a Forensics-tier step (a step of a plugin on the Forensics securable that has responded), it also writes response.forensics.read (target_type=Execution, target_id=<id>, detail forensics_rows=<n> plugins=<names> surface=bundle); it fails closed like the other REST reads: if that row cannot persist the response is 503 with Sec-Audit-Failed: true and no result is returned, and a later poll writes the row again. A bundle with no served Forensics-tier step writes no such row.

Notes:

Error handling and retry (for integrators):

Example:

# dispatch
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"agent_id":"agent-001","steps":[{"plugin":"os_info","action":"uptime"},{"plugin":"os_info","action":"os_name"}]}' \
  "https://yuzu.example.com/api/v1/bundles"
# -> {"data":{"bundle_id":"bundle-…","agent_id":"agent-001","expected":2}, ...}

# collate (poll until data.complete == true)
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://yuzu.example.com/api/v1/bundles/bundle-1a2b3c4d5e6f7a8b"

Device Tokens

Device tokens are scoped authentication tokens that restrict execution to a specific device and instruction definition. Used for unattended agent operations. DeviceTokenStore is currently not constructed by the server (capability 18.8 is deliberately shelved — same family as License Management, docs/adr/0052-device-token-store-postgres-migration.md Context), so these routes do not register today; documented for when a future change re-wires them.

principal_id vs device_id. These are two different identities. principal_id is the issuing operator’s username — set from the authenticated session at creation time, never supplied in the request body. device_id is the agent the token is bound to (set from the request body’s device_id field) — this is the identity a presenting agent is validated against when the token is used, and the identity the re-registration revoke below acts on.

Re-registration revoke (#823/#3401). When the agent named by a token’s device_id re-registers with the server, every still-valid token bound to that device_id is revoked before the new session is installed — closing the window where a briefly-impersonated agent (an mTLS-disabled registration) could otherwise replay a token issued to the legitimate agent. This is independent of who issued the token (principal_id). If the revoke sweep itself fails (a database fault), the registration is refused rather than proceeding with stale tokens left live; the agent’s normal reconnect/retry behavior applies.

GET /api/v1/device-tokens

List all device tokens.

Permission: DeviceToken:Read

Response:

{
  "data": [
    {
      "token_id": "a1b2c3d4e5f6",
      "name": "Kiosk daily update",
      "principal_id": "admin",
      "device_id": "kiosk-001",
      "definition_id": "windows-update-install",
      "created_at": 1711900800,
      "expires_at": 1714492800,
      "last_used_at": 1711987200,
      "revoked": false
    }
  ],
  "meta": { "api_version": "v1" }
}

Errors:

ConditionResponse
A genuine database read failure503

POST /api/v1/device-tokens

Create a device-scoped token. The raw token value is returned exactly once at creation time.

Permission: DeviceToken:Write

Request body:

FieldTypeRequiredDescription
namestringYesHuman-readable token name
device_idstringNoRestrict token to a specific agent
definition_idstringNoRestrict token to a specific instruction definition
expires_atintegerNoUnix timestamp for token expiration

Response (201):

{
  "data": { "raw_token": "ydt_a1b2c3d4e5f6..." },
  "meta": { "api_version": "v1" }
}

Errors:

ConditionResponse
Malformed JSON body400 — invalid JSON
name/device_id/definition_id exceeds 256 chars400 — invalid_input_length: ...
CSPRNG entropy exhaustion503 + Retry-After: 5 — CSPRNG unavailable: ...
A genuine database write or token-hashing failure503 + Retry-After: 5 — service unavailable

DELETE /api/v1/device-tokens/{id}

Revoke a device token.

Permission: DeviceToken:Delete

Response:

{
  "data": { "revoked": true },
  "meta": { "api_version": "v1" }
}

Errors:

ConditionResponse
No token with this id404 — token not found
A genuine database write failure503 — service unavailable

Pre-flight & Deploy

REST twins of the /auto pre-flight ASSESS stage’s saved-runs rail and the deploy ACT stage’s go/warn preview — see Deployments and pre-flight readiness checks for the full workflow these routes are read-only slices of. Both are owner-scoped (your own runs/deployments only) and confirmed inert — neither creates a run or a deployment. MCP twins: list_preflight_runs / get_deployment_preview (docs/mcp-server.md).

GET /api/v1/preflight/runs

Your own saved pre-flight runs, newest first. This is the saved-runs-rail HALF of GET /fragments/auto’s payload only — the config-options half (available management groups for the scope dropdown) is not duplicated here; see Management Groups for that identical catalogue (groups_fn_ sources from the exact same list_groups() call both routes share).

Permission: Infrastructure:Read

Parameters:

NameInRequiredDescription
limitqueryNoMax runs to return (default 12, matching the dashboard rail; capped at 100; a 0 or negative value is silently clamped to 1, not rejected)

Response:

{
  "data": [
    {
      "run_id": "a1b2c3d4e5f6a7b8",
      "name": "AcmeVPN 4.2.0 rollout",
      "scope_label": "Sales laptops",
      "status": "complete",
      "created_at_ms": 1758000000000,
      "deadline_at_ms": 1758001800000,
      "completed_at_ms": 1758001200000,
      "total": 40,
      "go": 35,
      "warn": 2,
      "nogo": 1,
      "incomplete": 2
    }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 12 },
  "meta": { "api_version": "v1" }
}

Errors:

ConditionResponse
limit cannot be parsed as an integer (e.g. non-numeric)400 — a parseable but out-of-range value (0, negative, or > 100) is clamped, not rejected
A service-scoped API token — this owner-scoped read cannot be confined to the token’s service403, audited under preflight.run.view (a distinct verb from preflight.run, the run-creation verb — see the audit note below)
Pre-flight run store never configured on this server (permanent)503 (A4 envelope, no retry_after_ms — this is a deployment-config condition, not one a client can retry past)
Pre-flight run store degraded (transient)503 (A4 envelope, retry_after_ms: 2000 — matches the MCP twin’s own kMcpStoreFaultShortRetryMs)

Audit: unaudited on a successful read (run scope/lifecycle metadata, not per-agent behavioural PII — matches the fragment’s own posture); a service-scoped-token denial is audited under preflight.run.view, deliberately not the reused preflight.run verb — that verb is the run creation audit (POST /fragments/auto/run’s success event), and reusing it here would make a denied list read indistinguishable from a run being created in the audit log.


GET /api/v1/deployments/preview

The deploy-config go/warn preview for one pre-flight run’s cleared cohort — how many devices are ready to receive an installer, before you configure and start a deployment.

Permission: SoftwareDeployment:Read

Parameters:

NameInRequiredDescription
runqueryYesThe source pre-flight run id

Response:

{
  "data": { "run_id": "a1b2c3d4e5f6a7b8", "name": "AcmeVPN 4.2.0 rollout", "go": 35, "warn": 2 },
  "meta": { "api_version": "v1" }
}

Errors:

ConditionResponse
Missing run parameter400
A service-scoped API token — this owner-scoped read cannot be confined to the token’s service403, audited under deployment.config.view
No such run, or it belongs to another operator404 — indistinguishable by design (closes the existence oracle)
Pre-flight run store never configured on this server (permanent)503 (A4 envelope, no retry_after_ms — this is a deployment-config condition, not one a client can retry past)
Pre-flight run store degraded (transient)503 (A4 envelope, retry_after_ms: 2000 — matches the MCP twin’s own kMcpStoreFaultShortRetryMs)

Audit: unaudited on a successful read (same rationale as the runs list above); a service-scoped-token denial is audited under deployment.config.view — already distinct from deployment.create (this route never creates a deployment).


GET /api/v1/deployments

Your own software deployments from the live /auto pipeline, newest first. This is the read-only list the dashboard fragment GET /fragments/auto/deployments renders; unlike GET /fragments/auto/deploy/result it never advances the engine and writes no per-poll audit row. It does not read the shelved /software-deployments store below. MCP twin: list_deployments.

Permission: SoftwareDeployment:Read

Parameters:

NameInRequiredDescription
limitqueryNoMax deployments to return (default 20; a whole integer outside 1..100 is clamped; anything else is 400: non-numeric, a number followed by other characters such as 20junk, or one too large for a 64-bit integer)

Response:

{
  "data": [
    {
      "deployment_id": "0a1b2c3d4e5f6071",
      "source_run_id": "a1b2c3d4e5f6a7b8",
      "name": "AcmeVPN 4.2.0 rollout",
      "artifact_filename": "acmevpn-4.2.0.msi",
      "artifact_sha256": "3a7bd3e2...",
      "has_args": true,
      "status": "complete",
      "state": { "code": "complete_failed", "label": "Complete, 2 failed", "tone": "warn" },
      "created_at_ms": 1758000000000,
      "completed_at_ms": 1758000900000,
      "total": 35, "succeeded": 32, "failed": 2, "skipped": 1, "active": 0
    }
  ],
  "pagination": { "start": 0, "page_size": 20, "truncated": false },
  "meta": { "api_version": "v1" }
}

pagination.truncated is true when you own more deployments than limit returned. There is no total and no paging cursor: raise limit (maximum 100) to see more, and note that a deployment older than your newest 100 is not reachable through this endpoint.

status is running or complete; the live engine has no staged, cancelled or rolled-back state. state.label carries the failed or skipped count of a finished deployment (“Complete, 2 failed”). The installer URL and installer arguments are never returned (either can carry a signed-URL token or a licence key); has_args says only whether arguments are set. Counts are as of the last engine advance, which only happens while a Deployments page is open, so a running row can be stale.

Errors:

ConditionResponse
limit is not a whole integer (non-numeric, trailing characters, or does not fit 64 bits)400
A service-scoped API token403, audited under deployment.list.view
Deployment run store never configured on this server503 (A4 envelope, no retry_after_ms)
Deployment run store degraded (transient)503 (A4 envelope, retry_after_ms: 2000) — never an empty list

Audit: unaudited on a successful read (run lifecycle metadata, not per-agent behavioural PII); a service-scoped-token denial is audited under deployment.list.view.


GET /api/v1/deployments/{id}

One of your deployments with its device grid. MCP twin: get_deployment. Read-only: it never advances the engine and writes no per-poll audit row.

Permission: SoftwareDeployment:Read

Parameters:

NameInRequiredDescription
idpathYesThe deployment id (lowercase hex, from the list)
limitqueryNoMax device rows (default 500, capped at 5000). The run counts are exact regardless; truncated is true when the grid holds fewer rows than total

Response: data is the list row above plus devices (ordered by hostname), device_limit and truncated:

{
  "data": {
    "deployment_id": "0a1b2c3d4e5f6071",
    "status": "running",
    "total": 2, "succeeded": 1, "failed": 0, "skipped": 0, "active": 1,
    "devices": [
      { "agent_id": "ag-1", "hostname": "host-1", "os": "windows", "step": "succeeded",
        "exit_code": 0, "error": "", "updated_at_ms": 1758000100000 },
      { "agent_id": "ag-2", "hostname": "host-2", "os": "windows", "step": "executing",
        "exit_code": 0, "error": "", "updated_at_ms": 1758000100000 }
    ],
    "device_limit": 500,
    "truncated": false
  },
  "meta": { "api_version": "v1" }
}

step is one of pending, staging, staged, executing, succeeded, failed, skipped. A device the engine refused (quarantined, kill-switched, out of scope) carries the engine’s own text in error.

Errors:

ConditionResponse
limit is not a whole integer (non-numeric, trailing characters, or does not fit 64 bits)400
A service-scoped API token403, audited under deployment.detail.view
No such deployment, or it belongs to another operator404 — indistinguishable by design
Store never configured / degraded503 as for the list

Software Deployment

Manage software packages and their deployments to agents. SoftwareDeploymentStore is currently not constructed by the server (capability 7.6 is deliberately shelved — same family as License Management, docs/adr/0051-software-deployment-store-postgres-migration.md Context), so these routes do not register today; documented for when a future change re-wires them.

GET /api/v1/software-packages

List all registered software packages.

Permission: SoftwareDeployment:Read

Response:

{
  "data": [
    {
      "id": "pkg-001",
      "name": "Firefox ESR",
      "version": "115.8.0",
      "platform": "windows",
      "installer_type": "msi",
      "content_hash": "sha256:abcdef...",
      "size_bytes": 58720256,
      "created_at": 1711900800,
      "created_by": "admin"
    }
  ],
  "meta": { "api_version": "v1" }
}

Errors:

ConditionResponse
A genuine database read failure503

POST /api/v1/software-packages

Register a new software package.

Permission: SoftwareDeployment:Write

Request body:

FieldTypeRequiredDescription
namestringYesPackage name
versionstringYesPackage version
platformstringNoTarget platform (default "windows")
installer_typestringNoInstaller type (default "msi")
content_hashstringNoSHA-256 hash of the installer
content_urlstringNoDownload URL for the installer binary
silent_argsstringNoSilent install arguments (e.g. /qn /norestart). Same validation rules as verify_command — max 512 chars, rejects shell metacharacters and control characters.
verify_commandstringNoPost-install verification command. Max 512 chars. Rejects shell metacharacters (; & `
rollback_commandstringNoRollback command on failure. Same validation rules as verify_command.
size_bytesintegerNoInstaller file size in bytes

Response (201):

{
  "data": { "id": "pkg-001" },
  "meta": { "api_version": "v1" }
}

May carry Sec-Audit-Failed: true — see Sec-Audit-Failed above.

Errors:

ConditionResponse
Malformed JSON body, a required field wrong-typed or empty, or verify_command/rollback_command/silent_args fails the shell-metacharacter/length check400
A genuine database write failure503

GET /api/v1/software-deployments

List software deployments, optionally filtered by status.

Permission: SoftwareDeployment:Read

Query parameters:

ParamTypeDescription
statusstringFilter by status: staged, deploying, verifying, completed, cancelled, rolled_back, failed

Response:

{
  "data": [
    {
      "id": "dep-001",
      "package_id": "pkg-001",
      "status": "completed",
      "created_by": "admin",
      "created_at": 1711900800,
      "started_at": 1711901400,
      "completed_at": 1711902000,
      "agents_targeted": 50,
      "agents_success": 48,
      "agents_failure": 2
    }
  ],
  "meta": { "api_version": "v1" }
}

Errors:

ConditionResponse
A genuine database read failure503

POST /api/v1/software-deployments

Create a new software deployment. Starts in status staged.

Permission: SoftwareDeployment:Execute

Request body:

FieldTypeRequiredDescription
package_idstringYesID of the registered software package
scope_expressionstringNoScope expression selecting target agents

Response (201):

{
  "data": { "id": "dep-001" },
  "meta": { "api_version": "v1" }
}

May carry Sec-Audit-Failed: true — see Sec-Audit-Failed above.

Errors:

ConditionResponse
Malformed JSON body, or package_id empty400
package_id does not reference a registered package400
A genuine database write failure503

POST /api/v1/software-deployments/{id}/start

Start a staged deployment (transitions to deploying).

Permission: SoftwareDeployment:Execute

Response: {"data": {"started": true}, "meta": {"api_version": "v1"}} — may carry Sec-Audit-Failed: true (see above).

Errors:

ConditionResponse
No deployment with this id, or it is not staged400
A genuine database write failure503

POST /api/v1/software-deployments/{id}/rollback

Roll back a deploying, verifying, or completed deployment (transitions to rolled_back).

Permission: SoftwareDeployment:Execute

Response: {"data": {"rolled_back": true}, "meta": {"api_version": "v1"}} — may carry Sec-Audit-Failed: true (see above).

Errors:

ConditionResponse
No deployment with this id, or it is not deploying/verifying/completed400
A genuine database write failure503

POST /api/v1/software-deployments/{id}/cancel

Cancel a staged or deploying deployment (transitions to cancelled).

Permission: SoftwareDeployment:Execute

Response: {"data": {"cancelled": true}, "meta": {"api_version": "v1"}} — may carry Sec-Audit-Failed: true (see above).

Errors:

ConditionResponse
No deployment with this id, or it is not staged/deploying400
A genuine database write failure503

License Management

Manage Yuzu license entries, seat counts, and alerts. LicenseStore is currently not constructed by the server (licensing is deliberately shelved — docs/adr/0048-license-store-postgres-migration.md Context), so these routes do not register today; documented for when a future change re-wires them.

GET /api/v1/license

Get the active license details, or {"status": "none"} if no license is active.

Permission: License:Read

Response:

{
  "data": {
    "id": "lic-001",
    "organization": "Acme Corp",
    "seat_count": 500,
    "seats_used": 142,
    "issued_at": 1704067200,
    "expires_at": 1735689600,
    "edition": "enterprise",
    "status": "active",
    "days_remaining": 275
  },
  "meta": { "api_version": "v1" }
}

Errors:

ConditionResponse
A genuine database read failure503

POST /api/v1/license

Activate a license.

Permission: License:Write

Request body:

FieldTypeRequiredDescription
organizationstringYesOrganization name
seat_countintegerYesLicensed seat count: a JSON integer of at least 0 (0 = unlimited); a float, string, null, negative or above-INT64_MAX value is 400 (#4644)
editionstringNoLicense edition (default "community")
expires_atintegerNoUnix timestamp for license expiration: a JSON integer of at least 0 (0 or omitted = perpetual); a float, string, null, negative or above-INT64_MAX value is 400, where a negative value used to be stored and read back as perpetual (#4644)
features_jsonstringNoJSON array of enabled feature flags
license_keystringYesLicense activation key

Response (201):

{
  "data": { "id": "lic-001" },
  "meta": { "api_version": "v1" }
}

Errors:

ConditionResponse
Malformed JSON body, or a body that is not a JSON object400 — invalid JSON
seat_count or expires_at not a JSON integer of at least 0400: nothing activated (#4644)
license_key or organization empty400
license_key already activated on another license400 — license key already activated
A genuine database write failure503

DELETE /api/v1/license/{id}

Remove a license entry.

Permission: License:Write

Response: {"data": {"removed": true}, "meta": {"api_version": "v1"}}

Errors:

ConditionResponse
No license with this id404
A genuine database write failure503

GET /api/v1/license/alerts

List license alerts (expiration warnings, seat limit approaching, etc.).

Permission: License:Read

Query parameters:

ParamTypeDescription
unacknowledgedflagIf present, return only unacknowledged alerts

Response:

{
  "data": [
    {
      "id": "alert-001",
      "license_id": "lic-001",
      "alert_type": "expiration_warning",
      "message": "License expires in 30 days",
      "triggered_at": 1711900800,
      "acknowledged": false
    }
  ],
  "meta": { "api_version": "v1" }
}

Errors:

ConditionResponse
A genuine database read failure503

Topology

GET /api/v1/topology

Get infrastructure topology data. For full topology rendering, use the HTMX fragment endpoint /frag/topology-data. The REST endpoint provides a pointer for external callers.

Permission: Infrastructure:Read


Fleet Visualization (3D)

The fleet-visualization endpoints expose a single aggregate fleet_topology.v1 document that the /viz/fleet 3D renderer consumes. The endpoint dispatches echo.fleet_snapshot to every connected agent on cache miss, aggregates per-agent snapshots into machine cubes + interior process nodes + connection edges, and applies a 60 s LRU-of-2 cache (keyed on include_vuln).

GET /viz/fleet

Browser-facing page that renders the 3D fleet topology. The page itself is auth-gated only; per-request RBAC enforcement happens when the page’s JS hits GET /api/v1/viz/fleet/topology (see below).

Permission: Session auth only (require_auth); redirects to /home on no session. The --viz-disable / YUZU_VIZ_DISABLE kill switch disables the page as well as the API: a signed-in request for /viz/fleet returns 503 with a plain-text message instead of the page shell (a request with no session still redirects to /home first).

Browser requirements: the dashboard stylesheet needs Chrome 111+, Edge 111+, Firefox 121+ or Safari 16.2+ (color-mix() and :has()), and this page also requires importmap support (Safari 16.4+; the other browsers already meet it). On a browser without importmap the page detects via HTMLScriptElement.supports('importmap') and surfaces a visible error overlay instead of a blank canvas.

Cache posture: the response sets Cache-Control: no-cache, no-store, must-revalidate. Vendored static assets (Three.js, OrbitControls, yuzu-viz.js) cache for 24 hours; the page itself revalidates on every navigation so a server upgrade cannot leave operators with a stale page that references the new assets.

Deployment constraint: the page hard-codes static asset paths (/static/three.module.min.js, /static/three-orbit-controls.js, /static/yuzu-viz.js) and the API path (/api/v1/viz/fleet/topology). Reverse-proxy deployments under a sub-path (e.g. location /yuzu/) are not currently supported — the absolute paths would 404 against the rewritten origin. Mount Yuzu at the root path of its host or fronting domain.

Controls:

Renderer behaviour (PR 6):

The page renders one translucent cube per fleet machine on a deterministic grid. Per-OS palette (on the default Slate scheme; the other schemes follow their own chart tokens): Linux #2dd4bf, macOS/Darwin #f5c542, Windows #4c9aff, default #9aa2ae. Live agents render at opacity 0.18; stale agents (no response within the 5 s echo.fleet_snapshot deadline) render at opacity 0.08 so they remain visible without competing for attention. Hostname labels appear below each cube as Sprite text and always face the camera; labels longer than 24 characters truncate with an ellipsis (the full hostname is visible in the hover tooltip). Layout is seeded by an FNV-1a 32-bit hash of agent_id so the same fleet renders identically across reloads even when the server returns rows in a different order.

Renderer behaviour (PR 7):

Each machine cube contains interior SphereGeometry dots, one per process reported in the processes array of the topology payload. Dots are coloured by process category using a six-colour palette (on the default Slate scheme; the other schemes follow their own chart tokens): system #9aa2ae, browser #4c9aff, database #f5c542, web #2dd4bf, runtime #a78bfa, other #9199a6. Category values in the JSON payload are lowercase strings matching category_to_string() in the server’s process classifier (server/core/src/process_category.hpp); the renderer normalises with String(category).trim().toLowerCase() and uses Object.prototype.hasOwnProperty.call for the palette lookup so unknown / mixed-case / whitespace-padded values fall through to other and prototype keys (constructor, __proto__) cannot poison the colour pipeline.

Dot positions are deterministic across reloads — hash(pid|ppid)-mod-bucket layout inside 78% of the cube’s interior volume, with per-process hash('j|pid') jitter to break visual stripes. Per-machine processes are attached as a named child group (yuzu-processes) of each cube so they orbit and pan with the cube under OrbitControls without synchronisation overhead. To bound the worst-case render cost on heavily-threaded hosts (e.g. JVM thread pools), the renderer soft-caps at 1000 dots per cube; the cube tooltip’s processes count still reflects the true total reported by the agent.

Hover-then-tooltip is rAF-throttled — mousemove fires up to ~120 Hz on macOS trackpads but the bidirectional raycast (process dots + cube meshes) only runs once per requestAnimationFrame tick (~60 Hz) so the dominant CPU cost stays bounded even at large fleets.

To suppress process-level visibility for specific agents (privacy-sensitive hosts, regulated workloads, etc.), set process_enabled=false on those agents via echo.configure — see docs/user-manual/agent-plugins.md §Echo for the per-source enable/disable surface. The echo.fleet_snapshot action will skip the process collector on those agents and the corresponding cubes will render with no interior dots (cube body and hover tooltip remain functional).

Browser error handling. When the JS renderer’s fetch to /api/v1/viz/fleet/topology fails, the renderer surfaces a visible overlay (#viz-error.shown) on the canvas rather than leaving the scene blank. Any previously-rendered cubes are removed before the overlay is shown so the operator does not see “live data” cubes alongside a denial message.

API responseOverlay message
401”Session expired. Please reload the page.”
403”Access denied. Your role no longer permits viewing the fleet topology.”
413”Fleet exceeds the configured machines_max cap. Ask an administrator to raise --viz-machines-max or scope the request via a management group.”
503”Fleet visualization is currently disabled by an administrator.”
Other 4xx/5xx”Failed to fetch fleet topology (HTTP ). Try reloading.”
Network failure”Cannot reach server: “
Truncated JSON / malformed body”Malformed JSON from server (truncated response or proxy issue): “
Schema mismatch”Unexpected topology schema: . Reload the page (Ctrl+Shift+R) to pick up the new dashboard.”
Empty machines field (server bug)“Server returned no machines field. This is a server-side bug; check the server log.”

These overlays are in addition to the standard browser console entry. Previous releases (PR 5 and earlier) left the scene blank on every error condition.

The 413 overlay text names a --viz-machines-max flag and a management group, but neither is a remedy: no such flag exists, and machines_max is a query parameter on the REST route (default 5000, ceiling 100000, see below) that the page does not send. The topology call is gated by a global Response:Read check and is not narrowed by management group, so scoping the request that way does not shrink the snapshot. To read a fleet above the default cap, call the REST route directly with a larger machines_max.

GET /api/v1/viz/fleet/topology

Returns the full topology as JSON.

Permission: Response:Read.

Query parameters

ParameterTypeDefaultDescription
include_vulnbool (0/1)0When 1, the per-process worst_severity and cve_count fields are populated from NVD CPE matching. (PR 2 hardening note: this overlay is wired but inert today because the agent payload doesn’t carry installed versions; PR 10 of the ladder activates it.) Selects a separate cache slot from the default.
freshbool (0/1)0When 1, the cache slot is invalidated before the get. A separate audit row (viz.fleet_topology.invalidate) is emitted. Use sparingly — concurrent ?fresh=1 storms force every dispatch to wait on the single-flight refill.
machines_maxinteger in [1, 100000]5000Soft cap on the number of fleet machines returned. If the materialised snapshot has more than this, the route returns 413 rather than truncating (truncation would mislead operators about which subset they’re seeing).

Responses

StatusWhenBody
200Successfleet_topology.v1 JSON envelope (see schema below). If the durable offline-host read failed, the live fleet is still returned and the envelope carries offline_roster_degraded: true (absent on success)
400machines_max non-numeric, out of [1, 100000], or overflows int{"error":{"code":400,"message":"..."}, "meta":{"api_version":"v1"}}
403RBAC deniedStandard auth error envelope
413Snapshot exceeds machines_max{"error":{"code":413,"message":"fleet topology exceeds machines_max..."}, "meta":{"api_version":"v1"}}
503Kill switch on (--viz-disable) or store unavailable{"error":{"code":503,"message":"..."}, "meta":{"api_version":"v1"}}

Schema (fleet_topology.v1)

{
  "schema": "fleet_topology.v1",
  "schema_minor": 4,
  "generated_at": 1715299200,
  "include_vuln": false,
  "machines": [
    {
      "agent_id": "...",
      "hostname": "host-1",
      "os": "linux",
      "local_ips": ["10.0.0.1"],
      "ts": 1715299200,
      "stale": false,
      "processes": [
        {"pid": 1234, "ppid": 1, "name": "postgres", "user": "postgres", "category": "database"},
        {"pid": 5678, "ppid": 1, "name": "psql",     "user": "alice",    "category": "database"}
      ],
      "listeners": [
        {"proto": "tcp", "port": 5432, "pid": 1234, "process_name": "postgres", "local_addr": "0.0.0.0"}
      ],
      "connections": [
        {"proto": "tcp", "src_pid": 1234, "src_addr": "10.0.0.1", "src_port": 5432,
         "dst_addr": "10.0.0.2", "dst_port": 54321, "scope": "internal_fleet",
         "dst_agent_id": "...", "state": "ESTABLISHED"},
        {"proto": "tcp", "src_pid": 1234, "src_addr": "127.0.0.1", "src_port": 5432,
         "dst_addr": "127.0.0.1", "dst_port": 53210, "scope": "local",
         "dst_pid": 5678, "state": "ESTABLISHED"}
      ]
    }
  ]
}

schema_minor is bumped (not schema) on additive evolution; renderers MUST ignore unknown keys.

Optional top-level key offline_roster_degraded: true is present only when the durable offline-host read failed; machines is then live-only. It is a request-time, envelope-level annotation like the MCP twin’s audit_persisted (set per request, outside the cached snapshot document), not a schema_minor change.

schema_minor history:

VersionChange
1Initial shape (PR 2–7)
2PR 8 — dst_pid (uint32) added to ConnectionEdge. Present only on scope: local edges with a resolved peer process on the same machine; omitted (not zero) on non-local edges. Unmatched local edges (no reciprocal half visible in the same snapshot) are dropped server-side before serialisation, so a local edge in the response always carries a non-zero dst_pid. Strict-validating consumers pinned to minor version 1 should relax their validator to minimum: 1 rather than exact-match.
3PR 9 — listeners[] array added to each MachineNode. Each entry is a ListenerSocket (proto, port, optional pid, optional process_name). LISTEN-state rows continue to appear in connections[] during the deprecation window so consumers filtering connections by state: LISTEN are not broken; a future release will remove them from connections[] with a Breaking CHANGELOG entry. Strict consumers should migrate to listeners[] now. Ingestion path also flipped: agents push echo.fleet_snapshot JSON via HeartbeatRequest.fleet_snapshot_json every 30 s (PR 10), so cache-miss latency drops from ~800 ms (full agent dispatch) to ~2 ms (in-process map walk). The dispatch path remains as a cold-start fallback.
4PR 12 — ListenerSocket grows an optional local_addr field (the kernel-reported bind address: 0.0.0.0, ::, a NIC IP, 127.0.0.1, etc., bounded server-side at 64 bytes per field). Omitted from the wire envelope when the agent did not populate it (older snapshots). The renderer reads this field and drops loopback-only listeners (127.0.0.0/8, ::1, including bracketed and v4-mapped-in-v6 forms [::ffff:127.x]) from the cube-surface socket ring — those sockets are by definition not reachable from any other instance. 0.0.0.0 and :: survive the filter; specific NIC IPs survive. Strict consumers pinned to schema_minor == 3 should relax their validator to minimum: 3.

Audit emissions

Every request produces a viz.fleet_topology row (target_type FleetTopology, target_id empty). ?fresh=1 additionally produces a viz.fleet_topology.invalidate row immediately before the get. See Audit Log for the full vocabulary.

Metrics

fleet_snapshot.v1 (agent-emitted payload). The fleet_topology.v1 document above is the server-aggregated shape; the per-agent payload the agent pushes (via HeartbeatRequest.fleet_snapshot_json) or returns from a dispatched echo.fleet_snapshot is fleet_snapshot.v1. Its schema_minor is at 2 — the 1 → 2 bump added an optional connections[].last_seen_seconds_ago field, emitted only when non-zero, alongside the operator-tunable Echo plugin config key fleet_snapshot_window_seconds (default 3600): connections seen within that window are merged into the snapshot even if not ESTABLISHED at the exact /proc sample instant. fleet_snapshot_window_seconds is an Echo plugin config key set via echo.configure, not a server CLI flag.

Example

curl -H 'Authorization: Bearer <token>' \
     'http://localhost:8080/api/v1/viz/fleet/topology?include_vuln=0&machines_max=2000'

GET /fragments/viz/fleet/topology

Identical data, wrapped in <script type="application/json" id="viz-data">...</script> for HTMX-driven swap-and-parse rendering. The < characters in JSON strings are escaped (<\/) before wrapping so an agent-controlled hostname or cmdline containing </script> cannot break out of the script element.

Permission, query params, status codes: identical to the JSON route above.

Content-Type: text/html; charset=utf-8 (the body is HTML wrapping JSON, not JSON proper).

GET /api/v1/viz/host/<agent_id>/topology

Returns a single host’s topology — one MachineNode sliced out of the current fleet snapshot — for the per-host IPC-graph drill-down page. The agent_id is taken from the path.

Permission: Response:Read.

Responses

StatusWhenBody
200Successhost_topology.v1 JSON envelope (see schema below)
404No machine with that agent_id in the current snapshot{"error":{"code":404,"message":"host not found"}, ...}
403RBAC deniedStandard auth error envelope
500Topology fetch threw or returned null{"error":{"code":500,"message":"..."}, ...}
503Kill switch on (--viz-disable) or store unavailable{"error":{"code":503,"message":"..."}, ...}

Schema (host_topology.v1)

{
  "schema": "host_topology.v1",
  "schema_minor": 1,
  "generated_at": 1715299200,
  "stale": false,
  "machine": { "agent_id": "...", "hostname": "host-1", "...": "MachineNode — same shape as a fleet_topology.v1 machines[] entry" }
}

The stale flag is promoted from machine.stale to the envelope so a renderer can decide whether to show a stale banner without reaching into machine. The embedded machine object is byte-for-byte the same MachineNode shape served by /api/v1/viz/fleet/topology.

Audit emissions: every request produces a viz.host_topology row (target_type HostTopology, target_id = agent_id), with result success / denied / failure and a detail of kill_switch, store_null, fetch_threw, snap_null, not_found, or fragment=1.

Example

curl -H 'Authorization: Bearer <token>' \
     'http://localhost:8080/api/v1/viz/host/cedar-01/topology'

GET /fragments/viz/host/<agent_id>/topology

Identical data, wrapped in <script type="application/json" id="viz-data">...</script> for HTMX rendering — same escaping and Content-Type posture as /fragments/viz/fleet/topology. Permission and status codes identical to the JSON route above.

GET /viz/host/<agent_id>

Browser-facing per-host drill-down page (the 2D Cytoscape IPC graph above the Echo process tree), opened by double-clicking a cube in /viz/fleet. Auth-gated only (require_auth); redirects to /home on no session. The agent_id path segment is allow-listed to [A-Za-z0-9._-] — any other character returns 400 — before it is templated into the page shell. Per-request RBAC is enforced when the page’s JS fetches /api/v1/viz/host/<agent_id>/topology.


Fleet Statistics

GET /api/v1/statistics

Get an aggregated view of fleet execution statistics.

Permission: Infrastructure:Read

Response:

{
  "data": {
    "executions": {
      "total": 15420,
      "today": 230,
      "success_rate": 97.3,
      "avg_duration_seconds": 4.2
    },
    "active_agents": 142
  },
  "meta": { "api_version": "v1" }
}

A failed read of the execution store answers 503 with retry_after_ms: 2000, never a zeroed summary (see Execution Statistics).


Plugin Configuration

Server-side per-plugin configuration, sealed plugin secrets, and the plugin/action kill switch (the plugin_config_store Postgres schema; decision record ADR-3005). Every response uses the {data, meta} envelope and every error the A4 envelope. Plugin and action names must start with a lowercase ASCII letter, followed by lowercase letters, digits or _ (1–64 bytes). A key follows the same rule but may also contain internal dots for a nested path such as smtp.host, up to 128 bytes. A store that is unavailable answers 503 with retry_after_ms: 2000.

Audit. Each mutation writes an attempted audit row before it touches the store, then a success or failure row once the store answers. If the attempted row cannot be saved, the route answers 503 and makes no change. Audit actions: plugin_config.set, plugin_config.delete, plugin_secret.set, plugin_secret.delete, plugin_config.kill_switch.set (see Audit Log). Request bodies over 128 KiB are refused with 413.

Secrets are write-only. You can set or delete a secret, but no REST route or MCP tool ever returns its value. Secret responses carry metadata only.

GET /api/v1/plugin-config

Lists plugin config rows (secrets are not included), optionally filtered with ?plugin=<name> (exact match). The list is capped at 5,000 rows. meta.truncated is true when more rows exist.

Permission: PluginConfig:Read, checked through the ADR-0017 list gate (require_list_read), which is the route’s only authorization check. A global grant, an active just-in-time elevation (full administrator for its window, as for every other permission check), or RBAC loaded and disabled is admitted. A grant confined to a management group is refused with 403 rather than narrowed: config rows are not per-agent data, so there is nothing to filter them by, and the refusal is recorded as an auth.permission_required denial row. A service-scoped token is refused. The MCP list_plugin_config tool and the System > Plugins and updates panel apply the same list decision; the panel additionally requires the administrator role its page gate asks for, so a caller who is not an administrator can read this route but not the panel.

{
  "data": [
    { "plugin": "email", "key": "smtp.host", "value": "mail.example.com",
      "updated_at_ms": 1760000000000, "updated_by": "admin" }
  ],
  "meta": { "api_version": "v1", "truncated": false }
}

Errors: 401 (not signed in), 403 (no global PluginConfig:Read, a confined grant, a service-scoped token, or an authorization store that is closed or degraded, which fails closed), 503 (the plugin config store is unavailable, or the authorization store is unavailable to an engine principal).

GET /api/v1/plugin-config/{plugin}/{key}

Reads one config row. Permission: PluginConfig:Read. Response: data is one row with the same shape as a list entry (plugin, key, value, updated_at_ms, updated_by).

Errors: 400 (invalid plugin/key grammar), 404 (no such row), 503 (store unavailable).

PUT /api/v1/plugin-config/{plugin}/{key}

Creates or overwrites one config value. Permission: PluginConfig:Write. Audit: plugin_config.set. The audit detail records the value’s length (len=<n>) but not the value itself. updated_by is the caller’s username.

{ "value": "mail.example.com" }

value is a required string of at most 8,192 bytes and must contain no NUL bytes. Response: data is the stored row.

Errors: 400 (invalid JSON, missing or non-string value, invalid plugin/key/value), 413 (body too large), 500 (write failed), 503 (store unavailable, or the audit row could not be saved).

DELETE /api/v1/plugin-config/{plugin}/{key}

Deletes one config value. Permission: PluginConfig:Delete. Audit: plugin_config.delete. The route checks first that the row exists, so deleting a key that is already gone answers 404 and writes no audit row.

{ "deleted": true, "meta": { "api_version": "v1" } }

Errors: 400 (invalid plugin/key grammar), 404 (no such row), 500 (write failed), 503 (store unavailable, or the audit row could not be saved).

PUT /api/v1/plugin-config/{plugin}/{key}/secret

Seals a plugin secret (an API key or webhook token, for example) under this install’s KEK. Each write mints a fresh DEK. Permission: PluginSecret:Write. Audit: plugin_secret.set. The audit detail is redacted by construction and never contains the value.

{ "value": "s3cr3t-token" }

value is a required string of 1 to 65,536 bytes. The response is metadata only and never contains a value field:

{
  "data": { "plugin": "webhook", "key": "token", "updated_at_ms": 1760000000000, "updated_by": "admin" },
  "meta": { "api_version": "v1" }
}

There is deliberately no GET route for secrets.

Errors: 400 (invalid JSON, missing or non-string value, invalid plugin/key/value), 413, 500 (write failed), 503 (store unavailable, secret encryption unavailable, or the audit row could not be saved).

DELETE /api/v1/plugin-config/{plugin}/{key}/secret

Deletes a sealed secret. Permission: PluginSecret:Delete. Audit: plugin_secret.delete. The secret plane has no way to check that a secret exists, so the attempt is audited before the store reports whether the key existed. Response: {"deleted": true, "meta": {"api_version": "v1"}}.

Errors: 400 (invalid plugin/key grammar), 404 (no such secret), 500, 503 (store unavailable, or the audit row could not be saved).

GET /api/v1/plugin-config/{plugin}/kill-switch

Shows the state of a plugin’s kill switch, or of one action’s switch with ?action=<action>. ?os=windows|linux|darwin selects a per-OS view. Permission: PluginConfig:Read.

This is an inspection view, not the decision dispatch uses. Dispatch fails closed: any store error counts as disabled. This view instead reports the store error. If no row has ever been set, the switch reads enabled: true. Without os the route reports only the all-OS layer and never shows a per-OS OFF row. With os, enabled is the effective state of the two layers combined: the OS layer can narrow the all-OS layer but never widen it, and within the OS layer an action@os ON row overrides a plugin@os OFF row. source names the row that produced the answer ("" when there is no row).

{
  "data": { "plugin": "execution_artifacts", "action": "", "os": "", "enabled": false,
            "reason": "default-off: forensics class (Wave 7); enable per PUT /api/v1/plugin-config/execution_artifacts/kill-switch", "set_by": "system", "updated_at_ms": 1760000000000,
            "source": "execution_artifacts" },
  "meta": { "api_version": "v1" }
}

For a protective action (the quarantine plugin’s quarantine/unquarantine/status/whitelist and __guard__.push_rules) the state shown is for information only: enabled: false can be displayed while dispatch still goes ahead (see Security Hardening and Upgrading).

Errors: 400 (invalid plugin/action/os, or ?os= present but empty), 503 (store unavailable).

PUT /api/v1/plugin-config/{plugin}/kill-switch

Turns a plugin’s switch on or off, or one action’s switch with ?action=<action>. Add ?os= to change the switch for one OS only. Permission: PluginConfig:Write. Audit: plugin_config.kill_switch.set, with target_id <plugin>[.<action>][@<os>].

{ "enabled": false, "reason": "investigating crash reports" }

enabled (boolean) is required. true allows the plugin or action, which is also the state when no row exists. false kills it. reason is optional, at most 512 bytes; it is stored unredacted in the audit log, which every AuditLog:Read holder can read for the retention period, so do not put secrets or personal data in it. Every dispatch gate that checks this switch fails closed on a store error, so switching it off is a reliable emergency stop. Exception: a row for a protective action is stored and returned but has no effect on dispatch. Its audit detail is marked protective_exempt=true and the server logs a warning. To stop Guardian rule delivery, disable or remove the rules instead. The response returns the row that was written, not the effective state, in the same shape as the GET above.

Errors: 400 (invalid JSON, missing or non-boolean enabled, non-string reason, invalid plugin/action/os/reason, or ?os= present but empty), 413, 500, 503 (store unavailable, or the audit row could not be saved).


File Retrieval

POST /api/v1/file-retrieval has been REMOVED (PR1.5c/1.6c). The legacy handler trusted a body-supplied agent_id as an unauthenticated metadata-only “upload” that stored nothing and had no relationship to actual bytes sent. It is replaced by the authenticated one-time upload-grant + chunked-receive protocol below (docs/adr/3004-artifact-blob-storage.md): the operator mint/list/revoke routes at /api/v1/upload-grants*, and the agent-facing session/chunk/status/commit/cancel routes at /api/v1/uploads*.

POST /api/v1/upload-grants

Mint a one-time upload grant for an agent. Operator-facing.

Permission: UploadGrant:Write

Request body:

FieldTypeRequiredDescription
agent_idstringYesThe agent authorised to redeem this grant
declared_max_sizeintegerYesUpper bound on the upload size in bytes — a CAP, not an equality requirement; the actual upload may be smaller
source_pathstringNoInformational only — never used to derive the destination key
expected_sha256stringNoOptional expected content hash, 64 lowercase hex characters
retention_classstringNostandard (default), extended, or transient
ttl_secsintegerNoGrant expiry override; server default applies when omitted (<= 15 min). When present it must be a JSON integer of at least 1 (a string, float, boolean, null, 0 or a negative value is 400 and mints nothing); a larger value is clamped to the 15-minute maximum.

Response (201):

{
  "grant_id": "a1b2c3...",
  "grant_secret": "d4e5f6...",
  "expires_at": 1735689600,
  "destination_key": "standard/a1b2c3..."
}

grant_secret is returned exactly once — the store persists only its SHA-256 digest. The response is sent Cache-Control: no-store, no-cache, must-revalidate and Pragma: no-cache.

GET /api/v1/upload-grants

List upload grants. Permission: UploadGrant:Read, routed through RbacStore::authorize_list_read (admit-then-filter — never a bare global permission check for a human caller). A service-scoped API token is refused 403 before any grant is read (service-scoped tokens may not list upload grants, no permission field, audit upload_grant.list.access_denied), whoever minted it; list with a non-service token. An engine principal is admitted only when RBAC is enabled and it holds an explicit UploadGrant:Read grant: with RBAC off, or with RBAC on and no grant, it gets 403 (503 when the RBAC store is unavailable), the same answer as the MCP list_upload_grants tool. The engine denial is audited as auth.permission_required.

DELETE /api/v1/upload-grants/{grant_id}

Revoke a grant before it is redeemed. Permission: UploadGrant:Delete. Returns 204 on success, 404 if the grant does not exist or is no longer revocable (already redeemed or already revoked). An expired grant that was never redeemed is still revocable, so it can be revoked to clear it.

The agent then redeems the grant and streams the file over the agent-facing upload session routes (/api/v1/uploads*), documented under Uploads. The content_dist agent plugin’s upload_file action is the reference client — see its InstructionDefinition for the operator-facing parameters (path, grant_id, grant_secret).


Uploads

The agent-facing half of the upload-grant protocol (docs/adr/3004-artifact-blob-storage.md). An operator mints a one-time grant with POST /api/v1/upload-grants. The agent then redeems it here and streams the file in chunks. These routes do not use an operator session, cookie or API token. They authenticate with the grant credential (X-Yuzu-Upload-Grant) and then the session credential (X-Yuzu-Upload-Session). There is no Permission: line for them, and there is no MCP twin: MCP authenticates as an operator, and this credential belongs to the agent (a permanent ADR-1005 exception). The content_dist plugin’s upload_file action is the reference client.

Common to all five routes:

POST /api/v1/uploads

Redeems a grant and opens an upload session. Send header X-Yuzu-Upload-Grant: <grant_id>.<grant_secret> and no body. Redemption is single-use: once a grant has been redeemed, it cannot be redeemed again.

Response (201):

{"upload_id":"9f2c...","session_secret":"5be1...","chunk_max_bytes":8388608,"offset":0,"expires_at":1735689600}

The session_secret is returned only in this response.

Errors: 401 grant_unknown (missing, malformed or unknown credential); 409 grant_already_redeemed; 410 expired.

Audit: upload_grant.session_open (success / failure).

GET /api/v1/uploads/{upload_id}

Returns the session’s status, for example so an agent can resume. Send X-Yuzu-Upload-Session: <upload_id>.<session_secret>.

Response (200): {"state":"open","offset":4194304,"expires_at":1735689600}. state is one of open, committed or cancelled; a committed or cancelled session still answers 200 with its final state. An expired session never answers 200: it gets 410 expired. Neither upload_id nor the declared size is returned.

Errors: 401 session_unknown; 410 expired. When this request is the first one after expiry, it also discards the partial file.

PUT /api/v1/uploads/{upload_id}/chunk

Writes one chunk. Headers: X-Yuzu-Upload-Session, plus Content-Range: bytes <start>-<end>/<total> (end is inclusive). The body is the raw bytes, and its length must be exactly end - start + 1. start must equal the session’s recorded offset. A chunk may be at most 8 MiB (chunk_max_bytes).

Response (200): {"offset": <new recorded offset>}.

Errors: 400 (missing or malformed Content-Range, or a body length that differs from it); 401 session_unknown; 409 offset_mismatch (the error object carries the server’s offset; resume from there); 409 session_terminal; 410 expired; 413 chunk_too_large; 413 size_exceeded (the asserted total or the running total exceeds the grant’s declared_max_size); 500 (the file could not be opened or written).

size_exceeded ends the session: the server cancels it and discards the partial file.

Audit: chunks are not audited one by one. Only upload_grant.chunk failure rows for expired and size_exceeded are written.

POST /api/v1/uploads/{upload_id}/commit

Verifies the received file and finishes the upload. Header X-Yuzu-Upload-Session; body:

{"sha256": "<hex SHA-256 of the whole file>"}

For the commit to succeed, the server’s own SHA-256 of the stored file must match the supplied sha256, and it must match the grant’s expected_sha256 if one was set. The file must also be no larger than declared_max_size. Hex comparison is case-insensitive.

Response (200): {"state":"committed","actual_size":10485760,"sha256":"<server-computed hex>"}.

Errors: 400 (sha256 missing); 401 session_unknown; 409 session_terminal (including when a concurrent commit or cancel got there first); 410 expired; 422 hash_mismatch (the session is cancelled and the partial file discarded); 500 (the stored file could not be read back for verification).

Audit: upload_grant.commit (success with size=<n>, or failure).

DELETE /api/v1/uploads/{upload_id}

Cancels an open session and discards the partial file. Header X-Yuzu-Upload-Session; no body.

Response: 204.

Errors: 401 session_unknown; 409 session_terminal (already committed or cancelled); 410 expired (an expired session gets 410 on every route, never 409).

Audit: upload_grant.cancel.


Guaranteed State

Operator-facing surface for the Guardian (Guaranteed State) policy engine. See Guaranteed State for the feature guide and YAML rule schema. The Windows registry guard is live end-to-end (detect → enforce write-back → event ingest); per-guard fleet compliance aggregation on the /status endpoints lands in Guardian PR 4.

RBAC matrix:

RoleReadWriteExecuteDeleteApprovePush
Administrator✓✓✓✓✓✓
ITServiceOwner✓✓✓✓✓✓
PlatformEngineer✓✓✓✓
Operator✓✓
Viewer✓

Administrator and ITServiceOwner inherit Execute and Approve on GuaranteedState from the cross-type CRUD seed loop — those operations have no active Guardian handler today but the grants exist in the DB. PlatformEngineer’s grants are explicit (Read/Write/Delete/Push only). Push is a Guardian-specific operation (distribute an existing rule set to scoped agents) that separates deploy authority from authoring authority.

GET /api/v1/guaranteed-state/rules

List all Guaranteed State rules.

POST /api/v1/guaranteed-state/rules

Create a rule.

A rule may be authored structured (the agent-enforceable form) or legacy (yaml_source only, stored but not agent-enforced). Supply either a spark+assertion pair or a yaml_source.

FieldTypeRequiredDescription
rule_idstringYesStable operator-chosen id. Must match [A-Za-z0-9._-]+, at most 256 bytes. Enforced at creation (#4665) — a violation returns 400, not just documented convention.
namestringYesHuman-readable name (unique per server).
sparkobjectStructured{type, params} trigger block, e.g. {"type":"registry-change"} or {"type":"file-change"}.
assertionobjectStructured{type, params} desired-state block, e.g. registry-value-equals, file-exists, file-hash-equals.
remediationobjectNo{type, params} — alert-only (default) or enforce. params carries the resilience policy (mode, max_attempts, backoff_*, …) and event_debounce_ms.
yaml_sourcestringLegacyFull rule YAML; required only when no structured spark+assertion is given. The server generates yaml_source from the structured form otherwise.
versionintegerNoStarting version (default 1). Must be a JSON integer in 1..2147483647 (400 otherwise, including a string, float, boolean or null): an update adds one to the stored version and the agent push casts it to an unsigned 64-bit value, so a value near the 64-bit maximum would overflow.
enabledbooleanNoDefault true.
enforcement_modestringNoenforce (default) or audit. Set once at creation — immutable on update (see PUT below).
severitystringNolow / medium (default) / high / critical.
os_targetstringNoEmpty (any) or windows / linux / macos.
scope_exprstringNoScope DSL expression selecting target agents.

The catalog of valid spark / assertion / remediation types and their params (including the resilience-policy bounds) is discoverable at GET /api/v1/guaranteed-state/schemas.

GET /api/v1/guaranteed-state/rules/{rule_id}

Fetch a single rule.

PUT /api/v1/guaranteed-state/rules/{rule_id}

Update a rule. Version is incremented on every successful update regardless of whether any field changed.

DELETE /api/v1/guaranteed-state/rules/{rule_id}

Delete a rule.

GET /api/v1/guaranteed-state/rules/{rule_id}/status

Per-guard fleet-wide agent-status drilldown (#4037, api-parity #2146 Batch A) — agent_id/state/updated_at for every agent that has reported this ONE rule’s state. Machine-readable twin of the dashboard’s per-guard fleet compliance census (/guardian/guard/{id}).

GET /api/v1/guaranteed-state/agents/{agent_id}/rules

Per-device all-guards view (#4037, api-parity #2146 Batch A) — every guard’s state for ONE device, unscoped to any Baseline. Machine-readable twin of the dashboard’s per-device Guardian lens (/fragments/device/guardian). Genuinely distinct from GET .../device-compliance below, which answers “is this device compliant with this ONE named Baseline” (requires both agent_id and baseline, and returns a baseline-shaped response) — this route has no baseline parameter and returns every guard the device has EVER reported, regardless of Baseline membership.

POST /api/v1/guaranteed-state/push

Queue a push of the active rule set to scoped agents. Returns 202 Accepted — agent delivery is asynchronous. The fan-out is live: the server resolves scope to the in-scope agents and delivers each a per-agent filtered rule set (only rules whose os_target and scope_expr match that agent).

FieldTypeRequiredDescription
scopestringNoScope DSL selector. Empty = all agents.
full_syncbooleanNoIf true, agents replace their rule set; otherwise (the default, so a request that omits it) they merge. A merge push does not retry a rule it omits that is still unresolved on the agent (the agent holds its policy generation instead); only a full push, or a push that names the rule, does. See Guaranteed State.

GET /api/v1/guaranteed-state/policy-generation

Read the Guardian policy generation the server holds. It is the value POST /api/v1/guaranteed-state/policy-generation/raise compares its target against, so read it before choosing a raise target (see “Restoring the policy generation” in Guaranteed State). The generation is one counter shared by the whole fleet.

{
  "data": {
    "generation": 4820,
    "ceiling": 9007199254740991
  },
  "meta": { "api_version": "v1" }
}

generation is the stored value; 0 is a real value (a store that has never changed its rule set). A stored value below zero, which only an out-of-band edit can produce, is not reported as 0: the read answers 503 (below), and the raise treats it as 0 for its previous_generation. ceiling is the largest target_generation the raise accepts.

POST /api/v1/guaranteed-state/policy-generation/raise

Raise the server’s Guardian policy generation to a chosen value. This is the supported recovery for the state in which agents hold a generation above the server’s and refuse every full push (see “Restoring the policy generation” in Guaranteed State). The generation is one counter shared by the whole fleet; the call sets it to the target when the target is higher than the stored value and changes nothing otherwise. It never pushes to an agent: the heartbeat reconcile converges every agent that is behind the new value, to the rule set the server holds at that moment, which replaces the rules the agent holds. That is why the request must acknowledge it.

FieldTypeRequiredDescription
target_generationintegerYesThe generation to raise to, from 1 to 9007199254740991 (2^53 - 1, the largest integer a JSON number carries exactly). A float, string, boolean, null, 0, a negative number or a larger number is 400.
acknowledge_rule_set_replacedbooleanYesMust be the JSON boolean true. It acknowledges that the raise converges every agent behind it to the rule set the server holds now. Missing, false or any other type is 400.
acknowledge_empty_rule_setbooleanOnly when the server holds no rules or no deployed BaselineMust be the JSON boolean true when the rule count or the deployed-Baseline count is 0, because the raise then converges every agent behind the new value to zero guards; otherwise the call is 400 and nothing changes. Optional otherwise; when sent it must be a boolean.
{
  "data": {
    "previous_generation": 4820,
    "generation": 4821,
    "raised": true,
    "rules": 12,
    "deployed_baselines": 2,
    "ceiling": 9007199254740991
  },
  "meta": { "api_version": "v1" }
}

previous_generation and generation are the stored value immediately before and after the call. rules is the number of rules in the rule store, read just before the raise; it is not the number of guards agents will receive, because agents converge only to enabled rules that are members of deployed Baselines. deployed_baselines is the number of deployed Baselines, read at the same point (a deployed_baselines of 0 means a full_sync converges agents to zero guards, which is why the call needs acknowledge_empty_rule_set in that case). ceiling is the largest accepted target. The call is atomic and idempotent: a target at or below the current value returns raised: false and changes nothing, so a retry is safe.

GET /api/v1/guaranteed-state/events

Query Guaranteed State events (rule violations, remediations, agent sync events).

GET /api/v1/guaranteed-state/status

Fleet-wide status rollup. errored_rules is real (#2298 item 6d): the count of distinct rule_ids with at least one agent currently reporting state errored in the guardian_agent_rule_status census, intersected against the live rule catalogue (a census row for a since-deleted rule is excluded) — the source of truth also behind the dashboard’s Unhealthy Guards card, not the (reaped, 30-day) event log. compliant_rules and drifted_rules stay 0 deliberately — full status ingest (action=="status") lands in a later rung. This route’s SOLE authorization gate is AuthRoutes::require_list_read (ADR-0017 admit-then-filter; never the flat require_permission, which does not consult management groups and cannot be stacked with a separate confinement check — the two do not compose). A service-scoped API token is denied (403) outright: this route aggregates across every agent’s census, and a token scoped to one service must not read the fleet-wide count. For every other caller, the route is management-group-confined: a global GuaranteedState:Read grant sees the fleet-wide count, a management-group-confined grant sees errored_rules scoped to that operator’s visible agents only (applied in SQL before the aggregate, ADR-0017 INV-3), and a caller with no grant anywhere is refused with 403. total_rules is never confined — it is the size of the global rule catalogue, which has no agent or management-group dimension. A real grant that resolves to ZERO visible agents (an empty or agent-less management group) is still 200 with errored_rules: 0 (ADR-0017 INV-2 — a narrow-but-real grant is not a denial); do not read a 0 as “fleet healthy” without also confirming the caller’s management-group scope actually covers the agents it should.

GET /api/v1/guaranteed-state/status/{agent_id}

Per-agent status. errored_rules and total_rules are BOTH real and BOTH intersected against the live rule catalogue (a census row for a since-deleted rule counts toward neither) — total_rules is the count of rules with any census entry for this agent still present in that catalogue, not the fleet catalogue size. compliant_rules/drifted_rules stay 0 for the same reason as the fleet route. Per-device behavioral read: scoped the same way as GET /guaranteed-state/device-compliance (a global grant passes fleet-wide, otherwise the caller must hold Read via a management group the device is in), and audited as guardian.device.view — fail-closed: 503 + Sec-Audit-Failed: true if the audit row cannot persist.

GET /api/v1/guaranteed-state/device-compliance?baseline={name}&agent_id={id}

Name-anchored, device-applicable Guardian compliance — the machine-readable sibling of the dashboard Baseline page, built for embedding a device’s compliance against a curated Baseline into an external CMDB / ITSM record. Looks the Baseline up by name and returns the Guards actually applicable to the device, each with that device’s last reported verdict.

Semantics — device-applicable subset. One Baseline can carry a superset of Guards, each with its own scope_expr; the push fan-out arms a different subset on each machine. This endpoint returns only the Guards actually applicable to the device — the Baseline’s deployed_snapshot intersected with the Guards the device has reported. A Guard that is out of scope for this device (never armed → never reported) is absent (not pending), so total_guards and guards[] reflect this machine’s applicable set: different machines, querying the same Baseline name, legitimately see different Guards. (pending therefore means a Guard the device reported with an unrecognized verdict — normally 0; an elevated pending after a partial fleet upgrade can indicate agent/server version skew — a newer agent emitting a verdict token this server build does not yet recognize, which upgrading the server resolves. The Guard still carries its real updated_at in that case.) Verdicts are the device’s last reported, Observe-mode state, with no device-liveness fold.

Report-driven caveats. Applicability is inferred from what the device has reported, with two consequences: (1) an in-scope Guard on an offline device is absent (not pending) until it reports — track device online/offline yourself and use last_updated for freshness; (2) a Guard that was re-scoped out and re-deployed keeps its last reported row, so it lingers on the CI until that row is overwritten or cleared (the forward direction — tag a device so a Guard starts applying — appears promptly; the reverse lags). The honest fix for both — evaluating each Guard’s scope_expr against the device so in-scope-unreported shows pending and out-of-scope never shows — is a tracked computed-denominator upgrade.

⚠️ Compliance percentage — do not gate on it (yet). Because the denominator is reported applicable Guards, an in-scope Guard the agent has not yet reported drops out of total_guards rather than counting against it. So compliant / total_guards over-estimates compliance — a device reporting 3 of its 10 in-scope Guards, all compliant, reads as 100% — and it over-estimates worst exactly when Guards are broken or the device is flaky. Do not gate automated remediation or a CMDB compliance flag on this percentage until the per-device scope_expr computed denominator ships (tracked follow-up). Treat the numbers as advisory: branch on assessable, watch the snapshot_total − total_guards gap as a partial-coverage cue, and cross-reference last_updated and your own device-liveness signal. (The assessable:false / 0/0 case below is the zero-coverage corner of this same caveat.)

⚠️ Do not rename a referenced Baseline. This route resolves the Baseline by name; renaming a Baseline that an integration pins (e.g. ServiceNow Compliance) makes every poll 404 until that integration is reconfigured to the new name — silently, until CIs stop updating. Treat the Baseline name as a stable external identifier; see the rename note in Guaranteed State.

Setup. The Baseline must be deployed (a draft returns deployed: false), and member edits appear here only after a re-deploy rewrites the deployed_snapshot. Branch on the deployed flag (not total_guards) to decide “No Baseline Deployed”: a deployed Baseline with zero applicable Guards legitimately returns deployed: true, total_guards: 0. Treat deployed: true with total_guards: 0 and last_updated: null as not assessable, not compliant — it means no applicable Guard has reported yet (the device is offline / newly-enrolled, or every Guard is out of its scope). Do not compute a compliance percentage from a zero denominator, and do not render 0/0 as a pass — an unreachable or dead device would otherwise read as green. Per-machine variation is driven by each Guard’s scope_expr (tag / OS / group) — see Guaranteed State.

Bulk CMDB sync. This is a point-in-time, single-device read (not a push/subscription). To populate many CI records, poll per-device on a cadence (15–60 min is typical for a CMDB) rather than bursting every CI at once — concurrent fleet-wide polls contend on the same store locks the agent-heartbeat ingest path uses, so a synchronized stampede can delay heartbeat processing.

Example:

curl -s -H "Authorization: Bearer $TOKEN" --get \
  "$YUZU_URL/api/v1/guaranteed-state/device-compliance" \
  --data-urlencode "baseline=ServiceNow Compliance" \
  --data-urlencode "agent_id=$AGENT_ID"
{
  "data": {
    "baseline": {"baseline_id": "ab12cd34ef56", "name": "ServiceNow Compliance", "lifecycle": "deployed"},
    "deployed": true,
    "assessable": true,
    "agent_id": "031e63a1-3139-48e6-beb6-50e3db062d0f",
    "total_guards": 3,
    "snapshot_total": 3,
    "compliant": 2, "drifted": 1, "errored": 0, "pending": 0,
    "last_updated": "2026-06-20T13:45:00Z",
    "guards": [
      {"rule_id": "g-fw",  "name": "Windows Firewall on", "status": "compliant", "updated_at": "2026-06-20T13:45:00Z"},
      {"rule_id": "g-rdp", "name": "RDP NLA required",    "status": "drifted",   "updated_at": "2026-06-20T12:30:00Z"},
      {"rule_id": "g-blk", "name": "BitLocker enabled",   "status": "compliant", "updated_at": "2026-06-20T13:44:00Z"}
    ]
  },
  "meta": {"api_version": "v1"}
}

GET /api/v1/guaranteed-state/alerts

Guaranteed State alerts (placeholder; alert aggregation lands in Guardian PR 11).

GET /api/v1/guaranteed-state/schemas

Guard authoring schema catalog — the static registry of spark / assertion / remediation types with per-type JSON Schemas. Driven by the same param-spec table the server-side validator uses, so the discovery surface and the validator cannot diverge. Drives dynamic authoring forms and agentic clients (the dashboard is one consumer).


DEX (Digital Employee Experience)

The DEX aggregation endpoints are the machine-readable equivalent of the DEX dashboard: the same store rollups the HTMX fragments render, JSON-shaped for agentic workers. The signal endpoints read the ruleless __observation__ projection (the same data the events endpoint exposes per-row); the perf endpoints aggregate registry heartbeat state at request time. All are gated on the same GuaranteedState:Read securable. The signal endpoints accept a window query parameter — one of 24h, 7d, 30d, all (omitted = 7d; any other value, including an empty one, is 400) — and an optional os filter (windows/linux/macos; omitted or any other value = every OS), which scopes them to one OS’s own signals exactly as the dashboard catalogue OS filter does; the perf endpoints are now-views with no window.

Audit boundary. The catalogue rollup, per-OS scope and the perf endpoints are aggregates / machine-health telemetry and are not audited. The per-signal drill-down returns a most-affected devices list (agent_ids — behavioral, individual-identifying) and emits a dex.signal.view audit row (target_type=ObsType, target_id=<obs_type>) on every call — the same verb as the dashboard per-signal view and consistent with the agent_id-filtered events query.

GET /api/v1/dex/signals

Whole-catalogue rollup — every observation type present in the window.

GET /api/v1/dex/scope

Per-OS signal coverage — how many distinct observation types each platform reports, with total event count (the live cross-OS coverage the dashboard derives).

GET /api/v1/dex/signals/{obs_type}

One signal type’s drill-down.

GET /api/v1/dex/perf/fleet

Fleet device-performance now-stats — the same numbers as the yuzu_fleet_perf_* Prometheus gauges and the /dex Performance tab, computed at request time.

GET /api/v1/dex/perf/cohorts

Fleet-relative performance percentiles per cohort — the distinct values of an operator-chosen tag key (see Cohort benchmarking).

GET /api/v1/dex/perf/cohort-diff

The direct A-vs-B cohort comparison (e.g. image_type vanilla vs layered, or model X vs Y) — where /perf/cohorts benchmarks each cohort against the fleet, this diffs two cohorts head-to-head. Closes the cohort-vs-cohort half of the benchmarking residual. (The fleet-per-app benchmark — per-app perf over time across the fleet — is the separate app-performance-over-time surface below: GET /api/v1/dex/perf/apps / app / group, reading the retained Postgres aggregate.)

GET /api/v1/dex/perf/devices

The one device list behind every Performance drill: worst devices by a metric (default), the not-reporting complement, or one cohort’s members.

Application performance over time

Per-application CPU / working-set by version, over the retained window, across the fleet or one management group — reading the retained Postgres aggregate (B1 per-device daily / B2 fleet rollup), not the live federated device drill. Per-app sampling (procperf_enabled) is on by default; a device contributes only after its first completed UTC midnight. The aggregate surfaces gate GuaranteedState:Read and are not audited (cohort posture); the per-device drill is behavioural PII and is audited fail-closed (see below). The same shared transform backs these endpoints, the get_dex_app_perf / get_dex_group_app_perf MCP tools, and the dashboard so the numbers cannot disagree.

Percentile + suppression semantics (all three trend endpoints). Each points[] entry’s cpu_p50/cpu_p95/ws_p50/ws_p95 is either null (absent — empty population, not zero) or {value, lower_bound}. lower_bound: true means the percentile fell in the open top histogram bucket, so value is a floor — render it as ”≥ value”, never an exact quantile. hist_stale: true means the row was stamped under a superseded histogram scheme, so percentiles are withheld (the exact mean/max still stand). Cohort floor: kDexCohortFloor is one device (maintainer ruling, 2026-10-14; previously 10): a (version, day) point is published with its stats whenever at least one device contributed, so only a point no device contributed to is suppressed (stats withheld, device_count only). A single-device point is therefore published — as a fleet-wide aggregate by application and version with no agent_id, behind GuaranteedState:Read; the per-device drill remains the audited path. This applies to both the fleet and the group endpoints.

GET /api/v1/dex/perf/apps

The picker: applications that currently have retained fleet data, so a worker discovers which app= values are answerable (A2 discoverability).

GET /api/v1/dex/perf/app

The fleet trend for one application — one point per (version, UTC day) over the B2 window (up to 180 days).

GET /api/v1/dex/perf/app/devices

The version-row “which devices” drill: unlike /perf/app above, each row here names an agent_id — a fleet-wide fan-out of identified per-device data (B1, up to 31 days) — so this route’s authorization posture is entirely different from its aggregate sibling. Devices reporting one EXACT (app, version) among their retained top-N daily summaries, one row per device at its most recent reporting day for that version. Not a census: a device absent here may still run this app-version, just not among its top-N resource consumers that day (use the software inventory endpoints for a full install census).

GET /api/v1/dex/perf/group

The same trend shape, aggregated on-the-fly over one management group’s member devices (B1, up to 31 days — shorter than the fleet’s 180-day B2 window, so a group series is shorter for the same app).

GET /api/v1/dex/perf/tag

The same trend shape as /dex/perf/group above, aggregated on-the-fly over the devices carrying one tag VALUE instead of a management-group id (B1, up to 31 days) — the REST/MCP twin of the dashboard’s device-model cohort filter, mutually exclusive with the group scope. A tag-value cohort (e.g. one hardware model) is equally a “named set of specific devices”, so it shares /dex/perf/group’s exact confinement, floor, and service-scoped-denial posture.

GET /api/v1/dex/perf/compare

The /auto VERIFY before/after comparison: did upgrading app from baseline to candidate change how the same machines in group perform? For each machine that ran both versions in the window, a per-machine CPU and working-set delta is computed (each device’s own baseline-version window vs its own candidate-version window, the window anchored to that machine’s transition, not to “today”), then the per-machine deltas are aggregated. A machine that ran only one version in-window is excluded and counted. Evidential only — no verdict, no threshold, no pass/fail. The same pure engine backs this endpoint, the compare_app_perf_versions MCP tool, and the dashboard VERIFY stage, so the numbers cannot disagree.

GET /api/v1/dex/devices/{id}

Per-device DEX read model — the machine-readable equivalent of the DEX lens on the /device?id= dashboard page.

GET /api/v1/dex/devices/{id}/app-perf

One device’s retained per-(app, version, day) performance history (B1) — the per-device companion to the fleet/group trend. This is behavioural PII (which applications a person runs, over time), so unlike the aggregate endpoints it is scoped + audited fail-closed. This endpoint also backs the /device dashboard DEX drill’s Application performance over time panel (same dex.device.app_perf.view verb, dashboard set-and-proceed posture). MCP twin: get_dex_device_app_perf (#4035) closed the earlier MCP-only gap — see the MCP tool table below; the MCP surface follows its own set-and-proceed / audit_persisted:false posture rather than this route’s fail-closed 503 (different surface, different posture — docs/api-twin-recipe.md §4).

GET /api/v1/dex/devices/{id}/history

Per-device raw signal history — the distinct signal-history capability from GET /api/v1/dex/devices/{id} above (same device, different data: every observation row, not just the rollup score). The machine-readable equivalent of the /device dashboard DEX lens’s Signal history table.

GET /api/v1/dex/devices/{id}/observations/{event_id}

Single-observation detail — every captured projection field for one event (the device-history row’s click target).

GET /api/v1/dex/app

App blast-radius drill — crash/hang summary, faulting modules, exception codes, and the affected-device list for one application. The machine-readable equivalent of the /fragments/dex/app dashboard drill.

GET /api/v1/dex/apps

App-centric stability list — every application with a Windows crash/hang signal in the window (the 100 affecting the most devices; see apps_total and apps_truncated), per application and per version. The Apps tab, the Overview top apps and GET /api/v1/dex/app stay all-platform until the tab is rewired, so the same application can show different counts on the two surfaces.

GET / PUT / DELETE /api/v1/dex/score-policy

The customer DEX score policy: tier edges and names, deduction weights, scoring model parameters and per-application overrides, stored as one JSON document that every server instance reads. GET returns the policy in force, PUT replaces it and DELETE removes it (the shipped defaults apply again). This is the only route that writes the document: PUT /api/config/dex_score_policy is refused. There is no Settings page for the policy yet: use this API. The MCP twins are tracked in #6087; there is no MCP tool for this route yet.

SELECT waiting.pid AS waiting_pid, blocker.pid AS blocking_pid, blocker.state,
       blocker.xact_start, left(blocker.query, 80) AS blocking_query
FROM pg_stat_activity AS waiting
CROSS JOIN LATERAL unnest(pg_blocking_pids(waiting.pid)) AS b(pid)
JOIN pg_stat_activity AS blocker ON blocker.pid = b.pid
WHERE waiting.query ILIKE '%runtime_config%';

Example: raise the excellent edge to 95. A fresh server has no stored policy, so GET answers the shipped defaults with revision default:

curl -si -H "Authorization: Bearer $TOKEN" \
  "https://yuzu.example.com/api/v1/dex/score-policy"
{
  "data": {
    "document": {
      "version": 1,
      "tiers": {
        "edges": { "excellent": 90.0, "good": 75.0, "fair": 60.0 },
        "names": { "excellent": "excellent", "good": "good", "fair": "fair", "poor": "poor" }
      },
      "weights": { "breadth": 60.0, "crashes": 20.0, "hangs": 8.0, "regression": 12.0 },
      "overrides": []
    },
    "revision": "default",
    "source": "default"
  },
  "meta": { "api_version": "v1" }
}

The response header is ETag: "default". Send that revision as If-Match on the PUT; a partial document is fine because the rest takes its defaults. Add ?dry_run=true first to check the change without storing it:

curl -si -X PUT -H "Authorization: Bearer $TOKEN" -H 'If-Match: "default"' \
  -H "Content-Type: application/json" \
  --data '{"tiers":{"edges":{"excellent":95}}}' \
  "https://yuzu.example.com/api/v1/dex/score-policy"

The answer is 200 with ETag: "2f9a498b26e94798", and data carries the full stored document, "revision": "2f9a498b26e94798", "source": "custom" and "previous_revision": "default". Sending the same PUT again with the old If-Match is refused, because the stored revision has moved on:

{
  "error": {
    "code": 409,
    "message": "the stored policy's revision is 2f9a498b26e94798, which is not the If-Match revision",
    "correlation_id": "req-18f2a1c3d-2a",
    "retry_after_ms": null,
    "remediation": "GET /api/v1/dex/score-policy and send the revision it returns (and review the current policy first)"
  },
  "meta": { "api_version": "v1" }
}

To go back to the shipped defaults, DELETE with the current revision: curl -si -X DELETE -H "Authorization: Bearer $TOKEN" -H 'If-Match: "2f9a498b26e94798"' "https://yuzu.example.com/api/v1/dex/score-policy".

GET /api/v1/dex/catalogue

Signal catalogue family cards (Catalogue View 1) — every catalogued signal family’s monitored/total type count, its own health-score slice, its window event count and busiest member type, plus an uncatalogued[] list of obs_types seen on the wire but not yet in a curated family. The machine-readable equivalent of the /dex Catalogue grid — previously fragment-only (ADR-0031 WS-A4 PR-1: the first public resource for the per-family health score / online-denominator coverage the dashboard has always computed with no REST/MCP twin). See docs/dex-signal-catalog.md for the family names.

GET /api/v1/dex/catalogue/group

One signal family’s member signals (Catalogue View 2) — per-type monitored/not-collected state, coverage platforms, event count + blast radius, plus the family’s own health-score slice. See docs/dex-signal-catalog.md for the family names.

GET /api/v1/dex/health

The derived/secondary composite health score (100 minus weighted per-family deductions). The machine-readable equivalent of the /dex Health score tab.

GET /api/v1/dex/trends

Cross-OS comparison + per-family day-by-day event counts (the small-multiples/heatmap source data). The machine-readable equivalent of the /dex Trends tab.

GET /api/v1/dex/overview

Fleet DEX overview — the /dex landing page’s fleet summary: per-device experience score distribution + the Device/App/Network composite, the measured crash-free rate, top apps, and the most-affected-devices list.

POST /api/v1/dex/devices/{id}/live

Dispatches a read-only instruction to the live agent now and synchronously returns the result (~20 s). The machine-readable equivalent of the dashboard “Get live info” button. POST, not GET — it has a side effect (it dispatches a command), so it must not be fired speculatively by prefetchers/proxies/retry libraries.

Scope boundary. This is the interactive, single-device probe and is concurrency-capped server-wide (over-budget callers get 429). It is not the fleet-scale path — do not fan a synchronous /live call out across thousands of devices. To read many devices at once, dispatch the equivalent read-only instruction to a scope via the async execution surface and collect results by execution_id (the cap deliberately keeps fan-out off this endpoint).


Network

The machine-readable siblings of the /network dashboard fragments (A1 — fleet link health must be answerable without scraping HTML). Same render-time aggregation over heartbeat network facts, same GuaranteedState:Read gate. The edge ships facts, never a verdict: these report measured RTT / retransmit / throughput and measured co-occurrence counts, never a causal “it’s the network” attribution.

Note — cohort parameter naming. The network surface uses key for the cohort tag dimension (empty = no cohort, length-guarded), where the DEX performance surface uses cohort_key (validated, defaults to model). The difference is intentional and reflects each surface’s cohort-resolution model — network cohorting is opt-in, DEX’s always resolves. There is no /api/v1/network/cohorts endpoint (network has no per-cohort statistical aggregation); discover valid tag keys via the tag API.

GET /api/v1/network/fleet

Fleet network-quality now-stats — the same numbers as the yuzu_fleet_net_* Prometheus gauges and the /network Overview cards, computed at request time.

GET /api/v1/network/devices

The one device list behind every network-quality drill: worst devices by a metric (default), the not-reporting complement, a co-occurrence band, or one cohort’s members.

GET /api/v1/network/discovered-devices

Hosts found by network discovery scans, including hosts with no agent. The REST/MCP twin of the /fragments/network/discovered dashboard fragment; all three call one builder, so they cannot drift. The older unbounded GET /api/discovery/results is unchanged.


Patch Management

GET /api/patches — Query missing/installed patches.

Note: patch inventory is populated only by PatchManager::record_patches(), which has no production caller today — this endpoint returns an empty result in every real deployment, not because the fleet has no missing patches. See docs/capability-map.md §8.5/§8.7 and #3676.

POST /api/patches/deploy — Create a patch deployment targeting specific agents.

FieldTypeRequiredDefaultDescription
kb_idstringYes—KB identifier (format: KBnnnnnnn)
agent_idsstring[]Yes—Target agent IDs (max 5000 after de-duplication; a longer list is rejected with too many target agents)
reboot_if_neededboolNofalseReboot agents after patching
reboot_delay_secondsintNo300Seconds to wait before reboot (clamped to 60–86400). A desktop notification warns the user before reboot.
reboot_atint64No0Optional epoch timestamp for scheduled reboot. Must be in the future. 0 = use delay instead.

Note: this creates a deployment record + a pending row per target — there is no server-side scan/install/verify/reboot orchestration (PatchManager::execute_deployment() was removed; ADR-0062, docs/capability-map.md §8.3/§8.4/§8.6, tracking issue #3669). Nothing advances a target past pending except POST /api/patches/deployments/:id/cancel (→ cancelled). reboot_if_needed/reboot_delay_seconds/reboot_at are accepted, clamped, and stored, but nothing acts on them.

Successful deploys are recorded in the audit log with action=patch.deploy, result=success. A validation rejection (invalid KB format, an oversized agent_ids list, or a failed transaction) is recorded with result=denied and detail carrying the error message — see docs/user-manual/audit-log.md’s patch.deploy row for the known gaps (#3705) in this outcome’s granularity.

GET /api/patches/deployments/:id — Deployment details with per-target status.

GET /api/patches/deployments — List deployments (paginated, default limit 50).

POST /api/patches/deployments/:id/cancel — Cancel a pending/running deployment.


Settings — OIDC Configuration

POST /api/settings/oidc — Save OIDC SSO configuration.

FieldTypeRequiredDescription
issuerstringYesOIDC issuer URL, exactly as the IdP’s discovery document states it in issuer (e.g., Entra ID: https://login.microsoftonline.com/<tenant-guid>/v2.0, the tenant ID form; Keycloak: https://<keycloak>/realms/<realm>)
client_idstringYesApplication (client) ID from Azure portal
client_secretstringNoClient secret value. If empty and a secret already exists, the existing secret is preserved.
redirect_uristringYesCallback URL; register exactly this value at the IdP. A blank value is refused with 400 and a red message naming the field (a provider without one never fetches discovery and GET /auth/oidc/start would answer 500 “OIDC redirect_uri not configured”). It is NOT auto-computed on this route: only the startup flag path fills in a localhost default.
admin_groupstringNoEntra group object ID for admin role mapping
skip_tls_verifystringNo"true" to disable TLS cert verification for OIDC discovery, JWKS and (Linux/macOS) token requests (insecure, dev only); the Windows token request ignores it. The save itself does not contact the IdP; use /test

POST /api/settings/oidc/test — Test OIDC discovery and signing-key connectivity.

GET /api/v1/settings/oidc

REST v1 read twin of GET /fragments/settings/directory (#4031) — note that despite its legacy fragment path, this is pure OIDC SSO config, not the AD/Entra directory-sync feature documented under Directory Sync above. Deliberately gated on a freshly-minted OidcConfig securable, never Directory, to avoid colliding with that unrelated capability.

Permission: OidcConfig:Read (Administrator only — matches the previous admin_fn_ gate)

MCP: REST-only — no MCP twin, same #520 exception as the Enrollment routes above.

The client secret is never disclosed — only a boolean client_secret_configured reports whether one is set, matching the existing fragment’s own non-disclosure posture (a ******** UI placeholder, never the real value).

Audited (settings.oidc.view, non-blocking) — IdP/admin-group configuration carries recon value to an attacker but is not per-person data.

Response:

{
  "data": {
    "configured": true,
    "issuer": "https://login.microsoftonline.com/<tenant-guid>/v2.0",
    "client_id": "abcd-1234",
    "client_secret_configured": true,
    "redirect_uri": "https://yuzu.example.com/auth/callback",
    "admin_group": "grp-object-id",
    "skip_tls_verify": false
  },
  "meta": { "api_version": "v1" }
}

Settings — Certificate Management

POST /api/settings/cert-upload — Upload PEM certificate file.

POST /api/settings/cert-paste — Paste PEM certificate content.

FieldTypeRequiredDescription
typestringYescert (server certificate), key (private key), or ca (CA certificate)
contentstringYesPEM-encoded content (must contain -----BEGIN)

Settings — Plugin Code Signing

These endpoints drive the Plugin Code Signing card, which appears on Settings and on Settings > Plugins and updates. The card lists the first 16 of N subjects when the bundle holds more than 16, and uploading over a loaded bundle asks for confirmation. The four /api/settings/plugin-signing/* routes are admin-only HTMX paths that return fragment HTML; the agent-facing distribution endpoint at /api/v2/agent/plugin-policy returns JSON (its predecessor /api/v1/agent/plugin-policy is deprecated — see below). See user-manual/agent-plugins.md → Plugin Code Signing for the operator workflow and user-manual/server-admin.md → vNEXT for the upgrade notes. See also Settings below — /api/v2/agent/plugin-policy is plugin-signing’s REST read-twin (#4028, #4144); it is documented in both places because it predates the Settings read-twins programme and keeps its original path rather than moving under /api/v1/settings/....

GET /fragments/settings/plugin-signing — Render the Plugin Code Signing card fragment.

POST /api/settings/plugin-signing/upload — Upload a PEM trust bundle.

POST /api/settings/plugin-signing/clear — Remove the trust bundle and reset the require flag.

POST /api/settings/plugin-signing/require — Toggle the require-signature flag.

GET /api/v2/agent/plugin-policy — Distribution endpoint for operator agent-config flows, and plugin-signing’s REST read-twin (#4028, #4144). Returns the current trust bundle PEM and require flag as JSON.

v1 deprecated. GET /api/v1/agent/plugin-policy predates #4028, is now DEPRECATED, and stays on its original flat-body shape (no data envelope, require_admin gate instead of PluginSigning:Read, no audit row, the old bespoke error shape) — see server-admin.md’s vNEXT note for the announced removal window. #4028 originally hardened this route’s shape in place; #4144 corrected that into the v1/v2 split below, since an in-place envelope reshape is a breaking change per docs/api-versioning-policy.md.


Settings — Multi-Factor Authentication (MFA / TOTP)

These endpoints drive the Settings → Security and sign-in → Two-factor sign-in card. They are legacy (no /v1/ prefix) and return HTMX fragments rather than JSON. All five require an admin session and target #mfa-section for swap-in. SOC 2 CC6.6 — see docs/auth-mfa-design.md.

GET /fragments/settings/mfa — Render the MFA card for the logged-in admin.

POST /api/settings/mfa/init — Begin TOTP enrollment.

POST /api/settings/mfa/verify — Confirm enrollment by submitting the first TOTP code.

POST /api/settings/mfa/recovery-codes — Regenerate the 10 recovery codes.

POST /api/settings/mfa/disable — Clear MFA state for the logged-in admin.

Note — admin force-disable for other users: the PR 1 endpoints are self-service only. An admin cannot disable another user’s MFA via the dashboard or REST in this release; that feature is planned. For emergency lockout recovery use the break-glass procedure in docs/ops-runbooks/auth-db-recovery.md § Emergency MFA disable.


Settings — User Management

These endpoints drive the Settings → Users tab. They are legacy (no /v1/ prefix) and return HTMX fragments rather than the standard JSON envelope. All three require an admin session and the dashboard swaps the response body into #user-section.

The handler enforces a self-target guard on destructive operations: the currently authenticated operator cannot delete or demote their own account, even via a hand-crafted HTTP request that bypasses the dashboard. See docs/user-manual/server-admin.md → “Deleting a User” for the operator-side recovery procedure.

GET /fragments/settings/users

Render the user table fragment.

POST /api/settings/users

Create a new local account.

Breaking change in v0.12.0: The role field is now ignored. New users are always created as user. To assign or change a role use the dedicated POST /api/settings/users/{username}/role endpoint documented below — this closes security finding C1 (privilege escalation via the role parameter on create). Operators that scripted user-create with role=admin should expect the user to land as user and explicitly promote via the role endpoint.

FieldTypeRequiredDescription
usernamestringYesAccount name. 1-64 chars, alphanumeric + . _ - only, and not made only of dots.
passwordstringYesNew password. 12–1024 bytes (UTF-8).
rolestring(ignored)Field is parsed but discarded; new users always land as user. Documented for backwards-compatibility — scripts that pass role=admin will not error, but the field has no effect.

POST /api/settings/users/:username/role

Change the role of an existing user. Dedicated endpoint introduced in v0.12.0 to give role transitions their own audit chain (governance C1).

{ "role": "admin" }

The role field must be exactly admin or user.

Example — promote bob to admin:

curl -X POST https://yuzu-server:8080/api/settings/users/bob/role \
  -H "Content-Type: application/json" \
  -H "X-Yuzu-Token: yzt_..." \
  -d '{"role":"admin"}'

DELETE /api/settings/users/:name

Delete the named account.

HTTP statusCondition
200Account deleted successfully
400Username failed the allowlist regex
404No persisted user matched :name
409:name is the last member of a protected administrator population (early or late form above)
503The guarded delete did not complete, or the account is deactivated but its session cleanup failed
403Cookie session without a same-site Origin/Referer (cross-site refusal; API-token callers exempt)
503The attempted audit row could not be written (Sec-Audit-Failed: true); nothing was revoked or removed
403Target equals the caller’s own username (self-delete guard)
403Caller is not an admin (rejected by admin gate before handler)
401Defensive — admin gate / session callbacks disagree
500Defensive — session has empty username

Example — attempt self-delete (will be rejected):

curl -X DELETE https://yuzu-server:8080/api/settings/users/admin \
  -H "X-Yuzu-Token: yzt_..." \
  -v
# HTTP/1.1 403 Forbidden
# HX-Trigger: {"showToast":{"message":"Cannot delete your own account","level":"error"}}

Legacy API Endpoints

The following endpoints are under /api/ (without the v1 prefix). They predate the versioned API and remain available for backward compatibility. These endpoints return JSON but do not use the standard v1 envelope.


Access — Users and roles

These endpoints back the Access → Users and roles page (/access/users). They return HTMX fragments, not the JSON envelope. The Users fragments below require an admin session. None of them changes anything. Only the Role assignments fragment writes a success audit row (rbac.assignments.list, with the full count, as its REST twin does). A refused browser session writes no denial row on the Users fragments (they load automatically); a refused API or MCP token is audited by the admin gate as auth.admin_required. A refused Roles, Role permissions or Role assignments fragment is audited as auth.permission_required. The Role assignments and Enforcement fragments also answer 401 without a session, and the Role assignments fragment answers 500 for a session that has no username. The page’s write controls call routes documented elsewhere: POST /api/settings/users, DELETE /api/settings/users/{name}, POST /api/settings/users/{name}/role and DELETE /api/v1/sessions?username=.

GET /fragments/access/users/list

Render the Users panel: one row per active account (name, source, account role).

GET /fragments/access/roles/list

Render the Roles panel: one row per role in the catalogue (name, description, whether it is built in).

GET /fragments/access/roles/permissions?role=<name>

Render one role’s permissions grouped by area.

GET /fragments/access/roles/assignments

Render the Role assignments panel: every fleet-wide grant (type, principal, role), with Assign and Remove controls on an administrator account.

GET /fragments/access/enforcement

Render the RBAC enforcement panel.

GET /fragments/access/users/detail?username=<name>

Render the Account panel for one active account.

Estate — Groups and enrolment

These endpoints back the management-group panels of the Estate → Groups and enrolment page (/groups). They return HTMX fragments, not the JSON envelope. All three are read-only: the page offers no control that creates, changes or deletes a group (that stays in Settings). A malformed id is a 400 with a one-line plain-text body, answered before any permission check and before any store read. None of them writes a success audit row (the REST twins write none). A refused request writes the gate’s ordinary denial audit row (auth.permission_required for the list and detail, auth.fleet_read_required for the members), as the REST twins do; opening /groups without ManagementGroup:Read therefore leaves one row, for the list panel, because the detail and members panels load only after a group is selected. Every response, including a refusal and the 400, sends Cache-Control: no-store. Each read materialises its result as the REST twin does (the whole group table, one group’s members), bounded by the store’s read timeout; the display caps below only bound the HTML.

GET /fragments/groups/list

Render the Management groups panel: the group tree (name, type, member count).

GET /fragments/groups/detail?id=<group id>

Render the selected group: name, type, parent (shown by its id when its name cannot be read), description, the stored rule of a rule-based group, who created it and when. A rule-based group’s (type dynamic) rule is shown as stored, with a note that its membership is not kept up to date from the rule. The panel also carries the loader for the members panel.

GET /fragments/groups/members?id=<group id>

Render one group’s members (agent id, source, when added).

Access — Tokens and principals

These endpoints back the Access → Tokens and principals page (/access/tokens). They return HTMX fragments, not the JSON envelope. None of them changes anything: every change the page makes is a call to an existing REST v1 route (/api/v1/tokens*, /api/v1/engine-principals*), which keeps its own permission, step-up, validation and audit. The 200 fragments send Cache-Control: no-store, private and Vary: Cookie; the 403 and 500 responses send no-store. The fragments also decide which controls to draw, using quiet permission probes (a refused browser session writes no audit row); the server stays authoritative.

GET /static/yuzu-tokens.js and GET /static/yuzu-tokens-actions.js

The page’s read and write scripts. Sent with Cache-Control: no-cache, no-store, must-revalidate.

GET /fragments/access/tokens/list (?state=active|all)

GET /fragments/access/principals/list and GET /fragments/access/principals/detail?id=engine:<slug>

Access — Audit and reviews

These endpoints back the read-only Access → Audit and reviews page (/access/audit). They return HTMX fragments, not the JSON envelope, and are the dashboard views of existing REST routes; they add no capability.

GET /fragments/access/audit/log

The audit-log panel. Same store and gate as GET /api/v1/audit (MCP query_audit_log), offering a subset of its filters (time range, exact principal, exact action; no until, target or limit) and rendering only that route’s fields.

GET /fragments/access/audit/sample

The dashboard view of GET /api/v1/audit/auth-sample with the window left open.

GET /fragments/access/audit/retention

The dashboard view of GET /api/v1/settings/data-retention (audit retention only).

GET /fragments/access/reviews/campaigns

The campaign list. Same gate, store read and audit row as GET /api/v1/access-reviews (MCP list_access_reviews).

GET /fragments/access/reviews/campaign?id=<campaign_id>

One campaign’s frozen grants (undecided first, capped at 1000 rows with a banner) plus its summary card, swapped out of band. Same gate, read and audit row as GET /api/v1/access-reviews/{id} (MCP get_access_review).

GET /fragments/access/reviews/grants

The live grant population, requested only by the Show current grants button. Same gate, read and audit row as GET /api/v1/access-reviews/export (MCP export_access_review).

Denied-access rows: the campaign list loads itself, so a refused browser session writes none for it; the campaign and grants fragments are requested by a click (or by the ?campaign=<id> link for the campaign), so a refused caller writes the ordinary permission-denied row. A refused API or MCP token writes one on every fragment.

The page makes every change (open, close, attest, flag) by calling the existing POST /api/v1/access-reviews routes; there is no dashboard-only write route. Flag records flagged_revoke evidence only and never revokes a grant.

Commands

POST /api/command

Send a command to one or more connected agents.

Request body:

{
  "plugin": "hardware",
  "action": "cpu-info",
  "agent_ids": ["agent-01", "agent-02"],
  "params": {},
  "stagger": 30,
  "delay": 5
}
FieldTypeRequiredDefaultDescription
pluginstringYes—Target plugin name.
actionstringYes—Action within the plugin.
agent_idsarray of stringNo—Target agent IDs. Omit the field to broadcast. A supplied [], a non-array value, or a non-string entry returns 400 (#2500).
paramsobjectNo{}Key-value parameters passed to the plugin. (This table previously named the field parameters; the handler has always read params, so a client written to the old text silently dispatched with no parameters at all.)
scopestringNo—Scope expression for device targeting (alternative to agent_ids), or __all__ for every enrolled agent. A supplied "" or non-string value returns 400 (#2500).
staggerintegerNo0Max random delay in seconds per agent before execution. Prevents thundering herd on large-fleet dispatch. 0 = no stagger. A JSON integer from 0 to 300 (the agent caps both stagger and delay at 300 seconds, so a larger value would run after five minutes while you believed it was held longer); anything else (a float, string, null, negative, or above 300) is 400 and nothing is sent. A float, string or null used to read as 0, 4294967297 as 1, and any value above 300 was silently capped (#4644).
delayintegerNo0Fixed delay in seconds per agent before execution. Added before the random stagger. 0 = immediate. Total agent wait = delay + random(0, stagger). Same range and 400 rule as stagger, so a malformed delay can no longer dispatch a held command immediately (#4644).

Targeting: omitted means everything, supplied-but-empty is an error (#2500). Omitting both agent_ids and scope broadcasts to all connected agents, as does an explicit "scope": "__all__". Supplying either field with a value that names no device is refused with 400 rather than widened — an empty agent_ids, a non-array agent_ids, a non-string entry, an empty scope, or a non-string scope. Before this, a client emitting numeric device ids ("agent_ids": [1,2,3]) or one whose device filter matched nothing ("agent_ids": []) dispatched to the entire fleet and received a success response.

agent_ids and scope are alternatives — supply exactly one, or neither. Supplying agent_ids together with a real scope returns 400 (reason=target_conflict). They used to resolve by precedence, with the scope winning and the explicit id list silently discarded, so {"agent_ids":["dev-a"],"scope":"tag:prod"} ran on every device matching tag:prod rather than on dev-a — the same “the executed set is not the requested set” defect this section is about, reached by two selectors disagreeing instead of by one being erased.

The single exception is "scope": "__all__" alongside agent_ids: __all__ is the broadcast request rather than a narrowing selector, and the explicit id list wins.

Refusals increment yuzu_server_dispatch_target_rejected_total{route="command",reason=...} and write a command.dispatch audit row with result=denied and detail=reason=<reason>. The body must be a JSON object; anything else is 400.

Destructive-class capabilities require explicit, non-empty agent_ids — broadcast and scope fan-out are refused (#3685). The command catalogue currently classifies 21 plugin.action pairs Destructive (e.g. echo.purge_source, filesystem.delete_lines, registry.delete_key); dispatching any of them with agent_ids omitted or empty, or with scope present at all — including "__all__" — is refused before the command reaches an agent. This is a narrower carve-out than the general targeting rule two paragraphs above: for an ordinary (non-Destructive) row, "scope": "__all__" supplied alongside agent_ids is the one case where the explicit id list wins and the call proceeds; for a Destructive row, supplying scope at all is refused even when agent_ids is also present and non-empty — the __all__-alongside-agent_ids exemption does not apply here.

{"error": {"code": 400, "message": "destructive action requires explicit in-scope agent_ids; broadcast and scope fan-out are refused"}, "meta": {"api_version": "v1"}, "audit_emitted": true}

Forensics-class capabilities are SINGLE-TARGET — exactly one explicit, in-scope agent_id, no scope (Wave 7 PR7.2). A row on the Forensics securable (e.g. execution_artifacts. shimcache) is ReadOnly, not Destructive, but a forensic read is per-device by nature: naming zero ids, more than one id, or any scope (including "__all__") is refused before the read reaches an agent, with its own distinct message:

{"error": {"code": 400, "message": "forensic read requires exactly one explicit in-scope agent_id; broadcast and scope fan-out are refused"}, "meta": {"api_version": "v1"}, "audit_emitted": true}

A Forensics read that names its one target confines to the caller’s visible agents exactly like a Destructive row — the 404 no reachable in-scope agent body above answers an out-of-group id the same way.

audit_emitted follows this file’s usual convention (see DELETE /api/v1/sessions?username=<name> above): present and false only when the paired command.dispatch audit row failed to persist (the response also sets Sec-Audit-Failed: true in that case), and omitted entirely when no audit store is configured — absent means no claim, not false.

The elevated securable/operation this row’s catalogue entry names (per-row, e.g. Infrastructure:Delete for echo.purge_source) is checked via require_permission before this refusal — a caller lacking the grant gets the ordinary 403 first, preserving the pre-#3685 403-before-400 ordering. A caller who is authorized but named explicit agent_ids is then confined to their currently visible agents (management-group scope); an id list that confines to nothing returns:

{"error": {"code": 404, "message": "no reachable in-scope agent"}, "meta": {"api_version": "v1"}, "audit_emitted": true}

(#2557) This 404 is now counted (yuzu_server_dispatch_target_rejected_total{route="command",reason="destructive_no_visible_target"}) and audited (command.dispatch, result=denied, same audit_emitted convention as every other denial on this route) — previously it carried neither, so an incident review of “an operator tried to dispatch a Destructive action at devices they cannot see” found nothing at all. Distinct from destructive_untargeted above: that reason means the caller named no target at all; destructive_no_visible_target means a non-empty agent_ids list WAS supplied, but every named id fell outside the caller’s visible-agent confinement.

A plugin.action pair the registry cannot classify (Unclassified/Ambiguous) is not independently denied by this gate — it falls through to the same dispatch chokepoint that denies every other unclassified pair, so a classify miss never silently reaches an agent through this path. Refusals are counted the same way as every other targeting refusal on this route (yuzu_server_dispatch_target_rejected_total{route="command",reason="destructive_untargeted"}) and audited (command.dispatch, result=denied).

MCP execute_instruction enforces the identical rule (same refusal message, same agent_ids-only requirement) — see docs/mcp-server.md. The dashboard exec console (POST /api/dashboard/execute) enforces it too: it refuses the same three shapes — __all__, a group:<id> scope, and no target selected at all — with the same two messages, confines an explicit selection to the operator’s visible agents, and counts refusals on yuzu_server_dispatch_target_rejected_total{route="dashboard",reason="destructive_untargeted"}. Because that surface answers htmx fragments rather than the A4 JSON envelope, its refusal is a 200 carrying the message in the console’s result line, not a 400/404. POST /api/instructions/{id}/execute and MCP execute_bundle do not yet enforce this gate.

Targeting the console by scope expression. POST /api/dashboard/execute also accepts an optional form field scope_expression, so a send can target the devices an expression matches. Absent, the route behaves exactly as above. Present, it is refused with a 400 (the body is the console’s result-line span, with the reason in its data-reason attribute, and a command.dispatch denied audit row with reason=<reason>) when:

reasonCause
target_conflictscope was also supplied, even empty. The route never picks one silently.
scope_expression_emptyThe field was supplied but empty. It is never widened to the fleet.
scope_expression_too_longLonger than 8192 bytes.
scope_expression_invalidThe expression does not parse (a bare tag:finance, __all__ and group:<id> are not expressions).
scope_unknown_attributeAn attribute outside the scope kinds catalogue. The resolver answers an empty string for an unknown name, and != and NOT turn that into every device, so a one-letter typo would otherwise fan out fleet-wide.

An expression target is never a named agent, so a Destructive-classified instruction is still refused as destructive_untargeted. A successful send is audited as command.dispatch with scope_expression=<text> appended to its detail. The Console disables its picker field and sends the expression only after a Preview of the same text, or after the operator retypes it.

Scheduled and background dispatch is deliberately not covered by it. A schedule targets by scope expression by construction (it has no agent_ids), so a scheduled destructive instruction keeps working exactly as before; it is gated instead by the schedule’s own fire-time re-verification of the arming principal and, where configured, its approval ticket.

Response (403): unauthorized dispatch. Two distinct causes share the 403 status but carry different messages (#1398) — an incident review can tell them apart from the audit trail’s detail=reason=<reason> field alone (forbidden vs approval_required), even without the response body.

A plain RBAC denial (the caller holds no grant for the pair’s classified securable/operation):

{"error": {"code": 403, "message": "permission denied: Execution:Execute"}, "meta": {"api_version": "v1"}}

A caller who does hold the grant but is dispatching one of the ~50 plugin.action pairs a compiled ExecuteGate marks AdminOrApproval/AlwaysApproval (e.g. script_exec.exec, filesystem.delete, registry.set_value), with no approval provenance and no admin role:

{
  "error": {
    "code": 403,
    "message": "approval required for script_exec.exec — this action requires either an admin caller or an approved request; dispatch it via POST /api/instructions/{id}/execute instead, which supports the approval workflow"
  },
  "meta": {"api_version": "v1"}
}

The governed path this message points at (POST /api/instructions/{id}/execute) supports the approval workflow this route deliberately does not — /api/command mints no approval ticket of its own (one core-owned approval primitive, ADR-1005). An admin caller, or a caller redispatching with a redeemed approval ticket via the governed path, is not subject to this denial.


Agents

GET /api/agents

List all currently connected agents with their metadata (hostname, OS, IP, connected plugins).


Help

GET /api/help

Returns a catalog of all available plugins and their supported actions, as reported by connected agents.


Instructions

GET /api/instructions

List instruction definitions stored in the server. Query params: name, plugin, type, set_id, enabled_only, limit.

Response (503): InstructionStore not yet initialized, or a genuine database/lease failure (ADR-0058) — the list is never silently rendered empty on a degraded store.

GET /api/instructions/{id}

Get a single instruction definition by ID.

Response (404): Unknown id.

Response (503): A genuine database/lease failure (ADR-0058), distinct from 404.

POST /api/instructions

Create a new instruction definition from JSON. The id field is optional — when omitted the server generates a UUID; when supplied it is validated for uniqueness against existing definitions.

Permission: InstructionDefinition:Write

Response (200): {"id": "<id>"} for the newly-created definition.

Response (400): Validation error (missing required field, invalid approval_mode, malformed JSON, a request/parameter_schema/visualization_spec/ response_templates_spec body nesting deeper than 32 levels (kMcpMaxJsonDepth), or an id under the reserved mcp. or guardian. prefix). Body is {"error": "<reason>"}.

The mcp. definition-id prefix is reserved (#2442): it names MCP approval tickets, and a definition authored under that prefix could line up with an MCP tool’s canonical arguments (docs/mcp-server.md documents the recall’s full match, which also binds origin and submitter). Every authoring route that accepts an explicit id refuses it — this one, POST /api/instructions/yaml, and POST /api/instructions/import — and boot-time auto-import skips such a definition. Product-pack install is unaffected: it never carries a declared id through, so the store always assigns one.

The store applies this at create time, so an id that predates the rule stays executable and can still be saved through PUT /api/instructions/{id}, because an update cannot originate an id. Note the dashboard’s YAML editor is stricter than the store here: POST /api/instructions/yaml validates a declared id on every save, so an existing mcp.-prefixed definition cannot be edited through it unless its document omits metadata.id.

Response (409): Returned when an explicit id is supplied that already exists in the store. Body is {"error": "instruction definition '<id>' already exists"}. Audit event recorded as instruction.create / denied / duplicate_id. To update the existing definition use PUT /api/instructions/{id}.

Response (503): Instruction store not yet initialized, or a genuine database/lease failure (ADR-0058) — distinct from the validation/conflict cases above, and worth a client retry. Audit event recorded as instruction.create / error / db_error.

PUT /api/instructions/{id}

Update an existing instruction definition.

Permission: InstructionDefinition:Write

Response (400): Validation error (same shape as POST above).

Response (404): Unknown id. Body is {"error": "not_found: definition not found: <id>"}. Audit event recorded as instruction.update / denied / not_found.

Response (503): A genuine database/lease failure (ADR-0058) — distinct from 404, and worth a client retry. Audit event recorded as instruction.update / error / db_error.

DELETE /api/instructions/{id}

Delete an instruction definition.

Permission: InstructionDefinition:Delete

Response (200): {"deleted": true}.

Response (404): Unknown id. Breaking change (ADR-0058): prior to this store’s PostgreSQL migration, a missing id returned 200 {"deleted": false}; it now returns 404. Any automation keying off the old deleted: false shape must be updated to handle 404 instead. Audit event recorded as instruction.delete / denied / not_found.

Response (503): A genuine database/lease failure (ADR-0058) — distinct from 404, and worth a client retry. Audit event recorded as instruction.delete / error / db_error.

GET /api/instructions/{id}/export

Export an instruction definition in a portable format.

Response (200) on an unknown id: {} (an empty JSON object, not an error) — pre-existing behavior, unchanged by ADR-0058; unlike every other route on this store, an unknown id here returns 200 {} rather than 404. Callers must check for an empty body, not a status code, to detect not-found.

Response (503): A genuine InstructionStore database/lease failure (ADR-0058) reading the definition, distinct from the empty-body not-found case above, and worth a client retry.

POST /api/instructions/import

Import an InstructionDefinition (JSON envelope). Requires InstructionDefinition:Write.

Signature enforcement (since #1073 / W7.4 sibling-gap closure): the server rejects unsigned imports by default. Mirrors the POST /api/product-packs --allow-unsigned-packs model — closes the equivalent fleet-RCE surface where an operator with import permission can otherwise publish a definition carrying an arbitrary plugin invocation that dispatches on every targeted agent.

Signed request body (recommended):

FieldTypeRequiredDescription
id, name, type, plugin, action, etc.variousyesStandard InstructionDefinition fields.
yaml_sourcestringyes (when signed)The verbatim YAML source — this is the signed-content carrier. The signature covers these bytes only. plugin, action, type, approval_mode and concurrency_mode are taken from the verified YAML (an envelope value that disagrees is refused with 400, and a signed YAML must declare plugin and action). enabled, instruction_set_id, TTLs, schemas, templates and the other columns are not expressible in YAML and are not signed.
signaturestringoptionalHex-encoded Ed25519 signature over yaml_source’s bytes.
publicKeystringoptionalHex-encoded Ed25519 public key (64 hex chars).

Signing rules:

A failed signature ALWAYS rejects, even when enforcement is off — --allow-unsigned-definitions only widens the unsigned-path policy, it does not skip crypto on present signatures.

Audit: every import attempt emits instruction.import with result=success on success, result=denied on rejection. The target_id is the definition’s id on success; empty on rejection.

Response (503): A genuine database/lease failure (ADR-0058) — audited the same as every other rejection above, distinct from a signature/validation/conflict 400/409.

POST /api/instructions/yaml

Create or update an instruction definition from raw YAML source (the dashboard New Definition panel’s Save endpoint). Form-encoded: yaml_source carries the document verbatim (stored as the definition’s source of truth); an optional id parameter switches to update mode. Responses are HTML fragments (this is a dashboard surface), plus an HX-Trigger toast header. Requires InstructionDefinition:Write.

The queryable columns are derived schema-aware from the YAML — both the canonical nested schema and the flat form the panel’s structured form generates are accepted, with this precedence:

ColumnCanonicalFallback
idmetadata.id (create only; honoured, 409 on conflict)store-generated
namemetadata.displayNamemetadata.name, then metadata.id
plugin / actionspec.execution.plugin / .actionspec.plugin / spec.action
approvalspec.approval.modescalar spec.approval (default auto)
version / type / concurrencymetadata.version / spec.type / spec.execution.concurrencydefaults 1.0.0 / question / per-device

On update, a yaml_source whose metadata.id differs from the id parameter is rejected. Explicit ids must match [A-Za-z0-9._-]{1,128}. Save and validate-yaml share one validation contract — YAML that passes validation always saves.

Audit: emits instruction.create / instruction.update (result=success), and instruction.create with result=denied / duplicate_id on conflict.

POST /api/instructions/validate-yaml

Validate YAML against the yuzu.io/v1alpha1 DSL schema without persisting it. Requires InstructionDefinition:Read. Runs the same validation contract as the Save endpoint above (required fields, type/approval enums, explicit metadata.id charset/length, size cap, scope-walking gates), so a document that validates green is guaranteed to save.

POST /api/instructions/{id}/execute

Execute an instruction definition by dispatching it to agents. Requires Execution:Execute permission.

Request body:

{
  "agent_ids": ["agent-uuid-1"],
  "params": {"key": "value"}
}

Behaviour change (#2500). This endpoint previously documented “empty string + empty agent_ids = broadcast”, and an explicitly empty agent_ids did broadcast. It is now 400. Omit both fields (or send "scope": "__all__") to target the whole fleet deliberately. Refusals increment yuzu_server_dispatch_target_rejected_total{route="instruction_execute"} and write an instruction.execute audit row with result=denied. The body must be a JSON object; anything else is 400.

Response (200):

{
  "command_id": "plugin-hexid",
  "agents_reached": 3,
  "definition_id": "filesystem.exists"
}

Response (202 — Approval Required):

Returned when the definition’s approval_mode is role-gated or always and the caller does not have direct-execute permission. The execution is held as a pending approval ticket and nothing runs yet. Approving an instruction-origin ticket records the decision but does not yet dispatch the instruction (#6064; see Upgrading).

{
  "status": "pending_approval",
  "approval_id": "abc123...",
  "definition_id": "def456..."
}

Parameter validation. The params are checked against the definition’s stored parameter_schema after the permission checks and before the approval gate, strictly: an undeclared name, a wrong type, a value outside enum, pattern, minimum/maximum or minLength/maxLength, a missing required name, a string containing a NUL, or a params that is not an object is refused. The values you send are never modified and no default is injected.

A schema is also checked when it is written. POST /api/instructions/import refuses a parameter_schema over 262144 bytes (400 parameter_schema is larger than the 262144-byte limit), one that cannot be prepared (400 parameter_schema is not a valid parameter schema: <up to 3 problems>[; and N more]; the problems name the property and a fixed reason, never a schema value) and one the server could not finish checking (400 parameter_schema could not be checked). Updating a definition (PUT /api/instructions/{id}, the YAML editor, response-template changes) keeps its stored schema.

Not a security boundary. Only this route validates params. Workflow steps, schedules, policy remediation, the result-set producers, MCP execute_instruction and POST /api/command do not validate against the stored schema yet.

Errors: 404 (definition not found), 400 (invalid body, or invalid params), 202 (approval required — execution queued, not yet dispatched), 403 (workflow blocked by approval-gated instruction and caller lacks execute-bypass permission), 500 (stored parameter schema invalid), 503 (no agents reached or store unavailable).


Instruction Sets

GET /api/instruction-sets

List all instruction sets.

Response (503): InstructionStore not yet initialized, or a genuine database/lease failure (ADR-0058) — the list is never silently rendered empty on a degraded store.

POST /api/instruction-sets

Create a new instruction set (a named collection of definitions).

Permission: InstructionSet:Write

Response (200): {"id": "<id>"} for the newly-created set. The id is always server-generated (this route does not accept a caller-supplied id), so the 409 case below is not reachable via normal use of this endpoint today — documented for API-contract consistency with the sibling POST /api/instructions route, not because it fires in practice.

Response (400): Validation error, including a name over 128 characters or a description over 512.

Response (409): A set with that id already exists (gov Gate 4/6, fixed to match POST /api/instructions’s equivalent branch — the response no longer leaks the raw internal conflict: prefix).

Response (503): A genuine database/lease failure (ADR-0058), or the attempted audit row could not be stored (Sec-Audit-Failed: true; no set created).

Audit: instruction_set.create writes attempted first, then an outcome on every path (success, denied detail conflict or invalid, error detail db_error); see the audit table entry above.

DELETE /api/instruction-sets/{id}

Delete an instruction set. Definitions that referenced the deleted set have their instruction_set_id cleared, not deleted.

Permission: InstructionSet:Delete

Response (200): {"deleted": true}.

Response (404): Unknown id. Breaking change (ADR-0058): prior to this store’s PostgreSQL migration, a missing id returned 200 {"deleted": false}; it now returns 404.

Response (503): A genuine database/lease failure (ADR-0058), or the attempted audit row could not be stored (Sec-Audit-Failed: true; nothing deleted).

Audit: instruction_set.delete writes attempted first, then an outcome: the 200 success (result=success), the 404 (result=denied detail not_found) and 503 (result=error detail db_error) branches (see the audit table entry above).


Executions

All seven routes below are management-group confined as of #3789 (docs/auth-architecture.md’s “Fourth migration”): a caller holding Execution:Read/Execute through a management group only sees/acts on executions it dispatched itself or that involve at least one agent it can see. A global grant is unrestricted. An execution outside the caller’s visibility and a nonexistent one return the identical 404 (no existence oracle).

Response (503): any of the seven routes returns 503 with retry_after_ms on either (a) the RBAC/management-group store being degraded when a confinement decision must be made (require_fleet_read fails closed, replacing the pre-#3789 flat 403), (b) the execution tracker itself being unreadable (ExecutionTracker’s _checked accessors), or (c) — under a confined grant only — the caller’s session identity failing to resolve after admission, which fails closed rather than silently falling through to agent-only visibility. Retry after the given interval; a persistent 503 indicates a store outage, not a permission problem.

GET /api/executions

Permission: Execution:Read. List executions. Accepts definition_id, status, and limit (capped at 500) as query parameters. Under a confined grant, agents_targeted/agents_responded/ agents_success/agents_failure are recomputed from only the caller’s visible agents.

GET /api/executions/{id}

Permission: Execution:Read. Get details of a single execution. Under a confined grant, scope_expression and parameter_values are redacted ("(redacted - confined view)") and the four agent counts are recomputed from only the visible agents; status/dispatched_by/ dispatched_at/completed_at/lineage stay truthful. Response (404): unknown or outside-scope id.

GET /api/executions/{id}/summary

Permission: Execution:Read. Get an execution summary (counts of targeted, responded, success, failure agents, progress_pct). Response (404): unknown or outside-scope id.

GET /api/executions/{id}/agents

Permission: Execution:Read. List agents involved in an execution, filtered to the caller’s visible agents under a confined grant. Response (404): unknown or outside-scope id.

GET /api/executions/{id}/children

Permission: Execution:Read. List child executions spawned from a parent execution. Each child is independently checked against the caller’s visibility — a visible parent does not by itself disclose a child dispatched by, or targeting, someone else. Response (404): unknown or outside-scope parent id. The underlying query is hard-capped at 100 rows (governance Gate 8 re-review fix, #2146 A2-R1; no caller-visible limit/cursor), with the cap now pushed down TOGETHER WITH the caller’s own visibility scope, before LIMIT - a confined caller’s cap applies to their own visible children, not the fleet-wide raw row set. result_truncated_by_cap: true is added alongside children when that scoped row set exceeded the cap.

POST /api/executions/{id}/rerun

Permission: Execution:Execute AND Execution:Read (the mutation’s target-visibility check runs through the Read fleet gate). Re-execute a previously run instruction with the same parameters and targets. Under a confined grant, every targeted agent must be visible to the caller — a partially-visible or not-yet-fully-reported execution is refused, with a 404 (deliberately non-distinguishing from a nonexistent id, to avoid disclosing a hidden cohort). A global (unconfined) grant is unaffected by this check and keeps the pre-existing 400 {"error": "original execution not found"} response for an unknown id. Escape hatch: same as cancel below — an execution whose targeted cohort never fully reports can permanently fail the complete-cohort admission check for a confined caller; a global grant is never subject to it.

POST /api/executions/{id}/cancel

Permission: Execution:Execute AND Execution:Read, same confinement rule as rerun. Response (409): the execution already finished (succeeded, completed or cancelled); nothing changed. Audited as execution.cancel / denied. A degraded execution store is a 503. Response (404): unknown execution id (this route no longer reports a false success for a nonexistent id), outside-scope, or an incomplete/partial target cohort. Escape hatch: an execution whose targeted cohort never fully reports (an agent gone offline mid-run, for example) can permanently fail the complete-cohort admission check for a confined caller — cancel it with a global Execution:Execute+Execution:Read grant instead, which is never subject to this confinement rule.


Schedules

A schedule fires unattended through the background ScheduleRunner poller with no further permission check at fire time — so schedule creation, and re-enabling a disabled schedule, are the only gates against a Schedule:Write-only principal reaching fleet-wide command dispatch. Delete and enable/disable are owner-scoped: they only affect a schedule whose created_by matches the caller.

GET /api/schedules

List schedules. Accepts definition_id and enabled_only as query parameters. Requires Schedule:Read.

POST /api/schedules

Create a recurring schedule for an instruction definition. Requires both Schedule:Write and Execution:Execute — a schedule is a fleet-wide command-dispatch primitive (empty scope_expression targets every agent; requires_approval defaults to false).

interval_minutes (default 60, 1 to 35791394, a ceiling of floor(INT32_MAX/60), about 68 years), day_of_week (default 0, 0 to 6) and day_of_month (default 1, 1 to 28) must each be a JSON integer in range when present. A float, string, null, boolean, array, out-of-range value or a number that does not fit (4294967297 minutes used to become an every-minute schedule) is 400 and nothing is created (#4644).

DELETE /api/schedules/{id}

Delete a schedule you created. Requires Schedule:Delete; a schedule created by another principal is left untouched (reports deleted: false, same as a nonexistent id).

POST /api/schedules/{id}/enable

Enable or disable a schedule you created. Requires Schedule:Write; enabling (but not disabling) additionally requires Execution:Execute, since enabling arms the schedule to fire — an operator can always disable a schedule to stop it even without Execution:Execute.

enabled is a JSON boolean, or the integer 0 (disable) or 1 (enable). An omitted key or a blank (empty or whitespace-only) body enables, as before. A string (including the old "true" / "false" encoding), any other number, null, an array or object, or a body that is truncated or not a JSON object is 400 and the schedule is neither armed nor changed: {"enabled":0} used to arm it (#4644).


Approvals

GET /api/v1/approvals/{id}

Fetch a single approval by id. This is the A4 status_url target: when an operation is denied with an approval-required envelope, a worker polls this endpoint for the current status rather than re-issuing the gated request. Requires Approval:Read. Read-only — it never mutates the approval lifecycle.

Response (200): the standard v1 envelope wrapping an Approval object:

{
  "data": {
    "id": "…",
    "definition_id": "…",
    "status": "pending",
    "submitted_by": "alice",
    "submitted_at": 1735689600,
    "reviewed_by": "",
    "reviewed_at": 0,
    "review_comment": "",
    "scope_expression": "tag:prod"
  },
  "meta": { "api_version": "v1" }
}

status is one of pending | approved | rejected | expired. The response always carries an X-Correlation-Id header.

Bound tickets (#6062). A ticket minted with a frozen request (today only the reserved Guardian rule-deploy surface; nothing in this release mints one yet) additionally carries three fields, so an agentic worker sees exactly what the dashboard’s Approvals page shows a reviewer. An ordinary ticket (every MCP, interactive-instruction and scheduled-fire ticket) carries none of them and its body is unchanged:

FieldTypeMeaning
requestobject or nullThe frozen request the reviewer approves, as a JSON object (the stored document parsed). null when there is no readable request: request_unreadable is true when one is stored but cannot be shown, and absent when none is stored at all (an inconsistent row holding only a digest).
request_digeststringLowercase hex SHA-256 of the exact stored bytes of the request. The object above is a parsed view and a re-serialisation (sorted keys, no whitespace) need not reproduce those bytes, so use the digest to identify the request, not to recompute it.
excluded_rootsarray of stringPrincipals barred from approving this ticket, in addition to its submitter, who is always barred. Matched byte-for-byte against the reviewer’s name.
request_unreadablebooleanPresent (true) only when request is null because the stored request could not be read or exceeds its bounds. Such a ticket cannot be approved (the approve call returns 400), but it can be rejected.
excluded_roots_unreadablebooleanPresent (true) only when the stored excluded-roots list could not be read. Such a ticket cannot be approved.

These fields are readable under the same bare Approval:Read gate as the rest of the row and are not confined by management group: a frozen request can name devices in its targets, and every holder of Approval:Read can already read the ticket’s scope_expression. The permission gate and the A4 error envelope are unchanged.

An aged approved ticket (#6098): status is only rewritten to expired by the next accepted approval submission, so an approved ticket whose reviewed_at is more than 7 days old keeps showing approved until then, although consuming it and ScheduleRunner already refuse it. Read reviewed_at as well as status before treating an approved ticket as usable.

Errors: 404 (no approval matches the id — A4 envelope), 503 (approval store not initialised — A4 envelope with retry_after_ms: 5000).

GET /api/v1/approvals

REST v1 twin of the legacy unversioned GET /api/approvals below and the widened MCP list_pending_approvals tool. This route, the single-fetch GET /api/v1/approvals/{id} route, and MCP list_pending_approvals share one JSON-row builder (approval_row_json) so those three cannot drift from each other; the legacy route below builds its rows with the same function, so a bound ticket’s request, request_digest and excluded_roots (see the single-fetch route above) appear on all of them. Permission: bare Approval:Read — approval requests carry no per-agent axis for the ADR-0017 admit-then-filter fleet_read_fn chokepoint to confine against (same rationale as GET /api/v1/schedules above), so this list is fleet-wide for anyone holding the permission; a reviewer approving or rejecting a ticket needs to see the ticket’s full scope_expression to make that decision, so confinement-filtering this list would break the maker-checker function itself. Accepts status and submitted_by query parameters, same as the legacy route. Omitting status returns ALL statuses — unlike MCP list_pending_approvals, which defaults to pending-only when its own status argument is omitted; a caller wiring both a REST dashboard and an MCP agent against the same queue with no filter gets two different result sets. A supplied status outside pending/approved/rejected/expired is rejected with 400 (governance fix — previously silently produced an empty result, indistinguishable from a genuinely empty match). The underlying query is hard-capped at 100 rows (no caller-visible limit/cursor); pagination.result_truncated_by_cap is added when more than 100 approvals match, so pagination.total is never presented as the true match count.

Response (200):

{
  "data": [
    {
      "id": "…",
      "definition_id": "…",
      "status": "pending",
      "submitted_by": "alice",
      "submitted_at": 1735689600,
      "reviewed_by": "",
      "reviewed_at": 0,
      "review_comment": "",
      "scope_expression": "tag:prod"
    }
  ],
  "pagination": { "total": 1, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

An aged approved ticket (#6098): status is only rewritten to expired by the next accepted approval submission, so an approved ticket whose reviewed_at is more than 7 days old keeps showing approved until then, although consuming it and ScheduleRunner already refuse it. Read reviewed_at as well as status before treating an approved ticket as usable.

Errors: 400 (invalid status value); 503 (a genuine store failure, never a false empty list — A4 envelope, retry_after_ms: 5000 on a transient failure such as pool exhaustion, retry_after_ms: null — will NOT clear on retry — on a permanent one such as schema drift or disk-full).

GET /api/v1/approvals/pending/count

REST v1 twin of the legacy unversioned GET /api/approvals/pending/count below and the new MCP get_pending_approval_count tool. Requires Approval:Read.

Response (200):

{
  "data": { "count": 3 },
  "meta": { "api_version": "v1" }
}

Errors: 503 (a genuine store failure, never a false zero count — A4 envelope, retry_after_ms: 5000 on a transient failure such as pool exhaustion, retry_after_ms: null — will NOT clear on retry — on a permanent one such as schema drift or disk-full).

GET /api/approvals

List approvals. Accepts status and submitted_by as query parameters. Response shape: {"approvals": [...]} — a bare array under one key, not the v1 {data, pagination, meta} envelope above. Not migrated onto the checked read path: on a genuine Postgres failure this route silently returns {"approvals": []} — indistinguishable from a real empty queue — rather than a 503; prefer GET /api/v1/approvals or MCP list_pending_approvals for failure-honest polling. status is validated against the same enum as the v1/MCP twins — an unrecognized value is rejected with 400 (#2146 A2-R4).

An aged approved ticket (#6098): status is only rewritten to expired by the next accepted approval submission, so an approved ticket whose reviewed_at is more than 7 days old keeps showing approved until then, although consuming it and ScheduleRunner already refuse it. Read reviewed_at as well as status before treating an approved ticket as usable.

GET /api/approvals/pending/count

Return the count of instructions awaiting approval. Response shape: bare {"count": N}, not the v1 {data, meta} envelope above. Not migrated onto the checked read path: on a genuine Postgres failure this route silently returns {"count": 0} — indistinguishable from a genuinely empty queue — rather than a 503; prefer GET /api/v1/approvals/pending/count or MCP get_pending_approval_count for failure-honest polling.

GET /fragments/approvals

HTML fragment behind the Instructions Approvals tab and the Approvals page (/approvals). Requires Approval:Read. view=page renders the page list (pending by default); status is one of pending, approved, rejected, expired, and an empty value means every status. An unknown status answers 200 with a data-invalid="status" note, a failed store read answers 200 with data-degraded="store", and a refused caller gets 200 with data-denied="true" (the dashboard drops 4xx bodies). The page view lists the 100 most recent rows and marks a cut list with data-truncated="1". On both the tab and the page view, Approve and Reject buttons render only for a session that passes the approver class check (an interactive, human, non-engine session), holds Approval:Approve, and sees at least one pending request that it did not submit itself; they are a render hint, and POST /api/approvals/{id}/approve|reject re-checks everything.

POST /api/approvals/{id}/approve

Approve a pending instruction execution. comment is capped at 4,000 bytes; a longer comment is refused with 400 {"error":"comment too long (max 4000 bytes)"} and no decision is recorded.

Who may call it. Requires Approval:Approve and an interactive cookie session (local or OIDC) whose own identity has an enrolled MFA factor with a fresh proof (#6079). A stale proof returns 401 with meta.mfa_step_up_required: true and meta.challenge_url, as on the other step-up endpoints (docs/user-manual/authentication.md, “Step-up on high-risk surfaces”). An API-token or MCP-token session is refused with 403 and an A4 envelope, and an engine principal is never admitted. The approver rule is stricter than the generic step-up gate and does not depend on --mfa-enforcement or --jit-oidc-amr-elevation: a local account with no enrolled factor is refused 403 approver_assurance_required:mfa_not_enrolled; an OIDC session whose sign-in carried no MFA amr (or no usable iat) is refused 403 mfa_proof_missing (with step-up on, typed only when --mfa-enforcement does not protect the role; otherwise the central check answers first with the 401 step-up), whereas one whose MFA amr is present but whose ID token was issued longer ago than the window gets the 401 step-up with challenge_url=/auth/oidc/start; a live session whose account row is deleted, deactivated or has an invalid username is refused account_unavailable (typed only with --mfa-step-up-window-secs at 0, or when the account is removed between the central check’s read and the approver rule’s: with the window above 0 the central check normally answers such an account first with 401 MFA step-up required); a SAML session is refused saml_mfa_unsupported (#4935; with step-up on, the central check’s generic 403 answers it first, so the typed refusal appears only with the window at 0). The approver rule runs after the central step-up check, so a proof older than the configured window, a SAML session and an engine principal are answered there while the window is above 0. The window is min(--mfa-step-up-window-secs, 300) seconds, and 300 when that setting is 0 or negative, so --mfa-step-up-window-secs=0 no longer disables the check (before #6079 it did, and admitted a SAML session). The ceiling is measured on the database clock against the proof timestamp stored with the session and read afresh for each decision, so a paused virtual machine or a resumed host cannot extend it. A local proof made before the account’s current MFA enrolment is treated as stale (401). For an OIDC session “fresh” means the provider issued an ID token with an MFA amr within the window; the MFA itself may predate the token, because the provider can reuse its own session, and no second factor is presented again at approval time; real re-authentication is a planned follow-up. Under --mfa-enforcement=optional or admin-only a non-admin cannot enrol itself, so an administrator must enrol each non-admin approver first (docs/ops-runbooks/auth-db-recovery.md, “Enrolling a non-admin approver”). A cookie-session POST must also carry a same-site Origin (or Referer); see the 403 cross-site request refused row below. The reviewer cannot be the submitter (400, audited as denied). Fail-closed behaviour: if the caller’s session cannot be resolved the route returns 503 and the request stays pending; the approval attempt is audited before the decision is applied, and if that audit row cannot be written the route returns 503 and changes nothing (#5823). The attempted and success audit rows carry detail=approver_assurance:enrolled_fresh_mfa, which marks a decision made under this control (a row without it predates it). An approved ticket not consumed within 7 days of its review is refused at consume. Full rules: docs/user-manual/instructions.md, “Who can approve”.

On success the response is 200 with {"status":"approved"} ({"status":"rejected"} for reject):

curl -s -b cookies.txt -X POST http://localhost:8080/api/approvals/REQ-001/approve \
  -H "Origin: http://localhost:8080" -H "Content-Type: application/json" \
  -d '{"comment": "Reviewed, safe to proceed."}'

Errors (checked in this order; POST /api/approvals/{id}/reject is identical). The first row is the server-wide same-site check, which a cookie session meets before anything else (a token caller skips it). The next five rows are the central MFA step-up check, which runs before the route handler (with --mfa-step-up-window-secs=0 only the first two of those can fire); the handler’s own checks follow it, and the handler’s step-up call only consumes the central pass:

StatusBodyCause
403A4 envelope, message cross-site request refusedServer-wide same-site check, after authentication and before the step-up check: a cookie-session POST whose Origin/Referer names another site, or carries neither header (a --csrf-trusted-origin entry is accepted). Audited as csrf.denied (central gate); no step-up is evaluated, no attempted row is written, and the ticket is unchanged.
503A4 envelope, message authentication store is temporarily unavailableCentral check: no authentication store is wired (a defensive boot-wiring state). It answers every caller class, an engine principal included, before the engine refusal below, and is audited as mfa.step_up.required with reason=auth_store_unwired.
403step-up envelope, message engine principals cannot perform this operationCentral check: an engine principal. Audited as mfa.step_up.required with reason=engine_principal_denied in the detail and the route label as target_id; the handler never runs, so no approval.approve / approval.reject row is written.
403step-up envelope, message MFA step-up is not available for SAML sessions in this releaseCentral check: a SAML session while step-up is on (#4935). A deployment whose approvers all sign in through SAML has no working approver path until #4935 is decided (tracked in #6098).
503A4 envelope, message authentication store is temporarily unavailableCentral check: the step-up gate could not read the account’s MFA status; it fails closed (a local account’s status read can also answer a 401 whose remediation reads auth_db unavailable).
401step-up envelope, meta.mfa_step_up_required: true, meta.challenge_urlCentral check: the session’s MFA proof is older than --mfa-step-up-window-secs. Complete the step-up and retry. A cross-site cookie POST never reaches this check; the first row answers it.
403the standard permission-denied responseIn the handler: the caller lacks Approval:Approve.
503A4 envelope, retry_after_ms: 5000In the handler: the caller’s session could not be resolved after the permission gate passed. The request stays pending and nothing is recorded.
403A4 envelope, message cross-site request refusedIn the handler: this route’s own copy of the same-site check, defence in depth. A cookie-session caller is answered by the first row and does not reach it; it can fire only where the server-wide check does not run. Audited as csrf.denied; the ticket is unchanged. Token callers are not subject to this check and are refused by the next row instead.
403A4 envelope, message approver_assurance_required:<reason>In the handler: class refusal. <reason> is non_interactive_session (API-token, MCP-token or any other non-cookie session) or non_human_principal; engine_principal is produced only on the MCP surface, because over REST an engine principal is refused by the central check above. Audited as approval.approve / approval.reject with result=denied and detail=approver_assurance:<reason>; the refusal does not depend on that row: if it cannot be written the 403 stands and the response carries Sec-Audit-Failed: true. API-token and MCP-token sessions pass the central step-up check and are refused here.
503{"error":{"code":503,"message":"service unavailable"}}In the handler: the approval store is not available.
503A4 envelope, message MFA verification is not available; the decision was not recorded, remediation contact an administratorIn the handler: Defensive fail-closed branch: the server wires the step-up closure and the approver MFA check at startup, so this appears only if the route was constructed without either (a wiring fault, not a request problem). Nothing is recorded or audited.
403A4 envelope, message approver_assurance_required:mfa_not_enrolledIn the handler (the approver rule, after the central check has passed): The local account has no enrolled MFA factor. The A4 remediation differs for an administrator (enrol under Settings) and a non-admin (sign out and sign in again, because sign-in offers enrolment when the deployment’s --mfa-enforcement requires it for the role; otherwise ask an administrator to enrol the account, and the text points that administrator at “Enrolling a non-admin approver” in the Auth Store Recovery Runbook). Audited denied, detail=approver_assurance:mfa_not_enrolled; no step-up is offered, because a code cannot help.
403A4 envelope, message approver_assurance_required:mfa_proof_missingIn the handler (approver rule): An OIDC session whose sign-in left no MFA proof: the identity provider asserted no MFA-method amr, or the token had no usable iat. With the window above 0 this is typed only when --mfa-enforcement does not protect the role (optional, or admin-only for a non-administrator); under required, or admin-only for an administrator, the central check answers first with the 401 step-up. Sign in again through the provider with MFA. If the identity provider re-authenticates you silently without asserting an MFA amr, the retry returns 403 mfa_proof_missing again; complete provider-side MFA (for example by signing out of the provider first) and then retry. Real re-authentication at approval time is a planned follow-up. A proof that exists but is older than the window is not this row; it is the 401 step-up row below. A proof that is dated after the database clock is the clock-skew 503 row below while the database clock is still behind that instant; once the database clock has passed that instant (after at most as long as the skew is large, even while the skew persists), a session whose cached proof was dropped answers this 403 until its cache re-derives (a session create, MFA mark, elevation or delete by any user clears it; an activity touch does not; so does a server restart; a new sign-in also works once the clocks agree). The remediation names both causes (the identity provider sent no MFA amr, or the clocks of the identity provider, the Yuzu server and its database disagree) and always ends with If it repeats, ask your administrator to check clock synchronisation between the identity provider, the Yuzu server and its database. Audited denied, detail=approver_assurance:mfa_proof_missing.
403A4 envelope, message approver_assurance_required:account_unavailableIn the handler (approver rule): A live session whose auth.users row is deleted, deactivated or has an invalid username. Typed only with --mfa-step-up-window-secs at 0, or when the account disappears between the central check’s read and this one: with the window above 0 the central check answers first with a 401 MFA step-up required whose remediation reads auth_db unavailable: retry shortly or contact an administrator. Sign in again or contact an administrator. Audited denied, detail=approver_assurance:account_unavailable.
403A4 envelope, message approver_assurance_required:saml_mfa_unsupportedIn the handler (approver rule): A SAML session (#4935) when step-up is switched off (--mfa-step-up-window-secs=0): with step-up on, the central check’s 403 above answers first, and this refusal holds in every configuration. A deployment whose approvers all sign in through SAML has no working approver path until #4935 is decided (tracked in #6098). Audited denied, detail=approver_assurance:saml_mfa_unsupported.
401step-up envelope, meta.mfa_step_up_required: true, meta.challenge_url (/login/mfa/stepup for a local session, /auth/oidc/start for OIDC)In the handler (approver rule): The session’s MFA proof is older than the approver window (at most 300 seconds, so this also fires inside a longer configured window, or with the window at 0; a proof older than the configured window is answered by the central 401 row above), or (local) predates the account’s current enrolment. Complete the step-up and retry. If the identity provider re-authenticates you silently without asserting an MFA amr, the retry returns 403 mfa_proof_missing; complete provider-side MFA (for example by signing out of the provider first) and then retry. Real re-authentication at approval time is a planned follow-up. Two audit rows are written: mfa.step_up.required (the gate’s own row; detail reason=proof_predates_enrolment when the proof predates enrolment) and approval.approve / approval.reject result=denied with detail=approver_assurance:mfa_proof_stale (the ticket-specific row). The two have different failure postures: if the ticket-specific row cannot be written, the response carries Sec-Audit-Failed: true; the gate’s own mfa.step_up.required row is best effort, so a failed write is logged on the server and never surfaced in the response.
503A4 envelope, retry_after_ms: 5000In the handler (approver rule): The MFA store could not be read (including a missing or unreadable TOTP secret), or the session’s stored proof could not be read, so the check fails closed and the ticket is untouched. Increments yuzu_auth_secret_unavailable_total{route="approval"}; audited as an error result with detail=approver_assurance:mfa_store_unavailable.
503A4 envelope, message the approver's identity-provider MFA proof is dated after the database clock, retry_after_ms null (the key is present with a null value)In the handler (approver rule): an OIDC approver whose stored MFA proof is dated after the database clock, which means the replica that took the sign-in ran ahead of the database clock (the sign-in clamps the ID token’s iat to that replica’s own clock, so an identity provider running ahead cannot cause this alone). Reached only when the central check does not answer first: default optional enforcement, admin-only for a non-administrator approver, or the step-up window at 0; under required (or admin-only for an administrator) with the window above 0 the approver instead sees a repeating 401 step-up challenge and no approval.* row. Retrying does not help until an administrator corrects clock synchronisation between the Yuzu server hosts and the database host; the A4 remediation says so (it names the identity provider too). Not counted in yuzu_auth_secret_unavailable_total{route="approval"} (that counter stays a store-fault signal); find it by the audit detail approver_assurance:mfa_proof_clock_skew (result=error) and by the server error log line. See docs/ops-runbooks/auth-db-recovery.md, “OIDC approvers refused with a clock-skew 503”.
400A4 envelope, message approval not foundIn the handler: The path id is empty, longer than 128 characters or outside letters, digits, _ and -, so the store is not asked. One denied row (detail invalid_approval_id, target_id invalid-id) and no attempted row; the refusal stands even if that row cannot be written, in which case the response carries Sec-Audit-Failed: true. This check runs after the permission, CSRF, class and MFA checks above and before the attempted row. An id that is literally invalid-id passes the shape check and is looked up like any other, so a row with target_id invalid-id is told apart by its detail.
503A4 envelope, header Sec-Audit-Failed: true, retry_after_ms: 5000The attempted audit row could not be written, so the decision was not applied and the ticket stays pending (#5823).
400{"error":"<reason>"}The store refused the decision, for example the reviewer is the submitter, or the ticket is no longer pending. Audited as denied.

Retrying after a lost response. The decision is applied once. A retry of a request whose response was lost is answered 400 approval already reviewed (status: <status>) while the caller’s proof is still inside the approver window, and the audit trail shows an attempted row followed by a denied row for it. Outside the window, or when the account’s state changed (for example the factor was cleared), the retry is refused before the store is asked: 401 for a stale proof or 403 approver_assurance_required:<reason>, each leaving a denied row and no attempted row. Read the ticket’s status (GET /api/v1/approvals/{id}) before deciding that the first request failed.

Example class refusal:

{
  "error": {
    "code": 403,
    "message": "approver_assurance_required:non_interactive_session",
    "correlation_id": "req-...",
    "remediation": "approving or rejecting requires an interactive human session (local or OIDC login) with an enrolled MFA factor and a fresh proof; API-token, MCP and engine credentials cannot decide an approval. Use the dashboard Approvals page."
  },
  "meta": { "api_version": "v1" }
}

POST /api/approvals/{id}/reject

Reject a pending instruction execution. comment is capped at 4,000 bytes; a longer comment is refused with 400 {"error":"comment too long (max 4000 bytes)"} and no decision is recorded. Same caller requirements and fail-closed behaviour as POST /api/approvals/{id}/approve above.


Audit (Legacy)

GET /api/audit

Query audit events. Legacy route — not merely GET /api/v1/audit without the v1 envelope; the response shape and accepted parameters both genuinely differ. Accepts principal, action, target_type, target_id, since, until, limit, and offset (the same filter names as the v1 route; the legacy route’s validation is looser in one respect only: it does not reject a target_id given without target_type). Response is {"events": [...], "count": N, "total": <number-or- null>} (field names events/count/total, not v1’s data/pagination/ meta), and each event row additionally carries principal_role and source_ip, which v1’s row shape omits. total is a best-effort second read taken after the page itself succeeds — it is null, not 0 or the page size, when that second read degrades; do not treat null as “unknown but probably zero”.

limit is capped at 1000 (a larger value is clamped, as on v1) and offset at 10000.

Permission: AuditLog:Read (admin-only while RBAC is disabled, #5771).

Errors: 403 — caller lacks AuditLog:Read; 400 — a since/until/ limit/offset query parameter is not a valid integer, limit < 1, offset outside 0..10000, since is negative, until is 0 or negative (a negative bound, and until=0, used to read as no bound and returned the whole log; omit the parameter for no bound, and since=0 is accepted and matches the same rows as omitting it), since is after until, or a string filter contains a NUL or invalid UTF-8; 503 — the audit store is closed, or the page-rows query degraded (ADR-0040 deny-on-degrade; never a false-empty 200).


Scope Engine

POST /api/scope/validate

Validate a scope expression without executing it.

Request body:

{
  "expression": "os = 'windows' AND tag:environment = 'production'"
}

POST /api/v1/scope/validate

Versioned twin of the legacy route above (#2146 Batch B2) — and of the MCP validate_scope tool. All three call the same yuzu::scope::validate(), so none of them can silently diverge on what counts as a valid expression.

Permission: Session-authenticated only — no RBAC gate (a syntax-only check with no data disclosure), matching the legacy route and validate_scope exactly.

Request body: same shape as the legacy route above.

Response: {"data": {"valid": true, "expression": "..."}, "meta": {"api_version": "v1"}}, or {"data": {"valid": false, "error": "..."}, "meta": {"api_version": "v1"}} for a syntactically invalid expression (still a 200 — the response reports validity, the request itself is well-formed).

Errors:

StatusReason
400expression missing or empty

POST /api/v1/scope/preview

Versioned twin of the MCP preview_scope_targets tool (#2146 Batch B2) — no legacy unversioned twin exists (POST /api/scope/estimate below is a different capability: a matched/total count only, for the workflow builder’s own confined scope_fn). Both this route and preview_scope_targets call the same preview_scope_targets() builder (scope_preview.hpp), so the matched-agent set cannot drift between transports.

#4981: this route now runs the SAME evaluation ladder a real dispatch uses (resolve_scope_targets, dispatch_scope_ladder.hpp) — alias resolution, owner-check gate, parse, then registry evaluation, each step fail-closed. tag:<key>, props.<key>, and from_result_set:<id> atoms all resolve identically to a real dispatch; this closes the pre-#4981 fleet-wide over-disclosure bug where from_result_set:/props.* were silently unresolved (always "" — unset) and a NOT-negated reference to either matched the WHOLE FLEET regardless of the referenced set’s real membership. The ladder’s own evaluation runs unfiltered against the whole fleet; the caller’s confinement (see Permission below) is intersected in AFTERWARD, mirroring how a real dispatch intersects against the operator’s Execution:Execute visible set before sending.

A tag:<key> atom in the expression now resolves identically to a real dispatch (#4981): the persistent tag store first, falling back to a locally-connected agent’s own live self-report only when the store has no row for that agent — a presence-only cross-replica agent has no live session to fall back to, so it still resolves store-only for such agents, same as real dispatch. See Tag source precedence. POST /api/scope/estimate below also resolves from_result_set: against the owner, but a failed owner-check there degrades to “zero members” rather than aborting the whole evaluation like this route’s ladder does — a separate, still-open fail-open gap, tracked #5003, not fixed by #4981.

Permission: Infrastructure:Read, via the admit-then-filter fleet-read chokepoint (ADR-0017) — this route discloses agent identities, unlike the syntax-only validate route above, so a management-group-confined caller’s matched_agents/matched_count are narrowed to their own visible devices after the ladder evaluates, never the whole fleet. A service-scoped API token is denied outright (403) before the fleet-read gate or the evaluation ladder ever run — same cross-service-reach reasoning as POST /api/v1/result-sets/from-inventory-query (#4980): a from_result_set:<id>/ props.<key> atom resolves against the minting operator’s identity (session->username), not the token’s own restricted scope, so admitting and merely narrowing the output (what fleet_read_fn alone would do) would let a service token probe or own-check a result set it never minted.

Request body: {"expression": "..."}

Response:

{
  "data": {
    "expression": "os = 'windows'",
    "matched_count": 42,
    "matched_agents": ["agent-001", "agent-002"],
    "warning": "scope matches 63 agents (>50). Phase 2 write operations targeting this scope will require approval."
  },
  "meta": { "api_version": "v1" }
}

warning is present only above the 50-agent display threshold.

Errors:

StatusReason
400expression missing/empty, or fails to parse/validate
403Service-scoped API token — denied outright, before the fleet-read gate or evaluation ladder ever run (#4980-class cross-service-reach fix). No .permission field in the error body: a service-scoped caller holding Infrastructure:Read is still denied, so naming it would be a false self-remediation claim
404A from_result_set:<id> atom references a result set that is absent, expired, or not owned by the caller (RESULT_SET_NOT_FOUND) — existence-oracle-safe, same body shape as the result-set routes’ own load_owned 404; a server-side audit row is still written
503Scope evaluation aborted — never silently under-reports the match set. A store preload (tag/props/result-set) failed or the cross-replica presence read failed is TRANSIENT (db_degraded/presence_degraded, retry_after_ms: 5000); no dispatching principal was available or a required store isn’t wired is PERMANENT (principal_unresolved/unresolvable, retry_after_ms: null — a retry cannot fix either); the evaluator itself being unwired (misconfiguration) also carries retry_after_ms: null

POST /api/scope/estimate

Estimate how many agents match a scope expression.

Request body:

{
  "expression": "os = 'windows' AND tag:environment = 'production'"
}

A from_result_set:<id-or-alias> reference in the expression is resolved against the authenticated principal’s owned result sets before the estimate is computed (see the scope DSL §9.3). An alias that resolves to an absent, expired, or unowned set counts as zero members.


Data Export

POST /api/export/json-to-csv

Convert a JSON result set to CSV format for download.

Permission: Response:Read. Body: a JSON array of objects, up to 100 MiB (the pre-auth body cap, json_to_csv_export). The header row is the first object’s keys.

Cells. A string cell and every header have a leading =, +, -, @, tab or carriage return neutralised with a leading ' (CWE-1236), then RFC 4180 quoting. A missing or null cell is empty. Any other non-string cell (number, boolean, nested value) is written as its JSON text with RFC 4180 quoting only, so -5 stays -5 and a nested object keeps its commas inside one cell; those values cannot spell a formula.

Errors. 400 request body nests too deeply when the raw text nests more than 32 levels (checked before parsing); 400 invalid JSON array when the body is not an array, is empty ([]), or its first element is not a non-empty object (the first object’s keys are the columns; later non-objects are skipped and keys absent from the first object are dropped); 403 without Response:Read. The reply is text/csv with Content-Disposition: attachment; filename="export.csv".


Responses

GET /api/responses/{id}

Get command responses for a specific command ID. Every served read writes a response.read success row (detail=legacy response query cid=<id>), fail-closed: if that row, or the scope-drop denied row, cannot be persisted the route answers 503 with Sec-Audit-Failed: true and no data (see “Audit posture” under Differences between the v1 and the legacy routes). An explicit limit is capped at 1000; when the caller asked for more than 1000 and the page came back full, the body carries a top-level result_truncated_by_cap: true (see “Differences between the v1 and the legacy routes” under Command/Instruction Responses).

GET /api/responses/{id}/aggregate

Aggregate response data for a command (counts, summaries). Every served read writes a response.read success row (detail=legacy response aggregate cid=<id>), fail-closed like the other two legacy readers: a row that cannot be persisted is a 503 with Sec-Audit-Failed: true and no data.

GET /api/responses/{id}/export

Export response data in CSV format (format=csv) or JSON. limit defaults to 10000 and is clamped to [1,10000] (limit=0 or a negative value serves one row). The export is also cut at 50 MiB of row payload, applied inside the store query; see “Export body size cap” and “Detecting a cut export” under Command/Instruction Responses. A cut CSV export ends with a # result_truncated_by_cap cause=<row_cap|byte_cap> trailer record (7 fields) and sets X-Result-Truncated-By-Cap: true; a cut JSON envelope carries a top-level result_truncated_by_cap: true; both are downloaded as responses-<id>-truncated.<json|csv>. The JSON envelope’s count is the number of rows served. Numeric query parameters (status, since, until, limit) are parsed strictly and a malformed value is 400; omitting since or until leaves that side unbounded, since=0 is accepted as a no-op, and until=0 or a negative bound is 400. The legacy export writes a response.read success audit row on every served export (and the scope-drop denied row when a drop occurs), fail-closed like the v1 twin: if the row cannot be persisted the export answers 503 with Sec-Audit-Failed: true and no data. The legacy list and aggregate routes behave the same way (see their sections above).

Audit (#4644). All three legacy routes (GET /api/responses/{id}, /aggregate, /export) write a response.read result=success audit row on every served read and fail closed with a 503 (Sec-Audit-Failed: true) when the row cannot be persisted, like their /api/v1/responses twins.

Confined (#1634). All three readers are gated by require_fleet_read (ADR-0017 admit-then-filter) — a management-group-confined operator is admitted and sees only their in-scope agents’ rows, real cross-operator isolation rather than the earlier inert per-row filter. /export and the catch-all GET push the visible-agent set into the underlying SQL query before LIMIT/OFFSET (ADR-0017 INV-3), so a confined caller’s page reflects only their own visible rows. All three now share ONE gate’s failure posture: a null/unopened response store returns 503; an open but corrupt RBAC store fails closed with 403 (rbac_enforcement_in_effect holds, so require_fleet_read’s underlying permission check denies rather than falling through to the legacy read path) — for all three readers alike, not the differentiated no-rows-vs-503 split of the pre-migration gate. Scripted/Grafana consumers that start receiving 503/403 after an upgrade should check /readyz and the server log for RbacStore open/migrate errors.


Tags (Legacy)

GET /api/tags

Get tags for an agent. Requires agent_id query parameter. Returns tags as an array of {key, value, source, updated_at} objects (different format from the v1 envelope).

POST /api/tags/set

Set a tag on an agent. Request body: {"agent_id": "...", "key": "...", "value": "..."}. A service-scoped token cannot set the service key on any agent — 403 (#3289); see Service-Scoped Tokens.

POST /api/tags/delete

Delete a tag from an agent. Request body: {"agent_id": "...", "key": "..."}. A service-scoped token cannot delete the service key on any agent — 403 (#3289).

POST /api/tags/query

Query agents that have a specific tag. Request body: {"key": "...", "value": "..."}. Returns {"agents": [...], "count": N}.

Storage failure (all four legacy routes): a degraded tag store returns 503 ({"error":{"code":503,"message":"tag store unavailable"}}) instead of an empty result or a false success (ADR-0050). Unlike the v1 DELETE, POST /api/tags/delete has no 404 — a not-found tag remains 200 {"deleted": false}; only the store-degrade path is new. POST /api/tags/set previously returned 200 {"status":"ok"} even when the write failed; it now returns 400 (validation) or 503 (store degrade) honestly.


Inventory (Legacy)

Requires Inventory:Read (global — not per-agent/management-group scoped; every caller holding the permission sees fleet-wide data, unlike the confined Responses routes above). Predates, and is distinct from, /api/v1/inventory/* (§ above) — the v1 routes cover the same three operations (tables, {agent_id}/{plugin}, query) wrapped in the standard A4 envelope; prefer the v1 surface for new integrations. These legacy routes retain their historical, non-A4 response shapes and remain live for existing callers.

GET /api/inventory/tables

List available inventory data types across the fleet: {"tables": [{"plugin", "agent_count", "last_collected"}, ...], "count": N}.

GET /api/inventory/{agent_id}/{plugin}

Get the most recent inventory record for one agent+plugin pair: {"agent_id", "plugin", "data", "collected_at"}. data is the plugin’s raw collected payload, parsed as JSON when valid, else returned as a raw string. 404 if no record exists for that agent+plugin pair.

POST /api/inventory/query

Query inventory records across agents. Request body (all fields optional): {"agent_id": "...", "plugin": "...", "since": <epoch>, "until": <epoch>, "limit": N}. limit is capped at 1000 regardless of the requested value (any larger integer, including one above the 32-bit range, clamps to 1000); a limit below 1 or one that is not a JSON integer is 400. Returns {"results": [...], "count": N, "result_truncated_by_cap": bool, "results_excluded_by_poison": N}. result_truncated_by_cap is true when more matching rows existed than limit allowed; results_excluded_by_poison (#4496) is the count of matching rows excluded because their stored data_json nested past the JSON depth guard (a poisoned/over-nested row), emitted unconditionally (0 when none were excluded) so a short count can never be mistaken for “nothing else matched”: the two truncation causes are otherwise indistinguishable from the response alone.

Storage failure (all three routes): a null/unopened inventory store returns 503 ({"error":{"code":503,"message":"inventory store not available"}}); a store that opens but degrades mid-call also returns 503 ("inventory store degraded") — distinct from the 404 on GET /{agent_id}/{plugin}, which means the store answered but the record doesn’t exist. POST /query returns 400 on a malformed request body.


Analytics and NVD

GET /api/me

Returns current user info (legacy version; prefer /api/v1/me). The body is the data object of GET /api/v1/me with no envelope: the same keys with the same meaning, including principal_kind, auth_source, mcp_tier, scope_service, rbac_roles and rbac_roles_resolved, and the same engine-principal and role-store-fault behaviour (an engine principal gets no invented rbac_role; a role-store fault answers 200 with rbac_roles_resolved: false and no role keys). Keys are emitted in insertion order, not alphabetically, so a JSON consumer must not depend on key order. Sent with Cache-Control: no-store, private.

GET /api/analytics/status

Requires Infrastructure:Read. Returns the status of the analytics event pipeline: {"enabled":true,"pending_count":N,"total_emitted":N}, or {"enabled":false,"pending_count":0,"total_emitted":0} when analytics collection is disabled (--no-analytics). Degrade-distinguishable (ADR-0049): a Postgres read failure returns 503 — {"error":{"code":503,"message":"analytics store degraded"},"meta":{"api_version":"v1"}} — rather than a possibly-inaccurate 200.

GET /api/analytics/recent

Requires Infrastructure:Read. Returns recent analytics events. Accepts limit as a query parameter (default 50). {"events":[...],"count":N} on success, {"events":[],"count":0} when analytics collection is disabled. Degrade-distinguishable (ADR-0049): a Postgres read failure returns 503 with the same envelope shape as /api/analytics/status above, rather than a possibly-inaccurate 200.

GET /api/nvd/status

Returns the status of the NVD (National Vulnerability Database) sync.

Response fields: enabled, syncing, last_sync_time, last_error, total_cves, backfill_complete, and backfill_oldest_published. enabled reflects whether NVD sync is configured on (i.e. --no-nvd-sync was not passed) — not merely whether the local mirror DB is open. Under --no-nvd-sync the mirror stays queryable (/api/nvd/match still works against seeded/previously-synced data) but enabled is false and POST /api/nvd/sync returns an error. Only enabled and total_cves are guaranteed present; the sync-progress fields (syncing, last_sync_time, last_error, backfill_complete, backfill_oldest_published) are omitted when sync is disabled, and the whole body is {"enabled":false} when the mirror DB is closed. total_cves is a count of distinct CVEs in the local store. (Prior to the CPE-range-matching change it counted one row per affected product, so a multi-product CVE inflated the figure — after upgrade the number reads lower even once fully synced, and reads near-zero briefly after the one-time schema migration until the next sync repopulates the mirror. This is expected, not data loss.)

backfill_complete (boolean) reports whether the newest-first catalog backfill has reached its configured floor and the catalog holds real NVD-sourced CVEs — the built-in fallback rules seeded at startup do not count, so total_cves can be non-zero while backfill_complete is still false. A mirror with no NVD CVEs is never reported complete, so a fresh or rate-limited deployment (or one whose upstream NVD fetches have not yet returned data) shows false with the cursor at the floor until real NVD data lands. That is expected, not a stall. backfill_oldest_published (ISO 8601 string) is the progress cursor — the published date of the oldest CVE fetched so far, walking backwards. During the initial backfill total_cves climbs continuously and last_sync_time advances after every successful fetch window — so a non-empty last_sync_time does not mean the mirror is complete. Use backfill_complete (with the backfill_oldest_published cursor for progress) as the authoritative “initial mirror built” signal, not last_sync_time.

last_error (string, present only while sync is enabled) surfaces the most recent sync-health problem and is cleared at the start of the next sync tick — a non-empty value is a transient, self-healing condition, not a product bug. Values you may see: a transient fetch failure (NVD backfill fetch failed (<reason>) — retrying (mirror incomplete) or NVD freshness fetch failed (<reason>) — retrying, where <reason> is one of connection/http_429/http_403/http_other/parse — cleared once a fetch succeeds; a persistent http_403 means a bad/revoked --nvd-api-key, so rotate it, and http_429 is expected rate-limiting that self-heals via backoff); a local persist failure (NVD backfill window persist failed — mirror incomplete / NVD freshness window persist failed — a disk/DB issue; the mirror holds its cursor and stays backfill_complete: false rather than dropping the fetched CVEs); a prolonged upstream outage (NVD returning empty responses — mirror not populated); and re-confirmation of a suspicious empty window (re-confirming a suspicious empty NVD window (n/N) — an older published-date window returned empty after real data had already landed; the backfill holds, staying backfill_complete: false, and re-checks it before trusting it, so a stale cache/proxy serving an empty page can’t make it skip a populated range and falsely report complete). These are operational states, not product bugs — the mirror recovers automatically once the underlying condition clears.

POST /api/nvd/sync

Trigger a manual NVD database sync. Requires Infrastructure:Execute (held by Administrator and ITServiceOwner, not Administrator alone). Runs asynchronously and returns immediately.

The request is audited before it is made as nvd.sync.request (result=success, or result=disabled when sync is off on this server and the call answers 503). If the audit row cannot be persisted the request is refused with 503 (an A4 error envelope with retry_after_ms: 5000) and Sec-Audit-Failed: true, and no sync is started. The 503 for sync-off is an A4 error envelope too, and so are the 503 and 400 of POST /api/nvd/match.

The same status JSON drives the dashboard card GET /fragments/auto/nvd (gated Infrastructure:Read), which shows the CVE count, last sync, whether a sync is running, and the last error (escaped and length-capped). The NVD proxy shown by GET /fragments/settings/nvd has any user:password@ removed before display.

POST /api/nvd/match

Match installed software against known CVEs in the NVD database. Matching evaluates full CPE version ranges (versionStartIncluding/Excluding, versionEndIncluding/Excluding, exact and wildcard). Product identity is matched by name (case-insensitive); vendor-precise CPE identity is a planned enhancement (ADR-0018), so name-collision false positives are possible. In each match, fixed_in carries the range’s exclusive upper bound (versionEndExcluding) when present; it is empty for inclusive-end, exact, or wildcard matches — empty means “no fix boundary derivable from the range shape”, not “no fix available”. Distro revision markers (e.g. Debian ~ in 1.0.0~deb1) are compared as plain separators rather than pre-release markers, so distro-backported versions can produce false negatives until backport-aware matching (M1b/OVAL) lands.


SSE Event Stream

GET /events

Server-Sent Events stream for real-time dashboard updates. Events include agent connections/disconnections, command responses, and system notifications. Live output events never carry a row written by a Forensics-tier plugin (see “Forensics-tier rows” under Command/Instruction Responses); a Forensics-tier frame publishes nothing to the live output stream (no row, no notice; the timing and command-status events, which carry no plugin output, still go out), so the dashboard Console’s results table stays on its “Waiting for results…” placeholder; open the execution results instead.

Example (curl):

curl -N -H "Cookie: yuzu_session=abc123" https://yuzu.example.com/events

Event format:

event: agent_connected
data: {"agent_id":"agent-01","hostname":"web-server-01"}

event: command_response
data: {"agent_id":"agent-01","command_id":"cmd-abc","status":"COMPLETED"}

GET /sse/executions/{id}

Per-execution Server-Sent Events stream introduced in v0.12.0. Backs the inline drawer’s live updates on the Instructions → Executions tab.

Forensics-tier output is not published (#4990). The live output events never carry a row written by a Forensics-tier plugin (app_usage, browser_inventory, execution_artifacts, privacy_permissions, remote_access_audit), whoever is subscribed, and the failure text in the execution events of such a command is a short machine token or the fixed value forensics_error. Read the rows through the response routes, which require Forensics:Read. Such a frame publishes nothing to the live output stream, not even a notice (the timing and command-status events, which carry no plugin output, still go out), so the dashboard Console’s results table stays on its “Waiting for results…” placeholder for such an action; open the execution results instead.

Management-group confinement (#1634). Gated by require_fleet_read/fleet_read_fn (ADR-0017 admit-then-filter), not a flat global Execution:Read check — a management-group-confined operator is admitted and then sees only their in-scope agents’ events. The caller who dispatched the execution is always admitted to the stream (visibility only — avoids a false 404 on a just-dispatched execution with zero responses yet), but that ownership never bypasses the per-event redaction below. RBAC-off → unrestricted (legacy-open). This gate is evaluated once, at subscribe time — it is not re-checked for the life of the connection. If your scope is narrowed, or your session is revoked, mid-stream, an already-open subscription is not automatically disconnected (tracked: #3788); a full server restart is the only thing that currently drops every live stream.

Reconnect / replay: the server keeps a per-execution ring buffer of up to 1000 events covering ~30 seconds of activity. Browsers’ EventSource automatically sends Last-Event-ID on reconnect; the server replays events whose monotonic id is greater than that value before resuming live publication.

Cross-replica delivery (HA WS-2a-2). On a multi-replica HA deployment, events that originate on another server replica are delivered onto this replica’s live stream by a ~2s cross-replica poll, so a subscriber sees live progress driven from any replica; this delivery is at-least-once (a duplicate may be re-sent, never dropped). The id: field is unchanged — it stays a per-channel monotonic value assigned in publish order, so Last-Event-ID / ?since reconnect works exactly as before on the replica you are connected to. Reconnect across replicas after a failover is not yet loss-free — see docs/executions-history-ladder.md. Single-replica deployments are unaffected.

Event types:

EventEmitted onPayload (JSON in data:)
agent-transitionOne per agent state change (update_agent_status write)AgentExecStatus snapshot — agent_id, status, exit_code, duration_ms, error
execution-progressEvery refresh_counts recomputecounts snapshot — total, succeeded, failed, running, pending
execution-completedCrossing the all-agents-responded threshold OR mark_cancelledterminal status — {"status":"succeeded"|"completed"|"cancelled"}. Client should close the EventSource after this event.

Confined-subscriber event projection (#1634). A management-group-confined subscriber (not the execution’s dispatcher) does not receive the raw event stream verbatim — a shared projector (execution_event_scope.hpp) applies one of three treatments per event, so a subscriber can never learn about agents outside their visible set:

EventConfined treatment
agent-transitionFiltered by agent_id — only transitions for agents in the caller’s visible set are forwarded; others are silently dropped.
execution-progressDropped entirely — the counts snapshot is execution-wide and carries no agent_id to filter by, so it cannot be safely narrowed.
execution-completedSanitized, not dropped — the real terminal status is preserved (so the client still knows to close its stream) but nothing else is added.

Status-code map:

HTTP statusCondition
200Stream attached; events follow
401No session / token
403RBAC Execution:Read denied
404{id} does not exist in the execution tracker, or exists but is not visible within the caller’s management-group scope — the two are deliberately indistinguishable (no existence oracle, #1634)
410Execution is already in a terminal status (succeeded / completed / cancelled / failed). Tells EventSource to stop reconnecting.
429Shared held-open-stream budget exhausted (ADR-0034). Body is plain text (too many live streams open — close a tab and retry) with a Retry-After: 5 header — note this surface predates the A4 envelope and does not use it.
503The per-execution event bus is not configured (test harness opt-out, or a configuration path that omits the bus). Returned at request time so the operator does not silently freeze waiting on a missing publisher.

This stream holds an HTTP worker thread for as long as the drawer stays open, so it leases from the same --max-sse-streams budget as GET /mcp/v1/, MCP streamed POST, GET /api/v1/events, and the legacy /events stream (ADR-0034). That means enough open drawers — or enough traffic on any of the other surfaces — can cause a new drawer to be refused with 429. A live stream is never evicted to make room.

Audit: every successful subscribe emits one execution.live_subscribe audit event (target_type=Execution, target_id={id}, result=success). A subscribe request against an execution that is genuinely nonexistent OR merely out of the caller’s scope (the 404 case above) also emits a result=denied row on the same action, using non-distinguishing detail text — the two cases must remain audit-indistinguishable, matching the response HTTP status. Per-session-per-execution dedup is not currently implemented (#700) — operators on the SOC 2 evidence chain receive a row per reconnect; the forensic-grade audit on first-load remains on /fragments/executions/{id}/detail’s execution.detail.view.

Example (curl):

curl -N -H "Cookie: yuzu_session=abc123" \
  https://yuzu.example.com/sse/executions/exec-abc123

Example output (running execution, two agents, one in progress):

event: agent-transition
id: 1
data: {"agent_id":"a-1","status":"running","exit_code":null,"duration_ms":null}

event: agent-transition
id: 2
data: {"agent_id":"a-1","status":"success","exit_code":0,"duration_ms":4218}

event: execution-progress
id: 3
data: {"total":2,"succeeded":1,"failed":0,"running":1,"pending":0}

event: agent-transition
id: 4
data: {"agent_id":"a-2","status":"success","exit_code":0,"duration_ms":5102}

event: execution-progress
id: 5
data: {"total":2,"succeeded":2,"failed":0,"running":0,"pending":0}

event: execution-completed
id: 6
data: {"status":"succeeded"}

GET /api/v1/events

Agentic-first JSON SSE channel introduced in sprint W5.1 (2026-05-18). This is the sibling of /sse/executions/{id} aimed at external LLM-driven workers (Claude, GPT, in-house) rather than a browser EventSource. Both routes subscribe to the same per-execution ExecutionEventBus and emit the same event taxonomy; the differences are:

The browser-oriented /sse/executions/{id} route is preserved for the dashboard drawer and is unchanged.

Permission: Execution:Read. Auth: Bearer token, X-Yuzu-Token header, or session cookie. Same auth surface as every other /api/v1/* endpoint.

Forensics-tier output is not published (#4990). The live output events never carry a row written by a Forensics-tier plugin (app_usage, browser_inventory, execution_artifacts, privacy_permissions, remote_access_audit), whoever is subscribed, and the failure text in the execution events of such a command is a short machine token or the fixed value forensics_error. Read the rows through the response routes, which require Forensics:Read. Such a frame publishes nothing to the live output stream, not even a notice (the timing and command-status events, which carry no plugin output, still go out), so the dashboard Console’s results table stays on its “Waiting for results…” placeholder for such an action; open the execution results instead.

Management-group confinement (#1634). Gated by require_fleet_read/fleet_read_fn (ADR-0017 admit-then-filter), not a flat global Execution:Read check — a management-group-confined operator is admitted and then sees only their in-scope agents’ events. The execution’s dispatcher is always admitted to the stream (visibility only — avoids a false 404 on a just-dispatched execution with zero responses yet), never a bypass of the per-event redaction below. RBAC-off → unrestricted (legacy-open). This route shares one event projector (execution_event_scope.hpp) with the dashboard’s /sse/executions/{id} sibling — see that section for the drop/sanitize table (agent-transition filtered by agent_id, execution-progress dropped, execution-completed sanitized to status only), and for the same subscribe-time-only gate staleness caveat (tracked: #3788) — this route shares that gap.

Required query parameter:

ParameterTypeDescription
execution_idstring [A-Za-z0-9_-]{1,128}Execution to subscribe to. Multi-execution / ?filter=execution_id:X|agent_id:Y syntax is sprint W5.2.

Optional replay parameters:

ParameterTypeDescription
since (query)integer ≥ 0Replay events with id > since. Overrides Last-Event-ID. A value that is not one non-negative base-10 integer is a 400 (answered before the stream budget and before the success audit row).
Last-Event-ID (header)stringBrowser EventSource auto-reconnect header. Used only when ?since is absent. A malformed value is served live only (nothing replayed), never a 400 and never the whole buffer.

Event envelope (every data: line):

{
  "execution_id": "exec-abc",
  "event_id": 12,
  "timestamp_ms": 1716000000000,
  "type": "agent-transition",
  "payload": {"agent_id": "a-1", "status": "success", "exit_code": 0}
}

The type field is the canonical taxonomy: agent-transition, execution-progress, execution-completed (real bus events) plus three synthetic types the worker should handle:

Synthetic typeWhen emittedMeaning
heartbeatEvery ≤3s of provider idleLiveness check; data: is empty. Workers should ignore.
replay-gapFirst frame on reconnect if the ring buffer has evicted events with id ≤ sinceWorker missed events [missing_from..missing_to] — state may be inconsistent. Payload: {execution_id, type:"replay-gap", missing_from, missing_to}.
events-droppedPer-connection queue cap (500) was exceededA burst of events was dropped from THIS connection (not the bus). Payload: {execution_id, type:"events-dropped", dropped_count, reason}. Reconnect with ?since=<last_id> to re-read what was dropped (subject to the bus ring-buffer window).

Status-code map and error envelope:

HTTPBodyCondition
200SSE stream beginsStream attached, events follow
400A4 envelopeMissing or malformed execution_id
401(auth layer)No session / token
403(perm layer)RBAC Execution:Read denied
404A4 envelopeExecution does not exist, or exists but is not visible within the caller’s management-group scope — deliberately indistinguishable (no existence oracle, #1634)
410A4 envelopeExecution already terminal
429A4 envelope with retry_after_ms:5000Shared held-open-stream budget exhausted (ADR-0034). Remediation text: close an existing /api/v1/events stream, or raise --max-sse-streams
503A4 envelope with retry_after_ms:5000Tracker or event bus not initialised (server warmup window)

This endpoint holds an HTTP worker thread open for the life of the subscription, so it leases from the same --max-sse-streams budget as GET /mcp/v1/, the dashboard executions drawer, MCP streamed POST, and the legacy /events stream (ADR-0034). A cap hit rejects the new stream with 429 — a live stream is never evicted to make room.

A4 envelope shape:

{
  "error": {
    "code": 503,
    "message": "event bus unavailable",
    "correlation_id": "req-184c8a9012-7",
    "retry_after_ms": 5000,
    "remediation": "retry after server warmup; live events are unavailable until the bus is initialised"
  },
  "meta": {"api_version": "v1"}
}

Response headers (always set on 200 and on most error responses):

HeaderValuePurpose
X-Correlation-Idreq-<hex-ms>-<hex-seq>Grep token tying the response to the audit row and server-side spdlog rows. Echoed inside the A4 envelope on errors.
Cache-Controlno-cachePrevents proxy / browser cache buffering.
X-Accel-BufferingnoTells nginx and similar proxies not to buffer the SSE stream.
X-Content-Type-OptionsnosniffBelt-and-braces against MIME sniffing.
Sec-Audit-Failedtrue (only when audit persist failed)SOC 2 CC6.6 evidence contract: subscription proceeded even though the audit row failed to persist (matches the PR #883 / W1.1 partial-failure pattern).

Audit: every successful subscribe emits one api.v1.events.subscribe audit event (separate verb from the dashboard sibling’s execution.live_subscribe so SIEM filters can distinguish browser vs agentic consumers). A subscribe request against an execution that is genuinely nonexistent OR merely out of the caller’s scope (the 404 case above) also emits a result=denied row on the same action, using non-distinguishing detail text — the two cases must remain audit-indistinguishable, matching the response HTTP status. Per-session-per-execution dedup is not currently implemented (Deferred-5 / #700); a worker reconnecting frequently generates one row per reconnect.

Restart behaviour: the bus is in-process and in-memory. On server restart, every Last-Event-ID is invalidated — replays against an event id assigned by a previous process instance return nothing even if the execution is still active. Workers should fall back to GET /api/v1/executions/<id> to recover terminal state after a 503 or a long disconnect.

Example (curl):

curl -N \
  -H "Authorization: Bearer $YUZU_TOKEN" \
  -H "Accept: text/event-stream" \
  "https://yuzu.example.com/api/v1/events?execution_id=exec-abc123"

Example output (running execution, A3 envelopes):

id: 1
event: agent-transition
data: {"execution_id":"exec-abc123","event_id":1,"timestamp_ms":1716000000000,"type":"agent-transition","payload":{"agent_id":"a-1","status":"running"}}

id: 2
event: agent-transition
data: {"execution_id":"exec-abc123","event_id":2,"timestamp_ms":1716000002034,"type":"agent-transition","payload":{"agent_id":"a-1","status":"success","exit_code":0,"duration_ms":4218}}

event: heartbeat
data:

id: 3
event: execution-progress
data: {"execution_id":"exec-abc123","event_id":3,"timestamp_ms":1716000005112,"type":"execution-progress","payload":{"total":2,"succeeded":1,"failed":0,"running":1,"pending":0}}

id: 4
event: execution-completed
data: {"execution_id":"exec-abc123","event_id":4,"timestamp_ms":1716000010301,"type":"execution-completed","payload":{"status":"succeeded"}}

Example output (late reconnect — replay-gap envelope):

id: 41
event: replay-gap
data: {"execution_id":"exec-abc123","type":"replay-gap","missing_from":41,"missing_to":118}

id: 119
event: agent-transition
data: {"execution_id":"exec-abc123","event_id":119,"timestamp_ms":...,"type":"agent-transition","payload":{...}}

Known limitations (sprint W5.1 skeleton; tracked as follow-ups):


Dashboard Echo

The Echo routes were named tar before 2026-10 (/api/v1/tar/..., /api/dashboard/tar-execute). There is no alias for the old paths; see the Echo section of Upgrading.

POST /api/dashboard/echo-execute

Execute an Echo warehouse SQL query against targeted agents. Returns HTMX HTML fragments: a thead OOB swap with dynamic column headers and sets up an SSE stream for result rows.

Permission: Execution:Execute. A browser (session-cookie) caller must also send a same-site Origin or Referer naming the console; one without either, or from another site, is answered 403 and audited as csrf.denied before anything is parsed or dispatched. Token callers are exempt. The same rule applies to POST /api/dashboard/execute.

Request body (form-encoded):

ParameterTypeRequiredDescription
sqlstringYesA SELECT query using $-prefixed table names (e.g., $Process_Live, $TCP_Hourly).
scopestringNoTarget scope. An agent ID, group:<expression>, or __all__ for all agents. Defaults to all connected agents.

Example (curl):

curl -X POST https://yuzu.example.com/api/dashboard/echo-execute \
  -H "Cookie: yuzu_session=abc123" \
  -H "Origin: https://yuzu.example.com" \
  -d "sql=SELECT name, pid FROM \$Process_Live LIMIT 10" \
  -d "scope=__all__"

Response: HTMX HTML fragments. The response contains:

  1. A <thead> element with hx-swap-oob="innerHTML:#echo-results-head" containing dynamic column headers derived from the query result schema.
  2. An SSE stream setup that delivers result rows as <tr> fragments.

Safety controls:

GET /echo

Render the Echo dashboard page. Requires an authenticated session (302 redirect to /home otherwise). The page hosts the retention-paused source list, the capture sources frame and the process tree viewer (a per-host process tree reconstructed from the device’s local Echo warehouse; see docs/user-manual/echo.md). The Ad-hoc Echo SQL form is no longer on this page: a short note links to the Result sets page, where From Echo query takes the same parent scope, SQL, name and include-empty fields.

Permission: Session-only (the page itself; embedded fragment endpoints carry their own permission gates — see below).

GET /fragments/echo/retention-paused

Render an HTML table fragment of the calling operator’s most-recent Echo retention scan. Empty-state placeholders distinguish “no scan yet” from “scan returned no paused sources.” Scope caveat (ADR-0017): this list gates on the global Infrastructure:Read permission — management-group confinement of this list/fan-out view is not yet effective (a confined operator is denied at the global gate rather than shown a narrowed list); when RBAC is disabled the list is the full enrolled fleet. Per-device confinement remains genuine only on the per-device reenable endpoint below.

Permission: Infrastructure:Read.

Request: no parameters.

Response: HTML fragment.

POST /fragments/echo/retention-paused/scan

Dispatch an echo.status command to the operator’s visible-agent set and record the new command_id in the per-username scan slot. The next GET /fragments/echo/retention-paused picks up the responses.

Permission: Execution:Execute. Reading the resulting list still requires only Infrastructure:Read, but dispatching a command to the fleet is an Execute action.

Request: no parameters. The dispatch gates on the global Execution:Execute permission; management-group confinement of the dispatch target is not yet effective (ADR-0017 — a confined operator is denied at the global gate, not narrowed). When RBAC is disabled, the target is the full enrolled fleet.

Response: HTML fragment showing the dispatched-agent count or an empty-state placeholder.

Audit: Emits echo.status.scan with detail dispatched to <N> agent(s) in scope.

POST /fragments/echo/retention-paused/reenable

Dispatch a single-device echo.configure with <source>_enabled=true. Per-source independence is preserved — re-enabling process does not affect tcp / service / user.

Permission: Execution:Execute. Per-device RBAC visibility is verified before dispatch; out-of-scope device_id values collapse to the same 404 response as not-connected agents (no enumeration oracle).

Request body (form-encoded):

ParameterTypeRequiredDescription
device_idstringYesThe agent ID. Must be in the operator’s visible-agent set; otherwise rejected with 404 (same body as not-connected).
sourcestringYesOne of process, tcp, service, user. Other values rejected with 400 to prevent forged form submissions. The retention-paused list covers the four listed sources; the other sources (arp, dns, procperf, netqual, module) are enabled/disabled via the Capture sources frame (below), not this route.

Response:

Audit: Emits echo.source.reenable with result=success and detail carrying device=<id> source=<src> on success, or result=failure with the real rejection reason on rejected attempts.

POST /fragments/echo/retention-paused/purge

Dispatch a single-device echo.purge_source that permanently drops every warehouse row for a source (<source>_{live,hourly,daily,monthly}) while leaving the collector paused — the “we have what we need; drop the rows but keep collection off” case (Phase 15.A). This is a destructive, irreversible operation.

Permission: Infrastructure:Delete (a higher tier than re-enable’s Execution:Execute). Per-device RBAC visibility is verified before dispatch; out-of-scope device_id values collapse to the same 404 response as not-connected agents (no enumeration oracle).

Confirmation: the dashboard requires the operator to type the device hostname before this POST fires (a native prompt(), CSP-safe). The confirmation is client-side — scripted callers that POST directly are still gated by the permission, the per-device visibility check, and the agent-side paused-guard.

Paused-guard (authoritative, agent-side): the agent refuses the purge (source_not_paused) unless the source currently reports disabled, and refuses (config_unreadable) when the enable flag cannot be read so it cannot confirm that; nothing is purged in either case. This closes the scan→purge TOCTOU (a source re-enabled between the operator’s scan and the purge is not dropped). The server does not enforce a SOURCE_NOT_PAUSED check — the retention-paused list is an ephemeral live scan with no persisted paused-state.

Request body (form-encoded):

ParameterTypeRequiredDescription
device_idstringYesThe agent ID. Must be in the operator’s visible-agent set; otherwise rejected with 404 (same body as not-connected).
sourcestringYesOne of process, tcp, service, user. Other values rejected with 400 to prevent forged form submissions.

Response:

Audit: Emits echo.source.purge with result=success and detail carrying device=<id> source=<src> on success (rbac_denied on the permission gate; result=failure with scope_violation/agent_not_connected on rejected attempts). Dispatch is fire-and-forget: rows_deleted is computed agent-side and appears in the command’s response record (keyed by command_id), not in this dispatch audit row.

Metric: yuzu_echo_source_purge_total{result} (counter) — counts dispatch outcomes.

Agent version: requires an agent that implements the echo.purge_source action (v0.14.0+). An older agent returns error|unknown action: purge_source in its response record (no crash); because dispatch is fire-and-forget the dashboard still shows “Purge dispatched” — verify the outcome via Scan.

POST /api/v1/echo/retention-paused/purge

The agentic-first (A1) structured surface for the same destructive purge — for automation / MCP workers / scripted callers that need a JSON contract and a command_id rather than the HTML fragment above. Same effect: permanently drop a paused source’s warehouse rows on one device, leaving the collector paused. Irreversible.

Permission: Infrastructure:Delete, enforced per-device and management-group-scoped (the scoped gate; an out-of-scope or unauthorized device is refused, not enumerated). Fail-closed if the scope gate is unwired.

Request body (JSON):

FieldTypeRequiredDescription
device_idstringYesThe target agent. Must be within the caller’s management scope.
sourcestringYesOne of process, tcp, service, user. Others → 400.

Responses:

Audit: echo.source.purge result=requested is written before dispatch (fail-closed). Metric: yuzu_echo_source_purge_total{result}. Agent version: same echo.purge_source (v0.14.0+) requirement as the fragment.

GET /api/v1/echo/process-tree

API-parity read twin (#4027). The agentic-first (A1) structured surface for the process-tree viewer’s device picker — JSON twin of the operator-scoped device list GET /fragments/echo/process-tree renders into its host-picker <select>.

Permission: Infrastructure:Read. A service-scoped API token is denied outright (403, echo.device_picker.view denied) — the fleet-wide device-enumeration guard shared with GET /api/v1/echo/capture-sources below, since neither route names a single agent_id to confine a service-scoped token’s own tag against.

Request: no parameters.

Response: {"data":{"devices":[{"agent_id","hostname","os","arch","agent_version","online"}]},"meta":{"api_version":"v1"}}. Every row carries online, which is always true today — the sole wired provider sources this list from the live-session registry, so a disconnected enrolled device is not included (#4027 fix round, CDX-P1-02/K1 — an earlier revision of this doc claimed offline-inclusive; that was aspirational, not the shipped behavior). Unlike the HTML picker, this list does not additionally hide any row by online status — a future offline-inclusive provider (tracked follow-up) would make that distinction meaningful.

Audit: none on success — a device identity/online list, not per-device behavioral content (matches the fragment’s own today-unaudited posture).

GET /api/v1/echo/capture-sources

API-parity read twin (#4027). Same shape and gates as GET /api/v1/echo/process-tree above — JSON twin of the operator-scoped device list GET /fragments/echo/capture-sources renders into its host picker (ADR-0015).

Permission: Infrastructure:Read. Same service-scoped-token 403 as the process-tree twin.

Response: {"data":{"devices":[{"agent_id","hostname","os","arch","agent_version","online"}]},"meta":{"api_version":"v1"}}.

GET /api/v1/echo/retention-paused

API-parity read twin (#4027). JSON twin of GET /fragments/echo/retention-paused: the calling operator’s most recent echo.status scan (per-username state), filtered to their visible agents, one row per (agent, paused source).

Permission: Infrastructure:Read.

Request: no parameters.

Response: {"data":{"scan_id","scan_count","scan_at","agents_responded","agents_with_no_paused_sources","agents_filtered_out_of_scope","store_degraded","result_truncated_by_cap","rows":[{"agent_id","agent_display","source","paused_at","live_rows","oldest_ts","value_error","enabled_raw"}]},"meta":{"api_version":"v1"}}. result_truncated_by_cap (#4644) is true when the scan’s response read hit the 10,000-row or 50 MiB payload cap, so rows and the counters are partial: dropped responses read as agents that never answered, i.e. as “collecting normally”. It is distinct from store_degraded (the read failed outright), but a cut scan ALSO reports store_degraded: true so a client that predates result_truncated_by_cap still sees an incomplete result; test result_truncated_by_cap first to tell a cut from a failed read. A client that retries on store_degraded should not retry a cut scan: the read is capped the same way each time, so only a narrower scan (a smaller management group) changes the result, whereas a failed read may succeed on retry. scan_id is "" when the operator has not dispatched a scan yet — POST /fragments/echo/retention-paused/scan is dashboard-only today (a mutating dispatch route, out of scope for #4027).

Headers: Cache-Control: no-store, private + Vary: Cookie — per-operator-scoped data, same UP-11 posture as the fragment.

Audit: none on success — scan/config metadata, not per-device behavioral content (matches the fragment’s own today-unaudited posture).

Not twinned (#4027 exception, recorded in scripts/ci/api-parity/echo.json): GET /fragments/echo/process-tree/result and GET /fragments/echo/process-tree/detail are deliberately not given REST/MCP twins by #4027 — deferred as scope, not impossibility. /detail’s cache token is a CSPRNG value minted and principal-bound only inside the /result handler itself; no other path mints one, so a /detail twin genuinely has no usable input today. /result’s pcmd/tcmd command-id pair is an ordinary echo sql dispatch result (Infrastructure:Read, no execute gate) and could in principle be obtained by an operator through the already-twinned generic dispatch surface (execute_instruction / POST /api/command) by reproducing the two canned $Process_Live/$TCP_Live queries verbatim — but that is not a dedicated API path, and a real /result twin would still need a new async “not ready yet” polling contract (REST/MCP have no htmx auto-reissue equivalent). Both routes are otherwise reachable today only via the dashboard-only GET /fragments/echo/process-tree/run route, itself excluded (a dispatch-shaped GET batched with a later dispatch-twin effort, #3994). Revisit once that later batch ships /run (and /result) twins that mint these artifacts directly over the API.

GET /fragments/echo/capture-sources

Render the Capture sources frame body for /echo (ADR-0015): an operator-scoped device picker plus a target the per-source toggle table loads into on host change.

Permission: Infrastructure:Read.

GET /fragments/echo/capture-sources/load

Self-re-issuing poll that dispatches echo.status + echo.compatibility to one selected device and renders the per-source toggle table (enable state, live-row count, per-OS support, category). First call dispatches and returns a polling fragment carrying the two command IDs; subsequent calls (scmd/ccmd/n params) poll until both complete.

Permission: Infrastructure:Read (scoped to the device) + Execution:Execute (the read dispatches a live query). Audit: echo.sources.read at dispatch.

Query params: device (agent ID); scmd/ccmd/n (poll continuation, server-supplied).

POST /fragments/echo/capture-sources/push

Apply staged capture-source enable/disable changes. The dashboard stages toggles client-side; Push dispatches one echo.configure <source>_enabled=<bool> per changed source (the accidental-enable guardrail — nothing dispatches until Push).

Permission: Infrastructure:Read (scoped) + Execution:Execute. Source names are validated against a fixed allowlist; values constrained to on/off.

Request body (form-encoded): device (agent ID); changes (comma-separated source=on|off, e.g. arp=on,dns=on).

Audit: echo.sources.configure — one row per changed source, detail = <source>_enabled=<bool> command_id=<id>.

GET /fragments/echo/process-tree/device-net

Self-re-issuing poll that dispatches read-only echo.sql over $DNS_Live + $ARP_Live to the selected host and renders the device-level DNS-cache and ARP-table panels embedded in the process-tree pane (ADR-0015). Device-level, not per-process (the DNS cache carries no pid).

Permission: Infrastructure:Read (scoped) + Execution:Execute. Audit: echo.dns.read and echo.arp.read (distinct verbs — DNS is usage-class PII, separately countable) at dispatch.

Query params: device; dcmd/acmd/n (poll continuation, server-supplied).


Product Packs

Product packs are bundles of InstructionDefinition, PolicyFragment, Policy, and other content shipped as a single signed multi-document YAML file. Pack signature enforcement is on by default (#802 / W7.4) — unsigned packs are rejected at install. See server-admin.md for the --allow-unsigned-packs escape hatch and upgrading.md for the pack-signing migration recipe.

POST /api/product-packs

Install a product pack from a multi-document YAML bundle.

Permission: ProductPack:Write.

Request body (form-encoded or multipart):

ParameterTypeRequiredDescription
yaml_bundlestringYesMulti-document YAML; each ----separated document is parsed for its kind: and delegated to the matching store (InstructionDefinition → instructions, PolicyFragment → policies, etc.). A ProductPack document carries the pack metadata (name, version, description, optional signature + publicKey).

Optional request header (#3481): Idempotency-Key (max 200 characters). A retry carrying the same key and a yaml_bundle identical after sanitization (the same sanitize-on-write step every free-text column goes through — invalid UTF-8/embedded NULs are normalized before comparison, so this is not a literal byte-for-byte check) returns 201 with the original pack id — no sibling-store install delegation runs again. The same key reused with a different yaml_bundle is rejected as 400 (see below). The key is global (not scoped per-caller/principal), so use a value unlikely to collide across callers (a UUID, for example). Omitting the header preserves prior behavior exactly: every call mints a fresh pack id with no dedup. A narrow race remains between two concurrent installs sharing the same not-yet-used key: both can pass the pre-check before either commits, in which case the loser hits the partial unique index as an ordinary persist failure and is compensated via the same path as any other late failure. A client retry then converges — to the winner’s id if the retried body matches the winner’s, or to the 400 idempotency-conflict error below if it differs (the loser’s original body, not the winner’s, is what gets retried in practice).

Response:

Audit: Emits product_pack.install with result=success and target_id=<pack-id> on an accepted install OR an idempotent replay, or result=denied with target_type=ProductPack, empty target_id, and the rejection message in detail on any rejection that reaches ProductPackStore::install() — this covers every 400/503 above except an oversized Idempotency-Key header, which is rejected before install() is ever called and is not audited, the same pre-existing convention this endpoint uses for a malformed request body or an unavailable store (see audit-log.md). On a partial success (#3479), the success row’s detail additionally carries "<failed>/<total> item(s) failed to install" — an auditor doesn’t need to re-fetch the response body to know a partial failure occurred, only its count; the specific per-item reasons live in the response body only.

GET /api/product-packs

List installed packs.

Permission: ProductPack:Read.

Response:

GET /api/product-packs/:id

Get a single pack with its items.

Permission: ProductPack:Read.

Response:

DELETE /api/product-packs/:id

Uninstall a pack, removing all delegated items.

Permission: ProductPack:Delete.

Response:

Audit: Emits product_pack.uninstall with result=success and target_id=<pack-id> on success, or result=denied with target_type=ProductPack, target_id=<pack-id>, and the rejection message in detail on any 404/503 rejection (ADR-0054 — pre-migration, a rejected uninstall was not audited at all).


MCP (Model Context Protocol)

The MCP endpoint enables AI models and automation tools to interact with Yuzu via JSON-RPC 2.0. Authentication is via Bearer token with an MCP tier. See Authentication > MCP Tokens for token creation details.

GET /mcp/v1/

The MCP Streamable HTTP SSE channel — the server→client half of a session minted by initialize. Carries heartbeats, replayed frames on reconnect, and notifications/progress frames for any POST /mcp/v1/ call on this session that requested progress tracking and isn’t being delivered as a streamed POST instead (see below).

Permission: the same credential as POST /mcp/v1/, plus the session’s Mcp-Session-Id header. The credential is re-checked once per tick (~3 s), whether or not a heartbeat frame is emitted, so revoking it ends a live stream, not just future ones.

Required headers: Mcp-Session-Id (from initialize), Accept: text/event-stream. Optional: Last-Event-ID to resume.

Status codes:

CodeMeaning
200SSE stream (text/event-stream, chunked, held open)
400Mcp-Session-Id absent (-32600)
404Session unknown, expired, another principal’s, or the Last-Event-ID cursor is outside the replay window (-32007) — re-initialize
403Origin not in --mcp-allowed-origin (-32008)
405Streaming disabled (--mcp-no-streaming)
406Accept did not include text/event-stream (-32011)
429Concurrent-stream cap, or a superseded stream on this session is still closing (-32012; honour retry_after_ms)
curl -N -H "Authorization: Bearer $TOKEN" \
     -H "Mcp-Session-Id: $SID" \
     -H "Accept: text/event-stream" \
     https://yuzu.example.com/mcp/v1/

Every error body carries the A4 envelope (correlation_id, nullable retry_after_ms, remediation). Streams end with a JSON-RPC notifications/yuzu.stream_closed notification (a normal message on the stream, not a bespoke event: stream-closed frame) whose params name the reason in that same A4 shape.

DELETE /mcp/v1/

Ends the session named by Mcp-Session-Id (and closes its stream). 200 on success; 404 if the session is unknown or belongs to another principal; 400 if the header is absent.

POST /mcp/v1/

JSON-RPC 2.0 endpoint for MCP tool calls, resource reads, and prompt requests.

Permission: Bearer token with mcp_tier set (readonly, operator, or supervised). The tier determines which tools are accessible, enforced before RBAC.

Request body (tool call):

{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "list_agents",
    "arguments": {}
  },
  "id": 1
}

Response (success):

{
  "jsonrpc": "2.0",
  "result": {
    "content": [
      {
        "type": "text",
        "text": "[{\"agent_id\":\"abc-123\",\"hostname\":\"workstation-01\",\"os\":\"windows\",\"status\":\"online\"}]"
      }
    ]
  },
  "id": 1
}

Response (error):

{
  "jsonrpc": "2.0",
  "error": {
    "code": -32001,
    "message": "MCP is disabled on this server"
  },
  "id": 1
}

Available methods:

MethodDescription
tools/listList available MCP tools for the current tier
tools/callInvoke an MCP tool by name with arguments
resources/listList available MCP resources
resources/templates/listList available MCP resource templates
resources/readRead an MCP resource by URI, or a resource template with a parameter substituted
prompts/listList available MCP prompts
prompts/getGet a prompt template by name

Phase 1 tools (22 read-only):

ToolDescription
list_agentsList connected agents with status
get_agent_detailsDetailed info for a specific agent
query_audit_logSearch audit events with filters
list_definitionsList instruction definitions
get_definitionGet a specific instruction definition
query_responsesCollect responses by execution_id (one dispatch) or instruction_id
aggregate_responsesAggregate response data
query_inventoryQuery inventory data with filters
list_inventory_tablesList available inventory tables
get_agent_inventoryGet inventory for a specific agent
get_tagsGet tags for a device
search_agents_by_tagFind agents matching tag criteria
list_policiesList all policies
get_compliance_summaryCompliance summary for a device
get_fleet_complianceFleet-wide compliance overview
list_management_groupsList management groups
get_execution_statusStatus of a specific execution
list_executionsList recent executions
list_schedulesList instruction schedules
validate_scopeValidate a scope expression
preview_scope_targetsPreview which agents match a scope
list_pending_approvalsList pending approval requests
get_pending_approval_countCount pending approval requests

query_inventory, list_inventory_tables, and get_agent_inventory read the Postgres-backed generic InventoryStore (ADR-0037) and now return a JSON-RPC internal-error response (code -32603) when the store is unavailable or degraded (previously a silent empty result).

Additional read-only tools (post-Phase-1). Beyond the Phase 1 set the server also exposes the DEX signal/perf, network, and inventory read tools. The application-performance-over-time tools (this release) are:

ToolDescription
list_dex_perf_appsApplications with retained fleet app-perf data (the picker)
get_dex_app_perfFleet trend for one application, by version, over time
list_dex_app_perf_devicesThe version-row “which devices” drill — the MCP twin of GET /api/v1/dex/perf/app/devices. Required app AND version (version is present-required, unlike get_dex_app_perf’s “omit = all versions”; pass "" for the unknown-version bucket). Unlike its aggregate siblings in this table, each row names an agent_id, so it gates on require_fleet_read (ADR-0017) instead of a bare permission check, confining a management-group/service-scoped caller to their visible devices with the filter pushed into the store query. Emits the dedicated dex.app_perf.devices.view audit verb (set-and-proceed, audit_persisted:false on a dropped row) plus the generic mcp.list_dex_app_perf_devices call audit. truncated:true means only the highest-CPU devices are returned.
get_dex_group_app_perfOne management group’s app trend (the cohort floor is one device, so only an empty point is suppressed). A service-scoped API token is denied outright (kPermissionDenied) — found by this branch’s own governance review (PR #3156); the same DIFFERENT-axis gap as its REST twin GET /api/v1/dex/perf/group. This tool’s own interim deny call was retired as provably dead code (guardian-confinement-2298 PR 3 “the flip”, #3290 Phase 2 bucket 1a): the shared perm_fn (AuthRoutes::require_permission) already denies any service-scoped token outright for (GuaranteedState, Read) before this tool-specific branch is reached, since the service-scope global-safe allow-list is seeded empty. That denial is audited under the generic auth.permission_required verb, not dex.perf.group.view (REST’s twin route denies BEFORE perm_fn via its own deny_fleet_wide_service_scoped call, so REST’s deny IS recorded under dex.perf.group.view).
get_dex_tag_app_perfThe device-model tag-value cohort twin of get_dex_group_app_perf — the MCP twin of GET /api/v1/dex/perf/tag. Required value and app; optional key (default "model", pattern ^[A-Za-z0-9_.:-]{1,64}$) and version. An absent key defaults; a PRESENT key (including "") is validated like REST’s has_param, so key="" 400s identically on both transports rather than MCP silently substituting the default. Same one-device floor, same service-scoped denial (kPermissionDenied) and the same retired-interim-deny-call / generic-auth.permission_required-verb posture as get_dex_group_app_perf above — not dex.perf.tag.view (REST’s twin denies before perm_fn and IS recorded under that dedicated verb).
compare_app_perf_versionsCohort-paired before/after comparison (the /auto VERIFY stage). Parameters group, app, baseline, candidate (all required) + window (integer days, default 7). Returns the same identity-free aggregate shape as GET /api/v1/dex/perf/compare. A successful call is recorded under the generic mcp.compare_app_perf_versions tool-call audit (not the REST dex.app_perf.compare verb) — but a service-scoped API token is denied outright (kPermissionDenied, found by this branch’s own governance review, PR #3156) and that denial IS recorded under dex.app_perf.compare, matching its REST twin’s deny-path verb rather than the generic one.
get_dex_device_score (#4035)Per-device DEX read model — the MCP twin of GET /api/v1/dex/devices/{id}. Score (-1 = no store configured) + this device’s own signal summary. Ancestor-aware SCOPED GuaranteedState:Read gate (like query_software_licenses); every call emits dex.device.view (set-and-proceed, audit_persisted:false on a dropped row — MCP has no Sec-Audit-Failed header, unlike the REST twin’s fail-closed 503). #4855: a DEGRADED signal-summary read (store closed / pool-acquire timeout / query error) returns a retryable kInternalError (retry_after_ms) instead of a result — never conflate this with the -1 “no store” case above; the dex.device.view behavioral-PII audit row still records success (the access happened), while the separate mcp.get_dex_device_score tool-invocation audit flips to failure.
get_dex_device_app_perf (#4035)Per-device retained daily app-perf series — the MCP twin of GET /api/v1/dex/devices/{id}/app-perf (closes the gap this section previously documented as “no MCP twin”). Optional app narrows to one app. Same SCOPED gate + dex.device.app_perf.view audit posture as get_dex_device_score.
get_dex_app (#4035)App blast-radius drill — the MCP twin of GET /api/v1/dex/app. Required name, optional window. devices[] is a fleet-wide aggregate with no per-caller confinement (WS-A4 PR-1 Gate 7 fix round — an earlier per-caller resolver was retired; see the REST twin’s own doc for why). deny_fleet_wide_service_scoped (the service-scoped-token axis) + fail-closed try_persist_audit under dex.app.view (audit_persisted:false on a dropped row), matching the REST twin’s posture even though the dashboard fragment only audits the denial.
list_dex_apps (#4035)App-centric stability list — the MCP twin of GET /api/v1/dex/apps: per-version rows, Windows-scoped counts, apps_total/apps_truncated and versions_truncated, share/rate/score over the Windows devices online now (distinct_devices_online is the share’s numerator), the score withheld as offline_affected while any affected device is offline, and a retryable error on a degraded read or one over the four-at-once read budget. No per-agent identity; no domain audit verb (the generic tool-call row is still written).
get_dex_catalogue (ADR-0031 WS-A4 PR-1)Signal catalogue family cards (Catalogue View 1) — the MCP twin of GET /api/v1/dex/catalogue. Optional os/window. No per-agent identity — not audited on success. A degraded fleet signal-summary read returns a retryable kInternalError (never a fabricated healthy, zero-event catalogue — WS-A4 PR-1 fix round, mirrors get_dex_device_score’s own #4855 degrade posture).
get_dex_catalogue_group (#4035)Signal-family drill (Catalogue View 2) — the MCP twin of GET /api/v1/dex/catalogue/group. Required name, optional os/window. An unknown family name returns kInvalidParams. benign (true for the one family whose window activity is routine reports, not incidents). Not audited (no per-agent identity).
get_dex_device_history (#4035)Per-device raw signal history — the MCP twin of GET /api/v1/dex/devices/{id}/history; the distinct signal-history capability from get_dex_device_score above (same device, different data). Ancestor-aware SCOPED gate, same shape as get_dex_device_score. Emits dex.device.view — the SAME verb get_dex_device_score uses (both the fragment and this pair deliberately reuse one verb across the two distinct per-device capabilities).
get_dex_observation (#4035)Single-observation detail — the MCP twin of GET /api/v1/dex/devices/{id}/observations/{event_id}. Required agent_id/event_id. A foreign or guessed event_id returns kInvalidParams — same no-oracle contract as the REST 404 (indistinguishable from a genuinely-absent event). Ancestor-aware SCOPED gate; emits dex.observation.view.
get_dex_health (#4035)Derived composite health score — the MCP twin of GET /api/v1/dex/health. Optional weighting/window. Not audited (no per-agent identity).
get_dex_trends (#4035)Cross-OS + per-family day-by-day trend — the MCP twin of GET /api/v1/dex/trends. Optional window. Not audited (no per-agent identity).
get_dex_overview (#4035)Fleet DEX overview — the MCP twin of GET /api/v1/dex/overview. Optional window. top_devices[] is a fleet-wide aggregate with no per-caller confinement (same retired-resolver history as get_dex_app above). deny_fleet_wide_service_scoped (the service-scoped-token axis) + fail-closed try_persist_audit under dex.overview.view, same “REST/MCP add more rigor than the fragment” rationale as get_dex_app above. ADR-0031 WS-A4 PR-1 additive fields: connected_platforms, busiest_family (null when nothing fired in-window), busiest_family_events.

The first three gate GuaranteedState:Read and are not audited (cohort posture) on success; get_dex_group_app_perf’s REST twin is audited on a service-scoped-token deny under the dedicated verb, but the MCP tool itself is not — its deny is recorded under the generic auth.permission_required verb instead (see above). compare_app_perf_versions also gates GuaranteedState:Read; because it has no cohort floor it is accountable — but over MCP that accountability is the generic mcp.<tool> tool-call audit, and the tool exposes only the identity-free aggregate (no per-machine drill — that is dashboard-only, see GET /api/v1/dex/perf/compare). The per-device app-perf drill (GET /api/v1/dex/devices/{id}/app-perf) is reachable via REST, the /device dashboard DEX drill (the Application performance over time panel), and now MCP (get_dex_device_app_perf, #4035) — the REST route stays fail-closed (503 on a dropped audit row), while the MCP twin follows the set-and-proceed / audit_persisted:false posture every other DEX MCP read tool uses (different surface, different posture — see docs/api-twin-recipe.md §4). See the Application performance over time REST section above for the shared percentile/suppression semantics.

Resources:

URIDescription
yuzu://server/healthServer health status
yuzu://compliance/fleetFleet-wide compliance data
yuzu://audit/recentRecent audit events (AuditLog:Read; admin-only while RBAC is disabled)

Prompts:

NameDescription
fleet_overviewGenerate a fleet status overview
investigate_agentInvestigate a specific agent
compliance_reportGenerate a compliance report
audit_investigationInvestigate audit trail activity

Server-side controls:

CLI FlagEffect
--mcp-disableReject all /mcp/v1/ requests
--mcp-read-onlyAllow only read-only tools regardless of token tier

Streamed responses (progress tracking). A tools/call for execute_instruction carrying _meta.progressToken (see docs/user-manual/mcp.md’s progressToken definition for the type/length contract; an out-of-spec value is silently treated as absent — no error, no progress) opts into progress tracking — this requires an active session: a non-empty Mcp-Session-Id header from a prior initialize. A Mcp-Session-Id that is present but invalid (unknown, expired, or another principal’s) fails the entire call with 404/-32007 before progress tracking is even considered — that is a property of every non-initialize method on this endpoint, not specific to progress tracking, and is covered above under GET /mcp/v1/. The table below assumes a valid session or none at all; where progress is tracked, delivery depends on the request’s Accept header and whether the server has --mcp-enable-streamed-post enabled (on by default; opt out with --no-mcp-streamed-post):

_meta.progressTokenMcp-Session-IdAccept: text/event-streamServer answers
presentsent, validpresent, streamed POST enabledthis POST response held open as an SSE stream — notifications/progress frames, then the JSON-RPC result last, then EOF
presentsent, validabsent, or streamed POST disabledplain JSON now; progress frames go to the session’s GET /mcp/v1/ stream instead
presentnot sentanyplain JSON, byte-identical to a call with no progress tracking
absentanyanyplain JSON, byte-identical to a call with no progress tracking

A streamed POST’s response headers are Content-Type: text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no (nginx only — Envoy/HAProxy/ALB/ Cloudflare need their own response-buffering opt-out, or the proxy will buffer the whole stream and the server cannot detect that the client has gone), X-Correlation-Id, and X-Content-Type-Options: nosniff. Capacity denials reuse -32012 / HTTP 429 — the same code and retry_after_ms/Retry-After A4 shape as the GET channel above, but a longer fixed value (30s vs. the GET channel’s 5s: none of these causes is likely to clear within a second or two) and a distinct set of causes (a shared cross-surface budget, this principal’s own streamed-call allowance, a server-wide capacity ceiling, or this session’s own slots). A duplicate request id or an unknown session get their own codes (409/-32600, 404/-32007) instead — see docs/user-manual/mcp.md “-32012: Stream limit reached” for the full cause-by-cause remediation, and docs/mcp-server.md “Streamed POST — SSE on the response” for the admission/refusal table, close reasons, and resume/recovery rules. A denial caused by the server disabling/shutting down streaming, or an allocation failure, degrades silently to the plain (non-streamed) response instead of erroring — see the same admission table for which causes degrade vs. answer. Every session open, close, and denial is recorded in the audit log under mcp.session.open / mcp.session.close / mcp.session.reject (target_type = McpSession) — see docs/user-manual/mcp.md “Audit”. Progress is best-effort regardless of delivery mode: admission (the same reserve() call this table describes) runs whether or not the request is a streamed POST, but only the streamed-POST arm answers a capacity rejection with an explicit 429 — the identical causes silently degrade to a plain response, with no progress delivered anywhere and no error surfaced, when progress is being delivered on the GET channel instead (streamed POST not requested, or not enabled). A caller must therefore still be prepared to poll (query_responses / get_execution_status) regardless of which delivery mode it used.


Authentication Endpoints

These endpoints manage user sessions and SSO flows.

POST /login

Authenticate with username and password.

Permission: None (unauthenticated).

Request body (form-encoded):

username=admin&password=secretpass

Responses:

StatusConditionBody
200 + Set-Cookie: yuzu_session=…Credentials valid; user has no MFA enrolled and no enforcement applies{"status":"ok"}
202 (mfa_required)Credentials valid; user has TOTP MFA enrolled{"status":"mfa_required","mfa_pending_token":"<opaque>","expires_in":120} — complete the challenge by posting the pending token + TOTP code (or recovery code) to POST /login/mfa
202 (mfa_enrollment_required)Credentials valid; user is un-enrolled and --mfa-enforcement (admin-only for admins / required for all) requires MFA{"status":"mfa_enrollment_required","mfa_pending_token":"<opaque>","otpauth_uri":"otpauth://...","secret_base32":"...","qr_svg":"<inline SVG, or empty>","expires_in":120} — show the QR/secret and complete enrollment via POST /login/mfa/enroll
401Invalid credentials — also a locked account, a password over 1024 bytes (rejected without hashing; no setting site accepts one), and an enforced user’s enrolment bootstrap whose password was changed or reset while the provisional secret was being issued (#5342 — nothing revealed, no pending token; audited mfa.enroll.failed detail credential_changed, not auth.login_failed, and no lockout strike){"error":{"code":401,"message":"Invalid username or password"}}
503 + Retry-After: 2The password could not be verified (#5342, #5274): reading the account from the auth store failed, or its stored password changed while it was being checked (a concurrent change or reset). Nothing was guessed, so no auth.login_failed row is written and nothing counts toward lockout (before this release a store read failure answered 401 and counted a strike){"error":{"code":503,"message":"authentication store is temporarily unavailable","retry_after_ms":2000},"meta":{"api_version":"v1"}} — retry. A store read failure increments yuzu_auth_read_degrade_total{route="login",reason="query_error"} and yuzu_auth_secret_unavailable_total{route="login"}; a concurrent change increments yuzu_auth_credential_changed_during_verify_total
503Enforcement applies but the auth store is unavailable (fail-closed; no session minted){"error":{"code":503,"message":"MFA enrollment is required but the authentication store is unavailable"}}
503The in-memory pending-challenge map is at capacity (server under a /login flood; transient load-shed){"error":{"code":503,"message":"too many pending authentications, retry shortly"}} — retry after a short back-off; emits yuzu_auth_mfa_pending_load_shed_total
503 + Retry-After: 2A login-path auth-store read/write was unavailable — fail-closed, no session minted{"error":{"code":503,"message":"authentication store is temporarily unavailable","retry_after_ms":2000},"meta":{"api_version":"v1"}} — the mfa_status decision read first rides out a transient Postgres blip with a bounded acquire-retry (the call that otherwise denies all logins on a blip); a persisted 503 means the outage outlasted it, or another login-path acquire failed, so honour Retry-After (see docs/auth-architecture.md §“Availability coupling”). Increments yuzu_auth_read_degrade_total{route="login",reason} (reason = pool_acquire_timeout / query_error / secret_unavailable)

Distinguish the two 202 variants by the status field: mfa_required routes to POST /login/mfa (the user already has a secret); mfa_enrollment_required routes to POST /login/mfa/enroll (the user must enroll first). The qr_svg field on the enrollment variant is a server-rendered inline SVG QR code encoding otpauth_uri — inject it into the DOM without HTML-escaping (it is pure shape geometry, no user content). If qr_svg is the empty string, QR encoding failed; fall back to displaying secret_base32 / otpauth_uri for manual entry. The mfa_pending_token is a 32-byte hex (64-char) opaque random; its lifetime is cfg.mfa_login_pending_secs (default 120 s). The pending state lives in process memory and is lost on server restart, and is not shared across HA replicas without sticky sessions.

Errors: a 503 from this endpoint can also mean the enrolled user’s MFA secret could not be read (PG/KEK unavailable, tamper, or a rotated/unresolvable key) rather than the pending-challenge-map load-shed above — this fails closed rather than silently falling back to “not enrolled.” See docs/auth-architecture.md § “MFA fails closed on secret-read failure.”

POST /login/mfa

Complete a pending MFA login. Called after receiving HTTP 202 from POST /login.

Permission: None (unauthenticated — the pending token is the bearer).

Request body (form-encoded):

mfa_pending_token=<64-hex>&code=<6-digit TOTP or XXXX-XXXX-XXXX-XXXX recovery code>

The endpoint distinguishes TOTP from recovery by code shape — exactly 6 ASCII digits is interpreted as TOTP; anything else routes through recovery-code validation. Each pending token allows at most 5 attempts before being invalidated; once invalidated the operator must start over from POST /login.

Responses:

StatusConditionBody
200 + Set-Cookie: yuzu_session=…Code accepted{"status":"ok"}
401Invalid or expired pending token, or rejected code{"error":{"code":401,"message":"Invalid verification code"}} — the wire body is identical for all failure modes so an attacker cannot distinguish “this pending token is valid; my code was wrong” from “this pending token is unknown.” The distinguishing detail is in the audit detail column only.
503The user’s MFA secret could not be read/decrypted (PG outage mid-read, tamper, an unresolvable/rotated KEK, or a corrupt blob){"error":{"code":503,"message":"auth_db unavailable"}}

An enrollment-pending token issued by the mfa_enrollment_required branch is rejected here (use POST /login/mfa/enroll); a login-challenge token is likewise rejected at the enroll endpoint.

Errors: the 503 above is a deliberate fail-closed outcome, never collapsed into the 401 “code rejected” case (which would burn one of the user’s limited verification attempts against a transient outage). See docs/auth-architecture.md § “MFA fails closed on secret-read failure.”

POST /login/mfa/enroll

Complete enforced TOTP enrollment for an un-enrolled user. Called after receiving HTTP 202 mfa_enrollment_required from POST /login (only reachable under --mfa-enforcement=admin-only|required). The POST /login 202 already revealed the otpauth_uri + secret_base32 for the user to scan; this endpoint confirms the first code, promotes the provisional secret to enrolled, mints the session, and returns the one-time recovery codes.

Permission: None (unauthenticated — the enrollment-pending token is the bearer).

Request body (form-encoded):

mfa_pending_token=<64-hex>&code=<6-digit TOTP>

Only a 6-digit TOTP code is accepted (recovery codes do not exist until enrollment completes). Shares the is_login per-IP rate-limit bucket and the 5-attempts-per-pending cap.

Responses:

StatusConditionBody
200 + Set-Cookie: yuzu_session=…Code accepted; enrollment complete, session minted{"status":"ok","recovery_codes":["XXXX-XXXX-XXXX-XXXX", … 10 total]} — revealed once; save them
401Invalid/expired pending token, wrong token type, malformed or rejected code, attempts exhausted, the account’s password was changed or reset after the login that issued the token (#5342 — audit detail credential_changed; nothing enrolled, token spent), or the auth store is not configured at all (null DB){"error":{"code":401,"message":"Invalid verification code"}} (uniform body; discriminator in the audit detail)
503Auth store reachable but the secret could not be verified — decrypt/store failure (e.g. KEK unresolvable){"error":{"code":503,"message":"authentication store is temporarily unavailable"}}
409The account was already enrolled by a concurrent verify before this pending token committed — a benign race, not a rejected code{"error":{"code":409,"message":"MFA is already enrolled on this account"}} — no session minted; complete login normally

A null auth store returns 401, not 503, deliberately: a distinct status would confirm “this pending token is valid” to an attacker holding one during a store outage. The reason is recorded in the audit detail only. The 503 above is reserved for the case where the store answered but the secret could not be decrypted or verified — that one is fail-closed and never burns an enrollment attempt. The 409 is only reachable by the caller who already holds a valid enrollment-pending token for that account (minted after their own password login), so it discloses nothing new, and it deliberately does not burn an enrollment attempt.

Audit: on success mfa.enroll.verified + mfa.recovery_codes.generated + auth.login; on a rejected code mfa.enroll.failed; on a password changed since the login mfa.enroll.failed result error detail credential_changed; on the benign already-enrolled race mfa.enroll.race (result ok).

POST /login/mfa/stepup

Refresh a session’s MFA proof so the next high-risk REST/Settings mutation is accepted within the mfa_step_up_window_secs window (PR 2 of the MFA ladder). Called automatically by the dashboard HTMX layer when a request to a step-up-gated endpoint returns 401 mfa_step_up_required; programmatic clients invoke it directly.

Permission: Existing session cookie (local or OIDC auth_source). API token / MCP token principals are step-up-exempt — they receive 400 here (“step-up is for session-cookie callers only — re-issue the API token to refresh MFA proof”).

Request body (form-encoded):

code=<6-digit TOTP or XXXX-XXXX-XXXX-XXXX recovery code>

Same strict-shape gate as POST /login/mfa: exactly 6 ASCII digits is interpreted as TOTP, anything else as a recovery code. There is no per-request attempts cap (the session is itself the credential and is rate-limited at the server layer via the shared is_login bucket).

Responses:

StatusConditionBody
200Code accepted; session’s mfa_verified_at refreshed to now{"status":"ok"}
400Missing code, or principal is an API/MCP token, or an OIDC session (OIDC re-proves via SSO, not local step-up; the body points to /auth/oidc/start), or a SAML session (SAML sessions carry no MFA attestation and cannot step up in this release; the refusal is audited as mfa.step_up.failed){"error":{"code":400,"message":"missing code"}} / step-up is for session-cookie callers only / OIDC sessions re-prove MFA by re-authenticating with the identity provider … / SAML sessions cannot satisfy step-up in this release; re-authenticating does not help
401No session cookie, or rejected code{"error":{"code":401,"message":"MFA step-up failed"}}
503auth_db unavailable (transient){"error":{"code":503,"message":"auth_db unavailable"}}

Errors: the 503 above includes the case where the session owner’s MFA secret specifically could not be read/decrypted (PG/KEK unavailable, tamper, or a rotated/unresolvable key) — require_mfa_step_up fails closed on this rather than treating it as “not enrolled.” See docs/auth-architecture.md § “MFA fails closed on secret-read failure.”

Audit verbs: mfa.step_up.passed on success (detail=method=totp or method=recovery); mfa.step_up.failed on each rejection with the rejection reason in detail.

Example:

# After a high-risk request returned 401 + mfa_step_up_required, post your
# current TOTP code (or a recovery code) to refresh the session's MFA proof:
curl -s -X POST https://yuzu.example.com/login/mfa/stepup \
  -H "Origin: https://yuzu.example.com" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --cookie "yuzu_session=<session>" \
  -d "code=123456"
# 200 {"status":"ok"}

Step-up envelope on high-risk endpoints

A fresh MFA proof is required on 37 named high-risk routes (listed below) for MFA-enrolled local accounts and OIDC sessions per --mfa-enforcement; un-enrolled accounts and API-token and MCP-token callers pass, and engine principals are denied. The three JIT-elevation routes, whose handlers apply their own stricter check, are separate. Other mutating routes and all MCP tools are not MFA-step-up gated in this release. The 37 are checked by one central step-up check that runs after the session is resolved and after the server-wide same-site check (a cross-site or header-less cookie write is answered 403 cross-site request refused before it), and before the route’s own authorization, own same-site copy and body checks. These routes return 401 with an MFA step-up envelope when the calling session’s mfa_verified_at is older than mfa_step_up_window_secs (the JIT-elevation routes at the end of this list use a 300s window instead when that flag is 0 or less; a positive value is used as it is).

Other outcomes of that check:

The server refuses to start (exit status 1) if a mutating route is registered that the step-up table does not classify; this can only happen on a fork or a custom build.

For OIDC sessions the envelope’s challenge_url is /auth/oidc/start (and the remediation points at re-SSO) instead of /login/mfa/stepup — an external identity has no local TOTP secret to step up against. An OIDC session whose IdP did not attest MFA at all (no amr) passes the gate under --mfa-enforcement=optional, but is gated (re-SSO) under required (or admin-only for an admin) — symmetric with a local user being forced to enrol. Approve and reject are the exception: they refuse an OIDC session with no MFA amr (403 approver_assurance_required:mfa_proof_missing) and a local account with no enrolled factor (mfa_not_enrolled) in every mode, and use a proof window of min(--mfa-step-up-window-secs, 300) (300 when that is 0), so they are not switched off by --mfa-step-up-window-secs=0.

Envelope shape:

{
  "error": {
    "code": 401,
    "message": "MFA step-up required",
    "correlation_id": "req-...",
    "remediation": "POST /login/mfa/stepup with current TOTP code or a recovery code, then retry"
  },
  "meta": {
    "api_version": "v1",
    "mfa_step_up_required": true,
    "challenge_url": "/login/mfa/stepup"
  }
}

meta.mfa_step_up_required is the boolean discriminator that distinguishes this 401 from an “unauthenticated” 401 (the 503 store-unavailable envelope carries it as true too, with no challenge_url, so prompt only on a 401); meta.challenge_url tells the client where to re-prove: /login/mfa/stepup for local sessions, /auth/oidc/start for OIDC sessions. API token / MCP token principals never see this 401: they pass the proof check (the bearer credential was issued as part of an authenticated session and is itself the step-up). Engine principals are refused with 403 on the routes above, as described there.

JIT Admin Elevation

Activate a time-boxed admin elevation (SOC 2 CC6.3/CC6.6) — see docs/auth-architecture.md “JIT admin elevation”. Eligibility is granted separately by an admin via POST /api/v1/users/{username}/elevation-eligibility.

POST /api/v1/elevate

Promote the current cookie session to admin for a bounded window. The session’s effective role becomes admin until the window lapses, then auto-reverts.

Permission: an authenticated cookie session only (a Bearer/MCP-token caller gets 401 — automation credentials can never elevate); the caller must be elevation_eligible (eligibility is keyed on a users table row — an OIDC identity with no such row is denied here, not later; provision it first via POST /api/v1/users). A second factor is mandatory, branched strictly on the session’s identity source (never a local namesake’s enrollment for an OIDC caller):

Body: {"justification": "<string, required>", "duration_secs": <int, optional>}. justification must be non-empty (control bytes are sanitised to space; capped to 1 KiB). duration_secs defaults to --jit-max-elevation-secs when absent or 0; a value above the cap is clamped; a negative value is a 400.

curl -s -X POST -H "Cookie: yuzu_session=$COOKIE" \
  -H "Origin: https://yuzu.example.com" \
  -H "Content-Type: application/json" \
  -d '{"justification":"prod incident #42","duration_secs":600}' \
  "https://yuzu.example.com/api/v1/elevate"

Response (200): {"status":"ok","expires_in":<seconds>,"expires_at":"<RFC3339 UTC>"}. expires_in is the TRUE remaining time computed after the grant (not an echo of the requested duration_secs) — the window is clamped to --jit-max-elevation-secs and to the session’s own absolute expiry (an elevation can never outlive the cookie session that carries it), so expires_in is always <= the requested duration. expires_at is the wall-clock projection of that same window.

Errors: 400 — blank/missing justification, wrong-typed field, or negative duration; 401 — not authenticated, no cookie (token caller), the session dissolved mid-request, or the session is already at/past its own absolute expiry (a dead-window guard: rather than granting a zero-or-negative-length window and misleading a scripted caller with a 200 ok, this is rejected the same way as “session vanished between validate and elevate”); 403 — not eligible, eligibility read failed (fail-closed), no MFA enrolled (local session), no MFA in the SSO login (OIDC session with no amr proof), or OIDC-amr elevation is disabled (--jit-oidc-amr-elevation off); the MFA step-up itself returns the standard step-up 401 challenge envelope (see /login/mfa/stepup); 500 with Sec-Audit-Failed: true — the mandatory grant audit could not persist, so the elevation was rolled back and not granted; 503 — the auth store is unavailable.

Audit: role.elevation.granted (detail=duration_secs=<N> mfa=<oidc_amr|local_totp> expires_at=<RFC3339 UTC> justification=<sanitised> — the machine-parsed mfa= and expires_at= tokens are placed before the free-text justification= field so a crafted justification can’t forge either) on success; role.elevation.denied on a 403, with a distinct detail reason per cause (not eligible / no MFA enrolled / no MFA in SSO login / OIDC-amr elevation disabled / mfa_step_up_refused). A window that later lapses PASSIVELY (no manual revoke) is audited lazily as role.elevation.expired on the operator’s next authenticated request — see docs/user-manual/audit-log.md.

POST /api/v1/elevate/revoke

Step an active elevation down early (reduces privilege; no MFA step-up required). Body: none. Answers 200 {"status":"ok"} once the elevation is cleared, whether or not a window was active; that is recorded as was_elevated=<bool> in the role.elevation.revoked audit detail, not the response body. If the server cannot durably clear the elevation it answers 503 (retry) and writes the same action with result=error, because the elevation may still be live.

POST /logout

Destroy the current session. Clears the yuzu_session cookie.

GET /auth/oidc/start

Begin the OIDC SSO login flow. Sets the short-lived browser-binding cookie (__Host-yuzu_oidc_bind, or yuzu_oidc_bind on plain HTTP) and redirects to the configured identity provider. Answers 500 with no cookie and no redirect if the server cannot start the flow.

GET /auth/callback

OIDC callback endpoint. The identity provider redirects here after authentication. Requires the browser-binding cookie set by GET /auth/oidc/start; a request without the matching cookie is refused with the generic /login?error=sso_failed redirect. Otherwise exchanges the authorization code for tokens and creates a local session. See OIDC Login Flow.

GET /auth/saml/start

Begin the SAML 2.0 SP-initiated login flow. Builds an <samlp:AuthnRequest> and redirects the browser to the configured IdP via HTTP-Redirect binding. Available only when all five --saml-* flags are set and the server is running on Linux or macOS.

POST /saml/acs

SAML Assertion Consumer Service endpoint. The IdP POSTs the <samlp:Response> here after authentication (HTTP-POST binding). The server validates the signed assertion (signature, audience, recipient, expiry, InResponseTo single-use) and mints a session cookie on success. On validation failure, the browser is redirected to /login with an error. Available only when SAML is enabled (see GET /auth/saml/start). When SCIM linkage is configured (ADR-2001 §4/PR4b), a login whose linked SCIM resource resolves deprovisioned is also refused here — see auth.saml.deprovisioned_denied below and docs/user-manual/scim-provisioning.md “Deny-at-login: a deprovisioned SAML identity cannot re-authenticate”.


SCIM v2 Provisioning

RFC 7643/7644. A separate protocol surface from the /api/v1/ JSON API above — its own RFC-defined schema (application/scim+json), its own bearer-token credential, and its own scim-service audit principal. Full design detail (provenance guard, deprovision/reactivation semantics, storage decision, deferred items) is in docs/auth-architecture.md “SCIM v2 provisioning”; operator setup walkthrough is docs/user-manual/scim-provisioning.md. This section is the wire-level endpoint reference.

Enablement: entirely inert (routes not registered) unless --scim-enable (YUZU_SCIM_ENABLE) is set; requires --scim-token (YUZU_SCIM_TOKEN) and HTTPS, or the server refuses to start. Prefer the YUZU_SCIM_TOKEN env var over --scim-token — an argv value is visible to any local user via ps; the env var is not.

Permission: every endpoint below, including the three discovery endpoints, requires Authorization: Bearer <scim-token> — validated constant-time against the configured token’s hash. Missing/invalid → 401

GET /scim/v2/ServiceProviderConfig

Discovery — static capability document (patch/filter/bulk support flags).

GET /scim/v2/ResourceTypes

Discovery — a SCIM ListResponse describing the User and Group resource types.

GET /scim/v2/Schemas

Discovery — a SCIM ListResponse describing the core User and Group schemas.

POST /scim/v2/Users

Provision a new user. Body: a SCIM User resource (only userName and externalId are read; other fields real IdPs send — name, emails, etc. — are tolerated and ignored).

userName must be a slug, not an email address. Yuzu usernames are alphanumeric plus ./_/- — no @. Stock Okta/Entra provisioning defaults userName to the user’s email and will 400 here; remap userName to a non-email slug attribute in the IdP’s attribute-mapping config before assigning users. The 400 body’s error message calls out the offending character(s).

Responses:

StatusConditionBody
201 + Location + ETagCreatedSCIM User representation
409 (scim_type=uniqueness)userName already provisioned on a currently-active accountSCIM error envelope
404The userName already holds an Administrator grant, or names a deactivated record whose role is not user (nothing is created or reactivated; the answer is a side-effect-free signal to the SCIM bearer holder, not an existence-free one)SCIM error envelope
409 (scim_type=mutability)active:false on create was refused by the last-Administrator guard (the new account stays active and mapped)SCIM error envelope
500A deactivated record’s role could not be read, a half-finished create could not be undone (scim.user.compensation_failed, state per outcome, see rbac.md), or the underlying account write failedSCIM error envelope
503 + Retry-After: 2The authorization store was unavailable (the Administrator-grant read failed, or the guarded deactivation for active:false timed out or lost its store). The body is the plain SCIM error (no retry_after_ms member); the detail says the account state may be indeterminate. After a grant-read failure nothing was created and a retry is safe; after a failed active:false deactivation the new account stays active and mapped and a retry of the POST answers the userName 409, so it does not convergeSCIM error envelope

SCIM provisioning now reads the RBAC store (the Administrator grant check) even when RBAC enforcement is off.

Created with a discarded CSPRNG password — the account authenticates via SSO, never a local password. Role defaults to user, recomputed immediately against --scim-admin-group membership (see Groups → role mapping below) — created role=admin iff already a member of that group at creation time. If the userName instead matches an existing SCIM-provisioned account that is currently deactivated, POST revives that account (a returning-employee reprovision) rather than 409ing.

GET /scim/v2/Users/{id}

Read a single provisioned user by its SCIM id.

StatusCondition
200Found — SCIM User representation
404Unknown id

GET /scim/v2/Users?filter=userName eq "x"&startIndex=&count=

Existence check + pagination, the standard “does this user already exist” call a connector makes before create. Only userName eq "value" is supported (case-insensitive attribute/operator, double-quoted value); any other filter expression is rejected 400 (scim_type=invalidFilter). Response is a SCIM ListResponse envelope; startIndex is 1-based per RFC 7644 §3.4.2.

PUT /scim/v2/Users/{id}

Replace the mutable identity fields (externalId, active). If active differs from the account’s current state, this triggers the identical deactivate/reactivate semantics documented under PATCH below — some IdP connectors issue PUT rather than PATCH for lifecycle changes.

StatusCondition
200Replaced — SCIM User representation
400 (scim_type=mutability)Body includes a userName change — rename is out of scope this slice
404Unknown id, or the provenance/role guard rejects the target (see below)
409 (scim_type=mutability)active:false would remove the fleet’s last protected administrator (see “Last-administrator refusal” below)
500The account was deactivated but its durable session wipe failed (an IdP retry is not promised to repeat the wipe, #5214), or another account write failed
503 + Retry-After: 2The guarded deactivation timed out or lost its store (the linked credentials were already revoked; the detail says the account state may be indeterminate), or active:true could not read the Administrator grants. A retry finishes the job only when the cause was a lock timeout in which nothing committed; it does not converge after a lost commit acknowledgement (the retry meets the 404 of the role guard on the now-deactivated account, #5214)

PATCH /scim/v2/Users/{id}

The primary lifecycle path. Both the pathless {"value":{"active":false}} and explicit {"path":"active","value":false} PatchOp forms are accepted.

StatusCondition
200Applied — SCIM User representation
404Unknown id, or the provenance/role guard rejects the target (see below)
409 (scim_type=mutability)active:false would remove the fleet’s last protected administrator (see “Last-administrator refusal” below)
500 / 503As for PUT above

active:true applies the same pre-checks as POST on a revive (behaviour change: such a request used to reactivate the account): a deactivated record whose role is not user, or a username that now holds an Administrator grant, is 404 (nothing reactivated), audited as scim.user.provenance_denied. A tombstone whose role cannot be read answers 500 and an unreadable grant answer is the 503 above (audit scim.user.reactivated / failure: tombstone role unreadable before reactivation or administrator grant check unavailable). Every 404 bumps yuzu_scim_provenance_denied_total, which the shipped alert describes as a compromised-IdP signal: this benign case also fires it. An elevated tombstone cannot be re-enabled through SCIM or the dashboard until its role is cleared (see rbac.md, “SCIM specifics”); SCIM can deactivate the holder of an Administrator grant but cannot reactivate it until the grant is removed.

DELETE /scim/v2/Users/{id}

Deprovision (equivalent to PATCH/PUT active:false for IdPs that issue a hard delete). 204 on success; 404 if unknown or guard-rejected; 409 (scim_type=mutability) under the last-administrator refusal below; 500 (account deactivated but its durable session wipe failed) or 503 + Retry-After: 2 (the guarded write timed out or lost its store), as for PUT.

Last-administrator refusal

PUT/PATCH active:false, DELETE, and POST with active:false answer 409 with scim_type=mutability when the deprovision would remove the last member of a protected administrator population (the last-Administrator guard; see docs/user-manual/rbac.md “Last-Administrator guard for account deactivation and demotion”). Because the provenance/role guard above already 404s any account whose role is not user, a SCIM request reaches this refusal only for a role=user account that holds the fleet’s sole Administrator grant, under RBAC enforcement on. Two forms:

Each refusal is audited as scim.user.last_administrator_refused (result denied for an early refusal and for a refused group-derived demotion; partial for a late refusal, because the credentials were revoked) and counted on yuzu_server_rbac_last_admin_guard_refused_total{transport="scim"}. A refused deprovision also raises a critical analytics event: the person was terminated at the IdP but their account stays active (and, after an early refusal, their API tokens and sessions stay live too), so an operator has to act. The audit detail states what was left in place. If the guarded write itself cannot complete (a lock wait that expired, an unavailable store) the answer is 503 with Retry-After: 2 (header only; the SCIM error body has no retry_after_ms member), not a refusal, and it is counted on yuzu_server_rbac_lifecycle_store_failure_total{op="deactivate"}. A 409 does not prove that an IdP stops retrying: expect the same request again on the next sync cycle. The audit row and counter are written every time; the server log line is limited to one warning per username per minute. A group-derived demotion that the guard refuses does not fail the Group request; the account keeps its role and the same audit action is recorded. On POST /scim/v2/Users, a username whose deactivated record carries a role other than user, or that already holds an Administrator grant, is refused 404 before anything is created or reactivated.

Provenance guard

Every deactivate/reactivate/update/delete call above re-verifies both provisioning_source == "scim" and role == "user" on the target account immediately before mutating it, and refuses with 404 — never 403 (a 403 would confirm the resource exists; 404 is indistinguishable from “no such SCIM resource”) plus a scim.user.provenance_denied audit row. This is what makes it safe to point an IdP’s SCIM connector at this surface at all: a locally-created admin, or the --break-glass-user account, can never be deactivated by a SCIM call; the only path from a SCIM request to role=admin is Groups → role mapping below, bounded to the single configured --scim-admin-group, so a compromised IdP cannot create an admin outside that mapping; and the role check means an operator-elevated former-SCIM account (later promoted by a human via the dashboard, or via Groups → role mapping) also drops out of SCIM’s write authority until it is back to role=user. See docs/auth-architecture.md “SCIM v2 provisioning” for the full threat discussion.

Groups → role mapping

Group is advertised in /scim/v2/ResourceTypes and /scim/v2/Schemas (schema urn urn:ietf:params:scim:schemas:core:2.0:Group). Gated by --scim-admin-group (YUZU_SCIM_ADMIN_GROUP, default empty — no SCIM group grants admin). Full design detail (role-resolution, provenance guard interaction, deprovision-ordering) is in docs/auth-architecture.md “SCIM v2 provisioning” § Groups → role mapping; operator walkthrough is docs/user-manual/scim-provisioning.md.

POST /scim/v2/Groups

Create a group. Body: displayName (required), optional members[] (each {"value": "<scim-user-id>"}).

StatusConditionBody
201 + Location + ETagCreatedSCIM Group representation
409 (scim_type=uniqueness)displayName already in useSCIM error envelope

The success audit row (scim.group.created) records recompute=complete when every affected member’s role was recomputed as intended, otherwise recompute=incomplete (the status stays 201); see scim.group.recompute_incomplete below.

GET /scim/v2/Groups/{id}

Read a single group by its SCIM id. 200 + Group representation, or 404 if unknown.

GET /scim/v2/Groups?filter=displayName eq "x"&startIndex=&count=

List groups. Same pagination contract as GET /scim/v2/Users (startIndex/count, SCIM ListResponse envelope, startIndex 1-based per RFC 7644 §3.4.2). Optional filter=displayName eq "..." (case-insensitive attribute/operator, double-quoted value); any other filter expression is rejected 400 (scim_type=invalidFilter).

PUT /scim/v2/Groups/{id}

Replace a group (displayName, members[]). 200 + Group representation; 404 if unknown; 409 (scim_type=uniqueness) if the replace would rename onto a displayName already used by a different group. The success audit row (scim.group.updated) records recompute=complete or recompute=incomplete (the status stays 200).

PATCH /scim/v2/Groups/{id}

Add/remove members — RFC 7644 §3.5.2 PatchOp on the members path ("op": "add" / "op": "remove", "path": "members"). 200 + Group representation, or 404 if unknown. The success audit row (scim.group.updated) records recompute=complete or recompute=incomplete (the status stays 200).

DELETE /scim/v2/Groups/{id}

Delete a group. 204 on success; 404 if unknown. The success audit row (scim.group.deleted) records recompute=complete or recompute=incomplete (the status stays 204).

Any Group POST/PUT/PATCH/DELETE that changes a user’s membership of the --scim-admin-group group recomputes that user’s role immediately (scim.user.role_changed audit row, see below) — there is no separate “apply role changes” step.

Audit actions

Audit result is success | failure | denied (not ok/error).

ActionResultWhen
scim.user.provisionedsuccess / denied / failurePOST succeeds (incl. a revived reprovision) / rejected 409 against an active account / rolls back 500
scim.user.updatedsuccess / failurePUT /scim/v2/Users/{id} succeeds / fails 500
scim.user.deactivatedsuccess / failurePATCH/PUT/DELETE sets the account inactive; failure (set-and-proceed) if the audit write itself could not persist. (#4966) A guarded deactivation that hit a store failure is also failure, answered 503, with detail starting account_state=unknown credentials_revoked=true (the linked credentials were revoked; the account may or may not be deactivated), and a failed durable session wipe after the deactivation is failure with account deactivated; durable session wipe failed (500)
scim.user.reactivatedsuccess / failurePATCH/PUT sets active:true. failure causes: the tombstone’s role could not be read (500, detail tombstone role unreadable before reactivation), the Administrator-grant read was unavailable (503, detail administrator grant check unavailable), the account or mirror write failed (500). An elevated tombstone or a name holding an Administrator grant is NOT a reactivated row: it is scim.user.provenance_denied (404)
scim.user.deletedsuccess / failureDELETE /scim/v2/Users/{id} succeeds / audit-write failure (set-and-proceed). (#4966) A guarded deactivation that hit a store failure is also failure, answered 503, with detail starting account_state=unknown credentials_revoked=true (the linked credentials were revoked; the account may or may not be deactivated), and a failed durable session wipe after the deactivation is failure with account deactivated; durable session wipe failed (500)
scim.user.provenance_denieddeniedA mutating call targets an account failing the provenance or role guard, or a POST or an active:true reactivation names a tombstone whose role is not user / a username that already holds an Administrator grant (the latter two also fire for a benign re-enable of an account an operator elevated or granted outside SCIM)
scim.user.last_administrator_refuseddenied / partialThe last-Administrator guard refused a deactivation, delete, POST with active:false or group-derived demotion (409 mutability on the Users routes; a refused group-derived demotion is not a 409: the Group request keeps its 2xx and the member keeps the role). On the Users routes detail carries attempted_action=..., early_refusal=true|false, credentials_revoked_before_refusal=true|false, account_remains_active=true and live_api_tokens_slug=<n> (the username’s own live tokens; unknown if the store could not answer), plus sessions_not_revoked=true on an early refusal or identity_links_unchanged=true and the revoke summary on a late one; a LATE refusal is partial (credentials were revoked), an early one and a refused group-derived demotion are denied. live_api_tokens_slug is followed by linked_principals_not_walked=true: linked OIDC/SAML principals’ tokens are not counted. The revoked_principals= list is lossy: ,, space, =, control bytes and every byte >= 0x80 become _, so two distinct principals can collapse into one token (the exact identity stays in identity_links). For a refused group-derived demotion it carries attempted_action=scim.user.role_changed and role_unchanged=true. Every refusal, including a refused demotion, also raises a critical analytics event
scim.group.recompute_incompletepartial(target type Group) A Group request’s role recompute did not apply every member as intended (a refused or failed demotion, an undetermined member whose read failed, or promotions skipped after a failed guarded write): detail carries members=<n> and, per non-empty class, refused, failed, undetermined or skipped_promotions with the count and up to 20 usernames (the first 20 are named; the count is exact), then the trigger. Counted on yuzu_scim_group_recompute_skipped_total{cause}. The Group request’s own status is unchanged, and its scim.group.created/updated/deleted success row says recompute=incomplete. A failed demotion is also a scim.user.role_changed failure row plus a critical analytics event
scim.user.compensation_failedfailureThe guarded undo of a half-finished create/revive was refused or failed; the request answered 500. outcome=guard_refused_last_administrator leaves the account active; deactivated_but_session_wipe_failed leaves it deactivated with a possibly live session; store_failure leaves its state indeterminate, so check the account. A critical analytics event and yuzu_scim_compensation_failed_total{outcome} accompany it
scim.auth.denieddeniedBearer-auth validation fails on any /scim/v2/* route, including discovery
scim.group.createdsuccess / denied / failurePOST /scim/v2/Groups succeeds / rejected 409 - displayName collision / rolls back 500. On success, detail is recompute=complete or recompute=incomplete
scim.group.updatedsuccess / denied / failurePUT/PATCH /scim/v2/Groups/{id} succeeds / rejected 409 - rename onto an existing displayName / fails 500. On success, detail is recompute=complete or recompute=incomplete
scim.group.deletedsuccess / failureDELETE /scim/v2/Groups/{id} succeeds / audit-write failure (set-and-proceed). On success, detail is recompute=complete or recompute=incomplete
scim.user.role_changedsuccess / failureA user’s role is recomputed to a new value on user create or a Group create/replace/patch/delete (records old_role→new_role, reason=group)
scim.user.deprovision_role_refused_with_linkfailureADR-2001 D1: a role-refused deprovision (deprovision_role_ok 404 — the slug’s role is not user) for a slug with ≥1 active linked OIDC identity, whose tokens are therefore NOT auto-revoked. Always written alongside yuzu_scim_deprovision_role_refused_with_active_link_total — see docs/auth-architecture.md “SCIM ↔ OIDC identity linkage for deprovision”.
auth.oidc.deprovisioned_deniedfailureADR-2001 §4/PR3: an OIDC login was refused because its linked SCIM resource resolved deprovisioned (deactivated, orphaned by a hard-deleted scim_resources row, or the store could not answer — fail-closed). Emitted from GET /auth/callback, not a /scim/v2/* route — listed here because it is part of the same ADR-2001 linkage. detail carries reason=linked_scim_resource_inactive;scim_id=<id> when a resolved resource (deactivated, or orphaned by a hard-deleted scim_resources row) drove the denial, or reason=scim_store_unavailable when the store could not answer (fail-closed — no scim_id to name); and — only on the post-mint re-check path (a concurrent deprovision landed after the primary check) — post_mint_recheck=true;sessions_invalidated=<N> (+db_persisted=false if that session invalidation itself failed to persist). The two reason values keep a genuine deprovision (CC6.8 evidence) distinguishable from a store outage. Pairs with yuzu_auth_oidc_deprovisioned_denied_total.
auth.saml.deprovisioned_deniedfailureADR-2001 §4/PR4b, the SAML analogue of the row above: a SAML login was refused at POST /saml/acs because its linked SCIM resource resolved deprovisioned (deactivated, orphaned by a hard-deleted scim_resources row) or because ScimStore could not answer at all (fail-closed). Principal is the saml:<entity_id>#<NameID> string. detail uses the identical shape the OIDC row above uses: reason=linked_scim_resource_inactive;scim_id=<id> when a resolved resource drove the denial, or reason=scim_store_unavailable (no scim_id) when the store itself could not be asked; and — only on the post-mint re-check path — post_mint_recheck=true;sessions_invalidated=<N> (+db_persisted=false if that session invalidation itself failed to persist). Pairs with yuzu_auth_saml_deprovisioned_denied_total.
auth.saml.link_unmatchedfailureADR-2001 #3072: on a SAML login with a linkable (stable-Format) NameID, the identity-link lookup ran but found either zero active SCIM resources matching the NameID as an externalId (reason=no_active_external_id_match;name_id_format=<format>) or more than one (reason=ambiguous_active_external_id_match;name_id_format=<format>, ADR-2001 §2 mis-link guard). Observe-and-proceed — the SAML login still succeeds (login-time linking is fail-open by contract; this is not the PR4b deny-at-login path). The two causes pair with separate counters, yuzu_scim_saml_link_unmatched_total and yuzu_scim_saml_link_ambiguous_total respectively, despite sharing one audit action. Emitted from POST /saml/acs. See docs/auth-architecture.md “SAML D2 observability (#3072)”.
auth.saml.link_lookup_failedfailureADR-2001 #3072: on a SAML login with a linkable NameID, the ScimStore active-externalId lookup itself could not answer (reason=scim_store_unavailable) — distinct from auth.saml.link_unmatched’s genuine zero-match answer. Observe-and-proceed — the SAML login still succeeds. Pairs with yuzu_scim_saml_link_lookup_failures_total. Emitted from POST /saml/acs.

Metrics

yuzu_scim_requests_total{op,status} (now including group ops), yuzu_scim_auth_failures_total, yuzu_scim_audit_write_failures_total, yuzu_scim_provenance_denied_total, yuzu_scim_role_changes_total, yuzu_scim_role_change_failures_total (a role change that was decided but failed to durably apply — pairs with scim.user.role_changed failure rows).

ADR-2001 (SCIM↔OIDC identity linkage, CC6.8): yuzu_scim_deprovision_role_refused_with_active_link_total (D1 — a deprovision was refused for a slug with an active linked federated identity whose tokens were not auto-revoked; a human must terminate them manually), yuzu_scim_deprovision_unlinked_total (D2 — a deprovision found a login observation that should have matched the slug’s externalId but no link had formed, almost always a misconfigured --oidc-scim-link-claim), yuzu_auth_oidc_deprovisioned_denied_total (§4/PR3 — the deny-at-login backstop refused an OIDC re-login against a deprovisioned linked identity), yuzu_scim_saml_link_write_failures_total (a SAML login’s identity-link write failed — see docs/user-manual/scim-provisioning.md “SCIM ↔ SAML identity linkage”), and yuzu_auth_saml_deprovisioned_denied_total (§4/PR4b — the SAML analogue of the OIDC deny-at-login backstop above, refused a SAML re-login against a deprovisioned linked identity); see the audit actions above and docs/user-manual/metrics.md “SSO login metrics”. Full description: docs/auth-architecture.md “SCIM v2 provisioning” § Metrics and “SCIM ↔ OIDC identity linkage for deprovision” § New metrics.

ADR-2001 #3072 (SAML D2 observability): yuzu_scim_saml_link_unmatched_total / yuzu_scim_saml_link_ambiguous_total (login-time: a linkable-Format SAML NameID matched zero / more than one active SCIM resource — pairs with auth.saml.link_unmatched above), yuzu_scim_saml_link_lookup_failures_total (login-time: the active-externalId lookup itself could not answer — pairs with auth.saml.link_lookup_failed above), and yuzu_scim_deprovision_saml_unlinked_total (deprovision-time: the SAML analogue of yuzu_scim_deprovision_unlinked_total above — a deprovision found a SAML login observation matching the slug’s externalId but no formed saml_identity_links row). See docs/auth-architecture.md “SAML D2 observability (#3072)” for the honest scope of what each half (login-time vs. deprovision-time) can and cannot attribute, and docs/user-manual/metrics.md “SCIM deprovision-linkage metrics” for the operator-facing description.


Notifications feed

The dashboard header bell reads and acts on the notification store through three dashboard JSON routes. They are not part of REST v1 and have no MCP twin (ADR-1005 grandfathers them as a settings-plane exception).

GET /api/notifications

Returns the unread feed, newest first. Requires Infrastructure:Read.

QueryMeaning
limitRows to return, default 50 (the bell sends 10). No server-side upper bound.
all=trueInclude read and dismissed rows.
offsetRow offset; only applied together with all=true.
{
  "notifications": [
    { "id": 4801, "timestamp": 1760000000000, "level": "warn",
      "title": "DEX alert: ...", "message": "...", "read": false, "dismissed": false }
  ],
  "unread_count": 1
}

timestamp is epoch milliseconds. level is a free string (success, warn, error and info in practice). title and message can contain agent-supplied text; render them as text only. 503 (A4 envelope, retry_after_ms 5000) is returned when the store is closed or a read fails, including a database connection timeout; an empty notifications array therefore means nothing is waiting. A negative limit or offset answers 400 (A4 envelope), with or without all=true.

POST /api/notifications/{id}/read

Marks one notification read. Requires Infrastructure:Write. 200 {"status":"ok"}; 404 when no row matches. A browser (session-cookie) call must carry an Origin or Referer naming this site (or a --csrf-trusted-origin entry), else 403 cross-site request refused (audit csrf.denied); token callers are exempt. Audit-first: a notification.mark_read row with result attempted is written before the change and the call answers 503 with Sec-Audit-Failed: true if it cannot be written; a success or failure row follows. 503 also when the database write fails.

POST /api/notifications/{id}/dismiss

Soft-deletes one notification. Requires Infrastructure:Write. 200; 404 when no row matches. Same-site rule, audit-first order and 503 behaviour as read, with the audit action notification.dismiss.

Behaviour to know:

The header bell polls GET /api/notifications?limit=10 with the session cookie every 60 seconds while the tab is visible and the panel is closed, and only within five minutes of the last keyboard, pointer or scroll input, because each request renews the session idle window.


Health

GET /health (alias: GET /api/health)

Note: /api/health is an identical alias of /health, provided for monitoring integrations that prefix every REST call with /api/. Both paths are unauthenticated and return the same JSON body. On the main listener, a request that carries a Cookie, Authorization or X-Yuzu-Token header is counted against a per-source budget. The source is the connection’s peer address (behind a proxy or load balancer, all callers share it). The budget is the --rate-limit value (default 100 requests/second, YUZU_RATE_LIMIT; values below 1 are treated as 1, so the limit cannot be turned off), /health and /api/health share one bucket per source, and the bucket is separate from the REST budget. A request over the budget gets 429 with Retry-After: 1 and the standard error envelope (including correlation_id and retry_after_ms). A request is charged by the presence of one of those headers, whether or not the credential is valid; a credentialed monitor behind a shared address shares the budget with every credentialed request from that address, so load balancer and orchestrator checks should use /livez or /readyz. A request that carries none of those headers is not counted, is never refused by this limit, and always gets the unauthenticated body. /livez and /readyz are not rate limited. The canonical path is /health; use /api/health only when your tooling enforces the /api/ prefix unconditionally. (Restored in v0.12.0, see issue #620.)

Note: When HTTPS is on, /health and /api/health are also answered on the plain-HTTP redirect port, in cleartext, where they always return the unauthenticated shape (no session is ever resolved there). That port answers 503 {"status":"not ready","reason":"https_listener_not_serving"} to all four probe paths while the HTTPS listener is not bound or has exited.

Note: /health and /api/health are intentionally NOT draining-aware (they continue returning 200 during graceful shutdown). For load-balancer health checks that should drain in-flight traffic before stopping, use /readyz instead — it returns 503 once the server begins draining.

Body shape varies by auth. Unauthenticated callers (the standard monitoring case) get the cheap probe response: status, uptime_seconds, agents.online, stores.*, enablement_regressed, version, tls.* (default-certificates flag, install CA fingerprint and expiry). Authenticated callers additionally get agents.pending, executions.*, and system.*. Those fields need extra store reads, so they are returned only to a caller with a session.

Structured JSON health check endpoint. This endpoint is unauthenticated and intended for load balancers, monitoring systems, and orchestration tools.

Permission: None (unauthenticated). Authenticated callers receive an extended response (see body-shape note above).

Response:

{
  "status": "healthy",
  "uptime_seconds": 86400,
  "agents": {
    "online": 42,
    "pending": 3
  },
  "stores": {
    "pg_pool": "ok",
    "pg_reachable": "ok",
    "responses": "ok",
    "audit": "ok",
    "instructions": "ok",
    "policies": "ok",
    "guaranteed_state": "ok",
    "offload_target": "ok",
    "ca": "ok"
  },
  "enablement_regressed": false,
  "tls": {
    "default_certs_active": false,
    "ca_fingerprint": "",
    "ca_expires_at": 0
  },
  "executions": {
    "in_flight": 5,
    "completed_last_hour": 120,
    "failed_last_hour": 2
  },
  "version": "0.1.0"
}
FieldTypeDescription
statusstring"healthy" or "degraded"
uptime_secondsintegerServer uptime in seconds
agents.onlineintegerNumber of currently connected agents
agents.pendingintegerNumber of agents awaiting enrollment approval
storesobjectHealth status of each data store ("ok" or "error"); the example shows a subset. Includes ca — the internal-CA store (ca_store, Postgres) — which is load-bearing whenever default certs are active; status is "degraded" if it is down. Includes pg_reachable (HA WS-8) — the only RUNTIME check here: whether this replica’s dedicated probe currently reaches a writable Postgres primary. The other store entries report whether each store opened at startup. /readyz carries the same pg_reachable row plus a pg reason field when it fails; see docs/user-manual/server-admin.md, “What /readyz checks”.
enablement_regressedbooltrue while the plugin-enablement store is declared regressed (its continuity anchor disagreed with the store, so no desired state is issued). Read from a cache, no I/O. Unauthenticated, like the rest of the cheap probe shape; /readyz carries the fuller enablement object (regression_pending, anchor_unwitnessed_at_boot, report_ahead), and neither gates readiness. See docs/user-manual/server-admin.md, “Enablement continuity anchor”.
tls.default_certs_activebooltrue when running with built-in per-install default certs (replace before production — see security-hardening.md). Unauthenticated so monitoring can detect it.
tls.ca_fingerprintstringSHA-256 fingerprint of the active default CA (empty when not on default certs). Public.
tls.ca_expires_atintegerUnix timestamp of the default CA’s expiry (0 when not on default certs).
executions.in_flightintegerCurrently running instruction executions
executions.completed_last_hourintegerExecutions completed in the past 60 minutes
executions.failed_last_hourintegerExecutions that failed in the past 60 minutes
versionstringServer binary version

The endpoint returns HTTP 200 when healthy and HTTP 503 when degraded.

Authenticated response:

When the request includes a valid session cookie, Authorization: Bearer <token>, or X-Yuzu-Token header, the response includes an additional system object with process health telemetry:

{
  "system": {
    "cpu_percent": 12.5,
    "memory_rss_bytes": 134217728,
    "memory_vss_bytes": 536870912,
    "grpc_connections": 42,
    "command_queue_depth": 5
  }
}
FieldTypeDescription
system.cpu_percentfloatServer process CPU usage percentage
system.memory_rss_bytesintegerResident set size in bytes
system.memory_vss_bytesintegerVirtual memory size in bytes
system.grpc_connectionsintegerNumber of connected agent gRPC streams
system.command_queue_depthintegerNumber of in-flight command executions

This object is omitted for unauthenticated callers to avoid exposing process internals.


Metrics

GET /metrics

Prometheus exposition format. Returns all server metrics.

Example (curl):

curl https://yuzu.example.com/metrics

Example output (excerpt):

# HELP yuzu_server_agents_connected Number of currently connected agents
# TYPE yuzu_server_agents_connected gauge
yuzu_server_agents_connected 42

# HELP yuzu_server_grpc_requests_total Total gRPC requests by method and status
# TYPE yuzu_server_grpc_requests_total counter
yuzu_server_grpc_requests_total{method="Register",status="ok"} 156
yuzu_server_grpc_requests_total{method="Heartbeat",status="ok"} 12483

# HELP yuzu_server_command_duration_seconds Command execution duration
# TYPE yuzu_server_command_duration_seconds histogram
yuzu_server_command_duration_seconds_bucket{plugin="hardware",le="0.1"} 89
yuzu_server_command_duration_seconds_bucket{plugin="hardware",le="1.0"} 95
yuzu_server_command_duration_seconds_bucket{plugin="hardware",le="+Inf"} 96

Management group metrics:

# HELP yuzu_server_management_groups_total Total number of management groups
# TYPE yuzu_server_management_groups_total gauge
yuzu_server_management_groups_total 5

# HELP yuzu_server_group_members_total Total members across all management groups
# TYPE yuzu_server_group_members_total gauge
yuzu_server_group_members_total 42

Process health metrics:

# HELP yuzu_server_cpu_usage_percent Server process CPU usage percentage
# TYPE yuzu_server_cpu_usage_percent gauge
yuzu_server_cpu_usage_percent 12.5

# HELP yuzu_server_memory_bytes Server process memory usage in bytes
# TYPE yuzu_server_memory_bytes gauge
yuzu_server_memory_bytes{type="rss"} 134217728
yuzu_server_memory_bytes{type="vss"} 536870912

# HELP yuzu_server_open_connections Number of connected gRPC agent streams
# TYPE yuzu_server_open_connections gauge
yuzu_server_open_connections 42

# HELP yuzu_server_command_queue_depth Number of in-flight command executions
# TYPE yuzu_server_command_queue_depth gauge
yuzu_server_command_queue_depth 5

# HELP yuzu_server_uptime_seconds Server process uptime in seconds
# TYPE yuzu_server_uptime_seconds gauge
yuzu_server_uptime_seconds 86400

Note: Process health metrics (CPU, memory, connections, queue depth) are exposed on /metrics for all callers. When --metrics-no-auth is enabled, this data is accessible to unauthenticated remote clients.

All server metrics use the yuzu_server_ prefix. Standard labels: agent_id, plugin, method, status, os, arch.