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:
- Use the standard JSON envelope (
data,error,meta,paginationfields) - Return
"api_version": "v1"in themetaobject of every response - Follow consistent error handling with structured error objects
- Are the recommended integration target for all new automation and tooling
- Will maintain backward compatibility within the v1 version; breaking changes will increment to v2
Legacy API (/api/ without version prefix)
Endpoints under /api/ without the v1 prefix are legacy endpoints that predate the versioned API. These endpoints:
- Remain available for backward compatibility
- Do not use the standard v1 JSON envelope
- May return inconsistent error formats
- Are deprecated and will be removed per the deprecation cycle in
docs/api-versioning-policy.md(a feature release, after the announced window) - Should be migrated to their v1 equivalents where available
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:
- REST JSON endpoints fail closed: they return
503+Sec-Audit-Failed: trueand serve no data, so a machine integration (CMDB / ServiceNow / agentic worker) never records evidence-less PII as audited. Distinguish this transient, retryable503from a non-transient misconfiguration503by the presence of theSec-Audit-Failedheader. - Dashboard HTMX fragments set-and-proceed: they set
Sec-Audit-Failed: truebut still render, so a transient audit hiccup never blanks a human operator’s lens. - MCP tools set-and-proceed with an
audit_persisted: falsefield in the result (absent on success) — JSON-RPC has no response-header channel.
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
- API Versioning
- Authentication
- JSON Envelope
- Pre-Auth Request Body Caps
- Numeric Query Parameters
- REST API v1 Endpoints
- Current User
- Devices
- Hardware
- Management Groups
- API Tokens
- Engine Principals
- Sessions
- Quarantine
- Internal CA
- KEK Rotation
- Engine Principals
- RBAC
- Tags
- Definitions
- Product Packs
- Response Templates
- Audit Log
- Access Reviews
- Policy Fragments
- Policies
- Compliance
- Runtime Configuration
- Settings
- Custom Properties
- Webhooks
- Offload Targets
- Directory Sync
- Enrollment
- Network Discovery
- Workflows
- Workflows, Executions & Schedules — v1 read twins (#4030)
- Command/Instruction Responses — v1 read twins (#2146 A2-R2)
- OpenAPI Spec
- Discovery (A2)
- Inventory
- Crypto
- Result Sets
- Forensics
- Software Licensing (SLE)
- Execution Statistics
- Live-Query Bundles
- Device Tokens
- Pre-flight & Deploy
- Software Deployment
- License Management
- Topology
- Fleet Statistics
- Plugin Configuration
- File Retrieval
- Uploads
- Guaranteed State
- Legacy API Endpoints
- Commands
- Agents
- Help
- Instructions
- Instruction Sets
- Executions
- Schedules
- Approvals
- Audit (Legacy)
- Scope Engine
- Data Export
- Responses
- Tags (Legacy)
- Analytics and NVD
- SSE Event Stream — includes
GET /events,GET /sse/executions/{id}, and the agenticGET /api/v1/events - Dashboard Echo
- MCP (Model Context Protocol)
- Authentication Endpoints
- SCIM v2 Provisioning
- Health
- Metrics
Authentication
Every request must be authenticated using one of three methods:
| Method | Header / Cookie | Example |
|---|---|---|
| Session cookie | yuzu_session cookie | Set automatically after POST /login |
| Bearer token | Authorization: Bearer <token> | Authorization: Bearer yzt_a1b2c3d4... |
| X-Yuzu-Token header | X-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:
| Field | Always present | Meaning |
|---|---|---|
code | yes | HTTP status echoed into the body for self-describing frames. |
message | yes | One-sentence human-readable summary. |
correlation_id | yes | A 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_ms | yes (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). |
remediation | when a hint exists | Natural-language self-recovery hint. Key is omitted when there is no hint (absence carries the same “no recovery available” meaning). |
permission | on permission denials | The "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_url | reserved | The §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/v1errors were previously emitted as{"error":"<string>"}(the single-argerror_json) or the older nested{"error":{"code","message"}}. They are now uniformly the nested A4 object above. A client that readerroras a string (String(body.error),body.error.startsWith(...)) will break —erroris always an object on these paths now. Migrate tobody.error.code/body.error.message. Seedocs/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):
| Family | Routes and parameters |
|---|---|
| Execution statistics | GET /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) |
| Executions | GET /api/executions, GET /api/v1/executions: limit; GET /api/v1/executions/{id}/visualization: index |
| Audit | GET /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, workflows | GET /api/instructions, GET /api/v1/instructions, GET /api/v1/product-packs, GET /api/product-packs, GET /api/workflows, GET /api/v1/workflows: limit |
| Compliance | GET /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 |
| Patches | GET /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) |
| Inventory | GET /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 window | GET /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 visualization | GET /api/v1/viz/fleet/topology: machines_max |
| Verification | the 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 fragments | GET /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 routes | GET /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 streams | GET /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 forms | POST /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:
| Status | Meaning |
|---|---|
413 | The body’s declared Content-Length exceeds the resolved cap for this route’s class. |
411 | The 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. |
415 | The 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:
- Multipart is not covered. httplib only appends to
req.bodyon the non-multipart branch ofread_content(httplib.h:11002swaps the receiver for the multipart parser once it detects a multipart body) — soreq.body.size()reads 0 for ANY multipart request, regardless of the real bytes read off the wire. No multipart class is covered — stated as a rule rather than a list, because an earlier draft of this text enumerated a single class and was wrong:/api/settings/updates/upload(ota_upload) and/api/settings/cert-upload(no table entry, so the 4 MiB default) are multipart too. Their chunked-body gap is not closed by this stage. This is not a regression introduced here — it is exactly as exposed as it was before this stage existed — but do not read the post-read stage as having closed coverage for every class. - The stage only runs when a route matched.
set_pre_request_handleris invoked from httplib’sdispatch_request, which only runs once routing has matched a route — a request to a path that 404s never reaches this handler at all. It is a backstop behind the pre-routing gate for matched routes, not a second universal pre-auth gate.
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):
| Method | Path prefix | Cap | path_class | Notes |
|---|---|---|---|---|
| any | /mcp/ | 4 MiB | mcp | One of four classes with requires_measurable=true — see the 411 row above. |
| POST | /api/v1/bundles | 70 MiB | bundles | Sized to the live-query bundle route’s own computed 64 MiB parameter-byte floor plus JSON overhead headroom. |
| any | /api/v1/uploads | 8 MiB | upload_session | The 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-config | 256 KiB | plugin_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/upload | 100 MiB | ota_upload | Kept at httplib’s own backstop deliberately, not squeezed — OTA agent binaries are legitimately multi-ten-MB. |
| POST | /api/export/json-to-csv | 100 MiB | json_to_csv_export | Unbounded 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/match | 8 MiB | nvd_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-chain | 256 KiB | ca_import_chain | Mirrors the handler’s own 256 KiB bound exactly. |
| POST | /api/v1/ca/issue-code-signing | 64 KiB | ca_issue_code_signing | Code-signing leaf issuance via CSR custody (gap-matrix #10); mirrors the handler’s own kMaxIssueCodeSigningBody exactly. |
| POST | /api/v1/ca/revoke | 64 KiB | ca_revoke | Mirrors the handler’s own kMaxRevokeBody exactly. |
| POST | /api/v1/secrets/kek/ | 64 KiB | kek_ops | Covers both /rotate and /rewrap — both take zero body fields and share the handler’s own kMaxKekBody. |
| POST | /api/settings/ca/import-chain | 512 KiB | ca_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/upload | 512 KiB | plugin_trust_bundle | † 2× the handler’s 256 KiB parsed-content check — same reasoned multipart-framing headroom, not measured. |
| any | /scim/v2/ | 64 KiB | scim | Matches 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/acs | 1 MiB | saml_acs | A legitimate signed SAMLResponse is well under 64 KiB; headroom against amplification, not judgment. |
| POST | /api/v1/definitions/ | 64 KiB | response_templates | Widest 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 KiB | response_templates | Same. |
| POST | /api/dashboard/echo-execute | 16 KiB | echo_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-query | 200 KiB | echo_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/rules | 16 MiB | guardian_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 MiB | guardian_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/workflows | 16 MiB | workflow_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-packs | 16 MiB | product_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/import | 16 MiB | instruction_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/yaml | 3076 KiB | instruction_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-yaml | 3076 KiB | instruction_yaml | † Same check, same margin — see above. |
| POST | /fragments/instructions/yaml-preview | 3076 KiB | instruction_yaml | † Same check, same margin — see above. |
| any | /api/v1/hardware | 4 KiB | hardware | The 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 KiB | rbac_role_assignment | A2 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/enforcement | 1 KiB | rbac_enforcement | A1 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-generation | 1 KiB | guardian_policy_generation | The 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/users | 16 KiB | users | The 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-policy | 320 KiB | dex_score_policy | The 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/crypto | 16 KiB | crypto | The /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/config | 16 KiB | config | The 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 MiB | default | Applies 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.
| Field | Meaning |
|---|---|
username | The stable authorization principal (for an SSO session an opaque oidc:<iss>#<sub> id). |
display_name | A readable label; falls back to username. |
role | The 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_kind | human 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_source | Always present. How the session authenticated: local, oidc, saml, api_token, mcp_token or engine_token. |
mcp_tier | The MCP authorization tier of an MCP token; an empty string for any other session. |
scope_service | The service tag of a service-scoped token; an empty string when the session is not service-scoped. |
rbac_enabled | The 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_role | Deprecated 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_roles | The 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_resolved | false 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_role | The effective legacy session role: admin while a just-in-time elevation is active, otherwise role. Like role, it is not a summary of authority. |
elevated | true while a just-in-time elevation is active. Always false for API and MCP token sessions. |
elevated_until | End of the elevation window, ISO-8601 UTC. Present only when elevated is true. |
can_access_settings | true 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" }.
| Status | Meaning |
|---|---|
200 | The 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. |
400 | Unrecognised sort, os or status token, or an invalid tag key. |
403 | Caller lacks Inventory:Read. |
503 | Route 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.
| Status | Meaning |
|---|---|
200 | The record. A degraded CI store alongside a degraded roster is still a 200 carrying identity_state and ci_state as degraded. |
400 | Device 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. |
403 | Caller lacks Inventory:Read. |
404 | Not found, or outside the caller’s fleet-read scope (indistinguishable by design). |
503 | Route 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": ""
}
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique group name |
description | string | No | Human-readable description |
parent_id | string | No | ID of parent group (empty for root) |
membership_type | string | No | "static" (default) or "dynamic" |
scope_expression | string | No | Scope 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'"
}
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | New group name (must be unique) |
description | string | No | Updated description |
parent_id | string | No | New parent group ID (empty string for root-level) |
membership_type | string | No | "static" or "dynamic" |
scope_expression | string | No | Scope engine expression for dynamic groups |
Validation rules:
- The root group (
000000000000, “All Devices”) cannot be re-parented. Attempting to setparent_idon the root group returns400. - Self-parent: A group cannot be set as its own parent. Returns
400. - Cycle detection: The new parent must not be a descendant of the group being updated. Moving a group under one of its own children would create a circular hierarchy and returns
400. - Depth limit: Re-parenting must not exceed the maximum hierarchy depth of 5 levels. Returns
400if exceeded.
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"
}
| Field | Type | Required | Description |
|---|---|---|---|
principal_type | string | No | "user" (default) or "group" |
principal_id | string | Yes | Username or group name |
role_name | string | Yes | "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:
| Parameter | Description |
|---|---|
command_id | The response set’s instruction/command id to count against. |
plugin | Plugin 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).
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Human-readable label |
expires_at | integer | No | Unix 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_tier | string | No | MCP 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):
| Condition | Response |
|---|---|
mcp_tier is not one of readonly / operator / supervised | 400 — invalid mcp_tier: must be one of readonly, operator, supervised (or omitted) |
mcp_tier set (or scope_service set) but expires_at omitted or 0 | 400: expires_at is required for an MCP-tier or service-scoped token |
mcp_tier set and expires_at is more than 90 days out | 400 — 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 string | 400: 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 future | 400: 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
tokenvalue 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:
| Condition | Response |
|---|---|
| No such token, or the token exists but is not owned by the caller | 404 — 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 default | 400: request body must be a JSON object |
| Overlap window below the 24h floor, or above the 10-year ceiling | 400 |
| 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 instead | 400 |
| 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 expired | 400 — credential is not currently active — nothing to rotate |
| Two active tokens in the rotation group exist but are not a recognized predecessor/successor pair | 400 — resolve via revoke, not rotate |
| More than two active tokens in the rotation group | 400 — resolve manually before rotating |
| A rotation already in flight, initiated by a different operator | 409 |
| Grace window elapsed with no confirm | 409 — grace window elapsed; confirm or revoke |
| No active credential found to rotate | 503 — 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 empty | 503 — 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 persist | 503 — retryable store failure |
| MFA step-up not satisfied | 401 |
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"
}
| Field | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | 1-128 chars; becomes principal_id = "engine:" + slug. |
display_name | string | No | UI/audit label. |
owner_username | string | Yes | Must reference an existing user (FK-validated before the row is written). |
classification | string | Yes | Exactly "internal" or "external" — required at creation, no default and no silent fallback. |
justification | string | Yes | Grant 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):
| Condition | Response |
|---|---|
slug empty | 400 — slug is required |
slug longer than 128 chars | 400 — 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 user | 400 — owner_username must reference an existing user |
classification missing or not internal/external | 400 — store validation error |
justification empty | 400 — store validation error (justification cannot be empty) |
| The create audit row could not persist | 503 + 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:
| Condition | Response |
|---|---|
| Overlap window below the 24h floor, or above the 10-year ceiling | 400 |
| Overlap window would outlive the predecessor’s or the successor’s own expiry | 400 |
| Exactly one active credential exists, but the engine-principal referent it would mint a successor against is unknown or revoked | 400 — 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 principal | 400 — resolve manually before rotating |
| A rotation already in flight, initiated by a different operator | 409 — a second operator cannot touch an in-flight rotation they didn’t start |
| Grace window elapsed with no confirm | 409 — grace window elapsed; confirm or revoke |
| No active credential found for this principal to rotate | 503 — 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 answer | 503 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 persist | 503 — retryable store failure |
| The reveal audit row could not persist | 503 + 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 satisfied | 401 |
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:
| Condition | Response |
|---|---|
token_id missing from the body, empty, not a string, or the body is malformed/non-object JSON | 400 — 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 rotation | 409 — 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 disagree | 409 — 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 it | 409, 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 pair | 503 — 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 passed | 403 — rotation secret mismatch — the presented secret does not verify against the pending successor |
| The confirm audit row could not persist | 503 + 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 persist | 503 — retryable store failure |
| MFA step-up not satisfied | 401 |
Missing Security:Write, or the caller’s own session is engine-classed | 403 |
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):
-
Authentication (
401without a session). -
Session class: an API, MCP or engine token, or a service-scoped token, is
403 token_session; an OIDC or SAML session is409 not_local(change it at the identity provider). -
Same-site request (this route’s own copy):
Origin/Referermust be the dashboard’s own origin (or a--csrf-trusted-originentry), and a request carrying neither header is refused with403 csrf.Content-Typemust beapplication/json, else415(not audited). A cookie-session caller never reaches thecsrfrefusal: the server-wide check (see Authentication) answers first, with messagecross-site request refusedand nocsrfcode, and this copy is defence in depth. -
MFA step-up, when the account is enrolled and its proof is stale —
401step-up envelope (meta.mfa_step_up_required,meta.challenge_url). -
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. -
The current password, through the same lockout-accounted check as
POST /login: a wrong password is403 wrong_currentand counts toward account lockout; a locked account gets the same403and message (account_lockedin 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 retryable503 verify_transientand never counts. If the lockout counter cannot be recorded the answer is503 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). -
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_changeaudit 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_unavailablewithSec-Audit-Failed: true. Any other store failure is503 store_unavailable. A503body 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:
| Status | Audit detail | Meaning |
|---|---|---|
400 | (not audited) | Malformed body, or new password outside 12–1024 bytes |
401 | — | No session, or MFA step-up required |
403 | token_session / csrf | Steps 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) |
403 | wrong_current | The current password is wrong. Counts toward lockout exactly like a failed /login |
403 | account_locked | The account is locked. Same message as a wrong password (“current password is incorrect”) |
403 | sso_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 |
404 | not_found | The account was deactivated in between (nothing changed) |
409 | not_local | OIDC/SAML session, or the account is SSO/SCIM-managed (nothing changed) |
409 | conflict | The 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 |
503 | verify_transient | The current password could not be verified (store read failure, or a concurrent change). Retry |
503 | audit_unavailable | The audit row could not be written, so the change was not made (Sec-Audit-Failed: true). Retry |
503 | store_unavailable | The 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:
- RBAC off: the caller’s own account, re-read from the auth store, must hold the
adminrole. - RBAC on: the caller must hold an
Administratorgrant as a user principal.
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).
- Not yourself: targeting your own username is
403 self_target— usePOST /api/v1/users/me/password, which requires your current password, so a stolen admin cookie cannot re-key its own account. - Not the break-glass account: the account named by
--break-glass-useris403 break_glass_target. It is re-keyed only out of band on the server host, or by signing in as it while armed and usingPOST /api/v1/users/me/password, so an identity-provider compromise cannot invalidate the escape hatch. - One transaction: under the target’s row lock — together, or not at all — the new password is written, every dashboard session of the target is deleted (durably, on every replica), a provisional (not yet enrolled) TOTP secret is wiped, any lockout is cleared, and the audit row(s) are written. If the audit row cannot be written nothing changes:
503 audit_unavailablewithSec-Audit-Failed: true; any other store failure is503 store_unavailable; a503body says 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. An enrolled second factor is not touched — if the account may be compromised, clear it too (yuzu-server --mfa-reset <user>, see Authentication). - Local accounts only: the account is classified under the lock. An SSO principal or a SCIM-provisioned account is
409 not_local; an absent or deactivated account is404 not_found— and a refusal changes nothing (the target’s sessions stay valid). A path segment cannot carry an SSO principal’s/and#, so in practice only local usernames reach the handler. An account literally namedmecannot be reset over REST —POST /api/v1/users/me/passwordis the self route (#5359 tracks reserving the name).
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" }
}
lockout_cleared—trueonly when the target had a lockout (locked_untilset — even if the lock has since expired — or a non-zero failure count) and it was cleared, in the same transaction as the reset, with anauth.lockout.clearedrow (detail=password_reset) in the same commit.falsemeans there was nothing to clear (noauth.lockout.clearedrow is written).provisional_mfa_cleared—truewhen an un-enrolled TOTP secret was wiped, so an enrolment started before the reset cannot be finished and the next enrolment gets a fresh secret.api_tokens_active— API tokens are deliberately left alone (automation may depend on them, and a reset is not by itself a compromise finding), so the response reports how many active tokens the account holds. If the token store cannot be read it isnulland"api_tokens_unknown": trueis added;remediationthen says the count could not be confirmed. The dashboard shows a warning unless the count is a confirmed0.
Errors:
| Status | Audit detail | Meaning |
|---|---|---|
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 |
403 | durable_admin_required | The caller is not a durable Administrator (see above) |
403 | token_session / csrf / self_target / break_glass_target | See 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 |
404 | not_found | No active account by that name (nothing changed) |
409 | not_local | SSO/SCIM account (nothing changed) |
415 | (not audited) | Content-Type is not application/json |
503 | admin_gate_unavailable | The RBAC/auth stores could not confirm the caller’s authority |
503 | audit_unavailable | The audit row could not be written, so the reset was not made (Sec-Audit-Failed: true) |
503 | store_unavailable | The 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:
- Path form —
POST /api/v1/users/{username}/elevation-eligibility— local usernames only in practice: both forms validate the target withis_valid_principal(#1852), but a path segment cannot carry the/and#an SSO principal contains, so only a local username reaches the handler this way. - Query form —
POST /api/v1/users/elevation-eligibility?username=<principal>— required for a durable SSO principal (oidc:<iss>#<sub>). A path segment cannot carry the/(in the issuer URL) and#an SSO principal contains — the server percent-decodes the path and strips the URL fragment before route matching, so the path form 404s for every real IdP identity. The query form accepts the same shapes asDELETE /api/v1/sessionsabove (is_valid_principal, #1852): a strict local username, or an SSO principal (URL-encode the#as%23;/does not need escaping in a query value).
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_devicetool: a management-group-confined operator holdingSecurity:Execute/Security:Readonly through a group can act on and see devices inside their own group(s), and is refused (403) for devices outside them. OnGETandDELETE, a global grant (or an unfiltered/legacy-RBAC-disabled deployment) still reaches every device, unchanged;POSTadditionally 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 a500rather 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 incrementsyuzu_server_quarantine_read_degrade_total— seedocs/user-manual/upgrading.mdanddocs/user-manual/metrics.md. If the list gate itself cannot decide (an unavailable authorization store, or a service-scoped API token whoseITServiceOwnerceiling cannot be read) it answers with its own401/403/503and the store is not read. An unwired gate answers500, 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), both0= never. Endpoint-containment confirmation state written byQuarantineContainmentReconciler, the background component that re-applies a device’s own firewall on reconnect.last_applied_atmeans a system re-dispatch of the stored whitelist was accepted (agents_reached > 0) — NOT proof of containment (a gateway-attached agent’ssend_toonly queues the frame).last_confirmed_atis set only after a follow-upquarantine.statusread reportsstate|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. Seedocs/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.enablerow withresult=attemptbefore it changes the record. If that row cannot be written the request is refused with503(Sec-Audit-Failed: true) and containment is unchanged. The change is then recorded assuccessorfailure; 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"gets403on this route (and on theDELETErelease below): supervisedSecurity:Executerequires 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:Executegate andwhitelistvalidation, and before theattemptaudit row or any record is written, the route runs the shared dispatch check forquarantine.quarantine, the same one MCPquarantine_deviceruns. REST has no approval-ticket path today, so the check admits only an administrator: here that means an account whose account role isadmin(a live temporary JIT elevation counts as that role). Holding the RBACAdministratorrole or a globalSecurity:Executegrant on an account whose role isuserdoes 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:Executewithout theadminaccount 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 holdSecurity:Executeglobally 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) and503(store). The table is grouped by cause, not ordered by when a request meets them: the500for an unwired audit identity writer comes first, right after authentication; the401for a changed principal comes at the second look-up, after the scoped gate andwhitelistvalidation, and before the dispatch check:
Status When Reason label (audit detail, metric)403RBAC on: the caller holds Security:Executeglobally but is not an administrator (account roleuser, no live JIT elevation)approval_required403RBAC on: the caller’s own Security:Executegrant 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)forbidden400The quarantine.quarantinepair cannot be classifiedunclassifiedorambiguous403Other 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 the401kill_switched,anonymous_operator401The 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.enablerow withresult=deniedanddetailauthenticated 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.enablerow withresult=failure)none 500The audit identity writer is not wired on this server (misconfiguration; written as a quarantine.enablerow withresult=failureanddetailaudit identity writer not configured)none Every refusal carries
error.messageanderror.remediation. Forapproval_requiredthe 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. Forforbiddenthe message ispermission denied: Security:Executeand 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, checkyuzu_server_rbac_read_degrade_totalbefore treating the refusal as permanent. The403bodies never carry apermissionfield: 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 noattemptaudit row. It writes aquarantine.enablerow withresult=deniedand the reason indetail, and incrementsyuzu_server_dispatch_denied_total{reason}. A session that expires between the first authorisation check and the request’s second look-up now ends at the401instead of writing a record attributed tosystem. 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 at401with adeniedrow 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 theattemptand outcome rows name the same account as the record’squarantined_by; rows written by the scoped permission gate itself, and thefailurerow for an unwired writer, still come from a look-up of the request. A server whose audit identity writer is not configured answers500on both routes and changes nothing. Thedeniedrows 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"
}
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string | Yes | Target device ID |
reason | string | No | Human-readable reason for quarantine |
whitelist | string | No | Comma-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" }
}
400vs503(ADR-0047). A400means a business/state error (a missingagent_id, the device is already quarantined, orwhitelistfails server-edge validation) — retrying the identical request will not succeed, anderror.retry_after_msisnull. A503means a genuine store/pool failure — retrying is reasonable, anderror.retry_after_mscarries a concrete5000hint (REST’s envelope has no nesteddataobject — that’s MCP’s JSON-RPC shape; REST matches the MCPquarantine_devicetwin’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/quarantinewrites the quarantine record only, synchronously. The live plugin isolation is dispatched by the MCPquarantine_devicetool, which has no REST twin. Two consequences for a client that treats the two transports as interchangeable:
- A
201here means the record was written, not that the device’s firewall is enforcing anything yet. To isolate a device immediately, dispatchquarantine.quarantinethrough 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.QuarantineContainmentReconcilerreconciles 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 storedwhitelistthe re-dispatch validator refuses. As of #3425 (d1f71c58f) this route validateswhitelistat write time (400instead of201for a malformed value), the same rule MCP’squarantine_devicealready enforced — so a fresh write here can no longer land in this state. Avalidation_failedreconcile now points at a record written via this route befored1f71c58fshipped, or — on a fleet with pre-#3623 history — one migrated from a legacyquarantine.dbby the since-retiredmigrate_from_sqlitebackfill (ADR-0047, which copiedwhitelistverbatim with no validation), not a fresh call. Either way, every reconcile attempt then countsvalidation_failedand nothing is ever dispatched. That failure is loud, not silent — the device stays inyuzu_server_quarantine_endpoint_unconfirmed{reachability="connected"}and tripsYuzuQuarantineEndpointUnconfirmedafter 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. The400-vs-503split 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, incrementsyuzu_server_dispatch_target_rejected_total{reason="quarantined"}, and writes aquarantine.dispatch_deniedaudit row (target_type=Security;target_idis 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/commandreports what it withheld. The success body carrieswithheld_quarantined, (#3424/#3511)withheld_unknown_pluginand (#5294)withheld_kill_switched_os— all always present,0on a clean dispatch — soagents_reached: 97on 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 carriesaudit_emitted(#2557), the same field every denial arm that callsemit_behavioral_auditon 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 andfalseonly when the pairedcommand.dispatchresult=successaudit row failed to persist (the response also setsSec-Audit-Failed: truein 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
503now 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.reasonMeaning retry_after_mscontainment_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. 5000quarantinedEvery target named is contained. The dispatch was withheld, not attempted. null— retrying will not help until the device is releasedplugin_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 installedkill_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 OSos_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_osandos_gate_unreadableare never produced for a protective action (the quarantine plugin’s four actions and__guard__.push_rules); see Upgrading.
reasonis a top-level key on the error object, not part of the A4error.dataenvelope.POST /api/instructions/{id}/executecarries the samereason503split asPOST /api/commandabove (containment_unreadable/quarantined/plugin_not_found/kill_switched_os/os_gate_unreadable, sameretry_after_msvalues) — closed alongside the architect finding that its response-building code was readingdispatch_outcome.command_id/.sentonly, discarding the richer fields already available on the same struct.A fourth, structurally distinct condition —
invalid_scope— is400, not part of the503table above. This route (unlikePOST /api/command) accepts a caller-suppliedscopeexpression; a malformed one is a client mistake, not a fleet-state fact, so it reportserror.reason: "invalid_scope"at400withretry_after_ms: null, checked before the503cascade so a bad expression is never shadowed bycontainment_unreadable. Its generic catch-all (noreasonkey, 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 aconcurrency_mode: for aper-devicedefinition, every named target may have been excluded by an already-open claim rather than being unreachable at all; the message points atyuzu_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.syncand__sync__.now, a closed set counted (not per-event audited) byyuzu_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 onyuzu_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" }
}
400vs503(ADR-0047). Same distinction asPOSTabove —400means the device is not currently quarantined (retrying will not help);503means 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
Securityoperations: certificate revocation usesSecurity:Delete(a destructive operation on an issued cert), while device quarantine usesSecurity: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
retry_after_ms: 5000(CA store unavailable, also afailurerow), and a server built without the check is503with noretry_after_ms(certificate list unavailable).
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:
| Parameter | Type | Description |
|---|---|---|
kind | string | Required in this form: active, expiring, expired, revoked, issued, issued_week or revoked_week |
days | integer | Required for expiring (3, 7, 14, 21, 30, 60), expired and revoked (7, 30, 60, 90) and issued (7); refused on other kinds |
week | string | ISO 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 |
bucket | string | superseded, unpublished or other; optional, revoked and revoked_week only. Omitted lists every reason |
as_of | integer | Epoch 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 |
limit | integer | 1 to 500, default 100 (refused outside the range, not clamped) |
cursor | string | The 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:
- 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). - 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-widesecrets_kek_opadvisory lock — wait for it to finish and retry."the live KEK version ceiling has been reached"(#2530) means--kek-max-live-versionshas 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"(SQLSTATE57014— deliberately worded that way, not “timed out”: Postgres also raises this for an administrator’spg_cancel_backend, and it is not necessarily transient — checkstatement_timeout, database load, cancellation activity, and the registered-column scan size before retrying), and"the KEK rotation clock is untrustworthy"(the newestsecrets.kek_metarow 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/rotatefor 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 carryretry_after_ms.
500half-committed — do NOT retry this route. If rotation registers the new version but fails to finish re-wrapping every row, this returns500with a remediation telling you to callPOST /api/v1/secrets/kek/rewrapto resume. Retrying/rotateinstead would mint a second, spurious version on top of the half-rotated state. This classification wins over aquery_canceledcause if the cancellation happened after the mint committed — you still get told to call/rewrap, never/rotate.
429rotation 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 honestretry_after_ms. The sole authoritative rate limit today is read fromsecrets.kek_meta.created_aton the database server’s own clock, cluster-wide and restart-persistent:--kek-min-rotate-interval(seconds, default3600= 1h). A fresh install’s first rotate attempt is refused for up to this interval too — KEK v1 is minted at boot withcreated_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/rotateon a500would otherwise mint a fresh version on every retry, and a KEK version can never be retired (#2525)./rewrapis NOT rate-limited and is the correct way to finish a half-committed rotation. Unlike the409/503cases above, this429’sretry_after_msis honest — computed from the same durable database-clock timestamp, so waiting genuinely resolves it (the field is auint32millisecond 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 returns409, not this.
--kek-min-rotate-intervalis 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 (/rewraponly 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 wedgedsecrets_kek_oplock — before these existed, a stuck holder made every KEK operation409forever with no way to see why.lock_held/lock_holder_pidreport the lock in this server’s own database (the underlying query is filtered bycurrent_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 treatlock_holder_pidalone 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 againstpg_stat_activitybefore any DBA action) is indocs/user-manual/server-admin.md§ “Diagnosing a stuck KEK op lock”.
live_versionsandlock_heldarenull— never a fabricated0/false— when the underlying query could not be determined. The key is always present;nullmeans “could not be determined”, not a truthful negative. This matters most forlock_held: anullhere must never be read as “no lock is held” — it means the lock state is UNKNOWN. Corroborate viapg_stat_activitybefore concluding anything, exactly as you would for a genuinely-held lock.
lock_holder_pidisnullin THREE distinct situations — always checklock_heldfirst 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: truewithlock_holder_pid: null→ the lock is held, but the holder’s backend pid could not be read frompg_locksat query time — there is no pid to corroborate againstpg_stat_activity; instead querypg_locksdirectly for any granted holder ofsecrets_kek_opin the current database (seedocs/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_kekexists 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" }
| Field | Type | Required | Description |
|---|---|---|---|
role | string | Yes | An 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:
| Condition | Response |
|---|---|
Caller lacks UserManagement:Read | 403 |
| The RBAC store is closed, its connection pool timed out, or the read failed | 503 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:
| Condition | Response |
|---|---|
Caller lacks UserManagement:Read | 403 |
{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 failed | 503 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"
}
| Field | Type | Required | Description |
|---|---|---|---|
securable_type | string | Yes | The resource type to check |
operation | string | Yes | The 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:
| Status | Meaning |
|---|---|
| 403 | Requires AccessReview:Read |
| 503 | RBAC 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" }
| Field | Type | Required | Description |
|---|---|---|---|
principal_type | string | Yes | Must be "user" — group-scoped fleet-wide assignment is not supported yet. |
principal_id | string | Yes | The 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:
| Parameter | Type | Required | Description |
|---|---|---|---|
agent_id | string | Yes | The 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"
}
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string | Yes | Target agent |
key | string | Yes | Tag key (max 64 chars, [a-zA-Z0-9_.:-]) |
value | string | Yes | Tag 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
}
| Field | Type | Required | Description |
|---|---|---|---|
id | string | No | Auto-generated if omitted. The reserved id __default__ is rejected. |
name | string | Yes | Operator-facing label. Max 200 characters. |
description | string | No | Optional longer description. |
columns | array of strings | No | Subset of plugin column names. Empty / omitted means “show all”. |
sort | object | No | {column: <name>, dir: asc|desc}. dir requires column. |
filters | array of objects | No | {column, op, value} clauses. op ∈ equals, not_equals, contains, starts_with, ends_with. |
default | boolean | No | At 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:
| Status | Reason |
|---|---|
| 400 | invalid JSON / missing name / unknown filter op / id collision / multiple default templates / __default__ as authored id |
| 404 | Definition not found |
| 413 | Body exceeds 64 KiB cap |
| 500 | Persist failure (rare; see server logs) |
| 503 | Service 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:
| Status | Reason |
|---|---|
| 400 | malformed id / __default__ reserved / invalid JSON / validation failure |
| 404 | Definition or template not found |
| 413 | Body exceeds 64 KiB cap |
| 500 | Persist failure |
| 503 | Service unavailable |
DELETE /api/v1/definitions/{id}/response-templates/{template_id}
Remove a template. Returns 400 when template_id is __default__.
Permission: InstructionDefinition:Write
Errors:
| Status | Reason |
|---|---|
| 400 | malformed id / __default__ reserved |
| 404 | Definition or template not found |
| 500 | Persist failure |
| 503 | Service 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 legacyGET /api/audit) return503when the underlying store or connection pool is unavailable — never an empty200. A reviewer, SIEM, or CMDB integration can therefore never mistake an infrastructure blip for “no audit activity”; treat a503here 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-Failedon the behavioural-PII routes).
Who may read the trail. Every audit read surface —
GET /api/v1/audit,GET /api/v1/audit/auth-sample, the legacyGET /api/audit,GET /api/v1/engine-principals/audit/no-admin, and the MCPquery_audit_log/audit_engine_no_admintools andyuzu://audit/recentresource — is gated onAuditLog:Read. With RBAC disabled (the shipped default) only anadmin-role session may read the trail (#5771):AuditLog:Readis in the authorization topology floor, so any other authenticated session is refused with403and the denial is audited with adetailbeginningtopology 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 seededAdministrator,PlatformEngineer,Operator,ITServiceOwnerandViewerroles 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:
| Parameter | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Max events to return (default 100, max 1000) |
offset | integer | No | Rows to skip, 0..10000 (default 0) |
principal | string | No | Filter by acting user |
action | string | No | Filter by action name |
target_type | string | No | Filter by target type (exact match) |
target_id | string | No | Filter by target id (exact match); requires target_type |
since | integer | No | Inclusive lower bound, epoch seconds. Omit for no bound; 0 is accepted and matches the same rows as omitting it. Negative is 400 |
until | integer | No | Inclusive 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
samplingobject reports this:candidates_considered(pool size the sample was drawn from),scan_cap(10000), andrecency_capped(truewhen the pool hit the cap). For a uniform sample of a high-volume period, narrow thefrom/towindow so it holds ≤ 10000 events. Samples are also non-reproducible (no seed) — re-running yields a different draw; the auditedaudit.auth_sample.exportedrow 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:
| Parameter | Type | Required | Description |
|---|---|---|---|
from | integer | No | Window start, epoch seconds, digits only (omit for unbounded; 0 is accepted and matches the same rows as omitting it) |
to | integer | No | Window end, epoch seconds, digits only (omit for unbounded; to=0 is 400, 0 is not a way to say unbounded) |
limit | integer | No | Sample 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:
| Action | Description |
|---|---|
audit.auth_sample.exported | Auth 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.create | Group created |
management_group.update | Group updated (rename, re-parent, membership type change) |
management_group.delete | Group deleted |
management_group.add_member | Agent added to group |
management_group.remove_member | Agent removed from group |
management_group.assign_role | Role assigned on group |
management_group.unassign_role | Role removed from group |
api_token.create | API token created |
api_token.revoke | API 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.create | Local 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_change | Local 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.delete | Local 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_change | Local 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_reset | Admin 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_required | Centralised 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.applied | Account 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.cleared | Account-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.fetch | GET /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_subscribe | Server-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.subscribe | Agentic-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.create | Instruction 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.update | Instruction 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.delete | Instruction 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.create | Instruction 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.delete | Instruction 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_failed | Emitted 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_aborted | Emitted 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.dispatch | Live-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.collate | Live-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.create | Policy fragment created. result ∈ {success, denied}. Denied detail value: duplicate_name (409, fragment with the same name already exists). |
policy.evaluate | Compliance 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.remediate | Manual 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.enable | Device 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.disable | Device released from quarantine (attempt, success or failure; denied in the same two cases as quarantine.enable) |
ca.cert.issued | Internal 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.revoked | Certificate 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.published | target_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.exported | The 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.imported | An 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.set | Tag created or updated |
tag.delete | Tag 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-0017authorize_list_readadmit-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. Seedocs/auth-architecture.md“Periodic access reviews” for the full rationale.AccessReviewis a dedicated securable type (not a reuse ofAuditLog) seeded only toAdministratorand theReviewerrole — an earlier round of this feature gated it onAuditLog:Read/AuditLog:Attestinstead, which over-disclosed the full grant population to theOperatorandPlatformEngineerroles (both seededAuditLog:Readfor unrelated reasons, neither seededUserManagement:Read/EnginePrincipal:Read, the permissions gating the equivalent-sensitivity/rbac/rolesand/engine-principals/{id}/rolesroutes — the latter moved off the over-broadSecurity:Readin #2376). Seedocs/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"orauth_source="engine_token", the samedeny_engine_sessionbelt 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 gets403; the corresponding audit verb is recorded withresult=deniedanddetail="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:
| Parameter | Type | Required | Description |
|---|---|---|---|
format | string | No | json 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).
| Value | Meaning |
|---|---|
enabled | RBAC reads as ON right now (a cached ON always reports this, live or stale) — grants above are enforced. |
disabled | RBAC reads as OFF, and that OFF reading is confirmed fresh (not stale) — grants above are not enforced; any authenticated session gets the legacy fallback. |
degraded | The 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" }
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Human-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."
}
| Field | Type | Required | Description |
|---|---|---|---|
principal_type | string | Yes | user | group | engine |
principal_id | string | Yes | Principal identifier |
role_name | string | Yes | Role being reviewed |
decision | string | Yes | attested | flagged_revoke |
justification | string | No | Free-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:
| Action | Description |
|---|---|
access_review.exported | Cross-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.list | Campaign 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_opened | Review 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.attested | Reviewer 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.flagged | Reviewer 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.closed | Campaign closed via POST /api/v1/access-reviews/{id}/close. target_id=<campaign_id>. result ∈ {success, failure}. |
access_review.get | Campaign 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:
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | No | Filter by fragment name (substring match) |
limit | integer | No | Maximum 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:
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | No | Filter by policy name (substring match) |
fragment_id | string | No | Filter by fragment ID |
enabled_only | boolean | No | Return only enabled policies |
limit | integer | No | Maximum 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 ReplayingGET /api/config.
Secret values are never returned. For an override whose key holds a credential (today:
oidc_client_secret) thevaluefield 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_byandupdated_atare still returned.PUT /api/config/oidc_client_secretsets 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.
oidc_*keys: the caller’s OWN role must beadmin(the local account role, or the role mapped from the identity provider), and the caller must not be an MCP-tier token (an elevation does not count). The handler also refuses a service-scoped token, as defence in depth, because the permission gate already does. A session that is an administrator only through a temporary elevation, a non-administrator holdingInfrastructure:Writeand an MCP-tier token are all refused. The403message is “changing sign-in provider settings needs your own administrator role”;detailiselevated_session_oidc_setting_blocked(elevation) oradmin_role_required_oidc_setting_blocked(any other non-administrator). An MCP-tier or service-scoped token is refused whatever its creator’s role, with the message “an administrator account is required; MCP-tier and service-scoped tokens cannot change sign-in provider settings” anddetailtoken_session_oidc_setting_blocked.- Administrator-only keys:
plugin_signing_required,dex_alert_routing,dex_blast_min_devices,dex_blast_window_seconds,dex_blast_cooldown_seconds,dex_cohort_export_keyandaudit_retention_days. The first six apply the same caller rule as their Settings routes, which are administrator-only.audit_retention_dayshas no Settings route (this route is its only writer); it is administrator-only by design because it is the stored audit-retention setting (reported byGET /api/config, evidence-bearing, and the lever that will govern enforcement once #483 lands), not because aPUTchanges the retention applied today: the value is stored only and is not enforced (see When a change takes effect; the enforced window is--audit-retention-daysorYUZU_AUDIT_RETENTION_DAYSplus a restart). It is administrator-class whileresponse_retention_daysandguardian_event_retention_daysstay onInfrastructure:Writebecause it is audit evidence and the other two are operational data; all three are equally stored only today, and the choice should be revisited if any retention store gains a setter. The Settings pages do not expose it: write it with an administrator API token that is not MCP-tier (exempt from the same-site check), or from an administrator session (a cookie session is a browser write and needs a same-siteOriginorReferer). The rule is the same for all seven: a session with an MCP-tier token or a service-scoped token, or whose EFFECTIVE role is notadmin, is refused (an active temporary elevation counts asadmin, exactly as on the Settings routes; this differs fromoidc_*). The role is the account’s legacy role or a live temporary elevation, never an RBAC grant: an RBAC Administrator grant alone does not count, so with RBAC on a user whose only administrator authority is that grant getsadmin_role_required_setting_blocked; an engine principal is refused too.detailistoken_session_admin_setting_blocked(MCP-tier or service-scoped token) oradmin_role_required_setting_blocked(effective role notadmin). The403message is “an administrator account is required; MCP-tier and service-scoped tokens cannot change this setting” for a token, and “changing this setting needs your own administrator role” for a non-administrator role. Theremediationfield is “change this setting with an administrator API token that is not MCP-tier, or as an administrator from the Settings page where one exists”. A plain administrator API token that is not MCP-tier is admitted. Only the caller rule is shared with the Settings routes: the Settings routes also validate and clamp DEX values and tag keys and apply them live, which this generic route does not (see When a change takes effect).
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):
| Key | Type | Takes effect | Description |
|---|---|---|---|
heartbeat_timeout | integer | immediately | Seconds 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_days | integer | stored only, not enforced - see note | Days to retain command response data |
audit_retention_days | integer | stored only, not enforced - see note | Days to retain audit log entries. Administrator-only (see Permission); no Settings page writes it |
guardian_event_retention_days | integer | stored only, not enforced - see note | Days to retain guaranteed-state events |
log_level | string | immediately | Server log verbosity (trace, debug, info, warn, error) |
auto_approve_enabled | boolean | never - see note | Accepted and stored, but no consumer reads it back |
plugin_signing_required | boolean | records intent only - see note | Server-side record of the Settings “Require signed plugins” toggle |
oidc_issuer | string | see note | OIDC issuer URL |
oidc_client_id | string | see note | OIDC client ID |
oidc_client_secret | string | see note | OIDC client secret (write-only through this API) |
oidc_redirect_uri | string | see note | OIDC redirect URI |
oidc_admin_group | string | see note | OIDC admin group ID |
oidc_skip_tls_verify | boolean | see note | Skip TLS verification to the IdP (test only) |
dex_alert_routing | string (JSON array) | persisted only - see note | Routed DEX observation types |
dex_blast_min_devices | integer | persisted only - see note | Blast-radius alert threshold (clamped on apply) |
dex_blast_window_seconds | integer | persisted only - see note | Blast-radius window |
dex_blast_cooldown_seconds | integer | persisted only - see note | Blast-radius cooldown |
dex_cohort_export_key | string | persisted only - see note | Cohort export tag key; "" disables export |
dex_score_policy | string (JSON document) | typically within 5 seconds | The 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:
- immediately - only two keys.
heartbeat_timeoutis assigned into the live server config and re-read on every stale-session sweep;log_levelis applied byRuntimeConfigStore::set()itself viaspdlog::set_level. - stored only, not enforced -
response_retention_days,audit_retention_days,guardian_event_retention_days. The handler updates the in-memory config, soGET /api/configreports the NEW value - but each store copied its retention window when it was constructed and exposes no setter, so the stores keep enforcing the window they were built with. The API reports the stored value, not the value being enforced.GET /api/v1/settings/data-retentionand the Data and retention page show the enforced value underenforced. To change a window, set--response-retention-days,--audit-retention-days,--guardian-event-retention-days(or the matchingYUZU_*environment variable) and restart. No route removes a stored override. - records intent only -
plugin_signing_requiredis read in exactly two places, both of which render the Settings status badge. No server or agent code consumes it to require signatures, and it is not carried in any proto field, so it never reaches an agent. Agent-side enforcement is the agent’s own--plugin-require-signatureflag paired with an operator-distributed--plugin-trust-bundle. Setting this key records the operator’s intent and changes what Settings displays; it does not harden anything by itself. - persisted only, applied at next boot or next save on the DEX → Alerts tab -
dex_alert_routing,dex_blast_min_devices,dex_blast_window_seconds,dex_blast_cooldown_seconds,dex_cohort_export_key. The live consumers (DexAlertRouter, the blast-radius detector, the cohort-gauge export key) are cached in memory and refreshed only byapply_dex_alert_config(), which runs at server startup and from the DEX -> Alerts tab save handlers - never from this endpoint. APUTthrough this API is inert on the running server until one of those two happens. To change DEX alerting and have it apply at once, use the DEX -> Alerts tab. - typically within 5 seconds -
dex_score_policy. Every server instance reads the stored document through a short-lived cache and refreshes it at most every 5 seconds while it serves requests, so a change is typically in use on each instance within 5 seconds with no restart; the policy in use is up to 60 seconds old when the store is unreadable. It is written through its own route,PUT /api/v1/dex/score-policy(the instance that served the write uses the new policy at once); it is read-only through this API (see above). - never -
auto_approve_enabledis accepted by the allow-list and written to the store, but no code reads it back from there.GET /api/configreportsauto_approve_enabledderived from whether any auto-approve rule exists, not from the stored value, so aPUTof this key can appear to have no effect - because it has none. Manage auto-approve through its own rules API. - OIDC keys (see note) - persisted only for the provider, and a restart does not rebuild it from
them. The
OidcProvideris constructed early in server startup from CLI/environment configuration, whereas the stored runtime-config overrides are applied later in the same startup (apply_runtime_config_overrides()); nothing rebuilds the provider afterwards. So a value written through this API never reaches the live provider, on this boot or any later one. Only Settings -> OIDC (POST /api/settings/oidc) applies OIDC settings, because that handler constructs a new provider and swaps it in - but that swap is process-local. The startup path DOES read these keys out of the store, just too late to matter to the provider - it is already built and nothing rebuilds it - so a restart builds the provider from whatever the command line or environment supplies. To change OIDC durably, update that value as well as saving through Settings. This matters most for a credential rotation: aPUTofoidc_client_secretreturns"applied": truemeaning persisted and accepted, never in use. The storedoidc_admin_groupis the exception: it does take effect after a restart, which is why writing anyoidc_*key needs your own administrator role.
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, a403permission denial, or a503authorization-store failure carriescorrelation_id(echoed onX-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/configis a legacy non-/api/v1route, and its own errors are either a bareerrorstring or a nested{"error":{"code","message"},"meta":{"api_version"}}object with nocorrelation_idand noretry_after_ms. Besides those shown below, the handler emits nested bodies for400“missing ‘value’ in request body”,400“invalid JSON body”,400“request body nests too deeply” (over 32 levels,kMcpMaxJsonDepth), and a503“runtime config store unavailable” when the runtime-config store is unavailable (GETandPUTnow share the identical message; an earlierGET-side wording of “runtime configuration store unavailable” was a drift, not a deliberate distinction, and has been unified). Note503and403are emitted by both sources, so status alone does not tell you which shape you have: test forerror.correlation_idrather than assuming it, on every status. These refusals use the A4 envelope (correlation_idpresent):403cross-site request refused, the403for a refused administrator-class write (see Permission; it carries aremediationand nopermissionfield),503audit log unavailable; the change was not made(retry_after_ms: 5000, aremediationhint, headerSec-Audit-Failed: true) and the400fordex_score_policy(written only through its own route,PUT /api/v1/dex/score-policy; no audit row). An unknown key is a bare400and 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:
- Success:
200 {"data": {"key": "...", "value": "...", "applied": true, "effect": "..."}, "meta": {"api_version": "v1"}}.appliedmeans the value was accepted and stored, not that it is in use:effectsays how soon it matters.immediateis used by the running server now (heartbeat_timeout,log_level);deferredwaits for the next DEX -> Alerts save or a restart (thedex_*keys) or for a restart (oidc_admin_groupandoidc_redirect_uri);stored_onlymeans nothing consumes it (the retention windows,auto_approve_enabled,plugin_signing_required, which records intent and hardens nothing, and the otheroidc_*keys).immediateis for the server that took the write: other replicas and agents behind a gateway keep the boot-timeheartbeat_timeout, see When a change takes effect. A secret key’svalueis omitted. If only the outcome audit row is lost the change stays applied,datacarries"audit_persisted": falseand the response carriesSec-Audit-Failed: true. - Errors: every refusal is an A4 error envelope:
400for a missing or invalidvalue(a JSONnull, array or object is refused: send a string or a number or boolean), an unknown key, or a key with its own route (dex_score_policy);401when a fresh MFA proof is needed (the step-up envelope below);403for a refused caller (withremediation, nopermissionfield), a cross-site cookie write, or a SAML session or engine principal (step-up);503when the store is unavailable or theattemptedrow cannot be stored (withretry_after_ms). - Step-up: one of the step-up routes: an MFA-enrolled local or OIDC cookie session needs a fresh proof per
--mfa-enforcement; un-enrolled accounts and API-token and MCP-token callers pass. It is checked by the central step-up check before the route runs, ahead of the permission and caller-rule checks, so an enrolled cookie session without a fresh proof gets the step-up401even if it also lacksInfrastructure:Write. The legacyPUT /api/config/:keyis unchanged and is not step-up gated.
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_urlis sanitized of embedded userinfo credentials (a URL of the formclickhouse://user:pass@host:9000/dbhas theuser:pass@segment stripped) before being returned — this closes a gap where the sibling HTML fragment previously rendered the URL verbatim, masking only the separateclickhouse_passwordfield. The raw password is never returned at all; onlyclickhouse_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}$"
}
| Field | Type | Required | Description |
|---|---|---|---|
key | string | Yes | Property key (unique identifier) |
display_name | string | No | Human-readable label |
type | string | No | Value type: string (default), int, bool, datetime |
description | string | No | Free-text description |
validation_regex | string | No | RE2 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"
}
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Endpoint to receive POST notifications (http:// or https://; see the cleartext-HTTP warning below) |
event_types | array | Yes | Events to subscribe to (see the table below) |
secret | string | No | HMAC-SHA256 secret for payload signing |
Event types (the event_types filter matches these literal strings):
| Event type | Fired when | Notable payload fields |
|---|---|---|
agent.registered | An agent enrolls or re-enrolls | agent_id, hostname, os, arch, agent_version |
execution.completed | Once per agent response to a command, not once per instruction: each target agent that answers produces its own event | command_id, agent_id, status, exit_code (and error when the response carries one) |
dex.blast_radius | N 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.signal | A 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:
- Create a webhook targeting your SIEM, Slack, or automation endpoint.
- Subscribe to the event types relevant to your workflow.
- Optionally set an HMAC secret and verify the
X-Yuzu-Signatureheader on receipt. - Monitor delivery history via
GET /api/webhooks/{id}/deliveriesto 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).
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique stable identifier referenced from spec.offload.targets. |
url | string | Yes | http:// or https:// POST endpoint. |
auth_type | string | No (none) | One of none, bearer, basic, hmac. |
auth_credential | string | No | Bearer token (Bearer), user:pass (Basic), shared secret (Hmac). Never returned by any endpoint. |
event_types | string | No (*) | Comma-separated list of event types or * for all. Same semantics as webhooks. |
batch_size | int | No (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). |
enabled | bool | No (true) | When false, no events are dispatched until re-enabled. |
Auth headers (set per auth_type):
none→ no Authorization header.bearer→Authorization: Bearer <auth_credential>.basic→Authorization: Basic <base64(user:pass)>.hmac→X-Yuzu-Signature: sha256=<hmac-sha256(auth_credential, body)>. Mirrors the webhook shape so receivers can share verification code.
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:
- 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).
- Set
event_typesto the events you actually need;execution.completedis the typical analytics feed,agent.registeredfor inventory hydration. - Tune
batch_sizeto your downstream ingestion preference. SIEMs commonly prefer 50–500 per POST; real-time alerting wantsbatch_size=1. - Monitor delivery via
GET /api/v1/offload-targets/{id}/deliveries. Repeatedconnection_failederrors 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.
- Splunk HEC uses the non-standard header
Authorization: Splunk <token>. Yuzu’sbearermode emitsAuthorization: Bearer <token>. Splunk HEC will reject these with HTTP 401. Useauth_type=none+ a Splunk HEC token enabled for “no authentication” (network-layer controls only) or front Splunk with a small reverse proxy that rewrites the header. - AWS S3 / EventBridge / Kinesis require AWS Signature v4 (Sigv4). Yuzu does not generate Sigv4 signatures in this revision; direct PUTs to S3 buckets and EventBridge endpoints will not work. Front them with a Sigv4-signing reverse proxy (e.g.
aws-sigv4-proxy). - Azure Monitor / Sentinel use AAD token flow. Not directly supported. Front with a token-refresh shim.
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": {}
}
| Field | Type | Required | Description |
|---|---|---|---|
scope | string | Yes | Scope expression selecting target agents |
parameters | object | No | Override 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:
- Define a workflow with ordered steps (each step maps to an instruction definition).
- Execute the workflow against a scope expression to target specific agents.
- Monitor execution progress via
GET /api/workflow-executions/{id}. - 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.
| Parameter | Meaning |
|---|---|
workflow_id | Exact workflow filter (1-128 characters of A-Z a-z 0-9 _ -). |
status | Exact filter: pending, running, completed, failed, cancelled. |
since / until | Epoch seconds, started_at >= since and started_at < until; 0 to 2^53; since must not be after until. |
limit | Page size, default 50. Below 1 is a 400; above 500 is clamped to 500. |
cursor | The previous page’s next_cursor. A position only: it does not bind the other filters. |
latest_per_workflow | true 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:
limitis clamped on both bounds on the two v1 routes that accept it,GET /api/v1/responses/{id}([1,1000], default 100 when omitted) andGET /api/v1/responses/{id}/export([1,10000], default 10000 when omitted);GET /api/v1/responses/{id}/aggregatehas nolimitparameter. The legacy export route (GET /api/responses/{id}/export) clamps the same way as of #4703 (it used to floor only its default at 10000, so an explicit?limit=999999999asked the store for an unbounded fetch), solimit=0or a negative value serves one row there. The legacy list route (GET /api/responses/{id}) now caps an explicitlimitat 1000 like v1 and MCP; on that route alimitof zero or below still means the store default (100). When the caller asked for more than 1000 and the page came back full (1000 rows), the body also carries a top-level"result_truncated_by_cap": true: more rows may exist past the ceiling, and a result of exactly 1000 sets it too, because that fetch does not look ahead. The field is absent otherwise, so a request that never exceeded the ceiling gets the same body as before.- Audit posture. All three legacy readers (
GET /api/responses/{id},.../aggregate,.../export) write aresponse.readsuccess row on every served read, with the same verb, target type and result as the v1 twins so the two are countable together; only thedetaildiffers (legacy response <query|aggregate|export> cid=<id>, againstREST v1 response <query|aggregate|export> cid=<id>). Like v1 they are fail-closed: a success row or a scope-dropdeniedrow that cannot be persisted, or a throwing audit pipeline, is a503withSec-Audit-Failed: true, an A4 envelope (retry_after_ms: 5000) and no data. A request rejected for a malformed parameter writes no row. The legacy detail strings keep thesurface=get|aggregate|exporttokens on thedeniedrows; v1 usesv1_get,v1_aggregateandv1_export. offsetis rejected with400onGET /api/v1/responses/{id}andGET /api/v1/responses/{id}/export(the two routes below with a row-level result set), matchingGET /api/v1/executions/{id}/responsesand MCPquery_responsesabove: the result set orders by a non-unique, actively-growingtimestamp, so offset-based paging can silently skip or duplicate rows. The legacy catch-all route still acceptsoffset(a pre-existing gap this change does not fix).
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):
- the taxonomy —
securable_typesandoperations, i.e. what the RBAC model can express — requires only the route’s ownInfrastructure:Read. It says nothing about who holds what, and an agentic worker needs it to author a grant at all (A2 discovery). - the role grid —
roles[].permissions[], every role’s actual granted securable/operation/effect — additionally requiresUserManagement:Read. That grid is authorization topology, and it is strictly more thanGET /api/v1/rbac/rolesdiscloses, so it carries the same permission the authorization-topology floor applies there.
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:
- Dialect. It is a JSON Schema subset, but the server (not the client) is the validator.
minLengthandmaxLengthcount UTF-8 bytes.patternuses RE2 syntax, so a generic JSON Schema validator in JavaScript may disagree with the server:(?P<name>...)named groups are valid RE2 but rejected by JavaScript, and\A/\zanchors are valid RE2 but JavaScript accepts them as a literalA/z. Anintegerproperty accepts a decimal digit string ("24") or a JSON integer; the execute route refuses a non-integral number such as24.5and also an integral float such as24.0. - Defaults.
defaultis published as documentation only. The server never injects it, and a parameter that has a default is not listed inrequired, so the client must still send it unless the target plugin applies its own default (some plugins reject an omitted defaulted parameter:crossplatform.echo.recent_processesdeclares a defaultsql, and theechoplugin’ssqlaction answersmissing required 'sql' parameterwithout it). See docs/yaml-dsl-spec.md for the DSL side of the same rule. - MCP. The MCP
execute_instructiontool takes string-valuedparams, so send anintegerorbooleanproperty in its string form ("24","true"). - Plugins catalogue.
GET /api/v1/discover/pluginsstill carries each action’s inlineparameter_schemain the raw stored DSL form (int32,displayName); the canonical form isinput_schemahere, for published definitions.
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(was1; v2 added the inlineparameter_schemaand top-levelactions_enriched_with_schemafields). The revision is additive; treatversionas 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).
-
API: the OpenAPI document link, an Open API reference button that opens the
/swaggerpage in a new tab, and a live count of documented operations. The count is path x method pairs in the hand-maintained OpenAPI document, not the number of registered routes, so it includes the operations marked unavailable. -
Routes: the
/discover/routescatalogue with a path/summary filter, capped at 300 rows. A route’s free-textdescriptionis not shown. An operation the document lists but this build does not serve (theunavailablefield of the catalogue) keeps its row and carries an unavailable marker with the reason, so it is not mistaken for a callable route. -
Permissions: each
Securable:Operationpair with the roles that allow it. The role grid needsUserManagement:Read, as on/discover/permissions; without it the page says so instead of listing nothing. -
Plugins: the build-embedded manifests with per-OS support. The list of plugins connected devices currently report is a separate, on-request list: no connected device is not the same as no plugins.
-
Load generator: Start and Stop send
{"plugin":"chargen","action":"chargen_start"}or"chargen_stop"to the existingPOST /api/commandand nothing else (no parameters, no target list), so a run always means every device the caller can see, at the plugin’s default of about 10 lines a second per device. Start asks first and states that scope and that every open Dashboard receives the rows; Stop sends at once. The same gates apply as to anyPOST /api/command:Execution:Execute, andchargen_startalso needs an administrator or an approval, whose refusal text is shown as the server wrote it. The result line counts the devices the request reached and any withheld; “sent” does not mean running or stopped. “Devices online” is the count connected to this server.
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:
| Status | Reason |
|---|---|
| 503 | Inventory 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:Executegrant 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:
| Status | Reason |
|---|---|
| 400 | RESULT_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”) |
| 400 | RESULT_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) |
| 400 | RESULT_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 |
| 400 | RESULT_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) |
| 400 | RESULT_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 |
| 400 | sql/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 |
| 404 | Unknown instruction_id, unknown parent set, or (on re-eval) a set the caller does not own |
| 429 | RESULT_SET_QUOTA_EXCEEDED — owner is at the per-owner set cap |
| 500 | RESULT_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 |
| 500 | RESULT_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 |
| 503 | RESULT_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 |
| 503 | RESULT_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) |
| 503 | RESULT_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:
| Status | Reason |
|---|---|
| 404 | No inventory data found for this agent+plugin |
| 503 | Inventory store unavailable or degraded |
POST /api/v1/inventory/query
Query inventory data across agents with filters.
Permission: Inventory:Read
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string | No | Filter by agent ID |
plugin | string | No | Filter by plugin name |
since | integer | No | Unix 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 |
until | integer | No | Unix 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) |
limit | integer | No | Max 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:
| Status | Reason |
|---|---|
| 503 | Inventory 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:
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string | No | Scope to a single agent |
combine | string | No | "all" (default) or "any": how to combine multiple conditions; any other value means all |
conditions | array | Yes | List of condition objects (at most 500; more is 400) |
Each condition object:
| Field | Type | Description |
|---|---|---|
plugin | string | Plugin name to query |
field | string | JSON field path within the inventory data |
op | string | Operator: ==, !=, >, >=, <, <=, contains, exists, version_gte, version_lte. An unrecognised operator matches nothing |
value | string | Value 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:
| Status | Reason |
|---|---|
| 503 | Inventory 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:
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | No | Exact software-name filter |
agent_id | string | No | Exact agent filter |
version | string | No | Exact version filter. A present-empty value (?version=) selects only the rows with an empty version; omit the parameter for any version |
q | string | No | Case-insensitive literal substring over name, publisher, ecosystem and source (%/_ are not wildcards; clamped to 128 bytes) |
kind / ecosystem | string | No | Exact filters; an empty value means no filter |
source | string | No | Exact source filter. source is an open set (<plugin>.<action>), never an enum |
limit | integer | No | Max rows (default 100, min 1, max 1000). In export mode it is the total cap (default and max 10000) |
cursor | string | No | Opaque next_cursor from the previous page (see below) |
format | json | csv | No | Selects 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:
| Status | Condition |
|---|---|
| 400 | limit 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) |
| 401 | Unauthenticated |
| 403 | No 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) |
| 503 | The 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:
| Parameter | Type | Description |
|---|---|---|
q | string | Case-insensitive literal substring over title name, publisher, ecosystems and sources (clamped to 128 bytes) |
kind, ecosystem | string | Exact filters (closed vocabulary — see inventory.md); empty means no filter |
source | string | Exact source filter (open set, <plugin>.<action>); empty means no filter |
agent_id | string | Per-device view: that device’s titles (installs are 1) |
versions_min | integer | Only titles with at least this many distinct versions (0–100000) |
limit | integer | Page size (default 100, 1–1000, clamped); with format=csv it is the total export cap (default and max 10000) |
cursor | string | Opaque next_cursor from the previous page |
format | json | csv | csv 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" }
}
totals{titles,installs}cover every row matching the filters, not just this page (same predicate as the rows), so a client can say “N of M”.installssums each title’s exact distinct-device count (a device carrying two matching titles counts twice); unfiltered, it equalskpis.installs.kpisis present on the first fleet page only — it isnullon continuation pages and on the per-device view. The headline numbers come from the hourly refresh; percentages are derived (os_pctof installs, rounded;stay_current.pctisnullwhen no title has a known version;sprawl.pctto one decimal).coverageis counts, not a ratio:reporting= devices with stored inventory,connected= devices currently connected (a registry read,nullwhen unavailable),stale= devices not seen in 48 hours (nullwhen that bounded count is unavailable). There is deliberately no coverage percentage — no honest denominator exists.building: truewith zero counts means the rollup has never been computed: the JSON page is still a200(the page must render), but the fleetformat=csvexport is a503until the first refresh lands, because an empty file would read as “no titles” (the per-device export’s rows and counts do not depend on the rollup and it still answers200; itsnewest_versioncolumn is blank until the first refresh). If the503persists past one hourly refresh the catalogue is not building but failing (a fleet above the ~6,000-endpoint refresh ceiling, #5545, or a database role withoutTEMPORARY): see inventory.md “Scale ceiling” andyuzu_inventory_catalog_rollup_total{outcome="error"}, and do not retry in a loop.- Cursor.
next_cursoris non-null when the page is full. It is bound to the filter set and to the catalogue refresh generation (kpis.refreshed_at, 1-second granularity — two refreshes inside one second share a generation). A malformed cursor, a cursor from a different filter set, or one issued before the latest refresh is a400(invalid cursor/cursor was issued for a different filter set/cursor predates the current catalogue refresh; restart from the first page) — a client holding a stale cursor restarts from the first page. If a refresh lands during a first-page read the server re-reads once; a second move is a503. - Per-device view (
agent_id): admitted for an in-scope device. For a confined callernewest_versionis blank andnewest_omitted: true(the newest version is a fleet-wide fact). A device outside the caller’s scope returns an empty page withdevices_omitted: 1and adeniedaudit row (no oracle on whether the device exists). - Export (
format=csv):text/csv; charset=utf-8,Content-Disposition: attachment; filename="software-titles.csv", headername,publisher,installs,versions,ecosystems,kinds,newest_version(lists;-joined).limitis the total cap (default and max 10000);X-Result-Truncated-By-Cap: truewhen the walk reached the cap (rows may have been left behind). A cell starting with=,+,-,@, tab or CR is prefixed with'(spreadsheet formula neutralisation), so a CSVnamemay need that prefix stripped before it is passed to the versions route. Audited asinventory.software.export(rows=is the number of titles written). Set-and-proceed:Sec-Audit-Failed: trueis set, and the export is still served, when the export audit row did not persist (also set when only the paireddeniedrow failed to persist) (machine-scope data, not behavioural PII). - Audit. Every call that serves a page writes
inventory.software.catalogsuccess— first and continuation pages (the latter taggedpage=continuation) — with detailtitles=N totals=T/I. An out-of-scope per-device read writesdeniedonly (onformat=csv,deniedplus an empty-fileinventory.software.exportsuccess). A400writes nothing; a confined or service-scoped refusal writesdenied; theInventory:Readgate’s own refusal is audited asauth.fleet_read_required. See audit-log.md for the SIEM keying.audit_persisted: falseis added when the row could not be persisted, including thedeniedrow.
Error responses (A4 envelope with correlation_id):
| Status | Condition |
|---|---|
| 400 | Repeated/malformed parameter, unknown format, a cursor with format=csv, or a refused cursor (see above) |
| 401 | Unauthenticated |
| 403 | Confined caller on the fleet view (permission: "Inventory:Read"), or a service-scoped token |
| 503 | A 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.
| Parameter | Type | Description |
|---|---|---|
name | string | Required exact title name, at most 1024 bytes (400 name is required) |
kind, ecosystem, source, agent_id | string | Same filters as the catalogue |
limit | integer | Default 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.
| Parameter | Type | Description |
|---|---|---|
family | string | Restrict to one family id (openssl, libressl, boringssl, gnutls, nss, mbedtls, wolfssl, libgcrypt, libsodium, nettle, botan, bouncycastle); empty means every family |
ecosystem | string | Exact 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"}
}
table_versionidentifies the curated mapping that produced the answer; it changes whenever the table is edited.names_sourcesays where the package NAMES the table is matched against came from:catalogue(the hourly software-catalogue rollup, while it is fresh: its refresh started at most two hours ago;names_as_ofis its refresh time in epoch seconds, and a package name first reported after it appears after the next hourly refresh) orlive(read from the inventory at call time because the catalogue was never built or is stale, was last refreshed over zero titles, or its stamp is more than five minutes in the future;names_as_ofisnull). A deny-all answer (an empty scope) reads nothing and reportslive/null. Counts and versions for already-listed names are live either way, and the two sources give the same answer for the same data. An unbuilt catalogue is not an error.familieslists only families with at least one matching device (zero-device families are omitted), ordered bydevice_countdescending thenid.device_countis the number of distinct devices carrying any package of the family: a device with bothlibssl3andopensslcounts once.versions[]are the (version, ecosystem) buckets, most-installed first, capped at 50 per family (versions_truncated).package_nameslists up to eight of the distinct matched names in the bucket, each cut to 256 bytes (a cut name ends in…);package_names_truncatedistruewhen the bucket has more than eight. An emptyversionis a real bucket (some rows carry no version).coverage.devices_reporting_inventoryis the denominator: in-scope devices with an installed-software report.devices_with_crypto_libraryis the distinct in-scope devices matching any family (or the requested one). The denominator is floored at the numerator, so it is never smaller thandevices_with_crypto_library.result_truncatedistruewhen the store’s row ceiling (2,000 version rows) or the byte budget of the whole summary (about 256 KiB, plus at most one first bucket per family) dropped some version rows; family device counts and coverage are unaffected. A family whose version list was cut by the per-family cap or the byte budget hasversions_truncated: true. When the row ceiling fires, every family hasversions_truncated: true, because the read cannot tell which families lost rows (some may be complete). Buckets are kept most-installed first.- An empty
familiesarray means “no matching package rows in the caller’s visible inventory”. It is not proof that a library is absent: statically linked, vendored and OS-bundled copies are invisible. - Audit. A served read writes
crypto.libraries.view(target_typeInventory,target_idfleetfollowed by;family=..;ecosystem=..when set; detailcid=.. tv=<table_version> family=<family or all> rows=<families> src=<catalogue|live> scope=<fleet|confined>;scope=confinedwhenever the caller’s read was limited to a visible device set, including an empty one). Set-and-proceed: when the audit row cannot persist the data is still served, the body carriesaudit_persisted: falseinsidedataand the response hasSec-Audit-Failed: true. A degraded read attempts afailurerow (cid=.. tv=.. scope=.. store degradedor... capacity in use); under connection-pool exhaustion that row can be lost, whichyuzu_server_audit_emit_failed_totalcounts.
Error responses (A4 envelope with correlation_id):
| Status | Condition |
|---|---|
| 400 | Parameter repeated with differing values, malformed parameter, unknown family, invalid UTF-8, over-long value |
| 401 | Unauthenticated |
| 403 | Caller lacks Inventory:Read (written by the gate); while RBAC enforcement is off, service-scoped tokens are refused 403 |
| 503 | crypto 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.
| Parameter | Type | Description |
|---|---|---|
family | string | Required family id (as above); 400 family is required if missing |
version | string | Exact version bucket, at most 1,024 bytes (the ingest bound). Present but empty selects rows with no version; omit for every version |
ecosystem | string | Exact 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 |
cursor | string | Opaque next_cursor from the previous page, at most 2048 bytes; bound to the family/version/ecosystem filter and the table_version |
limit | integer | Page 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"}
}
hostnameisnullwhen the device registry does not know the agent.table_versionis the curated mapping that produced this page; a cursor is bound to it.versionandecosystemecho the requested filters (nullwhen not requested).names_sourceandnames_as_ofare as on the summary (package names from the hourly catalogue, or read live; counts and versions live either way).countis the number of rows on this page.- Rows are ordered by
agent_id, package name;next_cursoris non-null when more rows exist and is passed back ascursor. A malformed cursor, or one issued for a different family/version/ecosystem, or under a differenttable_version, is a400and the walk restarts from the first page without a cursor. So is a page whose cursor was issued onlivenames when the next page finds the shared catalogue in use (it became fresh in between), because the catalogue may lack a name first reported after its refresh; this normally happens once, but recurs if the catalogue goes stale and recovers during the same walk. A newer catalogue refresh, or a switch from the catalogue tolive, continues: a newer catalogue lists every name that had rows at its refresh, and a name new in the newer source is listed for devices after the cursor while its rows on devices already passed are missed (restart to see them). The cursor is opaque but not authenticated, so every page served is audited. - Audit. Every served page writes
crypto.libraries.devices(target_typeInventory,target_idfamily=..followed by;version=..;ecosystem=..when set; detailcid=.. tv=<table_version> family=.. rows=<n> src=<catalogue|live> scope=<fleet|confined>), continuation pages taggedpage=continuationafter the scope. Set-and-proceed exactly as above (audit_persisted: false/Sec-Audit-Failed: true). A degraded read attempts afailurerow as above, which can be lost under connection-pool exhaustion.
Error responses (A4 envelope with correlation_id):
| Status | Condition |
|---|---|
| 400 | family 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) |
| 401 | Unauthenticated |
| 403 | Caller lacks Inventory:Read (written by the gate); while RBAC enforcement is off, service-scoped tokens are refused 403 |
| 503 | crypto 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.
| Parameter | Meaning |
|---|---|
family | One family id from the curated table; omitted or empty means every family. An unknown id is a 400. |
ecosystem | Exact 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.)
- Scope and completeness are machine-readable.
yuzu:crypto:scopeisfleet(unfiltered caller) orconfined(a management-group or service-scope confined caller: only their visible devices).compositionsis always[{"aggregate": "incomplete"}]: a name match cannot see statically linked, vendored or OS-bundled copies. - Presence only. Components are
library, nevercryptographic-asset; there are nocryptoProperties, strength, quantum-security-level or good/bad fields, and nopurl. - A cut is never silent. A family whose version list was cut sets
yuzu:crypto:versions_truncatedtrueon its components and is listed inyuzu:crypto:truncated_families(when the store row cap fires, every family is flagged), a bucket matching more package names than are listed setsyuzu:crypto:package_names_truncatedtrueon its component;yuzu:crypto:result_truncatedflags a whole-answer cut. - Audit. A served export writes
crypto.cbom.export(target_typeInventory, same target and detail key set ascrypto.libraries.view, plusserial=<urn:uuid:...> [ecosystem=<filter>] components=<n>(theecosystemkey only when that filter was given) on the success row so the audit row names the exact document served). Set-and-proceed: when the audit row cannot persist the BOM is still served,metadata.propertiescarriesyuzu:crypto:audit_persisted=falseand the response hasSec-Audit-Failed: true. A degraded read writes afailurerow.
| Status | Meaning |
|---|---|
| 200 | The BOM |
| 400 | Repeated parameter with differing values, unknown family, invalid UTF-8 or over-long value |
| 401 | Unauthenticated |
| 403 | Caller lacks Inventory:Read (written by the gate); while RBAC enforcement is off, service-scoped tokens are refused 403 |
| 503 | crypto 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" }
}
| Status | Condition |
|---|---|
| 400 | Malformed body, unknown or repeated id, too many ids, a bad expected_version, or another key (the message names the position, never the value) |
| 401 | Unauthenticated |
| 403 | Service-scoped token, MCP tier below Infrastructure:Write, or a cross-site cookie write |
| 409 | expected_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 |
| 413 | Body over 16 KiB |
| 503 | retry_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" }
}
- Every count object carries
rule_id,criterion,rule_set_version,as_ofandcount, plusthreshold(windowed counts: the window length in days, the ADR-0070 D10 condition-1 field),week(per-week counts) andbucket(revoked counts). These are counts of certificates, never labels. expiring_within_dayshas one entry per window (3, 7, 14, 21, 30, 60),expired_within_daysone per window (7, 30, 60, 90).revoked_within_dayshas three entries per window, one per bucket:superseded,unpublished,other(any other recorded reason, including an operator-typed reason stored asoperator-supplied: <text>). They are never summed.- Expiring and expired cover status
activeonly; a revoked certificate counts only in the revoked windows (R11). Issued excludesunpublishedcertificates (never delivered). weekshas 52 entries, oldest first, zero-filled, ending with the week containingas_of, which haspartial: true.week_startis that ISO week’s Monday 00:00 UTC.- Recompute any count with
Security:Readfrom the filteredGET /api/v1/ca/issuedwith the count’skind,days/week,bucketandas_of.
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:
| Param | Type | Description |
|---|---|---|
cursor | string | Opaque pagination cursor from a previous response |
limit | integer | Max 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:
| Status | Reason |
|---|---|
| 400 | limit is not one base-10 integer |
| 403 | Service-scoped API token — result-set listing cannot be confined to the token’s service |
| 503 | RESULT_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:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | Human-readable name |
source_kind | string | No | Defaults to manual_curate |
source_payload | object | No | Stored 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_id | string | No | Must reference a set the caller owns (else 404) |
device_ids | array of string | No | The 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:
| Status | Reason |
|---|---|
| 400 | RESULT_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) |
| 400 | name/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) |
| 400 | RESULT_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”) |
| 400 | RESULT_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 |
| 400 | RESULT_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 |
| 403 | Service-scoped API token |
| 403 | MCP 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 |
| 404 | parent_id supplied but not owned/found |
| 429 | RESULT_SET_QUOTA — owner is at the per-owner set cap |
| 503 | RESULT_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:
| Status | Reason |
|---|---|
| 403 | Service-scoped API token |
| 404 | RESULT_SET_NOT_FOUND — no such set, or not owned by the caller (identical body either way — no enumeration oracle) |
| 503 | Result-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:
| Status | Reason |
|---|---|
| 403 | Service-scoped API token |
| 403 | MCP 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. |
| 403 | Supervised-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) |
| 404 | No 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. |
| 409 | RESULT_SET_PINNED — unpin the set before deleting it |
| 503 | Result-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:
| Param | Type | Description |
|---|---|---|
cursor | string | Opaque pagination cursor |
limit | integer | Max 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:
| Status | Reason |
|---|---|
| 400 | limit is not one base-10 integer |
| 403 | Service-scoped API token, or the caller lacks Infrastructure:Read |
| 404 | No such set, or not owned by the caller |
| 503 | Result-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:
| Status | Reason |
|---|---|
| 403 | Service-scoped API token |
| 404 | No such set, or not owned by the caller |
| 503 | Result-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:
| Status | Reason |
|---|---|
| 403 | Service-scoped API token |
| 403 | MCP 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. |
| 404 | No 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. |
| 409 | PIN_LIMIT — owner is at the per-owner pin cap (pin only) |
| 503 | Result-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:
| Status | Condition |
|---|---|
| 401 | Unauthenticated |
| 403 | Caller lacks per-device-scoped Forensics:Read for this agent |
503 + Sec-Audit-Failed | The per-open audit row could not persist — fail-closed, no data served |
| 503 | Store 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:
| Status | Condition |
|---|---|
| 401 | Unauthenticated |
| 403 | Caller lacks per-device-scoped SoftwareLicensing:Read for this agent |
503 + Sec-Audit-Failed | The per-open audit row could not persist — fail-closed, no data served |
| 503 | Store 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:
| Status | Condition |
|---|---|
| 401 | Unauthenticated |
| 403 | Caller lacks the per-device-scoped Decommission:Delete permission |
| 500 | One 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-Failed | The attempt audit row could not persist — fail-closed, nothing was erased |
| 503 | Scope 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:
| Param | Type | Description |
|---|---|---|
agent_id | string | Filter to a single agent |
since | integer | Unix 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) |
limit | integer | Max 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:
| Param | Type | Description |
|---|---|---|
definition_id | string | Filter to a single definition |
since | integer | Unix 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) |
limit | integer | Max 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:
| Param | Description |
|---|---|
id | The response-store key (the command_id returned at dispatch time). |
Query parameters:
| Param | Type | Required | Description |
|---|---|---|---|
definition_id | string | Yes | ID 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._-]+. |
index | integer | No | Chart 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:
| Status | Cause |
|---|---|
400 | definition_id query parameter not provided, or index is not a non-negative integer. |
404 | Definition not found, has no spec.visualization configured, or the requested index is out of range. |
500 | The visualization spec parses but cannot be applied (invalid processor / invalid chart type). |
503 | Response 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:
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string | Yes | The single target device — a bundle targets one device. |
steps | array | Yes | 1–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[].plugin | string | Yes | Plugin name, lowercased [a-z0-9_], ≤ 64 chars. |
steps[].action | string | Yes | Action name, same charset / length rule. |
steps[].params | object | No | String→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:
| Status | Cause |
|---|---|
400 | Invalid 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). |
500 | The authenticated session resolved to an empty principal (a bundle must be attributable to its dispatcher). |
503 | Command 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:
| Param | Description |
|---|---|
id | The 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.
completeis terminal, NOT success. A bundle to an offline device completes (complete=true) withreceived=0,succeeded=0, and every stepdispatch_failed. A caller deciding “did the refresh work” must checksucceeded == expected(or inspect eachsteps[].state/status) — nevercompletealone. Pollingcompleteto mark a record refreshed would silently treat an unreachable device as a success.
Errors:
| Status | Cause |
|---|---|
404 | Bundle 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. |
503 | The 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:
- A bundle is not a tracked execution — it does not appear in the live executions drawer, and
notify_exec_trackerskips itsbundle-…correlation id. The collate poll is the sole completion authority. - v1 bundle state (the step map) is per-surface and in-memory: a bundle dispatched over REST is collated over REST (MCP→MCP). A server restart mid-bundle loses the in-flight step map (the responses survive in the response store, but attribution does not), and the caller must re-dispatch. A durable Postgres manifest for HA + cross-surface collation is a committed follow-up (ADR-0011).
Error handling and retry (for integrators):
- Poll cadence: poll
GET /api/v1/bundles/{id}with a short backoff (e.g. 1s, 2s, 4s) untilcompleteistrue, then readsucceeded/steps. - A bundle with a Forensics-tier step may never complete for a caller who cannot read it. For a caller without a global
Forensics:Readthat step stayspendingwith an empty output and carries no marker, so the bundle stayscomplete=falsefor that caller (an approval ticket to dispatch the step does not imply the right to read its output). The REST result carries noretry_after_ms; the MCPget_bundle_resulttwin does keep advertising one while the bundle is incomplete. Because each collate refreshes the manifest window (next bullet), a poller that never stops keeps the manifest alive, so bound the polling with a deadline of your own. - Manifest TTL ≈ 10 minutes, sliding. Each successful collate refreshes the window, so a bundle you are actively polling never expires; only an abandoned bundle (no poll for the full window) is swept.
- A 404 on a poll that previously returned 200 means the manifest expired (you stopped polling past the TTL) or the server restarted (in-memory state lost). The correct recovery is to re-dispatch the bundle — the already-executed step responses are harmless (idempotent reads). Do not treat the 404 as a hard failure.
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:
| Condition | Response |
|---|---|
| A genuine database read failure | 503 |
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:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Human-readable token name |
device_id | string | No | Restrict token to a specific agent |
definition_id | string | No | Restrict token to a specific instruction definition |
expires_at | integer | No | Unix timestamp for token expiration |
Response (201):
{
"data": { "raw_token": "ydt_a1b2c3d4e5f6..." },
"meta": { "api_version": "v1" }
}
Errors:
| Condition | Response |
|---|---|
| Malformed JSON body | 400 — invalid JSON |
name/device_id/definition_id exceeds 256 chars | 400 — invalid_input_length: ... |
| CSPRNG entropy exhaustion | 503 + Retry-After: 5 — CSPRNG unavailable: ... |
| A genuine database write or token-hashing failure | 503 + 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:
| Condition | Response |
|---|---|
| No token with this id | 404 — token not found |
| A genuine database write failure | 503 — 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:
| Name | In | Required | Description |
|---|---|---|---|
limit | query | No | Max 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:
| Condition | Response |
|---|---|
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 service | 403, 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:
| Name | In | Required | Description |
|---|---|---|---|
run | query | Yes | The 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:
| Condition | Response |
|---|---|
Missing run parameter | 400 |
| A service-scoped API token — this owner-scoped read cannot be confined to the token’s service | 403, audited under deployment.config.view |
| No such run, or it belongs to another operator | 404 — 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:
| Name | In | Required | Description |
|---|---|---|---|
limit | query | No | Max 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:
| Condition | Response |
|---|---|
limit is not a whole integer (non-numeric, trailing characters, or does not fit 64 bits) | 400 |
| A service-scoped API token | 403, audited under deployment.list.view |
| Deployment run store never configured on this server | 503 (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:
| Name | In | Required | Description |
|---|---|---|---|
id | path | Yes | The deployment id (lowercase hex, from the list) |
limit | query | No | Max 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:
| Condition | Response |
|---|---|
limit is not a whole integer (non-numeric, trailing characters, or does not fit 64 bits) | 400 |
| A service-scoped API token | 403, audited under deployment.detail.view |
| No such deployment, or it belongs to another operator | 404 — indistinguishable by design |
| Store never configured / degraded | 503 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:
| Condition | Response |
|---|---|
| A genuine database read failure | 503 |
POST /api/v1/software-packages
Register a new software package.
Permission: SoftwareDeployment:Write
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Package name |
version | string | Yes | Package version |
platform | string | No | Target platform (default "windows") |
installer_type | string | No | Installer type (default "msi") |
content_hash | string | No | SHA-256 hash of the installer |
content_url | string | No | Download URL for the installer binary |
silent_args | string | No | Silent install arguments (e.g. /qn /norestart). Same validation rules as verify_command — max 512 chars, rejects shell metacharacters and control characters. |
verify_command | string | No | Post-install verification command. Max 512 chars. Rejects shell metacharacters (; & ` |
rollback_command | string | No | Rollback command on failure. Same validation rules as verify_command. |
size_bytes | integer | No | Installer 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:
| Condition | Response |
|---|---|
Malformed JSON body, a required field wrong-typed or empty, or verify_command/rollback_command/silent_args fails the shell-metacharacter/length check | 400 |
| A genuine database write failure | 503 |
GET /api/v1/software-deployments
List software deployments, optionally filtered by status.
Permission: SoftwareDeployment:Read
Query parameters:
| Param | Type | Description |
|---|---|---|
status | string | Filter 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:
| Condition | Response |
|---|---|
| A genuine database read failure | 503 |
POST /api/v1/software-deployments
Create a new software deployment. Starts in status staged.
Permission: SoftwareDeployment:Execute
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
package_id | string | Yes | ID of the registered software package |
scope_expression | string | No | Scope 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:
| Condition | Response |
|---|---|
Malformed JSON body, or package_id empty | 400 |
package_id does not reference a registered package | 400 |
| A genuine database write failure | 503 |
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:
| Condition | Response |
|---|---|
No deployment with this id, or it is not staged | 400 |
| A genuine database write failure | 503 |
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:
| Condition | Response |
|---|---|
No deployment with this id, or it is not deploying/verifying/completed | 400 |
| A genuine database write failure | 503 |
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:
| Condition | Response |
|---|---|
No deployment with this id, or it is not staged/deploying | 400 |
| A genuine database write failure | 503 |
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:
| Condition | Response |
|---|---|
| A genuine database read failure | 503 |
POST /api/v1/license
Activate a license.
Permission: License:Write
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
organization | string | Yes | Organization name |
seat_count | integer | Yes | Licensed seat count: a JSON integer of at least 0 (0 = unlimited); a float, string, null, negative or above-INT64_MAX value is 400 (#4644) |
edition | string | No | License edition (default "community") |
expires_at | integer | No | Unix 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_json | string | No | JSON array of enabled feature flags |
license_key | string | Yes | License activation key |
Response (201):
{
"data": { "id": "lic-001" },
"meta": { "api_version": "v1" }
}
Errors:
| Condition | Response |
|---|---|
| Malformed JSON body, or a body that is not a JSON object | 400 — invalid JSON |
seat_count or expires_at not a JSON integer of at least 0 | 400: nothing activated (#4644) |
license_key or organization empty | 400 |
license_key already activated on another license | 400 — license key already activated |
| A genuine database write failure | 503 |
DELETE /api/v1/license/{id}
Remove a license entry.
Permission: License:Write
Response: {"data": {"removed": true}, "meta": {"api_version": "v1"}}
Errors:
| Condition | Response |
|---|---|
| No license with this id | 404 |
| A genuine database write failure | 503 |
GET /api/v1/license/alerts
List license alerts (expiration warnings, seat limit approaching, etc.).
Permission: License:Read
Query parameters:
| Param | Type | Description |
|---|---|---|
unacknowledged | flag | If 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:
| Condition | Response |
|---|---|
| A genuine database read failure | 503 |
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:
- Drag — rotate camera around scene origin (OrbitControls)
- Mouse wheel — dolly in/out (clamped to
[4, 400]units) W/A/S/D— pan the view in camera screen space (window-level listener; suppressed when a text-editable target has focus, so typing in a future overlay-panel input does not eat keystrokes)- Hover a machine cube body — surfaces a fixed-position tooltip with the cube’s hostname, OS, process count, and connection count. The cube tooltip is shown only when no interior process dot is intersected (process dots are raycasted first; see the process tooltip below). The wireframe outline overlay is excluded from hit-testing. Tooltip follows the cursor with a small offset to avoid flicker.
- Hover a process dot (interior of a cube) — surfaces a process tooltip with the process’s pid, name, user account, and category. Process dots are raycasted before cube meshes so an operator can hover a dot through the translucent cube face and still see process details (otherwise the cube’s outer face would always win by ray distance and dots would be unreachable). Agent-controlled fields (name, user, category) are HTML-escaped before render and capped at 256 characters before escaping to bound CPU cost on pathological 1MB cmdline-as-comm payloads.
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 response | Overlay 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 |
| Network failure | ”Cannot reach server: |
| Truncated JSON / malformed body | ”Malformed JSON from server (truncated response or proxy issue): |
| Schema mismatch | ”Unexpected topology schema: |
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
| Parameter | Type | Default | Description |
|---|---|---|---|
include_vuln | bool (0/1) | 0 | When 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. |
fresh | bool (0/1) | 0 | When 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_max | integer in [1, 100000] | 5000 | Soft 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
| Status | When | Body |
|---|---|---|
200 | Success | fleet_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) |
400 | machines_max non-numeric, out of [1, 100000], or overflows int | {"error":{"code":400,"message":"..."}, "meta":{"api_version":"v1"}} |
403 | RBAC denied | Standard auth error envelope |
413 | Snapshot exceeds machines_max | {"error":{"code":413,"message":"fleet topology exceeds machines_max..."}, "meta":{"api_version":"v1"}} |
503 | Kill 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:
| Version | Change |
|---|---|
| 1 | Initial shape (PR 2–7) |
| 2 | PR 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. |
| 3 | PR 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. |
| 4 | PR 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
yuzu_viz_topology_request_seconds(histogram) — end-to-end request latency on the success path (auth + RBAC + store + serialisation + response).yuzu_viz_topology_fetch_duration_seconds(histogram) — duration of the inner agent-dispatch path (echo.fleet_snapshotfan-out + response aggregation), measured only on cache-miss refills. Use to distinguish slow agent dispatch from slow auth / serialisation. Observed even on fetcher exception so a hung fetcher produces a visible upper-bound observation. (PR 6 / OBS-2.)yuzu_viz_cache_hit_total/yuzu_viz_cache_miss_total(counters).yuzu_viz_oversize_response_total(counter) — 413 cap-check fires.yuzu_viz_agent_dispatch_timeout_total(counter) — agents that didn’t respond within the 5 s fetcher deadline.yuzu_viz_refill_oversize_drops_total(gauge) — store-level 256 MiB cap exceeded; refill not cached.yuzu_viz_refill_wait_timeouts_total(gauge) — single-flight waiters that timed out on the refill.yuzu_viz_refill_waiters_total(gauge) — single-flight piggyback depth.yuzu_viz_topology_pushed_total{via=direct|gateway}(counter, PR 10) — agent-pushedfleet_snapshot.v1payloads accepted via heartbeat.via=directcounts direct-to-server agents;via=gatewaycounts gateway-routed agents. A zero value across both labels after agents have been running for >30 s indicates agents have not upgraded to push-enabled binaries; the server falls back to the dispatch path automatically.yuzu_viz_topology_push_parse_errors_total{via=direct|gateway}(counter, PR 10) — agent-pushed payloads rejected by the shared parser (oversized > 2 MiB,processes[]/connections[]exceeding the 4096-row cap, or malformed JSON). A non-zero value indicates agent/server version skew, a corrupt heartbeat, or a compromised agent attempting to inject malformed data. Each rejection also emits atopology.push.rejectedaudit event. The parser also length-clamps every agent-controlled string field (hostname, processname/cmdline/user, connection meta strings) — truncation is logged but not counted here.yuzu_viz_topology_push_rejected_total(gauge) — pushes rejected by the IP-spoof guard because a claimedlocal_ipis owned by a live agent (an agent that has pushed within the 5-minute reclaim window). Non-zero signals a spoofing campaign or a NAT/DHCP misconfiguration.yuzu_viz_pushed_cap_evictions_total(gauge) —pushed_map entries evicted because the map hit the 100 000-agent hard cap. The victim is the least-recently-seen agent by server receipt time (not the agent-controlledts); each eviction emits atopology.push.evicted_for_capaudit event.yuzu_viz_pushed_map_size(gauge) — currentpushed_map occupancy; the memory-pressure signal to alert on before evictions begin.
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
| Status | When | Body |
|---|---|---|
200 | Success | host_topology.v1 JSON envelope (see schema below) |
404 | No machine with that agent_id in the current snapshot | {"error":{"code":404,"message":"host not found"}, ...} |
403 | RBAC denied | Standard auth error envelope |
500 | Topology fetch threw or returned null | {"error":{"code":500,"message":"..."}, ...} |
503 | Kill 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:
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string | Yes | The agent authorised to redeem this grant |
declared_max_size | integer | Yes | Upper bound on the upload size in bytes — a CAP, not an equality requirement; the actual upload may be smaller |
source_path | string | No | Informational only — never used to derive the destination key |
expected_sha256 | string | No | Optional expected content hash, 64 lowercase hex characters |
retention_class | string | No | standard (default), extended, or transient |
ttl_secs | integer | No | Grant 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:
- TLS required. On a deployment serving plain HTTP, every route returns
400withreason: tls_required. - Error envelope. Protocol errors use
{"error":{"code":<http>,"message":"...","reason":"..."},"meta":{"api_version":"v1"}}.reasonis one of ten fixed values:grant_unknown(401),grant_already_redeemed(409),expired(410),offset_mismatch(409),chunk_too_large(413),size_exceeded(413),hash_mismatch(422),session_unknown(401),session_terminal(409),tls_required(400). Other failures (a malformed header or body, a store or disk fault) use the standard A4 error body with noreason. - Credentials. Both headers are
<id>.<secret>. An unknown id and a wrong secret get the same401(grant_unknown/session_unknown). For the four{upload_id}routes, the id inX-Yuzu-Upload-Sessionmust equal the{upload_id}path segment, or the request also gets401 session_unknown. - Expiry. A session expires when its grant expires. The first request after
expires_at(status, chunk, commit or cancel) discards the partial file and returns410 expired. - Body cap. Every
/api/v1/uploadsroute is capped at 8 MiB before authentication and must send a measurable body (aContent-Length, no chunked transfer encoding). See Pre-Auth Request Body Caps. - Store unavailable:
503.
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:
| Role | Read | Write | Execute | Delete | Approve | Push |
|---|---|---|---|---|---|---|
| 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.
- Permission:
GuaranteedState:Read - Response:
data[]ofGuaranteedStateRuleobjects (see OpenAPI schema). - 4xx:
403if a service-scoped API token queries this route — the rule catalogue isn’t owned by any one IT service, so there’s no per-target shape to confine against; the bare permission gate alone checks only the token’s ITServiceOwner role, never its own service-tag scope. - 5xx:
503if the store is unavailable (A4 envelope,retry_after_ms: 5000). - Audit:
guaranteed_state.rule.read(deniedonly — an ordinary successful list read is not audited). - MCP twin:
list_guardian_rules(#4037) — same store call, same service-scoped-token deny.
POST /api/v1/guaranteed-state/rules
Create a rule.
- Permission:
GuaranteedState:Write - Request body:
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.
| Field | Type | Required | Description |
|---|---|---|---|
rule_id | string | Yes | Stable 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. |
name | string | Yes | Human-readable name (unique per server). |
spark | object | Structured | {type, params} trigger block, e.g. {"type":"registry-change"} or {"type":"file-change"}. |
assertion | object | Structured | {type, params} desired-state block, e.g. registry-value-equals, file-exists, file-hash-equals. |
remediation | object | No | {type, params} — alert-only (default) or enforce. params carries the resilience policy (mode, max_attempts, backoff_*, …) and event_debounce_ms. |
yaml_source | string | Legacy | Full rule YAML; required only when no structured spark+assertion is given. The server generates yaml_source from the structured form otherwise. |
version | integer | No | Starting 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. |
enabled | boolean | No | Default true. |
enforcement_mode | string | No | enforce (default) or audit. Set once at creation — immutable on update (see PUT below). |
severity | string | No | low / medium (default) / high / critical. |
os_target | string | No | Empty (any) or windows / linux / macos. |
scope_expr | string | No | Scope 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.
- Response:
201withdata.rule_id. - 4xx:
400missing required fields, invalid JSON, a request body nesting deeper than 32 levels (kMcpMaxJsonDepth), arule_idthat doesn’t match[A-Za-z0-9._-]+or exceeds 256 bytes (#4665), or aversionoutside1..2147483647, an invalid resilience policy (e.g. Boundedmax_attempts< 1,backoff_initial_ms>backoff_max_ms) — returned as the A4 structured error envelope;409on duplicaterule_idor duplicatename;403if a service-scoped API token calls this route (same reasoning as theGETlist above — no per-target shape to confine against). - Audit:
guaranteed_state.rule.create(success/denied; an invalidrule_idis audited asdeniedtoo — #4665). - MCP twin:
create_guardian_rule(#2146 Batch B1) — same store write and validation.
GET /api/v1/guaranteed-state/rules/{rule_id}
Fetch a single rule.
- Permission:
GuaranteedState:Read - Response:
datais aGuaranteedStateRuleobject. - 4xx:
404if the rule does not exist;403if a service-scoped API token queries this route (same reasoning asGET .../rulesabove). - 5xx:
503if the store degrades (A4 envelope,retry_after_ms: 5000). - Audit:
guaranteed_state.rule.read(deniedonly). - MCP twin:
get_guardian_rule(#2146 Batch B1) — same store read.
PUT /api/v1/guaranteed-state/rules/{rule_id}
Update a rule. Version is incremented on every successful update regardless of whether any field changed.
- Permission:
GuaranteedState:Write - Request body: Any subset of the create-body fields except
enforcement_mode(absent fields retain their current values). A body carrying structuredspark/assertion/remediationblocks re-authors the Guard (re-deriving the canonical spec and re-validating the resilience policy) rather than dropping them; a metadata-only body leaves the existing spec intact. enforcement_modeis immutable. A body whoseenforcement_modediffers from the stored value is rejected with400(enforcement_mode is immutable — create a new Guard for a different posture (Watch vs Enforce)); a different posture is a different Guard. A no-op echo of the current value is accepted.- Response:
200withdata.updated = trueanddata.version. - 4xx:
400invalid JSON, a request body nesting deeper than 32 levels (kMcpMaxJsonDepth), an invalid resilience policy (A4 envelope), or anenforcement_modechange;404rule not found;409on name conflict or when the stored version counter is exhausted (a row stored at2147483647or above, which only a row written before the bound existed can be; create a new rule instead);403if a service-scoped API token calls this route (same reasoning as the create route above). - 5xx:
503if the pre-update rule lookup hits a degraded store (A4 envelope,retry_after_ms: 5000). - Audit:
guaranteed_state.rule.update. - MCP twin:
update_guardian_rule(#2146 Batch B1) — same validation and version-bump. No optimistic-concurrency check against concurrent writers on either transport (tracked in #4303).
DELETE /api/v1/guaranteed-state/rules/{rule_id}
Delete a rule.
- Permission:
GuaranteedState:Delete - 4xx:
404if the rule does not exist;403if a service-scoped API token calls this route (same reasoning as create/update above). - Audit:
guaranteed_state.rule.delete. - MCP twin:
delete_guardian_rule(#2146 Batch B1). Does NOT automatically push an unarm to agents already enforcing the rule (tracked in #4304).
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}).
- Permission:
GuaranteedState:ReadviaAuthRoutes::require_list_read, this route’s sole authorization gate (ADR-0017 admit-then-filter) — the same chokepointGET .../statususes, not a bareperm_fn. A service-scoped API token is refused outright (a rule has no single owning IT service to confine to); a management-group-confined grant’s visible-agent set filters the returned rows. - Response:
data[]of{agent_id, state, updated_at},pagination.total. Raw census — does not apply the dashboard fragment’s own offline-agent-folds-to-unknown rollup (same postureGET .../statusalready established for the identical class of data). - 4xx:
404ifrule_idnames no rule;403for a service-scoped token or noGuaranteedState:Readgrant anywhere. - 5xx:
503if the store is degraded (A4 envelope,retry_after_ms: 5000), the gate is unwired, or theguaranteed_state.rule.viewaudit row cannot persist (fail-closed,Sec-Audit-Failed: true, alsoretry_after_ms: 5000). - Audit:
guaranteed_state.rule.view(success/not_found),target_type=GuaranteedState,target_id=<rule_id>— the SAME verb the per-guard dashboard drilldown emits (notguardian.device.view; a fleet-wide-per-rule read is a different shape from a per-device read). MCP twin:get_guardian_rule_status(audit_persisted:falseposture instead of fail-closed).
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.
- Permission:
GuaranteedState:Read, per-device scoped (management-group aware) — samescoped_perm_fn(GuaranteedState, Read, agent_id)gateGET .../device-complianceand the dashboard Guardian device lens both use. - Response:
data.agent_id,data.guards[]of{rule_id, name, state, updated_at},data.total_guards. A device with no reported guards returns an emptyguards[], not an error. - 5xx:
503if the scoped-permission gate is unwired, the store is degraded (A4 envelope,retry_after_ms: 5000), or theguardian.device.viewaudit row cannot persist (fail-closed). - Audit:
guardian.device.view,target_type=Agent,target_id=<agent_id>— the SAME verbGET .../status/{agent_id}and the dashboard Guardian device lens emit. MCP twin:get_guardian_device_guards(audit_persisted:falseposture instead of fail-closed).
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).
- Permission:
GuaranteedState:Push - Cross-site writes: a session-cookie call must carry an
OriginorReferernaming this site (or a--csrf-trusted-originentry), else it is refused403cross-site request refusedand audited ascsrf.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:
| Field | Type | Required | Description |
|---|---|---|---|
scope | string | No | Scope DSL selector. Empty = all agents. |
full_sync | boolean | No | If 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. |
- Response:
202withdata.queued = true,data.rules(server-side rule count),data.agents(number of agents the push was dispatched to),data.scope. - 4xx:
400if the JSON body is present but not an object, or ifscopefails to parse as a Scope DSL expression;403cross-site request refusedfor a cross-site or header-less cookie-session call (see above);403if a service-scoped API token calls this route — the single most severe instance of this confinement-gap class on this branch, since afull_syncpush mutates what every OTHER service’s agents enforce, not merely reads it. An invalidscopeis refused with400before the fan-out reads the policy generation, the rule list or the deployed set, so it wins over a degraded-store503and does not disturb the heartbeat reconcile; agroup:scope is not parsed here when a management-group store is wired (it names a group, and its members are looked up later). - 5xx:
503(A4 envelope,retry_after_ms: 5000) when a store read the push depends on could not be answered: the policy generation, the rule store, the Baseline deployed set, the tag store or custom-property store (read while resolving the push scope or the scope of a rule), or, for agroup:scope, the management-group membership. Nothing was sent to any agent: the push is refused rather than fanned out with an empty or narrowed rule set (ADR-0038). Retry once the store recovers. The error message readsguaranteed-state store degraded: a store the push depends on is degraded (guaranteed-state, Baseline, tag, custom-property or management-group), naming every store the push reads, not only the rule store. The audit row isresult=deniedwith detailstore degraded scope="<sanitised-expr>". The heartbeat reconcile applies the same fail-closed rule (it declines to re-push rather than push an empty set). - Audit:
guaranteed_state.push(success/denied). A server-initiated re-push to a lagging agent on heartbeat reconnect is audited separately underguaranteed_state.reconcile(principalsystem). - MCP twin:
push_guardian_rules(#2146 Batch B1) — same gate, same scope-string push mechanism (not the sharedcommand_dispatch_fn/check_targeting_shapechokepoint). Genuinely non-idempotent, annotatedidempotentHint:false.
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.
- Permission:
GuaranteedState:Read. The generation is a fleet-wide scalar with no per-device or per-service slice, so a service-scoped API token is refused with403even when it holds the permission. This is not the administrator check the raise uses. - Response:
200.
{
"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.
- 4xx:
401when the caller is not authenticated;403for a caller withoutGuaranteedState:Reador for a service-scoped API token (A4 envelope). - 5xx:
503(A4 envelope,retry_after_ms: 5000) when the Guardian store cannot answer the read; the value is never reported as0in that case. A server with no Guardian store wired answers503with no retry hint. - Audit: none on success (the MCP twin’s successful call is recorded only by the generic
mcp.get_guardian_policy_generationtool-call row). A service-scoped token turned away on this route is audited asguaranteed_state.policy_generation.readwithresult=denied,target_type=GuaranteedState,target_id=policy_generation; the MCP twin’s refusal is audited asmcp.get_guardian_policy_generationinstead. See Audit Log. - MCP twin:
get_guardian_policy_generation, no arguments, the samegenerationandceilingfields. Readable from thereadonly,operatorandsupervisedtiers with no approval ticket. A degraded store is a retryable internal error.
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.
- Permission: a durable Administrator, the same dedicated
is_rbac_administratorcheck asPUT /api/v1/rbac/enforcement, notGuaranteedState:Pushand not an ordinary permission check. With RBAC enforcement off, the caller’s own local account must hold theadminrole; with it on, the caller must hold a user-principalAdministratorgrant. A JIT elevation or a group-held grant does not satisfy it. After that check the route re-checks the caller’s authority under the current RBAC regime, as the RBAC grant routes do (a caller admitted under a stale cached view is refused). MFA step-up applies when the account is enrolled and its proof is stale (401with the step-up envelope); a SAML session gets403at the step-up gate, an SSO (OIDC) session is judged on the identity provider’s MFA assertion and the--mfa-enforcementmode instead of a local enrolment, anapi_tokenormcp_tokenbearer session is exempt from step-up (mfa_step_up.cpp), the same accepted deviation as the RBAC enforcement toggle, and--mfa-step-up-window-secs 0disables the gate. Limit: with--mfa-enforcement optional(the default) an administrator who has not enrolled in MFA is not stepped up, and neither is an SSO (OIDC) administrator whose identity provider sent no MFA claim, and any administrator API-token session is exempt, so on this route such administrators are not MFA-gated. Order of checks: authentication, session class, durable-Administrator check, current-regime re-check, MFA step-up, this route’s own copy (defence in depth) of the same-siteOrigin/Referercheck (cookie sessions; the server-wide check has already answered first), Guardian store availability, body validation, the rule and deployed-Baseline counts and the empty-rule-set check, then the attempted audit row and the raise. - Cross-site writes: a session-cookie call must carry an
OriginorReferernaming this site (or a--csrf-trusted-originentry), else it is refused403cross-site request refusedand audited ascsrf.denied. API-token callers are exempt, so the curl example in “Restoring the policy generation” in Guaranteed State, which uses a token, needs noOrigin. A cookie-session variant of that call sends-H "Cookie: yuzu_session=$COOKIE" -H "Origin: https://yuzu.example.com"instead of theAuthorizationheader. The server-wide same-site check (see Authentication) answers first; this route also keeps its own copy, which is defence in depth. - Request body:
| Field | Type | Required | Description |
|---|---|---|---|
target_generation | integer | Yes | The 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_replaced | boolean | Yes | Must 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_set | boolean | Only when the server holds no rules or no deployed Baseline | Must 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. |
- Response:
200with the fields below, whether or not the counter moved.
{
"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.
- 4xx:
400for a body that is not valid JSON or not a JSON object, atarget_generationthat is missing, not an integer or outside1to9007199254740991, anacknowledge_rule_set_replacedthat is nottrue, or an empty rule set (no rules, or no deployed Baseline) withoutacknowledge_empty_rule_set: true;401when MFA step-up is not satisfied (see above);403for a caller who is not a durable Administrator (including one the current-regime re-check refuses), a service-scoped API token, an engine session (refused by the central step-up check), a bearer token that carries an MCP tier (denied before the administrator check, because REST has no approval ticket flow to legitimise a tiered token), a SAML session at the step-up gate, or a cross-site cookie-session call (cross-site request refused). - 5xx:
503(A4 envelope,retry_after_ms: 5000) when the RBAC store is not open or the administrator or current-regime check cannot be answered, when a Guardian store is not open, when the rule count or the deployed-Baseline count cannot be read (nothing is changed; auditeddenied), or when the store fails during the raise (auditedfailure). That last case is UNCONFIRMED: a lost commit acknowledgement can hide a change that did commit, so the response says the outcome is unconfirmed. Repeating the request is the verification and is safe because it is idempotent: a repeat of a raise that took effect returns200withraised: false, so success on a repeat looks like a no-op.503withSec-Audit-Failed: trueand the message “audit log unavailable; the change was not made” when theattemptedaudit row cannot be written: the counter is not touched. If the raise itself succeeded but its outcome row could not be persisted, the response is also503withSec-Audit-Failed: true, saying the audit record was not persisted, and the counter may already have moved; repeat the request, which is idempotent, to obtain a reply that carries the outcome. - Audit:
guaranteed_state.policy_generation.raise,target_type=GuaranteedState,target_id=policy_generationon every row of this verb, including the denials of a service-scoped token and an MCP-tier token. Anattemptedrow (detailtarget=<N>; rules=<X>; deployed_baselines=<Y>) is written first and is the gate: noattemptedrow, no change. The outcome row follows (success,deniedorfailure), withdetailtarget=<N>; previous=<P>; resulting=<R>; raised=<true|false>; rules=<X>; deployed_baselines=<Y>on success. A request refused with400(including the empty-rule-set refusal, whosedetailisempty rule set not acknowledged; rules=<X>; deployed_baselines=<Y>; target=<N>) or at a gate leaves a singledeniedrow and noattemptedrow. These cases write no row of this verb: a missing session, a step-up refusal (recorded undermfa.step_up.required), which includes an engine session (reason=engine_principal_denied, with the route label astarget_id), a cross-site cookie call (recorded ascsrf.denied), a missing RBAC store (503), and a missing Guardian state store or Baseline store (503, counted in the metric below only). On the MCP twin, an argument rejected by the input schema (-32602) and a call whose stores were never wired also write none; the empty-rule-set refusal (also-32602) is the exception and writes onedeniedrow. See Audit Log. - Metrics:
yuzu_server_guardian_policy_generation_raise_total{transport="rest",result}withresultone ofapplied,unchanged,refused(a rejected body or target, or an empty rule set sent withoutacknowledge_empty_rule_set,400),failed(a store fault, an unreadable count or an unwritableattemptedrow,503). On MCP,refusedis counted for the empty-rule-set refusal as well as on REST; a bad argument type is rejected by the MCP input-schema check before the handler runs and is not counted. A caller turned away by a gate is not counted. See Metrics. - MCP twin:
raise_guardian_policy_generation, same arguments (including the optionalacknowledge_empty_rule_set) and the same result fields; supervised tier with an approval ticket and the same administrator check (there is no MFA on MCP; the protection is the approval ticket plus the administrator check). The read-only counterpart isGET /api/v1/guaranteed-state/policy-generation(MCPget_guardian_policy_generation).
GET /api/v1/guaranteed-state/events
Query Guaranteed State events (rule violations, remediations, agent sync events).
- Permission: two gated shapes behind one route. A query with a non-empty
agent_id(max 256 chars, no control characters) requires per-device-scopedGuaranteedState:Read— management-group aware, and a service-scoped API token is confined to its own service’s agents. A query with noagent_id(fleet-wide) requires globalGuaranteedState:Readand denies a service-scoped token outright (403): the fleet fan-out returns every reporting agent’sagent_id+detail_json, which the bare service-token role check alone would not confine. - Query parameters:
rule_id,agent_id,severity,limit(default 100, capped at 1000),offset(default 0). - Response:
data[]of event objects. Each object:event_id,rule_id,agent_id,event_type,severity,guard_type,guard_category,detected_value,expected_value,detail_json,remediation_action,remediation_success,detection_latency_us,remediation_latency_us,timestamp.detail_jsonis a structured JSON string: for DEX observations it carries the uniform keyssubject/reason/symbolic/component/metric/platform(plus, forprocess.crashed, the legacyprocess/exception_code/faulting_module); empty string for plain drift events. Per-signal shapes are documented indocs/dex-signal-catalog.md. - 4xx:
400on non-integer or negativelimit/offset(both are parsed before thedex.device.viewaudit row is written, so a400here leaves no access row), or on anagent_idover 256 characters / containing a control character.403if a service-scoped token queries the fleet-wide shape, or names a device outside its own service’s scope. - Ruleless DEX signal observations share this endpoint. Filter
rule_id=__observation__to retrieveevent_type=<obs_type>rows (process.crashed,process.hung,service.crashed,os.boot, … — fleet-wide signals recorded independent of any rule;severityis a fixedinfo;expected_valueempty). See DEX signal observations and the DEX dashboard. - Audit (behavioral PII): BOTH shapes emit a
dex.device.viewaudit row on every read — the per-device shape returns that device’s signal history (detail_jsonreveals which apps a person runs) and auditstarget_type=Agent,target_id=<agent_id>(the same verb as the dashboard per-device drill-down); the fleet-wide shape returns every reporting agent’s rows and auditstarget_type=GuaranteedState,target_id=(empty — no single agent). Fail-closed on both shapes: the audit fires before the data is serialized; if the audit row cannot persist, the endpoint returns503+Sec-Audit-Failed: trueand serves no data (parity withGET /api/v1/dex/devices/{id}). A service-scoped token denied the fleet-wide shape also gets adex.device.viewdeniedaudit row, so a rejected probe still leaves a trace. - MCP twin:
list_guardian_events(#4037) — same dual gate shape, same query params.audit_persisted:falseposture instead of REST’s fail-closed (JSON-RPC has noSec-Audit-Failedheader channel). The fleet-wide branch’s confinement gap (#3238) is not closed by this twin — it deliberately keeps the same bare-perm_fnposture REST has today rather than inventing a different MCP-only gate.
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.
- Permission:
GuaranteedState:Read(non-service-scoped; management-group confined viaAuthRoutes::require_list_read, ADR-0017) - Response keys:
total_rules,compliant_rules,drifted_rules,errored_rules(field names match the agent-side protoGuaranteedStateStatus). - 4xx/5xx:
403for a service-scoped token, a caller with noGuaranteedState:Readgrant anywhere, or an unreachable/corrupt RBAC or management-group store for an ordinary caller —authorize_list_readfails closed to the SAME403as a genuine no-grant denial, not a503, for every non-engine caller (the two are not currently distinguished in the response code).503only for: an unwired list-read gate or a null Guaranteed State store (server misconfiguration, noretry_after_ms); a Guaranteed State store query-time degrade (transient,retry_after_ms: 5000); or, for an engine-principal caller specifically, an unreachable/unopened RBAC store (engine sessions get a distinct503here, unlike human/service sessions) — never a silent0. - MCP twin:
get_guardian_status(#4037) — calls the SAMEguardian_status_rollupbuilder and the SAMErequire_list_readgate (viaMcpServer::set_list_read_fn, wired from the identical server.cpp lambda REST uses), so REST and MCP cannot observe a different admit decision or a different rollup for the same caller.
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.
- Permission:
GuaranteedState:Read, per-device scoped - Response keys:
agent_id,total_rules,compliant_rules,drifted_rules,errored_rules. - 5xx:
503on an unwired scope gate/store, an audit-persistence failure, or a degraded store (A4 envelope,retry_after_ms: 5000) — never a silent0. - MCP twin:
get_guardian_agent_status(#2146 Batch B1) — calls the SAMEguardian_agent_status_rollupbuilder and the SAMEscoped_perm_fngate, so REST and MCP cannot observe a different admit decision or a different rollup for the same caller.
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.
- Why name, not id. An integration pins one stable constant (e.g.
ServiceNow Compliance) once. Baseline names are unique and survive reseeds/rebuilds, where abaseline_idchurns — so there is no per-environment id to reconfigure. - Permission:
GuaranteedState:Read, per-device scoped — a global grant passes fleet-wide; otherwise the caller must holdReadvia a management group the device is in (mirrors the dashboard Guardian device lens, so a group-scoped operator/service account is not locked out of in-scope devices). Upgrade note: a previously group-scoped token now receives403for devices outside its group(s) — earlier builds gated this route on a flat global check that would have passed them. A globalGuaranteedState:Readtoken (the documented ServiceNow service-account setup) is unaffected. - Audit:
guardian.device.view(target typeAgent) — same verb the dashboard per-device Guardian lens emits, so one SIEM filter catches both surfaces. (Behavioural per-device data.) A scoped-permission denial is audited separately at the auth layer asauth.scoped_permission_required. - MCP twin:
get_guardian_device_compliance(#2146 Batch B1) — calls the SAMEguardian_device_compliance_rollupbuilder and the SAMEscoped_perm_fngate; all four underlying reads complete before the access audit fires on either transport, so a degrade can never surface after an audited “success”. - Evidence integrity — fail-closed (CC7.2). This is a behavioural-PII read, so if the
guardian.device.viewaudit row cannot persist (locked store, disk-full, or a pipeline exception — including a throwing audit pipeline) the endpoint refuses to serve: it returns503+Sec-Audit-Failed: truewith an A4 envelope carrying aretry_after_mshint, and withholds the compliance body — parity withGET /api/v1/dex/devices/{id}. Serving audited per-device compliance while the evidence row is known-lost is exactly what audit-on-open prevents. The503is returned before the404, so an audit outage never reveals baseline existence without durable evidence. A CMDB integration should treatSec-Audit-Failed: trueas “retry after the audit subsystem recovers,” not a permanent error; an audit-off deployment (no audit callback wired) serves normally. (The realistic failure modes also incrementyuzu_server_audit_emit_failed_totaland log tospdlog.) Blast radius: because this is the fleet-polled CMDB endpoint and there is no degraded-serve fallback, a sustained audit-store outage 503s every poll fleet-wide — size audit-store availability for the polling load, and expect a compliance-data blackout (not stale data) for the duration. (Per-route retry jitter to avoid synchronized retries across pollers is a tracked platform-wide hardening, #1647.) - Query params:
baseline(the Baseline name, unique; URL-encode spaces, e.g.ServiceNow%20Compliance),agent_id(the device). Both required. 400if either param is missing, exceeds 256 chars (auth::kMaxAgentIdLength— the enrolled-agent-id ceiling, so a valid device id is never falsely rejected), or contains control characters (bytes< 0x20);403if the caller lacksReadon the device’s scope (checked before the baseline lookup, so an out-of-scope caller gets403even for an unknown name — no name-existence oracle);404if no Baseline has that name;503if the route is misconfigured (its stores or scoped-permission function are unwired — non-transient, do not auto-retry, noretry_after_ms) or if the baseline store or the guaranteed-state store itself faults (DB locked/corrupt — retryable, A4 envelope withretry_after_ms: 5000; a transient store fault returns503, not the404a CMDB would otherwise read as “no such baseline → delete this CI”). The400/404/503bodies use the A4 envelope (correlation_id); the403is emitted by the shared auth/RBAC layer and carries that layer’s denial body, not the A4 envelope (nocorrelation_id; exact shape varies by denial reason — RBAC vs service-scope). For a robust integration, branch on the HTTP403status and treat the body as opaque/diagnostic — do not structurally parse it (theerrorfield may be a JSON string or an object depending on the denial reason).- Response keys:
baseline—{ baseline_id, name, lifecycle }.deployed—truewhen the Baseline’s lifecycle isdeployed. Whenfalse,guardsis empty andtotal_guardsis0— render a “No Baseline Deployed” placeholder.assessable— machine-readable go/no-go.trueonly when the Baseline isdeployedand at least one applicable Guard has reported (total_guards > 0).falsefor a draft Baseline, or a deployed Baseline this device has reported no applicable Guard against (newly-enrolled / offline / all out-of-scope). Whenassessableisfalse, do not compute or render a compliance percentage — branch a business rule on this flag instead of reconstructing thedeployed+total_guards+last_updatedconjunction. It is the machine signal that closes the0/0green-wash.agent_id.total_guards,compliant,drifted,errored,pending— counts over the Guards applicable to this device (see Semantics + the compliance-percentage caution below). Invariant:total_guards == compliant + drifted + errored + pending.snapshot_total— the Baseline’s full deployed-member count (the superset across all devices, an upper bound — not this device’s in-scope count).total_guards <= snapshot_total; a gap is expected for a per-machine-scoped Baseline, but a large gap is a cue that this device’s coverage may be partial.last_updated— the newestupdated_atacross the guards (a “compliance as of” stamp);nullonly when this device has reported no applicable guard. A guard the device did report contributes its timestamp even if its verdict token is unrecognized (pending).guards[]—{ rule_id, name, status, updated_at }per applicable Guard.statusiscompliant|drifted|errored|pending;updated_atis the ISO-8601 time this device last reported that Guard’s verdict,nullonly when this device has not reported the guard (a reported guard carries its timestamp even whenstatusispending).
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 uselast_updatedfor 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’sscope_expragainst the device so in-scope-unreported showspendingand 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_guardsrather than counting against it. Socompliant / total_guardsover-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-devicescope_exprcomputed denominator ships (tracked follow-up). Treat the numbers as advisory: branch onassessable, watch thesnapshot_total − total_guardsgap as a partial-coverage cue, and cross-referencelast_updatedand your own device-liveness signal. (Theassessable:false/0/0case 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 poll404until 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).
- Permission:
GuaranteedState:Read - Response: empty list in PR 2.
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).
- Permission:
GuaranteedState:Read - Response:
200with{version, schemas[]}, each entry{kind, type, json_schema}. Includes the discriminatedregistry-value-equalsencoding (pervalue_type), thefile-hash-equalsexpected_hashhex format, and theservice-running/service-stoppedassertion schemas (withservice_namepattern validation mirroring the agent’s accepted service-name charset). The catalog is the source of truth for which spark/assertion/remediation types the agent actually implements — author against what it lists. - Caching: carries a content-derived
ETagandCache-Control: public, max-age=300; a conditional request withIf-None-Matchreturns304 Not Modified. The catalog is compiled-in, so this endpoint answers even when the rules store is unavailable.
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.
- Permission:
GuaranteedState:Read - Query parameters:
window(24h,7d,30dorall; omitted =7d; an empty value or anything else is400, where90dor30used to be answered with 7 days of data);os(windows/linux/macosnarrows the rollup to that OS’s own signals; omitted = every OS). - Response:
data[]of{obs_type, count, distinct_devices, last_seen}.obs_typeis the stable machine key (e.g.process.crashed,os.boot); map your own labels. Not audited.
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).
- Permission:
GuaranteedState:Read - Query parameters:
window(24h,7d,30dorall; omitted =7d; an empty value or anything else is400). - Response:
data[]of{platform, distinct_types, total_events}. Not audited.
GET /api/v1/dex/signals/{obs_type}
One signal type’s drill-down.
- Permission:
GuaranteedState:Read - Path parameter:
obs_type— must match[A-Za-z0-9._-]{1,64}. - Query parameters:
window(24h,7d,30dorall; omitted =7d; an empty value or anything else is400, refused before thedex.signal.viewaudit row is written);os(windows/linux/macosscopessubjects[],devices[]andby_day[]to that OS;by_os[]stays cross-OS since it IS the split; omitted = every OS);limit(capssubjects[]anddevices[], default 50, clamped to 500). - Response (
200): an object{obs_type, os, subjects[], by_os[], devices[], by_day[]}whereosechoes the applied filter (allwhen unscoped),subjects[]is{subject, count, distinct_devices, last_seen},by_os[]is{platform, count, distinct_devices},devices[]is{agent_id, count, last_seen}, andby_day[]is{day, count}. A well-formedobs_typewith no observations in the window returns200with empty arrays (it is a read-model query, not an entity lookup). - 4xx:
400on a malformedobs_typeor a non-integer / negativelimit.403if a service-scoped API token queries this route — thedevices[]array is fleet-wide (every agent exhibiting this signal), with no single agent to confine a per-target check against, so a service-scoped token is denied outright (the bare permission gate alone checks only the token’s ITServiceOwner role, never its own service-tag scope). - Audit (behavioral PII): the
devices[]array names theagent_ids exhibiting this signal, so the endpoint emitsdex.signal.view(target_type=ObsType,target_id=<obs_type>) before serving — see the audit boundary note above. A denied service-scoped token also emitsdex.signal.view(result=denied,target_id=empty) so a rejected probe still leaves a trace. Fail-closed: if the audit row cannot persist, returns503+Sec-Audit-Failed: trueand serves no device list (parity withGET /api/v1/dex/devices/{id}).
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.
- Permission:
GuaranteedState:Read - Response: an object
{cpu_pct, commit_pct, disk_lat_ms, reporting, windows_online, linux_online, macos_online, reporting_windows, reporting_linux, reporting_macos, perf_capable_online}where each metric is{avg, p50, p90, max, n}ornullwhen no device reported it this cycle (absent, never 0).reportingcounts devices contributing at least one metric;windows_online/reportingare byte-identical to their historical values — only the trailing fields are new.linux_online/macos_onlineare the same online-count per OS, andreporting_windows/reporting_linux/reporting_macossplit the reporting population by OS.perf_capable_onlineis the OS-aware denominator forreporting: online devices whose OS has a perf collector today (Windows + Linux), soreportingdoes not exceed it, except for a device whose Subscribe session was reaped while its heartbeat is still fresh (OS unknown for that sweep);windows_onlineis unchanged and counts Windows devices only.reporting_macosis always 0 today —macos_onlinecounts real online macOS agents, but no macOS perf collector exists yet (honest absence, not a bug). Not audited.
GET /api/v1/dex/perf/cohorts
Fleet-relative performance percentiles per cohort — the distinct values of an operator-chosen tag key (see Cohort benchmarking).
- Permission:
GuaranteedState:Read - Query parameters:
key— the cohort tag key ([A-Za-z0-9_.:-]{1,64}, defaultmodel). - Response:
{key, floor, cohorts[], available_keys[]}. Each cohort row is{cohort, devices, suppressed}plus, when not suppressed, per-metric stats as in/perf/fleet. Cohorts underfloor(1 reporting device, so only an empty cohort) carrysuppressed: truewith their population and no stats. A cohort keyed by a per-device tag value with one device is that device’s exact figures. Devices without the key form the explicitcohort: ""(untagged) residual.available_keyslists the fleet’s tag keys for picker UIs.400on an invalid key. Not audited.
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.)
- Permission:
GuaranteedState:Read - Query parameters:
key— the cohort tag key ([A-Za-z0-9_.:-]{1,64}, defaultmodel);a,b— the two cohort values to compare (both required; an empty value is the untagged residual). - Response:
{key, floor, found_a, found_b, a, b, delta_pct}.found_a/found_barefalsewhen a cohort has no reporting devices (its side isnull).a/bare cohort rows{cohort, devices, suppressed}plus per-metric stats when not suppressed.delta_pctis{cpu_pct, commit_pct, disk_lat_ms}where each is A’s p50 relative to B’s p50 (B the baseline; positive = A higher), ornullunless both cohorts expose that metric (neither suppressed below the one-device floor, i.e. neither empty). A cohort keyed by a per-device tag value with one device is that device’s exact figures.400on an invalid key or missinga/b. Not audited.
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.
- Permission:
GuaranteedState:Read - Query parameters:
metric(cpu/commit/disk_lat, defaultcpu);filter=not_reporting(devices of an OS with a real perf collector — Windows and Linux today — that had no perf sample this cycle; a macOS device never appears, collector or not);cohort_key(display key — always resolved, defaultmodel, so rows carry real cohort values);cohort_value(when present, restricts to that cohort; an empty value selects the untagged residual);limit(default 50, clamped to 500). - Response:
data[]of{agent_id, cohort, cpu_pct?, commit_pct?, disk_lat_ms?, fleet_pctile?, os}, worst-first by the sort metric (fleet_pctileis the device’s nearest-rank position among all reported values; omitted when the device did not report the metric;osis the normalized token —windows/linux/macos/empty for unrecognized).400on an invalidcohort_keyorlimit.403if a service-scoped API token queries this route (fleet-wideagent_idrows, no single agent to confine against — same rationale asdex/signals/{obs_type}above). - Audit (behavioral PII): each row is an
agent_id+ its perf metrics, individual-identifying, fleet-wide — emitsdex.perf.device.view(target_type=GuaranteedState,target_id=empty) before serving. A denied service-scoped token also emits this verb (result=denied). Fail-closed: if the audit row cannot persist, returns503+Sec-Audit-Failed: trueand serves no device list.
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’scpu_p50/cpu_p95/ws_p50/ws_p95is eithernull(absent — empty population, not zero) or{value, lower_bound}.lower_bound: truemeans the percentile fell in the open top histogram bucket, sovalueis a floor — render it as ”≥ value”, never an exact quantile.hist_stale: truemeans the row was stamped under a superseded histogram scheme, so percentiles are withheld (the exact mean/max still stand). Cohort floor:kDexCohortFlooris 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_countonly). A single-device point is therefore published — as a fleet-wide aggregate by application and version with noagent_id, behindGuaranteedState: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).
- Permission:
GuaranteedState:Read - Response:
{apps[], truncated}where each app is{app_name, versions, last_day}(versions= distinct retained versions;last_day= most recent UTC-midnight epoch day with data).truncated: truewhen the distinct-app set exceeded the picker cap.503(withretry_after_ms) when the store provider is unwired or the read degrades. Not audited.
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).
- Permission:
GuaranteedState:Read - Query parameters:
app— required, ≤ 512 bytes, no control characters (a400otherwise; a NUL would truncate the bound query).version— optional; omitted = every version interleaved, each point tagged with its canonicalizedversion; same length/charset rule. - Response:
{app, version, points[], versions[], versions_truncated, failure_window_days, exposure_basis, stat_failures}. Each point:{version, day, device_count, suppressed}plus the full stat fields (cpu_mean, cpu_max, cpu_p50, cpu_p95, ws_mean, ws_max, ws_p50, ws_p95, hist_stale) only whensuppressedis false — a sub-floor(version, day)point (the floor is one device, so a point no device contributed to) carriesdevice_countonly, the same suppression the group endpoint applies (see the percentile/suppression note above).400on a missing/invalidapporversion;503(withretry_after_ms: 2000) when either the performance read or the crash-and-hang read degrades. Not audited. - Per-version crash and hang join (additive): the response also carries
versions[],versions_truncated,failure_window_days,exposure_basisandstat_failures;app,versionandpoints[]are unchanged.versions[]has one row per version, newest canonical (four-part, numeric) version first, at most 20 rows (versions_truncated: truewhen older versions were dropped; the unknown-version""bucket sorts last). The cap keeps the 20 highest version strings the agents reported, so a device reporting implausible versions can push real ones out of the default list (versions_truncated: true);?version=reaches any of them. The list is derived from the same row-capped read aspoints[], which returns at most 100,000(version, day)rows ordered by version string and then day: an application with more retained rows than that (about 556 versions over the 180-day window) has its later-sorting version strings cut off before the numeric newest-20 selection runs, so a numerically newest version can be absent whileversions_truncatedreports only the 20-row cap. It names only versions with retained performance rows, so the absence of a row is not the absence of failures. Each row carriesversion,latest_dayandday_count(days the version appears in the window),device_countandsuppressed(the latest day’s device count and its sub-floor flag, as on a point),affected_devices,crashesandhangs, the exposure fields below,previous_versionandfailure_rate_ratio. Failures are joined to a row on the exact canonical version and subject the agent reported: a row withaffected_devices > 0andexposure_devices: nullis failures recorded under a version that has no samples, and a zero rate covers only failures recorded under exactly that version (a packaged app whose crash events carry no version lands in the""bucket). No device floor applies to this join (maintainer ruling, 2026-10-14; the cohort floor is one device, see the note above): every figure below is published at any device count, 1 device included. It is a fleet-wide aggregate by application and version only, with noagent_idor device identifier, behindGuaranteedState:Read:- Breadth —
affected_devicesis the number of fleet-wide devices that reported a crash or hang for the version in the window, andcrashesandhangsare the fleet-wide event counts; all three are always present (a type with no events is0). Breadth counts every reporting device, sampled or not. - Exposure —
exposure_devicesis the number of devices with retained resource-significant samples for that exact image and version in the window. It is a selected subset (the daily top-N consumers), not a census, henceexposure_basis: "resource_significant".nullmeans unknown (no sample rows), never zero. When it is known,exposed_crashesandexposed_hangscount the crash and hang events on exposed devices only (eachnullonly when exposure is unknown), and, when it is positive,failure_rateis{estimate, lower, upper, reliable}— those exposed events per exposed device over the window, with an exact (Garwood) 95 percent interval, at full double precision (it is up to the view to scale to per 1,000). A zeroexposure_devicesgives anullrate (never a divide by zero, never a healthy zero). The rate is per exposed device over the window, not per day of use: a version with fewer days in the window (day_count,latest_day) has had less time to fail, so read the rate besideday_count: it counts days in the 180-day trend window, so only a value belowfailure_window_days+ 1 means shorter in-window tenure.reliableisfalsebelow 20 events, an evidence-quality marker and not a floor: print the interval beside it. previous_versionis the next-older known version in the list (nullfor the oldest known version in the list (seeversions_truncated) and for"").failure_rate_ratiois{estimate, lower, upper, unbounded, reliable}— this version’s rate over the previous version’s, with an exact conditional (Clopper-Pearson) 95 percent interval — present only when both versions have a known, positive exposure;nullotherwise, andnullwhen neither version saw an exposed event. When the previous version saw no exposed events,estimateandupperarenullwithunbounded: true(a finitelowerstill holds). A request for one exactversionyields a one-rowversions[]with no previous version.failure_window_daysis the effective window for both the events and the exposure: 30, clamped to one day less than the observation retention (--guardian-event-retention-days) when that is 1 to 30 days, so no counted day has had its events reaped (at the default retention of 30 days it reports 29). That holds at a steady retention: raising--guardian-event-retention-days(orYUZU_GUARDIAN_EVENT_RETENTION_DAYS) and restarting widens the window at once but does not restore events already reaped under the old value, so for up tofailure_window_daysdays after such a restart the counts and the rate can read low against a full exposure window (an accepted residual); lowering it is safe. The window is that many whole UTC days plus the current day so far, cut at one UTC midnight for both; an observation stamped after the end of the current UTC day is excluded.stat_failurescounts statistics that returned nothing on valid input (expected0); documented refusals — both arms zero-event, an arm above one billion events — are not counted. If either underlying read degrades the whole request is a503, never a200with an emptyversions[].
- Breadth —
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).
- Permission:
GuaranteedState:Read, gated onAuthRoutes::require_fleet_read(ADR-0017 admit-then-filter) as the sole gate — never stacked with a bare permission check. A management-group- or service-scoped caller gets the real, narrowedagent_idintersection pushed into the store query itself (never a post-fetch filter), so a confined caller never sees an unfiltered page and a fully-confined-empty caller sees zero rows, not the whole fleet. - Query parameters:
app— required, ≤ 512 bytes, no control characters.version— required and present (unlike/perf/app’s “omit = all versions” convention): this drill is always scoped to exactly one version; pass an empty string for the unknown-version bucket (the only bucket Linux agents report today — Linux procperf does not yet capture a file version) rather than omitting the parameter, which is rejected with400. - Response:
{app, version, truncated, devices[]}where each device is{agent_id, last_day, samples, cpu_avg, ws_avg_bytes}. Rows are ordered by descendingcpu_avgand capped;truncated:truemeans only the highest-CPU devices are returned, not an arbitrary subset.400on a missing/invalidapp/version;403if the caller lacksGuaranteedState:Read;503on store degrade or a dropped audit row (Sec-Audit-Failed: true). - Retention mismatch: per-device data (B1) retains only 31 days, shorter than the fleet trend’s 180-day (B2) window above — a version last reported more than a month ago legitimately returns
devices: []even though/perf/appstill shows aggregate history for it. This is expected, not a bug; the two stores retain independently. - Headers:
X-Correlation-Idechoed on every response path. - Audit: emits
dex.app_perf.devices.view(target_type=GuaranteedState,target_id="",detailcarriesapp=<name> version=<token> devices=<n> cid=<correlation_id>) after the read (so the count is honest) but before the response is sent — fail-closed: a persist failure returns503+Sec-Audit-Failed: trueand serves no device data, same contract asGET /api/v1/dex/devices/{id}/app-perf. MCP twin:list_dex_app_perf_devices, set-and-proceed (audit_persisted:falsein the body on a dropped row, noSec-Audit-Failedheader channel).
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).
- Permission:
GuaranteedState:Read. Gated on the global permission (like the cohort surface), not the per-device scope gate: the global check excludes management-group-scoped-only principals, so only a fleet-wide-Read caller — who can already compute every fleet/cohort aggregate — reaches it. No cross-operator exposure on that axis. A service-scoped API token is a DIFFERENT axis, though:ITServiceOwnerholds a globalGuaranteedState:Readgrant regardless of scope, so the global-permission reasoning above does not confine it — it could otherwise supply anygroup_id, including one outside its own service. Found by this branch’s own governance review (PR #3156), while re-verifying the external review’s separate findings on this same file; a service-scoped token is now denied outright (403). - Query parameters:
group_id— required, ≤ 512 bytes, no control characters.app— required, same rule.version— optional, same rule. - Response:
{group_id, app, version, floor, points[]}whereflooris the suppression threshold (1: only an empty point is suppressed). Each point:{version, day, device_count, suppressed}plus the full stat fields only whensuppressedis false. An empty/unknown group returns200withpoints: [](not a503).400on a missing/invalid parameter;503on store degrade. - Audit:
dex.perf.group.view(deniedonly — an ordinary successful read is not audited, matching this route’s existing aggregate/cohort posture).
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.
- Permission:
GuaranteedState:Read. Same global-permission gate and the same DIFFERENT-axis service-scoped-token caveat as/dex/perf/group— see that section. A service-scoped API token is denied outright (403). - Query parameters:
key— optional tag key, default"model"; must match^[A-Za-z0-9_.:-]{1,64}$(400otherwise).value— required, ≤ 512 bytes, no control characters (an omitted or emptyvalueis400— there is no “every value” wildcard).app— required, same rule.version— optional, same rule. - Response:
{key, value, app, version, floor, points[]}whereflooris the suppression threshold (1: only an empty point is suppressed). Each point:{version, day, device_count, suppressed}plus the full stat fields only whensuppressedis false. An unknown tag value returns200withpoints: [](not a503) — indistinguishable from a genuinely empty cohort.400on a missing/invalid parameter;503on store degrade. - Audit:
dex.perf.tag.view(deniedonly — an ordinary successful read is not audited, matching/dex/perf/group’s 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.
- Permission:
GuaranteedState:Read, gated on the global permission (same posture as/dex/perf/group, including the same DIFFERENT-axis caveat for service-scoped tokens — see that section). A service-scoped API token is denied outright (403); found by this branch’s own governance review (PR #3156), while re-verifying the external review’s separate findings on this same file. The near-individual nature of this endpoint (real canaries are often 2-3 devices) makes the gap especially severe for it. - Query parameters:
group— required, ≤ 512 bytes, no control characters; the management group whose members are the cohort.app,baseline,candidate— required, same validation;baselineandcandidatemust differ (400if equal after canonicalization).window— optional integer days, default7; an integer in range is clamped to1–31, while a value that is not one base-10 integer or does not fit a signed 32-bit integer (window=99999999999) is a400(the dashboard fragment answers its usual200note instead). - Response (
200):{app, group_id, baseline_version, candidate_version, window_days, cohort_size, paired, baseline_only, candidate_only, no_data, small_cohort, insufficient, truncated, cpu{before_mean, after_mean, delta_median, before_p95, after_p95}, ws{…}, distribution{up, flat, down}}.truncated:truemeans the cohort exceeded the 100,000-row read cap — the counts are incomplete and may mis-pair machines (a device that ran both versions can be mis-reported as one-version-only); treat the result as unreliable and narrow the group or shorten the window. A zero-sample machine (one that measured nothing in-window) is excluded from pairing rather than counted as a 0%-CPU pair.cohort_size= group members;paired= ran both (the comparison population);baseline_only/candidate_only= excluded (one version only);no_data=cohort_size − paired − baseline_only − candidate_only.small_cohort:true=pairednon-zero but below the cohort floor — not suppressed (canaries are deliberately small); with the floor at one device the field stays on the wire but is alwaysfalse.insufficient:true=paired == 0(no machine ran both — thecpu/ws/distributionvalues are zero and should not be displayed).cpu.*are percent (float);ws.*are bytes (int64);delta_medianis the median per-machine delta (positive = candidate heavier).distribution.{up,flat,down}count machines whose per-machine CPU delta exceeded / stayed within / fell below ±0.3 pp. - No per-machine identity in the response — the aggregate carries no
agent_id. The per-machine pairs are a dashboard-only drill (/fragments/auto/verify/drill, auditeddex.app_perf.compare.drill); there is no REST or MCP per-machine surface in this slice (a REST audited-fail-closed per-machine drill is a deferred follow-up). - Error paths:
400on a missing/invalid required parameter, a malformedwindow, orbaseline == candidate;403if a service-scoped API token calls this route;503on store degrade or startup warmup (the A4 body’sretry_after_mscarries the suggested delay). - Headers:
X-Correlation-Idon every response path;Sec-Audit-Failed: truewhen the audit row could not persist (the read still proceeds — operational set-and-proceed). - Audit: emits
dex.app_perf.compare(target_id=<group_id>,detailcarriesapp=<name> base=<v> cand=<v> cohort=<N> paired=<N> view=aggregate cid=<cid>—paired=so a singleton (paired=1) aggregate, which is effectively per-machine, is distinguishable in the log). Because VERIFY has no cohort floor, this recorded read is the accountability that replaces suppression (operational set-and-proceed, not fail-closed — the aggregate carries no per-machine identity, so a lost row leaks no PII). A service-scoped-token denial is also recorded under this same verb (result=denied,target_id=empty). The MCP twin is recorded under the genericmcp.compare_app_perf_versionstool-call audit on SUCCESS, which carries the same subject in its detail (group/app/versions/cohort/paired) and setsaudit_persisted:falsein the result body on a dropped row (MCP has noSec-Audit-Failedheader channel) — but a service-scoped-token DENIAL over MCP is recorded underdex.app_perf.comparetoo, matching the REST verb rather than the generic one (the deny-path convention every REST/MCP twin pair in this doc uses).
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.
- Permission:
GuaranteedState:Read, scoped to the device’s management group (a REST worker is held to the same per-device scope as the dashboard lens). - Path parameter:
id— the agent’sagent_id. - Query parameters:
window— one of24h/7d/30d/all(default7d); an off-enum value is rejected with400. - Response:
dataobject{agent_id, window, score, signals[]}—scoreis the device’s DEX experience score (0–100;-1= n/a, treat as “no data”, not a low score);signals[]each{obs_type, count, distinct_devices, last_seen}for the window. An unknownagent_idreturns200withscore:-1and emptysignals(the scope gate, not existence, decides access).403when the device is outside the operator’s management scope.503when the store is unavailable,503with headerSec-Audit-Failed: truewhen the audit row cannot persist (see Audit below), or503(retry_after_ms: 2000, noSec-Audit-Failed) when the underlying signal-summary read degraded (store closed / pool-acquire timeout / query error, #4855) — a degrade is a retryable error, never a200with a fabricatedscore:0/100and no signals. This is a NAMED exception to the general “#2659 DEX reads degrade to empty” posture described in guaranteed-state.md — this one read fails closed instead. - Headers:
X-Correlation-Idis echoed on every response path (matches/api/v1/events); the value also appears ascorrelation_idin error bodies. - Audit: emits
dex.device.view(target_type=Agent,target_id=<agent_id>,detailcarriescid=<correlation_id>) before the behavioral PII is served (audit-on-open). Fail-closed: if the audit row cannot persist (audit DB locked/full/corrupt), the endpoint returns503+Sec-Audit-Failed: trueand serves no device data — serving audited PII while the evidence row is known-lost is exactly what audit-on-open prevents (SOC 2 CC7.2 / works-council).
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).
- Permission:
GuaranteedState:Read, scoped to the device’s management group. - Path parameter:
id— the agent’sagent_id. - Query parameter:
app— optional display filter applied in memory to the device’s returned rows (not a bound query parameter — the device’s retained rows are fetched byagent_id, then narrowed byapp), so there is no length/charset gate or400on it. Omitted = every resource-significant app-version on the device. - Response:
dataobject{agent_id, rows[]}where each row is one retained daily summary{app_name, version, day, samples, instances_max, cpu_avg, cpu_max, ws_avg_bytes, ws_max_bytes}. An unknownagent_idreturns200with emptyrows.403outside the operator’s scope;503when the store is unavailable, or503+Sec-Audit-Failed: truewhen the audit row cannot persist (serving no data). - Headers:
X-Correlation-Idechoed on every response path. - Audit: emits
dex.device.app_perf.view(target_type=Agent,target_id=<agent_id>,detailcarriescid=<correlation_id>) before the PII is served — kept a separate verb fromdex.device.view/dex.device.procperf.queryso usage-class reads stay separately countable for works-council evidence. Fail-closed as above.
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.
- Permission:
GuaranteedState:Read, scoped to the device’s management group. - Path parameter:
id— the agent’sagent_id. - Query parameter:
window— one of24h/7d/30d/all(default7d); an off-enum value is rejected with400. - Response:
dataobject{agent_id, window, crashes, hangs, signals, distinct_apps, last_seen, history[]}—history[]each{event_id, observed_at, obs_type, subject, reason, symbolic, component, metric}(raw facts; no pre-formatted display string — derive your own summary from the fields, the fragment’s “what happened” column is presentation-only).403outside scope;503on store unavailable or a dropped audit row (Sec-Audit-Failed: true). - Audit: emits
dex.device.view— the SAME verbGET /api/v1/dex/devices/{id}uses (a deliberate reuse: the dashboard fragment audits this exact signal-history capability under this exact verb too). Fail-closed as above.
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).
- Permission:
GuaranteedState:Read, scoped to the device’s management group. - Path parameters:
id(agent_id),event_id. - Response:
dataobject{event_id, agent_id, observed_at, obs_type, subject, reason, symbolic, component, version, metric, platform}.404when the event does not exist or belongs to a different device — same response either way (a foreign/guessedevent_idreveals nothing beyond what the scope gate already allowed, matching the dashboard fragment’s oracle-closed contract). - Audit: emits
dex.observation.view(target_type=Agent,target_id=<agent_id>). Fail-closed as above.
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.
- Permission:
GuaranteedState:Read. Every field this route returns, includingdevices[], is a fleet-wide aggregate with no per-caller confinement — a management-group-scoped grant does not narrow it (a bareGuaranteedState:Readpermission check, same as every other Read on this route family). An earlier per-callervisible-set resolver was retired (WS-A4 PR-1 Gate 7 fix round): it was dormant for a management-group-confined-only caller (who has no global grant to reach this gate with in the first place) and wrongly narrowed a JIT-elevated administrator instead of giving them the unfiltered view elevation earns. - Query parameters:
name— required, process image name (e.g.chrome.exe).window. - Response:
dataobject{process_name, window, crashes, hangs, signals, distinct_devices, first_seen, last_seen, modules[], exceptions[], devices[]}.400on missingnameor an off-enumwindow.403— a service-scoped API token is denied outright:devices[]has no single agent to confine the token’s own service-tag scope against. - Audit: emits
dex.app.viewbefore serving (fail-closed 503 +Sec-Audit-Failed: trueon a dropped row) — the dashboard fragment audits only the service-scoped denial under this verb (not an ordinary success), so this REST route deliberately carries MORE rigor than its own fragment (matching the sibling per-signal/per-device-perf routes’ fail-closed posture on an identical device-id-list shape).
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.
- Permission:
GuaranteedState:Read. - Query parameters:
window— one of24h/7d/30d/all(default7d); an off-enum value is rejected with400. - Response:
dataobject{window, population, population_basis, apps_total, apps_truncated, policy, apps[]}.apps[]holds the 100 applications affecting the most Windows devices (then crashes plus hangs, then name), listed by activity (crashes plus hangs descending, then name in byte order, not database collation);apps_totalcounts every application with a Windows crash or hang signal in the window andapps_truncatedistruewhen the cut dropped any. Each{subject, profile, crashes, hangs, distinct_devices, distinct_devices_online, last_seen, share_affected, rate_per_1000, score, versions[], version_count, versions_truncated}.versions[](up to 20, ordered by devices affected descending, then version;""is the unknown-version bucket) carries the same figures plusversion;versions_truncatedistruewhen that cut dropped any. A full response is about 1.2 MB at 100 applications with 20 versions each; MCP clients should request it only when the per-version rows are needed.crashes,hangsanddistinct_devicesare window-wide.share_affected,rate_per_1000andscoreare over one population, the Windows devices online now:distinct_devices_onlineis how many of the affected devices are online now (nullwhen no fleet source is wired, because membership is then unknown),share_affectedis that count over the population (Wilson interval at the score policy’s confidence level: 95 percent unless the policy setsmodel.interval_confidence),rate_per_1000is those devices’ events per 1,000 online devices (exact interval at the same level), and the score’s inputs are the same online devices’ figures. A device that crashed and then went offline is therefore in the counts and indistinct_devices, not in the share or rate, and the score is withheld asoffline_affectedwhen it would otherwise be published anddistinct_devicesexceedsdistinct_devices_online, because a score built from the online devices alone would read healthy for an application that takes its devices offline. Readdistinct_devices_onlineagainstdistinct_devicesbefore trusting a low share: figures over “online now” vary with the time of day (laptops overnight, weekends) for the same window. Expect this often: on a fleet with laptops or off-hours devices most applications with several affected devices will show a withheld score at any given moment, so use the share and rate, read againstdistinct_devices_online/distinct_devices, until an offline-aware score is decided (#5894). The online devices’ events are not published separately; the rate is derived from them.share_affectedcarriesreliable: falseunder 20 online affected devices andrate_per_1000under 20 online events.scoreis the transparent stability score{score, band, withheld, deductions[]}; the version-regression deduction is alwaysassessed: falsehere. - Score policy:
policynames the score policy every figure in the response was scored under:{source, revision, fault, tiers}.sourceisdefault(nothing stored, or a stored policy was refused) orcustom;revisionidentifies the stored policy document (defaultwhen none is stored);tiersholdsexcellent,good,fairandpoor, each{edge, name}, whereedgeis the lowest score in that tier andnameits display name (a label for people: key onscore.band, whose tokens never change). Poor’s edge is the fixed0, not a stored value. Each application’sprofileisoverridewhen a per-application override shaped its tiers, weights or model parameters anddefaultotherwise; its versions inherit it and carry noprofileof their own, andtiersshows the global policy. A change to the stored policy typically reaches every server instance within 5 seconds: each instance refreshes it from the store at most every 5 seconds while it serves requests, through one store read at a time, and the policy in use is up to 60 seconds old when the store is unreadable. A request arriving mid-refresh is scored under the last good policy while that is under 60 seconds old, so it never waits on that read (with no usable policy, it answers the503below at once). An invalid stored policy is never half-applied: the shipped defaults score andfaultsays why (sourceisdefault). If the stored policy cannot be read, the last good one is used for up to 60 seconds, after which the read fails as below. The responsetiersis a read-model projection (tiers.<tier>.{edge,name}), not the shape of the stored document (tiers.edges,tiers.names); weights and per-application override values are not echoed. The policy is read-only on this surface; write it withPUT /api/v1/dex/score-policy, which also shows the stored shape and its revision. Until a policy is written every response reportssource: "default". The cohort floor and the statistical methods are fixed; the scoring model parameters (event cap, regression ratios, interval confidence level) are part of the policy. - Score policy faults: when a stored policy document is refused,
policy.faultholds one ofunparseable at <path>: <message>(the document is not the expected shape, for exampleunparseable at tiers.edges.great: unknown key),unparseable at document: not valid JSON,oversized at document: more than 262144 bytes,unsupported_version at version: N above the supported <V>(Nis the document’sversion, newer than the<V>this build supports: the shipped defaults score and none of the document is applied), or<token> at <path>where<token>is one ofedge_out_of_range,edge_not_display_exact,edges_not_descending,edge_gap_below_minimum,name_invalid,weight_invalid,weight_sum_above_maximum,override_key_invalid,override_key_duplicate,too_many_overrides,event_cap_out_of_range,regression_ratio_out_of_range,regression_ratios_not_ascendingorinterval_confidence_out_of_range(<path>names the offending part, for exampleoverrides[3].weights, and an unknown key is shown with control characters replaced and capped at 256 bytes). These tokens are stable. The product’s own write route validates a document with the same checks before it stores it, so a refused document can occur only after a direct database write to thedex_score_policyrow, or when a newer release wrote a document this build does not support. Forunsupported_version, leave the row alone: a newer release wrote it, so finish the rolling upgrade (or upgrade this instance) and the document is applied; removing it would destroy a valid policy. For every other fault the shipped defaults score in the meantime; remove it withDELETE /api/v1/dex/score-policy(send the revision shown inpolicy.revisionasIf-Match) rather than editing the row in place. A503whose message ends(score_policy_unavailable)means the runtime-config store could not be read for more than 60 seconds or, with no usable policy cached, that the first read failed or is still in flight (a restart during the outage, or a concurrent first read), in which case the503answers at once: check PostgreSQL health andyuzu_server_runtime_config_read_degrade_total{reason}. The tokenscore_policy_unavailableis stable. - Population and scope: counts are Windows-scoped and
populationis the number of Windows devices online now (population_basis: "windows_online";null= no fleet source wired (unknown);0= no Windows device online now). With an unknown or zero population there is no share and no rate, and the score is withheld asno_population. The share is a proportion over that one population: its numerator is drawn from the same online devices the population counts, so the affected set can never exceed it. No device floor applies: figures are published at any device count. - Failure: a failed store read, a stored score policy that cannot be read within its 60-second staleness bound (the message ends
(score_policy_unavailable)) or a read over its concurrency budget returns503withretry_after_ms: 2000, never an empty list. The builder also refuses a policy that fails validation (the message ends(score_policy_invalid)); that is defence in depth, because a stored policy that fails validation is replaced by the shipped defaults before it reaches the builder. The read holds a database connection for several seconds on a large window, so at most four run at once per server; a fifth answers503immediately (counted onyuzu_server_guardian_read_degrade_total{reason="concurrency_budget"}) and the caller retries. - No per-agent identity (a distinct-device count per app, never an
agent_id) — not audited.
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.
- Permission:
GETneedsGuaranteedState:Read(with RBAC off, any authenticated session).PUTandDELETEneedInfrastructure:Write, the broad infrastructure grant (the seeded roles that hold it are Administrator and ITServiceOwner; with RBAC off, only administrators). A cookie session must also be same-site: a cross-site or header-less browser write is refused403cross-site request refusedby the server-wide check (see Authentication, “Cookie-session writes”) and audited ascsrf.deniedwithtarget_idPUT /api/v1/dex/score-policyorDELETE /api/v1/dex/score-policy. The server-wide check answers first; this route keeps its own copy as defence in depth. API, MCP and engine token callers are exempt from the same-site rule. GETresponse:dataobject{document, revision, source, fault?}, with the headerETag: "<revision>".documentis the stored shape (version,tiers.edges.{excellent,good,fair},tiers.names.{excellent,good,fair,poor},weights.{breadth,crashes,hangs,regression},model.{per_device_event_cap,regression_full_ratio,regression_start_ratio,interval_confidence},overrides[]), not thetiers.<tier>.{edge,name}projection that the Appspolicyblock carries.revisionisdefaultwhen nothing is stored and otherwise 16 lower-case hex digits that identify the stored text.sourceisdefaultorcustom;faultis present when the stored text is refused and the shipped defaults are in force.GETreads the store directly, is not audited, and answers503(retry_after_ms: 2000) when the store is unavailable.If-Match(required onPUTandDELETE): the revision the change is based on, quoted or bare:defaultwhen no policy was ever written, otherwise therevisionfromGET. An absent header is428.*, a weak validator (W/), a list (a repeatedIf-Matchheader is a list) or any other value is400. A revision that is not the stored one is409and the message names the revision now stored:GETit, review the current policy and retry. A write that loses a race with another writer between the check and the commit is also409, with a message that says the policy changed and names no revision. If the response to a write is lost,GETthe policy before repeating it: a replay with the oldIf-Matchanswers409once the write has applied, and the storedrevisionequals thedata.revisionthatdry_run=truereturns for the same document.PUTbody: the whole policy document in the stored shape, at most 256 KiB, validated exactly as the reader validates a stored text (same fields, same range checks, same refusal wording). Unknown keys are refused.PUTrefuses the Appspolicy.tiersprojection (tiers.excellent,source,revision,fault): the400names the field and says to sendtiers.edges,tiers.names,weightsandoverrides. The stored text is the canonical form of the document, soGETafterPUTmay differ in whitespace and key order from what was sent, but never in meaning. This server reads versions 1 and 2. A document carryingmodelmust say"version": 2: amodelin a version 1 document is refused (requires version 2). A document carrying aversionabove 2 is refused with the remediation to send a version 2 document or leave the stored one alone; during a rolling upgrade an older instance can still overwrite a newer document, so do not change the policy during a roll (see Upgrading).- Rules: any key left out of the global policy takes its shipped default (
versiondefaults to1). A refusal names the field and one of these tokens. Each edge is a number with0 < edge < 100and at most one decimal place (edge_out_of_range,edge_not_display_exact); the edges strictly descend with a gap of at least 1.0 point (edges_not_descending,edge_gap_below_minimum); thepoortier always starts at 0. A tier name is 1 to 24 printable ASCII characters without<,>,&or"(name_invalid). Weights are finite, non-negative numbers whose sum is at most 100 (weight_invalid,weight_sum_above_maximum); all zero is valid and scores every application 100. At most 200 overrides (too_many_overrides); each has animage(1 to 260 printable ASCII characters, folded to lower case, unique after folding:override_key_invalid,override_key_duplicate) and optional partialtiersandweightsthat inherit whatever they leave out and are validated as merged onto the global policy.modelholdsper_device_event_cap(an integer, 1 to 1000; default 5),regression_start_ratioandregression_full_ratio(finite numbers, 1 to 1000, withfullabovestart; defaults 1.0 and 3.0;event_cap_out_of_range,regression_ratio_out_of_range,regression_ratios_not_ascending) andinterval_confidence(0.5 to 0.999, global only; default 0.95;interval_confidence_out_of_range). An override’smodelmay set the cap and the two ratios only, and its ratios are validated as merged onto the global policy. A document that uses no model parameter is stored as the version 1 text. An unparseable value isunparseable at <path>: <message>and a newerversionisunsupported_version. dry_run:PUT ...?dry_run=truevalidates the document and checksIf-Matchagainst the stored revision, then answers what a real write would produce without storing anything; it writes onedex.score_policy.validateaudit row.dry_runaccepts onlytrueorfalse; any other value, or a repeateddry_runparameter that differs in its raw bytes, is400.DELETEdoes not supportdry_run: any value is400.- Response (
PUT,DELETE, dry run):200everywhere a request is applied, withdata{document, revision, source, previous_revision, dry_run?}.previous_revisionis the revision the store held whenIf-Matchwas checked.DELETEof a policy that was never written is200withrevision: "default"(idempotent; the audit trail showsattemptedthenno_change). TheETagheader is the new revision, except on a dry run, where it is the CURRENT stored revision (nothing changed; it is what the realPUTmust send) whiledata.revisionis the revision the write would produce anddata.dry_runistrue. A200that also carriesSec-Audit-Failed: truemeans the outcome audit row is missing (only theattemptedrow stands). For aPUT, or aDELETEthat removed a policy, the change was applied and a retry with the oldIf-Matchanswers409; aDELETEof a policy that was never written changed nothing and may be repeated.GETthe policy to confirm the state, treat theattemptedrow as the sole evidence of the request, and check the health of the audit store (yuzu_server_audit_emit_failed_totaland its PostgreSQL connection). - Statuses:
400(malformedIf-Match, baddry_run, body not valid JSON, too deeply nested, over 256 KiB, an unknown key or a value the validator refuses),401,403,409(staleIf-Match, or the policy changed during the write),411(a chunked body or one with noContent-Length) and413(a declared body over 320 KiB), which the pre-auth body cap answers before authentication (see Pre-Auth Request Body Caps; a body between 256 and 320 KiB reaches the handler and is a400),428(noIf-Match),503. Every503carriesretry_after_ms:2000when another policy write in this process is still running or the store is unavailable,5000when the audit store refused the row so nothing was changed (headerSec-Audit-Failed: true; the route fails closed on audit). - Outcome unknown (
503): after a store error that came back once the write was sent (a connection lost after the commit), the server re-reads the store. If the document you sent is stored, the answer is200. Otherwise the answer is503withretry_after_ms: 2000: the server cannot say whether the write applied, and the message names the revision now stored (or says it could not be re-read).GETthe policy and compare its revision before sending the change again: if it is theIf-Matchrevision you sent, the write did not apply and can be sent again; any other revision means the write applied or another writer changed the policy, so review it first. To tell those two apart, send adry_run=trueof your document with the revisionGETreturned as itsIf-Matchand compare itsdata.revisionwith that revision: equal means the stored policy is your document (a dry run sent with the OLDIf-Matchanswers409once anything has changed and gives nothing to compare). After aDELETE, the revisiondefaultmeans the policy is gone. A retry with the oldIf-Matchafter the write applied answers409, so it is safe. The answer never says the policy was left as it was, because another instance may have written in between. - Audit:
dex.score_policy.update(PUT),dex.score_policy.reset(DELETE) anddex.score_policy.validate(dry run),target_type=RuntimeConfig,target_id=dex_score_policy. Every refusal of the request leaves adeniedrow (including a badIf-Matchordry_run), except an unreadable store (503, no row) and a missing permission (the standingauth.permission_requiredrow); a real write leaves anattemptedrow BEFORE the store is touched, thensuccessorfailure. If an outcome row cannot be stored after the change was made, the200is kept andSec-Audit-Failed: truemarks the gap. Details are in the audit log.GETis not audited. - Convergence: the instance that served the write uses the new policy at once. Every other instance picks it up typically within 5 seconds (see “When a change takes effect” above). When the outcome was resolved by the re-read after a store error, the serving instance can keep the previous policy for up to 5 seconds as well.
- Known limit: the write holds a process-wide writer lock while it commits. The database call is bounded by the connection pool’s statement timeout (30 seconds by default), which a frozen database backend cannot enforce, and which is not applied at all when the database DSN carries its own
options=. A stalled commit therefore holds the initiating request and the writer lock for the stall; every other policy writer answers503withretry_after_ms: 2000and readers are unaffected. This is documented rather than bounded. To diagnose a persistent503that says another score policy write is still running, find who holds the lock:pg_stat_activityshows what each session waits on but not who holds the lock, so usepg_blocking_pids(query below) for a session blockingruntime_config_store.runtime_config. The way out is to wait out the 30-second statement timeout (it never fires when the DSN carries its ownoptions=), or to end the blocking session withSELECT pg_terminate_backend(<blocking_pid>); it is typically anidle in transactionpsql session. The server log lineruntime_config compare_and_set dex_score_policy: SQLSTATE <code>(orcompare_and_remove) appears only once the stalled statement fails, so a write that is still stalled has logged nothing yet. Expect57014(statement timeout),55P03(lock not available) or a connection-class code (08xxx,57P01);SQLSTATE nonemeans the failure came with no server error code, which is a connection-level failure.
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.
- Permission:
GuaranteedState:Read. - Query parameters:
os(all/windows/linux/macos, defaultall); an off-enum value is rejected with400.window— one of24h/7d/30d/all(default7d); an off-enum value is rejected with400. - Response:
dataobject{os, window, monitored_types, total_types, families[], uncatalogued[]}—families[]each{name, monitored, total, health_score, events, top_obs_type}index_signal_groups()order (health_score/top_obs_typearenullwhen the family is unmonitored, has no online denominator, or nothing has fired);uncatalogued[]each{obs_type, count, distinct_devices, last_seen}.503when the store is unavailable, or when the underlying fleet signal-summary read DEGRADES (store-not-open / pool-acquire timeout / query error, retryable,retry_after_ms=2000) — a degraded read never renders as a healthy, zero-event catalogue (WS-A4 PR-1 fix round). No per-agent identity — fleet aggregate, not audited.
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.
- Permission:
GuaranteedState:Read. - Query parameters:
name— required, exact family name (e.g.App reliability).os(all/windows/linux/macos, defaultall).window— one of24h/7d/30d/all(default7d); an off-enum value is rejected with400. - Response:
dataobject{group_name, os, window, monitored_count, total_type_count, health_score, active_events, max_signal_devices, benign, types[]}—types[]each{obs_type, monitored, coverage_platforms, count, distinct_devices, last_seen};health_scoreisnullwhen nothing is monitored or the scoped denominator is 0.benign(ADR-0031 WS-A4 PR-1) is true for the one family whose window activity is routine reports (boot/uptime), not incidents — the dashboard fragment shows “Reports (window)” instead of “Events (window)” on this flag alone.400on missingnameor an off-enumwindow.404on an unknown family name.503when the store is unavailable. Not audited (no per-agent identity).
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.
- Permission:
GuaranteedState:Read. - Query parameters:
weighting— one ofdefault/stability/productivity/security(defaultdefault; an off-list value resolves todefault).window— one of24h/7d/30d/all(default7d); an off-enum value is rejected with400. - Response:
dataobject{weighting, window, reporting, total_crashes, crash_free_pct, score, band, deductions[]}—score/band/crash_free_pctarenullwhenreporting(reporting Windows agents) is 0, never a fabricated100;deductions[]each{name, severity, deduction}in catalogue order.400on an off-enumwindow.503when the store is unavailable. Not audited (no per-agent identity).
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.
- Permission:
GuaranteedState:Read. - Query parameters:
window— one of24h/7d/30d/all(default7d); an off-enum value is rejected with400. - Response:
dataobject{window, total_catalogued_types, windows_reporting, crash_free_pct, os_cards[], days[], families[]}—os_cards[]each{platform, live, distinct_types, total_events}forwindows/macos/linux(fixed order);families[]each{name, total, counts[]},counts[]index-aligned withdays[]. Not audited (no per-agent identity).
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.
- Permission:
GuaranteedState:Read. Every field this route returns, includingtop_devices[]/top_apps[], is a fleet-wide aggregate with no per-caller confinement — same retired-resolver history asGET /api/v1/dex/appabove (a management-group grant does not narrowtop_devices[]; every admitted caller sees the same list). - Query parameters:
window— one of24h/7d/30d/all(default7d); an off-enum value is rejected with400. - Response:
dataobject{window, overall_experience, device_score, app_score, network_score, great, fair, poor, unscored, coverage_monitored, coverage_total, connected_platforms, busiest_family, busiest_family_events, crash_free_pct, windows_reporting, crashes_per_1k_device_days, total_crashes, devices_impacted, total_online, active_signal_types, health_score, os_reporting_count, segments[], crashes_by_day[], top_apps[], top_devices[], os_table[]}.unscored(#4855) counts connected devices whose per-device score could not be computed (no store, or a degraded per-device signal-summary read) — a non-zero value meansgreat/fair/poor/overall_experiencecover fewer devices than are actually connected, not a healthier fleet than reported.connected_platforms/busiest_family/busiest_family_events(ADR-0031 WS-A4 PR-1, additive) mirror the Explore section’s own “N platform(s)” coverage caption and busiest-family teaser —busiest_familyisnullwhen nothing has fired in-window.403— a service-scoped API token is denied outright. - Audit: emits
dex.overview.viewbefore serving (fail-closed 503 +Sec-Audit-Failed: trueon a dropped row) — the dashboard fragment audits only the service-scoped denial, not an ordinary success, so this REST route deliberately carries MORE rigor than its own fragment.
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.
- Permission:
GuaranteedState:ReadandExecution:Execute, both scoped to the device’s management group. - Path parameter:
id— the agent’sagent_id. - Query parameter:
kind— required.uptimedispatchesos_info/uptime;processesdispatchesprocesses/list_hashed(proc|pid|name|sha256|path; the SHA-256 is of each on-disk executable, resolved from the kernel, not argv[0], bounded at 512 MiB per image). - Dashboard-only kinds: the dashboard “Get live info” grid renders ten additional card kinds (
process_tree,services,users,netconfig,arp,dns_cache,listening,connections,capture_sources,disk). These are not yet available on this REST endpoint — passing any of them returns400. REST/JSON parity for all ten is deferred to #1649; to probe e.g. the process tree via REST today, dispatchprocesses/list_treedirectly through the async execution surface. - Response:
dataobject —kind=uptime→{kind, uptime_display, uptime_seconds};kind=processes→{kind, processes[].pid/name/sha256/path}. - Errors:
400—kindmissing/unrecognised.403— outside the operator’s scope, or missingExecution:Execute.429— too many concurrent live queries server-wide (back offretry_after_ms).502— the device reported an error or the query failed.503— the device is offline (no connected agent), or503+Sec-Audit-Failed: truewhen the audit row cannot persist (the command is not dispatched — see Audit).504— the device did not respond within the timeout (retry_after_ms). - Headers:
X-Correlation-Idis echoed on every response path. - Audit: emits
device.live.uptimeordevice.live.processes(target_type=Agent,target_id=<agent_id>,result=requested,detailcarriescid=<correlation_id>) before the command is dispatched — the operator’s request is the works-council-relevant event, kept separately countable per kind. Fail-closed: if the audit row cannot persist, the endpoint returns503+Sec-Audit-Failed: trueand the command is not dispatched (no usage-class probe without durable evidence). The dispatch outcome (reached an agent / offline) is carried by the HTTP response, not the audit row;command_idis recoverable from the response store / metrics bycid.
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/livecall 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 byexecution_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
keyfor the cohort tag dimension (empty = no cohort, length-guarded), where the DEX performance surface usescohort_key(validated, defaults tomodel). 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/cohortsendpoint (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.
- Permission:
GuaranteedState:Read - Response: an object
{rtt_ms, retrans_pct, throughput_bps, reporting, rtt_reporting, online, cooccurrence, available_keys}where each metric is{avg, p50, p90, max, n}ornullwhen no device reported it this cycle (absent, never 0).reportingcounts devices contributing at least one metric;rtt_reportingis the honest RTT denominator (smoothed RTT is Linux-only today, so it is smaller thanreporting);onlineis the total snapshot population.cooccurrenceis{degraded, also_device, also_app, network_only}— counts of net-degraded devices that also show device-perf pressure / app instability (measured co-occurrence, never a cause; dormant until the degraded classification lands).available_keyslists the fleet’s tag keys for a cohort-picker UI (parity with the/networkdashboard fragment andGET /dex/perf/cohorts’savailable_keys). Not audited.
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.
- Permission:
GuaranteedState:Read - Query parameters:
metric(rtt/retrans/throughput, defaultrtt);filter=not_reporting(devices with no network sample this cycle);cooc(device/app/network_only/degraded— a co-occurrence band over net-degraded devices);key(cohort tag key — resolves the per-device cohort value; does not filter by itself);cohort_value(when present, restricts to that cohort; an empty value selects the untagged residual);limit(default 50, clamped to 500). - Response:
data[]of{agent_id, platform, cohort, rtt_ms?, retrans_pct?, throughput_bps?, net_degraded, under_pressure, app_unstable, fleet_pctile?}, worst-first by the sort metric (under_pressure/app_unstableare the co-occurring facts shown inline for correlation, never a verdict;fleet_pctileis the device’s nearest-rank position, omitted when it did not report the metric).400on an invalidlimit.403if a service-scoped API token queries this route (fleet-wideagent_idrows, no single agent to confine against — same rationale asdex/signals/{obs_type}above). - Audit (behavioral PII): each row is an
agent_id+ its network perf/correlation facts, individual-identifying, fleet-wide — emitsnetwork.device.view(target_type=GuaranteedState,target_id=empty) before serving. A denied service-scoped token also emits this verb (result=denied). Fail-closed: if the audit row cannot persist, returns503+Sec-Audit-Failed: trueand serves no device list.
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.
- Permission:
Infrastructure:Read - Query parameters:
subnet(only hosts found on exactly this subnet string, at most 64 bytes);limit(1 to 500, default and cap 500). - Response:
{data[], summary, truncated, meta}.data[]rows are{id, ip_address, mac_address, hostname, subnet, discovered_by, discovered_at, last_seen}(epoch seconds), newest last-seen first.summaryis{total, subnets}over every row matching the filter, not just the returned page;truncatedistruewhen more rows matched than were returned.hostnameisunknownwhen a reverse lookup failed, andhostnameandmac_addressare agent-reported free text. - Management-group confinement: the read is gated by a flat
Infrastructure:Readcheck and is not narrowed by the admit-then-filter list chokepoint (the siblingGET /api/v1/network/devicesis flat too, onGuaranteedState:Read). When RBAC is enforced, an operator confined to a management group is refused with403rather than shown a narrowed list; a holder of the global grant sees every discovered host. With RBAC off, any authenticated non-service session is admitted. Narrowing rows bydiscovered_byis tracked by#5855(revisit by 2026-11-14). - No
managedflag: discovery does not yet match hosts to enrolled agents, so no managed or agent field is returned rather than a value that would read “unmanaged” for an enrolled host. - Errors:
400invalidlimitorsubnet;403a service-scoped API token (denied outright and audited asresult=denied) or missing permission;503the discovery store is unavailable or degraded. A store fault is never rendered as an empty list. - Audit (behavioral PII): each row names a host fleet-wide, so a served read emits
network.discovered.view(target_type=Infrastructure,target_id=empty). Fail-closed: if the audit row cannot persist, returns503+Sec-Audit-Failed: trueand serves no rows. MCP twin:list_discovered_devices(set-and-proceed, reportsaudit_persisted: falseinstead of refusing).
Patch Management
GET /api/patches — Query missing/installed patches.
- Permission:
Patch:Read - Query params:
agent_id,severity,status,limit(default 100)
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. Seedocs/capability-map.md§8.5/§8.7 and #3676.
POST /api/patches/deploy — Create a patch deployment targeting specific agents.
- Permission:
Patch:Write - Request body:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
kb_id | string | Yes | — | KB identifier (format: KBnnnnnnn) |
agent_ids | string[] | Yes | — | Target agent IDs (max 5000 after de-duplication; a longer list is rejected with too many target agents) |
reboot_if_needed | bool | No | false | Reboot agents after patching |
reboot_delay_seconds | int | No | 300 | Seconds to wait before reboot (clamped to 60–86400). A desktop notification warns the user before reboot. |
reboot_at | int64 | No | 0 | Optional epoch timestamp for scheduled reboot. Must be in the future. 0 = use delay instead. |
- Response (201):
{"deployment_id": "...", "kb_id": "...", "target_count": N, "status": "pending"}
Note: this creates a deployment record + a
pendingrow 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 pastpendingexceptPOST /api/patches/deployments/:id/cancel(→cancelled).reboot_if_needed/reboot_delay_seconds/reboot_atare 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.
- Permission:
Patch:Read - Response includes:
reboot_delay_seconds,reboot_at, and per-targetstatus— alwayspendingorcancelledtoday (the fullerscanning/downloading/installing/verifying/rebooting/completed/failed/skippedvocabulary is reserved by the schema but nothing currently sets it).completed_targets/failed_targetsare set once at creation and never recalculated.
GET /api/patches/deployments — List deployments (paginated, default limit 50).
- Permission:
Patch:Read
POST /api/patches/deployments/:id/cancel — Cancel a pending/running deployment.
- Permission:
Patch:Write
Settings — OIDC Configuration
POST /api/settings/oidc — Save OIDC SSO configuration.
- Permission: Admin only, on the caller’s own account (the local account role, or the administrator role mapped from the identity provider). A session that is administrator only through a temporary elevation gets
403; auditoidc.configure / denied / elevated_session_oidc_change_blocked. - Request body (form-encoded):
| Field | Type | Required | Description |
|---|---|---|---|
issuer | string | Yes | OIDC 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_id | string | Yes | Application (client) ID from Azure portal |
client_secret | string | No | Client secret value. If empty and a secret already exists, the existing secret is preserved. |
redirect_uri | string | Yes | Callback 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_group | string | No | Entra group object ID for admin role mapping |
skip_tls_verify | string | No | "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 |
- Response: Re-rendered Settings fragment (HTMX). Returns toast notification on success.
400with a red message whenissuerorclient_idis missing, orredirect_uriis blank; nothing is saved in that case. Each400, and the500returned when the provider cannot be built, also carries an error toast (HX-Trigger: {"showToast":{"message":"...","level":"error"}}) because the dashboard does not swap a 4xx or 5xx body. Saves are serialized: two concurrent saves run one after the other, and a blankclient_secretkeeps the secret stored when that save runs. - Trimming:
issuer,client_id,redirect_uriandadmin_groupare trimmed of surrounding whitespace before they are validated and stored (a trailing space inadmin_grouppreviously made the group match fail for every SSO administrator at the next sign-in). - Effect: Immediately reinitializes the OIDC provider with the new configuration - in THIS process only. The swap is NOT durable: the provider is rebuilt at the next startup from the command-line/environment value, not from the stored runtime config, so a restart reverts the provider to that value (the stored
oidc_admin_groupstill applies after a restart; see When a change takes effect). Rotating a credential durably requires updating--oidc-client-secret/YUZU_OIDC_CLIENT_SECRETas well - see When a change takes effect.
POST /api/settings/oidc/test — Test OIDC discovery and signing-key connectivity.
- Permission: Admin only
- Request body (form-encoded):
issuer,skip_tls_verify - Behaviour today: an empty
issuerfalls back to the stored issuer; the stored skip-TLS flag is OR-ed with the posted one (a postedfalsecannot turn off a storedtrue); the typed issuer is not trimmed here (unlike the save route); and the check fetches the discovery document and the signing keys. - Response: HTML feedback span — green on success, red on failure with specific error. It fetches and validates the discovery document the way a sign-in does, then also fetches the
jwks_uri(one request after the other, each with its own total time bound, so one call can take up to about 20 s in the worst case at the default timeouts: a slow discovery document followed by a stalled JWKS fetch; a dead IdP fails at the discovery step) and reports how many usable signing keys it found (an RSA key under 2048 bits, or with an exponent below 3, even or over 32 bits, is skipped and not counted; the server log names it). It does not exercise the token endpoint, client secret, redirect URI, audience or group claims. The typed issuer and thejwks_uriare subject to the server’s OIDC egress policy: an address the policy refuses (link-local and cloud-metadata always; loopback, RFC 1918, carrier-grade NAT and unique-local unless the server was started with--oidc-allow-private-networks) is not connected to, and the red feedback readsDiscovery failed: request failed: address not permitted by the OIDC egress policy(never the resolved address). The switch is a server start-up option; no form field of this route or ofPOST /api/settings/oidccan change it. This is a connectivity check, not a guarantee of what a sign-in will hit: it uses the form’sissuer(the saved issuer when blank), never reads or clears the running provider’s discovery state (the 30 s no-retry window and the in-flight mark), and skips TLS verification when the form’sskip_tls_verifyis"true"OR the server’s current value is true (an absent field cannot turn verification on while that value is true).
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.
- Permission: Admin only
- Request body (multipart/form-data):
type(cert|key|ca),file(.pem/.crt/.cer)
POST /api/settings/cert-paste — Paste PEM certificate content.
- Permission: Admin only
- Request body (form-encoded):
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | cert (server certificate), key (private key), or ca (CA certificate) |
content | string | Yes | PEM-encoded content (must contain -----BEGIN) |
- Response: Re-rendered TLS Settings fragment (HTMX). Returns toast notification on success.
- Effect: Writes PEM content to the server cert directory. A
keyis created owner-only (0600) and replaced atomically by rename (a symlink at the destination is replaced, not followed); a certificate directory the server creates is 0700, and an existing directory’s mode is left as it is. The agent (gRPC) certificate, key or CA path in the running configuration is updated. This does not change the dashboard HTTPS certificate (that comes from--https-cert/--https-key), and the running gRPC listeners are not reloaded; after a restart the file is used only if--cert/--key/--ca-certname it.
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.
- Permission: Admin only
- Response (200): HTML fragment showing status badge (Disabled / Trust bundle loaded / Enforced), bundle metadata (cert count, SHA-256, up to 16 subjects), upload form, require-flag toggle, and Remove button.
POST /api/settings/plugin-signing/upload — Upload a PEM trust bundle.
- Permission: Admin only
- Request body (multipart/form-data):
file(.pem/.crt; max 256 KB) - Validation: the server validates the PEM with OpenSSL on the way in — at least one parsable X.509 certificate must be present, BEGIN/END markers required.
- Response (200): Re-rendered fragment,
HX-Trigger: showToast level=success. Bundle is written atomically to<cert-dir>/plugin-trust-bundle.pem(temp + rename). - Response (400): Validation failure. Body:
<span class="feedback-error">Rejected: …</span>. Audit row emitted withresult=failure,target_type=PluginTrustBundle, detail = the validation error. - Response (500): I/O failure (cannot create cert dir, write tmp file, or rename to destination). Body:
<span class="feedback-error">…</span>.
POST /api/settings/plugin-signing/clear — Remove the trust bundle and reset the require flag.
- Permission: Admin only
- Request body: None (HTMX hx-confirm)
- Effect: Two-phase commit — writes
plugin_signing_required=falsetoruntime_configfirst, then removes<cert-dir>/plugin-trust-bundle.pem. If the DB write fails the file is not removed (prevents disk/DB desync) and a 500 is returned. - Response (200): Re-rendered fragment,
HX-Trigger: showToast level=info. Auditplugin_signing.bundle.cleared/success. - Response (500): DB write failure. Audit
plugin_signing.bundle.cleared/failurewith the store error indetail. Bundle file untouched.
POST /api/settings/plugin-signing/require — Toggle the require-signature flag.
- Permission: Admin only
- Request body (form-encoded):
required=true/on(checkbox checked) or absent (checkbox unchecked, treated as false). - Effect: Writes
plugin_signing_required=<true|false>toruntime_config. - Response (200): Re-rendered fragment,
HX-Trigger: showToast level=success. Auditplugin_signing.require.changed/success,target_type=RuntimeConfig,target_id=plugin_signing_required,detail=<new_val>. - Response (500): DB write failure with the store error.
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.
-
Permission:
PluginSigning:Read(Administrator-only via therbac_store.cppseed; floored inauthz_topology_floor.hppso an RBAC-off deployment stays admin-gated — the same practical posture the prior admin-only gate had for interactive sessions and non-MCP API tokens). No MCP token, at any tier, can satisfy this permission —PluginSigningis one of four server-administration securablesmcp_policy.hpp’stier_allows()denies outright, regardless of Read/Write, so an admin-owned MCP token that would otherwise fall through the legacy role check is stopped before it gets there. The bundle holds X.509 certificates only (no private keys), but the SHA-256 fingerprint and the trust-anchor identity are operationally sensitive — non-admin token holders are not authorized to see when the trust anchor rotates. Future automatic agent-side fetch will introduce a dedicated agent identity for this endpoint. -
Audit: fail-closed.
settings.plugin_signing.readis persisted before the response is built; if the audit subsystem is unavailable the route returns 503 rather than serve settings data without durable evidence. -
Stability: pilot-stable. The field set is unlikely to shrink (forward-compatible additions only).
cert_count/sha256/subjects/trust_bundle_pemare derived from a single filesystem read (#4144) — they cannot describe two different bundle versions on a concurrent-upload race, unlike v1. -
Response (200, bundle uploaded):
{ "data": { "enabled": true, "required": false, "cert_count": 2, "sha256": "abc123…", "trust_bundle_pem": "-----BEGIN CERTIFICATE-----\nMIIB…\n-----END CERTIFICATE-----\n" }, "meta": {"api_version": "v1"} } -
Response (200, no bundle uploaded):
{"data": {"enabled": false, "required": false, "trust_bundle_pem": ""}, "meta": {"api_version": "v1"}}(Status is 200, not 404 — “no bundle uploaded” is a normal operational state, not a fetch failure.)
-
Response (500, bundle on disk is unreadable): standard A4 error envelope.
{"error": {"code": 500, "message": "Trust bundle on disk is unreadable", "correlation_id": "…", "retry_after_ms": null}, "meta": {"api_version": "v1"}} -
Response (503, audit subsystem unavailable): same A4 error shape with
code: 503. -
Response (503, two further cases, #4028 fix round): the
requiredflag’s backingruntime_config_storeread failed (distinguishes a genuine outage from a healthy “not required” — see When a change takes effect above:plugin_signing_required“records intent only,” no server or agent code consumes it to require signatures, so a degraded read here does not affect real enforcement), or the trust bundle was concurrently uploaded/cleared between this request’s existence check and its PEM re-read. Both are retryable (A4 envelope,retry_after_msset). -
Operator usage: curl this into a local file on each agent host, then point the agent at that file with
--plugin-trust-bundle:curl -fsSL -H "Authorization: Bearer $YUZU_ADMIN_TOKEN" \ https://server.example.com:8443/api/v2/agent/plugin-policy \ | jq -r .data.trust_bundle_pem > /etc/yuzu/plugin-trust-bundle.pem
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.
- Permission: Admin only.
- Response (200): HTML fragment showing current status (
Not enrolled/Enabled/Disabled), recovery codes remaining, and the operative action buttons.
POST /api/settings/mfa/init — Begin TOTP enrollment.
- Permission: Admin only.
- Effect: Generates a fresh 20-byte CSPRNG secret and stores it provisionally (
mfa_enrolled_atstays NULL). Re-running on an already-provisional row re-reveals the same secret (reuse, not rotate); refused if the user is already enrolled (MfaAlreadyEnrolled→ 200 with error message in the fragment). - Response (200): HTML fragment containing the
otpauth://URI + base32 secret as a one-time reveal, plus the verify-code form. Response carriesCache-Control: no-store, private,Pragma: no-cache, andReferrer-Policy: no-referrerso browsers / proxies / CDNs cannot retain the material. - Password changed during enrolment (#5342): if the account’s password is changed or reset while this call is minting the secret, nothing is stored or revealed; the fragment says “Your password changed during enrolment — sign in again”.
- Audit:
mfa.enroll.initiated/ok(orerroron failure);mfa.enroll.failed/errordetailcredential_changedfor the password-changed case.
POST /api/settings/mfa/verify — Confirm enrollment by submitting the first TOTP code.
- Permission: Admin only.
- Request body (form-encoded):
code=<6-digit TOTP from authenticator>. - Effect on success: Sets
mfa_enrolled_at = CURRENT_TIMESTAMP, advancesmfa_last_counterto the matched counter (replay defence), generates 10 single-use recovery codes (PBKDF2-SHA256 hashed inmfa_recovery_codes). - Step-up proof: a successful confirmation also records a fresh MFA proof for the calling session, so an administrator who has just enrolled can use the two Settings MFA routes below (recovery-code regeneration and disable) at once, inside the step-up window and from that session only. The proof is recorded only when the call carries the local cookie session of the same user; otherwise the session is not stamped (a bearer principal, an absent or mismatched cookie, or a store failure) and the enrolment still succeeds. The audit detail
step_up_proof=stampedorstep_up_proof=not_stampedsays which. - Response (200, success): HTML fragment with the 10 recovery codes as a one-time reveal (
XXXX-XXXX-XXXX-XXXX, 80 bits each). SameCache-Control: no-storeheaders asinit. - Response (200, failure): Re-renders the verify form with an instruction to wait for the next 30 s code. Provisional row survives so the operator’s authenticator app keeps working; the secret is NOT re-revealed.
- Password changed during enrolment (#5342): a confirm whose write waits behind the account’s password change or reset enrols nothing and issues no recovery codes (
credential_changed; the fragment says “Your password changed during enrolment — sign in again”, with no retry form); a confirm arriving after the change finds no provisional secret and is answered as a rejected code while the session is revoked. - Audit:
mfa.enroll.verified(detailstep_up_proof=stamped, orstep_up_proof=not_stampedwhen the session could not be stamped) +mfa.recovery_codes.generatedon success;mfa.enroll.failedon rejection (detailcode rejected, orcredential_changedfor the password-changed case);mfa.enroll.racewhen a concurrent verify already enrolled the account (benign race, distinct from a rejected code, CC7.2).
POST /api/settings/mfa/recovery-codes — Regenerate the 10 recovery codes.
- Permission: Admin only. Requires existing enrollment.
- Step-up: one of the 37 routes in the step-up list, checked before the permission check: an MFA-enrolled session whose proof is older than
mfa_step_up_window_secsreceives401mfa_step_up_required. The Settings MFA panel cannot yet prompt for the code: the button shows a toast and the operator signs in again (a just-enrolled administrator is covered inside the window, for the confirming session only, by the proof recorded at enrolment confirmation). An account with no MFA enrolled is not affected. - Effect: Atomic DELETE + 10×INSERT inside a Postgres transaction, serialized on the caller’s
auth.usersrow (SELECT … FOR UPDATE) so two concurrent regenerations cannot interleave (#3779). All prior codes (consumed and unconsumed) are invalidated. If the account has been deactivated, the codes are not reissued and the request fails. - Response (200): HTML fragment with the fresh 10 codes as a one-time reveal. Same
Cache-Control: no-storeheaders. - Audit:
mfa.recovery_codes.generated/ok(detail =10 codes issued (rotation)).
POST /api/settings/mfa/disable — Clear MFA state for the logged-in admin.
- Permission: Admin only.
- Step-up: one of the 37 routes in the step-up list, checked before the permission check: an MFA-enrolled session whose proof is older than
mfa_step_up_window_secsreceives401mfa_step_up_required. The Settings MFA panel cannot yet prompt for the code: the button shows a toast and the operator signs in again (a just-enrolled administrator is covered inside the window, for the confirming session only, by the proof recorded at enrolment confirmation). An account with no MFA enrolled is not affected. - Effect: Atomic UPDATE users (clears
mfa_totp_secret,mfa_enrolled_at; stampsmfa_disabled_at) + DELETEmfa_recovery_codesinside one Postgres transaction, serialised on the caller’sauth.usersrow (SELECT ... FOR UPDATE). Self-target guard (PR 3): while--mfa-enforcementprotects the caller’s role (required→ all roles;admin-only→ admins), the disable is refused - the operator cannot strip the MFA that policy requires of them. After a successful disable every session of the account is ended, the caller’s included, because any of them may hold an MFA proof made under the factor just cleared. API tokens are not ended. - Response (503): the
mfa.disabledintent row (see Audit) could not be written, so nothing was cleared and no session was ended; the response carriesSec-Audit-Failed: trueandRetry-After: 5, and the message “Audit store unavailable; MFA was not changed.” is shown as a toast on both Settings pages (the response carries anHX-TriggershowToastheader; an error toast has a close button) and, on Security and sign-in, also inline in the Two-factor sign-in panel (that page swapstext/htmlerror bodies for requests made from inside its panels). Nothing was changed: retry once the audit store has recovered, or tell the operator that the audit store needs attention. - Response (200): on success an empty body with the
yuzu_sessioncookie cleared andHX-Redirect: /home(the same exit as logout; the user signs in again). When the self-target guard fires, the unchanged “Enabled” fragment with an inline “MFA is required by policy” message. When the factor was cleared but the sessions could not be ended, the fragment carries the inline message “MFA was disabled, but the account’s sessions could not be ended. Ask an administrator to end them.” There is no retry control on that page: after the clear the fragment shows the not-enrolled view, and the Disable form exists only in the enrolled view. An administrator ends the sessions withDELETE /api/v1/sessions?username=<name>oryuzu-server --mfa-reset <name>(seedocs/ops-runbooks/auth-db-recovery.md“Emergency MFA disable”). This route has no REST v1 or MCP twin yet (ledgered exception #6325,scripts/ci/api-parity/auth-mfa.json): the partial and unknown-state outcomes are HTTP 200text/htmlwith no machine-readable marker, so an automated caller must read the audit row to tell them from success. When the clear itself fails, the sessions are still ended, because the clear may have committed with its acknowledgement lost: if that delete succeeds the response is the same sign-out exit (with no hint that the clear failed; the audit row is the record), otherwise the fragment carries “Could not confirm MFA was disabled, and the account’s sessions could not be ended. Its state is unknown: ask an administrator to check it and end the sessions.” Cancel setup on an enrolment that was never verified clears no usable factor, so it ends no session and does not redirect: the not-enrolled fragment is returned. - Audit: a disable leaves two
mfa.disabledrows, ordered by the auditid. They are attributed to the acting session (principal, principal role and session are the caller’s, even though that session has just been deleted);target_idis the account. The first,attemptedwith detailMFA clear requested via Settings, is written before the factor is touched and fails closed (the503above). The outcome row follows:okwith detailsessions_ended=<n>when a verified factor was cleared and the sessions were ended;okwith detailno verified factor to clearwhen an unfinished enrolment was cancelled (no session ended);partialwith detailsessions_ended=failedwhen the factor was cleared but the durable session delete failed;errorwith detaildisable failed (sessions_ended=<n>)ordisable failed (sessions_ended=failed)when the clear itself failed (sessions are still ended);errorwith detailblocked: mfa_enforcement=<mode>when the self-target guard refuses the disable (the only row for that request, with noattemptedrow).<n>counts the durable session rows deleted, expired rows not yet reaped included. The outcome row is written after the changes and cannot undo them: if it cannot be written the request still completes and the server logs an error, leaving theattemptedrow as the only record. Anattemptedrow with no outcome row marks a clear whose result is unknown.yuzu-server --mfa-resetfollows the same two-phase rule.
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.
- Permission: Admin only
- Response (200): HTML fragment of the user table. The row matching the caller’s session username renders an italic
Current userbadge in place of the Remove button. All other rows include anhx-deleteattribute targeting the DELETE endpoint below. - Response (401): Returned defensively when the admin gate passes but the session cannot be re-resolved (e.g., concurrent logout). The dashboard should redirect to login.
- Response (500): Returned when the resolved session has an empty username — defense-in-depth against an upstream auth misconfiguration (e.g., OIDC returning empty
preferred_username). Server log records the cause.
POST /api/settings/users
Create a new local account.
Breaking change in v0.12.0: The
rolefield is now ignored. New users are always created asuser. To assign or change a role use the dedicatedPOST /api/settings/users/{username}/roleendpoint documented below — this closes security finding C1 (privilege escalation via the role parameter on create). Operators that scripted user-create withrole=adminshould expect the user to land asuserand explicitly promote via the role endpoint.
- Permission: Admin only
- Request body (form-encoded):
| Field | Type | Required | Description |
|---|---|---|---|
username | string | Yes | Account name. 1-64 chars, alphanumeric + . _ - only, and not made only of dots. |
password | string | Yes | New password. 12–1024 bytes (UTF-8). |
role | string | (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. |
- Response (200): Re-rendered user table fragment with the new account visible.
HX-Trigger: {"showToast":{"message":"User created","level":"success"}}. Audit event recorded asuser.create / success. - Response (403) cross-site / (503) audit: a session-cookie caller without an
OriginorReferernaming this site is refused with403(auditcsrf.denied); API-token callers are exempt. If theattemptedaudit row cannot be written the server answers503withSec-Audit-Failed: trueand creates nothing (safe to retry). - Response (400): Returned when
usernameis invalid,passwordis empty, or the password is shorter than 12 bytes or longer than 1024 bytes. Audituser.create / denied / weak_password(too short),too_long(over 1024 bytes, #5342), orinvalid_username. - Response (409): Returned when
usernamealready exists. Body is the A4 errorUsername already exists(with the duplicate-username toast header). Audituser.create / denied / duplicate_username. Also returned (A4 error body “The account could not be created. The name may still belong to a removed account, or the user store did not answer.”) when a password that meets the policy is refused by the store, which is what re-creating a removed username does: removed accounts keep their name. Audituser.create / denied / create_refused. - Response (401): Defensive — admin gate passed but session not re-resolvable.
- Response (500): Defensive — session resolved with empty username.
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).
- Permission: Admin, on the caller’s own account (the local account role, or the administrator role mapped from the identity provider). A session that is administrator only through a temporary elevation gets
403. - Request body (JSON):
{ "role": "admin" }
The role field must be exactly admin or user.
-
Response (200) success: Role changed. Body is the re-rendered user table fragment.
HX-Trigger: {"showToast":{"message":"Role updated","level":"success"}}. Audituser.role_change / successwithdetail=old_role=user,new_role=admin(or vice versa). All active sessions for the target user are invalidated atomically — the user must re-authenticate. -
Response (200) no-op: Same role requested as already assigned. Body is the re-rendered fragment with
HX-Trigger: {"showToast":{"message":"Role unchanged","level":"info"}}. Audituser.role_change / no_opwithdetail=same_role={admin\|user}. -
Response (403) cross-site / (415) / (503) audit: a session-cookie caller without a same-site
OriginorRefereris refused with403(auditcsrf.denied); API-token callers are exempt. A body that is notapplication/jsonis refused with415(audituser.role_change / denied / content_type_not_json). If theattemptedaudit row cannot be written the server answers503withSec-Audit-Failed: trueand changes nothing (distinct from the store-failure503below; safe to retry). -
Response (400) invalid request:
invalid_username—:usernamefails the allowlist (alphanumeric +._-, 1-64 chars).invalid_json— body is not valid JSON.missing_role— body has norolefield, orroleis not a string.invalid_role—rolevalue is notadminoruser.
Each branch emits
user.role_change / denied / <reason>. -
Response (403): Self-target rejected. Body is the re-rendered fragment with
HX-Trigger: {"showToast":{"message":"Cannot change your own role","level":"error"}}. Audituser.role_change / denied / self_role_change_blocked. To demote yourself, create a second admin, log out, log in as the second admin, and use this endpoint to demote the first. -
Response (403): The caller is an administrator only through a temporary elevation (their own account role is not
admin). Audituser.role_change / denied / elevated_session_role_change_blocked. Changing an account role needs the caller’s own administrator role. -
Response (404):
:usernamedoes not match any persisted user. Audituser.role_change / denied / user_not_found. -
Response (409): The guarded write refused the change (nothing changed, the sessions of the target are untouched):
last_administrator_blocked- demoting:usernamewould leave no active localadminaccount (the last-Administrator guard, seedocs/user-manual/rbac.md“Last-Administrator guard for account deactivation and demotion”).HX-Trigger: {"showToast":{"message":"Cannot demote: this account is the fleet's last administrator","level":"error"}}and an A4 error body with the same message. Audituser.role_change / denied / last_administrator_blocked. Counted onyuzu_server_rbac_last_admin_guard_refused_total{transport="dashboard"}.inactive_promotion_refused-:usernamewas deactivated between the route’s account lookup and the write, andadminwas requested (“Cannot promote an inactive account”). An account that is ALREADY inactive when the request arrives never reaches this: the route answers404“User not found” first. Audituser.role_change / denied / inactive_promotion_refused.
-
Response (503): The guarded role write did not complete (store fault, or the last-Administrator guard is not wired, which fails closed instead of writing unguarded). NO
Retry-Afterand noretry_after_ms(the A4 body carries neither): the change may or may not have been applied (a lost commit acknowledgement is possible, and the failure path’s durable session wipe may itself have failed), and a repeat after a commit is a200“Role unchanged” no-op that does not redo the wipe. The message tells the operator to check the user’s role and revoke the user’s sessions; no convergence is claimed. Audituser.role_change / failure / store_failure. A503with the message “Role updated, but session cleanup failed” has the same shape: the role is changed but a session at the old role may survive, and repeating the request is a200“Role unchanged” no-op that does not redo the cleanup. Revoke the user’s sessions instead (DELETE /api/v1/sessions?username=<name>); audituser.role_change / partialwithold_roleandnew_role.
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.
-
Permission: Admin only
-
Response (200): Account deleted. Body is the re-rendered user table fragment.
HX-Trigger: {"showToast":{"message":"User deleted","level":"success"}}. Audit event recorded asuser.delete / success. -
Response (400):
:namefails the username allowlist (e.g. contains$,:, or other disallowed bytes). Audituser.delete / denied / invalid_username. -
Response (404):
:namedoes not match any persisted user. Audituser.delete / denied / user_not_found. Returned with aUser not foundtoast in the re-rendered fragment. -
Response (403): Returned when
:namematches the caller’s own session username — the self-deletion guard. Body is the re-rendered user table fragment. Header includes:HX-Trigger: {"showToast":{"message":"Cannot delete your own account","level":"error"}}Audit event recorded as
user.delete / denied / self_delete_blocked. The rejected attempt is logged at warn level on the server. To delete the account you are signed in as, create a second admin, sign out, sign in as the second admin, then delete the original. -
Response (409) last administrator:
:nameis the last member of a protected administrator population (the last-Administrator guard, seedocs/user-manual/rbac.md“Last-Administrator guard for account deactivation and demotion”). Counted onyuzu_server_rbac_last_admin_guard_refused_total{transport="dashboard"}. There are two forms, and they differ in what already happened:- Early refusal - a read-only check, made before anything is revoked, showed the account is the last one. Nothing was changed or revoked.
HX-Trigger: {"showToast":{"message":"Cannot delete: this account is the fleet's last administrator","level":"error"}}, an A4 error body with the same message, audituser.delete / denied / last_administrator_blocked. - Late refusal - the early check passed (or could not answer) and the guard refused afterwards. The account’s linked credentials (API tokens and sessions, including those of linked SSO identities) were already revoked and the account remains active. The toast and the A4 message read “Cannot delete: this account is the fleet’s last administrator. Its credentials were revoked before the refusal; the account remains active”, and the audit row is
user.delete / partialwith detaillast_administrator_blocked credentials_revoked_before_refusal=true account_remains_active=truefollowed by the revoke summary (partialbecause the credentials were revoked).
- Early refusal - a read-only check, made before anything is revoked, showed the account is the last one. Nothing was changed or revoked.
-
Response (503): The guarded delete did not complete.
Retry-After: 2and an A4 body withretry_after_ms: 2000. Audituser.delete / failure / store_failure ...(the linked credentials were already revoked; the account may or may not be deactivated because a lost commit acknowledgement cannot be ruled out, and a repeat re-runs the guarded write and the session wipe, and the repeat that completes answers404). The message “User deactivated, but session cleanup failed” means the account is deactivated but a session may survive: repeat the delete (it answers404once the session cleanup completes) or revoke the user’s sessions withDELETE /api/v1/sessions?username=<name>; audituser.delete / partial. -
Response (401): Defensive — admin gate passed but session not re-resolvable.
-
Response (500): Defensive — session resolved with empty username.
| HTTP status | Condition |
|---|---|
| 200 | Account deleted successfully |
| 400 | Username failed the allowlist regex |
| 404 | No persisted user matched :name |
| 409 | :name is the last member of a protected administrator population (early or late form above) |
| 503 | The guarded delete did not complete, or the account is deactivated but its session cleanup failed |
| 403 | Cookie session without a same-site Origin/Referer (cross-site refusal; API-token callers exempt) |
| 503 | The attempted audit row could not be written (Sec-Audit-Failed: true); nothing was revoked or removed |
| 403 | Target equals the caller’s own username (self-delete guard) |
| 403 | Caller is not an admin (rejected by admin gate before handler) |
| 401 | Defensive — admin gate / session callbacks disagree |
| 500 | Defensive — 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).
- Permission: Admin only
- Response (200): HTML fragment. At most 500 rows are drawn; a banner states the true count when more exist. When the account store does not answer, the fragment is a red “could not be read” box with a Retry button and no table, still with status 200 (htmx discards 4xx/5xx bodies). It is never an empty table.
- Response (401): The admin gate passed but the session could not be re-resolved.
- Response (403): Not an administrator. The panel shows a “not available to your account” note.
- Response (500): The resolved session has an empty username. The page refuses rather than render rows with no “Current user” marker.
- Headers:
Cache-Control: no-store.
GET /fragments/access/roles/list
Render the Roles panel: one row per role in the catalogue (name, description, whether it is built in).
- Permission:
UserManagement:Read, the same securable and the same checked store read asGET /api/v1/rbac/roles. - Response (200): HTML fragment. A role store that does not answer is a red “could not be read” box with a Retry button and no table, still with status 200. An empty catalogue (which the store never produces: it seeds eight roles) is drawn as the same red box, never as “no roles”.
- Response (403): The caller lacks the permission; the panel shows a “not available to your account” note.
- Headers:
Cache-Control: no-store. No success audit row (the REST twin writes none).
GET /fragments/access/roles/permissions?role=<name>
Render one role’s permissions grouped by area.
- Query:
role— 1 to 64 of letters, digits, dot, dash and underscore, and not made only of dots. Anything else is a 400 with a one-line plain-text body, answered before the permission check and before any store read. - Permission:
UserManagement:Read, as for the list. - Response (200): HTML fragment, in one of four forms: the permissions, “No such role” (the catalogue answered and holds no such name), “This role holds no permissions” (the role exists and has none), or the red unreadable box (either of the two store reads failed). The first three are never confused with the fourth.
- Headers:
Cache-Control: no-store.
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.
- Permission:
AccessReview:Read, the same securable asGET /api/v1/rbac/roles/assignments. NotUserManagement:Read: the grant table is authorization topology. - Audit: a successful view writes
rbac.assignments.listwithcount=<grants on record>(before the display cap). A row that cannot be saved setsSec-Audit-Failed: trueand the panel is still returned. A failed read writes no row. - Response (200): HTML fragment. At most 2000 rows are drawn; a banner states the true count and the page then shows “Not shown” for any count derived from the table. An unreadable grant table is a red box with a Retry button and no table, still with status 200.
- Response (403): The caller lacks
AccessReview:Read, or the session is an engine session (the latter is audited asengine_session_denied). - Headers:
Cache-Control: no-store.
GET /fragments/access/enforcement
Render the RBAC enforcement panel.
- Permission: any signed-in session, the same as its twin
GET /api/v1/me. Neither the account-role gate nor a permission gate is applied. A service-scoped API token is refused first (403, audit actionaccess.fragment.access_denied), because the panel reports fleet-wide RBAC state. - Response (200): HTML fragment: the setting (On or Off) with an Enforce roles switch when the session is an administrator account (a hint: the server decides on every PUT), a read-only switch for everyone else, Unconfirmed when the store’s view could not be confirmed (the cached flag is not drawn at all in that case), or a red “could not be read” box when the RBAC store is not open. Never “Off” for a failed read.
- Headers:
Cache-Control: no-store. No audit row (the twin writes none).
GET /fragments/access/users/detail?username=<name>
Render the Account panel for one active account.
- Query:
username— URL-encoded (an SSO principal such asoidc:https://idp/x#submust encode#as%23). Empty or absent returns the “select a user” placeholder. - Permission, statuses, headers: as for the list. An unknown name is a 200 “No such user” panel, distinct from the red unreadable-store box.
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).
- Permission:
ManagementGroup:Read, the same securable and permission check asGET /api/v1/management-groups; unlike that route, a failed read is reported, not rendered as an empty list. The per-group member counts have no field onGET /api/v1/management-groups; a machine derives them from each group’s member list. - Response (200): HTML fragment. At most 500 rows are drawn; a note states the true count when more exist, and a group beyond the 500th row cannot be selected from the page. A groups read or a member-count read that does not answer is a red “could not be read” box with a Retry button and no table, still with status 200: it is never an empty tree and never a column of zero counts. A group whose parent is not in the list, or that sits in a cycle or beyond the display depth, is drawn as a top-level row rather than dropped. The group list and the member counts are two separate reads, so a group or member change between them can show an out-of-date count (a group deleted in between shows 0) until the panel is reloaded.
- Response (401 / 403): Not signed in / the caller lacks the permission. On a 403 the panel shows the heading “Not available to your account” above the sentence “Your account cannot read management groups.”
- Headers:
Cache-Control: no-store.
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.
- Query:
id— 1 to 64 ofa-fand0-9. Empty or absent returns the “select a group” placeholder (still permission-checked). - Permission:
ManagementGroup:Read, as for the list. - Response (200): HTML fragment: the detail, “No such group” (the store answered and holds no such id), or the red unreadable box with a Retry button. The three are never confused.
- Response (400 / 401 / 403): malformed
id/ not signed in / the caller lacks the permission. On a 403 the panel shows the heading “Not available to your account” above the sentence “Your account cannot read management groups.” - Headers:
Cache-Control: no-store.
GET /fragments/groups/members?id=<group id>
Render one group’s members (agent id, source, when added).
- Query:
id— required, 1 to 64 ofa-fand0-9; empty or absent is a400(unlike the detail, which treats an emptyidas the placeholder). - Permission:
ManagementGroup:Readthrough the fleet-read gate (ADR-0017) as the only gate. A caller whose visibility is narrowed to some management groups sees only the members inside that visibility. Rows outside it are omitted and no count or other figure derived from them is returned: the panel states “Only members within your scope are shown.” and its heading counts only the rows the caller may see. A service-scoped API token (RBAC on) whose ITServiceOwner ceiling holdsManagementGroup:Readpasses this gate and sees the members within its service scope; the list and detail refuse such a token with403. The REST and MCP twin (GET /api/v1/management-groups/{id},get_management_group) returns the whole member list, not narrowed, to any caller passingManagementGroup:Read; narrowing it is a follow-up. - Response (200): HTML fragment, at most 200 rows with a note stating how many the caller can see. A group with no members says so; for a rule-based group the text says membership is not kept up to date from its rule. A members read that does not answer is a red unreadable box with a Retry button and no count, still 200. An unknown group is “No such group”.
- Response (400 / 401 / 403 / 503): malformed or missing
id/ not signed in / lacks the permission / the authorization topology could not be read. On a 403 the panel shows the heading “Not available to your account” above the sentence “Your account cannot read the members of this group.” - Headers:
Cache-Control: no-store.
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)
- Permission:
ApiToken:Read. An administrator on a browser session sees every owner’s tokens; every other caller, including an administrator’s API, MCP or service-scoped token, sees only their own, asGET /api/v1/tokensdoes. - Response (200): HTML fragment, capped at 500 rows with a banner. A token store that does not answer is a red “could not be read” box (still 200), never an empty table.
- Response (401 / 403): not signed in / lacks
ApiToken:Read(the panel shows “not available to your account”). A refused browser session writes no audit row; a refused token does. - Response (500): the resolved session has no username.
GET /fragments/access/principals/list and GET /fragments/access/principals/detail?id=engine:<slug>
- Permission:
EnginePrincipal:Read, the same permission asGET /api/v1/engine-principalsand/{id}(administrators only while RBAC is disabled). The page refuses engine credentials and MCP-tier tokens whatever they hold:403with the A4 envelope messagethis credential cannot access this endpoint, audited asengine_principal.list/engine_principal.getwithresult=deniedanddetail=engine_session_deniedormcp_session_denied. - Response (401 / 403): not signed in / lacks
EnginePrincipal:Read(the panel shows “not available to your account”; the permission gate’s own denial, not the credential message above, which applies to a caller that holds the permission). A refused browser session writes no audit row. - Response (200): HTML fragment. The list carries
data-check="1"only when the caller also holdsAuditLog:Read(the page script then runs the no-administrator check). A malformedidis the “select a principal” placeholder with no store read; an unknown id is a 200 “not found” panel. - Revocation detail: for a revoked principal the free-text detail of the
engine_principal.revokeaudit event is shown only to callers who holdAuditLog:Read; others see it marked withheld (also when the grant could not be confirmed). The newest 200 events are read; when more exist the panel says so. - Assign role list: the custom roles offered for an active principal are read only for a caller who also holds
UserManagement:Read, the permissionGET /api/v1/rbac/rolesneeds; without it the fragment draws no role name and Assign is disabled with that reason (also when the grant could not be confirmed). - Audit: success writes
engine_principal.listorengine_principal.get, plusengine_principal.role.listedwhen the roles were read. A refused browser session writes no row; a refused token or engine caller does.
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.
- Permission:
AuditLog:Read(admin-only while RBAC is disabled, #5771). - Query:
range—1h,24h(default) or7d;principal,action— exact matches;offset—0to10000.limitis not read: the page is always 50 rows. - Response (200): HTML fragment: the table and pager,
No events matchwhen the store answered with zero rows, or a red “could not be read” box with no table when the store did not answer. A refused request (unknownrange,offsetoutside0..10000, NUL byte in a filter) is also 200 with a fixed note and nothing read. - Response (403): The caller lacks
AuditLog:Read(the gate’s own response). The panel loads on its own, so a refused browser session writes no denial row; a refused token does. - Audit: none on a read, like the REST route.
- Headers:
Cache-Control: no-store.
GET /fragments/access/audit/sample
The dashboard view of GET /api/v1/audit/auth-sample with the window left open.
- Permission:
AuditLog:Read. - Query:
limit—1to1000(default100); anything else is a 200 note, nothing read. - Audit:
audit.auth_sample.exported, once per successful draw. A dropped row setsSec-Audit-Failed: trueand the panel says so.
GET /fragments/access/audit/retention
The dashboard view of GET /api/v1/settings/data-retention (audit retention only).
- Permission:
ServerConfig:Read.
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).
- Permission:
AccessReview:Read(admin-only while RBAC is disabled). The Open review and Close controls are drawn only for a caller who also holdsAccessReview:Attest. - Response (200): HTML fragment: the table, “No review campaigns yet” when the store answered with none, or a red box with no table when it did not answer.
- Response (403): The gate’s own response; an engine-class session is refused exactly as the REST route refuses it (A4 body,
access_review.listdenied with detailengine_session_denied). - Audit:
access_review.list,count=<N>.
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).
- Query:
id— 1 to 64 ofA-Z a-z 0-9 _ -. Empty returns the placeholder; any other value is a 200 “No campaign with that id” note and nothing is read or audited. - Response (403): The gate’s own response; an engine-class session is refused exactly as the REST route refuses it (A4 body,
access_review.getdenied with detailengine_session_denied). - Audit:
access_review.get,target_id=<campaign_id>. An unknown campaign and a store fault write none.
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).
- Response (403): The gate’s own response; an engine-class session is refused exactly as the REST route refuses it (A4 body,
access_review.exporteddenied with detailengine_session_denied). - Audit:
access_review.exported,detail=format=view rows=<N> rbac_enforcement=<value>.
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
}
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
plugin | string | Yes | — | Target plugin name. |
action | string | Yes | — | Action within the plugin. |
agent_ids | array of string | No | — | Target agent IDs. Omit the field to broadcast. A supplied [], a non-array value, or a non-string entry returns 400 (#2500). |
params | object | No | {} | 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.) |
scope | string | No | — | Scope expression for device targeting (alternative to agent_ids), or __all__ for every enrolled agent. A supplied "" or non-string value returns 400 (#2500). |
stagger | integer | No | 0 | Max 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). |
delay | integer | No | 0 | Fixed 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:
reason | Cause |
|---|---|
target_conflict | scope was also supplied, even empty. The route never picks one silently. |
scope_expression_empty | The field was supplied but empty. It is never widened to the fleet. |
scope_expression_too_long | Longer than 8192 bytes. |
scope_expression_invalid | The expression does not parse (a bare tag:finance, __all__ and group:<id> are not expressions). |
scope_unknown_attribute | An 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):
| Field | Type | Required | Description |
|---|---|---|---|
id, name, type, plugin, action, etc. | various | yes | Standard InstructionDefinition fields. |
yaml_source | string | yes (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. |
signature | string | optional | Hex-encoded Ed25519 signature over yaml_source’s bytes. |
publicKey | string | optional | Hex-encoded Ed25519 public key (64 hex chars). |
Signing rules:
signature+publicKeyboth present + valid → accepted.signature+publicKeyboth present + invalid →400 signature verification failed for instruction — yaml_source may have been tampered with.- Exactly one of
signature/publicKeypresent →400 instruction-import has incomplete signing metadata — both signature and publicKey must be present together (or both absent). - Neither present, signature enforcement on (default) →
400 instruction-import is unsigned and signature enforcement is enabled (set --allow-unsigned-definitions / YUZU_ALLOW_UNSIGNED_DEFINITIONS=1 to bypass). - Neither present, signature enforcement off → accepted as unverified (operator opt-out via
--allow-unsigned-definitions, emitsserver.unsigned_definitions_allowedaudit row at startup).
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:
| Column | Canonical | Fallback |
|---|---|---|
| id | metadata.id (create only; honoured, 409 on conflict) | store-generated |
| name | metadata.displayName | metadata.name, then metadata.id |
| plugin / action | spec.execution.plugin / .action | spec.plugin / spec.action |
| approval | spec.approval.mode | scalar spec.approval (default auto) |
| version / type / concurrency | metadata.version / spec.type / spec.execution.concurrency | defaults 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"}
}
agent_ids(optional) — array of agent IDs to target. Omit to broadcast; a supplied[], a non-array value, or a non-string entry returns400(#2500).scope(optional) — scope expression (e.g.,group:servers), or__all__for every enrolled agent. A supplied""or non-string value returns400.
Behaviour change (#2500). This endpoint previously documented “empty string + empty
agent_ids= broadcast”, and an explicitly emptyagent_idsdid broadcast. It is now400. Omit both fields (or send"scope": "__all__") to target the whole fleet deliberately. Refusals incrementyuzu_server_dispatch_target_rejected_total{route="instruction_execute"}and write aninstruction.executeaudit row withresult=denied. The body must be a JSON object; anything else is400.
params(optional): an object of parameters passed to the plugin. When the definition stores aparameter_schema, it is validated against that schema (below). Omitted andnullmean “no params”.
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.
400:error.messageisinvalid params: <path>: <reason>, where<path>is/<name>,/<name>/<index>,/*for an undeclared name or(root). The message never contains a submitted value. No approval ticket, execution row or dispatch is created.500: the definition’s stored schema cannot be prepared, so the call fails closed:error.messageisstored parameter schema for this instruction is invalidanderror.remediationsays to export the definition, delete it and import it again with a correctedparameter_schema(signed, unless the server allows unsigned definitions; see Replacing a stored parameter schema). Nothing is dispatched. The audit row isinstruction.executewithresult=failureand detailreason=param_schema_invalid.- A definition whose stored schema is empty, whitespace or
{}declares nothing and is not validated. This is how a definition saved from the YAML editor behaves.
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, MCPexecute_instructionandPOST /api/commanddo 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:
| Field | Type | Meaning |
|---|---|---|
request | object or null | The 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_digest | string | Lowercase 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_roots | array of string | Principals barred from approving this ticket, in addition to its submitter, who is always barred. Matched byte-for-byte against the reviewer’s name. |
request_unreadable | boolean | Present (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_unreadable | boolean | Present (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:
| Status | Body | Cause |
|---|---|---|
403 | A4 envelope, message cross-site request refused | Server-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. |
503 | A4 envelope, message authentication store is temporarily unavailable | Central 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. |
403 | step-up envelope, message engine principals cannot perform this operation | Central 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. |
403 | step-up envelope, message MFA step-up is not available for SAML sessions in this release | Central 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). |
503 | A4 envelope, message authentication store is temporarily unavailable | Central 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). |
401 | step-up envelope, meta.mfa_step_up_required: true, meta.challenge_url | Central 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. |
403 | the standard permission-denied response | In the handler: the caller lacks Approval:Approve. |
503 | A4 envelope, retry_after_ms: 5000 | In the handler: the caller’s session could not be resolved after the permission gate passed. The request stays pending and nothing is recorded. |
403 | A4 envelope, message cross-site request refused | In 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. |
403 | A4 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. |
503 | A4 envelope, message MFA verification is not available; the decision was not recorded, remediation contact an administrator | In 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. |
403 | A4 envelope, message approver_assurance_required:mfa_not_enrolled | In 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. |
403 | A4 envelope, message approver_assurance_required:mfa_proof_missing | In 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. |
403 | A4 envelope, message approver_assurance_required:account_unavailable | In 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. |
403 | A4 envelope, message approver_assurance_required:saml_mfa_unsupported | In 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. |
401 | step-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. |
503 | A4 envelope, retry_after_ms: 5000 | In 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. |
503 | A4 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”. |
400 | A4 envelope, message approval not found | In 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. |
503 | A4 envelope, header Sec-Audit-Failed: true, retry_after_ms: 5000 | The 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:
| Status | Reason |
|---|---|
| 400 | expression 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:
| Status | Reason |
|---|---|
| 400 | expression missing/empty, or fails to parse/validate |
| 403 | Service-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 |
| 404 | A 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 |
| 503 | Scope 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.
- Permission:
Execution:Read(RBAC) or admin role {id}: the execution id returned byPOST /api/instructions/:id/executeor visible in the executions list (regex:[A-Za-z0-9_-]{1,128}).- Content-Type:
text/event-stream - Headers:
Cache-Control: no-cache,X-Accel-Buffering: no(so reverse proxies don’t buffer the stream into chunks).
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:
| Event | Emitted on | Payload (JSON in data:) |
|---|---|---|
agent-transition | One per agent state change (update_agent_status write) | AgentExecStatus snapshot — agent_id, status, exit_code, duration_ms, error |
execution-progress | Every refresh_counts recompute | counts snapshot — total, succeeded, failed, running, pending |
execution-completed | Crossing the all-agents-responded threshold OR mark_cancelled | terminal 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:
| Event | Confined treatment |
|---|---|
agent-transition | Filtered by agent_id — only transitions for agents in the caller’s visible set are forwarded; others are silently dropped. |
execution-progress | Dropped entirely — the counts snapshot is execution-wide and carries no agent_id to filter by, so it cannot be safely narrowed. |
execution-completed | Sanitized, 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 status | Condition |
|---|---|
| 200 | Stream attached; events follow |
| 401 | No session / token |
| 403 | RBAC 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) |
| 410 | Execution is already in a terminal status (succeeded / completed / cancelled / failed). Tells EventSource to stop reconnecting. |
| 429 | Shared 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. |
| 503 | The 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:
/api/v1/eventswraps every event in a structured JSON envelope so the worker can discriminate events without out-of-band context.- A4 error envelope on every 4xx/5xx —
correlation_id, optionalretry_after_ms, optionalremediation. - No fragment rendering — pure machine-consumable JSON.
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:
| Parameter | Type | Description |
|---|---|---|
execution_id | string [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:
| Parameter | Type | Description |
|---|---|---|
since (query) | integer ≥ 0 | Replay 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) | string | Browser 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 type | When emitted | Meaning |
|---|---|---|
heartbeat | Every ≤3s of provider idle | Liveness check; data: is empty. Workers should ignore. |
replay-gap | First frame on reconnect if the ring buffer has evicted events with id ≤ since | Worker missed events [missing_from..missing_to] — state may be inconsistent. Payload: {execution_id, type:"replay-gap", missing_from, missing_to}. |
events-dropped | Per-connection queue cap (500) was exceeded | A 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:
| HTTP | Body | Condition |
|---|---|---|
| 200 | SSE stream begins | Stream attached, events follow |
| 400 | A4 envelope | Missing or malformed execution_id |
| 401 | (auth layer) | No session / token |
| 403 | (perm layer) | RBAC Execution:Read denied |
| 404 | A4 envelope | Execution does not exist, or exists but is not visible within the caller’s management-group scope — deliberately indistinguishable (no existence oracle, #1634) |
| 410 | A4 envelope | Execution already terminal |
| 429 | A4 envelope with retry_after_ms:5000 | Shared held-open-stream budget exhausted (ADR-0034). Remediation text: close an existing /api/v1/events stream, or raise --max-sse-streams |
| 503 | A4 envelope with retry_after_ms:5000 | Tracker 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):
| Header | Value | Purpose |
|---|---|---|
X-Correlation-Id | req-<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-Control | no-cache | Prevents proxy / browser cache buffering. |
X-Accel-Buffering | no | Tells nginx and similar proxies not to buffer the SSE stream. |
X-Content-Type-Options | nosniff | Belt-and-braces against MIME sniffing. |
Sec-Audit-Failed | true (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):
- No multi-execution / wildcard subscription — open one connection per execution, or wait for W5.2.
- No per-principal connection cap — operators expecting >10 concurrent agentic subscriptions should size the httplib worker pool accordingly.
- Replay/subscribe race window: a publish that arrives between the
replay_sinceandsubscribecalls is silently lost. A bus-sidesubscribe_with_replayis filed as a follow-up against both this route and the dashboard sibling. The MCP— closed by #1088. Both the MCPexecute_instructiontool currently returnscommand_id, notexecution_idexecute_instructiontool and RESTPOST /api/instructions/{id}/executenow returnexecution_idalongsidecommand_id. The full agentic dispatch-to-observe loop is functional.
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):
| Parameter | Type | Required | Description |
|---|---|---|---|
sql | string | Yes | A SELECT query using $-prefixed table names (e.g., $Process_Live, $TCP_Hourly). |
scope | string | No | Target 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:
- A
<thead>element withhx-swap-oob="innerHTML:#echo-results-head"containing dynamic column headers derived from the query result schema. - An SSE stream setup that delivers result rows as
<tr>fragments.
Safety controls:
- Server-side: validates SELECT-only queries and applies a keyword blocklist (no INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, ATTACH, DETACH, PRAGMA, VACUUM, REINDEX).
- Agent-side: untrusted queries run on a dedicated read-only SQLite connection through a
sqlite3authorizer that permits onlySELECT/READof registry-known warehouse tables ($Process_Live,$TCP_Hourly, …) and scalar/aggregate functions. Writes,ATTACH,PRAGMA, schema-table reads (sqlite_master), recursive CTEs, and multi-statement input are denied at prepare time — the read-only connection makes writes structurally impossible regardless of the query text.$-prefixed table names are translated only outside string literals and comments, so the executed query always matches the validated form. Queries exceeding 4KB are rejected. A blocked query returnsquery rejected: operation or table not permitted.
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):
| Parameter | Type | Required | Description |
|---|---|---|---|
device_id | string | Yes | The agent ID. Must be in the operator’s visible-agent set; otherwise rejected with 404 (same body as not-connected). |
source | string | Yes | One 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:
- 200 OK with empty body and an
HX-Triggertoast on success. - 400 with explanatory body for missing/invalid params.
- 404 with body
Agent not reachable.for both out-of-scopedevice_idand not-connected agent. Audit detail records the real reason (scope_violationvsagent_not_connected) server-side.
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):
| Parameter | Type | Required | Description |
|---|---|---|---|
device_id | string | Yes | The agent ID. Must be in the operator’s visible-agent set; otherwise rejected with 404 (same body as not-connected). |
source | string | Yes | One of process, tcp, service, user. Other values rejected with 400 to prevent forged form submissions. |
Response:
- 200 OK with a
Purge dispatched — Scan to refresh.fragment and anHX-Triggertoast on success. The source row remains (purge does not re-enable); a subsequent Scan reconciles the now-zero counts. - 400 with explanatory body for missing/invalid params.
- 404 with body
Agent not reachable.for both out-of-scopedevice_idand not-connected agent. Audit detail records the real reason (scope_violationvsagent_not_connected) server-side. - 503 if command dispatch is unavailable.
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):
| Field | Type | Required | Description |
|---|---|---|---|
device_id | string | Yes | The target agent. Must be within the caller’s management scope. |
source | string | Yes | One of process, tcp, service, user. Others → 400. |
Responses:
- 202 Accepted —
{"data":{"command_id","device_id","source","agents_reached"},"meta":{"api_version":"v1"}}. Async: the agent computesrows_deleted(and the agent-sidesource_not_pausedrefusal if the source was re-enabled, orconfig_unreadableif the enable flag could not be read) into the command’s response record — poll it bycommand_id; it is not in the 202 body.X-Correlation-Idheader on every response. - 400 — invalid JSON / missing
device_id/source/ source not in the allowlist (A4 error envelope). - 403 / 404 — permission denied or device out of scope (scoped gate; A4 envelope).
- 404 — dispatch reached zero agents (device offline).
- 503 — command dispatch unavailable, or the audit row could not be persisted (
Sec-Audit-Failedheader; the purge is not dispatched without durable evidence).
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):
| Parameter | Type | Required | Description |
|---|---|---|---|
yaml_bundle | string | Yes | Multi-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:
201 Created{"id": "<pack-id>", "status": "installed"}on success, or on an idempotent replay (same key, same body). Partial success (#3479):install_fntolerates a single document failing without failing the whole bundle — when at least one document failed alongside ones that succeeded, the body additionally carries"errors": [...](one entry per failed document, in bundle order —"<kind>: <reason>"for a document whoseinstall_fndelegate rejected it, or a bare"document <N> has no kind"for one with no inferrablekind:at all),"installed_count"(how many succeeded), and"total_items"(item documents in the bundle, excluding theProductPackmetadata document itself). These three fields are OMITTED entirely when every item document succeeded — a client checkingbody.errors(or its absence) can distinguish a clean install from a partial one without parsing anything else. An idempotency-key replay never populates them (install_fndoesn’t re-run on a replay), even if the ORIGINAL install that minted the key was itself partial (gov Gate 4, unhappy-path UP-1) — the two attempts’ audit rows will disagree (the original’ssuccessrow names a failed/total count indetail, the replay’s does not), which is expected: the replay is reporting “no new install_fn work happened,” not re-asserting the original outcome. Check the ORIGINAL attempt’s audit row (sametarget_id) for the authoritative partial-failure detail, not the replay’s.400 Bad Request— malformed YAML, missing required fields, item-install delegation failures, a signature rejection, or an idempotency-key conflict. Distinct error messages:no items installed: <reason>; <reason>; ...— EVERY item document in the bundle failed to install (each<reason>is<kind>: <install_fn's rejection>, or a baredocument <N> has no kindfor one with no inferrablekind:at all — same two shapes as the partial-successerrors[]array above).#3479: every document’s reason is joined here (;-separated, bundle order), not just the first — a bundle with several independently-wrong documents used to only ever surface one.pack '<name>' is unsigned and signature enforcement is enabled (set --allow-unsigned-packs / YUZU_ALLOW_UNSIGNED_PACKS=1 to bypass)— the install was refused because the pack carried nosignature:field and the server is enforcing signatures (default since #802). Either sign the pack or set the escape-hatch flag.signature verification failed for pack '<name>' — content may have been tampered with— the signature was present but did not verify against the supplied public key.pack '<name>' has signature but no publicKey — cannot verify— the bundle carried asignature:field but nopublicKey:.duplicate item id in bundle: '<item_id>' (compensated N/M item(s))— two documents in the bundle were assigned the same item id (by theirinstall_fndelegate). Detected before any database interaction; this condition is deterministic, so a retry with the same bundle always fails the same way — never retry, fix the bundle instead. The(compensated N/M item(s))suffix (#3481, gov Gate 6) is appended whenever a compensation attempt actually ran (the real REST route always suppliescompensate_fn, so in practice this always appears here) —N == Mmeans every already-installed item was cleaned up;N < Mmeans a residual orphan needs manual/operator cleanup (see the compensation metric below).idempotency key already used with a different request body(#3481) — theIdempotency-Keyheader matches a prior install whoseyaml_bundlediffers from this request’s. Use a fresh key for a genuinely new install.Idempotency-Key header too long (max 200 characters)(#3481) — rejected before the store is touched; not audited (see below).
503 Service Unavailable— the product pack store is unreachable (down/unmigrated) or a database error occurred persisting the pack. The response body carries only a generic"service unavailable"message; the specific database error is logged server-side, never echoed to the caller (migrated store — ADR-0054, mirrorssw_deploy_client_message/device_token_client_message’s established rationale for not leakingPQerrorMessage()fragments). Since #3481: any sibling-store items already installed before this failure are now best-effort compensated (rolled back) rather than silently orphaned — seeyuzu_server_product_pack_install_compensation_totalin the metrics reference.- Both the
400and503bodies use the standard A4 error envelope ({"error": {"code", "message", "correlation_id", ...}, "meta": {"api_version": "v1"}}, see JSON Envelope above). Breaking change (ADR-0054): pre-migration, a rejected install returned a bare{"error": "<message>"}body — a client parsing that flat shape (rather than treating the body as opaque diagnostic text) must switch to readingerror.message.
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:
200 OK— JSON array of{id, name, version, description, installed_at, verified}objects.503 Service Unavailable— the store is unreachable or the list query failed. A4 error envelope, generic message (see thePOSTroute above for the no-raw-DB-error-to-caller rationale).
GET /api/product-packs/:id
Get a single pack with its items.
Permission: ProductPack:Read.
Response:
200 OK— the pack JSON object including theitems[]array (each{kind, item_id, name}).404 Not Found— no pack with that id. A4 error envelope.503 Service Unavailable— the store is unreachable or the read failed. A4 error envelope, generic message.
DELETE /api/product-packs/:id
Uninstall a pack, removing all delegated items.
Permission: ProductPack:Delete.
Response:
200 OK{"status": "uninstalled"}on success.404 Not Found— no pack with that id. A4 error envelope. Breaking change (ADR-0054): the pre-migration route always returned a bare{"error": "<message>"}body at400for a missing id; automation that special-cased400or parsed the flat body shape on this route must switch to404+error.message.503 Service Unavailable— the store is unreachable or the delete failed. A4 error envelope, generic message.
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:
| Code | Meaning |
|---|---|
200 | SSE stream (text/event-stream, chunked, held open) |
400 | Mcp-Session-Id absent (-32600) |
404 | Session unknown, expired, another principal’s, or the Last-Event-ID cursor is outside the replay window (-32007) — re-initialize |
403 | Origin not in --mcp-allowed-origin (-32008) |
405 | Streaming disabled (--mcp-no-streaming) |
406 | Accept did not include text/event-stream (-32011) |
429 | Concurrent-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:
| Method | Description |
|---|---|
tools/list | List available MCP tools for the current tier |
tools/call | Invoke an MCP tool by name with arguments |
resources/list | List available MCP resources |
resources/templates/list | List available MCP resource templates |
resources/read | Read an MCP resource by URI, or a resource template with a parameter substituted |
prompts/list | List available MCP prompts |
prompts/get | Get a prompt template by name |
Phase 1 tools (22 read-only):
| Tool | Description |
|---|---|
list_agents | List connected agents with status |
get_agent_details | Detailed info for a specific agent |
query_audit_log | Search audit events with filters |
list_definitions | List instruction definitions |
get_definition | Get a specific instruction definition |
query_responses | Collect responses by execution_id (one dispatch) or instruction_id |
aggregate_responses | Aggregate response data |
query_inventory | Query inventory data with filters |
list_inventory_tables | List available inventory tables |
get_agent_inventory | Get inventory for a specific agent |
get_tags | Get tags for a device |
search_agents_by_tag | Find agents matching tag criteria |
list_policies | List all policies |
get_compliance_summary | Compliance summary for a device |
get_fleet_compliance | Fleet-wide compliance overview |
list_management_groups | List management groups |
get_execution_status | Status of a specific execution |
list_executions | List recent executions |
list_schedules | List instruction schedules |
validate_scope | Validate a scope expression |
preview_scope_targets | Preview which agents match a scope |
list_pending_approvals | List pending approval requests |
get_pending_approval_count | Count 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:
| Tool | Description |
|---|---|
list_dex_perf_apps | Applications with retained fleet app-perf data (the picker) |
get_dex_app_perf | Fleet trend for one application, by version, over time |
list_dex_app_perf_devices | The 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_perf | One 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_perf | The 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_versions | Cohort-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:
| URI | Description |
|---|---|
yuzu://server/health | Server health status |
yuzu://compliance/fleet | Fleet-wide compliance data |
yuzu://audit/recent | Recent audit events (AuditLog:Read; admin-only while RBAC is disabled) |
Prompts:
| Name | Description |
|---|---|
fleet_overview | Generate a fleet status overview |
investigate_agent | Investigate a specific agent |
compliance_report | Generate a compliance report |
audit_investigation | Investigate audit trail activity |
Server-side controls:
| CLI Flag | Effect |
|---|---|
--mcp-disable | Reject all /mcp/v1/ requests |
--mcp-read-only | Allow 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.progressToken | Mcp-Session-Id | Accept: text/event-stream | Server answers |
|---|---|---|---|
| present | sent, valid | present, streamed POST enabled | this POST response held open as an SSE stream — notifications/progress frames, then the JSON-RPC result last, then EOF |
| present | sent, valid | absent, or streamed POST disabled | plain JSON now; progress frames go to the session’s GET /mcp/v1/ stream instead |
| present | not sent | any | plain JSON, byte-identical to a call with no progress tracking |
| absent | any | any | plain 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:
| Status | Condition | Body |
|---|---|---|
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 |
401 | Invalid 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: 2 | The 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 |
503 | Enforcement 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"}} |
503 | The 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: 2 | A 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:
| Status | Condition | Body |
|---|---|---|
200 + Set-Cookie: yuzu_session=… | Code accepted | {"status":"ok"} |
401 | Invalid 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. |
503 | The 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:
| Status | Condition | Body |
|---|---|---|
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 |
401 | Invalid/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) |
503 | Auth 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"}} |
409 | The 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:
| Status | Condition | Body |
|---|---|---|
200 | Code accepted; session’s mfa_verified_at refreshed to now | {"status":"ok"} |
400 | Missing 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 |
401 | No session cookie, or rejected code | {"error":{"code":401,"message":"MFA step-up failed"}} |
503 | auth_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:
403for a SAML session (no SAML MFA attestation in this release; the remediation says SAML sessions cannot satisfy step-up, re-authenticating does not help, and to use an existing untiered, unscoped API token where the route accepts one, or ask an administrator). The escape hatch precedes this denial, so with--mfa-step-up-window-secsat0or less SAML sessions pass all 37 routes. Also403for an engine principal on every one of the 37 routes, regardless of its grants (audited asmfa.step_up.requiredwithresult=errorandreason=engine_principal_denied; the body carriesmeta.mfa_step_up_required: falseand nochallenge_url). Once its credential resolves, an engine principal is refused before any role grant is consulted and without any MFA store read, even when--mfa-step-up-window-secsis0; it can never satisfy a TOTP challenge. During a database outage the pre-routing authentication cannot resolve the credential (an engine token reads the store on every request; a cookie session is served from the validation cache for about 30 seconds of a stall and is in the same position only once that cache lapses), so the request is answered as an unauthenticated one:401unauthorized(a JSON body on/api/,/eventsand/mcp/paths; other paths, such as/fragments/, get a redirect orHX-Redirect) without the step-up envelope, the engine quota or a step-up audit row. The checks that precede the denial are the pre-routing gates (on-behalf rejection, the rate limit, the401for no session and the engine quota429) and the unwired-store503below, which answers every caller class. On these 37 routes this replaces the per-route handler denial (aresult=deniedaudit row with detailengine_session_deniedand the body textengine principals cannot access this endpoint), which is no longer produced there; update SIEM rules keyed on it.503when the server has no wired authentication store (for any caller, including a token caller and a disabled window), or when the store cannot be read or the stored second factor cannot be decrypted (for a local session; API-token and MCP-token callers pass before any store read, and an OIDC session is judged on its identity-provider assertion). A503still carriesmeta.mfa_step_up_required: true, with nochallenge_url; a client must treat a503as “retry shortly” and prompt for a code only on a401. Any other failure to read the enrollment state of a local session is a401whose remediation names the store.- A refusal carries an
X-Correlation-Idresponse header equal to the body’serror.correlation_id, and the audit row of a refusal made by the central check carriescid=<id>(the same value) andstage=pre_authzin themfa.step_up.requiredrow’sdetail, so the response is joinable to its row. The detail of a stale or absent proof isuser=<name> age_secs=<n> window_secs=<n> cid=<id> stage=pre_authz(age_secs=-1when the session has no proof); every other refusal isuser=<name> reason=<token> cid=<id> stage=pre_authz. The gate the three JIT-elevation handlers apply in their own handler writes the same fields withoutcid=orstage=, so only the central check’s rows join by correlation id. The row is best effort, and the central check writes none for a request with no session (counted on the metric only; pre-routing answers anonymous requests first). An over-cap request body is refused413before the step-up check runs. - Dashboard controls. The
/fragments/...and/api/settings/...routes below answer in the same envelope. When the request carriesHX-Request(an htmx control, on these routes or on an/api/v1/route such asDELETE /api/v1/sessions) the response also carries anHX-Triggererror toast whose text is the envelope’smessage; afetchrequest has no such header and gets no toast. The Guardian page’s Edit and Delete controls usefetchthrough the shared dialog, which prompts for a code on a401envelope and shows themessageof a403or503inside the dialog. See Authentication for the prompts each dashboard control raises.
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.
POST /api/v1/tokens(mint API token)DELETE /api/v1/tokens/{id}(revoke API token)POST /api/v1/tokens/{id}/rotate(rotate an API token — runs on every call, including an idempotent re-serve)POST /api/v1/tokens/{id}/confirm(confirm an API token rotation)DELETE /api/v1/sessions(admin force-logout another user)POST /api/v1/users/{name}/unlock(clear a user’s account-lockout counter — parity withDELETE /api/v1/sessions)POST /api/v1/users/me/password(change your own local password — only when MFA is enrolled)POST /api/v1/users/{name}/password(reset another user’s local password)POST /api/v1/software-packages(upload software package)POST /api/v1/software-deployments/{id}/start(start deployment)POST /api/v1/guaranteed-state/rules(create Guardian rule)PUT /api/v1/guaranteed-state/rules/{id}(update Guardian rule)DELETE /api/v1/guaranteed-state/rules/{id}(delete Guardian rule)POST /api/v1/guaranteed-state/push(fan out Guardian rules)POST /api/v1/guaranteed-state/policy-generation/raise(raise the Guardian policy generation)PUT /api/v1/config/{key}(write a runtime-configuration override; its legacy twinPUT /api/config/{key}is not step-up gated)POST /api/settings/api-tokens(mint an API token from the dashboard)DELETE /api/settings/api-tokens/{id}(revoke an API token from the dashboard)POST /api/settings/mfa/recovery-codes(regenerate your MFA recovery codes from the dashboard)POST /api/settings/mfa/disable(disable your MFA enrollment from the dashboard)POST /fragments/guardian/guard/{id}/update(update a guard from the Guardian dashboard)POST /fragments/guardian/guard/{id}/delete(delete a guard from the Guardian dashboard)DELETE /api/settings/users/{username}(delete user)POST /api/settings/users/{username}/role(change user role)POST /api/v1/engine-principals/{id}/roles(assign a fleet-wide role to an engine principal)DELETE /api/v1/engine-principals/{id}/roles/{role}(unassign a fleet-wide role from an engine principal)POST /api/v1/engine-principals(create an engine principal)DELETE /api/v1/engine-principals/{id}(revoke an engine principal)POST /api/v1/engine-principals/{id}/credentials(mint the first credential)POST /api/v1/engine-principals/{id}/credentials/rotate(rotate a credential; runs on every call, including a grace-window re-serve)POST /api/v1/engine-principals/{id}/credentials/confirm(confirm a rotation cutover)POST /api/v1/engine-principals/{id}/transfer-owner(reassign the responsible owner)POST /api/approvals/{id}/approveandPOST /api/approvals/{id}/reject(decide an approval request; cookie sessions only, API-token and MCP-token sessions get403 approver_assurance_required:<reason>instead of passing through, #6061; the caller also needs an enrolled MFA factor with a fresh proof, withinmin(--mfa-step-up-window-secs, 300)seconds, #6079, a stricter rule than this gate)POST /api/v1/rbac/roles/{name}/assignments(assign a fleet-wide RBAC role to a human user)DELETE /api/v1/rbac/roles/{name}/assignments/{principal_id}(unassign a fleet-wide RBAC role from a human user)PUT /api/v1/rbac/enforcement(switch RBAC enforcement on or off)POST /api/v1/users/{username}/elevation-eligibilityandPOST /api/v1/users/elevation-eligibility?username=(grant or revoke JIT-elevation eligibility)POST /api/v1/elevate(activate a JIT admin elevation)
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):
- Local session: MFA must be enrolled (unconditionally, not gated on
--mfa-enforcement) and a fresh MFA step-up (TOTP) is required. - OIDC session: ⚠️ temporarily unavailable — since the
oidc:<iss>#<sub>identity re-key (#1837/#1857), an OIDC session has no localusersrow and is denied at the eligibility gate (403,"eligibility read failed"); restoration is tracked in #1852. Theamrbehaviour described here is the intended path #1852 restores: a seededamr-asserted MFA proof from the current IdP login satisfies the second-factor requirement — no local TOTP enrollment is consulted (default--jit-oidc-amr-elevation=true). A single-factor (no-amr) OIDC session is denied ("no MFA in SSO login"), and a seeded-but-stale proof still triggers the step-up challenge rather than a silent grant. With--no-jit-oidc-amr-elevation, OIDC sessions cannot elevate at all ("OIDC-amr elevation is disabled") — they cannot present a local TOTP step-up (their step-up challenge is re-SSO), so the operator must switch to a local-authenticated session with local TOTP.
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
WWW-Authenticate: Bearer. This is unrelated to session cookies, API tokens, or RBAC — there is norole/permission check on these routes, only the bearer credential.
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).
userNamemust be a slug, not an email address. Yuzu usernames are alphanumeric plus./_/-— no@. Stock Okta/Entra provisioning defaultsuserNameto the user’s email and will400here; remapuserNameto a non-email slug attribute in the IdP’s attribute-mapping config before assigning users. The400body’s error message calls out the offending character(s).
Responses:
| Status | Condition | Body |
|---|---|---|
201 + Location + ETag | Created | SCIM User representation |
409 (scim_type=uniqueness) | userName already provisioned on a currently-active account | SCIM error envelope |
404 | The 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 |
500 | A 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 failed | SCIM error envelope |
503 + Retry-After: 2 | The 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 converge | SCIM 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.
| Status | Condition |
|---|---|
200 | Found — SCIM User representation |
404 | Unknown 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.
| Status | Condition |
|---|---|
200 | Replaced — SCIM User representation |
400 (scim_type=mutability) | Body includes a userName change — rename is out of scope this slice |
404 | Unknown 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 | The 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: 2 | The 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.
active:falsedeprovisions — soft-deletes the auth account and cascades session revocation.active:truereactivates — restores the account and clears any stale lockout. MFA is not restored; the user re-enrolls on next login.
| Status | Condition |
|---|---|
200 | Applied — SCIM User representation |
404 | Unknown 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 / 503 | As 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:
- Early: the
detailis “cannot deactivate: this account is the fleet’s last administrator”. Nothing was revoked or changed. - Late: the
detailadds that the account’s linked credentials (API tokens and sessions, including linked OIDC/SAML identities) were revoked before the refusal and that the account remains active. The credentials-first order is deliberate (ADR-2001).
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>"}).
| Status | Condition | Body |
|---|---|---|
201 + Location + ETag | Created | SCIM Group representation |
409 (scim_type=uniqueness) | displayName already in use | SCIM 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).
| Action | Result | When |
|---|---|---|
scim.user.provisioned | success / denied / failure | POST succeeds (incl. a revived reprovision) / rejected 409 against an active account / rolls back 500 |
scim.user.updated | success / failure | PUT /scim/v2/Users/{id} succeeds / fails 500 |
scim.user.deactivated | success / failure | PATCH/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.reactivated | success / failure | PATCH/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.deleted | success / failure | DELETE /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_denied | denied | A 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_refused | denied / partial | The 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_incomplete | partial | (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_failed | failure | The 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.denied | denied | Bearer-auth validation fails on any /scim/v2/* route, including discovery |
scim.group.created | success / denied / failure | POST /scim/v2/Groups succeeds / rejected 409 - displayName collision / rolls back 500. On success, detail is recompute=complete or recompute=incomplete |
scim.group.updated | success / denied / failure | PUT/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.deleted | success / failure | DELETE /scim/v2/Groups/{id} succeeds / audit-write failure (set-and-proceed). On success, detail is recompute=complete or recompute=incomplete |
scim.user.role_changed | success / failure | A 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_link | failure | ADR-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_denied | failure | ADR-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_denied | failure | ADR-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_unmatched | failure | ADR-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_failed | failure | ADR-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.
| Query | Meaning |
|---|---|
limit | Rows to return, default 50 (the bell sends 10). No server-side upper bound. |
all=true | Include read and dismissed rows. |
offset | Row 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:
- State is global. Read and dismissed state is stored on the row, not per user: one operator’s action changes the feed for everyone. There is no route that restores a read or dismissed row.
- No retention and no coalescing. Rows are kept indefinitely and a fleet-wide failing instruction produces one row per device, so a large fleet can sit at 100 or more unread.
- Audience. With RBAC off every signed-in user can read the feed and only administrators can act. With RBAC on only global Administrator and ITServiceOwner assignments pass; Operators, Viewers and management-group-confined users get
403and the bell stays hidden. - Audit. Both mutations are audited (
notification.mark_read,notification.dismiss; see above), and a refused caller writes oneauth.permission_requiredrow per request.
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/healthis 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 aCookie,AuthorizationorX-Yuzu-Tokenheader 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-limitvalue (default 100 requests/second,YUZU_RATE_LIMIT; values below 1 are treated as 1, so the limit cannot be turned off),/healthand/api/healthshare one bucket per source, and the bucket is separate from the REST budget. A request over the budget gets429withRetry-After: 1and the standard error envelope (includingcorrelation_idandretry_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/livezor/readyz. A request that carries none of those headers is not counted, is never refused by this limit, and always gets the unauthenticated body./livezand/readyzare not rate limited. The canonical path is/health; use/api/healthonly when your tooling enforces the/api/prefix unconditionally. (Restored in v0.12.0, see issue #620.)Note: When HTTPS is on,
/healthand/api/healthare 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 answers503 {"status":"not ready","reason":"https_listener_not_serving"}to all four probe paths while the HTTPS listener is not bound or has exited.Note:
/healthand/api/healthare 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/readyzinstead — 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 getagents.pending,executions.*, andsystem.*. 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"
}
| Field | Type | Description |
|---|---|---|
status | string | "healthy" or "degraded" |
uptime_seconds | integer | Server uptime in seconds |
agents.online | integer | Number of currently connected agents |
agents.pending | integer | Number of agents awaiting enrollment approval |
stores | object | Health 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_regressed | bool | true 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_active | bool | true when running with built-in per-install default certs (replace before production — see security-hardening.md). Unauthenticated so monitoring can detect it. |
tls.ca_fingerprint | string | SHA-256 fingerprint of the active default CA (empty when not on default certs). Public. |
tls.ca_expires_at | integer | Unix timestamp of the default CA’s expiry (0 when not on default certs). |
executions.in_flight | integer | Currently running instruction executions |
executions.completed_last_hour | integer | Executions completed in the past 60 minutes |
executions.failed_last_hour | integer | Executions that failed in the past 60 minutes |
version | string | Server 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
}
}
| Field | Type | Description |
|---|---|---|
system.cpu_percent | float | Server process CPU usage percentage |
system.memory_rss_bytes | integer | Resident set size in bytes |
system.memory_vss_bytes | integer | Virtual memory size in bytes |
system.grpc_connections | integer | Number of connected agent gRPC streams |
system.command_queue_depth | integer | Number 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
/metricsfor all callers. When--metrics-no-authis 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.