yuzu

Server Administration

This document covers Yuzu server deployment, configuration, and ongoing administration. It is intended for operators who install, configure, and maintain the Yuzu server.


Table of Contents

  1. Server CLI Flags
  2. Configuration Files
  3. First-Run Setup
  4. Settings Page
  5. TLS Configuration
  6. User Management
  7. Agent Enrollment
  8. OTA Agent Updates
  9. RBAC Management
  10. Tag Compliance
  11. OIDC SSO Configuration
  12. SAML 2.0 SP Configuration
  13. Directory Sync (AD/Entra ID)
  14. Data Storage and Encryption
  15. PostgreSQL Substrate
  16. NVD CVE sync
  17. Retention Settings
  18. Settings API Reference
  19. Deployment
  20. Windows Service Installation
  21. Planned Features

Server CLI Flags

The Yuzu server binary accepts the following command-line flags. All flags are optional; defaults are shown in the table.

FlagDefaultDescription
--config(auto)Path to yuzu-server.cfg. If omitted, uses the platform default: /etc/yuzu/yuzu-server.cfg on Linux (and on macOS as root), ~/Library/Application Support/Yuzu/yuzu-server.cfg on macOS as a non-root user, C:\ProgramData\Yuzu\yuzu-server.cfg on Windows.
--data-dir(config dir)Directory for runtime state files (auto-approve rules) and the NVD CVE cache nvd_cves.db, the one remaining server SQLite store, plus the agent-updates/ (unless --update-dir is set) and upload-blobs/ file directories, and the enablement-anchor continuity file (keep it with backups; see Enablement continuity anchor). All other server data is in PostgreSQL — enrollment tokens and pending agents are PostgreSQL-authoritative (WS-6 6.2), not --data-dir files; see the blockquote below. Defaults to the parent directory of --config. Use this in containerized deployments where the config file is on a read-only mount but state files need a writable volume. The path is resolved to its canonical form at startup (symlinks are followed). Env: YUZU_DATA_DIR.
--postgres-dsn-file(none)Read the PostgreSQL connection string from this file instead of --postgres-dsn, which puts it, password included, on the command line where every local user can read it. The whole file is the connection string; trailing whitespace (including the final newline) and a UTF-8 byte-order mark are ignored. Fails at startup if the file is missing, empty or larger than 64 KiB, and cannot be combined with --postgres-dsn / YUZU_POSTGRES_DSN. The file’s own permissions are not checked (a Docker or Kubernetes secret is mounted world-readable inside its container): keep it where only the server’s account can read it. The Windows installer stores it in the locked %ProgramData%\Yuzu Server\postgres.dsn and passes this flag. Env: YUZU_POSTGRES_DSN_FILE.
--web-port8080HTTP listen port for the dashboard and REST API. When HTTPS is on this port is the HTTP→HTTPS redirect listener, which still answers GET/HEAD on the four health-probe paths directly (see Probing a TLS-enabled server).
--web-address127.0.0.1Web UI bind address.
--shutdown-drain-seconds0On SIGTERM, keep serving for at least this many seconds after /readyz turns 503 draining, so a load balancer stops routing here before the listener closes (HA WS-8). Range 0–60; 0 keeps the listener-closes-at-once behaviour a single server has always had. Set it to at least the load balancer’s health-check interval × unhealthy threshold, plus one interval — see “Load balancers and shutdown drain” below. Env: YUZU_SHUTDOWN_DRAIN_SECONDS.
--no-httpsoffDisable HTTPS (insecure, for development only). HTTPS is enabled by default; provide --https-cert and --https-key, or pass --no-https to disable. Env: YUZU_NO_HTTPS.
--no-tlsoffDisable all gRPC TLS (agent listener AND management listener). Plaintext gRPC, no encryption, no peer authentication. The administrative surface is ungated when this flag is passed. Intended for local UAT, customer demos, and development. The server emits a multi-line ERROR-level startup banner and a 5-minute recurring reminder when running in this mode.
--cert(none)Path to PEM-encoded gRPC server certificate for the agent listener (port 50051 by default). Env: YUZU_CERT.
--key(none)Path to PEM-encoded gRPC server private key for the agent listener. The file must not be world-readable (Unix: chmod 600). Env: YUZU_KEY.
--no-default-certsoffDo not auto-generate built-in default certificates on first boot. Restores the legacy refuse-to-start: the server will not start unless --cert/--key (and --https-cert/--https-key when HTTPS is enabled) are supplied. Use where operator- or HSM-provided certs are mandatory policy. (Defaults emit a startup banner, the audit actions server.default_certs_generated + server.default_certs_in_use, and the Prometheus gauge yuzu_server_default_certs_active.) Env: YUZU_NO_DEFAULT_CERTS.
--ca-dir(platform cert dir)Directory for the built-in CA root + default leaf certs (default-ca.pem/.key, default-server.pem, default-https.pem, …). Default: /etc/yuzu/certs (Linux; macOS running as root — matches the packaged-install convention), ~/Library/Application Support/Yuzu/certs (macOS running as a non-root user — e.g. a native scripts/start-UAT.sh dev run, since /etc/yuzu is root-owned and macOS has no packaged server installer), C:\ProgramData\Yuzu\certs (Windows). The CA root key is 0600 — back it up (losing it forces a full fleet re-enrollment). Env: YUZU_CA_DIR.
--cert-san(none)Repeatable. Extra Subject Alternative Name to add to every auto-generated default leaf (dashboard HTTPS, agent/management gRPC, and gateway), on top of the base localhost / 127.0.0.1 / ::1 / <hostname>. Forms: dns:<name>, ip:<addr>, or a bare value (auto-classified as IP vs DNS by shape); a single value may be comma-separated. Use this so the built-in certs validate for a name a client actually dials (for example --cert-san dns:gateway so an agent reaching the gateway by that service name passes TLS hostname verification, or --cert-san dns:yuzu.corp.example --cert-san ip:10.0.0.5 for a load-balancer name / VIP). An ip: value that is not an IP literal is ignored with a warning. Ignored when operator certs are supplied or --no-default-certs is set; changing it does not rotate an existing cert set: the server logs a warning naming the missing SANs. To re-issue the default leaves with them under the same CA, rename default-marker.json in --ca-dir aside (for example to default-marker.json.bak; moving it back undoes this if the next start refuses) and restart the server. With Compose, add YUZU_CERT_SAN to the server environment, run docker compose up -d server so the new environment applies (restart keeps the old one), then rename the marker and run docker compose restart server. Renaming the marker re-mints every default leaf with a NEW key, the gateway’s included: the restarted server auto-pins the new gateway key, and a gateway presents a certificate only when it dials, so one that dialled before the restart is refused (not_pinned) until it redials. The server restart itself drops the connection and forces that redial, and the gateway reads its certificate and key from the configured files when it redials (measured with the gateway’s gun-based upstream client against a local TLS listener on OTP 28: an open connection keeps presenting the certificate it opened with, and a reconnect reads the files; see The upstream client). So first check whether the gateway already presents the new certificate, as in cause class B of the incident runbook in Gateway upstream peer authorization (the denial counters have stopped rising and the certificate file has the new key’s SPKI prefix), allowing about 2 minutes after the files change for a cached copy of the old certificate to expire. Restart that gateway node, one at a time, only if denials continue and only after weighing the agent-side cost: a gateway restart disconnects its agents, and unless the agents run a build with the #5183 fix (shipped in 0.15.0), released agents stay wedged. The server leaf is re-keyed too, so any gateway mgmt_peer_pins entry for the old server key stops matching until you update it. Never clear the whole --ca-dir: it also holds the CA key and the secrets key-encryption key (secrets-kek-*.key); without the KEK the server refuses to start (kek_unresolvable, #5370), and without the CA key it refuses to mint a new CA over the root recorded in Postgres. Env: YUZU_CERT_SAN.
--ca-cert(none)Path to PEM-encoded CA certificate used to verify agent client certificates (full mTLS). Without this, the agent listener has no client-cert verification — --insecure-skip-client-verify plus YUZU_ALLOW_INSECURE_TLS=1 is required to start in that posture. Env: YUZU_CA_CERT.
--insecure-skip-client-verifyoffAllow gRPC TLS without --ca-cert (one-way TLS — server cert is presented but client certs are not verified). Applies to BOTH the agent listener and the management listener. Requires YUZU_ALLOW_INSECURE_TLS=1 in the environment as a second confirmation — the server refuses to start without it. Renamed from --allow-one-way-tls in v0.12.0; the old name is still accepted with a deprecation warning.
--allow-one-way-tlsoff[DEPRECATED] Renamed to --insecure-skip-client-verify. Still accepted for backward compatibility with a startup deprecation warning; will be removed in a future release.
--management-cert(none)Optional PEM cert for the management listener (port 50052 by default). If unset, the management listener reuses the agent listener’s certificate.
--management-key(none)Optional PEM key for the management listener. If --management-cert/--management-key are set without --management-ca-cert, the same --insecure-skip-client-verify + YUZU_ALLOW_INSECURE_TLS=1 gate applies.
--management-ca-cert(none)Optional CA cert for management client cert verification. Without this (and without --insecure-skip-client-verify), the management listener refuses to start.
--gateway-upstream(none)Address (host:port) the gateway-upstream gRPC service listens on, for an Erlang gateway in front of the agents. Empty (the default) leaves the service off. Enabling it requires gateway peer authorization to be resolved at boot: see Gateway upstream peer authorization. Env: YUZU_GATEWAY_UPSTREAM.
--gateway-peer-pin(none)Repeatable. SHA-256 of a gateway certificate’s SubjectPublicKeyInfo, 64 hex characters, that may use the gateway-upstream service. The environment form is comma-separated. A pin identifies a key, so a certificate reissued over the same key keeps matching. Explicit pins replace the automatic default-certificate pin. A value given on the command line makes YUZU_GATEWAY_PEER_PINS ignored entirely (the two are not merged). A supplied but blank value is refused at boot (--gateway-peer-pin "", or YUZU_GATEWAY_PEER_PINS set to a single space or a comma): it is never read as “not supplied”. One exception, a documented limit: a ZERO-LENGTH environment value (YUZU_GATEWAY_PEER_PINS=, which compose templates such as ${YUZU_GATEWAY_PEER_PINS:-} produce) is treated as unset by the command-line library and is not refused; on the generated default certificates the server then pins its own gateway certificate automatically, and on operator certificates it refuses to start with the no-pin message. Compute it with openssl x509 -pubkey -noout -in gw.pem | openssl pkey -pubin -outform DER | openssl dgst -sha256 | awk '{print $NF}' (on Windows, which has no awk, give the certificate to --gateway-peer-pin-file instead; the byte pipeline above is not documented for Windows PowerShell 5.1, whose text pipeline may corrupt the binary DER between the two openssl commands, and a cmd.exe run without the last step prints a label before the 64 digits). Pins are read once at boot; changing them needs a restart. Env: YUZU_GATEWAY_PEER_PINS.
--gateway-peer-pin-file(none)Repeatable. File of gateway pins: one or more PEM CERTIFICATE blocks with nothing else in the file (every block contributes its pin), or hex pins one per line (blank lines and # comment lines ignored). ASCII or UTF-8 only; at most 64 KiB and 32 entries (certificate blocks or hex lines) per file, 64 distinct pins across all sources. The file is read once at boot and never re-read: replace the file and restart the server to change the pins. A file that is missing, unreadable, empty or malformed refuses to start; “could not be read” covers a FIFO, a directory, a device and a permission error, and the message prints no cause. The environment form is a single path, and is ignored entirely when --gateway-peer-pin-file is given on the command line. A zero-length environment value (YUZU_GATEWAY_PEER_PIN_FILE=) is treated as unset, as for --gateway-peer-pin above, and a non-empty blank value is refused. Env: YUZU_GATEWAY_PEER_PIN_FILE.
--insecure-gateway-peeroffRun the gateway-upstream service with peer authorization disabled, on every port the service is reachable on. The only way to use --gateway-upstream with --no-tls, without a client CA, or without a pin on operator certificates. Refused together with any --gateway-peer-pin or --gateway-peer-pin-file. A boot error line, a server.gateway_peer_authz_disabled audit row and yuzu_server_gateway_peer_authz_mode (insecure_ack, or insecure_ack_tls when TLS and a client CA are present) say it is on. Development rigs only. Env: YUZU_INSECURE_GATEWAY_PEER (parsed as a boolean: 1 turns it on, 0 or false leaves it off).
--gateway-command-addr(none)Address (host:port) of the Erlang gateway’s ManagementService, used to forward operator commands to gateway-connected agents. Empty (the default) leaves command forwarding off unless --gateway-cluster-addr is set; with both unset, commands are not forwarded through a gateway. With TLS on, the server builds mutual-TLS credentials for this channel and, if it cannot, logs an error and leaves forwarding off (fail-closed). With --no-tls the channel is plaintext and unauthenticated, and a startup warning says so; keep it on a trusted network. When --gateway-cluster-addr is also given, this address is the default used for any cluster without its own entry. Env: YUZU_GATEWAY_COMMAND_ADDR.
--trusted-nat-cidr(none)Comma-separated (or repeatable) CIDR ranges (IPv4 or IPv6) declaring a trusted NAT boundary for direct-connect agents. When an agent’s Register and Subscribe source IPs both fall within one declared range, a per-session peer-IP mismatch is downgraded from a hard reject to an advisory (audit result="ok" outcome=advisory; counted on yuzu_grpc_subscribe_peer_advisory_total) instead of rejecting the stream. Strict exact-match is the default when absent; mismatches outside every declared range still reject (the stolen-session guard stays intact). Use for fleets behind multi-egress NAT, proxy pools, CG-NAT, or SD-WAN where an agent may egress from different public IPs on its two connections. Security note: declaring a range asserts the hosts in it are mutually trusted not to replay each other’s sessions; keep ranges as narrow as possible (never 0.0.0.0/0). Malformed entries are logged and ignored at startup. Env: YUZU_TRUSTED_NAT_CIDR.
--nat-trust-mtls-identityoffAlso downgrade a peer-IP mismatch to advisory when the Subscribe mTLS client identity matches the identity bound at Register (#1128). SAFE ONLY WITH PER-AGENT CLIENT CERTIFICATES. With a shared/fleet-wide client cert every identity “matches”, turning this into a session-replay bypass (an insider agent could hijack another agent’s session from its own IP). Off by default; enable only if each agent presents a unique client certificate. When both --nat-trust-mtls-identity and --trusted-nat-cidr are configured, mTLS-identity match takes precedence: a session whose mTLS identity matches records reason=mtls_identity_match (visible on the audit detail and the yuzu_grpc_subscribe_peer_advisory_total{reason=...} label), and CIDR containment is not consulted for that session. Enabling the flag emits a warn-level startup line — confirm it appears in the boot log so the operator who pulled the lever can sign off on the per-agent-cert posture. Env: YUZU_NAT_TRUST_MTLS_IDENTITY.
--https-port8443HTTPS listen port.
--https-cert(auto)Path to PEM-encoded TLS certificate for the dashboard. A per-install default cert is auto-generated when omitted (unless --no-default-certs); --no-https disables HTTPS entirely.
--https-key(auto)Path to PEM-encoded TLS private key. Auto-generated default used when omitted (unless --no-default-certs). The file must not be world-readable (Unix: chmod 600).
--no-https-redirectoffWhen HTTPS is enabled, do not start the HTTP listener at all (no redirect, and no plain-HTTP health probes). By default, HTTP requests are redirected, except GET/HEAD on /livez, /readyz, /health and /api/health, which are served directly.
--healthcheckoffProbe this server’s own health endpoint and exit 0 (HTTP 200) or non-zero, without starting the server. Uses the same listener flags and environment as the server (--no-https, --https-port, --web-port, --web-address, --https-cert, --ca-dir). Has no environment variable and never reads yuzu-server.cfg. See Probing a TLS-enabled server.
--healthcheck-path/readyzWith --healthcheck: the path to probe, /readyz or /livez.
--no-cert-reloadoffDisable automatic certificate hot-reload. By default, the server polls cert/key files and hot-swaps the SSL context when they change. Env: YUZU_NO_CERT_RELOAD.
--cert-reload-interval60Certificate reload polling interval in seconds. Minimum effective interval is 10 seconds. Env: YUZU_CERT_RELOAD_INTERVAL.
--metrics-no-authoffAllow unauthenticated /metrics access from any IP. By default, remote clients must authenticate; localhost access is always unauthenticated. Warning: enabling this exposes fleet composition data (OS, architecture, version counts) — and, when the cohort metrics export is enabled, operator tag values as cohort labels — to any network client. See Metrics Security. Env: YUZU_METRICS_NO_AUTH.
--csp-extra-sources(none)Extra Content-Security-Policy source-list entries appended to script-src, style-src, connect-src, and img-src. Space-separated string of host/scheme expressions or whitelisted CSP keywords ('self', 'none', 'sha256-...', 'sha384-...', 'sha512-...', 'nonce-...'). The server refuses to start if the value contains control bytes, semicolons, commas, or unsafe CSP keywords like 'unsafe-eval'. Use to whitelist customer CDNs, monitoring beacons, or analytics endpoints. See HTTP Security Response Headers. Env: YUZU_CSP_EXTRA_SOURCES.
--oidc-issuer(none)OIDC identity provider issuer URL, exactly as the discovery document states it in issuer (e.g., Entra ID: https://login.microsoftonline.com/<tenant-guid>/v2.0). Env: YUZU_OIDC_ISSUER.
--oidc-client-id(none)OIDC application (client) ID. Env: YUZU_OIDC_CLIENT_ID.
--oidc-client-secret(none)OIDC client secret. Env: YUZU_OIDC_CLIENT_SECRET.
--oidc-client-secret-file(none)Read the OIDC client secret from this file instead of --oidc-client-secret, keeping it off the command line. Same rules as --postgres-dsn-file; cannot be combined with --oidc-client-secret / YUZU_OIDC_CLIENT_SECRET. Env: YUZU_OIDC_CLIENT_SECRET_FILE.
--oidc-redirect-uri(auto)OIDC redirect URI. If omitted, it defaults to a localhost URL derived from the listen port (http(s)://localhost:<port>/auth/callback); set it explicitly for any real IdP. Must match the registered redirect in your identity provider. Env: YUZU_OIDC_REDIRECT_URI.
--oidc-admin-group(none)The value of the entry in the token’s groups claim that maps to the admin role (the group object ID for Entra ID; the group name for Keycloak with Full group path off). Users in this group are granted admin access on OIDC login. Env: YUZU_OIDC_ADMIN_GROUP. (Value is trimmed automatically, same as --saml-admin-group — #1830.)
--oidc-skip-tls-verifyoffDisable TLS certificate verification for OIDC discovery, JWKS and (Linux/macOS) token requests; the Windows token request ignores it. Insecure — dev only. Env: YUZU_OIDC_SKIP_TLS_VERIFY.
--oidc-allow-private-networksoffLet OIDC discovery, JWKS and token requests connect to loopback, RFC 1918, carrier-grade NAT (100.64.0.0/10) and IPv6 unique-local addresses; required for an IdP on an internal network. Link-local and cloud-metadata addresses are refused either way. Start-up option only: the dashboard and REST API cannot change it. See OIDC egress policy. Env: YUZU_OIDC_ALLOW_PRIVATE_NETWORKS.
--oidc-nat64-prefixnoneComma-separated NAT64 prefixes (RFC 6052: /32, /40, /48, /56, /64, /96) in use on this network; an IPv6 address inside one is classified by the IPv4 address it embeds, so a translator cannot be used to reach a private or link-local destination (the prefix is trusted to describe a translator, so it may permit a destination its own address class would gate). Prefixes the network’s DNS64 announces for ipv4only.arpa (RFC 7050) are learned without it, only ever added (kept until restart, at most 64; an answer past that refuses the fetch). At most 16 entries; an invalid entry is a startup error. Start-up option only: the dashboard and REST API cannot change it. See OIDC egress policy. Env: YUZU_OIDC_NAT64_PREFIXES. Operator warning: a configured prefix asserts that a NAT64 translator lives there and can permit HTTPS connections to ULA addresses even when --oidc-allow-private-networks is off; declare only actual translation prefixes (native IPv6 services inside a declared prefix get the same embedded-IPv4 classification; always-refused addresses remain blocked).
--oidc-scim-link-claimsubADR-2001 (SCIM↔OIDC identity linkage, CC6.8). Which validated ID-token claim is compared against a SCIM resource’s externalId to form the durable link a SCIM deprovision revokes API/MCP tokens across. Only sub and oid are accepted — the server refuses to start on any other value. Okta: leave as the default sub. Microsoft Entra ID: set oid — Entra’s SCIM externalId rides the ID token as the oid claim, not sub. See docs/user-manual/scim-provisioning.md “SCIM ↔ OIDC identity linkage”. Env: YUZU_OIDC_SCIM_LINK_CLAIM.
--saml-idp-entity-id(none)SAML 2.0 SP. Entity ID URI of the IdP (must match what the IdP uses in its assertions). Required and validated at startup — omitting it (along with the other four --saml-* flags) leaves SAML disabled. Env: YUZU_SAML_IDP_ENTITY_ID.
--saml-idp-sso-url(none)SAML 2.0 SP. IdP’s HTTP-Redirect SSO endpoint URL. Env: YUZU_SAML_IDP_SSO_URL.
--saml-idp-cert(none)SAML 2.0 SP. Filesystem path to the IdP’s assertion-signing certificate (PEM, max 64 KiB). The cert at this path is the sole trusted signing authority — in-document <KeyInfo> values are ignored. Env: YUZU_SAML_IDP_CERT.
--saml-sp-entity-id(none)SAML 2.0 SP. Entity ID URI this SP advertises to the IdP in the AuthnRequest. Env: YUZU_SAML_SP_ENTITY_ID.
--saml-sp-acs-url(none)SAML 2.0 SP. Full public URL of the Assertion Consumer Service (https://<host>/saml/acs). The IdP must be configured to POST the response to this URL. Env: YUZU_SAML_SP_ACS_URL.
--saml-sp-key(none)SAML 2.0 SP. Filesystem path to the SP AuthnRequest signing private key (PEM, RSA only — EC and RSA-PSS keys are rejected). Optional and independent of the five required --saml-* flags above. When set, AuthnRequests are signed over the HTTP-Redirect binding with RSA PKCS#1 v1.5 + SHA-256. The key file must pass the same private-key permission check as the HTTPS/gateway TLS keys (not group/other-readable). Fails closed: an unreadable, over-permissioned, oversized (>64 KiB), malformed, or non-RSA key disables SAML entirely at startup — never a silent fall-back to unsigned requests. When unset (the default), AuthnRequests remain unsigned — backward-compatible with IdPs that accept unsigned requests. Env: YUZU_SAML_SP_KEY.
--mcp-disableoffDisable the MCP (Model Context Protocol) endpoint entirely. When set, all requests to /mcp/v1/ are rejected with a JSON-RPC error. Use this in air-gapped or high-security environments where AI integration is not desired. Env: YUZU_MCP_DISABLE.
--mcp-read-onlyoffRestrict MCP to read-only tools only. Write and execute operations (Phase 2) are rejected even if the MCP token’s tier would normally allow them. Env: YUZU_MCP_READ_ONLY.
--mcp-no-streamingoffDisable the MCP Streamable HTTP transport (ADR-1005 Decision 15): no Mcp-Session-Id minting, GET/DELETE /mcp/v1/ return 405, and only plain JSON-RPC POST is served. The spec-required 202 status on notification POSTs still applies. Use where a buffering reverse proxy interferes with streaming. Env: YUZU_MCP_NO_STREAMING.
--mcp-enable-streamed-post / --no-mcp-streamed-postonEnable SSE-on-POST (streamed POST) for execute_instruction callers that supply a progressToken. Ships ON — the transport machinery is complete and reviewed, and the four defects that previously gated the on-by-default flip are fixed: #2739 (the 120 s response cap is now enforced on a busy execution — after it expires the bridge delivers one final drain of already-latched progress and then settles, bounding the response at the cap plus at most two ~3 s pump ticks plus one progress drain), #2740 (a committed-but-undelivered final no longer locks a session out of streaming — admission reclaims the slot and audits it), #2785 (streamed-POST frames now carry the replay-ring event id, so a POST-only client can build a Last-Event-ID resume cursor) and #2789 (end-to-end coverage of the per-principal admission reject). Pass --no-mcp-streamed-post to opt out. Distinct from --mcp-no-streaming, which disables the whole transport including the session lifecycle and GET channel.
--mcp-allowed-origin(none)Repeatable. An allowed Origin header value (scheme://host:port, exact match) for /mcp/v1/ DNS-rebinding defence. An absent Origin is always allowed (the endpoint requires a credential); an empty allowlist rejects any present Origin (secure default) — browser-based MCP clients must be listed explicitly, non-browser clients need no configuration. Env: YUZU_MCP_ALLOWED_ORIGINS.
--max-sse-streams128Concurrent held-open SSE responses this server is sized for, across EVERY streaming surface — GET /mcp/v1/, MCP streamed POST, GET /api/v1/events, the dashboard executions drawer, and the legacy /events stream. The HTTP worker pool is derived from this number: cpp-httplib is thread-per-connection, so each held-open response pins one worker for its whole life. That thread burns no CPU, and its resident cost is a fraction of a stack reservation that is virtual and platform-dependent (8 MB on Linux/glibc, 1 MB on Windows, 512 KB for macOS secondary threads). The resident fraction itself is not yet measured on our platforms (ADR-0034), so treat the default as a starting point rather than a sizing guarantee until a per-platform baseline exists. Utilisation is yuzu_http_held_open_responses / yuzu_http_held_open_capacity. The ceiling is thread-count; see ADR-0034. Env: YUZU_MAX_SSE_STREAMS.
--mcp-max-streams-per-principal4Max concurrent MCP SSE streams for one principal. An anti-monopoly policy, not a capacity limit — capacity is --max-sse-streams. Stops a single agentic token taking the channel; does not ration the fleet. Env: YUZU_MCP_MAX_STREAMS_PER_PRINCIPAL.
--http-worker-threads0 (derive)Pin the shared HTTP worker pool by hand. 0 derives it from --max-sse-streams, which is what you want. If you set it, the stream target is clamped to what your pool can actually carry (the startup log reports the effective figure). Env: YUZU_HTTP_WORKER_THREADS.
--viz-disableoffDisable the fleet visualization feature. When set, the REST endpoints (GET /api/v1/viz/fleet/topology, GET /fragments/viz/fleet/topology, and the per-host drill-down routes) and the page shells (GET /viz/fleet, GET /viz/host/<id>) all return 503. Tier-before-permission ordering: the kill switch takes effect even for callers who would otherwise fail RBAC. Two pieces of durable evidence that the switch took effect: the startup log line [VIZ] viz endpoint disabled by configuration, and a server.viz_disabled audit event (target_type = FleetTopology) written to the audit store at boot — so an auditor can confirm the disabled state from the audit trail even on a deployment with no viz traffic. Env: YUZU_VIZ_DISABLE.
--allow-unsigned-packsoffDangerous. Accept product packs at install without an Ed25519 signature. Default is to reject unsigned packs with pack '<name>' is unsigned and signature enforcement is enabled (set --allow-unsigned-packs / YUZU_ALLOW_UNSIGNED_PACKS=1 to bypass) (security-by-default since #802 / W7.4). Setting this flag restores the pre-W7.4 behaviour where any operator with pack-upload permission, or a MITM on pack delivery, could install a pack containing arbitrary InstructionDefinition or plugin payloads that would execute fleet-wide. Two pieces of durable evidence that the flag is active: a startup log line [SECURITY] product pack signature enforcement DISABLED by configuration, and a server.unsigned_packs_allowed audit event (target_type = ProductPack) written to the audit store at boot. Use only as a temporary migration aid; sign your packs and remove the flag as soon as feasible. Env: YUZU_ALLOW_UNSIGNED_PACKS.
--allow-unsigned-definitionsoffDangerous. Accept InstructionDefinition imports via POST /api/v1/instructions/import without an Ed25519 signature. Default is to reject unsigned imports with instruction-import is unsigned and signature enforcement is enabled (set --allow-unsigned-definitions / YUZU_ALLOW_UNSIGNED_DEFINITIONS=1 to bypass) (security-by-default since #1073 / W7.4 sibling-gap closure). Closes the equivalent fleet-RCE surface that --allow-unsigned-packs covers on the ProductPack side: without enforcement, any operator with InstructionDefinition:Write (or a MITM on a content sync) can publish a definition that dispatches a malicious plugin invocation on every targeted agent. Durable evidence: startup log line [SECURITY] instruction-definition signature enforcement DISABLED by configuration AND a server.unsigned_definitions_allowed audit event (target_type = InstructionDefinition). Env: YUZU_ALLOW_UNSIGNED_DEFINITIONS.
--mfa-enforcementoptionalMFA enforcement mode: optional (login never requires it; an administrator can enrol its own account in Settings, but Settings enrolment is administrator-only, so a non-admin cannot enrol itself, see Enrolling a non-admin approver), admin-only (an admin without MFA must enroll before login completes), or required (every role must enroll). Under admin-only/required an un-enrolled login is redirected through TOTP enrolment (POST /login/mfa/enroll) before a session is minted; the server logs an INFO line naming the active mode at startup. Breaking: earlier releases accepted admin-only/required as no-ops — if you staged the flag, read docs/user-manual/upgrading.md before upgrading (live enforcement begins immediately, and SSO users require an IdP that asserts amr). See docs/user-manual/authentication.md § Multi-Factor Authentication and docs/auth-mfa-design.md. Env: YUZU_MFA_ENFORCEMENT.
--mfa-step-up-window-secs300Seconds after a successful TOTP proof during which the 37 high-risk REST, Settings and Guardian dashboard routes enforced by the central step-up check (PR2 of the MFA ladder, since extended; see Authentication) accept the session as “stepped up” without re-prompting. Set to 0 (or less) to disable the stale-proof requirement on those routes (SAML sessions then pass too, and the authentication store is not read); the JIT-elevation routes keep a 300 s window, and engine denials and a server with no wired authentication store (503) still apply and are recorded (emits a startup WARN). Approval approve/reject are among those routes but are not switched off by 0: they always require an enrolled MFA factor and a proof made within min(this value, 300) seconds (300 when this is 0 or less), and are never admitted for a SAML session (#6079; see authentication.md, “Approver MFA requirement”). Decisions recorded before that change under 0 carry only the interactive-session assurance; review every approval.approve audit row with result=success from that period, and check every attempted row that has no outcome row, comparing counts per target_id (each decision also writes a result=attempted row first, and a decision whose outcome row failed to write leaves only that row; see audit-log.md). Env: YUZU_MFA_STEP_UP_WINDOW_SECS.
--mfa-login-pending-secs120Lifetime of the intermediate mfa_pending_token between password success and TOTP submission. The pending state is per-process (lost on restart, not shared across HA replicas without sticky sessions). Env: YUZU_MFA_LOGIN_PENDING_SECS.
--mfa-reset <username>(none)Break-glass. Clears the named user’s MFA enrolment and exits without starting the server - the recovery path from MFA-enforcement lockout. Also deletes the account’s durable sessions, so every session of the account ends on every replica (API tokens are untouched); if that fails the command exits non-zero with “MFA cleared but the account’s sessions were not ended” and the operator must run DELETE /api/v1/sessions?username=<name> by hand. Writes two mfa.reset.breakglass audit rows: a result=attempted intent row BEFORE it clears anything (if the audit store does not accept it the command exits 1 and nothing is changed), then an outcome row (principal = the OS account that ran the CLI; the outcome detail ends sessions_ended=<n>) with result=success, or sessions_ended=failed) with result=partial; result=error means the clear itself failed and its state is unknown). Requires the Postgres auth store (--postgres-dsn / YUZU_POSTGRES_DSN), and the same --config the service uses if it is not at the platform default (/etc/yuzu/yuzu-server.cfg on Linux and root macOS; C:\ProgramData\Yuzu\yuzu-server.cfg on Windows) - the container images run with --config /var/lib/yuzu/yuzu-server.cfg, and without it the binary falls into interactive first-run setup and exits. No TLS flags needed. See docs/ops-runbooks/auth-db-recovery.md § Emergency MFA disable.
--generate-tokens <N>(none)Mint N enrollment tokens directly into the same PostgreSQL auth.enrollment_tokens store the running server reads (WS-6 6.2), and exit without starting the server — the recovery/scripting equivalent of the dashboard “Generate Token” button. Requires the Postgres auth store (--postgres-dsn / YUZU_POSTGRES_DSN); refuses with the same style of message as --mfa-reset when it is absent or unreachable. Tokens are attributed created_by = "cli:<OS account that ran the CLI>" (kernel-authoritative identity, getpwuid(geteuid())/GetUserNameA, not getenv("USER") — the same anti-forgery rule the break-glass audit rows use), so a dashboard operator reviewing the token list can tell a CLI-minted batch apart from one minted through POST /api/settings/enrollment-tokens. Companions: --token-label (label prefix, default batch-<n>), --token-max-uses (default 1; 0 = unlimited), --token-ttl-hours (default 72; 0 = never expires); a negative value of either is refused with a usage error and nothing is minted. The mint is audited as enrollment.bulk_token_create (by, max_uses, ttl_s, label, count, ids). Prints {"count":N,"tokens":[...]} to stdout (each raw token shown once — capture it now) and exits non-zero if any token fails to persist, naming how many of the batch minted before the failure (those remain valid; revoke via the dashboard). Does not run the one-time legacy .cfg import (that only ever runs from the server’s own boot path, immediately after the auth store is wired — never from a CLI one-shot).
--auth-lockout-threshold5Consecutive failed local-password login attempts before an account is temporarily locked (SOC 2 CC6.3). A locked account returns the same generic 401 as a bad password — no enumeration/lock-state oracle. Counter resets on a successful login, an admin unlock (POST /api/v1/users/{name}/unlock), or a successful password change/reset (POST /api/v1/users/me/password, POST /api/v1/users/{name}/password). A wrong current password on the self-service change route counts as a failed attempt. Scope is local-password only — OIDC/SSO sessions and API tokens are unaffected. Setting 0 disables lockout (startup WARN) and constitutes a deviation from the CC6.3 hardened baseline — record it as a documented exception on your risk register, do not just flip it. NIST 800-63B §5.2.2 suggests allowing ≥10 attempts where network-layer rate-limiting is also present; raise the threshold accordingly if you front Yuzu with an IP throttle. Env: YUZU_AUTH_LOCKOUT_THRESHOLD.
--auth-lockout-window-secs900How long an account stays locked after the threshold is crossed. The lock auto-expires after this window — it is never permanent, so it cannot be weaponised to permanently deny a legitimate principal; a waited-out user regains a full attempt budget. Env: YUZU_AUTH_LOCKOUT_WINDOW_SECS.
--jit-max-elevation-secs3600JIT admin elevation maximum window (SOC 2 CC6.3/CC6.6). Caps the lifetime of a time-boxed admin elevation activated via POST /api/v1/elevate; a request asking for longer is clamped. Range 1–86400 (24h). Eligibility is the per-user users.elevation_eligible flag (admin-set via POST /api/v1/users/<name>/elevation-eligibility), elevation requires a fresh MFA step-up, and for Postgres-backed deployments the grant is durably persisted to the cookie session’s SessionStore row (HA WS-1/1a, ADR-2002 §4), so it survives a restart — bounded by this 24h ceiling and the session’s own absolute expiry, and auto-reverting on lapse, logout, or explicit revoke (config-file-only deployments keep the old in-memory-per-session behavior a restart drops). API/MCP tokens can never be elevated. Env: YUZU_JIT_MAX_ELEVATION_SECS.
--jit-oidc-amr-elevation / --no-jit-oidc-amr-elevationtrue (enabled)Whether an OIDC session whose IdP login attested MFA (the amr claim, seeding Session::mfa_verified_at at /auth/callback) can satisfy POST /api/v1/elevate’s mandatory second-factor requirement without local TOTP enrollment. An OIDC session never consults a local namesake account’s TOTP enrollment — a single-factor (no-amr) OIDC session is always denied regardless of this flag. Pass --no-jit-oidc-amr-elevation to disable JIT elevation for OIDC sessions entirely — an OIDC session cannot present a local TOTP step-up (its step-up challenge is re-authenticating via SSO, not a TOTP code), so with the flag off an operator must switch to a local-authenticated session with local TOTP to elevate. A one-time INFO log line is emitted at boot when OIDC is configured and this flag is on. Env: YUZU_JIT_OIDC_AMR_ELEVATION.
--session-inactivity-secs0Idle (inactivity) session timeout (SOC 2 CC6.3). Seconds of inactivity after which an operator dashboard cookie session is invalidated server-side — a sliding window that resets on each authenticated request, under the absolute 8-hour session lifetime. 0 (default) disables it (only the absolute lifetime applies — existing deployments are unaffected); a recommended hardened value is 900 (15 min). Scope is cookie sessions only: API tokens and MCP tokens are never idle-timed-out (long-lived automation is unaffected); OIDC users simply re-authenticate via SSO. The active window is logged once at boot for evidence; a value ≥ the absolute 8-hour session lifetime (28800s) is accepted but elicits a startup WARN (the idle window can never fire before absolute expiry). Env: YUZU_SESSION_INACTIVITY_SECS.
--auth-modestandardLocal-password login policy (SOC 2 CC6.3). standard = password login enabled. sso-only = local-password login is disabled fleet-wide — only an SSO provider mints a session — so the server refuses to start unless OIDC (--oidc-issuer + --oidc-client-id) or, on Linux/macOS with HTTPS enabled, a complete SAML SP config is present. A rejected local login returns the same generic 401 as a bad password (no oracle) and is counted via the metric yuzu_auth_local_disabled_total (metric, not a per-attempt audit row — avoids audit-flood under credential spray). A single --break-glass-user is exempt while armed. Env: YUZU_AUTH_MODE.
--break-glass-user <username>(none)The single local account exempt from --auth-mode=sso-only, exempt only while armed (see --break-glass-arm). Under sso-only the server refuses to start unless this account exists and has MFA enrolled (a break-glass account must carry a second factor). A break-glass login is forced through MFA regardless of --mfa-enforcement and writes an auth.breakglass.login audit row. Env: YUZU_BREAK_GLASS_USER.
--break-glass-window-secs86400Seconds the break-glass account stays armed after --break-glass-arm (default 24h). The arm auto-expires (evaluated lazily at login like the lockout window) — it is never a permanent standing exemption. Env: YUZU_BREAK_GLASS_WINDOW_SECS.
--break-glass-armoffBreak-glass. Arms --break-glass-user for the configured window and exits without starting the server — the recovery path when the IdP is down under --auth-mode=sso-only. Run on the server host as the service account (arming deliberately does not require a session). Validates the account (exists + MFA), verifies the audit store is writable before arming, and writes an auth.breakglass.armed audit row (principal = the OS account that ran the CLI). Requires --break-glass-user + the Postgres auth store (--postgres-dsn / YUZU_POSTGRES_DSN), and the same --config caveat as --mfa-reset above. Refuses (exit non-zero) if any check fails.
--principal-max-concurrency16Engine principals (ADR-1005 class, PR 4.4). Maximum in-flight requests for a single engine principal at any instant, checked at the server’s single pre-routing chokepoint on both REST and MCP. A streaming/SSE request holds its slot for the stream’s lifetime, not just until routing hands off. Exceeding it returns HTTP 429. Human, device-agent, and anonymous traffic is never gated by this cap. See docs/user-manual/engine-principals.md “Per-principal quota cap” for tuning guidance. Env: YUZU_PRINCIPAL_MAX_CONCURRENCY.
--principal-rate-limit20.0Engine principals (ADR-1005 class, PR 4.4). Sustained request rate cap (requests/second, token bucket, burst = 2x the configured rate) for a single engine principal. Exceeding it returns HTTP 429. Independent of --principal-max-concurrency — either dimension alone can reject a request. See docs/user-manual/engine-principals.md “Per-principal quota cap” for tuning guidance. Env: YUZU_PRINCIPAL_RATE_LIMIT.
--rate-limit100Maximum API requests per second per source address (token bucket, burst = the same value). Values below 1 are treated as 1, so the limit cannot be turned off. It is also the per-source budget for /health and /api/health requests that carry a Cookie, Authorization or X-Yuzu-Token header (requests with none of those are not counted); see Health Endpoints. A request over the budget gets 429 with Retry-After: 1. Env: YUZU_RATE_LIMIT.
--ota-max-concurrent-per-peer2Agent OTA pulls (#913). Maximum parallel DownloadUpdate streams a single peer may hold. This is the PRIMARY bound on the OTA path: the attack it closes is one authenticated agent opening many concurrent streams, each pinning a gRPC thread on blocking disk and network I/O. Exceeding it returns gRPC RESOURCE_EXHAUSTED (rejected, never queued). Admission keys on the peer’s certificate identity, falling back to peer IP when no client certificate is presented. Env: YUZU_OTA_MAX_CONCURRENT_PER_PEER.
--ota-rate-capacity20.0Agent OTA pulls (#913). Token-bucket burst per peer, a secondary bound that exists only to stop a fast open/close hammer loop. Deliberately loose: a bucket meters attempts, so tightening it punishes retries and can lock out honest slow-link or flapping agents rather than the attacker (this is the failure mode recorded on #934 and #941). Tune --ota-max-concurrent-per-peer first. Env: YUZU_OTA_RATE_CAPACITY.
--ota-rate-refill-per-min1.0Agent OTA pulls (#913). Tokens restored per minute per peer. At the defaults a peer that reconnects five times spends five tokens and recovers them in five minutes. A transfer that trips a server-imposed deadline refunds its token, so failures the server caused never count against the peer. Env: YUZU_OTA_RATE_REFILL_PER_MIN.
--ota-transfer-deadline-secs900Agent OTA pulls (#911). Whole-transfer bound, and the knob that binds on a slow link. Enforced by cancelling the RPC from a watchdog thread — the only thing that unblocks a synchronous ServerWriter::Write stalled on a peer whose HTTP/2 receive window has collapsed to zero. Keepalive does NOT catch that case: such a peer keeps answering pings while the stream is stalled. Tripping it returns DEADLINE_EXCEEDED and refunds the peer’s rate token. It implies a throughput floor: the default 900 s over a 100 MiB binary is ~0.93 Mbit/s sustained. A link below artifact_size / deadline can never complete an update — there is no resume, so each attempt restarts from offset 0 — and the only signal is yuzu_ota_download_deadline_exceeded_total{phase="transfer"} (watch it as a proportion of {decision="admitted"}). Raise it for satellite, residential 4G/LTE and congested-WAN fleets. Env: YUZU_OTA_TRANSFER_DEADLINE_SECS.
--ota-chunk-write-deadline-secs30Agent OTA pulls (#911). Per-chunk stall bound, catching a peer whose individual writes hang. Note this is rarely the binding constraint: 64 KiB / 30 s is 2.2 KiB/s, and a peer that slow blows the whole-transfer budget long before 30 chunks. For slow links tune --ota-transfer-deadline-secs, not this — see its row. Env: YUZU_OTA_CHUNK_WRITE_DEADLINE_SECS.
--ota-max-peers-tracked50000Agent OTA pulls (#935). Cardinality ceiling on the per-peer admission map. Load-bearing because the admission key falls back to peer IP when a peer presents no client certificate, which makes the key space attacker-influenced: a NAT’d peer rotating source ports would otherwise grow the map without bound. At the ceiling a new peer evicts the least-recently-seen entry that holds no in-flight transfer. Shared-bucket caveat: where peers present no client certificate, admission keys on source IP, so every agent behind one NAT egress shares a single bucket and therefore a single --ota-max-concurrent-per-peer allowance — on a large certless fleet behind one egress this can throttle legitimate updates. Enrolling agents with client certificates moves each onto its own bucket; watch yuzu_ota_admission_key_mode_total{mode="peer_ip"} to see how much of the fleet is affected. Floored at 1024: a value below that is raised at startup, and every surface (the capacity gauge, the settings page, the alerts that divide by it) then reports 1024, not what you set — a map smaller than that cannot usefully bound anything and would make eviction, not the rate bucket, the operative limit. Env: YUZU_OTA_MAX_PEERS_TRACKED.
--ota-max-concurrent-total64Agent OTA pulls (#913). Server-wide ceiling on concurrent transfers across ALL peers. The per-peer cap bounds one identity; where the identity gate is inert the admission key falls back to source IP, so that bound scales with a caller’s address space (a /24 buys 256 independent per-peer budgets). This is the bound that does not. Exceeding it returns RESOURCE_EXHAUSTED with a message naming the server capacity, distinct from the per-peer message. Env: YUZU_OTA_MAX_CONCURRENT_TOTAL.
--ota-cert-reserve-pct50Agent OTA pulls (#913). Percent of --ota-max-concurrent-total reserved for peers admitted on a certificate identity. IP-keyed peers may occupy at most the remainder. Without it the server-wide ceiling is a single shared resource, and on any deployment where the identity gate is inert an unauthenticated caller commanding a range of addresses can hold it entirely, locking the enrolled fleet out of updates — the per-peer cap does not help, because each address is its own peer. The reserve arithmetic is floored at one slot for non-zero ceilings, so a small cap cannot round the IP-keyed share to zero; a ceiling of 0 still admits nobody. Set 0 to disable the split, 100 to admit only certificate-keyed peers. Watch yuzu_ota_download_admission_total{decision="rejected_total"} and the rejection log’s cert_keyed= field. Env: YUZU_OTA_CERT_RESERVE_PCT.
--grpc-max-threads8192gRPC server bound (#913). Thread ceiling for the gRPC sync server, applied via ResourceQuota::SetMaxThreads. Without it the per-connection stream cap below bounds nothing globally — connections are uncapped, so N connections yield N x cap concurrent handlers. This is also what bounds the OTA admission map’s overshoot. THIS IS A FLEET-SIZE CEILING, not a tuning dial. AgentService is synchronous and Subscribe holds one thread for the entire life of each connected agent’s command stream, so this value MUST exceed your concurrently-connected agent count with headroom. Set below it, gRPC answers RESOURCE_EXHAUSTED to every RPC on every service sharing the quota — a fleet-wide outage, not back-pressure. Size it as expected_agents x 1.5, and raise it before a fleet grows into it. Env: YUZU_GRPC_MAX_THREADS.
--grpc-max-concurrent-streams128gRPC server bound (#913). Maximum concurrent HTTP/2 streams per gRPC connection. Before this setting the server’s one ServerBuilder carried keepalive/ping arguments and nothing else — no stream cap and no ResourceQuota existed anywhere — which is what made an unbounded per-peer OTA path a capacity-monopolisation issue rather than a theoretical one. Env: YUZU_GRPC_MAX_CONCURRENT_STREAMS.
--grpc-max-resource-memory-mb512gRPC server bound (#913). ResourceQuota memory ceiling in MiB for the gRPC server. At capacity gRPC rejects rather than queueing. Sized for a typical fleet server; raise it on large deployments if you observe rejections that do not correlate with an actual attack. Env: YUZU_GRPC_MAX_RESOURCE_MEMORY_MB.
--guardian-reconcile-budget-ms-per-s500Guardian heartbeat reconcile admission (#5513). Milliseconds of measured reconcile work this replica admits per wall-clock second: a leaky bucket debited by the wall time each admitted push actually takes, so it admits few pushes per second while a push is expensive and more when it is cheap, with no retuning. Must be greater than 0 (zero, negative and non-finite values are refused at startup); a positive value below 1 is raised to 1, and a value above 1000000 (1,000 s of work per second) is lowered to it, with a startup warning naming the flag. A reconcile the replica declines to admit is shed: nothing is sent, no audit row is written, and the agent’s 25 s per-agent slot is not consumed, so the agent simply reports the stale generation again on its next heartbeat (yuzu_server_guardian_reconciles_total{result="shed"}). Per replica: each server enforces its own budget, so the store load this puts on Postgres is the budget times the number of replicas. The default is a provisional starting value, not derived from fleet measurement. See Sizing the Guardian reconcile budget. Env: YUZU_GUARDIAN_RECONCILE_BUDGET_MS_PER_S.
--guardian-reconcile-burst-ms1000Guardian heartbeat reconcile admission (#5513). Capacity of the bucket above in reconcile-milliseconds; the bucket starts full, so a replica can absorb that much work in a burst before the per-second budget governs. Must be greater than 0 (zero, negative and non-finite values are refused at startup); a positive value below 1 is raised to 1, and a value above 3600000 (one hour) is lowered to it, with a startup warning naming the flag. Per replica. Provisional default, not derived from fleet measurement. Env: YUZU_GUARDIAN_RECONCILE_BURST_MS.
--guardian-reconcile-max-per-s1000Guardian heartbeat reconcile admission (#5513). Hard ceiling on reconcile pushes admitted per one-second window, applied even while the bucket is full (it bounds the burst before any push has finished and been debited). Counted in one-second tumbling windows. Must be greater than 0 (zero and negative values are refused at startup); a value above 1000000 is lowered to it, with a startup warning naming the flag. Per replica. Provisional default, not derived from fleet measurement. Env: YUZU_GUARDIAN_RECONCILE_MAX_PER_S.
--max-agents10000Deprecated, no effect. Never enforced; accepted for compatibility and will be removed. Setting it (or the env var) logs one startup warning. There is no replacement: the server does not cap how many agents connect; spread a large fleet across gateways. Do not use --grpc-max-threads to limit agents: it is a fleet-size ceiling that must stay above the connected-agent count (size it at about 1.5x that count); set below it, every RPC fails. Env: YUZU_MAX_AGENTS.
--log-levelinfoLogging verbosity: trace, debug, info, warn, error. Applies to every log sink (the console and the --log-file file). Read once at startup: a log_level stored through runtime configuration is re-applied at boot and overrides it, and the same key changes the level without a restart; level names are lowercase and case sensitive, and an unrecognised value given to this flag (including WARN) is treated as off. The Upgrade note “new Guardian T_* diagnostic log lines” lists the restart-free levers and what warn also silences (including authentication and session lines). Env: YUZU_LOG_LEVEL.
--log-max-size52428800Size in bytes at which the --log-file file rotates (50 MB). Ignored without --log-file. Env: YUZU_LOG_MAX_SIZE.
--log-max-files5Number of rotated --log-file files kept. Ignored without --log-file. Env: YUZU_LOG_MAX_FILES.
--log-file(none)Path for explicit on-disk log output. When set, log lines are written to this file in addition to stdout. The directory must be writable by the server’s runtime user; if the file or directory cannot be opened the server logs an ERROR but continues to start. Independent of the default platform log path (see File Logging).
--kek-min-rotate-interval3600KEK rotation runaway/abuse guard (#2530) — NOT a rotation-schedule setting. A floor on how frequently /api/v1/secrets/kek/rotate may be attempted at all (seconds), read from secrets.kek_meta.created_at on the database server’s own clock — cluster-wide and restart-persistent (the only authoritative control; a cheap process-local pre-check that used to sit alongside it was removed as a correctness bug, #2530 G7-S9 — see “Key management (secrets KEK)”). A rotate inside the window gets 429 with an honest retry_after_ms. The default is sized to stop looping automation, not to express how often you intend to rotate; most operators should never change it. Raising it delays emergency re-rotation after a suspected KEK compromise with no bypass (/rewrap only resumes an in-progress rotation, it never mints a new version) — do not set it to your rotation cadence (e.g. a 90-day quarterly policy), that is a routine rotation followed by a compromise the next day leaving you refused for the next three months. The upper bound (365 days) is a fat-finger sanity ceiling, not an endorsement of setting it that high. A fresh install’s first rotate attempt is refused for up to this interval — KEK v1 is minted at boot with created_at = now(), so the durable clock starts counting down from install time, not from your first rotate call. See “Key management (secrets KEK)” for the full contract. Env: YUZU_KEK_MIN_ROTATE_INTERVAL.
--kek-max-live-versions32KEK rotation runaway control (#2530). Backstop ceiling on the number of non-retired KEK versions; a rotate at or above it gets 409 with no retry hint. There is no retire route (#2525), so raising this above the default is the supported escape hatch that keeps rotation usable once an install hits it — a deliberate, logged (spdlog::warn at boot) and audited (server.kek_ceiling_raised) temporary risk acceptance, not a routine tuning knob; every server sharing the database needs the raised value for the ceiling to lift fleet-wide. See “Key management (secrets KEK)”. Env: YUZU_KEK_MAX_LIVE_VERSIONS.
--analytics-drain-interval10Seconds between passes of the analytics drain, which moves buffered analytics events from the PostgreSQL spool (analytics_event_store schema) to the configured sinks (--analytics-jsonl, ClickHouse). Must be a positive number; 0 or a negative value is rejected at startup. Has no effect with --no-analytics. No environment variable.
--analytics-batch-size100Maximum number of buffered analytics events claimed and delivered per drain pass. Must be a positive number; 0 or a negative value is rejected at startup. Has no effect with --no-analytics. No environment variable.
--clickhouse-databaseyuzuClickHouse database the analytics ClickHouse sink inserts into (sent as the database query parameter). Used only when --clickhouse-url is set. Env: YUZU_CLICKHOUSE_DATABASE.
--clickhouse-tableyuzu_eventsClickHouse table the sink inserts into (INSERT INTO <table> FORMAT JSONEachRow); create it beforehand. Used only when --clickhouse-url is set. Env: YUZU_CLICKHOUSE_TABLE.
--clickhouse-user(none)ClickHouse username, sent as the X-ClickHouse-User header. When empty, no credentials are sent at all (the password is ignored too). Used only when --clickhouse-url is set. Env: YUZU_CLICKHOUSE_USER.
--clickhouse-password(none)ClickHouse password, sent as the X-ClickHouse-Key header, and only when --clickhouse-user is set. Never returned by the Settings page or GET /api/v1/settings/analytics, which report only whether it is set. Prefer the environment variable to the flag, which puts the password on the process command line (see security-hardening.md, sensitive environment variables). Env: YUZU_CLICKHOUSE_PASSWORD.

Sizing the Guardian reconcile budget

Read this first. The admission budget removes the heartbeat reconcile’s O(fleet) cost (the scope check is now evaluated for the one agent, not built from a pass over the whole fleet) and makes it shed-safe: under load a replica declines excess reconciles instead of falling over. It does not by itself make the server handle 500,000 agents. The default values of the three flags are provisional starting points, not derived from fleet measurement, and every per-push cost below is an estimate unless it says otherwise. The policy-bump fan-out that the REST and dashboard routes run (as distinct from this heartbeat path) is not budgeted and runs synchronously. Per-heartbeat Postgres writes, the gateway forwarding of each push (a thread per forwarded push), audit volume and a cluster-wide (rather than per-replica) budget are separate follow-ups.

After a policy-generation bump or a fleet-wide restart, every agent that reports a generation behind the server’s is re-pushed from its heartbeat (see Guaranteed State). The three --guardian-reconcile-* flags bound how fast one replica does that.

No alert ships for shed, because a threshold needs fleet data. The audit store receives at most one row per admitted push (audit sampling is a follow-up); an admitted push that ends without sending and without aborting (no_session, a fresh generation that is not ahead of the agent, or a failed queue write) writes none. An admission writes the agent’s 25 s claim and nothing releases it early, including when the push ends without sending, so an agent heartbeating generation 0 in a loop is admitted at most once per 25 s. result="no_session" counts only an agent whose session was removed (reaped, revoked or torn down) while a pending-map or gateway-held session id still resolved, or a deregistration race, and writes no audit row. An agent that has Registered but not yet Subscribed already HAS a session (it is installed at Register): it takes the full push path, the send fails, and it shows only as yuzu_server_system_reserved_push_total{result="undelivered"}. There is no server-side per-agent back-off beyond the 25 s per-agent limit: the agent-side back-off for a failed boot re-arm plus this budget cover it.

Example

# HTTP only (development — HTTPS is on by default, must opt out)
./yuzu-server --no-https --web-port 8080

# HTTPS with certificate files (default mode)
./yuzu-server --https-cert /etc/yuzu/server.crt \
  --https-key /etc/yuzu/server.key

# HTTPS with custom cert reload interval (30 seconds)
./yuzu-server --https-cert /etc/yuzu/server.crt \
  --https-key /etc/yuzu/server.key \
  --cert-reload-interval 30

# HTTPS with cert reload disabled
./yuzu-server --https-cert /etc/yuzu/server.crt \
  --https-key /etc/yuzu/server.key \
  --no-cert-reload

# MCP disabled (air-gapped environment)
./yuzu-server --https-cert /etc/yuzu/server.crt \
  --https-key /etc/yuzu/server.key \
  --mcp-disable

# MCP read-only mode (AI can query but not execute)
./yuzu-server --https-cert /etc/yuzu/server.crt \
  --https-key /etc/yuzu/server.key \
  --mcp-read-only

# HTTPS with OIDC SSO
./yuzu-server --https-cert /etc/yuzu/server.crt \
  --https-key /etc/yuzu/server.key \
  --oidc-issuer "https://login.microsoftonline.com/<tenant-guid>/v2.0" \
  --oidc-client-id "YOUR_CLIENT_ID" \
  --oidc-client-secret "YOUR_SECRET" \
  --oidc-redirect-uri "https://yuzu.example.com:8443/auth/callback"

Settings: Integrations page

Settings → Integrations (/settings/integrations, admin only; the MCP and Gateway panels also stay on the /settings page). Every panel reads through admin-only fragments that are also gated by the securable of the matching JSON route (Infrastructure Read for webhooks and offload targets, Directory Read for directory sync), so a fragment is never wider than its JSON twin.

Upgrade note. POST /api/settings/mcp now requires exactly one enabled and exactly one read_only field, each the literal true or false, and answers 400 otherwise. Scripts that omitted a field or sent another value must be updated. Creating a webhook or offload target with user:password@ in the URL, or with a control character in the URL or event types, now answers 400 (existing rows are not changed). Webhook and offload-target audit rows now record the destination’s scheme and host plus a short fingerprint, never the URL path or query; rows written by earlier builds still hold the full URL and should be treated like a credential. These writes (and directory sync, the group-mapping save and POST /api/settings/mcp) write an attempted audit row first and refuse with 503 if it cannot be stored, and refuse a cross-site browser request with 403.


Configuration Files

The server’s configuration file (--config, default /etc/yuzu/yuzu-server.cfg on Linux, C:\ProgramData\Yuzu\yuzu-server.cfg on Windows) and its runtime state files live on disk; the state files are written to --data-dir (default: the directory containing the config file). Apart from those files, the NVD cache and the agent-updates//upload-blobs/ file directories, everything — users, sessions, MFA/TOTP, SCIM and every other server store — lives in PostgreSQL (see PostgreSQL Substrate). There is no auth.db; legacy *.db files left in --data-dir by older releases are only probed at startup to warn, never read.

FilePurpose
yuzu-server.cfgFirst-boot seed for the initial admin. Holds the initial admin credential as PBKDF2-SHA256 with a per-user salt, which is seeded into the PostgreSQL auth schema on first boot. After that the auth schema is authoritative and this file is seed-only (#5274): editing it does not change a stored password — change passwords in the product (Settings → User Management, or POST /api/v1/users/me/password, #5342). Keep this file as the seed for disaster recovery.
auto-approve.cfgAuto-approve enrollment policy rules and match mode.
nvd_cves.dbNVD CVE cache. The one remaining server SQLite store — a recorded deferral, not a permanent exemption (docs/postgres-migration-ladder.md).
agent-updates/Agent OTA package binaries. The package records (update_registry.update_packages) are in PostgreSQL; the files they name live here. Relocated by --update-dir when that flag is set.
upload-blobs/Blob root for completed agent file uploads (ADR-3004). The records (upload_grant_store.completed_uploads) are in PostgreSQL; the blobs live here. Always under --data-dir; no flag overrides it.
enablement-anchorPlugin-enablement continuity anchor (ADR-3006): three lines, the enablement store’s id, epoch and counter. It is this host’s witness that the store has not gone backwards. Written on a boot that finds none, from the store’s current highest; never lowered. Copy it with the data directory and with every backup: it is not in the pg_dump. What unwitnessed, regressed and unreadable mean, and how to recover, are in Enablement continuity anchor.

Backup recommendation: A complete backup is a pg_dump --format=custom of the Yuzu database plus the entire CA/cert directory --ca-dir, captured at the same point in time and restored as a pair — --ca-dir holds default-ca.key (the per-install CA private key) and the secrets KEK files secrets-kek-v<N>.key, without which the secret columns in the dump (TOTP secrets, webhook and plugin-config secrets, runtime-config and offload-target secrets) cannot be decrypted. See Backup — the KEK pairing rule and PostgreSQL Substrate for the procedure. Retain old KEK versions for as long as the backups that need them. Also back up the --data-dir .cfg files above, nvd_cves.db (use sqlite3 nvd_cves.db ".backup ...", NEVER cp against a live DB), and the two blob directories agent-updates/ (or your --update-dir) and upload-blobs/ — the dump holds only the package and upload records that point into them, so restoring the dump without these directories leaves records with no files behind them. Losing default-ca.key forces a full fleet re-enrollment (every agent’s cert chains to that root, and the server refuses to silently re-root — see below). Losing the Postgres auth schema AND yuzu-server.cfg requires re-running --first-run-setup to create a new admin. Losing authentication state alone is a Postgres restore — see docs/ops-runbooks/auth-db-recovery.md. Also keep enablement-anchor (it is a witness of the plugin-enablement store, not part of the dump; scripts/yuzu-backup.sh and scripts/yuzu-restore.sh copy it): restoring the dump without it is reported as unwitnessed, and restoring a dump older than the anchor starts with enablement regressed by design (see Enablement continuity anchor). The internal-CA inventory + CRL history (ca_store schema, ADR-0053) is one of the migrated Postgres stores — back it up with pg_dump/pg_restore, not as a separate local file.

Built-in default certificates — convenience, not production. With no --cert/--key/--https-cert supplied (and without --no-default-certs), the server generates a per-install ECDSA CA + server leaves on first boot so a fresh install is encrypted with zero config. Operational caveats:

  • 10-year, no auto-renewal. The server leaves do not auto-renew; the yuzu_server_cert_expiry_timestamp_seconds{cert="default-ca"} gauge + the yuzu-tls alerts (docs/prometheus/yuzu-alerts.yml) warn ahead of expiry, one at a time with rising urgency: YuzuCertificateExpiringSoon (warning, 30 to 7 days left: schedule the renewal), YuzuCertificateExpiryImminent (critical, 7 days to 24 hours: renew today), YuzuCertificateExpiryCritical (critical, under 24 hours: renew now). The gateway reports its own certificate files the same way (yuzu_gw_cert_expiry_timestamp_seconds, alerts YuzuGatewayCertificateExpir*; see Certificate rotation and expiry). Replace defaults before production rollout with operator-provided certs (--cert/--key, --https-cert/--https-key) or, to rotate the built-in set, follow the clean re-root in PKI architecture “Operator runbook”, which removes the default-* files and default-marker.json and also clears the CA inventory in Postgres (ca_store). Never clear the whole --ca-dir: it also holds the secrets key-encryption key (secrets-kek-*.key), and the server refuses to start without it (kek_unresolvable, #5370).
  • SAN limitation. Default leaf SANs cover localhost, 127.0.0.1, ::1, and the boot-time hostname only. Reaching the dashboard/agent listener by a LAN IP or a different FQDN needs operator-provided certs (or DNS that resolves to a covered name). A host rename invalidates the SAN — rotate the certs after renaming.
  • No silent re-root. If ca_store (the internal-CA Postgres store, ADR-0053) already holds a CA root but the on-disk certs in --ca-dir are missing/corrupt (e.g. a wiped cert dir on a persistent data volume, or ordinary later damage to an established install — a bad partial restore, a lost leaf file), the server refuses to start rather than mint a new CA that would orphan every enrolled agent — unless this exact instance can prove it minted the still-incomplete root (its local CA key file still resolves and cryptographically pairs with the stored root), in which case it resumes automatically and re-mints its own default leaves under the same root (ADR-0053). When that self-heal condition does not hold, restore default-*.{pem,key} from backup (matching the ca_store root), or perform a deliberate clean re-root by clearing ca_store.ca_root/ca_issued/ca_crl_versions directly against Postgres — see docs/pki-architecture.md “Operator runbook” for the full procedure.

Enrollment tokens and the pending-agent approval queue are in PostgreSQL (WS-6 6.2, auth.enrollment_tokens / auth.pending_agents — see PostgreSQL Substrate), not --data-dir files: every server replica shares one authoritative view, and --generate-tokens mints straight into the same store the running server reads. If enrollment-tokens.cfg or pending-agents.cfg is present in the directory holding yuzu-server.cfg or in --data-dir at any boot where the database holds no import record for that file (a fresh database never does), the server imports each file once as live credentials, without checking where it came from, then renames it to <name>.cfg.imported (see the enrollment.import audit action and the yuzu_server_enrollment_import_total metric). A fresh deployment must not start with such files left over from another install.

File permissions (Unix): yuzu-server.cfg is 0600 after every write, and the KEK files in --ca-dir are created 0600. No manual chmod is required.

Windows Defender: No authentication-file exclusion applies — authentication state is in PostgreSQL, not a local database file. See docs/ops-runbooks/auth-db-recovery.md.

Enablement continuity anchor

enablement-anchor (ADR-3006) is this host’s witness that the plugin-enablement store has not gone backwards. It holds three lines: the store’s id, and the highest epoch and counter this host has seen. The tables ride the ordinary pg_dump; the anchor is the one extra artefact, so keep it with the data directory and with every backup. The server never lowers it. Nothing in this release issues desired state, so the counter is 0 and the anchor is in practice a store-identity check; the counter becomes meaningful with the later change that issues desired state, which also keeps the file current after each batch. A dump restored together with an anchor taken at the same time is consistent and silent; the anchor catches a database rolled back, or replaced, while the live file survives.

Exit from a declared regression in this release. With the server stopped, either restore a dump taken at or after the anchor (this keeps the ordering of states, and is the right answer for store_behind), or, when nothing has been issued, clear the declaration and the anchor together:

UPDATE plugin_config_store.enablement_meta SET regressed_at = NULL, regressed_reason = '' WHERE id = 1;

Then remove or correct enablement-anchor in the data directory and start the server, which writes a fresh anchor from the store. Clearing only one of the two re-declares at the next boot: the SQL alone leaves the mismatching file, and removing the file alone leaves the declaration (the server does not write the anchor while regressed). This is safe in this release only because the counter is 0, so nothing has been issued that an endpoint could hold; once desired state is issued, discarding the ordering is a decision with consequences, and the operator recovery flow that arrives with it replaces this procedure.

High availability. Use one persistent data directory per replica, never shared and never cloned from another deployment. A lagging replica or a failed-over leader has a weaker witness than the replica that wrote the anchor last. One replica’s declaration is cluster-wide (it lives in the database), but the other replicas’ /readyz stays stale until they restart in this release. An ephemeral data directory (a container without a volume for --data-dir) makes the mechanism inert: every boot is unwitnessed, and a data directory the server cannot write sets yuzu_server_enablement_anchor_writable to 0, which raises YuzuEnablementAnchorWriteFailing after 5 minutes.


First-Run Setup

When the server starts for the first time and no yuzu-server.cfg exists, it enters interactive setup mode on the terminal. The setup prompts for:

  1. Admin username — the initial administrator account.
  2. Admin password — entered twice for confirmation. Stored as a PBKDF2 hash.

After setup completes, the server writes yuzu-server.cfg and starts normally. Subsequent restarts skip the setup prompt.

Headless deployment: For automated or containerized deployments, pre-create yuzu-server.cfg with PBKDF2-hashed password entries before starting the server for the first time — first-run setup is interactive and will exit without a TTY.

Seeded credentials

No image or installer provisions a default account. No image COPYs a yuzu-server.cfg, and there is no built-in account: a server started without a config runs interactive first-run setup, which prompts for an administrator account only (since 0.15.0; earlier versions also prompted for a second user account that could never sign in on PostgreSQL — #5343). Create further accounts in Settings → User Management after the first sign-in.

One checked-in file, deploy/config/uat/yuzu-server.cfg, does hold a single admin entry — with a fixed salt and a fixed PBKDF2 digest committed to git, for the known password adminpassword1. It is orphaned: no script, compose file or image reads it (the UAT rigs each generate their own config at a temp path with a fresh random salt per run). Never copy it into a deployment, and do not read its presence as a supported default — removing it from the tree is tracked separately.

If you seed an account yourself, change its password before exposing the server — sign in and use Settings → User Management → Change password (or POST /api/v1/users/me/password). Editing the config file afterwards does not change it: once the account exists in the PostgreSQL auth store, that row is the only credential checked (#5274). For enterprise deployments, integrate OIDC SSO and disable local accounts.


Settings: General page

Settings → General (/settings/general, admin only; every panel it shows also stays on the /settings page).


Upgrade Notes

Fresh deployment only for 0.15.0. Entries below that tell you to upgrade, roll back or restore an earlier build are historical and not a supported path (see “Deployment contract (hard cutover)” in Upgrading). Entries describing behaviour of the 0.15.0 build still apply.

What changed. Agent plugins that mount a logged-out Windows profile’s NTUSER.DAT offline (to read its HKEY_USERS hive without a live session) have always run a file-level check against that hive — the path must not cross a UNC/non-fixed-drive or a reparse-point ancestor, and the file itself must be a regular file owned by the profile, LocalSystem or Administrators, with no more than one hard link (hive_hardlinked). Before this change only one plugin (privacy_permissions) opted into that check; the other five production callers (autoruns, installed_apps, license_scan, registry, and Echo’s mapped-drive-history collector) mounted with no check at all. The check is now the default for all of them.

Who this affects. A profile hive that fails the check — most commonly an FSLogix, UWP-container or other non-persistent-image profile root, or any profile directory not owned by its own user/LocalSystem/Administrators — now surfaces as a named, unreadable-source row instead of being silently mounted and read: autoruns list/catalog and installed_apps list_per_user name the specific refusal reason per profile; license_scan list reports the aggregate reason hive_file_refused on its per_user_hives probe status (the specific per-profile reason goes to the agent log only); registry answers its existing hive-file-refused error (unchanged wording, no reason suffix); Echo’s mapped-drive history retries an affected profile individually, up to 7 attempts over 24 h apart, before giving up on that profile. A fleet with many such profiles (e.g. a VDI/FSLogix rollout) may see these constraint rows appear for a noticeable fraction of devices right after upgrading.

What to do. No action is required — this is a fail-closed tightening of an existing check’s reach, not a new restriction with a workaround; a profile that reads fine today under the new default was never at risk from it. Automation that treats one of the five affected plugins’ previously-silent success as a guarantee (a script that only checks for a non-empty list row count, a SIEM rule that alerts only on an explicit error) should instead treat a hive_*/hive_file_refused/hive-file-refused row the same way it already treats any other named per-profile constraint from these plugins (not_found, privilege_missing, mount_failed) — none of these are new categories of outcome, only a more specific one for this particular cause.

vNEXT - MFA step-up is checked once, before the route’s own checks; the server refuses to start with an unclassified mutating route (breaking order of checks)

What changed. A fresh MFA proof is required on 37 named high-risk routes 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. Other mutating routes and all MCP tools are not MFA-step-up gated in this release. The check now runs in the request pipeline before the route’s own authorization and its own same-site copy (and after the server-wide same-site check, which answers a cross-site cookie write 403 first), so a session with a stale or missing proof receives the 401 step-up envelope instead of the route’s 403; engine principals receive 403 on every one of the 37 routes regardless of grants (once their credential resolves and the pre-routing gates and the unwired-store 503 below have been ruled out; during a database outage the credential cannot be resolved and the request is answered 401 unauthorized instead), audited as mfa.step_up.required instead of the per-route handler denial row; and POST /api/settings/mfa/recovery-codes and POST /api/settings/mfa/disable are newly gated. A server with no wired authentication store answers 503 on these routes, and an unreadable store answers 503 to local sessions. Full list of behaviour changes: Upgrading (“Breaking: MFA step-up is checked once”).

Boot refusal. At boot the server checks that every POST/PUT/PATCH/DELETE route registered through the route sink is classified in the step-up table. If one is not, it logs start_web_server(): mutating route has no MFA step-up policy row: <METHOD> <pattern> for each, then aborting: N mutating route(s) are not classified in step_up_policy.hpp, stops the gRPC listeners and exits with status 1 before the web listener starts. The gRPC agent port is already listening when the check fails and keeps accepting connections for about five seconds while the server shuts down, before the process exits (about eleven to twelve seconds in total, measured), so while a restart policy keeps starting it, connected agents and gateways see the server accept connections for about five seconds out of every eleven to twelve and then drop, and the web UI never comes up. A restart policy (restart: always or unless-stopped, systemd Restart=, a Kubernetes pod) turns this into a restart loop, and a rolling deployment halts because the new replica never becomes ready. It can only trigger on a fork or a custom build; stock builds are checked by compiled and source-scan tests before release. Recovery: roll back to the previous binary or image (nothing is migrated), or add the missing row to step_up_policy.hpp and rebuild. No flag disables the check.

Alerts. YuzuStepUpGateUnavailable fires on sustained 503s from the check (see Step-up returns 503); YuzuStepUpWindowDisabled fires while --mfa-step-up-window-secs is 0 or less.

POST/DELETE /api/webhooks, POST/DELETE /api/v1/offload-targets, POST /api/directory/sync, PUT /api/directory/group-mappings and POST /api/settings/mcp now answer 403 to a request authenticated by a session cookie that carries no Origin/Referer, or one naming another site. A script that reuses a login cookie jar (curl -b) must switch to an API token (token callers are not gated) or send a same-site Origin; behind a proxy that rewrites Host, declare the external origin with --csrf-trusted-origin as described above. Each of these writes also records a result=attempted audit row before it changes anything and answers 503 with Sec-Audit-Failed: true when that row cannot be stored; during an audit-store outage they are unavailable, deleting a webhook or offload target included (the MCP switches can still be turned off with --mcp-disable and a restart; check /healthz stores.audit and the YuzuAuditPersistFailures alert). SIEM rules that filter result != success will now also see the attempted rows. Webhook and offload audit rows record url=<scheme>://<host>[:<port>] url_fp=<12 hex> instead of the full URL, and their log lines name the host (and the webhook or target id where one exists), never the URL; rows written by earlier builds keep the full URL, so rotate any hook URL that appears in one.

vNEXT - POST /api/settings/mcp now refuses a body that omits or repeats enabled or read_only (breaking for scripted callers)

The dashboard route behind the MCP settings form used to read the first enabled and read_only value it found and treat an absent one as false: a request carrying only enabled=true switched Read-only MCP to Full access, and an empty request switched MCP off. It now requires exactly one enabled and exactly one read_only, each true or false, and answers 400 with nothing changed otherwise. The rebuilt panel always sends both. A script that posted a single field must send both.

vNEXT - changing an account’s role or granting elevation eligibility needs your own administrator role (breaking for elevation-only operators)

What changed. POST /api/settings/users/{username}/role and the grant form (eligible=true) of POST /api/v1/users/{username}/elevation-eligibility refuse, with 403, a session that is administrator only through a temporary elevation. The attempt is audited (elevated_session_role_change_blocked, elevated_session_eligibility_grant_blocked).

Who this affects. Operators who rely on an elevated session to change roles or grant eligibility, in particular deployments where every administrator is an elevation-eligible user account.

What to do. Make the change from an account whose own role is admin (a local administrator, or a member of the identity provider’s administrator group). Revoking eligibility is still allowed during an elevation.

vNEXT - --gateway-upstream refuses to start without gateway peer authorization (breaking for own-certificate and plaintext installs)

The gateway-upstream service now admits only a gateway whose certificate key (SPKI SHA-256) is pinned, carries serverAuth and is inside its validity window, and the server exits at boot, before binding a port, when --gateway-upstream is set and that cannot be arranged. Who is affected and what to do:

Check your own unit, env file, compose file or installer arguments before upgrading; the literal commands, the per-deployment table and the rollback steps are in Upgrading (“Breaking: --gateway-upstream now refuses to start without gateway peer authorization”), and the full reference is Gateway upstream peer authorization below. Pins are fixed at boot; changing or withdrawing one is a restart.

vNEXT - changing sign-in provider settings needs your own administrator role (breaking for elevation-only operators)

What changed. POST /api/settings/oidc (Settings > OIDC) and PUT /api/config/{key} for the oidc_* keys refuse, with 403, any session whose own role is not admin: a session that is administrator only through a temporary elevation, and (for the config route) a non-administrator account that holds Infrastructure:Write through a custom or service role, or whose authority is an RBAC Administrator grant only, and any MCP-tier token. The attempt is audited (elevated_session_oidc_change_blocked and elevated_session_oidc_setting_blocked for an elevation, admin_role_required_oidc_setting_blocked for a non-administrator, and token_session_oidc_setting_blocked for an MCP-tier or service-scoped token). Seven other keys, plugin_signing_required, the dex_* alerting keys and audit_retention_days, are administrator-only on the same route: see the next note. Every remaining runtime-config key is unaffected.

Who this affects. Operators who rely on an elevated session to change the OIDC issuer, client, secret, redirect URI or administrator group, in particular deployments where every administrator is an elevation-eligible user account; and automation that writes oidc_* through PUT /api/config/{key} with a token whose creator is not an admin account.

What to do. Make the change from an account whose own role is admin (a local administrator, or a member of the identity provider’s administrator group). If no such account can sign in, use the break-glass account (--break-glass-user, --break-glass-arm) or the direct-SQL fallback in auth-db-recovery.md. A stored oidc_* value overrides the matching --oidc-* flag after a restart, so correct the stored value rather than only the flag.

vNEXT - the runtime-configuration read shows a caller only the overrides it could write (breaking for MCP-tier and non-administrator readers)

What changed. GET /api/config, the new GET /api/v1/config and the MCP tool get_runtime_config leave out the stored override of every key the caller could not write (the oidc_* keys, plugin_signing_required, the DEX alert keys and audit_retention_days), and the effective audit_retention_days. An administrator account’s GET /api/config body is what it was, apart from log_level reading warn instead of warning. heartbeat_timeout is now limited to 30 to 86400 seconds on every write route, and a stored value outside that range is moved to the nearest bound at server start (logged).

Who this affects. Scripts and backups that read GET /api/config with an MCP-tier token or a non-administrator role and expect the administrator-only keys; scripts that compare config.log_level to "warning"; scripts that write a heartbeat_timeout above 86400.

What to do. Read with an administrator API token that is not MCP-tier. Use GET /api/v1/config to see which keys were withheld (withheld_keys). See Upgrading.

vNEXT - seven runtime-config keys are administrator-only on PUT /api/config/{key} (breaking for MCP-tier tokens and non-administrator roles)

What changed. PUT /api/config/{key} for plugin_signing_required, dex_alert_routing, dex_blast_min_devices, dex_blast_window_seconds, dex_blast_cooldown_seconds and dex_cohort_export_key now applies the same caller rule as the Settings routes that also write them, and audit_retention_days joins them by design (it is the stored audit-retention setting, reported by GET /api/config, evidence-bearing, and the lever that will govern enforcement once #483 lands; it is stored only today and a PUT does not change the retention actually applied; it has no Settings route, so this generic route is its only writer): an MCP-tier token, a service-scoped token, or a session whose effective role is not admin (a temporary elevation counts) is refused with 403. The callers that newly reach this refusal are a supervised-tier MCP token (even one minted by an administrator) and, with RBAC on, a non-administrator role holding Infrastructure:Write; service-scoped tokens (and readonly or operator tier MCP tokens) were and remain refused earlier by the permission gate (auth.permission_required). A refusal at the new rule is audited as config.update / denied (token_session_admin_setting_blocked or admin_role_required_setting_blocked) and nothing is stored. The oidc_* keys, which already needed your own administrator role, are unchanged (the handler also refuses a service-scoped token, as defence in depth; the permission gate already does). The role checked is the account’s legacy role or a live temporary elevation, never an RBAC grant: an RBAC Administrator grant alone does not count. Every other key (response_retention_days, guardian_event_retention_days, log_level, heartbeat_timeout) is unchanged and stays writable under Infrastructure:Write.

Who this affects. Automation that writes these keys with a supervised-tier MCP token, even one minted by an administrator, and, with RBAC on, a non-administrator role that holds Infrastructure:Write.

What to do. Change these settings with an administrator account: through the Settings pages (DEX alerts, Plugin Code Signing), or with an administrator API token that is not MCP-tier. audit_retention_days has no Settings page: use PUT /api/config/audit_retention_days with an administrator API token that is not MCP-tier, or an administrator session (a cookie session is a browser write and needs a same-site Origin or Referer; an administrator API token that is not MCP-tier is exempt). PUT only stores the audit_retention_days value: the window the server enforces comes from --audit-retention-days (or YUZU_AUDIT_RETENTION_DAYS) plus a restart, so check enforced in GET /api/v1/settings/data-retention. When #483 lands, every previously stored value becomes live at the next boot, so reviewing the stored values is a precondition of that upgrade. To find earlier writes and for the replay caveat, rolling-upgrade behaviour and the audit query, read the full note in Upgrading (“Breaking: seven runtime-config keys are administrator-only on PUT /api/config/{key}”).

vNEXT — the shipped Docker Compose files keep /etc/yuzu/certs on a volume (#5370; action needed before you recreate a 0.14.0 container)

Fresh deployment only for 0.15.0. This block is not an upgrade path to 0.15.0. If you still run a 0.14.0 Docker Compose stack, the copy-out below remains required before you recreate its server container.

Action required before upgrading from 0.14.0 on Docker Compose: copy /etc/yuzu/certs out of the running server container first (Upgrading → “Docker Compose: copy /etc/yuzu/certs out of the server container before you recreate it”). Affected: a 0.14.0 Docker Compose stack whose server has no named volume on /etc/yuzu/certs while Postgres has one — deploy/docker/docker-compose.yml (the 0.14.0 README quickstart), docker-compose.uat.yml, docker-compose.full-uat.yml, docker-compose.viz-uat.yml, docker-compose.demo.yml used with --keep, any compose copied from them, and Compose Wizard output from before 0.14.1 in Plaintext mode, with ‘Persist generated certs’ unticked, or with named volumes off and an external Postgres. Not affected: docker-compose.reference.yml (which the README quickstart saves as docker-compose.yml since #5419) and docker-compose.reference-gateway.yml (their certs volume).

deploy/docker/docker-compose.yml, docker-compose.uat.yml, docker-compose.full-uat.yml, docker-compose.viz-uat.yml, docker-compose.demo.yml and the Compose Wizard’s Default and Plaintext output (with named volumes on, or with an external Postgres) now mount a server-certs volume on /etc/yuzu/certs, which holds the internal CA and the secrets KEK. In 0.14.0 that directory sat in the container’s own layer, so recreating the container deleted the KEK. Postgres still had it registered, so the server refused to start (kek_unresolvable). The reference composes already persisted it, on their certs volume. The Upgrading section gives the copy-out commands, the diagnostics to run before anything destructive, a rollback that keeps the keys, and the recovery path if the files are already gone. See also What must persist.

vNEXT — a new always-on background sweep auto-cancels executions already stuck at running (#4982, NOT breaking)

What changed. A new clock-guarded background pass, ExecutionTracker::reap_stuck_running_executions (~15-minute cadence, no CLI flag or env var — same unconfigurable-by-design posture as its sibling reapers), recovers an execution row left permanently stranded at status='running' because a post-dispatch bookkeeping write (set_agents_targeted/mark_cancelled) itself failed. It excludes any execution with a still-pending outbox entry or with at least one real per-agent response already recorded — only a genuinely-orphaned row (never reached, or genuinely abandoned by a dispatch that failed its own cleanup) is cancelled.

What an operator sees on this upgrade. Any execution rows ALREADY stranded at running before this build started will usually auto-cancel within roughly 15–30 minutes of the first post-upgrade tick that reaches them, each writing a new execution.cancel audit row with principal="system" on a best-effort basis (if the audit store is unavailable, that pass’s cancellations go unaudited — see yuzu_server_audit_emit_failed_total) — the first system-sourced row that action has ever carried; a SIEM rule or dashboard filter keyed only on human/session principals for execution.cancel will not see these. The 15–30 minute figure assumes a normal-sized backlog: if the pre-existing stranded population is unusually large, the sweep caps itself at 500 cancellations per pass (capped, draining the rest over following passes) or, if stranded rows exceed half of all currently-running executions, declines to act at all (would_wipe) on the assumption that something more systemic is wrong rather than a one-time backlog — in that case the rows stay running and yuzu_exec_tracker_stuck_reap_total{outcome="would_wipe"} holds nonzero until an operator investigates and, if the backlog really is benign, cancels it by hand to bring the ratio back under the sweep’s own threshold. This is otherwise a one-time settling period for pre-existing stuck rows, not an ongoing behaviour change for new dispatches. See docs/user-manual/metrics.md “Execution bookkeeping + stuck-execution reap metrics” for the new yuzu_exec_tracker_stuck_reap_total{outcome} counter and docs/clock-guarded-retention.md for the full design.

vNEXT — the Windows server installer locks its data directory and keeps secrets off the command line (#5196, #5210, #5272; breaking for unattended installs)

Windows server is not a supported deployment (ADR-0035), and the YuzuServer service cannot yet run under the Service Control Manager at all (#5325; see also the open issue #1835, “Windows server binary has the identical SCM control-protocol defect as #1822 (agent)”). This note is for anyone using YuzuServerSetup-*.exe anyway.

What the installer does now. The secrets it handles are kept in %ProgramData%\Yuzu Server, which it locks to exactly Administrators and SYSTEM and verifies before it installs anything:

FileHoldsPassed to the server as
yuzu-server.cfgdashboard password hashes--config
postgres.dsnthe PostgreSQL connection string--postgres-dsn-file
oidc-client-secretthe OIDC client secret--oidc-client-secret-file
certs\your TLS certificates and keys--https-cert, --cert, …
data\the server’s data directory--data-dir

The service’s command line, which every local user can read, now carries only these paths, and is written with the executable path quoted. Earlier installers meant to pass the OIDC client secret there. In practice, an sc.exe quoting fault dropped every argument, so the service they registered ran with no arguments at all, and the directory was readable by local users. The installer reads the command line back after writing it. If it cannot be written, for example because security software blocks the change, the service is disabled and Setup exits with code 10, rather than leaving the old command line in force.

The server’s own CA, default certificates and key-encryption keys stay where the server keeps them: C:\ProgramData\Yuzu\certs. The server applies its own Administrators-and-SYSTEM permissions to that folder and to those key files when it writes them. Moving them into the locked directory needs server changes first (#5273).

Unattended installs:

Upgrading from an earlier version. (This describes what the installer does when it finds an earlier installation’s directory. It does not make an upgrade of an earlier build’s database supported in 0.15.0: see “Deployment contract (hard cutover)” in Upgrading.) Its data directory is not locked, so the installer builds a new, locked one. It copies across only plain files that are owned by Administrators or SYSTEM and that no other account can change, from folders that are owned by Administrators or SYSTEM and in which no other account can delete or rename anything: the configuration, postgres.dsn, oidc-client-secret, and the files directly in certs\ and data\. Subdirectories stay behind. The old directory is renamed to %ProgramData%\Yuzu Server.insecure-<date>-<time>; it is never changed or deleted.

If any file that would be copied, or a folder holding one, fails that check, or a file is a link or a hard link, the upgrade stops and names it, and nothing is changed. This also catches a folder that a standard user created before the original install: they stay its owner, so they could have swapped its files for their own. Inspect what is named. If it is yours, remove it, or make Administrators its owner and reset its permissions (icacls <path> /setowner *S-1-5-32-544 /L, then icacls <path> /reset /L), then run the installer again. An unlocked %ProgramData%\Yuzu Server with no Yuzu Server installation registered on the machine is refused, because it cannot be shown that an administrator created it. A junction or symbolic link at that path is always refused.

Fresh deployment only for 0.15.0. This does not make an upgrade of an earlier build supported (see “Deployment contract (hard cutover)” in Upgrading). If the installer has nevertheless run in upgrade mode over an earlier directory, the exposure described in these steps is real whatever the contract says, so carry them out.

After upgrading from an earlier version:

  1. Rotate what the old directory exposed. Local users could read it, so treat its contents as disclosed: reset the dashboard passwords, re-issue the TLS keys in certs\, and rotate the OIDC client secret. Reset the passwords in the product (Settings → User Management, or POST /api/v1/users/me/password / POST /api/v1/users/{name}/password): the installer refuses a new /ADMIN_PASS on an upgrade (exit code 11), because the config file only seeds a fresh database and could not change an account that already exists in the PostgreSQL auth store (#5274). The old hashes stay in yuzu-server.cfg — the server logs a boot warning naming each account whose stored password no longer matches it.
  2. Move the server’s data subdirectories, if you ran the server. data\agent-updates\ and data\upload-blobs\ (OTA packages and uploads that database rows refer to) stay in the renamed directory. Local users could create files in them, so check their contents. Then stop the server, move them into the new data\, make Administrators their owner and reset their permissions: icacls "%ProgramData%\Yuzu Server\data" /setowner *S-1-5-32-544 /T /L, then icacls "%ProgramData%\Yuzu Server\data" /reset /T /L. If you would rather not trust them, re-upload the packages instead.
  3. Delete the renamed directory once the upgrade is confirmed. It still holds the old password hashes and keys.

Constrained Language Mode (WDAC script enforcement, or AppLocker script rules for an install run by an administrator) blocks the .NET calls the installer needs to hash new passwords, so a fresh install with /ADMIN_PASS stops with a message saying so. An upgrade (which keeps the existing accounts and hashes nothing) works, and so does an install run as SYSTEM, which AppLocker exempts.

vNEXT — a hand-edited config listing local users but none with role=admin now fails boot, on every restart (breaking)

What changed. On every boot (not only first boot), the server checks the loaded config’s local user list: if it is non-empty but none of the entries has role=admin, the server now refuses to start (Fatal: the loaded config lists N local user(s) but none has role=admin). Previously, this shape silently promoted the first configured user to Administrator regardless of its declared role — a real bug this change fixes — so the check is not new logic layered on top of prior behavior, it is a correction that happens to also be fail-loud where the old code was silently wrong.

Who this affects. Any deployment whose yuzu-server.cfg local-user list was hand-edited to a shape with no role=admin entry — most plausibly a fleet that migrated fully to SSO/OIDC/SAML and removed or demoted its local admin config entry, relying on the old silent-promotion behavior (or simply never noticing it) rather than on the actual RBAC/session-role state in the database. This check is config-shape-based, independent of whether auth.users already has admins in Postgres — it fires even on a long-running, fully-provisioned deployment if its config happens to carry this shape on the next restart.

Fix, before or immediately after upgrading. Either mark exactly one entry in the local user list role=admin in the config file, or — for an SSO-only fleet — remove all local user entries from the config so this check never triggers. The server logs the config’s user count and refuses to guess; there is no other remediation needed once the config is corrected.

vNEXT — installed_apps list rows carry two more fields (breaking)

What changed. The installed_apps agent plugin’s operator list action (definition crossplatform.software.inventory) now emits seven |-separated fields per row instead of five: app|name|version|publisher|install_date|install_location|bundle_id. install_location is the Windows Uninstall-key InstallLocation (unexpanded) or the location macOS reports; bundle_id is the macOS CFBundleIdentifier. Either is - where the OS has none: every Linux row ends -|-, and many Windows rows have no InstallLocation (182 of 241 on the reference developer workstation). The first five fields keep their position; every field is now escape-aware (\ folds to /, | to \|, CR/LF to a space, a field over 4 KiB is cut), a no-op for every value in the three reference captures. Row shape for query, list_per_user and the daily-sync inventory (ADR-0016) is unchanged, though on macOS the collector list/query/list_per_user share now sorts same-named apps by (name, install_location) instead of leaving their order arbitrary.

Who this affects. Automation reading installed_apps list output from GET /api/v1/responses/{id}, its /export, or MCP query_responses that (a) unpacks or anchors exactly five fields, or (b) treats the last field as install_date. The dashboard is unaffected: it has always split these rows into app plus one remainder cell, so the new fields appear inside that cell (and the search box matches them) but are not separate, sortable or filterable columns. A policy or script that substring-matches the raw output now also matches install paths and bundle identifiers.

Mixed fleets. Upgrade order is server first, so agents keep answering with five fields until they take the release carrying installed_apps 1.2.0 (the definition’s minAgentVersion stays 1.0.0). Agents older than 1.2.0 do not escape | in name, version, publisher or install date, so a row from one of them can split into any count under the escape-aware split: accept a row only when it has exactly 5 or exactly 7 tokens, reject any other count, and read the sixth/seventh columns only for agents known to be on plugin 1.2.0 or later.

Existing deployments keep the old definition. The bundled definition is seeded once and never refreshed on an existing server (see the InstructionStore note in upgrading.md; #2555), so get_definition and discover_instructions keep listing four columns at version 1.0.0. Row content is unaffected. Editing the definition (dashboard YAML editor or PUT /api/instructions/{id}) to declare the two new columns is optional and cosmetic.

Before upgrading, check whether this affects you. Search your scripts, SIEM parsers and saved exports for consumers of installed_apps/crossplatform.software.inventory output and change any fixed five-field pattern to app|name|version|publisher|install_date[|install_location|bundle_id].

vNEXT — re-eval on a result set now refuses instead of broadcasting when its recorded parent set has been deleted (#4306, breaking)

What changed. POST /api/v1/result-sets/{id}/re-eval and MCP reevaluate_result_set previously synthesised the sibling’s dispatch scope from the original’s live, nullable parent_id foreign key. If the original’s parent result set was later deleted (parent_id ... ON DELETE SET NULL), the column read as absent, and an absent parent_id reaching dispatch synthesis meant “broadcast to __all__” — silently turning “re-ask the same narrow question” into “ask the whole visible fleet.” Both routes now refuse (400 RESULT_SET_BAD_REQUEST, reason=parent_gone) when the live parent is gone but the original’s persisted source_payload shows it was narrowed at creation, instead of broadcasting.

Who this affects. Any caller (REST or MCP) whose automation re-evaluates a result set that was originally narrowed to a parent_id, where that parent set has since been deleted. Previously such a call silently succeeded with a 202 (REST) or a materialized/pending result (MCP) dispatched to the entire visible fleet; it now refuses instead — REST returns 400 RESULT_SET_BAD_REQUEST, MCP returns a JSON-RPC error (kInvalidParams) over HTTP 200, per that transport’s existing error-shape convention. No legitimate caller should have been relying on the fleet-wide broadcast — this was the target-erasure defect being fixed — but any automation catching only success responses on this route should add handling for the new 400 reason=parent_gone case: create a fresh result set from the intended parent instead of re-evaluating the orphaned one. A genuinely parentless original (no parent_id was ever supplied at creation) still broadcasts on re-eval, unchanged.

vNEXT — a result-set parent_id alias longer than 64 bytes is no longer accepted on from-echo-query/from-instruction-result (#4734, breaking)

What changed. parent_id accepts either a canonical rs_... id or a per-operator alias (the set’s own name, valid up to 256 bytes). POST /api/v1/result-sets/from-echo-query and /from-instruction-result now bound parent_id to 64 bytes (kResultSetParentIdMaxLen) before attempting alias resolution, closing a gap where an oversized value was copied unbounded into the persisted scope_input_id lineage marker. MCP’s equivalent producer tools already enforced this bound; REST did not — this change brings REST to parity.

Who this affects. Any caller referencing a result set by a name-based alias longer than 64 bytes as parent_id on these two REST routes. Previously such a call resolved the alias and dispatched normally; it now returns 400 (“parent_id must be at most 64 bytes”) before resolution is attempted. Use the set’s canonical rs_... id instead (GET /api/v1/result-sets lists both id and name for every set you own).

vNEXT — GET /api/v1/result-sets can now answer 503; the async result-set producers’ post-dispatch fault code changes from 400 to 500 on REST (#4306, breaking)

What changed. GET /api/v1/result-sets previously always answered 200, even when the underlying store read was degraded — ResultSetStore::list_by_owner returned a plain (possibly empty) container rather than surfacing the failure, so a degraded Postgres read answered 200 with an empty result_sets array indistinguishable from a genuinely-empty owner. It now answers 503 RESULT_SET_STORE_UNAVAILABLE (Retry-After present) on a genuine store-level read failure. Separately, on REST, the three async result-set producers (POST /api/v1/result-sets/from-echo-query, /from-instruction-result, and /{id}/re-eval) previously mapped a post-dispatch store fault — the pending result-set row failing to persist after a real command had already dispatched to agents — to 400. That was a client-error status for a server-side fault; it is now 500 RESULT_SET_STORE_FAULT_AFTER_DISPATCH, matching MCP’s rs_run_async, which already used its kInternalError branch for the identical case (the JSON-RPC error type is unchanged). The same three REST routes also gained a new PRE-dispatch 503 RESULT_SET_STORE_UNAVAILABLE when the per-owner quota cannot be verified before dispatch — nothing is sent in that case, and the request is safe to retry.

Who this affects. Any REST caller that treats GET /api/v1/result-sets as never-erroring, or that pattern-matches the old 400 on the three async producers’ post-dispatch failure path. A 503 should be retried (Retry-After header present); a 500 RESULT_SET_STORE_FAULT_AFTER_DISPATCH means a command already dispatched — do not re-send, poll GET /api/v1/executions/{id} for its outcome instead. MCP’s error type (kInternalError) is unchanged, but the embedded fault-message token was also renamed to RESULT_SET_STORE_FAULT_AFTER_DISPATCH for the same reason as REST — any MCP caller pattern-matching the old RESULT_SET_STORE_UNAVAILABLE token string on this specific post-dispatch branch should update to the new token (the 3 MCP tool descriptions in kTools[] document both tokens explicitly).

vNEXT — the 4 result-set write routes now enforce MCP tier/approval, closing a cross-transport bypass (#5047, breaking)

What changed. POST /api/v1/result-sets, /{id}/pin, /{id}/unpin, and DELETE /api/v1/result-sets/{id} — and their dashboard-fragment equivalents — previously had no RBAC or MCP-tier gate at all beyond ownership; only their MCP tool twins (create_result_set, pin_result_set, unpin_result_set, delete_result_set) enforced the tier/approval belt every other MCP-tiered operation gets. An MCP-tiered bearer token could reach the identical mutation its own tool is gated for simply by calling REST or a dashboard fragment instead of /mcp/v1/. All 8 sites now apply the same belt MCP already did: a readonly- or operator-tier bearer is denied Infrastructure:Write/Infrastructure:Delete outright; a supervised-tier bearer deleting a set must use the MCP ticket-then-recall flow (REST/fragment delete now requires approval on every transport except /mcp/v1/ itself, matching create/pin/unpin’s own approval posture — the routes never granted approval, they simply had no gate to enforce it). Ownership remains the primary, and for a plain (untiered) session the ONLY, gate — this is not a new RBAC securable and does not change who can reach their own result sets; see rbac.md’s “Not RBAC-gated: per-operator result sets”. The still-open, separately-tracked #4309 gap — a plain RBAC session or an API token minted with no mcp_tier skips this belt entirely, on every transport, including MCP itself — is unaffected by this change.

Who this affects. Any integration that mints MCP-tiered bearer tokens (readonly/operator/ supervised) and calls the JSON /api/v1/result-sets* routes or the dashboard fragments directly, rather than going through /mcp/v1/. A readonly- or operator-tier caller that previously reached create/pin/unpin/delete on their own result sets via REST now gets 403 ("MCP token tier does not allow Infrastructure:Write" or :Delete). A supervised-tier caller deleting via REST now gets 403 with a remediation pointing at the MCP ticket-then-recall flow, instead of succeeding directly. A supervised-tier caller’s create/pin/unpin is unaffected (never approval-gated at any tier). An untiered caller (a plain cookie session, or an API token minted with no mcp_tier) sees no behavior change. See rest-api.md’s per-route Errors tables for the exact new status codes.

vNEXT — POST /api/policies/{id}/evaluate and /remediate can now answer 503 where they previously answered 409/400 (#4981; breaking)

What changed. Both routes previously classified a degraded scope evaluation — a from_result_set: reference whose owning set was deleted by a concurrent operation, or a presence-store outage during a fleet-wide scope check — as an ordinary “no agents matched” or “request rejected” outcome: /evaluate returned a clean 409, and /remediate (when agent_ids is supplied) returned a 400 audited as an operator denied. Neither was true — the check could not actually be evaluated. Both routes now answer 503 for this case, audited as error, with the response message (and audit detail) carrying the specific underlying cause rather than a generic string.

Who this affects. Any automation that treats 409 from /evaluate or 400 from /remediate (with agent_ids) as terminal, or that logs either code as an operator denial. A 503 from either route is safe to retry once the underlying condition (a concurrent result-set delete, a presence-store hiccup) clears — typically within seconds.

What to do. Treat 503 on these two routes as retryable infrastructure degradation, distinct from a genuine 409/400. No action needed if your automation already retries on 503 generically.

vNEXT — server TLS listeners now pin a fixed TLS 1.2 cipher allow-list; a previously-set GRPC_SSL_CIPHER_SUITES no longer applies (#4722; breaking)

What changed. The server now unconditionally overwrites GRPC_SSL_CIPHER_SUITES in its own process environment before any gRPC call, and applies the same six-suite ECDHE TLS 1.2 allow-list to the HTTPS dashboard listener and its certificate hot-reload validation. It self-checks the resolved policy at boot and refuses to start if the allow-list resolves to zero usable TLS 1.2 ciphers on the local OpenSSL build. See TLS policy for the exact list and what CI proves about it.

Who this affects. Any deployment that previously set GRPC_SSL_CIPHER_SUITES in the server’s environment to select or restrict its own cipher suites — that variable is now silently ignored, and the server’s fixed allow-list applies instead.

What to do. There is no override flag and no config option to restore the previous behavior — this is a deliberate policy floor, not an oversight. If your environment relied on a non-default GRPC_SSL_CIPHER_SUITES value, remove it (it no longer does anything) and confirm your TLS clients support at least one suite in the pinned list (tls.md); operator-issued RSA certificates remain supported via the pinned ECDHE-RSA suites.

vNEXT — new Guardian T_* diagnostic log lines at info level (#4606; NOT breaking)

What changed. The server now writes one info-level line for every Guardian event it stores for an ordinary rule (ruleless DEX observations are excluded):

Guardian T_server event_id=… agent=… rule=… recv_ns=… committed_ns=… agent_ns=… store_ms=…

The agent writes Guardian T_wire event_id=… domain=… sent=… wire_wall_ns=… for every Guardian event it attempts to send over the Subscribe stream on the legacy detection path (domain=legacy; sent=0 means the link was down or the local write failed, and that event was dropped and is not retried). Ruleless DEX observations are not logged. Only once the Spark path is the live backend (prefer_spark, off by default) does it also write Guardian T_detect … for each outbox entry an evaluation pass stages (a pass evaluates every rule on the watched key and stages up to two entries for each, so a key shared by several rules can produce several lines, and a pass that stages nothing produces none), plus the same T_wire line for the Spark outbox path (there sent=0 means the local write failed and the entry is retried; a down stream logs nothing). They exist to measure detect-to-deliver latency for the Spark cutover benchmark (#4606); they are plain log lines, not metrics or audit events, and no shipped component consumes them. If you do consume them, join on the agent and event_id (the agent-side lines carry no agent field, so take the agent from which log the line came from and from the server line’s agent=), and never on log-file line order: a T_wire line can appear before its own T_detect line.

What the lines contain. Only identifiers, times and a few flags and counters: the event id, the agent id, the rule id, instants or elapsed times, and fields such as sent, accepted and seq. They contain no event detail, no detected or expected value, no user name, process name or path. (The one exception among the lines named here is the server’s Guardian ingest error warning, which also prints the store’s error message; it has control bytes and line separators folded, keeps its spaces, and is shortened to 256 bytes. Two further server lines print their agent and one other name neutralised: the observer-threw warning prints the observation type and the agent, and the line for an ignored __guard__ action prints the action name and the agent. The observer-threw warning also prints the exception text, which is not neutralised.) Ids are neutralised before they are written: every byte outside printable ASCII, and any space, = or , (a line break included), becomes _, and an id longer than 256 bytes is shortened to 256 by keeping its head and its last 24 bytes (the part that tells two events apart), identically on the agent and the server and on the server’s Guardian ingest replay, conflict, error, oversized-detail and parse-failure lines, so an operator-chosen rule id such as Disk Full appears as Disk_Full and a non-ASCII rule name appears as underscores. The event id printed on the server’s replay, conflict and oversized-detail lines is this neutralised form, so to find that event in the store, match on the agent, the rule and the time rather than pasting the id. This covers the lines named here. It does not extend to the server’s other log lines that print an agent id read back from the session table or the enrollment store (the Heartbeat, BatchHeartbeat, ReportInventory and ProxyInventory ingest paths and the shared ingestion modules they call, the Subscribe stream open and close lines, the agent registry’s other log lines that print a session-table id (removal, rollback, session-timeout, sweep and dispatch-routing lines), and the enrollment store’s own agent-id log lines in auth_db.cpp (the admin-denied warnings and the consumed, added-to-pending, approved, denied and removed-from-queue lines) and the server’s command-forward, asset-tag push and quarantine dispatch-denied lines in server.cpp): those print the id as stored. The enrollment store has already refused control bytes and , in it, so it cannot add a newline-delimited line, but an id enrolled or imported before the new-id character check may still contain a space, = or a non-ASCII byte, and so can add same-line key=value tokens. AuthDB’s agent-id check also accepts U+0085, U+2028 and U+2029, which a log reader that splits on Unicode line breaks (a splitlines()-style reader) treats as line breaks, so such an id can still appear to start a new line to that reader. The exception text of the ingest catch-alls on those paths is printed as is. #4665 closed the rest of this list: every other rule-id, Spark-key, path, hive, service-name and unit-name print site across the Guardian/Spark subsystem — the legacy file, registry and service guards, SparkEngine, the Guardian engine’s arm/baseline/rule-parsing messages, the Spark runtime’s own lines that name only a watched key, the server’s Guardian push enforce-downgrade warning, and the event store’s own error lines — is now neutralised too. A rule id on any of these lines is wrapped the same way as on the lines above (log_id_token: control bytes, DEL, space, =, , and every non-ASCII byte fold to _, then a 256-byte head/tail shorten), so the same Disk_Full-style search still finds it. A path, registry hive/key, service or unit name embedded in a '...'-quoted fragment is wrapped with the newer, more permissive log_key_token (common/include/yuzu/log_token.hpp): it preserves spaces and non-ASCII bytes, so a real Windows path or a non-ASCII name still reads naturally, and folds only control bytes, DEL, and three Unicode line-separator sequences (NEL, LINE SEPARATOR, PARAGRAPH SEPARATOR) to _ — the minimum needed to stop a forged physical log line. On any of these lines a new physical line cannot be forged from an id or key. The agent’s hostname on the “Agent registered” line is not wrapped this way: it takes the same closed-set fold as an id (control bytes, space, =, , and every non-ASCII byte become _, 256-byte head/tail shorten), so a hostname such as x' plugins=0 id=victim ' appears as x'_plugins_0_id_victim_' and cannot add a same-line token. The registration-path lines that fold agent-supplied values are the registration, update-check and update-download request logs, the agent certificate signing logs on the registration path, and the agent registry’s registration and revoke logs; the update-download admission and identity-rejection lines fold control characters only, as described below. A same-line key=value token can still be forged where a value is wrapped with log_key_token (paths, registry keys, service and unit names, the Guardian ingest error text, and on the registration lines tag keys and store error text), and likewise where the control-byte-only fold is used (gateway node names, cluster ids, session ids and plugin names on the gateway stream-status, heartbeat and inventory lines, and the update-download admission and identity-rejection lines), because that wrapper keeps spaces and =. The control-byte-only fold also leaves U+0085, U+2028 and U+2029 in place, so a log reader that splits on Unicode line breaks can still see a line break in those gateway node names, cluster ids, session ids, plugin names and update-download lines (the exception is the session ids on the ProxyRegister adopted, lost-race and success lines and on four stream-status lines, which are wrapped with log_key_token and so have those three code points folded too). Store and database error text in these log lines is shortened to 256 bytes (head and tail kept), so the later hosts of a multi-host libpq message can be cut from the log line; the connection pool keeps the full text in memory for its last-error accessor. A regression-net test (tests/test_guardian_spark_log_injection_tripwire.py) fails the build on a future unwrapped call site in this subsystem — a lexical scanner with documented blind spots (for example it cannot trace a value wrapped several lines above its print site through local-variable construction, nor a value copied into a renamed local; the full list is in the test’s own header comment). They are ordinary log output, so how long they are kept is decided by your log pipeline, not by the Guardian event retention period, and they are not an audit record.

Impact. Log volume only. There is no API, schema, metric, alert or wire change, and no change to any detection, dispatch or ingest decision. Nothing to do on upgrade.

If log volume matters. There is no dedicated switch for these lines. The only lever is the log level, and it is blunt: warn also suppresses every other info line, including on the server the authentication and session lines (for example User '…' authenticated, Local session created) and the API-token created/revoked lines, which a SIEM ingesting the server log would then lose, and the Guardian arm/commit messages on an agent. It also silences the agent’s own “Received signal, shutting down…” line (an ordinary info-level call since #4666 PR-2): at --log-level warn or above that line is absent entirely on a clean SIGINT/SIGTERM/Ctrl-C stop, so if you rely on it to confirm an intentional stop, keep agents at info or below, or check the process exit code (0 = clean) instead. The audit log is a separate store and is not affected by the log level. Prefer applying it to agents only, and weigh it before applying it fleet-wide.

Retiring or gating these lines once the benchmark concludes is recorded in docs/spark-flip-gate.md §7.

vNEXT — gateway-fronted agents stay dispatchable across circuit-recovery replays (HA WS-4 4.4, #4246 #6; NOT breaking)

New, non-breaking, purely additive. No operator action required.

Before this change, a gateway that lost and regained its connection to the server (a replica failover or an ordinary network blip) would replay its held agent registrations — and on EVERY such replay, the server wiped that agent’s placement (gateway_node/capabilities) before deciding whether to reuse or refuse the session, silently making the agent unreachable via that gateway until it happened to reconnect on its own. This was reachable on a single, otherwise-healthy replica; no core restart was required.

This section applies to circuit-recovery replays, not to a server-only restart while a gateway stays connected; for that case see What happens when the server restarts (a gateway at this version re-registers the sessions the server reports unknown).

What changes: the server now decides adopt-vs-refuse for a replayed session before installing anything, the gateway re-announces the agent’s own connection to converge placement on a successful adopt, and a genuinely superseded/stale replay is refused outright rather than silently accepted. New Prometheus counters (yuzu_gw_upstream_notify_dropped_total, yuzu_server_gateway_route_desync_total{outcome="session_superseded"}) give visibility into both the normal drop path and a refused replay. At fleet-wide reconnect-storm scale a re-announcement can still be dropped under load; that case is no longer stuck forever — the row now ages out and is purged within the route’s existing lease TTL+grace window instead of being kept alive indefinitely by ordinary heartbeat renewals. This makes the stuck row observable (a new drop counter, plus the shortfall desync outcome on the next heartbeat) and eligible for reclaim, but it is not an instant fix — actual re-convergence still needs the next circuit-recovery replay or the agent’s own reconnect. See docs/adr/2002-high-availability-architecture.md §7c for the full mechanism and two remaining known limitations: (1) a large fleet recovering from an outage longer than the lease grace window sees a transient wave of reclaim activity rather than instant convergence, and (2) a replay refused because the routing directory itself was degraded at that moment is not retried within that recovery cycle and can strand an agent server-unknown until its own next reconnect (#4634).

Update (#4632, 2026-10-05): a gateway that includes #4632 holds a re-announcement it cannot send at once (10 notifications already in flight, or the circuit breaker not closed) and sends it first in first out, instead of dropping it as described above. It is lost at admission, and counted in yuzu_gw_upstream_notify_dropped_total{reason="at_capacity"}, only when the store is full: notify_pending_max keys are held, counting the key of a half open probe that is out. A notification lost after admission (a failed or killed worker, or a gateway stop with entries held) is not counted there. See “Stream Status Notification” in gateway.md.

vNEXT — multi-cluster gateway mode now binds each agent to its own cluster, closing a claim-then-answer hijack (#4669; NOT breaking, gateway-fronted multi-cluster deployments only)

New, non-breaking, purely additive. No operator action required for single-gateway (non-multi-cluster) deployments — read on only if you run --gateway-cluster-addr (multi-cluster gateway mode).

Before this change, a rogue or compromised gateway process could re-register an agent identity that was already approved and live on a DIFFERENT cluster (ProxyRegister needs no per-agent secret for an already-approved agent), then legitimately answer for it — intercepting command payloads and forging terminal results for that agent, despite each --gateway-cluster-addr entry conceptually representing a separate trust zone.

What changes: each agent now durably binds to the first cluster that legitimately confirms its connection (trust-on-first-use). A later claim from a DIFFERENT cluster for an already-bound agent is refused — for the ordinary case, before anything is published to the routing directory or the in-memory dispatch path; on a definitive conflict caught only at the durable write (a rare defense-in-depth case — the fast pre-check missed it, e.g. under a transient store hiccup), an in-memory placement already published moments earlier is reverted rather than left live — and the refusal is audited (gateway.cluster_affinity_violation, see audit-log.md) and counted (yuzu_server_gateway_route_desync_total{op="announce_connected",outcome="cluster_affinity_violation"}, alerted via YuzuGatewayClusterAffinityViolation). An agent’s affinity re-binds when the previously-bound cluster has been genuinely unreachable for the full stale-route grace window and no session is currently claiming the row — a single rogue registration alone (with no completed connection) parks the row instead, preserving its affinity, precisely so a rogue cannot force a re-home just by registering (an operator-forced re-home route is tracked as a follow-up, #4696 — not available in this release).

Rollout window — read this if you run multi-cluster gateway mode today. Every agent that was already connected before you upgrade to this version has NO bound affinity until its NEXT CONNECTED notification — until then, that agent is still open to a same-shape claim from any cluster, exactly as before this fix. Long-lived gateway↔agent connections may not reconnect on their own for a long time. To close this window immediately across your whole fleet, force your agents to reconnect after upgrading — each forced reconnect binds that agent’s affinity right away rather than waiting on an organic reconnect. Restarting your gateway processes is one way to force that, but restart them only if you accept the risk described in gateway.md “What happens when the server restarts”: a gateway restart disconnects every agent it fronts, and on agents without the #5183 fix (shipped in 0.15.0) it can leave released agents wedged (if you do, go one node at a time).

This does not provide full trust-zone isolation between clusters — a gateway’s claimed cluster_id is still not cryptographically bound to its actual identity (mitigation 2, tracked separately, not implemented). Do not present multi-cluster gateway mode as providing that guarantee. Multi-cluster gateway mode has no production deployments as of this release.

vNEXT — a command forwarded to a gateway-connected agent always resolves instead of getting stuck at RUNNING (#4672; NOT breaking)

New, non-breaking, purely additive. No operator action required.

Before this change, a command dispatched to a gateway-fronted agent (multi-cluster gateway mode — see ha-postgres.md and ADR-2002 §7) could get stuck at RUNNING forever with no terminal signal if the forward itself failed: the gateway’s mgmt-plane peer pin rejecting this server’s certificate, the gateway staying unreachable after retries, a target cluster with no configured --gateway-cluster-addr, or a response that could not be attributed to the intended agent. The executions drawer and any API caller polling that command’s status saw it idle indefinitely.

What changes: every one of those cases now resolves the command to a terminal FAILURE, at most once, with error_detail (the field the REST/MCP response surfaces — there is no separate structured error.code) prefixed with a specific reason code you can use to diagnose the cause, e.g. [gateway_unavailable] Gateway unreachable after 3 attempts — command not delivered:

Reason code prefixMeaningWhat to check
gateway_unauthenticatedThe gateway’s mgmt-plane peer authorization (the SPKI pin, #1422, and/or the server identity, #6016) rejected this server’s certificateThe server’s mgmt-plane leaf cert and the gateway’s mgmt_peer_auth_mode / mgmt_peer_pins configuration agree; the reason is on the gateway’s yuzu_gw_mgmt_auth_rejected_total{reason} (see Authorizing the server by its identity)
gateway_unknown_clusterNo --gateway-cluster-addr is configured for the agent’s clusterServer startup flags / the compose/env configuration for that cluster
gateway_unavailableThe gateway was unreachable after 3 retry attemptsGateway process health, network path between server and gateway
gateway_agent_mismatchThe gateway answered for a different agent than the one this command targetedPossible cross-cluster response forgery or a stale cluster resolution — treat as a security-relevant signal, not routine noise
gateway_forward_failedAny other gateway SendCommand RPC failureThe gateway’s own logs for the specific gRPC error

Recovery: there is no automatic re-drive for a gateway-forward failure — re-dispatch the command once the underlying cause is fixed. Automatic durable retry is tracked as a follow-up in #4690. One case is not yet covered by this fix: a gateway response stream that closes cleanly with zero frames still leaves the command stuck at RUNNING (tracked separately, #4691).

vNEXT — human API-token self-rotation is now reachable under the default config, and covers your own MCP-tiered/scoped tokens (#2963; NOT breaking)

New, non-breaking, purely additive. No operator action required.

Before this change, POST /api/v1/tokens/{id}/rotate/.../confirm (and the MCP twins rotate_api_token/confirm_api_token_rotation) composed the shipped RBAC-off default with the store’s self-service-only ownership check into something reachable by nobody but an admin out of the box — a plain non-admin owner of a token got 403 trying to rotate their own credential. Separately, a dashboard/cookie session could never rotate or confirm its own MCP-tiered or service-scoped token — only that exact token’s own credential could, which was backwards precisely when the token’s secret is under suspicion.

What changes: any authenticated, non-admin owner of a token can now self-rotate it under the default configuration, and a plain interactive session can now rotate/confirm any of its own tokens regardless of that token’s own tier/scope. Nothing that previously succeeded now fails — this only widens which previously-403/400’d callers now get 200. The minted successor still always inherits the token’s own tier/scope and expiry verbatim; no caller can mint a credential broader than the one it replaces. A token within 24 hours of its own expiry still cannot be rotated (mint a new one instead) — unchanged, by design.

See docs/user-manual/authentication.md “Rotating a Token” for the full operator-facing detail, and docs/security-reviews/2963-token-rotation-default-permission-2026-09-17.md for the decision record.

vNEXT — CRL publishing is serialised in Postgres, and a missed CRL now republishes itself (HA WS-6 6.1, #4126; NOT breaking)

Every CRL publish (startup, an operator revoke, a subordinate-CA import, and the freshness re-publish) now runs as one Postgres transaction under a lock on the ca_store.ca_crl_versions table, instead of behind a lock inside one server process. The ca_store schema migrates to v4: v3 adds a nullable revoked_count column on ca_crl_versions (nothing is backfilled), v4 adds a trigger that refuses to delete or change a revoked ca_issued row. A subordinate-CA import (POST /api/v1/ca/import-chain) now waits for any CRL publish already in progress before it swaps the root; if that takes longer than the database’s lock timeout (10 s by default) the import fails with a database error and can simply be retried.

What changes on every deployment, including single-server:

Single-server remains the only supported topology. If you nevertheless run two server versions against one database during an upgrade, a publish from the older binary does not take the lock and can still publish a CRL that omits a revocation the newer binary just recorded; the newer binary’s freshness pass republishes to cover it (and can do so on every tick for as long as the older binary keeps publishing).

Rollback to the previous release is safe: an older binary boots against the v4 schema and ignores the new column. It will not self-heal a missed CRL (rolling forward again republishes once), and its default-cert inventory purge fails while any revoked default leaf exists (logged, harmless) because the trigger stays in place — which is the point: it cannot un-revoke anything. Multi-replica PKI also still needs WS-6 slices 6.2 (enrollment) and 6.3 (CA key and KEK custody): today the freshness pass runs only on the elected leader, and a leader whose CA directory lacks the CA key can never publish.

vNEXT — the server now elects a background-work leader at startup (HA WS-3; NOT breaking)

New, non-breaking, and inert on a single-server deployment. As one step toward making a second server replica safe, the singleton background loops that dispatch — scheduled instructions, policy remediation, quarantine containment reconciliation, and the CRL freshness re-publish — now run only on a fenced leader elected over a dedicated Postgres coordination connection (ADR-2002 §3). On a single server the sole replica is always the leader, so behaviour is unchanged.

The elector alone is not a reason to run a second server replica. It ensures only the leader attempts the dispatching loops. The correctness guarantee that a paused ex-leader cannot still commit a leader-driven dispatch is the epoch fence in the claim write (the command outbox, since the follow-on slice 3.3; operator-triggered remediation is arbitrated by its own durable per-target claim instead). A supported active-active deployment additionally needs gateway-fronted routing, shared agent presence, PKI HA and the rest of the safe-to-scale gate (docs/ha-delivery-matrix.md); that gate is not lifted. Single-server is the only supported topology today.

What you will see, on every deployment including single-server, are new startup log lines — these are routine, not a fault:

If instead you see [HA] leader_elector could not open its coordination connection; FencedLeaderOnly background loops are PAUSED …, the server could not reach its Postgres coordination connection: it keeps serving and re-tries every election cycle (so a transient blip self-heals within seconds), but while the message persists the leader-only background loops (the four above, plus command-outbox delivery) do not run. This is fail-closed by design — a paused loop never double-dispatches — and a persistent occurrence is a Postgres-reachability problem to investigate, not a server bug. (The operator-triggered paths are unaffected: a manual policy remediation or evaluation, and an operator CRL revoke, run on whichever replica received the request — and a remediation/evaluation is also completed on that same replica, so it still reaches a terminal verdict even while that replica is not the leader. Only the leader-owned scheduling half — the automatic due-policy dispatch and the periodic CRL freshness re-publish — pauses.)

Leader election: metrics and alerts (HA WS-11)

Running more than one server replica is not supported today (the safe-to-scale gate in docs/ha-delivery-matrix.md is not lifted); the multi-replica wording below is for pilots and pre-GA testing. On the supported single-server deployment YuzuLeaderSplitBrain cannot fire (one series); in practice only YuzuLeaderNoLeader and YuzuLeaderCoordinationStale fire, and YuzuLeaderFlapping only on a Postgres connection flap or a restart loop whose acquire is delayed.

Each replica exposes the elector’s state as yuzu_server_leader_* series on its own /metrics (reference: metrics.md “Leader election metrics”), and docs/prometheus/yuzu-alerts.yml ships the yuzu-ha-leader group. Reload your Prometheus rule file to pick it up. Prometheus must scrape each replica individually (not through the load balancer), and unrelated Yuzu deployments sharing one Prometheus need separate jobs (the shipped scrape configs all use job_name: 'yuzu-server', so a second stack must be renamed), because the alerts aggregate sum by (job). /readyz does not include leadership: a replica that cannot lead is a ready follower, so these alerts are the only signal that the leader-only passes (scheduled dispatch, policy dispatch, quarantine reconciliation, the command-outbox delivery loop and the CRL re-publish) are paused. The thresholds below (2 m, 5 m, 6 acquisitions per 30 m, 120 s) are initial claims with no multi-replica fleet data behind them; tune them from your own history.

Every one of these alerts needs a successful scrape, and no server up == 0 rule ships. If the primary freezes while its kernel keeps acknowledging packets, the /metrics handler’s own database queries hang too, the scrape fails, every yuzu_server_leader_* series goes stale at once and every rule below evaluates empty. Configure an up{job="<your server job>"} == 0 alert (the shipped scrape configs name the job yuzu-server; use yours, with a for: of a few scrape intervals) and probe /readyz from a blackbox exporter or your load balancer as the backstop. A hung scrape becomes up == 0 only after Prometheus’s scrape_timeout. No server dead-man’s-switch rule ships: #2956 tracked it and was closed without building it.

Find the replica that is the leader with yuzu_server_leader_is_leader == 1; the cluster’s authoritative record is SELECT * FROM leader_elector.leader_state; (its holder_id matches the (holder_id=srv-…) text in the acquired leadership log line of the process that holds it).

vNEXT — Guardian compliance % and the fleet honesty banner now reflect Linux Service Guards correctly (#4252; NOT breaking)

New, non-breaking, and inert on a fleet with no deployed Service-type Guards. The /guardian dashboard’s compliance rollup previously treated every non-Windows agent as “not implemented” for every Guard type — correct for Registry/File (genuinely Windows-only), but wrong for Service, which arms (observe-only) on Linux today. A Linux agent with a real compliant/drifted status row for a deployed Service Guard was folded into that Guard’s “not implemented” bucket a second time, corrupting the ”% compliant” headline and the fleet/by-baseline breakdowns.

What you will see, if your fleet has Linux endpoints with deployed Service-type Guards: the affected Guard(s)’ ”% compliant” figure will likely move — typically upward, since the double-count previously inflated the denominator without inflating the numerator. If some of those Linux Service Guards never actually arm (see the known limitations below), part of any upward movement is those pairs going from “counted as not implemented” to “not counted at all” — also raises the %, but is coverage disappearing, not new enforcement; don’t read the whole jump as pure correctness without checking D-Bus/systemd reachability on the affected endpoints. The fleet honesty banner (the list of agents flagged as running on a platform Guardian doesn’t fully cover) also narrows: it now names an agent only if it owns an actually-unenforced Guard pair, not merely for being on a less-covered platform, so a Linux agent whose only deployed Guard is a Service Guard may disappear from that banner. Nothing about how a Guard is authored, deployed, or enforced changes — this is a dashboard-reporting correctness fix only, with no REST/MCP-visible effect (the /api/v1/guaranteed-state/status endpoint never applied this fold and is unaffected).

Two known, accepted limitations of this fold — see guaranteed-state.md for detail and metrics.md’s yuzu_server_guardian_platform_matrix_stale_total entry for the diagnostic signal: a Service Guard that never actually arms on a genuinely-supported platform (no system D-Bus — every containerized/compose Linux agent, a disabled build flag, or an invalid unit name) now silently drops out of the denominator rather than showing “not implemented”; and a status row surviving a guard-type revision on the same rule id can render a now-unenforced pair as compliant with no marker on that rule (tracked as #4263, not yet fixed).

vNEXT — scheduled instruction fires now go through a durable command outbox (HA WS-3 3.3; breaking for SIEM/audit-count assurance)

Scheduled instruction fires no longer dispatch to agents inline from the poller. The poller now (a) creates the tracked execution row and (b) commits a durable pending occurrence to a new born-on-PG store, CommandOutboxStore (schema command_outbox_store); a leader-gated delivery loop drains that outbox and performs the actual wire dispatch. No operator action is required — the store’s schema migration runs automatically on upgrade (fresh-start/ no-backfill, since this store never existed before 3.3).

Behaviour changes you will see:

Rollback note: a pending outbox row committed by a 3.3-or-later binary is invisible to a pre-3.3 binary, which has no outbox reader. Rolling back mid-flight leaves that occurrence undelivered until you roll forward again — a narrow window, and no data corruption (the row stays durable in Postgres and delivers as soon as a 3.3-or-later binary is running and holds leadership).

vNEXT — manual policy remediation is now claimed durably, cross-replica (HA WS-3 3.4; breaking for automation asserting agents == len(agent_ids))

POST /api/policies/{id}/remediate now arbitrates its per-target claim through a durable per-(policy, agent) row in PolicyStore (policy_status.remediation_claim_at), instead of an in-process-only guard. This closes the cross-replica gap noted in the ADR-0056 Follow-ups: two replicas racing a remediate call for the same policy/agent can no longer both dispatch a fix. (This is the guarantee for when the safe-to-scale gate lifts, ADR-2002 §7c; exactly one server replica is supported today.)

Scope of the guarantee. This holds between replicas running the same schema version. A rolling upgrade across the migration boundary can transiently run an old binary that predates the durable claim alongside a new one; that mixed-version window is covered today only by the safe-to-scale gate (docs/ha-delivery-matrix.md, which is procedural and not lifted), pending a durable cluster-capability admission gate (#6528). The exposure is one-directional: a row written by an old binary carries claim-generation 0, a value a new binary never mints. Separately, this is effectively-once, not exactly-once (consistent with ADR-2002): a replica that pauses for longer than fixing_stale_seconds between winning a claim and actually sending a command over gRPC — the fix, or the subsequent post-fix verify — can still dispatch that one send after a sibling has reclaimed the target — the wire send is not itself transactionally fenced. The durable claim prevents concurrent or duplicate claims; it bounds, but does not make impossible, a single late duplicate dispatch from a long-paused claim-holder.

Behaviour changes you will see:

Migration note: schema migrations v2–v4 add policy_status.remediation_claim_at (v2), policy_status.remediation_claim_gen (v3, the ABA claim-generation fence), and the remediation_claim_seq sequence (v4) — all BIGINT NOT NULL DEFAULT 0 columns plus one sequence. They run automatically on upgrade, are metadata-only, and require no operator action and no downtime.

Post-restart note: after a restart, a durable claim left behind by the previous process may briefly block re-remediation of the agents it was mid-flight for. This self-heals once the claim ages past the staleness window (fixing_stale_seconds, default 1800s) — the same window claim_due_policies’s own stranded-fixing sweep uses.

vNEXT — gateway management plane now pins its peer (#1422, breaking for custom gateway configs)

The gateway’s :50063 command plane requires, on any network-reachable listener, strict mTLS plus an SPKI peer pin (auth_fun => fun yuzu_gw_authz:check_mgmt_peer/1 and {yuzu_gw, mgmt_peer_pins}) — and the gateway refuses to boot without them (mode identity: without a resolvable server identity; mode identity_or_pin: with neither a pin nor a resolvable identity, #6016). Two upgrade orderings fail, in opposite directions:

BYO-cert installs: point mgmt_peer_pins at your server certificate — which must carry the serverAuth EKU — or paste its SPKI SHA-256; pin-list edits take effect on gateway restart. A pin is not the only way to admit the server any more (#6016): with the built-in certificates the gateway can instead admit it by the identity URI SAN yuzu://<install>/server that the install CA puts on the server’s certificate (mgmt_peer_auth_mode identity_or_pin or identity), which needs no gateway change when the server’s key is renewed when no pin is configured (a pin kept in identity mode must follow the renewal). It is an opt-in: the shipped reference configs and an unset mode are pin, and the identity trusts the issuing CA’s issuance policy, so it must not be enabled behind a CA that lets a requester choose a URI SAN. Rolling the gateway back to a binary from before #6016 ignores these settings: the deny-list is no longer honoured, and a configuration with no pins (dropped for identity, or identity_or_pin running on the identity alone) refuses to boot (no_mgmt_peer_pins); restore the pins, matching the key the server presents now, first (see the “Rolling back” paragraph of that section). The rollback is a gateway restart, so do it only if the node’s agents can take the disconnect, because agents without the #5183 fix stay wedged after it. Bring-your-own certificates, --no-default-certs and a server given its own --cert/--key carry no identity and stay on pins. See Authorizing the server by its identity and docs/adr/2004-gateway-mgmt-server-identity.md. {allow_insecure_mgmt, true} is a lab-rig acknowledgement only; never set it where :50063 is reachable from untrusted networks.

vNEXT — the wifi plugin’s Linux output changes shape (breaking)

The wifi plugin moves to reading NetworkManager directly over D-Bus on Linux (plugin version 1.1.0). Three operator-visible changes to the records it emits:

During a staged rollout the two shapes are mixed, and the record does not say which one it is. An un-upgraded agent reports the profile name in field 6 and an upgraded agent reports the interface, with no discriminator in the row — unlike the firewall change, where a backend| value distinguished per device. Until the fleet is fully upgraded, treat field 6 as free text: match on agent version rather than on the field’s content, or defer any query that keys on it. SELECT on the plugin version, not on the value.

On macOS 14 and later list_networks still normally returns no networks. airport was removed in that release, and the system_profiler fallback the plugin now tries needs a Location Services grant that a background daemon does not normally have. Without it the plugin reports a single wifi|info row (wifi:macos_scan_requires_location_services) instead of an empty scan, so the cause is visible; networks appear only on a host where Location Services has been granted.

vNEXT — event_logs acquires natively; Windows message text and count semantics change (breaking)

The event_logs plugin now reads the event log in-process — wevtapi on Windows, sd_journal on Linux (falling back to a bounded journalctl invocation where libsystemd is unavailable) — replacing the previous PowerShell and shell-out legs. Automation that parses event_logs rows needs review before upgrading; the changes fail silently (fewer rows, or a rule that stops firing), not with an error.

What changes for a row consumer:

Mixed-fleet blend during rollout. Upgraded and non-upgraded agents emit different message columns for the same Windows event, and there is no per-row field identifying which leg produced it. Expect a blended view until the fleet is fully upgraded, and prefer event ID plus provider for any rule that must hold across both.

vNEXT — a duplicate SCIM externalId now refuses to boot (ADR-2001, CC6.8) (breaking)

What changed. This release adds a partial unique index, scim_resources_external_id_uniq ON scim_resources (external_id) WHERE external_id IS NOT NULL, applied by the ScimStore migration that runs at construction — unconditionally, regardless of whether --scim-enable is set. Postgres itself detects any pre-existing duplicate non-empty external_id and raises a unique_violation, which fails the migration; ScimStore then reports !is_open() and the server refuses to start, the same fail-closed posture every born-on-PG store uses on a failed migration. This is deliberate, not a bug to work around: a duplicate externalId is exactly the mis-link hazard ADR-2001’s SCIM↔OIDC token-revoke linkage exists to prevent (a duplicate would let link formation pick a SCIM resource arbitrarily and revoke — or fail to revoke — the wrong principal’s tokens), so the index is a stronger posture than what shipped before, not a regression to relax.

Who this affects. Any deployment with existing SCIM data (has ever run --scim-enable, even if it isn’t set on this upgrade) where two or more scim_resources rows share the same non-empty external_id — most likely from an IdP connector bug, a manual data-fix gone wrong, or a resource re-provisioned under a new SCIM id without the old row ever being cleaned up.

Before upgrading, run this against your existing database to check:

SELECT external_id, COUNT(*) FROM scim_store.scim_resources
WHERE external_id IS NOT NULL
GROUP BY external_id
HAVING COUNT(*) > 1;

If it returns any rows, the upgrade will fail to boot until you resolve the duplicates first — deactivate or delete the stale/incorrect row(s) for each external_id this query surfaces (keep whichever row is the IdP’s actual current resource), then re-run the query until it returns nothing. If it returns no rows, this note does not affect you and the migration applies silently on next boot.

vNEXT — pre-auth request-body caps now apply to (almost) every route, not just /mcp/ (#2407) (breaking)

What changed. Before this release, only the /mcp/ ingress was capped ahead of authentication (4 MiB, #2437); every other route was bounded only by httplib’s own 100 MiB server-wide default, so an unauthenticated caller could make the server buffer up to 100 MiB per connection on any route. The server now resolves a per-route-class cap from a single table (body_cap_policy.hpp) at the same pre-routing chokepoint, before the body is read at all, including GET/HEAD requests (a GET declaring a Content-Length still buffers a body regardless of method) and the four unauthenticated health-probe paths (/livez, /readyz, /health, /api/health), which were previously the last unauthenticated 100 MiB buffer on the server because their early-return sat above the cap; they now hit the cap first and remain exempt from the on-behalf-of guard and the general rate-limit bucket (/health and /api/health requests that carry a credential header are counted in a separate per-source bucket, see Health Endpoints). Separately, a request declaring a compressed (non-identity) Content-Encoding is now refused outright (415) on every class, because httplib decompresses transparently and bounds only the decompressed size against its own 100 MiB global limit; a sub-cap compressed body could otherwise expand to ~100 MiB before this cap (or any handler) ever saw it. Full per-class numbers and the error contract: docs/user-manual/rest-api.md “Pre-Auth Request Body Caps”.

What a client sees.

Who this affects. Any integration that was sending a body larger than its route’s new class cap and relying on httplib’s 100 MiB backstop to accept it. The most likely surface to notice: a very large POST /api/v1/guaranteed-state/rules, /api/workflows, or /api/product-packs body — those three classes are capped at a generous but explicit 16 MiB (a judgment call, not a measured contract, because no aggregate size limit for that content exists yet) rather than the previous 100 MiB backstop. Most other routes had a smaller handler-level check already (response templates, CA import) — for those, the effect really is just an earlier rejection at the same byte count (before the body is buffered rather than after). Also newly affected: any client or intermediary that sends a compressed request body — that is now refused outright with 415 on every class (see “What changed” above); no shipped Yuzu client compresses request bodies, so this is expected to affect only a custom integration or a reverse proxy doing transparent compression. One class is not the simple “earlier rejection, same effect” case, and clients should not assume “no behavior change” from it: Echo queries (POST /api/dashboard/echo-execute, POST /api/v1/result-sets/from-echo-query) — these two classes’ caps as originally shipped bounded the RAW body at the same byte count their handlers check on a DECODED/PARSED value, which is not the same number; that unit mismatch caused some legitimate queries to be rejected here that the handler would have admitted. Both caps carry a margin now (see docs/user-manual/rest-api.md for the sizing), so this class is now also a clean earlier-rejection-only change going forward. SCIM is no longer a special case either (fixed as part of this same change, D7): the pre-routing rejection now publishes SCIM’s own RFC 7644 §3.12 application/scim+json error shape, matching what a strict SCIM client already expected from the handler’s own (now-superseded) check.

How to raise a cap. Edit the table in server/core/src/body_cap_policy.hpp (with review) and the matching row in docs/user-manual/rest-api.md. Do not reach for httplib’s global Server::set_payload_max_length — that knob is shared by every route on the listener, including the ~70 MiB live-query bundle route and the OTA agent-binary upload, so a single global value can’t fit every route class at once. Rejections are visible per class via yuzu_body_cap_rejected_total{path_class,reason} — see docs/user-manual/metrics.md.

vNEXT — /auto deployment execute now enforces per-device Execution:Execute confinement (breaking)

What changed. /auto deployment advance (staging + executing an installer on a pre-flight run’s go-cohort) previously dispatched every content_dist.stage/content_dist.execute_staged command under the server’s own system authority, bypassing the caller-identity check the chokepoint performs for every other operator-facing dispatch surface — the check the route’s own permission gate depends on to confine which devices an operator’s Execution:Execute grant actually reaches. It now dispatches under the triggering operator’s real identity and — as of a second, related fix landing in the same release — is confined to that operator’s Execution:Execute-visible device set at the point of dispatch, the same per-device confinement RestApiV1/WorkflowRoutes/BundleOrchestrator/McpServer/DashboardRoutes already enforce.

Who this affects. Only deployments where a role that can trigger /auto deploy (Infrastructure:Read + SoftwareDeployment:Execute, per the permissions table in preflight.md) holds a management-group-scoped Execution:Execute grant — narrower than the full fleet — rather than a global one. For that role, a deployment whose go-cohort includes a device outside the role’s Execution:Execute scope will now correctly skip that device (visible as skipped in the deployment’s device list) instead of executing on it. A role with a global Execution:Execute grant sees no change.

Before upgrading, check for any role combining a scoped Execution:Execute grant with SoftwareDeployment:Execute — that combination is the only one affected. If none of your deployment-triggering roles hold a scoped (rather than global) Execution:Execute grant, this note does not affect you.

vNEXT — an authorization-topology floor now applies regardless of RBAC, and engine-principal reads move off Security:Read (#2376) (breaking)

This note has two independent breaking directions — read both, they affect different deployments.

Direction 1 — RBAC-disabled installs lose three non-admin reads.

Who this affects. Any deployment running with [rbac] enabled = false (the shipped default) where a non-admin, authenticated session was relying on reading one of: the fleet-wide access-review export (/api/v1/access-reviews*), GET /api/v1/rbac/roles, or the engine-principal inventory/roles (/api/v1/engine-principals* and the list_engine_principals/get_engine_principal/list_engine_roles MCP tools). RBAC-enabled deployments are unaffected by this direction (see “Why” below).

Why. RBAC ships disabled by default. With RBAC disabled, the legacy authorization fallback previously allowed every authenticated non-engine session to perform every Read — including reads of the authorization topology itself. A plain non-admin user on a default, just-installed server could read who holds what role and pull the complete access-review grant population that exists to be SOC 2 CC6.2 evidence. As of this release, AccessReview:Read, UserManagement:Read, and EnginePrincipal:Read (see Direction 2) require the admin session role regardless of the RBAC toggle. This is not configurable — there is no setting that reopens it. Full design rationale: docs/security-reviews/authz-topology-floor-2026-08-05.md.

What does NOT change. A live RBAC grant is unaffected — under RBAC-enabled enforcement, a non-admin holding the seeded Reviewer role still reaches the access-review export exactly as before; a non-admin holding UserManagement:Read/EnginePrincipal:Read (e.g. the built-in Viewer role holds both) still reaches the other two reads. The floor only engages inside the RBAC-off legacy fallback.

What to do. If a non-admin session needs one of these three reads, enable RBAC and grant the matching role:

[rbac]
enabled = true

See rbac.md “The authorization topology floor” and “Before enabling RBAC in production” before flipping the toggle on a live fleet — turning RBAC on also applies management-group-scoped device visibility immediately.

Direction 2 — RBAC-enabled deployments with a custom role granted Security:Read for engine-principal access must re-grant.

Who this affects. Any RBAC-enabled deployment with a custom role (not Administrator/Viewer, which are updated automatically — see below) that was granted Security:Read specifically so its holders could reach GET /api/v1/engine-principals* or the list_engine_principals/get_engine_principal/list_engine_roles MCP tools.

Why. Those reads moved off Security:Read onto a new, narrower EnginePrincipal:Read securable (the same move #2324 made cutting AccessReview away from AuditLog:*) — Security:Read also gates unrelated operational reads (quarantine visibility, CA issued-certs, /ca/root-csr, KEK status) that stay on Security:Read unchanged and un-floored.

What changes automatically. No schema migration is required — the new EnginePrincipal securable and its Administrator (full CRUD) and Viewer (Read) grants are seeded idempotently on every server boot (RbacStore::seed_defaults()’s INSERT OR IGNORE loops), so the two built-in roles pick it up on the next boot automatically, with no operator action, matching exactly the access they held via Security:Read before this change.

What to do. A custom role does not get this automatic re-seed. Check whether any custom role was granted Security:Read for this purpose, and if so, grant it EnginePrincipal:Read too — via the Settings UI or RbacStore::set_permission() directly. Security:Read alone no longer reaches /api/v1/engine-principals* or its MCP twins after this upgrade.

Direction 3 — API consumers of GET /api/v1/discover/permissions (and the MCP discover_permissions twin) lose the role grid unless they hold UserManagement:Read.

Who this affects. Anything reading the discovery catalogue’s roles[].permissions[] under a principal that holds Infrastructure:Read but not UserManagement:Read.

What changes. The route still returns 200 and the full securable_types/operations taxonomy — that half is unchanged and still needs only Infrastructure:Read. The roles key is replaced by "roles_omitted": true plus a roles_omitted_reason. A consumer that iterates roles must handle its absence; one that checks roles_omitted gets an unambiguous answer.

Why. That grid is authorization topology, and strictly more of it than GET /api/v1/rbac/roles discloses — which this same change floors. Leaving it on the unfloored Infrastructure:Read, which every authenticated session holds on an RBAC-off install, left the floor reachable around. Flooring Infrastructure:Read itself was rejected: it gates ordinary operational reads and would have repeated the too-coarse mistake this change avoided with Security:Read.

What to do. Grant the consuming principal UserManagement:Read if it legitimately needs the grid, or update it to read roles_omitted. Do not treat a missing roles key as “no roles exist” — that is precisely why the omission is declared.

Check your automation, not just your operators — this is the failure that hides. Neither breaking direction produces a startup warning or a migration prompt. Both surface only as a 403 at some later moment, and that moment may be inside a script rather than in front of a person. Audit anything non-interactive that reads the three floored surfaces (/api/v1/access-reviews/export, /api/v1/rbac/roles, /api/v1/engine-principals* and their MCP twins) under a non-admin token:

Either enable RBAC and grant the job’s principal the appropriate role (Reviewer for access reviews, UserManagement:Read for /rbac/roles, EnginePrincipal:Read for engine-principal reads), or move the job to an admin credential.

One caveat on revoking a built-in role’s grant. Because the seed loops above run on every boot, a grant you deliberately remove from a built-in role — for example revoking Viewer’s EnginePrincipal:Read — is re-inserted on the next restart, with no audit line distinguishing it from an operator-set grant. This is long-standing behaviour for every seeded securable, not new here, but it is worth knowing before you narrow a built-in role: express the narrowing as a custom role or an explicit deny row instead, both of which survive a restart.

vNEXT — three new securables for directory-sync and enrollment reads; Viewer auto-gains Directory:Read (#4031) (breaking)

Who this affects. RBAC-enabled deployments with a custom role that reads AD/Entra directory-synced users, or that previously reached the directory-sync status / enrollment auto-approve rules / pending-agent list / OIDC config surfaces before this release added dedicated REST v1 routes for them.

What changes automatically. Three new securables — Directory, Enrollment, OidcConfig — are seeded idempotently on every boot (RbacStore::seed_defaults()), same mechanism as EnginePrincipal above. Their built-in-role grants are not symmetric, unlike EnginePrincipal:

What to do. No action needed for Administrator/Viewer — the seed loop picks these up automatically. If a custom role needs Enrollment:Read/OidcConfig:Read, grant it directly (no built-in non-admin role holds either). If a custom role’s exposure to directory-user PII via an inherited Viewer-shaped permission set is a concern, review it explicitly — the auto-grant is intentional but new.

Also new in this release: the legacy GET /api/directory/users route (pre-existing, not new in #4031) previously issued no audit call at all despite returning PII; it is now audited as directory.users.view, though via a fire-and-forget path that cannot detect a dropped audit row — see audit-log.md’s directory.users.view row for the full three-way posture (REST v1 fail-closed / MCP set-and-proceed-with-signal / legacy silent).

vNEXT — the audit trail is admin-only while RBAC is disabled (#5771) (breaking)

Who this affects. Deployments with RBAC enforcement off (the shipped default; see GET /api/v1/rbac/enforcement) where a non-admin, authenticated session or API token reads the audit trail — a SIEM export, an evidence-collection script or a dashboard user signed in as a plain user. RBAC-enabled deployments are unaffected.

What changes. AuditLog:Read joins the authorization topology floor (see the #2376 note above). With RBAC disabled, every audit read surface now requires the admin session role: GET /api/audit, GET /api/v1/audit, GET /api/v1/audit/auth-sample, GET /api/v1/engine-principals/audit/no-admin, the MCP query_audit_log and audit_engine_no_admin tools, and the yuzu://audit/recent resource. A non-admin gets 403, audited with a detail beginning topology floor: non-admin role denied AuditLog:Read (match on the prefix: an MCP-tier token’s row appends (mcp_tier=<tier>)) and counted in yuzu_auth_topology_floor_denied_total{permission="AuditLog:Read"}. Why: the trail is the SOC 2 CC6.1/CC7.2 evidence chain, and the legacy route also returns each actor’s role and source IP.

What does not change. An admin-role session, and an MCP token minted by one, still read the trail. Under RBAC-enabled enforcement a role holding AuditLog:Read (the seeded Administrator, PlatformEngineer, Operator, ITServiceOwner and Viewer, or any custom role granted it) is admitted exactly as before. Engine principals and service-scoped tokens were already refused with RBAC off.

What to do. If a non-admin identity needs the trail, either enable RBAC and assign it a role that holds AuditLog:Read, or run the automation as an admin-owned token. See rbac.md “The authorization topology floor”.

vNEXT — approval tickets outstanding at the upgrade must be re-requested (#2442) (breaking)

Who this affects. Any deployment holding an MCP approval that was granted but not yet redeemed at the moment of upgrade — that is, one an agentic worker will present back to an MCP tool call. This is independent of the mcp. prefix entry below: it does not matter whether you author any mcp.-prefixed definitions, and a deployment running only Yuzu-supplied content is affected if it has an MCP approval in flight.

Scheduled approvals are not affected. A schedule redeems its own approval by matching the schedule id, not through the MCP recall, so the origin check never sees it and a scheduled run outstanding across the upgrade still fires. If nothing is awaiting an MCP recall when you upgrade, there is nothing to do.

Rolling BACK across this release re-opens the check, and rolling forward does not close it again — but as of this same release, it does not stay open either. The back-fill runs once, as a schema migration. An older server started against an already-migrated database does not re-run it and does not refuse to start; approvals it mints while you are rolled back record no minting surface at all (the column is never set). Rolling forward will not revisit those rows, because the migration has already run — but the MCP mint in this same release always declares its own surface (see the “MCP mint now declares its own surface” entry below), so a row with no recorded surface is no longer the value that grants: it is refused at recall exactly like a declared foreign surface, not exempted. There is no window in which a rollback-minted ticket is redeemable once you are back on a current server. Treat a roll-back as a window during which such tickets simply won’t work later — mint fresh ones after rolling forward rather than relying on anything minted during the roll-back.

What happens. This release records which surface minted each approval, and rows that predate the column carry no surface at all. Rather than assume one they may not have come from, the upgrade labels them with a sentinel that fails closed — so an approval granted before the upgrade is refused at recall. It is reported as approval already used (one-time ticket) even though it was never consumed: that message is deliberately identical for every refusal so the recall cannot be used to probe which surface minted a ticket, and that is why this note exists rather than the error explaining itself.

Recover by calling the tool again without approval_id to mint a fresh ticket and have it re-approved. Nothing is lost but the review. The affected population is only what is outstanding at upgrade time, it does not grow afterwards, and approvals expire after 7 days regardless. On the server side the refusal is distinguishable: the audit row records refused: foreign_origin, not the uniform client message.

vNEXT — the MCP mint now declares its own surface; undeclared tickets stop redeeming (#2442) (breaking)

Who this affects. Any deployment holding an MCP approval that was granted but not yet redeemed at the moment of upgrade — the same population as the entry above, widened. The previous release closed the check for a ticket a REST or scheduled mint declared as its own surface; an MCP-minted ticket was still exempt, because the MCP gate could not yet declare kMcp itself. This release removes that exemption: the MCP mint now stamps every new ticket kMcp explicitly, and an undeclared ticket (the value an omitted origin argument used to produce) is refused at recall exactly like a declared foreign surface.

What happens. Same mechanics as the entry above, same client message (approval already used (one-time ticket)), same audit distinction (refused: foreign_origin). The only difference is which population is now caught: any MCP ticket minted by a pre-upgrade server — every one of them, since none could declare kMcp before this release — that is still outstanding when you upgrade.

Recover the same way: call the tool again without approval_id. The affected population does not grow after the upgrade — every ticket minted by the new server declares its surface at mint time, so nothing outstanding from this point on can hit this case.

vNEXT — MCP approval recall is now bound to its submitter (#2442) (intentional compatibility break, no supported flow affected)

What changes. An approval ticket’s approval_id is a bearer capability — presenting it is what authorizes the recall — and an id can be disclosed to a third party: GET /api/approvals returns the full id to any principal holding Approval:Read, seeded to the Viewer role. Until this release, a Viewer who also held the target tool’s own RBAC permission could take another operator’s approved ticket id from that listing and redeem it themselves. The MCP recall now refuses a ticket whose recorded submitter does not match the recalling principal, the same way it already refuses one minted on a foreign surface — same client message, a distinct audit token (refused: foreign_submitter) for the log.

Who this affects. No supported flow: the one production redemption path in this codebase (the MCP recall) has only ever redeemed a ticket as the same principal that minted it, verified by an exhaustive sweep of every mint and consume call site in the tree, and the non-binding was documented as this issue’s own hazard, never as an intended capability. What this cannot rule out is an external integration that relied on the old behavior to hand an approval_id between two different authenticated principals deliberately — nothing in this codebase does that, but an operator’s own tooling might have. If something in your deployment presented a ticket as a different principal than the one that minted it, that now fails; there is no supported replacement for it today (see #2442 if you need one). Nothing to do at upgrade for the ordinary case: there is no outstanding-ticket population affected by this specific change, because a legitimately-outstanding ticket was always going to be recalled by its own submitter.

vNEXT — the mcp. instruction-definition id prefix is reserved (#2442) (breaking)

Who this affects. Anyone whose instruction definitions include an id beginning mcp.. No shipped or bundled content uses that prefix, so a deployment running only Yuzu-supplied content needs no action for this item — but the outstanding-approval entry above applies regardless of what content you run.

Why. mcp.<tool> names an MCP approval ticket. At the time this shipped, the MCP recall matched a ticket on its definition id and scope expression alone, so a definition authored under that prefix could line up with an MCP tool’s canonical arguments — a ticket raised on one surface being redeemable on another. (Two later releases closed the redemption side of this directly, by binding the recall to the ticket’s recorded origin and separately to its submitter — see the two entries above. This prefix reservation is the narrower, mint-time-adjacent half: it stops a definition from being authored under mcp. at all.) Consuming a confused ticket still required the schema check, the tier gate, per-handler RBAC and a human approval, so this closes a namespace confusion rather than an open escalation.

What changes. Creating a definition whose id starts mcp. is refused with a 400 on every authoring route that accepts an explicit id — POST /api/instructions, POST /api/instructions/yaml, and POST /api/instructions/import — and such a definition is skipped (counted in defs_errored) at boot auto-import. The dashboard’s YAML validator refuses it too, so Validate and Save agree on the create path. Product-pack install is unaffected because it never carries a declared id through at all: the store assigns one, so a pack’s own id, reserved or not, has never been honoured.

What does NOT change. The store applies the rule at creation only, so a definition that already carries such an id keeps executing and can still be saved through PUT /api/instructions/{id} — an update cannot originate an id, so blocking it there would strand content rather than protect anything. One exception, and it is the reason to rename rather than live with it: the dashboard’s YAML editor runs the same validation on every save, create or update, so an existing mcp.-prefixed definition cannot be edited there if its document declares metadata.id (one that omits it still saves, since the validator only checks a declared id).

What to do. Before upgrading, check for affected content. Query the instruction database directly rather than the REST list route: GET /api/v1/definitions caps its result at 100 definitions, and the shipped content alone exceeds that, so an API-based check can report a false all-clear. Use a case-sensitive glob-style match — Postgres LIKE is case-sensitive by default, but ~ (POSIX regex) is used below for an explicit anchor — the reservation is case-sensitive: MCP.foo is a different id everywhere else in the system and is not reserved. (Superseded — instruction_definitions moved to PostgreSQL under ADR-0058; this is no longer a sqlite3 query against instructions.db, see the PostgreSQL Substrate section below.)

psql "$YUZU_POSTGRES_DSN" -c \
  "SELECT id FROM instruction_store.instruction_definitions WHERE id ~ '^mcp\.';"

Any id listed keeps executing after the upgrade and can still be edited through PUT /api/instructions/{id}, but re-importing an export of it will fail, and so will saving it from the dashboard’s YAML editor (that path validates the id on every save, not only on create). Rename those definitions before upgrading — create a replacement under a different id and delete the original.

vNEXT: the guardian. instruction-definition id prefix is reserved (#6062) (breaking)

Who this affects. Anyone whose instruction definitions include an id beginning guardian.. No shipped or bundled content uses that prefix, so a deployment running only Yuzu-supplied content needs no action for this item. Operator-authored definitions are the population this cannot rule out: the prefix reads like a natural name for a Guardian-related definition, and nothing before this release refused it.

Why. guardian.<...> is the namespace reserved for Guardian rule-deploy approval tickets (four-eyes epic #6060, ADR-1009). Keeping authored definitions out of it keeps the two namespaces separable in the store, on the Approvals page and in the audit log, in the same way mcp. is kept apart for MCP tickets (previous entry). No Guardian deploy approval exists yet in this release; the reservation lands first so that no definition has to be renamed later, when a ticket that uses the prefix does exist.

What changes. Creating a definition whose id starts guardian. is refused with a 400 on every authoring route that accepts an explicit id (POST /api/instructions, POST /api/instructions/yaml, POST /api/instructions/import) and such a definition is skipped (counted in defs_errored) at boot auto-import. The dashboard’s YAML validator refuses it as well, so Validate and Save agree. The error text is definition id may not use the reserved 'guardian.' prefix (reserved for Guardian deploy approvals); the mcp. text is unchanged, so a SIEM or script rule keyed on it keeps matching exactly what it matched before. The rule is case-sensitive and exact-prefix: Guardian.foo, my.guardian.foo and guardianx.foo are not reserved.

What does NOT change. As for mcp., the store applies the rule at creation only. A definition that already carries a guardian. id keeps executing and can still be saved through PUT /api/instructions/{id}. The one exception is the dashboard’s YAML editor, which runs the same validation on every save, create or update: an existing guardian.-prefixed definition cannot be edited there if its document declares metadata.id (a document that omits it still saves). Re-importing an export of such a definition also fails.

What to do. Before upgrading, look for affected ids. Query the instruction database directly rather than the REST list route: GET /api/v1/definitions caps its result at 100 definitions and the shipped content alone exceeds that, so an API-based check can report a false all-clear. The query is read-only and case-sensitive, matching the reservation:

psql "$YUZU_POSTGRES_DSN" -c \
  "SELECT id FROM instruction_store.instruction_definitions WHERE id ~ '^guardian\.';"

An empty result means nothing to do. For any id listed, create a replacement under a different id (for example site.guardian-baseline-check) and delete the original; update any schedule, workflow or script that refers to the old id first. Definitions you leave in place keep executing, but you lose the dashboard YAML editor and re-import for them.

vNEXT — behind a reverse proxy, declare your external origin or CSRF-gated dashboard actions keep failing (#2537)

Who this affects. Anyone running the dashboard behind nginx, Envoy, HAProxy, an ALB or Cloudflare where the proxy rewrites the Host header rather than passing the client’s value through. Direct access, and proxies configured to preserve Host, are unaffected and need no change. For nginx, the proxy_set_header Host $http_host; snippet in Security Hardening forwards the client’s Host with its port.

The symptom. Every dashboard write fails with 403 cross-site request refused: cookie-session writes require a same-site Origin or Referer, and that is decided by one server-wide check, applied after authentication and before step-up evaluation, challenge emission and route-handler effects. The dashboard renders fine, sign-in works and reads work; every write fails, including creating an API token and the step-up elevation request. Most dashboard pages do not display the refusal (the page does not swap an error response into itself), so the visible symptom is usually a button that does nothing. The response body, an error envelope with a remediation that names the flag, is visible in the browser’s network tab. The refusal is also recorded as a csrf.denied audit row, counted in yuzu_server_csrf_denied_total{path_class,reason}, and logged as a warning that names the flag (at most one log per 60 seconds per reason; the counter records all of them). The per-route message texts that earlier releases showed for individual dashboard actions are now produced only for a token caller of a route that keeps a non-token-exempt copy of the check.

Cookie-session requests that dispatch work, persist state or force a refresh of server state (the guarded requests) are held to the same rule, but a Sec-Fetch-Site header that is present decides alone: exactly one header with value same-origin or none is admitted and any other value is refused with origin_mismatch, without consulting Origin or Referer. So behind a TLS-terminating proxy, where the browser talks to the proxy over HTTPS, a proxy that rewrites Host does not refuse a browser’s own guarded requests. Only a client that sends no Sec-Fetch-Site (a script, Safari before 16.4, or a browser talking plain HTTP to a non-loopback address, which sends no fetch metadata) falls back to the Origin or Referer comparison that --csrf-trusted-origin feeds. A same-origin GET carries no Origin, so for those clients the guarded requests depend on the Referer the browser sends; the server’s default Referrer-Policy supplies it, and a proxy or page policy of no-referrer would break them. A repeated Sec-Fetch-Site header is refused and is recorded with reason origin_mismatch, even when a good Origin is sent; to cure a refusal caused by a Sec-Fetch-Site header, send it once with value same-origin or none, or remove the header so that a same-site Origin or Referer decides.

Alerts. The shipped yuzu-web-writes group in docs/prometheus/yuzu-alerts.yml watches the counter: YuzuCookieWritesRefusedSameSite (warning) fires when at least 3 origin_mismatch or referer_mismatch refusals occur within 15 minutes, which is the signature of a proxy that rewrites Host, and YuzuSameSiteGateInternalError (warning) fires on any gate_error in 15 minutes. no_origin_no_referer is deliberately not alerted, because a script without the header is expected noise. The 15 minute window and the threshold of 3 are tentative and have not been tuned against fleet data.

What the lockout does and does not do.

Why. The same-site check compares the browser’s Origin against the server’s Host. When the proxy substitutes its own Host, those two legitimately differ: the browser says https://yuzu.customer.example while the server sees yuzu-server:8080. The check was correct and the deployment was correct; there was simply no way to tell the server its external name.

The fix — declare the external origin:

yuzu-server --csrf-trusted-origin https://yuzu.customer.example
# or
YUZU_CSRF_TRUSTED_ORIGIN=https://yuzu.customer.example

Repeatable, and a single value may be comma-separated:

--csrf-trusted-origin https://yuzu.example --csrf-trusted-origin https://yuzu-dr.example
--csrf-trusted-origin "https://yuzu.example,https://yuzu-dr.example"

Two accepted forms, and the difference matters:

FormMatches
https://yuzu.examplethat host over https only: an http:// Origin for the same host is refused, including when Host is forwarded and matches
yuzu.examplethat host over either scheme

Prefer the scheme-qualified form. A scheme-qualified https:// entry also pins the scheme for that host: the server behind a TLS-terminating proxy listens in plain HTTP and cannot see the public scheme, so an https://yuzu.example entry is the operator’s declaration that https is the public scheme, and an http://yuzu.example Origin is then refused even when Host is forwarded and matches. Declare it even when the proxy forwards Host unchanged. A server that terminates TLS itself (HTTPS enabled) needs no declaration for its own address: it requires the Origin or Referer to be https:// and treats only :443 as the default port, so https://yuzu.example:80 is a different origin from Host: yuzu.example:443. Remaining limit: on a plain-HTTP listener with no https:// entry declared for the host, an http:// Origin naming the same host and port as Host is still treated as same-site.

Entries are case-insensitive, may carry a port, and any path is ignored. Wildcards are not supported — *.example is accepted by the parser but will never match anything, deliberately. The reserved token null is refused as an entry: it is the serialisation of an opaque origin (sandboxed iframes, redirected cross-origin POSTs, file:// documents), not a host, so trusting it would admit all of them at once.

Port handling follows RFC 6454 — a port is dropped only when it is the default for that entry’s scheme:

entrycanonical formnote
https://yuzu.example:443https://yuzu.example443 is the https default
http://yuzu.example:80http://yuzu.example80 is the http default
https://yuzu.example:80unchanged80 is not the https default, so it is significant
yuzu.example:8443unchangednot a default under any scheme
yuzu.example:443rejectedambiguous — a bare entry cannot say which scheme’s default this is

The last row is the one to read twice. An earlier version collapsed a bare yuzu.example:443 to yuzu.example, which then trusted both http://yuzu.example and https://yuzu.example — one declared origin silently trusting a second, which is the whole defect this canonicalisation exists to prevent. Guessing https would be a guess, and keeping the port would make the entry unmatchable (the request side canonicalises https://yuzu.example:443 to yuzu.example). So it is refused, and you write https://yuzu.example or http://yuzu.example instead.

A bare entry with no port is still deliberately loose: yuzu.example covers https://yuzu.example, https://yuzu.example:443, http://yuzu.example and http://yuzu.example:80 — but not https://yuzu.example:80, which is a different origin.

Entries are also rejected if they are empty, host-less (:443), or carry userinfo (u@host) — the last because the request side always fails closed on @, so accepting it would put an entry in the boot log that could never match.

Rejections are reported. If any supplied value is refused, the server warns at boot with the accepted-versus-supplied counts. Before this, an all-invalid config produced boot output identical to not passing the flag at all, and the first symptom was the same opaque 403 the flag exists to remove.

Confirming it took. The server prints the accepted set once at start-up, to standard output (check the container or service log). It is emitted before the log level and --log-file are configured, so --log-level does not hide it and it is not written to --log-file:

CSRF same-site gate: accepting 1 operator-declared external origin(s) in addition to the request Host — https://yuzu.customer.example

If that line is absent, or lists something other than what your users type into the address bar, the 403s will continue. It is the fastest diagnosis for a mistyped entry.

When no valid entry is configured the server instead prints one line stating the rule and that no --csrf-trusted-origin value was accepted, with the same advice for a Host-rewriting proxy.

What has NOT changed. A request whose Origin already matches Host behaves exactly as before, so an unproxied deployment sees no difference for browsers. Leaving the flag unset means same-host only, which is correct for any deployment that does not rewrite Host. A request authenticated only by an API, MCP or engine token is not subject to this check.

What did change. A cookie-session write that carries neither Origin nor Referer is refused 403, including one from curl or other automation; use an API token or send the header. The guarded requests (cookie-session requests that dispatch work, persist state or force a refresh of server state) are refused 403 unless they carry one Sec-Fetch-Site header with value same-origin or none, or, when they carry no Sec-Fetch-Site header, a same-site Origin or Referer.

Deliberately not implemented: X-Forwarded-Host is never consulted. Reading it would let anyone able to reach the server’s port declare their own external hostname and defeat the CSRF check outright. Gating it on a trusted-proxy CIDR does not rescue that — on the container networks the reference composes use, “inside the CIDR” is usually every sibling container, and the failure mode is silent: the dashboard keeps working while the control is dead. A config value cannot be set by an attacker, which is why the trust anchor here is a flag and not a header.

For the same reason, never configure the proxy to inject or rewrite Origin or Referer: that turns the control off for that deployment. Declare the public origin with --csrf-trusted-origin instead.

vNEXT — a device target that was supplied but names nothing is now refused, not widened to the fleet (#2500)

What changed. Three REST surfaces treated a targeting argument the caller supplied that resolved to no devices as identical to one they never sent — and “no target named” meant broadcast. POST /api/command with {"plugin":"service","action":"restart","agent_ids":[1,2,3]} restarted the service on every connected agent under plain Execution:Execute, with no approval step, and returned a success response. So did {"agent_ids": []}, which is what any device filter that matched nothing produces. Each of these is now 400.

What breaks. Requests that previously succeeded and now fail:

EndpointShapeWasNow
POST /api/commandagent_ids [], non-array, or containing a non-stringbroadcast to all400
POST /api/commandscope "" or non-stringbroadcast to all400
POST /api/instructions/{id}/executeagent_ids [], non-arraybroadcast to all400
POST /api/instructions/{id}/executescope "" or non-stringbroadcast to all400
POST /api/v1/result-sets/from-*parent_id empty, non-string, or nullsearched/dispatched unscoped400
POST /api/policies/{id}/remediateagent_ids [], non-array, or containing a non-stringremediated every non-compliant agent in the policy400
POST /api/policies/{id}/remediatescope supplied at allsilently ignored, so a narrowing selector remediated every non-compliant agent400 — the route selects targets by agent_ids only
POST /api/commandbody is not a JSON objecttreated as “no target” → broadcast400
POST /api/commandplugin/action outside [A-Za-z0-9_.-] or over 128 bytesaccepted400
POST /api/instructions/{id}/executebody is not a JSON objecttreated as “no target” → broadcast400
POST /api/commandscope is "__all__"400 invalid scopedispatches to all connected agents
POST /api/v1/result-sets/from-*body is not a JSON object500 (uncaught type error)400
POST /api/dashboard/executeform field scope= supplied but EMPTYbroadcast to allrefused, nothing dispatched
POST /api/dashboard/echo-executequery param scope= supplied but EMPTYbroadcast to allrefused, nothing dispatched

The two remediation rows matter for the same reason as the rest: on that route an absent agent_ids means “every non-compliant agent in this policy”, so a supplied selector that named nothing — or a scope, which that route cannot act on — quietly became a fleet-wide mutating remediation. Omitting agent_ids entirely still targets every non-compliant agent, unchanged.

The instructions-execute row is the one to read twice: "scope": "" + empty agent_ids = broadcast was documented behaviour in rest-api.md, so a client written against the published contract is affected. The from-* row covers from-echo-query, from-instruction-result, re-eval and from-inventory-query.

One thing got LOOSER, not tighter. POST /api/command previously rejected "scope": "__all__" with 400 invalid scope — the scope parser has no rule for it — while the sibling instruction-execute route broadcast on the same string. It now broadcasts on both. This grants no new access: broadcast was already reachable on that route by omitting targeting entirely, under the same Execution:Execute, so __all__ only gives a name to something a caller could already do. But if you have a script that sends __all__ and treats the 400 as a no-op, it will now dispatch to the fleet.

What still works, unchanged. Omitting both agent_ids and scope broadcasts to all connected agents, on every route. So does an explicit "scope": "__all__" — the ground scope kind already advertised by /discover/scope-kinds and by the MCP execute_instruction schema. If you have a client sending "scope": "" to mean “everything”, change it to omit the field or to send "__all__"; both are supported and neither is deprecated.

Stored data — narrower than it first looks. Only requests that explicitly send "scope": "__all__" record scope_expression = "__all__" on the execution row, and those already did so before this change. Broadcasting by omitting both fields is unchanged and still records "" — the execution row is written from the raw request value, before the omitted-means-__all__ mapping is applied for dispatch, and the mapped value is never written back. The one practical change is that the dashboard’s “All agents” button now sends __all__ explicitly, so rows created that way look different from before. A saved query selecting historical broadcasts by scope_expression = '' still matches everything except dashboard-initiated ones.

Dashboard users driving the UI in a browser need do nothing — the Instructions execute dialog’s “All agents” option sends __all__ instead of an empty string.

But automation that POSTs the dashboard forms directly does need attention. As of the Wave-1 foundations change, /api/dashboard/execute and /api/dashboard/echo-execute distinguish an OMITTED scope from one SUPPLIED as empty (scope=): omitted still means the whole fleet, but supplied-but-empty is now refused and dispatches to nobody, where it previously broadcast. If a script builds the form body unconditionally and leaves scope blank when no device is selected, it will stop dispatching — which is the intended outcome, but a silent one. Send scope=__all__ to keep the fleet-wide behaviour deliberately.

Detecting affected clients — and the limit of what is possible. There is no reliable way to find them before upgrading. The audit trail records the OUTCOME of a dispatch, not the request shape that produced it: command.dispatch|success stores plugin:action -> N agent(s) and instruction.execute|success stores agents=<sent>, and neither preserves the agent_ids or scope the caller actually sent. So a historical broadcast that was deliberate and one that was an accidentally-widened three-device request are indistinguishable in existing rows. The closest available pre-upgrade signal is reviewing automation you believe targets a subset for dispatches whose agent count is suspiciously close to your full fleet size. (The stored detail uses a literal \u2192 arrow, not ->, so match on the agent count rather than the separator.)

After upgrading, refusals are counted by yuzu_server_dispatch_target_rejected_total{route,reason} (all series pre-seeded at boot, so absent() stays meaningful) and audited as command.dispatch|denied (detail=reason=<reason> <plugin>:<action>), instruction.execute|denied (detail=reason=<reason>) or result_set.create|denied (detail=reason=<reason> source_kind=<kind>). The same action’s failure result (#4496 + follow-up, the POST /api/v1/result-sets/from-inventory-query producer and its MCP twin) carries detail=reason=store_degraded|query_truncated|poison_excluded|parse_error_excluded source_kind=inventory_query - only the latter three of those four are counted on yuzu_server_dispatch_target_rejected_total (route="result_set_inventory_query"); store_degraded is a store-availability failure, not a targeting-shape refusal, so it is not on this series. The YuzuDispatchTargetRejected alert fires when the 15-minute increase exceeds 3 — deliberately not on every single refusal, because a rule that pages on one malformed request gets silenced. Use the audit rows, not the alert, to find individual offenders.

query_truncated’s failure mode is structural, not a per-record near-miss - plan its runbook step separately from poison_excluded/parse_error_excluded. poison_excluded and parse_error_excluded are both per-record and self-heal once the offending record is fixed or excluded - they are DIFFERENT causes (over-nested data_json vs. data_json that fails to parse as JSON at all), each with its own reason so an operator can tell which guard excluded a record, but the same “per-record, self-healing” runbook shape applies to both. query_truncated fires whenever the generic-inventory read backing both producer routes exceeds the hard-coded 5,000-row cap or the 8 MiB aggregate payload cap - there is no pagination on this path today. On a fleet whose inventory has grown past either cap, EVERY subsequent call to POST /api/v1/result-sets/from-inventory-query or its MCP twin refuses with query_truncated, and the alert never clears on its own. If you need to silence YuzuDispatchTargetRejected on such a fleet before the fix lands, scope the Alertmanager silence to reason="query_truncated" AND route="result_set_inventory_query" specifically - never the bare alertname, which would also hide poison_excluded/parse_error_excluded, genuine near-miss signals that must stay visible. Tracked fix: #2633 (InventoryStore::query row cap (5000): keyset pagination + limit+1 truncation probe).

vNEXT - result-set instruction_id/params fields are now bound-checked (#4373) (intentional compatibility break, no supported flow affected)

What changed. POST /api/v1/result-sets/from-instruction-result and POST /api/v1/result-sets/{id}/re-eval had no bound on the instruction_id or params fields feeding an InstructionDefinition dispatch: an over-keyed/oversized params object could reach fleet-wide dispatch, and an oversized instruction_id could reach an unbounded instruction_store lookup (a real registered instruction id is capped at 128 characters, so an oversized id could never match one and dispatch - it reached the not-found fallback instead). Both routes now enforce the same caps MCP’s create_result_set_from_instruction_result/reevaluate_result_set tools enforce: instruction_id at 256 bytes, params at 32 keys / 256-byte keys / 64 KiB values.

What breaks. Requests that previously succeeded and now fail:

EndpointShapeWasNow
POST /api/v1/result-sets/from-instruction-resultinstruction_id over 256 bytesreached an unbounded store lookup, then 404400
POST /api/v1/result-sets/from-instruction-resultparams over 32 keys, a key over 256 bytes, or a value over 64 KiBdispatched400
POST /api/v1/result-sets/{id}/re-evalsame, on a set whose stored instruction_id/params exceed the capsre-dispatched (params) or reached an unbounded store lookup then 400 (instruction_id)400
POST /api/v1/result-sets/from-instruction-result or {id}/re-evalparams present but not a JSON object (a string, array, or number)dispatched/re-dispatched with an EMPTY params map, silently discarding it400
POST /api/v1/result-sets/from-echo-querysql present but not a JSON stringuncaught exception, bare 500400
POST /api/v1/result-sets/from-instruction-result or {id}/re-evalinstruction_id present but not a JSON stringuncaught exception, bare 500400
POST /api/v1/result-sets/from-echo-query or from-instruction-resultname present but not a JSON stringuncaught exception, bare 500400
POST /api/v1/result-sets, from-echo-query, from-instruction-result, or from-inventory-queryname over 256 bytes, or (generic create route only) source_kind over 64 bytespersisted/dispatched unbounded400
POST /api/v1/result-sets (the generic/synchronous create route)name or source_kind present but not a JSON stringuncaught exception, bare 500400

Who this affects. Callers sending a field past a numeric/count bound (instruction_id, params count/key-length/value-length, both at the same values MCP’s equivalent tools enforce - MCP’s handler-side bounds landed within days of this fix, in the same unreleased cycle, not a long-standing MCP/REST gap), AND separately callers sending a wrong-typed params, name, or source_kind (not a numeric bound at all - a shape/type mismatch, always rejected regardless of size). No supported flow constructs any of these fields anywhere near the numeric limits or with the wrong JSON type, so no compliant client is affected either way.

vNEXT — POST /mcp/v1/ can now hold its response open as an SSE stream (2f PR 3b)

A tools/call for execute_instruction that carries _meta.progressToken and an SSE-capable Accept now receives its progress on the POST response itself rather than on the session’s GET stream. Opt-in and per request: a call that sends neither, or only one of the two, behaves exactly as before and is byte-identical on the wire. Plain POST clients (mcp-remote, Claude Desktop) are unaffected.

Three operator-visible consequences:

--mcp-no-streaming remains the kill switch and now also degrades the streamed POST arm back to plain JSON, not just GET/DELETE.

vNEXT — streamed POST (--mcp-enable-streamed-post) now ships ON by default (breaking)

The feature described in the vNEXT entry above already existed; this entry is about its default changing. Every prerequisite that gated the on-by-default flip (#2739, #2740, #2785, #2789) is fixed, so a server started with no MCP flags now serves streamed POST for any execute_instruction call that sends _meta.progressToken with an SSE-capable Accept — no configuration required.

What this means for an upgrading operator, restated from the Sizing and per-principal bullets above because they are no longer conditional on opting in — they now apply to every deployment by default:

To opt out, pass --no-mcp-streamed-post (or set YUZU_MCP_ENABLE_STREAMED_POST=false) to keep the pre-flip plain-POST-only behavior. --mcp-no-streaming remains the broader kill switch and disables streamed POST along with the rest of the Streamable HTTP transport.

vNEXT — MCP stream revalidation rides the tick, and the pin-drift alert moves to a new counter

Two operator-visible changes to held-open MCP GET SSE streams. Neither changes a revocation bound: the documented one-tick single-server figure still holds and is now pinned by tests.

Credential re-checks are once per tick, and busy streams stop sending heartbeat filler. A stream delivering frames continuously previously re-validated its credential and slid its session TTL on every frame; both now run once per ~3 s tick, and a pass that delivered real frames skips the redundant heartbeat. If anything on your side counts event: heartbeat frames as a liveness signal, key on any delivered frame instead.

The admission-drift reading moved counters. yuzu_mcp_stream_final_unpinned_total used to carry the “replay-ring pin accounting has drifted” reading; that reading now belongs to the new yuzu_mcp_stream_pin_displaced_total (the server now displaces the oldest pinned terminal instead of committing the newest unprotected, which is strictly less bad). If you alert on final_unpinned_total > 0, keep that rule and add the same rule for pin_displaced_total — reference rules for both ship in docs/prometheus/yuzu-alerts.yml, and the response procedure is docs/ops-runbooks/mcp-stream-pin-displacement.md (diagnostic only — no restart).

vNEXT — engine-principal streams: liveness re-checks are cached, and the outage grace window is measured differently (#2367)

Two operator-visible changes to held-open MCP/SSE streams authenticated by an engine principal. Nothing changes for human or API-token streams.

Liveness re-checks are cached for 15 seconds. Every held-open stream re-validates its credential on each ~3 s heartbeat tick. For engine principals that check previously read PostgreSQL every tick, which under a connection-pool brownout was self-amplifying and could starve ordinary request traffic — not just streaming. It is now served from a short cache, and a store that cannot be reached is rate-limited rather than re-asked on every tick.

Fresh authorization is unaffected: creating a session and on-behalf-of target checks still read through on every call, so revoking an engine principal stops new sessions immediately. On the server handling the revoke, live streams are still cut on the next tick — the cache is invalidated as part of that write. Across replicas, a revoke reaches another replica’s stream within the 15 s TTL plus a tick. If your compliance posture quotes a revocation-latency figure for engine principals, that is the number.

A stream may now end sooner during a PostgreSQL outage. The 60 s indeterminate grace window is now measured from the stream’s last authoritative credential confirmation rather than from the moment the outage was noticed. Previously a stream could ride cached answers and then collect a full fresh 60 s window on top of them; total survival past a real confirmation is now bounded by the window itself. In practice a stream can close with auth_unavailable up to ~15 s earlier than before. Clients reconnect and resume via Last-Event-ID as they already do, and durable results remain fetchable by execution_id.

New observability. Four metrics (yuzu_server_engine_revalidate_cache_hits_total, ..._misses_total, ..._cache_size, ..._backoff_suppressed_total — see metrics.md). The last is the brownout signal: it moves only while the store is unreachable. The server also emits a startup warning when effective SSE stream capacity exceeds 16x --postgres-pool-size; if you see it, watch yuzu_pg_acquire_wait_seconds and yuzu_pg_pool_in_use before raising either.

No configuration changes are required and no flags were added.

vNEXT — MCP supervised-tier calls now enforce the published input schema pre-approval (#2405)

Approval-gated MCP tools/call arguments are now validated against the tool’s published inputSchema BEFORE an approval ticket is minted or consumed. Most callers only fail earlier (junk args that would have failed after admin approval now answer -32602 immediately, with no ticket created or burned).

Previously-succeeding shapes that now break — supervised-tier execute_instruction / execute_bundle, plus any approval-gated tool taking an integer parameter:

The bound-based rejections above were already the published schema contract (added in track 2f) but were client-advisory until this change; they are now enforced on the approval-gated path. Operator and readonly callers of these two tools are unchanged — schema validation runs where requires_approval is true, which for Execution:Execute is the supervised tier only, so those bounds stay advisory on the other tiers (full every-path enforcement is tracked in #2437). Note the gate keys on the operation, not the tier: delete_tag is approval-gated on operator as well, so its arguments are validated there too — it simply carries no length or count bound today, so nothing breaks. Any approval ticket already minted-and-approved for one of the shapes above becomes unrecallable fail-closed (it is never consumed) and expires on the normal 7-day approval TTL; re-submit the corrected call for a fresh ticket.

Also note: a recall whose arguments fail schema validation now answers -32602 before the ticket is even looked up — clients that pattern-matched -32003 for every recall failure should treat -32602 as “fix the arguments, ticket untouched”. Full detail: docs/mcp-server.md “Pre-approval input-schema validation”.

vNEXT — MCP tool annotations are now truthful; write tools carry destructiveHint:true for the first time

Track 2g PR 2. Every MCP tool now advertises the four standard annotation hints (readOnlyHint/destructiveHint/idempotentHint/openWorldHint), generated from a single-source classification and enforced truthful by a CI cross-check test. Operator-visible effect: an agentic worker that renders a confirmation prompt off destructiveHint will, for the first time, prompt on the write tools that previously carried no annotation (execute_instruction, execute_bundle, set_tag, delete_tag, quarantine_device, revoke_certificate, and more), and three previously false-safe hints (confirm_engine_rotation, close_access_review) are corrected. The hints are advisory UX only — the tier + maker-checker approval gate is unchanged and remains the enforcement. Because MCP advertises tools.listChanged:false, long-lived clients should reconnect after this deploy to pick up the corrected hints. Not a breaking change (no previously-working call is rejected). Full detail: docs/user-manual/mcp.md “Available Tools”. (Since superseded for confirm_engine_rotation: #2384 added a required token_id argument that pins the confirm to the exact pending rotation, so its idempotentHint is corrected back to true — and the new required argument IS a breaking change for that one unreleased tool/route.)

vNEXT — confirm_engine_rotation replay-after-resolution is now a terminal error (#2404)

Direct sequel to the #2384 entry above. A confirm (REST POST /api/v1/engine-principals/{id}/credentials/confirm or MCP confirm_engine_rotation) replayed after the rotation already resolved — a network-dropped 200, a double-submit, or a client racing the auto-revoke sweep — now returns a terminal 409 / MCP kInvalidParams (rotation already confirmed / no rotation in flight ... already the sole active credential), instead of the previous retryable 503. A confirm that finds more than two active credentials likewise now returns a terminal 400 (was 503). Only a genuinely-empty read and an unrecognized two-credential pair stay 503.

Not a breaking change by this file’s convention — no previously-succeeding call is rejected; the first, real confirm still returns 200 identically. But worth a glance for any integration that pattern-matches 503 => retry: such a client will now loop forever against a permanently-503 call under the old behavior, which is exactly the livelock this fixes — after the upgrade it gets a terminal 409/400 and should stop and (if it genuinely needs a new credential) call rotate, not replay confirm. Agentic MCP clients honouring idempotentHint:true get the correct terminal answer automatically. Full detail: docs/user-manual/rest-api.md (confirm error table) and docs/user-manual/engine-principals.md.

vNEXT — API-token rotation confirm-identity binding now survives a restart; first schema change to api_tokens (#2961)

Migration v3 adds api_tokens.rotation_initiator (durable twin of the confirm-identity binding described in docs/auth-architecture.md “Confirm-identity binding survives a server restart”). This is the FIRST schema change ever applied to api_tokens since it shipped — every authenticated request validates a Bearer token against this table, so it is one of the hottest tables on the server.

Not breaking, but worth knowing before a rolling upgrade. ALTER TABLE ... ADD COLUMN ... DEFAULT '' takes Postgres’s briefest possible form (a metadata-only “fast default” on PG 11+, not a table rewrite) but is still an ACCESS EXCLUSIVE DDL statement: it must wait for every transaction currently touching api_tokens to finish before it can run, and every new transaction on the table queues behind it in turn while it waits. During a rolling upgrade, an outgoing replica’s still-open request handling can hold such a transaction open. This is bounded and loud, not silent: the pool’s lock_timeout (10s, pg_pool.hpp) cancels the migration’s ALTER if it queues that long, and ApiTokenStore construction fails closed exactly as it does for any other migration failure — the new binary refuses to start with a clear log line rather than serving degraded or hanging the table. If you see this on a rolling upgrade, retry once traffic quiesces (a load balancer draining the outgoing replica is usually enough); it is not a data integrity concern either way.

vNEXT — a device quarantined while offline now re-contains itself automatically on reconnect (#3425) (breaking)

QuarantineStore gains schema v2: two columns on quarantine_records (last_applied_at, last_confirmed_at, both BIGINT NOT NULL DEFAULT 0) tracking whether a new background component, QuarantineContainmentReconciler, has re-applied and confirmed a device’s endpoint firewall since it last reconnected. Same ACCESS EXCLUSIVE migration-lock note as the API-token entry above applies here too — negligible in practice, since quarantine_records is a small, manually-curated security-event table, not a hot path.

No operator action needed for the reconciler itself. Previously, a device quarantined while offline stayed contained at the control plane (the #881 dispatch gate) indefinitely, but its own firewall was never (re-)applied until someone noticed and manually re-issued the quarantine_device MCP call. On upgrade, every pre-existing active quarantine record starts unconfirmed, so expect one automatic re-application attempt per connected contained device shortly after the new binary starts serving — this is idempotent and is the correct, intended behaviour (those are exactly the devices whose endpoint containment was never independently confirmed). See docs/user-manual/security-hardening.md “Reconnect re-application (#3425)” for the mechanism and the new yuzu_server_quarantine_endpoint_unconfirmed{reachability} / yuzu_server_quarantine_reapply_total{result} / yuzu_server_quarantine_reconciler_tick_healthy metrics.

BREAKING for callers of POST /api/v1/quarantine. This route now validates whitelist at write time (≤512 chars, [0-9A-Fa-f.:] tokens only) and rejects a malformed value with 400 instead of persisting it — a caller relying on the old permissiveness for a CIDR range or hostname entry now gets 400 where it previously got 201. This route only ever creates a NEW record (it already refuses with 400 if the device is already quarantined), so no existing containment is ever lost by this change — but a caller that fires a quarantine request and ignores a 400 response now gets zero protection for that device, where before a malformed-but-persisted record still left it denied at the #881 control-plane dispatch gate (its endpoint firewall was never actually enforceable either way, since the same malformed value could never be dispatched). Check the response status; do not assume success.

QuarantineContainmentReconciler is always on, with no configuration surface — no CLI flag or env var disables or tunes it (matching the #881 dispatch gate it complements, which is the same way). Its cadence (20s tick), per-agent timing (60s minimum reapply interval, 15-minute backoff cap), and per-tick dispatch cap (50 agents) are fixed constexpr constants in quarantine_containment_reconciler.hpp, not runtime-configurable.

vNEXT — the rotation sweep now carries the full clock-guarded-retention shape (#2964)

What changed. ApiTokenStore::sweep_expired_rotations — the 60-second background sweep that auto-revokes rotation predecessors for both engine-credential and human API-token overlap pairs — previously issued its delete on a bare local wall-clock comparison, capped per tick but with no persisted clock anchor and no anomaly detection. It now reads a Postgres-authoritative clock, persists a durable anchor in a new rotation_retention_meta table (schema v4), and classifies every tick through the same guard audit_store’s retention pass uses — a clock anomaly or a not-yet-trustworthy anchor declines the tick outright rather than revoke on an unverified reading, leaving both credentials in every affected pair active for at least one more tick. Detail: docs/user-manual/authentication.md “Rotating a Token”, docs/user-manual/engine-principals.md “Rotate the credential (overlap-pair model)”, and docs/user-manual/metrics.md “Rotation-sweep clock guard metrics”.

HA / multi-replica. Unlike audit_store’s retention pass, this sweep is single-writer by construction: before it will classify anything, it takes a store-wide PostgreSQL session advisory lock in its own key namespace. This is what makes the per-tick auto-revoke cap (≤200) a cluster-wide cap rather than a per-replica one that would otherwise let an N-replica deployment auto-revoke up to N x 200 predecessors in one tick. A replica that does not win the lock on a given tick records a SkippedLock outcome and does nothing that tick — expected, routine behaviour on a genuinely multi-replica deployment, where roughly (N-1)/N of ticks land there for any given replica. On a single-replica deployment — the default — this is not “routine leader-election contention” at all: one dedicated sweep thread on a sequential 60-second loop cannot lose an election against itself, so SkippedLock there can only mean a second writer holding the same session lock — a zombie process left over from a crashed instance, a botched blue-green cutover overlapping two live instances briefly, or an unauthorised second server pointed at the same DSN. If you run single-replica, treat any sustained non-zero yuzu_rotation_sweep_lock_skipped_total as a fault to investigate, not background noise; see docs/ops-runbooks/rotation-sweep-clock-guard.md.

vNEXT — API-token rotation confirm now requires proof of possession (#3015) (breaking)

What changed. confirm on a rotation — REST POST /api/v1/tokens/{id}/confirm and POST /api/v1/engine-principals/{id}/credentials/confirm, plus the MCP twins confirm_api_token_rotation/confirm_engine_rotation — previously admitted on caller identity plus the successor’s token_id alone. A caller who recovered an unknown successor’s token_id out-of-band (a support ticket, a log line) could confirm — and thereby revoke the predecessor for — a rotation whose secret they never received. All four confirm surfaces now additionally require the raw successor secret in the request body/args, verified with a constant-time hash comparison against the successor’s stored hash, checked LAST — strictly after ownership, pair-state, the token_id pin, tier, scope, and the initiator binding have all already passed — before the predecessor is touched.

Who this affects. Any caller confirming with only token_id: REST now returns 400 instead of succeeding; MCP returns kInvalidParams. A caller confirming with a wrong secret gets REST 403 / MCP kPermissionDenied. Correctly-installing automation is unaffected — the secret required here is the same raw value the rotate response already returns exactly once (REST data.token), so automation that installs the successor from that response and passes it straight to confirm sees no behavior change.

If you lose the rotate response before confirming, you can no longer confirm — the secret cannot be manufactured from the token_id alone. Two recovery paths:

  1. Wait for the automatic overlap-window sweep. Proof of possession gates the immediate, explicit confirm call only — the 60-second background sweep is unaffected by this change and still auto-revokes the predecessor on its own schedule with no secret required, provided the successor secret was actually installed and presented (used) at least once.
  2. Revoke the unknown successor and start a new rotation (DELETE /api/v1/tokens/{token_id} or the engine-principal twin) — keeps the predecessor working immediately, at the cost of restarting the rotation.

Full detail: authentication.md “Rotating a Token”, engine-principals.md “Rotate the credential”, and mcp.md rows 61/71.

vNEXT — Guardian status routes gain real data, new denial/failure modes (#2298 item 6d) (breaking)

What changed. GET /api/v1/guaranteed-state/status and /status/{agent_id} previously returned a hardcoded placeholder (errored_rules: 0, always 200). errored_rules is now real, derived from the same per-agent compliance census (guardian_agent_rule_status) the dashboard’s Unhealthy Guards card reads, intersected against the live rule catalogue. compliant_rules/drifted_rules remain placeholder 0 pending full status ingest, tracked separately.

What a client sees.

Who this affects. Any integration polling either route that (a) uses a service-scoped API token against the fleet route — that call now needs either a non-service-scoped credential or a per-agent call against /status/{agent_id} instead, and the per-agent route is not an unconditional substitute: require_scoped_permission additionally checks that the target agent_id’s own service tag matches the token’s scope (tag_store-backed), so a service-scoped token still gets 403 there for any device outside its own service, distinct from the fleet route’s simpler “any service-scoped token, unconditionally” denial; (b) holds a management-group-confined (not global) GuaranteedState:Read grant and polls /status/{agent_id} for a device outside that scope — that call now gets 403 where it previously got 200 with placeholder data, the same confinement /guaranteed-state/device-compliance has always enforced; (c) holds a management-group-confined grant and polls the fleet route (/status) — that call still gets 200, but errored_rules now reflects only the caller’s visible agents rather than the whole fleet; 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 real grant that is simply narrow is not a denial), distinct from holding no GuaranteedState:Read grant anywhere, which is 403 on either route; or (d) treats every response as 200 — both routes can now return 403/503 and a client that does not already retry on 5xx (standard practice for every other Guaranteed State route) should add that handling. No change for a global-permission, non-service-scoped caller on the happy path beyond errored_rules becoming a real, changing number instead of a constant 0.

vNEXT — macOS antivirus posture is now probed, not asserted

The antivirus plugin’s macOS leg previously hardcoded av|XProtect|active without reading anything, and its third-party checks grepped for the wrong process name. Visible after upgrading agents:

  1. av|XProtect|<state> is now a real probe of the XProtect definition bundle: active comes with a new xprotect_version|<n> row; unknown means the bundle was unreadable (never assumed active). Third-party EDR/AV detected via endpoint-security system extensions emit an additional edr|<bundle id>|<version> row each. Integrations keying on the old always-present av|XProtect|active row should treat unknown as a signal to investigate, not as product-absent.
  2. The status action on macOS returns real XProtect data (definition version, freshness, Remediator/MRT versions) instead of status|not_available. A new darwin-only definition security.antivirus.xprotect_status exposes it; being a new id, it seeds on upgraded installs at next boot (unlike edited descriptions, which reach fresh installs only — the amended security.antivirus.products text lands there alone).
  3. Mixed-fleet blend during rollout: agents not yet upgraded keep emitting the hardcoded XProtect row and process-grep results. Not a server bug; the presence of an xprotect_version row identifies an upgraded agent.

vNEXT — macOS firewall state now reports the Application Firewall (backend key changed)

The firewall plugin’s macOS state action previously reported the pf packet filter — which is off by default and unrelated to the Application Firewall a Mac admin means — so a Mac with the real firewall on could read disabled. Three things are visible after upgrading agents:

  1. The backend row value changes from pf to appfirewall, and state now reflects socketfilterfw --getglobalstate. Integrations keying on backend|pf or treating state as pf state must switch to the new rows. Two additive rows appear: mode|block_all (only when block-all is set) and pf|<state> (the demoted pf signal; unknown on agents not running as root). state|unknown means the check was unreadable — never assumed safe.
  2. Mixed-fleet blend during rollout: agents not yet upgraded keep emitting backend|pf + pf-based state. Expect both shapes side by side until the fleet is fully upgraded — not a server bug; the backend row disambiguates per device.
  3. Existing installs keep the old definition description. Bundled definitions seed insert-or-skip by id at boot, so the corrected security.firewall.state description (v1.1.0) lands on fresh installs only; upgraded fleets get the corrected behavior regardless. To refresh the text, delete security.firewall.state and re-import it via POST /api/instructions/import — do not edit it in the dashboard YAML editor, which drops the definition’s spec.visualization on save.

vNEXT — Linux firewall state may now read unknown more often for ufw/iptables hosts (correctness fix)

The firewall plugin’s Linux ufw/iptables legs previously reported a parsed active/inactive state as soon as the underlying subprocess exited 0 — even if the read had actually timed out or been truncated before finishing, which could report a stale or simply wrong verdict with full confidence. state| is now gated on the same completeness check ruleset| already used (tool_ran && exit_code==0 && !timed_out && !output_truncated), matching the nftables leg’s own honest-degrade behavior added alongside it.

  1. Hosts whose ufw status/iptables -S reads were marginal (slow, near-timeout, or hitting the output cap) will now read state|unknown more often post-upgrade, for the SAME underlying firewall state as before. This is the fix working as intended — a previously false-confident active/inactive becomes an honest “couldn’t tell” — not a detection regression. ruleset|unknown already had this behavior; state| now matches it.
  2. No row shape changed. unknown was already a valid state value on every backend (it is the nftables leg’s own honest-degrade outcome); this only changes which reads reach it. Integrations already handling state|unknown need no changes.
  3. Mixed-fleet blend during rollout: agents not yet upgraded keep the prior completeness posture for ufw/iptables; a wider spread of state|unknown across the fleet for these backends specifically identifies upgraded agents whose reads were genuinely marginal, not a server-side fault.

vNEXT — KEK rotation is now durably rate-limited (#2530) (breaking)

Before this release, POST /api/v1/secrets/kek/rotate was rate-limited only by a 5-minute process-local cooldown — cheap, but per-process and restart-clearable, so an install could in practice rotate roughly every 5 minutes (or more often across a restart, or across several servers pointed at the same database). This release replaces that with a durable, database-backed rate limit, --kek-min-rotate-interval (default 3600 seconds = 1 hour, env YUZU_KEK_MIN_ROTATE_INTERVAL), read from secrets.kek_meta.created_at on the database server’s own clock — it survives a restart and is shared cluster-wide by every server pointed at the same database. An install that could previously rotate every ~5 minutes can now be durably refused (429) for up to an hour by default. If your operational tooling or runbooks assume a short rotation cadence is always available (smoke tests immediately after install, scripted rotation drills, etc.), review them against the new default before upgrading — a fresh install’s very first rotate attempt is also refused for up to this interval, because KEK v1 is minted at boot with created_at = now(). Lower --kek-min-rotate-interval if your operational model genuinely needs more frequent rotation, but read the “runaway/abuse guard, not a rotation-schedule setting” caveat in “Key management (secrets KEK)” first — this flag also bounds how quickly you can rotate in a genuine emergency. See that section and docs/prometheus/yuzu-alerts.yml (group yuzu-secrets) for the full contract, the new VersionCeiling/QueryCanceled/ClockAnomaly failure modes, and the new observability surface.

vNEXT — API/MCP bearer tokens invalidated on upgrade (ApiTokenStore → Postgres, ADR-0030) (breaking)

The API/MCP bearer-token store moves from SQLite (api-tokens.db) to the PostgreSQL substrate as a fresh-start cutover with no data migration — every pre-upgrade API token and MCP token stops working the instant the new server starts (interactive cookie-session/SSO login is unaffected). Re-mint every API/MCP bearer token after upgrading (POST /api/v1/tokens) and update the credential wherever it is stored; plan a maintenance window and notify automation owners, since all bearer-token integrations break at once. A boot-time warning names the legacy file (inert, removable). Full detail + multi-instance caveat: the ## ⚠️ Breaking section in docs/user-manual/upgrading.md and ADR-0030.

vNEXT — GET /mcp/v1/ is a live SSE channel; ALL streaming surfaces now share one worker budget

Track 2f PR 2, plus ADR-0034. Three things change for an operator:

  1. GET /mcp/v1/ no longer returns 405. It is the MCP session’s server→client SSE channel (heartbeats, Last-Event-ID resume). Nothing publishes onto it yet — notifications/progress arrives in the next rung — so a client that never issues a GET is unaffected. Behind a reverse proxy, note these are held-open responses: the server sets X-Accel-Buffering: no (nginx honours it); Envoy, HAProxy, ALB and Cloudflare need their own no-buffering opt-out.

  2. Every held-open SSE response now leases from one shared budget — MCP’s GET channel, MCP streamed POST, GET /api/v1/events, the dashboard executions drawer, and the legacy /events stream. cpp-httplib is thread-per-connection, so each of those pins a worker for its entire life; previously only MCP was counted, which meant the plain-REST reserve was arithmetic rather than a guarantee. A cap hit returns 429 with Retry-After; a live stream is never evicted. This closes a starvation path that existed before MCP streaming shipped at all: enough dashboard tabs could exhaust the pool and stall plain REST.

  3. The worker pool is derived from --max-sse-streams (default 128), not the other way round. The old default sized itself from httplib’s accidental 32-thread pool and yielded 12 streams — on a platform designed for hundreds of agentic clients. A blocked thread burns no CPU; its resident cost is a fraction of a virtual, platform-dependent stack reservation and has not yet been measured (ADR-0034), so the pool is sized for the workload you declare rather than off a per-thread constant. Watch yuzu_http_held_open_responses / yuzu_http_held_open_capacity; the ceiling is thread-count, and the durable fix (moving long-lived connections off the thread-per-connection server) is recorded in ADR-0034.

vNEXT — MCP notification POSTs now answer 202 (was 204); Streamable HTTP sessions added

The /mcp/v1/ endpoint gains the MCP-spec Streamable HTTP transport (track 2f, PR 1). Three changes are visible to existing clients:

  1. A JSON-RPC notification POST (a request with no id, e.g. notifications/initialized) now returns HTTP 202 Accepted instead of 204 No Content. The body stays empty. This is a spec MUST and applies regardless of the kill switch below. Affected: only a strict client that asserts the status is exactly 204 (or asserts an empty-body status other than 202) — the reference clients (mcp-remote, Claude Desktop) treat any 2xx-with-empty-body as success and are unaffected. Adjust such assertions to accept 202.
  2. initialize responses now carry an additive Mcp-Session-Id header. Clients that don’t use it can safely ignore it (plain-POST flows are otherwise byte-identical). Clients that do may present it on later requests; an unknown/expired/foreign id returns 404, at which point the client re-initializes (sessions are in-memory, so a server restart has the same effect). DELETE /mcp/v1/ ends a session.
  3. initialize now negotiates the protocol revision instead of always returning 2025-03-26. A client that sends params.protocolVersion of a supported revision (2025-03-26 or 2025-06-18) gets that value echoed; anything else (including no protocolVersion) still returns 2025-03-26. Affected: only a newer client that requests 2025-06-18 — a legacy client requesting 2025-03-26 (or nothing) sees no change. Negotiation is independent of the --mcp-no-streaming kill switch.

New CLI flags (all optional):

vNEXT — DEX per-application sampling (procperf) is a new telemetry category

This release adds per-application resource sampling (top-N processes by CPU and working set, by image name) to the Echo edge warehouse. Superseded: it is now on by default (procperf_enabled=true on every agent, including a one-time reset of any stored false at the echo.db schema v8 upgrade — see Upgrading); this release originally shipped it off. It is a distinct telemetry category, separate from the device-level performance sampling (perf_enabled, on by default, no per-app identity) that shipped in the prior release on Windows — on Linux, device-level sampling starts automatically on upgrade to this release (see the upgrade checklist in the user manual’s Upgrading page and the Echo manual’s upgrade note; opt out per host with perf_enabled=false). To disable per-app sampling, set procperf_enabled=false via an Echo configure instruction (fleet-wide or per-device). The data is image names only (no command lines), 7-day raw / 31-day hourly retention, and is captured in the Workstream E data inventory in docs/enterprise-readiness-soc2-first-customer.md. Echo warehouse tables are now also created on every database open, so upgraded agents need no manual table-creation step. New webhook egress: the dex.signal event (operator-routed signals) — see the security questionnaire note in the assurance package if you answer data-egress questions.

vNEXT — POST /login returns 202 for MFA-enrolled users (breaking)

Programmatic clients (CI pipelines, health checks, curl scripts) that call POST /login and treat anything other than HTTP 200 + {"status":"ok"} as failure will silently break the first time an authenticating user enrolls in TOTP MFA via Settings → Security and sign-in → Two-factor sign-in. The new response is HTTP 202 with body {"status":"mfa_required","mfa_pending_token":"<opaque>","expires_in":120} — handle this branch by posting mfa_pending_token + the 6-digit TOTP code (or a XXXX-XXXX-XXXX-XXXX recovery code) to POST /login/mfa to mint the session cookie. See docs/user-manual/authentication.md § Multi-Factor Authentication for the full flow.

MFA CLI flags: --mfa-enforcement (default optional; admin-only/required now enforce — see the breaking note in docs/user-manual/upgrading.md), --mfa-step-up-window-secs (default 300), --mfa-login-pending-secs (default 120), and the break-glass --mfa-reset <username> (clears a locked-out user’s MFA and exits, writing an mfa.reset.breakglass audit row — see docs/ops-runbooks/auth-db-recovery.md). Recovery code format changed from XXXXX-XXXXX (50 bits) to XXXX-XXXX-XXXX-XXXX (80 bits) — codes printed by earlier PR1 commits remain valid until consumed or regenerated. The break-glass procedure for a user who has lost both their authenticator and all recovery codes — and the recovery path for an operator locked out by an enforcement misconfiguration (SSO IdP not asserting amr, or a sole admin who could not enroll) — lives at docs/ops-runbooks/auth-db-recovery.md.

vNEXT — service-scoped API tokens can no longer read or mutate the confirmed fleet-wide Guardian/DEX/network/inventory/Echo/Schedule surfaces found as of this release (breaking)

What changed. A pre-existing gap let a service-scoped API token read identity-linked, fleet-wide device data — and, for Guardian Baselines, MUTATE what every agent enforces — across every reporting agent, not just its own service’s agents. This note does not claim the underlying class is fully closed — three tracked issues (#3123 device-discovery, #3124 response/execution data, #3125 inventory data) document further confirmed instances on surfaces this release does not touch, found by an independent adversarial review during this branch’s own governance run; see those issues for the current list. The confinement check on these fleet-wide (no single agent_id) reads only ever verified the token’s role, never its service scope, and several of the affected reads had no per-open audit trail at all. Fixed across REST, the dashboard, and MCP: GET /api/v1/guaranteed-state/events (no-agent_id shape), GET /api/v1/dex/signals/{obs_type}, GET /api/v1/dex/perf/devices, GET /api/v1/network/devices and their MCP twins now deny a service-scoped token outright and are access-audited (dex.signal.view / dex.perf.device.view / network.device.view — the latter two had no audit coverage at all before this release); the Guardian dashboard’s fleet status, guards list, event timeline, per-Guard drilldown, baselines list, and per-Baseline fragments, plus the /fragments/dex/perf/devices, /fragments/network/devices, /fragments/dex/overview, /fragments/dex/app, and /fragments/dex/catalogue/signal dashboard fragments now deny a service-scoped token the same way. The same confinement-gap class extends beyond GuaranteedState:Read: GET /fragments/devices/list (Infrastructure:Read), GET /fragments/inventory/devices (Inventory:Read — GDPR-personal-data serial/system_uuid/primary_mac columns), and both Echo frame device pickers (GET /fragments/echo/process-tree, GET /fragments/echo/capture-sources) now deny a service-scoped token too. It also reached a MUTATING surface: POST /fragments/auto/run (the /auto Pre-flight dispatch) resolved its device cohort the same unconfined way before dispatching the configured checks — now denied the same way, before any dispatch occurs. A different shape of the same root cause also reached three more /auto Pre-flight routes: GET /fragments/auto (the saved-runs rail), GET /fragments/auto/result (the run result poll), and POST /fragments/auto/delete (run delete) scope by session->username alone, and a service-scoped API token shares its creating principal’s username (ApiToken::principal_id) — so a token scoped to e.g. one IT service could read back, poll, or delete a fleet-wide pre-flight run its own principal created interactively. All three now deny a service-scoped token the same way. The same owner-scoping shape reached the /auto Deploy stage too: GET /fragments/auto/deploy (the deploy config form) and POST /fragments/auto/deploy/delete (deployment delete) now deny a service-scoped token for the same reason; GET /fragments/auto/deploy/result (the deployment result poll) now denies too, and — because it also re-invokes the deployment engine’s mutating advance step on every call — this additionally narrows how far an already-in-flight deployment can progress for a service-scoped caller. The same owner-scoping shape reached the Schedule API too, and worse: POST /api/schedules (create) and POST /api/schedules/{id}/enable (re-enable) now deny a service-scoped token outright, even one holding both Schedule:Write and Execution:Execute — a recurring schedule dispatches fleet-wide through ScheduleRunner with no per-fire confinement at all, unattended, and reaching it needs no pre-existing state (unlike every route above). DELETE /api/schedules/{id} (delete) denies the same way as the ordinary owner-scoping class. Disabling a schedule (enable=false) is deliberately not denied by this per-file check — the kill switch is intended to stay reachable even for a service-scoped token. Caveat added 2026-08-21 (#3290 Phase 2 bucket 1a): that guarantee does not currently hold in practice — require_permission(Schedule,Write) denies a service-scoped token unconditionally at the top of this handler, before enabled is even parsed, so a disable is denied the same as an enable. Pre-existing, not introduced by this note’s own fixes; tracked as #3378. A separate, worse-than-username-scoping gap in the same feature area: GET /api/schedules (REST) and the MCP list_schedules tool now also deny a service-scoped token, because ITServiceOwner grants full CRUD on Schedule and the underlying query had no owner/service filter of any kind, so Schedule:Read alone let a service-scoped token enumerate every schedule from every other service. GET /fragments/schedules (the dashboard twin) is fixed the same way, and separately gains an RBAC gate it never had — this fragment was previously reachable by any authenticated session, regardless of role or grant. Independently, POST /api/schedules/{id}/enable had a request-parsing bug that silently reinterpreted the standards-compliant JSON boolean {"enabled": false} as enabled=true — this is now fixed; see “What to do” below for who needs to check their integration. Two more instances were found and fixed during this branch’s own governance review, both in files already touched above: the Guardian dashboard’s six MUTATING fragments (guard create, guard enable/disable, baseline create/deploy/delete/update) never got the same deny their read-only siblings got in the same earlier commit — worst of all the fixes in this note, since a service-scoped token could deploy a Baseline (a fleet-wide, full_sync operation) outside its own service; and GET /fragments/inventory/find/results, GET /api/v1/inventory/software, and MCP query_installed_software (the software-search family) never got the deny their sibling /fragments/inventory/devices got. See docs/user-manual/audit-log.md for the full list of new/changed audit verbs and docs/enterprise-readiness-soc2-first-customer.md “The machine-health audit exemption” for the narrowed CC7.2 scope.

Who this affects. Any integration authenticating with a service-scoped API token (a token bound to one service’s agents) that currently calls any of the routes/tools/fragments above will start receiving 403 instead of fleet-wide data on this upgrade — this is the intended fix, not a regression. Separately, any integration calling POST /api/schedules/{id}/enable with enabled as a native JSON boolean will see its enabled: false calls actually disable the schedule for the first time — see “What to do” below. Ordinary (non-service-scoped) operator sessions are unaffected by the confinement changes.

What to do. An integration that needs this data should use a token scoped appropriately for the surface it reads: a global (non-service-scoped) credential for a fleet-wide aggregate view, or the existing per-device REST/MCP reads (.../{agent_id} shapes), which remain available and stay confined to the token’s own service. For the /auto Pre-flight and Deploy rail/result/config/delete routes there is no per-device equivalent — a service-scoped token cannot manage its own principal’s fleet-wide pre-flight runs or deployments at all; use a non-service-scoped credential for pre-flight and deployment workflows. The Schedule API has no per-device equivalent either — a service-scoped token cannot list, create, arm, or (see the #3378 caveat above) disable a runaway schedule; use a non-service-scoped credential for schedule management, including as the kill-switch for an incident involving a service-scoped-triggered schedule. Guardian Guard/Baseline mutation and fleet-wide software search have no per-device equivalent either — use a non-service-scoped credential for those too. For the enabled parsing fix: if your integration sends {"enabled": false} as a real JSON boolean and has been relying on (or working around) it actually re-enabling the schedule, update it — that was always a bug, and the workaround is now unnecessary and will produce the opposite of the intended effect.

vNEXT — service-scoped API tokens are now denied by default everywhere, not just on the routes named above (guardian-confinement-2298 PR 3 — “the flip”) (breaking)

What changed. The note immediately above this one (PR 2 of this same series) closed an enumerated list of routes/tools/fragments — real fixes, but each one required someone to find and name the affected surface first. This release replaces that approach with a default-flip: AuthRoutes::require_permission’s service-scoped branch previously admitted any operation the ITServiceOwner role happened to grant, and that role holds broad CRUD across most securables — so a token bound to one IT service’s agents could, in practice, reach fleet-wide data anywhere that role’s grants reached, whether or not anyone had found and fixed that specific route yet. A (securable, operation) pair must now also clear a server-side allow-list that ships empty, so every route not yet migrated to real per-request confinement denies a service-scoped token outright — including routes nobody has found yet. Mirrored at the MCP tools/call dispatch layer via a new per-tool ServiceScopeClass classification (denied is the default; confined and global_safe are explicit, reviewed exceptions). This supersedes the PR 2 note’s scope: those routes stay fixed the same way, but so does everything else that shares the same require_permission/require_scoped_permission/MCP tools/call gate. See docs/adr/1006-service-scope-default-deny.md for the full design.

Who this affects. Any integration authenticating with a service-scoped API token that currently reaches fleet-wide data or actions through a route not on the seeded-empty allow-list will start receiving 403 on this upgrade — this is the intended fix, not a regression. This is a strictly larger set than the PR 2 note above: that note’s fixes were routes someone had already found; this flip additionally denies routes nobody has found yet, the moment they’re reached. Ordinary (non-service-scoped) operator sessions are unaffected.

What to do. Same guidance as the PR 2 note above: use a global (non-service-scoped) credential for anything that genuinely needs fleet-wide reach, or the per-device REST/MCP reads that stay confined to the token’s own service. There is no per-deployment or per-operator way to widen the allow-list — it is compile-time, and widening it for a specific (securable, operation) pair is a security decision requiring security-guardian sign-off, not an admin setting. If a service-scoped token’s workflow breaks on this upgrade and there is no per-device equivalent for what it was doing, that workflow needs a non-service-scoped credential going forward; there is no config flag to opt back into the old admit-by-default behavior for one route or one deployment.

vNEXT — GET /api/v1/inventory/software and MCP query_installed_software diverge from the rest of the service-scoped-deny family: filtered data, not a 403 (#3290 Phase 2) (still in this vNEXT batch)

What changed. Both notes immediately above this one (PR 2’s enumerated fixes, then “the flip”) describe GET /api/v1/inventory/software and MCP query_installed_software as denying a service-scoped API token outright (403). This still-unreleased batch changes that a second time, before either prior note has shipped: both surfaces now migrate onto AuthRoutes::require_fleet_read, a real per-request confinement gate — a correctly-confined service-scoped token gets a filtered 200 (rows limited to its own service-tagged agents, intersected with any management-group grant), not a 403. This is a least-privilege improvement, not a widening: no caller sees any row it did not already hold a grant for. (A service-scoped caller’s devices_omitted count is 0 while its scope is within the SQL push-down bound of 10 000 visible devices; only for a wider scope can it be nonzero, as the count of distinct out-of-scope devices within the scanned window(s) when a name-filtered query matches software outside its own scope — a bounded, audited existence-only signal, reviewed and accepted, see docs/security-reviews/service-scope-phase2-migrations-2026-08.md’s UP-2 ruling — not a row-level disclosure.) The two prior notes’ own routes/tools are otherwise unaffected — only this one route pair’s end-state changes. Because all three notes land in the same unreleased version, an operator reading only the two notes above would be told to expect a permanent 403 from these two surfaces that will not, in fact, occur. Full behavior table (every caller class, not just this one): docs/security-reviews/service-scope-phase2-migrations-2026-08.md.

Who this affects. Any integration using a service-scoped API token against these two specific surfaces. If it was built to expect (or work around) the 403 the notes above describe, it will now receive real data instead — code that specifically branches on a 403 here to mean “not available to this token” should be updated; code that simply surfaces the error to an operator needs no change, since the success case is a strict improvement.

What to do. Nothing required for the common case. If your integration has error-handling logic keyed specifically on a 403 from these two surfaces, update it to handle the real filtered result instead.

vNEXT — dashboard /fragments/results, the workflow executions drawer, and MCP get_agent_details now confine to the caller’s management group / service scope (#1712, ADR-0017)

What changed. These three surfaces previously gated on a flat permission check with no per-agent filter: a management-group-confined operator holding Response:Read/Execution:Read saw the whole fleet’s response data and agent statuses through the dashboard/workflow surfaces (not just their own group), and a service-scoped API token calling MCP get_agent_details was denied outright (403) rather than shown anything. All three now migrate onto AuthRoutes::require_fleet_read, the same admit-then-filter primitive GET /api/v1/inventory/software/query_installed_software already use (#3290 note above) — a management-group-confined operator now sees a genuinely narrowed, correct result on the two dashboard/workflow surfaces; a correctly-confined service-scoped token now gets a filtered result from get_agent_details instead of a 403. get_agent_details on an out-of-scope agent returns the identical “not found” response a genuinely nonexistent agent would (closing a related existence-oracle, #3564) — the tool can no longer be used to discover which agents exist outside a caller’s scope.

Who this affects. Any management-group-confined operator using the dashboard results page or the workflow executions drawer will now see a narrower view than before — this is the intended fix, not a regression; an unconfined (global) operator’s view is byte-identical to before. A confined operator who newly reaches /fragments/results will also not see the “Create Group from N Agents” button that an unconfined operator sees — its downstream flow (/fragments/create-group-form, POST /api/dashboard/group-from-results) is not yet scope-safe, so the button is withheld entirely for a confined caller rather than risk a confined-N-vs-unscoped-M mismatch (tracked #3489). Any integration authenticating with a service-scoped API token that calls MCP get_agent_details: code that specifically branched on a 403 from this tool to mean “not available to this token” should be updated to expect a real (filtered) result instead, mirroring the query_installed_software guidance above.

What to do. Nothing required for the common case. If a confined operator reports dashboard/workflow results that look “smaller than before,” that is the fix working as intended — confirm their management-group membership matches the agents they expect to see. If a service-scoped integration’s error handling is keyed on a 403 from get_agent_details specifically, update it the same way as the query_installed_software note above.

vNEXT — GET /api/v1/dex/app and GET /api/v1/dex/overview (REST + MCP) stop narrowing an elevated administrator’s device list

What changed. GET /api/v1/dex/app/get_dex_app and GET /api/v1/dex/overview/get_dex_overview carried a per-caller visible-set resolver whose confinement was already dormant for the operator class it was meant to protect — a management-group-confined-only operator has no global grant, so the routes’ bare GuaranteedState:Read permission gate denied them (403) before that resolver ever ran — but the SAME resolver ran for a JIT-elevated administrator and wrongly narrowed their device list to the base identity’s own (usually empty) management-group grant instead of the unfiltered view elevation earns. The dormant, never-narrowing-anyone-real resolver has been retired outright; every admitted caller (a global grant, RBAC disabled, or an elevated administrator) now sees the same unfiltered devices[]/top_devices[] list. GET /api/v1/dex/signals/{obs_type}/get_dex_signal_detail never carried this resolver and is unaffected.

Who this affects. A JIT-elevated administrator who previously received a narrowed (often empty) result from GET /api/v1/dex/app or GET /api/v1/dex/overview will now correctly see the unfiltered fleet. No other caller class changes: a management-group-confined operator still receives 403 from the unchanged permission gate, and a service-scoped API token is still denied outright.

What to do. Nothing required.

v0.10.0 — API token revocation is owner-scoped

Starting with v0.10.0, non-admin users can no longer revoke API tokens they do not own. A caller holding the ApiToken:Delete permission may revoke only tokens whose principal_id matches the session’s username; the global admin role, or a session with an active just-in-time admin elevation, is the only bypass. Prior releases allowed any holder of ApiToken:Delete to revoke any token, which was an IDOR (tracked in GitHub issue #222).

If your deployment uses a non-admin service account to rotate tokens for other principals — for example, a shared ops role that recycles service-account tokens on a schedule — those rotations will begin receiving HTTP 404 token not found after upgrade. To restore the behavior, either:

  1. Assign the rotation account the global admin role, or
  2. Refactor the rotation so each principal owns and rotates its own token.

Option (2) is the recommended long-term posture because it aligns with least-privilege. The same ownership constraint applies to both the REST path DELETE /api/v1/tokens/{id} and the HTMX dashboard path DELETE /api/settings/api-tokens/{id}. Denied attempts are recorded in the audit log with action=api_token.revoke, result=denied, and detail=owner=<real owner> so operators can distinguish an enumeration probe from a legitimate self-revoke.

Both paths return HTTP 404 token not found on a cross-user revoke attempt — identical to the response for a truly-nonexistent token — to prevent the endpoint from being used as an enumeration oracle.

v0.12.0 — Echo dashboard page + mixed-version agent caveats

The new /echo dashboard page (issue #547) surfaces every device × source pair where an Echo collector has been disabled. The page is reachable from the Echo entry in the sidebar’s Posture group; viewing requires Infrastructure:Read and the per-source Re-enable / Scan-fleet actions require Execution:Execute.

Mixed-version rollout caveat. Agents running a build older than v0.12.0 do not emit the new per-source paused_at / live_rows / oldest_ts lines on echo.status. The dashboard renders an em-dash (—) for those columns when a pre-v0.12.0 agent appears in a scan result. This is not a server bug — it is an honest “we don’t know when this collector was disabled because the agent reporting it pre-dates the field.” Operators upgrading the server before the agent fleet should expect the em-dash for any pre-existing paused sources until the agent at the affected device is upgraded. See docs/user-manual/echo.md for the full Echo dashboard workflow.

Per-operator scan state caveat. Scan results are held in the server’s memory keyed by operator username, with a 30-second per-operator cooldown to defend against retry-storms. Restarting the server clears all scan state; operators will see “No scan data yet — click Scan fleet” after a restart and a fresh scan returns within seconds. Persistence across restarts and multi-server coordination land in Phase 15.G operational hardening.

Audit log additions. Two new audit actions emit on operator activity: echo.status.scan (every Scan-fleet click; result=success/denied/failure with detail) and echo.source.reenable (every Re-enable click; result=success/denied/failure). SIEM rules can distinguish forged form submissions from genuine connectivity failures via the detail=scope_violation vs detail=agent_not_connected distinction even though the HTTP response body is identical (Agent not reachable. 404) for both cases — the body identity is load-bearing for the no-enumeration-oracle property.

vNEXT — Fleet visualization (3D) (/viz/fleet)

The fleet-visualization feature ladder lands across the feat/viz-engine branch. PRs 1–12 are shipped: agent collector + server store + REST/fragment routes + page scaffold + camera controls + cube renderer + Sprite labels + hover tooltip + interior process nodes coloured by category + intra-cube localhost edges + per-cube listening-socket spheres + cross-machine connection edges + push-based topology ingestion + three-tier stacked layout, talking-socket dots, curved tube wires, loopback-bind filter (PR 12). Remaining ladder PRs add the vulnerability overlay and final polish (LOD, edge bundling, a11y, perf).

Operator-visible state today (PRs 1–12). Navigating to /viz/fleet shows one translucent cube per fleet machine, organized into three architectural tiers — frontend cubes on the top Y plane, applications in the middle plane, databases on the bottom plane. Within each tier, machines fall onto a deterministic per-tier grid (FNV-1a hash on agent_id). Live agents render at opacity 0.18; stale agents (no echo.fleet_snapshot push within the staleness threshold) drop to 0.08. Per-OS palette (on the default Slate scheme; the other schemes follow their own chart tokens): Linux #2dd4bf, macOS/Darwin #f5c542, Windows #4c9aff. Hostname Sprite labels render below each cube. Inside each cube, one SphereGeometry dot per process is laid out deterministically (hash(pid|ppid)-mod-bucket inside the cube’s interior) and coloured from a six-category 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. Each cube’s TOP face carries a ring of coral-coloured listening-socket spheres (one per listeners[] row) with :port labels; loopback-only listeners (127.x, ::1) are now hidden from the surface — they aren’t reachable from other instances. Each cube’s BOTTOM face carries a ring of cool-blue talking-socket dots (one per unique outbound (proto, dst_ip, dst_port)). Cross-machine connections render as thick curved tubes (THREE.TubeGeometry along a CubicBezierCurve3 with vertical end-tangents) that drop from the source’s talking dot down/across to the destination’s listener sphere. External (off-fleet) destinations render as short grey stub lines with ring markers. Hover order: listener sockets → talking sockets → process dots → edges → cubes. WASD pans, drag rotates, wheel zooms.

Tier classification heuristic. classifyTier reads listeners[] port hints (DB_PORTS = {3306, 5432, 6379, 27017, 1521, 1433, 9042, 9200, 5984, 8086, 11211} / WEB_PORTS = {80, 443, 8080, 8443, 8088}) plus process-category strings ('database', 'web'). Priority is db > web > app: a host with any DB signal lands on the database tier regardless of whether it also serves web traffic. A host with no DB or web signal defaults to the application tier. Known limitations: (a) databases on non-standard ports (e.g. Postgres on 5431 for a sharded cluster) fall through to the application tier unless one of the host’s processes is classified as database by process_category.hpp; (b) WEB_PORTS is intentionally narrow — it covers reverse proxies / load balancers / API gateways on ports 80/443/8080/8443/8088 but not dev-server defaults (3000/4200/5173/8000), because in a classic three-tier deployment a nodejs/django/vite server listening on those ports is application work, not the front edge. There is no operator-accessible override today; both limitations are tracked as a follow-up issue on function-aware tier classification.

Why empty cubes show no lines. Intra-cube edges appear only for processes with active loopback socket pairs (e.g. Prometheus scraping node_exporter, a client connected to a local Redis / Postgres). A fresh agent with no inter-process loopback traffic shows process dots but no lines — this is expected, not a bug. Lines appear as workloads generate loopback flows.

Browser requirements. The stylesheet every dashboard page loads, this one included, uses color-mix() and :has(), so the supported floor is Chrome 111+, Edge 111+, Firefox 121+, and Safari 16.2+. :has() sets the Firefox figure (color-mix() alone would allow Firefox 113) and color-mix() sets the others. Browsers below the floor still load the pages but lose some state styling, such as hover, selected-row and tinted-badge backgrounds. This page also uses ES module imports resolved through an <script type="importmap"> declaration, which needs Chrome 89+, Firefox 108+, Safari 16.4+, and Edge 89+; the stylesheet is therefore the stricter requirement everywhere except Safari, where importmap raises the floor to 16.4+ on this page (all browsers released since December 2023 meet both). A browser without importmap receives a visible error overlay on the page rather than a blank canvas — the page detects via HTMLScriptElement.supports('importmap') before attempting the module load. MeshPhysicalMaterial cubes additionally require WebGL 2.0, which is bundled with all browsers in the support floor but can be disabled by enterprise group policy on locked-down terminals. Browsers without WebGL 2 will see the grid but the cubes will fail to render with a black or missing material; verify WebGL 2 is enabled in browser policy before pilot rollout. Operators on enterprise-locked browser configurations that lag 2+ years should test on a representative deployment. Page weight: the /viz/fleet route pulls three.js (~685 KB), three-orbit (~32 KB), htmx (~51 KB), the yuzu-viz renderer (~84 KB), plus the design-system CSS. Approximate first-load total is ~1.5 MB of JS (uncompressed). For metered-link or low-bandwidth deployments, consider --viz-disable as a default and enabling it selectively for ops staff.

Reverse-proxy 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). Sub-path proxy deployments (e.g. nginx location /yuzu/) are NOT supported — the absolute paths would 404 against the rewritten origin. Mount Yuzu at the root path of its host or a fronting domain.

Cache posture. The /viz/fleet page response sets Cache-Control: no-cache, no-store, must-revalidate so a server upgrade cannot leave operators with a stale page that references the new vendored assets (which themselves cache for 24 hours via max-age=86400). The vendored asset bundles are content-addressed by server binary version — a fresh page revalidation after upgrade picks up any bundle changes immediately.

Kill switch behaviour. --viz-disable / YUZU_VIZ_DISABLE disables the whole feature: the REST/fragment endpoints, the per-host drill-down routes, and the /viz/fleet and /viz/host/<id> page shells all return 503 (a plain-text “fleet visualization is disabled by an administrator” body). An operator who sets the flag will not see a half-working page. The static asset routes (/static/yuzu-viz.js etc.) are not gated — they are inert without the page — so gating them at a reverse proxy is optional, not required. The kill switch is boot-time only (seeded from config at startup); there is no runtime toggle, so disabling viz under a live incident requires a server restart.

Sizing and capacity. Push-based ingestion holds one RawAgentSnapshot per agent in an in-memory pushed_ map. Per-agent footprint is bounded by the parser caps (4096 processes + 4096 connections per snapshot, ~1–2 MB worst case, typically 5–20 KB). The map is hard-capped at 100 000 agents (kPushedMapHardCap); at the cap, a new agent’s push evicts the least-recently-seen entry (LRU by server receipt time) and emits a topology.push.evicted_for_cap audit event. Watch yuzu_viz_pushed_map_size and alert before it approaches the cap. The store is in-memory only — on server restart it is empty until agents re-push (recovery window ≈ one agent push cycle, ~30–60 s); the pull-based dispatch fetcher serves as the cold-start fallback during that window.

Permission model. The page route is auth-gated only (require_auth); the data fetch at GET /api/v1/viz/fleet/topology enforces Response:Read. An operator with a session but without Response:Read can land on the page (sees the grid and the “Access denied” overlay when the JS fetch fires) but every JSON fetch returns 403. The viz.fleet_topology audit row is emitted on the API path, not the page path — auditors querying “who accessed fleet visualization?” should filter on the FleetTopology target_type (covered in detail in audit-log.md).

Browser-side errors are not server-logged. WebGL context loss, module-fetch 503, and importmap-resolution failures surface only in the operator’s browser console. A future polish PR will add a client-error beacon; today, support engineers diagnosing “viz is blank” reports should ask for a screenshot of the browser console.

Per-host drill-down (/viz/host/<agent_id>). Double-clicking a cube opens a new tab with a 2D bipartite IPC graph (processes + sockets, Cytoscape cose layout) above the existing Echo process tree, with cross-pane select-to-highlight and a resizable splitter. The page route is auth-gated only; the data fetch (GET /api/v1/viz/host/<agent_id>/topology) enforces Response:Read and honours the --viz-disable kill switch. Audit rows are emitted as viz.host_topology (target_type = HostTopology). The agent_id path segment is allow-listed to [A-Za-z0-9._-] before templating; any other character returns 400.

Connection window — fleet_snapshot_window_seconds (Echo plugin config). echo.fleet_snapshot reports not only the connections ESTABLISHED at the exact /proc sample instant but also connections Echo observed recently in its tcp_live warehouse, so short-lived flows still reach the viz. The look-back window is operator-tunable via the Echo plugin’s KV config key fleet_snapshot_window_seconds (default 3600). It is an Echo plugin config key, not a server CLI flag — set it through the Echo plugin’s configuration surface. The 60 s sampler still cannot see sub-interval connections; that needs kernel eventing (tracked separately).

vNEXT — Agent interval triggers now functional

yuzu_register_trigger / yuzu_unregister_trigger were previously no-op stubs — no TriggerEngine was instantiated on the agent, so interval triggers were silently discarded and never fired. As a result the Echo warehouse *_live tables (tcp_live and friends) sat permanently empty in the field, and any plugin that registered an interval trigger had it silently dropped.

Upgrade caveat. After upgrading the agent daemon, registered interval triggers begin firing for the first time. On the first collect_fast cycle (default 60 s) the Echo *_live tables start populating, and operators may see a small increase in per-agent CPU/IO proportional to the number of registered triggers. Any plugin that registered an interval trigger expecting the old no-op behaviour will now have that trigger fire — this is the fix, not a regression. Server-only upgrades are unaffected; the change is entirely agent-side.

vNEXT — OTA update binary signing (#416)

Endpoints that upgraded via OTA self-update have no trust-anchor directory. The installers create it, but agent self-update replaces the binary in place and runs no installer — so on Windows and macOS, where OTA is the normal upgrade route, neither the directory nor its ACL hardening exists after upgrading. Create it before you enable signing, or --update-trust-bundle points at nothing:

# Windows, elevated, in Windows PowerShell. Paste the WHOLE block. It runs only once its
# closing brace arrives, so a broken paste runs nothing, and it stops at the first check
# that fails. Nothing in it is recursive: the directory holds only files, and a recursive
# takeown or icacls would follow a junction planted in it out of the directory.
& {
    $ErrorActionPreference = 'Stop'
    $d = 'C:\ProgramData\Yuzu\agent-certs'
    # True when $p is a plain directory, owned by Administrators or SYSTEM, protected from
    # inheritance, and granting exactly those two full control. Accounts are compared by
    # SID, so this works on any language.
    function Test-Locked($p) {
        if (((Get-Item -LiteralPath $p -Force).Attributes -band 1024) -ne 0) { return $false }
        $s = (Get-Acl -LiteralPath $p).Sddl
        ($s -match '^O:(BA|SY)G:') -and ($s -match 'D:P[A-Z]*\(A;OICI;FA;;;(SY|BA)\)\(A;OICI;FA;;;(SY|BA)\)$') -and ($Matches[1] -ne $Matches[2])
    }

    if (Test-Path -LiteralPath $d) {
        # 1. An existing directory must ALREADY be secured, and is not re-locked. This
        #    procedure does not take over one someone else created: whatever is in it
        #    already decides which updates the agent trusts. If it stops here, check what
        #    the directory contains, move any update-trust-bundle.pem you placed there
        #    yourself somewhere safe, delete the directory -- removing any junction or
        #    link first with cmd /c rmdir (directory) or cmd /c del (file), which remove
        #    the link, never its target; never Remove-Item -Recurse, which in Windows
        #    PowerShell 5.1 deletes a junction target's contents -- run this again, then
        #    copy the bundle back in.
        if (-not (Test-Locked $d)) { throw "$d already exists and is not secured. See the step 1 comment." }

        # 2. Its contents: plain files only, each already owned by Administrators or
        #    SYSTEM and not a hard link -- nothing here takes ownership of a file, so it
        #    only ever repairs files an administrator placed. Then reset them so they
        #    inherit exactly Administrators and SYSTEM. This repairs the rc1..rc5
        #    lock-out (#5196).
        $c = @(Get-ChildItem -LiteralPath $d -Force)
        $bad = @($c | Where-Object { ($_.Attributes -band 1040) -ne 0 })
        if ($bad) { throw ("Only files may be in $d. Remove these first (a junction or directory link with cmd /c rmdir, a file link with cmd /c del): " + (($bad | ForEach-Object { $_.FullName }) -join ', ')) }
        foreach ($i in $c) {
            if ($i.LinkType -eq 'HardLink') { throw "$($i.FullName) is a hard link. Remove it and copy the file in again." }
            if ((Get-Acl -LiteralPath $i.FullName).Sddl -notmatch '^O:(BA|SY)G:') { throw "$($i.FullName) is not owned by Administrators or SYSTEM, so it cannot be confirmed you placed it. Inspect it; if it is yours, run: icacls `"$($i.FullName)`" /setowner *S-1-5-32-544 /L" }
        }
        if ($c) { icacls "$d\*" /reset /L /C /Q | Out-Null }
    } else {
        # 3. A new directory is built and locked in your own temporary folder, which no
        #    other account can reach, then moved into place, so it never exists unlocked.
        #    The lock: make Administrators the owner, drop every explicit entry (/grant:r
        #    alone replaces only the accounts it names), then break inheritance and grant
        #    exactly Administrators and SYSTEM; /L on each, so icacls acts on the path
        #    itself. Never /T: on a file the (OI)(CI) grant is invalid, and icacls then
        #    leaves every existing file with an EMPTY permission list -- SYSTEM, and so
        #    the agent, can no longer read it -- while still reporting success (#5196).
        #    If something takes the name first, the checks around the move stop it; see
        #    step 1 for how to clear it.
        $stage = Join-Path $env:TEMP ('yuzu-agent-certs-' + [guid]::NewGuid().ToString('N'))
        New-Item -ItemType Directory -Path $stage | Out-Null
        icacls $stage /setowner '*S-1-5-32-544' /L /C /Q | Out-Null
        icacls $stage /reset /L /C /Q | Out-Null
        icacls $stage /inheritance:r /grant:r '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' /L /C /Q | Out-Null
        if (-not (Test-Locked $stage)) { throw "The new directory could not be secured." }
        if (-not (Test-Path -LiteralPath (Split-Path $d))) { New-Item -ItemType Directory -Path (Split-Path $d) | Out-Null }
        if (Test-Path -LiteralPath $d) { throw "$d appeared while it was being prepared. Check it and run this again." }
        Move-Item -LiteralPath $stage -Destination $d
        if (-not (Test-Locked $d) -or @(Get-ChildItem -LiteralPath $d -Force)) { throw "$d is not as prepared. Remove it and run this again." }
    }

    # 4. Verify -- the same check the installer runs. Every line must say OK: the directory
    #    protected and granting exactly Administrators and SYSTEM full control, each file
    #    inheriting exactly that, all owned by Administrators or SYSTEM. A BAD line means
    #    something else has access or SYSTEM cannot read the item.
    foreach ($i in @(Get-Item -LiteralPath $d -Force) + @(Get-ChildItem -LiteralPath $d -Force)) {
        $p = $i.FullName
        if ((($i.Attributes -band 1024) -ne 0) -or (($p -ne $d) -and (($i.Attributes -band 16) -ne 0))) { 'BAD  {0}  (link or subdirectory)' -f $p; continue }
        $s = (Get-Acl -LiteralPath $p).Sddl
        $e = if ($p -eq $d) { 'D:P[A-Z]*\(A;OICI;FA;;;(SY|BA)\)\(A;OICI;FA;;;(SY|BA)\)$' } else { 'D:[A-Z]*\(A;(?:OICI)?ID;FA;;;(SY|BA)\)\(A;(?:OICI)?ID;FA;;;(SY|BA)\)$' }
        $ok = ($s -match '^O:(BA|SY)G:') -and ($s -match $e) -and ($Matches[1] -ne $Matches[2])
        '{0}  {1}' -f $(if ($ok) { 'OK ' } else { 'BAD' }), $p
    }
}

Put the bundle in only once the directory exists and is secured, by the installer or by this block: copy it in as an administrator, then check that its owner is Administrators or SYSTEM ((Get-Acl <file>).Owner). Some admin accounts make themselves the owner of files they create; if so, inspect the file and make Administrators its owner with icacls "<file>" /setowner *S-1-5-32-544 /L, or the installer and this block refuse it. A directory created or pre-staged any other way is refused by the installer, which leaves the agent service as it was, and by this block.

Endpoints that ran an agent installer from 0.14.0-rc1 to rc5: those installers took over an agent-certs that already existed, including anything in it. If you did not place the update-trust-bundle.pem there yourself, delete it and copy in a fresh one before relying on signature checking.

What this does not cover: the parent directory C:\ProgramData\Yuzu is not itself locked down (#5257), and a process that opened the directory or a file in it before it was locked keeps the access it opened with (#5258).

# macOS, as root.
mkdir -p /etc/yuzu-agent/certs && chown root:wheel /etc/yuzu-agent/certs
chmod 755 /etc/yuzu-agent/certs

Linux endpoints upgraded through .deb/.rpm need nothing here — Debian’s post-install step re-runs on upgrade, and the RPM ships the directory in its payload. A Linux endpoint that upgraded by OTA self-update, however, is in the same position as Windows and macOS and needs install -d -m 0755 -o root -g root /etc/yuzu-agent/certs.

Default behaviour is unchanged. Agents that pass no --update-trust-bundle behave exactly as before: the downloaded binary is checked against the server-supplied SHA-256 and applied. Signature verification is entirely opt-in.

What signing does and does not close — read this before relying on it. With a trust bundle configured, a compromised or impersonated update server can no longer push a binary your signing key never signed. That is the substitution attack, and it is closed.

It does NOT close ROLLBACK. The signature covers the binary’s CONTENT only, while latest_version and the SHA-256 both come from the server. So a hostile server can still serve a genuinely signed OLD release — one you really did sign — while labelling it a newer version and quoting a hash computed over those same bytes. Every agent-side check passes and the endpoint installs a real but outdated, possibly known-vulnerable build, then reports itself up to date. Closing this needs the version bound into the signed material (a signed manifest, TUF-style), which is not in this release. Treat signing as confining an attacker to binaries you have signed, not to the binary you meant to ship, and keep retiring the signing key’s authority over withdrawn releases accordingly.

New on-disk artifact — the trust anchor. /etc/yuzu-agent/certs/ on Linux and macOS, C:\ProgramData\Yuzu\agent-certs\ on Windows, created by the installers. It is deliberately NOT the server’s /etc/yuzu/certs — that path is the server’s own CA directory, which the server owns and re-tightens on boot, so no single mode serves both. You place the PEM bundle there yourself; nothing is installed into it.

Second new artifact — the signature sidecar. An uploaded signature is stored as <binary>.sig beside the package in the update directory. Like the package itself it is node-local and not covered by the database backup: it is recreated by re-uploading the package with its signature, so it needs no separate backup step.

Two new agent flags, both off by default: --update-trust-bundle (YUZU_UPDATE_TRUST_BUNDLE) and --update-require-signature (YUZU_UPDATE_REQUIRE_SIGNATURE). Setting the second without the first refuses to start, rather than running with enforcement silently inert.

Nothing to do on upgrade unless you want signing. If you do, read Signing update binaries — in particular the two-stage rollout, because a fleet that enforces before every package is signed stops updating.

vNEXT — Plugin code signing (#80)

Plugin signature verification ships in two parts: an agent-side CMS verifier and a server-side Settings UI for managing the trust bundle. Default behaviour is unchanged — agents that do not pass --plugin-trust-bundle and operators that do not upload a bundle through the new Settings card see identical behaviour to prior releases (allowlist-only, sha256 hash check).

New on-disk artifact. plugin-trust-bundle.pem in the default cert directory (Linux, and macOS as root: /etc/yuzu/certs/plugin-trust-bundle.pem; Windows: C:\ProgramData\Yuzu\certs\plugin-trust-bundle.pem). The server always writes it there, even when --ca-dir points elsewhere. Server-managed via Settings → Plugin Code Signing. Back up the default cert directory (the path above), paired with the Postgres dump (see Configuration Files); that directory is --ca-dir only when --ca-dir is left at its default. A backup that captures the database but not that directory restores plugin_signing_required=true (in runtime_config) without the trust bundle file — agents fetching the policy will receive a 500 and require-mode agents will reject every plugin until the bundle is restored.

Cert-dir collision check. The server now treats this filename as authoritative. If a prior deployment placed an unrelated PEM at this exact path for a different purpose, it will be interpreted as the plugin trust bundle on first read. This is unlikely (the filename was unused before this release) but worth confirming before upgrade. Run ls <cert-dir>/plugin-trust-bundle.pem and rename the file if it pre-exists for any other purpose.

New runtime_config key. plugin_signing_required (string "true" or "false"). Set via the Settings card; reading the key directly via the runtime-config REST surface is supported, and writing it there needs an administrator account (an MCP-tier or service-scoped token is refused) but is not recommended — the Settings UI guarantees the disk-and-DB invariants (two-phase clear, file-presence-equals-enabled).

New audit actions. plugin_signing.bundle.uploaded, plugin_signing.bundle.cleared, plugin_signing.require.changed — see audit-log.md for the result and detail conventions. SIEM rules already filtering on success/failure/denied will pick these up unchanged; no new vocabulary tokens.

Operator distribution. The server hosts the bundle at GET /api/v2/agent/plugin-policy (PluginSigning:Read, Administrator-only; unreachable by any MCP token at any tier as of #4028). Agents are pointed at a local copy via --plugin-trust-bundle <path>; the manual workflow today is curl + jq + write the JSON’s data.trust_bundle_pem field to disk on each agent host. The old GET /api/v1/agent/plugin-policy (flat top-level body, no data envelope) is deprecated — see the vNEXT note below for the migration and removal window. Automatic agent-side fetch is a forthcoming change.

Fleet-suicide caveat. The Yuzu release pipeline does not yet sign the 44 in-tree plugins under agents/plugins/. Do NOT enable “Require signed plugins” until you have signed every plugin your fleet uses, including the in-tree ones. Use the transitional mode (bundle uploaded, Require off) during rollout. The Settings card surfaces this warning inline.

vNEXT — Response templates (#254, Phase 8.2)

Superseded (ADR-0058). instruction_definitions/instruction_sets moved from per-replica SQLite to the shared PostgreSQL substrate — the sqlite3 instructions.db commands and the SQLite schema_meta v1→v3 probe-and-stamp mechanism described below no longer apply; that mechanism doesn’t exist in the current codebase. For backup/restore and schema-version checks on this store today, use the PostgreSQL Substrate section’s pg_dump/ pg_restore procedure against the instruction_store schema, not the commands in this subsection. Left as-written below for historical reference (this is what the Phase 8.2 migration looked like on SQLite, in case an operator is diagnosing an upgrade that predates the Postgres cutover).

Phase 8.2 ships named response-view configurations attached to each InstructionDefinition: a column subset, sort order, and filter presets the dashboard’s filter-bar View dropdown surfaces. The feature is purely additive — operators who never author a template see a synthesised __default__ view that is byte-identical in behaviour to the prior “show all columns, sort by Agent” default.

Schema migration. instruction_definitions gains one column: response_templates_spec TEXT NOT NULL DEFAULT '[]'. The migration ledger advances from v2 to v3. ALTER TABLE ADD COLUMN with a constant default is O(1) in SQLite (metadata-only, no table rewrite); the migration is non-destructive.

Pre-upgrade snapshot (recommended). Take a backup of the InstructionStore database before upgrade:

cp /var/lib/yuzu/instructions.db /var/lib/yuzu/instructions.db.bak
# or, for a hot-running server, use SQLite's online backup:
sqlite3 /var/lib/yuzu/instructions.db ".backup /var/lib/yuzu/instructions.db.bak"

Post-upgrade validation.

sqlite3 /var/lib/yuzu/instructions.db \
  "SELECT version FROM schema_meta WHERE store='instruction_store';"
# expected output: 3

If the value is 2 instead of 3, the migration did not run — check the server logs for MigrationRunner: instruction_store migrated to v3 (or for a probe-and-stamp failed line in the InstructionStore section).

Boot wedge recovery. A corrupt schema_meta row or a pre-existing response_templates_spec column with a missing schema_meta v3 stamp will trip the probe-and-stamp guard, which fails closed (server logs InstructionStore: probe-and-stamp failed; closing database). To recover, restore the snapshot or apply the column manually:

# Stop the server first.
systemctl stop yuzu-server

sqlite3 /var/lib/yuzu/instructions.db \
  "ALTER TABLE instruction_definitions ADD COLUMN response_templates_spec TEXT NOT NULL DEFAULT '[]';"
sqlite3 /var/lib/yuzu/instructions.db \
  "INSERT OR REPLACE INTO schema_meta (store, version, upgraded_at) VALUES ('instruction_store', 3, strftime('%s','now'));"

systemctl start yuzu-server

New audit actions. response_template.create, response_template.update, response_template.delete — see audit-log.md for the failure-reason vocabulary. SIEM rules already filtering on success/denied will pick these up unchanged.

Authoring caveats. The dashboard YAML editor’s lightweight line-scanner does not extract spec.responseTemplates into the indexed column; author through POST /api/v1/definitions/import (JSON envelope) or the REST template endpoints. Imported templates with the reserved id: __default__ are silently dropped during normalisation.

vNEXT — webhook store moves to Postgres; secrets now encrypted at rest (ADR-0057) (breaking)

WebhookStore moves from SQLite (webhooks.db) to the PostgreSQL substrate, and the webhook HMAC signing secret is now envelope-encrypted at rest (SecretCodec, ADR-0010) instead of a plaintext column. No production fleet ever ran a pre-Postgres build of this store, so there was no real legacy data to migrate — the mandatory backfill this originally shipped with was retired shortly after (#3623, ADR-0057’s Update); a legacy webhooks.db file is never read into Postgres, only hardened to 0600 and counted for a diagnostic warning at boot. POST /api/webhooks now returns 400 for an invalid URL, distinct from a 503 for a genuine store/database error; both POST and DELETE return 503 (rather than a silently-empty/silently-failed result) on that latter case, previously ambiguous. A legacy webhooks.db file left on disk still holds every pre-cutover secret in plaintext and is restricted to the file owner where the platform supports it (POSIX only, see the ADR) — see rest-api.md for guidance on disposing of it securely. Full detail: docs/adr/0057-webhook-store-postgres-migration.md and the ## ⚠️ Behaviour change: webhook store moves to Postgres (ADR-0057) section in docs/user-manual/upgrading.md.

vNEXT — initialize can answer 503 during a graceful shutdown (#3042)

With MCP streaming on, initialize now returns HTTP 503 / JSON-RPC -32015 (“Server is shutting down”) for a narrow, transient window (seconds, not the deploy’s whole grace period) if it lands after ServerImpl::stop() has begun draining live sessions. Affected: any MCP client integration — the reference clients and most SDKs already treat a non-2xx initialize as a transient failure and retry/reconnect; a client that specifically asserted “initialize never 503s” needs updating. No retry_after_ms is given (this process has no visibility into when a replacement instance will be reachable); reconnect and re-initialize once it is. A session that was already live when shutdown began instead receives a clean notifications/yuzu.stream_closed close frame (reason: session_terminated) rather than a bare connection drop — see MCP — Troubleshooting for the full symptom/cause/fix.

vNEXT — Linux adapter names no longer carry the @peer suffix (breaking)

What changed. The network_config agent plugin’s adapters and ip_addresses actions were rewritten to read Linux interfaces via rtnetlink instead of parsing ip -o link show text. The previous parser copied iproute2’s display form for any interface with a parent link — a veth pair, a VLAN sub-interface, a tunnel device — which renders as <name>@<parent>, for example eth0@if74 or eth0.100@eth0. The new leg reads the kernel’s own interface name directly (IFLA_IFNAME), which never carries that suffix: the same two interfaces now report as eth0 and eth0.100. This is a deliberate choice, not an oversight — eth0@if74 is iproute2 presentation syntax that no other data source on the host recognises, and it was also silently breaking the adapter’s link-speed lookup (/sys/class/net/eth0@if74/speed never resolves; the un-suffixed path does), so restoring the old string would also restore that bug.

Who this affects. Any deployment with a Linux fleet segment where at least one host has an interface with a parent link — containerised hosts (veth is the default Docker/Kubernetes networking model), VLAN-tagged interfaces, or tunnel devices (WireGuard, OpenVPN, GRE) — and has a saved dashboard filter, a report, an external inventory join, or automation keyed on the adapter-name string containing @. A plain bare-metal or VM fleet with no such interfaces is unaffected; the field’s value changes only for interfaces that previously carried the suffix.

Before upgrading, check whether this affects you. On a representative Linux host, compare:

ip -o link show | awk -F': ' '{print $2}' | grep '@'

If this returns any interface names, those hosts will report a different (shorter) adapter name for those interfaces after upgrade — update any saved filter, dashboard, or join key that references the old @-suffixed string. If it returns nothing, this note does not affect you.


vNEXT — software_actions.installed_count’s reported number can change after upgrading (breaking)

What changed. The software_actions agent plugin’s installed_count action was migrated off popen/a powershell -Command shell-out onto native counting, and the counting rule changed on both platforms it touches:

Who this affects. Any deployment with automation, a dashboard trend line, or an alert threshold keyed on the literal count|N value from installed_count — particularly a Linux fleet with apt-mark holded packages, or any Windows fleet (the registry-count/property-count divergence is rare but not bounded to a specific host shape, so it isn’t pre-checkable the way the Linux change is).

Before upgrading, check whether this affects you. On a representative Linux host:

dpkg-query -W -f='${db:Status-Abbrev}\n' | grep -c '^hi'

A nonzero result means that host’s installed_count will report a higher number after upgrading, by exactly that many. If your automation only compares the count to a rough threshold or trend, no action is needed; if it asserts an exact expected value, re-baseline it after upgrading.

vNEXT — GET /api/v1/agent/plugin-policy is DEPRECATED; use GET /api/v2/agent/plugin-policy (#4028, #4144)

What changed, and why there are now two versions. This route already existed pre-#4028 (documented here as the trust-bundle-PEM distribution path for agent config management) but was off the REST-v1 API-parity ledger. #4028 originally hardened it onto the same conventions every other /api/v1/* route in this manual uses — but did so in place, reshaping GET /api/v1/agent/plugin-policy’s response envelope without a version bump. An external review (#4144) caught that this violates docs/api-versioning-policy.md’s own rule (“changing an error envelope’s shape” requires a /api/v2/ sibling and a deprecation cycle). Corrected: the hardened behavior now lives at GET /api/v2/agent/plugin-policy; GET /api/v1/agent/plugin-policy is restored to its exact pre-#4028 shape and formally deprecated.

GET /api/v2/agent/plugin-policy (the hardened route — migrate to this):

GET /api/v1/agent/plugin-policy (DEPRECATED — unchanged since before #4028): flat top-level body ({"enabled":..., "required":..., "trust_bundle_pem":..., "cert_count":..., "sha256":...}), require_admin gate (not RBAC), the old bespoke error shape, no audit row, and the pre-existing runtime_config_store/PEM-race behavior described above. Kept exactly as it always was — no new hardening is being back-ported to it.

Deprecation window (per docs/api-versioning-policy.md). Announced 2026-09-08. GET /api/v1/agent/plugin-policy keeps working for at least 90 days and at least one intervening feature release, whichever is longer (so no earlier than 2026-12-07, and not before the next feature release ships) — removal will carry its own CHANGELOG.md Breaking/Removed entry per the cycle’s Step 3, never a silent drop.

Who this affects, and what to do. Any script, admin tool, or manual curl pipeline reading this route directly. Point it at /api/v2/agent/plugin-policy and read response["data"]["trust_bundle_pem"] (was response["trust_bundle_pem"]) — no CLI flag or configuration change is needed, this is a URL and response-shape change only. No action is required before the removal window closes, but migrating now also picks up the TOCTOU integrity fix.

vNEXT - a poisoned inventory record now makes two result-set producer routes refuse instead of silently narrowing (#4496) (breaking)

What changed. POST /api/v1/result-sets/from-inventory-query and the MCP create_result_set_from_inventory_query tool now refuse (503/kInternalError) when a candidate inventory record’s stored data_json nests past the JSON depth guard (the #2437-class poisoned/over-nested record). Previously the poisoned record was silently skipped with no signal at all, and the call succeeded, materialising a result set that had quietly excluded that agent.

Who this affects. Any deployment with an existing stored inventory record (inventory_store.inventory_data) whose data_json nests deeper than the JSON depth guard allows - most likely a row predating the #2437-class write-side guard. A call to either route above that previously succeeded despite such a record now refuses outright instead of materialising a result set narrower than the true match set.

How to identify the affected record(s). There is no SQL-level detection query or purge endpoint for this today - the only current signal is a server log line at WARN level, evaluate_inventory: excluding agent=<agent_id> plugin=<plugin> - data_json nests too deeply (#2437-class), emitted once per excluded record on every call that reaches the guard. Watch the server log for this line following a 503 from either route above to identify which agent/plugin’s record needs re-collection at the source. Restart the affected agent to force a full resync; if the same WARN line (or a subsequent poison_excluded refusal) recurs afterward, the source data itself genuinely exceeds the depth guard and re-collection alone will not clear it - the source plugin needs a fix, or an operator can manually run DELETE FROM inventory_store.inventory_data WHERE agent_id=... AND plugin=... (the row repopulates on the next sync cycle if the source data is unchanged).

vNEXT - a malformed (unparseable) inventory record ALSO now makes the same two result-set producer routes refuse (#4496 follow-up) (breaking)

What changed. Same two routes as the entry above (POST /api/v1/result-sets/from-inventory-query and the MCP create_result_set_from_inventory_query tool), a DIFFERENT trigger: a candidate inventory record whose stored data_json fails to parse as JSON at all (a syntax defect, not over-nesting) now also refuses (503/kInternalError, reason=parse_error_excluded) rather than being silently skipped. Kept as a separate, distinctly-named cause from poison_excluded above so an operator can tell WHICH guard excluded a record.

Who this affects. Any deployment with an existing stored inventory record whose data_json is not valid JSON - the write-side depth guard (gateway_service_impl.cpp’s json_exceeds_depth) checks nesting depth only, not general JSON validity, so a malformed-but-shallow blob has always been able to reach storage. A call to either route above that previously succeeded despite such a record now refuses outright instead of materialising a result set narrower than the true match set. The read-only POST /api/v1/inventory/evaluate route instead surfaces this as a results_excluded_by_parse_error count field alongside results_excluded_by_poison (present only when non-zero), the same posture as the depth-guard entry above. POST /api/inventory/query is UNAFFECTED by this specific change: it never calls evaluate_inventory() (it lists records by agent/plugin/time metadata, not by evaluating conditions against parsed JSON) and already degrades gracefully on a parse failure, returning the record with its data field as a raw string rather than dropping it - there is no narrowed-match-set hazard on that route for this cause.

How to identify the affected record(s). Same mechanism as the entry above: a server log line at WARN level, evaluate_inventory: excluding agent=<agent_id> plugin=<plugin> - data_json failed to parse (malformed JSON), emitted once per excluded record. Watch the server log for this line following a 503 (reason=parse_error_excluded) from either producer route to identify which agent/plugin’s record needs re-collection at the source. Same remediation ladder as the entry above: restart the affected agent to force a full resync; if the same WARN line (or a subsequent parse_error_excluded refusal) recurs afterward, the source data itself is genuinely malformed and re-collection alone will not clear it - the source plugin needs a fix, or an operator can manually run DELETE FROM inventory_store.inventory_data WHERE agent_id=... AND plugin=... (the row repopulates on the next sync cycle if the source data is unchanged).


vNEXT — certificates delete on Windows fails closed instead of silently switching stores (#4377) (breaking)

What changed. The security.certificates.delete action on Windows opened the named store under the LOCAL_MACHINE hive; if that open failed, it silently retried under CURRENT_USER and deleted there if a same-named store existed and matched. delete now opens LOCAL_MACHINE only — a failure to open there reports error|<store> store could not be opened; nothing removed (non-zero exit, status|not_found never returned in this case) and removes nothing, rather than falling back. list/details are unaffected in kind — they keep the CURRENT_USER read fallback, and now disclose it more completely (an explicit output row and a CONSTRAINED/PARTIAL result whenever a store fell back, an earlier store couldn’t be opened, or its enumeration was incomplete, not only when the final match itself came from the fallback).

Who this affects. Any deployment where a delete automation’s target LOCAL_MACHINE store can become unopenable (a permissions misconfiguration, a corrupted store) and a same-named CURRENT_USER store exists on the same host. Before this fix, that combination made delete silently remove a certificate from a store the caller never named — which is the bug this closes, not a regression. Automation that only checked for a non-zero exit code was already correctly informed on every OTHER failure path; this is the one combination where the old behavior masked a wrong-target delete with an apparent success.

Before upgrading, check whether this affects you. If your delete automation for Windows hosts does not already treat a non-zero exit / an error|... result as a hard failure requiring investigation, add that check now. There is no way to pre-check for the specific LOCAL_MACHINE-unopenable-with-a-CURRENT_USER-fallback condition from outside the action itself; the fix is unconditional and has no opt-out.

vNEXT — service-scoped tokens can no longer create a result set from an inventory query (#4980) (breaking)

What changed. POST /api/v1/result-sets/from-inventory-query now denies a service-scoped API token outright (403), matching its 8 non-dispatch sibling result-set routes. Before this fix, the route gated only through the Inventory:Read fleet-read chokepoint (fleet_read_fn), which admits and confines a service-scoped caller rather than denying it — but the result set this route creates is owner-scoped to the minting principal’s identity, not the token’s own service tag, so a service-scoped token holding Inventory:Read could mint a set that its minting principal’s other tokens/session could then read back.

Who this affects. Any integration using a service-scoped API token to call this specific route. No legitimate use of a service-scoped token should have depended on this — the other 8 result-set routes have always denied this way, and the MCP twin (create_result_set_from_inventory_query) was never affected. There is no opt-out.

What to do. Nothing for a correctly-built integration. If a service-scoped workflow did depend on this route, it needs a non-service-scoped credential going forward, same as the flip notes above; there is no per-route or per-deployment allow.


vNEXT - agent logging is now asynchronous, with a new self-exit code 5 (#4666 PR-2) (NOT breaking)

What changed. The agent no longer writes log lines synchronously on the thread that produced them; main.cpp installs a bounded async hand-off logger as the process’s spdlog default (see “Agent logging is now asynchronous”, the paragraph immediately before Stopping a wedged agent under systemd Units, further down this page, for the queue-size/memory-cost/overrun details). Agent shutdown gains one more possible self-exit code, 5, distinct from the existing 1/3/4, fired from either of two causes at that step: tearing down that logger (flushing its queue, joining its worker thread) does not complete within an internal 2-second watchdog, or the teardown itself fails outright (an immediate exit with no wait at all — see Stopping a wedged agent under systemd Units, further down this page, for both causes in full).

Impact. Not a breaking change: no flag, wire format, API, or default behavior changes, and no operator action is required. Log output looks the same (same pattern/JSON formatting, same --log-file/rotation behavior) with one exception below. The externally-visible differences are: (1) under sustained log-sink overload, the agent can now silently drop older queued lines (overrun_oldest) rather than blocking, so a very bursty logger under a stuck sink may show gaps instead of a stall; (2) a shutdown wedge that used to hang or need SIGKILL before this and the related #2233 watchdogs landed can now self-exit with code 5 specifically, in addition to the pre-existing 1/3/4; (3) the “Received signal, shutting down…” line the agent prints on SIGINT/SIGTERM/Ctrl-C used to be a raw, fixed-format write straight to stderr — it is now routed through the same configured logger as everything else, so it picks up the configured pattern (or JSON structure under --log-format json) and now also lands in --log-file when one is configured, not stderr alone. A plain substring match against the message text (the default text pattern keeps the original words verbatim) is unaffected; a line-anchored or byte-exact matcher, or one that assumed this specific line was stderr-only, needs updating; (4) that same line is now an ordinary info-level call rather than an unconditional raw write, so at --log-level warn or above — a configuration this page itself recommends for agents to cut noise, see “If log volume matters” above — the line is silently absent entirely, whereas before it always printed regardless of level. If you rely on this line’s presence to confirm a clean/intentional stop, either keep --log-level at info or below, or switch to checking the process exit code (0 = clean) instead.

Who should check. Any operator running a supervisor script or monitoring rule that pattern-matches the agent’s process exit code against a fixed set ({0,1,3,4} or similar) should widen it to include 5. An unrecognised exit code there should not be interpreted as “impossible” or treated as a different failure class than the documented watchdog exits already are. See Stopping a wedged agent under systemd Units, further down this page, for what each code means and how they interact.

vNEXT — device_ids on result-set creation now requires Infrastructure:Read (#4983, breaking)

What changed. POST /api/v1/result-sets, MCP create_result_set, and the dashboard CSV-paste import (POST /fragments/result-sets/create) previously accepted an arbitrary caller-supplied device_ids array with only a type check and a size cap — no RBAC check of any kind ran when device_ids was supplied, beyond the ordinary session-authenticated/owner-scoped gate every call to these routes already passed. All three now additionally gate a non-empty device_ids through the admit-then-filter Infrastructure:Read chokepoint (fleet_read_fn, ADR-0017) and validate that every entry both exists and is visible to the caller’s own scope — a nonexistent or out-of-scope id now rejects the whole request (400 RESULT_SET_UNKNOWN_DEVICE_ID on REST, the JSON-RPC equivalent on MCP, an error toast on the dashboard fragment) instead of being silently accepted as a member. Each entry is also now capped at 256 bytes (400 RESULT_SET_DEVICE_ID_TOO_LONG on REST, the JSON-RPC equivalent on MCP, an error toast on the dashboard fragment) — a separate, additional new rejection path on all three surfaces.

Who this affects. Any RBAC-enabled deployment where a non-admin principal creates a result set by supplying device_ids directly. Checked against this repository’s actual seed grants (RbacStore::seed_defaults), of the 8 seeded built-in roles only Administrator and ITServiceOwner hold Infrastructure:Read — Viewer, Operator, PlatformEngineer, ApiTokenManager, Reviewer, and ForensicsAnalyst do not (ForensicsAnalyst was added later, so it is not a role that could have created result sets with device_ids before this release). A principal holding one of the five roles that existed at that release (Viewer, Operator, PlatformEngineer, ApiTokenManager, Reviewer) who could previously create a result set with device_ids (the RBAC-enabled default before this release carried no permission check on this field at all) will now receive a new 403 on all three surfaces. This is a genuine, confirmed regression path, not a hypothetical one. A caller who omits device_ids, or supplies an empty array, is completely unaffected on any surface — the gate is never consulted. RBAC-disabled deployments (the shipped default) are entirely unaffected — the legacy permission fallback admits every authenticated session regardless of this change.

Check your automation and your dashboard operators, not just your admins — this is the failure that hides. Before upgrading an RBAC-enabled deployment, identify every non-admin principal (API token, service account, or interactive session) that currently calls POST /api/v1/result-sets / create_result_set / the dashboard’s CSV-paste import (“New result set from a list of device ids”) with a non-empty device_ids. This includes a dashboard operator pasting a CSV of ids just as much as an automation calling the API — both hit the identical new gate. Audit your AuthDB/API-token principal list and your dashboard-role assignments for holders of Viewer, Operator, PlatformEngineer, ApiTokenManager, or Reviewer who use this capability; each will start receiving 403 the moment this release is live, with no advance warning from the caller’s own side.

What to do. Of the 7 fleet-wide-assignable built-in roles, only Administrator holds Infrastructure:Read — ITServiceOwner (the 8th, management-group-scoped-only) also holds it. A principal who needs to keep creating result sets with device_ids after this release needs one of those two roles; per rbac.md’s own current state, there is no REST API or Settings UI yet for creating a custom role or editing an existing role’s permission set (tracked as planned, not implemented) — the only way to grant Infrastructure:Read to a role that doesn’t already hold it is a direct RbacStore::set_permission() call against the shared Postgres rbac_store schema (see rbac.md’s “Custom roles” section for the exact mechanism). There is no per-route opt-out; a caller that only ever creates result sets via parent_id/source_payload (no device_ids) is unaffected and needs no change.

Settings Page

The Settings page is the primary administrative interface. It is accessible only to users with the admin role and is rendered server-side using HTMX.

URL: /settings (redirects to the sign-in page, /home, if unauthenticated; a signed-in non-admin is sent to /)

The Settings page is organized into sections, each loaded as an HTMX fragment. Changes take effect immediately without a server restart unless otherwise noted.

Sections

SectionFragment RouteDescription
TLS Configuration/fragments/settings/tlsAgent (gRPC) certificate, key and CA upload or paste, plus the in-memory mTLS display switch. Does not control dashboard HTTPS (see /fragments/settings/https, read-only). Shown on Settings → Security and sign-in as “Agent connections (mTLS)”.
User Management/fragments/settings/usersCreate and delete local user accounts, change your own password and reset other local users’ passwords, revoke sessions. The Users and roles page (/access/users) in the Access section of the sidebar offers add, remove, change role, revoke sessions, and your own password change and sign-out-everywhere; resetting another user’s password stays here or on the REST route.
Two-factor sign-in (MFA)/fragments/settings/mfaShown on Settings → Security and sign-in. Per-operator TOTP enrollment + recovery codes. Admin-only in this release. Self-service for the logged-in admin only; to clear another (locked-out) user’s MFA use the audited break-glass CLI yuzu-server --mfa-reset <username> — see docs/ops-runbooks/auth-db-recovery.md § Emergency MFA disable.
Enrollment Tokens/fragments/settings/tokensGenerate and revoke tokens for Tier 2 agent enrollment. Also shown on the Groups and enrolment page (/groups, Estate group), from the same fragment.
Pending Agent Approvals/fragments/settings/pendingApprove or deny agents waiting in the Tier 1 approval queue. Also shown on Groups and enrolment.
Auto-Approve Policies/fragments/settings/auto-approveDefine rules for automatically approving agents based on criteria (hostname pattern, IP range, etc.). Also shown on Groups and enrolment.
API Tokens/fragments/settings/api-tokensCreate and revoke bearer tokens for REST API automation.
Plugin Code Signing/fragments/settings/plugin-signingUpload a PEM trust bundle for agent plugin CMS signature verification, toggle the require-signed-plugins flag, and remove the bundle. The trust bundle persists at <cert-dir>/plugin-trust-bundle.pem; the require flag persists in runtime_config under key plugin_signing_required. Distribution to agents is operator-driven today (curl into a local file referenced by --plugin-trust-bundle); automatic agent-side fetch is a forthcoming change. See the user-manual Agent Plugins → Plugin Code Signing section.
OTA Updates/fragments/settings/updatesUpload agent binaries, view available versions, promote a version to production. The upload form also takes an optional detached CMS Signature (.sig, max 64 KB), stored as a sidecar beside the binary and served to agents on CheckForUpdate — see Signing update binaries. The server stores but never verifies it.
Tag Compliance(moved)Now the Compliance by tag panel on the Compliance page; see Tag Compliance.
RBAC Management(planned — no fragment yet)Enable or disable RBAC enforcement, create and manage roles. RBAC is enforced via RbacStore and the /api/v1/rbac/* REST API, but has no Settings page fragment yet.
OIDC SSO / Directory/fragments/settings/directoryConfigure OIDC single sign-on (issuer, client ID, secret, admin group). Editable form with a “Test SSO” button (checks the issuer’s discovery document and signing keys). Changes are applied to the running server AND persisted to runtime config. This is the only path that applies an OIDC change to the RUNNING server (the swap is process-local - a restart rebuilds the provider from the command-line/environment value, so a durable rotation must update that too; the stored admin group is the exception and still applies; see security hardening) - a PUT /api/config/oidc_* persists the value but never reaches the live provider, on this boot or a later one (see REST API -> Runtime Configuration).
Internal CA/fragments/settings/caView the built-in Certificate Authority (algorithm, SHA-256 fingerprint, expiry), download the CA certificate + CRL, browse the issued-certificate inventory, and revoke a certificate. Security:Read to view, Security:Delete to revoke.
Access Reviews/fragments/settings/access-reviewsPeriodic access review (SOC 2 CC6.2) convenience panel — pull/download the cross-principal grant export (JSON/CSV), open a new attestation campaign, and record per-grant attested/flagged_revoke decisions against an open campaign (/fragments/settings/access-reviews/campaign?id=...). Gated on AccessReview:Read (the write controls appear for a caller who also holds AccessReview:Attest), a view over the same /api/v1/access-reviews* REST API and its MCP twins — not a separate data path. Unlike the Audit and reviews page (/access/audit), viewing this panel writes no access_review.list, access_review.get or access_review.exported audit row; the changes made from it are audited by the REST routes it calls. See docs/auth-architecture.md “Periodic access reviews”.

Plugins and updates (System page)

An admin-only page under Settings in the sidebar reads the same data as the Settings panels and REST routes it sits over (no new write route):

The panels apply the page’s admin gate and the matching REST route’s own permission, and show a note (not an empty list) when denied or when the store could not be read. The stored-configuration panel decides from the one authorization check GET /api/v1/plugin-config and the MCP list_plugin_config tool use (the ADR-0017 list-read gate, which admits an active just-in-time elevation and refuses a management-group-confined grant), so the panel and those two routes make the same list decision for any principal who also passes the page’s administrator gate (the REST route and the MCP tool record the confined-grant refusal). A panel refused for a signed-in browser session leaves no denial row in the audit log (the panels load by themselves, so a refusal is not an attempt); an API-token or engine caller refused the same panel leaves the usual auth.admin_required or auth.permission_required row. Opening the page itself as a non-administrator is audited.

Certificates and secrets (System page)

An admin-only page under Settings in the sidebar reads the same data as the Settings panel and REST routes it sits over (no new write route):

The page itself requires an administrator. Each panel is loaded from its own route, which enforces the matching REST permission (Security:Read), and the page shows a note (not an empty list) when a panel is denied or cannot be read. A revocation list whose next-update time has passed shows Expired list rather than Current.

Revoking an agent certificate from the dashboard

When the server runs its built-in CA, Settings → Certificates and secrets (or Settings → Internal CA on the Settings page) lists the newest 500 issued certificates; for an older one, find its serial with GET /api/v1/ca/issued?offset= and revoke it with POST /api/v1/ca/revoke. A caller whose view of the fleet is confined (management-group confinement), or who holds no Inventory:Read grant, is refused that route with a 403, and the panel shows a note in place of the issued-certificate list while keeping the CA details, the root and CRL downloads and the import form. A confined Security:Delete holder can still revoke a serial it already knows through POST /api/v1/ca/revoke, but cannot list serials: use an unconfined account, or read revoked serials from the CRL (/api/v1/ca/crl). The panel’s reply to a revoke or an import is never blank for such a caller: it shows the panel with a note where the list would be, plus a confirmation banner and a success toast after a success; the banner adds that the issued-certificate inventory is not shown (see the note below). A caller without Security:Read gets the plain note instead of the panel, and after a success the same confirmation banner and toast, without that sentence about the inventory. To revoke one (e.g. a decommissioned or compromised agent):

  1. Find the agent’s row in the inventory (match on Subject = agent_id or the Serial).
  2. Optionally type a reason (e.g. key compromise, decommissioned) — it is stored on the revocation record and audited.
  3. Click Revoke and confirm. The panel refreshes in place showing the cert as Revoked and the public CRL is republished automatically. If that publish fails (the Settings panel does not show this, while Certificates and secrets shows Out of date; the REST/MCP revoke response returns crl_republished:false and a ca.crl.published failure is audited), the server republishes it on its own: on the next 15-second freshness tick once the cause clears, or up to about 5 minutes later if that attempt fails too. You do not need to revoke again.

Revocation takes effect immediately server-side: the agent is refused on its next connection, and any already-open command stream is torn down by the revocation sweep within ~15s. A revoked agent cannot re-enroll its way back by deleting its key (re-issuance is refused while the revocation stands). The same operation is available over REST (POST /api/v1/ca/revoke) for automation. The dashboard action is CSRF-protected and requires Security:Delete.

Gateway-proxied agents: revocation is enforced on direct-connect agents only — an agent reaching the server through a gateway presents its cert to the gateway, not the server, so also disconnect it at the gateway. See docs/auth-architecture.md “Gateway-proxied agents: revocation scope”.

Issuing a code-signing certificate (gap-matrix #10)

The internal CA can issue a code-signing-only leaf for signing agent plugins (agents/plugins/*.so, see docs/user-manual/agent-plugins.md “Plugin Signing”), so operators don’t have to stand up a separate hand-rolled CA just to get a signer cert. This is not a key-minting service — the operator generates and keeps the private key; the server only sees and signs a CSR.

End-to-end workflow:

  1. Generate a keypair and CSR locally (never sent to the server):
    openssl ecparam -genkey -name prime256v1 -out signer.key
    openssl req -new -key signer.key -out signer.csr -subj "/CN=Plugin Signer"
  2. Submit the CSR to POST /api/v1/ca/issue-code-signing (Security:Write) with a label (becomes the leaf’s subject CN, ^[A-Za-z0-9._-]{1,64}$) and an optional validity_days (default 365, hard ceiling 730):
    curl -s -X POST -H "X-Yuzu-Token: $TOKEN" -H 'Content-Type: application/json' \
      -d "{\"csr_pem\": $(jq -Rs . < signer.csr), \"label\": \"plugin-signer-2026\"}" \
      https://localhost:8443/api/v1/ca/issue-code-signing > issuance.json
    jq -r .certificate_pem < issuance.json > signer.pem
    jq -r .chain_pem       < issuance.json > signer_chain.pem
    The equivalent MCP tool is issue_code_signing_cert (same schema, supervised-tier + approval-gated like every other Security:Write MCP tool). The issuance is also visible afterward in Settings → Internal CA’s issued-certificate inventory (purpose: code-signing).
  3. Sign the plugin with the returned leaf, including the issuer chain:
    openssl cms -sign -binary \
      -signer signer.pem -inkey signer.key -certfile signer_chain.pem \
      -in chargen.so -outform pem -out chargen.so.sig
  4. Distribute the trust bundle to agents. Since the leaf chains to the server’s own CA, the trust bundle agents need is simply the already-public CA root — GET /api/v1/ca/root — no separate CA-distribution step. Ship it as /etc/yuzu/plugin-trust-bundle.pem alongside the .sig files, same as any other plugin-signing setup.

Why this route is scoped to code-signing only. Usage is hard-pinned to codeSigning (never clientAuth/serverAuth), so a leaf issued here is rejected outright by the mTLS SSL_CLIENT purpose check and can never impersonate an agent at the #1118 identity gate regardless of its CN — which is what makes it safe to ship while the general POST /api/v1/ca/issue (operator-chosen CN, operator-chosen EKU) stays deferred. See docs/pki-architecture.md “Code-signing certificate issuance” for the full argument.

Revocation and expiry — operational caveats. Revoking a code-signing leaf (POST /api/v1/ca/revoke, same as any other issued cert) republishes the CRL and marks it revoked in the issued-certificate inventory — but the agent-side plugin-load verifier (docs/user-manual/agent-plugins.md “Plugin Code Signing”) does not consult the CRL, so revocation does not yet stop an agent from loading a plugin already signed with that leaf. Closing this gap is a tracked follow-up (#4234); until then, treat “revoked” as “will not be reissued/renewed”, not “immediately rejected fleet-wide”. Separately, the signer leaf’s own expiry already matters: once its not_after passes, CMS_verify rejects the signature at the agent’s next restart — track not_after from the issuance response and re-sign/re-issue before it lapses.


TLS Configuration

The Yuzu server has two independent TLS surfaces:

  1. HTTPS — the dashboard and REST API (port 8443 by default). Configured via --https-cert / --https-key (the Settings page only displays it). Disabled with --no-https.
  2. gRPC TLS — the agent listener (port 50051) and the management listener (port 50052). Configured via --cert / --key / --ca-cert (and optionally --management-cert / --management-key / --management-ca-cert for a separate management cert). Disabled entirely with --no-tls.

The two surfaces are configured separately and can be in different states (e.g., HTTPS enabled but gRPC TLS disabled for a local UAT against a remote dashboard).

Both surfaces pin the same TLS 1.2 cipher allow-list and version floor; see TLS policy for the exact list, what’s proven by CI, and what isn’t pinned yet.

HTTPS via CLI Flags

HTTPS is enabled by default. Pass --https-cert and --https-key at server startup. Use --no-https for development without TLS. See Server CLI Flags.

gRPC TLS via CLI Flags

The recommended posture is mutual TLS (mTLS) — the server presents a certificate and verifies a client certificate from each connecting agent:

./yuzu-server \
  --cert /etc/yuzu/grpc-server.crt \
  --key  /etc/yuzu/grpc-server.key \
  --ca-cert /etc/yuzu/agent-clients-ca.crt

If you have not yet stood up a CA for issuing agent client certificates, you have two fallback options:

Option 1 — One-way TLS (server cert is presented but client certs are not verified):

export YUZU_ALLOW_INSECURE_TLS=1   # required as a second confirmation
./yuzu-server \
  --cert /etc/yuzu/grpc-server.crt \
  --key  /etc/yuzu/grpc-server.key \
  --insecure-skip-client-verify

This applies to both the agent listener and the management listener. The server emits an ERROR-level startup banner and a 5-minute recurring reminder log line for the duration the listener runs in this mode. An audit event with action server.tls_degraded is also written every 5 minutes for SOC 2 evidence.

Option 2 — --no-tls (no encryption, no peer authentication, plaintext gRPC):

./yuzu-server --no-tls

This is the supported posture for local UAT, customer demos, and development. The administrative surface is ungated — anyone reachable on port 50052 can issue management RPCs. The server emits a multi-line ERROR-level startup banner and a 5-minute recurring reminder. Do not run --no-tls against any network you do not control end-to-end.

Upgrade note (v0.12.0)

The --allow-one-way-tls flag was renamed to --insecure-skip-client-verify AND now requires YUZU_ALLOW_INSECURE_TLS=1 in the environment as a second confirmation. Existing deployments that pass --allow-one-way-tls (or the new flag name) will refuse to start after upgrade until the env var is set. The old flag name remains accepted with a deprecation warning for one release.

For systemd-managed deployments, add the env var via a drop-in:

sudo systemctl edit yuzu-server   # creates /etc/systemd/system/yuzu-server.service.d/override.conf
[Service]
Environment="YUZU_ALLOW_INSECURE_TLS=1"

Via Settings Page

The Settings → Security and sign-in page has two certificate panels, and neither one turns HTTPS on:

  1. Dashboard HTTPS is read-only. It shows whether HTTPS is on, the port, the certificate and key paths and the HTTP redirect. The dashboard certificate and key come from --https-cert / --https-key and reload from disk when the files change (not when the server runs with --no-cert-reload). There is no HTTPS toggle and no dashboard-certificate upload.
  2. Agent connections (mTLS) uploads or pastes the three agent (gRPC) files: server certificate, private key and CA certificate. Each replace asks for confirmation, overwrites the file with no backup, and does not change the running listeners (gRPC certificate hot-reload is not supported). After a restart the server uses the uploaded file only if --cert, --key and --ca-cert name it; if they do and the material is wrong, the server will not start. The gateway command plane pins the server certificate, so changing it can stop command forwarding. The mTLS switch on this panel changes only the value this server reports; it is not saved.

Certificate Requirements

Certificate Hot-Reload

The server automatically detects when HTTPS certificate or key files change on disk and hot-swaps the SSL context without requiring a restart. This enables zero-downtime certificate rotation, including automated renewal via ACME/certbot.

How it works:

  1. The server polls the cert and key file modification times at a configurable interval (default: 60 seconds).
  2. When a change is detected, the new files are validated:
    • PEM format is parseable
    • Certificate and private key match
    • Key file permissions are secure (Unix: not group/others-readable)
    • Files are not empty and not larger than 1 MB
  3. If validation passes, the SSL context is updated atomically. New connections use the new certificate; existing connections are unaffected.
  4. If validation fails, the current certificate is preserved and an error is logged.

Configuration:

FlagDefaultDescription
--no-cert-reloadoffDisable automatic hot-reload. Env: YUZU_NO_CERT_RELOAD.
--cert-reload-interval60Polling interval in seconds. Minimum: 10s. Env: YUZU_CERT_RELOAD_INTERVAL.

Best practice for atomic file replacement:

# Write to temp files first, then move atomically
cp new-cert.pem /etc/yuzu/certs/server.crt.tmp
cp new-key.pem /etc/yuzu/certs/server.key.tmp
chmod 600 /etc/yuzu/certs/server.key.tmp
mv /etc/yuzu/certs/server.crt.tmp /etc/yuzu/certs/server.crt
mv /etc/yuzu/certs/server.key.tmp /etc/yuzu/certs/server.key

Observability:

Limitations:


Gateway upstream peer authorization

The gateway-upstream service (--gateway-upstream) requires an authorized gateway peer: an SPKI public-key pin, or an explicit acknowledgement. Without the check the service would rely on transport CA membership and the network boundary alone (the decision record, Gateway peer authorization (minimal), 2026-10-07, states what that exposes and the residual risks). The decision is made once at boot, after certificate bootstrap and before any listener is built. A configuration that cannot be authorized makes the server exit non-zero before binding a port, with a message naming the flag to change.

What is checked on every call. Each of the five gateway-upstream RPCs (ProxyRegister, BatchHeartbeat, ProxyInventory, NotifyStreamStatus, ForwardGuardianMessage) is refused with gRPC UNAUTHENTICATED and one fixed message (gateway peer not authorized) unless the caller presented a client certificate on a TLS connection that the transport authenticated, and that certificate:

  1. parses, and lists serverAuth in its extended key usage (the generated default-gateway leaf carries both serverAuth and clientAuth; agent leaves issued by the server’s CA are clientAuth-only, so they fail this step even if their key were pinned);
  2. has a public key whose SHA-256 (over the full SubjectPublicKeyInfo DER) is in the pin set; and
  3. is inside its validity window at the time of the call. The TLS handshake checks validity only once per connection, so this check runs on every call: a certificate that expires on a long-lived connection is refused from that moment (outside_validity). The window opens 5 minutes before the certificate’s notBefore (an allowance of this per-call check only; the TLS handshake applies none), and a call is denied when the current time, in whole seconds, is later than notAfter.

The pin is the identity. Membership of the install CA, the certificate subject, its issuer and the request contents never authorize a call. The reason for a refusal is never sent to the caller; it is the reason label of yuzu_server_gateway_peer_denied_total.

It applies on every port. The check wraps the service, and the server registers that one service on the single gRPC server that also listens for agents (--listen, default 50051) and for management (--management, default 50052). The check therefore runs wherever the service can be reached, including the agent listener on default certificates, which requests but does not require a client certificate (a caller with no certificate is refused as not_authenticated). Acknowledged mode (below) disables the check on all of those ports together, not only on --gateway-upstream.

Refusal reasons and remedies. reason is a closed set of eight values. The checks run in the order of this table, so an unpinned key is attributed not_pinned (or no_server_auth_eku) whatever its dates.

reasonMeaningRemedy
null_contextThe call carried no transport context. A defensive check: a call arriving over a real gRPC connection always has a context.Nothing to fix on the server.
not_authenticatedThe connection was not transport-authenticated (no TLS client authentication).Make the gateway dial with TLS and a client certificate. A caller reaching the agent port with no certificate lands here.
no_certThe transport reported the peer as authenticated but supplied no certificate to the check. A defensive check, not seen in the guard’s wire tests. A TLS-terminating proxy does not produce it: that removes the client certificate before the server sees the connection, which is not_authenticated (see “Proxies”).Not expected over a real connection and not fixable by configuration. If it appears, keep the server log line and report it as a defect.
bad_certThe presented certificate could not be parsed by the server’s own parser. A defensive check, not seen in the guard’s wire tests.Replace the gateway certificate.
no_server_auth_ekuThe certificate lacks serverAuth.Present the right certificate: a gateway leaf needs serverAuth and clientAuth. An agent leaf is expected to land here.
not_pinnedThe key is not in the pin set.Confirm the key is your gateway’s, then add its pin and restart (see “Rotation”). To find out which side is wrong, follow “Lockout diagnostic” below: compare the first 16 hex of the gateway certificate’s SPKI hash with the spki= in the denial log line and with the pin prefixes in the boot log line.
outside_validityThe certificate is expired or not yet valid. The check runs on every call, so a gateway whose long-lived connection outlives its certificate’s notAfter is refused on every call from that moment.Renew the certificate before notAfter. A renewed certificate (over the same key or a new one) is presented only after the gateway redials: status 16 is an RPC result, so the gateway does not redial by itself, and the call is admitted again once it does (with no server restart if the key is unchanged). A gateway redials after its connection drops (for example when the server restarts) and reads its certificate and key from the configured files when it does (measured with the gateway’s gun-based upstream client against a local TLS listener on OTP 28; see The upstream client). Plan renewal before notAfter, inside a maintenance window (see “Certificate expiry” and cause class B in the incident runbook below, which says how to tell whether the gateway has already redialled and what the certificate cache adds).
internal_errorA server-side fault while deciding.Check the server log.

What decides at boot.

SituationResult
--gateway-upstream not setNothing to authorize. Pins or --insecure-gateway-peer given anyway are ignored with a warning.
--insecure-gateway-peer together with any explicit pinRefuses to start (they contradict: the acknowledgement would silently disable the pins).
--insecure-gateway-peer aloneRuns with peer authorization disabled, with a boot error line, a boot audit row and a mode gauge (see “Acknowledged mode”). The automatic pin is not applied.
--no-tls without --insecure-gateway-peerRefuses to start. --no-tls is not an acknowledgement.
TLS on but no client CA for the listener (--insecure-skip-client-verify without --ca-cert)Refuses to start: the listener never requests a client certificate, so no pin could ever match. Use --ca-cert, or acknowledge.
Explicit pin (flag or file)Enforces the explicit pins. They win over the automatic pin and replace it.
No explicit pin, and the gRPC listener credentials are the generated default filesEnforces the automatic pin (below).
No explicit pin, operator-supplied certificatesRefuses to start: pin the gateway key explicitly.
An explicit pin source that is malformed, over a bound, missing, unreadable, empty, or supplied but blank (--gateway-peer-pin "", or YUZU_GATEWAY_PEER_PINS set to a single space or to ,: gateway peer pin option supplied but contains no pin)Refuses to start. There is no fallback from a broken explicit source to the automatic pin, and no partial start with the pins that did load from another source.
A ZERO-LENGTH environment value (YUZU_GATEWAY_PEER_PINS= or YUZU_GATEWAY_PEER_PIN_FILE=, which a compose template such as ${YUZU_GATEWAY_PEER_PINS:-} produces)Treated as unset, never refused: the command-line library ignores an empty environment value. On the generated default certificates the server then pins its own gateway certificate automatically; on operator certificates it refuses to start with the no-pin message. The command-line form --gateway-peer-pin "" and a non-empty blank environment value ARE refused.
Automatic pin selected but default-gateway.pem is missing or unreadableRefuses to start.
Every loaded pin comes from a certificate that lacks serverAuth (for example a CA certificate pinned instead of the gateway’s own)Refuses to start: every configured gateway peer pin lacks the serverAuth extended key usage, so every call would be denied; pin the gateway's own certificate (not a CA certificate). A mixed set (some pins from certificates lacking serverAuth) boots and logs a warning naming the count.

If this server runs no gateway at all, omit --gateway-upstream instead of configuring any of the above. The shipped Linux unit passes it unconditionally (see Upgrading).

Automatic pin. When the gRPC listener credentials are the server’s generated default files (the agent gRPC listener runs on the default server certificate and the default CA, and no --management-cert, --management-key or --management-ca-cert override is set) and no explicit pin is given, the server pins its own default-gateway.pem (in --ca-dir, or the platform default certificate directory when that is unset). The condition is about the gRPC credentials, not about defaults in general: HTTPS running on generated defaults while gRPC uses your own certificates does not trigger it, because the gateway you deployed is then not necessarily presenting that file. With your own certificates the automatic pin does not exist and the server refuses to start until you pin the gateway key.

Moving from the automatic pin to an explicit pin. Explicit pins replace the automatic pin; they are not added to it. Switching a running install to an explicit pin for a new gateway key therefore denies the gateway that still presents default-gateway.pem. Keep the old key admitted during the overlap by listing it as well, for example --gateway-peer-pin-file <ca-dir>/default-gateway.pem alongside the new pin, and remove it once the gateway presents the new key (see “Rotation”).

Pinning your own gateway.

# SHA-256 of the gateway certificate's public key (the pin), as bare hex
openssl x509 -pubkey -noout -in gw.pem | openssl pkey -pubin -outform DER | openssl dgst -sha256 | awk '{print $NF}'

yuzu-server ... --gateway-upstream 0.0.0.0:50055 --gateway-peer-pin <64-hex>
# or point at the certificate itself
yuzu-server ... --gateway-upstream 0.0.0.0:50055 --gateway-peer-pin-file /etc/yuzu/gateway-peer.pem

Without the awk step openssl dgst prints a label before the digest (for example SHA2-256(stdin)= <hex>, or (stdin)= <hex> on older OpenSSL), which is not a valid pin. The same values can be set in the environment (YUZU_GATEWAY_PEER_PINS, comma-separated, and YUZU_GATEWAY_PEER_PIN_FILE, one path), for example in /etc/yuzu/yuzu-server.env for the systemd unit. A value given on the command line makes the matching environment variable ignored entirely: the two are not merged, so a rotation that lists the old pin in the environment and adds the new one on the command line drops the old one. List every pin in the same source. A gateway leaf needs serverAuth for the server’s check and clientAuth for the TLS handshake to complete: a serverAuth-only leaf fails the handshake, which is a different symptom from a pin refusal. Pin files must be readable by the server’s user; under the packaged systemd unit keep them under /etc/yuzu (ProtectHome and PrivateTmp hide /home and /tmp). A pin file is integrity-critical, since whoever can write it decides which gateway key is admitted: make it writable only by the server administrator. The server does not check the file’s permissions or ownership, so that protection is yours to provide.

Pin file format. Either PEM certificates with nothing outside the -----BEGIN CERTIFICATE----- / -----END CERTIFICATE----- blocks (any other text, including openssl x509 -text output, makes the file malformed), each block contributing its own pin, or hex pins one per line (blank lines and # comment lines ignored). A file that mixes the two is malformed. A PEM block that is passphrase-protected (it carries a Proc-Type: 4,ENCRYPTED header) is refused as malformed and never prompts for a passphrase. ASCII or UTF-8 only (a leading UTF-8 byte order mark is accepted); a UTF-16 file, which Windows PowerShell 5.1 writes for > and Out-File, is refused. At most 64 KiB and 32 entries (certificate blocks or hex lines, counted before de-duplication) per file, and 64 distinct pins across all sources. The file must be a regular file (a symbolic link to one is followed); a FIFO, a directory, a device or a file the server cannot open for permission reasons is reported only as could not be read, with no cause printed.

Lockout diagnostic (not_pinned). Three values have to agree, and each can be read without guessing:

  1. The gateway certificate’s SPKI hash. Compute it with the command under “Pinning your own gateway”; its first 16 hex characters are the prefix the logs show.
  2. The denied call’s key. The warning gateway peer denied: rpc=<rpc> reason=not_pinned spki=<16 hex> (and the session.gateway_peer_denied audit row’s detail) carries the first 16 hex of the key the server saw. If it equals the prefix from step 1, the gateway presents the key you computed and the server’s pin set lacks it. If it differs, the gateway is presenting a different certificate from the one you hashed.
  3. The server’s pin set. At boot with explicit pins the server logs, at info level, gateway peer authorization: enforcing N pin(s) from A --gateway-peer-pin value(s) and B pin file(s); pins are fixed until restart; pin prefixes (first 16 hex): p1,p2,.... On the generated default certificates, where the automatic pin applies, the line is instead gateway peer authorization: enforcing; the default gateway certificate (<path>) is pinned automatically (default gRPC certificates in use); pin prefixes (first 16 hex): <p>. The prefixes are sorted and at most 8 are listed, then ... and K more, so with more than 8 pins a prefix may be hidden: compare it through the spki= prefix in the denial log line, or recompute the pin from the certificate. Read the line on every replica: a prefix that one replica lacks is the divergence behind an intermittent not_pinned. This is the only way to compare pin sets across replicas.

Inputs the server refuses at boot (so a bad value cannot start the server, and the error names the first 8 characters of the value): the colon-separated form of openssl x509 -fingerprint -sha256 (AB:CD:...), a SHA256 Fingerprint= prefix, a SHA2-256(stdin)= or (stdin)= prefix, a sha256: prefix, and any value that is not exactly 64 hexadecimal characters (63 or 65 characters, for example). Surrounding whitespace is ignored.

The mistake that boots and then fails not_pinned: a 64-hex hash of the whole certificate (DER) instead of its public key (SPKI), for example openssl x509 -in gw.pem -noout -fingerprint -sha256 with the colons removed. It looks like a valid pin, so the server accepts it, and no call ever matches it. Recompute the pin with the SPKI command above, or give the certificate itself to --gateway-peer-pin-file, which computes the SPKI hash for you.

Acknowledged mode (--insecure-gateway-peer). Intended for plaintext development and demo rigs, which have no peer certificate to pin. The server logs GATEWAY PEER AUTHORIZATION IS DISABLED once at boot at error level, writes one server.gateway_peer_authz_disabled audit row (when the audit store is open) and sets yuzu_server_gateway_peer_authz_mode: insecure_ack when TLS is off or there is no client CA, insecure_ack_tls when TLS is on and a client CA is present, a production-shaped configuration in which the acknowledgement switched off a control that would have worked. The service then admits any caller that can reach any port the shared gRPC server listens on, which is the exposure this control exists to close. Treat the acknowledgement as a time-boxed risk acceptance and do not use it where those ports are reachable by anything you do not trust. The boot audit row records only the mode and whether TLS is on: it has no owner, no expiry and no record of where the flag was set. Keep that record yourself, for example:

Gateway peer authorization acknowledgement (--insecure-gateway-peer)
Owner:          <named person or team accountable for it>
Date accepted:  <date>
Expiry:         <date by which a pin replaces it, or by which this record is renewed>
Ticket:         <change or risk-register reference>
Set in:         <unit file, env file, compose file or installer parameters>
Reachable from: <networks that can reach --listen, --management and --gateway-upstream>

Only insecure_ack_tls (TLS on with a client CA, the production-shaped case) has a shipped alert. insecure_ack (a plaintext stack, or TLS with no client CA) has none, because it is the intended state of a development rig. For a production Prometheus add an operator-side rule such as yuzu_server_gateway_peer_authz_mode{mode="insecure_ack"} == 1. The shipped plaintext rigs carry it: the full, sanitizer and viz UAT composes (deploy/docker/) and the scripts scripts/start-stack.sh, scripts/start-UAT.sh, scripts/win-start-UAT.sh and scripts/integration-test.sh run the server from the build under test and pass the flag; the two compose files that pull published release images (docker-compose.uat.yml and deploy/docker/docker-compose.demo.yml) and the compose wizard’s plaintext gateway output set the environment variable YUZU_INSECURE_GATEWAY_PEER=1 instead, because an older release image ignores an unknown environment variable but exits on an unknown flag.

Rotation (restart-based). Pins are fixed at boot, so changing the gateway key is a coordinated restart. With one server: add the new pin (keep the old one), restart the server, switch the gateway to the new key, confirm calls are admitted, remove the old pin, restart again. Keep the old and the new pin in the same source: a --gateway-peer-pin on the command line makes YUZU_GATEWAY_PEER_PINS ignored entirely (they are not merged), and the same holds for the pin-file option and its variable. With several server replicas (HA), do the same with the union of old and new pins on every replica:

  1. Add the new pin next to the old one on every replica, and roll the replicas one at a time. Verify: each replica’s boot log line gateway peer authorization: enforcing N pin(s) ... lists both pin prefixes (see “Lockout diagnostic”), and yuzu_server_gateway_peer_authz_mode{mode="enforce"} is 1 on every replica.
  2. Switch the gateway to the new key: put the new certificate and key in the files the gateway is configured with. A running gateway presents the certificate it loaded when it dialled, so the switch takes effect when the gateway redials (a gateway redials after its connection drops and reads its certificate and key from the configured files when it does: measured with the gateway’s gun-based upstream client against a local TLS listener on OTP 28, see The upstream client). A redial can still present the previous certificate for up to about 2 minutes after the files change, because the Erlang ssl PEM cache cleans every 120 s (measured on OTP 28.4.2 with a stub by a reviewer, not on a Yuzu gateway), so allow about 2 minutes. Restart that gateway node, one at a time, only if its agents can take the disconnect, as in cause class B of the incident runbook: unless the agents run a build with the #5183 fix (shipped in 0.15.0), a gateway restart leaves released agents wedged until their service is restarted, and a restart also clears the certificate cache. Verify: the first 16 hex characters of the SPKI hash computed, with the command under “Pinning your own gateway”, from the certificate file the gateway is configured with are the new key’s prefix.
  3. Confirm that yuzu_server_gateway_peer_denied_total{reason="not_pinned"} stays flat on every replica. A flat counter does not prove the gateway switched keys: both pins are admitted and the server records only denials, so no signal on either side names the key the gateway presented. Treat the gateway as switched only after a redial that followed the file change plus the 2-minute allowance in step 2; step 4 is then the test, because a gateway that still presents the old key is refused with not_pinned and an spki= equal to the old key’s prefix as soon as the old pin is removed (putting the old pin back and restarting the replicas restores admission). The alerts look at a 5-minute increase window and stay active for a few minutes after the last denial (about 6 minutes for YuzuGatewayPeerDenied): check that the counter is flat, not that the alert has cleared.
  4. Remove the old pin from every replica and roll them again. Verify: the boot log lines list only the new prefix, and the counter is still flat.

On the generated default certificates every replica auto-pins its own default-gateway.pem, which is per --ca-dir. If you ever give replicas different certificate directories, supply the same explicit pin on all of them. During a mixed-version window an old replica (which has no peer authorization) admits calls that a new replica refuses, so upgrade every replica before relying on the control. Renaming default-marker.json (the way to apply new --cert-san values, see PKI architecture) mints a new gateway leaf key: the restarted server auto-pins the new key, and a gateway that dialled before the restart presents the old one until it redials. The server restart drops the connection and forces that redial, so the gateway may already present the new certificate. That is cause class B in the incident runbook: check first, and restart that gateway node, one at a time, only if denials continue and its agents can take the disconnect, because unless the agents run a build with the #5183 fix (shipped in 0.15.0) released agents stay wedged afterwards.

Withdrawing a gateway key. Remove the pin from every pin source and restart every serving replica. There is no reload and no revocation check on this path: revoking the certificate’s serial in the CA store does not remove a key pin, and a certificate reissued over the same key is still admitted. If the key itself is compromised, replace it as well as withdrawing the pin, since a copied credential stays valid until its pin is gone from every replica.

Incident runbook: gateway refused, then corrected. A pin mismatch is not a connection failure. The TLS session completes and the server answers each call with gRPC status 16 (UNAUTHENTICATED), which the gateway reports as upstream RPC errors rather than TLS-handshake failures. A refused NotifyStreamStatus is logged by the gateway as Failed to notify stream status for <agent>: .... What happens to the refused notice then depends on the gateway build:

A server that restarted has no record of the gateway’s sessions, reports them unknown in its heartbeat replies, and a gateway that includes the #1197 heartbeat-verdict consumer (#5383) replays them; an older gateway does not act on the verdict until it is upgraded (see Upgrading, “BatchHeartbeatResponse gains unknown_session_ids”). Recovery needs an operator decision, and which one depends on where the cause is (the cause classes below). On a gateway that includes #5695, correcting a server-side cause is enough for the refused notices to be delivered, but a gateway-side cause still needs the gateway to redial (status 16 does not make it redial, so until then every probe is refused again), and the step 4 checks still decide whether placement converged.

What a refusal window costs. While the gateway is refused: sessions whose CONNECTED notice was refused stay unplaced, on a gateway that includes #5695 until the refusal stops and the held notice is sent, and on an older gateway possibly until the agent reconnects (above); ForwardGuardianMessage frames dropped in the window are never replayed; a refused DISCONNECTED notice leaves a stale placement (on a gateway that includes #5695 until the held notice is sent after the refusal stops); and the gateway retries heartbeats every second and logs a WARN each time (noise, not a fault). Agent Register calls through the gateway fail with INTERNAL (Upstream registration failed: ...) from the FIRST refusal (yuzu_gw_agent_service.erl), not only once the breaker has opened, and those refused registrations count toward the gateway’s circuit breaker. ProxyRegister (a replay registration included), ProxyInventory and, on a gateway that includes #4632, the half open probe (a held NotifyStreamStatus: a refused probe reopens the breaker, a successful one closes it) feed the breaker. On a gateway that includes #5695 a refused ordinary NotifyStreamStatus sent while the breaker is closed feeds it too, and because the refused notice is sent again at once, one refused notice is enough to open the breaker (above). A refused BatchHeartbeat or ForwardGuardianMessage does not feed it, and on an older gateway neither does a refused ordinary NotifyStreamStatus (yuzu_gw_upstream.erl; Gateway, “Replay failures feed the shared circuit breaker”). So the breaker can stay closed, and the gateway’s /readyz can answer 200, while every heartbeat is refused; that is what a window with no registering agents looks like (and, on a gateway that includes #5695, with no agent connecting or disconnecting either). When the breaker does open, /readyz answers 503 (its circuit_breaker check) and a gateway that includes #4632 holds stream-status notifications (yuzu_gw_upstream_notify_pending rises) and sends them first in first out once the half open probe succeeds; a notification is lost at admission only when the store is full, counted in yuzu_gw_upstream_notify_dropped_total{reason="at_capacity"}. A gateway without #4632 drops them and counts reason="circuit_open". There is no breaker state gauge, and /readyz alone does not tell you the state: circuit_breaker is true for both closed and half open, and 503 is also returned when any other readiness check fails. The signals are the gateway log lines Circuit breaker: OPEN (will probe in <ms>ms) and Circuit breaker: half_open -> closed (probe succeeded), and the counter yuzu_gw_upstream_circuit_transitions_total{state}. Status 16 is an RPC result and the gateway never redials by itself, so an open breaker whose cause is on the gateway side does not close by waiting (inferred from reading the gateway code, not tested).

Three cause classes decide what happens to the gateways. The circuit state alone never decides it.

  1. Find the cause on the server: yuzu_server_gateway_peer_denied_total{reason} per replica, and the session.gateway_peer_denied audit rows for authenticated peers. Verify: you can name one reason, and whether the cause is on the server side (class A) or the gateway side (class B). For not_pinned, run “Lockout diagnostic” above.

  2. Restore the pins or the credentials. Class A: put the correct pin (or the acknowledgement) on every replica. Class B: put the corrected certificate and key in the gateway’s configured files; a NEW key needs its pin on every replica first (see “Rotation”), while a renewal over the same key changes nothing on the server. Triggers include: pin sets that differ between replicas; a gateway certificate switched before the new pin was deployed; and a gateway certificate that expired while its connection was open (see “Certificate expiry” below). Verify: for class A, the pin or acknowledgement is present in the configuration of every replica (for example in the unit, the environment file or the compose file; it is not live until the restart in step 3). For class B, the first 16 hex characters of the SPKI hash computed from the certificate file the gateway is configured with (the command under “Pinning your own gateway”) are the prefix you expect.

  3. Restart the serving replicas only if step 2 changed server configuration: class A, or class B with a NEW key whose pin you just added. A same-key renewal needs no server restart. When it applies, restart every serving replica that was refusing, so all replicas agree: this is the server restart that step 2’s change needs, made once after step 2 is in place on every replica, not as a second restart. Verify: each replica’s boot log line gateway peer authorization: enforcing ... lists the pin prefix of the key the gateway presents (see “Lockout diagnostic”), and yuzu_server_gateway_peer_authz_mode{mode="enforce"} is 1 on each. For class A, yuzu_server_gateway_peer_denied_total is also flat on every replica; for class B it keeps rising until the gateway presents the corrected certificate (step 5). Check that the counter is flat, not that the alert has cleared: the alert looks at a 5-minute increase and stays active for about 6 minutes after the last denial.

  4. Check three separate things. None of them stands in for another, and agreeing counts alone do not prove placement.

    • Registration counts. Compare the totals, not the series (a per-series subtraction returns nothing, because the label sets differ):

      sum(yuzu_gw_agents_current) - sum(yuzu_agents_connected{job="yuzu-server"})

      Sum across all gateway nodes and all serving replicas, and use the job name your Prometheus gives the server. yuzu_gw_agents_current is a gauge sampled every 10 s. The server’s gauge counts agents that connect directly as well as those behind a gateway, so zero or below is what convergence looks like, and it can hide a gateway-fronted shortfall as large as the number of direct agents. A result that stays above zero means the gateways hold sessions the servers do not count. yuzu_agents_connected counts REGISTRATION (ProxyRegister).

    • Placement. Placement (the route to the gateway that holds an agent) is set by NotifyStreamStatus, separately from registration, and a refused or dropped CONNECTED notice leaves an agent counted but unreachable for commands (Gateway, “Dispatch reachability after the session is adopted”). Over the recovery window there should be no rise in the raw yuzu_gw_upstream_rpc_errors_total{rpc_name="notify_stream",code="16"} or yuzu_gw_upstream_notify_dropped_total (the code="16" series has no sample until its first event, so an absent series means none; a gateway with #4632 creates yuzu_gw_upstream_notify_dropped_total at 0 for reason="at_capacity" and reason="unknown", so there a flat 0 means none and an absent series means an older gateway, where the series likewise has no sample until its first drop). Then dispatch a read-only command to a sample agent and read its result. Dispatch with POST /api/command and a body such as {"plugin":"hardware","action":"manufacturer","agent_ids":["<agent-id>"]} (hardware.manufacturer is a read-only Inventory:Read action; the caller needs Execution:Execute). A 200 carrying "agents_reached":1 shows only that the server accepted the command: for a gateway-attached agent the server merely queues it for the gateway, so the proof is the agent’s own result, read with GET /api/v1/responses/<command_id>?agent_id=<agent-id> (the command_id is in the dispatch response; Response:Read), which must return a row with status 1 within a few seconds. An agent that is registered but has no placement is expected to be refused with 503 and failed to send command to any agent; 503 with no agent connected is the answer when the server holds no reachable agent at all (both read from command_routes.cpp and agent_registry.cpp, not run). No endpoint reports which gateway node holds an agent, so test several agents spread across the fleet and, where you know which agents dial which gateway node, behind every node: a pass on a few agents does not prove that all are placed.

    • Breaker. Read it from the log lines and the transitions counter described above, not from /readyz alone.

    Timing allowances (read from the gateway code and gateway.md, not measured here). The open-period cap applies only if the breaker opened: the open period doubles from 10 s up to 30 s with the default settings on a gateway that includes #5695 (circuit_breaker_max_reset_timeout_ms), and up to 300 s on an older gateway. The unknown-session verdict arrives only when the agent’s heartbeat appears in a gateway flush: up to one agent heartbeat interval (30 s by default) plus one flush (1 s by default). On a gateway that includes #5695 the replay then starts after a random delay of up to registration_replay_start_jitter_ms (1 s by default) and runs up to upstream_replay_concurrency (24 by default) registrations at once, so its length is the gateway’s agent count times the server’s ProxyRegister time divided by 24. On an older gateway it runs as a drip of registration_replay_spacing_ms (20 ms by default) per agent plus the RPC time of each, so 10,000 agents take at least 200 s. Allow for all of it before judging. On a gateway older than #5695 a first successful registration or inventory call after failures also starts a replay of the gateway’s registrations (yuzu_gw_upstream.erl, on_success); since #5695 a breaker recovery starts no replay; only the heartbeat verdict, or a CONNECTED lost at a full held store, does. Verify: the registration expression settles at zero or below within those allowances, the placement checks pass, and yuzu_server_gateway_peer_denied_total stays flat.

  5. Make the gateway present its current certificate. Only class B, or class C when step 4 failed, reaches this step.

    • Class B. First check whether the gateway has already redialled and presents the new certificate: a server restart, or any dropped connection, forces a redial. It has when yuzu_server_gateway_peer_denied_total and yuzu_gw_upstream_rpc_errors_total{code="16"} have stopped rising, no new spki= denial lines appear in the server log, and the certificate file the gateway is configured with has the expected SPKI prefix (compute it with the command under “Pinning your own gateway”). Read the raw counters twice, a minute apart, rather than using increase(): the gateway series has no sample until its first event, and a series born at its first value is invisible to increase() (the server series is pre-seeded at 0). After the files are replaced, a redial can still present the previous certificate for up to about 2 minutes (the Erlang ssl PEM cache cleans every 120 s; measured on OTP 28.4.2 with a stub by a reviewer, not on a Yuzu gateway), so wait about 2 minutes before judging. Restart that gateway node, one node at a time, only if denials continue after that wait and only if its agents can take the disconnect: a restart clears the certificate cache and forces the redial, which status 16 does not do by itself, whatever the circuit state (an open breaker whose cause is on the gateway side does not close by waiting, which differs from the advice in Gateway for a server-side outage). Unless the agents behind the node run a build with the #5183 fix (shipped in 0.15.0), the restart disconnects every agent the node holds and released agents stay wedged and need an agent service restart (Gateway, “What happens when the server restarts” and “Agent dependency”); plan certificate renewals before notAfter, inside a maintenance window. Verify: the two raw counters are flat across reads a minute apart, no new spki= denial line appears, and, if you restarted the node, yuzu_gw_agents_current on it returns to its earlier level.
    • Class C. Placement loss is suspected only when the step 4 checks still fail after the allowances. The observed fix is to restart the agent service on the specific unreachable agents: Gateway, “Dispatch reachability after the session is adopted”, records that this recovers any agent (OBSERVED) and that the notify series carry no agent label, so find the unreachable agents by dispatching a command to them (the placement check in step 4). Fall back to restarting the gateway node only if many agents behind one node are affected, one node at a time and with the #5183 caveat above: unless the agents run a build with that fix, released agents stay wedged afterwards. That fallback is inferred from the code and untested (Gateway records that it was not tested whether the gateway alone recovers stranded agents). Verify: the step 4 checks pass.

If a mixed set of replicas was refusing and admitting at once (an old replica with no peer authorization beside a new one), a call can be admitted by one and refused by another, and a refused call can re-arm a replay: this is expected from reading the gateway code and was not measured. Roll every replica to the same version and pin set before judging recovery.

If the gateway was refused only at registration (for example the server started with the wrong pin and the gateway’s first calls failed), correcting the pin and restarting the server is expected to be enough, because a restarted server reports the gateway’s sessions unknown and a gateway with the #1197 consumer (#5383) replays them (step 4 will show it, within the allowances); an older gateway does not act on that verdict until it is upgraded.

Certificate expiry (the cliff). The guard re-checks the certificate’s validity on every call, but a TLS connection checks it once, at the handshake. A gateway whose long-lived HTTP/2 connection outlives its certificate’s notAfter is therefore refused with outside_validity on every call from that moment, while its connection stays up. Status 16 is an RPC result, so the gateway does not redial by itself: a renewed certificate, over the same key or not, is presented only after the gateway redials. A gateway redials after its connection drops (for example when the server restarts) and reads its certificate and key from the configured files when it does (measured with the gateway’s gun-based upstream client against a local TLS listener on OTP 28; see The upstream client). Renew before notAfter, inside a maintenance window; the gateway’s YuzuGatewayCertificateExpiringSoon / ExpiryImminent / ExpiryCritical alerts on yuzu_gw_cert_expiry_timestamp_seconds{role="upstream_client"} warn 30 days, 7 days and 24 hours ahead (see Certificate rotation and expiry). After replacing the files, check whether the gateway already redialled and allow about 2 minutes for the certificate cache (cause class B above); restart each gateway node, one at a time, only if denials continue and its agents can take the disconnect, because on agents without the #5183 fix (shipped in 0.15.0) released agents stay wedged afterwards. After notAfter the breaker can stay open and waiting does not help, because the cause is on the gateway side. A renewal over the same key needs no server change and no pin change; a new key needs its pin first (see “Rotation”). Neither a connection-age cap nor a gateway-side redial on status 16 exists today. The gateway’s upstream channel holds one connection per endpoint by default, so this is all or nothing, as with grpcbox. If pool_size is raised above 1, the connections redial on their own when they drop, so a certificate change can leave them presenting different certificates for a while and a share of the calls refused rather than all of them (INFERRED from the channel code, not run).

Gateway key custody. The gateway key file is the credential the pin authorizes, so who can read it decides who can act as a gateway. On the generated defaults it is default-gateway.key, group-readable (0640) by the shared --cert-group when that is set. Keep it readable only by the server and gateway processes: do not mount the key-bearing certificate volume into other long-running containers (the reference compose’s one-shot ca-export service below is the one deliberate exception), never widen --cert-group membership beyond those two, and when --cert-group is set while the generated default gateway key is in use (the automatic pin, or gRPC on the default credentials) the server logs a warning at boot that every process in the group holds gateway authority. The reference gateway compose mounts the key-bearing certificate volume into the server and the gateway, and a third time read-only into a one-shot ca-export service, which copies only the public CA certificate (default-ca.pem) into the ca-public volume (it runs with the shared group so it can read the directory, which means it could also read the group-readable gateway key; it runs one install command and exits). The agent container mounts ca-public and never the key-bearing volume. ca-export runs again on every docker compose up, so after a CA re-root prefer docker compose up -d, which re-runs it after the server is healthy, to refresh ca-public (restart also restarts the one-shot container but without that ordering); see PKI architecture. If anything else ever held read access to the gateway key (for example an agent container from an earlier compose file), replace the gateway certificate or move to a gateway-only certificate with a 0600 key pinned explicitly, and withdraw the old pin: changing mounts cannot invalidate a key that was already copied. Production installs should issue a gateway-only certificate and pin it explicitly.

Windows service. The installer rebuilds the service command line from its own state on every run, so a flag appended to the service’s command line by hand is lost at the next upgrade. The durable convention is a pin file named gateway-peer-pin in the installation’s certs folder (%ProgramData%\Yuzu Server\certs): the installer passes --gateway-peer-pin-file for it whenever gateway mode is selected, TLS is not skipped and the file exists.

Windows service limits. When the service fails to start, the Windows service manager reports error 1053, 7000 or 7009 whatever the cause, so the reason (including a gateway peer refusal at boot) is in <install>\logs\yuzu-server.log, not in the service manager’s message. The installer fixes the gateway-upstream listen address at 0.0.0.0:50055 and does not pass --ca-dir (the server then uses the platform default certificate directory), so two server services on one host collide; this is a limitation, not a parameter.

Proxies in front of the gateway-upstream port. The gateway’s client certificate is what identifies it, so the path from the gateway to the server must be L4 passthrough. A TLS-terminating proxy removes the client certificate, and every call is then refused as not_authenticated.

Observability.

inhibit_rules:
  - source_matchers: ['alertname="YuzuGatewayPeerDeniedBatchHeartbeat"']
    target_matchers: ['alertname="YuzuGatewayPeerDenied"']
    equal: ['instance', 'reason']

The gateway-side alerts are scraped from the gateway, so their instance is the gateway’s and differs from the server’s: they cannot be inhibited by this rule, and YuzuGatewayUpstreamAuthRefused will fire alongside the server-side pair. The out-of-memory fallback of the refusal path answers CANCELLED, not status 16 (gateway_peer_guard.cpp, denied_status), so YuzuGatewayUpstreamAuthRefused misses that one call: out-of-memory only.

Rolling back. See the “Rollback / temporary exception” section of Upgrading.

Known limits of the verification. (1) The guard’s wire tests produce not_authenticated, no_server_auth_eku, not_pinned and outside_validity over a real mutual-TLS connection and null_context by a direct call; no_cert and bad_cert could not be produced over a real gRPC connection in that harness and are covered at the policy-function level only (tests/unit/server/test_gateway_peer_policy.cpp). (2) The integration rig (scripts/integration-test.sh) keeps --insecure-gateway-peer in its --tls branch, so CI never exercises a pinned gateway hop end to end with the real gateway; the guard is exercised by unit tests over a real mutual-TLS harness, and the boot-refusal test presents the default gateway certificate (admitted) and the default server leaf (denied) to the real server binary on its three listeners. (3) The Windows installer change was compiled with Inno Setup 6.7.3 and silent-installed on a Windows 11 test host (34 of 34 silent-install cases at the final tip, including upgrade, refusal and uninstall cases, the refusal of /NOTLS with a pin and the wizard-path refusal wording, plus eight direct runs of the server executable). Starting the service under the Windows service manager could not be tested, because the server binary does not implement the service control protocol (pre-existing; open issue #1835, “Windows server binary has the identical SCM control-protocol defect as #1822 (agent)”, which prevents the installed service from starting under the Windows service manager). The interactive wizard path was not tested. (4) A pin file’s permissions and ownership are not checked by the server (see “Pinning your own gateway”). The decision record, Gateway peer authorization (minimal), 2026-10-07, states these and the TLS session-resumption question in full.

What this does not do. The pin establishes which gateway is calling; it does not restrict which agents a gateway may relay for. A compromised pinned gateway can still assert identities and events for the agents behind it (a documented residual; per-gateway scoping is tracked in #1292). The pin applies to the gateway-upstream service only; direct-connecting agents are not covered by it.


User Management

Yuzu supports two built-in roles for local users:

RolePermissions
adminFull access to all features, including Settings, user management, agent enrollment, and instruction execution.
userRead-only access to the dashboard, agent list, and query results. Cannot modify settings or execute instructions.

Creating a User

  1. Navigate to Settings > User Management.
  2. Enter a username and a password (12–1024 bytes). The form also shows a role selector, but the role is ignored — see below.
  3. Click Add User.

The password is hashed with PBKDF2 before storage. Plaintext passwords are never written to disk.

Breaking change in v0.12.0 — the role field is ignored on create. New users are always created as user. To grant admin, call POST /api/settings/users/{username}/role (the Users table has no role button; use Access → Users and roles → Change role or see “Changing a User’s Role” below). This is a deliberate split (security finding C1): collapsing role assignment into the create endpoint allowed a 4xx-on-create + audit-as-success pattern that operators couldn’t audit cleanly. Each role transition now produces a single user.role_change audit event with old_role and new_role recorded in the detail field.

Changing a User’s Role

The Settings → User Management table does not render a role button (the Access → Users and roles page does); change a role with the Settings endpoint from an admin session whose own account role is admin (it is MFA step-up gated):

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

The server emits an audit event on every branch:

BranchAudit resultDetail
Role changedsuccessold_role=user,new_role=admin (or vice versa)
Same role requestedno_opsame_role=admin (or user)
Self-target rejecteddeniedself_role_change_blocked
Caller is an administrator only through a temporary elevationdeniedelevated_session_role_change_blocked (HTTP 403)
Invalid usernamedeniedinvalid_username
Invalid JSON bodydeniedinvalid_json
Missing role fielddeniedmissing_role
Invalid role valuedeniedinvalid_role
User not founddenieduser_not_found
Would leave no active local admin (last administrator)deniedlast_administrator_blocked (HTTP 409)
Account deactivated between the route’s lookup and the write, admin requesteddeniedinactive_promotion_refused (HTTP 409; an account already inactive when the request arrives is user_not_found, HTTP 404)
Role store unavailable, or the guard is not wiredfailurestore_failure (HTTP 503, no retry hint: the change may or may not have been applied, so check the user’s role and revoke the user’s sessions)
Role changed but session cleanup failedpartialrole_changed session_wipe_failed=true old_role=<r> new_role=<r> (HTTP 503, no retry hint: revoke the user’s sessions)

Changing a role needs your own administrator role. A session that is administrator only through a temporary elevation (POST /api/v1/elevate) is refused with HTTP 403: an account role is a standing grant, an elevation is not.

You cannot change your own role. The endpoint rejects self-target with HTTP 403, audited as denied:self_role_change_blocked. The same motivation as the self-delete guard: prevent an operator from locking themselves out via a misclick or scripted demotion.

Active sessions are invalidated atomically. After a successful user.role_change, every active session for the target user is destroyed; the user must re-authenticate to pick up the new role.

The last administrator cannot be demoted. Demoting the only active local admin account is refused with HTTP 409 and the toast “Cannot demote: this account is the fleet’s last administrator”; nothing changes. Create or promote a second admin first. This is a recovery rule, not a proof that anyone can sign in (lockout, MFA, sso-only deployments and identity-provider state are outside it). See docs/user-manual/rbac.md, “Last-Administrator guard for account deactivation and demotion”.

Deleting a User

  1. Navigate to Settings > User Management.
  2. Click Remove next to the target user.
  3. Confirm the deletion.

Note: You cannot delete your own account. The Users table renders the text “Current user” in place of the Remove button for the currently authenticated operator’s row, and the server rejects any hand-crafted DELETE /api/settings/users/<your-username> request with HTTP 403 and a Cannot delete your own account toast. This prevents a misclick — or a scripted revoke loop — from dropping the only credential on the running server and locking every operator out until the process is restarted against its on-disk config. To remove the account you are signed in as, first create a second admin, log out, log in as the second admin, and delete the original.

The last administrator cannot be deleted. Deleting the only active local admin account (and, with RBAC enforcement on, the holder of the fleet’s only Administrator grant) is refused with HTTP 409 and the toast “Cannot delete: this account is the fleet’s last administrator”. The server first runs a read-only early check and, when it already shows the account is the last one, refuses before revoking anything. That check takes no lock and is best-effort. If the guard refuses after it, the toast says that the account’s linked credentials (API tokens and sessions, including linked SSO identities) were already revoked and that the account remains active; the account’s holder must sign in again and re-create any API tokens. Credentials are revoked before the delete on purpose, so a completed deprovision never leaves live credentials behind (an early refusal revokes nothing, so those credentials stay live). An early refusal is audited as user.delete / denied / last_administrator_blocked, a late one as user.delete / partial (credentials were revoked), and both are counted on yuzu_server_rbac_last_admin_guard_refused_total{transport="dashboard"}. If the delete cannot complete because the store is unavailable, the answer is HTTP 503 with Retry-After: 2; a repeat re-runs the guarded write and the session wipe (on an already-deactivated account it answers 404 after re-running the wipe).

Operator notes. Every guarded delete and role change takes the same database row lock, waiting at most 2000 ms, so a burst of SCIM deprovisions queues on it and some requests can time out (dashboard 503 with Retry-After: 2, SCIM 503 with Retry-After: 2; the dashboard role change answers 503 with no retry hint) with the account itself unchanged after a lock timeout (its credentials were already revoked, see above). A SCIM retry finishes the job only after a lock timeout in which nothing committed; it does not converge after a lost commit acknowledgement, a failed session wipe, or a POST with active:false (#5214). Each request keeps a pooled PostgreSQL connection while it waits (default pool 16, --postgres-pool-size; a deactivation can wait up to four times 2000 ms), so watch yuzu_pg_pool_waiters, yuzu_pg_acquire_wait_seconds and yuzu_server_rbac_lifecycle_store_failure_total{op}; the full capacity and stuck-lock procedure is in docs/user-manual/rbac.md, “Last-Administrator guard for account deactivation and demotion”. A refused SCIM deprovision leaves the person’s account (and, after an early refusal, their tokens and sessions) live and raises a critical analytics event: an operator has to act. No alert rule ships for these series yet (tracked in #5673). A replica running a build without this rule does not enforce it, so drain old replicas during a rolling upgrade. A server with an authentication database but no RBAC store refuses to start.

Changing and resetting passwords

Settings → User Management shows a password button on every local account’s row; SSO (OIDC/SAML) and SCIM-provisioned rows have none — their credentials live at the identity provider (#5342).

Both require a password of 12–1024 bytes (UTF-8), and prompt for an MFA code when your session’s step-up proof is stale. The new password, the sign-out of the account’s sessions and the audit record (user.password_change / user.password_reset) are saved in one database transaction — all of it or none of it; if anything fails, nothing changed and an error toast asks you to retry. The REST contract (POST /api/v1/users/me/password, POST /api/v1/users/{name}/password) is in rest-api.md; there is no MCP tool for either, by design (#5357 tracks a temporary-password reset tool).

yuzu-server.cfg no longer resets a password (#5274). The config file seeds the first administrator into the PostgreSQL auth store on a fresh database. After that, login checks the auth store only — editing a hash in the config file, regenerating it, or re-running an installer with a new admin password does not change an existing account’s password (the Windows installer now refuses /ADMIN_PASS on an upgrade, exit code 11). Use the buttons above or the REST routes; if no administrator can sign in at all, see the recovery runbook.


Force-logging out a user (incident response)

Use this when an account credential is suspected of compromise but the account itself should remain functional (the user reports a stolen laptop but still needs to keep working from a clean device, or a short-lived contractor’s badge is being rotated).

  1. Navigate to Settings > User Management.
  2. Click Revoke sessions next to the target user.
  3. Confirm the blast-radius warning. The user’s active dashboard sessions end immediately; their account remains intact and they can re-authenticate normally.

The audit log records session.revoke_all with target_id=<username>, target_type=User, and detail=count=<N> where N is the number of cookie sessions wiped (the durable session store’s count, or this server’s cached count if the durable delete failed). The action is also surfaced on the yuzu_auth_sessions_revoked_total{caller="admin",scope="cookies"} Prometheus counter — a sustained spike there is the operator’s automated alert for either a real incident response in progress or a misbehaving automation script.

API tokens are not revoked by this flow. Use it for a leaked session cookie. If the user’s API tokens are also implicated, either revoke them individually via Settings → API Tokens or instruct the user to click Sign out everywhere themselves (see below), which revokes both cookies and tokens.

Verify persistence after a partial failure. If the response body reports db_persisted: false (or the audit row shows result=partial with db_error=true), the in-memory wipe succeeded but the durable session rows in PostgreSQL (SessionStore) survive. A server restart, or a cache miss on another replica, will resurrect those sessions. Retry the revoke once the database is healthy again.

Self-service “Sign out everywhere”

Any user — not just admins — can wipe every credential bearing their identity by clicking the Sign out everywhere button on their own row in Settings → Users. Unlike the admin Revoke sessions button, this revokes BOTH cookie sessions AND every API token the user owns (the lost-laptop scenario must kill every credential, not just browser cookies). After the request the page redirects to /home and the response clears the session cookie via Set-Cookie: Max-Age=0.

MCP-tier and service-scoped tokens cannot self-revoke. Those credential classes have no other write privilege; accepting a self-revoke from one would create a novel DoS surface against the human owner. The endpoint returns 403 and the audit row records session.revoke_all.self with result=denied. Use the dashboard from a password-authenticated session.


Agent Enrollment

Yuzu uses a tiered enrollment model. Each tier provides a different balance of security and convenience.

Enrollment is managed on the Groups and enrolment page (Estate group, /groups; see Groups and enrolment page) or, equivalently, in the Settings panels Pending Agent Approvals, Enrollment Tokens and Auto-Approve Policies. Both places show the same lists and use the same routes. The page and its navigation entry use the UK spelling “enrolment”; the existing Settings panel names keep the US spelling “enrollment”.

Tier 1: Manual Approval

The default enrollment method. No pre-shared token is required.

  1. The agent starts and sends a RegisterRequest to the server.

  2. The server places the agent in the pending queue.

  3. An admin reviews pending agents in Estate > Groups and enrolment (panel Agents waiting for approval) or in Settings > Pending Agent Approvals; both show the same queue.

  4. The admin approves or denies each agent.

  5. Approved agents complete registration on their next registration retry (at most about 4 minutes after approval).

  6. A denied agent stays in the queue as Denied (“denial recorded”) as a durable record. The page offers no way to reverse a denial, and the product has no reinstate action today: Approve on a denied agent is refused (409), Forget is offered only for waiting agents, and the same agent id stays denied whatever else it presents, including a valid enrollment token or a matching auto-approve rule. If the agent registers again, the server answers enrollment_status denied (reason “enrollment denied by administrator”) and it is not enrolled. The Deny and Deny all confirmations say so; deny only when you mean it.

    If a machine was denied by mistake, the only way back is for it to register as a new agent. Reinstall it or wipe its agent data directory (default <data-dir>, which holds the agent id in agent.db and, by default, its client certificate in certs/), so that it generates a new id and a new certificate. Setting a different --agent-id / YUZU_AGENT_ID is not enough on its own on a server that requires client certificates: the agent keeps presenting the old certificate (CN = old id), and the server refuses a Register whose id does not match it (“agent_id must match client certificate identity”). Use --agent-id only together with a wiped data directory (or a different --data-dir).

    The new id is an ordinary first registration, not a special case. A valid enrollment token it presents enrolls it at once, an enabled auto-approve rule it matches approves it, and otherwise it waits in the pending queue. The denial is keyed on the old agent id only, so if you meant to keep that machine out, remove or tighten the token or rule that would admit it. The old id stays listed as Denied. There is no reinstate action and no supported way to delete a denied row.

Tier 2: Pre-Shared Token

For automated or bulk deployment. Agents present a token at startup for instant enrollment.

Server side:

  1. Navigate to Estate > Groups and enrolment (panel Enrolment tokens) or Settings > Enrollment Tokens; both show the same tokens.

  2. Fill in the Generate token form:

    • Label — free text shown in the list.
    • Max uses — how many agents can use this token. Left empty it takes the default of 100; 0 is unlimited; any other value must be one whole number. A typo such as 0x5 or 5abc is refused with 400 and no token is minted (it used to be read as 0, which means unlimited).
    • Expires after (hours) — time limit. Left empty it takes the default of 72 hours; 0 is no expiry; the same whole-number rule applies, and a negative value is refused with 400 too, because it used to compute to “never expires”.

    With both limits set to 0 the token never stops working, and the form shows “Unlimited uses and no expiry” while both fields hold a 0. A field left empty takes its default, so it does not count as unlimited.

    Size both limits to the rollout. A token that has expired, been revoked or used up is refused at registration. An agent that presents one through --enrollment-token or YUZU_ENROLLMENT_TOKEN is refused again on every start (it does not fall back to the approval queue while that token is presented) until the option is replaced or removed. An agent that read it from a token file removes the file when the server refuses it, so its next start registers without the file’s token (with an --enrollment-token / YUZU_ENROLLMENT_TOKEN value if one is set, otherwise without a token, into the approval queue); a file that could not be removed is read and presented again on the next start, so keep it where the agent can delete it. A token file staged on more machines than the token has uses, or used after its expiry, ends this way; so does a golden image or VDI template that carries a token file, since every machine started from it registers with a new identity and uses one of the token’s uses.

  3. Click Generate token and copy the string shown. It is shown once. The reveal sits inside the token panel, so navigating away, clicking Done, revoking a token, generating another or anything else that reloads the panel removes it for good; if you did not copy it, revoke that token and generate a new one. Copy to Clipboard needs HTTPS or localhost. On Groups and enrolment, over plain HTTP or if the browser refuses, the button says “Copy failed. Select the text.” and you copy the token by hand. The Settings panel keeps its older copy button, which has no failure message: without HTTPS or localhost the button does nothing, and if the browser refuses the copy it still says “Copied!”. Check the clipboard (or select the token by hand) before you leave the panel. The response is sent with Cache-Control: no-store, private.

The list shows each token’s ID, label, uses, expiry (UTC) and status: Active, Expired, Exhausted or Revoked, with active tokens first. Only a token that is not revoked has a Revoke button.

Agent side:

./yuzu-agent --enrollment-token "TOKEN_STRING"

The agent passes the token in RegisterRequest.enrollment_token. If the token is valid and not expired or exhausted, the agent is enrolled immediately.

Tier 3: Platform Trust (Planned)

Reserved protocol fields (machine_certificate, attestation_signature, attestation_provider) support future integration with:

Auto-Approval Policies

For environments that need something between manual approval and pre-shared tokens, auto-approval policies can automatically approve agents that match defined criteria:

  1. Navigate to Estate > Groups and enrolment (panel Auto-approve rules) or Settings > Auto-Approve Policies.
  2. Create a rule with conditions (e.g., hostname matches prod-web-*, source IP in 10.0.0.0/8). The Add rule confirmation asks before it saves; see Auto-Approval Policies for what each rule type matches, what the server refuses and which rules the page flags.
  3. Agents matching any active policy are approved automatically at registration time. In “all” match mode every enabled rule must match, so an enabled trusted_ca rule (which never matches at this release) means no agent is auto-approved.

OTA Agent Updates

The server can distribute agent binary updates to enrolled endpoints.

Uploading a New Version

  1. Navigate to Settings > OTA Updates.
  2. Click Upload and select the agent binary. Optionally select a detached CMS signature in the Signature field — see Signing update binaries below, and do supply one: without it the agent can verify integrity but not authenticity.
  3. The server stores the binary (and the signature, if given) and assigns a version identifier.

Package filenames may not end in .sig (case-insensitive); the upload is rejected with a 400. That suffix is reserved for signature sidecars, named <binary>.sig, so a package called x.sig would occupy package x’s slot.

Promoting to Production

  1. In the OTA Updates list, locate the uploaded version.
  2. Click Promote to mark it as the current production version.
  3. Agents check for updates on their next heartbeat and download the new binary automatically.

Managing Versions


Signing update binaries (#416)

The SHA-256 the agent checks is not an authenticity check. The server supplies that hash over the same gRPC channel that delivers the binary, so anything able to substitute the binary substitutes the hash beside it. It detects corruption and a truncated download; it does not detect a malicious or impersonated server. Since the OTA path installs and runs code on every managed endpoint, sign your packages.

A signature is a detached PEM CMS blob over the binary’s bytes, produced by a certificate carrying the codeSigning EKU. Yuzu does not sign for you — the party that chooses the binary is the party that must sign it, and on this path that is you: packages reach the registry by admin upload.

The signing certificate’s profile is load-bearing, and getting it wrong fails in a confusing way. The agent builds its trust store with OpenSSL’s codeSigning purpose, which imposes requirements beyond “chains to the bundle”:

A minimal OpenSSL extension file for the leaf:

[ codesign ]
basicConstraints       = critical,CA:FALSE
keyUsage               = critical,digitalSignature
extendedKeyUsage       = codeSigning
subjectKeyIdentifier   = hash
authorityKeyIdentifier = keyid,issuer

Then sign the binary:

openssl cms -sign -binary -outform PEM \
  -signer code-signing-cert.pem -inkey code-signing-key.pem \
  -in yuzu-agent-0.14.0-x86_64-linux -out yuzu-agent-0.14.0-x86_64-linux.sig

If your signing certificate chains through an intermediate, add -certfile intermediate.pem. Without it the signature carries only the leaf, the agent cannot build the chain to your root. Note the agent does NOT log OpenSSL’s familiar unable to get local issuer certificate — CMS reports it as untrusted chain with error:17000064:CMS routines::certificate verify error, so grepping for the X509 string finds nothing. Two-tier PKIs are the normal enterprise case, so this is easy to hit.

Diagnosing a rejection. The agent logs either untrusted chain or invalid signature — or, when its own trust bundle cannot be loaded at all (missing, unreadable by the agent’s account, not a regular file, larger than 1 MiB, held open by another process that denies shared reading (Windows), or not a PEM certificate file), the update trust bundle could not be loaded, so the signature was not checked, counted as reason="bundle_unreadable". That last one is a fault on the endpoint, not in your signing; fix the bundle file or its permissions. Agents before 0.14.1 reported it as untrusted chain (#5249). Be aware that a certificate-profile problem — a non-critical keyUsage, a missing codeSigning EKU, a missing intermediate — is reported as untrusted chain, the same as a genuinely wrong CA. Check the profile above before concluding the trust bundle is wrong. Verify a certificate locally with:

openssl verify -purpose codesign -CAfile trust-bundle.pem code-signing-cert.pem

Before you debug the certificate, check the server side. A stored signature that cannot possibly cover the binary beside it produces the same untrusted chain string as a genuinely wrong CA, and no amount of openssl verify on your certificate will reveal it. The Signed column shows signature mismatch for exactly this case: the signature landed but the binary write that should have followed it did not, so the two artifacts are from different uploads. The fix is to re-upload the binary and its signature together; the certificate is not the problem. If the column says unsigned for a package you signed, the sidecar was rejected or is unreadable — the server log names which.

Upload it alongside the binary: Settings → OTA Updates has an optional Signature file input beside the binary input. The server stores it as a sidecar next to the package and hands it to agents on CheckForUpdate; it does not verify it and is deliberately not trusted to. Signatures are capped at 64 KB — a detached CMS signature is a few KB, so a rejection here usually means the binary was selected into the signature field by mistake.

Place the trust anchor out of band. Agents verify against a PEM bundle you install on the endpoint — never fetched over the update channel, because a trust anchor delivered by the party being verified anchors nothing. The installers now create the directory for it. It is deliberately separate from /etc/yuzu/certs, which is the SERVER’s CA directory — the server owns that path and re-tightens its mode on every boot, so an anchor placed there would be either unwritable by the server or unreadable by the agent, with no mode satisfying both:

PlatformPathOwnership
Linux/etc/yuzu-agent/certs/root:root, mode 0755
macOS/etc/yuzu-agent/certs/root:wheel, mode 0755
WindowsC:\ProgramData\Yuzu\agent-certs\Administrators + SYSTEM, owned by Administrators (or SYSTEM), and holding only files. The installer builds a new one in its private temporary folder, locks it there (icacls /setowner /L → /reset /L → /inheritance:r /grant:r /L), checks it, moves it into place and checks it again; an existing, secured directory is not re-locked. If it already exists, it must already be secured exactly; the installer refuses rather than take over a directory it did not secure, because whatever is in it already decides which updates are trusted. It also refuses a junction, symbolic link or subdirectory at or in it, and nothing it runs is recursive. Files in an existing, secured directory must already be owned by Administrators or SYSTEM, and not be hard links, before their permissions are reset to inherit the grant (this repairs the rc1..rc5 lock-out); the installer never takes ownership of a file, and refuses one someone else placed. The check runs before the agent service is stopped, so a refusal leaves the service as it was. A refusal also never leaves the directory less protected than it was found: the lock is applied only to the private copy, so if security software blocks it nothing is created at the path, and an existing directory’s own permissions are never changed (#5250). If the installation fails later, after the service was stopped (for example, a file cannot be replaced; Setup exits with code 5), the installer starts the service again before it exits if it was running or still starting — even when /NOSTART was given, since /NOSTART applies only to a completed installation — and its /LOG= log says on a DeinitializeSetup: line whether it saw the service RUNNING again or could NOT start it (then run sc.exe start YuzuAgent, never plain sc, which PowerShell reads as Set-Content). Files it had already replaced are not put back, so run the installer again. Installers up to 0.14.0 left the service stopped (see the agent bundle guide). Not covered: the parent C:\ProgramData\Yuzu (#5257), and a handle opened before the install (#5258). A pre-install check compares the security descriptor of the directory and of everything inside it exactly: the owner, and an entry list of Administrators and SYSTEM with full control and nothing else (no deny entries, no other accounts). It aborts the install otherwise.

How much protection that directory gives you depends on the platform, and it is worth being precise about it. On Linux the agent runs as the unprivileged yuzu-agent user and genuinely cannot write the anchor. On macOS and Windows the agent runs as root and LocalSystem respectively — it has to, in order to replace its own binary — so it can write the anchor there. On those platforms the directory permissions protect the anchor from unprivileged local users, not from the agent process itself.

This is a real limit rather than an oversight: a process privileged enough to replace the system binary is privileged enough to rewrite the file that authorises the replacement. Signature verification on those platforms defends against a malicious or impersonated server, which is the threat it was built for; it is not a defence against an already-compromised root/SYSTEM process on the endpoint. If you need the stronger property, run the agent as a dedicated non-privileged service account and grant it only what it needs.

Then point agents at it. On a packaged install the agent is started by the service manager, not by hand, so set these through the environment rather than by editing the unit:

PlatformHow
Linux (systemd)systemctl edit yuzu-agent and add [Service] / Environment="YUZU_UPDATE_TRUST_BUNDLE=/etc/yuzu-agent/certs/update-trust-bundle.pem", then systemctl restart yuzu-agent. The shipped unit has a fixed ExecStart, so a drop-in is the supported route.
macOS (launchd)Add the variable to EnvironmentVariables in /Library/LaunchDaemons/com.yuzu.agent.plist, then launchctl kickstart -k system/com.yuzu.agent. Package upgrades keep every YUZU_* variable there, except YUZU_PLUGIN_DIR and YUZU_DATA_DIR, which the package sets.
WindowsSet them in the service’s own environment, a REG_MULTI_SZ value named Environment under the service’s registry key, then restart the service. Not in its binary path: every installer run rewrites that path and drops them, and signing is then silently off (#5196). See Windows: the service’s Environment value below for the commands and the cases that remove the value.

Every flag below has the environment variable shown beside it:

Agent flagEnvDefaultMeaning
--update-trust-bundleYUZU_UPDATE_TRUST_BUNDLEunsetPEM bundle of CAs permitted to sign update binaries. Unset disables signature checking entirely.
--update-require-signatureYUZU_UPDATE_REQUIRE_SIGNATUREoffRefuse packages that carry no signature.

Setting --update-require-signature without a trust bundle refuses to start, rather than running with enforcement silently inert.

Confirming what an agent loaded. At startup the agent logs the mode it is enforcing and the bundle path, for example OTA update signature mode: bundle+require (trust bundle: /etc/yuzu-agent/certs/update-trust-bundle.pem), or OTA update signature mode: off (…update binaries are NOT signature-checked) when no bundle is set. That startup line is the verification: it says what this agent will enforce. Beside it the agent warns about any environment variable that starts with YUZU_UPDATE_ but is not one it reads, so a misspelling after the prefix no longer passes silently. The names it accepts are YUZU_UPDATE_TRUST_BUNDLE, YUZU_UPDATE_REQUIRE_SIGNATURE and YUZU_UPDATE_CHECK_INTERVAL (the agent’s own options) and YUZU_UPDATE_DIR (the server’s update-directory option, accepted silently so a host that also runs a server is not warned about it).

The bundle-load warning, OTA update trust bundle cannot be loaded as of this check: …, is NOT logged beside the mode line. The OTA update checker logs it once, when it first starts, after the agent first connects, and only when auto-update is enabled; the startup report does no file I/O because it runs before a Windows service reports itself started. The same mode (off, bundle or bundle+require) travels on every heartbeat as the status tag yuzu.ota_signature_mode (see metrics.md → Agent-side signature refusals). The server stores that tag in the agent-health snapshot, but no REST endpoint, MCP tool or dashboard view exposes it yet, so the startup log line is the per-endpoint source of truth. These signals arrived in 0.14.1 (#5249); an older agent logs none of them.

Windows: the service’s Environment value

The Service Control Manager merges a service’s Environment value into the environment it starts the service with, so the agent sees these variables beside the usual Path, SystemRoot and so on. Elevated, for the trust bundle alone (stage 1 of Rolling signing out to a live fleet):

reg add HKLM\SYSTEM\CurrentControlSet\Services\YuzuAgent /v Environment /t REG_MULTI_SZ /d "YUZU_UPDATE_TRUST_BUNDLE=C:\ProgramData\Yuzu\agent-certs\update-trust-bundle.pem" /f
Restart-Service YuzuAgent

For stage 2, give both variables, separated by \0: /d "YUZU_UPDATE_TRUST_BUNDLE=C:\ProgramData\Yuzu\agent-certs\update-trust-bundle.pem\0YUZU_UPDATE_REQUIRE_SIGNATURE=1".

Get these right, because several mistakes are silent:

To check an endpoint, use the script below. Run it elevated, or as SYSTEM (as Intune and Configuration Manager compliance scripts run): the bundle directory is readable only by Administrators and SYSTEM, so an unelevated run reports NOT CONFIGURED. It prints OK and exits 0 only when all of these hold:

Otherwise it prints NOT CONFIGURED and exits 1. Save it as a .ps1 and run that, or use it as a configuration-management compliance script, comparing its output with OK. Pasted into an interactive PowerShell window, its exit closes the window.

$stage = 1   # 2 once the endpoint also refuses unsigned packages
if ($stage -notin 1, 2) { 'NOT CONFIGURED'; exit 1 }
$b = 'C:\ProgramData\Yuzu\agent-certs\update-trust-bundle.pem'
$want = @("YUZU_UPDATE_TRUST_BUNDLE=$b") + @(if ($stage -eq 2) { 'YUZU_UPDATE_REQUIRE_SIGNATURE=1' })
$k = Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Services\YuzuAgent
$have = @($k.Environment | Where-Object { $_ -match '^YUZU_UPDATE_(TRUST_BUNDLE|REQUIRE_SIGNATURE)=' })
$na = '[^\x20-\x7E]'
$lookalike = @($k.Environment | Where-Object { (($_ -split '=', 2)[0] -cmatch $na) -or (($_ -match '^YUZU_UPDATE_') -and ($_ -cmatch $na)) }).Count -gt 0 -or ([string]$k.ImagePath -cmatch $na)
$ok = ($k.Environment -is [string[]]) -and -not ($k.Environment -contains '') -and -not $lookalike -and ($have.Count -eq $want.Count) -and (@($want | Where-Object { $have -notcontains $_ }).Count -eq 0) -and (($k.ImagePath -replace '"', '') -notmatch '(--|/)update-(trust-bundle|require-signature)') -and (Test-Path -LiteralPath $b -PathType Leaf)
if ($ok) { 'OK'; exit 0 } else { 'NOT CONFIGURED'; exit 1 }

-cmatch (case-sensitive) is deliberate in the non-ASCII test: a case-insensitive match can fold some non-ASCII characters, such as the Kelvin sign, onto ASCII letters and let them through.

What OK does and does not mean. It means the service is configured the way this section describes. It is not proof that the agent loaded that configuration. For that, use the agent’s own signal (0.14.1 and later, #5249): after restarting the service, its log carries OTA update signature mode: bundle (stage 1) or OTA update signature mode: bundle+require (stage 2) and the bundle path, with no YUZU_UPDATE_ warning after it. With auto-update enabled, also check that no trust bundle cannot be loaded warning follows OTA update checker started once the agent has connected — that warning comes from the update checker, not from the startup lines. The heartbeat carries the same mode as the status tag yuzu.ota_signature_mode, but the server only stores it today (no REST, MCP or dashboard view shows it), so the log is the check. An agent older than 0.14.1 logs none of this, so for it the script is the only check, and it assumes an agent recent enough to have these options (yuzu-agent.exe --help lists --update-trust-bundle). The script does not check the other entries’ contents beyond the non-ASCII rule, only that none is empty, and it compares names case-insensitively, so type them in plain ASCII. It also does not check that the bundle file holds the right certificates: a wrong bundle makes the agent refuse signed updates, which shows in yuzu_agent_ota_signature_refused_total and the agent log (untrusted chain), while a bundle the agent cannot read at all is logged and counted separately (reason="bundle_unreadable").

The verifier’s catastrophic invariants

Four properties of agents/core/src/detached_signature.cpp are recorded as catastrophic-if-violated in .claude/routed-concerns.md. They are stated here in full because that table is character-budgeted and carries only a pointer. Anyone changing that file must preserve all four.

  1. CMS_verify must never receive CMS_NO_SIGNER_CERT_VERIFY or CMS_NO_CONTENT_VERIFY. Each individually disables one half of the check — the certificate-chain validation or the content digest — while still returning success. The call passes exactly CMS_BINARY | CMS_DETACHED.
  2. The X509_STORE must keep X509_PURPOSE_CODE_SIGN. Without it, any leaf chaining to a trusted root becomes a signing authority — the normal case for an internal PKI issuing mTLS and S/MIME from one root, so the consequence is not hypothetical.
  3. An unreadable trust bundle must fail CLOSED. “Cannot check” is never “checked out fine”. The store load is the first check performed. It surfaces as CmsFailure::kBundleUnreadable (reason=bundle_unreadable; the plugin loader still reports it as untrusted), is refused in both modes, and — since 0.14.1 — the bundle is read bounded (regular file only, at most 1 MiB) and parsed from memory.
  4. OTA verification stays after the SHA-256 compare and before apply, reading the HELD descriptor rather than re-opening the path. Apply is the point of no return — the execute bit on POSIX, the live-binary move on Windows. Reading the held descriptor is mandatory rather than preferable on Windows: the staged file is opened dwShareMode=0 and cannot be opened twice.

Two further properties are load-bearing without being catastrophic: --update-require-signature governs only whether an ABSENT signature is tolerated — a present-but-invalid one is refused in both modes given a bundle — and the trust-anchor directory must stay root-owned and non-world-writable.

Rolling signing out to a live fleet

Roll it out in two stages, and understand what the first stage does not give you. Deploy the bundle with --update-require-signature OFF first: an unsigned package is then accepted with a loud warning, while a package whose signature is present and does not verify is refused — that rejection is unconditional and does not depend on this flag. Once every package you serve is signed, turn the flag on.

The permissive stage is a genuine downgrade oracle for as long as it lasts. There is no capability handshake on this RPC, so an agent cannot tell “this server is too old to sign” from “the signature was stripped in transit”; both look like an absent signature and both are accepted. Keep the window short.

Withdrawing a signed release, or responding to a suspected key compromise

Deleting the package from the server does not withdraw it. The signature covers the binary’s content and carries no version or expiry, so any copy of a binary your key has ever signed stays acceptable to every anchored agent forever. An attacker who kept a copy can replay it (see the rollback limitation above). Removing it from Settings → OTA Updates only stops you serving it.

The only true withdrawal lever is the trust anchor itself, and it is blunt:

  1. Issue a new signing key and certificate, and re-sign every release you still want endpoints to accept.
  2. Re-upload the re-signed packages to the server. Do this BEFORE the new bundle reaches any endpoint: an agent that gets the new anchor while the server is still serving old-key signatures refuses everything.
  3. Distribute a bundle containing only the new certificate — not both. Last, because this is the step that actually invalidates the withdrawn release, and until it lands the withdrawal has not happened.

For a suspected key compromise, do the same but do not wait to batch it, and invert the ordering caution from Rotating the signing key below: there the old certificate is removed last to avoid an outage; here leaving it in place is the risk you are trying to remove. Plan for a window in which updates stop rather than a window in which a compromised key is still trusted.

Rotating the signing key

If you are enabling signing for the first time, read Rolling signing out to a live fleet above first — this section is about replacing a key you already use.

The spec for this feature asked for custody and rotation guidance, and rotation is worth doing deliberately because the naive sequence strands a fleet.

The trap. Agents verify against whatever is in the trust bundle at the moment they check. If you sign new packages with a new key and have not yet distributed the new anchor, every agent that has the old anchor refuses them — and refusal is unconditional for a signature that is present and does not verify, so the transitional mode does not help.

The safe order:

  1. Distribute the new anchor alongside the old one, and wait. A PEM bundle may hold several certificates; append the new one rather than replacing it, so an agent trusts both keys at once. Do not sign anything with the new key yet. Wait until you are confident every endpoint has the updated bundle — the fleet gauge cannot tell you this, so use your configuration-management tooling.
  2. Start signing with the new key. Both old and new packages verify, because every agent trusts both.
  3. Re-sign anything you still serve. A package uploaded under the old key keeps its old signature; it stays valid only while the old anchor is trusted.
  4. Remove the old certificate from the bundle, last.

Re-signing an existing package. Upload it again with the new .sig. Do NOT re-upload the binary without a signature intending to add one afterwards — the upload replaces the sidecar along with the binary, so the package would be served unsigned in between, and --update-require-signature agents would refuse it for as long as that window lasts. The Signed column in Settings → OTA Updates shows what agents are actually being served.

Custody. The signing key never needs to touch a Yuzu server — the server stores and forwards the signature but never produces or verifies one. Keep it wherever you keep your other code-signing material.

Watch yuzu_fleet_ota_signature_refusing_agents. The server derives this gauge from the agents’ heartbeats: it counts how many endpoints have refused at least one update since the agent process last started. It counts AGENTS rather than refusals on purpose — one wedged endpoint retrying every six hours would otherwise dominate a refusal count and hide how much of the fleet is affected.

Read it as “has refused”, not “is currently failing”. The underlying counter is cumulative and never resets, so an endpoint that refused once and has since updated cleanly keeps contributing until it restarts — and conversely, an agent restart zeroes one that is still failing. A rising number is a real signal and worth acting on; a flat non-zero number is not proof that those machines are still stuck. To confirm current state, check whether the affected endpoints’ reported agent version is advancing.

A refusing agent still cannot tell you WHY. There is no status-report RPC on the update path, so the reason appears only in that endpoint’s own log — the gauge tells you how many are affected, not what to fix. The strings to grep for are untrusted chain, invalid signature, and trust bundle could not be loaded (the agent’s own bundle is missing or unreadable, #5249); an unsigned package logs update package is unsigned and --update-require-signature is set (missing is the metric label for that case, not text that appears in the log). Verify on a pilot group before a fleet-wide flip.

Signature verification runs after the hash check and before anything irreversible — before the execute bit is set on POSIX and before the live binary is moved aside on Windows — and reads the already-downloaded file through the descriptor that was hashed, so the bytes verified are the bytes that were checked.

RBAC Management

Role-Based Access Control adds granular permissions beyond the built-in admin/user roles.

Enabling RBAC

  1. Navigate to Settings > RBAC Management.
  2. Toggle Enable RBAC.
  3. When RBAC is enabled, the system enforces fine-grained permissions based on assigned roles.

Built-in vs. Custom Roles

AspectBuilt-in RolesCustom Roles (RBAC)
GranularityTwo roles: admin, userPer-operation permissions on securable types
AssignmentPer-userPer-principal (users, service accounts, API tokens)
Management groupsNot scopedPermissions can be scoped to management groups

Creating a Role

  1. Navigate to Settings > RBAC Management.
  2. Click Create Role.
  3. Name the role and assign permissions for each securable type and operation.

For the full RBAC model, see docs/user-manual/rbac.md.


Tag Compliance

Tag compliance shows how many devices carry each structured tag category. It lives on the Compliance page, in the Compliance by tag panel (it was a Settings panel before).

  1. Open Compliance and scroll to Compliance by tag.
  2. For each category the table shows the display name, how many devices are tagged, how many are missing the tag, and the allowed values (Free-form when any value is accepted), with a summary line of how many devices have every required tag.

Reading it needs the Tag:Read permission; without it the panel says so and the rest of the page still loads.

Tag compliance data is also available via the REST API (GET /api/v1/tag-compliance) for integration with external dashboards and reporting tools.


OIDC SSO Configuration

Yuzu supports OpenID Connect (OIDC) for single sign-on with an external identity provider that serves OpenID Connect Discovery. Verified end to end against Keycloak 26.4.0 (Linux test host, plain http and https via a TLS-terminating proxy). Microsoft Entra ID is supported by the standard (endpoints and keys come from its discovery document) but has not been verified end to end by the project; see Authentication for the manual procedure and the sign-in troubleshooting log lines.

Configuration

OIDC can be configured via CLI flags at startup or through the Settings page:

ParameterCLI FlagDescription
Issuer URL--oidc-issuerThe issuer base URL. It must equal the issuer in the document at {issuer}/.well-known/openid-configuration and the tokens’ iss byte for byte, including any trailing slash. The server fetches that document (and the JWKS) itself on the first sign-in.
Client ID--oidc-client-idApplication (client) ID from your identity provider.
Client Secret--oidc-client-secretClient secret for the confidential client flow.
Redirect URI--oidc-redirect-uriCallback URL registered with the identity provider. Must point to the Yuzu server’s /auth/callback path. When omitted it defaults to a localhost URL derived from the listen port; set it explicitly for any real IdP.
Admin Group--oidc-admin-groupThe value of the entry in the token’s groups claim that maps to the admin role (the group object ID for Entra ID; the group name for Keycloak with Full group path off).
Skip TLS Verify--oidc-skip-tls-verifyDisable TLS cert verification for OIDC discovery, JWKS and (Linux/macOS) token requests (insecure, dev only); the Windows token request ignores it. Env: YUZU_OIDC_SKIP_TLS_VERIFY.
Allow private networks--oidc-allow-private-networksNot a Settings-page form field. Lets OIDC requests reach loopback, RFC 1918, carrier-grade NAT and unique-local addresses (an IdP on the internal network); link-local and cloud-metadata addresses are refused either way. The Settings save keeps the value the server was started with and ignores a form field of that name. Env: YUZU_OIDC_ALLOW_PRIVATE_NETWORKS.
NAT64 prefixes--oidc-nat64-prefixNot a Settings-page form field. Declares the network’s NAT64 prefixes so an address inside one is classified by the IPv4 address it embeds. The Settings save and Test Connection use the value the server was started with and ignore a form field of that name. Env: YUZU_OIDC_NAT64_PREFIXES. Operator warning: a configured prefix can permit HTTPS connections to ULA addresses even when --oidc-allow-private-networks is off; declare only actual translation prefixes.
Link Claim--oidc-scim-link-claimADR-2001 (SCIM↔OIDC identity linkage, CC6.8) — which validated ID-token claim (sub default, or oid for Entra) links a SCIM slug to its OIDC login identity so a SCIM deprovision can also revoke that identity’s API/MCP tokens. Not a Settings-page form field — this page saves the OIDC config CLI/env already set for this value unchanged rather than resetting it to the struct default. See SCIM ↔ OIDC identity linkage. Env: YUZU_OIDC_SCIM_LINK_CLAIM.

Identity Provider Setup

  1. Register Yuzu as an application in your identity provider.
  2. Set the redirect URI to https://<yuzu-server>:<port>/auth/callback.
  3. Note the client ID and client secret.
  4. Enter these values in the Yuzu Settings page or pass them as CLI flags.

User Mapping

OIDC-authenticated users are mapped to Yuzu roles based on claims or group membership. The mapping configuration depends on your identity provider and is set in the OIDC section of the Settings page.


SAML 2.0 SP Configuration

Yuzu supports SAML 2.0 SP-initiated single sign-on as an alternative to OIDC for enterprises whose identity infrastructure requires SAML. Linux and macOS only — SAML is not supported on Windows builds.

HTTPS is required. The __Host-yuzu_saml_bind binding cookie is Secure-only and is silently dropped by browsers over plain HTTP. If --https-cert/--https-key are not configured the server logs an error at startup and leaves SAML disabled (fail-closed).

Windows note: On Windows builds, any SAML flag is logged as an error at startup and SAML is not enabled.

Configuration

All five flags below must be supplied for correct operation, and all five are validated at startup — a partial set leaves SAML disabled (fail-closed) until every flag is set. The two group→role flags further down are optional and independent of this five-flag gate.

FlagEnv varDescription
--saml-idp-entity-idYUZU_SAML_IDP_ENTITY_IDEntity ID URI of the IdP (must match what the IdP uses in assertions).
--saml-idp-sso-urlYUZU_SAML_IDP_SSO_URLIdP’s HTTP-Redirect SSO endpoint URL.
--saml-idp-certYUZU_SAML_IDP_CERTFilesystem path to the IdP’s assertion-signing certificate (PEM). The cert at this path is the sole trusted authority; in-document <KeyInfo> values are ignored.
--saml-sp-entity-idYUZU_SAML_SP_ENTITY_IDEntity ID URI this SP advertises to the IdP in the AuthnRequest.
--saml-sp-acs-urlYUZU_SAML_SP_ACS_URLFull public URL of the Assertion Consumer Service (https://<host>/saml/acs).

Example startup:

./yuzu-server \
  --https-cert         /etc/yuzu/server.crt \
  --https-key          /etc/yuzu/server.key \
  --saml-idp-entity-id "https://idp.example.com/saml" \
  --saml-idp-sso-url   "https://idp.example.com/saml/sso" \
  --saml-idp-cert      /etc/yuzu/idp-signing.pem \
  --saml-sp-entity-id  "https://yuzu.example.com" \
  --saml-sp-acs-url    "https://yuzu.example.com/saml/acs"

Role mapping

Two additional, optional flags grant admin access via IdP-attested group membership — mirroring the OIDC --oidc-admin-group mechanism:

FlagEnv varDescription
--saml-group-attributeYUZU_SAML_GROUP_ATTRIBUTE<Attribute Name="..."> in the assertion’s <AttributeStatement> whose <AttributeValue>s are group identifiers.
--saml-admin-groupYUZU_SAML_ADMIN_GROUPThe group value (from --saml-group-attribute) that grants role=admin.
--saml-name-attributeYUZU_SAML_NAME_ATTRIBUTEOptional. <Attribute Name="..."> whose first value is the user’s display name (e.g. Entra’s displayname claim URI). Empty (default) leaves the session display as the raw NameID. Display/audit only — never an identity or authz input.
--saml-email-attributeYUZU_SAML_EMAIL_ATTRIBUTEOptional. <Attribute Name="..."> whose first value is the user’s email (e.g. Entra’s emailaddress claim URI). Used only as a display fallback and logged — never stored durably or used for identity.

A session is role=admin only when both flags are set and the assertion’s group list contains an exact match for --saml-admin-group; otherwise (including when either flag is left empty, the default) the session is role=user. Group values are read from the same signature-verified assertion NameID is read from, and NameID/email/display name are never treated as group-membership evidence. Changing either flag requires a server restart (no hot-reload). JIT elevation remains non-functional for SAML users (no local users row in the auth schema) regardless of role — a group-mapped admin gets role=admin directly at login, not via the elevation endpoint.

Configuring --saml-admin-group against a real IdP: the value must be the exact identifier the IdP puts in the assertion, not a display name — Entra ID sends group object ID GUIDs, not group names, so configure the GUID. Matching is case-sensitive. A user in more than ~150 Entra groups hits “groups overage”: Entra omits the groups claim entirely for that assertion (substituting a Graph API link), so such users can never resolve to admin via --saml-admin-group regardless of actual membership — use a dedicated low-membership group for the mapping. At most 200 group values from the configured attribute are considered.

Fine-grained RBAC (parity with OIDC). When RBAC is enabled and --saml-group-attribute is configured, every asserted group value is ALSO reconciled into the RBAC store as saml:<value> group principals (source "saml") on every login, the same reconcile_idp_memberships mechanism OIDC uses for source "entra" — assign roles to saml:<value> groups via the management-group role-delegation API, POST /api/v1/management-groups/{id}/roles, with "principal_type": "group" and "principal_id": "saml:<value>" (only the Operator and Viewer roles can be delegated this way). Both mechanisms coexist: --saml-admin-group still grants the coarse session role independently of any fine-grained RBAC grants. Reconciliation runs before the session is minted (fail-closed on error), and two cases deliberately do NOT fall through to a normal reconcile:

See authentication.md’s SAML Fine-Grained RBAC section for the full detail.

AuthnRequest signing

One additional, optional flag, independent of the five-flag gate, signs SP-initiated AuthnRequests:

FlagEnv varDescription
--saml-sp-keyYUZU_SAML_SP_KEYFilesystem path to the SP AuthnRequest signing private key (PEM, RSA only — EC and RSA-PSS keys are rejected).

When set, AuthnRequests are signed over the HTTP-Redirect binding with RSA PKCS#1 v1.5 + SHA-256 (SigAlg http://www.w3.org/2001/04/xmldsig-more#rsa-sha256), carried as the SigAlg/Signature query parameters. The key file must pass the same private-key permission check as the HTTPS/gateway TLS keys (not group/other-readable). Left unset (the default), AuthnRequests remain unsigned — backward-compatible with an IdP that accepts unsigned requests.

Fails closed: a configured key that is unreadable, over-permissioned, exceeds 64 KiB, is malformed, encrypted/passphrase-protected, is not RSA, or has a modulus outside 2048–16384 bits disables SAML entirely at startup — loudly (an ERROR log line), never a silent fall-back to unsigned requests. A per-request signing failure fails /auth/saml/start rather than emitting an unsigned redirect. The key must be unencrypted — a passphrase-protected key is rejected, not prompted for. The 2048-bit floor rejects factorable weak keys; the 16384-bit ceiling bounds per-request signing cost on the unauthenticated start endpoint.

Yuzu does not yet publish an SP metadata endpoint, so you must register the signing key’s public certificate with the IdP by hand (as the AuthnRequest/request-signing verification certificate) — otherwise the IdP rejects the signature despite a clean Yuzu boot. See the user manual’s AuthnRequest Signing section for the openssl keypair-generation and IdP-registration recipe.

Known limitations in this release

See docs/user-manual/authentication.md “SAML 2.0 SSO” for the full login flow, capability table, and docs/auth-architecture.md “SAML 2.0 SP” for the implementation reference.


Directory Sync (AD/Entra ID)

POST /api/directory/sync pulls users, groups, and group memberships from Microsoft Entra ID into DirectorySync (schema directory_sync, ADR-0063). See docs/user-manual/upgrading.md’s ADR-0063 entry for the Postgres-cutover migration note.

Diagnosing a stuck directory sync (ADR-1007)

sync_entra holds a single in-process guard (an std::atomic<bool>, not a Postgres advisory lock — this is a single-server-process concern, unlike the KEK op lock below) for the duration of one sync call, so two overlapping POST /api/directory/sync calls can no longer race and interleave writes. A call that arrives while another is already in flight is rejected immediately with 409 Conflict — no queueing, no blocking, and the caller gets its response right away rather than waiting for the in-flight sync to finish; retry once the guard clears (see below for how long that can take).

If 409 responses persist well past a normal sync’s duration (a normal sync completes in seconds to low tens of seconds; each individual HTTP call IS bounded — 15s connect / 30s read+write per Microsoft Graph call, 10s connect / 15s read+write for the OAuth token exchange — but nothing bounds the sync AS A WHOLE, which can page through an arbitrarily large tenant across many such calls), the guard is most likely still held by a sync that is genuinely slow — a large tenant, or a slow/degraded Graph API response — not wedged forever, since the guard is released via RAII on every exit path of sync_entra, including error returns. There is no operator override to force-clear the guard — restarting the server is the only way to reset it, because the guard is in-process state with no persisted record for an admin surface to reset independently (a Postgres advisory lock, by contrast, can be inspected and — carefully — cleared without restarting anything; see “Diagnosing a stuck KEK op lock” below for what that looks like when the underlying primitive supports it). Before restarting, check the server log for the sync’s own completion or error line and confirm Microsoft Graph reachability from this host — a restart discards no synced data (the store itself is unaffected), but it does drop the in-flight sync attempt.

HA / multi-replica: this guard, like the SAML/OIDC login-state limitation above, is in-process and therefore per-replica — a second server replica has its own independent guard and would not see a sync in flight on the first. Coordinating this across replicas is not yet built (docs/adr/2002-high-availability-architecture.md); today’s single-server deployment model is unaffected.

Data Storage and Encryption

Yuzu stores persistent server data in PostgreSQL (see PostgreSQL Substrate): the response store, audit log, RBAC store, authentication and every other server store. Only the runtime state files, the NVD CVE cache nvd_cves.db and the agent-updates//upload-blobs/ file directories live on disk, in --data-dir (or --update-dir for agent updates) (see Configuration Files).

Important: Yuzu does not encrypt the database at rest. Secret columns (for example TOTP secrets) are envelope-encrypted by the server (ADR-0010; see Key management), but query results, audit logs and agent metadata sit in plaintext in PostgreSQL’s data directory, and the --data-dir files are plaintext too. Any user or process with read access to those files can read this data.

Protecting Data at Rest

Operators must use full-disk encryption to protect Yuzu data at rest:

PlatformRecommended SolutionNotes
Linuxdm-crypt / LUKSEncrypt the partition or volume where Yuzu data resides (PostgreSQL’s data directory and --data-dir). Most distributions support LUKS during OS installation.
WindowsBitLockerEnable BitLocker on the drive containing the Yuzu server directory. Requires TPM or startup key.
macOSFileVaultEnable FileVault in System Settings. Encrypts the entire startup volume.

For containerized deployments (Docker Compose), ensure the host volumes backing server-data and the PostgreSQL data volume are on an encrypted filesystem.

Planned: A future --encrypt-db option was scoped as SQLCipher encryption for the server’s SQLite files. With the server stores on PostgreSQL it would now cover only the NVD cache; database-level encryption for PostgreSQL is the operator’s platform choice today.


PostgreSQL Substrate

The server’s storage substrate is PostgreSQL (ADR-0006/0007; the agent stays SQLite). As of the cut-over (#1320 PR 3) the server requires a reachable database at boot and fails closed without one — it constructs a shared connection pool at startup and, if --postgres-dsn / YUZU_POSTGRES_DSN is unset or the database is unreachable, refuses to start and exits non-zero (no SQLite fallback for the server). There is a distinct [PG] Refusing to start log line so the cause is unambiguous in systemd / kubectl logs.

Upgrade action (BREAKING): before upgrading to this release, provision PostgreSQL and set YUZU_POSTGRES_DSN. Docker Compose deployments already bundle the postgres service and wire the DSN (no action beyond pulling the new images). Native installs must run the provisioning helper below (or point the DSN at a managed PostgreSQL 16+) first — otherwise the upgraded server will not boot. Restore pairing (ADR-0010): a database restore must be paired with the matching --ca-dir / key-directory restore.

Dedicated coordination connection (HA WS-3, ADR-2002 §10). Beyond the pool, each server holds one additional, never-recycled Postgres connection for background-work leader election, plus one for the /readyz reachability probe (HA WS-8). (The probe’s connection runs one plain query per tick and holds no session state, so unlike the leader-election connection it works through a transaction-mode pooler; it reconnects after any failure.) Per §10 it is deliberately outside the pool and must reach the primary directly (an HAProxy-to-primary front is fine; a transaction-mode pooler is not, as it breaks the session advisory lock leadership rests on).

Sizing max_connections (#4943). The server’s whole Postgres footprint is static: pool_size + 2 per server — the pool never opens more than --postgres-pool-size connections, plus the two dedicated ones above; there is no other connect site. So

max_connections ≥ N_servers × (pool_size + 2) + superuser_reserved_connections (3) + every other client

— backup tooling, Grafana’s data source, ad-hoc psql. Sized this way the servers cannot exhaust the database by themselves; only other clients can. To keep them out of the servers’ share, the shipped yuzu-postgres images (single-node and the HA Patroni profile) set reserved_connections (PostgreSQL 16+; default 40, env YUZU_PG_RESERVED_CONNECTIONS, 0 disables) and grant pg_use_reserved_connections to the app role at first boot. Once the free slots fall to superuser_reserved_connections + reserved_connections, only the app role (and superusers) can still connect — so a backup job or a stray session can never take the slot the /readyz probe needs to reconnect, which would otherwise turn a replica red on a database that still serves (the probe holds its connection in steady state and reconnects only after a failure; at the limit that reconnect was the connection refused first). The reserve covers the pool and the probe alike — the probe connects exactly as a pool connection does, so it is never green while the pool is refused. Set YUZU_PG_RESERVED_CONNECTIONS to N_servers × (pool_size + 2) (the default fits two servers at the default pool, e.g. 2 × (16 + 2) = 36 ≤ 40). Never set it to max_connections minus superuser_reserved_connections or higher — both scripts refuse to boot on that value (the boot-time check reads the live max_connections; the Patroni entrypoint checks against its own literal 200), because it would leave zero connection slots any ordinary (non-privileged) client could ever use, not merely under load. On the single-node image this refusal happens AFTER the role/database/grant already exist on the now-initialized data directory (Postgres’s initdb-only-runs-once convention means those steps ran before this check) — restarting the SAME container with a corrected value does not retroactively apply anything; either start over on a fresh (wiped) data volume, or apply the fix by hand per “Existing databases” just below. The Patroni entrypoint’s check runs before ANY of that (at bootstrap, before the cluster exists), so a Patroni refusal is a clean, no-op-safe retry. This check runs once, at bootstrap. A later patronictl edit-config --set postgresql.parameters.max_connections=... on an already-running Patroni cluster is not re-validated against the reserved_connections value already in force — re-check the arithmetic by hand after any live max_connections change (tracked for a real runtime check: #4943 fast-follow). Existing databases: first-boot init does not re-run; apply it by hand — ALTER SYSTEM SET reserved_connections = 40; (restart required; under Patroni, patronictl edit-config --set postgresql.parameters.reserved_connections=40 then patronictl restart <scope> — edit-config alone only writes the DCS config and marks the member pending-restart, it does not apply a postmaster-context GUC like this one; the GRANT below is unchanged either way) and GRANT pg_use_reserved_connections TO yuzu;. Verify with SHOW reserved_connections and SELECT pg_has_role('yuzu', 'pg_use_reserved_connections', 'MEMBER'). On PostgreSQL 15 or older the grant is skipped with a notice and only the formula protects you. Re-run this same verification after anything that can silently undo it: a first boot interrupted between the GRANT and the ALTER SYSTEM (the init script never re-runs once the data directory exists, so a kill in that narrow window leaves the grant in place but the GUC at Postgres’s own default of 0 — inert, not incorrect, but not protecting anything either), or any role recreation, credential rotation, or disaster-recovery restore that can drop and re-add the app role without this script running again.

Connection-pool sizing. The server opens up to --postgres-pool-size / YUZU_POSTGRES_POOL_SIZE connections (default 16). Each heartbeat persists last-seen with one short-lived lease (≈33/s at 1 000 agents on a 30 s heartbeat — well within 16), and /viz/fleet draws one. Register also draws a short-lived lease per call, and a mass-reconnect event (a fleet-wide server restart, or a network partition healing across many agents at once) can spike concurrent Register calls well above the steady-state heartbeat rate — size with that burst in mind, not just steady-state. A Software-page Sync now run can do the same on purpose: up to 500 agents (the per-request cap) re-send their inventory at nearly the same moment, a transient ingest burst on the pool. Raise the size for large fleets (rule of thumb: +1 per ~1 000 agents beyond 5 000, plus headroom per additional Postgres-backed store as they migrate) or for a slow managed-PG link. Tune against the yuzu_pg_pool_{in_use,open,size} gauges, the yuzu_pg_acquire_wait_seconds histogram (the leading saturation signal), and the yuzu_pg_{connect_failed,acquire_timeout,unhealthy_discard}_total counters (unhealthy_discard counts pooled connections dropped on a failed health probe); the bundled alert rules (YuzuPgPoolSaturated, YuzuPgAcquireWaitHigh, YuzuPgConnectFailing, YuzuServerPostgresUnreachable in docs/prometheus/yuzu-alerts.yml). Pool saturation never affects /readyz — a busy but healthy server must stay in rotation — while an unreachable database turns /readyz red within seconds, well before those alerts’ for: windows page. The heartbeat upsert is best-effort with a 250 ms acquire deadline, so a saturated pool degrades the stale-host display, never the live fleet.

Saturation fast-fail (not operator-configurable). When the pool is already saturated at the moment of acquire (no idle connection, no spare capacity to open one), every bounded acquire across every Postgres-backed store now gives up after a short, fixed ceiling (~500 ms) instead of running to the caller’s own, often much longer, timeout, freeing the calling worker thread for other routes rather than pinning it on a connection unlikely to free up in time. This substantially reduces, but does not eliminate, the risk of a saturated pool cascading into broader worker-thread exhaustion (including on unrelated routes such as auth) under sustained load; the underlying pool-to-worker sizing ratio is unchanged, so a large enough sustained saturation event can still exhaust worker capacity, just at a materially higher load threshold than before this mitigation. This ceiling is a compiled-in default, not exposed via a CLI flag or environment variable; if it proves wrong for your deployment’s connection-hold-time distribution, that is a code change, not a config change. The metrics and alert rules named above (particularly yuzu_pg_acquire_wait_seconds and YuzuPgAcquireWaitHigh) remain the right signals to watch; a rising rate of fast-failed acquires under this ceiling is visible via the same yuzu_pg_acquire_timeout_total counter as a genuine full-timeout exhaustion (the counter does not currently distinguish the two).

Held-open SSE streams also lease this pool: each re-validates its credential every ~3 s tick. Those reads are cached (60 s for API tokens, 15 s for the engine-principal liveness check), so steady-state cost is proportional to distinct credentials rather than to stream count — but the refreshes still land here alongside ordinary traffic, and a stream capacity far above the pool size is the shape that turns a brief pool blip into a correlated stall. The server warns at startup when effective SSE stream capacity exceeds 16x --postgres-pool-size. Treat that as a prompt to watch yuzu_pg_acquire_wait_seconds and yuzu_pg_pool_in_use, not as an instruction to enlarge the pool reflexively — adding connections against an already-struggling database makes matters worse, and lowering the stream capacity is often the better lever.

Bootstrap-time floor (default-cert generation, ADR-0053). First-boot (or self-heal) default-cert generation takes the yuzu:default_certs_bootstrap Postgres advisory lock for the duration of its critical section, holding one pool lease for that whole window while the CA-store writes it performs inside the lock each draw their own nested per-call lease from the SAME pool — an N-way race at this one boot-time code path needs roughly N+1 simultaneous connections. At the allowed minimum --postgres-pool-size=1 this is not merely “can self-contend under a race” — it fails every boot that needs to generate certs, deterministically, zero racers required, since the outer lease alone exhausts a pool of size 1. It fails closed and loudly (a nested-acquire timeout surfaces as an ordinary record_issued failure → refuse to start), never a hang or silent corruption, but a pool sized at the bare minimum for steady-state heartbeat/SSE traffic above can still be too small for this one-time bootstrap path — size for at least 2 even on the smallest deployments.

endpoint_state holds last-seen history for departed devices. For a connected agent the identity fields (hostname, OS, agent version, architecture) are repopulated from its next heartbeat. The schema is also the only record of every device that has since disconnected: /hardware lists those devices as Offline, with their real last-seen time, for 30 days, and the retention prune’s clock anchor is stored beside the rows (endpoint_state.retention_meta). Neither regenerates from heartbeats, so include the schema in a full restore; a targeted restore that omits it loses the Offline history until each device reconnects. Retention: a row is deleted 37 days after its device was last seen (the 30-day roster window plus a 7-day margin). Each replica runs the prune hourly and removes at most 12,000 rows per hour. The first pass runs about a minute after the server starts. On the first start after the upgrade it only records its clock anchor, so deletions begin with the next hourly pass; on every later start it already deletes; a backlog inherited from an older release (for example 100,000 rows) drains at that rate, in about 8 hours. A decommissioned device’s row is erased by the decommission route (#5682); a device that is never decommissioned ages out under the same 37-day rule.

Provisioning a native (non-container) install

Docker Compose deployments get PostgreSQL automatically — every tracked compose bundles a postgres service (the ghcr.io/devnullltd/yuzu-postgres image: PostgreSQL 18 + pgvector + first-boot role/database init). Native installs use the provisioning helper instead:

Install methodHelper locationInvocation
.deb / .rpm/usr/share/yuzu/scripts/install-server-postgres.shRun automatically (non-fatally) by the package post-install hook
Release tarballscripts/install-server-postgres.sh inside the archiveRun manually as root after unpacking
Git checkoutscripts/install-server-postgres.shRun manually as root

Two modes:

# Mode 1 — external/managed Postgres: writes the DSN to
# /etc/yuzu/yuzu-server.env (0600), which the systemd unit loads
# via EnvironmentFile=. No local Postgres is touched.
sudo bash install-server-postgres.sh --dsn 'postgresql://yuzu:...@db.example.com:5432/yuzu'

# Mode 2 (default) — local Postgres: provisions the app role + database
# on an already-installed local PostgreSQL 16+ and writes the DSN env file.
# Idempotent — never clobbers an existing role, database, or env file.
sudo bash install-server-postgres.sh

The helper is non-fatal when no local cluster is found (prints install hints and exits 0) — this posture flips to a hard failure when the server starts requiring the DSN. The app role’s credential is freshly random and never shared with the postgres superuser. Per-store schemas are not created by the helper; the server’s migration runner owns those at startup (ADR-0008).

Backing up PostgreSQL state

A complete backup is the Postgres dump below plus the whole --ca-dir, taken at the same point in time and restored as a pair (see the restore-pairing invariant under Key management), together with the --data-dir state files, the NVD cache nvd_cves.db, and the blob directories agent-updates/ (or --update-dir) and upload-blobs/ listed in Configuration Files. The dump holds only the OTA-package and completed-upload records; restoring it without those two directories leaves records that point at files that no longer exist.

Use pg_dump (logical, consistent-by-construction — safe against a live database, unlike filesystem copies).

Native installs — the DSN lives only in the root-only systemd environment file (/etc/yuzu/yuzu-server.env), not in interactive shells, so load it first. Then split the password out so it rides the PGPASSWORD environment variable instead of the process argv (/proc/<pid>/cmdline is world-readable, and the command lands in shell history):

# Run as root. Assumes the standard helper-written DSN shape
# scheme://user:password@host:port/db.
. /etc/yuzu/yuzu-server.env
export PGPASSWORD="$(printf '%s\n' "$YUZU_POSTGRES_DSN" | sed -E 's!^[a-z]+://[^:/@]*:([^@]*)@.*$!\1!')"
DSN_NOPASS="$(printf '%s\n' "$YUZU_POSTGRES_DSN" | sed -E 's!^([a-z]+://[^:/@]*):[^@]*@!\1@!')"

pg_dump --format=custom --file="yuzu-pg-$(date +%F).dump" "$DSN_NOPASS"

Docker Compose (reference template — superuser peer auth inside the container; no credential on the host command line):

docker exec yuzu-postgres pg_dump -U postgres --format=custom yuzu \
  > "yuzu-pg-$(date +%F).dump"

Restore only a dump taken from this 0.15.0 deployment. The server does not check where a dump came from: a dump written by an earlier build is unsupported and is migrated forward without notice. Restore with the server stopped, then start the server (the migration runner reconciles schema versions at boot). On a fresh disaster-recovery target, the app role and database must exist before pg_restore — run install-server-postgres.sh (or your managed-DB provisioning) first:

# Native — same env-file + PGPASSWORD/DSN_NOPASS preamble as the backup recipe
pg_restore --clean --if-exists --no-owner --role=yuzu \
  --dbname="$DSN_NOPASS" "yuzu-pg-YYYY-MM-DD.dump"

# Docker Compose
docker exec -i yuzu-postgres pg_restore --clean --if-exists --no-owner \
  --role=yuzu -U postgres --dbname=yuzu < "yuzu-pg-YYYY-MM-DD.dump"

Restore enablement-anchor with the --data-dir files. Restoring a dump onto the data directory it came from is consistent; restoring an OLDER dump under a newer anchor starts the server with enablement regressed by design, and a restore onto a host with no anchor is reported as unwitnessed, as is a restore-then-upgrade while nothing has yet been issued (the anchor names the old store id, both counters are 0). See Enablement continuity anchor for what each means and for the exit.

Schedule the dump alongside the --ca-dir and --data-dir backups (and --update-dir, if set outside --data-dir); verify restores periodically against a scratch database (createdb yuzu_restore_test && pg_restore --dbname=... ).

The whole-database dump covers every Yuzu schema, including the per-operator /crypto dashboard layouts (crypto_dashboard_layout_store, ADR-0071). That schema is loss-tolerant: a restore that lacks it, or lacks an operator’s row, leaves those operators on the default layout, and nothing else depends on it. It holds operator user names (personal data); rows are erased when an operator is deprovisioned, so a restore from before a deprovision can bring a deleted operator’s row back until that user name is deprovisioned again or the row is deleted by hand (DELETE FROM crypto_dashboard_layout_store.layouts WHERE username = '<name>';).

Key management (secrets KEK)

Secret columns in PostgreSQL are envelope-encrypted app-side (ADR-0010): each value is sealed under a fresh data-encryption key (DEK), and the DEK is wrapped by the install’s key-encryption key (KEK). The KEK is a 32-byte key file generated on first boot (secrets-kek-v1.key, mode 0600, in the same key directory as the CA root key — --ca-dir, default /etc/yuzu/certs on Linux and on macOS running as root, ~/Library/Application Support/Yuzu/certs on macOS running non-root, C:\ProgramData\Yuzu\certs on Windows) and never enters the database — kek_meta in the secrets schema records only non-secret fingerprints (key-check values), which the server verifies against the key files at every boot.

Three of the four gated stores now write secret columns through this machinery: auth (TOTP secrets, since 2026-07-16), webhooks (the outbound HMAC signing secret, ADR-0057), and runtime_config_store (the OIDC client secret, ADR-0060). offload_targets adopts it once it migrates to Postgres (ADR-0059). Set your backup procedure up for the pairing below now — every additional migration widens the blast radius of a KEK/DB backup mismatch, never narrows it.

The restore-pairing invariant. pg_dump output and volume snapshots contain ciphertext and wrapped DEKs only — a database backup alone recovers no secrets, and a database restore is unusable without the matching keys directory. DB backups and keys-dir backups are a pair: back them up on the same schedule, restore them together, and keep a separate offline copy of the KEK file exactly like the CA root key. The restore-verification drill must restore both halves and confirm a clean boot — the server checks every registered KEK fingerprint at startup and fails closed rather than serving with unreadable secrets. The failure classes below are stable error prefixes at the start of the fatal startup message (match the prefix in the message text when writing log-scraping alerts; they are not structured log fields):

Startup error prefixMeaningRecovery
kek_unresolvableA registered KEK version has no key file. Causes: keys dir older than the DB (backup skew), wrong keys directory, a second server instance pointed at the same database (unsupported — one KEK per database), or, on macOS, a database registered by a server run as root (/etc/yuzu/certs) later opened by the same binary run non-root, or vice versa (~/Library/Application Support/Yuzu/certs) — the two euid-dependent defaults do not share key material.Restore the keys directory from the backup paired with this database, or pass --ca-dir pointing at whichever directory the original KEK actually lives in.
kek_corruptThe key file exists but does not match its registered fingerprint (torn/corrupt file or foreign key material — not row tamper).Same: restore the paired keys directory.
provider_failureCSPRNG or key-storage failure during KEK generation or check-value computation (first boot / rotation).Check the keys directory is writable and system entropy is healthy; if a prior first boot crashed, the message names the torn file to delete.
db_errorPostgres connection/transaction failure during the secrets schema migration or kek_meta read/write.Check the DSN and Postgres service health — triage as “DB down”, not key loss.

Rotation mints secrets-kek-v<N+1> and re-wraps only the small wrapped-DEK header of each stored blob — payloads are untouched, so rotation is cheap, incremental, and interruptible (a crash resumes by re-running the re-wrap; already-rotated rows are detected by the blob header; a crash before the new version’s fingerprint registers leaves an orphan key file that the next rotation attempt safely adopts). The operator-facing surface for this — REST + MCP, the half-committed contract, retirement preconditions, and a DR drill — is below.

Rotating the KEK

Three operations, mirrored on REST and MCP so both surfaces answer identically for the same failure:

RESTMCP toolPermissionReturns
POST /api/v1/secrets/kek/rotaterotate_kekSecurity:Write{new_version, rotation_complete}
POST /api/v1/secrets/kek/rewraprewrap_secretsSecurity:Write{rows_rewrapped}
GET /api/v1/secrets/kek/statusget_kek_statusSecurity:Read{active_version, oldest_in_use, rotation_complete, live_versions, lock_held, lock_holder_pid}

All three take no request parameters (an empty body or {}; any other field is rejected 400). rotate/rewrap are Security:Write — on the MCP side that means the supervised-tier approval gate applies, same as every other write tool.

/status’s live_versions, lock_held, and lock_holder_pid are #2530 diagnostic fields — see “Diagnosing a stuck KEK op lock” below for what they’re for and how to use them. live_versions and lock_held are null (never a fabricated 0/false) when the underlying query could not be determined; a null lock_held means the lock state is unknown, not “not held”. lock_held/lock_holder_pid report the lock in the server’s own database — the query is filtered by current_database(), not cluster-wide, so a same-named advisory lock held by an unrelated tenant database sharing the same Postgres instance never shows up here.

# 1. Mint a new version and re-wrap every row under it.
curl -X POST -H "Authorization: Bearer $TOKEN" \
  https://yuzu.example.com/api/v1/secrets/kek/rotate

# 2. Confirm completion.
curl -H "Authorization: Bearer $TOKEN" \
  https://yuzu.example.com/api/v1/secrets/kek/status
# → {"active_version":2,"oldest_in_use":2,"rotation_complete":true,
#    "live_versions":2,"lock_held":false,"lock_holder_pid":null,...}

/rotate’s response deliberately omits a row count (rotation_complete: true is the honest signal — see below); call /rewrap if you want an actual rows_rewrapped number.

The half-committed contract — read this before you retry anything. rotate_kek mints the new version and then re-wraps every row as one operation. If it fails, the response tells you which of two states you are in:

  • A plain failure (503/generic 500) — nothing changed; safe to retry /rotate.
  • Half-committed (500, message “KEK rotation did not finish re-wrapping every secret”) — the new KEK version is already active; only the row-by-row re-wrap did not finish. Call POST /api/v1/secrets/kek/rewrap (or the rewrap_secrets MCP tool) to resume. Do NOT call /rotate again — it would mint a second, spurious new version on top of an already-half-rotated state, not fix anything. /rewrap is idempotent and safe to call repeatedly, including once there is genuinely nothing left to do (rows_rewrapped: 0 is a normal, non-error outcome).

A practical consequence: rotation attempts are rate-limited, and a failed attempt consumes that budget too. That is deliberate — it is precisely the retry-on-500 loop above that the limit exists to stop, and automation that ignores the half-committed message would otherwise mint a fresh, never-retirable KEK version on every retry. /rewrap is not rate-limited, so the correct recovery path is never blocked. Treat a 429 on /rotate as “you are recovering, use /rewrap”, not as “try again shortly”.

One authoritative rate limit, durable and cluster-wide (#2530; #2530 G7-S9 removed a second, weaker tier). An earlier cut of this hardening pass added a cheap process-local pre-check (5 minutes, in-memory, no DB round trip) alongside the durable control below, to short-circuit an obviously-too-soon retry before it cost a query. That pre-check was removed: its hardcoded 5-minute window could not be configured down (an operator setting --kek-min-rotate-interval below 5 minutes was still refused for the full 5 minutes on that path), and it never populated an honest retry_after_ms, silently falling back to a fixed value on exactly the requests it refused. It was also never the correctness guarantee — it was per-process, so servers sharing one database each kept their own state, and a restart cleared it. The sole authoritative control today is a durable rate limit read from secrets.kek_meta.created_at, compared against the database server’s own now() in a single statement (so it is never comparing an app-host clock to a DB clock): --kek-min-rotate-interval (seconds, default 3600 = 1h, YUZU_KEK_MIN_ROTATE_INTERVAL). It survives a restart and is shared cluster-wide by every server pointed at the same database. A rotate refused by this durable check gets 429 with an honest retry_after_ms computed from that same durable timestamp — this is the one failure in this whole surface where a numeric retry hint is truthful, because waiting genuinely resolves it. (retry_after_ms is a uint32 millisecond count; an unreasonably large configured interval saturates it at its maximum rather than wrapping, so the hint can only ever come out over-long, never falsely short.)

This is a runaway/abuse guard, not a rotation-schedule setting — 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; the default is sized for that job and most operators should never change it. Raising it has a sharp, real cost: it directly delays emergency re-rotation after a suspected KEK compromise — the single most time-critical thing this surface exists to support — with no bypass (/rewrap only resumes an already-in-progress rotation; it never mints a new version). Set this to, say, 90 days to “match” a quarterly rotation policy, and a routine rotation followed by a compromise the very next day leaves you refused with 429 for the next three months, with no escape short of a restart at a lower value in the middle of the incident. Rotation cadence (how often you choose to rotate) and this minimum spacing (how often you permit rotation to be attempted at all) are different concepts — keep them separate. The flag’s upper bound (365 days) is a sanity ceiling against a fat-fingered value, not a recommendation.

Cluster-wide correctness (as opposed to rate limiting) still comes from the advisory lock, which reports 409 — see Concurrency below. A second, unrelated 409 — the live-version ceiling — is covered right after it.

Week-one gotcha: a fresh install’s FIRST rotate is refused too (#2530 G7-S7). KEK v1 is minted at boot with created_at = now() — it is a kek_meta row like any other, so the durable clock above starts counting down from install time, not from whenever you happen to first call /rotate. If you install the server and immediately try to rotate as a smoke test, you get the same 429 cooldown response an abuse-guard trip would produce, with retry_after_ms counting down from --kek-min-rotate-interval (default 1h) minus however long the server has been up. This is expected, not a bug — treat a 429 in the first hour after a fresh install as “working as designed”, not as a signal something is wrong with the install.

Verify completion. GET /status (get_kek_status) is the source of truth: rotation_complete is true when no stored secret blob still references a version older than active_version — equivalently, oldest_in_use (null when there are no secret rows at all) is >= active_version. This is the ADR-0010 §3 completion signal; don’t infer completion from a lack of errors alone — always confirm with /status after a /rotate or /rewrap call.

Concurrency. /rotate and /rewrap take a cluster-wide Postgres advisory lock (secrets_kek_op), non-blocking. A second concurrent attempt gets 409 on REST (“another KEK operation is in progress”) or a retryable MCP error with an honest retry_after_ms — that is expected behaviour under contention, not a fault. Wait for the in-flight operation to finish and retry. GET /status deliberately never takes this lock — a status poll during a long-running rotation would otherwise itself be blocked, which is the opposite of what a diagnostic endpoint is for; see “Diagnosing a stuck KEK op lock” below for how /status observes lock state without taking it.

The live-version ceiling — a second, different 409 (#2530). --kek-max-live-versions (default 32, YUZU_KEK_MAX_LIVE_VERSIONS) caps the number of non-retired KEK versions. /rotate at or above the ceiling also gets 409 — “the live KEK version ceiling has been reached” — which reads exactly like the lock-conflict 409 above but means something different and needs a different response: waiting never clears it (there is no automatic decay, and no retry_after_ms is sent for this one), and because there is no retire route (#2525), the only way past it is deliberate operator action.

Raising --kek-max-live-versions above the default is the supported escape hatch for that — do it deliberately, as a temporary risk acceptance pending #2525, not as a routine tuning knob. Setting it above the default:

--kek-max-live-versions is a per-process CLI flag, not cluster state — it is not stored anywhere the cluster shares. If several servers point at the same database, every one of them needs the raised value before the ceiling genuinely lifts fleet-wide: since secrets_kek_op serialises rotate attempts across the whole cluster, a caller can still land on an un-bumped server’s evaluation of the ceiling and get refused, even after you’ve raised it on others.

Clock-anomaly guard (#2530). The durable rate limit above depends on comparing now() to secrets.kek_meta.created_at, both read from the same Postgres statement precisely so the comparison never crosses an app-host clock against a DB-host clock. If the newest kek_meta row’s created_at is future-dated relative to that same now(), /rotate refuses immediately with a distinct 503 — “the KEK rotation clock is untrustworthy” — not a 429 cooldown, and with no retry hint at all. A 429 here would carry a retry_after_ms computed from the exact timestamp that has just been proven untrustworthy, which would be a lie rather than a hint. What to actually do: this means the database server’s own clock (or its NTP sync) is wrong — investigate that, not the KEK subsystem.

The two skew directions behave DIFFERENTLY — read this before assuming “it’ll clear itself” (#2530 G7-B6). A backward clock skew (the database server’s clock jumps or drifts BACKWARD after the row was minted) is transient and self-clearing: once the clock reads sanely again relative to the stored row, /rotate proceeds normally on the very next attempt, with nothing to reset by hand. A forward skew — the row was minted while the clock was already ahead (bad NTP source, a VM restore, a failover to a host whose clock is ahead) — is not self-clearing: the stored created_at stays in the future relative to now(), and therefore stays > now(), for the ENTIRE skew duration, blocking every /rotate attempt for as long as that lasts. Because the anomaly check runs before the cooldown and ceiling checks, this refusal has no bypass at all — no flag, no restart, no override — making an emergency re-rotation after a suspected key compromise impossible until real time catches up to the stored timestamp.

Diagnosing which direction you’re in. Both the 503 body and the server log line report the observed skew magnitude — how many seconds into the future the row is dated — specifically so you can tell “a few seconds of NTP jitter” (self-clears within moments) from “this row is dated next year” (does not self-clear on any practical timescale) at a glance, rather than having to query kek_meta by hand to find out. If the reported magnitude is small (seconds), it is almost certainly ordinary clock jitter around the now()/created_at boundary — wait a few seconds and retry. If it is large (hours, days, or more), treat it as a genuine incident:

  1. Confirm the database server’s actual wall-clock time and NTP sync status directly (not through the application).
  2. Correct the clock (or complete the VM restore / failover cutover that left it skewed).
  3. Once SELECT now() on the database server reads a sane time again, /rotate proceeds normally on the next attempt — there is nothing to reset in kek_meta by hand.

There is deliberately no flag to bypass this check even for a confirmed, large, persistent forward skew — a code-level escape hatch on a security guard is a separate design decision with its own review, not something this hardening pass adds. If a persistent forward skew blocks an emergency rotation for you in practice, file an issue describing the scenario rather than working around it by hand-editing kek_meta.created_at.

Query-cancellation classification (#2530). Any KEK rotate/rewrap/status Postgres query that is canceled or exceeds statement_timeout (SQLSTATE 57014) returns a distinct 503 — “a KEK query was canceled or exceeded its statement timeout” — instead of a generic 500. Read that phrasing literally: SQLSTATE 57014 is query_canceled, which Postgres also raises for an administrator’s pg_cancel_backend, so “timed out” alone would overclaim what actually happened, and this is not necessarily transient — don’t just retry blind. Check, in roughly this order: statement_timeout (is it configured too low for current load?), current database load, whether an administrator issued a cancel, and the size of the registered-column rewrap scan (SecretCodec::registered_columns() — today just auth.users.mfa_totp_secret; a second registered secret column makes the unbatched full-column scan a real scale risk, deliberately deferred and out of scope for this hardening pass — see the trip-wire test named in tests/unit/server/test_secret_column_registration_tripwire.cpp). If a query_canceled arrives after /rotate has already advanced the active version, it is reported as HalfCommitted, not QueryCanceled — the half-committed contract above always wins, because the operator must still be told to call /rewrap, never to retry /rotate.

Diagnosing a stuck KEK op lock (#2530)

Before this, a backend wedged holding the secrets_kek_op advisory lock made every KEK operation return 409 forever, with no way to see why and no documented remedy. GET /status (get_kek_status) now reports four fields for exactly this:

FieldMeaning
live_versionsCount of non-retired KEK versions. null if the count query could not be determined — never a fabricated 0.
lock_heldtrue iff secrets_kek_op currently has a granted holder in this server’s own database (the query is filtered by current_database(), not cluster-wide). null if the holder query could not be determined — never a fabricated false; a null here means the lock state is UNKNOWN, not “not held”.
lock_holder_pidThat holder’s Postgres backend pid. THREE possible states, not two — always read lock_held first to disambiguate a null pid: (1) lock_held: false → null, genuinely unheld; (2) lock_held: true with a non-null pid → a normal held lock, corroborate it in pg_stat_activity (step 2 below); (3) lock_held: true with a null pid → HELD, but the holder’s backend pid itself could not be read from pg_locks at query time — there is no pid to corroborate with, see step 1’s third case below. lock_held: null (query itself failed) also reports lock_holder_pid: null, but that is a fourth, entirely separate “undetermined” case — do not conflate it with case (1) or (3).
lock_holder_captured_atISO-8601 UTC instant (YYYY-MM-DDTHH:MM:SSZ) the lock_held/lock_holder_pid snapshot above was taken — null in lockstep with them when undetermined (#2530 H1). This exists so a stale reading is visible as stale rather than authoritative: the longer the gap between this timestamp and the moment you are about to act, the less you should trust that lock_holder_pid still names the same backend — Postgres reuses pids, so an old-enough reading can now point at a completely unrelated connection. Treat a lock_holder_captured_at more than a few seconds in the past as a reason to re-read /status (or, before anything irreversible, re-query pg_locks directly — see step 3) rather than act on the number in front of you.

These are lock-free diagnostic snapshots, not one coordinated read. Each is its own SELECT, taken at a possibly different instant from the others and from active_version/oldest_in_use in the same response — /status still never takes the lock itself (see Concurrency above). Never derive a “safe to retire” conclusion from any combination of them — that is precisely the #2525 hazard this surface has no retire route for; these fields add observability, not a new safety guarantee.

A null lock_held means UNKNOWN, never “not held” (#2530). If the holder query itself fails — the exact scenario a wedged Postgres backend or substrate outage produces — lock_held and live_versions come back null rather than the confident-sounding false/0 an earlier version of this surface fabricated. Treat a null lock_held as “corroborate before concluding anything”: check pg_stat_activity directly and check whether the Postgres substrate itself is degraded (see the yuzu-postgres alerts) before deciding either way.

The procedure — follow it in order, do not skip a step:

  1. Identify the holder. GET /status’s lock_holder_pid is the Postgres backend pid currently holding secrets_kek_op in this database. Three cases, not two:

    • lock_held: false — nothing to diagnose here: a 409 you’re separately seeing is either a genuinely in-flight operation about to finish, or the pid you captured earlier has already released and moved on.
    • lock_held: null — do not conclude “not held”; the holder query itself failed. Go straight to pg_stat_activity (step 2) and check Postgres substrate health before drawing any conclusion.
    • lock_held: true with lock_holder_pid: null — the lock is held, but the holder’s backend pid could not be read from pg_locks at query time, so there is no pid to plug into step 2’s WHERE pid = <lock_holder_pid>. Instead, query pg_locks directly for any granted holder of this lock in the current database and read whatever pid it reports at that moment:
      SELECT pid FROM pg_locks
       WHERE locktype = 'advisory' AND classid = 2037545589
         AND objid = (hashtext('secrets_kek_op')::bigint & 4294967295)::oid
         AND granted
         AND database = (SELECT oid FROM pg_database WHERE datname = current_database());
      If that also returns a null/no pid, retry /status once (the pid column can be transiently unreadable) before escalating to a DBA inspection of pg_stat_activity for any session in this database holding an advisory lock, since the affected session cannot be targeted by pid alone.
  2. Corroborate in pg_stat_activity — never act on the pid alone.

    SELECT pid, state, wait_event_type, wait_event, query, query_start, xact_start
      FROM pg_stat_activity
     WHERE pid = <lock_holder_pid>;

    A healthy long rewrap (a large registered-secrets scan) and a genuinely wedged backend look identical from lock_held/ lock_holder_pid alone — the difference is entirely in what pg_stat_activity shows. An actively-progressing query with a sensible, recent query_start is a rewrap doing real work. A backend sitting idle or idle in transaction while still holding the lock, or a query_start/xact_start far older than any registered-column table could plausibly take to scan, is the wedge.

  3. Only a DBA terminates it, deliberately, and only after step 2 shows a genuine wedge. Read this in full before running pg_terminate_backend on anything.

    The holder is very likely one of YOUR OWN Yuzu servers, mid-rotation — not an intruder or a hung process. The secrets_kek_op lock is held for the ENTIRE /rotate//rewrap call, and a large re-wrap (every registered secret row, one query) can legitimately take a long time — holding this lock for 30+ minutes on a big table is EXPECTED, not itself evidence of a wedge. Step 2’s pg_stat_activity check exists precisely because this gauge and these fields cannot tell “healthy and slow” from “wedged” apart on their own.

    Terminating the holder mid-rotation produces exactly the half-committed state this whole feature exists to help you avoid. If you kill a backend that is genuinely a Yuzu server partway through /rotate, the new KEK version it already minted stays active — only the row-by-row re-wrap is interrupted. Recovery is POST /api/v1/secrets/kek/rewrap (or the rewrap_secrets MCP tool). Do NOT retry /rotate to “fix” this — see the half-committed contract above; a retried /rotate mints a second, spurious KEK version on top of the mess and can never be retired (#2525), it does not undo anything. In other words: the wrong call here does not just fail safely, it creates the exact incident this runbook was written to help you diagnose and avoid.

    Before doing anything else, check whether the backend belongs to a Yuzu server, and prefer stopping that server cleanly over killing its backend:

    SELECT pid, application_name, client_addr, client_port, backend_start
      FROM pg_stat_activity
     WHERE pid = <lock_holder_pid>;

    Match client_addr/client_port against your known Yuzu server hosts (Yuzu’s pooled Postgres connections do not set application_name, so expect it blank for the lock holder — do not treat a blank application_name as evidence the holder is not a Yuzu server; only the /readyz probe’s own connection is tagged, yuzu-readyz-probe). If the pid traces to a live Yuzu server process you can reach, stop that server cleanly (its own shutdown path releases the advisory lock through the ordinary KekOpLockGuard destructor, the same clean-exit path a completed rotation takes) rather than terminating its Postgres backend out from under it mid-statement.

    If, and only if, step 2 has already shown a genuine wedge (an idle / idle in transaction backend still holding the lock, or a query_start/ xact_start implausibly old for any registered-secrets scan) and the check above could not identify a Yuzu server you can stop cleanly:

    • Re-confirm the pid is still the granted holder, at the moment you are about to act — not from the /status reading you captured earlier. lock_holder_captured_at tells you how old that reading already is, and Postgres reuses backend pids, so a pid that was correct when you first read it can now belong to an unrelated connection. Re-run the pg_locks query from step 1’s third case (or /status again) and only proceed if it still names the same pid:
      SELECT pid FROM pg_locks
       WHERE locktype = 'advisory' AND classid = 2037545589
         AND objid = (hashtext('secrets_kek_op')::bigint & 4294967295)::oid
         AND granted
         AND database = (SELECT oid FROM pg_database WHERE datname = current_database());
    • Only then does a DBA run SELECT pg_terminate_backend(<pid>); deliberately — session-scoped advisory locks die with their session, so terminating the backend releases the lock. If the target turned out to be a Yuzu server after all, be ready to call /rewrap afterward — never /rotate.

Two more staleness caveats, on top of “these are lock-free snapshots”: the pid you read can vanish (the backend has already exited normally between your /status read and your pg_stat_activity query — treat “no such pid” in step 2 as “already resolved, no action needed”, not as an error) and can be reused by an unrelated later connection (Postgres recycles backend pids). Always corroborate against pg_stat_activity in the same narrow window as the /status read that produced the pid — never a pid captured minutes earlier. lock_holder_captured_at (#2530 H1) is what makes “minutes earlier” checkable instead of assumed: it is the wall-clock instant the lock_holder_pid snapshot was taken, so before acting on a pid you can see for yourself whether it is fresh or something you should re-read before trusting.

Retirement preconditions — and why there is no retire endpoint. An old KEK version is only safe to destroy when both hold: (1) zero stored blobs reference it (GET /status’s oldest_in_use has moved past it), and (2) no backup you intend to be able to restore still contains rows wrapped under it — a restored backup needs the KEK version its rows reference, so you must retain every version referenced by any backup inside your retention window. That second condition is a policy decision only you can make; this document will not pick a number for you:

Operator decision — set your backup-retention window here: <SET-ME: e.g. "90 days" / "13 monthly backups" — however long you keep a backup you intend to be able to restore>. Retain every KEK file that covers that window. Set this too short and a restore from an older backup can be permanently undecryptable; set it (or “forever”) and you simply keep more small key files around — annoying, never unsafe.

Given that, Yuzu deliberately ships no retire/decommission route or MCP tool, even though SecretCodec::retire_kek exists internally and is tested. #2525 documents a write race that makes exposing it unsafe: SecretCodec::encrypt() snapshots the active KEK version, releases its lock, and the caller persists the resulting blob afterwards — so a retirement can pass its “zero references” check and delete the key while an in-flight write is still about to commit a blob wrapped under exactly that version, permanently bricking that row. This is not an HA-only hazard; it reproduces on a single server, and no lock this surface could take would close it, because ordinary secret writers don’t participate in the KEK operation lock. Advertising a version as “safe to retire” without a safe way to retire it would be worse than saying nothing.

Consequence for you: old KEK files accumulate, and that is correct and safe. They cost a few dozen bytes each and are exactly what you need to restore an older backup. Do not delete a KEK file by hand — there is no supported path to determine it’s truly safe to remove, and doing so risks exactly the permanent data loss #2525 describes.

Audit evidence. A successful rotation produces two audit rows by design, not a duplicate bug: a system-attributed kek.rotated (emitted inside the codec itself, detail {"kek_version": <N+1>}) and an operator-attributed kek.rotate (emitted by the REST route / MCP tool that invoked it, detail new_version=<N+1>). The codec-level event genuinely cannot know which operator called it — attribution rides the caller’s session, which only the surface that received the request has (ADR-0010, design review “arch-7”) — so an auditor correlating the audit log should expect the pair, not treat it as a double-count. rewrap/rewrap_secrets similarly audits kek.rewrap with rows_rewrapped=<n>; GET /status/get_kek_status is read-only and is not audited (as the CA metadata routes /api/v1/ca/root and /api/v1/ca/crl are not; GET /api/v1/ca/issued is audited).

Decrypt failures are counted per store and failure class as yuzu_server_secret_decrypt_failures_total{store, failure_class} (classes: tag_mismatch, kek_unresolvable, malformed_blob, crypto_failure). This is live as of the auth store’s Postgres migration — the auth store (auth.users.mfa_totp_secret, TOTP secrets) was the first secret-bearing store to ship; webhook_store (webhooks.secret, ADR-0057) joined it, so store already has more than one live value and gains a new one with each further secret-gated migration (offload_targets, then the OIDC client secret). Scope any dashboard/alert to the specific store you care about rather than assuming a single fixed value. A sustained non-zero kek_unresolvable rate after a deployment or restore is the primary backup-skew alert signal; a single-row tag_mismatch is the tamper signal and warrants investigation, not retry. Ready-made alert rules for both are in docs/prometheus/yuzu-alerts.yml (group yuzu-secrets).

KEK rotation-runaway metrics (#2530). Sampled every 15s on the same background thread that recomputes fleet health (health_recompute_thread_), never synchronously inside the /metrics handler itself — so a slow or unreachable database degrades a stale gauge value on the next scrape rather than stalling or failing the scrape during exactly the incident an operator needs it for. The sampler’s reads run inside an explicit transaction with a 500ms SET LOCAL statement_timeout (#2530 G7-B4) — not just a bounded pool acquire — because this is a SERIAL thread shared with the security-relevant agent-revocation teardown sweep (and joined before pg_pool_.reset() in stop()); an unbounded statement here previously could hold that shared thread for a long multiple of a healthy pass.

MetricTypeMeaning
yuzu_server_kek_op_lock_heldgauge1 if secrets_kek_op currently has a granted holder in this server’s own database (filtered by current_database(), not cluster-wide), else 0.
yuzu_server_kek_live_versionsgaugeCount of non-retired KEK versions. Compare against yuzu_server_kek_max_live_versions.
yuzu_server_kek_active_versiongaugeThe KEK version new secrets are currently encrypted under.
yuzu_server_kek_max_live_versionsgaugeThe configured --kek-max-live-versions ceiling (#2530 G8-S12). Unlike the three gauges above, this is a static config value set once at boot, not sampled from Postgres on the 15s sweep — it stays published even when the KEK substrate itself is never reachable. yuzu_server_kek_live_versions / yuzu_server_kek_max_live_versions is the ceiling-proximity ratio the YuzuKekCeilingApproaching alert (docs/prometheus/yuzu-alerts.yml) fires on at 0.8 — because there is no retire route (#2525), this ceiling is a lifetime cap, and without this alert the first signal is a 409 VersionCeiling at the moment rotation is needed.
yuzu_server_kek_operations_total{op,outcome}counterRotate/rewrap/status attempts. op is one of rotate|rewrap|status; outcome is one of success, conflict, cooldown, ceiling, query_canceled, clock_anomaly, half_committed, unavailable, internal. Pre-seeded to 0 for every {op,outcome} combination at boot, so an outcome that has never fired reads as a true zero, not absent.
yuzu_server_kek_metrics_unavailable_totalcounterKEK cluster-state reads that could not reach Postgres (pool acquire failure, statement_timeout, or the substrate never being wired up at all) — see “Diagnosing a stuck KEK op lock” and the YuzuKekMetricsUnavailable alert. Short-circuits after the first failed read within a sweep (#2530 G8-S2), so this is at most one increment per 15s sweep.

yuzu_server_kek_oldest_version_in_use was RETIRED from this sampler (#2530 G7-B4) — it was the one query here that is an UNBATCHED full-column scan (SecretCodec::oldest_kek_version_in_use), whose scale ceiling #2530 explicitly deferred as out of scope (see the registered-column trip-wire test). Running that scan every 15s instead of only on operator demand made the deferred problem worse, not better, so it was dropped from the periodic sweep and the gauge was deleted. GET /status’s oldest_in_use field is unaffected — it still computes this value on demand, which is where a full-column scan belongs.

On a database-read degrade during a sampling pass, every gauge above HOLDS its prior published value rather than publishing a fabricated 0 — a metric nobody could read is absent, never a false “no KEK versions” / “lock free” reading during exactly the outage you’d want this for — and yuzu_server_kek_metrics_unavailable_total (counter) is bumped instead, so a sustained increase there is itself the “these KEK gauges are stale” signal (this now also fires when the KEK substrate itself — auth_secret_codec_/ pg_pool_ — is unavailable, not only on a query-level degrade, #2530 G7-M1). A ready-made alert for a persistently-held op lock, and a second for a sustained metrics-unavailable rate, are in docs/prometheus/yuzu-alerts.yml (group yuzu-secrets) — see “Diagnosing a stuck KEK op lock” above before acting on the first one.

yuzu_server_kek_operations_total’s {op,outcome} accumulator is published even when the KEK substrate is unavailable (#2530 G7-M1). An earlier cut of this pass published it only from inside the same guard that gates the Postgres-backed gauges above — which is the EXACT condition under which every KEK operation records outcome="unavailable", so the counter went dark precisely when the outcome it exists to show was firing. It is a pure in-process accumulator read (no DB access), so it now publishes unconditionally on every sampling pass.

DR restore-pairing drill

Run this periodically against a scratch environment, alongside the PostgreSQL restore-verification drill in Backing up PostgreSQL state above.

This section is the source of truth for the rotation side of key management. The recovery side — per-symptom boot triage, the paired backup commands, and the post-restore verification that catches a wrong-keys restore before users are locked out — lives in docs/ops-runbooks/auth-db-recovery.md. Read both before running the drill; they are deliberately not duplicated.

Break-glass (KEK permanently lost). KEK loss is painful, never a total lockout: admin sign-in survives by design (MFA recovery codes are verify-only hashes and need no KEK — sign in with a recovery code and re-enroll TOTP), and every gated secret class is re-enrollable/re-issuable (webhook secrets re-issued, offload credentials re-issued, OIDC client secret re-pasted). The explicit voided-secrets boot flag described in ADR-0010 ships with the first secret-bearing store migration.


NVD CVE sync

The server maintains a local mirror of the NVD (National Vulnerability Database) CVE catalog, used by the fleet-topology vulnerability overlay. On first boot it runs a newest-first backfill of the full catalog, then switches to periodic freshness re-checks. Configuration is via CLI flags at startup (each has an env-var equivalent).

CLI FlagEnvDefaultDescription
--nvd-api-keyYUZU_NVD_API_KEY(none)NVD API key. Raises the NVD rate limit substantially — the difference between the initial backfill taking minutes versus hours.
--nvd-proxyYUZU_NVD_PROXY(none)HTTP proxy for egress to services.nvd.nist.gov, for deployments with restricted outbound network access. Accepted forms: host:port, http://host:port, https://host:port (the scheme is case-insensitive and only the host and port are used: the proxy is always contacted over plain HTTP; a port outside 1 to 65535, or not a plain number, falls back to 8080). user:password@ in the URL is ignored: the client does not authenticate to the proxy, so a proxy that requires authentication answers 407 and the sync fails. A value with no usable host (a scheme other than http/https such as socks5://, an empty host, or a ? or # ahead of the credentials) is refused: the server logs an error at startup and every NVD fetch fails as a connection failure, shown in GET /api/nvd/status as last_error and counted in yuzu_nvd_sync_failures_total{reason="connection"}. It never falls back to a direct connection. Prefer YUZU_NVD_PROXY over the flag, since a flag value is visible in the process list.
--nvd-sync-intervalYUZU_NVD_SYNC_INTERVAL4Freshness re-check cadence, in hours, once the backfill has completed.
--nvd-backfill-yearsYUZU_NVD_BACKFILL_YEARS8How far back (in years) the newest-first backfill walks. 0 = full history. The floor is clamped to NVD’s catalog start (1999-01-01) and to a 200-year effective maximum, so no value reaches before the catalog begins.
--no-nvd-syncYUZU_NO_NVD_SYNCoffDisable NVD sync entirely.

Note: the initial backfill makes sustained HTTPS requests to services.nvd.nist.gov and grows the local NVD database to hundreds of MB. Without an API key it can take hours. The backfill is resumable — after a restart it resumes from where it left off rather than starting over. Sync progress is observable via GET /api/nvd/status (backfill_complete, backfill_oldest_published, total_cves).

Rate-limit and auth-error handling: HTTP 429 responses are backed off automatically (honouring Retry-After, else exponential to a 30-minute cap) and the same page is retried — expect NVD HTTP 429 … backing off warnings during a large backfill without an API key; this is expected, not a failure. HTTP 403 means a bad or revoked --nvd-api-key: it is logged distinctly and NOT retried — check/rotate the key. Both surface via yuzu_nvd_sync_failures_total{reason=...} (see the metrics reference).


Retention Settings

The server applies retention policies to stored data to manage disk usage. Retention values are set via CLI flags at startup.

Data TypeCLI FlagDefault TTLDescription
Instruction responses--response-retention-days90 daysResults from executed instructions. Older responses are purged on a daily schedule.
Audit log entries--audit-retention-days365 daysRecords of who did what, when, and on which devices.
Guardian (Guaranteed State) events--guardian-event-retention-days30 daysGuaranteed State drift events, remediation events, and agent-sync events written by the Guardian engine. See Guaranteed State for the feature context.

Increasing a TTL preserves more history for compliance. Reducing one does not reclaim disk retroactively — and that is true of all three stores, not just the audit log. Each stamps ttl_expires_at once, at INSERT, from the retention setting in force at the time (AuditStore::log, ResponseStore::store, GuaranteedStateStore::compute_ttl_epoch), and nothing ever rewrites it, so existing rows always age out on their original TTLs. Only rows written after the change get the shorter window.

What is specific to the audit log is how the expired rows then leave: see the note below.

Audit retention is a floor, not a ceiling. A cleanup pass declines once when it would expire every datable row, and every accepted pass is capped at 25,000 rows, so deletion is paced rather than immediate.

One side effect of the shared insert-stamped-TTL scheme is specific to this guard and worth knowing: the guard’s “is any datable row still alive?” horizon is derived from the CURRENT window, so after a reduction the older long-TTL rows fall outside it and stop counting as survivors. That makes a single declined pass more likely right after the change. It is self-healing — see The retention clock guard.

Note: All three retention values can also be set via environment variables (YUZU_RESPONSE_RETENTION_DAYS, YUZU_AUDIT_RETENTION_DAYS, YUZU_GUARDIAN_EVENT_RETENTION_DAYS). PUT /api/config/<key> (with Infrastructure:Write; audit_retention_days also needs an administrator account, see REST API) persists a value via RuntimeConfigStore and /api/config then reports it — but each running store copied its retention value at construction and does not re-read it, so a stored value is not the window being enforced. Change a window with the startup option or environment variable and restart. This limitation is shared across all three retention keys and is tracked as issue #483. The Data and retention page (Settings, System group) shows the enforced value and flags a stored value that is not in force.

Approval requests are not pruned, and a bound request is stored in clear text (#6062)

None of the settings above applies to approval tickets. The approvals table has no retention pass today and nothing deletes a row, so every approval ever submitted is kept (growth is tracked in #3728). Each row records who submitted, reviewed and consumed a ticket, which is why it is kept rather than aged out.

From #6062 an approval row can also carry a bound request: the exact effect the reviewer was shown, frozen at submission. It is stored in the request_json column as clear TEXT, up to 16 KiB, with its SHA-256 in request_digest and the barred reviewers in excluded_roots. Treat request_json as follows:

No feature creates a bound request in this release, so no request_json is stored yet; this is the contract for the change that does.


Settings API Reference

All Settings page operations are backed by REST API routes. These can be called directly for automation.

Authentication

All API routes require a valid session cookie (obtained via POST /login) or, when available, a bearer token. Admin-role sessions are required for all write operations.

TLS

MethodRouteDescription
GET/fragments/settings/tlsRender the TLS configuration fragment (HTMX).
POST/api/settings/tlsChange the in-memory mTLS value this server reports (not saved; start-up flags decide).
POST/api/settings/cert-uploadUpload PEM certificate and key files (multipart form).
POST/api/settings/cert-pastePaste PEM certificate content (form-encoded).

OIDC / Directory

MethodRouteDescription
GET/fragments/settings/directoryRender the OIDC/Directory configuration fragment (HTMX).
POST/api/settings/oidcSave OIDC/Entra ID configuration (form-encoded). Persisted to runtime config.
POST/api/settings/oidc/testTest OIDC discovery for the form’s issuer: fetches and validates the discovery document, then fetches the jwks_uri and counts the signing keys. It does not exercise the token endpoint, client secret, redirect URI, audience or group claims, and it does not read the running provider’s discovery state, so it is not a guarantee of what a sign-in will hit (see Authentication).

Users

MethodRouteDescription
GET/fragments/settings/usersRender the user management fragment (HTMX).
POST/api/settings/usersCreate a new user. Body: { "username", "password", "role" }.
DELETE/api/settings/users/{username}Delete a user by username.

Enrollment Tokens

MethodRouteDescription
GET/fragments/settings/tokensRender the enrollment tokens fragment (HTMX); also the Enrolment tokens panel of /groups. Sent with Cache-Control: no-store, private and Vary: Cookie.
POST/api/settings/enrollment-tokensGenerate a new enrollment token. Form fields: label, max_uses (empty = 100, a literal 0 = unlimited), ttl_hours (empty = 72 hours, a literal 0 = never). A malformed or negative number is a 400 and nothing is minted. The response is the token-list fragment with the raw token shown once; it carries Cache-Control: no-store, private.
POST/api/settings/enrollment-tokens/batchGenerate multiple tokens in one call. Body: { "label", "count", "max_uses", "ttl_hours" }. Defaults when a field is empty or omitted: count 10 (allowed 1 to 10000), max_uses 1, ttl_hours 72 hours (a literal 0 = no expiry). Each number may be a JSON integer or a numeric string. A malformed, fractional, boolean, null or negative number, one outside the 32-bit range, a count outside 1 to 10000, or a body that is not one JSON object, is a 400 and nothing is minted. The response carries Cache-Control: no-store, private.
DELETE/api/settings/enrollment-tokens/{id}Revoke a token by ID. An unknown ID revokes nothing: the response carries a warning toast (“Token not found - nothing revoked”) and the audit row is denied.

Pending Agents

MethodRouteDescription
GET/fragments/settings/pendingRender the pending agents fragment (HTMX); also the Agents waiting for approval panel of /groups. Sent with Cache-Control: no-store, private and Vary: Cookie.
POST/api/settings/pending-agents/{id}/approveApprove a pending agent. Only a pending row is moved: 409 if the agent was already approved or denied (an approve never reverses a denial), with the refreshed list in the body and an error toast. An unknown id changes nothing and answers with a warning toast; the audit row is denied in both cases.
POST/api/settings/pending-agents/{id}/denyDeny a pending agent. Same rule as approve: 409 if the agent was already approved or denied (a deny never flips an enrolled agent).
DELETE/api/settings/pending-agents/{id}Remove a pending agent from the queue. 409 if the row is no longer pending (already approved/denied) — removal only succeeds against a genuinely pending row, by design (WS-6 6.2).

The agent-supplied agent_id (accepted at gRPC Register, not at these admin routes) may not contain an ASCII control character or a comma — both are rejected before the row is ever created, closing an audit-log injection path (WS-6 6.2). A UUID, hostname, or MAC-derived id is unaffected.

An agent id seen for the first time must also use only letters, digits, ., _, : and -, and must not consist only of dots; otherwise Register (and the gateway’s ProxyRegister) answers INVALID_ARGUMENT and no row is created. The check applies only when the enrollment store holds no row for the id: an agent that is already enrolled, pending or denied keeps registering under its existing id. The agent generates a UUID, so only an operator-chosen --agent-id / YUZU_AGENT_ID is affected. In the pending list, an id that is empty or is . or .. is shown without row actions and the approve, deny and remove routes answer 400 for it; the Approve all / Deny all buttons are shown for such a row (even when it is the only pending one), and they act on every pending agent, so review the queue first.

Auto-Approval Policies

MethodRouteDescription
GET/fragments/settings/auto-approveRender the auto-approval policies fragment (HTMX); also the Auto-approve rules panel of /groups.
POST/api/settings/auto-approveCreate a new auto-approval rule. Returns 400 and saves nothing for an unknown type, an empty value, a value over 253 or a label over 128 characters, a : or control character in value or label, a malformed ip_subnet, a trusted_ca that is not 64 hex characters, or a match-everything rule (*, /0). The four rule writes are refused with 403 when Origin/Referer names another site, and are refused with 503 if their audit row cannot be stored.
POST/api/settings/auto-approve/modeSet the match mode (any or all); any other value returns 400.
POST/api/settings/auto-approve/{index}/toggleToggle an auto-approval rule on or off. Optional form fields type, value and enabled (0 or 1) state the rule the caller saw at that position; the dashboard always sends them. If the rule now at {index} differs, there is no rule at {index} any more, or a stated type or enabled is malformed, nothing changes and the answer is 409 with the refreshed list, a warning toast and a denied audit row (stale_rule_view, or rule_not_found when the index is gone). A request that states none of them acts on the position, as before (an unknown index is a 200 no-op).
DELETE/api/settings/auto-approve/{index}Delete an auto-approval rule. Optional query parameters type and value state the rule the caller saw; same 409 rule as toggle.

Groups and enrolment page

/groups (first entry of the Estate group in the navigation) puts the three enrolment panels on one page: Agents waiting for approval, Auto-approve rules and Enrolment tokens. A fourth panel, Management groups, only points to Settings, where management groups are still managed. The panels are the Settings fragments below, served by the same routes; the page adds no endpoint of its own beyond the page and its script.

The page opens for every signed-in user and redirects to /login without a session. Each panel needs the Administrator account role, so a user without it sees “Not available to your account” and the reason in each panel instead of a list. A refused browser session writes no denied-access audit row for any of the three panels (they load automatically, and the queue reloads whenever an agent arrives, so a page view must not fill the audit log); a refused API or MCP token that reaches a fragment URL does, and so does every refused write.

MethodRouteDescription
GET/groupsThe page. Redirects to /login without a session. Sent with Cache-Control: no-store.
GET/static/yuzu-groups.jsThe page’s script (clipboard copy, the unlimited-token warning, failure toasts, the Add rule confirmation). Sent with Cache-Control: no-cache, no-store, must-revalidate.
GET/fragments/settings/tokensEnrolment tokens panel, also the Settings Enrollment Tokens panel. Administrator only. Sent with Cache-Control: no-store, private and Vary: Cookie.
GET/fragments/settings/pendingAgents waiting for approval panel, also the Settings Pending Agent Approvals panel. Administrator only. Sent with Cache-Control: no-store, private and Vary: Cookie; the page refreshes it when an agent arrives.
GET/fragments/settings/auto-approveAuto-approve rules panel, also the Settings Auto-Approve Policies panel. Administrator only.

Each list shows at most 200 rows. Tokens are listed active first, then in the store’s newest-first order; waiting agents are listed first, then denied ones. Rows past the limit are not listed. The tokens panel says “Showing 200 of N tokens”. The waiting-agents panel says “Showing 200 of N” when more than 200 agents are waiting, and, when every waiting agent is listed but denied ones are cut off, “Showing 200 of N agents. Every waiting agent is listed; the K other denied agents are not.” Approve all and Deny all act on every waiting agent, including the ones not shown and any that arrive after the page loaded, so they are disabled while more than 200 agents are waiting; approve or deny the listed agents one at a time. Their confirmation gives the total and the first ten hostnames.

A store that does not answer is shown as an unavailable panel, never as an empty list. If a panel cannot load, the page says so and offers Retry. A click that the server refuses, that fails with a server error, or that is lost on the network leaves the list as it was and shows an error message that stays until closed. A click on a row that had already changed (approve or deny on a decided agent, a rule that moved or was removed) is answered with 409: the list is replaced by the refreshed one and the server’s message is shown.

API Tokens

MethodRouteDescription
GET/fragments/settings/api-tokensRender the API tokens management fragment (HTMX).
POST/api/settings/api-tokensCreate a new API bearer token. Form fields: name, ttl_hours, mcp_tier; an MCP-tier token needs a TTL of at most 2160 hours. MFA step-up gated, as POST /api/v1/tokens is.
DELETE/api/settings/api-tokens/{id}Revoke an API token by ID. MFA step-up gated, as DELETE /api/v1/tokens/{id} is.

Tokens and principals page

/access/tokens (Access group in the navigation) lists and manages API tokens and engine principals. The page is open to every signed-in user; each panel is gated by its own fragment, and a panel that is not available to your account says so instead of rendering empty. A control is drawn only when your account can use it; a panel that hides one says what it needs (“Creating tokens needs an Administrator browser session”, or the named permission when RBAC is enforced).

MethodRouteDescription
GET/access/tokensThe page. Redirects to /login without a session.
GET/fragments/access/tokens/listAPI token list (HTMX). ?state=active (default) or all. Gated on ApiToken:Read. Administrators on a cookie session see every owner; every other caller, including an administrator’s API or MCP token, sees their own tokens only, as GET /api/v1/tokens does. Capped at 500 rows with a banner.
GET/fragments/access/principals/listEngine principal list (HTMX). Gated on EnginePrincipal:Read, as GET /api/v1/engine-principals; engine and MCP-tier credentials are refused. Writes an engine_principal.list audit row, plus engine_principal.role.listed when the Roles column was read.
GET/fragments/access/principals/detailOne principal with its active credentials and roles (HTMX). ?id=engine:<slug>. Same gate. Writes an engine_principal.get audit row, plus engine_principal.role.listed when the roles were read. A revoked principal’s revocation detail needs AuditLog:Read and is otherwise shown as withheld (also when the grant could not be confirmed). The custom roles offered by Assign are read only for a caller who also holds UserManagement:Read, the permission GET /api/v1/rbac/roles needs; without it the role list is withheld, no role name is drawn and Assign is disabled with that reason.

Each panel loads on its own. A refused browser session writes no denied-access audit row for any of them (the panels load automatically, so opening the page must not fill the audit log); a refused API or MCP token that reaches a fragment URL does. The no-administrator check at the top of the principals panel (a call to /api/v1/engine-principals/audit/no-admin, which needs AuditLog:Read) runs only when the principals fragment reports that the caller holds that permission. The AuditLog:Read checks that decide this (and whether a revoked principal’s free-text detail is shown) are quiet for browser sessions only: a non-browser caller that holds EnginePrincipal:Read but not AuditLog:Read writes one ordinary permission-denied row per principals-list load and per revoked-principal detail load. The no-administrator result is a statement about the roles and grants the server could read: a role read that degrades is not distinguishable from “no roles” on that route, so the page words a clean result accordingly. Device tokens are not shown on this page.

Changing credentials from the page

Every change is a call to an existing REST v1 route, so each keeps its own step-up, validation and audit row; the page adds no route. An account with a second factor enrolled is asked for a fresh code before a token or principal change.

ActionRouteNeeds
New API tokenPOST /api/v1/tokensApiToken:Write (RBAC off: administrator)
Rotate / Confirm rotationPOST /api/v1/tokens/{id}/rotate, /confirmApiToken:Rotate; the token’s owner only (RBAC off: any signed-in owner)
Revoke API tokenDELETE /api/v1/tokens/{id}ApiToken:Delete and the token’s owner or an administrator (RBAC off: administrator)
New principal, mint / rotate / confirm credential, transfer owner, revoke principal, assign / remove role/api/v1/engine-principals/...Security:Write (RBAC off: administrator)

A token or engine credential with 24 hours or less of life left cannot be rotated (the button says so; revoke it and create or mint a new one); the rotate dialog offers only overlap periods that end before the old token expires, and defaults to the longest of them up to 7 days. A new secret (API token, rotated token, engine credential) is shown once in a strip at the top of the page. The strip has no timer: it stays until you press “I have stored this”, and the page will not mint another secret while one is showing, so a secret that has not been stored is never overwritten or discarded on a clock. Leaving the page with an unstored secret asks first. The secret is never put in a URL, the page history, browser storage or a later fragment. The responses that carry a secret (POST /api/v1/tokens, and the rotate and engine mint routes) are sent Cache-Control: no-store. If the response is lost, rotating again within two minutes returns the same secret; after that, revoke the new token or credential and rotate again.

Assigning a role to an engine principal offers custom roles only: built-in roles are refused for an engine principal, and no route creates a custom role, so on a stock install the button is disabled with that reason. Remove is disabled, with a fixed reason, for a role whose name holds a slash or is . or .., because the remove route cannot address it.

Audit and reviews page

/access/audit (Access group in the navigation) shows the audit log, an authentication sample, the audit retention period and the access reviews. The page itself changes nothing: the review controls call the existing /api/v1/access-reviews routes. The page is open to every signed-in user; each panel is gated by its own fragment, and a panel that is not available to your account says so instead of rendering empty. See Audit Log for what each panel shows.

MethodRouteDescription
GET/access/auditThe page. Redirects to /login without a session.
GET/fragments/access/audit/logAudit log panel (HTMX). Gated on AuditLog:Read. Query: range (1h, 24h or 7d), principal, action (exact), offset (0 to 10000). Fixed 50-row pages; limit is never read. Writes no audit row.
GET/fragments/access/audit/sampleAuthentication sample (HTMX). Gated on AuditLog:Read. Query: limit (1 to 1000). Requested only by the Draw sample button; writes an audit.auth_sample.exported row.
GET/fragments/access/audit/retentionRetention panel (HTMX). Gated on ServerConfig:Read. Requested only by the Show retention button.
GET/fragments/access/reviews/campaignsReview campaign list (HTMX). Gated on AccessReview:Read. Writes access_review.list.
GET/fragments/access/reviews/campaignOne campaign’s frozen grants and summary (HTMX). ?id=<campaign_id>. Gated on AccessReview:Read. Writes access_review.get.
GET/fragments/access/reviews/grantsCurrent grant population (HTMX), requested only by Show current grants. Gated on AccessReview:Read. Writes access_review.exported with format=view.

A request the page cannot honour (an unknown range, an offset past 10000, a NUL byte in a filter, a bad sample size) is answered with status 200 and a short note, because htmx discards 4xx and 5xx bodies; nothing is read from the store.

Which refusals leave a denied-access audit row differs by panel, because some panels load themselves and some wait for a click. The campaign list loads on every page view, so a refused browser session writes no denied-access row for it (opening the page must not fill the audit log). Opening a campaign, including by the ?campaign=<id> link (which loads that campaign when the page opens), and reading the current grants are deliberate requests: a refused caller writes the ordinary permission-denied row. A refused API or MCP token that reaches any of the three fragment URLs writes one every time. An engine-class session is refused with 403 on all three fragments and leaves an access_review.list, access_review.get or access_review.exported row with result=denied and detail=engine_session_denied, as the REST twins do.

OTA Updates

MethodRouteDescription
GET/fragments/settings/updatesRender the OTA updates fragment (HTMX).
POST/api/settings/updates/uploadUpload an agent binary (multipart form). Parts: file (the binary, required), signature (an optional detached PEM CMS signature, ≤64 KB), platform, arch, rollout_pct, mandatory. Returns 400 if the signature is not a PEM CMS block, and 500 if the signature could not be stored, the binary could not be written, or the binary could not be published. Treat any non-2xx as “retry”, never as “uploaded”. How much changed depends on where it failed: a 400, or a 500 storing the signature, changes nothing at all. A 500 writing or publishing the binary leaves the previous binary in place, and — on a signed upload — the new signature already stored beside it, so agents refuse that package until you retry successfully. The upload order guarantees only that a failure never leaves the served binary UNPROTECTED; it does not guarantee the pair is consistent. After a 500 on a signed upload, check the Signed column: signature mismatch is the expected state, and re-uploading clears it. An unsigned upload that fails on the binary changes nothing, so the column is unchanged. Omitting signature on a re-upload removes any existing signature for that package.
DELETE/api/settings/updates/{platform}/{arch}/{version}Delete an uploaded agent binary and its signature sidecar.
POST/api/settings/updates/{platform}/{arch}/{version}/rolloutPromote a version to production rollout.

Tag Compliance

MethodRouteDescription
GET/fragments/settings/tag-complianceRender the tag compliance table fragment (HTMX); loaded by the Compliance page.
GET/api/v1/tag-complianceTag compliance summary (JSON, via REST API v1).

DEX Alerts

DEX alerting is configured on the DEX page, Alerts tab (it moved out of Settings). The tab routes individual DEX signal types to operator notifications and the dex.signal webhook, tunes the fleet blast-radius thresholds (min devices / window / cooldown, clamped to their ranges on save) and sets the per-cohort Prometheus gauge export tag key (saving it asks for confirmation because the key’s tag values become /metrics labels). Changes apply live and are audit-logged. A non-administrator who opens the tab sees a role note instead of the settings. See the user-manual DEX -> Routing signals to alerts and DEX -> Fleet performance rollup sections.

MethodRouteDescription
GET/fragments/dex/alertsThe Alerts tab: DEX sub-nav, role note and the configuration form (HTMX). Any signed-in viewer may load it; the form renders for administrators only. Composed of the settings fragment below.
GET/fragments/settings/dex-alertsRender the DEX alerts configuration form body only (HTMX). Admin-only.
POST/api/settings/dex-alerts/routingUpdate the routed signal types. Body: form-encoded types=<obs_type> repeated per checked type; values are allow-listed against the signal catalogue. Persisted to runtime_config key dex_alert_routing (sorted JSON array). Applied live. Audit: settings.dex_alerts.routing (detail records the full routed set).
POST/api/settings/dex-alerts/blastUpdate the blast-radius thresholds. Body: min_devices, window_seconds, cooldown_seconds (a malformed value is 400 and nothing is saved; a well-formed integer is clamped server-side to [2,100000] / [60,86400] / [0,604800]). Persisted to the dex_blast_* keys. Applied live. Audit: settings.dex_alerts.blast.
POST/api/settings/dex-alerts/cohort-exportSet (or clear) the cohort metrics export tag key. Body: export_key (tag-key alphabet [A-Za-z0-9_.:-], max 64; empty disables — the default). When set, the per-cohort yuzu_fleet_perf_cohort_* Prometheus gauges are published for that key’s cohorts (top 50 by population, the one-device floor — an empty cohort is withheld —, yuzu_fleet_perf_cohort_clipped makes capping visible). Persisted to dex_cohort_export_key. Applied on the next gauge sweep. Audit: settings.dex_alerts.cohort_export.

New runtime_config keys. All are runtime-set via the DEX Alerts tab and applied live (and re-applied at boot):

KeyTypeDefaultDescription
dex_alert_routingJSON array string[]DEX obs_type strings routed to operator notifications + the dex.signal webhook. Empty = nothing routed.
dex_blast_min_devicesinteger string5Blast-radius minimum distinct-device threshold. Clamped [2, 100000].
dex_blast_window_secondsinteger string900Blast-radius detection window (seconds). Clamped [60, 86400].
dex_blast_cooldown_secondsinteger string3600Blast-radius per-incident re-alert cooldown (seconds). Clamped [0, 604800].
dex_cohort_export_keytag-key string(empty)Tag key whose cohorts export as yuzu_fleet_perf_cohort_* Prometheus gauges. Empty = export disabled. Invalid stored values disable the export (fail closed).

New audit actions.

ActionEmitted when
settings.dex_alerts.routingAn admin changes the routed signal-type list. Detail records the full new routed set (the runtime-config store keeps no history, so this row is the change-management evidence).
settings.dex_alerts.blastAn admin changes the blast-radius thresholds (detail records the new min/window/cooldown).
settings.dex_alerts.cohort_exportAn admin sets or clears the cohort metrics export tag key (detail records the new key, or “export disabled”).
dex.device.perf.queryAn operator loads a device performance sparkline panel (DEX device drill-down). Execute-gated; detail records the target agent and command id.
dex.device.procperf.queryAn operator loads a device’s per-application panel (usage-class telemetry — deliberately a separate verb from the machine-health dex.device.perf.query so usage reads stay separately countable). Execute-gated; detail records the target agent and command id.

Home (sign-in landing page)

GET /home is the landing and sign-in page: the first page a visitor sees, with no sidebar and no header bar because there may be no session yet. Top to bottom it shows a slim bar (the Yuzu mark; the server’s host name and the running version are added only once signed in, because the page is reachable without a session), and a headline with the sign-in panel beside it. Signed out, that is all the page shows. Once signed in, the fifteen screens of the product appear below as tiles in five families (Operate, Posture, Estate, Access, System), each saying what the area does. The page fetches no data. It is reached from the Yuzu logo on every other page; it has no sidebar entry, and / stays the Console.

Signing in. The panel carries the one sign-in form: username and password, a “Sign in with single sign-on” link when an identity provider (OIDC or SAML) is configured, and a note about two-factor codes when --mfa-enforcement is admin-only or required. The form posts to POST /login and, where applicable, POST /login/mfa or POST /login/mfa/enroll, exactly as before; GET /login now redirects to /home, so bookmarks and the SSO error bounces keep working. After signing in the user stays on /home: the panel is replaced by a signed-in panel showing the display name and role, an “Open Console” button, a “Sign out” button, and the line “You are signed in. Open the Console, or pick an area below.” A signed-in user who opens /home (or clicks the logo) sees that panel straight away. Sign out returns to the form.

Opening a screen without a session. Any page requested without a session is redirected to /home?next=<key>, where <key> names the requested screen (devices, compliance, … one of the fifteen tiles; a sub-page such as /approvals counts as its parent, Instructions). The page shows “Sign in to open .” above the form and, after sign-in, labels the primary button “Open ” instead of “Open Console”. next is only ever one of those keys: a path, a URL or an unknown value is ignored, so it cannot redirect anywhere, and the server never redirects automatically after sign-in.

MethodRouteDescription
GET/homeThe landing and sign-in page. No session needed: a signed-out visitor gets the sign-in form and headline, with no tiles (with ?next=<screen key> driving the “Sign in to open .” notice), a signed-in one the signed-in panel. Never cached.
GET/loginRedirects (302) to /home, forwarding a validated next key and a known error code (sso_denied, sso_failed, sso_invalid, saml) and nothing else.

Dashboard panels

The Dashboard (/) carries, under the console, a four-cell readout (devices online, policy checks passing, failed executions in the last 24 hours, approvals waiting), a Needs attention list beside Server health, a Recent events table, and Estate state beside Compliance by policy. Each is an HTMX fragment loaded once per page view; none of them polls (the console’s own scope picker still refreshes every 5 seconds, as before). The page header (shared by every page) does poll in the background: the online count and approvals badge every 30 seconds and the notifications bell every 60 seconds while the tab is visible. A figure the account is refused is decided with a non-auditing check (the same decision as the gate, with its auth.permission_required and auth.fleet_read_required rows withheld), so rendering the Dashboard writes no denial rows; the Recent events panel also checks the administrator role first. That includes a direct request to a panel URL by a refused interactive sign-in, and a store outage met by one on those panels (the 503 and the degrade counter remain); an API, MCP or engine token that reaches a panel URL still leaves its denial row. The bell and the approvals badge keep the auditing gate on their own requests: an account refused either leaves one auth.permission_required row per page view, because each stops polling after the first 403. When the live registry knows more connected agents than the device store counts as live (for example after failed presence writes or during start-up lag), the Estate figures read “at least N” instead of understating the fleet or claiming no devices; if it persists, check the database and the server log for presence-write failures. The devices figure for an unrestricted account comes from one database count that costs seconds on a large fleet, so each server process reuses it for 60 seconds; a reload inside that minute shows the same device numbers, and the “As of” time on the panels is the time of the view, so the online count can be about a minute (plus the registry’s 3 second presence cache) older than it. If that count cannot be read, devices show “could not read” (never 0) for the next 5 seconds of views without trying again. The cache is per server process: with several replicas behind a load balancer each reads and caches on its own, so two views can differ by up to a minute, and the database sees one such count per replica per minute while reads succeed (one attempt per replica every 5 seconds while the database cannot answer). When the live registry’s online count cannot be read (it is unavailable past about 100,000 presence rows, and on a presence-store error) the online number is the database presence count instead, which leaves out an agent that has connected but not yet been written to the presence store. Accounts limited to a group of devices are never served this cached count.

Every figure is a typed read of an existing source and keeps its own gate. A figure that cannot be shown says why (no access, limited to a group of devices, could not be read) and is never rendered as 0. “Nothing needs attention” appears only when every contributing figure was read successfully. The enrolment row is shown to administrators only. The design’s remaining Dashboard rows (a ranked “devices to look at first” list, events by severity, command throughput and activity by hour) have no data source on the server and are not drawn rather than filled with sample figures; the scope bar’s reference line lists the scope kinds from the same catalogue as GET /api/v1/discover/scope-kinds.

MethodRouteDescription
GET/fragments/dashboard/summaryThe readout, Needs attention and Estate state panels (HTMX). Per-figure gates: Inventory:Read, Policy:Read, Execution:Read, Approval:Read. Answers 200 for every signed-in caller: a refused or degraded figure becomes a panel note, never an error body. A service-scoped token is refused first with 403 (audit action dashboard.fragment.access_denied); a request with no session gets 401. Accounts limited to a group of devices see device figures narrowed to their group, read from the newest 100,000 devices and shown as “at least N” when that read is full, and a “limited” note for fleet-wide compliance and failed executions. Estate state is the same device figure as the readout (seen in 30 days, online, offline, not seen for 2 days or more).
GET/fragments/dashboard/policiesCompliance by policy (HTMX): the five enabled policies with the lowest pass rate and “Lowest 5 of N policies”, where N counts the first 100 enabled policies. A policy with no results yet shows “No results yet”, never 0%. Gate: Policy:Read; accounts limited to a group of devices see a “limited” note because the per-policy counts are fleet-wide. A refused or degraded read, and a service-scoped token (audit action dashboard.fragment.access_denied), become a panel note with status 200; a request with no session gets 401.
GET/fragments/dashboard/activityRecent events (HTMX): the eight newest audit rows as a five-column table (time, who, action, target, severity). The severity column is the audit row’s own result (success, failure, denied) and nothing more. Shown to administrators only, and also gated by AuditLog:Read; other accounts see a note and no denial is written for them. The detail, source address, user agent and session of an event are never shown. The panel is not narrowed to a device group: an administrator limited to a group who holds AuditLog:Read sees what GET /api/audit returns. The “Audit log” button appears only when the navigation has an audit item. Notes answer 200; a request with no session gets 401.
GET/fragments/dashboard/healthServer health (HTMX): overall status, database reachability, uptime, version, agents connected to this server and, for an account with unscoped Execution:Read, executions running. A signed-in session is required; a service-scoped token gets a note first. Notes answer 200; a request with no session gets 401.

Agent OTA pull bounds

Runbook for the yuzu-ota alert group. The agent OTA path (DownloadUpdate) is bounded on four axes: a per-peer concurrency semaphore, a per-peer token bucket, a server-wide transfer ceiling, and two deadlines.

Admission keying, and why it matters operationally. A peer is bucketed by its certificate identity where it presents one, and by source IP where it does not. Check yuzu_ota_admission_key_mode_total to see which mode your fleet is in. The IP fallback has two consequences worth planning for:

Metrics to watch, and what each one means. These are the raw signals; the yuzu-ota alert group in docs/prometheus/yuzu-alerts.yml pages on them, and each alert’s own annotations record what its threshold was chosen against and where it is known to be blind. Chart these directly if you want to set thresholds of your own — the shipped ones are reasoned starting points, not fleet-validated numbers.

MetricWhat a rising value meansFirst action
yuzu_ota_download_admission_total{decision="rejected_concurrency"}A peer is repeatedly refused for holding too many parallel transfers.Find the peer in the server log — the rejection line carries key= and mode=. That line is sampled 1-in-100 (the first always logs, then every hundredth, on a counter shared with the server-wide rejections), so a short burst may leave one line or none; the counter is the complete record. There is deliberately no audit row for admission rejections.
yuzu_ota_download_admission_total{decision="rejected_total"}The server-wide ceiling is saturated, so agents are refused regardless of their own per-peer budget.Two very different causes look identical here; the rejection log’s cert_keyed= field separates them — but that line is sampled 1-in-100, so correlate with yuzu_ota_admission_key_mode_total when the log is thin. All-true means a genuine rollout wants more concurrency: raise --ota-max-concurrent-total, sizing roughly as devices x transfer_seconds / rollout_seconds. false concentrated on a few addresses is a denial attempt against the shared ceiling, which --ota-cert-reserve-pct exists to bound.
yuzu_grpc_ota_identity_rejected_total{reason}A peer presented no usable certificate, one from a foreign CA, or an agent_id that does not match it.Check the reason label first — this counter is complete. Then the session.ota_identity_rejected audit rows for attribution, bearing in mind two reasons they under-count: no_client_identity is metric-only and writes none, and the row is rate-limited to ~2/s per (peer, RPC, reason). yuzu_ota_identity_audit_suppressed_total counts what was dropped. Never read row volume as rejection volume.
yuzu_ota_download_deadline_exceeded_total{phase}Transfers are hitting a deadline. This is the silent-outage signal: every aborted transfer refunds its rate token, so no bucket drains and every other dashboard stays green while the fleet stops updating.Check the phase label. transfer usually means --ota-transfer-deadline-secs is below artifact_size / link_throughput for that fleet — raise it. Watch it as a PROPORTION of {decision="admitted"}, not as a count: agents check every 6 hours, so a small fleet failing completely produces a low absolute rate.
yuzu_ota_download_peers_evicted_total--ota-max-peers-tracked is at or below the live key count, which silently disables the rate dimension — an evicted key is re-minted with a full burst.Raise it above your expected distinct-key count.
yuzu_ota_download_peers_tracked / yuzu_ota_download_peers_capacityThe admission map is approaching its ceiling.Same action. Note the map never shrinks in-process, so this stays high until restart.
yuzu_ota_download_refund_total vs yuzu_ota_download_deadline_exceeded_totalDeadline aborts outrunning refunds is a regression in the refund wiring, not a tuning problem.Check the deadline branches in agent_service_impl.cpp.

Alert responses.

AlertWhat it meansFirst action
YuzuOtaConcurrencyRejectionsA peer is repeatedly refused for holding too many parallel transfers.Find the peer in the server log — the rejection line carries key= and mode=. That line is sampled 1-in-100 (the first always logs, then every hundredth, on a counter shared with the server-wide rejections), so a short burst may leave one line or none; the counter is the complete record and the log is for attribution only. There is deliberately no audit row for admission rejections.
YuzuOtaServerCapacityRejectionsThe server-wide ceiling is saturated, so agents are refused regardless of their own per-peer budget.Two very different causes look identical in the metric. The rejection log’s cert_keyed= field is the discriminator — but read it carefully: cert_keyed means “presented a certificate this listener accepted”, not “is an enrolled Yuzu agent” (see #3828). On a multi-CA trust bundle a peer holding a certificate from any other CA in that bundle also reads as cert_keyed=true, so it does not by itself distinguish a rollout from an attack. That line is also sampled 1-in-100, so correlate with yuzu_ota_admission_key_mode_total when it is thin. All-true on an enrolled-only trust root means a genuine rollout wants more concurrency: raise --ota-max-concurrent-total, sizing roughly as devices x transfer_seconds / rollout_seconds. false concentrated on a few addresses is a denial attempt against the shared ceiling, which --ota-cert-reserve-pct exists to bound.
YuzuOtaIdentityRejectionsA peer presented no usable certificate, one from a foreign CA, or an agent_id that does not match it.Check the reason label first — this counter is complete. Then the session.ota_identity_rejected audit rows for attribution, bearing in mind two reasons they under-count: no_client_identity is metric-only and writes none, and the row is rate-limited to ~2/s per (peer, RPC, reason). yuzu_ota_identity_audit_suppressed_total counts what was dropped. Never read row volume as rejection volume.
YuzuOtaTransfersAbortingTransfers are hitting a deadline. This is the silent-outage detector: every aborted transfer refunds its rate token, so no bucket drains and every other dashboard stays green while the fleet stops updating.Check the phase label. transfer usually means --ota-transfer-deadline-secs is below artifact_size / link_throughput for that fleet — raise it. The rule fires on a PROPORTION of transfers aborting, not a count, because agents check only every six hours and any fixed count goes blind below some fleet size. Its own annotation records the residual blind spot: a fleet where under ~10% of devices fail permanently sits below the bar.
YuzuOtaPeerMapEvicting--ota-max-peers-tracked is at or below the live key count, which silently disables the rate dimension — an evicted key is re-minted with a full burst.Raise it above your expected distinct-key count. Note the floor: values below 1024 are raised at startup, so the effective ceiling may not be what you set.
YuzuOtaPeerMapNearCapacityThe admission map is above 80% of its ceiling.Same action. The map never shrinks in-process, so this stays firing until restart.
YuzuOtaRefundDivergenceDeadline aborts are outrunning refunds — a regression in the refund wiring, not a tuning problem.Check the deadline branches in agent_service_impl.cpp. The rule detects a dropped call site, not a refund that fails inside PrincipalQuota::refund.

Known limitation — keep OTA artifacts on local storage. The per-chunk read of the update binary is blocking and cannot be interrupted; the transfer watchdog cancels the RPC but cannot unpark a thread waiting on the kernel. If the artifact lives on a network mount that stalls, the handler never returns, and the peer whose pull wedged holds one of its concurrency slots until the server restarts — so that peer is refused RESOURCE_EXHAUSTED on every later pull. At --ota-max-concurrent-per-peer=1 that is permanent for that peer. The blast radius is bounded to that peer and to one share of --ota-max-concurrent-total, but there is no in-process recovery.

Known limitation — per-process, not fleet-wide. All of these bounds live in one server process’s memory. Behind a load balancer with N replicas the effective ceiling is configured_cap x N, and a peer that reconnects to a different replica gets a fresh allowance.

Response export bounds

Runbook for the response routes’ export limits (#4644, #4703). GET /api/responses/{id}/export and GET /api/v1/responses/{id}/export serve at most 10,000 rows and at most 50 MiB of row payload (output plus error_detail); neither bound is configurable.

Detecting a cut export. Watch yuzu_server_response_export_truncated_total{surface,cause}: cause="row_cap" means more rows matched than the limit, cause="byte_cap" means the payload cap cut the export (preferred when both applied). A byte-cap cut also logs one warning, at most one a minute per process, naming the surface and (on v1) the request’s correlation id. For a single download, the signals are the trailer record that ends a cut CSV (# result_truncated_by_cap cause=...), the result_truncated_by_cap field (JSON; under pagination on v1), the X-Result-Truncated-By-Cap: true header (CSV) and a responses-<id>-truncated download name. A plain curl -o out.csv ... loses the header and the name but not the trailer; curl -sS -D - -o out.csv ... prints the headers. The byte-cap warning is rate-limited per surface (legacy rest and rest_v1 each get their own one-a-minute allowance).

What the counters do not cover. yuzu_server_response_export_truncated_total counts the two export routes only. A cut on the execution visualization route, the dashboard results fragment’s unfiltered read and the Echo retention-paused scan page is NOT counted: the visualization route logs a warning naming the cause (not rate-limited), the dashboard fragment and the scan page log nothing, and the in-band signal (result_truncated_by_cap and truncation_cause in the visualization payload, the “Partial result” notices on the dashboard, the result_truncated_by_cap boolean on the scan page’s REST and MCP JSON) is the only evidence. If a count is wanted, a sibling ..._read_truncated_total{surface,cause} family is the shape; it does not exist today.

A response read that answers 503 Sec-Audit-Failed. The legacy GET /api/responses/{id}, GET /api/responses/{id}/aggregate and GET /api/responses/{id}/export, and their v1 twins GET /api/v1/responses/{id}, .../aggregate and .../export, write a response.read success row after the store read and before any body is built, and fail closed (the scope-drop denied row is fail-closed too): if a row cannot be persisted, the read answers 503 (A4 envelope, retry_after_ms: 5000, header Sec-Audit-Failed: true) and serves no data. A run of these after an upgrade means the audit store is unhealthy, not the response store: check /healthz stores.audit, free disk and Postgres connection saturation, and watch yuzu_server_audit_emit_failed_total (the bundled YuzuAuditPersistFailures alert fires on it). A failure the audit pipeline reports by THROWING (an allocation failure, for example) is answered the same way but is not counted in that metric, so a 503 spike with a flat counter points at that case; correlate by the X-Correlation-Id on the 503. Scripted consumers of the legacy routes must treat that 503 as retryable.

What the bound covers. The cut is applied inside the store query, so an export holds about 50 MiB of payload plus one final row while it is fetched. 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 at ingest before invalid bytes and NULs become the 3-byte U+FFFD, so a row is about 4 MiB for text output and up to about 12 MiB for output dense in invalid bytes or NULs; the error_detail written when a terminal frame closes a running row is cut the same way). The serialization-time backstop counts escaped bytes (the whole body so far for CSV, each serialized row object for JSON), so a result under 50 MiB of raw payload can still be cut and reported as byte_cap. One measurement, 400 rows of 512 KiB: the store query’s peak resident memory rose by 99 MiB with the bounded fetch, against 398 MiB with the unbounded one; 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, 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, and the dashboard results fragment’s filtered branch (it reads by response id) has no byte bound. The execution visualization route, the dashboard results fragment’s unfiltered read and the Echo retention-paused scan page’s read are bounded by the same 50 MiB cap in SQL and say so when it cuts (result_truncated_by_cap, or a visible “truncated” notice on the dashboard). The visualization route applies the caller’s management-group scope inside that query; the dashboard fragment and the scan page apply it after the fetch, so a confined caller can see a cut notice when every dropped row was outside their scope, and out-of-scope rows can use up the 50 MiB budget that would otherwise reach their own rows. Other internal reads also have no byte bound and take their limit from something other than a request parameter; for example (not an exhaustive list), the fleet visualization snapshot’s collect poll (the number of agents it dispatched to, plus 16), the deployment poll (a fixed 50,000), the pre-flight per-check read (a fixed 50,000), a bundle execution’s result read (1000) and an execution-detail page read (500).

Pool sizing. An export holds one connection from the server’s shared Postgres pool for the fetch and the parse. The pool is sized by --postgres-pool-size (default 16) and is shared by AuthDB, the session store, the response store, the audit store and the RBAC store, among others. The response store’s read path waits up to 2000 ms to acquire a connection; the RBAC permission lookup waits only 250 ms, and two consecutive failed pool touches open a breaker that denies every authorization check without touching the pool until a probe succeeds. So at the default size, 16 exports in flight at once occupy every connection, and a permission check can then be refused (fail closed) while they run. This change adds no concurrency limit on exports, so bound it from outside: lower limit, narrow with since/until/agent_id/status, use a management-group-confined principal, or rate-limit the export routes at the proxy. Watch yuzu_pg_pool_in_use and yuzu_pg_acquire_wait_seconds (see “Connection-pool sizing” above). A pool-acquire timeout on a response read surfaces as 503 response store degraded.

Malformed filters. yuzu_server_response_param_rejected_total{surface} counts requests refused for a malformed numeric parameter on the response routes, MCP query_responses and MCP list_issued_certs only (both MCP tools count under surface="mcp"). A steady non-zero rate on one surface is a client sending fractional timestamps, nulls or trailing characters; it is not a server fault. The same strict parsing now answers 400 (or -32602 on MCP) on about a dozen other route families and on MCP query_audit_log, and those rejections have no counter of their own: after an upgrade, a rise in 4xx on those routes is visible only in the generic request metrics.

File Logging

Yuzu writes logs to stdout by default. File logging is opt-in via --log-file, with a best-effort fallback at the platform default path (/var/log/yuzu/server.log on Linux, C:\ProgramData\Yuzu\logs\server.log on Windows, ~/Library/Logs/Yuzu/server.log on macOS).

PathBehaviourFailure mode
--log-file <path> (explicit)Writes to <path> in addition to stdout.If the file/directory cannot be opened, server logs an ERROR and continues without file logging.
Platform default path (implicit)Writes to the platform default path if it exists and is writable.If the directory cannot be created or the file cannot be opened, server logs a single INFO line and continues without file logging. The default fallback is best-effort observability, not load-bearing.

The Docker server image pre-creates /var/log/yuzu (mode 0750, owned by the yuzu user) so the implicit default path works out of the box. When mounting an external host volume at /var/log/yuzu, ensure the host directory is owned by the same UID as the in-container yuzu user (verify with docker exec yuzu-server id yuzu); a wrong-ownership mount silently degrades to stdout-only logging.

Health Endpoints

Yuzu exposes four HTTP probe endpoints for orchestrators, load balancers, and monitoring integrations. All four are unauthenticated. /livez and /readyz are exempt from the API rate limiter.

On the main listener, a /health or /api/health request that carries a Cookie, Authorization or X-Yuzu-Token header is counted against a per-source budget; a request with none of those headers is not counted and is never refused by this limit. The check is on the presence of the header, whether or not the credential is valid. The source is the connection’s peer address, so behind a proxy or load balancer all callers share one budget, and a credentialed monitor behind a shared address shares it with every credentialed request from that address; load balancer and orchestrator checks should use /livez or /readyz. The budget is the --rate-limit value (default 100 requests/second, YUZU_RATE_LIMIT; values below 1 are treated as 1, so the limit cannot be turned off). /health and /api/health share one bucket per source, and it is separate from the REST budget, so API traffic from the same address does not use it up. A request over the budget gets 429 with Retry-After: 1 and the standard error envelope. Probe cadences of one request every one to five seconds per source are far below the default. A refused request is counted only in yuzu_http_requests_total{status="429"}, which carries no path or source label, and is not logged; a path-labelled counter is planned follow-up work. Per-source budget entries are kept until the server restarts; purging them is also planned follow-up work.

PathUse caseBodyDraining-aware
/livezKubernetes liveness probe — fast check that the HTTP listener is up.{"status":"ok"}No
/readyzReadiness probe for load balancers and orchestrators — “stop routing to me”. Covers runtime Postgres reachability (the pg_reachable row), per-store migration completion at startup, AND graceful-shutdown drain.{"status":"ready","enablement":{"regression_pending":false,"anchor_unwitnessed_at_boot":false,"report_ahead":false}} (200), {"status":"draining"} (503), or {"status":"not ready","failed_stores":["pg_reachable", ...],"pg":"unreachable","enablement":{...}} (503). The enablement object is present in both the ready and the not-ready body whenever the enablement store is wired; it is detail, never a gate (see below). pg appears only when pg_reachable fails and names the reason: unreachable, stale, read_only (connected to a standby), or not_yet_probed. A non-critical store that’s on but degraded (currently: analytics_event_store, ADR-0049, and the NVD CVE cache nvd_db, whose failure leaves vulnerability matching empty) is reported via a non-gating "degraded":[...] array alongside either status value, rather than flipping the node to not-ready.Yes
/healthMonitoring dashboards (Prometheus blackbox exporter, Datadog, Nagios). Rich JSON with per-store status, agent counts, execution stats, and version.Structured JSON — see REST API: Health.No
/api/healthIdentical alias of /health, provided for monitoring integrations that prefix every REST call with /api/. Restored in v0.12.0 (issue #620).Identical to /health.No

Choose the right endpoint for your use case. Load balancers that should drain in-flight traffic during a rolling deploy MUST use /readyz. /health and /api/health continue returning 200 during shutdown by design (Kubernetes pattern: liveness/health probes are not draining-aware). Aggressive monitoring poll cadences (sub-second) should target /livez rather than /health to minimise per-probe store reads, and a /health poller that sends a credential should stay within the per-source budget above. Point an orchestrator’s liveness probe at /livez, never /readyz: a database outage turns /readyz red on every replica, and restarting them all fixes nothing.

What /readyz checks (HA WS-8)

Probing a TLS-enabled server

The shipped images contain no curl (the chiselled image ships a static busybox with wget, see deploy/docker/Dockerfile.server.chisel), and images built before --healthcheck existed (#5906) answer every request on the HTTP port with a redirect when HTTPS is on. Two mechanisms let an orchestrator probe such a server.

1. The HTTP port answers the four probes directly, in cleartext. With HTTPS on, the redirect listener (--web-port, 8080 by default) serves /livez, /readyz, /health and /api/health without redirecting. The port is unencrypted and unauthenticated: /health returns the version, the store names, agents.online, the enablement_regressed flag (whether enablement issuing is frozen) and the tls block (whether default certificates are active, the install CA fingerprint and its expiry), /readyz carries the three enablement booleans, and a failing /readyz names the failed stores and the Postgres state. Publish it only to the orchestrator network or 127.0.0.1, or remove it with --no-https-redirect. --web-address 127.0.0.1 binds the HTTPS dashboard to loopback too (both listeners share that address), so it is not a way to restrict only the plain port: for that, use network placement or --no-https-redirect. The rules:

2. yuzu-server --healthcheck. Probes the real HTTPS listener from inside the container and exits 0 only on HTTP 200:

AspectBehaviour
Targethttps://<web-address>:<https-port>/readyz (a wildcard bind, 0.0.0.0 or ::, is dialled over loopback); with --no-https, http://<web-address>:<web-port>/readyz. --healthcheck-path /livez selects liveness.
TrustThe certificate chain is verified against only a trust anchor, never the system trust store. Default-certs mode: <ca-dir>/default-ca.pem (the install CA). Operator certificate (--https-cert): the first certificate in that file (the server certificate itself); certificates after it are not trusted, and a first certificate that is not a -----BEGIN CERTIFICATE----- block (for example X509 CERTIFICATE or TRUSTED CERTIFICATE) is refused with an SSL certificate loading failed error, never the whole file. In operator-certificate mode the anchor must resolve to a regular file of at most 64 KiB; a larger, missing or unreadable file is refused with the same error. The host name is not checked.
RedirectsNot followed. A 301 is a failure.
TimeEach phase is bounded: TCP connect 1 s, each TLS handshake step 1 s, the request as a whole 3 s; and the whole probe, name resolution and handshake included, is cut at 4 s with the reason timed out after 4000 ms. A plain or post-handshake server that accepts but never answers is reported in about 2 s, a TLS peer that accepts and never starts the handshake in about 1 s, one that trickles a response in about 3 s, one that trickles its TLS handshake at 4 s.
OutputNothing on success. Exit status 0 is healthy, non-zero otherwise (1 is a failed probe; a bad flag exits with CLI11’s own code). On failure one stderr line naming the target, the HTTP status or TLS reason (for example certificate has expired), and the trust anchor used. It never prints a key path or a response body. docker inspect --format '{{json .State.Health}}' <container> shows the last lines; for a 503, the server’s docker logs carry the [readyz] line that says why.
InputsThe server’s own flags and environment variables, with the server’s defaults. It does not read yuzu-server.cfg.

A compose healthcheck: does not inherit the container’s CMD arguments, so repeat any listener flags the server was started with. Default certificates:

healthcheck:
  test: ["CMD", "yuzu-server", "--healthcheck"]
  interval: 10s
  timeout: 5s
  retries: 3
  start_period: 30s

Operator certificate (the same --https-cert the server uses; the probe trusts the first certificate in that file as its anchor, which must be a -----BEGIN CERTIFICATE----- block in a regular file of at most 64 KiB, and refuses anything else rather than trusting the whole file):

healthcheck:
  test: ["CMD", "yuzu-server", "--healthcheck", "--https-cert", "/etc/yuzu/tls/server.pem"]
  interval: 30s
  timeout: 5s
  retries: 4
  start_period: 30s

Kubernetes exec probe. timeoutSeconds defaults to 1, which is under the probe’s own 4 s bound, so always set it to 5 or more. The startupProbe holds the liveness probe off for up to 5 minutes, because a large upgrade’s first boot builds an index before anything answers (see docs/user-manual/upgrading.md):

startupProbe:
  exec:
    command: ["yuzu-server", "--healthcheck", "--healthcheck-path", "/livez"]
  failureThreshold: 30
  periodSeconds: 10
  timeoutSeconds: 5
livenessProbe:
  exec:
    command: ["yuzu-server", "--healthcheck", "--healthcheck-path", "/livez"]
  initialDelaySeconds: 30
  periodSeconds: 10
  timeoutSeconds: 5

Notes:

Load balancers and shutdown drain

On SIGTERM the server sets /readyz to 503 {"status":"draining"} and then keeps serving every other route for at least --shutdown-drain-seconds (env YUZU_SHUTDOWN_DRAIN_SECONDS, default 0, range 0–60) before it closes its listener, and for as long as executions are still running (up to 30 s). It logs a countdown every 10 s. The two background roll-ups (app-perf and the software catalogue) do not start new work once draining begins. With the default of 0 and nothing running, the listener closes straight away — fine for a single server, but a load balancer in front of several replicas would then find out from refused connections.

For any deployment behind a load balancer:

  1. Set --shutdown-drain-seconds to at least the load balancer’s health-check interval × unhealthy threshold, plus one interval (e.g. 5 s interval × 2 failures + 5 s = 15 s).
  2. Set the health-check thresholds to interval 5 s, unhealthy_threshold 2, healthy_threshold 3 (the names vary: HAProxy inter 5s fall 2 rise 3, Kubernetes periodSeconds: 5, failureThreshold: 2, successThreshold: 3, AWS target groups the same three fields). Eviction then needs ≥ 10 s continuously red, so a single database stall never evicts a replica; re-admission needs ≥ 15 s continuously green, so a database hovering at the edge of its capacity does not pull every replica in and out every few seconds — which drops held-open SSE/MCP streams, and their reconnects are what turns a slow database into a slower one. /readyz itself adds no hysteresis (#4944): two failed probes red it, one success greens it.
  3. Know what your load balancer does when every backend is unhealthy. During a Postgres failover or a sustained overload every replica is red at once (above). An AWS ALB/NLB and nginx upstream route to all backends anyway (fail open) — requests then fail fast or slowly depending on the outage; a Kubernetes Service removes every endpoint and HAProxy answers 503 (fail closed) — an outage of the API plane for the duration. Neither is wrong; make sure the choice is yours.
  4. Raise the orchestrator’s stop timeout by the same N. The drain grace comes before the rest of the shutdown, which is unchanged, so it adds to it: the shipped compose stop_grace_period and systemd TimeoutStopSec of 210 s become 210 + N. Kubernetes’ default terminationGracePeriodSeconds of 30 s is far too short in any case (see docs/user-manual/upgrading.md, the stacked-shutdown section).
  5. Health-check /readyz, not /health.

A second SIGTERM still exits immediately.

Deployment

Yuzu provides multiple deployment options: Docker Compose for quick setup, systemd units for bare-metal Linux, and a development stack script for local testing.

Docker Compose

The default Docker deployment runs the server and agent standalone — no gateway required. For scaled deployments with a gateway, see docker-compose.full-uat.yml.

Files:

FileDescription
deploy/docker/Dockerfile.serverMulti-stage build for the Yuzu server binary
deploy/docker/Dockerfile.gatewayErlang/OTP build for the gateway node
deploy/docker/docker-compose.ymlBuild-from-source dev stack (server + agent + monitoring)
deploy/docker/docker-compose.reference.ymlCopyable deployment template — pulls pinned ghcr.io images, uses a named server-data volume, carries inline TLS hardening + backup + rollback commentary. Requires operator hardening (TLS, bind address) before production use.
deploy/docker/docker-compose.full-uat.ymlGateway deployment (server + gateway + monitoring)
docker-compose.uat.ymlSelf-contained single-file UAT stack pulled from ghcr.io (server + gateway + Prometheus + Grafana + ClickHouse)
deploy/docker/docker-compose.demo.ymlChiselled (FROM scratch) Ubuntu 26.04 sales-demo stack — server + gateway + N agent replicas, release-pinned. Entry point scripts/start-demo.sh; see docs/demo-environment.md. Not for production (runs --no-tls with a baked admin password).

Usage:

cd deploy/docker
docker compose up -d          # start all services
docker compose logs -f        # follow logs
docker compose down           # stop all services

What must persist. Every shipped compose that keeps Postgres on a volume also keeps these two server directories on named volumes, and a compose you write needs the same two. (The sanitizer rig, docker-compose.sanitizer-uat.yml, keeps no state at all and is the one exception.)

Mount pointVolume (shipped composes)Holds
/var/lib/yuzuserver-data--data-dir: the .cfg files, nvd_cves.db, agent-updates/, upload-blobs/
/etc/yuzu/certsserver-certs (certs in the two reference composes)The server’s default cert dir: the internal CA (default-ca.key), the default leaf certificates, the secrets KEK files secrets-kek-v<N>.key, the dashboard-uploaded TLS files server.pem / server-key.pem / ca.pem, and plugin-trust-bundle.pem. The last four are written here whatever --ca-dir says

The second is easy to miss, because the image never passes --ca-dir and the server falls back to /etc/yuzu/certs. The KEK is registered in Postgres, so this directory has to live exactly as long as the Postgres volume does. Without a volume, recreating the container (an image upgrade, down then up, up --force-recreate) deletes the key files, and the server then refuses to start with kek_unresolvable (#5370). docker compose down without -v keeps both volumes. down -v deletes them along with the Postgres volume, which is a full reset. The yuzu-server image creates /etc/yuzu/certs owned by the yuzu user, and a new named volume takes that owner, so no chown is needed. The chiselled image up to 0.14.0 has no such directory, so a new volume there is root-owned; the demo compose’s one-shot server-certs-init service hands it to uid 1000. Keep /etc/yuzu/certs on a volume even if you pass --ca-dir; on an existing install do not move it (#5273): the CA store records the key’s absolute path. The volume is lost to docker volume rm, docker volume prune / docker system prune --volumes (while no container uses it, such as after down), a Docker Desktop data purge, or renaming the compose project, and moving a stack between a reference compose and any other compose moves the keys too — use the copy-out recipe. Affected: a 0.14.0 Docker Compose stack whose server has no named volume on /etc/yuzu/certs while Postgres has one — deploy/docker/docker-compose.yml (the 0.14.0 README quickstart), docker-compose.uat.yml, docker-compose.full-uat.yml, docker-compose.viz-uat.yml, docker-compose.demo.yml used with --keep, any compose copied from them, and Compose Wizard output from before 0.14.1 in Plaintext mode, with ‘Persist generated certs’ unticked, or with named volumes off and an external Postgres. Not affected: docker-compose.reference.yml (which the README quickstart saves as docker-compose.yml since #5419) and docker-compose.reference-gateway.yml (their certs volume). If yours is affected: Upgrading, “Docker Compose: copy /etc/yuzu/certs out of the server container before you recreate it”. Back the directory up with the database as a pair: the KEK pairing rule.

Pinning a specific release with docker-compose.uat.yml:

The top-level UAT compose file parameterises its ghcr.io/.../yuzu-server and yuzu-gateway tags through ${YUZU_VERSION:-<default>}. The default tracks the latest published release, but operators testing an earlier or newer image can override at the command line:

YUZU_VERSION=0.9.0 docker compose -f docker-compose.uat.yml up -d

A GitHub Actions check (scripts/check-compose-versions.sh) runs as the first step of the release workflow and blocks asset publication if any tracked compose file carries a hardcoded X.Y.Z tag or a ${YUZU_VERSION:-...} default that does not match the release tag — so the default in the checked-in file is guaranteed to match the latest shipped release.

Exposed ports:

PortServiceDeployment
8080HTTP→HTTPS redirect + cleartext health probes with HTTPS on (--no-https-redirect removes it); the dashboard and REST API in plain HTTP with --no-httpsAlways
8443Web dashboard + REST API (HTTPS)Always when HTTPS is on (the default)
50051gRPC (agent connections)Always — server in standalone, gateway in scaled
50052gRPC (management)Always
50055gRPC (gateway upstream)Gateway deployments only
50063gRPC (gateway command forwarding)Compose-network only — never host-published (#1422). The privileged fleet-command plane; the server dials gateway:50063 over the compose network, no shipped compose publishes it, and none should. See security-hardening.md “Gateway TLS”
8081Gateway health/readinessGateway deployments only
9568Gateway Prometheus metricsGateway deployments only
9090PrometheusMonitoring stack
3000Grafana (default login: admin/admin)Monitoring stack

Volumes: server-data, server-certs (certs in the reference composes), postgres-data, agent-data, prometheus-data, grafana-data are persisted across container restarts.

systemd Units

For bare-metal Linux deployments, systemd service files are provided for each component.

Files:

FileDescription
deploy/systemd/yuzu-server.serviceYuzu server unit
deploy/systemd/yuzu-agent.serviceYuzu agent unit
deploy/systemd/yuzu-gateway.serviceErlang gateway unit

Agent logging is now asynchronous (#4666 PR-2). main.cpp no longer calls spdlog’s sinks directly on the thread that logs; it installs a bounded async hand-off logger (agents/core/src/log_handoff.hpp) as the process’s spdlog default. A producer thread formats the line and enqueues it (overrun_oldest policy); one dedicated worker thread does the actual sink I/O. The queue is a FIXED-SIZE ring, pre-allocated in full at startup, not proportional to log volume: 8192 message slots plus one in-flight slot (8193 total), each slot sizeof(spdlog::details::async_msg) == 408 bytes on x64: 8193 x 408 = 3,342,744 bytes = 3.34 MB (about 3.19 MiB) of RSS, paid up front the moment the agent starts, whether or not anything is ever logged. Formatted-text payload beyond that fixed per-slot cost is additional and not bounded by this primitive. Under sustained overload (more lines produced than the worker can write, e.g. a stalled log destination) the queue does not block the producer and does not grow; overrun_oldest silently evicts the oldest still-queued message to admit the new one, so a busy agent under a stuck sink can lose log lines rather than pause. There is currently no counter or heartbeat field surfacing how many lines were dropped this way: an overrun_total() accessor exists on the primitive but nothing in the shipped binary reads it yet (planned for a later PR’s heartbeat poller). A sink-level write/format failure (as opposed to an overrun) is tracked internally too — count plus the last 256 bytes of the failing message, in LogHandoff’s private ErrorState. Three accessors read it: log_errors_total() (exercised by the unit tests), last_log_error_for_test() (despite its name, currently called by nothing), and stderr_emits_dropped() (below). None has a production/heartbeat consumer yet, the same later-PR heartbeat poller planned for overrun_total() above. The only production-visible signal today is a rate-limited (once per second) fallback line to stderr at the moment of failure, reproducing what spdlog’s own default error handler always did before #4666 PR-2 installed this one. That line is written by a short-lived helper thread, never by the logging worker, using a plain write that holds no C-library lock. A stderr that is blocked when a sink fails can therefore no longer stall the worker through this diagnostic, and a stuck helper cannot hang a normal shutdown (verified on Linux). While one such write is stuck, the diagnostics that follow are dropped and counted by stderr_emits_dropped(); if the helper thread cannot be created, that one diagnostic is dropped and counted and the next one retries. The fallback line is unlikely to be visible at all under a genuine Windows-service session (--install-service, no console): the agent has no valid standard error handle there and attaches no stderr sink.

This covers the diagnostic’s own write only, and on the shipped configurations that is a narrow gain: the stall it removes is the one a blocked stderr caused through this diagnostic when stderr is not itself a sink, or when a producer thread hits the failure. When stdout or stderr is itself one of the agent’s log sinks, a blocked stream still stalls the logging worker in that sink’s own write and queued lines are dropped oldest-first (overrun_oldest), exactly as for any stuck sink; only the shutdown watchdog bounds that, and only at shutdown. That is the case for the shipped systemd unit and the container images (no --log-file, so a stdout sink), for --log-file outside a Windows service (a stderr sink beside the file sink), and for an unopenable --log-file (which falls back to a stdout sink). In those setups a stalled log collector still stalls log delivery. There is no --log-sync flag or other escape hatch back to synchronous logging; this is unconditional for every build. A --log-file that cannot be opened still falls back to console-only logging exactly as before (used_log_file_fallback() prints the same kind of startup diagnostic), except the console sink is now async too, not synchronous as it was pre-#4666.

If log lines go missing under load with no error printed (#5023). Check the consumer of the agent’s stdout and stderr first (journald, the container log driver, a collector backlog): a stalled one stalls delivery as described above, and restarting the agent does not fix it. As root, ls -l /proc/<pid>/fd/1 /proc/<pid>/fd/2 shows whether each is a pipe (containers) or a socket (journald under the shipped unit), and grep . /proc/<pid>/task/*/wchan shows a logging thread parked in the write. The symbol depends on the kernel (pipe_write or anon_pipe_write for a pipe, sock_alloc_send_pskb for a socket). Without root, only the agent’s own account can read these, and only while the agent process is dumpable (a process that gained capabilities at exec, for example from file capabilities on a binary started outside a NoNewPrivileges=true unit, is not); otherwise the wchan files read 0 and the fd links fail with “Permission denied”. Then check disk space and fd limits on the log destination. overrun_oldest drops are silent in this release, with no counter or alert to point at them yet.

Stopping a wedged agent (Linux/macOS). SIGTERM/SIGINT (systemctl stop, Ctrl-C) triggers a graceful agent stop — plugin shutdown, thread joins, store close. If that teardown hangs (e.g. the server is unreachable and a drain is stuck), send the signal a second time (kill -TERM <pid> again, or a second Ctrl-C): the agent immediately hard-exits with code 1. This escalation is deliberate and has no grace window — the second signal always force-exits, even if the first stop was progressing normally — so double-signalling stop tooling will force-kill healthy agents; send one signal and wait. You no longer need SIGKILL to recover a stuck agent. SQLite state is WAL crash-safe across the hard exit. Separately (#2233 item 3), a stuck teardown is now also caught automatically: AgentImpl::stop() and the agent’s own run()-exit teardown each arm a 20-second internal watchdog, and self hard-exit (code 4 — new, distinct from this section’s exit 1 and the crash-loop-backstop’s exit 3 below) if guardian/spark/DEX teardown or any other blocking step hasn’t returned within it (on a Windows agent with Spark detection enabled, see After an agent exit with code 4 below for what a code 4 can cost). Separately again (#4666 PR-2), once the rest of shutdown has already finished (after both watchdogs above have been cancelled and after the F3 orphan-exit check below has run), the agent tears down its own async log hand-off logger before the process exits: code 5 (distinct from codes 1/3/4) fires from EITHER of two causes at that step — a 2-second internal watchdog if flushing the queue and joining the logging worker thread hasn’t finished in time, or an immediate exit (no 2-second wait at all) if the teardown itself fails outright (an exception during the flush/join sequence, or a failure while releasing this image’s own reference to the logger — the exe-image-swap step this exists to protect is unconditional on every platform, not gated to macOS, even though the underlying hazard it guards against is real only there: macOS’s agent and its shared library run in separate spdlog registries, so a failed swap there can leave a live reference past the watchdog; on Linux and Windows (both confirmed to have ONE shared registry — Windows measured 2026-10-03, see docs/spark-rebuild-baselines/4666-mi1b-windows-registry-verdict.md, and docs/darwin-compat.md’s “spdlog registry identity across images” row) the registry is shared and a later teardown step already nulls it regardless, so a transient failure at this specific step forces the same hard exit there too even though nothing was actually left dangling). An operator seeing code 5 land instantly, with no apparent delay, should not read that as a broken or skipped watchdog; it means the second cause fired. You do not need to send a second signal to recover from a code-4 or code-5-class wedge; doing so just makes the exit happen sooner (code 1) instead of after the relevant internal deadline. Codes 3, 4, and 5 are sequential, non-overlapping checkpoints in the same shutdown, not independent watchdogs racing each other: code 4’s watchdogs run first (during stop()/run()-exit teardown), the F3 orphan check (code 3) runs after both have been cancelled, and the log-teardown watchdog (code 5) is the last step before the process exits, so at most one of them can actually fire for a given shutdown. Code 1 is different: because the agent stays signal-responsive throughout every one of those checkpoints, an operator’s second signal can still preempt whichever one would otherwise have fired, so code 1 can race with 3, 4, or 5 for the same underlying wedge; treat the exact code reported in that case as a useful hint, not a certain diagnosis, mirroring the existing code 1-vs-4 framing main.cpp’s own comments use. On Windows, a second Ctrl-C also terminates promptly (via the escalation or the CRT’s default disposition); the service path (sc stop) is now also bounded by the same 20s watchdog, and by a second, independent 20-second wait (kServiceMainDrainGrace) that run_service() performs after the SCM’s control dispatcher returns, before handing control back to main() for its own log-teardown step: a timeout there also self hard-exits with code 4 (the same code as the two stop()/run()-exit watchdogs, reused deliberately rather than minting a fourth code for “something past its own internal watchdogs is wedged”; see shutdown_deadline_guard.hpp). Because this wait sits strictly after service_main has already reported its final SCM status (SERVICE_STOPPED, clean or with a “specific error” code, see below), a timeout here changes nothing about what the SCM was already told; it only means the process takes longer to actually exit. The agent’s own --install-service path (see below) does configure automatic service recovery (3 restarts, 60s apart, resetting after 24h, firing on both a crash and a clean exit that never reported SERVICE_STOPPED, #1822), so a watchdog fire there behaves similarly to the Linux Restart=always unit, not as a permanent stop. What it does change: TerminateProcess bypasses report_status, so none of codes 3/4/5 land in the sc query/Event Viewer “specific error” buckets described further down. A code-4 fired by AgentImpl::stop()’s or run()-exit’s own watchdog, while service_main is still running and has not yet reported SERVICE_STOPPED, surfaces as a generic unexpected termination. A code-3, a code-5, or a code-4 fired by run_service()’s own drain-grace wait all sit strictly after service_main has already reported its final status (SERVICE_STOPPED, clean or with a “specific error” code, see below), so by construction none of those three changes what the SCM was already told, and none produces an Event Viewer entry distinguishable from a normal stop; the process simply takes a little longer to actually exit than the SCM’s report suggested.

After an agent exit with code 4 (#5604). On any agent with Spark detection enabled (off in every released agent), lifecycle-journal records waiting to be written at shutdown are lost when the watchdog ends the stop; they are audit evidence and are not recoverable. On a Windows agent with Spark detection enabled a baseline capture waiting to be written is lost too. Treat a file-hash-equals Guardian rule that has no authored expected_hash as possibly re-baselined (the next start captures the file’s current content and logs it exactly like a first capture; any drift in between is accepted as the baseline). Find the process holding kv_store.db locked (Resource Monitor, or Sysinternals handle) and author an expected_hash for the rules that matter (see “Where the baseline lives” in Guaranteed State). On every other agent a code 4 loses no baseline, because the legacy path writes it at capture time; still check the disk and any process holding kv_store.db locked (fuser or lsof on Linux and macOS). At shutdown, a write to a locked kv_store.db normally waits about 1 second per statement step; SQLite’s own internal retries (for example on the WAL index) are not covered by that cap and were measured at up to about 2.3 seconds against a 1 second budget on Linux (phasing dependent), on every platform. Several waits follow one another, so a stop against a locked store can still take several seconds.

Two watchdogs, not one, and their budgets don’t share a clock. AgentImpl::stop() and the agent’s own run()-exit teardown each arm their own separate 20s deadline — in the worst case (both phases legitimately slow but not individually wedged) the two can compose sequentially to as much as ~40 seconds before either one actually fires, exceeding this 30s SCM hint with neither watchdog triggering. That specific case is not a hang and not a restart: both phases genuinely finish, the agent reports a normal clean stop (SERVICE_STOPPED/success), and per the SCM’s own recovery-action rules a clean stop with a zero exit code does not trigger SC_ACTION_RESTART either — the service simply reports stopped later than the SCM’s hint anticipated, with no automatic action taken either way. Composition-aware budgeting between the two deadlines (so a slow stop() shortens the remaining budget available to the teardown that follows it) is tracked as a follow-up (#3756), not implemented today. If the agent logs shutdown watcher unavailable at boot (thread/fd exhaustion), a hard-exit handler is installed instead: the agent exits promptly on the FIRST signal, ungracefully — no plugin shutdown, no clean store close. (A default signal disposition would be discarded by PID 1 in a container, so the handler is the posture that stays killable.)

A third, independent watchdog now follows the two above (#4666 PR-2), and this is a SUM of bounded phases plus genuinely unbounded ones, not a single derived guarantee. The log-teardown watchdog (kLogTeardownGrace, 2s default) only arms once the earlier phases have already finished, so its grace ADDS to theirs rather than overlapping them. On the POSIX/console path the fully-bounded phases are: AgentImpl::stop()’s watchdog (≤20s) plus the run()-exit ScopeExit’s watchdog (≤20s) plus the F3 orphan-exit grace (kOrphanDrainGrace, 3s) plus the log-teardown grace (2s), 45 seconds total in the worst case where every phase is legitimately slow but none individually wedged. That figure is bounded-phases-only and does not cover the whole shutdown: the inline plugin-shutdown loop that run()’s reconnect thread runs BETWEEN noticing stop_requested_ and the run()-exit ScopeExit even arming its own watchdog has no named bound at all (tracked as #3756 item 5), and neither does ~Agent’s own destructor work once every watchdog has already been cancelled; a hang in either of those is caught by nothing described here. On the Windows service path the arithmetic is different in shape, not just in number: run_service()’s own drain-grace wait (kServiceMainDrainGrace, 20s) sits AFTER the SCM dispatcher returns and effectively caps whatever is left of service_main at that point, including the F3 orphan grace and ~Agent, as one bounded 20-second window, regardless of which specific phase service_main is actually in when the dispatcher returns; the log-teardown grace (2s) then runs separately, in main(), strictly after run_service() itself has already returned, adding to that 20s rather than being covered by it. Neither deploy/systemd/yuzu-agent.service nor deploy/packaging/macos/com.yuzu.agent.plist overrides its supervisor’s own stop-timeout default (TimeoutStopSec= is absent from the unit; ExitTimeOut is absent from the plist); systemd’s and launchd’s own defaults apply on top of everything above, and this document does not assert what those defaults are. Check systemd.service(5)/launchd.plist(5) on your own host if you need the exact figure.

Stopping a wedged server (Linux/macOS, #3007). Identical mechanism to the agent above, applied to the server. If a stop appears to hang: send the signal a second time (kill -TERM <pid> again, or a second Ctrl-C) and the server immediately hard-exits with code 1, no grace window — exactly like the agent. SQLite/Postgres state is crash-safe across the hard exit. On Windows, a second Ctrl-C also terminates promptly.

Mechanism: SIGTERM/SIGINT (systemctl stop, docker stop, Ctrl-C) triggers a graceful stop on a dedicated watcher thread — HTTP admission stop, background thread joins, up to ~130s of stacked waits including webhook/ offload store quiesce and the wait for an in-flight registry.write audit write (see the stacked-shutdown-bound section in Upgrading; a rare thread-creation-exhaustion fallback path can push the quiesce portion alone to ~120s, worst case ~190s total), then store teardown. Those figures leave out two things that can each add to them: a background roll-up recompute already in flight at SIGTERM (app-perf up to 120 s, catalogue up to 60 s — the 210 s shipped grace was sized for these two, not for them on top of everything else, so the fallback path plus an in-flight app-perf recompute can pass 210 s), and a frozen primary, against which the execution-drain query and the background-thread joins block in their own Postgres calls until it answers. A --shutdown-drain-seconds grace of N seconds runs before all of this and adds to it — raise the stop timeout by N when you set it. A long-seeming wait can be completely normal, not evidence of a wedge: each stage logs a Shutting down server: waiting up to Ns for ... line at its start, but nothing further until it completes or times out — so a silent gap of up to a minute or so between progress lines is expected, not a hang by itself. If the server logs shutdown watcher unavailable at boot, it falls back to a hard-exit handler: a SIGTERM/SIGINT then exits the process promptly on the FIRST signal, ungracefully (no store flush, no clean close) — the server keeps running normally until a signal actually arrives, this only changes how it responds once one does. Separately, a SIGTERM/SIGINT arriving before the server has finished starting up (Server::create() — TLS cert bootstrap, gRPC listener setup) also exits promptly with code 1 instead of attempting a graceful stop — a boot-time signal cannot be handled gracefully, so the server fails visibly rather than silently continuing to boot (or, before #3007, being silently ignored).

Crash-loop backstop (systemd). The yuzu-agent unit sets Restart=always + RestartSec=10, but also StartLimitIntervalSec=300 + StartLimitBurst=5 (ADR-0021 rung 7.7a). A Guardian I/O worker wedged past its grace period triggers a hard_exit() (exit 3); the shutdown-teardown watchdog above (#2233 item 3) triggers the same hard_exit() mechanism at exit 4; the log hand-off teardown watchdog (#4666 PR-2) triggers it at exit 5. Either way, against a permanently wedged target (a dead NFS mount, a hung service query) that would otherwise restart-loop every 10s forever. Instead, after 5 restarts within 300s systemd puts the unit into failed and stops retrying (the device goes dark rather than looping silently). Recover with systemctl reset-failed yuzu-agent && systemctl start yuzu-agent once the wedged target is resolved. Alert on the failed state; the old restart-forever behaviour hid a crash-looping agent. The unit sets no SuccessExitStatus=, so systemd’s default success check (exit code 0 only) applies to every exit the agent makes, including a hard_exit()-driven one. Restart=always restarts on any SPONTANEOUS exit the process makes (0 included; it does not distinguish success from failure for the restart decision), except one thing: systemd suppresses Restart= entirely for a process that exits in response to an explicit systemctl stop (systemd.service(5)), so a wedge that self-exits 1/3/4/5 while an operator-initiated stop is in flight does NOT trigger a restart and does NOT count toward StartLimitBurst=5, even though the exit code is still a “failure” result and can still leave the unit reporting failed in systemctl status rather than inactive. The StartLimitBurst=5 ceiling above applies only to the crash-loop case this paragraph is about: a spontaneous, unrequested exit that systemd itself decides to restart from. This document does not independently verify the suppress-on-explicit-stop behaviour against a real systemd instance; it follows from systemd.service(5)’s own documented Restart= semantics.

Persisting deploy-time settings (systemd). The yuzu-agent unit also loads an optional EnvironmentFile=-/etc/yuzu-agent/yuzu-agent.env (- = no error when absent, not shipped by the .deb/.rpm - operator-created), so YUZU_AGENT_* flags such as YUZU_AGENT_SPARK_DISABLE survive restarts and package upgrades instead of only a one-off command-line override. See SparkEngine for the specific rollback-lever case, and docs/spark-flip-gate.md §6 for the drill procedure and recovery from a malformed value (it shares the crash-loop backstop above).

Installation:

# Copy binaries
sudo cp build-linux/server/core/yuzu-server /usr/local/bin/
sudo cp build-linux/agents/core/yuzu-agent /usr/local/bin/

# Create service user
sudo useradd --system --no-create-home yuzu

# Install units
sudo cp deploy/systemd/yuzu-server.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable yuzu-server
sudo systemctl start yuzu-server

Security hardening: The systemd units include NoNewPrivileges, ProtectSystem=strict, ProtectHome=true, and PrivateTmp=true for defense-in-depth.

Development Stack Script

The scripts/start-stack.sh script starts the full development stack locally (without Docker).

Components started:

  1. yuzu-server — gRPC on :50051, web on :8080
  2. Erlang gateway — agent gRPC on :50051, metrics on :9568
  3. yuzu-agent — connects to gateway on :50051
  4. Prometheus — scraper on :9090
  5. Grafana — dashboards on :3000

Usage:

bash scripts/start-stack.sh          # start all components
bash scripts/start-stack.sh stop     # kill all components
bash scripts/start-stack.sh status   # show running processes and ports

Windows Service Installation

On Windows, the Yuzu agent has a native Windows service wrapper (yuzu-agent.exe --install-service); this is what the shipped installer uses, and is the recommended path — see the Agent: --install-service subsection below. A native wrapper for the Yuzu server is still planned for a future release; until then, use sc.exe or NSSM (Non-Sucking Service Manager) for the server, as described below.

REM Register the service (binPath is written for you, including the internal
REM --service marker the agent needs to run under the SCM control protocol)
yuzu-agent.exe --install-service

REM Point it at your server / data dir / log file via sc config. binPath= takes a
REM SINGLE value, so the whole "exe + arguments" string must be ONE quoted token,
REM with the quotes around a spaced exe path escaped as \" -- sc.exe does NOT
REM reassemble several quoted segments back into one binPath. Written the other way
REM round (quoting only the exe and leaving the flags bare after it) sc.exe parses
REM --service/--server/... as unknown OPTIONS to sc itself, prints its usage block,
REM and exits 1639 ERROR_INVALID_COMMAND_LINE without touching the service. That is
REM #1468: the shipped installer had exactly that defect, and because Inno ignores
REM [Run] exit codes it failed silently -- the service kept the argument-less binPath
REM --install-service had written, so the agent ran with no --server and fail-closed
REM on TLS. Check your work with `sc.exe qc YuzuAgent`: every flag below must appear.
sc.exe config YuzuAgent binPath= "\"C:\Yuzu\bin\yuzu-agent.exe\" --service --server yuzu.example.com:50051 --data-dir \"C:\ProgramData\Yuzu\" --plugin-dir \"C:\Yuzu\plugins\" --log-file \"C:\Yuzu\logs\yuzu-agent.log\""

sc.exe start YuzuAgent
sc.exe stop YuzuAgent

REM Remove it
yuzu-agent.exe --remove-service

The service currently registers to run as LocalSystem (unchanged by the #1822 SCM-protocol fix — tracked separately as #1442). Moving it to a least-privilege account requires a manual post-install sc.exe config obj= "NT SERVICE\YuzuAgent" plus directory ACLs the installer doesn’t set today — see docs/agent-privilege-model.md and #1442 for the tracked follow-up, and the Service Account guidance below (written for the server, but the same obj= mechanism applies).

Re-running --install-service is idempotent — it updates an existing registration’s binPath in place rather than failing with “service already exists”, so it’s safe to re-run after an upgrade. It resets binPath to the bare exe + --service marker (the same minimal form shown above), dropping any --server/--data-dir/--plugin-dir/--log-file a prior sc config had applied — always follow it with sc.exe config to restore your runtime args, exactly as the installer’s own [Run] sequence does (--install-service then sc config then sc start). Recovery actions (3 restarts, 60s apart, resetting after 24h) are configured automatically and fire on both crashes and clean-exit-with-error.

Important: the --service flag tells the binary to speak the SCM control protocol (ServiceMain/SetServiceStatus) instead of running as a console program — it is added automatically by --install-service and must be present in any sc.exe/manually-crafted binPath for the agent. Omitting it reproduces the pre-fix behavior: sc start fails with error 1053. Do not add --service when wrapping the agent with NSSM (below) — NSSM launches the agent as an ordinary child process, not via the SCM itself, so the agent would try (and fail) to connect to a dispatcher that isn’t there.

Fleet-upgrade gotcha: an agent registered by an earlier installer keeps its earlier command line, --enrollment-token <value> included, until the installer is run again (sc.exe qc YuzuAgent shows which form a service has); the over-the-air update replaces the binary only. Because --install-service always resets binPath to the bare minimal form, a silent/unattended re-run of the shipped installer (e.g. an SCCM/Intune package upgrade) that does not re-supply the original /SERVER=//NOTLS parameters on that specific invocation will reconfigure the agent back to localhost:50051 with TLS on — and because the SCM protocol now actually works (post-#1822), the service starts successfully against that wrong address instead of failing loudly the way it always did before this fix. The agent goes dark from the fleet with no installer-visible error. Always replay the same install-time parameters on every upgrade run, not just the first install. /TOKEN=//TOKEN_FILE= is the one exception: the registered service command line always carries the static --enrollment-token-file flag whether or not either is given on a given run, so an already-enrolled agent needs neither replayed on an upgrade. The installer has parameters for /SERVER=//NOTLS only, so any other flag you added to the binary path — notably --update-trust-bundle and --update-require-signature — is dropped by every installer run with nothing to show it; set those through the service’s Environment registry value instead, which installing over the existing agent leaves alone, though uninstalling deletes it (see Windows: the service’s Environment value).

If sc.exe start YuzuAgent still fails after this fix: check the log file first ({app}\logs\yuzu-agent.log via the installer; <data-dir>\yuzu-agent.log if you configured --service manually without --log-file), it has the actual reason. sc.exe query YuzuAgent/Event Viewer only distinguish which of three generic buckets: specific error 1 covers three distinct causes that land on the same code: the agent failed to construct (bad agent.db, SQLite/config problem), or startup completed but the gRPC channel couldn’t be built under the fail-closed TLS posture (missing/unreadable CA or client cert/key, #1303), including, notably, the exact misconfiguration the fleet-upgrade gotcha above can introduce by silently flipping TLS back on, or a mid-life failure: the dispatch thread pool could not be re-created on a reconnect (host out of threads), which previously ended the service silently as a clean stop; specific error 2 (the agent stopped on its own without a stop/shutdown request, unexpected, check the log for what run() returned early on); specific error 3 (an unhandled exception reached the service dispatcher, check the log for the exception message). After a refused token file, specific error 2 followed by the 60 s restart is the expected signature: the log shows “Server permanently rejected registration: invalid, expired, or exhausted enrollment token”, then “Removed the enrollment token file … after the server refused the token”, and “Enrollment token source: none” on the restart. None of these three codes carry more detail on their own; the log file is where the actual cause lives. These SCM “specific error” buckets are a separate namespace from the agent’s own process exit codes (1/3/4/5) described under Stopping a wedged agent above; they cover why sc start failed to bring the service up in the first place, not why a running service later stopped. In particular, a code-4 shutdown-watchdog exit (#2233 item 3) fired while service_main is still running happens via TerminateProcess, which bypasses report_status entirely, so it does not land in any of these three buckets; Event Viewer shows it as a generic unexpected termination, not “specific error N”. A code-5, a code-4 fired by run_service()’s own post-dispatcher drain wait, or a code-3 fired by the EXPLICIT F3 orphan check on service_main’s normal path (#4666 PR-2) are stranger still: each fires strictly after service_main has already reported one of the buckets above (or a clean SERVICE_STOPPED), so it changes none of them and shows up in neither sc query nor Event Viewer as anything distinguishable from that already-reported outcome. One exception to that ordering, pre-existing and not introduced by PR-2: OrphanExitGuard’s destructor (hard_exit.hpp) is ALSO a fail-closed backstop covering an exception that unwinds out of agent->run() itself before the explicit F3 check is even reached; on that path a code-3 can fire from the destructor DURING unwind, before any report_status call, so this “already reported” property does not hold universally for every possible code-3, only for the ordinary explicit-check case.

Server: sc.exe (native wrapper not yet available)

REM Create the Yuzu server service
sc.exe create YuzuServer binPath= "C:\Yuzu\yuzu-server.exe --https-cert C:\Yuzu\certs\server.crt --https-key C:\Yuzu\certs\server.key" start= auto DisplayName= "Yuzu Server"

REM Set startup type to automatic (delayed start, recommended)
sc.exe config YuzuServer start= delayed-auto

REM Configure recovery: restart on first, second, and subsequent failures
sc.exe failure YuzuServer reset= 86400 actions= restart/5000/restart/10000/restart/30000

REM Start / stop the service
sc.exe start YuzuServer
sc.exe stop YuzuServer

Note: With sc.exe, spaces after = are required (e.g., start= auto, not start=auto). This is a quirk of the sc.exe command parser.

Using NSSM

NSSM provides a more user-friendly wrapper with a GUI configuration dialog. It remains a valid option for the server (no native wrapper yet) and for the agent if you prefer NSSM’s process-monitoring/log-rotation over the native --install-service path — just don’t pass --service to an NSSM-wrapped agent (see the note above).

REM Install services
nssm install YuzuServer "C:\Yuzu\yuzu-server.exe"
nssm install YuzuAgent "C:\Yuzu\yuzu-agent.exe"

REM Set arguments (no --service for the NSSM-wrapped agent)
nssm set YuzuServer AppParameters "--https-cert C:\Yuzu\certs\server.crt --https-key C:\Yuzu\certs\server.key"
nssm set YuzuAgent AppParameters "--server yuzu.example.com:50051"

REM Configure startup and recovery
nssm set YuzuServer Start SERVICE_DELAYED_AUTO_START
nssm set YuzuAgent Start SERVICE_DELAYED_AUTO_START

REM Configure stdout/stderr logging
nssm set YuzuServer AppStdout "C:\Yuzu\logs\server-stdout.log"
nssm set YuzuServer AppStderr "C:\Yuzu\logs\server-stderr.log"
nssm set YuzuAgent AppStdout "C:\Yuzu\logs\agent-stdout.log"
nssm set YuzuAgent AppStderr "C:\Yuzu\logs\agent-stderr.log"

REM Start the services
nssm start YuzuServer
nssm start YuzuAgent

Service Account

For production deployments, create a dedicated service account with minimal permissions rather than running as LocalSystem:

  1. Create a local user account (e.g., YuzuSvc) with no interactive logon rights.
  2. Grant the account read/write access to the Yuzu installation directory and data directory only.
  3. Assign the “Log on as a service” right via Local Security Policy (secpol.msc).
  4. Configure the service to run as this account: sc.exe config YuzuServer obj= ".\YuzuSvc" password= "PASSWORD".

Planned: A native Windows service wrapper (yuzu-server --install-service) is planned for a future release, which will handle service registration, recovery configuration, and Event Log integration automatically.


Planned Features

The following server administration features are on the roadmap but not yet implemented.

FeaturePhaseDescription
Runtime Configuration APIPhase 7 (7.3)Change retention TTLs, connection limits, and other server parameters via REST API. Not all keys apply live - retention TTLs are stored and reported immediately but not enforced (the enforced window comes from the --*-retention-days flag or its environment variable, #483), and OIDC keys apply only via Settings -> OIDC. See REST API -> When a change takes effect.
AD/Entra IntegrationPhase 7 (7.5)Sync users and groups from Active Directory (LDAP) or Microsoft Entra ID. Auto-create Yuzu principals from directory membership.
System Health MonitoringPhase 7 (7.2)Server-side health dashboard showing memory, CPU, disk, database size, connected agent count, and gRPC stream health. Prometheus metrics for server internals.

Fleet certificate inventory storage

The fleet certificate inventory (certificate_inventory_store schema, one row per device and certificate) is written by the certificate_inventory daily-sync source.