Upgrading
This guide covers upgrading Yuzu components (server, agent, gateway) between versions.
Fresh deployment only. 0.15.0 supports initial deployment into a freshly provisioned database; it provides no upgrade path from an earlier build’s database or backups and no return to an earlier build. The server does not enforce this: migrations do not refuse a database written by another build, and an importer that runs at any boot where the database holds no import record for the file still reads leftover
enrollment-tokens.cfgandpending-agents.cfgfiles without checking where they came from. Procedures below that describe upgrading, rolling back or restoring an earlier build’s data are historical and not supported for 0.15.0 (see “Deployment contract (hard cutover)” in the Forensics section of this page).
Behaviour change: the activity-record feature is renamed Echo (tar.db adopted as echo.db; stored references migrated)
What changed. The feature that records endpoint activity was called TAR and is now called Echo, with no aliases for the old names except the upgrade-window shim below. The agent plugin is echo (echo.dll, echo.so, echo.dylib; the Windows installer removes the old tar.dll). REST routes move from /api/v1/tar/... to /api/v1/echo/..., MCP tools from *_tar_* to *_echo_* (for example list_tar_retention_paused is now list_echo_retention_paused, and create_result_set_from_tar_query is now create_result_set_from_echo_query), metrics from yuzu_tar_* to yuzu_echo_*, audit actions from tar.* to echo.*, instruction ids from crossplatform.tar.* to crossplatform.echo.*, the agent heartbeat tags from yuzu.plugin.tar.* to yuzu.plugin.echo.*, and the result-set matcher kind from tar_rows_ge to echo_rows_ge (a request that still sends the old kind to POST /api/v1/result-sets/from-instruction-result or its MCP twin is now refused with an error naming the kind, as is any other unrecognised kind, instead of being accepted into a set that never matches anything). The OpenAPI operationId and tag changes are listed in the OpenAPI note further down this page.
Who is affected. Anything that calls the old routes or tool names, scrapes the old metric names (including the path_class label values tar_dashboard_sql and tar_result_set_sql on yuzu_body_cap_rejected_total and yuzu_server_csrf_denied_total, now echo_dashboard_sql and echo_result_set_sql), filters audit rows by tar.*, or references crossplatform.tar.* instruction ids outside the server’s own stored data. Update scripts, dashboards and alert rules to the new names (the metrics reference, docs/user-manual/metrics.md, lists the yuzu_echo_* series). Audit rows written before the upgrade keep their tar.* action spelling and are never rewritten: an echo.* filter matches both spellings, a tar.* filter matches only the old rows, so a SIEM rule that must see the whole history needs both.
Agents: tar.db becomes echo.db on first start. The first time an upgraded agent starts, before any plugin loads its data, it renames its existing tar.db (and the -journal, -wal and -shm files beside it) to echo.db and re-keys its plugin key-value namespace from tar to echo. Sidecar files move first, so an interrupted adoption simply resumes on the next start. The database keeps its contents and, on Windows, its owner-only DACL. On the next open the plugin renames the tables inside it (tar_state, tar_config and tar_cursor become echo_state, echo_config and echo_cursor) in one transaction and the file reads as schema version 9; the success lines are Adopted legacy ... as ... and EchoDatabase: migrated to schema version 9 (tables renamed). The agent never creates a new, empty echo.db beside an un-adopted tar.db: that would restart Echo with default settings and re-enable sources an operator had paused. Deliver this release’s agent with its package installer: an OTA update replaces the agent binary only, so the renamed plugin (echo.dll, echo.so or echo.dylib) may not be installed yet. While the plugin directory still holds the old tar plugin and no echo plugin, the agent does not load the old plugin (counted in yuzu_agent_plugin_rejected_total{reason="retired_name"}), logs legacy tar plugin still installed; adoption deferred until echo.<ext> is installed, and leaves tar.db and its key-value namespace as they are; the first start after echo.<ext> is installed adopts them. The same holds when the plugin allowlist or the signature check refuses the old plugin before it is opened: the file name tar.<ext> is enough to defer, and that rejection is counted under its own reason (load_failed, signature_missing, signature_untrusted_chain or signature_invalid) rather than retired_name.
Two cases the agent refuses, and what to do.
- Adoption fails. The agent logs
Legacy tar.db adoption failed: <cause>and Echo does not open (legacy tar.db present at ... and not adopted - refusing to initialise a fresh echo.db beside it); the rest of the agent runs normally. The cause names the file: typically a permission problem, or another process holding the file open (on Windows, a scanner or backup agent). A-wal,-shmor-journalfile that exists under both thetar.dband theecho.dbname is refused as well, because pairingtar.dbwith the wrong write-ahead log would silently drop committed rows. Fix the named cause. For the two-names case, copy both sets of files aside, then move theecho.db-wal,echo.db-shmorecho.db-journalfile out of the data directory (the sidecar cannot be paired safely with either database, and the copy you set aside is the only record of any rows it holds), leavetar.dband its owntar.db-*files in place, and restart the agent: adoption continues from where it stopped. Across a fleet, the agents whose Echo plugin did not start are counted byyuzu_fleet_plugin_init_failed{plugin="echo"}on the server, and an Echo dispatch to one of them is refused withplugin_not_found. - Both
tar.dbandecho.dbexist. These are two independent databases (for example an agent that was downgraded after the rename and upgraded again). The agent logsLegacy ... tar.db exists beside ... echo.db: two independent databasesand usesecho.db;tar.dband its sidecars are left untouched and are never read, merged or deleted. To look at the old history, open a copy oftar.dbwith a SQLite shell (its tables keep the oldtar_names); removetar.dband its sidecars when you no longer need them. Atar.db-wal,tar.db-shmortar.db-journalfile left in the data directory with notar.dbbeside it (for example aftertar.dbwas removed by hand) is never moved ontoecho.db: the agent logsLegacy ... tar.db-wal left in place: there is no ... tar.db to adoptand leaves the file, because a stale write-ahead log applied toecho.dbwould overwrite its current contents; delete it by hand. If theecho.dbyou did not expect is a file another account placed there, the Windows owner and hard-link refusal described in “Behaviour change: every Echo capture source is on by default” further down this page applies to it, with the same remedy. Rolling back an agent (not supported): a pre-rename agent build finds notar.db(the upgrade moved it), creates a fresh one and starts with every source enabled and default settings; upgrading that agent again is exactly this both-databases case.
Server: stored references. This release is a fresh-database deployment (see “Deployment contract (hard cutover)” further down this page), so a new server database holds the renamed vocabulary from the start and the migrations below find nothing to rewrite. Run against a database carried forward from an earlier build, which is not a supported upgrade path, the server does rewrite the stored references to the old names in eleven stores on its first start: instruction definitions, schedules, executions, approvals, device tokens, policies, workflows, responses, plugin configuration (including kill switches such as tar@windows and the enablement rules), persisted result sets and the command outbox. Each of those migrations is idempotent, and there is no return to an earlier server build after that first start. They are serialised by a database advisory lock, so every serving replica must run the new build, as the deployment contract requires: a second replica that starts while the first is still migrating waits up to the connection pool’s 10 s lock timeout, then does not start; start one replica first, let its first start finish, then start the others, which find the work done. The response-store and execution-tracker updates are one sequential pass over the retained response history and the all-time execution history; budget minutes, not seconds, on a multi-million-row store, before starting the other replicas. Operator-authored definitions keep their prose and SQL literals as written; only the plugin and the plugin references in their YAML move (plugin:, requiredPlugins:, and a - tar list item under requiredPlugins: or tags:; a bare - tar under any other key, such as a list of archive formats, is left alone).
Upgrade-window shim. Upgrade the server before the agents. An agent that has not been upgraded still reports the plugin name tar: the server treats it as echo everywhere (presence checks, help, health tags) and writes tar on the wire to that agent, including through the gateway directory route used for high availability, until the agent is upgraded and reconnects. The shim runs in one direction only: an upgraded agent talking to a server that has not been upgraded has no fallback, and every Echo dispatch to it is refused with plugin_not_found.
Behaviour change: a refused or unusable RHEL entitlement directory pauses the licence sync for that host
What changed. This is an agent change on Linux. The license_scan plugin now reads /etc/pki/entitlement in-process instead of running openssl x509. entitlement_certs is an authoritative surface, so an error on it keeps the last good state and skips that day’s software_licensing sync for the whole host: its RPM/dpkg, FlexLM and entitlement rows all stay at their last synced values until the cause is cleared. Three causes newly pause the sync. A directory the agent may not list is now cert_permission_denied (result status PERMISSION_DENIED); before, it read as an empty surface and the daily sync wiped the host’s stored entitlement rows while the other rows kept syncing. A .pem larger than 64 KiB is now a parse failure for that file; the openssl runs parsed any size. A directory or file past the new scan limits also pauses it: more than 4,096 directory entries or 64 certificate files, or more than 16 certificates in one .pem; the scan also stops starting new files after 10 seconds, where it was 60. An unreadable, empty or unparseable .pem already paused the sync before this release.
Who is affected. RHEL-family hosts where the agent account cannot list /etc/pki/entitlement, or where a .pem in it is oversized or junk. A host whose certificates are all readable, parse and are under 64 KiB each sees no pause. Whether the unprivileged agent can read the directory on a real RHEL host has not been measured, so check a RHEL host after the upgrade rather than assuming either answer.
First sync after the upgrade. A Linux host with entitlement certificates sends its full rows once more, because its rows can change: they are now ordered by file name rather than directory order, and a .pem with several certificates yields one row per certificate (up to 16 per file) where the openssl x509 runs read only the first. On macOS a bundle with a binary Info.plist now reports its display name and version where it reported the bundle name and an empty version.
What to do. Run the license_scan plugin’s surfaces action on a RHEL host and read the probe_status|entitlement_certs| line; remove the cause its token names. See When the entitlement directory is refused.
Rollback. Rolling an agent back to an earlier build restores the silent wipe: a directory it cannot list reads as empty again and the next sync replaces the host’s stored entitlement rows with nothing.
New: leader-election metrics and alerts (HA WS-11, #4014)
Additive; no behaviour change and no action is needed to keep a deployment working. Every server with a
Postgres-backed elector now exposes seven yuzu_server_leader_* series on /metrics (reference:
docs/user-manual/metrics.md, “Leader election metrics”), and docs/prometheus/yuzu-alerts.yml gains the
yuzu-ha-leader group: YuzuLeaderSplitBrain, YuzuLeaderNoLeader and YuzuLeaderCoordinationStale
(critical) and YuzuLeaderFlapping (warning). Reload your Prometheus rule file to pick them up. The
alerts aggregate sum by (job), so Prometheus must scrape each replica individually and unrelated Yuzu
deployments sharing one Prometheus need separate jobs. On a single-replica deployment YuzuLeaderNoLeader
is the alert that can fire: it means the leader-only background passes are paused while /readyz stays
green (leadership is deliberately not a readiness input). Runbook: docs/user-manual/server-admin.md,
“Leader election: metrics and alerts (HA WS-11)”. The thresholds are initial claims without fleet data.
Two things to know: the leader_elector: acquired leadership ... at epoch N log line now ends with
(holder_id=srv-…) (match on the prefix if you alert on that line), and no server up == 0 alert ships, so
configure one (these alerts need a successful scrape; see the runbook). If you ever run mixed versions of a
multi-replica deployment (unsupported today), reload the rule file only after the last replica is upgraded, or
silence YuzuLeaderNoLeader for the window: an old binary exports no yuzu_server_leader_* series, so an upgraded
follower’s 0 with an old leader’s absent series sums to zero and the alert fires after 5 minutes. Silence it
during planned Postgres maintenance longer than 5 minutes as well, scoped to the job and ending shortly after the
window (the silence must expire), then confirm a replica reads yuzu_server_leader_is_leader 1.
This does not lift the safe-to-scale gate (docs/ha-delivery-matrix.md); running a second replica is still
unsupported.
Behaviour change: device.filesystem.find_by_hash is bounded and reports what it skipped (#5624)
What changed. The agent hashes in-process and stops at the first of 20 seconds, 50,000 entries visited, 4 GiB hashed or 100 matches. The limits are fixed; scan a large tree one subdirectory at a time. When anything was skipped or the search stopped early, a truncated|<reason>|visited=N|... row precedes matches_found|N. Any symlink counts, so most scans have one; scripts must not read it as a match. The result is CONSTRAINED/PARTIAL when a limit was hit, an entry was unreadable, a network mount, UNC share or non-fixed Windows drive was skipped, or the mount table could not be read (a network_mount_guard|unavailable row says so). Symlinks are no longer followed, so a symlink to the target file is not reported; empty files are now hashed. Agents and server may upgrade in either order.
Stored definition. Upgrades keep definition 0.3.0, which declares the columns path, size and matches_found. The rows are unchanged, but the stored definition does not mark a row discriminator, so until it is replaced a column matcher on this instruction matches no device, as it did before (path contains X misses real hits). Check GET /api/v1/instructions/device.filesystem.find_by_hash for "version":"0.4.0". To adopt it, replace the definition after every agent is upgraded, because the 0.4.0 description promises a bounded walk that never follows symlinks, which an agent older than this change does not do (Replacing a stored parameter schema; edit result_schema, not parameter_schema). A replaced bundled definition never receives later corrections. A column matcher saved before the replacement is not rewritten: recreate it afterwards.
⚠️ Breaking: the registry plugin 2.0.0 writes typed values, verifies them and refuses registry links
registry 2.0.0 changes three existing actions. set_value writes any of six value types (REG_SZ, REG_EXPAND_SZ, REG_BINARY, REG_DWORD, REG_MULTI_SZ, REG_QWORD) from value text or exact data_hex bytes, captures the previous value, reads the new one back on the same handle and fails with verify_mismatch when it differs. It validates its type and numeric text instead of storing every non-REG_DWORD type as REG_SZ: 1.0.0 parsed a REG_DWORD value with stoul, so 0x10 wrote 0, -1 wrote 4294967295 and 12abc wrote 12, abc or an empty value threw out of the plugin, and a type spelt in lower case (reg_dword) stored the text as REG_SZ; 2.0.0 refuses each of those (invalid_data, value_overflow, invalid_type). A creating write whose first key component is missing directly under the hive root is refused root_level_create. delete_value and delete_key fail with tokens (key_not_found, value_not_found, key_has_subkeys, access_denied, …) instead of sentences; delete_key still removes only a key with no subkeys, and on success appends four rows (counted_keys, counted_values, dry_run, capped) after status|ok. All three mutating actions now validate their input before anything is opened and refuse it as invalid_key_path or invalid_value_name: a key path that is empty, longer than 255 bytes or 32 components, has an empty, . or .. component (so a leading, trailing or doubled backslash), begins with a hive alias (HKLM\..., HKEY_LOCAL_MACHINE\...: the hive is its own parameter), or holds a control byte or invalid UTF-8; and a value name longer than 16,383 bytes or holding a control byte or invalid UTF-8 (control bytes are refused on the instruction and MCP routes; POST /api/command passes an embedded NUL to the plugin as a C string, so the value is cut at it). Every mutating action emits one leading audit| row before status|ok or error|<token>, so the first output line of these actions is now the audit row once the target resolves; a missing or unparseable hive or key is a bare error| line with no audit row. Registry links are refused, not followed: 1.0.0 followed them, so every set_value, delete_value and delete_key under HKLM\SYSTEM\CurrentControlSet (itself a link to the active control set) now answers error|key_is_link; reads still follow it. The read actions (get_value, enumerate_keys, enumerate_values, get_user_value) map |, CR and LF in registry data to _.
- Affected: scripts that write under
HKLM\SYSTEM\CurrentControlSet(useHKLM\SYSTEM\ControlSet001, or the numbered setSelect\Currentnames); scripts that create a key directly under a hive root; scripts that rely on a multi-component create leaving its first key behind when a later component fails (2.0.0 tries to remove every key the call created; one it cannot remove, or that now holds a value or key another party put there, stays and is reported ascreated=<n>withresult=failed); scripts that create keys under a tree whose inherited ACL gives the agent identity noDELETEon new keys (the create now answersaccess_denied; see the registry README, caveat 3); scripts that parseset_valueerrors as sentences, read the first output line of a mutating action, or expectstatus|okto bedelete_key’s only row; scripts that pass a key path or value name 2.0.0 now refuses (above); scripts that spell a key with different NON-ASCII letter case than it is stored with (refused askey_is_link, nothing written: spell the key as stored); and scripts that parse|out of read output expecting a literal|from registry data; and scripts that create keys belowHKCU\Software\Classesfor a real user. For a real user’s hiveHKCU\Software\Classesis itself a registry link (to the user’s_Classeshive), so a creating write below it is refusedkey_is_link(1.0.0 followed it); as SYSTEM, the agent’s identity,HKCUisHKU\.DEFAULTandSoftware\Classesis not a link, so such writes succeed; to write a user’s classes usehive=HKUwith<SID>_Classes. - Rollout order: upgrade the server replicas first, so the
registry.writeaudit hook is live before any agent emitsaudit|lines, then the agents. Roll a fleet-wide write out in waves: every reply of a mutating action writes one audit row synchronously on the server’s shared database pool, so a write that reaches many agents at once should not exceed what--postgres-pool-sizewas sized for (#6295). - Minimum agent version for
registry.writerows: agents at v0.14.1 or earlier never stamp the dispatched action on their output frames and mint noregistry.writerows, silently, while the write succeeds. 0.15.0 is the first release that contains PR #6294 (merge5f4064fb4); an agent built from any earlier commit does not stamp the action. - Mixed fleets: an agent still running registry 1.x writes an EMPTY
REG_SZover the value for adata_hex-onlyset_valueand answersstatus|ok(1.x reads onlyname,valueandtype; withtype=REG_DWORDanddata_hexonly it throws and writes nothing), storesREG_QWORD,REG_MULTI_SZ,REG_EXPAND_SZandREG_BINARYvalues asREG_SZtext and reportsstatus|ok, and follows a write underHKLM\SYSTEM\CurrentControlSetthat 2.0.0 refuses. The server does not gate dispatch on a plugin version (minAgentVersionis declared and exported, never compared at dispatch), so upgrade the agents before using the new value types ordata_hex. A NUL inside a parameter sent throughPOST /api/commandcuts the value at the NUL on every agent version (the instruction and MCP routes refuse control bytes). This reaches every way aset_valueis dispatched: review schedules, workflows, policies and bundles that carry one, not only scripts. A reply that is a barestatus|okwith no leadingaudit|row came from a 1.x agent: read the value back withget_value. Until the fleet is upgraded, scope such a write to upgraded devices (ostype == "windows" AND tag:<key> == "<value>", with a tag that marks the upgraded devices) or switch the action off with theset_valuekill switch:PUT /api/v1/plugin-config/registry/kill-switch?action=set_valuewith the JSON body{"enabled": false, "reason": "registry 1.x agents still in the fleet"}(enabledis required and must be a boolean;reasonis optional), which needsPluginConfig:Write(or the MCPset_plugin_kill_switchtool);{"enabled": true}switches it back on (REST API). - Existing installs keep the 1.0.0 definitions: bundled definitions are inserted only when absent, so a server that already holds
windows.registry.set_value,delete_valueanddelete_keykeeps their 1.0.0 text, enum and parameters (nodata_hex, atypeenum ofREG_SZandREG_DWORDonly,valuerequired) after the upgrade, and a server that holdswindows.registry.get_valuekeeps its 1.0.0 description (only that text changed in 1.1.0). To get the 1.1.0 text, enum anddata_hex, export, delete and re-import the three write definitions (docs/user-manual/instructions.md, “Replacing a stored parameter schema”); re-importingget_valueis optional and cosmetic, because its parameter schema is unchanged and only its description text differs. A fresh install already has all four.
2.0.1: registry 2.0.1 bounds get_value: it reads at most 1 MiB (the bound get_user_value and the write path already use), and a larger value answers error|value exceeds 1 MiB limit with rc 1 instead of being read whole into the agent’s memory. A get_value whose value kept growing through the bounded re-reads now answers error|value changed while reading ... and any other failed read error|win32:<code>; only a missing value still answers error|value not found. 2.0.1 also widens the fold of the read actions and of every plugin row rendered through the shared sanitize_field: beside the |, CR and LF folded since 2.0.0, every other control character except TAB (ESC, VT, FF, FS, GS, RS and an interior NUL among them), DEL, the C1 controls U+0080-U+009F (NEL, CSI and OSC among them) and U+2028 and U+2029 each become _, and trailing NULs are dropped, so an embedded NUL in a name or in get_value data no longer cuts a row short (get_user_value and list_profiles read a string value up to its first NUL, as Windows does). The rows that gain the fold are the registry read actions (list_profiles and get_user_value included), the hive-access lines of installed_apps and autoruns, and the REG_MULTI_SZ and REG_LINK values that autoruns and the execution_artifacts AmCache walk read through read_reg_value. The rendered text is display text: the folded characters cannot be recovered from it. The registry.write audit tokens already folded the C0 controls and DEL and gain the C1 controls, U+2028 and U+2029. Affected: callers that read a value over 1 MiB, callers that match the error|value not found line for every failed get_value, and callers that compare a rendered name or value with the raw registry text. An agent with registry 2.0.0 keeps the unbounded read and the narrower fold until it is upgraded.
registry plugin 2.1.0: per-user writes, apply and recursive delete_key (additive, with a mixed-fleet hazard)
registry 2.1.0 adds three actions: set_user_value and delete_user_value write or delete one value in a user’s hive (the live HKEY_USERS\<SID> hive, or an offline NTUSER.DAT behind the hive-file guard; read back, and on the offline arm flushed before the unload), and apply runs a declarative plan of up to 256 HKLM set_value / delete_value / create_key / delete_key items with a dry run by default and an undo manifest in the reply (nothing is persisted on the endpoint). delete_key gains two optional parameters, recursive and dry_run; without them it behaves exactly as 2.0.1. The server’s audit binding now treats six registry actions as mutating, so the three new ones mint registry.write rows too: apply one per plan item it attempts, and a dry run mints success rows with dry_run=1 (a reader filtering result=success must also read dry_run). An apply of 256 items is up to 256 rows, so three of them inside one minute on one agent exceed the server’s 600 rows per agent per minute and the later rows are suppressed (counted and logged, as for any frame past the budget).
- Affected: every agent released before 2.1.0, which runs registry 1.x or 2.0.x (v0.14.1 ships registry 1.0.0), and every server older than this release.
- Rollout order: server replicas first, then the agents, and use the new actions and flags only when both are done. An older server mints no
registry.writerow forset_user_value,delete_user_valueorapply: the writes succeed unaudited. - Mixed fleets: read this before using
dry_run. A registry 1.x or 2.0.x agent reads neitherrecursivenordry_run. Adelete_keywithdry_run=truesent to it DELETES the key when it has no subkeys (a key with subkeys is refused) and answers as for a real delete: a barestatus|ok(1.x) orstatus|okwithdry_run|0(2.0.x) in answer todry_run=truemeans the key was deleted.recursive=trueis ignored the same way, and the three new actions answererror|unknown action. The server does not compare a plugin version at dispatch (the definitions’minAgentVersionis declared and exported, not compared; relates to #6484), so upgrade the agents to registry 2.1.0 first, in schedules, workflows, policies and bundles as well as scripts, and scope any preview to devices verified at 2.1.0 with a tag that marks them (ostype == "windows" AND tag:<key> == "<value>", the same guidance as the 2.0.0 mixed-fleet note above). Until the fleet is upgraded, thedelete_keykill switch is the stop-gap:PUT /api/v1/plugin-config/registry/kill-switch?action=delete_keywith{"enabled": false, "reason": "registry 1.x/2.0.x agents still in the fleet"}(PluginConfig:Write, or the MCPset_plugin_kill_switchtool). As declared, theminAgentVersion: 0.15.0pin (ondelete_key1.2.0, the three new definitions andset_value) separates registry 1.x (v0.14.1) from 2.1.0 but cannot separate 2.0.x from 2.1.0 if both first ship in 0.15.0. - Re-importing
delete_key, and when. Bundled definitions are inserted only when absent, so a server that already holdswindows.registry.delete_keykeeps its stored text and parameters (1.0.0 on an install upgraded from a release, 1.1.0 on one built from dev; neither hasrecursiveordry_run); the three new definitions are inserted on the upgrade. Re-import it only after every targeted agent is verified at 2.1.0: the stored schema refusesdry_runon the instruction route, so the re-import is the step that makes the hazard above reachable there (POST /api/commandtakes no schema and reaches it from day one). Do it once, together with the 2.0.0 re-import above if that is still owed: export the stored definition, delete it, and import the bundled 1.2.0 document fromcontent/definitions/registry.yamlwith itsyaml_source, which carries the mixed-fleet text (docs/user-manual/instructions.md, “Replacing a stored parameter schema”). - What
recursivecan and cannot do. A tree is vetted first (at most 10,000 keys and values and 600 levels; past that a real run is refusedtree_too_largeand nothing is deleted, a dry run reports a lower bound withcapped|1), a registry link anywhere in it, or a key whose ACL denies the delete, refuses the call before anything is removed. A real run is not atomic: a link or a malformed name planted after the vet, a tree that grew past the bound, or a delete a security product refuses stops it part-way, the keys already removed stay removed and the result isfailed. The counts of a real run are what it removed, which can exceed a dry run’s snapshot (a key added after the vet is deleted like any other). The undo rows of a deleted key live only in that reply and in the stored response, which is kept 90 days by default (--response-retention-days). See the registry README, “How it works”. - What the undo manifest can and cannot restore. It restores values, not keys: a deleted key’s owner, DACL, SACL, class and volatility are not captured, and it is a snapshot taken at capture time. Save each reply to the change record when it arrives; replay in reverse item order (the registry README, “Undoing a plan”).
⚠️ Breaking: the runtime-configuration read shows a caller only the overrides it could write
What changed. GET /api/config (and the new GET /api/v1/config and MCP tool get_runtime_config) leave out the stored override of every key the caller could not write with PUT /api/config/{key}: the oidc_* keys, plugin_signing_required, the DEX alert keys and audit_retention_days, plus the effective audit_retention_days. An administrator account’s GET /api/config body is what it was, apart from the log_level spelling below, so the Settings pages are unaffected. An MCP-tier token, a non-administrator role holding Infrastructure:Read, and (for the oidc_* keys) an elevated-only session now get a smaller overrides object. Before, they received every override, including oidc_admin_group. The legacy route does not say what it left out; GET /api/v1/config returns withheld_keys.
Also in this release. heartbeat_timeout must now be 30 to 86400 seconds on every write route (the upper bound is new), a stored value outside that range is moved to the nearest bound at server start, and the read reports log_level as warn (the spelling the write accepts) instead of warning. See REST API: Runtime Configuration.
What to do. Read the configuration with an administrator account or an administrator API token that is not MCP-tier if a script needs the administrator-only overrides.
Behaviour change: a Guaranteed State delta push no longer drops the retry obligation of a rule it omits (#5547)
What changed. This is an agent change. The agent holds an acknowledgment application per policy push and reports a policy generation only once that application has no unresolved arm. Before this release every push replaced the open application, so a delta push (full_sync=false) that named only some rules forgot the pending arm, retained wedge or drained failure of every rule it left out. The generation could then be acknowledged while an omitted rule was still unarmed, with nothing left to retry it. Now a delta carries the unresolved outcome of each rule it does not name into the new application, and the following differ:
- The generation stays held. While a carried rule is unresolved the agent does not acknowledge the generation. On an agent that enforces through the Spark engine (
prefer_spark=true), a carried arm failure or wedge also keeps counting inyuzu.guardian_arm_failed(fleet gaugeyuzu_fleet_guardian_arm_failed). A carried synchronous failure holds the generation without being counted there, and at the defaultprefer_spark=falsethe agent emits no arm gauge at all. Only a full push, or a delta that names the rule, replaces the carried entry. Repeating the same delta does not retry it. - No automatic full push follows. The REST and MCP push endpoints do not bump the policy generation (a rule create, update or delete does, and the heartbeat reconcile then re-pushes the trailing agent), so a delta at the generation the agent already holds is not followed by a heartbeat reconcile. To retry a carried failure, send a full push (after fixing any store write fault the agent log reports, because a full push can fail partway while one persists; see below):
POST /api/v1/guaranteed-state/pushwith{"full_sync": true}and ascopecovering every agent the delta reached (or the MCP toolpush_guardian_ruleswithfull_sync), as in Guaranteed State. At the defaultprefer_spark=falseno per-agent signal names the affected agents (see below), so the scope of the delta that carried the hold is the one to use. A full push first tears down every legacy Guard on the agent (at the defaultprefer_spark=false; on a Spark-enforcing agent a healthy, unchanged rule is left alone) and then re-applies the rules it carries, so it disturbs every agent in its scope, not only the affected ones. It reaches only agents that are connected when it is sent: the202response counts the agents it was queued to, and an agent that is disconnected, or connected to another server replica, is not in that count; if it already holds the server’s generation it is not reconciled when it returns, because this push bumps no generation, so send the full push again once the agent is connected to the server that handles it. The202does not report whether an agent applied the push; the evidence is each agent’s ownGuardian: apply_rules ok (... failed=N ...)log line for that push. A missing line does not by itself mean the push failed partway: it is also missing when the push was refused or rejected, when the agent runs at a log level above info, and, withprefer_spark=true, when the push was suppressed as an identical retry. A failed store write is logged as aKvStore::seterror. If, at log level info, that error appears between the push’sReceived command: plugin=__guard__, action=push_rulesline and the nextReceived commandline (or the end of the log), and noapply_rules okline appears in that window, a rule write failed (other store writers can log the same error, including inside that window, so confirm it is the push’s own rule write): that rule and the rules after it were not re-armed, and a full push at a generation the agent already holds is not followed by an automatic resend. If theapply_rules okline did print, the push completed and only the generation write failed, so the agent keeps its previous generation. Fix the store fault, then, if a rule write failed, send the full push again. Re-deploying a Baseline that delivers the rule also retries it: it bumps the generation and sends a full push to the agents connected to the server that handles the deploy, and unlike a REST or MCP push the heartbeat reconcile then re-pushes an agent that still trails (against a persisting store fault that agent is re-pushed, and torn down, again at each reconcile); a wedged rule is the exception: it stays held until its hung call returns or another exit in flip gate AC-1 applies (a full push that drops the rule, an edit that changes its watch target, or an agent restart, each with AC-1’s conditions). Deleting a deployed Baseline also bumps the generation and sends a full push, but it withdraws every Guard that no other deployed Baseline delivers, so it is not a repair: a rule withdrawn that way is no longer enforced, and the hold on it ends because the rule is no longer pushed, not because it armed. Deleting a Baseline that was never deployed changes nothing on agents. - A failure with no rule id is inherited, on the legacy path too. A full push whose rule-key sweep, key delete or teardown fails, a push that cannot persist a rule, and a push interrupted by an exception used to be forgotten by the next delta, which then acknowledged the generation. The failure is now inherited by later deltas and cleared only by a full push whose steps all succeed. This applies at the default
prefer_spark=false, so it changes behaviour on agents that do not enforce through the Spark engine. - Boot re-arm failures keep their identity. A failed start-up re-arm is recorded against its rule, so an unrelated delta no longer clears
yuzu.guardian_boot_rearm_unresolved, except when the boot application could not be opened (see the residual below). A delta that names the rule clears it only when it arms the rule cleanly and nothing else is still unresolved or inherited. Only a full push now counts toward the boot catch-up back-off, so a stream of unrelated deltas neither extends nor refreshes it. A known residual remains: if the agent could not open its boot application (an allocation failure at start-up) and a boot re-arm also failed, the failure has no identity to carry, so the first later clean push, even an unrelated delta, clears the flag while that rule stays unarmed. It is live at the defaultprefer_spark=false(see AC-13 in the Spark flip gate). No in-product signal marks it, because the flag has cleared: the only trace is the agent’s start-up log (Guardian: rule '<id>' failed to re-arm (...)andN cached rule(s) failed to re-arm at boot), and a restart re-runs the boot re-arm pass over the cached rules, which arms the rule only if the cause has cleared. - A push that names the same rule id twice is rejected before anything is changed. Rule ids are the primary key of the server’s rule table, so only a hand-built push is expected to contain one.
- An allocation failure after the agent has begun applying a push now leaves the application latched instead of acknowledgeable.
Who notices. Operators who push deltas through the REST or MCP endpoints, and anyone watching yuzu.guardian_arm_failed. A push to either endpoint without full_sync is a delta, because full_sync defaults to false. The dashboard’s Baseline deploy, the delete of a deployed Baseline, and the heartbeat reconcile of an agent that trails the server’s generation send full pushes and are not affected. The gauge used to reset on every replacement; it can now hold at a nonzero value across omitting deltas until a full push or a push naming the rule replaces it (a wedge whose hung call is still outstanding is re-observed and counted again). That includes an entry that no longer reflects the rule’s true state (a wedge adopted or settled before the agent retained it, the flip gate’s AC-15), so a sustained 1 can persist for a rule that has since armed. The wedge suppress budget has always restarted on every replacement; what is new is that a carried hold survives the replacement, so alternating deltas and full pushes leave a hold with no elapsed-time bound. See Guaranteed State and the Spark flip gate (AC-11, AC-15).
What you will see.
- On a Spark-enforcing agent, a rule that fails to arm stays counted in
yuzu.guardian_arm_failedafter an unrelated delta, where it previously dropped to0and the generation was acknowledged. - At the default
prefer_spark=falsethe agent emits no arm gauge, and this release has no in-product detector for a carried synchronous failure there. The signals that exist are the agent log line of the original failure, where one is logged (for exampleGuardian: reconcile threw for rule '<id>', orGuardian: full_sync teardown threw; an exception that unwinds out of a push logs no Guardian-specific line) and, only while the server has pushed a newer generation than the agent holds, the agent’syuzu.guardian_generationnot advancing. That heartbeat tag is not listed per agent by any operator page or API in this release (see Guaranteed State); the server-side view of an agent that trails is its repeatedguaranteed_state.reconcileaudit rows and the rise ofyuzu_server_guardian_reconciles_total{result="sent"}, which has no per-agent label. A delta at the generation the agent already holds shows none of these. - An agent whose start-up re-arm failed reports policy generation
0andyuzu.guardian_boot_rearm_unresolvedof1through unrelated deltas, with two exceptions: during the catch-up back-off window it reports its real generation while the tag stays1, and in the start-up residual described under “Boot re-arm failures keep their identity” above, an unrelated clean delta clears the flag. A full push that applies cleanly clears it, as does a delta that arms the failing rule with nothing else outstanding. - A held agent whose reported generation trails the server’s is re-pushed once the server’s 25 s per-agent limit has elapsed, which at the default 30 s heartbeat is every heartbeat unless the replica sheds the reconcile. Each push writes one
guaranteed_state.reconcileaudit row (the write is best effort) and is a full push that re-applies all of that agent’s rules. There is no generic back-off yet (see limit 3 under Known limits in Guaranteed State). The audit volume of repeated re-pushes to held agents is estimated under Costs in the wedged-rule hold note in Guaranteed State. - The
yuzu_fleet_guardian_arm_failedandyuzu_fleet_guardian_arm_pendingHELP texts change to describe this. The metric names and labels are unchanged. - To diagnose a report of “agent not enforcing rule X after upgrade”, collect: the agent version (a build without this change forgets the rule, see Restart and rollback), the agent’s
prefer_sparksetting, the server’s policy generation against the one the agent reports, whether the last push to that agent was a delta, the agent’sguaranteed_state.reconcileaudit rows, and the agent log lines of the original failure listed above.
Restart and rollback. The carried state lives in agent memory only. Restarting the agent discards it, but does not by itself repair what caused it. The agent re-arms its cached rules at boot and a rule that still cannot arm is recorded again, in which case it reports generation 0 with the companion tag yuzu.guardian_boot_rearm_unresolved rather than its persisted generation. A rule that was never persisted (a persist failure on the push) is not among the cached rules, and no reconcile follows while the agent’s generation equals the server’s, so send a full push for it. If the store fault that stopped the persist is still present, that full push fails partway too (see “No automatic full push follows” above), so fix the store first. Rolling an agent back to an earlier build restores the behaviour this change removes: an omitting delta forgets the unresolved rule and the generation can be acknowledged while the rule is unarmed. An agent on the earlier build also resets its arm counts on every replacement, so a falling yuzu_fleet_guardian_arm_failed after a rollback is not evidence of repair; compare agent versions. No data change is involved.
Rolling upgrades. Agents that have not been upgraded keep the previous behaviour, and the fleet gauges sum agents that behave differently until all are upgraded. No server configuration or data migration is required; the server change is the metric HELP text.
⚠️ Breaking: seven runtime-config keys are administrator-only on PUT /api/config/{key}
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, which are administrator-only. audit_retention_days joins them by design, for a different reason: it is the stored audit-retention setting (reported by GET /api/config, evidence-bearing, and the lever that will govern enforcement once #483 lands). Today a stored value is stored only and is not enforced (see “A stored audit_retention_days is not what the server enforces” below). It has no Settings route at all, so PUT /api/config/audit_retention_days is its only writer and it follows the same caller rule rather than mirroring a Settings route. It is administrator-class while response_retention_days and guardian_event_retention_days stay on Infrastructure:Write because it is audit evidence and the other two are operational data; all three are equally stored only today, and the choice should be revisited if any retention store gains a setter. A session with an MCP-tier token or a service-scoped token, or whose effective role is not admin (an active temporary elevation counts as admin), gets 403. The role is the account’s legacy role or a live temporary elevation, never an RBAC grant: an RBAC Administrator grant alone does not count. An engine principal is never admitted either. The refusal happens before the runtime-config store is opened and before the request body is parsed, nothing is stored, and the audit log records config.update with result denied, target_type RuntimeConfig, target_id the key and no preceding attempted row. detail is token_session_admin_setting_blocked (an MCP-tier token; in production only a supervised-tier one reaches it) or admin_role_required_setting_blocked (effective role not admin). The 403 body carries a remediation (“change this setting with an administrator API token that is not MCP-tier, or as an administrator from the Settings page where one exists”) and no permission field. Before this change the route admitted these keys under Infrastructure:Write alone.
The oidc_* rule is unchanged (your own role must be admin; an elevation and an MCP-tier token are refused; the handler also refuses a service-scoped token, as defence in depth, because the permission gate already does). Only the caller rule is shared with the Settings routes: the generic route still validates less than they do (they validate and clamp DEX values and tag keys and apply them live), so a value this route accepts may still be rejected or clamped when saved through Settings. The shape of GET /api/config is unchanged by this note (the read-scoping change is the first note above). Every other key stays writable under Infrastructure:Write, including response_retention_days, guardian_event_retention_days, log_level, heartbeat_timeout and auto_approve_enabled, and that includes a supervised-tier MCP token whose creator is an administrator. That is deliberate for now, pending a maintainer ruling.
Who is affected.
- Automation that writes these keys with a supervised-tier MCP token, even when an administrator minted the token.
- With RBAC on, a non-administrator role that holds
Infrastructure:Write, and a user whose only administrator authority is an RBAC Administrator grant (the account’s own legacy role is notadmin), who getsadmin_role_required_setting_blocked. - Service-scoped,
readonlyandoperatortier tokens were already refused earlier by the permission gate (auditauth.permission_required), so they see no change and produce noconfig.update / deniedrow.
How to detect. After the upgrade, search the audit log for action config.update with result denied: GET /api/v1/audit?action=config.update&target_type=RuntimeConfig&target_id=<key> for one key (target_id requires target_type), or drop target_id and keep rows whose result is denied. The detail values above say which rule fired. The Settings routes audit their denials as auth.admin_required, so a SIEM rule keyed on auth.admin_required will not see a refusal on this route: add config.update with result denied.
Review stored values. Before this change a supervised-tier MCP token could write these seven keys. Stored values persist across the upgrade, and the dex_* ones are re-applied when the server starts; plugin_signing_required only records intent (see REST API, “records intent only”). To check what is stored now:
GET /api/configwith an administrator account (any other caller does not receive these keys) and readupdated_byandupdated_atfor each of the seven keys underoverrides. This includesaudit_retention_days.- Query the audit log for each key as above and read the
successrows (detailisvalue=<new value>). Foraudit_retention_daysuseGET /api/v1/audit?action=config.update&target_type=RuntimeConfig&target_id=audit_retention_days.
Two limits. The stored audit events carry principal, principal_role, principal_class (agent for bearer tokens), session_id, source_ip and user_agent, but not the MCP tier, and mcp_tool is empty for REST calls, so a past MCP-tier write cannot be told apart from an administrator API-token write. GET /api/v1/audit returns only id, timestamp, principal, action, result, target_type, target_id and detail; principal_class and session_id are present only in the stored rows (the legacy GET /api/audit adds principal_role and source_ip). And rows older than your enforced audit retention window (--audit-retention-days, not a stored audit_retention_days) may have been pruned. If a value is suspect, set the key to the value you intend from the Settings page (DEX alerts, Plugin Code Signing) and read it back with GET /api/config. audit_retention_days has no Settings page: replace a suspect stored value with PUT /api/config/audit_retention_days using an administrator account (an administrator API token that is not MCP-tier, or an administrator session). Saving the DEX blast form writes all three dex_blast_* keys at once. For plugin_signing_required, toggle the plugin-signing checkbox: it records intent only and does no further validation. The updated_by of a Settings write is the username for the DEX forms and the literal ui for the plugin-signing checkbox (the clear-trust-bundle action records system).
A stored audit_retention_days is not what the server enforces. The audit store copies its retention window from --audit-retention-days (or YUZU_AUDIT_RETENTION_DAYS; the default is 365 and there is no enforced minimum, so 365 is a default, not a floor) when it is constructed, and the server applies stored runtime-config overrides afterwards, so at boot the stored value only updates the in-memory config that GET /api/config reports. As the code is ordered, a low stored value did not by itself make the server prune audit events earlier, and a restart does not make it take effect. Read the window actually enforced from enforced in GET /api/v1/settings/data-retention, and change it with the flag or environment variable and a restart. To find out whether audit events were in fact pruned, check the retention-pass log lines and the oldest audit row, not the stored value.
What to do. Change these seven settings with an administrator account (the account’s own role must be admin; an RBAC Administrator grant alone does not count): through the Settings pages for the six that have one, and for audit_retention_days (no Settings page) with PUT /api/config/audit_retention_days and an administrator API token that is not an MCP-tier token, or an administrator session. A cookie session is a browser write and needs a same-site Origin or Referer header (otherwise 403 and a csrf.denied audit row); an administrator API token that is not MCP-tier is exempt from that check. PUT only stores the audit_retention_days value: the window the server enforces is --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 above is a precondition of that upgrade. response_retention_days, guardian_event_retention_days, log_level and heartbeat_timeout stay on Infrastructure:Write for now, so automation that writes only those needs no change.
Rolling upgrades and rollback. The check is enforced per server replica. During a mixed-version roll the same call can return 200 on a replica that has not been upgraded and 403 on one that has, so finish the roll before relying on the restriction. Rolling a replica back reopens it. No data changes in either direction.
Config-as-code replay. A caller that is not an administrator account (for example an MCP-tier token) no longer reads the administrator-only overrides at all: GET /api/config leaves them out of overrides, so a backup taken with such a token silently lacks them, and a reconcile client that treats a missing key as “unset” sees them as unset. Writing one of those keys back with that caller still stops with 403, so keys written before that point stay applied and the rest are not. GET /api/v1/config lists the withheld keys in withheld_keys: treat each as indeterminate, not unset. Take the backup, and replay, with an administrator API token that is not MCP-tier, or write the seven keys separately (the Settings pages for the six that have one, PUT /api/config/audit_retention_days with an administrator account for the seventh).
Behaviour change: the sessions table rejects an inconsistent principal class (#2343)
What changed. The durable sessions table (schema session_store) gains a CHECK that allows
exactly two pairings: a human session (any source except engine_token) or an engine session
(engine_token). The migration runs automatically at boot. Every decision, gate and audit-class
site that reads a session now classifies an engine with the same Session::is_engine() rule.
Who is affected. Nobody in normal operation: the server writes only human sessions from a
local, OIDC or SAML sign-in, and API, MCP and engine token sessions are never stored. Only a
session row edited by hand into another pairing is affected: the migration deletes it, logs a
WARNING in the Postgres server log (session_store v2 (#2343): deleted N inconsistent session row(s); with the default libpq notice handling it also appears, prefixed WARNING:, on the
server process’s standard error, so check the journal there if the Postgres log is not
available to you), and that principal signs in again. The migration also bumps the session write generation in that case, so other replicas drop
their cached copy of the deleted rows within about a second. A clean upgrade deletes nothing and
bumps nothing.
What to do. No action is needed, with one precondition. Do not start the upgrade while
pg_dump or another long transaction is running: the migration takes a brief exclusive lock on
sessions and waits at most 3 seconds to acquire it; once held, the migration scans the table
once, and the table normally holds only a few dozen rows, so the hold is brief. If it cannot get the lock the migration rolls back
completely and the server refuses to start, logging canceling statement due to lock timeout on
the preceding PgMigrationRunner line; start it again once the transaction ends. While it waits,
sign-ins and session checks on replicas that are still running pause for up to those 3 seconds,
plus the time the migration then holds the lock.
An older binary can still run against the upgraded database.
Behaviour change: engine principals are default-deny when RBAC is off
What changed. An engine principal’s only authority is an explicit RBAC grant. With RBAC off, the
legacy-open fallback that admits every authenticated human no longer applies to an engine
credential: GET /api/v1/upload-grants answers it 403 (503 when the RBAC store is
unavailable), and the shared per-agent list reads, device visibility, scheduled arming and
dispatch-permission checks return no agents or refuse it instead of treating it as legacy-open.
Who is affected. Only deployments whose engine credentials ran with RBAC disabled (the default) and relied on that fallback. Human sessions are unchanged.
What to do. Enable RBAC and assign the engine principal the grants it needs (for example
UploadGrant:Read to list upload grants). Enabling RBAC applies to every operator, so assign the
human roles first and follow the prerequisites in Role-Based Access Control.
⚠️ Breaking: a management-group-confined operator, or one with no Inventory grant, is refused the unfiltered issued-certificate list
What changed. GET /api/v1/ca/issued without a filter parameter, the MCP tool list_issued_certs without filter arguments, and the issued-certificate list in the Settings certificate panel now apply the confinement check the filtered form already used. A caller whose Inventory:Read is granted only for management groups (a confined view), or who holds no Inventory:Read grant at all, gets 403 (MCP: permission denied) with no permission named. In the Settings panel only the issued-certificate list is withheld, with a note in its place; the CA details (trust mode, algorithm, validity, root fingerprint), the root, signing-request and CRL downloads and the import form are still shown, and a revoke or an import answers the panel with that note (and, after a success, a confirmation banner and toast) instead of a blank body. If the check reports itself unavailable the answer is 503 with retry_after_ms: 5000. Both forms of the route and the tool are now audited crypto.ca.certificates.view (set-and-proceed).
Who is affected. An operator or agentic worker whose role grants global Security:Read but no global Inventory:Read (whether Inventory:Read is granted only through management groups or not at all, as for a custom role that holds Security:Read alone), and who listed the CA inventory; and anyone who relied on that call being unaudited. The seeded Viewer and Administrator roles hold a global Inventory:Read, so they are not confined and are not affected. To find out whether anyone is: after upgrading, query GET /api/v1/audit?action=crypto.ca.certificates.view (the route has no filter on result, so read the result field of each row) and look for denied rows; their principal is each refused caller. An agentic worker that calls list_issued_certs and already treats a permission-denied error as an error needs no change: the tool’s input schema is unchanged, and an unfiltered call can now also carry audit_persisted: false (a marker the filtered form already had) when its audit row could not be written.
What did not change, stated plainly.
- RBAC-off installs (the shipped default) are unchanged: every
Security:Readsession is served. - A failed access check is never read as permission. If a store behind the Inventory access check (permissions, management groups) cannot be read, the route answers
503withretry_after_ms: 5000and afailurerow, and the tool a retryable error and afailurerow; the panel and the/cryptodrill show a note and write no row (#6384). A client that polls the list should retry after the interval. The check that tells a failed read from “no grant” is shared: see “Every fleet-gated read answers 503, not 403, when its authorization read fails” below. - Removing a caller’s last scoped
Inventory:Readgrant no longer widens what it can see: such a caller holds no grant and is refused like any other caller without one.
What to check.
- A confined caller that needs the inventory must use an account with an unconfined view (for example one holding a global
Inventory:Read). A confinedSecurity:Deleteholder can still revoke a serial it already knows, but cannot list serials; revoked serials are in the CRL (/api/v1/ca/crl). - SIEM volume. A SIEM rule or report that counts
crypto.ca.certificates.viewrows now sees onesuccessrow per inventory page (two rows per MCP call, withmcp.list_issued_certs),deniedrows for refusals, andfailurerows when the access check or the store is unavailable (a polling client during an outage writes one per call). Everysuccessrow endsstanding=available;deniedrows endwithheld=confined(a confined view) orwithheld=refused(noInventory:Readgrant). A token or MCP caller (not an interactive cookie session) whose Inventory check is refused for lack of a grant, or fails, also leaves the gate’s ownauth.fleet_read_requiredrow, so a REST call writes two rows and an MCP call three (the third ismcp.list_issued_certs); a confined view is admitted by the gate and withheld by the CA route, so a REST call writes one row and an MCP call two.
Behaviour change: every fleet-gated read answers 503, not 403, when its authorization read fails
What changed. The shared list-read check (RbacStore::authorize_list_read) used to answer the same “deny” for a caller who holds no grant and for a caller whose permission, permission-group or management-group read FAILED, and every fleet-gated route turned that into a 403. It now reports a failed read separately, and the fleet-read gate and the list-read gate answer a retryable 503 (retry_after_ms: 5000, no permission named) with an audit row result=failure (auth.fleet_read_required or auth.permission_required, detail ... authorization read failed ...) instead. An engine principal whose grant read fails gets the same 503, and so does one whose RBAC enforcement state cannot be confirmed (for example a replica whose cached “disabled” view is stale because its refresh keeps failing past the 5 second bound, so another replica may have enabled RBAC), on these two gates only; it used to get a terminal 403 there. A fresh “disabled” view is still 403, and the plain require_permission gates keep their 403 (#6437). A management-group store that is not wired or not open now also reads as unavailable (503) on require_list_read routes, as it already did on the fleet-read gate. Both answers still deny.
Who is affected. A client of any fleet-gated or list-gated read (status and drill-down views, plugin configuration lists, inventory, executions, responses, devices, hardware, enrollment, result sets and others) that treats 403 as terminal will now see 503 on a transient database fault and should retry after the interval. Dashboard fragments already handle 503 as “unavailable”. The two upload-grants list resolvers do not carry the new answer: a failed read there is still a plain deny.
What to check. Alerts or dashboards that count 403s on these routes see fewer during an outage; the root-cause signals are the RBAC and management-group read-degrade counters and their alerts (YuzuRbacReadDegraded, YuzuRbacBreakerOpen, YuzuMgmtGroupReadDegraded). A SIEM rule that counts auth.fleet_read_required rows with result=denied as authorization denials is unaffected; the new rows are failure.
Breaker and counter behaviour. A failed role lookup inside any cache-missing permission check (check_permission, which every require_permission, fleet and list-read gate uses, not only the CA reads) used to read as “no roles” and was recorded as a breaker success (it reset the failure streak) with no degrade count. It now counts as a failed read: it increments yuzu_server_rbac_read_degrade_total{reason="query_error"} and moves the RBAC fail-fast breaker (yuzu_server_rbac_breaker_open, opens after 2 consecutive failures), so YuzuRbacReadDegraded and YuzuRbacBreakerOpen can fire for an outage they were silent about before, and two consecutive failures on a replica open its breaker, which sheds that replica’s cache-missing authorization reads for the cooldown. A caller with no roles at all counts as a success: it adds no degrade count and does not count toward opening the breaker.
Scope. The 503 applies to the list-read and fleet-read checks. The front Security:Read gate that precedes them on the CA routes, and the other require_permission call sites, still answer 403 when their own first read fails (their failed reads now count toward the breaker and the degrade counter as described above) (deny-on-degrade, ADR-0041); that is unchanged and is tracked as #6437.
No action on upgrade. There is no schema change.
Behaviour change: engine credential mint, rotate and the no-admin auditor fail closed on a degraded read
What changed. The engine credential routes POST /api/v1/engine-principals/{id}/credentials and .../credentials/rotate, and the MCP tools mint_engine_credential and rotate_engine_credential, now read the token store through checked reads. If the check for an existing credential cannot be answered, nothing is minted and the caller gets 503 (REST, Retry-After: 2) or a retryable error (MCP, retry_after_ms). If a mint or rotation commits but the new credential cannot be read back, the secret is withheld and the audit row is partial; retry a rotation within about two minutes against the same replica to receive the same secret, or recover as described in Engine Principals. The no-admin auditor (GET /api/v1/engine-principals/audit/no-admin, audit_engine_no_admin) answers 503 / a retryable error instead of ok:true when a read it needs fails. The auditor also now reads grants by principal id, so it reports grants held by user-typed rows for engine: ids (invisible before), reports role by name (a single role that covers the whole grid was reported with an empty role before; only a union-only finding still has ""), and records scope=all|active in its success audit detail; a script that gates on ok or parses role should be checked. On the MCP tool, include_revoked=false skips only principals the roster lists as revoked and also reports a grant whose principal has no roster row as orphan_grant (the default run does not), so it is not a subset of the default run. A mint whose read-back answers but finds no active credential is now the same 503 with the secret withheld and a partial audit row, and a rotation whose read-back finds no linked successor answers 503 with no retry hint.
No action on upgrade. There is no schema change. A client that treated a 200 with an empty token_id as success will now see the 503, and should retry as described above.
Behaviour change: engine principal list and get answer 503 instead of an empty result on a degraded read
What changed. GET /api/v1/engine-principals, GET /api/v1/engine-principals/{id}, the MCP tools list_engine_principals and get_engine_principal, and the Settings Engine Principals panel used to turn a database connection pool timeout or query failure into “no engine principals”, active_credential_count: 0 or an empty active_credentials list. They now answer 503 with Retry-After: 2 and retry_after_ms: 2000 (MCP: a retryable internal error with retry_after_ms), write an engine_principal.list or engine_principal.get audit row with result=failure, and the Settings panel shows “unavailable” with a Retry button.
Who is affected. Clients that parsed an empty 200 as “nothing exists” during a database incident, and alerting that counts result=failure rows for these two verbs. A healthy server answers exactly as before.
No action on upgrade. There is no schema change. Retry after the Retry-After interval.
Behaviour change: every Echo capture source is on by default; stored opt-outs reset once (echo.db schema v8)
What changed. All 16 Echo capture sources now ship enabled on every OS. Eight of them were off by default before: procperf, netqual, module, software, arp, dns, netconn and mapdrive. A fresh agent now reports <source>_enabled|true in echo.status for all of them.
Before you upgrade, if you paused any of the eight on purpose (for example to preserve evidence): run echo.status on that agent and record its config|<source>_enabled|false lines (and the matching config|<source>_paused_at|<epoch> lines), and export what you need with echo.sql, which reaches every table and every tier (echo.query and echo.export read only the live tables, and of these eight only software, arp, dns and mapdrive have one). The reset re-enables the source, and retention skips only disabled sources, so the exemption ends with it: from the first rollup pass after the upgrade (rollups run every 15 minutes) rows older than their tier cutoff start being pruned, and that cannot be undone. After the upgrade, re-apply <source>_enabled=false with echo.configure for each source you recorded to pause it again: that stops collection and exempts the rows that remain from pruning, but rows already pruned do not come back.
One-time reset on upgrade. The first time an upgraded agent starts, it migrates its echo.db to schema version 8. For each of the eight sources above whose stored <source>_enabled value is false (including a value an operator set deliberately), the migration sets it to true and clears the source’s <source>_paused_at marker, in one transaction. A value of errored (any stored value other than exactly true or false) is left alone, as are the stored values of the eight sources that were already on (process, tcp, service, user, perf, power, removable, usage). The success line in the agent log is EchoDatabase: migrated to schema version 8; an opt-out written after it sticks. If any step fails, the agent logs EchoDatabase: v8 migration failed at followed by the failing step, rolls the whole migration back and retries it on the next start; until a start succeeds the old stored values stay in force, and a disable written for one of the eight in that window is reset by the retry, so look for the success line before relying on one. On Windows the first open after this upgrade also re-secures echo.db and its sidecars (-wal, -shm, -journal) to a protected owner-only DACL, and refuses to open an echo.db owned by an account other than SYSTEM, Administrators or the agent’s, or carrying more than one hard link (the owner test needs a volume with ACLs; where the owner cannot be read, as on FAT or exFAT, nothing is refused on that ground). For a foreign-owned or hard-linked file the log names the file and why. For a foreign-owned echo.db, move it and its sidecars aside or remove them and restart (the history in them is lost); if the owner SID the log names is a former agent account’s and all four files carry that same owner, an administrator can instead take ownership of the four files and grant the agent account full access (this sequence has not yet been verified on a Windows host). If the agent’s account changed from LocalSystem or an administrator account to a non-administrator one, no owner is named: the log shows could not open <file> to secure it (error 5) and then failed to open echo.db, and an administrator must grant the new account full access to the four files or move them aside. For a hard link, remove the other link. The same checks apply to the echo.db this upgrade adopts or finds beside an un-adopted tar.db (see “Both tar.db and echo.db exist” in the Echo rename section at the top of this page).
Opt-outs after the upgrade stick. The reset runs once. A <source>_enabled=false written through echo.configure after the migration has succeeded (look for the success line) is kept across restarts and further upgrades.
What to expect. New collection begins without operator action, on each OS where the source has a collector (OS compatibility matrix in docs/user-manual/echo.md), for procperf (and the daily per-application performance sync it feeds), netqual, module (Windows ETW), software, arp, dns, netconn and mapdrive (netconn and mapdrive also run history backfills; netconn_lookback_seconds bounds the first). A source whose <source>_enabled row cannot be read is treated as disabled for that tick and its stored rows are kept.
⚠️ Breaking: MFA step-up is checked once, before the route’s own checks, and two more routes need a fresh proof
A fresh MFA proof is required on 37 named high-risk routes (the list is in
REST API, which also names the three
JIT-elevation 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.
“High-risk” names these routes; it is not a claim that every high-risk operation is covered. Other
mutating routes and all MCP tools are not MFA-step-up gated in this release. The check on those 37
routes now runs in one place, in the server’s request pipeline, instead of inside each handler,
and two of the 37 are existing routes that are newly gated; six more are routes that are new in this release (see below). The proof each caller needs and the step-up window are unchanged. The
three JIT-elevation routes keep their own handler gate. What an operator or script can notice:
- Two routes are newly gated.
POST /api/settings/mfa/recovery-codes(the Settings page’s recovery-code regeneration) andPOST /api/settings/mfa/disable(the Settings page’s MFA disable button) previously needed no fresh proof. An MFA-enrolled operator whose proof is older than--mfa-step-up-window-secsis now refused. API clients are told to step up through the401envelope, but the Settings MFA panel cannot yet prompt: after the proof is older than the window (default 300 s), the two buttons show a toast and the operator must sign in again. An administrator who has just enrolled is covered inside the window, for the confirming session only, because enrolment confirmation records a fresh proof for that session (the audit rowmfa.enroll.verifiedsaysstep_up_proof=stamped, orstep_up_proof=not_stampedwhen the session could not be stamped: a bearer principal, an absent or mismatched cookie, or a store failure). An account with no MFA enrolled is not affected. - What is newly gated, measured against the last release (v0.14.1). Only the two Settings MFA
routes above are gated for the first time by this change. Of the other 35, 25 already called the
step-up check from their handlers in v0.14.1. The remaining ten were gated in their handlers
earlier in this release cycle, so they need a fresh proof too, but
not through this change. Four are existing routes that v0.14.1 did not step-up gate: the
dashboard API-token create and revoke routes (see “Behaviour change: dashboard API-token create
and revoke apply MFA step-up” below), and
POST /api/approvals/{id}/approveandPOST /api/approvals/{id}/reject(documented in their own Breaking section below). The other six are routes that did not exist in v0.14.1: the local password change and the administrator password reset, the Guardian guard Edit and Delete routes,POST /api/v1/guaranteed-state/policy-generation/raiseandPUT /api/v1/config/{key}. For those ten, this change alters the order of checks and the engine-principal denial described below, not the proof the caller needs. - Step-up comes before authorization, and after the server-wide same-site check. A session with
a stale or missing MFA proof is answered with the
401step-up envelope before the route’s own403(permission, durable-Administrator,token_session,not_local) could be. An MFA-enrolled operator who calls an administrator-only route without the permission is asked to step up first and then receives the403. The step-up check does not readOriginorReferer. For a cookie session the order is: the server-wide same-site check first (a cross-site or header-less write is answered403cross-site request refused, audited ascsrf.denied, with no step-up evaluation, challenge or audit row), then the step-up check, then the route’s own checks (a route’s owncsrfcopy is defence in depth). Token callers skip the same-site check. A script that sends a cross-site or header-less write on a session cookie and expects the step-up401sees the same-site403instead; add a same-siteOriginorRefererto the request. An over-cap request body is still refused413before step-up (the body cap runs just before it, once the body is read). Scripts and tests that send an unauthorized request on a session cookie and expect403may now see401withmeta.mfa_step_up_required: true. Accounts with no MFA enrolled (under--mfa-enforcement=optional) are not affected, and API-token and MCP-token callers pass the proof check because they have no MFA factor to prove. Audit rows for these refusals carrystage=pre_authzin themfa.step_up.requiredrow’sdetail; filter on it when comparing counts before and after the upgrade. Refusals now also carry anX-Correlation-Idresponse header equal to the body’scorrelation_id, and the row’sdetailrecords the same value ascid=<id>, so a refusal a client reports can be joined to its audit row. The detail of a stale or absent proof isuser=<name> age_secs=<n> window_secs=<n> cid=<id> stage=pre_authz(age_secs=-1when the session has no proof); every other refusal isuser=<name> reason=<token> cid=<id> stage=pre_authz. The gate inside the three JIT-elevation handlers writes the same fields withoutcid=orstage=, so the join by correlation id covers the central check’s rows only. - Engine principals receive
403on every step-up-gated route, regardless of grants, once their credential resolves. The refusal is made at the central check, before any role grant is consulted, and the denial itself reads no MFA store. During a database outage an engine credential cannot be resolved by the pre-routing authentication (it reads the store on every request; a cookie session is still served from the validation cache for about 30 seconds of a stall, and only once that cache lapses is it in the same position), so the request is answered as an unauthenticated one:401unauthorized(a JSON body on/api/,/eventsand/mcp/paths; other paths, such as/fragments/, get a redirect orHX-Redirectinstead), with no step-up envelope, no engine quota and no step-up audit row. The checks that precede the denial are the pre-routing gates (on-behalf rejection, the rate limit, the401for no session, the engine quota429) and the503of a server with no wired authentication store, which every caller class gets. An engine principal cannot satisfy a TOTP challenge, so it is never asked to. The audit shape changes on these 37 routes. The denial is now recorded asmfa.step_up.required(result=error,reason=engine_principal_denied). The per-route handler denial row (result=denied, detailengine_session_denied) and the older body textengine principals cannot access this endpointare no longer produced there, and the body now readsengine principals cannot perform this operation. SIEM rules or alerts keyed on the old rows or text must be updated. - An unusable authentication store answers
503. Which callers see it depends on what the server could not do. A server whose authentication store is not wired answers503on these routes whatever the caller class and even with the window disabled, because the check cannot tell what it would be passing. A store that is wired but cannot be read, or whose stored second factor cannot be decrypted, answers503to local (password) sessions only, since those are the only sessions whose enrollment is read from the store; API-token and MCP-token callers pass before any store read, and OIDC sessions are judged on the identity provider’s assertion. The503still carriesmeta.mfa_step_up_required: true, with nochallenge_url, and writes anmfa.step_up.requiredaudit row whosedetailnames the cause (an unexpected failure inside the check itself, or a session that could not be fingerprinted, is recorded asreason=chokepoint_exception). Retry; re-proving cannot help. A client must treat a503as “retry shortly” and prompt for a code only on a401. See the recovery runbook. The new alertYuzuStepUpGateUnavailablefires on it. - SAML sessions cannot satisfy step-up in this release. They receive
403on all 37 routes (reasonsaml_session_no_mfa_this_release, see #4935), unless--mfa-step-up-window-secsis0or less: the disabled window is checked before the SAML refusal, so SAML sessions then pass all 37. A deployment whose only interactive sign-in is SAML has no session route to any of them. The refusal’s remediation says SAML sessions cannot satisfy step-up in this release (re-authenticating does not help) and to use an existing untiered, unscoped API token where the route accepts one, or ask an administrator; that applies to all 37 routes, not only the token routes, and a SAML session cannot mint a token itself. A token helps only on a route that accepts one (some routes, such as the password-change routes, refuse a token session), so otherwise ask an administrator who signs in locally or through OIDC. For the token routes, see “Behaviour change: dashboard API-token create and revoke apply MFA step-up” below. - A request that the step-up check has passed keeps the principal it passed. If a later
credential resolution in the same request would land on a different principal, that resolution
yields no session and the route answers its ordinary no-session response (usually
401; approve and reject answer503withretry_after_mswhen the change falls between the handler’s two resolutions). Audit rows built from the request after that point have an emptyprincipal,principal_roleandsession_id(principal_classstill reflects the presented credential); rows that name their principal explicitly keep it. - The server refuses to start when a mutating route has no step-up classification. At boot,
every
POST/PUT/PATCH/DELETEroute registered through the route sink is checked against the step-up table. If one is missing, the server logs one critical line per route (start_web_server(): mutating route has no MFA step-up policy row: <METHOD> <pattern>), thenaborting: N mutating route(s) are not classified in step_up_policy.hpp, stops its gRPC listeners and exits with status1before the web listener starts. The gRPC agent port is already listening when the check fails, and it keeps accepting connections for about five seconds while the server shuts down, before the process exits (measured: the whole cycle takes about eleven to twelve seconds). Under a restart policy (restart: alwaysorunless-stopped, a systemdRestart=, a Kubernetes pod) this is a restart loop: agents and gateways see the server accept connections for about five seconds out of every eleven to twelve and then drop, the web UI never comes up, and a rolling deployment halts because the new replica never becomes ready. This can only happen on a fork or a custom build: every stock build is checked by compiled and source-scan tests before it ships, so a release binary never trips it. The fix is a row instep_up_policy.hppand a rebuild, never a flag. To recover a deployment in the meantime, roll back to the previous binary or image; nothing is migrated, so a rollback needs no data step. - How a refusal appears in the dashboard. A refused htmx request (on a dashboard route or an
/api/v1/route, such as Settings > Users “Revoke sessions”) now carries anHX-Triggererror toast, so the button no longer appears to do nothing; MCP never gets one. The Guardian page’s Edit and Delete controls are not htmx: they use afetch-based dialog, which gets no toast. On a stale proof (401) that dialog opens a “Confirm it is you” modal for the code and replays the request on success; a403or503appears inside the dialog as the server’s message, with no prompt. Where an htmx request does hit a401in the Console shell, a native browser prompt asks for the code. The Settings MFA buttons show only the toast and never prompt (see the first item above). - The step-up check’s own refusal logging is sampled. The check writes one log line in 100 per
refusal site, so a retrying client cannot flood the log through it. The
yuzu_auth_mfa_step_up_gate_totalcounter and the audit rows are not sampled. The audit write is a separate path: the audit store logs each failed audit insert while it is open (a store that is not open only counts the failure, silently) and the route layer warns once per failed audit write, both unsampled, so while the audit store is open and failing a refusal storm still writes about two log records per refusal. Alert onyuzu_auth_mfa_step_up_gate_totalandyuzu_server_audit_emit_failed_total(exposed as a counter, refreshed by a sweep every 15 seconds), not on log volume.
The same test suite also classifies every non-Read MCP tool in a second table and fails on a missing or stale row; that table is data only and changes no MCP behaviour.
Action. None required for a stock deployment. Check any script that signs in with a session
cookie and calls one of the 37 routes: it may now see 401 where it saw 403. Measured from
v0.14.1, six existing routes that needed no fresh proof now need one inside the window: the two
Settings MFA routes (gated by this change) and the four existing routes listed under “What is newly gated”
above (gated earlier in this release cycle). The six routes that are new in this release need one too.
--mfa-step-up-window-secs 0 (or less) disables
the stale-proof requirement on the 37 routes and still logs a startup WARN. While it is in
force no stale proof is refused (so none is recorded), and the SAML refusal and the
authentication-store read are skipped too, so SAML sessions also pass; engine denials and
unwired-store refusals still apply and are still recorded. The JIT-elevation routes keep a 300 s window (the global
window when it is positive, else 300 s). The new alert YuzuStepUpWindowDisabled fires while the
window is 0 or less. This release has no supported return to v0.14.1 (see “Deployment contract (hard cutover)”). v0.14.1 does not gate the two Settings routes or the four existing routes listed under “What is newly gated” above in their handlers, and it does not have the six routes that are new in this release. A server whose /metrics lacks yuzu_auth_mfa_step_up_gate_total and yuzu_auth_mfa_step_up_window_seconds has no step-up gate on these routes at all, and YuzuStepUpGateUnavailable and YuzuStepUpWindowDisabled cannot fire there: their silence is not evidence that the gate is on.
Behaviour change: /health and /api/health per-source budget
/health and /api/health now apply a per-source budget to requests that carry a credential header
(Cookie, Authorization or X-Yuzu-Token):
- Budget. The
--rate-limitvalue (default 100 requests per second, burst 100; values below 1 are treated as 1), counted per source address (the connection’s peer address, so behind a proxy or load balancer all callers share it). The two paths share one bucket per source, and it is separate from the REST budget. - Over the budget the server answers
429withRetry-After: 1and the standard error envelope (correlation_idandretry_after_msincluded). - What counts as a credential. A request is charged by the presence of a
Cookie,AuthorizationorX-Yuzu-Tokenheader, whether or not the credential is valid. A credentialed monitor behind a shared address shares the budget with every credentialed request from that address, so load balancer and orchestrator checks should use/readyz(readiness) or/livez(liveness). - Unchanged. Requests with no credential header are not counted and are never refused by this
limit, and
/livezand/readyzare not rate limited. - Observability. A refused health 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. - Tuning. Raise
--rate-limit(YUZU_RATE_LIMIT) if a monitor that sends a credential polls faster than the default budget allows; the same value also sets the REST budget. - 429 body. Every pre-routing rate-limit refusal (the general
--rate-limitbucket, the login bucket--login-rate-limitand this health budget) now carriescorrelation_idandretry_after_ms; every field those refusals had before is unchanged.
⚠️ Breaking: the OpenAPI document is larger and typed, every operation has an operationId, and there is a /swagger page
What changed. GET /api/v1/openapi.json now gives 262 of its 264 operations a success response with a content block (the previous release’s document listed 241 operations and did so for 14), and every operation carries an operationId (the earlier document had none). The document also lists 23 operations that the previous release’s document did not, among them GET /config, PUT /config/{key} and GET, PUT and DELETE /dex/score-policy; all 23 are typed. The default form of the document, which is also what the MCP resource yuzu://openapi serves, grew from about 314 KB to about 670 KB as served. A second form, GET /api/v1/openapi.json?guides=true (the value must be exactly true or 1), adds a prose guide to each of the 47 tag groups. The MCP resource and the discover tools never serve the guide form, short of a build defect. A new dashboard page, /swagger, renders the guide form in the browser. Both the route and the page need Infrastructure:Read, and the page has no navigation entry. The default form is re-serialised by the server, so its key order and whitespace differ from the guide form.
Who is affected. Anyone who generates a client from the document (typed return types, renamed methods and a changed security-scheme model can break generated code), anyone who compares or hash-pins the document byte for byte, and any agentic worker that reads yuzu://openapi into its context.
What to check.
- A client generator that derived method names from the path and verb now uses the
operationIdvalues, so generated method names change. Regenerate the client and review the diff. - The Echo operations were renamed. The activity-record routes were renamed from TAR to Echo (
/api/v1/echo/...), so theiroperationIdvalues changed with them:getTarCaptureSources,getTarProcessTree,getTarRetentionPaused,postTarRetentionPausedPurgeandpostResultSetsFromTarQueryare nowgetEchoCaptureSources,getEchoProcessTree,getEchoRetentionPaused,postEchoRetentionPausedPurgeandpostResultSetsFromEchoQuery, and theDashboard TARtag isDashboard Echo. A generated client needs regenerating. - Byte-for-byte comparisons and hash pins. The default document is now re-serialised by the server, compact and with its keys in alphabetical order, so it differs from the previous release’s bytes almost throughout, not only from the guide form. A snapshot or hash taken from the earlier release must be re-baselined. The response also carries an
ETagnow, which the earlier release did not send. - An integration that reads
yuzu://openapiorGET /api/v1/openapi.jsoninto a size-limited context needs about twice the room: the default form grew from about 314 KB to about 670 KB as served before compression, and?guides=trueis about 980 KB. An agentic worker that wants the routes and not the whole document should calldiscover_routes(orGET /api/v1/discover/routes), which returns a projection of the same source (about 192 KB, against about 670 KB for the document): method, path, summary, tags, description and, for an unserved operation, anunavailablenote. - Response types. 229 operations had no success schema (they described the body in prose, and two of them,
GET /ca/rootandGET /ca/crl, had a content block with no schema); 227 of them now declare one, and all 23 operations added since are typed. So a generated client returns model types where it used to return an untyped object. 262 of the 264 operations are typed; the twoDELETEoperations that answer204(DELETE /upload-grants/{grant_id}andDELETE /uploads/{upload_id}) have no body and stay untyped. The models are derived from the handlers, but a client that deserialises strictly will now fail on any place where the document and a handler disagree, which an untyped client never noticed. Report such a mismatch as a defect in the document. - Security schemes. The top-level
securitywas one requirement that asked for the bearer token, theX-Yuzu-Tokenheader and the session cookie together. It is now three alternatives, any one of which authenticates. ThecookieAuthscheme names the real cookie,yuzu_session(it saidsession).GET /ca/rootandGET /ca/crlcarrysecurity: []because they are public. A generator or gateway that enforced the old combined requirement, or that copied the cookie name, needs regenerating. - Operations the server does not serve. 14 operations in three groups (License Management, Device Tokens and Software Deployment) are in the document but are not registered by the server today, so each carries an
x-yuzu-unavailablenote. A client generated from the document has a method for each of them, and calling one fails because the route does not exist. Skip them, or filter on the marker before generating. - Revalidate instead of refetching. Send the
ETagof the last response back inIf-None-Matchand an unchanged document answers304with no body. Without it, every request is built and compressed again: on a 32-core host a request that accepts brotli took about 1.4 seconds for the default form and about 2.0 seconds for?guides=true, and 24 such requests in parallel did not make/healthmiss a response (it answered in 1.4 to 5.5 ms). The compressed bytes are not cached, so the cost is CPU per uncached request. Hosts with 2 to 4 cores were not measured and will saturate sooner, so a script that polls the document, or a monitor that fetches/swagger’s data on a timer, should sendIf-None-Match. A browser loading/swaggerrevalidates by itself. The comparison is exact, so a proxy that rewrites theETagto a weak one defeats it. - Rolling back. The document is compiled into the server, so rolling back to the previous binary or image restores the previous document, and nothing is migrated. Clients generated from the new document keep their method names until they are regenerated; regenerating against the older server returns the path-derived method names and mostly untyped responses, the
ETagheader is no longer sent, the/swaggerpage is gone and?guides=trueis ignored, and a hash taken from the new document no longer matches. Regenerate clients and re-baseline any pin after a rollback.
Correction: webhook subscriptions to command.completed and policy.violation have never delivered
What changed. Nothing in the server: the REST API reference listed command.completed and policy.violation as webhook events, and the server has never sent either. It sends only agent.registered, execution.completed, dex.signal and dex.blast_radius (offload targets match the same events). The reference now says so.
Who is affected. Anyone who subscribed a webhook to command.completed or policy.violation. A subscription that lists only those types has never delivered anything, so anyone who relied on a policy.violation webhook for compliance alerting has had no alert from it since the subscription was created.
What to check. execution.completed is the sent event that stands in for command.completed. There is no policy-violation event to subscribe to: poll GET /api/v1/compliance (needs Policy:Read; the MCP twin is get_fleet_compliance) for the fleet’s compliant and non-compliant counts, or GET /api/v1/compliance/{id} for one policy, and review any subscription that lists only the two unsent types.
⚠️ Breaking: the guardian. definition-id prefix is reserved; approvals gain frozen-request columns; a new audit action and metric (#6062)
This release lands the storage and redemption half of the four-eyes bound approval primitive (ADR-1009, epic #6060). Nothing in this release creates a bound approval ticket and there is no Guardian deploy gate yet; those arrive in later changes (#6063 onwards). What an operator can see today is the three items below, plus a fourth refusal that normal operation never triggers.
1. The guardian. definition-id prefix is reserved (breaking for operator-authored ids). A
new instruction definition whose id starts guardian. is refused on create, import and the
dashboard YAML editor, and such a definition is skipped at boot auto-import. No shipped content
uses the prefix. Definitions that already carry such an id keep executing, but cannot be saved
from the YAML editor if the document declares metadata.id, and cannot be re-imported. Before
upgrading, run the read-only check and rename anything it lists: see
the guardian. entry in Server Administration.
2. Approvals schema migration v3 (store approval_manager). It adds three text columns to
approvals (request_json, request_digest, excluded_roots), each NOT NULL DEFAULT '', so
every existing row reads as “binds no request, excludes nobody” and behaves as before. The
migration is additive and needs no operator step, but it takes a brief ACCESS EXCLUSIVE lock on
approvals. If a long-running or idle-in-transaction session holds that table, the upgraded
replica cannot finish the migration and refuses to start (Refusing to start: approval manager migration/open failed) rather than serve with a half-migrated schema; end the session and start
it again. It follows the data-only v2 step of the scheduled-approval change (#6096, below), which
claimed v2 first, so a database upgraded from a build with neither change runs v2 and then v3 in
two separate transactions, and one already at v2 runs only v3. The v3 step is idempotent
(ADD COLUMN IF NOT EXISTS), so a development or UAT database that ran an earlier build of this
branch, which recorded the same columns as version 2, upgrades cleanly instead of failing on a
column that already exists.
- Rolling upgrade. An older replica never touches the new columns and keeps redeeming the tickets it and this build mint, all of which are unbound. The migration runner applies only versions above the one stored for the store and has no newer-schema refusal, so an older binary starts against a newer database: a build with neither change against a v2 or v3 database, and a build with the #6096 change but not this one (highest version 2) against a v3 database. That is established by reading the runner; it has not been exercised as a live rolling restart.
- Rollback to the previous build is therefore expected to work while no bound ticket exists, which is the case for this whole release: the previous build ignores the columns and the default-empty values lose nothing. Rolling back across the #6096 change as well has its own consequences, described in that section.
- Rollback is NOT supported once a bound ticket exists. The previous build does not read
excluded_roots, so a reviewer who was barred from a bound ticket could approve it, and it redeems without comparing the frozen request. The change that first creates a bound ticket (#6063) must not do so until every replica runs a build that includes this migration, and must define what rollback means after that point. Until it does, a row with a non-emptyrequest_jsonis not expected to exist; if you find one, do not roll back.
3. A new audit action and metric: approval.precondition_denied and
yuzu_approval_precondition_denied_total{origin}. When an approved ticket is refused at
redemption because the state its effect depends on has changed, the approval store now counts the
denial and writes an approval.precondition_denied audit row (result=denied; see
the audit log reference). The only redemptions that supply such a check today are
MCP recalls (every approval-gated tool checks that the ticket still matches the recalled tool and
arguments; confirm_engine_rotation also checks the rotation’s state), so for now the new series
moves only with origin="mcp"; the other three origin values are present at 0 until their producers exist.
For SIEM and alert owners: add approval.precondition_denied to any audit-action allow-list.
An MCP recall refused this way still writes its own mcp.* denied row as before, so one MCP event
now leaves two rows: do not sum the two when counting denials. The older
yuzu_mcp_approval_precondition_denied_total{tool} series keeps its meaning but is now
pre-seeded, and can move, for every approval-gated tool rather than only confirm_engine_rotation.
4. A redemption integrity re-check, and a row-moved refusal. Redeeming an approved,
unconsumed ticket now re-checks the stored row. This applies to every MCP recall of an
approval-gated tool (the ticket flow that runs before the tool for a supervised-tier token, for
example execute_instruction, quarantine_device, revoke_certificate and
confirm_engine_rotation), not only to the one that supplies a precondition. A ticket is refused if its reviewer is the submitter or an excluded root, it records no reviewer,
its excluded roots cannot be read, or its stored request does not match its digest. Through MCP it surfaces as the same “approval already used (one-time ticket)” refusal a replay gets, and the
audit row reads refused: foreign_origin; the cause is only in the server log. Approving a
ticket always records a distinct reviewer, so ordinary operation never triggers this: it
indicates a row edited by hand, restored from a backup or written by an older build, and such a
ticket stays unredeemable until its seven-day lifetime ends. Separately, a ticket whose row was
rewritten by another transaction between the check and the consuming statement (and, rarely,
VACUUM FULL or CLUSTER running at that moment, which move the row) is also refused, audited as
refused: not_consumable. Both read like a replay in the counter and the audit trail. The
second is a false refusal only when the cause was a no-op rewrite or one of those maintenance
commands: a retry then succeeds. When the other transaction was a concurrent recall that spent
the ticket, or a real edit, the refusal is correct and a retry is refused again (“approval
already used”). See MCP approvals and
metrics.
Behaviour change: the Windows agent installer passes the enrollment token by file
What changed. The Windows agent installer no longer puts the enrollment token on the
registered service command line. It writes the token to %ProgramData%\Yuzu\enrollment-token
(restricted to Administrators and SYSTEM, owned by Administrators), configures the service with
--enrollment-token-file pointing at that file, and the agent removes the file after its first
accepted registration or when the server refuses the token. /TOKEN=<token> still works; /TOKEN_FILE=<file> is new and keeps the
token out of Setup’s own log too (Setup’s command line, and therefore its /LOG= log, still
records a /TOKEN= value verbatim if that is the form given). A service registered by this
installer shows --enrollment-token-file and no token value in sc.exe qc YuzuAgent.
Who is affected. Anyone installing or upgrading the Windows agent with /TOKEN=, and every
Windows agent registered by an earlier installer.
- An agent registered by an earlier installer keeps its earlier command line, token included,
until the installer is run again. Nothing else rewrites it: an over-the-air agent update
replaces the binary only.
sc.exe qc YuzuAgentshows which form a service has:--enrollment-token-filewith no token value, or--enrollment-token <value>. To move an agent to the new form, run the installer again with the same/SERVER=and/NOTLSyou installed it with: a re-run that omits them registers the service againstlocalhost:50051with TLS on (see the fleet-upgrade gotcha under Server Administration). No/TOKEN=or/TOKEN_FILE=is needed on that run, because an already-enrolled agent needs no token. - A service whose environment still sets
YUZU_ENROLLMENT_TOKENkeeps starting: a token read from the file takes precedence, and with no usable file the environment value is used. - An agent binary older than this change does not know
--enrollment-token-file: if a service registered by this installer is rolled back to one, it exits with code 109 and writes no log line. Run the previous installer again with the previous parameters to restore a matching command line.
How to fix, and what the installer and agent now refuse.
- The installer exits with code 7 (its
/LOG=log says why) before it installs anything or stops the service when: both/TOKEN=and/TOKEN_FILE=are given (or/TOKEN_FILE=together with a token typed in the wizard); a/TOKEN_FILE=file is missing, not a plain file, unreadable, empty or contains NUL bytes; the token is not exactly 64 lowercase hexadecimal characters (a list, a JSON document or a URL is refused); or the token file path or its staging path is a link or cannot be written, or anenrollment-token.stagefolder left beside it by an interrupted run is in the way (it may hold a staged token, readable by Administrators and SYSTEM only; delete it and run the installer again; uninstalling removes the folder only when it is already empty). The/TOKEN_FILE=source file itself is left in place; delete it when the deployment is done. - The agent reads a token file only if it holds a 64-hex token on its first line and is owned by
SYSTEM or root, Administrators, or the account the agent runs as (on Linux and macOS also not
group- or other-writable, and readable by the agent). Anything else is ignored with a warning in
the agent log, and the agent registers with
--enrollment-token/YUZU_ENROLLMENT_TOKENif one is set, else without a token (the approval queue). - When the server refuses a token the agent read from the token file (unknown, expired, revoked or
used up), the agent removes the file, and its next start registers without the file’s token:
with an
--enrollment-token/YUZU_ENROLLMENT_TOKENvalue 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 refusal that is not about the token, such as an administrator denying the agent, leaves the file in place. To enrol with a token again, supply a current one (run the installer again with/TOKEN=or/TOKEN_FILE=). Do not bake a token file into a golden image or VDI template: each machine started from it registers with a new identity and uses one of the token’s uses. A non-persistent image that still carries a token the server has expired, revoked or used up adds a new entry to Pending Agent Approvals on every boot (each boot is a new agent id), and the queue is not capped. Sizemax_usesand the expiry to the rollout window. - For a new unattended install, prefer
/TOKEN_FILE=<file>over/TOKEN=when the token must not reach Setup’s own log or deployment-tool command-line records (SCCM’s AppEnforce.log, Intune). An installation that fails after the token file is written leaves the file in place (Administrators and SYSTEM only) and the previous service restarts with its previous command line; an installation that is refused while the new file is being moved into place leaves no token file at all. Either way, run the installer again with the same token. The agent removes whatever is at the token-file path when its first registration is accepted or the server refuses the token, so when re-staging a token for an agent that already runs with--enrollment-token-file, write it only after that agent has registered, or stop the service first.
Behaviour change: stored responses of Forensics-tier plugins need Forensics:Read (#4990, fresh deployment only)
This section describes how the new build behaves in a fresh deployment. It is not a procedure for upgrading an existing database: see “Deployment contract (hard cutover)” below.
What changed. Rows written by Forensics-tier plugins (app_usage, browser_inventory, execution_artifacts, privacy_permissions, remote_access_audit) are now served only to a caller who holds Forensics:Read in addition to Response:Read. The set of plugins is derived from the capability catalogue (every plugin with an action on the Forensics securable). This applies on every path that reads stored responses: the legacy /api/responses/{id} routes, the /api/v1 response, execution-response and visualization routes, query_responses and aggregate_responses on MCP, the dashboard results view and its filter options, the executions drawer and page detail, the Hardware Actions-lens result poll, and the agent counts in the management-group previews. To any other caller those rows are absent: counts, truncation flags and filter options are computed over the rows the caller may read, and no error is returned. An empty or short result therefore no longer implies that no data exists. A read that serves such rows also writes a response.forensics.read audit row.
The same tier is applied wherever the server reads those rows for a reason other than a direct response read:
- Bundles.
GET /api/v1/bundles/{id}and the MCPget_bundle_resultcollate a Forensics-tier step’s output only for a caller with a globalForensics:Read. Approval to dispatch the step does not imply the right to read its output. For any other caller that step stayspendingwith an empty output, so the bundle never reportscomplete=trueto that caller. - Result sets. A result set whose source is a command of a Forensics-tier plugin (for example
from-instruction-resulton such a definition) materializes with no members for every creator. - Compliance. A policy whose check instruction runs a Forensics-tier plugin is scored
unknownfor every agent, with no stored check output. The check instruction is still dispatched to the endpoints and the plugin’s response row is still stored (readable only through the tier, kept for the response retention window); only the verdict andcheck_resultare withheld. Deleting the policy (DELETE /api/policies/{id}) removes its status rows. - Live output. The live output event stream publishes no row of a Forensics-tier plugin. Such a frame publishes nothing to that stream, not even a notice (the timing and command-status events, which carry no plugin output, still go out, and a dashboard dispatch still resets the Console to “Waiting for results…”), so the dashboard Console, which fills its results table from the output stream, stays on that placeholder for such an action; the rows are read through the execution results, which apply the tier. Nothing is published when the plugin set is unwired or the frame carries no output.
- Execution error text. For a command of a Forensics-tier plugin, the failure text kept in the execution record, the event outbox and the event stream is reduced to a short machine token of the shape
scope:cause[:detail](for examplewindows:amcache:hive_locked; at most 96 bytes), or to the fixed valueforensics_errorwhen the message is not of that shape. Status and exit code are unchanged. The same reduction is applied when the execution record is read, and to the copies of the failure text that the server writes into the failure notification message, the analytics eventerror_messageand theerrorfield of webhook and offload payloads. On a read, the reduction applies to every agent’s failure text in an execution that ran at least one such command, so an ordinary plugin’s failure text in that same mixed execution is also reduced when read (it is stored and published as written, except that the transition event of a mixed execution is built from the persisted text and can carry the reduced form). The stored response row itself keeps the plugin’s own message, and is served through the tier like any other row. - Provenance. A response is labelled with the plugin the server recorded when it dispatched the command (a
plugincolumn on the correlation recordcommand_execution, one row per dispatch, written at the one dispatch chokepoint before any frame is sent), not with the command id the agent echoes back. A response whose command id has no server record (an id the server never issued, a record whose write failed, or a record older than the 24 hour retention window) is stored with the label~unverified. That label is part of the Forensics set, so such a response is withheld from every caller withoutForensics:Read, publishes nothing to the live output stream, and its failure text is reduced in every sink, with no separate rule per consumer.yuzu_server_response_provenance_total{outcome}counts every response byverified,unrecorded(no record) orlookup_degraded(the record read failed twice); a sustainedunrecordedorlookup_degradedrate means failed record writes, records reaped under a long-running command, or an endpoint echoing ids the server never issued.
What the caller sees. A caller without Forensics:Read sees fewer rows, or none, where those plugins ran. If the server cannot complete the permission check (the authorization store is unavailable, or a dependency of the check was never wired), the REST and MCP routes answer a retryable error (503 with retry_after_ms 5000 and no permission field on REST, since granting a permission does not fix an outage; an internal error carrying a retry hint on MCP) instead of a partial result, and the dashboard shows a notice instead of an empty list. This holds for sessions, API tokens, MCP tokens and engine principals (an engine principal is included once its cached RBAC view says RBAC is off and is older than the 5 second stale-serve bound: whether RBAC is on can no longer be confirmed, so no grant is read and the engine is refused with the retryable error, whatever grant it holds; a cached view that says RBAC is on still reads the grant, and a failed read is the retryable error; inside the bound a stale view that says RBAC is off still answers a definitive 403; the authorization model reference, docs/authz-model.md, has the detail), and a failed role query counts as unavailable as well (yuzu_server_rbac_read_degrade_total{reason="query_error"}). A service-scoped token is the exception: while the authorization store is degraded it is default-denied with a definitive 403 (no retry hint; such a token never holds the grant). The 503 shape above (retry_after_ms 5000, no permission field) is the Forensics check’s own answer. Every surface first runs its own admission gate (Response:Read; Inventory:Read and Execution:Execute on the Hardware poll), and when a whole-store outage fails that gate first, the gate’s own answer is returned instead, in its own shape (it can name a permission), and yuzu_server_forensics_tier_withheld_total does not move. Retry, and see the alerts below if it persists. “Create group from results” creates nothing and shows an error toast (“The permission check for this action could not be completed. Nothing was created. Retry shortly.”) when the permission check cannot be completed (HTTP 200; the toast arrives in an HX-Trigger showToast header because the form swaps no body into the page); its store-degraded case answers 503 with its own error toast and also creates nothing. The Hardware Actions-lens result poll tells a caller who may not read a Forensics-tier reply that the reply needs Forensics:Read, instead of reporting a timeout; that notice, and the notice shown when the permission could not be confirmed, leave no audit row of their own. The poll is not re-armed after the “could not be completed” notice: do not press Run again for that reason, because Run starts a new collection although the first reply may already be stored; open the execution’s results (the Executions page) instead. A call made with an API token, an MCP token or an engine principal that does not hold a global Forensics:Read also leaves one auth.permission_required denied audit row per read of these responses, so expect new denial rows from automation that polls stored responses; an interactive browser sign-in (local, OIDC or SAML) does not leave one. The volume is one row per read attempt per such caller: a token that polls once per second leaves about 86,400 denial rows a day (arithmetic from the one-row-per-attempt rule, not a measurement), so size audit retention and SIEM ingest before pointing such automation at a tier it does not hold.
What an operator or an agentic worker will notice. A succeeded execution of one of these plugins shows zero rows to a caller without Forensics:Read (the fan-out counts come from the execution record, not from the rows); that is the tier, not a lost result. A retryable 503 / -32603 with retry_after_ms means the authority could not be determined, not that access was refused. A bundle containing a Forensics step never completes for a caller who cannot read it (the step stays pending; the MCP result keeps advertising retry_after_ms, and each poll refreshes the manifest window, so a worker must bound its polling with its own deadline). The dashboard Console stays on “Waiting for results…” for a Forensics action, because nothing is published to it; open the execution results. A policy whose check runs one of these plugins shows unknown for every agent although the check is still dispatched.
Who is affected. Callers who held only Response:Read: the Viewer, Operator, PlatformEngineer and ITServiceOwner roles, and, while RBAC enforcement is off, every non-administrator session (Forensics:Read is administrator-only until enforcement is on). They no longer see the stored rows of these plugins, including in the executions drawer. A caller with the Execution:Read permission but not Response:Read no longer sees the responses section of the executions drawer at all; it names the missing permission instead. Administrators, and holders of the new ForensicsAnalyst role (below) or of a role delegated Forensics:Read, see no change. Automation that polls these plugins’ results with an API token is affected the same way as its owner: every one of them needs a global Forensics:Read; a service-scoped token never proves it. A plain API token carries its owner’s authority, and so does an MCP-tier token created by a user holding a global Forensics:Read.
How to fix. Decide who needs these rows, then:
- RBAC enforcement on. Assign the new built-in
ForensicsAnalystrole (read-only:Forensics:Read,Response:Read,Execution:ReadandInventory:Read) fleet-wide, through the Users and roles page,POST /api/v1/rbac/roles/ForensicsAnalyst/assignmentsor the MCPassign_rbac_roletool. It does not allow dispatching anything: collecting forensic data stays approval-gated, and a user holdingOperatorplusForensicsAnalystcan read what was collected but still needs an approval ticket to collect it. A user holding onlyForensicsAnalystcannot dispatch at all:POST /api/commandanswers403for want ofExecution:Execute. On the MCPexecute_instructiontool at the supervised tier, the approval step runs before the tool’s own permission check, so an approval ticket can be spent by a call that is then refused for the same reason; that ordering already applied to any caller who lackedExecution:Execute.Administratoralso holdsForensics:Read. See “The ForensicsAnalyst role” in the RBAC guide. - RBAC enforcement off (the shipped default). Only administrators can read these rows, and no role grant changes that while enforcement is off (an assigned
ForensicsAnalystgrant is stored but inert until enforcement is switched on). To delegate the read, turn enforcement on and assign the role. Enforcement is fleet-wide and changes more than this read: follow “Enabling RBAC” in the RBAC guide first, because with enforcement on a user who has no management-group role assignment sees no agents, so every operator needs a management-group role before the switch. - Only a fleet-wide grant reads these rows. There is no management-group-scoped read tier: a
Forensics:Readgrant scoped to a management group (which can only be written directly in PostgreSQL, which is unsupported and is not recorded in the in-product audit log) reads nothing from the stored-response surfaces (the per-device application-usage read,GET /api/v1/forensics/agents/{id}/app-usageand MCPget_agent_app_usage, is a different data set with its own per-device scoped gate and still honors such a grant for devices in the group). The scoped assignment routes accept onlyOperatorandViewer, so evenAdministratoris refused at management-group scope, and engine principals cannot hold a scoped assignment or the seeded built-in roles (a hand-made non-system role that reuses a built-in name is not barred; see “Role name collision” below).
The filter works per plugin because only the plugin name is stored with a row; no plugin with both Forensics and ordinary actions exists today. The tier is per plugin, not per kind of data: other plugins can return comparable information under their own securables (for example file, registry, WMI or event-log reads), and their stored rows remain governed by Response:Read as before.
Deployment contract (hard cutover). This release supports initial deployment into a fresh database with no data imported from earlier builds. Existing databases and backups from earlier builds are unsupported. Every server writer and serving replica must run the new build. Earlier binaries must have no access to the new deployment’s database. Restarts, additional replicas and recovery may reuse data created entirely within this deployment contract. Recovery means rolling forward on the new build, or restoring this build’s own backups into a fresh deployment; a database or backup written by an earlier build is unsupported. The executable does not enforce this: it does not verify the provenance of the database it opens and it cannot keep an earlier binary away from it. Provisioning a fresh database and excluding earlier binaries from it are deployment responsibilities. Data written by earlier builds is outside the supported population: this section describes no upgrade path, no operation alongside earlier builds and no return to an earlier build, and makes no statement about what an earlier build stored. An earlier binary has no Forensics tier, so pointing one at this deployment’s database would let every holder of Response:Read read these rows again, and the executable cannot prevent it. Notification history is served as stored (there is no read-time scrub and no retention bound), so a database carried forward from an earlier build would expose earlier failure text through the notification list to a holder of Infrastructure:Read, and nothing in the executable detects such a database. Test environments follow the same rule: because this release is a fresh-deployment cutover, any pre-release upgrade test (the /test skill’s Phase 2, which upgrades the previous release’s database to HEAD) must run against a freshly provisioned database, and standing rigs (the 3-env stack, a home server, a shared test PostgreSQL, long-lived agent hosts) must be reprovisioned fresh before they are pointed at this build. The contract is a scope decision of the project owner, 2026-10-24 (no upgrade path is provided); it is not an acceptance of any residual risk, and it is procedural, not enforced by the executable.
Leftover enrollment files (added 2026-10-30 as a hazard disclosure; not part of the owner’s decision above). The statement above that no data is imported from earlier builds has an exception for two files. The server keeps one import record per file kind (tokens, pending agents), holding a SHA-256 of the file. At a boot where there is no record for that kind (a fresh database, or one restored from a dump taken before the first import), it imports enrollment-tokens.cfg and pending-agents.cfg from --data-dir, or from the directory holding yuzu-server.cfg when --data-dir has no copy, without checking where they came from; a copy in the other directory is never imported but stays in place, so remove it too. Where a record exists, an identical file is skipped and a different file (for example one restored with an older data directory) is refused, not merged: the server logs [enrollment-import] ... DIFFERS from the file that was already imported at CRITICAL, writes an enrollment.import audit row with result=warning, counts yuzu_server_enrollment_import_total{outcome="fingerprint_mismatch"} and leaves the file in place; remove or archive it. A file that exists but cannot be read stops the server from starting. Imported tokens (with their max_uses, use_count, expires and revoked state) and approved pending agents are live credentials. Before the first boot of a fresh deployment, confirm both files are absent from both directories; a fresh database is not enough if the data directory is reused. Also look for their .imported renames: the server never reads those again, but they show that an import happened. The directories are: for the Docker image and the reference compose files, /var/lib/yuzu (the <project>_server-data volume); for the packaged Linux service, /var/lib/yuzu and /etc/yuzu; for the Windows installer, %ProgramData%\Yuzu Server and %ProgramData%\Yuzu Server\data. To find out whether an import already happened, look for the log line [enrollment-import] ONE-TIME import of legacy ..., an enrollment.import audit row (source=<path> in the detail), the counter yuzu_server_enrollment_import_total{outcome="imported"} (per process, so it resets on restart), and rows whose created_by or status_changed_by is legacy-import. The audit row, the legacy-import rows and the import record are stored in the database, so a database restored from a dump taken before the import contains none of them; the .imported renames and the server log are on the host and are not rolled back by a restore. With several server replicas on one database, check the directories on every replica. To undo an import, revoke the imported tokens with DELETE /api/settings/enrollment-tokens/{id} and deny any imported agent that is still pending (POST /api/settings/pending-agents/{id}/deny). An imported agent that was already approved cannot be denied or removed through the dashboard or the REST API (both answer 409), is listed nowhere, and registers without an enrollment token; there is no supported way to remove one. Treat a deployment that imported approved agents as one whose enrollment state you cannot trust, and redeploy from a fresh database and empty directories. Removing the rows in SQL (auth.pending_agents, status_changed_by = 'legacy-import') is outside the supported surface and unaudited.
After deployment.
- Assign the
ForensicsAnalystrole (orAdministrator) fleet-wide to the people and automation that need to read these rows (see “How to fix”). - Watch
yuzu_server_forensics_tier_withheld_total(it should stay flat),yuzu_server_response_provenance_total{outcome}(theunrecordedandlookup_degradedoutcomes should stay flat) and the counters that name the cause:yuzu_exec_correlation_write_degrade_total(dispatch-record writes that failed or, in the operator Guardian rule push after its first failure, were skipped; one outage push adds one count per target),yuzu_exec_correlation_read_degrade_total{reason}(record reads that failed) and theyuzu_pg_*pool and lock metrics. The alertsYuzuDispatchRecordWriteFailing(write failures),YuzuResponseProvenanceLookupDegraded(record reads that fail twice) andYuzuExecCorrelationReapSaturated(the retention sweep running at its cap) watch the record (see “Alert” below). If one fires, check PostgreSQL health and locks first, then the matching counter. Also watch the server log warning “Forensics execution lookup failed” (a second signal, log only with no counter: the execution lookup that decides whether stored failure text is shown failed, and the text was withheld; throttled to about one line a minute), and theresponse.forensics.readaudit verb (it is the evidence of who read these rows, with limits: it is written only by reads that return at least one such row; aggregate, facet-option and count reads, the dashboard filter bar and the create-group-from-results action write no such row; an interactive cookie session that lacks the grant leaves no denial row; and on the dashboard and MCP surfaces a failure to persist it does not stop the data being served, unlike REST; the audit log guide lists the reads it does not count). Expectauth.permission_requireddenial rows from token automation that polls these responses without the grant (about 86,400 a day for a once-per-second poller; see “What the caller sees”). - Verify. Run a Forensics-tier action against one device, then read its responses with
query_responsesas an Administrator (the rows are returned and aresponse.forensics.readrow is written) and as an Operator (no rows and no error). If the Administrator gets none, first check that the action completed on the device and that the Administrator holdsForensics:Read. Then run an ordinary plugin (one that is not Forensics-tier) against the same device and confirm that the Operator sees its rows and thatyuzu_server_response_provenance_total{outcome="verified"}moved. This second check matters: if every dispatch-record write is failing, every response is stored~unverified, which is Forensics-tier, so the first check gives the same observations as a healthy tier while every ordinary plugin’s results are hidden from every caller withoutForensics:Read.
Known limits. The filter covers reads of stored responses and the server-side consumers listed above. It does not change who may dispatch a Forensics-tier plugin; a separate follow-up (#6065) tracks the dispatch-side authorization of policy checks. The provenance label proves that the server issued the command id for that plugin; it does not prove who sent the frame. An endpoint that was legitimately sent a command (or was inside a broadcast of it) can replay that id on a frame of its own and so label that frame with the command’s plugin, and the content of a frame is not validated. A single execution that runs longer than the 24 hour record window finds no record when it answers and is stored as ~unverified; a delayed or staggered command does not reach that window, because delay and stagger are each limited to 300 seconds (600 seconds in total) and only POST /api/command accepts them. A dispatch whose record write failed has its responses stored as ~unverified, so a reader that excludes the Forensics set (for example the Hardware Actions-lens result poll) never finds them, and a registry write command with no record mints no registry.write audit rows, because those rows are minted only for a response labelled registry.
Role name collision. The ForensicsAnalyst role is created by the idempotent seed pass at server start and is assigned to nobody. If a role with that name that is not a built-in role already exists (it can only have been created by hand in PostgreSQL), it is left as it is, the four read grants are NOT added to it (so its existing holders gain no Forensics:Read), and the server logs one error naming the role at start-up; the built-in role is then unavailable until the hand-made role is removed and the server restarted. To find it, list the roles (GET /api/v1/rbac/roles) and look for ForensicsAnalyst with is_system false, and its holders with GET /api/v1/rbac/roles/assignments. While the collision exists, assigning ForensicsAnalyst through POST /api/v1/rbac/roles/ForensicsAnalyst/assignments or assign_rbac_role is refused as a name collision (409 on REST, an invalid-params error on MCP, audited). There is no product route to remove a role, so the remedy is direct SQL on the rbac_store schema, which is unsupported and not audited: create a replacement role under another name first if its holders need one, then DELETE FROM rbac_store.roles WHERE name = 'ForensicsAnalyst' AND NOT is_system, which also removes the role’s grants and assignments (they reference the role by name with ON DELETE CASCADE, and a role with grants cannot be renamed); a hand DELETE does not bump the RBAC generation, so restart every serving replica afterwards. If the seed’s role check cannot be completed at start-up, the four grants are withheld for that boot and added at the next one (an error is logged). Grants an earlier build already merged into such a role are not removed. Because it is not a built-in role, the engine-principal bar does not apply to it: review who holds it and what it grants.
Alert. YuzuForensicsTierUnresolved fires when reads are being refused because the caller’s authority could not be determined, repeatedly over about 10 minutes (it is rate(yuzu_server_forensics_tier_withheld_total[5m]) > 0 for 10 minutes, so a single refusal or a burst shorter than about 5 minutes does not fire it, and neither does a refusal that recurs only every 5 minutes or longer, by design, because the 5 minute rate window is then false between events). It points at the authorization store (rbac_unavailable), as the metric’s reason label names; unwired is not a store problem but a wiring defect in the build, which a running server should not show. For rbac_unavailable, check the authorization store’s health and correlate with yuzu_server_rbac_read_degrade_total, the pool metrics and the server log line “Forensics-tier visibility could not be resolved”; the reads recover on their own once the store answers.
Three more alerts (four in all, with YuzuForensicsTierUnresolved above) watch the dispatch record, which decides the label (and so the tier) of every stored response. YuzuDispatchRecordWriteFailing (warning) fires when rate(yuzu_exec_correlation_write_degrade_total[5m]) > 0 for 10 minutes: dispatch is unaffected, but each response to a command whose record was not written is stored ~unverified. YuzuResponseProvenanceLookupDegraded (warning) fires when yuzu_server_response_provenance_total{outcome="lookup_degraded"} has a non-zero rate for 10 minutes: the record read failed twice for incoming responses, with the same consequence. For both, check PostgreSQL health and locks and the yuzu_pg_* pool and lock metrics first, then the counter; responses already stored ~unverified are not repaired when the store recovers, so re-run the affected commands. A dispatch rate below about one every 5 minutes is not detected by the rate window (the counter is the complete evidence). YuzuExecCorrelationReapSaturated (warning) fires when the hourly sweep of the dispatch-record table has run at or near its 50,000 rows per pass cap for 24 hours (increase(yuzu_exec_correlation_reap_total[6h]) >= 200000): records are created faster than the sweep reclaims them, which is a capacity signal and leaves nothing unverified. There is deliberately no alert on the unrecorded outcome: it also counts an id the server never issued, a record reaped after 24 hours and an empty id, so it is non-zero on a healthy fleet.
Start-up refusal. If the log shows [Forensics] Refusing to start: <reason> (#4990) - fail-closed at ERROR level and the server exits, the capability catalogue compiled into this binary yields no usable Forensics plugin set, and the server will not start rather than serve a tier that excludes nothing. It is a defect in the build, not in the configuration or the database: install a correct build of the same release.
Behaviour change: new enrollment tokens default to 72 hours and 100 uses
What changed. A limit left blank when minting an enrollment token now defaults to 72 hours
and 100 uses, on every create surface: the dashboard form, POST /api/settings/enrollment-tokens,
POST /api/settings/enrollment-tokens/batch (ttl_hours only — its max_uses default stays 1),
and the server CLI --generate-tokens --token-ttl-hours (its --token-max-uses default also
stays 1). An explicit 0 still means unlimited uses / no expiry, exactly as before. The CLI now
refuses a negative --token-ttl-hours or --token-max-uses (a negative --token-ttl-hours used
to mint a token with no expiry, and a negative --token-max-uses was refused by the store rather
than by the option parser), and its enrollment.bulk_token_create audit row now records ttl_s=
and label=.
Who is affected. A script or operator that relies on leaving a limit blank to mean unlimited/never-expiring now gets a 72-hour/100-use token instead. A rollout that stages tokens in advance, or enrolls more machines from one token than its uses, is refused once the token is spent or expired.
How to fix. Pass an explicit 0 for max_uses and/or ttl_hours to keep the old
unlimited/no-expiry behaviour, or size them to the rollout window and the number of machines.
Behaviour change: a new Postgres schema for the fleet certificate inventory, and an eighth decommission store
The server now creates the Postgres schema certificate_inventory_store at boot (seven tables, no
data until a certificate-collecting agent reports). The server’s database role needs CREATE on the
database, as for every other store; if the schema cannot be created the server refuses to start
(fail-closed, ADR-0007). /readyz includes certificate_inventory_store once the server is up, so a degraded store after boot shows there.
DELETE /api/v1/sle/agents/{id} now fans out across eight stores: the response’s stores map
gains a certificate_inventory key and deleted reaches 8 when every store is configured. During a
rolling upgrade a replica that has not been upgraded runs the old cascade and returns 200 without
that key; it did not erase certificate rows. Re-issue the DELETE once every replica is upgraded (the
same advice as for the presence row, #5682). Erasure is not durable yet: a still-running agent
repopulates its rows on its next report until the decommission tombstone ships in a later change.
No agent sends certificate_inventory yet; agents start only once the server advertises that it
accepts the source, in a separate later change. The server’s gateway path (ProxyInventory) now
stores such a blob in this typed store, exactly as the direct ReportInventory path does. The
previous release discarded it there; releases before the typed-source read filter (see “Behaviour change: generic
inventory reads never return a typed source’s rows” below) stored it in the generic store, which that
filter and the boot purge now exclude. It never reaches the generic inventory store.
Rolling back leaves the certificate_inventory_store schema in the database; it is harmless to
the older binary and is not read. Plan capacity before agents start reporting: DER is optional and
bounded at 1 MiB per device, so a large fleet is storage- and WAL-heavy (see
Server administration).
Behaviour change: generic inventory reads never return a typed source’s rows
What changed. The generic inventory store’s reads (query_inventory, get_agent_inventory, list_inventory_tables and their REST twins, POST /api/v1/inventory/evaluate, and the inventory-query result-set source) no longer return rows for the typed daily-sync sources: installed_software, app_perf, device_ci, software_licensing, app_usage, and the reserved certificate_inventory. Those sources have their own stores and gates. Before this change, a typed source’s blob that reached the generic store through an older server’s gateway path could be read there under Infrastructure:Read or Inventory:Read until the next boot purge removed it. That window is now closed on every server at this release or later (see the per-server note below). A certificate_inventory blob, a source no agent sends yet, arriving through a gateway is stored only in the certificate inventory’s own typed store (see the certificate inventory note above), never generically.
Who notices. Only a caller that read one of those six source names from the generic store. Their data was never meant to be there. Use the typed surface instead, which is unchanged:
| Source | REST | MCP |
|---|---|---|
installed_software | GET /api/v1/inventory/software | query_installed_software |
device_ci | GET /api/v1/hardware/{id} | get_hardware_ci, list_hardware_ci |
app_perf | GET /api/v1/dex/perf/apps and the other /api/v1/dex/perf/app* routes | list_dex_perf_apps, get_dex_app_perf |
software_licensing | GET /api/v1/sle/agents/{agent_id} | query_software_licenses |
app_usage | GET /api/v1/forensics/agents/{agent_id}/app-usage | get_agent_app_usage |
certificate_inventory | reserved; its routes arrive with its store | — |
Deploy order, and what is per server. The filter protects reads served by a server at this release or later, and nothing else. Every server older than this release, or rolled back below it, keeps storing typed-source blobs that arrive through a gateway attached to it, and keeps serving them under Infrastructure:Read or Inventory:Read. In a multi-replica deployment that means the exposure is not closed until every replica runs this release: during a rolling upgrade, reads routed to an older replica still return those rows, and rows an older replica writes after an upgraded replica’s boot purge stay in the table (unreadable through upgraded replicas) until a purge runs again. Upgrade servers before agents, finish the rolling upgrade promptly, and never roll a server back below this release once agents send certificate_inventory (none does yet).
No action on upgrade. There is no schema change. The server still removes any stray typed rows at every boot, but it now does so in the background in bounded batches, so startup no longer waits on the purge and a large backlog cannot delay serving. Stray rows were already unreadable from the generic store, so nothing depends on the purge finishing. Completion or failure is logged, and removed rows and failures are counted in yuzu_inventory_typed_purge_rows_total and yuzu_inventory_typed_purge_failures_total. If a pass fails, a restart re-runs it.
Behaviour change: the DEX score policy document carries a version (#6085, #6086)
What changed. The canonical text of the dex_score_policy document now includes "version": 1, and version is a recognised key. A document without the field reads as version 1. A server that meets a version above the one it supports scores under the shipped defaults, reports fault: unsupported_version at version: N above the supported 1 on every Apps response, and logs it once. It never applies part of a document, and the revision stays the hash of the stored text. PUT /api/v1/dex/score-policy writes the document from this release. A build that predates the score policy reader ignores it. A development build that has the reader but predates this change refuses a version key as an unknown key and serves the shipped defaults with a fault for any document that carries it.
The rule, from this release. PUT and DELETE /api/v1/dex/score-policy are live, so the rule applies: do not change the score policy during a rolling upgrade or rollback: a newer server may write a document that an older server in the same fleet refuses or, if it predates the score policy reader, ignores (on a rollback, a build that predates the version key refuses it as an unknown key), and until every instance runs the same release the instances can score under different policies.
If a stored document is refused. A later release that tightens validation can refuse a document an earlier release accepted. The server then serves the shipped defaults with a fault naming the field. Save the policy again through PUT /api/v1/dex/score-policy (the response to a GET of the policy shows the revision to send as If-Match). Nothing is migrated automatically.
No action on upgrade. There is no schema change. No policy written by an earlier release exists to migrate (a row placed by a direct database write is read as any other document).
Behaviour change: the DEX score policy document gains model parameters and version 2
What changed. The score policy can now set the per-device event cap, the regression full-weight and start ratios (each globally and per application) and the interval confidence level (globally). The canonical text stays version 1 and byte-identical, with the same revision, until one of them differs from its default (5, 3.0, 1.0, 0.95) or a per-application override sets one, even at the global value, because that pins the application. The writer then stores version 2, with a model object. A version 1 document that carries model is refused. With the defaults every score, interval and band is exactly what the previous release produced.
The rule. A server on the previous release meets a version 2 document as unsupported_version at version: 2 above the supported 1. It scores under the shipped defaults with that fault on every Apps response and never applies part of the document. So do not set a model parameter during a rolling upgrade or rollback.
While a version 2 document is stored, new and old replicas return different Apps figures. An older replica’s GET /api/v1/dex/score-policy shows the shipped defaults, not the stored document, and a PUT or DELETE from it replaces the whole document. The audit row records the new document and the prior revision, not the replaced text, so keep a copy of the version 2 document (GET it from a current replica) before any such write. After a rollback, save the policy again without the model block first; DELETE only if resetting tiers and weights too is intended. The unsupported_version fault is described in REST API.
No action on upgrade. There is no schema change. A stored version 1 document stays valid and is read as before.
⚠️ Breaking: POST and DELETE /api/v1/quarantine now apply the dispatch approval check (#6065)
What changed. POST /api/v1/quarantine (and the dashboard’s Quarantine device button, which calls it) now runs the same dispatch approval check as the quarantine plugin’s quarantine action, after the scoped Security:Execute gate and the whitelist validation and before any record is written. DELETE /api/v1/quarantine/{agent_id} (the Release from quarantine button) now runs the same check for the plugin’s unquarantine action, after the scoped gate and before the attempt audit row and the release. Neither depends on any approval-workflow setting.
“Administrator” here means an account whose account role is admin (a live temporary JIT elevation counts as that role). The RBAC Administrator role, or a global Security:Execute grant, on an account whose role is user does not make it one.
- RBAC on, a non-administrator who holds
Security:Executeglobally: admitted before (201, and the reconciler then isolated the device), now403approval_required. This includes an account that holds the RBACAdministratorrole but whose account role isuser. REST has no approval-ticket path, so such a caller cannot create a quarantine record through REST or the dashboard. - RBAC on, a caller who holds
Security:Executeonly through a management group: now403forbidden, the same answer MCPquarantine_devicegives at its pre-check. So is an account that is an administrator only through a JIT elevation and has no globalSecurity:Executegrant of its own: the check reads the account’s own grants, not the elevated role. - RBAC off (the default): nothing changes for non-administrators, because the legacy gate already refuses
Security:Executewithout theadminaccount role, so this check adds no new refusal there. A JIT-elevated administrator is admitted as before. - A session that expires between the route’s first authorisation check and its second look-up now ends the request at the
401. Before, the record was written and attributed tosystem. A request whose two look-ups name different principals (a session cookie that lapsed with a bearer token for another account on the same request) also ends at401and writes no record, and the audit rows of both routes now name the session the route resolved, so they agree with the record’squarantined_by. When searching the audit log for callers relying on the old behaviour, ignoredeniedrows whosedetailisauthenticated principal changed during the request now=<account>: they are that401, not an approval refusal.
A refused request writes no quarantine record and releases none. The 403 bodies carry message and remediation and no permission field.
Release. The same answers apply to DELETE: a non-administrator who could end a record before now gets 403 approval_required (RBAC on, global grant) or 403 forbidden (group-only, or an unreadable RBAC store), the record stays active, and the refusal is audited as quarantine.disable with result=denied and no attempt row. Only an administrator (with RBAC on, one who also holds Security:Execute globally) can release a record through REST or the dashboard. A JIT elevation counts as the administrator role only if the account also holds Security:Execute globally; otherwise the release is refused forbidden. The remediation text names the MCP supervised flow, which can create a record but has no release tool, so for a release the only path is an administrator. Release still ends the server record only; it does not remove the endpoint firewall rules.
Rolling upgrades and rollback. The check is enforced per server replica. During a rolling upgrade, or after rolling a replica back, a replica that has not been upgraded still accepts POST /api/v1/quarantine from a non-administrator who holds Security:Execute on the device (201, and the leader then isolates the device) and accepts the same caller’s DELETE release (200). The check applies across the cluster only once every replica runs this version, so do not treat a 201 from a non-administrator during the rollout as a failure of the new check. Rolling back restores the old behaviour; no data changes in either direction.
What to check. Automation that creates or releases quarantine records with a non-administrator API token or cookie session must move to a durable administrator account (not a JIT elevation; with RBAC on, one that also holds Security:Execute globally in its own right). For creation only, MCP quarantine_device on the supervised tier is the alternative, where an administrator approves the ticket in an interactive session with MFA step-up (see the #6061 note below); release has no such path and needs an administrator. To find callers that were relying on the old behaviour, search the audit log for action quarantine.enable (create) or quarantine.disable (release) with result denied: the principal and the device are on the row. yuzu_server_dispatch_denied_total{reason} shows only the fleet-wide trend; it is shared with MCP and /api/command and cannot name this route. For a least-privilege containment operator (Security:Execute, not an administrator) there is no REST or dashboard approval path yet; adding one is planned follow-up work, and until then creation can go through the MCP supervised flow or the quarantine plugin’s quarantine action on the Instructions page (role-gated approval). Releasing through the plugin’s unquarantine action alone clears the endpoint firewall rules but leaves the active record, so while the record is active the reconciler re-applies the stored isolation when the device next reconnects; an administrator must end the record. No configuration or data migration is required.
⚠️ Breaking: cookie-session writes require a same-site Origin or Referer
Before you upgrade. Do these on the version you are running now:
- If a reverse proxy fronts the dashboard, check that it forwards the client’s
Hostunchanged, port included. nginx rewritesHostby default (it sends$proxy_host), so addproxy_set_header Host $http_host;in everylocationthat proxies to Yuzu, or atserverlevel.$hostdrops the port, so use$http_hostunless the public port is 80 or 443. Behind a TLS-terminating proxy, also declare the public origin with its scheme (for example--csrf-trusted-origin https://yuzu.example.com) even whenHostis forwarded: the server cannot see the public scheme, and the scheme-qualified entry makes it refuse a plain-http://Originfor that host. If the proxy cannot forwardHost, set--csrf-trusted-origin https://<public-origin>(or the environment variableYUZU_CSRF_TRUSTED_ORIGIN) on every server replica first, restart, and confirm the start-up line described under “Confirming it took” in Server Administration. - Create at least one API token for your automation before upgrading. Creating a token from the dashboard is itself a cookie-session write, so it is refused on a misconfigured proxy after the upgrade.
- A script that reuses a login cookie jar should switch to the token.
Symptom and recovery. On a deployment that skipped step 1, every dashboard write answers 403 cross-site request refused; sign-in and ordinary reads still work, API tokens cannot be created, and the step-up elevation request is refused. The check cannot be disabled: the remedies are the flag, an API token or rolling back. Recovery is to restart with the flag or environment variable, or to reach the server directly (for example an SSH tunnel to the web port, so Host matches Origin), sign in and create a token. A script that reaches the server directly must send an Origin naming the address it connects to (for example http://localhost:8080, or https://localhost:8443 when the server terminates TLS itself, where an http:// origin is refused). Direct access needs a local sign-in: a deployment that offers only OIDC or SAML sign-in redirects to the public URL, so for those only restarting with the flag works. There is no reload: the value is read at start. Every replica needs the same value, or the same page flaps between 200 and 403 depending on which replica answers. An Origin: null header, which some browser and proxy configurations send, cannot be fixed by the flag; do not set Referrer-Policy: no-referrer at the proxy (it removes the Referer fallback, and the Fetch standard makes browsers serialise the Origin of a non-CORS form submission as null under that policy; script-driven requests are unaffected), and the remedy is to remove it or roll back. /livez and /readyz stay green throughout, so a probe does not detect this. The upgrade makes no schema change, so downgrading is safe.
What changed. Every request other than GET, HEAD or OPTIONS that is authorised by a session cookie must carry an Origin or Referer header naming this site (or an entry from --csrf-trusted-origin). Otherwise it answers 403 cross-site request refused (the standard error envelope, with a remediation) and is audited as csrf.denied. One check, applied centrally, decides this after authentication and before step-up evaluation, challenge emission and route-handler effects, and before the request body is parsed; a client that sends Expect: 100-continue has already been told to send the body, and a large upload may still be in flight when the 403 arrives. A request that presents a valid session cookie is treated as a cookie-session request even if it also carries a token. A request authenticated only by an API, MCP or engine token is not subject to this check, and neither is a request answered before a session is resolved. The per-route checks described elsewhere in this guide stay in place as defence in depth; a route that keeps a copy of its own that is not token-exempt, so a token caller of one of those can still be refused by it.
Guarded reads. The same rule also covers cookie-session requests that dispatch work, persist state or force a refresh of server state, whichever method they use (the guarded reads). The exact path, never a query flag, selects the gate. The mechanism for them is the browser’s fetch metadata, and a Sec-Fetch-Site header that is present decides alone: exactly one header with value same-origin or none is admitted, any other value (or a repeated Sec-Fetch-Site header) is refused with origin_mismatch, and Origin and Referer are not consulted. Only when Sec-Fetch-Site is absent does the Origin or Referer rule above apply. Browsers send Sec-Fetch-* headers only to potentially trustworthy origins (HTTPS and loopback), and Safari before 16.4 sends none, so on a plain-HTTP deployment at a non-loopback address these requests depend on the Origin/Referer rule; a same-origin GET carries no Origin, so it depends on Referer, which the server’s default Referrer-Policy (strict-origin-when-cross-origin) supplies. A proxy or page policy of no-referrer would break those dashboard panels on such a deployment; a TLS-terminating proxy is fine, because the browser talks to the proxy over HTTPS. These requests are audited as csrf.denied with a target_id such as GET /path. Token callers are not gated.
Who is affected. A script or tool that signs in with a cookie and writes without an Origin or Referer header now gets 403. A deployment behind a reverse proxy that rewrites Host and has no --csrf-trusted-origin entry has every cookie-session write refused until the flag is set. At start-up the server prints one line to standard output: the number of origins accepted and their values when at least one is accepted, otherwise the rule and a note that none was accepted (it is not written to --log-file, and --log-level does not hide it). The first refusal of each kind logs a warning naming the flag. The dashboard and other browsers send these headers on every write and need no change. A script or tool that sends the guarded reads (cookie-session requests that dispatch work, persist state or force a refresh of server state) with a login cookie and no Sec-Fetch-Site header must now send a same-site Origin or Referer; one that sends Sec-Fetch-Site must send it once with value same-origin or none. Otherwise it gets 403. The audit row for such a refusal still carries the fixed detail text “browser write refused”. Browsers need no change. On most dashboard pages a refused write shows no error message (the page does not swap the 403 response in), so a proxy misconfiguration can look like buttons that do nothing; check the audit log and yuzu_server_csrf_denied_total.
How to fix. Send Origin matching the console origin (for example -H "Origin: https://yuzu.example.com"), or Referer for an endpoint that validates Origin against its own allowlist (see MCP), or use an API token. Behind a reverse proxy, declare the public origin with --csrf-trusted-origin, including the scheme (for example https://yuzu.example.com); this is required if the proxy rewrites Host, and with a scheme it also makes the server refuse a plain-http:// Origin for that host. A server that terminates TLS itself needs no declaration: it requires an https:// Origin or Referer. For the guarded reads, either send no Sec-Fetch-Site header and a same-site Origin or Referer, or send Sec-Fetch-Site once with value same-origin or none (a Sec-Fetch-Site header that is present decides alone, so a script that sets it to any other value, or sets it twice, is refused even with a good Origin); or authenticate with an API token. Do not configure the proxy to inject or rewrite Origin or Referer, which would turn the control off for that deployment. No data migration is required.
What to check. Since this release, a csrf.denied row for a cookie-session caller has result denied, target_id set to the sanitised request method and path (for example POST /path; the request path is truncated at 200 bytes) and a detail of fixed text followed by reason=<token>. Rows keyed on the earlier per-route target_id values or on result error now appear only for token callers of routes that keep their own non-token-exempt copy, so a SIEM rule keyed on those values goes quiet for cookie callers: re-key it on action csrf.denied (and result denied), and treat the date of this release as the boundary in exported evidence. The counter yuzu_server_csrf_denied_total{path_class,reason} records every refusal (reason is no_origin_no_referer, origin_mismatch, referer_mismatch or gate_error) and the shipped yuzu-web-writes alert rules watch it (Metrics); a rise after the upgrade is usually a script without the header or a proxy without the flag.
Behaviour change: privileged REST routes refuse cross-site cookie writes
What changed. A call to a privileged REST write route (the routes whose reference entry carries a “Cross-site writes” note in the REST API guide) authorised by a session cookie must carry an Origin or Referer header naming this site (or an entry from --csrf-trusted-origin). Otherwise it answers 403 cross-site request refused and is audited as csrf.denied. The central same-site check (see the section above) answers first, after authentication and before authorization; each of these routes keeps its own copy after its authorization gates and MFA step-up as defence in depth, and before the request body is read. API, MCP and engine token callers are not affected by these routes.
Who is affected. A script or tool that signs in with a cookie and calls one of these routes without an Origin or Referer header now gets 403. No dashboard page calls these routes.
How to fix. Call the route with an API token, or send an Origin header matching the dashboard origin (for example -H "Origin: https://yuzu.example.com"). Behind a reverse proxy that rewrites Host, declare the public address with --csrf-trusted-origin. No configuration or data migration is required.
⚠️ Breaking: approving or rejecting a request now needs an interactive session and an enrolled MFA factor with a fresh proof; MCP cannot approve (#6061, #6079)
Before you upgrade (checklist).
- List your administrators and everyone who holds
Approval:Approve(see “Find affected automation before you upgrade” below). With RBAC enforcement off (the shipped default) the approvers are exactly the accounts withrole = 'admin'; the step-0 query in step 2 returns the local ones that are not yet enrolled. - Check who is enrolled: run the step-0 SQL, the first block of Path A in the recovery runbook. On a Linux package install the
psql "$YUZU_POSTGRES_DSN"blocks in this section fail in an interactive shell, because the DSN lives only in the env file, which is readable only by root and theyuzuaccount; read the value into a shell variable (do not source the file, because a DSN containing&or spaces breaks as shell) withYUZU_POSTGRES_DSN=$(sudo sed -n 's/^YUZU_POSTGRES_DSN=//p' /etc/yuzu/yuzu-server.env)and then run each block as written. - Enrol each administrator in Settings → Security and sign-in → Two-factor sign-in; another administrator cannot do it for them.
- Plan each non-administrator approver (possible only once RBAC enforcement is on): it needs the runbook path, not Settings.
- A SAML deployment has no approver path (#6098); give those approvers a local or OIDC account first.
- Have at least two enrolled approvers: a submitter cannot review their own request, so a single enrolled approver cannot decide that approver’s own requests.
- After the upgrade, run the approvals-refused query (step 6 of “After upgrading, verify approvals on a test request” below) to see whether approvals are being refused and why.
- Owners of scripts that run
yuzu-server --mfa-reset, and of SIEM rules onmfa.disabledormfa.reset.breakglass:--mfa-resetnow exits1when ending the sessions fails (result=partial), when the audit store refuses its intent row, and when the clear itself fails; every clear writes two audit rows; a failed session delete isresult=partial, notsuccess(details in the--mfa-resetnotice below).
What changed. Approve and reject (POST /api/approvals/{id}/approve, POST /api/approvals/{id}/reject,
the dashboard Approvals page, and the MCP tools approve_request / reject_request) now require all of:
an interactive cookie session (local or OIDC), an enrolled MFA factor with a fresh proof (#6079, described below), a human
principal and not an engine principal. The existing rule that the reviewer cannot be the submitter is unchanged.
- API-token and MCP-token sessions are refused with a typed
403(A4 error envelope). - MCP can no longer approve or reject. The MCP transport is not an approval surface (it
runs no MFA step-up), so
approve_requestandreject_requestalways return a typed refusal, whatever credential the caller used. This is an exception recorded in ADR-1005’s exception ledger (entry 2026-10-18: “human review is a human-session act”). The tools stay registered, and listing pending approvals through MCP is unchanged. Their tier/permission mapping isApproval:Approve(it was mapped asApproval:Write, #3151). - SAML users cannot approve. SAML sessions can never pass step-up (#4935), so a SAML user is refused with
403. With step-up on (the default--mfa-step-up-window-secs), the central step-up check answers first with its generic403“MFA step-up is not available for SAML sessions in this release” and anmfa.step_up.requiredrowreason=saml_session_no_mfa_this_release; the typedapprover_assurance_required:saml_mfa_unsupportedappears only with--mfa-step-up-window-secs=0. A deployment whose approvers all sign in through SAML therefore has no working approver path until they are given an OIDC provider or a local account (under--auth-mode=sso-onlythe only local sign-in left is the break-glass account, which is not a substitute). This is a known limitation, tracked in #6098. Before #6079,--mfa-step-up-window-secs=0admitted a SAML approver; it no longer does. - The approver needs an enrolled MFA factor and a fresh proof, at most 300 seconds old (#6079). The
generic step-up gate can only ask for a factor the account has, so it passed an account with none; approve and
reject now use their own rule, in every
--mfa-enforcementmode:- Local account: it must have an enrolled TOTP factor, or the decision is refused
403approver_assurance_required:mfa_not_enrolled. The proof must also be no older than the window and must not predate the account’s current enrolment (disabling MFA and enrolling again invalidates older proofs; the response is the usual step-up401). - OIDC session: the identity provider must have asserted an MFA-method
amr(with aniat) on the ID token at sign-in. If it did not (no MFAamr, oriatmissing or not positive), the decision is refused403approver_assurance_required:mfa_proof_missing(with step-up on, typed only when--mfa-enforcementdoes not protect the role; otherwise the central check answers first with the401step-up). If it did, but the token was issued longer ago than the approver window, the response is instead the step-up401withchallenge_url=/auth/oidc/start. Read “fresh” narrowly: it means the provider issued an ID token with an MFAamrwithin the window; the MFA itself may predate the token, because the provider can reuse its own session. No second factor is presented again at approval time, and any single MFA-methodamrvalue counts. Real re-authentication (auth_timewithmax_age) is a named follow-up. - The window is
min(--mfa-step-up-window-secs, 300)seconds, and 300 when the setting is0or negative: 300 seconds is a ceiling, not a fixed window, so a setting of 60 gives a 60-second window. The ceiling is measured on the database clock against the session’s stored proof timestamp, so a paused virtual machine or a resumed host cannot extend it. It does not depend on--mfa-enforcement, so--mfa-step-up-window-secs=0no longer lowers approval assurance and no setting lengthens it past 300 seconds. - A deleted, deactivated or invalid account cannot decide an approval: a live session whose account row no
longer exists, is deactivated, or has an invalid username is refused. With step-up on (the default) the central
check answers first with
401MFA step-up required(remediationauth_db unavailable: retry shortly or contact an administrator); the typed403approver_assurance_required:account_unavailable(audit detailapprover_assurance:account_unavailable) appears only with--mfa-step-up-window-secs=0, or when the account disappears between the two reads. Sign in again or contact an administrator. - Break-glass accounts are enrolled by validation and get no exemption. The reviewer must still differ from the submitter.
- Known limitation, unchanged by this release: “differ from the submitter” means a different account name. One person holding a local and an SSO identity (or two SSO identities) counts as two reviewers. Resolving identities to one person is a planned follow-up; see “Who can approve” in Instructions.
- A non-admin cannot enrol itself under
--mfa-enforcement=optionaloradmin-only(login enrols only the accounts the mode protects, and the Settings enrolment routes are administrator-only). The seededOperatorrole holdsApproval:Approve, so with RBAC enforcement on, every such approver is refusedmfa_not_enrolledafter the upgrade until an administrator enrols them, either with a temporary enrolment sign-in window or by promoting each account to administrator just long enough to enrol and then demoting it; see Enrolling a non-admin approver, including the identity-proofing step (under the window, whoever signs in first with the password enrols the factor) and each path’s consequences. Plan this before upgrading, and make sure at least one enrolled, authorised approver exists who is not the submitter of each request you expect to be decided: until then nothing can be approved. A seeded administrator that has never enrolled is in the same position. Self-service enrolment for non-admins is a planned follow-up and is not part of this change.yuzu-server --mfa-resetends the account’s sessions, and disabling MFA in Settings ends every session of the account, the one you are using included (neither ends API tokens).--mfa-resetnow exits non-zero when ending the sessions fails (the factor is already cleared; it recordsresult=partialin its audit row): scripts that run it and treat exit0as success must handle that, and end the sessions by hand. It also exits1witherror: audit store did not accept the intent row; nothing was changedwhen the audit store refuses itsattemptedintent row (nothing is cleared; a second line on stderr points at the runbook’s “Fallback: direct SQL” section), and1when the clear itself fails. Every reset, and every Settings disable, now writes an extraattemptedrow before the outcome row, so a SIEM rule that counts one row per clear sees two; do not match onresult <> 'success'without excludingattemptedanddetail LIKE 'blocked:%'rows, and key failure rules onresult IN ('partial','error'). Order the pair by the auditid. With RBAC enforcement off (the shipped default), the legacy gate requires theadminrole for approving, so a non-admin is refused403“admin role required” before the MFA check and enrolling a non-admin approver achieves nothing until RBAC enforcement is turned on. - When the approver rule’s own read fails (an unreadable stored session proof for any approver, or, for a local
approver, an MFA-store fault at the approver’s own read: with the step-up window at
0or negative, or a fault between the central check’s read and that one), the answer is503withretry_after_ms, counted asyuzu_auth_secret_unavailable_total{route="approval"}; nothing is decided. While step-up is on, a local approver whose MFA store is unreadable is refused first by the central check:503withoutretry_after_ms, counted asyuzu_auth_secret_unavailable_total{route="mfa_stepup"}andyuzu_auth_mfa_step_up_gate_total{outcome="unavailable"}. - An OIDC approver whose stored MFA proof is dated after the database clock (the replica that took the sign-in ran
ahead of the database clock; the sign-in clamps the ID token’s
iatto that replica’s own clock, so an identity provider running ahead cannot cause this alone) is refused503with the messagethe approver's identity-provider MFA proof is dated after the database clockandretry_after_msnull; auditresult=error,detailapprover_assurance:mfa_proof_clock_skew. It is not counted inroute="approval". An administrator must correct clock synchronisation between the Yuzu server hosts and the database host (the remediation also names the identity provider). This503is typed only when the central check does not answer first (default--mfa-enforcement=optional,admin-onlyfor a non-administrator approver, or the step-up window at0), and only until the database clock reaches the stored instant (after at most as long as the skew is large): afterwards the approver gets403mfa_proof_missinguntil the session cache re-derives (a session create, MFA mark, elevation or delete by any user clears it; an activity touch does not; so does a server restart), and signing in again re-creates that state while the skew persists. Underrequired(oradmin-onlyfor an administrator) with the window above0, the central check answers first: the approver sees a repeating401step-up challenge, noapproval.*skew row, and a risingyuzu_auth_mfa_step_up_gate_total{outcome="refused"}; check clock synchronisation first. See OIDC approvers refused with a clock-skew 503.
- Local account: it must have an enrolled TOTP factor, or the decision is refused
- A same-site
Origin(orReferer) is now required. A browser or scriptPOSTto/api/approvals/{id}/approveor/rejectthat authenticates with a session cookie and carries noOriginorReferernaming this site now gets403(audited ascsrf.denied; the server-wide same-site check answers it before the step-up check, so a cross-site request that also has a stale proof gets this403, not the step-up401, and spends no step-up). A script that signs in withcurl -bmust send-H "Origin: <the server's own base URL>". A deployment behind a TLS-terminating or Host-rewriting reverse proxy must declare the public address with--csrf-trusted-origin, as for the sibling cookie-write routes described further down this page (for example the hardware-sync andPUT /api/config/{key}notes) and in Server Administration, or the dashboard’s Approve and Reject buttons are refused. Token callers are not subject to this check (they are refused as non-interactive sessions instead). The dashboard dialog shows anapprover_assurance_requiredrefusal as its own remediation text, but shows any other403(a missingOrigin, or a missingApproval:Approve) as “You do not have permission to decide approvals”, so a missingOriginlooks like a permission problem there; see “Who can approve” ininstructions.mdfor the causes. - A degraded session read fails closed. The route answers
503and the request stays pending; it no longer records the reviewerunknown(#4128). - The attempt is audited first. If the audit row cannot be written, approve and reject return
503and change nothing (#5823). - Expiry is enforced at consume. An approved request not used within 7 days of its review is refused
when it is consumed, using database time, even if no new request has been submitted since. The
statusshown on the Approvals page and returned by the list routes (GET /api/approvals,GET /api/v1/approvals, MCPlist_pending_approvals) is only rewritten toexpiredby the next accepted approval submission, so an aged approved ticket keeps showingapproveduntil then even though consume andScheduleRunneralready refuse it (tracked in #6098).
Who this affects. Any automation that approved or rejected over MCP or with an API token. It will now
fail. A supervised-tier agentic worker still requests approval and recalls with the approval_id, but a
person must approve in the dashboard or over REST on a signed-in session. SAML-only approvers must sign
in with a local account or OIDC. Also affected: every approver whose account has no enrolled MFA factor, which on a default install means every approver. With RBAC enforcement off
and --mfa-enforcement=optional (the shipped defaults) the only approvers are administrators, and none is enrolled until it
enrols itself in Settings, so each is refused until it does (another administrator cannot enrol it). Once RBAC enforcement is on, every
non-admin approver under optional or admin-only is in the same position and needs the runbook path. Also affected is every OIDC approver whose provider does not assert an MFA
amr; both are refused until fixed. Scripts that approve with a session cookie must complete the step-up
(POST /login/mfa/stepup) when they receive 401 with meta.mfa_step_up_required.
If you set --mfa-step-up-window-secs=0, approval no longer loses its MFA requirement: approve and reject use a
300-second window instead, and still require an enrolled factor. The setting still switches the step-up gate off for
the other step-up endpoints. Decisions made before the upgrade, and in particular any made with the setting at
0, gain no retroactive assurance: review every approval.approve audit row with result=success from that period,
and check every attempted row that has no outcome row (compare counts per target_id). A build that has #6061
also writes a result=attempted row first, so do not count both, and a decision whose outcome row failed to write
leaves only the attempted row (see Audit Log). A build that predates #6061 writes no
attempted row, only the outcome row.
Find affected automation before you upgrade. Nothing is listed for you, so search the audit log for callers that approved or rejected without a signed-in browser session.
-
Over MCP. Audit rows with action
mcp.approve_requestormcp.reject_requestare MCP approvals. The REST audit API can find them:GET /api/v1/audit?action=mcp.approve_request(andmcp.reject_request);principalnames the account that made the call. -
Over REST with a token. The audit row for a decision made with a bearer token carries
principal_class = 'agent'; a signed-in browser session carries'human'.GET /api/v1/auditreturns neitherprincipal_classnorsession_id, so the REST audit API cannot separate the two. Query the audit store (PostgreSQL) directly:SELECT timestamp, principal, action, result, target_id, source_ip, user_agent FROM audit_store.audit_events WHERE action IN ('mcp.approve_request', 'mcp.reject_request') OR (action IN ('approval.approve', 'approval.reject') AND principal_class <> 'human') ORDER BY timestamp DESC;A row with an empty
principal_classis one the server could not attribute to a token or a session; readuser_agentandsource_ipfor those.
The search only sees what the audit retention window still holds, so an empty result is not proof that nothing
approves by token. Before upgrading, also check which service accounts and API tokens hold Approval:Approve
and move each approval step to a person on a signed-in session. No audit query lists a token or service account
that holds Approval:Approve but has never approved: after this change it is refused anyway, so the check is
about finding automation to move, not about finding exposure. To list what is granted, read the RBAC grants
(GET /api/v1/rbac/roles/assignments, or the Access Reviews export GET /api/v1/access-reviews/export; both
need AccessReview:Read) and match each principal’s roles against the roles that carry Approval:Approve.
GET /api/v1/tokens lists only the calling user’s own tokens, so check each token owner’s grants.
What that search cannot see. The audit query finds token and MCP approvers only. The approvers most likely
to hit the new behaviour are human accounts, which carry principal_class = 'human' and so look the same before
and after the upgrade: SAML users (refused), OIDC users whose identity provider asserts no MFA-method
amr (now refused mfa_proof_missing; before this change they passed unless --mfa-enforcement=required), and local
accounts with no enrolled factor (now refused mfa_not_enrolled; before this change they passed without a second factor). To
list the SAML and amr-less OIDC operators who have signed in, query the login rows in the audit store (the
retention window applies):
SELECT principal, action, to_timestamp(max(timestamp)) AS last_login
FROM audit_store.audit_events
WHERE action = 'auth.saml_login'
OR (action = 'auth.oidc_login' AND detail LIKE '%amr_mfa_asserted=false%')
GROUP BY principal, action
ORDER BY last_login DESC;
timestamp is epoch seconds, hence to_timestamp. That lists who signed in by those routes, not who holds
Approval:Approve; compare it with the role assignments by hand. For an OIDC row the detail column carries the
display name and e-mail (personal data), which helps match a principal to a person. To list the local accounts
that have no enrolled MFA factor (read-only; SSO identities are rows with another identity_source), query the
auth schema:
SELECT username, role, last_login_at
FROM auth.users
WHERE is_active
AND identity_source = 'local'
AND mfa_enrolled_at IS NULL
ORDER BY username;
role is the account’s legacy role column, not its RBAC grants, so compare the result with who holds
Approval:Approve. An account listed here is refused as an approver (mfa_not_enrolled) after the upgrade, so enrol it first if it is meant to approve; a non-admin needs the enrolment sign-in window described above.
With RBAC enforcement off (the shipped default), a non-admin account is refused 403 “admin role required” before
the MFA check, so mfa_not_enrolled is what an administrator sees, and a non-admin sees it only once enforcement is on.
After upgrading, verify approvals on a test request. Use a test device and a harmless, read-only instruction, and do not skip the safety notes in step 1: an execute call that is not held for approval really dispatches.
-
Create a pending test request. Pick an instruction definition whose
approval_modeisalways(it needs a ticket whoever submits it) and that only reads state, such as a status or inventory query. No shippedalwaysdefinition is read-only (all are mutatingserver.*actions), so with one of those the202pending_approvalbefore dispatch is the safety property, and anything else is a finding. Do not use arole-gateddefinition from an administrator account: underrole-gatedan administrator, including one with an active JIT elevation, gets no ticket and the instruction is dispatched. As a signed-in dashboard session, or any session you can send a same-siteOriginwith, call the execute route with a JSON object body that names ONE test device explicitly:curl -b cookies.txt -X POST "https://yuzu.example.com/api/instructions/<definition-id>/execute" \ -H "Origin: https://yuzu.example.com" -H "Content-Type: application/json" \ -d '{"agent_ids": ["<test-device-agent-id>"]}'Never omit both
agent_idsandscopeon this call: an omitted target is the whole fleet, and only destructive and forensic actions refuse it. The body must be a JSON object (a bodilessPOSTanswers400“invalid request body”;{}is the minimum), and a session-cookiePOSTwithout a same-siteOriginis refused with403and acsrf.deniedrow. Expect202with"status":"pending_approval"and anapproval_idbefore you go on. Only a200(the instruction was dispatched) means it ran, so stop, treat it as a finding, and check what was dispatched; a400,403,404or503means nothing was dispatched, so fix the cause and retry. A500with messagedispatch failedmay already have queued the command, so check the executions list before retrying. You need two distinct accounts: the submitter cannot approve their own request (that attempt answers400and leaves anattemptedand adeniedrow), so approve it as a different human account that holdsApproval:Approve. Approving a ticket created this way does not run the instruction: an instruction-origin ticket has no redemption path yet, so the approval is recorded and nothing is dispatched (tracked in #6064). The test proves the approval gate, not execution. -
As that human approver on a signed-in browser session, approve the pending test request on the Approvals page. The approver must be an enrolled local account (or an OIDC login your provider sent an MFA
amrfor). Expect the step-up prompt if the proof is older than the approver window (min(--mfa-step-up-window-secs, 300), or 300 if that is0), then success. The audit store should show anapproval.approverow withresult=attemptedfollowed by one withresult=success, both withdetail=approver_assurance:enrolled_fresh_mfa, and thesuccessrow should carryprincipal_class = 'human'. If you script this step with a session cookie, send anOriginheader naming the server (for example-H "Origin: https://yuzu.example.com"), or thePOSTis refused with403before anything is recorded other than acsrf.deniedrow. -
With an API token that holds
Approval:Approve, sendPOST /api/approvals/{id}/approvefor a pending request (create a second test request if step 2 already decided the first). Expect403with messageapprover_assurance_required:non_interactive_session, and oneapproval.approverow withresult=deniedanddetail=approver_assurance:non_interactive_session. The same attempt through MCPapprove_requestreturns-32003with the same message. (A token withoutApproval:Approvegets the plain permission403first, so it does not prove the new check. Over MCP, an operator-tier or readonly-tier token is refused earlier by the tier check with-32004; use a supervised-tier or untiered token for this step.) -
Repeat step 2 with one account of each kind you run and check the outcome: a SAML user (expect
403MFA step-up is not available for SAML sessions in this releasefrom the central step-up check, with anmfa.step_up.requiredrowreason=saml_session_no_mfa_this_releaseand noapproval.*row; the typedapprover_assurance_required:saml_mfa_unsupportedappears only with--mfa-step-up-window-secsat0), an OIDC user your identity provider does not send an MFAamrfor (expect403approver_assurance_required:mfa_proof_missing; under--mfa-enforcement=required, oradmin-onlyfor an administrator, the central check answers first with a401that sends the user back to the identity provider, so a provider that never assertsamrloops instead of showingmfa_proof_missing), a local account with no enrolled factor (expect403approver_assurance_required:mfa_not_enrolled, with adeniedrow whose detail isapprover_assurance:mfa_not_enrolled), and an enrolled local account whose last MFA proof is older than the window (expect the step-up401, then success after the code). Then enrol a non-admin approver through the runbook and confirm it succeeds. Each repeat needs its own pending test request, submitted by someone other than the account being tried, and a decided request cannot be decided again. With RBAC enforcement off (the shipped default), only administrator accounts reach the MFA check (any other account is refused403“admin role required” first), so this step can only be run with administrator accounts, and the non-admin enrolment outcome cannot be observed until enforcement is on. -
Clean up. Reject every test request that is still pending, from the dashboard Approvals page or with
POST /api/approvals/{id}/reject(same gates as approve: a human session with a fresh step-up, a same-siteOrigin, and not the submitter). A ticket that is alreadyapprovedcannot be rejected; it stays listed asapproved, is refused at use 7 days after its review, flips toexpiredon the first accepted approval submission after that (#6098), is never deleted, and, for an instruction-origin ticket, dispatches nothing. Record the ids of the approved test tickets in your change record so nobody mistakes them for a real decision later. -
To see that approvals are currently being refused, and why, query the audit store (read-only; the approver gate’s refusals carry
approver_assurance:<reason>indetail; a refusal by the central step-up check is anmfa.step_up.requiredrow instead):SELECT detail, result, count(*) FROM audit_store.audit_events WHERE action IN ('approval.approve','approval.reject') AND detail LIKE 'approver_assurance:%' AND result IN ('denied','error') AND timestamp >= extract(epoch FROM now() - interval '1 hour') GROUP BY detail, result ORDER BY 3 DESC;A run of
approver_assurance:mfa_not_enrolledmeans approvers still have to enrol; the runbook section Enrolling a non-admin approver has the same query and the enrolment paths. Anapprover_assurance:mfa_proof_clock_skewrow withresult=errormeans OIDC approvers are being refused503because their proof is dated after the database clock; the fix is clock synchronisation (see OIDC approvers refused with a clock-skew 503).
Rolling back, or running mixed versions. The approver-assurance change itself adds no schema; the separate scheduled-approval change (#6096, below) adds a data-only approvals step and a command-outbox index, both of which an older binary ignores, so a rollback starts cleanly. What changes is which checks run.
Warning: mixed versions silently weaken the approver control. The enrolled-factor and fresh-proof requirement (#6079) is in force only while every replica that serves
/api/approvals/*runs a build that has it. Behind one load balancer, an old replica keeps the weaker gate: it passes an approver who has no enrolled factor, and a build that predates #6061 also accepts bearer and MCP approvals. The same person can therefore get403mfa_not_enrolledfrom one replica and succeed on the next request, and nothing in the response says which replica answered. Rolling those replicas first shortens the exposure window; the gap closes only when approval traffic reaches upgraded replicas only (pin or drain approvals), and stop-all, upgrade, start-all is the default recommendation. Running more than one server replica is unsupported today (the safe-to-scale gate,docs/ha-delivery-matrix.md), so this describes a rolling upgrade and is not a statement that a multi-replica deployment is supported. Rolling back to an older build is a security-policy downgrade, not only a convenience: it re-opens the weaker gate, and tickets decided before the upgrade gain no retroactive assurance.If you are about to roll, write down each replica’s upgrade time (in UTC) in the change ticket as you go; see “Mixed-version admission” below for why it matters and for the queries that use it.
-
A replica or a rolled-back binary that predates this change still accepts bearer and MCP approvals, and does not check an approved ticket’s age when it is consumed or when
ScheduleRunnerfires it. While any such replica serves traffic, the assurance is only as strong as that replica; drain it, or send approval traffic to upgraded replicas only. -
Endpoint agents need no change.
-
An older binary does not write the
result=attemptedrow, and does not read it. Rows written by the new binary stay in the audit store and are ignored by the old one; adeniedrow withdetail=approver_assurance:<reason>simply stops appearing. -
Telling old and new rows apart. The
attemptedandsuccessrows of an approve or reject written by a build with #6079 carrydetail=approver_assurance:enrolled_fresh_mfa. A row without it was written before the control, or by a replica that does not have it. Use that token as the cutover marker when sampling decisions, and to spot an old replica still serving approvals during a roll. The audit row does not name the replica; your load balancer’s access log is the place to find which one answered. -
Mixed versions and clearing a factor. Until every replica and the CLI image run this build, an old binary’s
--mfa-resetor Settings disable clears the factor without ending the account’s sessions, so its existing sessions stay live. Afterwards run the new binary’s--mfa-reset(before the user enrols again, because it clears the factor) orDELETE /api/v1/sessions?username=<name>. To find the accounts cleared during the mixed window, list themfa.disabledandmfa.reset.breakglassrows in it. The bounds follow the same rules as the approval query below (UTC, an explicit+00on each, widened by a margin for clock skew). A row written by an old binary has nosessions_ended=in itsdetail; everyokorsuccessoutcome row from this build has one, exceptno verified factor to clear(a cancelled, unfinished enrolment, which cleared nothing); anerrorrow from the CLI has none. This build also writes anattemptedintent row before it clears anything, and that row has nosessions_ended=either; theresult IN ('ok', 'success')filter is what keeps those rows out of the list, so do not drop it.psql -q "$YUZU_POSTGRES_DSN" -c \ "SET TIME ZONE 'UTC'; SELECT to_timestamp(timestamp) AS at, principal, action, result, target_id, detail FROM audit_store.audit_events WHERE action IN ('mfa.disabled', 'mfa.reset.breakglass') AND result IN ('ok', 'success') AND COALESCE(detail, '') NOT LIKE '%sessions_ended=%' AND COALESCE(detail, '') <> 'no verified factor to clear' AND timestamp >= extract(epoch FROM timestamptz '<first replica upgraded, minus margin, UTC with +00>') AND timestamp < extract(epoch FROM timestamptz '<last replica upgraded, plus margin, UTC with +00>') ORDER BY timestamp;"Each row’s
target_idis an account whose sessions were left live. End them as above. An empty list is not evidence that none were cleared: it has the same blind spots as the approval query below (bounds that miss the window, an audit store restored or purged, a lost audit write), so run the same count control over the same bounds. -
Mixed-version admission is a documented known limitation of a rolling upgrade. The approver gate is stateless and no minimum-version check exists, so while a rolling upgrade is in progress an old replica can admit a decision the new gate would refuse. The Yuzu project maintainer accepted this on 2026-10-28 as inherent to the upgrade method, on the condition that the upgrade is controlled; the acceptance is recorded in the ADR-1005 exception ledger (
docs/adr/1005-headless-platform-use-case-engines.md, entry of 2026-10-28) as a working-session statement, not a signed decision. The condition is: record each replica’s upgrade timestamp (in UTC) in the change ticket (SOC 2 CC8.1) as you roll, because the audit row does not name the replica. A decision row withoutapprover_assurance:enrolled_fresh_mfawas written before the control existed, by a replica that did not have it, or after a rollback. To list the decisions in a window that carry no marker, set the start to the first replica’s upgrade time minus a margin and the end to the last replica’s upgrade time plus a margin, or to the present time if a rollback happened or a replica may still be old. Both bounds are UTC. The command’s first statement pins the session to UTC, becausepsqlotherwise reads a baretimestamptzliteral in the session’s own time zone (the server default, the role’s setting,PGTZor a DSN option), which shifts the window by whole hours and returns no rows without any error; write each bound with an explicit+00, for example2026-10-20 09:55+00. If the times in your change ticket are local times, convert them to UTC first.psql -q "$YUZU_POSTGRES_DSN" -c \ "SET TIME ZONE 'UTC'; SELECT to_timestamp(timestamp) AS at, principal, action, target_type, target_id, result, source_ip, detail FROM audit_store.audit_events WHERE ( (action IN ('approval.approve', 'approval.reject') AND result IN ('attempted', 'success') AND detail <> 'approver_assurance:enrolled_fresh_mfa') OR (action IN ('mcp.approve_request', 'mcp.reject_request') AND result = 'success')) AND timestamp >= extract(epoch FROM timestamptz '<first replica upgraded, minus margin, UTC with +00>') AND timestamp < extract(epoch FROM timestamptz '<last replica upgraded, plus margin, UTC with +00>') ORDER BY timestamp;"The
atcolumn is shown in UTC. Match each row against the ticket’s per-replica timestamps and the load balancer log, and review the decision it records. Onlyattemptedandsuccessrows are listed for the REST actions, because a refusal (denied) never carries the marker. Rows before the window are pre-upgrade decisions and gain no retroactive assurance. Rows inside the leading margin, before the first replica was upgraded, are also pre-upgrade decisions: expect them in the list, they are not old-replica admissions.A list with no rows is not evidence of a clean upgrade. The same output appears when the bounds miss the window, when the audit store is empty or was restored from an earlier copy, and when an audit write was lost (the
successrow of a REST decision is best effort). Run this control over the SAME bounds. It counts every audit row in the window, whatever the action:psql -q "$YUZU_POSTGRES_DSN" -c \ "SET TIME ZONE 'UTC'; SELECT count(*), to_timestamp(min(timestamp)) AS earliest, to_timestamp(max(timestamp)) AS latest FROM audit_store.audit_events WHERE timestamp >= extract(epoch FROM timestamptz '<first replica upgraded, minus margin, UTC with +00>') AND timestamp < extract(epoch FROM timestamptz '<last replica upgraded, plus margin, UTC with +00>');"For a window in which the deployment was running,
countmust be above zero andearliestandlatestmust fall inside the bounds. Acountof zero means the bounds or the audit store are wrong (check that both bounds are UTC, that the start is earlier than the end, and that the audit store was not restored or purged), not that the upgrade was clean; do not use an empty list as evidence until the control is above zero. A non-zero count shows the bounds select audit rows, not that every decision left a row. Attach both command texts, with the bounds filled in, and their output to the change ticket.The
mcp.approve_requestandmcp.reject_requestrows are listed for a different reason. A decision made over MCP never carries the marker, because the upgraded binaries refuse MCP approvals and rejections, so anysuccessrow for these actions inside the window came from a replica that had not been upgraded yet, or from a rollback. For those rowstarget_typeismcp_toolandtarget_idis the tool name; the approval ticket id is indetail. The MCP query in “Find affected automation before you upgrade”, above, finds the same actions across the whole retention period; this one limits them to the window.The
timestampcolumn is written from the clock of the replica that wrote the row, while the bounds you recorded in the change ticket come from wherever you read the time, so widen both bounds by a margin that covers the NTP skew between your replicas (the observed skew, or a few minutes if you have not measured it). The margin covers clock skew only. It does not cover a time-zone offset, which is why the bounds must be UTC and the session is pinned to UTC. A replica that wrote no row is invisible to these queries, so also attach the load balancer log excerpt for the window, together with the per-replica upgrade timestamps. Re-run both commands, with the end bound set to the present time, until every replica is confirmed upgraded and no rollback or re-creation from an old image is possible, and attach the final run. -
A ticket approved by an older replica carries a
reviewed_atstamped from that replica’s own clock. The new binaries judge it against database time, so a replica whose clock runs more than 5 minutes ahead of the database produces a ticket the new binaries treat as not yet live (a recall over MCP answersapproval expired (past its 7-day lifetime), although the ticket is in fact ahead of the clock), and a replica running behind shortens the ticket’s life by the same amount. Once the next accepted approval submission on any replica has run, such a ticket isexpiredfor good, with only an info log line as a trace, and correcting the clock does not bring it back, so request approval again. Until that submission has run nothing has been written, so correcting the clock first makes the ticket live again: if the query indocs/clock-guarded-retention.mdfinds held-back tickets, fix the clock before anyone submits an approval request. (A submission refused because the pending queue is full rolls its sweeps back, so the ticket staysapprovedand unusable until a later submission is accepted.) A query that lists approved tickets ahead of the database clock (until that sweep runs) is indocs/clock-guarded-retention.md, “ApprovalManager ticket liveness”. The same applies to a database host whose own clock is stepped backwards by more than 5 minutes: an approved, unused ticket whose review time is more than 5 minutes ahead of the new database time (roughly, one reviewed within the last (step size minus 5 minutes) before the step) is expired permanently once an accepted submission has swept it (until then correcting the clock restores it), and older approved tickets stay live (observability tracked in #6098). -
Submission time (
submitted_at) now also comes from the database, like the review and use times, so a server whose own clock is wrong can no longer lengthen or shorten how long its pending requests live. A recurring schedule no longer decides that an approval is spent by comparing clocks: when the occurrence an approved ticket belongs to has been queued, the ticket is marked used (a rejected ticket is marked used when it skips its occurrence), so one approval runs at most one occurrence whatever the clocks say (#6096). A used ticket keeps its status (approvedorrejected); only its use time and user are filled in. The schedule’s own run times are still stamped by the server that advances it; they only decide when an occurrence is due. -
Scheduled approvals are retired once on upgrade (#6096). Before this release a spent scheduled ticket was never marked used; it was recognised as spent by comparing clocks. The approvals schema step (v2) therefore marks every outstanding approved or rejected ticket that belongs to a schedule as used, once per database, with no clock comparison. A schedule that was holding an occurrence for an approval you had already given (approved but not yet fired when you upgraded) asks again for that held occurrence, and a rejection you had already given no longer skips that occurrence: the schedule asks again. Pending requests, requests from the dashboard and MCP, and tickets already used are untouched. Nothing to do. The step is data only (no table or column changes). It touches every unsettled approved or rejected scheduled ticket, including old rejected ones (rejected tickets never expire and are never swept); that is normally small. It runs inside the upgrade transaction under a 300 s statement timeout and is skipped on every later start. If the 300 s timeout is hit the step fails, nothing is applied, and the approvals store does not open (the server logs a schema migration failure for that store); investigate the table size and restart. The command outbox also gains a small index on its approval id (
outbox_approval_idx) at the same upgrade; its build is bounded by the same 300 s timeout. -
Upgrade one replica first when the outbox table is large (#6096). Building
outbox_approval_idxtakes a share lock on the outbox table for up to 300 s, so enqueue and claim on other replicas wait meanwhile. A second replica that boots at the same time waits on the migration advisory lock under the pool’s default lock and statement timeouts and may fail its boot closed; restarting it recovers, because the migration is then applied. The recommendation is to start ONE replica, wait for it to finish migrating, then start the others. These are not measured numbers. -
Where the legacy marker lives (#6096). The retired tickets carry
consumed_by = 'schedule:legacy-6096'inapproval_manager.approvals. It is readable only by SQL; no API returns it. Tickets the outbox showed had already been used by an earlier occurrence carry the related markerconsumed_by = 'schedule:ledger-spent:<schedule id>:<n>'; tickets settled by a normal fire or skip carryschedule:<schedule id>:<n>. To list the legacy ones:SELECT id, status, schedule_id, consumed_at FROM approval_manager.approvals WHERE consumed_by = 'schedule:legacy-6096'; -
Mixed versions and rollback for scheduled approvals (#6096). Recommended practice: drain or stop every replica that predates this change before anyone approves scheduled work. The command outbox records which approval ticket each scheduled run used, and the new binary refuses to fire a ticket the outbox shows was already used by an earlier occurrence, so a ticket fired by an older leader is inert after the upgrade. A rolled-back binary does not consult the outbox ledger or the used marker, so an older binary can re-fire a ticket the new binary marked used (the old clock comparison); rolling back restores the old behaviour, including that exposure. After a rollback followed by a second upgrade, run this once, because a rejected ticket spent under the older binary would otherwise skip one occurrence. It also retires approved-but-unfired scheduled tickets, which then ask again:
UPDATE approval_manager.approvals SET consumed_at = EXTRACT(EPOCH FROM now())::bigint, consumed_by = 'schedule:legacy-6096' WHERE schedule_id <> '' AND consumed_at = 0 AND status IN ('approved','rejected'); -
Tickets already approved when you upgrade keep their original
reviewed_at; the seven days are counted from it.
Behaviour change: plugin-enablement tables and the continuity anchor (plugin_config_store schema v3, ADR-3006)
The plugin_config_store schema moves to v3. It adds the tables of the plugin-enablement store (enablement_rules, enablement_endpoints, enablement_meta) and records a new store id. Nothing flips on upgrade: there is no operator surface yet and nothing issues desired state, so no endpoint’s behaviour changes.
- What you will see.
/readyzgains anenablementobject and/healthanenablement_regressedflag; neither gates readiness. On the first boot after the upgrade the server writesenablement-anchorbeside its data and reportsanchor_unwitnessed_at_boot: trueonce. That is expected and needs no action. - Rollback is safe. v3 only adds tables, and an older server starts on it without complaint (there is no schema-ahead guard) and ignores them.
- Rolling upgrade. No behaviour change: a replica on the older release ignores the new tables.
- Retrying a failed upgrade. The v3 migration seeds a new store id, and the server writes
enablement-anchornaming it. If you then restore the pre-upgrade dump and upgrade again, the new store id no longer matches the surviving anchor, and the server logs one warning naming the old and new store id. While nothing has been issued (the anchor’s counter and the store’s counter are both0) that is reported as unwitnessed (anchor_unwitnessed_at_boot: trueonce), the server rewrites the anchor with the current store id, and no regression is declared; the same applies to a fresh database with a kept data directory. If the anchor’s counter or the store’s counter is above0, the server starts with enablement declared regressed (reasonstore_id_mismatch, alertYuzuEnablementRegressed); before retrying, removeenablement-anchorfrom the data directory or restore it from the same backup point as the dump. If a regression was already declared, the exit is in Enablement continuity anchor. - Backups. The tables ride the existing dump. The anchor file is outside it: keep
enablement-anchorwith the data directory.scripts/yuzu-backup.shandscripts/yuzu-restore.shnow copy it. - Alerts. The
yuzu-enablementgroup indocs/prometheus/yuzu-alerts.ymlships with this change. Until the later change that issues desired state, onlyYuzuEnablementRegressedandYuzuEnablementAnchorWriteFailingcan fire.
Behaviour change: the macOS agent package installs transactionally and can refuse
This applies to every macOS .pkg, including the release package.
What changed.
- The installer stages the new agent beside the running one and validates it. The running agent is stopped only after that succeeds. If a running agent was replaced and the new one does not stay running, the previous version is restored and restarted.
- The installer now refuses in these cases, before anything is stopped or
changed, and says which one applies:
/usr/local,/usr/local/libor/usr/local/lib/yuzu, or/usr/local/binfor the loose-binary package, is not root-owned or is group- or world-writable. That is typical of Macs whose Homebrew lives in/usr/local: Intel Macs, and Apple silicon Macs running an Intel or migrated Homebrew.- An agent binary, rollback file or plugin (at any depth) in those directories is a symlink, or another account could have written it, including through an access control list. The installer lists each entry.
/etc/yuzu-agentor/etc/yuzu-agent/certsis not root-owned, or is group- or world-writable./Library/Application Support/Yuzuor/Library/Logs/Yuzu, or anything in them, is not root-owned, is group- or world-writable, or has an access control list that lets another account write to it; or an entry in them is a symlink, a special file or a hard-linked file. The agent runs as root and opens files there by name. A Mac wherescripts/install-agent-user.shgave them to_yuzuneeds them returned to root first. The refusal lists each entry; inspect and fix them one at a time, and neverchown -R, which would make a planted symlink look root-owned (see the packaging README)./var/db/yuzu-agent, or a recovery snapshot in it, has an access control list. The installer never sets one, so it refuses rather than remove it.- A new plugin has the same name as one no Yuzu package installed, or as a Yuzu plugin whose bytes were changed after installation.
- The existing service requires signed plugins (
--plugin-require-signature) and the package contains an unsigned plugin. Every release.pkgdoes.
- A first install of the stock package is installed but not started,
because the agent will not run without a server trust anchor. An agent that
was stopped is left stopped, including one disabled with
launchctl disable system/com.yuzu.agent; a disabled agent that was still running is restarted and stays disabled for the next boot. Previously the installer loaded the service every time. - Upgrades keep the existing service definition’s arguments, every
YUZU_*environment variable and the standard proxy variables. Previously an upgrade replaced/Library/LaunchDaemons/com.yuzu.agent.plist, losing anything added to it. Any other environment variable is dropped and named in/var/log/install.log. If the existing definition cannot be parsed, the package default is installed with a warning and the agent is not started; earlier settings must be re-applied. - An unrecognized development app under the data directory is moved to
/var/db/yuzu-agent/quarantine/and reported; it no longer blocks the install oruninstall.sh.
What to check.
- If a refusal names a
/usr/localdirectory, make it root-owned and remove the group and world write bits. Then inspect the files it lists: changing the directory’s mode does not change who wrote what is already in it. Do notchown -Rthem: that makes another account’s files look root-installed. - If the data directory is refused, never delete it: it holds the agent’s
identity and enrolment. Run
chmod go-won it, or restore its owner. - For signed-plugin enforcement, remove
--plugin-require-signaturebefore upgrading to an unsigned package, or build a signed one. - Fleet deployment: pre-stage the server address and trust anchor so the first install starts the agent. See Deploying with MDM.
- Rolling a fleet back to an older package and forward again is supported. A package from before this installer replaces the service definition with its default, so re-apply the server and trust settings after such a rollback.
- After a failed install there is nothing to clean up: the next package run
repairs or discards any recovery state it finds under
/var/db/yuzu-agent.
The opt-in signed-app development lane (Endpoint Security) additionally moves
the executable into /Library/Application Support/YuzuAgent/YuzuAgent.app and
always runs with --no-auto-update, which preserves the bundle signature.
Update that lane by installing a new package (for example through MDM), not
over the air. The development .pkg is intentionally unsigned; it is not a
customer-distribution lane. Before switching lanes, read the
packaging and recovery runbook and the
macOS development foundation.
Deprecated: --max-agents / YUZU_MAX_AGENTS has no effect and will be removed (#6165)
What changed. --max-agents (env YUZU_MAX_AGENTS) was never enforced: the server has never capped how many agents connect. It is now marked deprecated. Setting it explicitly logs one startup warning; the default logs nothing. GET /api/v1/settings/server-config still returns max_agents for compatibility.
What to check. Remove --max-agents and YUZU_MAX_AGENTS from your service units, compose files and environment. When the flag is removed in a later release, an explicit --max-agents will stop the server from starting (unknown option), while a leftover YUZU_MAX_AGENTS will be ignored. A malformed leftover value (for example YUZU_MAX_AGENTS=unlimited) still stops the server from starting today, because the value is still parsed as a number. 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.
Version Compatibility
| Server Version | Min Agent Version | Min Gateway Version | Notes |
|---|---|---|---|
| 0.1.x | 0.1.0 | 0.1.0 | Initial release family |
| 0.5.x | 0.5.0 | 0.5.0 | Compiler hardening flags (-fstack-protector-strong, _FORTIFY_SOURCE=2, full RELRO), config file permission enforcement (0600 on Unix), SRI integrity attributes on CDN scripts, configurable trigger limit (default 2000), git-derived version strings, chargen instruction definitions. |
| 0.6.x – 0.9.x | same as 0.5.x | same as 0.5.x | No on-disk format changes from 0.5.x; upgrade directly to 0.10.x. |
| 0.10.x | 0.10.0 | 0.10.0 | Server-side schema migration runner wired into every SQLite store. Upgrading from 0.9.x or earlier is data-preserving: the first 0.10.x startup stamps each database at schema v1 and runs a one-time legacy compatibility shim for stores that historically added columns via silent ALTER TABLE (api_token_store, instruction_store, patch_manager, policy_store, product_pack_store, response_store). Failed migrations close the affected store’s DB handle and are reported via /readyz with the failed store name — check /readyz, not /livez, to confirm upgrade success. |
| 0.15.x (next) | 0.12.0 | 0.12.0 | The audit log is admin-only while RBAC is off, and audit reads are bounded (breaking; #5771). Non-admin sessions and tokens get 403 on every AuditLog:Read surface until RBAC is enabled with a role holding it; legacy GET /api/audit clamps limit to 1000 and refuses offset above 10000; MCP query_audit_log rejects wrong-typed filters. Server restart only; see the section below. |
| 0.15.x (next) | 0.12.0 | 0.12.0 | Gateway listeners and the upstream channel move out of {grpcbox, [...]} (breaking for every gateway config; one edit covers both). The listeners move from {grpcbox, [{servers, ...}]} to {yuzu_gw, [{grpc_listeners, ...}]}, and the channel to the server moves from {grpcbox, [{client, ...}]} to {yuzu_gw, [{upstream_client, ...}]} with the value unchanged. The agent (:50051) and management (:50063) listeners are served by the gateway’s own gRPC layer over Cowboy and the gateway-to-server hop is its own gRPC client over gun; the gateway no longer contains grpcbox. A config that still has a {grpcbox, [{servers, ...}]} or a {grpcbox, [{client, ...}]} block refuses to start, and an old gateway image given the new config starts, reports healthy and binds nothing, so the image and its sys.config are an atomic pair. The gateway now also decides the client address it reports to the server (gateway_observed_peer) and discards what the agent sent: upgrade every gateway before you rely on audit source_ip. No agent or server change. See “Gateway listeners move to grpc_listeners” and “The upstream channel moves to upstream_client” below, and Client addresses behind a proxy. |
| 0.15.x (next) | 0.12.0 | 0.12.0 | Fleet visualization three-tier layout + talking sockets + curved tube wires (PR 12). /viz/fleet no longer renders machines on a single flat grid. Cubes now stack into three architectural tiers: frontend on the top Y plane, applications in the middle, databases on the bottom. Classification is heuristic — classifyTier reads listener-port hints (DB/web port sets) and process category, priority db > web > app. Behavioural break for automation consumers: if you scripted SIEM rules or dashboards that filter by “where a cube falls in the canvas”, expect tier reassignments after upgrade. The wire change is additive — schema_minor bumps 3 → 4 with a new optional local_addr field on ListenerSocket carrying the kernel-reported bind address (server-side bounded at 64 bytes per field). Strict-validating consumers pinned to schema_minor == 3 should relax their validator to minimum: 3. Loopback-only listeners (127.x, ::1, [::1], ::ffff:127.x) no longer appear on cube surfaces — they’re not reachable from other instances. New talking-socket primitive: each cube grows a ring of cool-blue dots on its BOTTOM face, one per unique outbound (proto, dst_ip, dst_port); hover surfaces talking: tcp → ip:port. Wire geometry changed: cross-machine connections render as THREE.TubeGeometry along a CubicBezierCurve3 with vertical end-tangents instead of 1px THREE.Line — wires drop straight down out of the source cube floor, run mostly-straight through space, and dock straight up into the destination’s listener sphere. Screen-scrapers that parsed wire colour or geometry need updating. Origin RGB AxesHelper removed from the empty-scene scaffold — the three tier planes replace it as the orientation cue. Default camera reframed to (45, 60, 45) looking at the middle tier (was (35, 30, 35) looking at origin); bookmarked URLs will land on the new framing. Bundle size ~70 → ~84 KB. Known limitation: databases on non-standard ports (Postgres on 5431, etc.) misclassify as app tier unless their process is identified as database by the agent’s process classifier. Rolling-upgrade behaviour: during a staged agent rollout, agents on a build older than the echo.fleet_snapshot action have no topology to push and appear in /viz/fleet as dimmed stale cubes until their agent is upgraded — this is expected, not a regression (previously such agents vanished from the visualization entirely once any agent pushed). Kill-switch change: --viz-disable now also 503s the /viz/fleet and /viz/host/<id> page shells, not just the REST endpoints — an operator who sets the flag no longer sees a half-working page; it also writes a server.viz_disabled audit event at boot. Governance Gate 7 hardening (no operator action required): parser field caps on all agent-controlled strings, an IP-claim reclaim window so a crashed agent no longer strands its IPs forever, CAP-1 eviction keyed on the server clock, per-entry isolation in gateway BatchHeartbeat ingest, and a fix for a registration-replay storm under upstream flapping. Scope-walking YAML fromResultSet: DSL (PR-E). Policies whose spec.scope: used a selector: mapping block previously stored an empty scope (matched all devices — the selector was silently ignored). Existing rows are not migrated, but re-creating or re-importing such a policy after upgrade applies the selector as a real predicate and may narrow targeting — review the intended scope before re-import. Inline flow-mapping scope (scope: {fromResultSet: x}) is now rejected; use the block form. Result-set aliases referenced from fromResultSet: must be drawn from the [A-Za-z0-9_.:*-] charset (no spaces/quotes). Inventory freshness gauge now server-clock-stamped (#1685). yuzu_inventory_stale_agents keys on the server’s receipt time, not the agent-supplied collected_at. A one-time migration (v3) at first 0.15.x startup clamps any inventory_state row whose last_seen/first_seen was stamped from a future-skewed agent clock back down to now. Operator-visible: if any agents had future-skewed clocks, the gauge may show a one-time increase post-upgrade as previously-hidden endpoints re-enter the staleness window with a fresh ~48h grace — genuinely active agents fall back out within two daily sync cycles; this is the intended security correction, not an incident. No operator action required (the YuzuInventoryStaleAgents alert ships disabled). Rollback note: downgrading the server below 0.15.x after v3 has run is data-safe (schema unchanged) but new inventory syncs revert to stamping last_seen from agent time, silently re-opening the clock-skew gap for those rows. DEX application performance over time. New per-app, per-version CPU/working-set trend views (DEX → Performance → “Application performance over time”; REST /api/v1/dex/perf/{apps,app,group} + the list_dex_perf_apps/get_dex_app_perf/get_dex_group_app_perf MCP tools; per-device drill GET /api/v1/dex/devices/{id}/app-perf). No action is required to upgrade. Per-application sampling (procperf_enabled) is now on by default on every OS (see “Behaviour change: every Echo capture source is on by default” at the top of this guide), so the views fill on their own while the daily-sync master switch --inventory-disable is unset; a device where sampling was switched off with procperf_enabled=false stays empty until it is re-enabled via echo.configure. Data appears after the first completed UTC midnight on each sampling device (the agent ships a daily summary, not immediately), and the trends lengthen as days accumulate (fleet ≤180 days, group ≤31 days). A freshly-enrolled device, or one with sampling switched off, shows an honest empty state (“no application performance history yet”), not a bug. The per-device drill is also reachable from a dashboard panel on the /device DEX lens (“Application performance over time” — same retained data, no live query, no Execute permission; no upgrade action needed). The per-device drill is behavioural PII — scoped + audited (dex.device.app_perf.view, fail-closed); the fleet and group aggregates suppress any app/version on fewer than 10 devices to a count only (no singling-out). Response/execution reads fail closed on a corrupt RBAC store (#1634, partial). The response readers (query_responses + aggregate_responses MCP tools, GET /api/v1/executions/{id}/visualization, GET /api/responses/{id} / /aggregate / /export) route through a per-agent management-group filter. The only operator-visible change in this release is fail-closed behavior under a corrupt/load-failed rbac.db: these surfaces now return zero rows (the legacy /api/responses/{id}/aggregate returns 503) instead of exposing the whole fleet via the legacy read fallback. Not yet changed: under normal RBAC operation these reads are not management-group-scoped — a holder of global Response:Read still sees all agents’ responses (the filter is inert under the current global gate; the gate change that makes scoping effective is tracked under #1634). RBAC explicitly disabled is unchanged. No operator action required. MCP agentic write surface + A2 discovery + A4 error-shape (R2, #289 / #1794). Five MCP write tools (set_tag/delete_tag/approve_request/reject_request/quarantine_device) ship with a ticket-then-recall approval flow, plus the /api/v1/discover/* discovery family. The approvals store gains additive consumed_at + consumed_by columns (auto-migrated at first startup; no operator action). Breaking wire-shape change: many /api/v1 error bodies that were previously {"error":"<string>"} are now the nested A4 object {"error":{"code","message","correlation_id",…}} — a REST client that read error as a string must migrate to error.code / error.message (see rest-api.md §Error envelope). The MCP write surface is gated behind the existing tier model + a maker-checker approval workflow; audit mcp.<tool> covers every write. NVD CVE-store schema migration (v1→v2) + full CPE version-range matching. The server-side NVD store is reshaped on first startup (flat cve → normalized cve + cve_match); /api/nvd/match now evaluates real CPE version ranges. Operator-visible on upgrade: (a) the migration drops and rebuilds the local CVE mirror — vulnerability-matching coverage is reduced until the next NVD sync completes (rate-limited; up to a few hours without an API key), self-healing and logged with a warning at migration time; (b) GET /api/nvd/status total_cves now counts distinct CVEs (was one row per affected product) so it reads lower after upgrade even once fully synced, and near-zero during the rebuild window — expected, not data loss. Any SIEM/dashboard alerting on the total_cves magnitude should be re-baselined. No config change or action required. NVD CVE sync now actually runs (was dormant). A rate_limit() integer overflow meant the server-side NVD sync slept ~292 years before its first request, so it never populated on any prior deployment (/api/nvd/status total_cves stayed at the built-in seed). It now runs on startup. Operator-visible: (a) the server makes new outbound HTTPS requests to services.nvd.nist.gov — restricted-egress / air-gapped deployments that silently never reached it before may now log connection failures (set --no-nvd-sync to disable, or --nvd-proxy); (b) total_cves grows from the seed as the (keyword-scoped) sync populates. No config change required to benefit. OIDC JWT signature verification now enforced on the Windows server (#1856/#1782, CRITICAL). A Windows server build previously skipped OIDC id_token signature verification entirely — accepting forged RS256/384/512 tokens (session minting / account takeover). It now verifies on every platform via the same OpenSSL path. Operator-visible only if you run a Windows server with OIDC SSO: verification now genuinely depends on the IdP’s JWKS being reachable, so a misconfigured or unreachable jwks_uri that the old Windows build silently tolerated (by accepting everything) will now cause all OIDC logins to fail closed. Before upgrading a Windows + OIDC deployment, confirm the server can reach the IdP jwks_uri and that system clock skew is within tolerance; if OIDC logins start failing fleet-wide post-upgrade, check JWKS reachability first. Local-password and API-token auth are unaffected; Linux/macOS servers already verified and are unchanged. MCP execute_instruction’s zero-agents-reached response schema gained three new fields and a widened status enum (#3424/#3511). The published outputSchema’s oneOf grew from 2 branches to 5: the success branch is unchanged, but the single {status:"no_agents_reached", ...} zero-reach branch is now four mutually-exclusive branches — no_agents_reached (same trigger condition, but now REQUIRES retry_after_ms where it was previously absent/unspecified), plus new quarantined, containment_unreadable, and plugin_not_found — each REQUIRING three new fields that were not previously present: retry_after_ms (a per-branch const — 5000 for the two retryable branches containment_unreadable and no_agents_reached, null for the two permanent branches quarantined and plugin_not_found), agents_quarantined, and agents_unknown_plugin (integer counts, present on every zero-reach branch regardless of which status matched). Strict-validating consumers pinned to the old 2-branch oneOf or the old no_agents_reached-only zero-reach shape will reject a response using one of the three new statuses, or reject the new required fields on the shape they already recognized. Relax any pinned oneOf/const validation to accept the widened schema (GET /mcp/v1/ tools/list always reflects the current, authoritative shape) before upgrading an agentic client with strict response validation. A client that only branches on status == "no_agents_reached" string-equality (rather than treating an unrecognized status as “some non-retryable or retryable failure, check message”) should add explicit handling for the three new values — see docs/user-manual/mcp.md’s execute_instruction row for the full semantics and retry guidance. Alert-rule change (action may be required if you maintain a fork). YuzuDispatchTargetRejected’s exclusion widens from reason!="quarantined" alone to reason!~"quarantined|unknown_plugin" — same reasoning as the #881 exclusion above: a misconfigured automation retrying against an absent plugin is a caller mistake, not a fleet outage, and without the exclusion it clears the alert’s >3/15m threshold on its own. If you maintain a fork of docs/prometheus/yuzu-alerts.yml, apply the same widened exclusion (!~, not =~ — the fork’s rule excludes both reasons, it does not match only them). |
| 0.15.x (next) | 0.12.0 | 0.12.0 | Fleet visualization three-tier layout + talking sockets + curved tube wires (PR 12). /viz/fleet no longer renders machines on a single flat grid. Cubes now stack into three architectural tiers: frontend on the top Y plane, applications in the middle, databases on the bottom. Classification is heuristic — classifyTier reads listener-port hints (DB/web port sets) and process category, priority db > web > app. Behavioural break for automation consumers: if you scripted SIEM rules or dashboards that filter by “where a cube falls in the canvas”, expect tier reassignments after upgrade. The wire change is additive — schema_minor bumps 3 → 4 with a new optional local_addr field on ListenerSocket carrying the kernel-reported bind address (server-side bounded at 64 bytes per field). Strict-validating consumers pinned to schema_minor == 3 should relax their validator to minimum: 3. Loopback-only listeners (127.x, ::1, [::1], ::ffff:127.x) no longer appear on cube surfaces — they’re not reachable from other instances. New talking-socket primitive: each cube grows a ring of cool-blue dots on its BOTTOM face, one per unique outbound (proto, dst_ip, dst_port); hover surfaces talking: tcp → ip:port. Wire geometry changed: cross-machine connections render as THREE.TubeGeometry along a CubicBezierCurve3 with vertical end-tangents instead of 1px THREE.Line — wires drop straight down out of the source cube floor, run mostly-straight through space, and dock straight up into the destination’s listener sphere. Screen-scrapers that parsed wire colour or geometry need updating. Origin RGB AxesHelper removed from the empty-scene scaffold — the three tier planes replace it as the orientation cue. Default camera reframed to (45, 60, 45) looking at the middle tier (was (35, 30, 35) looking at origin); bookmarked URLs will land on the new framing. Bundle size ~70 → ~84 KB. Known limitation: databases on non-standard ports (Postgres on 5431, etc.) misclassify as app tier unless their process is identified as database by the agent’s process classifier. Rolling-upgrade behaviour: during a staged agent rollout, agents on a build older than the echo.fleet_snapshot action have no topology to push and appear in /viz/fleet as dimmed stale cubes until their agent is upgraded — this is expected, not a regression (previously such agents vanished from the visualization entirely once any agent pushed). Kill-switch change: --viz-disable now also 503s the /viz/fleet and /viz/host/<id> page shells, not just the REST endpoints — an operator who sets the flag no longer sees a half-working page; it also writes a server.viz_disabled audit event at boot. Governance Gate 7 hardening (no operator action required): parser field caps on all agent-controlled strings, an IP-claim reclaim window so a crashed agent no longer strands its IPs forever, CAP-1 eviction keyed on the server clock, per-entry isolation in gateway BatchHeartbeat ingest, and a fix for a registration-replay storm under upstream flapping. Scope-walking YAML fromResultSet: DSL (PR-E). Policies whose spec.scope: used a selector: mapping block previously stored an empty scope (matched all devices — the selector was silently ignored). Existing rows are not migrated, but re-creating or re-importing such a policy after upgrade applies the selector as a real predicate and may narrow targeting — review the intended scope before re-import. Inline flow-mapping scope (scope: {fromResultSet: x}) is now rejected; use the block form. Result-set aliases referenced from fromResultSet: must be drawn from the [A-Za-z0-9_.:*-] charset (no spaces/quotes). Inventory freshness gauge now server-clock-stamped (#1685). yuzu_inventory_stale_agents keys on the server’s receipt time, not the agent-supplied collected_at. A one-time migration (v3) at first 0.15.x startup clamps any inventory_state row whose last_seen/first_seen was stamped from a future-skewed agent clock back down to now. Operator-visible: if any agents had future-skewed clocks, the gauge may show a one-time increase post-upgrade as previously-hidden endpoints re-enter the staleness window with a fresh ~48h grace — genuinely active agents fall back out within two daily sync cycles; this is the intended security correction, not an incident. No operator action required (the YuzuInventoryStaleAgents alert ships disabled). Rollback note: downgrading the server below 0.15.x after v3 has run is data-safe (schema unchanged) but new inventory syncs revert to stamping last_seen from agent time, silently re-opening the clock-skew gap for those rows. DEX application performance over time. New per-app, per-version CPU/working-set trend views (DEX → Performance → “Application performance over time”; REST /api/v1/dex/perf/{apps,app,group} + the list_dex_perf_apps/get_dex_app_perf/get_dex_group_app_perf MCP tools; per-device drill GET /api/v1/dex/devices/{id}/app-perf). No action is required to upgrade. Per-application sampling (procperf_enabled) is now on by default on every OS (see “Behaviour change: every Echo capture source is on by default” at the top of this guide), so the views fill on their own while the daily-sync master switch --inventory-disable is unset; a device where sampling was switched off with procperf_enabled=false stays empty until it is re-enabled via echo.configure. Data appears after the first completed UTC midnight on each sampling device (the agent ships a daily summary, not immediately), and the trends lengthen as days accumulate (fleet ≤180 days, group ≤31 days). A freshly-enrolled device, or one with sampling switched off, shows an honest empty state (“no application performance history yet”), not a bug. The per-device drill is also reachable from a dashboard panel on the /device DEX lens (“Application performance over time” — same retained data, no live query, no Execute permission; no upgrade action needed). The per-device drill is behavioural PII — scoped + audited (dex.device.app_perf.view, fail-closed); the fleet and group aggregates suppress any app/version on fewer than 10 devices to a count only (no singling-out). Response/execution reads fail closed on a corrupt RBAC store (#1634, partial). The response readers (query_responses + aggregate_responses MCP tools, GET /api/v1/executions/{id}/visualization, GET /api/responses/{id} / /aggregate / /export) route through a per-agent management-group filter. The only operator-visible change in this release is fail-closed behavior under a corrupt/load-failed rbac.db: these surfaces now return zero rows (the legacy /api/responses/{id}/aggregate returns 503) instead of exposing the whole fleet via the legacy read fallback. Not yet changed: under normal RBAC operation these reads are not management-group-scoped — a holder of global Response:Read still sees all agents’ responses (the filter is inert under the current global gate; the gate change that makes scoping effective is tracked under #1634). RBAC explicitly disabled is unchanged. No operator action required. MCP agentic write surface + A2 discovery + A4 error-shape (R2, #289 / #1794). Five MCP write tools (set_tag/delete_tag/approve_request/reject_request/quarantine_device) ship with a ticket-then-recall approval flow, plus the /api/v1/discover/* discovery family. The approvals store gains additive consumed_at + consumed_by columns (auto-migrated at first startup; no operator action). Breaking wire-shape change: many /api/v1 error bodies that were previously {"error":"<string>"} are now the nested A4 object {"error":{"code","message","correlation_id",…}} — a REST client that read error as a string must migrate to error.code / error.message (see rest-api.md §Error envelope). The MCP write surface is gated behind the existing tier model + a maker-checker approval workflow; audit mcp.<tool> covers every write. NVD CVE-store schema migration (v1→v2) + full CPE version-range matching. The server-side NVD store is reshaped on first startup (flat cve → normalized cve + cve_match); /api/nvd/match now evaluates real CPE version ranges. Operator-visible on upgrade: (a) the migration drops and rebuilds the local CVE mirror — vulnerability-matching coverage is reduced until the next NVD sync completes (rate-limited; up to a few hours without an API key), self-healing and logged with a warning at migration time; (b) GET /api/nvd/status total_cves now counts distinct CVEs (was one row per affected product) so it reads lower after upgrade even once fully synced, and near-zero during the rebuild window — expected, not data loss. Any SIEM/dashboard alerting on the total_cves magnitude should be re-baselined. No config change or action required. NVD CVE sync now actually runs (was dormant). A rate_limit() integer overflow meant the server-side NVD sync slept ~292 years before its first request, so it never populated on any prior deployment (/api/nvd/status total_cves stayed at the built-in seed). It now runs on startup. Operator-visible: (a) the server makes new outbound HTTPS requests to services.nvd.nist.gov — restricted-egress / air-gapped deployments that silently never reached it before may now log connection failures (set --no-nvd-sync to disable, or --nvd-proxy); (b) total_cves grows from the seed as the sync populates. No config change required to benefit. NVD sync now builds the FULL CVE catalog (newest-first), not ~20 keywords. The sync backfills every CVE published within a configurable window — --nvd-backfill-years / YUZU_NVD_BACKFILL_YEARS (default 8 years; 0 = full history) — newest-first and resumable across restarts, then settles into a periodic last-modified freshness re-check. Operator-visible: the server makes sustained HTTPS requests to services.nvd.nist.gov, the local NVD DB grows into the hundreds of MB, and /api/nvd/status total_cves climbs continuously while backfill_complete stays false until the backfill floor is first reached — last_sync_time advances after every successful fetch window and is not a completion signal (use the new backfill_complete + backfill_oldest_published fields, and the yuzu_nvd_total_cves / yuzu_nvd_backfill_complete metrics, for progress). /api/nvd/status enabled semantics corrected: it now reflects whether sync is configured on, so under --no-nvd-sync it reports enabled:false (was true); a monitor keying on enabled to mean “mirror usable” should check total_cves instead. The initial backfill is NVD-rate-limited (hours without an --nvd-api-key, minutes with one) and resumes where it left off if interrupted. Set --no-nvd-sync to disable, --nvd-proxy for restricted egress. Product matching stays name-based (vendor-precise CPE identity pending ADR-0018). Certificate inventory now reads System/SystemRoot keychains and Linux /etc/ssl/certs natively (no more openssl/security CLI shell-out for those stores). The certificates plugin’s list/details/delete actions are unchanged in output shape (same pipe-delimited columns, same field values) — this is a collection-mechanism change only, verified byte-parity against the prior CLI-based output. The macOS login keychain read has ALSO moved off its governed-shell path in this same release (#3406): it now runs as a pre-split argv through the bounded runner, with the console user’s home directory resolved in-process instead of by the shell. Output shape is unchanged. One operator-visible correction: when the console user cannot be determined — most often a slow or unreachable directory service on a domain-joined Mac — certificates.list/details with store=login or all now return an explicit `not_available |
| 0.15.x (next) | 0.12.0 | 0.12.0 | Fleet visualization three-tier layout + talking sockets + curved tube wires (PR 12). /viz/fleet no longer renders machines on a single flat grid. Cubes now stack into three architectural tiers: frontend on the top Y plane, applications in the middle, databases on the bottom. Classification is heuristic — classifyTier reads listener-port hints (DB/web port sets) and process category, priority db > web > app. Behavioural break for automation consumers: if you scripted SIEM rules or dashboards that filter by “where a cube falls in the canvas”, expect tier reassignments after upgrade. The wire change is additive — schema_minor bumps 3 → 4 with a new optional local_addr field on ListenerSocket carrying the kernel-reported bind address (server-side bounded at 64 bytes per field). Strict-validating consumers pinned to schema_minor == 3 should relax their validator to minimum: 3. Loopback-only listeners (127.x, ::1, [::1], ::ffff:127.x) no longer appear on cube surfaces — they’re not reachable from other instances. New talking-socket primitive: each cube grows a ring of cool-blue dots on its BOTTOM face, one per unique outbound (proto, dst_ip, dst_port); hover surfaces talking: tcp → ip:port. Wire geometry changed: cross-machine connections render as THREE.TubeGeometry along a CubicBezierCurve3 with vertical end-tangents instead of 1px THREE.Line — wires drop straight down out of the source cube floor, run mostly-straight through space, and dock straight up into the destination’s listener sphere. Screen-scrapers that parsed wire colour or geometry need updating. Origin RGB AxesHelper removed from the empty-scene scaffold — the three tier planes replace it as the orientation cue. Default camera reframed to (45, 60, 45) looking at the middle tier (was (35, 30, 35) looking at origin); bookmarked URLs will land on the new framing. Bundle size ~70 → ~84 KB. Known limitation: databases on non-standard ports (Postgres on 5431, etc.) misclassify as app tier unless their process is identified as database by the agent’s process classifier. Rolling-upgrade behaviour: during a staged agent rollout, agents on a build older than the echo.fleet_snapshot action have no topology to push and appear in /viz/fleet as dimmed stale cubes until their agent is upgraded — this is expected, not a regression (previously such agents vanished from the visualization entirely once any agent pushed). Kill-switch change: --viz-disable now also 503s the /viz/fleet and /viz/host/<id> page shells, not just the REST endpoints — an operator who sets the flag no longer sees a half-working page; it also writes a server.viz_disabled audit event at boot. Governance Gate 7 hardening (no operator action required): parser field caps on all agent-controlled strings, an IP-claim reclaim window so a crashed agent no longer strands its IPs forever, CAP-1 eviction keyed on the server clock, per-entry isolation in gateway BatchHeartbeat ingest, and a fix for a registration-replay storm under upstream flapping. Scope-walking YAML fromResultSet: DSL (PR-E). Policies whose spec.scope: used a selector: mapping block previously stored an empty scope (matched all devices — the selector was silently ignored). Existing rows are not migrated, but re-creating or re-importing such a policy after upgrade applies the selector as a real predicate and may narrow targeting — review the intended scope before re-import. Inline flow-mapping scope (scope: {fromResultSet: x}) is now rejected; use the block form. Result-set aliases referenced from fromResultSet: must be drawn from the [A-Za-z0-9_.:*-] charset (no spaces/quotes). Inventory freshness gauge now server-clock-stamped (#1685). yuzu_inventory_stale_agents keys on the server’s receipt time, not the agent-supplied collected_at. A one-time migration (v3) at first 0.15.x startup clamps any inventory_state row whose last_seen/first_seen was stamped from a future-skewed agent clock back down to now. Operator-visible: if any agents had future-skewed clocks, the gauge may show a one-time increase post-upgrade as previously-hidden endpoints re-enter the staleness window with a fresh ~48h grace — genuinely active agents fall back out within two daily sync cycles; this is the intended security correction, not an incident. No operator action required (the YuzuInventoryStaleAgents alert ships disabled). Rollback note: downgrading the server below 0.15.x after v3 has run is data-safe (schema unchanged) but new inventory syncs revert to stamping last_seen from agent time, silently re-opening the clock-skew gap for those rows. DEX application performance over time. New per-app, per-version CPU/working-set trend views (DEX → Performance → “Application performance over time”; REST /api/v1/dex/perf/{apps,app,group} + the list_dex_perf_apps/get_dex_app_perf/get_dex_group_app_perf MCP tools; per-device drill GET /api/v1/dex/devices/{id}/app-perf). No action is required to upgrade. Per-application sampling (procperf_enabled) is now on by default on every OS (see “Behaviour change: every Echo capture source is on by default” at the top of this guide), so the views fill on their own while the daily-sync master switch --inventory-disable is unset; a device where sampling was switched off with procperf_enabled=false stays empty until it is re-enabled via echo.configure. Data appears after the first completed UTC midnight on each sampling device (the agent ships a daily summary, not immediately), and the trends lengthen as days accumulate (fleet ≤180 days, group ≤31 days). A freshly-enrolled device, or one with sampling switched off, shows an honest empty state (“no application performance history yet”), not a bug. The per-device drill is also reachable from a dashboard panel on the /device DEX lens (“Application performance over time” — same retained data, no live query, no Execute permission; no upgrade action needed). The per-device drill is behavioural PII — scoped + audited (dex.device.app_perf.view, fail-closed); the fleet and group aggregates suppress any app/version on fewer than 10 devices to a count only (no singling-out). Response/execution reads fail closed on a corrupt RBAC store (#1634, partial). The response readers (query_responses + aggregate_responses MCP tools, GET /api/v1/executions/{id}/visualization, GET /api/responses/{id} / /aggregate / /export) route through a per-agent management-group filter. The only operator-visible change in this release is fail-closed behavior under a corrupt/load-failed rbac.db: these surfaces now return zero rows (the legacy /api/responses/{id}/aggregate returns 503) instead of exposing the whole fleet via the legacy read fallback. Not yet changed: under normal RBAC operation these reads are not management-group-scoped — a holder of global Response:Read still sees all agents’ responses (the filter is inert under the current global gate; the gate change that makes scoping effective is tracked under #1634). RBAC explicitly disabled is unchanged. No operator action required. MCP agentic write surface + A2 discovery + A4 error-shape (R2, #289 / #1794). Five MCP write tools (set_tag/delete_tag/approve_request/reject_request/quarantine_device) ship with a ticket-then-recall approval flow, plus the /api/v1/discover/* discovery family. The approvals store gains additive consumed_at + consumed_by columns (auto-migrated at first startup; no operator action). Breaking wire-shape change: many /api/v1 error bodies that were previously {"error":"<string>"} are now the nested A4 object {"error":{"code","message","correlation_id",…}} — a REST client that read error as a string must migrate to error.code / error.message (see rest-api.md §Error envelope). The MCP write surface is gated behind the existing tier model + a maker-checker approval workflow; audit mcp.<tool> covers every write. NVD CVE-store schema migration (v1→v2) + full CPE version-range matching. The server-side NVD store is reshaped on first startup (flat cve → normalized cve + cve_match); /api/nvd/match now evaluates real CPE version ranges. Operator-visible on upgrade: (a) the migration drops and rebuilds the local CVE mirror — vulnerability-matching coverage is reduced until the next NVD sync completes (rate-limited; up to a few hours without an API key), self-healing and logged with a warning at migration time; (b) GET /api/nvd/status total_cves now counts distinct CVEs (was one row per affected product) so it reads lower after upgrade even once fully synced, and near-zero during the rebuild window — expected, not data loss. Any SIEM/dashboard alerting on the total_cves magnitude should be re-baselined. No config change or action required. NVD CVE sync now actually runs (was dormant). A rate_limit() integer overflow meant the server-side NVD sync slept ~292 years before its first request, so it never populated on any prior deployment (/api/nvd/status total_cves stayed at the built-in seed). It now runs on startup. Operator-visible: (a) the server makes new outbound HTTPS requests to services.nvd.nist.gov — restricted-egress / air-gapped deployments that silently never reached it before may now log connection failures (set --no-nvd-sync to disable, or --nvd-proxy); (b) total_cves grows from the seed as the sync populates. No config change required to benefit. NVD sync now builds the FULL CVE catalog (newest-first), not ~20 keywords. The sync backfills every CVE published within a configurable window — --nvd-backfill-years / YUZU_NVD_BACKFILL_YEARS (default 8 years; 0 = full history) — newest-first and resumable across restarts, then settles into a periodic last-modified freshness re-check. Operator-visible: the server makes sustained HTTPS requests to services.nvd.nist.gov, the local NVD DB grows into the hundreds of MB, and /api/nvd/status total_cves climbs continuously while backfill_complete stays false until the backfill floor is first reached — last_sync_time advances after every successful fetch window and is not a completion signal (use the new backfill_complete + backfill_oldest_published fields, and the yuzu_nvd_total_cves / yuzu_nvd_backfill_complete metrics, for progress). /api/nvd/status enabled semantics corrected: it now reflects whether sync is configured on, so under --no-nvd-sync it reports enabled:false (was true); a monitor keying on enabled to mean “mirror usable” should check total_cves instead. The initial backfill is NVD-rate-limited (hours without an --nvd-api-key, minutes with one) and resumes where it left off if interrupted. Set --no-nvd-sync to disable, --nvd-proxy for restricted egress. Product matching stays name-based (vendor-precise CPE identity pending ADR-0018). Certificate inventory now reads System/SystemRoot keychains and Linux /etc/ssl/certs natively (no more openssl/security CLI shell-out for those stores). The certificates plugin’s list/details/delete actions are unchanged in output shape (same pipe-delimited columns, same field values) — this is a collection-mechanism change only, verified byte-parity against the prior CLI-based output. The macOS login keychain is unchanged (still reads via the existing governed-shell path, a deliberate exception — see docs/agent-spawn-sink-manifest.md). No operator action required; mixed old/new-agent fleets and rollback are safe (no server-side or schema change). |
| 0.15.x (next) | 0.12.0 | 0.12.0 | PatchManager now runs on PostgreSQL (ADR-0062). /api/patches/* (patch inventory + deployment tracking) moved off its own patches.db SQLite file onto the shared Postgres substrate (schema patch_manager). No data carries over from a pre-Postgres install (fresh-start-by-default, ADR-0009) — any deployment record that existed before upgrade is gone and must be re-created via POST /api/patches/deploy. Server startup now fails closed if the patch_manager schema can’t be created/opened — a posture upgrade from the SQLite era, where construction was unconditional/best-effort and no caller ever checked whether the store had actually opened; confirm success via /readyz (patch_manager is now reported by both /readyz and /healthz, absent from both before this release). Removed: automatic patch-deployment orchestration. PatchManager::execute_deployment() (the scan → install → verify → reboot workflow) had zero production callers on any released build — nothing ever wired a dispatch/OS-lookup callback to it — and is deleted, not ported; POST /api/patches/deploy still creates a deployment + per-target rows, but nothing in the server drives them through that workflow automatically (see #3669, filed alongside this change, and docs/capability-map.md §8.3/§8.4/§8.6). Patch inventory (GET /api/patches) is separately unwired: record_patches(), the only method that writes it, also has no production caller — this predates the migration and is not something upgrading changes — so GET /api/patches returns empty in every real deployment today; see docs/capability-map.md §8.5/§8.7 and #3676. No operator action required beyond re-creating any in-flight deployment after upgrade. |
| 0.15.x (next) | 0.12.0 | 0.12.0 | WorkflowEngine now runs on PostgreSQL (ADR-0064). /api/workflows* and /api/workflow-executions/* moved off workflows.db SQLite onto the shared Postgres substrate (schema workflow_engine). No data carries over from a pre-Postgres install (fresh-start-by-default, ADR-0009) — any workflow definition and its execution history that existed before upgrade is gone; re-create workflows via POST /api/workflows (or product-pack re-install). Server startup now fails closed if the workflow_engine schema can’t be created/opened — a posture upgrade from the SQLite era, where construction was unconditional/best-effort and no caller ever checked whether the store had actually opened; confirm success via /readyz (already reported before this release) and /healthz (newly reported — was absent before this release). Delete semantics changed: DELETE /api/workflows/:id now soft-deletes. The response shape is unchanged (`{“deleted”: true |
| 0.15.x (next) | 0.12.0 | 0.12.0 | Guardian file-hash max_bytes now has a hard ceiling (#2233). An authored file-hash-equals rule’s max_bytes (the hashing-DoS cap) was previously accepted unbounded from the authoring API. It is now clamped to 1 GiB (kMaxFileHashBytes) on the agent, and the server rejects a new/edited rule authoring a value above that ceiling in either JSON wire form (400). Operator-visible only if you have a PRE-EXISTING file-hash-equals rule authored (before this release) with max_bytes above 1 GiB, watching a file at or above that size: after upgrade, that file reports <oversize> instead of being hashed — a compliance-verdict change with no authoring-time signal (the rule already exists, so the new server-side reject cannot retroactively catch it). List your rules via GET /api/v1/guaranteed-state/rules (the route returns every rule; there is no server-side filter), check any file-hash-equals rule for max_bytes over 1073741824, and re-author within the ceiling if the larger cap was intentional. No operator action required otherwise. DEX and management-group reads now fail closed on a degraded read instead of answering a healthy/empty result (#4855, #1762) — see “Behaviour change: DEX device score and management-group member reads now fail closed on a degraded read (#4855, #1762)” below. |
| 0.15.x (next) | 0.12.0 | 0.12.0 | BatchHeartbeatResponse gains unknown_session_ids and unknown_session_ids_truncated (#1197), and the gateway now acts on them - restart the gateway to deploy (this disconnects every agent it holds, and released v0.13.0 and v0.14.0-rc6 agents stayed wedged after a graceful gateway restart in testing, so upgrade agents to a build with the #5183 fix first or expect to restart the agent service on them; see “Agent dependency” in What happens when the server restarts); no wire change and no new required configuration. The server lists, per BatchHeartbeat, the session ids it does not hold, and the new gateway reads that list and re-registers exactly the sessions it still holds (the registration-replay drip, bounded by a per-session guard and a queue cap). The behavioural change is that, after a server restart, agents behind a new gateway become known to the server again without operator action; before, they stayed unknown (/health agents.online stayed 0 in the observed runs). Any pairing interoperates: an OLD server never sends the fields, so a new gateway sees an empty list and does nothing; an OLD gateway ignores the added response fields (proto3 unknown fields, field numbers 2 and 3) and keeps the old behaviour. Recovery needs the new gateway and a server that sends the fields. Observed on a local rig: the agent was known again about 17 s after the server was healthy, on its first heartbeat after that; after an outage long enough to open the gateway circuit breaker, recovery waits for the breaker’s remaining backoff (58 s observed after a 300 s outage with 1 agent; with 10 and 30 agents the breaker opened in later runs and the breaker’s own replay recovered them, with all agents online 85.2 s and 50.4 s after the server was healthy; the backoff is capped at 300 s, and steps above 160 s were not observed). A second operator-visible change is a new yuzu_server_gateway_route_desync_total{op="batch_heartbeat",outcome="malformed_session_id"} series (pre-seeded at 0, expected to stay 0, no alert): over-length (more than 64 bytes) unknown session ids in a BatchHeartbeat are now counted under that series, per entry, instead of under op="renew_leases", outcome="unknown_session". The gateway adds three counters and two optional application-env tunables, described under What happens when the server restarts, which also states what the reconcile does not promise (fleet completion inside a route lease, dispatch reachability after the session is adopted, several core replicas). |
| 0.15.x (next) | 0.12.0 | 0.12.0 | Gateway session ids are now resolved against the durable Postgres route directory (HA WS-5 slice 2, ADR-2002 §7f); server restart only, no gateway restart, no flag, no proto field. (This release also carries a separate gateway_route_store v5 migration, a one-time exclusive lock at the first start: see the v5 section below.) Heartbeat, inventory and stream-status RPCs look a presented session_id up in gateway_route_store.agent_routes instead of one replica’s in-memory map. After a server-only restart the server still lists directory-known sessions in unknown_session_ids so the gateway replays ProxyRegister and rebuilds the local registry (drip-paced; ~1200 s for 10k agents at 100 ms), while leases keep renewing; this needs a gateway with the #1197 reconcile (an older gateway gives the pre-reconcile behaviour). Rolling deploy: an old replica reports directory-known sessions as unknown, so drain old replicas first. Run exactly ONE server replica until the safe-to-scale gate lifts (ADR-2002 §7c); this change does not lift it. Rollback is safe (the previous binary boots; see the v5 section’s rollback bullet for what it does on a race). A degraded directory read is fail-soft for heartbeats; a NotifyStreamStatus CONNECTED during it is accepted only for a session this replica holds locally and is otherwise refused (connected_unverified; the agent regains dispatch at its next reconnect). A session id matching more than one live row is refused and pages YuzuGatewaySessionAmbiguous; a ProxyRegister replay presenting an id another agent’s row holds is refused and pages YuzuGatewaySessionHeldByOtherAgent. See gateway.md. |
| 0.15.x (next) | 0.12.0 | 0.12.0 | Gateway heartbeat admission is now bound to the connection that opened the session (gateway restart required; breaking for L7-fronted gateways). The gateway admits an agent Heartbeat only on the HTTP/2 connection that opened the session’s Subscribe stream; any other heartbeat is answered NOT_FOUND and not forwarded. Deploy with a gateway restart, and check for an HTTP/2-terminating hop in front of :50051 first. A rejected agent recovers by re-registering. That logic exists from v0.13.0, but the released v0.13.0 and v0.14.0-rc6 agents wedge in their reconnect path with default settings (bug #2182, fixed by PR #5183, shipped in 0.15.0) and recover only with --no-auto-update; v0.12.0 never recovers by itself (observed; older versions were not tested; v0.14.1 does not contain the fix and was not tested). Upgrade the agents first, then the gateway, with 0.15.0 or later (the #2182 fix); until the agents are upgraded, restart an agent that stays rejected. Agents that do not connect through the gateway are not affected by this requirement (see Older agents). No wire or server change. See the section “Breaking: gateways fronted by an HTTP/2-terminating proxy or mesh sidecar” below and Heartbeat admission. |
| 0.15.x (next) | 0.12.0 | 0.12.0 | --gateway-upstream now refuses to start without gateway peer authorization (breaking for own-certificate and plaintext installs). The gateway-upstream service admits only a gateway whose certificate key (SPKI SHA-256) is pinned, which carries serverAuth and is inside its validity window; the server exits at boot, before binding a port, when --gateway-upstream is set and that cannot be arranged. Generated default certificates are pinned automatically; your own certificates need --gateway-peer-pin / --gateway-peer-pin-file first; a plaintext (--no-tls) rig needs --insecure-gateway-peer; the Linux package unit passes --gateway-upstream unconditionally. Agents and gateways are unaffected on the wire. See the section “Breaking: --gateway-upstream now refuses to start without gateway peer authorization” below. |
| 0.15.x (next) | 0.12.0 | 0.12.0 | The gateway now holds stream-status notifications instead of dropping them (#4632; gateway restart required). Deploy it with a gateway restart, which disconnects every agent the node holds, so read “Upgrade day” and “Agent dependency” in What happens when the server restarts first (released v0.13.0 and v0.14.0-rc6 agents do not recover by themselves after a gateway restart). Read the Gateway upgrade note for #4632 before you restart: a gateway whose configured cluster_id cannot be represented as UTF-8 now refuses to start. There is no hot code load for this change: the upstream client’s code_change is the identity function while its state gained fields, so deploy it by restart, not by a hot upgrade (INFERRED from the code, not run). No wire change and no change to the minimum agent or server version: NotifyStreamStatus is unchanged, so any pairing interoperates. What changes: a CONNECTED or DISCONNECTED notification that arrives while 10 are in flight or the circuit breaker is open is held in a bounded, memory-only store and sent first in first out, where the old gateway dropped it; when the breaker goes half open the new gateway sends one held notification as the probe, where the old one let up to 10 notifications through; and an agent-supplied peer address is sanitised and capped before it is held (a value that is not valid UTF-8 becomes invalid, and one over 255 bytes is cut at a character boundary). New settings notify_pending_max (default derived from the agent listeners: three per connection they admit, the sum of their limits.max_connections, at least 15000, so 15000 on a listener without a ceiling and 180000 with the 60000 ceiling the packaged, production and reference configs set; sys.config.prod and the reference compose config also set 180000 explicitly; valid range 1 to 1000000; a value outside the range or not an integer falls back to the default with a WARN that names the key; see “Upstream client tuning” in gateway.md); size it at least as large as the largest Subscribe burst you expect; and notify_probe_timeout_ms (default 15000, valid range 100 to 300000, not present in any shipped sys.config, same fallback rule), the gateway’s own deadline for the half-open notification probe: a probe worker that has not reported by then is killed and the probe fails like any failed probe. The other behaviour changes of the same restart are listed in the upgrade note below the table. Metrics: new gauges yuzu_gw_upstream_notify_pending and yuzu_gw_upstream_notify_inflight and counter yuzu_gw_upstream_notify_coalesced_total. The reason label of yuzu_gw_upstream_notify_dropped_total is now the closed set at_capacity and unknown: the value circuit_open is retired, so during a rolling upgrade old nodes still export circuit_open and new nodes export at_capacity, and a rule or dashboard that sums the family sees both. A rule or panel of your own that selects reason="circuit_open" stops matching once every node is upgraded: use yuzu_gw_upstream_notify_pending for held notifications and reason="at_capacity" for lost ones. The rollback is the previous gateway release. A rollback is itself a gateway restart: it disconnects every agent the node holds and carries the same agent-reconnect cost as the upgrade (see “Upgrade day” and “Agent dependency” in What happens when the server restarts, and the released-agent caveat above), so weigh it against the problem you are fixing. It also brings back the earlier behaviours: notifications dropped at 10 in flight or with the breaker open, a request that the gateway’s own validation or encoder rejected (or a server status that means the request itself was wrong) counted as an upstream failure that can open the breaker, and no cluster_id boot refusal. The pending store is memory only, so nothing persists and nothing needs to be cleaned up; the notifications held at that moment are lost, as they were dropped before. Runbook rows and the measured evidence: Stream Status Notification and the evidence record. |
| 0.15.x (next) | 0.12.0 | 0.12.0 | Gateway: a planned stop needs up to 40 s, deleting a pin file no longer withdraws the pin, and the management listener, circuit breaker, connection ceilings and certificate-expiry alerts change (#5695; deployed by the same gateway restart as the listener move above, so read “Upgrade day” and “Agent dependency” in What happens when the server restarts first: agents without the #5183 fix do not recover by themselves after a gateway restart). (1) Stop timeout. On SIGTERM the gateway now ends its agent sessions and sends their DISCONNECTED notices to the server before its processes stop, within shutdown_flush_budget_ms (default 20000 ms, valid 0 to 600000). Every shipped compose file sets stop_grace_period: 40s on the gateway service and deploy/systemd/yuzu-gateway.service sets TimeoutStopSec=40. A supervisor of your own must allow a stop timeout above shutdown_flush_budget_ms, with room for the processes to stop after it (40 s for the default budget): Docker’s default is 10 s (a docker run, or a compose file you maintain without stop_grace_period), a Kubernetes pod’s default terminationGracePeriodSeconds is 30 s. Raise the timeout and the budget together. A stop cut short by SIGKILL sends no notices, and the server keeps those sessions listed as online until the agents register again. See Planned stop. (2) Pin files. A key read from a {cert_file, Path} entry of mgmt_peer_pins now stays pinned when the file later cannot be read or parsed (reported by yuzu_gw_cert_file_readable{role="mgmt_peer_pin"} reading 0 and by YuzuGatewayMgmtPinUnresolved), so deleting, emptying or corrupting the file no longer withdraws the pin: it does not revoke the pinned key, which the gateway keeps admitting. To change or revoke the pinned key, replace the file with the new certificate (picked up within mgmt_pin_refresh_ms, 5 s by default). Removing the entry from mgmt_peer_pins also revokes it, but applies only at the next gateway restart, which disconnects every agent on the node, and agents without the #5183 fix stay wedged after it. The kept key is only ever one that was pinned; a file that has never been readable since the gateway started still pins nothing (fail closed). Withdrawing a certificate without a file is the serial deny-list mgmt_peer_denied (#6016, read at boot, so a change applies at the next gateway restart, which disconnects the node’s agents). See Certificate rotation and expiry and Authorizing the server by its identity. (3) Management listener. A listener that routes the ManagementService defaults to 4096 concurrent HTTP/2 streams (an agent listener keeps 1024) and 100000 received frames per 10 s (Cowboy’s own limit, kept on the agent listener, is 10000), because the server sends a whole command fan-out over one connection; it has no per-connection memory ceiling unless limits.max_connection_memory_bytes sets one. A value in the entry’s http2 or limits map still wins, so a management entry you wrote with a lower max_concurrent_streams keeps it. See gRPC listeners. (4) Connection ceilings and held notifications. The packaged release config (gateway/config/sys.config, which the release packages and the systemd unit run) sets limits.max_connections to 60000 on the agent listener and 256 on the management listener, the values sys.config.prod sets; a listener without the key has no connection ceiling. Change the agent value together with the unit’s LimitNOFILE. notify_pending_max defaults to three per agent-listener connection (at least 15000), so 180000 with that ceiling, and sys.config.prod and the reference compose config set 180000 explicitly. (5) Circuit breaker. The longest wait before the breaker probes the server again, circuit_breaker_max_reset_timeout_ms, defaults to 30000 ms (was 300000), and every shipped config now sets 30000. A config copied from an earlier release sets 300000 explicitly and keeps it: change it, because YuzuGatewayUpstreamCircuitOpened assumes the breaker reopens at least every 4 minutes or so. See The upstream client. (6) Alerts (check your Alertmanager routes and silences). YuzuCertificateExpiringSoon (server, warning) now fires from 30 days before expiry instead of 7; the 7-day band is the new critical YuzuCertificateExpiryImminent, and YuzuCertificateExpiryCritical stays at 24 hours. The gateway gains the same three steps for its own certificate files (YuzuGatewayCertificateExpiringSoon, YuzuGatewayCertificateExpiryImminent, YuzuGatewayCertificateExpiryCritical). YuzuGatewayMgmtAuthRejected no longer counts reason="pin_mismatch": that reason has its own YuzuGatewayMgmtPinMismatch, which stays quiet for 4 minutes after the gateway publishes a rotated pin; the old name keeps its silences and routes for the other reasons. The server’s YuzuGatewayForwardUnauthenticated is unchanged and can still fire for up to an hour after a rotation that had a command in flight (#5988 removes that cause). New gateway rules: YuzuGatewayUpstreamCircuitOpened (the breaker stays open for 3 minutes, or keeps flapping), YuzuGatewayUpstreamSlow, YuzuGatewayCertificateFileUnreadable, YuzuGatewayMemorySheddingActive, YuzuGatewayMemorySheddingTransitionFailing, YuzuGatewayMemorySheddingInactive (shedding enabled but not running), YuzuGatewayListenerNearConnectionCeiling, YuzuGatewayFileDescriptorsNearLimit and YuzuGatewayProcessBacklogged. (7) Memory-shedding marks are checked at boot. With memory_shedding enabled, a low mark not below the high mark, a mark at or above the gateway’s memory limit, or a mark that is not positive now refuses to start (Refusing to start: {yuzu_gw, memory_shedding}: ...); before, such a config booted with shedding silently off. Setting only high_water_bytes (or only low_water_bytes) is now valid: the other mark is derived from it. Check a memory_shedding block you wrote against the gateway’s memory limit before the upgrade. See “Node-wide memory load shedding” under gRPC listeners. Rollback: the previous gateway release with its own config (see the listener move above); a rollback is itself a gateway restart, with the same agent-reconnect cost. |
| 0.15.x (next) | 0.12.0 | 0.12.0 | registry plugin 2.0.0: typed, verified writes; registry links refused (breaking; agent plugin). Once the 1.1.0 set_value, delete_value and delete_key definitions are in the store (an existing install keeps the 1.0.0 ones until they are re-imported; see the same page’s “Breaking” section), they also reach an agent whose registry plugin is still 1.x, and nothing compares a plugin or agent version at dispatch: such an agent writes an EMPTY REG_SZ for a data_hex-only set_value (with type=REG_DWORD and data_hex only it throws and writes nothing), stores the new value types as REG_SZ text, answers `status |
| 0.15.x (next) | 0.12.0 | 0.12.0 | The plain-HTTP port answers the four health probes when HTTPS is on (#5906). GET/HEAD on /livez, /readyz, /health and /api/health on the redirect port now return 200 (or 503 while the HTTPS listener is not serving, the server is draining, or Postgres is down) instead of 301, in cleartext; every other request still redirects. Server restart only; see the section below. |
| 0.15.x (next) | 0.12.0 | 0.12.0 | The built-in server certificate is re-issued once with a server identity URI (#6016). Same root, same key, so existing mgmt_peer_pins stay valid and the gateway needs no action for the re-issue. The gateway in this release can also admit the server by that identity (mgmt_peer_auth_mode, mgmt_peer_identity, mgmt_peer_denied; new reasons on yuzu_gw_mgmt_auth_rejected_total, new counter yuzu_gw_mgmt_auth_admitted_total, new alert YuzuServerIdentityUriAbsent): the identity is an explicit opt-in: the shipped reference configs and an unset mode are all pin, so nothing changes until you set identity_or_pin or identity. The old certificate shows as revoked with reason superseded in the CA inventory and the next CRL (bookkeeping, not a compromise). New audit action server.default_certs_server_identity_upgraded, new gauge yuzu_server_identity_uri_present. See Authorizing the server by its identity and “Behaviour change: the built-in server certificate is re-issued once with a server identity URI”. |
| 0.15.x (next) | 0.12.0 | 0.12.0 | Breaking, fresh deployment only: stored responses of Forensics-tier plugins are served only to callers holding Forensics:Read (#4990). Applies to every stored-response read path (REST, MCP, dashboard) and to the server-side consumers of those rows (bundle collation, result-set membership, policy checks, the live output stream, execution error text), with the limits listed under “Known limits” in that section; other readers see the rows as absent, with counts and caps computed over what they may read, so an empty result no longer implies no data. Viewer, Operator, PlatformEngineer and ITServiceOwner, and with RBAC off every non-administrator session, no longer see these rows. New built-in ForensicsAnalyst role (read-only, assign it fleet-wide with RBAC on) and new audit action response.forensics.read. A read whose authority cannot be determined is refused with a retryable error rather than a partial result. This release is a hard cutover: it supports initial deployment into a fresh database only and provides no upgrade path from an earlier server’s database (see “Deployment contract” in the section; the agent and gateway columns above are the minimum agent and gateway versions, not a supported upgrade starting point for the database). See “Behaviour change: stored responses of Forensics-tier plugins need Forensics:Read (#4990, fresh deployment only)“. |
| 0.15.x (next) | 0.12.0 | 0.12.0 | MFA step-up is enforced by one central check on 37 named routes, before the route’s own checks (breaking order of checks; server restart only, no agent or gateway change, no migration). A stale or missing MFA proof is now answered with the 401 step-up envelope before the route’s own 403 (permission, token_session, not_local; a cross-site cookie write is answered 403 by the server-wide same-site check before the step-up check); engine principals receive 403 on every step-up-gated route regardless of grants (audited as mfa.step_up.required, no longer as the per-route handler denial row); a server with no wired authentication store answers 503 on these routes, and an unreadable store answers 503 to local sessions; POST /api/settings/mfa/recovery-codes and POST /api/settings/mfa/disable are newly gated; SAML sessions still cannot satisfy step-up (403, unless the window is disabled with --mfa-step-up-window-secs 0). The server now exits with status 1 at boot when a mutating route has no step-up classification, which can only happen on a fork or custom build (a restart policy turns it into a restart loop; roll back to recover). Other mutating routes and all MCP tools are not MFA-step-up gated in this release. New metric yuzu_auth_mfa_step_up_gate_total and alerts YuzuStepUpGateUnavailable and YuzuStepUpWindowDisabled. See Breaking: MFA step-up is checked once. |
| 0.15.x (next) | 0.12.0 | 0.12.0 | Seven runtime-config keys are administrator-only on PUT /api/config/{key} (breaking for non-administrator and MCP-tier callers; server restart only, no agent or gateway change, no migration). plugin_signing_required, dex_alert_routing, dex_blast_min_devices, dex_blast_window_seconds, dex_blast_cooldown_seconds, dex_cohort_export_key and audit_retention_days now answer 403 to a supervised-tier MCP token and, with RBAC on, to a non-administrator role that holds Infrastructure:Write; every other config key is unchanged. Server restart only; see the first section on this page. |
| 0.15.x (next) | 0.12.0 | 0.12.0 | The runtime-configuration read shows a caller only the overrides it could write (breaking for MCP-tier and non-administrator readers; server restart only, no agent or gateway change, no migration). GET /api/config, GET /api/v1/config and the MCP tool get_runtime_config leave out the oidc_*, plugin_signing_required, DEX alert and audit_retention_days overrides unless the caller could write them; an administrator account’s GET /api/config body is unchanged apart from log_level reading warn. heartbeat_timeout is limited to 30 to 86400 seconds on every write route. Mixed-version fleets are not a concern (server-side only). |
| 0.15.x (next) | 0.12.0 | 0.12.0 | The activity-record feature is renamed Echo (breaking; hard cut, no aliases). The agent plugin is echo (was tar), and the REST routes, MCP tools, metrics, audit actions, instruction ids, heartbeat tags and the result-set matcher kind carry the new name. Upgrade the server before the agents: the server accepts an agent that still reports tar and writes the old name on the wire to it (an upgrade-window shim, also on the gateway directory route), but an upgraded agent talking to a server that has not been upgraded has no fallback and every Echo dispatch to it is refused with plugin_not_found. Deliver the agent by package installer: an OTA update replaces the binary only, and the agent defers adopting tar.db as echo.db until echo.<ext> is installed. A carried-forward server database (not a supported upgrade path under the deployment contract) has its stored references rewritten in eleven stores on the first start. See “Behaviour change: the activity-record feature is renamed Echo (tar.db adopted as echo.db; stored references migrated)“. |
| 0.14.x | 0.12.0 | 0.12.0 | Fleet visualization intra-cube edges (PR 8). /viz/fleet now draws faint white lines (opacity 0.3) inside each machine cube connecting process dots that are reciprocal ends of a loopback TCP socket (127.0.0.1 / ::1). Two operator-visible changes: (a) wire shape — /api/v1/viz/fleet/topology schema_minor bumps 1 → 2 and a new optional dst_pid field appears on scope: local connection edges. Renderers that ignore unknown keys per the contract see no break; strict-validating consumers pinned to schema_minor == 1 should relax their validator to minimum: 1. (b) dropped unmatched halves — unpaired Local-scope edges (kernel snapshot race during teardown, agent’s 4096-connection cap cutting a partner) are now dropped server-side before serialisation. Integrations counting connections array length per machine as a proxy for active IPC pairs should re-baseline after upgrade; the count trends marginally lower. Lines appear only when the host has active loopback flows (e.g. Prometheus scraping node_exporter, a client talking to local Redis / Postgres); a fresh agent with no inter-process loopback shows process dots but no lines — expected, not a regression. Windows agent (#5196): set update-signing options in the YuzuAgent service’s Environment registry value, not its binary path. Every installer run rewrites the binary path and silently drops them, and uninstalling deletes the Environment value, so a deployment that uninstalls first must set it again (Windows: the service’s Environment value in server-admin.md). Agent installers from 0.14.0-rc1 to rc5 also stop with exit code 7 wherever PowerShell runs in Constrained Language Mode; use this release’s. Before upgrading Windows agents: this installer stops with exit code 7, naming the reason in its /LOG= file, if C:\ProgramData\Yuzu\agent-certs exists but is not secured (for example a folder created or pre-staged by hand, or one a Group Policy adds permissions to), if a file in it is not owned by Administrators or SYSTEM, or if it or anything in it is a junction, symbolic link, hard link or subdirectory (a backup subfolder, say). The agent service is left running when it stops. Provision the bundle after installing, by copying it in as an administrator. The trust-anchor procedure block in server-admin.md stops on the same conditions, naming the reason (and on a healthy or rc1–rc5 endpoint completes, repairing rc files, as long as each file in it is owned by Administrators or SYSTEM; icacls "<file>" /setowner *S-1-5-32-544 /L fixes one you placed yourself): run it on a few endpoints first, and pilot the upgrade before a fleet-wide push. Linux native packages need a recent distribution: the server needs Ubuntu 26.04 or Fedora 42 class, the agent Ubuntu 24.04 class or newer; Ubuntu 22.04, Debian 12 and RHEL/Rocky 9 are not supported by the native packages. Check before upgrading older hosts, or use the container images (Supported Platforms in the user manual README, #5143). |
| 0.13.x | 0.12.0 | 0.12.0 | Fleet visualization process layer. /viz/fleet now renders interior process dots inside each machine cube, coloured by category (system/browser/database/web/runtime/other) — no operator action required, but operators upgrading from a 0.12.x build will see the dashboard suddenly populated with thousands of small spheres on next page load. Process data was already collected via echo.fleet_snapshot since 0.12.x; PR 7 only renders it. To suppress process visibility for specific agents (privacy-sensitive hosts, regulated workloads), set process_enabled=false on those agents via echo.configure — this also suppresses their dots on the visualization. Hover a dot to see pid/name/user/category; agent-controlled string fields are HTML-escaped and length-clamped before render. Per-cube dot count is soft-capped at 1000 for graceful degradation on heavily-threaded hosts; the cube tooltip still shows the true reported count. |
| 0.12.x | 0.12.0 | 0.12.0 | Build-time content auto-import. All YAML files in content/definitions/ (217 InstructionDefinitions) and content/packs/ (10 InstructionSets at this version) are now embedded in the server binary and auto-imported on every startup. Existing operator-customised definitions with matching IDs are NEVER overwritten — conflicts are silently skipped. Behaviour change for upgrades: definitions that an operator previously DELETED via the REST API or dashboard will reappear after upgrade because the auto-import treats a missing row as “needs creation”. To permanently suppress a shipped definition, set enabled: false via the dashboard or PATCH /api/v1/definitions/{id} rather than DELETE-ing the row. Each auto-import write emits an audit_events.action="content.bundled_import" row with principal=system so operators can audit which definitions were inserted at boot. Yuzu dark navy palette + Inter webfont (visual change every operator sees) and Apache ECharts chart renderer (replaces bespoke SVG; same payload contract — no operator migration required) ship in the same release. |
Rule of thumb: agents and gateway should be the same minor version as the server, or one minor version behind. The server is always upgraded first. Upgrading the server first restarts it while gateways stay connected. A gateway at this version re-registers the sessions the server reports unknown, so a server-only restart recovers without operator action once both sides are upgraded; see What happens when the server restarts. Behind a gateway that does not yet read that report (which includes the server restart that ships this fix, because the server is upgraded first; restart the gateway after upgrading it, and read the next sentence first), agents can read offline after a server restart (observed on one local rig after a SIGKILL restart; graceful upgrade restarts were not tested), and dispatch worked only for the remaining route lease (up to about 90 s). A gateway restart disconnects every agent that gateway holds, and the agent then has to register again by itself: in a graceful gateway restart test, released v0.13.0 and v0.14.0-rc6 agents with default settings stayed wedged and v0.12.0 never re-registered (bug #2182, fixed by #5183, shipped in 0.15.0), while a build with the fix re-registered in 11 to 12 s. Upgrade the agents to 0.15.0 or later (the first release with #5183) before the gateway restart, or expect to restart the agent service on older agents behind it; see “Agent dependency” in What happens when the server restarts.
Behaviour change: new Crypto page, REST routes and MCP tools are reachable with Inventory:Read
What changed. The server adds the Posture > Crypto page (/crypto), two REST routes
(GET /api/v1/crypto/libraries and /api/v1/crypto/libraries/devices) and two MCP tools
(get_crypto_libraries, list_crypto_library_devices). This release also adds a CycloneDX 1.7 export of the same data
(GET /api/v1/crypto/cbom and the MCP tool export_crypto_cbom), also reachable with Inventory:Read alone. All of them are gated by
Inventory:Read alone, so any custom role that already holds Inventory:Read now reaches them.
- Same data as
/software. The page reads the installed-software inventory the/softwareand/inventorypages already show, matched by package name; it adds no collection and no agent change. It names the devices carrying each library family, so the exposure is the oneInventory:Readalready granted, in a new shape. - RBAC-off default. While RBAC enforcement is off (the default) every authenticated session
can read it, as with the other inventory reads. A service-scoped API token is refused with a
403until RBAC enforcement is on. - Confinement applies. A management-group-confined caller sees counts over its visible devices only.
table_version3. Every response carriestable_version, the revision of the curated package-name table (The library table). Windows installer builds of OpenSSL are not matched by this table. A device-drill cursor is bound to the table version, and records the names source (names_source/names_as_of): a walk that straddles an upgrade, or one whose previous page was read on live names when the catalogue is now in use (a new or upgraded server whose catalogue becomes fresh mid-walk), gets a400and restarts without a cursor. The restarted walk is on the catalogue; the refusal can recur only if the catalogue goes stale and recovers during a walk.- Admission cap, per server process. Each server process runs at most two
/cryptostore reads at once (the summary and the drill share the bound; N replicas allow 2N). A read over the bound is refused at once with a503(crypto read capacity in use; retry) carryingretry_after_ms, counted asyuzu_inventory_read_degrade_total{reason="admission_full"}. Clients that fan outlist_crypto_library_devicescalls should serialise them. A read that fails in the inventory store is a different503(crypto library read degraded). - Where the package names come from. Responses carry
names_source(catalogueorlive) andnames_as_of(epoch seconds,nullwhen live). While the hourly software-catalogue refresh is fresh (at most two hours old) the names come from it, so a package name first reported after the refresh appears after the next one; otherwise, including on a new or upgraded server whose catalogue has not been built yet, the names are read live and the answer is current. Counts and versions are live either way. The live read takes about 1.7 to 2.2 s (summary) at 15M rows with 30,000 names, and on a fleet above about 140,000 to 180,000 distinct package names with a stale catalogue it can pass its 5 s bound and answer503; see Crypto. - Large fleets. Fleets above about 2.7M installed-package rows (roughly 6,000 endpoints at
about 450 packages each, about 1,800 at 1,500): the hourly catalogue does not build, so
/cryptoreads names live on every call (about 2 s per summary at 15M rows / 30,000 distinct names) and, with many replicas on one database, can time out. See Scale.
Action. None required. To keep a custom role out of the new surface, remove Inventory:Read
from it. Rolling back removes the page, routes and tools; nothing is migrated.
Behaviour change: gateway sessions are unique per agent; a one-time migration (gateway_route_store v5, #5683)
Short answer. A normal upgrade needs no action. The first start takes an exclusive lock on
agent_routes (about 2.4 s per million rows) and the health probes are silent meanwhile. If a
blocking session holds the lock past 30 s the server exits and a supervisor retries about every 35 s
until it is cleared. Details follow.
What changed. In a gateway-fronted deployment the route directory enforces at most one non-NULL
route row per session id by a database unique index; the tombstoned-victim window is not closed; run
one replica. Migration v5 of the gateway route directory adds a unique index on
gateway_route_store.agent_routes(session_id) (tombstoned rows are not indexed) and drops the old
non-unique one. Two agents racing to adopt one unheld session id used to both succeed; now the second
is refused (FAILED_PRECONDITION, counted as
yuzu_server_gateway_route_desync_total{outcome="session_held_by_other_agent"}, alert
YuzuGatewaySessionHeldByOtherAgent).
- One-time lock at the first start. The migration takes an exclusive lock on
agent_routes, removes any duplicates and builds the index. The lock blocks every other statement on that table from any process (a second server replica,pg_dump, an analytics tool), not only writers. On a single replica the gateway listener is not yet up while the migration runs, so nothing queues behind it from the gateway. Build time is roughly linear in the row count. A first local measurement (PostgreSQL 18, 1,000,000 rows, no duplicates, a container withfsync=off) took 6.4 s; a re-measure on a default PostgreSQL 18.4 container gave about 0.6 s at 200,000 rows, 2.4 s at 1,000,000 rows and 22 s at 10,000,000 rows (about 900 MB of WAL at that size). The table is one row per agent plus recent disconnects, so most deployments are far smaller. Check the size withSELECT count(*) FROM gateway_route_store.agent_routes;. - Health probes do not answer until the migration finishes, as for the other store migrations: the
HTTP listener opens after the route store opens. What a probe that gives up early does depends on
the topology. Docker and Compose never restart a container for being unhealthy; but a service that
depends on the server with
condition: service_healthymakesdocker compose upfail once the server’sstart_periodplusintervalxretriesis exceeded (the shippeddeploy/docker/docker-compose.reference-gateway.ymlgives the server about 310 s:start_period10 s plus 30 retries x 10 s, and its gateway service waits on it; the rootdocker-compose.uat.ymlallows 50 s with nostart_period, but no service in that file waits on the server), so size those to the measured build time PLUS the up-to-30 s lock wait described below. systemd’sTimeoutStartSecdoes not apply to theType=simpleyuzu-server.service. A KubernetesstartupProbeorlivenessProbethat gives up first WOULD restart the server mid-build (the build rolls back and the next start begins again); no Kubernetes manifest ships with Yuzu, so allow the same total there. - Seeing whether it is building or waiting. In
psql(opening a SQL session in each shipped topology: gateway.md),SELECT phase, blocks_done, blocks_total FROM pg_stat_progress_create_index;shows a build in progress. A row inpg_stat_activityfor the server’s connection withwait_event_type = 'Lock'means it is still waiting for its lock. - If the 30 s lock wait expires. The migration waits at most 30 s for its lock. If another session
holds a conflicting lock for longer, it rolls back, the server logs
GatewayRouteStore: schema migration failedfollowed by[PG] Refusing to start: gateway-route store migration/open failed, and the server exits. Under a supervisor with a restart policy (a containerrestart: on-failure/unless-stopped, systemdRestart=on-failurewithRestartSec=5) it is started again and retries about every 35 s, until the blocker is cleared (systemd’s start limit does not trip at that cadence; a failure that exits within a second, such as an existing wrong-shapeux_agent_routes_session_id, does trip the shipped unit’sStartLimitBurst=3within about 15 s, after whichsystemctl reset-failed yuzu-serveris needed once the object is dropped). Nothing is half-applied. End the blocking session (pg_locks/pg_stat_activity); the next attempt succeeds. - Existing duplicates are cleared, not kept. If two rows already shared a session id (expected: none),
every row of that group is tombstoned, because no row can be shown to be the legitimate owner. Each
affected agent re-establishes its route through the normal heartbeat replay within one heartbeat cycle
or two (if the gateway circuit breaker had opened: up to about 2 minutes on a gateway that includes
#5695, about 6 minutes on an older one). The count is logged once as
migration v5 tombstoned N agent_routes row(s)and exported asyuzu_server_gateway_route_session_dedup_total. That counter is present whenever--gateway-upstreamis configured (the route store, the migration, the lock and the counter exist only then) and is 0 on a clean database. It reports the migration of THIS start only, so it reads 0 on any later restart: the durable record isSELECT value FROM gateway_route_store.route_meta WHERE key = 'v5_session_dedup_tombstoned_rows';, and a later start also logs it once. A future alert on the counter must use> 0, notincrease(). While those agents re-register, some replays are refused assession_held_by_other_agentand raiseYuzuGatewaySessionHeldByOtherAgent. Those refusals are the one-time migration artifact ONLY IF BOTH hold: (a) the number ofop="proxy_register"session_held_by_other_agentrefusals since that start, summed over every server replica, is at most N, the number of rows migration v5 tombstoned on that start (yuzu_server_gateway_route_session_dedup_totalon the migrating start; themigration v5 tombstoned NWARN, or theroute_metavalue after a restart), because a tombstoned group of k rows can produce at most k-1 refusals (N is a safe upper bound); count the refusal WARN lines rather than the counter if any replica restarted since the migrating start (the counter resets on restart); anyop="announce_connected"refusal is NOT the artifact; and (b) they all occur within that start’s window: one or two agent heartbeat cycles (30 s by default,--heartbeatchanges it), plus the gateway circuit breaker’s backoff if it opened (up to 30 s on a gateway that includes #5695, capcircuit_breaker_max_reset_timeout_msingateway/config/sys.config; up to 300 s on an older gateway), plus the replay (on a #5695 gateway a random start delay of up to 1 s,registration_replay_start_jitter_ms, then up to 24 registrations in flight at once,upstream_replay_concurrency; on an older gateway a drip of about 20 ms per agent): allow roughly 2 minutes at the defaults when every gateway includes #5695 (plus the replay itself: the gateway’s agent count times the server’sProxyRegistertime, divided by 24; about 10 s for 5,000 agents at 50 ms a call), or roughly 6 minutes plus 20 ms per agent if any gateway is older. Refusals past that window, or more than N, are NOT the artifact (escalating errs on the safe side): treat them as a forged or colliding id. Refusals inside the bound and the window are still NOT proof of benignness (a forged presenter acting at the same time could hide inside them): re-check the count after the window has passed and do not silence the alert beyond it (it usesincrease()[1h], so a silence would hide later refusals). The counter keeps its migrating-start value for the whole process lifetime, so it proves nothing about timing: establish timing from the WARN timestamps (or, while the store is still at version 5,SELECT version, to_timestamp(upgraded_at) FROM public.schema_meta WHERE store = 'gateway_route_store';; a later store migration overwrites it), and if the migrating start’s time or log cannot be found, escalate (full rule: gateway.md). - A CONNECTED notification is covered only when the presenting agent has no directory row. An agent
that still has a row, including a tombstone, is not covered: its CONNECTED lands as
session_mismatchand does not unpublish (the tombstoned-victim residual recorded in ADR-2002 7f “Still open”, #6339; unchanged by this change). - Not a rolling-upgrade change. Run one server replica, as before; the safe-to-scale gate is not lifted.
- Updated alert rules. If you import
docs/prometheus/yuzu-alerts.yml, re-import it: theYuzuGatewaySessionHeldByOtherAgentexpression now also matchesop="announce_connected", and this release adds the metricyuzu_server_gateway_route_session_dedup_total. - Rolling back to the previous binary boots and works (it ignores the migration; its queries still
find the unique index), but the unique index now rejects its racing duplicate write as a generic
route-write error (
yuzu_server_gateway_route_write_failed_total{reason="db_error"}, andYuzuGatewayRouteWriteFailedafter about 5 minutes if it persists). The old binary’sProxyRegisterthen fails closed (UNAVAILABLE), whileannounce_connectedstays fail-open: the in-memory placement is not unpublished. The tombstones the migration wrote are not undone.
Action. None for a normal upgrade. On a very large directory, schedule the first start for a quiet period and raise any startup probe timeout. To learn WHICH agents share a session id (the migration erases the association), run the duplicate query from step 1 of gateway.md “Recovering a missing unique session index” before the first start and keep the output with the change record.
Behaviour change: new ca_store indexes, built during the upgrade
What changed. The CA store’s migration v5 adds three indexes on ca_issued (one row per
certificate Yuzu ever issued). They back the Yuzu-issued certificate counts and the certificate
list behind them on the /crypto dashboard (ADR-0070 D13).
- The build blocks CA writes while it runs. It runs at the first start of the new version
inside the migration and blocks writes to
ca_issued(certificate issuance and revocation, so new enrolments too) while it runs. The build time grows with the number of rows: about 1.3 s per 250,000 rows and 9 s per million in local measurement (PostgreSQL 18, a 12-core machine). The build itself has no time limit, so a large table is not cut off part-way. Check the row count beforehand withSELECT count(*) FROM ca_store.ca_issued;. - Health probes do not answer until the migration finishes. The server opens its HTTP
listener only after the CA store has opened, so
/livez,/readyzand/healthzrefuse connections during the build and during any wait for its lock. A liveness or startup check that gives up first (a containerstart_period, a KubernetesstartupProbe, systemdTimeoutStartSec) restarts the server mid-build, the build rolls back, and the next start begins it again. On a CA store above about a million rows, or wherever the build may wait behind a long transaction, allow at least two minutes for the first start of this version. - Seeing whether it is building or waiting. In
psql,SELECT phase, blocks_done, blocks_total FROM pg_stat_progress_create_index;shows a build in progress. A row inpg_stat_activityfor the server’s connection withwait_event_type = 'Lock'means it is still waiting for its lock; while it waits, every other write toca_issued(issuance, revocation) queues behind it too, for up to the 30 s below. - Start one replica first. A second replica that starts while the first is building waits up to the connection pool’s 10 s lock timeout for it, then logs the same two lines as below and does not start. Start it again once the first replica is up: it finds the migration done.
- If the 30 s lock wait expires. The build waits at most 30 s for its lock on
ca_issued. If another session holds a conflicting lock for longer, such as a long transaction, the migration fails and rolls back, nothing is left half-built, and the server logsCaStore: schema migration failed — CA persistence disabledfollowed by[PG] Refusing to start: ca store migration/open failedand does not start (the CA store is required). Find and end the blocking session (pg_locks/pg_stat_activity), then start the server again: the migration is retried from the start.
Action. On a large CA store, schedule the upgrade for a quiet period, and raise any startup or liveness probe timeout first (see “Health probes do not answer” above). Rolling back leaves the v5 indexes in the database; they are harmless to the older binary.
Behaviour change: new crypto_dashboard_layout_store schema, erased on deprovision
What changed. The server keeps each operator’s /crypto dashboard layout (ADR-0071) in a new
Postgres schema, crypto_dashboard_layout_store, keyed by the operator’s username.
- New database schema. The server creates the
crypto_dashboard_layout_storeschema at its first start, so the server’s database role needsCREATEon the database, as for every other Yuzu schema. If the schema cannot be created or opened, the server still starts: saved layouts are unavailable until a restart with the problem fixed, the server log says so at error, and/readyzstays200, listing the store underdegradedwithout leaving rotation (ADR-0071). - Deprovision erasure. Deprovisioning an operator (SCIM or a dashboard user delete) erases
their saved layout after the deprovision commits, best-effort. A failure is counted on
yuzu_server_crypto_layout_erase_failures_total{reason}and alerted byYuzuCryptoLayoutEraseFailed; the remedy is the periodic check in Crypto: Deprovision erasure. - Backups. The new schema is in the Yuzu database, so the whole-database
pg_dumpalready covers it. It is loss-tolerant: restoring without it leaves operators on the default layout. A restore can resurrect erased layouts: a backup taken before an operator was deprovisioned still holds their layout row (keyed by their username). After any restore, run the check in Crypto: Deprovision erasure and delete the rows of principals deprovisioned since the backup.
Action. Confirm the server’s database role can create schemas (it already must, for every
other store). Rolling back leaves the crypto_dashboard_layout_store schema in the database; it
is harmless to the older binary.
Behaviour change: /crypto becomes a per-operator widget dashboard with Yuzu-issued certificate counts
What changed. The Crypto page (/crypto) is now a grid of widgets each operator adds,
removes and reorders (see The dashboard). The library figures are still
there as widgets; new widgets count the certificates Yuzu’s own internal CA issued (agent,
server, gateway, HTTPS and code-signing certificates alike), never the certificates on your
devices.
- The page looks different. Every operator starts on the default grid: the library figures plus Yuzu-issued active, expiring within 30 days, revoked in the last 30 days and issued per week. There is nothing to migrate; layouts start empty and each operator’s first change saves theirs.
- Who sees the Yuzu-issued counts. The counts need
Inventory:Readand an unconfined view of the fleet (ADR-0070 R2): an unconfinedInventory:Readholder now sees how many certificates Yuzu’s CA has issued and revoked, and so the fleet’s size and enrolment churn. A management-group-confined caller, or a service-scoped token that confines its caller, sees “not available to this account” instead. RemoveInventory:Readfrom a custom role to keep it out. - The certificate list behind a count needs
Security:Read, the existing permission ofGET /api/v1/ca/issued, and is withheld from a confined caller. It is served by that route with new optional filter parameters (kind,days,week,bucket,as_of,cursor) and by MCPlist_issued_certswith the same arguments. A filtered list pages by keyset cursor (next_cursor,nullon the last page), never byoffset(refused with a filter), so a certificate revoked between pages never shifts another out of the walk; a pinnedas_ofis checked against the database clock. Without those parameters both list the inventory withoffsetpaging, as before. Both forms are withheld from a confined caller and auditedcrypto.ca.certificates.view; the unfiltered list used to return every row to a confinedSecurity:Readholder and was not audited. - New REST routes and MCP tools.
GET /api/v1/crypto/widgets,GET/PUT /api/v1/crypto/dashboard/layoutandGET /api/v1/crypto/ca/stats; MCPlist_crypto_widgets,get_crypto_dashboard_layout,set_crypto_dashboard_layout(annotated destructive: it overwrites the caller’s own layout) andget_crypto_ca_stats. New audit verbs:crypto.ca.stats.view,crypto.ca.certificates.view,crypto.dashboard.layout.update,crypto.dashboard.layout.access_denied. Drawing the dashboard writes onecrypto.libraries.viewrow and onecrypto.ca.stats.viewrow per draw. A layout save names the version it replaces (expected_version); one without it is a first save only, and over a stored layout it is a409naming the stored version. - If the layout schema did not open (see the section above), every operator sees the default
layout, read-only, under a notice; the layout REST routes answer
503with no retry hint and the MCP layout toolskInternalErrorwith no retry hint. - Capacity. The Yuzu-issued reads share the existing two-read
/cryptobound per process; on a connection pool of 3 or fewer (--postgres-pool-size) they are always refused, and on a pool of one connection the library reads and the CBOM export (GET /api/v1/crypto/cbom, MCPexport_crypto_cbom) are refused too. That refusal lasts until a restart with a larger--postgres-pool-size, so it carries no retry hint (REST503withoutretry_after_ms, an MCP error without one) and names the flag; raise the pool before upgrading if it is that small. The counts are never cached: every call reads the CA table at request time, bounded only by that admission limit (ADR-0070). Degrades are counted onyuzu_inventory_read_degrade_totalwith newsourcevaluesca_issuedandcrypto_layout, and the new alertYuzuCryptoCaReadDegradedfires after 15 minutes of Yuzu-issued read degrades (see metrics.md).
Action. Review which custom roles hold Inventory:Read if enrolment counts should not be
visible to them. Rolling back removes the widgets and routes. The two sections above cover the
ca_store indexes and the layout schema this dashboard relies on.
Behaviour change: the built-in server certificate is re-issued once with a server identity URI (#6016)
On first boot after the upgrade, an install that uses the built-in default certificates has default-server.pem re-issued once, under the same root and the same key, with one added Subject Alternative Name: the server identity yuzu://<ca-fingerprint>/server. The previous certificate then appears revoked with reason superseded in the CA inventory (GET /api/v1/ca/issued, the CA page) and in the next CRL. That is bookkeeping, not a key compromise: the new certificate certifies the same key. Existing mgmt_peer_pins entries stay valid because the key is unchanged, so the gateway needs no action. The one-time event is audited as server.default_certs_server_identity_upgraded and logged as a WARN line. The CRL carries no revocation reason code, so the superseded reason is visible in the CA inventory only; a CRL consumer sees an ordinary revoked serial.
The gateway in this release can admit the server by that identity instead of by its key pin. Nothing changes until you opt in. The shipped reference configs (gateway/config/sys.config.prod, deploy/docker/reference-gateway-sys.config) set {mgmt_peer_auth_mode, pin} explicitly, and an installation with its own sys.config that does not set the key is pin too (an unset mode is always pin, never the identity, so a configuration with no pins and no mode still refuses to boot on an exposed listener). If you set mgmt_peer_identity while the mode is pin, the identity is ignored and the gateway logs a warning saying so. Once you opt in, a server key renewal that keeps the identity needs no change on the gateway when no pin is configured (a pin kept in identity mode must follow the renewal); in identity_or_pin the identity admits the renewed key whatever the pin says, and there is no pin_mismatch window. In identity_or_pin with pins configured, a peer that fails both paths because it has no identity (no_uri_san, identity_mismatch) is reported with the pin’s reason (pin_mismatch, no_pins_resolved); identity_unresolved (the gateway could not resolve the identity) and revoked are always reported as themselves, and a pin that never resolved is reported (yuzu_gw_mgmt_auth_pin_unresolved_total) even while the identity admits the traffic. The gateway’s rejection log line now ends see mgmt_peer_auth_mode, mgmt_peer_pins) (it ended see mgmt_peer_pins), so a SIEM or log rule that matches the old text needs updating. The identity trusts the issuing CA’s issuance policy: do not enable it where the management listener’s cacertfile is a bring-your-own corporate CA that lets a requester choose a URI SAN with serverAuth (stay in pin); an identity over a cacertfile that is not demonstrably exactly one readable certificate (a bundle, or an empty, unreadable or half-written file) refuses to boot unless the mode is identity with a pin (identity_over_ca_bundle). See Authorizing the server by its identity for the modes, how the identity is resolved, the serial deny-list mgmt_peer_denied and the trust trade-off (the identity needs a certificate the install CA signed, not the server’s private key).
To move a gateway to the identity: (1) upgrade the server to this release and confirm yuzu_server_identity_uri_present is 1 on every replica (YuzuServerIdentityUriAbsent warns when it stays 0); (2) set the gateway to identity_or_pin and keep the pin (the reference configs ship pin, so this is a change you make); (3) set identity only when both the gauge reads 1 on every replica and, after real command traffic through the gateway, yuzu_gw_mgmt_auth_admitted_total{method="identity"} moved while {method="pin"} did not; neither is sufficient alone (the gauge is set once at server boot and describes the file default-server.pem, not the certificate the server presents to the gateway, and a replica on the previous binary does not export it; {method="pin"} standing still on an idle gateway proves nothing). After that the pins may be dropped or kept as an extra constraint that must also match. mgmt_peer_auth_mode is read at boot, so a change applies when the gateway next restarts; do that only if its agents can take the disconnect, because agents without the #5183 fix stay wedged after a gateway restart.
Bring-your-own certificates, --no-default-certs and a server given its own --cert/--key carry no identity and stay on pins. yuzu_server_identity_uri_present is 1 once default-server.pem carries the URI; it describes the file, so it is a readiness signal only when the agent and management listeners use the built-in certificates. Operator-supplied certificates, --no-default-certs and a server given its own --cert/--key are not touched by the server’s re-issue, and rolling back the server is safe (an older server build ignores the extra name in its certificate).
Rolling back the gateway is not the same. A gateway binary from before #6016 ignores mgmt_peer_auth_mode, mgmt_peer_identity and mgmt_peer_denied (nothing else reads them). After such a rollback (i) the deny-list is not honoured, so a withdrawn serial whose key is still in a pin is admitted again, with no signal; (ii) if the node has no pins (dropped for identity, or identity_or_pin running on the identity alone), the older binary refuses to boot (no_mgmt_peer_pins) and the command plane for that node is down; and (iii) the rollback is itself a gateway restart, which disconnects the node’s agents: do it only if they can take the disconnect, because agents without the #5183 fix stay wedged after a gateway restart. Before you roll back, restore the pins (and the contents of any pin file), make sure they match the key the server presents now (a literal spki_sha256 pin can be stale after a key renewal made on the identity), check each withdrawn serial against the CA inventory, and re-key the server (or accept the re-admission) for any withdrawn serial whose key is still pinned. See Authorizing the server by its identity, “Rolling back”.
If you had already revoked the built-in server certificate for another reason (for example a suspected key leak) and not replaced it, the upgrade is skipped on purpose, with a warning on every boot and yuzu_server_identity_uri_present at 0: re-certifying a key you withdrew would undo your revocation. Replace the key first by re-keying the set (see --cert-san in Server administration: renaming default-marker.json aside re-mints every default leaf with a new key, which also changes the server key every gateway mgmt_peer_pins entry refers to, so update those pins at the same time). The same warning and 0 appear while the certificate directory is read-only or the certificate authority key is not in it; if that is intentional the warning is harmless, and the identity appears the first boot the directory is writable. The same applies if the revocation lands while the upgrade is running: the new certificate is then revoked too (audit detail replacement_withdrawn=true) and the set must be re-keyed.
Behaviour change: the Security and sign-in page (single sign-on, two-factor, HTTPS and agent mTLS)
Settings gains a Security and sign-in page (/settings/security, administrators only). It hosts the same four panels that /settings already loads, so no route, field name or audit row changes and /settings keeps working. What an operator or script can notice:
- Saving SSO trims whitespace.
POST /api/settings/oidctrims leading and trailing whitespace fromissuer,client_id,redirect_uriandadmin_groupbefore validating and storing them. A value saved earlier with a trailing space is stored as typed until it is saved again; a trailing space in the admin group previously made the group match fail for every SSO administrator at the next sign-in. - The mTLS switch toast no longer says “saved”. The switch changes only the value this server reports; it is not saved and start-up flags decide. Anything that matched the old toast text should be updated.
- Errors appear inside the panel. A failed SSO save or a rejected certificate paste is now shown in the panel on the new page instead of being dropped. The HTTP statuses are unchanged; the error text now sits inside the panel markup instead of an out-of-band span.
- The panel is renamed. “Multi-Factor Authentication” is now Two-factor sign-in, found under Settings → Security and sign-in. The
/fragments/settings/mfaroute is unchanged. The elevation error text for an account with no enrolled second factor names the new location.
Behaviour change: dashboard API-token create and revoke apply MFA step-up
POST /api/settings/api-tokens and DELETE /api/settings/api-tokens/{id} (the Settings page’s
API-token Create and Revoke buttons) now apply the same step-up check as the REST token routes.
- SAML sessions cannot satisfy the check and get
403on both routes (unless the window is disabled with--mfa-step-up-window-secs 0), as they already do on/api/v1/tokens. A deployment whose only interactive sign-in is SAML (--auth-mode=sso-onlywith no OIDC provider) therefore has no Settings-page route to create or revoke tokens. Create or revoke them from an API-token or MCP caller (the step-up check does not apply to those), or sign in with the armed break-glass account and create or revoke within--mfa-step-up-window-secsof that sign-in (the account is gated like any other local account if it has MFA enrolled, and passes the check if it has not (undersso-onlyit is enrolled by construction), and it must hold an administrator role). If a deployment has neither an existing token nor a usable break-glass account, the only remaining route is to restart with--mfa-step-up-window-secs 0as a temporary measure: it disables the stale-proof requirement on every gated endpoint, not only these two (see Authentication), so restore the window afterwards. - OIDC sessions are gated only when the identity provider attests MFA (
amr) or--mfa-enforcementprotects the role. An OIDC-only--auth-mode=sso-onlydeployment is not exempt: with noamrclaim and--mfa-enforcement=admin-onlyorrequired, the session fails the gate (see the recovery runbook). - Local MFA-enrolled operators whose proof is older than
--mfa-step-up-window-secsmust sign in again before creating or revoking a token. The Settings page cannot yet prompt for the code, so the button shows an error toast (“MFA step-up required”) until the operator re-authenticates. - With no MFA enrolled and no SAML sign-in, nothing changes.
Behaviour change: web UI wording and layout (Console, Home after sign-in, hidden quarantine controls)
- “Dashboard” is now “Console”. The navigation item, the Home tile, the page title,
the “back to” links and the related Settings labels use the new name. Routes
(
/,/fragments/dashboard/...), API paths, MCP tool names and permission names are unchanged, so bookmarks and scripts keep working. Prose elsewhere in this manual still says “dashboard” for the web UI as a whole. - Home lists the screens only after sign-in. Signed out,
/homeshows the sign-in form and nothing else; the fifteen screen tiles appear once you are signed in. Health probes that fetch/homestill receive200. - The Hardware pages no longer show quarantine controls. The Quarantine link, the
Quarantined pill, the Quarantined devices panel, the containment banner and tab, and the
quarantine plugin’s rows in the Actions lens are hidden until an approval workflow for containment actions is added. Quarantine itself is unchanged apart from the Breaking entry at the top of this page: the MCP tools, the quarantine
plugin and the role-gated quarantine instructions on the Instructions page work as
before, the REST create and release routes now run the dispatch approval check, and server-side enforcement does not depend on the UI. While the controls are
hidden, use
GET /api/v1/quarantineto list records.DELETE /api/v1/quarantine/{agent_id}ends the server record only; to remove the device’s firewall rules, run the quarantine plugin’s release instruction (security.quarantine.release, “Release Device from Quarantine”) from the Instructions page (or throughexecute_instruction) and confirm with the status instruction. No configuration change is needed. - Colour schemes. Settings > General > Appearance now offers six schemes (Slate stays the default). The choice is stored in the browser, not on the server.
Behaviour change: the Groups and enrolment page; the Settings enrolment panels look and act differently
- New page: Estate > Groups and enrolment (
/groups). It hosts the agent approval queue, the auto-approve rules and the enrolment tokens on one page. Management groups stay in Settings. The page opens for every signed-in user; the three panels need the Administrator account role, as they did in Settings, and a user without it sees “Not available to your account” in each panel. - The Settings panels use the same renderers, so they change too (Enrollment Tokens, Pending Agent Approvals, Auto-Approve Policies). Routes, element ids, form fields and the Administrator decision are unchanged, so a script that calls these routes still reaches them. Two behaviours differ for such scripts (a stale approve or deny, and a stale rule action, now answer
409; see below). The one difference in the gate is audit: a refused browser session on the three auto-loading panels no longer writes anauth.admin_requiredrow per panel per view (a refused token, and every refused write, still does). What you will see:- Token Expires is shown in UTC (it was raw epoch seconds). The status (Active, Expired, Exhausted or Revoked, as before) is now a pill, and active tokens are listed first. The token reveal has a Done button.
- The approval queue shows when each agent asked to join (UTC) and gains Forget, which removes a waiting request (the agent is queued again the next time it asks to join). Approve all and Deny all replace the two-click buttons with a confirmation that gives the total and the first ten hostnames, and are disabled while the list shows only part of the queue.
- Auto-approve rules are described by what they match (hostname and cloud provider are agent-reported and not verified; address rules are IPv4 only; a CA fingerprint rule is marked as never matching at this release). The match mode is two buttons that each ask for confirmation, where it was a drop-down that applied at once. Enable and Add rule now ask for confirmation too, and Remove (which already asked) names the rule. Disable asks only in “all must match” mode, where it normally widens admission; for the last enabled rule it warns that no agent will be approved automatically until a rule is enabled. A rule that matches every agent is flagged Matches every agent.
- Each list shows at most 200 rows; the token and waiting-agent panels say how many are not shown.
- Approve and Deny act only on an agent that is still waiting. A request for an agent that was already approved or denied now answers
409with the refreshed list and an error message; before, a stale Approve silently reversed a denial and a stale Deny turned an enrolled agent into a denied one. Scripts that approve an agent twice now get409, and so does a script that approves a denied agent to re-admit it: that used to work only because the store applied the decision without checking the row’s status, and was never a supported recovery. A denial is permanent for that agent id, and there is no reinstate action today. A machine denied by mistake has to register as a new agent: reinstall it or wipe its agent data directory (a different--agent-idalone is not enough where client certificates are required), and remove or tighten any token or auto-approve rule that would admit it again; see Agent enrollment and Device Management. - Rule Enable, Disable and Remove check the rule they were drawn for. The dashboard now sends the rule’s type and value (and, for a toggle, its state) with the request, and a request for a rule that moved, changed or was removed since the list loaded answers
409with the refreshed list. Scripts that address a rule by position alone are unchanged. - Audit strings changed. If a SIEM rule keys on them:
enrollment.auto_approve.rule_toggledetail is nowtype=<t> value=<v> enabled=<0|1> by=<operator>(it wasenabled=<0|1> by=<operator>);enrollment.auto_approve.rule_removedetail gainstype=(type=<t> value=<v> label=<l> by=<operator>); and there are new refusal details withresult=denied,stale_rule_viewon rule toggle and remove andrefused_wrong_statuson approve and deny.rule_not_foundalso now accompanies a409when a dashboard request names a rule whose position is gone (it was only a200no-op). See Audit Log. - The token and pending fragments, and the responses that mint tokens, carry
Cache-Control: no-store, privateandVary: Cookie, so a proxy or the browser cache no longer keeps them. The re-rendered list that revoke, approve, deny, forget and the bulk actions return does not carry them.
- The management-groups panel is read-only, and a failed group-list read now counts on a metric. The panel lists the group tree, shows the selected group’s details and its members (members only within the viewer’s scope), and creating and deleting groups stays in Settings. Its reads, and the legacy
list_groups()that other callers use (dashboard, Pre-flight, Settings, MCP, REST, server), now go throughlist_groups_checked, so a failed group-list read incrementsyuzu_server_mgmt_group_read_degrade_totaland can fireYuzuMgmtGroupReadDegradedon a degraded store. Those callers still receive an empty list, as before; only the counter gains a source. - No setting, migration or action is needed. Rolling back restores the earlier panels.
Behaviour change: the plain-HTTP port answers the four health probes when HTTPS is on (#5906)
What changed. With HTTPS on, GET and HEAD on /livez, /readyz, /health and /api/health on the redirect port (--web-port, 8080 by default) now return the probe’s answer instead of 301: 200, or 503 while the HTTPS listener is not bound or has exited (https_listener_not_serving), the server is draining, or Postgres is down. Every other request on that port still redirects. The bodies are the unauthenticated ones the HTTPS port already served, now also readable in cleartext on this port (version, store names, agents.online and the tls block). --no-https-redirect removes the listener.
What to check.
- A monitor that expected
301on the plain port, or followed it to the TLS listener, now sees a probe answer. Point it athttps://<host>:8443/readyz, or runyuzu-server --healthcheckin the container. - A Kubernetes
httpGetprobe on the plain port keeps working but now reads the plain port’s own answer (for/livez,503only while the HTTPS listener is not bound or has exited;/readyzalso answers503while draining or when Postgres is down). The kubelet used to follow the301to the TLS listener (same host name, port ignored), so a wedged HTTPS listener failed the probe; for TLS-listener liveness usehttpGetwithscheme: HTTPSon 8443, or theexecprobe. A probe with a customHostheader never followed the redirect and now sees real503s. - Port 8080 now serves health data without encryption or authentication. Publish it only to the orchestrator network, or set
--no-https-redirect.--web-address 127.0.0.1binds the HTTPS dashboard to loopback too, so it does not restrict only the plain port. - Responses on the plain port (the
301s,400s and probe answers) now count inyuzu_http_requests_totalwith their own method and status labels, so request-rate totals and the 5xx error ratio include them; a plain-port503while the HTTPS listener is down is counted, but while that listener is down plain/metricsis itself a301to it, and the listener binds once per process, so recovery is a restart that resets the counter: those503s never reach an error-ratio alert such as the exampleYuzuHighErrorRate. Use--healthcheckorup == 0to catch that state. - That port’s responses now carry the same security headers as the HTTPS port, minus HSTS and the CSP
upgrade-insecure-requestsdirective. Its worker pool is fixed-size (at most 64 threads, created at boot), and shutdown waits at most 15 s for its thread. - Images built before this change are unchanged: they redirect, and have no
--healthcheck.
Behaviour change: new agent, policy and fragment identifiers are restricted; page links encode identifiers
- A
Register(direct or through the gateway) with an agent id the server has not seen before is now refused withINVALID_ARGUMENTunless the id uses only letters, digits,.,_,:and-and is not only dots (counted inyuzu_register_invalid_agent_id_total{reason="charset"}). Agents that are already enrolled, pending or denied are not affected, and the agent’s own generated UUID always passes. If you set--agent-idorYUZU_AGENT_IDto a value with other characters (for exampleDOMAIN\HOST,user@hostor a space), change it before the agent first registers. An agent whose enrollment row no longer exists is treated as new again: a pending row an administrator removed, or an enrollment store restored from an older backup, reset or rebuilt. Such an agent, if its id has other characters, gets the refusal on every attempt (retrying with backoff up to 300 seconds) until you change its--agent-id/YUZU_AGENT_IDto a conforming value. Through the gateway the agent sees the server message wrapped in anINTERNALstatus. Before upgrading, check any explicit agent ids you set against the rule. - A
Register(direct or through the gateway) with an agent id that contains a NUL byte, a control byte or,is refused and counted inyuzu_register_invalid_agent_id_total{reason="nul"}or{reason="control"}; no agent started with a conforming id produces either. A direct agent seesINVALID_ARGUMENT; an agent connecting through the gateway seesINTERNALwith a message that startsUpstream registration failed, as the charset bullet above notes. An id with a NUL byte or a control byte or,was already refused withINVALID_ARGUMENT, but it was logged as an enrollment store failure; the NUL refusal now has its own count and message and is decided before the enrollment store is read, so it also answersINVALID_ARGUMENTrather thanUNAVAILABLEwhile no enrollment store is attached. The custom-propertiesPUTandDELETEroutes likewise refuse an agent id that is empty, contains a NUL byte or is over 256 bytes, and theGETroute answers empty for one. - The registration-path log lines now print agent ids and hostnames folded: spaces,
=,,and non-ASCII bytes are shown as_and long values are shortened. The “Agent registered” line quotes the hostname. An id with a control byte or,is now logged at warn level and counted (reason="control") instead of as an error-levelenrollment status read failedline, so adjust any log alert that matches that text. A legacy id such asmy hosttherefore appears asmy_hoston the registration lines but as stored on the Heartbeat, Subscribe and registry lines, so one log can hold two spellings of the same id. The section on the newGuardian T_*diagnostic log lines in server-admin.md lists which lines fold and which print as stored. - A new policy or policy fragment with an explicit
idmust use letters, digits,.,_and-(up to 128 characters) and not only dots, the same rule instruction definitions use. Existing records are not changed. - Dashboard links and actions that carry an agent, policy or update-package identifier now percent-encode it. A row whose identifier is empty,
.or..is shown without actions, and the pending-agent approve, deny and remove routes answer400for such an identifier.
Behaviour change: the Settings link follows the server’s own admin decision (#5779, #4208)
GET /api/me and GET /api/v1/me now report effective_role, elevated,
elevated_until (only while elevated) and can_access_settings, and the shell
shows the Settings entry only when can_access_settings is true. Users with an
active just-in-time elevation now see the link; RBAC PlatformEngineer or
Administrator-label holders who are not legacy admins no longer see a link that
redirected away. During a rolling upgrade an older replica omits the new keys, so
the Settings link is hidden for every user, administrators included, whenever
/api/me is answered by an older replica; open /settings directly by URL (it is
unaffected) or wait for the upgrade to finish. Tabs opened before the upgrade keep
the old shell until reloaded. No configuration change is needed.
Behaviour change: GET /api/v1/me and GET /api/me no longer invent a role (#2146)
This is listed as a behaviour change rather than a break because the value that goes away was fabricated, was never documented for an engine principal, and rbac_role is now documented as an optional key.
- An engine principal’s
rbac_roleis now absent where it used to readViewer(orAdministrator, taken from its legacy session role). The endpoints now look an engine principal up under its own RBAC principal type. - With RBAC on, an engine principal’s grants now appear in
rbac_roles. An engine principal with no grant has norbac_role. - If the role store cannot be read, the call answers
200withrbac_roles_resolved: falseand neitherrbac_rolenorrbac_roles, instead of a made-up role. Treat an absent list as unknown, not as “no roles”. rbac_roleis deprecated. Readrbac_roles(direct assignments only; group grants are not listed) instead.- Browser users: no action is needed. If the role store cannot be read during a page load, the navigation badge shows the lowercase legacy
role(adminoruser) instead ofAdministratororViewer; the Settings link is unaffected. - New keys are additive:
principal_kind(humanorengine),auth_source,mcp_tier,scope_service,rbac_rolesandrbac_roles_resolved.GET /api/menow emits its keys in insertion order rather than alphabetically; a JSON consumer must not depend on key order. - A new MCP tool,
get_me, is the twin ofGET /api/v1/me. - During a rolling upgrade an older replica omits the new keys, still reports
Viewerfor an engine principal, and has noget_metool (it answers unknown-tool).
⚠️ Breaking: the audit log is admin-only while RBAC is off, and audit reads are bounded (#5771)
Audit trail access. With role-based access control switched off (the shipped
default), only administrators can now read the audit log. A non-admin signed-in
user or API token gets 403 on GET /api/audit, GET /api/v1/audit,
GET /api/v1/audit/auth-sample, GET /api/v1/engine-principals/audit/no-admin,
the MCP tools query_audit_log and audit_engine_no_admin, and the
yuzu://audit/recent resource. Previously any signed-in user could read it.
To give a non-admin the trail, enable RBAC and assign a role that holds
AuditLog:Read. Admins and RBAC-enabled AuditLog:Read holders are unchanged.
Find what broke. Denials are audited and counted. As an admin, run
GET /api/v1/audit?action=auth.permission_required&since=<upgrade epoch>&limit=1000
and read principal on the rows whose detail begins
topology floor: non-admin role denied AuditLog:Read. Alert on
sum(yuzu_auth_topology_floor_denied_total{permission="AuditLog:Read"}) > 0. A
poller that treats 403 as “no new events” stops collecting evidence silently,
so make evidence collectors fail loudly on any non-200.
Two ways to restore access. (1) Enable RBAC and assign a role holding
AuditLog:Read; read Enabling RBAC first, because a user
with no management-group role then sees no agents. (2) Run the collector on an
admin-owned token; while RBAC is off that token carries full admin authority, so
prefer (1) for evidence collectors.
Rolling upgrade. An older replica still serves the trail to non-admins until every replica is upgraded.
Bounded audit reads. GET /api/audit now clamps limit to 1000 and returns
400 for an offset above 10000, matching GET /api/v1/audit and the MCP tool.
Callers that paged deeper or asked for more than 1000 rows should narrow the
window with since/until. MCP query_audit_log now rejects a wrong-typed or
negative filter with an invalid-params error (-32602) instead of silently
ignoring it.
query_audit_log string filters are now limited to 1024 characters and a longer value is -32602. GET /api/audit and GET /api/v1/audit return 400 for a NUL or invalid UTF-8 in a string filter (a NUL used to truncate the filter and widen the read), and GET /api/audit also returns 400 for a negative since/until or an inverted window. query_audit_log now returns has_more beside entries in its structured result; the text channel is unchanged. During a rolling upgrade an older replica omits has_more, so a client that validates structuredContent against the new output schema can fail against it until every replica is upgraded. Clients that sent such filters should shorten or drop them.
Role reads fail closed. GET /api/v1/rbac/roles and
GET /api/v1/rbac/roles/{role}/permissions now answer 503 when the permission
store is degraded (previously an empty 200) and 404 for an unknown role
(previously 200 {"data":[]}).
Behaviour change: POST /api/v1/hardware/{id}/sync refuses cross-site cookie writes
What changed. A call to POST /api/v1/hardware/{id}/sync (and the new fleet form POST /api/v1/hardware/sync) authorised by a session cookie must carry an Origin or Referer header naming this site (or an entry from --csrf-trusted-origin), otherwise it answers 403 cross-site request refused and is audited as csrf.denied. API, MCP and engine token callers are not affected.
What to check. A script that signs in with a cookie and calls the per-device sync route without Origin or Referer now gets 403: use an API token, or send the header. The Hardware and Software pages already send it. Behind a reverse proxy that rewrites Host, declare the public address with --csrf-trusted-origin.
Behaviour change: adding, removing and re-roling a local account refuse cross-site cookie writes and are audit-first
What changed.
POST /api/settings/users,DELETE /api/settings/users/{username}andPOST /api/settings/users/{username}/rolecalled with a session cookie must carry anOriginorRefererheader naming this site (or an entry from--csrf-trusted-origin), otherwise they answer403 cross-site request refusedand write acsrf.deniedaudit row. Calls authorised by an API token are not affected.POST /api/settings/users/{username}/roleaccepts onlyapplication/jsonand answers415otherwise.- Each of the three writes a
result=attemptedaudit row before it changes anything. If that row cannot be written the call answers503withSec-Audit-Failed: trueand changes nothing; if only the outcome row is lost the2xxstands and the header is set.
What to check. A script that signs in with a cookie and calls these routes without Origin or Referer, or posts the role body as a form, now gets 403 or 415: use an API token, send the header, or post JSON. A SIEM or alert rule that counts csrf.denied rows will now see the three user-management routes as targets, and a rule on user.create, user.delete or user.role_change rows will now also see attempted rows.
Behaviour change: MCP revoke_upload_grant is audit-first and refuses during an audit-store outage (#6169)
What changed. The MCP tool revoke_upload_grant now writes an upload_grant.revoke audit row with result attempted before it revokes anything, then a success or failure row, the same rows its REST twin DELETE /api/v1/upload-grants/{grant_id} writes. If the attempted row cannot be stored, the tool answers a retryable -32603 error carrying retry_after_ms and the grant is not revoked. It stays redeemable until it expires at its TTL. The REST route already refused the same way, with 503, so no transport can revoke a grant while the audit store is down. The success result no longer includes audit_persisted. mint_upload_grant is unchanged.
What to check. An MCP client that treated revoke_upload_grant as always succeeding while the grant store is up must handle the retryable error: retry after retry_after_ms, and on the supervised tier request a fresh approval, because the refused call consumed its ticket. A client that read audit_persisted from this tool must stop. A SIEM or alert rule that counts one upload_grant.revoke row per revoke now sees two (attempted, then the outcome), so match on result=success. An attempted row with no outcome row for the same target_id means the outcome is unknown: check the grant’s state with list_upload_grants.
Behaviour change: PUT /api/config/{key} is audit-first, refuses cross-site cookie writes and floors heartbeat_timeout at 30 seconds
What changed.
- A call to
PUT /api/config/{key}now writes aconfig.updateaudit row with resultattemptedbefore it changes anything, then asuccessorfailurerow. Each accepted call leaves two rows where it left one. If theattemptedrow cannot be stored the call answers503withSec-Audit-Failed: trueand nothing is changed, so a runtime setting (including the log level) cannot be changed during an audit-store outage. - A call authorised by a session cookie must carry an
OriginorRefererheader naming this site (or an entry from--csrf-trusted-origin), otherwise it answers403 cross-site request refusedand is audited ascsrf.denied. API, MCP and engine token callers are not affected. heartbeat_timeoutbelow 30 seconds is refused with400. A value below 30 that an earlier build stored is raised to 30 when the server starts (a WARN line names it);GET /api/configkeeps listing the stored override until youPUTa value of 30 or more.
What to check. A script that signs in with a cookie and calls this route without Origin or Referer now gets 403: use an API token, or send the header. A SIEM or alert rule that counts one config.update row per change now sees two; match on result=success. Behind a reverse proxy that rewrites Host, declare the public address with --csrf-trusted-origin or the General page’s Save is refused. A stored value below 30 cannot be cleared by saving 30 on the General page, because that page already shows 30 as the current value: save another value first, or PUT it with a token. The Developer page’s fragments are now administrators only (a non-admin holding Infrastructure:Read gets 403 from them), and the permission grid no longer writes a denial row on each load. Whatever you set, keep heartbeat_timeout well above the heartbeat interval your agents run with: 30 leaves no margin for a late heartbeat.
⚠️ Breaking: OIDC refuses RSA signing keys under 2048 bits or with a degenerate exponent (#1901, #6058)
OIDC sign-in no longer accepts an RSA signing key whose modulus is under 2048 bits, or whose public
exponent is below 3, even, or wider than 32 bits (65537 is the near-universal choice). Such a key is
skipped when the IdP’s key set is parsed, with the warning
OidcProvider: failed to parse JWK kid=<kid> (RSA modulus under 2048 bits) (or (RSA exponent is 1),
(RSA exponent is even), (RSA exponent over 32 bits)), and its siblings are kept.
- Who is affected. An IdP that signs ID tokens with such a key, for example a legacy, in-house or test
IdP. That includes an IdP that also publishes a larger key it does not sign with. A token that names the
skipped key’s
kidis refused withno matching JWKS key found; a token with nokidthat the skipped key signed is refused astoken may be forged; if every key the IdP serves is skipped, nobody can sign in. Settings Test Connection reports only the keys that remain, so it can be green while sign-ins fail: a count lower than the number of RSA signing keys the IdP publishes means keys were skipped. - Check before upgrading. Fetch the IdP’s
jwks_uriand confirm that each RSA key it signs with has a modulus (n, base64url-decoded) of at least 2048 bits and an exponent (e) of 65537 or another odd value of 3 or more that fits in 32 bits. --auth-mode=sso-only. If every operator signs in through the IdP, a refused key locks them all out. Before upgrading, confirm a break-glass account is named and enrolled, and that you can runyuzu-server --break-glass-armon the server host (see “Hardened auth mode” below); the local-password path does not use the OIDC callback.- Rollback. There is no setting, flag or environment variable that lowers these checks; they are fixed
in the server. Rolling back to the previous release accepts such keys again (a weaker posture) and needs no
data change, because nothing is persisted. It is the recovery if you are locked out under
sso-onlywith no armed break-glass account.
Also in this release (a fix, not a break): a token with no kid member is now checked against each usable
signing key of the IdP in turn, at most the first 16 in the order published, instead of only the first. An
IdP that omits kid while it publishes several signing keys, as it rotates, no longer has sign-ins refused
intermittently.
Behaviour change: OIDC sign-in now requires the issuer’s discovery document (#5692)
What changed. The server no longer derives the OIDC authorization and token endpoints from the issuer URL, and the JWKS location (jwks_uri) is no longer left empty. All three are read from {issuer}/.well-known/openid-configuration the first time a sign-in needs them. Before this change an IdP whose endpoints were not the derived ones (Keycloak, for one) answered the sign-in redirect with an HTTP 404 (the browser was redirected to an authorization URL the IdP does not serve), and every ID token failed signature verification because no JWKS was ever fetched. Both failed closed; both now work. Neither server start nor a Settings save contacts the IdP, so a bad issuer is not noticed until the first sign-in or until someone clicks “Test Connection” (a connectivity check; it is not a guarantee of what a sign-in will hit, see Test Connection).
No working sign-in can regress, but a previously green Test Connection can now be red. In v0.13.0 and later, before this change, the server did not read jwks_uri from the issuer’s discovery document, so ID token signatures could not be verified and sign-in failed closed. There is therefore no working sign-in to break in those releases. “Test Connection” is stricter now: it can turn red for an issuer that differs from the document’s issuer by even a trailing slash, a document with no jwks_uri, an endpoint on a different scheme than the issuer, a JWKS that cannot be fetched or holds no usable key, or an address the egress policy refuses (see below). A green “Test Connection” before this change did not prove that sign-in worked. Rollback is a binary downgrade with no schema change, and it returns to the previous OIDC behavior, which cannot complete a sign-in: it does not restore a working one.
Redirect URI. The earlier manual said the Redirect URI was auto-computed when left blank; outside the startup-flag default (http(s)://localhost:<port>/auth/callback) that was never true, and a provider without one never fetches discovery. Behavior change: a Settings save (POST /api/settings/oidc) with a blank Redirect URI is now refused with 400, a red message naming the field and an error toast, where before it was saved and GET /auth/oidc/start then answered 500. Enter the callback URL registered at the IdP.
Review a forgotten OIDC configuration before upgrading. An OIDC configuration left in place but unused (in v0.13.0 and later, before this change it could not complete a sign-in, so nobody noticed) becomes a live sign-in on upgrade. If --oidc-admin-group is also set, members of that IdP group can now sign in as administrators. Check the flags and the group, or remove the flags.
Breaking: an IdP on a private network now needs --oidc-allow-private-networks. The server now refuses to make an OIDC request (discovery, the JWKS, Test Connection, the token POST) to a loopback, RFC 1918 (10/8, 172.16/12, 192.168/16), carrier-grade NAT (100.64/10) or IPv6 unique-local (fc00::/7) address unless it is started with --oidc-allow-private-networks (env YUZU_OIDC_ALLOW_PRIVATE_NETWORKS). This applies to the issuer host and equally to the hosts in the discovery document’s jwks_uri and token_endpoint, which are checked when they are used. A self-hosted Keycloak or ADFS on an internal address, and a lab IdP on localhost, will fail with request failed: address not permitted by the OIDC egress policy (and Test Connection will show the same text) until you set the flag. Link-local addresses (including the cloud metadata address 169.254.169.254), the listed cloud metadata addresses (100.100.100.200, 168.63.129.16, fd00:ec2::254), multicast and unspecified addresses are NEVER allowed, with or without the flag; Oracle Cloud’s 192.0.0.192 is the exception, because it lies in the 192.0.0.0/24 special-purpose block that the flag admits. The flag is a server start-up option only; it cannot be set from the dashboard or the REST API. Windows caveat: the token request is checked before WinHTTP resolves the name again, so a DNS change in between is not covered there, and a name whose answer mixes permitted and refused addresses is refused outright (WinHTTP cannot be pinned to the permitted one). Details: authentication.
Breaking: plain http:// to a public address is now refused, with no switch. An OIDC request over http:// (the issuer, or a discovery-document endpoint) whose target address is public is not made; the error is request failed: plain http is not permitted to a public address (token endpoint request failed: ... for the token POST), and Test Connection shows the same text. https:// to a public address is unchanged. http:// to a loopback, private, carrier-grade NAT or unique-local address still works, but only with --oidc-allow-private-networks, and it stays exposed to anyone on that network path: the signing keys, the token response and the client secret travel unencrypted there. If an IdP on a public address is configured with an http:// issuer, change it to its https:// URL.
What to do.
- Network. The server itself must reach the issuer host, and also the hosts in the document’s
jwks_uriandtoken_endpoint, which can differ from the issuer host, over HTTP(S) when someone signs in (and from “Test Connection”). The token endpoint was already server-side egress; the discovery and JWKS URLs are new. No HTTP(S) proxy is used on the discovery and JWKS path (the Windows token request uses the system default proxy), so a proxy-only egress needs a direct route to those hosts. Use anhttpsissuer: the JWKS is the trust root for every ID token and the client secret is sent to the token endpoint, both over the issuer’s scheme, so anhttp://issuer on a non-loopback host lets anyone on the network path forge a sign-in (the server logs a warning; anhttp://issuer on a public address is refused outright, and on a private address it needs--oidc-allow-private-networksand stays exposed to that network path).--oidc-skip-tls-verifynow also governs discovery and JWKS (the Windows token request ignores it), and a private CA must be in the server’s operating-system trust store. - JWKS per sign-in. Every sign-in now makes one request to the IdP’s
jwks_uri(nothing is cached, so a key the IdP withdraws stops verifying at the next sign-in). Check that your IdP’s published rate limit for that endpoint covers your peak sign-in rate: a 429 or 5xx logsOidcProvider: JWKS fetch failed: HTTP <status>and fails that sign-in withsso_failed, with no retry. Under--auth-mode=sso-onlya JWKS outage locks out every operator who has no armed break-glass account, so confirm one is named and enrolled before upgrading (see Hardened auth mode); the local-password path does not use the OIDC callback. A sign-in holds one HTTP worker of the pool shared with the dashboard and REST for up to about 10 s while its JWKS request runs. - First login after a restart pays one discovery round trip, and sign-ins that arrive during it are refused at once (retry in a moment). That state ends when the fetch returns, within the total bound (10 s by default: the 5 s per-operation timeout times 2). If sign-ins keep being refused this way with no cause line well past that, restart the server, which clears the in-flight state.
- If discovery fails,
GET /auth/oidc/startanswers500(“could not start the SSO sign-in flow”) and no sign-in begins; a callback that cannot reach discovery or the JWKS is refused with the usualsso_failedredirect. A failed discovery is not retried for 30 seconds; the JWKS is fetched afresh on every sign-in, so a failed fetch is tried again at the next one. A sign-in that arrives while another is fetching discovery gets the same JSON500at once, with no cause line of its own, and succeeds when retried a moment later. - Time bounds. Each discovery and JWKS request has 5 s per-operation timeouts and a total bound of 2 x that timeout (10 s by default) from the start of the request, covering name resolution (on Linux and macOS where the HTTP client library’s non-blocking resolver is built in: true of the Linux build, not checked for macOS; on Windows the server cancels its own lookup at the per-operation timeout and then waits up to 5 s more for the canceled lookup to finish, and keeps one that still has not finished in a bounded ledger of 16 until it does, failing further lookups closed while the ledger is full), connect, the TLS handshake and the response. The token-exchange POST has per-operation timeouts only (10 s, 15 s, 15 s on Linux and macOS; the WinHTTP defaults on Windows) and no total bound; its response is capped at 256 KiB, and on Windows it does not follow redirects and accepts only HTTP status 200 (a 3xx fails the sign-in). “Test Connection” runs discovery and then the JWKS fetch one after the other, each with its own total bound, so one click can take up to about 20 s in the worst case (a slow discovery document followed by a stalled JWKS fetch); a dead IdP fails at the discovery step.
- Descriptors. On Linux and macOS a request also needs one spare file descriptor beyond its socket, reserved before the socket is opened: with none available it fails closed with
request failed: no file descriptor available. - First checks. Settings > “Test Connection” shows whether the server can fetch the discovery document and the signing keys for the issuer typed in the form; then read the server log line
OidcProvider: discovery failed for issuer <issuer>: <reason>. See OIDC sign-in fails for the log lines and what each means. - Monitoring. A discovery failure at
/auth/oidc/startis logged only (OIDC auth flow could not start), with no audit row and no counter (#5704); failed callbacks are audited asauth.oidc_login_failedand counted inyuzu_auth_oidc_login_total{result="error"}, and a callback’s discovery or JWKS failure shares that generic row and counter. The log lines worth alerting on are listed under “Log lines to watch” in OIDC sign-in fails. - If the IdP changes. A successful discovery is kept for the lifetime of the provider. An IdP that moves its
jwks_urior endpoints needs a Settings save (which rebuilds the provider) or a server restart. Signing keys are fetched afresh for every sign-in and are not cached, so a key the IdP has retired or revoked stops verifying at the next sign-in (#1901 UP-19); the cost is one JWKS request per sign-in, and a JWKS endpoint that is down refuses every sign-in until it answers (there is no grace period for a previously fetched key). A token that names akidthe key set lacks is refused. A malformed key in a served set is skipped and its siblings are kept. Host names must be ASCII: give an internationalized name in its punycode (xn--) form. - The issuer must match byte for byte. The configured issuer must match the discovery document’s
issuerand the tokens’issbyte for byte, including any trailing slash. Copy it from the IdP’s discovery document. Every endpoint must be an absolute URL on the issuer’s scheme (anhttpsissuer whose document listshttpendpoints is refused), at most 2048 bytes, with no fragment (a query is allowed and is kept; the authorization endpoint’s query must not use the names of the OAuth request parameters Yuzu adds, and a token endpoint query is kept on every platform), with a plainhost[:port]authority (an IPv4-mapped IPv6 literal such as[::ffff:1.2.3.4]is rejected on purpose: the HTTP client library cannot represent it); the configured issuer must not contain a query or fragment; the document must list all three ofauthorization_endpoint,token_endpointandjwks_uri, and one missing is refused even when the step in progress would not use it. - Verified against Keycloak 26.4.0 (plain
httpandhttps, Linux); Entra not verified. Microsoft Entra ID is supported by the standard but has not been verified end to end by the project. Use the tenant-ID issuer (https://login.microsoftonline.com/<tenant-guid>/v2.0) and the manual procedure in authentication. - Windows. The token exchange uses WinHTTP; discovery and the JWKS fetch use the portable HTTP client from handler threads, which was once found failing there for
httpsissuers. The tree has anhttpstest from a handler thread, but a realhttpssign-in on a Windows server has not been verified by the project: test one before relying on it; a failure shows as the log lines above. - No operator override of the endpoints. They come from the discovery document or not at all, so an air-gapped IdP that does not serve its discovery document cannot sign in. A Settings save does not wait for sign-ins in progress (one rare exception, see “Known limits”): they hold the OIDC lock only to pick up the current provider. A sign-in whose exchange finishes after a save replaced the provider is refused with
sso_failed(audit reasonprovider_replaced) instead of being minted under the old admin group or issuer; a session that is already live is not revoked by a save. Every save rebuilds the provider, so users who are mid-sign-in must start again: save outside peak sign-in hours. The SIGTERM behavior while a token POST is stalled is under “Known limits”. See “Known limits” in authentication.
Behaviour change: sign-in moves to the landing page, /login redirects
What changed. /home is now the sign-in page (and the landing page for a signed-in user). A page requested
without a session is redirected to /home?next=<screen key> instead of /login, and GET /login answers
302 to /home, forwarding only a valid screen key and a known SSO error code. POST /login, /login/mfa,
/login/mfa/enroll, the OIDC and SAML callbacks and the session cookie are unchanged. Signing in no longer
leaves the user on the Dashboard: password and single-sign-on sign-ins both end on /home, which offers an
“Open Dashboard” button. A request sent by htmx without a session now answers 401 with an HX-Redirect: /home
header rather than a redirect, so a panel never swaps the sign-in page into itself.
What to check. Any monitor, load-balancer health check or script that expects GET /login to answer 200
must probe /livez, /readyz, /health or /home instead (/home answers 200 without a session). Scripts
that sign in with POST /login need no change. Bookmarks to /login keep working through the redirect.
Behaviour change: kill-switch audit rows carry the operator’s reason
The detail of the plugin_config.kill_switch.set audit rows (both the attempted row and the
success or failure row, over REST and MCP) now carries , reason="<text>" when the caller gave a
reason. A reason over 512 bytes is refused before anything is written; if one ever reached the audit
writer it would be cut at a character boundary and , reason_truncated=true would follow the closing
quote. Before, detail was exactly enabled=true|false, with , protective_exempt=true appended
for a protective pair. A SIEM rule that matches the whole string (detail == "enabled=false") stops
matching a call that carries a reason: match enabled=true|false as a prefix instead. The reason is
free text entered by an operator (at most 512 bytes before escaping; \ and " are backslash-escaped),
so do not parse it as structured data. A call without a reason is unchanged. See
Audit Log.
Behaviour change: the plugin-config list is authorized by one list-read gate
GET /api/v1/plugin-config, the MCP list_plugin_config tool and the Plugins and updates panel now
decide from the one ADR-0017 list-read gate instead of a permission check stacked on a list check.
What you may notice:
- A session with an active just-in-time elevation is now admitted on the REST route and the MCP tool (before, an elevated session with no underlying grant was refused there while other permission checks admitted it).
- A management-group-confined grant is still refused. On the REST route the refusal is still an
auth.permission_requireddenial row, now with the detail textmanagement-group-scoped grant cannot read the unscoped list PluginConfig:Read. On MCP it is now recorded asmcp.list_plugin_configwith resultdenied(it wasauth.permission_required). Update a SIEM rule that matched the old MCP verb. - For a non-engine caller, a closed or degraded authorization store answers 403 (it fails closed), where the REST route’s explicit store check could answer 503. Boot already fails closed on that store, so this is rare in practice.
Behaviour change: Guardian heartbeat reconcile is admission-controlled and fails closed on a degraded tag or property store (#5513)
What changed. The heartbeat reconcile is the re-push the server sends to an agent that reports a policy generation behind the server’s (see Guaranteed State). Three things differ from the previous release:
- Admission control. Each server replica now admits reconcile pushes against its own in-memory time budget and a soft in-flight cap of 256. A reconcile that is not admitted 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 retries on its next heartbeat. The per-agent limit itself is unchanged: one re-push per 25 s per agent.
- Fail closed on a degraded scope read. If the tag store or the custom-properties store cannot be read while the reconcile evaluates a rule’s scope for the agent, the reconcile now aborts and sends nothing. Previously the unreadable store was treated as “agent not in scope”, and the server pushed a rule set narrowed to exclude that rule, which disarmed the scoped rule on the agent until a later push restored it.
- The agent refuses a lower-generation full push (#5611). The agent binary now refuses a
full_syncpush labelled below the policy generation it holds (a push at the held generation is still applied). Agents on the previous release keep applying an older full push. If a server’s generation counter ever falls below what its agents hold (for example after a database restore), upgraded agents refuse every full push until the counter catches up, and rule edits are not delivered to them meanwhile; the server counts this inyuzu_server_guardian_agent_generation_ahead_total. See “Restoring the policy generation” in Guaranteed State. - A supported, audited way to raise the server’s policy generation.
POST /api/v1/guaranteed-state/policy-generation/raiseand the MCP toolraise_guardian_policy_generationset the generation to a target of1to9007199254740991, never lower it, and push nothing: the heartbeat reconcile converges the agents behind the new value. They are the supported recovery for the agents-ahead state above, and take the place of a manual database edit, which remains only as a last resort. The caller must be an administrator and must sendacknowledge_rule_set_replaced: true, because the raise converges every agent behind it to the rule set the server holds at that moment. Check the rule store and the deployed Baselines first. When the server holds no rules or no deployed Baseline (the usual state after a rebuild or the move from SQLite) the call is refused unless it also sendsacknowledge_empty_rule_set: true, meaning every agent behind the new value converges to zero guards. REST applies MFA step-up when the account is enrolled and its proof is stale (a SAML session is refused, an API-token session is exempt,--mfa-step-up-window-secs 0disables the gate, and with--mfa-enforcement optional, the default, an administrator who has not enrolled is not stepped up, and neither is an SSO (OIDC) administrator whose identity provider sent no MFA claim), requires a same-siteOriginorRefereron a cookie-session call, and refuses service-scoped and MCP-tier bearer tokens; MCP has no MFA, and its protection is the supervised tier with an approval plus the administrator check. Each call is audited asguaranteed_state.policy_generation.raiseand counted inyuzu_server_guardian_policy_generation_raise_total. - A read-only way to read the server’s policy generation.
GET /api/v1/guaranteed-state/policy-generationand the MCP toolget_guardian_policy_generationreturngeneration(the stored value) andceiling(the largest target a raise accepts), so the value can be read before a raise target is chosen. They needGuaranteedState:Readand are refused for a service-scoped API token. A degraded read is a503or a retryable MCP error, never a generation of0. They change nothing; the REST call writes no audit row on success.
New flags (all per replica). --guardian-reconcile-budget-ms-per-s (default 500), --guardian-reconcile-burst-ms (default 1000) and --guardian-reconcile-max-per-s (default 1000), each with a YUZU_GUARDIAN_RECONCILE_* environment variable; see Server Administration. The defaults are provisional starting values, not derived from fleet measurement. No flag needs setting to upgrade.
Who notices. In steady state (no policy change), only agents that are behind are re-pushed, so shed stays near zero. Admission matters after a policy bump or a fleet-wide restart, when many agents are behind at once. At an estimated 5 ms per admitted push (not measured end to end), one replica at the defaults admits about 100 pushes per second, so a fleet of 3,000 agents converges in about 30 seconds and a fleet of 30,000 in about 5 minutes; use the yuzu_server_guardian_reconcile_duration_seconds histogram to replace the estimate with your own figure.
What you will see.
yuzu_server_guardian_reconciles_total{result="shed"}rising after a bump on a large fleet. This is the budget working, not a fault, and no alert ships for it.result="degraded"andguaranteed_state.reconcileaudit rows beginningheartbeat reconcile ABORTEDduring a tag or property store outage. These rows are new for a scope-read failure. They do not raiseYuzuGuardianReadDegraded;YuzuGuardianReconcileDegradedand the tag-store and custom-properties read-degrade alerts (YuzuTagStoreReadDegraded,YuzuCustomPropertiesReadDegraded, all warning severity) are the alerts that fire for it.YuzuGuardianReconcileDegradedalso fires whenyuzu_server_guardian_reconcile_exceptions_totalkeeps rising.yuzu_server_guardian_reconcile_exceptions_totalrising: the reconcile hook threw out of the reconcile flow into the heartbeat handler. The exception is caught and logged as a warning and the rest of that heartbeat’s ingestion still runs; the counter makes a persistent failure visible, andYuzuGuardianReconcileDegradedfires when it keeps rising.- Agents with an armed rule keep it during such an outage; an agent that has nothing armed yet receives no rules, scope-free rules included, until the store recovers.
- A dashboard Baseline deploy or delete whose fan-out is abandoned because a store read degraded is audited
result=degraded(detail containingfan-out deferred to reconcile) and shows a warning toast; the Baseline change itself is saved. The reconcile converges the fleet once the store recovers only if the generation bump succeeded. yuzu_server_guardian_agent_generation_ahead_totalrising: at least one agent holds a policy generation above the server’s. It does not count agents, is sampled at most once a minute per replica, and the shipped alertYuzuGuardianAgentGenerationAhead(warning) fires on it. Follow the restore runbook in Guaranteed State, which uses the raise operation above.
Rolling upgrades with more than one server. A replica still on the previous release keeps the old behaviour (no budget, and a narrowed push on a degraded tag or property read) until it is upgraded. The REST, MCP and dashboard policy-bump fan-out now also aborts on a degraded tag or property read (#5610): REST answers 503, MCP returns an internal error with a retry hint, and the dashboard answers 200 with a warning toast. A replica still on the previous release narrows the push instead.
No config or data migration is required. The agent binary does change (the lower-generation refusal above); agents that have not been upgraded keep the previous behaviour.
Behaviour change: an over-long facet value is left out of the response’s facets (#5623)
What changed. A facet value over 2048 bytes (measured in bytes on the value as stored) is now
excluded from a response’s facets, never truncated. The response row, its output and every other facet
of that response still persist. Before, one such value made the whole facet insert fail and the response
lost ALL of its facets. The limit exists because the value is part of two database indexes, which refuse
an entry over about 2.7 KB. A new counter, yuzu_server_response_facets_dropped_total{reason} (value_too_long,
cap, batch_failed), counts the distinct (column, value) facets that did not persist; it is
published only after the response commits, is pre-seeded at 0 at boot, and a failed commit publishes
nothing. No alert rule ships or is owed: it is a data-completeness signal, and a per-response marker
and alert are tracked in #5630. See metrics.md.
Stored values. Older servers indexed values that fit the database index limit: about 2.6 KB for an
incompressible value (less the instruction id length), and far more for a value that compresses well
(measured on PostgreSQL 18.4: 99,999 bytes of repeated text inserted, 499,998 were rejected). So values
from 2049 bytes up to that limit were indexed before and are not now. Rows already stored by an older
server stay readable until retention removes them.
Facet rows are removed with their response by the existing reaper (--response-retention-days,
default 90); nothing new is added.
Effect on the dashboard. For newly ingested responses a value over 2048 bytes is no longer indexed,
including values between 2049 bytes and about 2.6 KB that older servers could index, and compressible
values far larger than that. The facet filter, the dropdowns and the group-from-results and create-group
counts therefore show nothing or “0 agents” for such a value, and a direct group-from-results submit
answers 422 “No agents match” and creates no group when no agent matches. Every one of these surfaces
now says so when an active exact-value filter (typed, or applied by a saved View’s default) is longer
than 2048 bytes:
- Results page. A static hint in the results summary, “A filter value is longer than 2048 bytes.
Newly stored responses do not index values that long, so this filter may match nothing. Clear the
filter (or switch the View to Default) and use the Search box.” The hint never repeats the value, and
the results, counts, Create Group button and the
422are unchanged. - REST agent-count preview and MCP preview tool.
GET /api/v1/management-groups/agent-count-previewandpreview_management_group_agent_countaddunindexed_filter_keysto the result: the filter keys (for exampleoutput, never the values) whose value is longer than 2048 bytes, measured as ingest stores it (a NUL or invalid UTF-8 byte counts as 3). It is always present and is empty when no filter is over the bound. When it is not empty,agent_countcan be 0 or an undercount.agent_count, the status codes and the503degrade are unchanged, so a client that ignores the new field behaves as before. - Create-group form. In place of “N agents will be added” it shows a static sentence, “A filter value is longer than 2048 bytes. Newly stored responses do not index values that long, so the agent count may be wrong.” The submit button works as before.
- Direct group-from-results submit. It still answers
422and creates no group. When the422is for an empty selection while a filter value is over the bound, the message adds “A filter value is longer than 2048 bytes; values that long are not indexed in newly stored responses, so this may be an undercount.”
For a value in the 2049 bytes to about 2.6 KB band the answer 0 can be wrong: agents whose newly ingested output contains the value are not counted, because the value is not indexed. Rows ingested before the upgrade, when older servers indexed it, are still counted, so the count from older rows can still match.
5,000-facet cap. Above 5,000 distinct facets in one response, the facets kept are now chosen in a fixed order: most lines first, then column index ascending, then value ascending. Before, they were whichever the server happened to hold first, so a rare value could be cut and the choice was not repeatable. Different rare values may therefore survive or be lost compared with before.
Lost facets are not recoverable. A response whose facets were lost (batch_failed, or cut by
cap) has no facets re-derived later, and nothing marks the response as affected (#5630).
What to do depends on the reason. batch_failed (the facet insert was refused, for example by an
unusually long echoed command id): run the command again; the new run gets a fresh response, and
RECREATE any static group built from the affected response from the NEW run, because running the
command again does not repair an existing group. cap: the same output keeps the same 5,000 facets,
so a re-run cuts the same ones; use a command or filter that returns fewer distinct values, or accept the
short result (#5630 tracks a marker). value_too_long: there is nothing to re-run, the value is never
indexed. To find a response that lost facets, search the server log (a cap or batch_failed response
logs a warning with its response_id); a value_too_long exclusion logs only at debug level, so at the
default level only the counter shows it.
Rolling upgrade. The change is server-only: there is no schema, configuration, agent or gateway step, the order in which replicas are upgraded does not matter, and rolling back is starting the previous server binary. What differs during a rollout:
- Ingest decides the facets. A facet is lost, or kept, on whichever replica ingested the response. An old replica that ingests a response carrying a value too long for the database index drops ALL facets of that response; a new replica excludes only the long value and keeps the rest.
- Every replica reads the same table. Every replica reads the same
response_facetsrows, so a group built from a response that an OLD replica ingested is short whichever replica builds the group. Upgrading the replica that serves the group does not repair it; use the workaround above. - A typed filter with a NUL or invalid UTF-8 byte. A new replica matches such a filter in the form it is stored in; an old replica cuts a NUL filter short at the database (so it can match a different value) and fails the read for invalid UTF-8, which shows as an unavailable count. During a rollout the same request can therefore answer differently on different replicas.
See response-store.md (Facets are bounded).
Behaviour change: the last administrator can no longer be deleted or demoted (#4966)
What changed. Deleting (deactivating) an account and demoting a local admin account now go through
one guarded write that refuses to remove the last member of a protected administrator population: the
active local role='admin' accounts (with RBAC enforcement on or off) and, with enforcement on, the
accounts that hold the Administrator grant. This covers the dashboard user delete and role change
(409) and every SCIM deactivation, delete, active:false and group-derived demotion (409,
scimType mutability). It is a recovery rule, not proof that anyone can sign in. The audit results
and the new counters are listed in the changelog entry and in docs/user-manual/rbac.md,
“Last-Administrator guard for account deactivation and demotion”.
What to do.
- Check before you upgrade or demote anyone.
SELECT count(*) FROM auth.users WHERE role='admin' AND is_active;should return at least 2, so no ordinary operation has to be refused. - Drain old replicas first. A replica running an older build does not enforce the rule, so during a rolling upgrade drain the old replicas before relying on it. There is no schema change.
- A rollback to an older binary silently drops the guard. Nothing in the database remembers it: the older binary will delete or demote the last administrator without refusing.
- A server with an authentication database but no RBAC store does not start (a boot self-check), and at runtime the same condition fails closed. Fix the RBAC store before starting.
- SCIM connectors: expect
409on a deprovision of the fleet’s last administrator and503withRetry-After: 2when the guarded write times out. A refused deprovision leaves the person’s account active until an operator acts, and raises a critical analytics event. SCIM provisioning now reads the RBAC store even when enforcement is off. Behaviour change:active:trueon a tombstone. A SCIMPATCH/PUTwithactive:trueon a deactivated resource whose role is notuser, or whose username holds anAdministratorgrant, now answers404(auditedscim.user.provenance_denied, counted onyuzu_scim_provenance_denied_total) instead of reactivating the account. SCIM can deactivate the holder of anAdministratorgrant but cannot reactivate it until the grant is removed (MCPunassign_rbac_role); an elevated tombstone has its role cleared withUPDATE auth.users SET role = 'user' WHERE username = '<name>' AND NOT is_active;(seerbac.md). A Group request whose role recompute is incomplete (a refused or failed demotion, a member whose read failed, or promotions skipped after a store failure) now records onescim.group.recompute_incompleteaudit row, and its success row saysrecompute=incomplete. Known limits of the Group recompute are listed in the SCIM provisioning guide.
Behaviour change: POST /api/instructions/{id}/execute now validates params against the stored schema
What changed. The route checks the request’s params against the definition’s stored
parameter_schema and answers 400 (invalid params: <path>: <reason>) when they do not conform. The
check runs after the permission checks and before the approval gate, so a refused call creates no
approval ticket, execution row or dispatch. It is strict: a name the schema does not declare, a value of
the wrong type or outside enum, pattern, minimum/maximum or length bounds, a missing required
parameter, a string containing a NUL, and a non-object params are all refused. A definition with no
stored schema (empty, whitespace or {}) is not validated. A stored schema that cannot be prepared makes
the route answer 500 instead of dispatching. A schema is also checked when it is written:
POST /api/instructions/import refuses an over-limit or invalid parameter_schema with a 400, and a
bundled definition that fails the same check makes the server refuse to boot (the golden test guards the
shipped content). Updating a definition (PUT, the YAML editor, response templates) keeps its stored
schema. Details: REST API, Instructions.
Who is affected. Callers that send something the stored schema does not accept:
- Names the definition does not declare. They used to ride through to the plugin; they are now
400(path/*, the name is not echoed). Six shipped definitions under-declared what their plugin reads and are corrected in this release:agent.content_dist.cleanup(hours),agent.content_dist.execute_staged(expected_hash),device.wol.check(timeout_ms),device.agent_actions.set_log_level(any letter case, pluswarninganderr, which the agent accepts),workflow.config_search_and_replace(declarespath,search,replacement,regex,case_sensitive,dry_run,max_replacementsand the retainedbase_dir) andworkflow.version_compliance_check(declarespath,minimum_versionandbase_dir). The twoworkflow.*definitions used to declare parameter names their plugin never read. - Values outside an enum or a bound.
enumis case-sensitive ("True"and"1"are refused for atrue/falseenum), and a value above a declaredmaximum(or below aminimum) is400where a plugin may have clamped it (for exampledevice.agent_logging.get_loglinesabove 500). ""andnullfor optional parameters. They are values, not omissions: they are refused for an optional integer,enumorpatternparameter. Omit the key instead.
Not affected. The 7 shipped definitions that have no parameters: block and so store no schema
(windows.app_control.wdac_policy, windows.app_control.applocker_policy,
crossplatform.local_security_policy.password_policy, ...lockout_policy, ...audit_policy,
...sudoers and windows.rdp.status), definitions saved from YAML or created in the dashboard, and every
other dispatch surface (workflow steps, schedules, policy remediation, result-set producers, MCP
execute_instruction, POST /api/command), which do not validate yet.
A stored schema is not refreshed by an upgrade. The bundled reseed inserts a definition only when its id
is absent (ON CONFLICT (id) DO NOTHING), so the six corrections above reach fresh installs only; an existing
install keeps the old stored schema and keeps refusing the same calls until it is replaced (export the
definition, delete it and import it again, signed unless the server allows unsigned definitions; see Replacing a stored parameter schema). Comparing the
parameter declarations at v0.14.0 with this release, exactly 6 definitions differ in what the server
enforces (the six above), and 13 more differ only in description text and enforce what they did
(agent.content_dist.upload_file, device.agent_logging.get_log, device.event_logs.errors,
device.event_logs.query, device.filesystem.find_by_hash, device.filesystem.read,
device.filesystem.search, device.filesystem.search_dir, device.script_exec.bash,
device.script_exec.exec, device.script_exec.powershell, device.windows_updates.patch_connectivity,
workflow.patch_connectivity_audit).
Detecting it. Each refusal writes an instruction.execute audit row: result=denied with detail
reason=param_schema path=<path> for a 400, or result=failure with reason=param_schema_invalid for the
500. Each also increments
yuzu_server_instruction_param_rejected_total{route="instruction_execute",reason}. See
Audit log and Metrics.
Rollback. No migration and no schema change. Rolling the server back removes the check, and while replicas
run different releases the same call can be refused by one and accepted by another. A release without the check
still rewrites a stored schema in one case: a YAML-editor save on the older release writes {} over it, because
that route builds the definition from the YAML alone. The other update routes of the older release (PUT,
response templates) write back the schema they loaded. A definition that was hit this way is not validated, on
this release or any other, until its schema is replaced (see
Replacing a stored parameter schema); rolling forward
does not restore it.
Behaviour change: response routes parse numeric parameters strictly and exports are bounded (#4644, #4703)
Breaking for scripts that relied on a malformed value being silently misread. On the legacy
/api/responses/{id}[/aggregate|/export] routes, the /api/v1/responses/{id}[/aggregate|/export]
routes, GET /api/v1/executions/{id}/responses and MCP query_responses, status, since,
until and limit (and the legacy catch-all route’s offset) must now be one whole base-10 integer.
A value that used to be read as a different one is now 400 (-32602 on MCP): since=1e9 (was
epoch second 1), status=0x1 (was 0), limit=100abc (was 100), since=1.5 (was 1),
a leading +, whitespace, and a status below -1 (was “no filter”). An empty value and a value
too large for its type were already 400.
Who is affected, and what to do.
- Scripts that build
since/untilfromdate +%s.%Nor any fractional timestamp: send integer seconds (date +%s). - Breaking: scripts and integrations that call the legacy
GET /api/responses/{id},GET /api/responses/{id}/aggregateorGET /api/responses/{id}/export: each now writes aresponse.readsuccess audit row on every served read and is fail-closed, like its v1 twin. When the audit store is down (or the row cannot be persisted) the route answers503withSec-Audit-Failed: true, an A4 envelope (retry_after_ms: 5000) and no data, where it used to serve the read unaudited (all three routes,get,aggregateandexport, used to answer200with the data even when the audit write failed). The scope-dropdeniedrow on all three is fail-closed too, so a management-group-scoped caller whose drop row cannot be persisted gets the same503. Retry on503. A SIEM rule keyed onresponse.readnow seesresult=successrows withdetail=legacy response <query|aggregate|export> cid=<id>from these routes as well as the v1 ones; a global (unscoped) caller used to leave no row at all ongetandaggregate. Every served read on these routes now adds one audit INSERT (the audit reaper’s retention cap is the only backpressure), so a script that polls/api/responses/*raises audit volume in proportion to its request rate. - Scripts that send
until=0or a negativesinceoruntilto a response route: it is400now. A negative bound, anduntil=0, used to be read as “unbounded”, so a window computed asnow - nthat went past the epoch silently returned the whole result. Omit the parameter for “no bound on that side”.since=0stays valid and matches exactly what omittingsincematches; no script that sendssince=0changes behaviour. - Scripts that send a negative
sinceoruntil, oruntil=0, to other epoch-bounded reads (the execution-statistics, legacy audit, auth-sample and inventory-query routes): it is400now too, with the same fix (omit the parameter for no bound). The list is under Behaviour change: numeric query parameters on other routes. - MCP clients that send
"limit": nullor"status": null, or a float or string for either: omit the key to get the default. Astatusabove2147483647and an unsigned value above9223372036854775807are-32602too (the latter used to wrap to-1, “any status”; the same fix applies to every other MCP argument read with the strict integer reader, 16 call sites). - Callers that pass
limit=0or a negativelimitto the legacy export: it now serves one row (clamped to1..10000, like the v1 export), and its JSONcountis the number of rows served. The legacy list route caps an explicitlimitat 1000; zero or below still means its default of 100. When the caller asked for more than 1000 and got a full 1000-row page, the legacy list body now carries"result_truncated_by_cap": true(absent otherwise).
Exports are bounded. Both export routes stop at 50 MiB of row payload (output plus
error_detail) in addition to the 10,000-row limit, and the cut is made inside the database query,
so a large export no longer loads every row into the server first. The cap is not operator-tunable.
A cut export (row limit with more rows left, or the byte cap) is marked with a top-level
result_truncated_by_cap: true (legacy JSON) or pagination.result_truncated_by_cap (v1 JSON),
and a cut CSV ends with one extra trailer record, # result_truncated_by_cap cause=row_cap (or
byte_cap), padded to the header’s width (7 fields legacy, 10 on v1). A CSV parser that expects a
number in the id column will fail on that record, and only on a cut file; that is deliberate, so a
cut file is never read as complete. An uncut CSV is byte-identical to before. A cut export also
carries an X-Result-Truncated-By-Cap: true header (CSV) and a download name
responses-<id>-truncated.json or .csv; a plain curl -o out.csv ... keeps neither, `curl -sS -D
- -o out.csv …
shows the header andcurl -OJkeeps the name. The 50 MiB cap counts payload bytes, the last row kept can run past it by up to its own size (about 4 MiB for text output, up to about 12 MiB for output dense in invalid bytes or NULs; a terminal frame'serror_detailis cut the same way), and a result under 50 MiB of raw payload can still be cut when its escaped serialized form crosses the cap. A legacy export of an id containing a character outside[A-Za-z0-9.-]is now downloaded under a name with that character written as. A quoted CSV cell can contain text that looks like the trailer, so read the file as CSV and take the final record rather than matching lines. There is no cursor: to read past a cap pull once peragent_id, or setuntilto the oldesttimestampreceived (inclusive, so de-duplicate onid`).
Rolling upgrade. Replicas on the old and the new build answer a malformed value differently
(200 versus 400), so a client can see both during the rollout. No configuration or data
migration is needed. Two new counters report the effect:
yuzu_server_response_param_rejected_total{surface} and
yuzu_server_response_export_truncated_total{surface,cause}
(Metrics).
Behaviour change: numeric query parameters on other routes are parsed strictly too (#4644)
Breaking for scripts that relied on a malformed value being silently misread. The strict
parsing the response routes got (above) now also covers the routes listed in the REST API
reference under Numeric Query Parameters: the
execution-statistics routes, the executions lists, the audit routes, the instruction, product-pack,
workflow and compliance lists, the patch lists, the software-inventory list, the fleet-topology
machines_max and the app-performance compare window. A value that is not one whole base-10
integer now returns 400 where it used to be read as something else: limit=100abc (was 100),
since=junk on the statistics routes (was 0, “from the beginning”), ?limit=abc on
/api/analytics/recent and the patch lists (was ignored), window=7days (was 7).
On MCP, query_audit_log answers -32602 for a since, until or limit that is not a JSON
integer or does not fit a signed 64-bit value (a string used to mean 0, unbounded, and
18446744073709551615 wrapped to -1), and the app-performance compare tool does the same for
window. Other integer arguments that do not fit saturate instead of wrapping, except three tools
where a saturated value would be harmful and the argument is strict and bounded instead (-32602
otherwise): create_offload_target batch_size must be 1 to 10000 (the declared schema range, now
enforced), mint_upload_grant declared_max_size must be a JSON integer that fits a signed 64-bit
value, and create_guardian_rule version must be 1 to 2147483647. mint_engine_credential
ttl_days of the wrong JSON type is -32602 too (nothing is minted; it used to fall back to the
90-day maximum). Security-gating MCP arguments are strict too: issue_code_signing_cert validity_days must be a
JSON integer from 1 to 730 (4294967297 wrapped to a 1-day certificate; a string, float or null took the
365-day default), mint_upload_grant ttl_secs must be a JSON integer of at least 1 (a wrong type, 0 or a
negative value minted the 900 s maximum; larger values still clamp), create_api_token expires_at must be 0
or a future epoch (a negative value minted a token that never expires), and activate_platform_license
seat_count and expires_at must be integers of at least 0 (a negative expires_at was perpetual); each is
-32602 and creates nothing. A wrong-typed boolean on create_guardian_rule/update_guardian_rule enabled,
push_guardian_rules full_sync and the engine-principal include_revoked is -32602 instead of a bare
HTTP 500, and create_offload_target now neutralises the name it writes to audit rows. The two bounds now hold
at the REST twins and in the stores, so no writer bypasses them: POST /api/v1/offload-targets
reads batch_size as a 64-bit value and answers 400 outside 1 to 10000 (an oversized number is
rejected, not wrapped) and for a float, string, boolean or null (a float such as 5.5 used to be
truncated), POST /api/v1/guaranteed-state/rules answers 400
for a version outside 1 to 2147483647, and PUT /api/v1/guaranteed-state/rules/{id} and MCP
update_guardian_rule refuse a rule whose stored version is already at 2147483647 or above (409 on REST,
-32602 on MCP) instead of overflowing the version bump. The store enforces the range only when a
write changes the version, so a rule that already holds an out-of-range stored version can still be
enabled or disabled from the dashboard. The MCP tools get_execution_statistics_by_agent and
get_execution_statistics_by_definition answer -32602 for a limit below 1 (the schema minimum;
it used to be replaced by the default 50), and so do their REST twins, GET /api/v1/execution-statistics/agents and /definitions (400 for a limit below 1 or malformed). Two more REST inputs are new 400s: the limit in the body of POST /api/v1/inventory/query and POST /api/inventory/query (below 1 or not an integer; above 1000 still clamps to 1000, and 4294967297 no longer wraps to 1), and the ttl_days of POST /api/v1/engine-principals/{id}/credentials (present but not a JSON integer from 1 to 90; absent still means 90). That mint route’s body is optional only when it is empty or whitespace-only: a truncated document or a body that is not a JSON object ([], 5, "x", null) is now 400 and mints nothing, where it used to mint the default 90-day credential. {} and an object without ttl_days still mint 90 days.
A negative since is refused (400, -32602 on MCP) on the legacy GET /api/audit and MCP
query_audit_log, and so is an until of 0 or below (a negative since or until, and an until
of 0, each used to mean no bound and are new 400s here). The same holds for a negative since on
GET /api/v1/execution-statistics/definitions/agents and their MCP twins, for to=0 on
GET /api/v1/audit/auth-sample, and for an until of 0 or below (or a non-integer bound) in the
body of POST /api/v1/inventory/query and POST /api/inventory/query; a non-integer since or
until in an inventory query body made the body read throw and is a clean 400 now. The bounds are
presence-tracked now: -3600 and until=0 used to mean no bound and returned the whole log,
statistics set or inventory. Omit the parameter for no bound; since=0 (from=0 on the auth sample)
is accepted and matches the same rows as omitting it. On the auth-sample export a present but empty
from or to is accepted as unbounded on that side. GET /api/v1/audit follows the same rule: an
earlier build of this release line read 0 as unbounded there; an until of 0 or below (or a
negative since) is now 400.
The same strictness now covers the dashboard fragments’ page/per_page, the software
inventory (the three software fragments answer 200 with an inv-caveat note for a malformed
limit and ignore a well-formed one, which uses a fixed page size), DEX perf, network quality, hardware and CA-issued lists, the notification, webhook and
offload delivery lists, the DEX, network and result-set limit and the Guaranteed State events limit/offset (trailing characters are 400; an integer outside the result-set page range still uses the default page size),
the pre-flight run list limit, the pre-flight run form’s min_gib and window (a malformed value
or a negative min_gib is 400; an empty field keeps its default; a window of zero or below uses 30
minutes and a larger value is clamped to 1 to 2880 as a 64-bit value, so one beyond a signed 64-bit
integer is 400; see Pre-flight), the hardware sync poll’s await_since
(a malformed freshness bound used to read as “no bound”), the Echo process-tree node and the
GET /api/v1/events ?since cursor (the workflow stream has the header only). A malformed
Last-Event-ID header is served live only (nothing replayed, never the whole buffer) rather than
a 400, and -1 and 5abc are never accepted as cursors (5abc used to replay from 5). On the
query-parameter routes, a parameter that is present but empty (?limit=) is malformed, not
absent, GET /api/v1/audit limit included, except limit on the software list and catalogue
routes and fragments, where an empty limit= means the default (see
the software installs read).
The verify fragment’s window
is refused with its usual 200 note instead of a 4xx (the dashboard drops 4xx bodies).
Four admin forms stop silently saving a different value than the one typed. A malformed
min_devices, window_seconds or cooldown_seconds on the DEX blast-radius thresholds is a 400
and saves nothing (it used to persist the clamped minimum). On the enrollment-token forms and the
API-token ttl_hours, 0x5 used to read as 0; max_uses 0 means unlimited and a TTL of 0
means no expiry, so a mistyped limit minted an unlimited or never-expiring credential. Only an
empty field, or a value that parses to a literal 0 (0, -0, 00), means unlimited or no
expiry now, and a negative ttl_hours is refused (it would have computed 0). The API-token form
refuses with its usual 200 feedback span and mints nothing; the other forms answer 400 plus an
error toast. On the batch route an empty max_uses defaults to 1. A malformed rollout_pct on an OTA
package upload or rollout change is a 400 instead of silently becoming 100. Behaviour change on
the rollout change route only: an empty rollout_pct and a value above 100 are now 400 as well
(an empty value used to mean 100 and a larger one was clamped to 100); a negative value still
clamps to 0; the upload route keeps an empty value as 100, clamps a larger one to 100 and a negative one to 0, and refuses only a malformed value. A negative limit or offset on GET /api/notifications, and a negative limit on the patch lists
(GET /api/patches, GET /api/patches/deployments), is 400 too (the store answered an empty 200),
and so is a negative limit on the legacy GET /api/policy-fragments and GET /api/policies (it
reached Postgres as LIMIT -1 and answered 503; the /api/v1/ policy routes clamp it as before).
A malformed or out-of-range enrollment-token number now also writes an enrollment.token_create or
enrollment.bulk_token_create row with result=denied and detail=invalid_numeric_parameter (see
Audit Log).
Security-gating numbers in JSON request bodies are strict too. Before / after: POST /api/v1/tokens expires_at of -1, or a value above 9223372036854775807 that wrapped to -1, minted a token that never expires, 5.5 or 1e3 minted an already-expired one, and a string, boolean or null (or a non-object body) was a bare 500; now each is 400 and mints nothing (0 or omitted still means never expires, and a past epoch is refused). overlap_secs on POST /api/v1/tokens/{id}/rotate and POST /api/v1/engine-principals/{id}/credentials/rotate must be a JSON integer, and a non-blank body that is truncated or not an object is 400 instead of rotating with the 7-day default (an empty body still takes the default). version on POST /api/v1/guaranteed-state/rules must be a JSON integer in range (5.5 was stored as 5). ttl_secs on POST /api/v1/upload-grants must be a JSON integer of at least 1 when present (a wrong type, 0 or a negative value used to mint the 900 s maximum). On POST /api/settings/enrollment-tokens/batch a JSON number for count, max_uses or ttl_hours used to be silently ignored, so the documented curl example minted single-use tokens that never expired; numbers and numeric strings are now both honoured, a float, boolean, null, overflow or malformed body is 400, and an empty string still means the default. An ip_subnet auto-approve rule with a malformed prefix is refused when added (400); a malformed one already stored used to throw out of agent registration and now never matches, so it never auto-approves. POST /api/schedules interval_minutes (1 to 35791394), day_of_week (0 to 6) and day_of_month (1 to 28) must be JSON integers in range (4294967297 minutes became an every-minute schedule); POST /api/schedules/{id}/enable enabled must be a JSON boolean, the integer 0 or 1, or the exact string "true" or "false" (an omitted key or a blank, empty or whitespace-only, body still enables), and any other string, another number, null or a malformed body is 400 and changes nothing ({"enabled":0} used to arm the schedule). POST /api/command stagger and delay must be integers from 0 to 300 (a float, string or null read as 0, so a delayed command ran immediately; a value above 300 was silently capped by the agent at 300 seconds and is now a 400). A result-set matcher {"kind":"echo_rows_ge","n":N} needs an integer N from 1 to 2147483647 on POST /api/v1/result-sets/from-instruction-result and the MCP create_result_set_from_instruction_result (400 / -32602), and a matcher that is present but not a JSON object, or a kind that is not a string, is refused too (it used to be dropped, so every responder qualified; omit matcher for that default); a malformed n or kind already stored, or an n below 1, now matches no device instead of throwing in the maintenance tick or admitting everyone. The pre-flight form’s app_min, app_max and os_min must be empty or a dotted-decimal version with an optional -beta / +build suffix; abc, v4.2.0 or 4.2.x is 400 and starts no run (it read as version 0 and passed every device). POST /api/v1/license seat_count and expires_at follow the MCP twin: JSON integers of at least 0, else 400 (the licence surface is dormant until the LicenseStore is wired, ADR-0048). Rolling upgrade: replicas on the old and new build answer these inputs differently.
Three more inputs that used to be read as something else are refused (400 on REST, -32602 on MCP), and one stored value is repaired:
a conditions[].value on the inventory evaluate and result-set routes that is a JSON number is
accepted with ==, !=, >, >=, <, <= (and a boolean with == or !=); all six compare it
as a number against the raw stored value (exact for integers and for integer against a decimal, a stored
numeric string counting as that number; JSON integers are exact and every JSON float (a number written with a decimal point or exponent, such as 5.0, 1e3 or 5.5, or an integer outside -9223372036854775808 to 18446744073709551615, which arrives as a float) is refused because its original text is lost once parsed: send the decimal as a JSON string under >, >=, < or <=, which is accepted as a whole number within int64/uint64 (digits or an integer-valued exponent form such as "1e16", read exactly) or with at most 15 significant digits, a magnitude below 2^53, a value that is zero or at least the smallest normal double, and a value within the range of a double, and a plain-decimal string such as "1e400" is refused instead of ordering lexically; == and != with a string stay a text compare, so numeric equality covers JSON integers only; write a whole number as an integer up to 18446744073709551615 or as digits, and a string such as "9007199254740993.0" is read as that exact integer; a non-integer at or above 2^53 such as "9007199254740992.5" is refused), so behaviour change: a stored value that is not a number,
such as "5 GB" or "N/A", no longer satisfies >, >=, < or <= against a numeric operand (it was read as 5
or compared as text), and any other operand type is refused (a number used to become the empty string, so {"op":">","value":5}
selected every device that had the field); a string operand that is a plain decimal number ("4", "2e-7") is ordered numerically under >, >=, <, <=: a stored number or fully numeric string compares exactly (a stored integer within int64/uint64, including an integer-valued exponent spelling that fits (such as "564141138995e7"), and a stored decimal with a fraction of at most 15 significant digits and a magnitude below 2^53 compare exactly; any other stored decimal, one of more digits or a non-integer at or above 2^53, or a stored negative integer below -9223372036854775808, is compared as the nearest double), a stored string that merely starts with a number (a dotted version "1.2.3", "5 GB") has its leading number read and compared the same way (use version_gte / version_lte for dotted versions), and any other stored value ("N/A", "", a boolean) never matches, where a JSON number operand never matches a stored string that is not a number in full; while == / != and a non-numeric string operand such as "v2" are unchanged; a result-set matcher whose column, op or value is
present with the wrong type no longer qualifies every responder; the DEX window on
GET /api/v1/dex/signals, /dex/scope, /dex/signals/{obs_type} and their MCP tools (and get_dex_device_score) must be
24h, 7d, 30d or all (90d, a non-string such as 30 or null, or an empty ?window= was answered with 7 days; only an omitted window keeps 7d). Separately, a stored schedule interval_minutes
above 35791394 now advances instead of failing in Postgres on every tick (new schedules are capped at
that value).
Still lenient, and not an exhaustive list: the n retry-attempt counter on the live-snapshot poll
fragments (the deployment, device, hardware, DEX, pre-flight and Echo process-tree polls), which is
clamped to a fixed range per poll (1 to 100000 on the deployment and pre-flight polls, at most 50 on
the others), and values parsed from stored rows, agent output or server configuration rather than
from a request. Numeric path segments matched by a (\d+)
pattern (notification read/dismiss and the webhook routes) now answer 404 for an id that does
not fit a signed 64-bit integer; they used to fail with 500. The auto-approve rule index answers
200 with an audited denied row (rule_not_found) and changes nothing.
What to do. Send whole integers, and omit a parameter to get its default. No configuration or
data migration is needed, but review Settings > Auto-Approve for ip_subnet rules with a sign or an odd prefix (they match nothing, so those agents go to the pending queue), and optionally list API tokens with expires_at=0 and enrollment tokens minted before the upgrade. During a rolling upgrade, replicas on the old and new build answer a
malformed value differently (200 versus 400).
Changed: a degraded execution-statistics read is a 503, not an empty 200 (#4644)
GET /api/v1/execution-statistics, /agents, /definitions, GET /api/v1/statistics and the
four MCP statistics tools used to answer a store failure (execution store not open, pool timeout,
failed query) with 200 and an empty list or an all-zero summary, which reads as “no
executions”. They now answer 503 with retry_after_ms: 2000 on REST and a retryable error with
retry_after_ms on MCP. A filter or confined scope that genuinely matches nothing is still 200
with an empty data array. Each degraded read also logs a warning on the server
(ExecutionTracker::try_get_<reader> degraded: tracker not open, pool exhausted or query failed).
What to do. Treat a 503 from these routes as retryable. A dashboard or script that read
data: [] as “nothing to report” during an outage will now see the error instead.
Changed: a saturated pre-flight response window is inconclusive (#4644)
Each pre-flight check reads its newest 50,000 responses for a run. On a very large cohort a device that had answered could be pushed out of that window and then read as “no response” while the runner kept re-dispatching. A device absent from a full window now shows “inconclusive: response cap reached”, stays Incomplete (never in Deploy’s go cohort) and the runner stops re-dispatching that run; the run still completes at its deadline. Re-run against a smaller group to resolve those devices.
Behaviour change: per-agent execution statistics are confined to the caller’s scope (#3526)
GET /api/v1/execution-statistics/agents and the MCP tool get_execution_statistics_by_agent used
to gate on a bare Execution:Read check. That check admits only a holder of a global grant, and it
returned one row per agent across every execution of every operator with no per-agent filter. A
management-group-scoped-only operator (no global grant) was refused with 403. Both now go through
the same admit-then-filter fleet-read gate as GET /api/v1/executions (ADR-0017): the visible-agent
set is applied in the query before the grouping and the row limit, and an empty visible set returns
no rows, never the fleet. This widens who is admitted and narrows what a confined caller sees:
nothing a caller could read before is now refused, with one exception: the gate fails closed, so
when the RBAC or management-group store is not open the route answers a retryable 503
(retry_after_ms 5000), even to a global admin, where the bare check answered 200.
- A group-scoped-only operator goes from
403to a200that carries only the rows of the agents it can see. A holder of a global grant sees the same rows as before. - A service-scoped API token on the REST route used to be refused (
403) by the bare gate. With RBAC enforcement on it is now admitted as a confined view: the token must clear theITServiceOwnerauthority ceiling (the seeded role holdsExecution:Read), and the rows are then intersected with the agents tagged for its service. A degraded tag store answers503. The MCP tool still denies service-scoped tokens, and widening that is a separate decision, not made here. - Confinement is effective only with RBAC enforcement on. With RBAC off (the shipped default)
the rollup is unfiltered for every authenticated caller other than a service-scoped token or an
engine principal (a service-scoped token is refused
403with RBAC off). Treat the confined view as available once you enable RBAC enforcement, not before. GET /api/v1/execution-statistics/definitionsand the fleet summary carry no agent identity and keep the plainExecution:Readgate (the summary’sactive_agentsis a fleet-wide count).GET /fragments/executionsis not changed by this entry.- A negative
sinceon/agentsand/definitionsis400(see the previous section).
During a rolling upgrade, replicas on the old and the new build admit and return different row sets to a confined caller.
Behaviour change: the software installs read validates its inputs and audits an out-of-scope target
Breaking for scripts that relied on a malformed value being tolerated. GET /api/v1/inventory/software and MCP query_installed_software now share one parser with the catalogue routes:
- A
name,agent_id,version,kind,ecosystem,sourceorcursorholding a NUL byte or invalid UTF-8, or one over its bound (name/version1024 bytes, the others 256,cursor2048), is400(-32602on MCP). A NUL byte used to reach the store and come back as a retryable503. limitmust be one whole integer:limit=12abcis400(it was read as12), and an emptylimit=now means the default (it was400).- MCP only: a wrongly typed argument is refused with
-32602instead of being ignored or converted.version: 2.0used to be dropped and read the whole fleet unfiltered,agent_id: 123was read as"123", and an unsignedlimitabove9223372036854775807wrapped.agent_id,name,versionandcursormust be strings andlimitan integer that fits an int64; an in-rangelimit(for example50000) still clamps as before, and unknown argument names are still ignored.
Audit trail addition. A denied audit row (inventory.software.query, or inventory.software.export for an export; mcp.query_installed_software on MCP) is now also written when a request names an agent_id outside the caller’s scope. The response is unchanged (under push-down: an empty page, devices_omitted 0). A SIEM rule that counted denied rows from these routes will see them for this case too.
Who is affected, and what to do. Scripts that sent a malformed or over-long value, or a non-string MCP argument: send well-formed values. Scripts that retried a 503 on a NUL byte: it is a 400 now and will not succeed on retry.
Behaviour change: a cut visualization, results fragment or Echo scan is reported (#4644)
The execution visualization route (GET /api/v1/executions/{id}/visualization), the dashboard
results fragment’s unfiltered read and the Echo retention-paused scan page now read through the same
SQL-side 50 MiB payload cap as the response exports, and say so when it cuts.
- The visualization payload gains
result_truncated_by_cap: trueandtruncation_cause(row_caporbyte_cap) on a cut read;rows_cappedis now exact (it also fired on a read of exactly 10,000 rows) and is the row cap only. The dashboard chart card shows a “Partial result” notice. - The results fragment shows a “truncated” notice in its result summary.
- The Echo scan page shows a “Partial result” banner and qualifies its header counts; with no rows in
the part that was read, its empty state says it cannot tell, instead of “still in progress” or “all
clear”.
GET /api/v1/echo/retention-pausedandlist_echo_retention_pausedgain aresult_truncated_by_capboolean (always present) and also setstore_degraded: trueon a cut scan, so a client that treatsstore_degradedas “the read failed” now sees it for a cut as well: testresult_truncated_by_capfirst to tell the two apart. - None of these cuts is counted on a metric (see Response export bounds), and the dashboard fragment and the scan page apply management-group scope after the fetch, so a confined caller can see the notice when every dropped row was outside their scope.
- The filtered results-fragment branch, the plain list routes and the internal reads are not bounded by this change.
Gateway upgrade note: stream-status notifications and startup checks (#4632)
Read this before restarting a gateway that includes #4632 (the version table above has the headline). A gateway restart is required and disconnects every agent the node holds; there is no hot code load. What changes for an operator:
- Breaking: a gateway whose configured
cluster_idcannot be represented as UTF-8 now refuses to start. The previous gateway started, then failed every notification. The name comes fromYUZU_GW_CLUSTER_ID(which replaces thesys.configsetting when set); a name with a surrogate, a code point above 16#10FFFF or bytes that are not UTF-8 stops the boot with a critical log line that names the source and never prints the value. A UTF-8 name in the variable on the shipped images (latin1 nodes) is not refused. Insys.configwrite a non-ASCII name as a string ("Z\x{FC}rich") or as<<"Z\x{FC}rich"/utf8>>: a plain<<"...">>binary holding code points above 255 is truncated to bytes by the Erlang term reader and is accepted only if those bytes happen to be valid UTF-8, so always use the/utf8form. Check the configured name before you restart; the rules and the runbook row are in thecluster_idparagraph of gateway.md. - Out-of-range or invalid values of the circuit breaker settings now log a WARN that names the key and take the default (before,
0for a reset timeout made the half open probe a hot loop, and0for the failure threshold opened the breaker on the first failure). The accepted ranges are 1 to 1000 forcircuit_breaker_failure_thresholdand 100 to 3600000 ms for both reset timeouts, and a reset timeout above the maximum now raises the maximum to match it with a WARN; see “Circuit breaker tuning” in gateway.md. - A failure inside the gateway’s own request encoder no longer opens the circuit breaker, where the previous gateway counted it as an upstream failure. A held notification that cannot be encoded no longer blocks the queue: as the half open probe it is evicted with a WARN, and with the breaker closed an ordinary worker loses it and logs
Failed to notify stream status. - A
Registerwith an agent id outside 1 to 256 bytes is now refused at the gateway edge withINVALID_ARGUMENT, without an upstream call. The server applied the same bound, so a conforming agent is not affected. Server statuses that mean the request itself was wrong (INVALID_ARGUMENT,NOT_FOUND,ALREADY_EXISTS,FAILED_PRECONDITION,OUT_OF_RANGE) no longer count as breaker failures, and a request over 4194304 bytes is refused in the gateway instead of being sent. The warnings a refused registration can trigger are limited to one line per second per kind, so during an outageUpstream Register failed: circuit_openis no longer one line per refusedRegister. During an outage read theCircuit breaker:lines and the(N similar line(s) suppressed since the last)suffix: acircuit_openrejection never reaches the call, soyuzu_gw_upstream_rpc_errors_totaldoes not count it. That counter now has acode="encode"series for a local encoder failure (alertYuzuGatewayUpstreamEncodeErrors), and the edge refusals are counted inyuzu_gw_register_refused_total(also visible as the suffix on theRegister refusedline). A refusedRegisterno longer reaches the server, so the server’syuzu_register_invalid_agent_id_total{source="gateway_proxy"}does not move for it; useyuzu_gw_register_refused_total{reason="agent_id_length"}to see these. The alertYuzuGatewayUpstreamEncodeErrorsfires at about tencode="encode"increases in 10 minutes (increase()is extrapolated), so one rejected request does not trip it. - The sanitised peer address is sent to the server in the notification’s informational
peer_addrand is not sent in the registration request.
Operator note: the software-inventory store migration (v7) is a hard cutover (#5172)
Schema v7 of the software-inventory store adds package_id and source columns and a row id to
installed_software. The row id is added with a table rewrite under an exclusive lock, so the first
start after upgrade takes time that grows with the table (about 10-20 s at 4M rows). A rolling upgrade is
not supported for this migration at this stage: above about 2 million rows stop every server replica,
then start one and let it finish; below that, start one replica first. The procedure, the row-count query
and the free-space guidance are in Installed-Software Inventory (Upgrading).
Behaviour change: yuzu-server.cfg no longer overrides a stored password (#5274); passwords are now changed in the product (#5342)
What changed. Login now reads a local account’s password from the PostgreSQL auth store (auth.users) only. Before this release the server loaded the password hashes in yuzu-server.cfg into memory at boot and checked them first, so a hash in the config file silently took precedence over the stored one on the server that loaded it. That is gone: the config file is now a seed — it provisions the first administrator into an empty auth schema, and is never consulted for an account the auth store already holds.
Who is affected — the password reverts. Anyone who reset a password by editing (or regenerating) yuzu-server.cfg and restarting — including re-running the Windows server installer with a new /ADMIN_PASS on an existing install. That no longer changes the password, and a password rotated that way reverts on upgrade: the password that works is the one in the auth store, which for most installs is the one the account was first created with, and the rotated one stops working. If you had “reset” the admin password through the config file, sign in with the original password (or recover as below). If you rotated it because the old one was exposed, reset it in the product immediately after upgrading — until you do, the exposed password is the live one. At boot the server logs a warning naming each config-file account whose hash differs from the stored one (“#5274 cfg is seed-only; stored credential wins”) and sets the gauge yuzu_auth_cfg_credentials_stale to their count, so you can see which accounts this affects.
The config file is still a bootstrap credential. It seeds the first administrator whenever the auth schema is empty, so rebuilding onto an empty database (a lost or recreated Postgres, a restore onto a fresh database) brings back whatever password the file holds. Keep it protected, and change the administrator password in the product after any such rebuild. The reverse also holds: a fresh install pointed at an already-populated database (for example a reinstall, or a new server joining an existing cluster) seeds nothing — the installer’s /ADMIN_PASS (or the first-run prompt) is written to the new config file but ignored by the server, and the accounts already in the database keep their passwords.
Windows server installer. On an upgrade (a yuzu-server.cfg already exists in %ProgramData%\Yuzu Server), the installer now refuses a non-empty /ADMIN_PASS= or /OPERATOR_PASS= with exit code 11 before it stops the service or changes anything, instead of writing a config file the server would ignore. The interactive wizard skips its account pages on an upgrade. Remove those parameters from your upgrade command lines (SCCM/Intune packages included); see the Windows server installer section of Server Administration (#5196 note).
Breaking — the second first-run account and the Windows installer’s /OPERATOR_USER//OPERATOR_PASS are removed (#5343). They were written to yuzu-server.cfg only and were never provisioned into the PostgreSQL auth store, so the account they created could never sign in. First-run setup now prompts for the administrator only, and a fresh Windows install that passes a non-empty /OPERATOR_USER= or /OPERATOR_PASS= is refused with exit code 7 (nothing changed; an upgrade given /OPERATOR_PASS= still gets exit code 11, and an upgrade given /OPERATOR_USER= alone gets exit code 7). Remove both parameters from fresh-install command lines and create further accounts in Settings → User Management (POST /api/settings/users). Upgrades are unaffected: an existing installation keeps its accounts, and a leftover yuzu-server.cfg entry that has no stored account is reported by name in a warning at boot — delete that line.
Rolling upgrades with more than one server. A server still on the previous release keeps checking yuzu-server.cfg first and caching hashes, so it can keep accepting an old password after a change or reset made on an upgraded server, and it has no password routes (404). Upgrade every server before using Change password / Reset password, or before relying on a password change to lock someone out.
Passwords over 1024 bytes stop working. The new maximum applies at sign-in too: /login answers a password longer than 1024 bytes as a wrong password, without hashing it, and the attempt counts toward account lockout. An account whose existing password is longer than that (only possible if it was set outside the product, for example a hand-generated config entry) can no longer sign in. An administrator resets it to a shorter one (Settings → User Management → Reset password, then Unlock if it locked); if it is the only administrator, use the direct-SQL fallback in the auth-db-recovery runbook.
What to use instead.
- Settings → User Management → Change password on your own row (
POST /api/v1/users/me/password), or Reset password on another local account’s row (POST /api/v1/users/{name}/password— a durable Administrator only: with RBAC off your own account must hold theadminrole, with RBAC on you need a userAdministratorgrant; a JIT elevation, an IdP-group admin role or a custom role is refused; plus MFA step-up). See Authentication and REST API. Non-admin users have no dashboard page for their own change yet and use the REST call (#5353). - If no administrator can sign in at all: auth-db-recovery runbook → Password reset (a direct-SQL fallback).
Also in this release. Passwords must be 12–1024 bytes (UTF-8 bytes, not characters). A change or reset is one database transaction: the new password, the sign-out of every session of the account on every server, the discarding of an unfinished MFA enrolment, the target’s lockout (reset only) and the audit row are saved together or not at all — a refusal or failure (503 audit_unavailable / store_unavailable) changes nothing. After changing your own password you are signed out and sign in again — no replacement session is issued. A reset leaves the target’s API tokens and any enrolled second factor in place (the response reports the token count). The configured break-glass account cannot be reset over the API. Neither action has an MCP tool, by design (#5357 tracks a temporary-password tool). /login now answers 503 with Retry-After — not 401, and with no lockout strike or auth.login_failed row — when a password cannot be verified because the auth store could not be read or the password changed while it was being checked; clients that treat every non-401 as fatal should retry a 503. New metrics: yuzu_auth_password_changes_total{kind,result}, yuzu_auth_cfg_credentials_stale and yuzu_auth_credential_changed_during_verify_total. New audit actions: user.password_change, user.password_reset.
No config or data migration is required.
Behaviour change: per-OS plugin kill switch rows (#5294)
The plugin kill switch can now be narrowed per agent OS (windows, linux, darwin). The
plugin_config_store schema moves to v2 (ALTER TABLE kill_switches ADD COLUMN os). Existing
plugin and action rows read back unchanged, so nothing flips on upgrade.
- Rollback. v2 is an additive column and an older server starts on it without complaint (there
is no schema-ahead guard). A server older than this change ignores every
<plugin>@<os>row: per-OS OFF stops being enforced and itsGETreportsenabled=true. - Mixed versions. An older replica applies
PUT .../kill-switch?os=<os>as the PLUGIN-LEVEL row.enabled=truecan therefore re-enable a plugin you stopped for the whole fleet, andenabled=falsestops the plugin on every OS. Set per-OS rows only after every replica runs this build, and never write one from an older binary. After a rollback per-OS OFF rows stay stored but are not enforced: if the plugin must stay stopped, set its plugin-level OFF row. Find your rows in theplugin_config.kill_switch.setaudit entries (target_id<plugin>[.<action>]@<os>) and confirm each withGET ...?os=<os>once every replica is back on this build. - Limits. An agent whose OS is unknown or empty is never withheld. The OS is what the agent
reports and is matched exactly (
windows,linux,darwin). The MCP pre-dispatch dry run cannot see the per-OS layer. Remote agents (connected through another replica) rely on the presence snapshot. - Integrations. MCP clients should key on
status, tolerate unknown fields and re-fetchtools/list.execute_instructionhas two newstatusvalues (kill_switched_os,os_gate_unreadable) and its output schema now requiresagents_kill_switched_osin every zero-reach branch. The kill-switch REST responses gain additiveosandsourcefields, and the/api/commandresponse gainswithheld_kill_switched_os.
Behaviour change: protective actions exempt from plugin enablement
What changed. Five plugin.action pairs are now protective: the quarantine plugin’s
quarantine.quarantine, quarantine.unquarantine, quarantine.status and quarantine.whitelist,
and the Guardian rule push __guard__.push_rules. No plugin-enablement gate refuses them any more:
not the per-plugin/action kill switch, not the per-OS kill switch (or its agent-presence read), not
the plugin-presence filter, and not a degraded plugin-config store. The set is closed. The four
quarantine actions are newly exempt; before this change only the containment gate exempted
them. Decision record: the 2026-10-12 update in ADR-3005.
A kill-switch row for one of these pairs is still accepted and displayed, but it is kept and
inert: it no longer stops dispatch. A plugin-level quarantine / __guard__ row never stops the
plugin’s protective actions either, and still gates any non-protective action the plugin declares
(none today: quarantine declares only the four, and __guard__ dispatches only push_rules). A
PUT on one is audited with protective_exempt=true in the plugin_config.kill_switch.set
detail and logs a warning. If you stopped Guardian rule delivery by switching
__guard__.push_rules off, that stop ends when you upgrade: rules deploy on the next Baseline
deploy or heartbeat reconcile.
Before upgrading.
- Find held rows. There is no list route. Search the audit log for
plugin_config.kill_switch.setentries whosetarget_idis__guard__.push_rules,quarantine.<action>or the plugin-level__guard__/quarantine, each with an optional@<os>suffix. Each write leaves two rows (attempted, thensuccessorfailure) and a laterenabled=truesupersedes an earlier OFF, so take the latestresult=successrow pertarget_id. Confirm each withGET /api/v1/plugin-config/<plugin>/kill-switchat every layer: the plugin row, the action row (?action=<action>) and each?os=windows|linux|darwinvariant, readingsourcein the response: it names the row that wins. - Re-establish the stop through the rule lever first, because the hold ends on upgrade. Disable
the Guards (
PUT /api/v1/guaranteed-state/rules/<id>with{"enabled": false}) or remove them. This is state-only: it disarms each Guard on the agent’s next Baseline deploy or heartbeat reconcile, so the latency is the reconcile cadence. It disarms the rule; it does not freeze a push already in flight. No lever stops delivery while leaving armed Guards armed: the old switch did that, but disabling or removing a rule disarms it on the next reconcile. It needsGuaranteedState:Write(disable) orGuaranteedState:Delete(remove).PluginConfig:Writealone no longer stops anything, and a service-scoped token is denied on rule writes. Aquarantine.*hold has no replacement switch: those actions are governed by their permission (Security:Executeon the catalogue rows) and any approval policy, not by enablement.
Also be aware of:
- Rollback. An older server has no exemption and honours stored OFF rows again, which can strand
a quarantine release or hold back a rule push. Before rolling back,
PUT enabled=trueon every protective-pair OFF row you found (each layer and OS), then roll back. - Mixed versions. During a rolling upgrade an older replica still honours a held row (or a degraded store or
plugin-inventory gap) and refuses a protective dispatch while an upgraded replica delivers it, so the outcome depends on which replica serves the
heartbeat or the reconcile. Lifting a hold arms rules fleet-wide on the next reconcile, including
enforcerules, so expect a burst of arming rather than a gradual one. - Agents without the quarantine plugin. The server no longer withholds a protective dispatch
because the agent’s reported plugin inventory lacks the plugin.
dispatch_confirmedmeans the plugin registry accepted the frame; it is never proof of isolation. A device whose agent lacks the plugin answers its own “plugin not found”, andYuzuQuarantineEndpointUnconfirmedcan fire for it. Verify containment withquarantine.status, and upgrade agents before you rely on quarantine. - Integrations. The kill-switch
GETandPUTresponses are unchanged:enabled=falsefor a protective pair is a stored row, not the dispatch decision. Theos_gate_unreadable,plugin_not_foundandkill_switched_oszero-reach causes are never produced for a protective action.
Behaviour change: Guardian full_sync agent log lines and in-place rule: rows (#4415)
Three changes are live on every agent on upgrade, on both detection paths. The skip of healthy,
unchanged rules that motivated them runs only on the Spark path, which production does not enable
(prefer_spark is off); see the Spark flip gate AC-18.
- Log line renamed.
Guardian: full_sync cleared N prior rule(s)is nowGuardian: full_sync removed N prior rule(s) absent from the push, and it is not logged when no stored row is removed. Log rules or dashboards keyed on the old string go silent. Do not use the old line as a “a full push happened” marker. - New line on every full push.
Guardian: full_sync preserved N rule(s) (skipped re-arm) ids=[...] more=Mis logged by every agent on every full push that reaches the end of its rule loop. Use it as the full-push marker instead. Legacy-path agents always logpreserved 0. - Rows updated in place. The agent’s stored
rule:rows are no longer cleared and reinserted on a full push: only the rows for rules the push omits are deleted, and a row whose bytes are unchanged is not rewritten. A crash after the omitted rows are removed and before the carried rules are rewritten leaves the previous rows for carried rules until the next push (a restart then re-arms those, including a rule the push had disabled or changed). - Rollback. The row format is unchanged. An older agent reads the rows without complaint, and its own full_sync clear-and-reinsert restores the old write pattern. A newer agent rewrites any row whose bytes differ. There is no migration and a downgrade is safe.
⚠️ Docker Compose: copy /etc/yuzu/certs out of the server container before you recreate it (0.14.0 → 0.14.1, #5370)
Who this affects. 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).
Why. The server keeps its internal CA, its default certificates and the secrets key-encryption key (KEK, secrets-kek-v1.key) in /etc/yuzu/certs. Since 0.14.0 the KEK is also registered in Postgres, whose data is on a volume. In the affected composes the key files sit in the container’s own writable layer instead, so anything that replaces the container deletes them: an image upgrade, docker compose down then up, up --force-recreate, or an up after a change to the server’s settings. Bumping only the image tag (or YUZU_VERSION) under the old compose file also triggers it. The next start then fails, on every restart, with:
SecretCodec::init() failed for the auth store bootstrap — kek_unresolvable: registered KEK 'secrets-kek-v1' does not resolve through the KekProvider
The 0.14.1 composes add a server-certs volume on /etc/yuzu/certs. Switching to them still recreates the container, so copy the files out first. Do this before anything else touches the stack.
How to run the blocks below:
- Run them in one terminal session, from the directory that holds your compose file (
deploy/dockerfor the build-from-source stack). Later blocks use the$OUTvariable that block 2 sets, so do not close the shell between them. - If your compose file is not named
docker-compose.yml, runexport COMPOSE_FILE=<file>first. Fordocker-compose.uat.yml, also exportYUZU_VERSION=0.14.1and every variable the file requires (for exampleYUZU_GW_COOKIE) before block 1. - Paste one block at a time. The blocks contain no comments, so they paste the same way into bash and into zsh (including macOS zsh, which treats a pasted
#line as a command). - Block 2 ends with a line
OK: <path>. Go on only if you see that line. Anything else, including no output at all, means stop.
Block 1 — steps 0 and 1. Step 0 checks that there is exactly one server container and that the keys are in it: the listing must show secrets-kek-v1.key (mode -rw-------) and default-ca.key. If it does not, do not go on; go to “If the server is failing with kek_unresolvable” below. Step 1 stops the server without removing it, because its container still holds the keys.
docker compose ps -a server
docker compose exec -T server ls -ln /etc/yuzu/certs </dev/null
docker compose stop server
Block 2 — step 2: copy the directory out of the stopped container. The copy goes to $HOME/yuzu-keys-backup, outside any checkout, owner-only (0700 directory, 0600 file), and never over an existing file. docker cp ... - writes a tar stream, which keeps each file’s owner and mode. The function keeps the archive only if docker cp succeeds, the whole archive reads back, it holds the CA certificate and key, and every KEK file in it is exactly 32 bytes (the size of a KEK). Otherwise it prints STOP and removes the partial file. It returns instead of exiting, so a failure leaves your shell open. It prints the CA fingerprint; note it. The archive holds the CA private key and the KEK: keep it as safe as the server itself.
BK="$HOME/yuzu-keys-backup"
yuzu_copy_out() {
local cid out k n
mkdir -p "$BK" && chmod 700 "$BK" || { echo "STOP: cannot create $BK"; return 1; }
cid=$(docker compose ps -aq server)
[ -n "$cid" ] && [ "$(printf '%s\n' "$cid" | wc -l | tr -d ' ')" = 1 ] \
|| { echo "STOP: expected exactly one server container, found: ${cid:-none}"; return 1; }
out="$BK/yuzu-certs-$(date -u +%Y%m%dT%H%M%SZ).tar"
( umask 077; set -o noclobber; docker cp "$cid":/etc/yuzu/certs - > "$out.partial" ) \
|| { echo "STOP: copy failed; do not continue"; rm -f "$out.partial"; return 1; }
tar -tf "$out.partial" > /dev/null 2>&1 \
&& tar -tf "$out.partial" | grep -qx 'certs/secrets-kek-v1.key' \
&& tar -tf "$out.partial" | grep -qx 'certs/default-ca.key' \
&& tar -tf "$out.partial" | grep -qx 'certs/default-ca.pem' \
|| { echo "STOP: the archive is incomplete; do not continue"; rm -f "$out.partial"; return 1; }
for k in $(tar -tf "$out.partial" | grep -x 'certs/secrets-kek-v[0-9]*\.key'); do
n=$(tar -xOf "$out.partial" "$k" | wc -c | tr -d ' ')
[ "$n" = 32 ] || { echo "STOP: $k is $n bytes, not 32; do not continue"; rm -f "$out.partial"; return 1; }
done
mv -n "$out.partial" "$out" && [ ! -e "$out.partial" ] \
|| { echo "STOP: could not move the archive into place"; return 1; }
tar -xOf "$out" certs/default-ca.pem | openssl x509 -noout -fingerprint -sha256
OUT="$out"; echo "OK: $OUT"
}
yuzu_copy_out
If you have ever rotated the KEK, check that every version the database has registered has a file in the archive. Each number that docker compose exec -T postgres psql -U yuzu -d yuzu -tAc 'select kek_version from secrets.kek_meta' </dev/null prints needs a certs/secrets-kek-v<N>.key line in tar -tf "$OUT".
Step 3 — switch to the 0.14.1 compose file. In a checkout, git pull. Otherwise download the file again into the SAME directory: a different directory is a different compose project, with its own empty volumes. For a compose of your own, add the volume yourself (see below). Regenerating a Compose Wizard compose is the same switch.
Block 3 — steps 4 to 6: fill the new volume and recreate the server. The function first checks the archive again, and refuses to touch anything unless $OUT names a non-empty archive that reads back in full and holds a 32-byte secrets-kek-v1.key. Step 4 then copies the archive into the server-certs volume. docker compose run creates the volume and mounts it in a one-off container, so the stopped server container is left alone. Step 5 checks that the server user can read a 32-byte KEK in the volume: the files must be owned by the uid the server runs as, 999 for yuzu-server and 1000 for yuzu-server-chisel. Only then does step 6 recreate the server. Each step that fails prints STOP and nothing after it runs. If you opened a new shell after block 2, replace "$OUT" with the path block 2 printed. For the build-from-source stack, add --build after "$OUT"; it is passed on to docker compose up -d.
yuzu_fill_and_recreate() {
local p="$1" n
shift
[ -n "$p" ] && [ -s "$p" ] && tar -tf "$p" > /dev/null 2>&1 \
|| { echo "STOP: no complete archive at ${p:-(unset)}; nothing was changed"; return 1; }
n=$(tar -xOf "$p" certs/secrets-kek-v1.key 2> /dev/null | wc -c | tr -d ' ')
[ "$n" = 32 ] || { echo "STOP: the KEK in $p is ${n:-0} bytes, not 32; nothing was changed"; return 1; }
docker compose run --rm --no-deps -T --user 0 --entrypoint tar server \
-x -p --same-owner --numeric-owner -C /etc/yuzu/certs --strip-components=1 -f - < "$p" \
|| { echo "STOP: step 4 could not fill the volume; the server was not recreated"; return 1; }
n=$(docker compose run --rm --no-deps -T --entrypoint sh server -c \
'test -r /etc/yuzu/certs/secrets-kek-v1.key && test -w /etc/yuzu/certs && wc -c < /etc/yuzu/certs/secrets-kek-v1.key' \
< /dev/null | tr -d ' \r')
[ "$n" = 32 ] || { echo "STOP: step 5 found no 32-byte KEK the server user can read; the server was not recreated"; return 1; }
docker compose up -d "$@" || { echo "STOP: step 6 failed"; return 1; }
echo "OK: the server was recreated on the copied keys"
}
yuzu_fill_and_recreate "$OUT"
The chiselled yuzu-server-chisel image has no echo, so on it the function stops at step 4 and recreates nothing (the docker compose run there has already created the volume). Do steps 4 and 5 in a throwaway container instead, then step 6 by hand. <project>_server-certs is the volume’s full name, which docker volume ls prints. The listing must show the files owned by uid 1000, with secrets-kek-v1.key 32 bytes:
docker run --rm -i --network none -v <project>_server-certs:/x/certs alpine tar -x -C /x -f - < "${OUT:?run block 2 first}"
docker run --rm --network none -v <project>_server-certs:/c:ro alpine ls -ln /c
Only if that listing shows secrets-kek-v1.key at 32 bytes, owned by uid 1000, recreate the server:
docker compose up -d
Run step 2 once. After step 6 the server container is a new one. If you re-run block 2 then, against a server that came back on the copied keys it only writes a second archive of the same keys; against one that came back without them it prints STOP. Either way the first archive is untouched.
Then confirm the server came back on the same keys. docker compose logs server | grep secret_codec shows verified 1 registered KEK version(s) (the count is of KEK versions, and the line repeats once per store that opens the codec), and this fingerprint matches the one block 2 printed:
docker compose exec -T server cat /etc/yuzu/certs/default-ca.pem </dev/null | openssl x509 -noout -fingerprint -sha256
Keep the archive as the keys half of a paired backup (the KEK pairing rule) or shred -u it; never commit it.
For a compose of your own, the change is one volume on the server service:
services:
server:
volumes:
- server-certs:/etc/yuzu/certs
volumes:
server-certs:
A new named volume takes its owner from the image’s /etc/yuzu/certs, which the yuzu-server image creates owned by yuzu, so a fresh install needs no chown. The chiselled yuzu-server-chisel image creates that directory only from 0.14.1; on 0.14.0 and earlier chiselled images a new volume is owned by root and the server cannot write to it. The demo stack (docker-compose.demo.yml) runs a one-shot server-certs-init service that gives the volume to the server user, so it also works with older chiselled images.
Rolling back. To return to 0.14.0, change only the image tag/YUZU_VERSION; keep the 0.14.1 compose with its server-certs line. Reverting the compose file strands the keys on the undeclared volume, and down -v under the old file deletes Postgres while leaving the keys.
If the server is failing with kek_unresolvable. Work out which failure it is before you touch anything; several of them leave the keys intact, and only the last one means they are gone. These checks change nothing:
docker compose ps server
docker compose logs server | grep -m1 -E 'kek_unresolvable|provider_failure|kek_orphaned'
docker compose config | grep /etc/yuzu/certs
docker volume ls --filter name=server-certs
docker compose run --rm --no-deps -T --user 0 --entrypoint ls server -ln /etc/yuzu/certs </dev/null
A STATUS of Restarting means the server refuses to start. Then match the output against these cases, in order. They run the listing as root (--user 0), so a wrong owner shows as such instead of Permission denied.
-
(a) The compose lost the volume line (a rollback, or an old file):
docker volume lslists<project>_server-certsfor your project, butdocker compose config | grep /etc/yuzu/certsprints nothing. The filter matches any project’s volume, so check the<project>_prefix (the project name is the compose directory’s name unless you set one). The listing is empty in this case too, because the old file mounts nothing there; that does not mean the keys are gone. Restore the line and rundocker compose up -d. Neverdown -v. -
(b) The files are there but owned by the wrong uid. Give them to the server’s uid (999 for
yuzu-server, 1000 foryuzu-server-chisel), then rundocker compose up -d:docker compose run --rm --no-deps -T --user 0 --entrypoint chown server -R 999:999 /etc/yuzu/certs </dev/null -
(c)
provider_failure: the directory is not writable (a chiselled 0.14.0-or-older image’s root-owned volume, or an:romount). Chown it to 1000:1000 or make it writable. -
(d) The listing is empty: look for the keys on a dangling volume. If the server’s certs mount was an anonymous volume (
- /etc/yuzu/certswith no name in front, as in Compose Wizard output from before 0.14.1 with named volumes off),docker compose downthenupleft the keys on the old, now unused volume and gave the new container an empty one. Bothdocker compose downanddown -vleave such a volume in place, anddocker volume prunedeletes it, so do not prune. List the dangling volumes that hold KEK files:if docker image inspect alpine > /dev/null 2>&1 || docker pull alpine > /dev/null; then for v in $(docker volume ls -qf dangling=true); do docker run --rm --network none -v "$v":/c:ro alpine ls /c 2> /dev/null | grep -q '^secrets-kek-v' && echo "keys on volume: $v" done echo "search finished" else echo "STOP: the alpine image is not available, so the search did not run. Load it (docker load) and run this again." fiThe search needs the
alpineimage; on a host without internet access,docker loadit first. Go on only aftersearch finished. Eachkeys on volume:line names a volume that holds keys; if there is none, case (e) applies. If there is more than one, they come from different installs: comparedocker run --rm --network none -v <volume>:/c:ro alpine cat /c/default-ca.pem | openssl x509 -noout -fingerprint -sha256with the CA your agents trust. Switch to the 0.14.1 compose (step 3), copy the keys from that volume into theserver-certsvolume, then rundocker compose up -d:docker compose run --rm --no-deps -T --user 0 -v <volume>:/from:ro --entrypoint cp server -a /from/. /etc/yuzu/certs/ </dev/null -
(e) The listing is empty and none of the above applies: the key files are gone. Recovery is below.
The chiselled image has no ls, chown, cp or echo. For it, run the same commands in a throwaway container on the volume, for example docker run --rm --network none -v <project>_server-certs:/c:ro alpine ls -ln /c (docker volume ls prints the full name).
Only case (e) means the key files are gone. Nothing inside the server can rebuild them.
-
You have a copy of the directory, from a backup taken with the database (see the KEK pairing rule) or an archive like the one in block 2. Switch to the 0.14.1 compose and fill the volume. From a
docker cparchive, use block 3 as written. From a copy that has passed through your own filesystem (an unpacked backup, say), the files now belong to your user rather than to the server, so hand them back to it:tar -C <directory-containing-certs> -cf - certs | \ docker compose run --rm --no-deps -T --user 0 --entrypoint bash server \ -c 'tar -x -C /etc/yuzu/certs --strip-components=1 -f - && chown -R yuzu:yuzu /etc/yuzu/certs'Then run
docker compose up -d. Restore the KEK and thedefault-*files together. If the KEK comes back but the CA files do not, the server refuses to mint a new CA over the one recorded in Postgres. Restoredefault-*.{pem,key}too, or follow the deliberate clean re-root indocs/pki-architecture.md“Operator runbook”. -
You have no copy. Work through these in order.
-
Dump the database first. It holds your audit log and configuration; the server cannot export it for you while it refuses to start. Keep the dump and query it offline, but never restore it into the new install (it registers the lost KEK).
BK="$HOME/yuzu-keys-backup"; mkdir -p "$BK" && chmod 700 "$BK" ( umask 077; docker compose exec -T postgres pg_dump -U yuzu --format=custom yuzu < /dev/null \ > "$BK/yuzu-final-$(date -u +%Y%m%dT%H%M%SZ).dump" ) && echo "dump written to $BK"With an external Postgres, run
pg_dumpagainst it with the server’s DSN instead. -
Do not delete rows from
secrets.kek_metato force a start. The server then mints a new key under the same version number, every secret sealed under the lost key (webhook secrets, plugin and runtime config secrets, offload credentials) becomes permanently undecryptable with no error at boot, and a key file you later find cannot be used (#5421). -
This database cannot be brought back by any supported means. Every start checks each registered KEK and refuses. The one-shot modes
--mfa-resetand--break-glass-armrun after that check, so they stop with the samekek_unresolvableerror. What is gone: the CA private key, so every agent certificate it issued no longer chains and every agent must enroll again; and every secret sealed under the KEK, including TOTP enrolments, webhook signing secrets and the other secret columns listed indocs/user-manual/server-admin.md“Key management (secrets KEK)”. Passwords and API tokens are hashed, not sealed, but they live in the same database. -
Start a new install. With the bundled Postgres,
docker compose down -vdeletes the Postgres volume along with the others.down -vdoes not reset an external Postgres: that database still registers the lost KEK, so a new install against it fails the same way. Give the new install a new, empty database. Then provision the admin account again, re-enroll your agents, and re-create your configuration.
-
Behaviour change: the executions list fragment and MCP summarize_working_set (execution kind) use the fleet-read gate (#3526, #4753)
GET /fragments/executions (the dashboard executions list) and the execution kind of the MCP tool
summarize_working_set moved from a plain Execution:Read permission check onto the
management-group-aware fleet-read gate that GET /api/v1/executions already used. This is
hardening plus an admission change, not a fix for a leak: the old gate only admitted callers
holding a global grant, and those callers were never filtered. An author-run reachability probe
(not independently adjudicated; the JIT-elevated case was checked by code reading only) found nothing
served through the old gate that the unconfined GET /api/v1/executions would not serve the same
principal, with RBAC on or off (with RBAC off every non-service, non-engine caller whose tier allows it is unconfined by design; an engine principal is refused with 403)
(docs/security-reviews/3526-confinement-reachability-probe-2026-10-05.md). There is no
admission change for a caller with a global Execution:Read grant, and nothing changes with RBAC
off for such a caller. What changes for every caller is the degrade and audit behaviour below,
plus one new prerequisite.
- RBAC off (the shipped default). With RBAC off, both surfaces are unconfined for every authenticated non-service, non-engine caller whose tier allows it (an engine principal is refused with 403 with RBAC off),
before and after the move to the fleet-read gate; a service-scoped token is refused
(
403on the REST and MCP twins, the permission note described under “Degraded store, fragment” on the fragment). Confinement on these two surfaces takes effect only with RBAC enforcement on. - Admission prerequisite (applies to every caller on these two surfaces). The fleet-read gate
needs both the authorization store and the management-group store open. When the RBAC store is
not open, every caller except a JIT-elevated non-service session (which returns before any store
check) gets a retryable
503(retry_after_ms5000) from the gate; when the management-group store is not open, every caller except an elevated session and an engine principal does (an engine principal is resolved from the RBAC store alone, and gets the503only when that store is not open). A global-grant administrator is included in both cases; the old plain gate never needed the management-group store. The REST and MCP twins show the503itself. On the dashboard fragment the gate still audits the refusal, but the response is replaced with an HTTP200note (data-degraded="gate", see “Degraded store, fragment”), because the dashboard drops4xx/5xxbodies;GET /api/v1/executionskeeps its real503. - Group-scoped-only operators (an
Execution:Readgrant held only through a management group) previously got403from the fragment and now get a confined view: only executions that touched one of their agents or that they dispatched, with the counters and the error preview recomputed from their visible agents only.summarize_working_setis different: its first gate is still a plainInfrastructure:Readcheck, so a group-scoped-only operator still gets403there. Only a caller who holds a GLOBALInfrastructure:Readplus a group-scopedExecution:Readis newly admitted to the confined execution view, and its narrative carries the same projected counts. - Service-scoped API tokens get a confined view from
GET /fragments/executionswhere they got403, but only when RBAC enforcement is ON, theITServiceOwnerrole holdsExecution:Read(the seeded default; see the next item) and the tag store is reachable. With RBAC enforcement off the gate still refuses it (“service-scoped tokens require RBAC to be enabled”), and a missing or degraded tag store is a retryable503(retry_after_ms5000) on the REST twin; the fragment shows the matching note at HTTP200. In a normal deployment the tag store always exists (the server refuses to boot without it). On this fragment a service-scoped token sees only executions that touched an in-scope agent (see “Owner disjunct” below); its own just-dispatched execution stays invisible until an in-scope agent replies.summarize_working_setis unchanged for service-scoped tokens: it stays denied. ITServiceOwnerceiling (applied by PR #5546, not by the executions-list move). A service-scoped token is subject to theITServiceOwnerauthority ceiling on the fleet-read gate. The executions-list move relies on that ceiling but does not add it. Its Breaking effect (403fromGET /api/v1/enrollment/pending-agentsfor a service-scoped token), the retryable503for a failed ceiling read, the remediation and the audit search are in “Behaviour change: service-scoped tokens, theITServiceOwnerceiling on the fleet-read gate, andGET /api/v1/upload-grants(#3526)” below.- Degraded store, fragment. Every refusal or failure on
GET /fragments/executionsnow renders an operator-visible note at HTTP200, because the dashboard drops4xx/5xxbodies and a refusal used to leave the panel on “Loading…” forever. There are three degrade kinds, set in thedata-degradedattribute on<div class="empty-state">:tracker(the execution query or the agent-status read failed),unavailable(the fleet-read gate or the execution tracker is not wired, or a confined read has an empty principal: an administrator or a bug) andgate(the authorization gate refused with503: the RBAC store, the management-group store or the tag store is not open or unavailable, or theITServiceOwnerceiling read is degraded; the text is “Executions unavailable (the authorization service could not be reached). Retry shortly.”). A gate403(noExecution:Read, RBAC off for a service-scoped token, the ceiling denial, an engine principal without a grant) renders<div class="empty-state" data-denied="true">with “You do not have permission to view executions.” and no reason text. A401for an unauthenticated request passes through unchanged. The gate still writes its audit row first, and noRetry-Afterheader is sent with the note. Detect the notes with thedata-degradedanddata-deniedattributes; none of them is the “No executions yet” text. Monitoring blind spot:/fragments/executionsno longer answers403or503for these cases, so an HTTP-status monitor, a reverse-proxy access log or a synthetic check that treats200as healthy will not see them. The signals are the gate’s audit rows (auth.fleet_read_requiredwithresult=denied), the panel text, and, for the ceiling case,yuzu_server_rbac_read_degrade_total.GET /api/v1/executionsis unchanged and keeps its real403/503. - Approvals fragment and Executions filters answer 200 with a note.
GET /fragments/approvalsnow answers a403as200withdata-denied="true", a store failure as200withdata-degraded="store"and an unknownstatusas200withdata-invalid="status"; the executions fragment answers a malformeddefinition_id(default view too) or an unknownstatuswith200anddata-invalid. Monitoring blind spot: an HTTP-status monitor of these fragments sees200for those cases; detect the attributes instead. - Runbook, degrade note on the Executions panel.
data-degraded="tracker"means the execution tracker or its agent-status read failed: checkExecutionTrackerwarnings in the server log and PostgreSQL availability.data-degraded="unavailable"is a misconfiguration or bug branch (the fleet-read gate or the execution tracker is not wired, or a confined read has an empty principal), not a storage fault: it needs an administrator or a bug report, and a Postgres check will not clear it.data-degraded="gate"means the authorization gate could not decide. Look for audit rowsauth.fleet_read_requiredwithresult=denied(detailfleet read blocked: management-group store unavailable, or the ceiling detail from theITServiceOwnersection below) andyuzu_server_rbac_read_degrade_total(it moves ONLY for theITServiceOwnerceiling-read case: the management-group-store-unavailable refusal writes an audit row but does not move it). The server keeps no per-request access log. None of the notes clears on its own: the panel is loaded once, when the Instructions page reveals it (hx-trigger="revealed", no polling), so after the cause is fixed reload the Instructions page (or reopen the Execution History section). - Degraded store, MCP.
summarize_working_setreturns an error carryingretry_after_mswhere it used to say the execution “was not found”. - Empty confined page. A confined caller who sees zero executions gets “No executions visible in
your scope.” instead of “No executions yet.”, because out-of-scope executions may exist. Until an
in-scope agent replies, a confined caller sees no trace of an execution they did not dispatch,
because per-agent status rows are written as responses arrive; the panel can therefore show this
text while an execution is in flight. MCP
list_executionsshows a confined caller only the executions they dispatched, not the in-scope executions of others (seedocs/user-manual/mcp.md). The per-row status badge is still the execution’s fleet-wide status while the counters are projected to the caller’s agents (the same as the REST twin, SSE and the MCP detail view). - Owner disjunct. Ordinary callers keep it: a principal’s own dispatches are shown even when
none of their agents replied, with the counters still projected. Service-scoped tokens do NOT get
it on
GET /fragments/executions. Known limit (#5557): a service-scoped token’s session username is the account that minted it. OnGET /api/v1/executions,/{id},/children, MCPget_execution_status, MCPlist_executions, legacy/api/executions*, the detail fragment, the SSE channel/sse/executions/{id}andGET /api/v1/events, a service-scoped token is therefore also shown executions its MINTER dispatched, even outside the service scope (counters projected, but id, definition, status and timing visible). Mint service tokens from an account that dispatches only inside the service scope. Executions the minting account has already dispatched stay visible to its service tokens on those surfaces, so a token minted from an account with unscoped history is exposed to that history. - Audit and SIEM. For
kind=execution, a CONFINED caller whose id is absent or outside scope now producesaction=mcp.summarize_working_set,result=denied, detailnot found or outside caller's fleet-read scope: <id>(the id is neutralised fork=vand CR/LF forgery and capped at 128 bytes; thesuccessrow’s id gets the same treatment for EVERYsummarize_working_setkind, not onlyexecution). Only callers newly admitted by the move to the fleet-read gate can produce that row (the plain gate admitted only unconfined, global-grant callers, and confined callers got403before), so an existing rule keyed onresult=successloses nothing for the callers it already saw; a rule keyed onresult=deniedfor this action may now see the new callers. An UNCONFINED caller’s absent id staysresult=success(no denial occurred). The scope-collapsedeniedrow (detailnot found or outside caller's fleet-read scope: <id>) is written only for a CONFINED caller onkind=execution, so it is never written with RBAC off. Otherdeniedrows on this action (tier refusal, permission-gate refusal, service-scoped default-deny) are separate and can occur regardless of RBAC state. The same neutralise-and-cap (128 bytes) now applies to the id in theget_execution_statusdeniedrows and theget_agent_detailsdeniedandfailurerows. A NUL byte in asummarize_working_setkind=executionid or aget_execution_statusid is rejected with an invalid-params error before any lookup. The degraded-store errors ofget_execution_status(agent-status read) andlist_executionsnow carryretry_after_ms, likesummarize_working_set. Thedeniedrow cannot tell a typo from an out-of-scope probe; that is intentional (no existence oracle). The narrative for an absent id is a success-shaped result, unlikeget_execution_status, which returns an error for the same input. - Still unscoped.
summarize_working_setwithkind=fleetorkind=result_setreturns the whole-registry agent count (tracked in #4753, whose checklist, includingkind=fleetandget_fleet_posture_fast, stays open; the executions-list move covers onlykind=execution). The same whole-registry branch is also reached bykind=executionorkind=agentwith an EMPTY id, or when the execution tracker is unavailable (not separately tracked). (GET /api/v1/execution-statistics/agentswas listed here and is now confined; see “Behaviour change: per-agent execution statistics are confined to the caller’s scope (#3526)”.)
Rollback. Redeploy the previous binary. The plain Execution:Read gate returns, so
group-scoped-only operators go back to 403 on the fragment. The visible_agents filter is a
read-time parameter with no stored state, so there is nothing to migrate or clean up. Audit rows
already written with result=denied for mcp.summarize_working_set remain in the audit store.
Behaviour change: service-scoped tokens, the ITServiceOwner ceiling on the fleet-read gate, and GET /api/v1/upload-grants (#3526)
Two chokepoints let a service-scoped API token reach more than the ITServiceOwner role allows (found
in a review of the fleet-read gate, #3526). Both now refuse it. This is a tightening for service-scoped tokens only;
non-service callers, elevated sessions and engine principals are unaffected.
Breaking for service-scoped tokens, 1: GET /api/v1/enrollment/pending-agents now answers 403.
The management-group-aware fleet-read gate (require_fleet_read, ADR-0017) did not apply the
ITServiceOwner authority ceiling that require_permission applies to a service-scoped token. It now
does, through one shared helper (authz::service_ceiling_check): the token is refused (403,
“service-scoped token does not grant permission field, audit auth.fleet_read_required / denied) unless that role itself holds
the pair, whatever its minter holds. ITServiceOwner does not hold Enrollment:Read, so a
service-scoped token that used to receive a pending-agent view narrowed to its tagged agents now gets
the 403. Remediation: Enrollment:Read is intentionally NOT granted to ITServiceOwner; use an
Administrator-minted non-service token for that route. The cost is that this replaces a confined
credential with an Administrator-grade one, and there is no narrower option today: Enrollment:Read is
Administrator-only by default and custom-role authoring is not reachable through REST, MCP or the
dashboard yet.
Breaking for service-scoped tokens, 2: GET /api/v1/upload-grants now answers 403. The route’s
only gate evaluated the minter’s username, so a service-scoped token inherited its minter’s
UploadGrant:Read view (every grant, for a minter holding a global grant). It is now refused before any
grant is read (403, “service-scoped tokens may not list upload grants”, no permission field, audit
upload_grant.list.access_denied). The MCP tool list_upload_grants already refused a service-scoped
token. Remediation: list grants with a non-service token. Non-service sessions are unchanged.
The ceiling applies wherever a seeded ITServiceOwner permission is absent. With the seeded
defaults ITServiceOwner holds every other pair the fleet-read routes pass, so nothing else changes
for Execution, Response, Inventory, Infrastructure, Policy, GuaranteedState and
Workflow:Read. No REST, MCP or CLI surface removes a seeded ITServiceOwner permission today, and a
direct database DELETE of the row is re-seeded at the next boot unless the pair is recorded in
revoked_seed_defaults, so a refusal on one of those pairs does not arise in a default deployment; the
rule is the safeguard for a pair removed through RbacStore::remove_permission (which records it) and for
any future surface that narrows the role.
Failure behaviour. A FAILED read of the ITServiceOwner role’s permissions is a retryable 503
(retry_after_ms 5000, an audit row with detail “RBAC read degraded resolving the ITServiceOwner
ceiling”, and yuzu_server_rbac_read_degrade_total increments) rather than 403, because an outage
is not a missing grant; it still fails closed (the dashboard executions fragment shows it as the HTTP 200 data-degraded="gate" note). This holds on the fleet-read gate and equally on
require_scoped_permission, which answered 403 for this failure before this change; only a
definitive deny is 403. require_permission is different: a service-scoped token is refused there by
the default-deny allow-list whatever the ceiling read returns, so a retry could not succeed, and a
failed read answers the same default-deny 403 as a healthy one (no retry_after_ms, no
yuzu_server_rbac_read_degrade_total increment); only its audit row differs, ending with
”; ceiling read degraded”. The ceiling read goes through the RBAC authz circuit breaker with a 250 ms acquire budget, so a degraded
store fails quickly and, once the breaker is open, requests are answered without touching the pool; an
open breaker is counted under the pool_acquire_timeout reason. The
breaker bounds how many requests wait, not how long an already admitted read holds its connection:
such a read can still wait up to the pool’s lock_timeout (10 s default) or statement_timeout
(30 s default). On a dark network path (no reply at all) the wait is bounded instead by the pool’s
tcp_user_timeout (10 s), which is confirmed on Linux, unconfirmed on Windows and a no-op on macOS, and
until two failures have returned, up to the pool size (16 by default, --postgres-pool-size) of these
reads can each hold a connection for that long. The breaker is the one operator permission checks use,
so ceiling-read failures can open it and an open breaker denies operators’ cache-miss checks too (fail
closed). The breaker counts consecutive failures and any successful authz read, a ceiling read
included, resets the count, so a partial fault that lets the role_permissions read succeed while
other authz reads fail can delay the breaker opening for operators’ cache-miss checks.
New alert: YuzuRbacBreakerOpen (critical). docs/prometheus/yuzu-alerts.yml now ships a per-replica
alert that fires when yuzu_server_rbac_breaker_open has stayed at 1 on a replica for 5 minutes. It
means authorization reads on that replica keep failing. While the breaker is open the replica refuses
(fail closed) the operator permission checks that miss its permission cache and sheds service-token
ceiling reads; cached decisions keep being served for at most about 5 seconds.
A breaker that closes again within 5 minutes does not page. Load the updated rule file if you maintain
your own copy of the shipped alerts.
List-read A4 body. The A4 body of a service-scoped token’s 403 from the list-read gate no longer carries a permission field; clients should not read it.
The sibling gates changed their budget. require_permission and require_scoped_permission now
read the ceiling with the 250 ms authz acquire budget behind that shared breaker. Before, they acquired
with the 2000 ms kReadTimeout and were not breaker-gated, but the pool clamps a bounded acquire to
500 ms when it is already saturated at entry (PgPool saturated_fast_fail), so on a saturated pool the
wait was up to about 500 ms, not 2 s (a pool that saturated only after the call entered it was bounded
by the full 2 s). The change lowers that bound to 250 ms and, once the breaker is open, answers the
request without a pool touch.
Rollback. Downgrading the binary restores the previous behaviour for all of the above. The change adds no schema and persists no state.
To find affected callers, search the audit log for action=auth.fleet_read_required with
result=denied and a detail containing “ITServiceOwner permission” (definitive deny, 403) versus
“RBAC read degraded” (store fault, 503), for the sibling gate action=auth.scoped_permission_required
with the same two details (“lacks ITServiceOwner permission” for the 403, “RBAC read degraded” for
the 503), and for action=upload_grant.list.access_denied. action=auth.permission_required never
carries the “RBAC read degraded” detail: a service token on a route behind the plain permission gate is
refused by the default-deny allow-list (“default-deny”, 403) whatever the ceiling read returns, and
during an outage of the ITServiceOwner read that row ends with “ceiling read degraded” (a healthy
default-deny row does not), so rows from require_permission routes with that ending mark the outage. For
Execution:Read the affected routes are the executions drawer’s
/fragments/executions/{id}/detail fragment, the legacy GET /api/executions* routes,
GET /api/v1/executions with its /{id}, /children and /api/v1/events twins, and the MCP tools on
that gate that service tokens can reach at all. POST /api/executions/{id}/rerun and /cancel are not
affected: a service-scoped token is already refused at their first gate, Execution:Execute.
kServiceScopeGlobalSafe is not widened.
Behaviour change: vuln_scan reports an unreadable config check as UNREADABLE and adds a summary row (#4961)
On Linux, vuln_scan (scan, config_scan) now reports a config file it could not read (/proc/sys/kernel/randomize_va_space, /proc/sys/fs/suid_dumpable, /etc/ssh/sshd_config, /proc/mounts) as UNREADABLE|config|<title>|<path>: <cause> instead of a HIGH/MEDIUM finding. summary always emits a seventh row, summary|UNREADABLE|<n>, and an absent sshd_config reads INFO “not applicable” with the SSH password row now emitted.
- Affected: scripts that index
summarypositionally or assert six rows; CEL or severity filters (a “no critical/high” policy passes a host it could not measure); dashboards keyed on MEDIUM counts on RHEL-family hosts where a non-root agent cannot read a mode-0600sshd_config. - Recommended: treat
UNREADABLEas not assessed, and usesummary|UNREADABLE|<n>as the coverage signal. - No operator action is required, and rollback is safe (output strings only).
Behaviour change: OTA refusal reason for an unreadable agent trust bundle (0.14.0 → 0.14.1, #5249)
- An OTA update refused because the agent cannot load its own update trust bundle (missing, permission-denied, or not a PEM certificate bundle) is now counted as
yuzu_agent_ota_signature_refused_total{reason="bundle_unreadable"}, no longerreason="untrusted". - The agent logs it as
OTA update <version> REFUSED: the update trust bundle could not be loaded. The grep string the 0.14.0 notes gave for #5196,signature verification FAILED (untrusted chain), stops matching this fault; update any alert rule, dashboard or saved search keyed on the old reason or text. A genuinely untrusted signer still reportsuntrusted chain/reason="untrusted". - New refusal, not a relabel: the trust bundle is now read bounded. 0.14.0 loaded a bundle of any size from any file it could open. From 0.14.1 the agent refuses a bundle that is larger than 1 MiB, is not a regular file (a pipe, device or directory), or, on Windows, is held open by another process that does not allow shared reading (the agent does not wait for it). This applies to both
--update-trust-bundleand--plugin-trust-bundle: an affected update is refused asbundle_unreadable, and an affected plugin as untrusted. On an agent with--update-require-signatureor--plugin-require-signaturethis blocks every signed update or plugin until the bundle is fixed, and a refused OTA update cannot deliver the fix, so check before upgrading: the bundle must be a regular file of at most 1048576 bytes (stat -c %s <path>on Linux,stat -f %z <path>on macOS,(Get-Item <path>).Lengthon Windows). A code-signing anchor bundle is normally a few kilobytes. - The update is still refused in both modes, and the heartbeat total
yuzu.ota_signature_refused(soyuzu_fleet_ota_signature_refusing_agents) still includes it. Agent-only; no server change. - The matching early warning,
OTA update trust bundle cannot be loaded as of this check, is logged once by the OTA update checker when it first starts (after the agent first connects), and only when auto-update is enabled. It is not printed beside the startupOTA update signature mode:line.
Behaviour change: DEX device score and management-group member reads now fail closed on a degraded read (#4855, #1762)
Not a Breaking lead for the DEX routes and the management-group MEMBER-read path — those already documented a 503 response before this release; what changes for them is when it fires, not the documented contract. This does NOT hold for GET /api/v1/management-groups/{id}’s own GROUP-ROW read or its MCP twin get_management_group (governance round-2, #1762): before this release, a degraded group-row read answered the SAME flat, undocumented 404 "group not found" a genuinely nonexistent group id gets — there was no 503 contract for that case at all. This release adds a NEWLY DOCUMENTED, additive 503/retryable error path for a degraded group-row read specifically; the 404 contract for a genuine not-found is unchanged. A client that already treats any 503 from these routes as retryable per the A4 contract needs no code change; a client that inferred “management group not found” purely from a 404 status code should note that 404 now unambiguously means “no such group” (never “could not tell”) — narrower, not wider, than before.
Operator-visible behaviour change (fail-closed reads). Two previously fail-soft read paths now surface a degraded Postgres read as a retryable error instead of a healthy-looking or empty result:
- Per-device DEX experience score (#4855).
GET /api/v1/dex/devices/{id}and MCPget_dex_device_scorenow answer503/a retryable MCP error when the per-device signal-summary read degrades, instead of the previous fabricated healthy score of 100 with no signals. The device-page DEX lens shows a “DEX store degraded.” placeholder in the same case.GET /api/v1/dex/overview(and its dashboard/MCP twins) gains a newunscoredfield counting connected devices whose per-device score could not be computed — a non-zerounscoredmeansgreat/fair/poor/overall_experiencecover fewer devices than are actually connected, not a healthier fleet than reported. - Management-group reads (#1762). The
/dex/perf/appgroup-trend read (GET /api/v1/dex/perf/groupand its dashboard/MCP twins),/autoVERIFY’s before/after compare, andGET /api/v1/management-groups/{id}(+ MCPget_management_group) now report a member-lookup degrade as503/a retryable error, instead of rendering it as “0 members” or “no member reported”. As of governance round-2,GET /api/v1/management-groups/{id}andget_management_groupALSO report a degrade in the GROUP ROW’s own read the same way (503/retryable), instead of the flat404that previously made a store outage indistinguishable from a genuinely nonexistent group.
Counters/alerts to watch during and after upgrade: yuzu_server_guardian_read_degrade_total (alert YuzuGuardianReadDegraded; this release adds a new source="guardian_rules" value alongside the pre-existing source="guardian_state", both now pre-seeded to 0 at boot across all three reason values) and yuzu_server_mgmt_group_read_degrade_total (alert YuzuMgmtGroupReadDegraded, now also counting the get_members_checked member-read degrade). A sustained non-zero rate on either during the upgrade window means the affected reads are answering retryable errors, not that devices or groups actually shrank.
No config or data migration is required.
Behaviour change: devices that disconnect cleanly stay on /hardware as Offline (#5678)
Not a Breaking lead: no flag, wire format or API shape changes. In 0.14.0 a graceful agent disconnect deleted the device’s endpoint_state row, so the device vanished from /hardware instead of showing as Offline. A graceful disconnect now clears only the row’s presence session, so the device stays listed with its real last-seen time.
- Migration v4 runs automatically at server start. It is additive (
CREATE TABLE IF NOT EXISTS endpoint_state.retention_meta), so rolling back to the previous server binary is safe: the old binary ignores the table. - Devices that disconnected under 0.14.0 were deleted and are not restored. Each reappears on
/hardwarethe next time it connects. /viz/fleetand MCPget_fleet_topologynow also show a departed device, flagged stale, for 7 days, soyuzu_viz_offline_hosts_totalrises after the upgrade.- 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 deletes 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 large backlog inherited from an older release drains at that rate (100,000 rows in about 8 hours).
- Decommission erases the row.
DELETE /api/v1/sle/agents/{id}deletes the device’sendpoint_staterow (#5682), so a decommissioned device leaves/hardware. A still-enrolled agent that heartbeats again re-creates it, so stop the agent first. A device that is never decommissioned ages out under the 37-day rule.
Behaviour change: the DEX cohort floor is one device, and the Apps list fails closed
Not a Breaking lead: no flag or route is removed and no response field is renamed. Three operator-visible behaviours change.
- The cohort floor is 1 (was 10). A point, cohort or per-version figure with a single contributing device now publishes that device’s exact CPU and memory aggregate on the un-audited DEX reads, including a cohort keyed by a per-device tag value (an asset tag, serial number or owner). No
agent_idis emitted and the permission is unchanged (GuaranteedState:Read); the audited per-device drill is unchanged. Thefloorfield those responses echo now reads 1. - The Prometheus cohort export carries one-device cohorts. With
dex_cohort_export_keyset, a one-device cohort exports asyuzu_fleet_perf_cohort_*{cohort=<tag value>}on/metrics, governed by/metricsauthentication (loopback is always unauthenticated; remote scrapes need any authenticated session, with noGuaranteedState:Readcheck, unless--metrics-no-authis set) rather thanGuaranteedState:Read. Choose keys whose values do not name a device or person.yuzu_fleet_perf_cohort_clippedcan now rise on high-cardinality keys. - The Apps list answers
503on a degraded read.GET /api/v1/dex/appsanswers503withretry_after_ms: 2000, and MCPlist_dex_appsa retryable error, where they previously returned an empty list. Treat503as retry; it also answers503at once when four Apps reads are already running on that server (keep to four parallel calls and honourretry_after_ms; each refusal is counted onyuzu_server_guardian_read_degrade_total{reason="concurrency_budget"}, which trips the warning alertYuzuGuardianReadDegradedif it persists). The rows gaindistinct_devices_online(an integer,nullwhen no fleet source is wired); the share, rate and score are over the Windows devices online now, and the score is withheld asoffline_affectedwhile any affected device is offline (expect that on most multi-device applications of a fleet with laptops; use the share and rate until an offline-aware score is decided, #5894). The list is now Windows-scoped and keeps the 100 applications affecting the most devices (apps_totalandapps_truncatedreport the cut); the Apps tab is unchanged.
No config or data migration is required.
Behaviour change: a failed device-roster read is reported instead of shown as an empty fleet (#5531)
Not a Breaking lead: GET /api/v1/hardware and GET /api/v1/hardware/{id} already declared a 503 response; what changes is when it fires. (The new 400 on the record and sync routes, below, applies only to ids over 256 bytes or carrying a byte below 0x20, which agent registration never accepts.) Before this release a failed read of the persisted device roster (pool timeout or query error) was shown as an empty roster.
GET /api/v1/hardware: a failed roster read now answers503withretry_after_msinstead of a200with no rows. The Hardware list shows the “Hardware roster unavailable” banner instead of an empty table. REST consumers that treated200with no rows as “the fleet is empty” must treat503withretry_after_msas “unknown, retry”, never as an empty fleet.GET /api/v1/hardware/{id}: when the roster read failed and the device was not otherwise found (CI record readable-and-absent, no software, no tags) the route answers503, not404. A record that can still be served carries a new additive keyidentity_state(found,degradedorabsent).GET /api/v1/hardware/{id},POST /api/v1/hardware/{id}/syncand the Hardware CI fragments: a device id longer than 256 bytes or containing a byte below0x20now answers400. OnPOST .../syncand the fragments this replaces the permission check’s403; onGET .../{id}it follows the route’sInventory:Readcheck (a caller without access still sees401/403) and replaces the scope404/ roster503. Agent registration refuses both (the same 256-byte limit, and a byte below0x20through its enrollment check), so any id registration accepts passes and a registered device is not affected.- Hardware CI record (dashboard): when the roster read failed and no CI record can be shown, the page says “Device roster unreadable” instead of “Device not found”; when a record can be shown, its Overview lens carries a “Device identity unavailable” note.
GET /api/v1/viz/fleet/topologyand MCPget_fleet_topology: still return the live fleet; the body now carriesoffline_roster_degraded: truewhen the durable offline-host read failed.- Observability: the failure is counted on
yuzu_server_agent_presence_store_failed_total{op="query_stale_within"}(once per failed read, not per cache window) and the audit rows recordresult=failure. A record served during a roster outage auditsresult=failure: SIEM rules keyed onresult=successforinventory.device.ci/inventory.device.softwaremust also key onidentity=roster unreadableandstate=roster unreadable(see the audit log reference).
Informational: internal-CA CRL publish/audit/observability follow-ups (HA WS-6, #4828–#4830)
No schema, config, or wire-shape change — this is documentation of behaviour that already
shipped with HA WS-6 slice 6.1 (ca_crl_versions migrations v3/v4, PR #4126) plus additive
audit/metrics coverage.
Migration v3/v4 boot-time lock window (informational, applies to any upgrade crossing them).
ca_crl_versions migration v3 (revoked_count column) and v4 (the ca_issued_keep_revoked
append-only trigger) each take a schema-level lock guarded by a 30 s lock_timeout — long
enough to outlast a legitimate in-flight CRL publish (which holds SHARE ROW EXCLUSIVE on the
same table for at most its own 5 s lock wait plus signing time), but a rolling upgrade that has
one replica already on the new schema while an older replica is still publishing can see up to
~30 s of the migrating replica’s readers/writers queueing behind the older replica’s lock. This
is a one-time cost on first contact with each version and does not recur.
ca.crl.published is now audited on every publish path, not only operator revoke (#4829).
Every CRL publish — the boot pre-publish, the leader-gated freshness re-publish, the
count-compare self-heal re-publish, and the two operator-triggered paths (revoke, subordinate-CA
import on REST/MCP/dashboard) — now writes a ca.crl.published audit row. The three paths with
no live operator request (boot, freshness, self-heal) audit under principal=system; every row
carries a reason= token (startup/freshness/self_heal/revoke/import_chain) so a
self-heal republish that resolves an earlier revoke-triggered failure leaves its own
result=success reason=self_heal row as the resolution, rather than the silence a prior gap left
here. See docs/user-manual/audit-log.md.
New Prometheus counters (#4830): yuzu_server_ca_crl_publish_failure_reason_total{reason}
(a bounded, closed cause breakdown alongside the existing
yuzu_server_ca_crl_publish_failures_total) and
yuzu_server_ca_unpublished_revocation_check_failures_total (the freshness pass’s own
self-heal check failing — distinct from a publish failing outright). See
docs/user-manual/metrics.md. No operator action required; both are additive.
⚠️ Breaking: --gateway-upstream now refuses to start without gateway peer authorization
Affects you if the server runs with --gateway-upstream (or YUZU_GATEWAY_UPSTREAM). The Linux package’s systemd unit passes --gateway-upstream unconditionally, so a Linux package install is affected even when you run no gateway: with your own certificates it will not start until you pin or drop the flag (see the table). The gateway-upstream service now requires an authorized gateway peer (an SPKI public-key pin, or an explicit acknowledgement). The decision is made at boot, before any listener is bound: a configuration that cannot be authorized makes the server exit non-zero with a message naming the flag to change. Nothing is silently downgraded, so check the steps for your deployment type before upgrading the server. Full reference: Gateway upstream peer authorization.
Check before you upgrade the server. Read the arguments the server will actually start with, on the host, and apply the decision rule below.
| Deployment | Command |
|---|---|
| Linux package (systemd) | systemctl cat yuzu-server | grep -v '^[[:space:]]*#' | grep -E -- '--(management-)?(cert|key|ca-cert)|--no-tls|--gateway-upstream|YUZU_(CERT|KEY|CA_CERT|GATEWAY|INSECURE_GATEWAY)' and, for the environment file, grep -v '^[[:space:]]*#' /etc/yuzu/yuzu-server.env | grep -E 'YUZU_(CERT|KEY|CA_CERT|GATEWAY|INSECURE_GATEWAY)'. systemctl cat prints the unit and every drop-in, so a flag or an Environment= line added by a drop-in is covered. YUZU_CERT, YUZU_KEY and YUZU_CA_CERT are the environment names of --cert, --key and --ca-cert, so a unit with Environment="YUZU_CERT=..." plus --gateway-upstream refuses to start the same way a flag does. The grep -v drops comment lines: the shipped unit’s comment block mentions every one of these flags, so without it the output is mostly comments (on the shipped unit the filtered output is the single --gateway-upstream line, and on the shipped yuzu-server.env.example it is empty) |
| Docker / Compose | From the directory of the compose file you actually use: docker compose -f <file> config | grep -E -- '--(management-)?(cert|key|ca-cert)|--no-tls|--gateway-upstream|YUZU_(CERT|KEY|CA_CERT|GATEWAY|INSECURE_GATEWAY)', and the same two-stage grep (grep -v '^[[:space:]]*#' first) over any env_file: it names |
| Windows | sc.exe qc YuzuServer (the flags are in BINARY_PATH_NAME: look for --ca-cert, --cert, --key, the --management-* overrides, --no-tls and --gateway-upstream), and dir "%ProgramData%\Yuzu Server\certs": a grpc-cert.pem or ca-cert.pem means your own gRPC certificates, a gateway-peer-pin file is a stored pin |
Decision rule (--cert-san and --cert-group matches do not count as certificates; the --management-cert, --management-key and --management-ca-cert overrides exist only as command-line flags, so no environment-file check can find them):
- No
--gateway-upstreamand noYUZU_GATEWAY_UPSTREAM: not affected. --gateway-upstreamand--no-tls: add--insecure-gateway-peer(orYUZU_INSECURE_GATEWAY_PEER=1), or the server will not start.--gateway-upstreamand your own certificates (--cert,--key,--ca-certor theirYUZU_variables), or any management override (--management-cert,--management-key,--management-ca-cert): a--ca-certgiven without--certand--key, and any management override, make the gRPC listener credentials operator-supplied, so the automatic pin does not apply and the server refuses to start without a pin. One corner: the server compares the path (server.cpp,agent_creds_are_default_files), so a--ca-certgiven without--certand--keywhose value is exactly<ca-dir>/default-ca.pemis the generated default, not your own, and the automatic pin still applies. You need a pin (--gateway-peer-pin,--gateway-peer-pin-fileor their variables) before the upgrade. If you run no gateway, remove--gateway-upstreaminstead.--gateway-upstreamand none of the above (generated default certificates): nothing to set; the server pins its own gateway certificate.- A pin and
--insecure-gateway-peertogether: refused. Remove one.
A literal dry run (a --check-config-style command that evaluates this decision without starting the server) does not exist; it is listed as a deferred follow-up in the decision record, so use the check above.
Back up the pin sources, and know what a restore does. The pin is configuration the server reads once at boot, and it lives outside the database. Add these to your pre-upgrade backup: any explicit pin files (wherever you keep them; under the packaged unit they must be under /etc/yuzu), /etc/yuzu/yuzu-server.env (or your compose file and its env file), and on Windows %ProgramData%\Yuzu Server\certs\gateway-peer-pin. Restoring the server without the pin file makes it refuse to boot (a missing explicit pin source never falls back to the automatic pin). Restoring an older certificate directory under the automatic pin pins the old gateway key, so a gateway that presents a newer one is refused until the server’s certificate directory and the gateway’s certificate match again (then restart the server).
What the service exposed before. The service was reachable on every port the server’s single gRPC server listens on, not only on --gateway-upstream. On the generated default certificates the agent port (--listen, default 50051) requests but does not require a client certificate, so a caller with no certificate could reach it. On the strict ports (management, gateway-upstream, and every port once you supply your own certificates) any holder of a client certificate signed by the install CA could, including an agent leaf. A caller whose ProxyRegister passed the handler’s enrollment checks was also recorded as a trusted gateway peer by IP address, and under --gateway-mode that relaxes the Subscribe peer-IP binding for that address. This is exposure, not a demonstrated end-to-end exploit. The change closes the service to anything but a pinned gateway key; it does not change what the agent listener itself accepts.
What it is now. Every call must come from a TLS-authenticated peer whose certificate lists serverAuth, whose public key (SHA-256 of the SubjectPublicKeyInfo) is in the pin set, and which is inside its validity window. Pins are fixed at boot: changing them needs a server restart. There is no pin reload and no revocation read on this path.
| Your deployment | What happens | What to do |
|---|---|---|
Generated default certificates (no --cert/--key, no --management-* overrides), TLS on, gateway presents the server’s generated default-gateway.pem (the Docker reference gateway compose does; the Linux packages and the Windows installer with /GATEWAY also start the server on its generated certificates, so the same pin applies, but the gateway you deploy must present that certificate: the yuzu-gateway package’s shipped configuration dials the upstream in plaintext and does not connect to a TLS server at all, see the comment block in deploy/systemd/yuzu-server.service) | The server pins default-gateway.pem automatically and boots. With --cert-group set it logs a warning that every process in the group holds gateway authority. | Check key custody: the gateway key file is the credential that authorizes a gateway. For a Docker reference-compose install, the shipped compose file now mounts only the public CA certificate into the agent container (a one-shot ca-export service fills a ca-public volume), and pulling the new file changes nothing until the agent container is recreated: from deploy/docker run YUZU_ENROLL_TOKEN=<token> docker compose -f docker-compose.reference-gateway.yml up -d for the whole stack (the file refuses to start without YUZU_ENROLL_TOKEN; any value works for an agent that is already enrolled), not up -d server gateway, which leaves a running agent on its old mounts. If an agent container ever held the old mount, treat the gateway key as exposed: replace it and withdraw its pin (changing mounts cannot invalidate a copied key). A gateway on another host that does not present default-gateway.pem is denied until you pin its key. |
Your own certificates (--cert/--key/--ca-cert), gateway connected over TLS | Refuses to start until a pin is configured. | Compute the pin with openssl x509 -pubkey -noout -in gw.pem | openssl pkey -pubin -outform DER | openssl dgst -sha256 | awk '{print $NF}' and pass --gateway-peer-pin <hex> (or --gateway-peer-pin-file <pem>; env YUZU_GATEWAY_PEER_PINS / YUZU_GATEWAY_PEER_PIN_FILE). On Windows, which has no awk, prefer --gateway-peer-pin-file <cert.pem> with the gateway’s leaf certificate: the byte pipeline is not documented for Windows PowerShell 5.1, whose text pipeline may corrupt the binary DER, and a wrong-looking hash is the commonest mistake (see “Lockout diagnostic” in the operator guide). The gateway certificate must carry serverAuth and clientAuth. |
| HTTPS on generated defaults but gRPC on your own certificates | Treated as your own certificates: no automatic pin. | As the row above. |
Plaintext (--no-tls) with a gateway | Refuses to start. --no-tls is not an acknowledgement. | Add --insecure-gateway-peer (env YUZU_INSECURE_GATEWAY_PEER=1). Peer authorization is then disabled on every port the service is reachable on, with a boot error line and a boot audit row. Never combine it with a pin flag: that also refuses to start. |
TLS on but no client CA (--insecure-skip-client-verify) | Refuses to start: no pin could ever match. | Supply --ca-cert, or use --insecure-gateway-peer on a development rig. |
| Linux packages (systemd unit) | The shipped unit passes --gateway-upstream unconditionally and runs TLS on generated certificates, so the automatic pin applies and it boots. An install that gave the server its own certificates refuses to start unless a pin is set, even when it runs no gateway. The deb postinst only enables the service, so the failure surfaces at the next restart or reboot; the RPM restarts the service on upgrade, and systemd stops retrying after 3 failed starts in 60 seconds. The RPM %pre and the deb preinst of this release print an advisory warning on stderr during an upgrade when /etc/yuzu/yuzu-server.env or a yuzu-server.service override under /etc/systemd/system shows your own certificates with --gateway-upstream and no pin or acknowledgement; it never fails or changes the upgrade, it reads only those files, and the check above remains the authority. | With your own certificates, set the pin in /etc/yuzu/yuzu-server.env. If you run no gateway, omit --gateway-upstream with a drop-in (the literal block is under the table); a package upgrade overwrites the unit file, a drop-in survives. A --no-tls drop-in now also needs --insecure-gateway-peer. Pin files must be readable by the yuzu user and live outside /home and /tmp (ProtectHome and PrivateTmp hide them): keep them under /etc/yuzu. |
| Windows installer | /GATEWAY with /NOTLS adds --insecure-gateway-peer. /GATEWAY with generated certificates needs nothing, but a gateway on another machine must either present the server’s generated default-gateway.pem or have its key pinned. /GATEWAY with your own gRPC certificate and key needs a pin the first time: the installer refuses an upgrade or install that would start gateway mode on your own certificates with no pin, before anything is changed. | Give /GATEWAY_PEER_PIN_FILE=<cert.pem> (preferred: the leaf certificate only, because a chain file pins every block) or /GATEWAY_PEER_PIN=<64 hex>; the two are mutually exclusive, and the interactive wizard has no pin page, so a pin is given on the command line only. The installer stores it as gateway-peer-pin in the certs folder under the data directory (%ProgramData%\Yuzu Server\certs) and, whenever gateway mode is selected, TLS is not skipped and that file exists, passes --gateway-peer-pin-file for it on every run, so the pin survives later upgrades. With /NOTLS the file is kept but not passed, because the server refuses the acknowledgement together with a pin, and /GATEWAY /NOTLS given together with a pin parameter is refused by the installer with nothing changed. A refused silent install exits with code 7 and the reason is only in the /LOG= file on a line starting PrepareToInstall: (deployment tools that report only the exit code do not show the reason); the existing service is left as it was. If you later revert the install to the generated default certificates, delete certs\gateway-peer-pin: the stored pin keeps being passed and replaces the automatic pin, so the generated gateway certificate would be refused. The installer checks that /GATEWAY_PEER_PIN is exactly 64 hexadecimal characters and applies only cheap checks to a pin file (it exists, is not empty, has no NUL byte); the server validates the contents when it starts. Giving either parameter again replaces the stored pin, so rotation with an overlap needs a multi-entry pin file. A service command line edited by hand is rebuilt by the installer on every run, so do not rely on it. |
| Compose wizard output (plaintext stacks) | A stack generated by an earlier wizard revision refuses to start after an image pull: its server runs --gateway-upstream with --no-tls and no acknowledgement. | Add the variable to the server’s environment: block in that block’s own style (the wizard’s list form - YUZU_INSECURE_GATEWAY_PEER=1, or YUZU_INSECURE_GATEWAY_PEER: "1" in a mapping block; compose rejects mixing the two), or regenerate the stack: the wizard now emits the list-form variable for a gateway stack without TLS. |
The repository’s own plaintext rigs already carry the acknowledgement in this release. Four scripts and three compose files pass it as a flag: scripts/start-stack.sh, scripts/start-UAT.sh, scripts/win-start-UAT.sh and scripts/integration-test.sh (which passes it in its plaintext mode only: its --tls mode passes no acknowledgement and enforces the automatic pin of the generated default gateway certificate), and deploy/docker/docker-compose.full-uat.yml, docker-compose.sanitizer-uat.yml and docker-compose.viz-uat.yml. The two compose files that pull published release images, the root docker-compose.uat.yml and deploy/docker/docker-compose.demo.yml, set it as the environment variable YUZU_INSECURE_GATEWAY_PEER, as does the compose wizard’s plaintext gateway output. deploy/docker/docker-compose.reference-gateway.yml runs TLS on generated certificates and needs no acknowledgement. scripts/ci/qa-stack.sh launches no server of its own: it runs that reference-gateway compose on published release images, so the automatic pin applies and it needs no acknowledgement either (only a comment changed there). deploy/config/docker.env.example and deploy/config/yuzu-server.env.example document the new variables and deploy/systemd/yuzu-server.service carries a comment block for the unit. If you keep a modified copy of any of these files, carry the change over by hand. Release images that predate the acknowledgement ignore the environment variable, so with such an image the variable is harmless; to get the new behaviour set YUZU_VERSION to a release that includes it, or rebuild the images locally (a stale local image does not have it either).
Linux package, own certificates, no gateway. The shipped unit passes --gateway-upstream unconditionally. Run systemctl cat yuzu-server to see the unit you actually have (a local edit may differ), then run systemctl edit yuzu-server and enter the following, which first clears the inherited command and then repeats it without --gateway-upstream (this is the shipped ExecStart of deploy/systemd/yuzu-server.service minus that one line; keep any other arguments your unit carries):
[Service]
ExecStart=
ExecStart=/usr/local/bin/yuzu-server \
--listen 0.0.0.0:50051 \
--web-port 8080 \
--data-dir /var/lib/yuzu
Then systemctl restart yuzu-server. If you do run a gateway, leave the unit alone and set the pin in /etc/yuzu/yuzu-server.env instead.
Rotating or withdrawing a pin is a restart. Pins are read once at boot. To rotate, list the old and the new pin together, in the same source (a command-line pin makes the matching environment variable ignored, not merged), on every replica, roll the replicas, switch the gateway (it presents a new certificate only after it redials; a gateway redials after its connection drops and reads its certificate and key from the configured files when it does, measured on the gateway’s own upstream client: see The upstream client), confirm yuzu_server_gateway_peer_denied_total{reason="not_pinned"} stays flat on every replica, then remove the old pin and roll again. To withdraw a key, remove its pin from every source and restart every serving replica. Revoking a certificate serial in the CA store does not remove a key pin. The procedure, including what to do after a refusal window, is in Gateway upstream peer authorization.
After a refusal window, work out which side the cause was on first. A gateway that includes #5695 keeps a NotifyStreamStatus refused with gRPC status 16 and sends it again (at once at first, then at each half open probe once the refusals have opened its circuit breaker), so the notice is delivered once the cause is fixed, unless the gateway restarted or its held store filled in the meantime. An older gateway does not retry a refused NotifyStreamStatus sent by an ordinary notification worker, so a session whose CONNECTED notice was refused can stay unplaced. The step-by-step runbook, with a verification after each step, is in Gateway upstream peer authorization; in short, there are three cause classes.
- A, the cause was on the server side (a wrong or missing pin or acknowledgement, other server configuration; the gateway’s certificate is unchanged): restart the serving replicas with the corrected configuration and do not restart the gateways first, unless the placement check below fails (class C), because on agents without the #5183 fix (shipped in 0.15.0) a gateway restart leaves released agents wedged. A gateway that includes the #1197 heartbeat-verdict consumer (#5383) recovers through its replay path, after at most one breaker open period (30 s at most with the default settings on a gateway that includes #5695, 300 s on an older one, and only if it opened), the agent heartbeat interval and the replay drip. To roll the change back, revert the pin or acknowledgement on every replica and restart the replicas (the pin set is fixed at boot). Verify with three separate checks:
sum(yuzu_gw_agents_current) - sum(yuzu_agents_connected{job="yuzu-server"})(summed across all gateway nodes and all replicas; the server gauge also counts direct agents and counts registration, not placement), no rise inyuzu_gw_upstream_rpc_errors_total{rpc_name="notify_stream",code="16"}oryuzu_gw_upstream_notify_dropped_totalover the window, and a successful read-only command to a sample agent, confirmed by that agent’s own result (the exact request and the limits of the check are in the runbook). Agreeing counts alone do not prove placement. - B, the cause is on the gateway side (its certificate expired or was renewed, its key rotated, renaming
default-marker.jsonminted a new leaf, or it points at the wrong certificate): the gateway presents its current files only when it redials, and a server restart or any dropped connection forces a redial, so first check whether it already has: the raw countersyuzu_server_gateway_peer_denied_totalandyuzu_gw_upstream_rpc_errors_total{code="16"}have stopped rising (read them twice, a minute apart, not throughincrease(): the gateway series is not pre-seeded), no newspki=denial line appears in the server log, and the certificate file the gateway is configured with has the expected SPKI prefix. A redial can still present the previous certificate for about 2 minutes after the files change (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. A same-key renewal needs no server restart. Restart that gateway node, one node at a time, only if denials continue and its agents can take the disconnect: a gateway restart disconnects every agent the node holds, and unless the agents run a build with the #5183 fix (shipped in 0.15.0) released agents stay wedged afterwards; see Gateway, “What happens when the server restarts”. Plan renewals beforenotAfter, inside a maintenance window. - C, placement loss is suspected after a refusal window (notify errors or drops rose, or a sample agent is unreachable although the counts agree): restart the agent service on the specific unreachable agents, which Gateway records as the observed fix. Restart the gateway node, one node at a time, only if many agents behind one node are affected and the class A checks still fail after the timing allowances in the runbook, with the same #5183 caveat: unless the agents run a build with that fix, released agents stay wedged afterwards. The gateway restart is inferred from the code and untested.
Certificate expiry is class B. A gateway whose connection outlives its certificate’s notAfter is refused on every call and does not redial by itself, so renew the certificate before notAfter, inside a maintenance window, and follow class B above to tell whether the gateway has already redialled.
Mixed replicas (HA). Give every replica the same pin set (on the generated defaults each replica pins its own default-gateway.pem, so if you ever give replicas different certificate directories, supply the same explicit pin on all of them). Compare the pin prefixes in each replica’s boot log line. Roll the replicas first; a gateway needs a restart afterwards only if the cause is on the gateway side and denials continue (class B above), because unless the agents run a build with the #5183 fix (shipped in 0.15.0) released agents stay wedged after one. While replicas run different versions an old replica admits calls that a new one refuses.
Rollback / temporary exception
Rolling back is a binary-only change: gateway peer authorization has no schema or store change. But the previous binary has no gateway peer authorization at all, so a rollback is itself a risk acceptance of the same kind as --insecure-gateway-peer. Record who accepted it, the date, and the date by which it will be remediated (a pin in place on the new binary), and schedule that work rather than leaving the old binary running.
Remove the new flags BEFORE rolling the binary back. An older binary rejects an unknown flag at startup (a command-line parsing error) but ignores an unknown environment variable, so a configuration that passes --insecure-gateway-peer, --gateway-peer-pin or --gateway-peer-pin-file on the command line fails to start on the old binary, while the same settings supplied as YUZU_INSECURE_GATEWAY_PEER, YUZU_GATEWAY_PEER_PINS or YUZU_GATEWAY_PEER_PIN_FILE are simply ignored. Remove the flags wherever they live:
- Windows service. The command line is the service’s
ImagePath(HKLM\SYSTEM\CurrentControlSet\Services\YuzuServer), which the installer writes. Reinstall with the previous version’s installer rather than editing the registry value by hand. The previous installer does not know the storedcerts\gateway-peer-pinfile and never passes it. - Linux package. The unit file or the drop-in under
/etc/systemd/system/yuzu-server.service.d/;/etc/yuzu/yuzu-server.envcarries only environment variables, which an old binary ignores. - Compose files and local start scripts. The seven flag-based rigs (
docker-compose.full-uat.yml,docker-compose.sanitizer-uat.yml,docker-compose.viz-uat.yml,scripts/start-stack.sh,scripts/start-UAT.sh,scripts/win-start-UAT.sh,scripts/integration-test.sh): use the previous revision of each file, because the new revision passes a flag the old binary rejects. The environment-based files (docker-compose.uat.yml,docker-compose.demo.yml, wizard output) need no change, and neither doesscripts/ci/qa-stack.sh, whose only change is a comment. - Reference gateway compose. The previous revision of the compose file mounts the whole certificate volume into the agent container again. That restores the key exposure described above; do not roll the compose file back without the same risk record.
To keep a new binary running while you fix a pin, --insecure-gateway-peer is an exception, not a fix: it disables peer authorization on every port the service is reachable on. Treat it as a time-boxed risk acceptance too: record who accepted it, the date and the remediation date, put the pin in place, and remove the flag. On a TLS server with a client CA the exception is reported as yuzu_server_gateway_peer_authz_mode{mode="insecure_ack_tls"} and raises YuzuGatewayPeerAuthzDisabledWithTls; every start with it writes a server.gateway_peer_authz_disabled audit row.
New flags and series: --gateway-peer-pin, --gateway-peer-pin-file, --insecure-gateway-peer; yuzu_server_gateway_peer_* (see Metrics).
⚠️ Breaking: gateways fronted by an HTTP/2-terminating proxy or mesh sidecar (#3869)
Affects you only if you run the Erlang gateway and agents reach it through something that terminates
HTTP/2: an HTTP/2-aware reverse proxy (for example nginx grpc_pass), an L7 load balancer, or a
service-mesh sidecar. Agents that connect to :50051 directly, or through an L4 / TLS-passthrough
path that keeps one TCP connection per agent, are not affected by the proxy requirement; see Older
agents and the registry restart note below.
Minimum agent version. An agent rejected by the gateway recovers by re-registering through its
NOT_FOUND handling. That logic exists from v0.13.0, but the released v0.13.0 and v0.14.0-rc6 agents
wedge in their reconnect path with default settings (bug #2182, fixed by PR #5183, which ships in 0.15.0) and recover only with --no-auto-update (observed with v0.13.0 and v0.14.0-rc6), or on a
build that includes the fix. v0.12.0 never recovers by itself (observed; older versions were not tested; v0.14.1 does not contain the fix and was not tested). Upgrade the
agents first, then the gateway, using 0.15.0 or later (the #2182 fix); until the agents are upgraded,
restart an agent that stays rejected (restarting the agent service re-registers it). Agents that do
not connect through the gateway are not affected by this requirement.
The gateway now admits an agent Heartbeat only on the HTTP/2 connection that opened the session’s
Subscribe stream, and only for a session that gateway node holds. A heartbeat that arrives on any
other connection is answered NOT_FOUND (unknown session), counted and not forwarded.
An HTTP/2-terminating proxy is supported only when it is configured for connection affinity (each
client connection gets its own backend connection, kept for as long as the client connection lives;
see Keeping each agent on one backend connection).
One that pools backend connections across clients is unsupported: it may cause repeated heartbeat
rejection or share gateway-side connections across agents, removing the per-agent connection
separation this check requires. Observed in a test with nginx grpc_pass in front of two agents: every heartbeat was
rejected, agents still enrolled and received commands, no heartbeat reached the server and the
server’s online count flickered; an L4 TCP forwarder (nginx stream) caused no rejections. Each rejection raises
yuzu_gw_heartbeat_session_mismatch_total{event="security"} or yuzu_gw_heartbeat_rejected_total{reason}
and appears in a rate-limited gateway summary log line (the connection_mismatch= count). The
agent-facing message is the same unknown session for every reason, so diagnose from the counters
and that log line.
Check before upgrading. Look for an HTTP/2-terminating hop between the agents and :50051
(reverse proxy, L7 load balancer, mesh sidecar). If there is one, move agents to an L4 or
TLS-passthrough path first, exempt :50051 from the proxy, or configure the proxy for connection
affinity and run the check in Client addresses behind a proxy
(HAProxy 3.2.25 and the OKD 4.21 router were measured at 50 agents; nginx grpc_pass rejected
every heartbeat in the one two-agent test). Also check the agent versions of
your fleet (the Hardware list at /hardware shows each device’s agent version) and upgrade every
agent older than 0.13.0 that connects through the gateway before you deploy the new gateway, and
plan to restart any released 0.13.0 or rc6 agent that stays rejected; see Older agents below.
Restart requirement. Deploy the new gateway with a restart. The session index is created when the
gateway registry starts and hot code upgrade is not supported for this change. A node that had the
new code loaded without a restart has no index table, rejects every heartbeat as
registry_unavailable, and reports sessions_index in /readyz (503 while the table is missing).
Rollback. Redeploy the previous gateway release. The only new state is the in-memory session index, and there is no wire, agent or server change, so nothing needs migrating; agents with the reconnect fix re-register on their own (released agents may need a restart, see the next paragraph for older agents), and a rollback removes the connection check. This path is derived from the change and was not run.
Older agents. A rejected agent recovers by re-registering through its NOT_FOUND handling
(escalating cooldown, 2 s doubling to a 300 s cap). That logic exists from v0.13.0 (checked in the
agent source), but in the released v0.13.0 and v0.14.0-rc6 agents it is blocked by bug #2182 (the
update-check thread join wedges the reconnect teardown; inferred cause), fixed by PR #5183, which ships in 0.15.0. With default settings those agents log (#1894) and Heartbeat thread stopped
and then never re-register (observed, 19 minutes, reproduced on a second agent); with
--no-auto-update they re-registered 20 to 21 s after a gateway registry restart (observed with
v0.13.0 and v0.14.0-rc6; it is a command-line flag with no environment variable). Agent v0.12.0 only logs Heartbeat failed and never re-registers by itself (older versions were not tested)
(observed with v0.12.0, 29 failures in 14.5 minutes with default settings; a --no-auto-update run was watched for only about 2 minutes and behaved the same; older than v0.12.0 was not tested), so for persistent missing state they stay
rejected until restarted or upgraded (such agents were still counted online by the server’s /health agents.online in the rig, so check the rejection counters and the agent log, not the online count; a heartbeat that falls in the short gap between the session
leaving the pending table and its agent process registering can succeed later without
re-registration). Upgrade the agents first, then the gateway, with a build that includes the #2182
fix (0.15.0 or later); until the agents are upgraded, restart an agent that stays rejected (restarting the agent service
re-registers it). Agents that do not connect through the gateway are not affected. This matters
only when heartbeats are rejected, which happens in four cases:
- a topology that breaks the one-connection assumption;
- a gateway running without the session index;
- a gateway registry process restart or crash while agent connections stay up. The registry recreates its tables empty, so every heartbeat for the agents it held is rejected until they re-register. A node failover that leaves the session not held by the surviving node is expected to behave the same way (inferred, not tested);
- for released agents, a gateway process restart. In a graceful SIGTERM and restart run the
released agents tested (v0.14.0-rc6, v0.13.0, v0.12.0, default settings) did not notice the lost
Subscribestream, sent their next heartbeats over a re-established channel to the new gateway and gotNOT_FOUND; v0.14.0-rc6 and v0.13.0 then wedged (no re-register through the 1 minute 40 s the run watched them) and v0.12.0 kept logging failures. The agent built from the branch tree noticed the lost stream and re-registered in 11 to 12 s with no rejections (observed). Restart released agents after a gateway restart if they stay rejected.
The registry-restart recovery in 17 to 37 s was observed with agents built from the branch tree (version 0.14.0, which includes the #2182 fix). Released agents were observed as described above.
See Heartbeat admission for the supported topologies, counters and runbook.
⚠️ Breaking: GET /api/v1/openapi.json now requires authentication (#2057)
The OpenAPI spec endpoint used to be public — any unauthenticated client could
fetch it to learn the REST surface. It now gates Infrastructure:Read, the
same permission the MCP yuzu://openapi resource twin already required, so
the two surfaces agree.
Breaking for pre-auth tooling. A script, health-checker, or API-client
codegen step that fetched the spec before logging in now gets 401 instead
of the document:
{"error":{"code":401,"message":"unauthorized"},"meta":{"api_version":"v1"}}
What to do. Fetch the spec with a session cookie or an API token, same as
any other /api/v1/* route:
# Session cookie (dashboard-style login)
curl -c cookies.txt -X POST http://localhost:8080/login \
-d "username=admin&password=<password>"
curl -b cookies.txt http://localhost:8080/api/v1/openapi.json
# API token
curl -H "Authorization: Bearer <api-token>" \
http://localhost:8080/api/v1/openapi.json
Agent OTA pulls are now bounded per peer (#913, #911) — behaviour change
DownloadUpdate is now admitted through a per-peer gate. A refused pull returns
gRPC RESOURCE_EXHAUSTED; a transfer exceeding --ota-transfer-deadline-secs
(default 900) or --ota-chunk-write-deadline-secs (default 30) returns
DEADLINE_EXCEEDED. Agents retry on their normal update interval, so no update is
lost — but the first fleet-wide update after upgrading can take substantially
longer than before, and on one deployment shape it can take days.
Check this before your first post-upgrade fleet update. Admission keys on the peer’s certificate identity, falling back to source IP where no client certificate is presented. Every certless agent behind one NAT egress therefore shares one bucket: 2 concurrent transfers and a sustained 1 admission per minute for the entire site, after an initial burst of 20.
| Certless devices behind one egress | Time for a fleet-wide update |
|---|---|
| 20 | minutes (absorbed by the burst) |
| 500 | ~8 hours |
| 5,000 | ~3.5 days |
What to do. Run yuzu_ota_admission_key_mode_total and look at the
mode="peer_ip" share. If it is material:
- Preferred: enrol those agents with client certificates. Each then gets its own bucket and the problem disappears.
- Otherwise: raise
--ota-rate-refill-per-minand--ota-max-concurrent-per-peerfor the rollout window, and raise--ota-max-concurrent-total(default 64) if you raise the per-peer values across a large fleet.
Watch yuzu_ota_download_admission_total{decision="rejected_rate"} during the
rollout, and see the “Agent OTA pull bounds” runbook in server-admin.md.
Also new, and check this one before you upgrade a large fleet:
--grpc-max-threads (default 8192) now caps the gRPC sync server’s thread pool.
Previously nothing did. It is a fleet-size ceiling rather than a tuning dial —
AgentService is synchronous and Subscribe holds one thread for the life of
each connected agent’s command stream, so the value must exceed your
concurrently-connected agent count. Below it, gRPC answers RESOURCE_EXHAUSTED
to every RPC on every service sharing the quota, which is a fleet-wide outage
rather than back-pressure. 8192 covers the fleets this release targets; if you
run more than ~5,000 concurrent agents, raise it (expected_agents x 1.5)
in the same change as the upgrade.
--ota-cert-reserve-pct (default 50) reserves half of --ota-max-concurrent-total
for peers admitted on a certificate identity. If your fleet is largely
unenrolled — no client certificates, so admission keys on source IP — the
remaining half is the whole capacity those agents can use, and you may want to
lower the reserve or raise the total for the rollout window.
⚠️ Security: rotate oidc_client_secret if it was ever set on this install
Every release up to and including v0.13.0 emitted the OIDC client secret in the clear to three places, and this upgrade closes all three:
- the server log, written verbatim by the startup override pass on every boot;
GET /api/config, a route gated only onInfrastructure:Read;- the
config.updateaudit detail written byPUT /api/config/:key, which is durably retained and readable by every role seededAuditLog:Read- the seededOperatorrole among them.
Breaking for automation: closing the second path changed a response shape.
GET /api/config now reports "is_set": true|false for a secret-valued key instead of value, so a
client reading value for oidc_client_secret gets nothing where it previously got the credential.
Full shape and the reason it is not a placeholder:
rest-api.md.
Because the log path fired on every boot, historical logs — and anything that ingested them, such as journald, a Docker log driver, or a SIEM — may still hold the secret in plaintext. Purge or restrict those as your retention policy allows.
Audit rows written before the upgrade are now redacted when read, so the value stops being disclosed
through that path too. The rows themselves are deliberately left intact: an audit row is compliance
evidence, and rewriting history to conceal a mistake is a worse posture than declining to disclose it.
So the plaintext remains at rest in those rows, and a direct database read or a restored backup
still shows it. Do not assume a fixed expiry: audit_retention_days defaults to 365, but each prune
pass is capped, and the retention guard deliberately declines a pass after a clock anomaly or a long
outage, so rows can outlive the nominal figure. The secret also remains stored in plaintext
in runtime-config; this fix closes disclosure paths and does not encrypt it at rest (see
security-hardening.md, “Current encryption posture”).
Who this affects: any install where oidc_client_secret was ever set through Settings -> OIDC
or PUT /api/config/oidc_client_secret on an affected release. All three paths above read from
runtime-config, so a secret supplied only via --oidc-client-secret or YUZU_OIDC_CLIENT_SECRET,
and never written through Settings or the API, was not disclosed by those three paths.
That is not the same as “not disclosed”. A secret passed as --oidc-client-secret sits in the
process command line, world-readable through ps or /proc/<pid>/cmdline, and it is typically also in
a systemd unit, a Compose file, or shell history. If you cannot rule those out, rotate anyway.
YUZU_OIDC_CLIENT_SECRET is better against other local users — /proc/<pid>/environ is owner-only,
unlike cmdline — but it is not better if the value is written into a Compose file or unit, since
docker inspect exposes it to anyone in the docker group. Prefer a secrets manager that injects the
environment variable at runtime.
Before you begin, check you have a local account you can sign in with — and on
--auth-mode=sso-only, that it is the configured break-glass account, MFA-enrolled and armable.
Rotating breaks SSO until Yuzu is re-pointed, and the way back in needs an account that already exists:
break-glass names one rather than creating it. On an install with none, the only fallback
auth-db-recovery.md documents is destructive.
What to do: rotate the client secret at your IdP, and delete the old one. The upgrade closes the disclosure paths but does not change the stored value — anything that already read it still holds a working credential, and only the IdP can invalidate it. Most IdPs let you add a second client secret without removing the first; adding one is not a rotation, because the disclosed secret keeps working until it is deleted.
Deletion does not reach what the secret already bought: access tokens minted with it are signed JWTs
that stay valid until they expire, and IdP sessions can outlast them. Treat revoking those as part of
the same job. Yuzu’s own sessions are separate — the IdP cannot reach them — and last at most 8 hours; with
the Postgres substrate they are durable rows and survive a restart, so a restart does not end them.
End them per operator via DELETE /api/v1/sessions?username=<name> (admin, UserManagement:Write;
for an SSO operator the name is oidc:<iss>#<sub>). Neither reaches API
tokens: one minted under a session that used the disclosed secret survives every step above. List
them with GET /api/v1/tokens and revoke with DELETE /api/v1/tokens/{id}.
Re-pointing Yuzu at the new secret is a configuration task rather than a remediation one: once
everything above is dealt with, SSO failing until you update it is an outage, not a continuing
disclosure. It is also configuration-specific and easy to get subtly wrong, so it is being written up as
a reviewed runbook rather than summarised here. Until that lands, see
authentication.md (“OIDC Single Sign-On”) for the durable and non-durable ways to
configure OIDC.
⚠️ Breaking: a multi-host --postgres-dsn now needs target_session_attrs=read-write, and load_balance_hosts is refused (HA WS-8)
Affects you only if the server’s Postgres connection names more than one host — host=n1,n2,n3,
postgresql://n1,n2/yuzu, a PGHOST/PGHOSTADDR list in the server’s environment, or a host list in
the pg_service.conf entry a service= DSN or PGSERVICE names — or sets load_balance_hosts (in
the DSN, PGLOADBALANCEHOSTS or that service entry). A single host without load_balance_hosts,
including a proxy or managed endpoint (RDS, Azure Flexible Server, Cloud SQL, the shipped HAProxy
compose), is unaffected.
Check before upgrading — look for a host list or load balancing in the DSN and the server’s environment:
grep -rnE 'YUZU_POSTGRES_DSN|postgres-dsn|PGHOST|PGHOSTADDR|PGLOADBALANCEHOSTS|PGSERVICE' \
/etc/yuzu/ /etc/systemd/system/yuzu-server.service* <your compose/env files> 2>/dev/null
If that shows service= or PGSERVICE, also read the named entry in the service file libpq uses
(PGSERVICEFILE, else ~/.pg_service.conf of the server’s user, else pg_service.conf in
PGSYSCONFDIR) for host, hostaddr, load_balance_hosts and target_session_attrs.
- No
target_session_attrsset: the server addstarget_session_attrs=read-writeand logs a warning at startup (... using target_session_attrs=read-write ...). It will no longer connect to a standby, which it previously could do silently whenever a standby was listed first. The DSN is rebuilt from libpq’s own parse — the same settings in keyword form — and checked option by option before use. Set the attribute yourself to silence the warning. read-writeorprimary: unchanged.- Any other value (
any,read-only,standby,prefer-standby, or an unrecognised one): the server refuses to start withInvalid --postgres-dsn: .... Change it toread-write(or remove it) before upgrading. load_balance_hostsset to anything butdisable(in the DSN orPGLOADBALANCEHOSTS), with any number of hosts: the server refuses to start. Remove it (or setdisable) before upgrading. The server writes to one primary, so with read-write the shuffle balances nothing — and/readyzholds one connection, so it cannot see a host that fails only some of the pool’s shuffled connections.- A
service=entry orPGSERVICE: libpq applies the service file only when it connects, so the server checks what libpq resolved on its first Postgres connection at startup, and refuses to start (Invalid Postgres connection settings: ...) if the resolved settings setload_balance_hosts, or list several hosts withouttarget_session_attrs=read-write(orprimary) — it cannot add the attribute to a service file, so set it there yourself. The readiness probe repeats the check each time it opens a connection, but it keeps a healthy one open, so a later edit that breaks these rules shows on/readyzonly at its next reconnect — restart the server after editing the service file. - Not checked — set
target_session_attrs=read-writeyourself: one host name that resolves to several servers (DNS round-robin, a Kubernetes headless service).
Behaviour change: /readyz now goes red when Postgres is unreachable, and shutdown can hold for a drain grace (HA WS-8, ADR-2002 §12)
/readyz gains a gating pg_reachable row, fed by a small probe on its own Postgres connection. Before
this change, /readyz stayed 200 through a Postgres outage whenever the server had idle pooled
connections or no traffic (the pool’s connect breaker only notices a failed new connection, and every
store’s row only reports whether it opened at startup). A load balancer health-checking /readyz kept
sending traffic to a server that could not serve it.
What you may observe after upgrading:
-
/readyzanswers 503 during a database outage or failover, with"failed_stores":["pg_reachable"]and a"pg"reason (unreachable,stale,read_only,not_yet_probed). During a Postgres failover every replica goes red at once, for roughly the failover time. If an orchestrator’s liveness probe points at/readyz, move it to/livezbefore upgrading — otherwise a database blip restarts every server. -
One more Postgres connection per server (the probe). Budget
N_servers × 2connections beyond the pool againstmax_connections(the other extra one is the leader-election connection) — the full formula is inserver-admin.md, “Sizingmax_connections”. -
The shipped
yuzu-postgresimages now reserve connection slots for the app role (#4943):reserved_connections = 40(envYUZU_PG_RESERVED_CONNECTIONS) andGRANT pg_use_reserved_connectionsto the app role, applied at first boot only (PostgreSQL 16 or newer). Three cases:- A fresh install or reinstall (a new, empty data volume) picks up the new default silently, with no
action needed — but it also reduces headroom for third-party tooling (a backup job, a monitoring agent)
sized against
max_connectionsalone with no margin by up to 40 connections versus a deployment built before this release. - An in-place upgrade of an existing database does NOT pick this up — the new default only applies at
first boot, and an existing data volume already had its first boot. Without action, a backup job or
ad-hoc session can still take the slot the
/readyzprobe needs to reconnect, same as before this release. - To apply it to that existing database, run the
ALTER SYSTEM(restart) and theGRANTby hand as “Sizingmax_connections” above shows.
A new boot-time failure mode on the Postgres container itself, not just the server binary: both
yuzu-postgresimages now refuse to start ifYUZU_PG_RESERVED_CONNECTIONSis set at or pastmax_connections − superuser_reserved_connections— that value would leave zero connection slots any other client could ever use. Only reachable by explicitly setting the env var too high; the shipped default (40) never triggers it. On the single-node image this refusal happens after the role/database/grant already exist, so restarting the same container with a corrected value does not retroactively apply anything — see the note in “Sizingmax_connections” above. - A fresh install or reinstall (a new, empty data volume) picks up the new default silently, with no
action needed — but it also reduces headroom for third-party tooling (a backup job, a monitoring agent)
sized against
-
A new alert,
YuzuServerPostgresUnreachable, and threeyuzu_server_pg_reachab*metrics — seedocs/user-manual/metrics.md. -
New flag
--shutdown-drain-seconds(YUZU_SHUTDOWN_DRAIN_SECONDS, default 0, max 60). OnSIGTERMthe server keeps serving for at least that long after/readyzturns503 draining, so a load balancer stops routing to it before the listener closes. The default 0 keeps today’s shutdown timing; set it for any deployment behind a load balancer, and raise your orchestrator’s stop timeout by the same amount (guidance indocs/user-manual/server-admin.md, “Load balancers and shutdown drain”). The execution-drain wait it sits alongside is now timed in wall-clock seconds (at most 30 s) rather than counted as 30 polls. -
/readyzalso goes red on a primary that refuses writes (default_transaction_read_onlyon — some managed Postgres services do this when storage fills), reported as"pg":"read_only". -
Multi-host
--postgres-dsnnow requirestarget_session_attrs=read-write— a breaking change with its own section above. -
Docker healthchecks. The demo and viz-UAT composes healthcheck
/readyz; that is right for readiness, but under Docker Swarm or an auto-heal sidecar an outage longer than the healthcheck’s retry window marks the container unhealthy and restarts it. Point restart-driving checks at/livez.
What to do: point liveness probes at /livez, readiness at /readyz; size max_connections by the
formula and apply the reserved-slot grant on an existing database; set --shutdown-drain-seconds and the
recommended health-check thresholds if a load balancer fronts the server.
Behaviour change: legacy /api/executions* routes are now management-group confined (#3789)
The legacy pre-v1 GET /api/executions (list), /{id} (detail), /{id}/summary, /{id}/agents,
/{id}/children, POST /{id}/rerun, and POST /{id}/cancel routes previously carried NO
management-group confinement at all — a bare Execution:Read/Execute grant, global or
group-scoped, saw and could act on the entire fleet’s executions. They now gate on
require_fleet_read, the same admit-then-filter mechanism GET /api/v1/executions/{id} already
used.
Who this affects: operators holding Execution:Read/Execute ONLY through a management group
(not a global grant) — these callers are newly ADMITTED to the five GET routes (previously flat
403’d outright) but see a narrowed, redacted view. A global grant holder sees no behavior change
on read shape. Everyone sees the following response-shape changes regardless of scope:
GET /{id}/summary,/{id}/agents, and/{id}/childrennow return404for an unknown execution id, instead of a zero-filled/empty200.POST /{id}/cancelnow returns404for a nonexistent execution id, instead of a false200 {"status":"cancelled"}(the underlyingUPDATEalways reported success even when it matched zero rows).- A caller holding
Execution:Executebut notExecution:Readcan no longer rerun or cancel an execution — both mutating routes now also requireReadadmission through the fleet gate, sincerequire_fleet_readis the only place the mutation’s target-visibility check happens. GET /api/executions(list) now capslimitat 500 (previously unbounded).- A gate failure caused by a degraded RBAC/management-group store now returns
503withretry_after_msinstead of a flat403. rerun’s unknown-id response differs by grant scope, deliberately. A confined caller attempting a rerun always gets the uniform404above. An UNCONFINED (global-grant) caller hitting a genuinely unknown execution id instead keeps the pre-existing400 {"error":"original execution not found"}fromcreate_rerun— unchanged from before #3789, and intentionally not unified with the new404, to minimize behavior change for existing global-grant automation.cancelhas no equivalent asymmetry: it returns404for an unknown id under every grant scope.- An execution whose targeted cohort never fully reports (an agent that went permanently offline
mid-run, for example) can permanently fail
rerun/cancel’s complete-cohort check for a confined caller. The escape hatch is a globalExecution:Execute+Execution:Readgrant, which is never subject to this confinement rule.
What to do: if you have automation with a group-scoped (not global) Execution:Read/Execute
grant, re-verify it still sees the executions it depends on — a confined caller now sees only
executions it dispatched itself or that involve at least one agent it can see, rather than being
denied outright or (previously) seeing everything. If you scripted around the old /cancel
false-success or the old zero-filled /summary response for an unknown id, update to expect 404.
Verify: GET /api/executions/{id} for an execution outside your management-group scope should
return 404, not the previously-unfiltered full record. docs/auth-architecture.md’s “Fourth
migration (#3789)” section has the full design; docs/user-manual/rest-api.md’s “Executions”
section has the per-route reference including the 503 shape and the rerun escape hatch above.
Behaviour change: Destructive-class dispatch now requires explicit agent_ids on REST and MCP (#3685)
17 plugin.action pairs are classified Destructive in the command catalogue: echo.purge_source,
echo.rollup, filesystem.delete_lines, registry.delete_value, registry.delete_key,
storage.clear, content_dist.stage, content_dist.execute_staged, content_dist.cleanup,
content_dist.upload_file, tags.clear, script_exec.exec, script_exec.powershell,
script_exec.bash, http_client.download, certificates.delete, and quarantine.quarantine
(19 as of this writing — power_health.set_power_plan and printing.clear_queue have since joined
them, under the same rule).
Dispatching any of them without explicit, non-empty agent_ids — an omitted/empty target, or a
scope (including "__all__", even alongside agent_ids) — is now refused with 400
("destructive action requires explicit in-scope agent_ids; broadcast and scope fan-out are refused") on REST POST /api/command, and -32602 with the same message on MCP
execute_instruction (refused before an approval ticket is minted or consumed, at both the
supervised-tier pre-mint gate and the operator-tier handler).
MCP execute_instruction had no Destructive-targeting gate at all before this release — a
scope-targeted or broadcast call to one of the 17 rows above dispatched normally and succeeded.
REST /api/command’s outcome for these 17 rows is unchanged — the prior guard already denied
a scope-targeted or broadcast dispatch to any of them, both before and after this release. What
changed on REST is the guard’s shape, not its outcome: the prior code was an if that collapsed
“classified and not Destructive” and “failed to classify at all” (a classify-miss) into the same
skipped branch, relying on a classify-miss being denied unconditionally further downstream
(build_classified_command) rather than proving that locally. This release replaces it with an
exhaustive switch with no default arm, so a future change to the verdict enum forces a
compile-time decision here too, instead of silently falling through to a guard shape that was
correct only because of a downstream backstop.
Who this affects: two surfaces change outcome in this release. MCP execute_instruction,
for any operator or automation dispatching one of the 17 rows above with scope or an
omitted/empty target. And the dashboard execute surface (POST /api/dashboard/execute), which
gains the gate for the first time here — including the case most likely to bite, an omitted
scope: that surface treated “no target named” as the whole fleet, so console automation that
posted neither agent_ids nor scope for one of these rows previously dispatched fleet-wide and
is now refused. A REST /api/command caller was already refused before this release and sees no
behavior change. Dispatches to a row NOT on this list are unaffected on all three surfaces.
A second dashboard change, distinct from the targeting gate: an explicitly-targeted Destructive
dispatch from the console is now confined to the agents visible to the calling operator, matching
REST. With RBAC off this is a no-op — every enrolled agent belongs to the auto-created root “All
Devices” group, so the visible set is the whole fleet. It bites only with RBAC enforcement on
where an operator holds Execution:Execute as a global grant rather than through a
management-group role: that operator has no management-group rows to be visible through, so the
confined list is empty and the dispatch is refused with no reachable in-scope agent (audited
reason=scope_violation). The fix is to grant the operator a management-group role covering the
agents they administer. Note the message names in-scope reachability, not connectivity — an agent
shown as online in the console can still produce it.
What to do — the field to send differs by surface, so do not apply one remediation to both:
- MCP
execute_instruction(and REST/api/command): send an explicit, non-emptyagent_idsarray. - Dashboard
POST /api/dashboard/execute: this endpoint has noagent_idsfield and ignores one if sent. It takes a singlescopeform field, and one explicit agent is expressed asscope=<agent-id>(a management group isscope=group:<id>, the fleet isscope=__all__— the latter two are what the gate now refuses for Destructive rows). Console automation must therefore sendscope=<agent-id>. The browser UI already does this and needs no change.
In both cases, if RBAC is enforced, also confirm the calling operator’s management-group roles
cover the agents named — see the confinement note above.
POST /api/instructions/{id}/execute and MCP execute_bundle do not yet enforce the gate — a
tracked follow-up, not a gap this release closes.
Verify: re-run the affected dispatch with an explicit single target — agent_ids on
MCP/REST, scope=<agent-id> on the dashboard — and confirm it succeeds as before; a group-scope,
broadcast, or untargeted call to one of the 17 rows should now return the refusal above rather
than dispatching. To confirm the gate is exercising in your environment:
yuzu_server_dispatch_target_rejected_total{route=~"command|mcp|dashboard",reason="destructive_untargeted"}
Note the regex matcher (=~): a label matcher cannot take an alternation of quoted strings. The visible-agent confinement refusal is deliberately not on that counter —
look for the command.dispatch|denied audit row carrying reason=scope_violation instead.
Behaviour change: SAML login gains a new availability coupling to rbac_store (#1832)
SAML SSO now reaches parity with OIDC’s group-to-role RBAC reconciliation (see
authentication.md “SAML Fine-Grained RBAC”). This introduces a
coupling that did not exist before: on a deployment that already has RBAC enabled
and --saml-group-attribute configured, a SAML login now depends on rbac_store
being reachable — a transient reconcile-store error denies the login outright
(fail-closed), rather than granting a session under stale or unreconciled roles.
Previously, a --saml-group-attribute-configured deployment’s SAML logins did not
touch the RBAC store at all (only the coarse --saml-admin-group admin/user mapping
ran), so this is a genuinely new dependency for that combination of flags, not a
tightening of an existing one.
What to do: if you run SAML with both RBAC enabled and --saml-group-attribute
set, ensure rbac_store (PostgreSQL) health and availability are covered by whatever
you already monitor for RBAC-gated REST/MCP calls — a rbac_store outage now also
takes down SAML login for that deployment, not just RBAC-gated reads. Deployments that
leave --saml-group-attribute unset, or that run with RBAC disabled, are unaffected —
this section is a no-op for them.
Also note: the --saml-group-attribute group-value cap (saml::kMaxGroupValues)
is raised from 64 to 200 in this release, to align with
RbacStore::kMaxIdpGroupsPerLogin. A SAML assertion carrying 65–200 group values,
which previously had its excess values silently truncated (and the login still
succeeded), now has all of them considered. An assertion carrying more than 200 group
values, which previously also truncated-and-logged-in, now instead denies the
login (mirrors OIDC’s group_count_exceeded behaviour) — see “SAML Fine-Grained
RBAC” in authentication.md for the full detail, including the practical httplib
form-body-size caveat on how large an assertion can realistically reach /saml/acs.
Behaviour change: InstructionStore moves to PostgreSQL — deleted content no longer resurrects (ADR-0058)
InstructionStore (InstructionDefinitions and InstructionSets — the operator- and
build-time-shipped instruction/question catalog) moves from per-replica SQLite to the shared
PostgreSQL substrate (ADR-0006/0007), like every other migrated store. No legacy-SQLite
backfill (ADR-0009’s fresh-start-by-default class, following ResponseStore’s precedent): no
production fleet has ever run a pre-Postgres build of any Yuzu store, so the pre-migration
instructions.db is never read. On upgrade, the bundled catalog reseeds fresh on first boot the
same way it always does, and any operator-authored content must be re-created via the normal
API (POST /api/instructions, POST /api/instruction-sets) — there is no automatic carry-over.
Breaking, deliberate: an operator-deleted bundled definition/set no longer reappears.
Since 0.12.x (see that row above), deleting a build-time-shipped definition and rebooting made
it silently reappear — the old per-replica SQLite reseed logic couldn’t distinguish “never
seeded” from “deliberately deleted”, so enabled: false was the documented workaround for
permanent suppression. That workaround is no longer necessary but still works. Post-upgrade,
a deleted bundled id stays deleted — across every reboot, every replica, and every future
release — via a tombstone the reseed loop consults. Recovery path: the tombstone only
gates the build-time-trusted reseed path; an operator can still freely bring the id back at any
time via a normal POST /api/instructions create or POST /api/instructions/import.
A side effect worth knowing: delete-and-reboot was also, incidentally, the only existing way to force an existing bundled definition to pick up newer bundled content shipped in a later release (delete it, then the next boot’s reseed re-inserts the current version). That side channel is now closed along with the resurrection bug it depended on — an existing bundled row, edited or not, never automatically picks up newer shipped content. There is currently no other refresh mechanism; tracked in #2555.
REST contract change on the direct /api/instructions* / /api/instruction-sets* routes:
DELETE on a missing id now returns 404 (was 200 {"deleted": false}); create/update/
import/create-set now return 503 on a genuine database failure instead of a misleading
400. See rest-api.md for the full per-route response-code
reference. Any automation that specifically parsed the old 200 {"deleted": false} shape or
treated every non-2xx response as a validation error should be updated.
No operator action is required to upgrade; the reseed loop runs automatically at boot. See
docs/adr/0058-instruction-store-postgres-migration.md for the full design record.
Caveat (rollback direction): the above covers forward migration only. instructions.db
still exists after the upgrade (it keeps backing the still-SQLite
ExecutionTracker/ApprovalManager/ScheduleEngine siblings), but this binary never writes
InstructionDefinition/InstructionSet rows to it. If you roll the server binary back to a
pre-migration release, the outcome depends on your deployment’s history and is never an empty
catalog either way — the pre-migration binary’s own boot sequence unconditionally
CREATE TABLE IF NOT EXISTS-es the catalog tables and runs its own every-boot bundled-content
reseed loop, the same way this store always has:
- A deployment that upgraded from a pre-migration install still has its old
instructions.dbcatalog tables, untouched by the new binary. The rolled-back binary reads them and serves that pre-cutover snapshot — including re-resurrecting any bundled definition or set an operator deliberately deleted in Postgres after cutover, since the old binary has no concept of the tombstone table. - A deployment that was Postgres-first from its first boot has no catalog tables in
instructions.dbyet. The rolled-back binary creates them fresh and reseeds that old release’s full bundled catalog — a stale but non-empty catalog, not an empty one. - Either way, anything created or edited via the API while running the new (Postgres) binary is invisible during the rollback — it lives only in Postgres, which the old binary never reads — and, because there is no backfill, is not recovered automatically when you roll forward again either; content authored while rolled back must be re-entered.
Behaviour change: token-rotation confirm now requires proof of possession (#3015)
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 actually received. All four confirm surfaces now additionally require the raw successor secret itself 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 (human or automation) confirming a rotation with only the token_id, and any caller confirming with the wrong secret.
- REST: a confirm body carrying no
secretfield (or an empty one) now gets400instead of succeeding; a wrongsecretgets403. - MCP:
confirm_api_token_rotation/confirm_engine_rotationcalled without asecretarg now getkInvalidParams; a wrong one getskPermissionDenied.
Correctly-implemented automation already holds the secret — it’s the same raw value the rotate response returns exactly once (REST data.token) — so a caller that installs the successor from that response and passes it straight through to confirm sees no change in behavior.
What to do if you lose the rotate response before confirming: you can no longer confirm — there’s no way to manufacture the secret from the token_id alone. Two recovery paths, and neither is new — this change makes the mechanism enforce guidance already documented in authentication.md “Rotating a Token” (don’t look the successor up and confirm blind):
- Wait for the automatic overlap-window sweep. Proof of possession gates this immediate, explicit
confirmcall 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. - Revoke the unknown successor and start a new rotation. This keeps the predecessor working immediately, at the cost of restarting the rotation.
See authentication.md and engine-principals.md for the full detail, and rest-api.md for the exact REST error bodies.
Behaviour change: MCP approval-store permanent failures now say don’t retry (#2786)
An MCP approval-ticket recall that hits a store fault has always returned -32603 with a retry_after_ms hint. Previously that hint was 5000 (retry after 5 seconds) for every store fault except a never-opened store, including one that was open but failing in a way no amount of retrying clears — SQLite corruption, not-a-database, a read-only filesystem, or a full disk. Those four cases now correctly get retry_after_ms: null (the same “escalate to an operator” response a never-opened store gets) instead of the transient retry hint.
What to do: if you have automation that blindly retries on -32603 without checking retry_after_ms, it now stops retrying sooner in this specific case — which is the correct behavior (the old retries were futile). If your automation already honors retry_after_ms per invariant A5, no change is needed. See mcp.md “-32603: Approval store unavailable” for the full response-body reference.
Behaviour change: four MCP tool input schemas got stricter (#2444)
revoke_certificate.serial_hex, confirm_engine_rotation.token_id, quarantine_device.reason/whitelist, and the eight approval-gated engine-principal tools’ principal_id (create/revoke_engine_principal, mint/rotate_engine_credential, transfer_engine_principal_owner, assign/unassign_engine_role) now carry pattern/maxLength bounds mirroring their handlers’ own checks (previously enforced only at the handler, after an approval ticket was already consumed). get_engine_principal and list_engine_roles also gained the same principal_id pattern, but per this codebase’s standing rule that input-schema validation runs only on the approval-gated path (mcp-server.md’s “Pre-approval input-schema validation”), those two are Read-tier and never approval-gated — the pattern is advertised metadata on tools/list only, not server-enforced; a malformed principal_id still reaches the handler and is rejected there exactly as before.
Who this affects: any operator or automation with an outstanding (pending or
approved-but-unconsumed) approval ticket for one of these ten tools —
revoke_certificate, confirm_engine_rotation, quarantine_device,
create_engine_principal, revoke_engine_principal, mint_engine_credential,
rotate_engine_credential, transfer_engine_principal_owner,
assign_engine_role, unassign_engine_role — whose arguments don’t match the
new bound: serial_hex must be ^[0-9A-Fa-f]{1,64}$; token_id must be
^[0-9a-f]{24}$; quarantine_device.reason/whitelist must be ≤1024/≤512
bytes (whitelist additionally charset-restricted); principal_id must match
^engine:[a-z0-9._-]+$ (or the bare-slug form on assign/unassign_engine_role).
Full patterns: mcp-server.md’s “Pre-approval input-schema validation” section.
What to do: such a ticket becomes unrecallable fail-closed once the new
server is running — the recall fails pre-consume just like any other
schema-invalid call, and the ticket is never burned, but it also can’t be
redeemed as originally minted. It stays valid until its normal 7-day expiry
(pending or approved-unconsumed), then ages out and must be re-requested. To
check exposure before upgrading, list your outstanding tickets for these ten
tools (list_pending_approvals MCP tool or GET /api/v1/approvals) and
compare each one’s arguments against the patterns above; no action is needed
for a ticket that already matches, or if you have none outstanding.
Verify: re-request any ticket the check above flagged, confirm the new
recall succeeds under the same tier, and confirm a deliberately malformed
value (e.g. a non-hex serial_hex) is now rejected at mint time — -32602,
no approval created — rather than being accepted and only failing later at
the handler.
Behaviour change: discover_instructions/GET /api/v1/discover/instructions null out a non-object stored parameter_schema (#2986)
Both surfaces share one builder (build_instructions_catalog) that parses each InstructionDefinition’s stored parameter_schema text. It previously forwarded any value that parsed as JSON, even a non-object (an array, string, number, or boolean). It now forwards it only when the parsed value is itself a JSON object — a non-object value is reported as null instead, matching GET /api/v1/discover/plugins’ existing behavior for the same field.
Who this affects: an operator or integration that authored an InstructionDefinition with a non-object parameter_schema, written before the write-time schema check existed, or by a non-standard write (the store now refuses a non-object parameter_schema, and POST /api/instructions/import is the only REST route that can supply one). No shipped content sets parameter_schema to anything but an object or leaves it unset (defaults to {}), so this affects only a deliberately or accidentally malformed definition.
What to do: if you have such a definition and relied on the old raw-forwarding behavior, re-author parameter_schema as a JSON Schema object (see Replacing a stored parameter schema). No action is required otherwise.
Behaviour change: webhook and offload-target deliveries, and enrollment/execution-failure notifications, now actually fire (#3261)
A boot-ordering bug wired NotificationStore/WebhookStore/OffloadTargetStore into
the agent-service RPC path (Register, Subscribe, process_gateway_response) before
those three stores were constructed, so the wiring silently never took effect. Dashboard
notifications for agent enrollment and execution failures, and configured webhook/offload
deliveries for agent.registered and execution.completed events, have been dead for
the life of the process since the stores were introduced — for NotificationStore/
WebhookStore, every tagged release from v0.10.0 through v0.13.0 (roughly four months);
OffloadTargetStore didn’t exist until v0.12.0, so its affected window is v0.12.0 through
v0.13.0 (roughly three months). Every use site double-guards ptr && ptr->is_open() with a
silent skip, so nothing logged or alerted; the bug produced zero signal. They now fire on
every boot.
What to do: if you configured a webhook (POST /api/webhooks) since v0.10.0, or an
offload target (POST /api/v1/offload-targets) since v0.12.0, expecting live traffic and
saw none, that integration has been silently non-functional the entire time, not merely quiet
— there is no queued backlog to replay, because the events were never generated in the
first place. If you use one of these integrations as part of your own monitoring or
compliance evidence chain (a SIEM feed, an alerting pipeline), treat the gap as a
documented monitoring-coverage lapse for that period, not just a product bug, when
recording it in your own change or incident log. After upgrading, confirm your receiving
endpoint still exists and can handle real traffic before you rely on it — deliveries
start immediately, with no flag or opt-in involved. If your webhook fires to Slack or a
similar noisy channel for enrollment alerts, note that agent.registered fires on every
gRPC reconnect, not only first enrollment (this is unchanged, documented behaviour — see
“Webhooks” / “Offload Targets” in rest-api.md — but was invisible
until now); the “Agent Enrolled” dashboard notification specifically does not repeat
on reconnect, so a server restart with N connected agents does not flood that feed.
Behaviour change: mcp.bridge.* audit rows can now carry result=failure (#2487 / #2506)
Audit rows for the MCP progress bridge (mcp.bridge.done_reap, session_dead, arming_reaped, pin_acked, forced_expire) were previously stamped result=success unconditionally, regardless of what actually happened. They now report the real outcome: result=failure when a background teardown could not release one of the resources it owns, or when the terminal-frame publish ladder poisoned the session, threw, or was never reached. The detail field names which, and no longer asserts a delivery or a poisoning that did not occur.
What to do: if you have a SIEM rule, dashboard or evidence query that treats result on this verb family as a constant success - for example “alert if any mcp.bridge.* row is not success” - update it before upgrading, or it will fire on legitimate rows. Note also that failure here is self-audited: a rule that surfaces failure branches by filtering on denied will not see these. Filter on result != "success" where you need both. mcp.bridge.cancel is unaffected and remains success.
These rows are background-actor events (principal=system), not operator actions, and a failure row does not mean a client lost a result - executions stay durably fetchable by execution_id. See audit-log.md for the verb family and ../ops-runbooks/mcp-bridge-teardown-recovery.md for what to do when the paired alert fires.
Behaviour change: MCP unknown-tool calls now audit result=denied, not failure (#2445)
An MCP tools/call request naming a tool that doesn’t exist has always returned -32601 (kMethodNotFound) with the same error message. The audit row for that rejection previously carried result=failure - the token this codebase reserves for server-side faults - while every sibling denial on the surface (tier, read-only, schema, input-bounds, per-submitter-cap) uses result=denied. It now audits result=denied too, matching those siblings. Nothing about the JSON-RPC response changed.
What to do: if you have a SIEM rule, dashboard or evidence query that classifies mcp.<tool_name> rows by filtering on result=failure to catch unknown-tool probes, re-point it at result=denied before upgrading, or it will stop matching. A rule that already filters on result != "success" sees both and needs no change. Note result=failure was never a clean isolator for unknown-tool probes specifically - it is also emitted for genuine server-side faults on known tools (store degraded, dispatch exception, etc.) and for several other client-caused-but-mislabeled rejections on the same surface (still open, tracked in #3176), so a rule that wants unknown-tool rows specifically should filter on detail="unknown tool", which this change does not touch. See audit-log.md “Result vocabulary” for the surface’s current token usage.
Behaviour change: a failed MCP progress-bridge teardown is now retried instead of being permanently retained (#2513)
A mcp.bridge.* teardown step (unsubscribe, releasing the streamed admission charge, or erasing the correlation record) that failed used to strand the record - and the bus channel, replay buffer, and per-session admission slot it held - for the rest of the process’s life. It now gets up to Config::teardown_retry_max retries beyond the first attempt (4 total attempts by default), one per later sweep tick, before falling back to the old permanent-retention behavior. New yuzu_mcp_bridge_teardown_retry_total{outcome="recovered"|"exhausted"} metric and mcp.bridge.teardown_retry audit action evidence which happened; see audit-log.md and ../ops-runbooks/mcp-bridge-teardown-recovery.md.
What to do: the shipped YuzuMcpBridgeTeardownIncomplete alert changed from a raw counter test (fires and never clears, since the underlying series is monotonic) to a windowed increase(...[15m]) > 0 test, because most of its movement now self-heals via retry within a few sweep ticks. If you copied the old rule into your own alerting config instead of re-importing docs/prometheus/yuzu-alerts.yml, update it the same way or it will stay permanently firing on transient, already-recovered blips. The new YuzuMcpBridgeTeardownRetryExhausted alert is now the actual permanent-retention signal - add it if you track these alerts individually rather than loading the bundled rules file wholesale.
If you have dashboard or SIEM logic reading the pre-existing yuzu_mcp_bridge_teardown_incomplete_total counter directly rather than through the bundled alert - it is unchanged in name and label shape, but its increment semantics changed: previously one increment per stranded record (ever), now up to Config::teardown_retry_max + 1 increments for the SAME record (once per failed step, per attempt) before it either recovers or exhausts. Arithmetic that read the counter as “count of stranded records” will now overcount under retry; read yuzu_mcp_bridge_teardown_retry_total{outcome="exhausted"} for that instead. Config::teardown_retry_max is a code-constant default, not an operator-configurable flag or env var - there is nothing to tune here.
Behaviour change: agent command replay protection is now durable, and a redelivered command replays its real result (HA WS-0, ADR-2002)
The agent’s command deduplication moved from an in-memory set (cleared on every reconnect) to a durable SQLite store (command_dedup.db in the agent data directory). Two consequences an operator or automation may observe:
Breaking for automation/SIEM. A redelivered/duplicate command no longer receives a REJECTED response with the fixed reason "command replay rejected: duplicate command_id". That string is retired. A duplicate now either replays the command’s original terminal frame (its real SUCCESS/FAILURE/REJECTED status, exit code, and structured error — the terminal frame carries the outcome, not the streamed stdout, which is not re-sent) or, while the first attempt is still running, returns a non-terminal RUNNING frame with output "duplicate command still in flight". Any detection rule, dashboard, or evidence query that matched the old rejection string on a redelivered command will silently stop matching — re-point it before upgrading (there is no longer a single “this was a replay” signal on the wire; the server correlates by command_id).
What to do: update or remove any rule keyed on "command replay rejected: duplicate command_id". If you tracked replays for security evidence, note the agent now emits yuzu.dedup_* heartbeat tags (dedup_degraded for an unopenable store; dedup_claim_errors/dedup_record_errors/dedup_release_errors for post-open write failures; dedup_replays) instead; fleet-wide yuzu_fleet_* aggregation + an alert land with the HA observability workstream (WS-11).
New on-disk artifact. command_dedup.db (plus -wal/-shm) appears in the agent’s --data-dir, bounded to the most recent commands. It is a regenerable safety net, not a backup target. Upgrade is fresh-start — no migration from the old in-memory scheme (there was no persistent state to carry); worst case on upgrade is that one already-delivered command could re-execute if redelivered during the restart, exactly as it could before this change. Downgrade is safe: an older agent simply ignores the file.
Degraded mode is fail-open. If the store cannot be opened or written (e.g. a full disk), the agent keeps executing commands undeduplicated rather than refusing them, and surfaces the condition on the heartbeat: yuzu.dedup_degraded=1 for a store that never opened, and the dedup_claim_errors/dedup_record_errors/dedup_release_errors counters for post-open write failures. A per-destructive-instruction-class fail-closed option is planned but not in this release.
Behaviour change: an MCP maintenance failure degrades instead of aborting the server (#2487)
A transient failure in the MCP progress-bridge sweep or the MCP session collector, which runs on the server’s maintenance thread, previously escaped that thread and terminated the whole process. It is now contained: the tick is skipped and retried on the next cycle.
What to do: be aware this is a genuine trade, not a pure win. The old failure was self-announcing - the process died, your supervisor restarted it, and uptime monitoring saw it. The new one is silent unless you are scraping metrics: a sustained failure means bridge teardown or session collection stops running, live MCP progress can degrade for the remaining life of the process, and /readyz will continue to report healthy throughout (MCP session state deliberately does not gate readiness). If you run Prometheus, scrape yuzu_mcp_bridge_teardown_incomplete_total, yuzu_mcp_maintenance_tick_failures_total and yuzu_mcp_bridge_records_active, and load the bundled rules in docs/prometheus/yuzu-alerts.yml - they are not applied automatically. If you do not, you will not see this condition at all.
Note this narrows the crash surface rather than closing it: other work on the same maintenance thread remains unguarded, so a sufficiently severe resource exhaustion can still terminate the process from a different call site.
Behaviour change: yuzu-agent no longer restart-loops forever (ADR-0021 rung 7.7a)
The yuzu-agent systemd unit now sets StartLimitIntervalSec=300 + StartLimitBurst=5 in its [Unit] section. Previously the unit was Restart=always with no start limit, so an agent that could not stay up restarted every 10 seconds indefinitely. With this change, if the agent exits 5 times within 300 seconds systemd stops retrying and the unit enters failed.
This matters because the agent’s Guardian engine can now hard_exit() when an I/O worker is wedged past a grace period (a permanently wedged watched target, e.g. a dead NFS mount or a hung service query); without the start limit that would be a silent 10-second crash loop.
What to do: if you alert on systemd unit state, add yuzu-agent entering failed to your alerts - it means the agent gave up after a crash loop and the device is now dark, which the old restart-forever behaviour hid. Recover with systemctl reset-failed yuzu-agent once the underlying cause (e.g. the wedged mount) is resolved, then systemctl start yuzu-agent. The Guardian spark detection path this rung wires is dormant (--spark-disable unchanged, legacy detection unaffected), so there is no detection-behaviour change to account for. See also server-admin.md “Stopping a wedged agent”.
Behaviour change: a Guardian rule that fails to arm no longer crashes the agent (ADR-0021 rung 7.7b PR-1a)
Independent of the dormant spark path above, the Guardian engine’s apply_rules (which runs on the live legacy detection path regardless of --spark-disable) now firewalls a per-rule reconcile failure. Previously, if arming a rule threw - e.g. a std::thread creation failure under resource exhaustion, or a bad_alloc - the exception escaped onto the agent’s dispatch thread and aborted the whole daemon. It now degrades: the failure is caught and counted, the rule stays persisted, and the agent keeps running. The full_sync teardown and the dispatch boundary carry the same firewall.
One behaviour to be aware of: when a rule fails to arm, the agent holds its policy generation (does not report “caught up”) so the server’s heartbeat reconcile keeps re-pushing and retrying - a transient resource failure self-heals within a reconcile cycle. A rule that fails persistently (rare - it requires a durable resource condition, not bad rule content) keeps the agent behind-generation and drives a re-push each reconcile interval; this is visible in the server’s guaranteed_state.reconcile audit rows for that agent. No operator action is required.
Behaviour change: Guardian event-drop metric narrows to genuine collisions (ADR-0021 rung 7.7b PR-Sv)
Guardian event ingest now distinguishes an idempotent redelivery (a matching-fields event_id PK conflict — the durable agent lifecycle journal’s expected crash-durable, duplicate-tolerant, bounded-retry redelivery, not a guarantee of eventual delivery - an un-acked record that ages out of the 7-day retention window is a rare, counted loss, not silent) from a genuine collision (a mismatched-payload conflict — a forged-id pre-claim or an event_seq_ reset carrying a different event). The eviction-loss counter (yuzu.guardian_journal_evicted_no_send_evidence) is in-memory only and resets on agent restart, and today has no dashboard, REST, or MCP surface at all, per-agent or fleet-wide - see Reconnect replay traffic for the full picture (tracked under #2298). A high redelivery count after an agent outage or reconnect storm is expected and is NOT a loss signal - genuine loss shows up only as a mismatched collision (..._events_dropped_total) or the rare journal-eviction case above. Three operator-visible consequences:
yuzu_server_guardian_events_dropped_totalnarrows. It now counts only mismatched collisions; benign redeliveries move to the newyuzu_server_guardian_events_redelivered_total. If you deployed the bundledYuzuGuardianEventsDroppedrule fromdocs/prometheus/yuzu-alerts.yml(a copy, not something the server auto-upgrades) with your own or a wider threshold tuned to reconnect churn, re-apply the updated rule (now> 5/1h) — otherwise your old threshold’s baseline drops post-upgrade and the alert goes silently dead, not loud, on a CC7.3 signal.- A metric series steps down, and a new one appears. Any Grafana panel/PromQL against
..._events_dropped_totalshows a step-change drop at upgrade with no incident — expected, not data loss; watch total PK-conflict volume via the new..._events_redelivered_totalseries. A third new series,..._events_ingest_errors_total(with a bundledYuzuGuardianIngestErrorsalert), counts operational ingest faults so a persistent fault can’t silently hide collisions. - DEX de-duplication on reconnect. Before this change, a redelivered event re-ran the DEX blast-radius + alert observers on every agent reconnect. If you saw duplicate/false blast-radius sightings or duplicate routed alerts during reconnect storms, those stop now — an intentional fix, not a regression.
Verify: post-upgrade, confirm the new series are present and re-tune any custom copy of the drop alert:
yuzu_server_guardian_events_redelivered_total # should appear and climb on reconnects
yuzu_server_guardian_events_ingest_errors_total # should stay at 0 in a healthy fleet
No config or data migration is required.
Behaviour change: a degraded file-change rule now reports itself unhealthy instead of staying silently green (#4748)
A file-change Guardian rule watches its target’s parent directory to detect the target being renamed or moved. If that watch’s own teardown repeatedly fails to confirm (normally only a wedged filesystem driver, or sustained packet loss on a network share), rename detection for that rule permanently disables until the rule is re-armed. Previously the only signal was a single agent log line — the rule kept reporting guard.compliant (the device page’s compliance census showed it green) for as long as the agent ran, even though it could no longer detect a rename of its watched directory.
After this upgrade, a rule that reaches this state reports itself errored on the device page (the same red state as an unresolved drift) instead, with an operator-facing detail explaining the cause and how to recover, and re-asserts every 5 minutes for as long as it stays disabled. The rule’s own file presence/content detection is unaffected either way — only rename detection is lost, and only its reporting changes here.
This is not a retroactive flip. The disabled state is in-memory only (never persisted), so it resets on the very restart that ships this fix — no rule that was showing compliant before upgrading suddenly shows errored at upgrade time. What changes is future behavior: if a rule reaches this degraded state after upgrading (e.g. from another bout of network-share packet loss), it now shows red where a pre-upgrade agent would have stayed silently green. If you see a previously-quiet file-change rule turn red after upgrading, that is very likely this fix correctly surfacing a pre-existing condition on your network share, not a new regression — see the degraded-mode section for the recovery steps (re-push the rule’s policy, re-deploy its Baseline, or restart the agent).
No config or data migration is required.
Behaviour change: dashboard YAML Save is schema-aware and stricter (#1993)
POST /api/instructions/yaml (the New Definition panel’s Save endpoint) and
POST /api/instructions/validate-yaml now share one schema-aware parser that
accepts the canonical nested InstructionDefinition schema (metadata.id,
spec.execution.plugin/action — the documented format every shipped
definition uses), which the old substring scanner rejected. In exchange, the
endpoint is stricter about malformed input:
Who this affects: automation POSTing YAML to /api/instructions/yaml
that (a) omits apiVersion:/kind: lines, (b) relied on a stray name:
substring anywhere in the document being picked up as the definition name,
(c) re-creates a definition whose metadata.id already exists (now 409
with a denied audit row instead of a silent second copy under a generated
id), (d) supplies an explicit id outside [A-Za-z0-9._-]{1,128}, or
(e) pastes multi-document (----separated) files (now rejected; save one
definition at a time). Interactive panel users are unaffected — the
structured form’s output stays accepted.
Verify: re-run your automation against a test server; failures surface as
specific per-field messages (e.g. Missing metadata.id (or metadata.name) field), and successful saves now emit instruction.create /
instruction.update audit rows.
⚠️ Reserved on-behalf-of headers rejected + principal_class metric label (ADR-1005 Phase 1)
Two operator-visible changes ship together:
1. Reserved headers now 403. Five header names are reserved and rejected
on every HTTP endpoint before authentication (On-Behalf-Of,
X-On-Behalf-Of, X-Yuzu-On-Behalf-Of, X-Yuzu-Delegated-Operator,
X-Yuzu-Delegation-Artifact, case-insensitive; the equivalent gRPC metadata
keys cause the call to be cancelled). Nothing previously consumed these
headers, so a correctly-built integration is unaffected — but pre-flight
check any proxy, service mesh, or SSO gateway in front of Yuzu: some
API-gateway products stamp an on-behalf-of/OBO convention header on every
upstream request (the name is Microsoft’s Entra OBO term), which would 403
all REST/MCP/dashboard traffic after upgrade. Health probes (/livez,
/readyz, /health, /api/health) are exempt, so the pod stays in rotation
— a green probe with a 100% 403 rate is the signature (see
docs/operations/troubleshooting.md). Rejections are visible in
yuzu_onbehalf_rejected_total and throttled [ADR-1005] warn lines.
2. yuzu_http_requests_total gains a principal_class label
(human/agent/none; engine reserved). This is a Prometheus
series-identity change on a long-shipped metric: old {method,status} series
freeze at upgrade and new {method,status,principal_class} series start at
zero. Plain selector queries and sum by (method, status) keep working;
re-baseline any dashboard or alert that matches the exact label set or joins
on series identity.
⚠️ Breaking: account lockout is ON by default
This release adds account lockout for failed local-password logins (SOC 2
CC6.3) and it is active by default (--auth-lockout-threshold=5,
--auth-lockout-window-secs=900) on any deployment that runs with a
persistent auth database — i.e. one started with --data-dir.
⚠️
--data-diris required. Lockout state lives in the persistentauth.db, which only exists when the server is started with--data-dir. The shipped container images and compose files pass--data-dir /var/lib/yuzu, so lockout genuinely is on by default there. The systemd/.deb unit now also passes--data-dir /var/lib/yuzu(added in this release) — but if you run a custom invocation without--data-dir, the server falls back to in-memory auth and lockout (and session persistence) is silently off. The server logs a loud startupWARNin that state. Set--data-dirto make the control active.Superseded by the AuthDB→Postgres migration (see below). Lockout state now lives in Postgres (
users.failed_login_count/locked_until), notauth.db. It is active whenever--postgres-dsn(orYUZU_POSTGRES_DSN) is set — which the server now requires to boot at all (no SQLite fallback) — and--data-dirno longer gates it. Read the--data-dirrequirement above as historical, for pre-migration builds only.
No further config change is required to opt in — a deployment that already runs
with --postgres-dsn gains the behavior the instant you start the new build.
What changes on upgrade:
- After 5 consecutive failed
POST /loginattempts a local-password account is locked for 15 minutes. While locked, every login attempt — including one with the correct password — returns the same generic 401 as a bad password (noRetry-After, no “you are locked” message; this is deliberate, to avoid a username-enumeration / lock-state oracle). - The lock auto-expires after the window — it is never permanent — and a user who waits it out regains a full attempt budget.
- Scope is local-password only. SSO/OIDC logins (throttled by your IdP) and API/automation tokens are unaffected — no automation breakage.
Highest-risk targets on upgrade: shared or service accounts that log in with a password, and any password-rotation / monitoring automation that may submit a stale password in a loop — these can now lock themselves out where previously nothing happened.
Before upgrading, do ONE of:
- Accept the default (recommended for most) — it closes a real credential-stuffing surface. Make sure operators know the recovery path below.
- Raise the threshold if you also rate-limit at the network layer
(NIST 800-63B §5.2.2 suggests ≥10):
--auth-lockout-threshold=10. - Disable it (not recommended; constitutes a deviation from the CC6.3 hardened
baseline):
--auth-lockout-threshold=0(the server logs a startupWARN).
Recovery if an account is locked out:
- Another admin can clear it immediately:
POST /api/v1/users/{username}/unlock(UserManagement:Write, MFA step-up). - Or wait out the window (default 15 min).
- Single-admin deployments: there is no self-service unlock for the only
admin (the unlock endpoint requires a second privileged principal), so either
wait out the window or use the offline recovery procedure in
docs/ops-runbooks/auth-db-recovery.md§ Account lockout recovery. That runbook is now Postgres-native throughout: its fallback clearsfailed_login_count/locked_untilwithpsql "$YUZU_POSTGRES_DSN"againstauth.users, which is exactly the sole-admin case. It writes no audit row, so record it in your change-management system.
Behaviour change: operator/API tags now beat agent self-report (#1411)
An agent’s self-reported tags (scopable_tags, synced on every Register) can no
longer overwrite an operator- or API-set tag for the same (agent_id, key) — the
operator/API value is now authoritative. This closes a path where a rogue or
misconfigured agent could self-assign into an operator-declared benchmark cohort.
Who this affects: only an operator who deliberately relied on agent-reported values winning over an operator/API-set tag for the same key. After upgrade those agent values stop overriding — silently (no error, no log line); an affected device simply drops out of the cohort the operator-set value defines.
Verify: audit the source field via MCP get_tags — GET /api/v1/tags does not
expose source, only key/value pairs (see the read-side note below) — to see whether
each key is api/mcp-sourced (operator/API) or agent-sourced.
Read-side completion (#3295): #1411 closed the write-time overwrite; scope-DSL
tag:<key> evaluation (dispatch targeting, management-group membership) had its own,
separate precedence — a currently-connected agent’s live self-report answered before the
store was even consulted. That is now also store-first: an operator/API-set row always
wins for scope-DSL purposes too, and an agent-claimed service value never answers from
the live self-report at all (it is dropped at registration, not just at store sync).
Who this affects: an operator who deliberately relied on a live agent’s tag value
overriding an operator/API-set tag specifically for dispatch targeting or cohort
membership while that agent stayed connected. See docs/asset-tagging-guide.md “Tag
source precedence (read time, scope-DSL, #3295)”.
Caution: the /devices tag-chip display is unaffected by this change and can now
visibly disagree with what actually governs targeting: it renders only the agent’s live
self-report, never the TagStore row, so a store value that now wins for dispatch
purposes — of any source, including a previously-synced agent value that’s now stale
relative to the agent’s live report, not only an operator-set one — may not be what the
dashboard chip shows. When a store row exists for the key, MCP get_tags (its source
field) is the source of truth for what governs a device’s scope-DSL matching, not the
dashboard; GET /api/v1/tags?agent_id=<id> does not expose source. For a
gateway-proxied or not-yet-synced agent with no store row at all, the live self-report
(what the dashboard shows) is what actually governs — an empty get_tags/GET /api/v1/tags result for a key means the live claim decides, not that nothing does.
Remediate: if an agent-reported value was the intended one, re-set it explicitly
via the REST API (writes source=api) or MCP set_tag (writes source=mcp) — either is
authoritative over an agent-sourced row. Keys the agent reports that the operator never
set are unaffected.
Behaviour change: MCP approval-gated calls use ticket-then-recall (-32006) (#289)
Supervised-tier MCP tokens that attempt an approval-gated operation (e.g. delete_tag,
quarantine_device) now run through a ticket-then-recall approval flow. The first
call returns JSON-RPC error -32006 (ApprovalRequired) whose error.data carries an
approval_id and a status_url (GET /api/v1/approvals/{id}). After a second principal
approves the ticket, the caller re-invokes the same tool with the approval_id and the
operation executes (the ticket is consumed exactly once, args-bound, replay-rejected).
An earlier development iteration returned
-32004(TierDenied) for these operations because the approval re-dispatch path was unbuilt. That intermediate behaviour never shipped in a release and is fully superseded by the flow above — there is no-32004approval-denial in the released product.
Who this affects: any MCP client that matched on the approval-gated denial. Match
-32006 and read error.data.approval_id / status_url to drive the approval workflow;
operator-tier executions are auto-approved and are unaffected. On the REST transport an
approval-gated operation by an MCP-tier token is denied (there is no JSON-RPC ticket
channel on REST) — use the MCP /mcp/v1/ flow or the dashboard.
Three review-round hardenings in the same release (PR #1796):
- Supervised REST quarantine is now mirror-denied.
POST /api/v1/quarantineandDELETE /api/v1/quarantine/{agent_id}return403for a supervised-tier token. An operation-mapping mismatch previously let a supervised token quarantine via REST with no approval — if any automation relied on that, it was riding the bug; route it through the MCPquarantine_deviceticket flow or use a non-tiered token. - Approved-but-unconsumed approval tickets expire after 7 days (measured from the review). An approved ticket is a live one-time capability; it now ages out on the same window as unreviewed pending requests. Recalling an expired ticket returns the standard “approval was expired” denial — submit a fresh request.
consumed_bytraceability column (additive, auto-migrated): the approvals store records WHO recalled each consumed ticket alongside the existingconsumed_at(SOC 2 CC7.2 evidence chain:submitted_by→reviewed_by→consumed_by).
Behaviour change: POST /api/v1/tokens now honors mcp_tier
The token-mint endpoint now applies the mcp_tier field from the request body
(readonly / operator / supervised). Previous releases silently dropped
it: a token requested with an mcp_tier was stored tier-empty (RBAC-deferred),
which means it was over-privileged relative to the tier the operator intended —
a readonly request behaved as a full RBAC-scoped token, not a read-only MCP
token. The endpoint also now validates at mint time: an unrecognised tier, or a
tiered/service-scoped token whose expires_at is missing or more than 90 days
out, returns 400 (previous releases returned a misleading 503).
Who this affects: any operator who minted an MCP token via POST /api/v1/tokens
before this upgrade and relied on the mcp_tier to constrain it. Those tokens
are still tier-empty and therefore broader than the requested tier.
Remediate: rotate any pre-upgrade mcp_tier tokens — revoke the old token
(DELETE /api/v1/tokens/{id}) and re-mint it; the new token stores the tier and is
constrained as intended. Tokens minted with no mcp_tier are unaffected.
Additive (no operator action required): the discover_plugins discovery response
(REST GET /api/v1/discover/plugins and the MCP tool) is now version: 2 — it adds an
inline parameter_schema per action (where a published InstructionDefinition exists and
the caller holds InstructionDefinition:Read) plus a top-level actions_enriched_with_schema
count. The change is purely additive; any client that pinned version == 1 should relax the
check to a minimum (>= 1).
⚠️ Breaking: API and MCP bearer tokens are invalidated on upgrade (ApiTokenStore → Postgres, ADR-0030)
ApiTokenStore — the store backing every Authorization: Bearer API token and
every MCP token — moves from SQLite (api-tokens.db) to the server’s PostgreSQL
substrate in this release (engine-principals PR 4.1; ADR-0006/ADR-0030). This is
a fresh-start cutover with no data migration: the new server creates an empty
Postgres api_token_store.api_tokens table and never reads the old
api-tokens.db, so every API/MCP token minted before the upgrade stops working
the instant the new server starts. Interactive cookie-session login (dashboard,
OIDC/SAML SSO) is not affected — only bearer tokens.
The rationale for no backfill (convention with every prior server-store migration, plus the token store holds only verify-only hashes) is in ADR-0030.
Who this affects: every external automation consumer that authenticates with an API or MCP bearer token — CI/CD scripts, SIEM/webhook pollers, cron jobs, MCP clients. Because it is a fresh-start cutover, all of them break at once at the moment of upgrade; there is no rolling/staged token continuity.
Remediate: after upgrading, re-mint every API/MCP bearer token (POST /api/v1/tokens) and update the credential wherever it is stored (CI secrets, cron
configs, MCP client configs). Plan the upgrade in a maintenance window and
notify automation/integration owners in advance so a batch of 401s across
every integration is expected, not a surprise incident.
Diagnostic: if the legacy api-tokens.db is still on disk, the server logs
[auth] Legacy SQLite api-tokens.db found at … — API/MCP tokens now live in PostgreSQL and any prior tokens were INVALIDATED by the migration (ADR-0030) at
boot. The old file is inert (never read again) and can be removed once you have
re-minted; it contains only token hashes + metadata, no plaintext secret.
Multi-instance note: the Postgres substrate now permits running multiple server replicas against one database. Token validation uses a per-process 60-second cache, so a token revoked on one replica may remain accepted on another replica for up to that window — see ADR-0030 for the tracked hardening.
⚠️ Breaking: engine: namespace reservation — server refuses to start on a pre-existing collision (engine principals PR 4.2)
This release introduces the engine principal class — a distinct identity
class for autonomous/agentic callers, stored in the new EnginePrincipalStore
and resolved through RBAC/ApiTokenStore (design doc §3.1/§3.3). To make that
resolution unambiguous, the engine: prefix is now a reserved namespace
for both local usernames (users.username) and local RBAC group names
(groups.name).
Pre-upgrade collision check (mirrors the plugin-trust-bundle filename
collision check above). At boot, the server now scans for any pre-existing
engine:-prefixed local user or local RBAC group and refuses to start if
it finds one — silently coexisting with (or being shadowed by) a real engine
principal is not an acceptable outcome, so this fails closed rather than
booting into an ambiguous state (decision log #3). Before upgrading, run
against both stores.
Superseded by the RbacStore → PostgreSQL migration (ADR-0041): the per-node
sqlite3 rbac.dbquery below is a HISTORICAL artifact from the era when local RBAC groups were SQLite. RBAC config is now a single shared PostgreSQLrbac_storeschema, so run onepsqlquery, not per-nodesqlite3. Both queries below target Postgres:
# Local users — Postgres.
psql "$YUZU_POSTGRES_DSN" -c \
"SELECT username FROM auth.users WHERE username LIKE 'engine:%';"
# Local RBAC groups — PostgreSQL (shared; one query, not per-node — ADR-0041).
psql "$YUZU_POSTGRES_DSN" -c \
"SELECT name FROM rbac_store.groups WHERE source = 'local' AND name LIKE 'engine:%';"
If either query returns rows, rename or remove those users/groups before
upgrading — the new server will not boot until the collision is cleared (or,
if the boot-time collision scan itself fails, e.g. a mid-scan database error,
the server also fails closed rather than guessing). See
docs/ops-runbooks/engine-principal-store-recovery.md
for the full recovery procedure if you hit this at boot.
Scope of this release: PR 4.2 ships the engine-principal store, RBAC resolution, and attribution plumbing only — there is no operator-facing surface yet (no dashboard/REST CRUD for minting or managing engine principals). That ships in PR 4.3.
⚠️ Breaking: --mfa-enforcement now enforces
Releases before this one accepted --mfa-enforcement=admin-only and
--mfa-enforcement=required but treated them as no-ops (the parser
accepted the value for forward-compat and the server emitted a startup
WARN). This release makes them enforce. If you staged the flag based
on that prior documentation, enforcement goes live the instant you start
the new build.
What changes on upgrade if the flag is set to admin-only or required:
- An un-enrolled user covered by the mode can no longer log in directly.
POST /loginreturns a 202mfa_enrollment_requiredchallenge and the user must complete TOTP enrollment (scan QR → enter first code atPOST /login/mfa/enroll) before a session is minted. This is a no-lockout bootstrap, but it requires the user to enroll at their next login. - The startup log line for non-default modes changes from
WARN(no-op) toINFO(enforcement active). - An operator can no longer disable their own MFA while the mode protects their role.
Before upgrading with the flag set, do ONE of:
- Recommended: leave the flag at
optional, upgrade, have all affected users enroll (Settings → Security and sign-in → Two-factor sign-in), then setadmin-only/requiredand restart. or - Upgrade with the flag set and accept that affected un-enrolled users will be walked through enrollment on their next login.
SSO / OIDC pre-flight (required reading if you use SSO): under
required (and admin-only for admin SSO users), OIDC sessions are
MFA-gated by the IdP’s amr claim — an SSO login the IdP did not MFA
is blocked from high-risk endpoints (it must re-authenticate via SSO),
symmetric with a local user being forced to enrol. Yuzu cannot mint a
second factor for an external identity, so before enabling
required/admin-only with SSO you MUST verify your IdP asserts an amr
claim containing a recognized MFA method (Entra: mfa; others:
otp/hwk/etc., RFC 8176). If it does not, affected SSO users will be
unable to reach high-risk endpoints — recoverable by restarting in
optional (see docs/ops-runbooks/auth-db-recovery.md § “Locked out by MFA
enforcement misconfiguration”; that runbook is now Postgres-native throughout).
Under optional, no IdP amr configuration is required (SSO sessions pass
the gate).
Single-admin deployments: do not first-boot a fresh single-admin
deployment straight into required. Enroll the admin under optional first,
then switch. If you do start with required, the admin must complete
login-time enrollment within --mfa-login-pending-secs (default 120s); if
the token expires, restart with optional, log in, enroll, then re-enable.
Recovery if you get locked out (IdP doesn’t assert amr, or the sole
admin can’t enroll): restart the server with --mfa-enforcement=optional
(this re-seeds the in-memory config), log in, resolve enrollment, then
re-enable. See docs/ops-runbooks/auth-db-recovery.md — that runbook is now
Postgres-native throughout, so both the restart procedure above and its SQL
steps apply as written to a Postgres-backed AuthDB.
Hardened auth mode (--auth-mode=sso-only) — opt-in (SOC 2 CC6.3/CC6.6)
No operator action on upgrade. Existing deployments default to
--auth-mode=standard and behave exactly as before. This release adds the
option to disable local-password login fleet-wide so only an SSO provider can mint a
session.
If you intend to enable sso-only, do this first — the server fails
closed (non-zero exit, refuses to serve) otherwise:
- Configure OIDC (
--oidc-issuer+ the related flags) and confirm SSO works instandardmode.sso-onlywithout an SSO provider (OIDC, or a complete SAML SP config on Linux/macOS with HTTPS) refuses to start (it would lock every operator out). - (Recommended) Create a single break-glass local account and enroll MFA on
it (Settings → Security and sign-in → Two-factor sign-in). Point
--break-glass-userat it.sso-onlyrefuses to start if the named break-glass user doesn’t exist or has no MFA enrolled — a break-glass account must carry a second factor. - Restart with
--auth-mode=sso-only. Local-password login is now rejected for everyone except the break-glass account, and only while armed.
Breaking the glass (IdP outage). The break-glass account is dormant until
armed out-of-band on the server host: yuzu-server --break-glass-arm --break-glass-user <name> --config … --data-dir … --postgres-dsn … arms it for
--break-glass-window-secs (default 24 h, auto-expiring). It still requires the
account’s MFA at login. Since the AuthDB→Postgres migration this one-shot opens
the Postgres auth store, so --postgres-dsn (or YUZU_POSTGRES_DSN) is
required — point it at the same database the server uses; without it the
command fails closed with “requires the Postgres auth store” and does nothing.
--data-dir is still needed for the audit record. Full runbook:
docs/ops-runbooks/auth-db-recovery.md § Break-glass arm (that runbook is now
Postgres-native throughout).
Migration. break_glass_armed_until is a nullable column on auth.users.
It arrived as SQLite auth.db migration v4, but the AuthDB→Postgres cutover
folded every historical migration into the born-on-PG schema, which starts at
v1 — there is no v4 to apply and no rollback-past-v4 question. See the
AuthDB→Postgres section above for the (fresh-start, no-backfill) upgrade
contract that replaces it.
⚠️ Breaking: server generates default TLS certificates on first boot (v0.13.0)
Before v0.13.0 the server refused to start without operator-provided certs
(or --no-tls/--no-https). From v0.13.0, when a TLS surface has no certs the
server auto-generates a per-install ECDSA CA + leaf certs on first boot and
serves encrypted with no operator action.
Impact by prior configuration:
| Prior startup flags | After upgrade |
|---|---|
--cert/--key (+ --https-cert/--https-key) supplied | No change — operator certs always win; defaults are never generated for a supplied surface. |
--no-tls --no-https (plaintext dev) | No change — both surfaces disabled; no certs generated. |
--no-tls only (HTTPS previously errored without certs) | HTTPS now serves an auto-generated default cert instead of failing. The agent surface stays plaintext. |
| No cert flags (previously refused to start) | Now starts, encrypted, on default certs, with a loud banner. |
What to expect / do:
- Browsers show an untrusted-issuer warning for the dashboard until you trust
the per-install CA (
<ca-dir>/default-ca.pem, default/etc/yuzu/certs) or replace the cert with--https-cert/--https-key. The connection is encrypted; only issuer verification is missing. - Agents: while on default certs the agent listener is encrypted but does
not require client certs (per-agent mTLS arrives in a later release).
Agents that previously connected over plaintext must switch to TLS — point them
at the CA with
--ca-cert <ca-dir>/default-ca.pem. - To keep the legacy refuse-to-start behaviour, pass
--no-default-certs. - Back up
<ca-dir>/default-ca.key(0600) and theca_storePostgres schema (pg_dump/pg_restore, ADR-0053) — losing the CA key forces a full fleet re-enrollment. - Relocate the cert directory with
--ca-dir(e.g. a dedicated container volume).
Generic inventory store no longer reads or scrubs a legacy inventory.db (ADR-0037)
InventoryStore’s one-time first-boot backfill from a legacy inventory.db SQLite file
(migrate_from_sqlite()) has been retired (#3623) — no production fleet ever ran a
pre-Postgres build, so there was no real legacy data to migrate. Two separate behaviors changed:
- A reachable Postgres database whose schema can’t migrate or open is still a fatal startup error (fail-closed), same as before.
- A legacy
inventory.dbfile with real content does NOT fail startup and its content is never imported — the server opens it read-only, purely to count rows ininventory_datafor a diagnostic warning, then boots regardless of what it finds. delete_agent()no longer erases anything from a legacyinventory.dbeither — the previous “erase from Postgres AND the retained legacy SQLite copy” behaviour is gone. A leftover legacy file is inert: never read on boot beyond the diagnostic warning above, and never scrubbed by a device decommission. If your environment genuinely still has a legacyinventory.dbyou need gone (e.g. for a completed decommission’s data-retention obligations), delete it manually.
Verify: after the server reports ready, generic inventory reads (GET /api/v1/inventory or
equivalent) behave exactly as before — this store’s runtime read/write/query behavior is
unchanged, only the one-time backfill and the legacy-file erasure are gone.
Rollback caution. Rolling back to a build older than this release, against a database that
has already run this store’s retirement migration, unconditionally fails that older
binary’s boot with a Postgres undefined_table error on its own idempotency-marker check — not
only when a legacy inventory.db happens to be present. This is a deliberate fail-safe (a clear
startup error, never silent data loss), not a bug, but it means “just roll back the binary” is
not a supported recovery path once this migration has run. The same is true for
CaStore/ManagementGroupStore/QuarantineStore/WebhookStore below, each for its own dropped marker
table. RbacStore is the one genuine exception (see its own section): its retirement migration
poisons the marker rows to a sentinel value rather than deleting them, so an old binary’s own
lookup finds them present (not 0 rows) and — reading a value it understands as “no verified
source, but not a failure” — boots normally UNLESS a real, unmigrated legacy file is also present
on that specific host. This is RbacStore’s own deliberate choice to tolerate rollback, not an
artifact of a row count. AuditStore also removes marker rows rather than dropping a table, but do
NOT assume it behaves the same way as either group — its own section explains why an old binary’s
boot fails just as reliably as the DROP-TABLE group’s, via a CHECK constraint rather than a SQL
error on a missing table.
⚠️ Behaviour change: internal-CA store moves to Postgres (ADR-0053)
CaStore (internal-CA root metadata, issued-certificate inventory, CRL version
history — everything the mTLS-accept revocation gate and GET /api/v1/ca/*
read) moves from the SQLite ca.db file to the server’s PostgreSQL substrate
in this release (schema ca_store). The private CA root key is unaffected —
it was never in ca.db and stays a local file behind KeyProvider (--ca-dir).
- No legacy-SQLite migration path. No production fleet ever ran a
pre-Postgres build of this store, so there was no real
ca.dbdata to carry over — the one-time backfill this section originally described was retired under ADR-0009’s fresh-start-by-default amendment (see ADR-0053’s Update). A reachable Postgres database whose schema can’t migrate or open is still a fatal startup error (fail-closed); a legacyca.dbfile with real content does NOT fail startup and its content is never imported — the server opens it read-only, purely to count rows inca_root/ca_issued/ca_crl_versionsfor a diagnostic warning, then boots regardless of what it finds. - New fail-closed-at-boot behaviour. A genuine Postgres error while wiring the per-agent mTLS revocation checker at boot now refuses to start the server, rather than starting with revocation enforcement silently unwired. If the server previously started cleanly on a working Postgres connection, this changes nothing observable; it only changes what happens during a Postgres outage at exactly that boot step, from “starts degraded” to “refuses to start.”
GET/POST /api/v1/ca/*and the CA MCP tools now return a503/internal error on a genuine database error, instead of a silently-empty or silently-false result.- Every other CA behavior — revocation semantics, CRL numbering, the single
sign_agent_csrchokepoint — was unchanged by that migration. CRL numbering and publication were later reworked for multiple server replicas (HA WS-6 slice 6.1): seedocs/user-manual/server-admin.md“vNEXT — CRL publishing is serialised in Postgres”. Detail:docs/pki-architecture.md,docs/adr/0053-ca-store-postgres-migration.md. - Rollback caution: see the InventoryStore section’s “Rollback caution” note above — the same unconditional rollback-refusal applies to this store (its own dropped marker table).
- New: an established, already-running default-cert install can now self-heal
its own listener leaves without operator action. This is not limited to a
first-boot crash window — any boot where the on-disk
--ca-dirdefault leaves are missing, corrupt, or a leaf was later lost (a bad partial restore, a lost volume file) hits the same path, as long asca_storealready holds a root and this instance’s local CA key file still matches it. When that holds, the server automatically re-mints its own https/server/gateway leaves with fresh private keys under the existing root and resumes, instead of refusing to start. It never touches the CA root itself or any agent-issued certificate. Every occurrence logs aspdlog::warnline, but there is currently no dedicated audit-log row for it (unlike enrollment-timeca.cert.issued) — tracked as a follow-up, not fixed in this release. - HA note: a losing first-boot replica self-heals within the same boot attempt
(UP-3), bounded. Multiple server instances sharing one
--ca-dircert volume and oneca_storePostgres substrate is not an officially supported deployment topology today (see ADR-0053’s Decision section) — this note describes what happens if it’s done anyway, safely, not a recommendation to do it. If two instances start against the same freshca_storeat once, exactly one wins the root race and generates the live default certs; the other polls the shared cert directory for up to 15s for the winner’s complete set to land (it only adopts a fully-written set — the winner’s completion marker is written last, so a partial/in-flight write is never picked up) and, on success, continues booting on the winner’s certs without a restart. If the winner hasn’t finished within that window (a slow Postgres, lock contention, or a winner that’s itself still starting up), the loser falls back to the original behavior: it exits (refuses to start, non-zero) rather than serving with its own discarded material — it does not reach a running-but-unready state, so a readiness-probe-driven restart never applies here. Recovery in that case is a plain process-supervisor restart (systemdRestart=on-failure, KubernetesrestartPolicy) once the winner’s certs are in place, so the losing instance picks them up from disk on its next boot attempt. On systemd specifically, a slow winner (e.g. Postgres itself under load) can interact with the crash-loop guard (StartLimitBurst/StartLimitIntervalSec) — a losing replica that exhausts its restart budget first lands in the service’s “failed” state and needs a manualsystemctl reset-failedonce the winner has actually finished, rather than retrying forever on its own. Diagnosing a bootstrap that seems permanently stuck (neither replica ever finishes): checkpg_locksfor a lingeringyuzu:default_certs_bootstrapsession advisory lock with no live backend behind it (a host crash or network partition can leave one held until Postgres notices the dead session) andpg_terminate_backendit — seedocs/pki-architecture.md’s operator runbook.
⚠️ Behaviour change: webhook store moves to Postgres (ADR-0057)
WebhookStore (operator-registered outbound webhooks and their delivery log —
everything behind GET/POST/DELETE /api/webhooks and
GET /api/webhooks/{id}/deliveries) moves from the SQLite webhooks.db file
to the server’s PostgreSQL substrate in this release (schema webhook_store).
The webhook HMAC signing secret is now envelope-encrypted at rest
(SecretCodec, AES-256-GCM, ADR-0010) instead of a plaintext column.
- No legacy-SQLite migration path. No production fleet ever ran a
pre-Postgres build of this store, so there was no real
webhooks.dbdata to carry over — the mandatory, both-tables backfill this section originally described was retired under ADR-0009’s fresh-start-by-default amendment (see ADR-0057’s Update). A reachable Postgres database whose schema can’t migrate or open is still a fatal startup error (fail-closed); a legacywebhooks.dbfile with real content does NOT fail startup and its content is never imported — the server hardens it to 0600 (main file and any-wal/-shm/-journalsidecars, since it may still hold a pre-cutover signing secret in plaintext — POSIX only, a no-op on Windows, #3593) and counts rows inwebhooks/webhook_deliveriesfor a diagnostic warning, then boots regardless of what it finds. There is no move-aside: the legacy file stays at its original path. - New fail-closed-at-boot behaviour. A Postgres/
SecretCodecerror at any point in this store’s boot sequence (schema migration or KEK verification) now refuses to start the server, rather than starting with webhooks silently unwired. - If you have a real
webhooks.dbyou need re-imported: there is no automated recovery path — recreate the webhooks (and, if used, their signing secrets) viaPOST /api/webhooksafter the server starts. Seerest-api.mdfor guidance on securely disposing of a legacy file that still holds a plaintext secret. - If you already upgraded through an EARLIER release (one where the
now-retired backfill genuinely ran), check for a
webhooks.db.migrated- <unix-epoch>file (and its-wal/-shmsidecars) at the same directory — that historical rename-on-success behavior is gone in this release (a legacy file is never renamed any more), but a file it already produced is NOT cleaned up by this upgrade and still holds every pre-cutover signing secret in plaintext. Dispose of it securely if you no longer need it; the current probe only checks the un-renamedwebhooks.dbpath, so it will never warn about a.migrated-<epoch>file left over from before. - Rollback caution: see the InventoryStore section’s “Rollback caution” note above — the same unconditional rollback-refusal applies to this store (its own dropped marker table).
POST/DELETE /api/webhooksnow distinguish a caller error from a store failure:POSTreturns400for an invalid URL scheme (previously an ambiguous non-error response), andGET/POST/DELETE /api/webhooks(list/create/delete) return503on a genuine database error instead of a silently-empty or silently-failed result.GET /api/webhooks/{id}/deliveries’s degrade-vs-error handling specifically is unchanged by this migration — a degraded read there still renders an empty delivery list rather than a503, the same as before the cutover.GET /api/webhooks/{id}/deliveries’s?limit=handling changed.limit=0(or any non-positive value) previously meant “return zero rows”; it now falls back to the default of 50, the same as an omittedlimit. A value above 10000 was previously passed through unbounded; it is now silently capped at 10000. If your integration relies onlimit=0meaning “give me nothing,” or on retrieving more than 10000 rows in one call, update it.- Every other webhook behavior — event-type matching, HMAC-SHA256 signature
format (
X-Yuzu-Signature: sha256=<hex>), unsigned delivery when no secret is configured — is unchanged. Detail:docs/adr/0057-webhook-store-postgres-migration.md.
⚠️ Behaviour change: response history resets on Postgres cutover (ADR-0039)
ResponseStore (agentic command/instruction results — the executions drawer
and Echo read source) moves from the SQLite responses.db file to the server’s
PostgreSQL substrate in this release (ADR-0006 Wave 1, schema response_store).
Like the ApiTokenStore and AuthDB cutovers, this is a fresh-start cutover
with no data migration — response results are expendable, TTL’d telemetry
(ADR-0009 skippable backfill), so the legacy responses.db is never read on
upgrade.
What happens on first PG boot:
- The server logs a one-time
response history reset on Postgres cutoverwarning. - Executions-drawer and Echo views for commands issued before the cutover are empty; results for commands issued after are unaffected and refill immediately as agents report.
- No operator action required. To preserve pre-cutover response history,
export it from the old
responses.dbbefore upgrading (seedocs/user-manual/response-store.md→ periodic exports).
Also in this release: non-UTF-8 bytes in a plugin’s output/error (which
the old SQLite TEXT column tolerated but PostgreSQL TEXT rejects) are now
replaced with the Unicode replacement character (U+FFFD) at ingest — the
response row is still stored and still renders, defanged, rather than being
dropped (governance #1593). Retention moved from an hourly background thread
to a clock-guarded, capped reap on the maintenance tick (no operator-visible
behaviour change beyond the same 90-day default).
⚠️ Behaviour change: directory sync state resets on Postgres cutover (ADR-0063)
DirectorySync (AD/Entra ID integration — synced users/groups, group
memberships, and group→role mappings) moves from the SQLite
directory-sync.db file to the server’s PostgreSQL substrate in this release
(migration-programme PR 3, schema directory_sync). This is a fresh-start
cutover with no data migration (ADR-0009) — the legacy directory-sync.db
is never read on upgrade.
What happens on first PG boot:
- The server logs a one-time
DirectorySync initialized (schema directory_sync) — fresh start, no legacy backfillline, and a separate warning naming a row count if the legacydirectory-sync.dbstill holds synced users/groups/mappings. GET /api/directory/users,GET /api/directory/status, and the group→role mapping list all start empty.- Before upgrading, capture your current group→role mappings (they will
NOT survive the cutover):
PUT /api/directory/group-mappingswith an empty{"mappings": []}body is a safe, non-mutating read — an empty input array is a no-op, and the response always echoes the store’s full current mapping list regardless. There is no dedicated read-only endpoint for this today; save the response’smappingsarray (each entry{"group_id": "...", "role_name": "..."}) somewhere durable before you upgrade. - Operator action required, after upgrading: re-run
POST /api/directory/syncwith the sametenant_id/client_id/client_secretyou already supply on every sync call — this store never persisted the credential, so this migration adds no new credential-gathering step, but you still need it in hand to re-populate synced users and groups. Then re-apply every mapping you saved above via onePUT /api/directory/group-mappingscall with body{"mappings": [{"group_id": "...", "role_name": "..."}, ...]}— those mappings ARE lost by the cutover and must be recreated from your saved copy. Verify the re-sync captured everything: compareGET /api/directory/status’suser_count/group_countagainst your Entra tenant’s own user/group counts before considering the upgrade complete. - The MCP
export_access_review/REST access-review export’s optional email enrichment (matched off synced users’ UPN) is unaffected in shape — it degrades to “no enrichment” until the first post-upgrade sync completes, exactly as it already did whenever directory sync was never configured.
Also in this release: directory_memberships gained a real foreign key
(ON DELETE CASCADE on both user_id and group_id) — the SQLite era had
none. /readyz and /healthz now report directory_sync — the SQLite-era
store was never checked at boot or reflected in either probe.
Behaviour change beyond the storage cutover itself: a user or group
deleted from Entra now actually disappears from DirectorySync on the next
successful sync — the SQLite era kept every previously-synced user/group
around forever, upserting but never removing one Entra had deleted. If your
integration (or a saved report) relied on a removed identity remaining
visible via GET /api/directory/users/GET /api/directory/status after
leaving Entra, it will stop being visible starting with the first
post-upgrade sync. Relatedly, a malformed/unexpected Microsoft Graph groups
API response is now a hard sync failure (surfaced in
GET /api/directory/status’s last_error) rather than a silently-empty
group sync — this closes a gap the deletion behavior above would otherwise
have introduced (a transient Graph glitch wiping every synced group).
⚠️ Behaviour change: schedules, approvals, and execution history reset on Postgres cutover (ADR-0065)
ScheduleEngine, ApprovalManager, and ExecutionTracker move from the
shared SQLite instructions.db file (via InstructionDbPool) to their own
independent PostgreSQL schemas in this release (migration-programme PR 5,
the last of the components the postgres-migration ladder had never tracked
— WorkflowEngine/ADR-0064 having already made the same move in PR 4 —
schemas schedule_engine, approval_manager, execution_tracker
respectively). This is a fresh-start cutover with no data migration
(ADR-0009) — the legacy instructions.db is never read for data on
upgrade. InstructionDbPool itself is deleted; instructions.db is retired
and no Yuzu store writes to it again.
Before upgrading, capture what you’ll lose (none of it carries over):
GET /api/schedules, GET /api/approvals, and GET /api/executions are
safe, non-mutating reads — use them to record your active recurring
schedules and any outstanding (pending or approved-but-unconsumed) approval
tickets before you cut over, so you know what to re-create afterward rather
than discovering gaps after the fact. GET /api/executions defaults to the
100 most recent rows (?limit=<N> to raise it — there is no offset/cursor
parameter) — for a fleet with more history than that, raise the limit
before relying on this as a full capture. This capture step runs on the
pre-upgrade binary, which necessarily predates #3789 — the ADR-0065 Postgres cutover this
section documents shipped before #3789 did, so any binary old enough to still need this
procedure cannot yet carry #3789’s limit cap; raise ?limit as high as needed for this
specific step. (A binary that already carries #3789, by contrast, caps GET /api/executions’
limit at 500 regardless of the value requested, with no offset/cursor to page past it — see
docs/user-manual/rest-api.md’s “Executions” section — but that cap cannot bite on this
already-completed cutover’s capture step.) The retired instructions.db file is left on disk,
not deleted — if you need to recover consumed-approval audit evidence
(the submitted_by → reviewed_by → consumed_by chain) after upgrading,
that file is the most complete source (the audit store also carries
approval.approve/approval.reject and mcp.<tool> consume events for
the same tickets, but not the full row); back it up separately before any
unrelated disk cleanup removes it, since Yuzu itself never will.
What happens on first PG boot:
- The server logs three one-time lines —
ScheduleEngine initialized (schema schedule_engine) — fresh start, no legacy backfill, similarly forApprovalManager/approval_manager, andExecutionTracker/execution_tracker— plus a separate warning naming a row count for any ofschedules/approvals/executions/agent_exec_statusstill present in the legacyinstructions.db. GET /api/schedulesstarts empty. Operator action required: re-create any recurring schedules viaPOST /api/schedules.- Any MCP or REST instruction-approval ticket pending, approved-but-unconsumed,
or already consumed at the moment of cutover is gone. Operator action
required: a pending approval must be re-requested by whatever flow
originally submitted it (MCP
execute_instruction’s approval gate, a scheduled run requiring approval, or a direct REST submission) — there is no way to recover an in-flight ticket across the cutover. Consumed-ticket audit history (thesubmitted_by → reviewed_by → consumed_byevidence chain) does not carry forward either. - The executions drawer, REST execution routes (
GET /api/v1/executions/{id},GET /api/v1/execution-statistics+ its/agents//definitionssub-routes), and MCP execution-status tools (get_execution_status,query_responses’s execution-id join) all start empty. No gRPCCommandResponsein flight at the exact moment of upgrade has anywhere to land until the next dispatch. No operator action required — new executions populate normally from first boot. /readyznow lists all three stores (ExecutionTrackerwas already present, re-keyed off its ownis_open()instead of the shared pool’s);/healthzgains net-newschedule_engineandexecution_trackerrows — neither was ever reported there before this release (ApprovalManagerwas already in both, unchanged).
Also in this release: advance_schedule’s locked select-then-compute-
then-update collapses into one atomic UPDATE ... RETURNING, closing a
two-statement race an app-level lock previously covered — no operator-visible
behavior change. ExecutionTracker’s one sqlite3_changes() call (gating
the terminal-transition SSE event on the shared SQLite connection) is closed
via UPDATE ... RETURNING, fixing a latent race under concurrent callers —
also no operator-visible change; the executions-history SSE contract
(progress-before-terminal event ordering) is unchanged. The MCP approval
error envelope’s permanent-vs-transient discriminator changes internally
from a raw SQLite extended error code to a Postgres SQLSTATE string — the
same two response shapes are still chosen by the same rule, so no client
migration is needed.
Behaviour change: per-device dispatch can now refuse a target it always accepted before (ADR-1007)
ExecutionTracker gains a new table, concurrency_claims (same
execution_tracker schema introduced above), backing real enforcement of
the per-device concurrency mode — the shipped default and the only one of
the five documented modes that was ever actually enforced (see
docs/user-manual/instructions.md §8 and ADR-1007 for the full
real-usage audit; per-definition/global/global-singleton/per-set
remain accepted but unenforced, unchanged from before this release).
Operator-visible: dispatching a per-device definition (via POST /api/instructions/:id/execute or a ScheduleEngine run) to a device that
already has an execution of that same definition in flight now excludes
that device from the target list instead of sending a second, overlapping
command as it always did before. This is the intended fix, not a
regression — but any automation that dispatches the same definition
repeatedly in quick succession to the same fleet (e.g. a tight retry loop)
may see partial dispatch where it previously always saw full dispatch.
Watch the new yuzu_server_dispatch_concurrency_skipped_total counter (or
ExecutionTracker::claim_concurrency_slots’s spdlog::info line, which
reports a count — how many of the dispatch’s candidates were already busy
for the definition — not individual device ids) to see when this fires —
the executions API’s agents_targeted field is the post-claim SENT count,
not a pre-claim total, so there is no “targeted vs. claimed” comparison to
read it against. Error code 3003 (ConcurrencyBlocked) is registered for
this condition but not yet surfaced through the dispatch response.
Quarantining a device mid-flight (after it was claimed but before it
reports a terminal status) behaves the same as a crash for this purpose —
its claim ages out at the reconciler’s TTL rather than releasing
immediately, so a device released from quarantine may still read as “busy”
for a per-device definition for up to that TTL window.
The claim’s exclusion window is bounded by a flat one-hour timeout, renewed
by a dedicated agent-core keepalive that runs independently of any given
plugin — so a long-running script_exec.* action (a MUTATING instruction —
runs an operator-named program or authored script that can hold real state)
with an operator-configured timeout near the one-hour ceiling stays
correctly excluded even if it produces little or no output. The keepalive
sends one empty gRPC frame every 5 minutes per in-flight per-device
command — negligible traffic even at fleet scale, but worth knowing about
if you’re diffing agent-to-server wire volume before/after this release.
See ADR-1007 for the full mechanism.
Rolling-upgrade behaviour: the keepalive lives in the agent binary, not
the server — during a staged agent rollout, a per-device command running
on an agent still on a build older than this release has no keepalive
thread, so its claim relies on the same rarer, less reliable renewal
signals this release replaces (a plugin’s own output volume crossing an
internal threshold, or nothing at all for a quiet action). This is a
narrowing of a pre-existing exposure, not a new one — an old agent’s
long-running quiet command was always at risk of this race before this
release; upgrading the server first only protects commands running on
already-upgraded agents. Upgrade agents promptly to get full coverage; per
the standing rule below, never upgrade agents before the server.
A claim is released when the excluded-from device’s prior execution reaches
a terminal state; an execution that never gets a terminal response (agent
crash or disconnect mid-run) has its claim force-released by a periodic
reconciler, bounded by the claim’s own expires_at — worst case, a device
can be excluded from a repeat per-device dispatch of the same definition
for up to one hour (a claim’s lease is capped at one hour regardless of the
originating command’s own expiry). Cancelling an execution does not
release its claim — there is no way to tell the agent to stop, so a
cancelled-but-still-running execution’s claim behaves exactly like a normal
in-flight one until a terminal response or the reconciler’s TTL bound
releases it. A per-device definition dispatched via a workflow step
(rather than a direct POST /api/instructions/:id/execute call or a
schedule) releases on the agent’s real terminal response and renews on its
keepalive the same as every other dispatch path — workflow-step dispatch
still does not correlate a real execution id for the executions drawer
(so its progress won’t show there), but that gap no longer affects claim
timing: it is not release-by-TTL-only. Raw MCP execute_instruction and
any other dispatch call that does not resolve an InstructionDefinition
are unaffected (no definition_id in scope, so this gate never fires for
them), as is the
fleet-broadcast dispatch arm. No operator action is required to upgrade.
Also in this release: DirectorySync::sync_entra (POST /api/directory/sync) now rejects a concurrent call while a sync is already
in progress with 409 Conflict instead of racing — previously two
overlapping sync calls could interleave writes. A caller that retries on
409 behaves correctly; one that treated every non-2xx response as a hard
failure should special-case 409 as “try again shortly.” ConcurrencyManager
(dead code with zero production callers, already flagged in
docs/postgres-migration-ladder.md) is deleted rather than migrated to
Postgres.
⚠️ Behaviour change: buffered analytics events reset on Postgres cutover (ADR-0049)
AnalyticsEventStore (the outbox spool behind /api/analytics/status and
/api/analytics/recent — login/MFA/OIDC/SAML/role-elevation and agent/gateway
command-lifecycle events, drained to any configured JSONL/ClickHouse sink)
moves from the SQLite analytics.db file to the server’s PostgreSQL
substrate in this release (ADR-0049, Wave 2 batch 3, schema
analytics_event_store). Like ResponseStore, this is a fresh-start
cutover with no data migration — the buffer is a transient spool, not
authoritative or compliance evidence (ADR-0009 skippable backfill), so the
legacy analytics.db is never read on upgrade.
What happens on first PG boot:
- The server logs
[PG] analytics spool on Postgres ...at info level — this line appears on every subsequent restart too (it states the store’s steady-state configuration, not a one-time cutover event), so don’t expect it to disappear after the first boot. - Any events buffered but not yet drained to a sink at the moment of cutover are lost. In healthy operation this is bounded by the drain interval (10s default); if a sink was failing before the upgrade, whatever backlog had accumulated is lost too. Already-drained events were already delivered and are unaffected.
- New events buffer and drain normally from first boot. No operator action required.
- If analytics collection is disabled (
--no-analytics), no store is constructed and no log line is emitted for it. If it’s enabled but the schema fails to migrate, the server logs an error ([PG] analytics-event store migration/open failed ...) and the store stays constructed but degraded for that run (is_open()==false) — it keeps acceptingemit()calls, which fail-soft and count astore_not_opendrop, and/api/analytics/status//api/analytics/recentreturn503rather than a200. Either way, a broken analytics store never blocks server startup (this store is the one Postgres-backed store on this ladder that does NOT fail the server closed on a construction failure; see ADR-0049) —/readyz’sdegradedfield (notfailed_stores) names it when it’s on but dead, without affecting the node’s ready/not-ready status. - The store does not retry a failed open.
is_open()is latched once at construction — a Postgres blip that resolves moments after boot still leaves the store degraded for the rest of that process’s uptime. Restart the server once Postgres connectivity for the configured DSN is confirmed restored; there is no in-process self-heal to wait out instead. - The Settings page’s Enabled/Disabled analytics label is not accurate in
the migration-failed case above (governance Gate 6 finding, 2026-08-16) —
it reads the
--no-analyticsconfig flag directly, not whether the store actually opened, so it can still say “Enabled” while a failed migration has left collection degraded for the run. Check/readyz’sdegradedfield orGET /api/analytics/status(503 {"error":{"message":"analytics store degraded"}}in this case — NOT"enabled":false, which is reserved for--no-analytics) for the accurate state, not the Settings page, until this is wired up. - Security note, separate from the cutover itself: this release also
fixes a pre-existing issue in the same store —
AnalyticsEvent.session_idis now a hash of the session cookie, not the raw bearer token. If a token may have reached a shared analytics sink or a broadly-read analytics row before upgrading, see the rotation guidance inchangelog.d/20260816-analytics-session-id-hash.security.md(assembled into the release’s Security section at release time).
RBAC store moves to PostgreSQL (RbacStore → Postgres, ADR-0041)
RbacStore — the authorization substrate holding role definitions,
role→permission grants, principal→role assignments, RBAC groups + membership,
and the global rbac_enabled flag — moves from the SQLite rbac.db file to
the server’s PostgreSQL substrate in this release (ADR-0041, Wave 2.1), schema
rbac_store. No new flag or environment variable is added (it reuses the
shared server PgPool).
No legacy-SQLite migration path. No production fleet ever ran a
pre-Postgres build of this store, so there was no real rbac.db data to carry
over — the mandatory backfill this section originally described (including
the rbac_enabled-flag-first transfer and the move-aside/fingerprint-verify
mechanism) was retired under ADR-0009’s fresh-start-by-default amendment (see
ADR-0041’s Update). These are two SEPARATE behaviors, not one — do not
conflate them:
- A reachable Postgres database whose schema can’t migrate or open is still a fatal startup error (fail-closed), unchanged from before.
- A legacy
rbac.dbfile with real content does NOT fail startup and its content is never imported — the server opens it read-only, purely to count rows inrbac_config(the table that held therbac_enabledflag),securable_types,operations,roles,role_permissions,principal_roles,groups, andgroup_membersfor a diagnostic warning, then boots regardless of what it finds. Fresh installs are unaffected — no legacy file, nothing to warn about.
If you see the legacy-row-count warning and the environment genuinely has
real RBAC config to keep, there is no automated recovery path: re-author
the equivalent roles/grants/groups against the new Postgres-backed store via
the RBAC admin UI/REST API, and re-check rbac_enabled after boot (it seeds
false on a fresh schema — it does NOT carry over from a legacy file, so an
operator who had RBAC enabled must re-enable it explicitly).
Rollback caution. Do not roll back to a build older than this release
while a legacy rbac.db file with real content still sits at its configured
path on any replica. An older binary’s own (now-removed-from-current-code)
backfill logic still runs on that replica and, seeing this database already
past this migration, refuses to auto-migrate rather than silently doing
anything to rbac_enabled — you’ll see a clear startup refusal, not silent
corruption, but it IS a startup refusal. Move the legacy file aside first if
a rollback is genuinely needed.
What to expect / do:
- Routine (not just one-time) boot-time cost, every deployment. Every
server boot’s
seed_defaults()reseed now coordinates its built-in-default grants against any concurrent revoke via a cluster-wide advisory lock (closes a rare Postgres race where a revoked permission could otherwise be silently resurrected mid-boot). This adds a small, ordinarily negligible amount of boot time on every restart, not just first boot — but if you bulk-restart or scale out MANY replicas of the same RBAC-on-Postgres deployment simultaneously, their reseed passes serialize against each other, which can add up at large replica counts. Prefer a rolling (not all-at-once) restart/redeploy strategy for this reason, as you likely already do for other reasons. - Reads now FAIL CLOSED (deny-on-degrade). A degraded or unreachable
rbac_store(pool-acquire timeout, query error) now denies authorization rather than falling through to an allow — this closes the prior “corruptrbac.dbfails open” behavior. Watch the newyuzu_server_rbac_read_degrade_totalmetric and theYuzuRbacReadDegradedalert after upgrade; a degrade denies authz fleet-wide. - Multi-replica staleness caveat. If you run multiple server replicas, a
role/permission/enabled-flag change on one replica is typically visible on
the others within a ~1 s window under normal conditions (a durable
generation token, refreshed at most once per second) — this is a target the
refresh loop aims for, not a hard guarantee: a replica whose refresh is
genuinely slow (pool saturation, a Postgres blip) can observe staleness
beyond the window, now counted via a
stale_beyond_accepted_bounddegrade rather than silently assumed. A revoke is therefore not strictly instantaneous cross-replica — well inside the fleet’s existing revocation-latency envelope (heartbeat + session/token TTLs measured in minutes), and an accepted residual risk (LISTEN/NOTIFYis the named follow-up). - Bounded stale-serve, then fail-fast deny, under backend degradation —
two independent mechanisms. An already-cached authorization decision
keeps answering from cache for up to a ~5 s bound past the last
confirmed-good refresh, regardless of anything else failing (bounded
staleness for continuity) — a brief blip does not deny cached decisions
immediately. Separately, a fail-fast breaker governs pool access, not
cache validity: once it sees 2 consecutive pool-acquire/query
failures, any check that is not already a cache hit denies immediately
rather than blocking on the acquire budget first, and it stops touching
the pool for a ~1 s cooldown between recovery probes. “How fast” depends
on the degradation mode: pool exhaustion (no connection available) trips
the breaker in well under a second (2 × the 250 ms acquire budget); a
query blocked on a PostgreSQL-side lock (e.g. a migration touching
rbac_meta) instead inheritsPgPool’slock_timeout(10 s default) per attempt — measured ~18.5 s for 2 such attempts against a live held lock (#3016). Both converge on the same fail-closed deny, just not at the same speed. Net effect on a sustained outage: previously-seen decisions keep answering for up to ~5 s regardless of breaker state; new/uncached decisions deny once the breaker trips, at whichever of the two speeds above applies; once the 5 s bound elapses, every check denies until the backend recovers. Not just a sustained outage — flapping (repeated short degrade/recover cycles, the shape a managed-Postgres failover or a brief network partition actually produces) is arguably the worse case for this mechanism, not a milder one: the breaker’s closed-state failure streak resets to 0 on a single success, so each recovery — even one that lasts only as long as the next probe — reopens the breaker back to FULL concurrency rather than easing back in; a Postgres backend that is flapping rather than cleanly down can therefore see repeated full- concurrency retry bursts instead of a single clean trip-and-stay-open (tracked for a half-open concurrency cap, see #3016). Blast radius while open is bounded by the shared connection pool, not the breaker itself: every authz check on this replica denies while the breaker is open — including checks against securable types that have nothing to do with whatever degraded — because the breaker gates pool ACCESS, shared across everyRbacStorecaller on the process, not per-query health. No operator action is needed for recovery — the breaker self-heals: once a probe succeeds (the next attempt after its ~1 s cooldown), it closes again automatically and normal service resumes. Watchyuzu_server_rbac_breaker_open(gauge) andyuzu_server_rbac_authz_check_seconds(histogram) after upgrade. A companion alert,YuzuRbacBreakerOpen(docs/prometheus/yuzu-alerts.yml), is added in the current release, not this one: see “Behaviour change: service-scoped tokens, theITServiceOwnerceiling on the fleet-read gate, andGET /api/v1/upload-grants” above. It fires, per replica, when the gauge stays at1for 5 minutes, and complementsYuzuRbacReadDegraded, which is a rate summed across replicas by reason. The gauge changes only when an authorization read reports its outcome, so a replica that receives no authorization checks keeps its last value after the database recovers. - If you alert on the raw
generation_refresh_failedreason label, re-baseline after upgrade. This release splits what was previously a single reason into two:generation_refresh_failed(still denying — unchanged meaning) and the newgeneration_refresh_failed_within_bound(a refresh failure that landed inside the bounded ~5 s stale-serve window above and denied nobody). A custom alert or dashboard built against the pre-upgrade single-reason series may see its rate drop after upgrade — that is the intended effect of the split, not a sign the underlying condition stopped occurring. The shippedYuzuRbacReadDegradedalert already accounts for this (seedocs/prometheus/yuzu-alerts.yml); a custom query built directly againstyuzu_server_rbac_read_degrade_totalshould be reviewed against the reason list inmetrics.mdbefore relying on it post-upgrade. - Shutdown grace bounds now stack; raise your orchestrator’s termination
grace period, but understand what that does and does not buy you. A
graceful
SIGTERMwalks several independently-bounded waits — up to 30 s draining in-flight executions (plus any--shutdown-drain-secondsgrace, HA WS-8, which runs before everything listed here and adds to it — raise your stop timeout by the same amount), up to 5 s on the gRPC shutdown deadline (moved up by #3495 to run earlier in the sequence, ahead of the four joins below and several other quick housekeeping joins not separately listed here, though still after the execution drain — see below), up to 5 s waiting on the NVD-sync background thread, up to 15 s waiting on the HTTP listener thread (#2703 Gate 7 item 2), and (#3261 governance hardening) up to 60 s waiting for WebhookStore and OffloadTargetStore to drain their delivery queues — that stage runs the two stores CONCURRENTLY, not sequentially, so it adds 60 s to the total rather than 120 s, and, as a separate stage right before the audit store is released, up to 15 s waiting for aregistry.writeaudit write that a gateway forwarder thread has in flight (milliseconds unless the database is stuck or slow: a connect or a lock wait can use the whole bound). Stacked, this can reach ~130 s in the worst case if more than one stage is genuinely wedged; the total and each stage’s own bound are unchanged by the reorder, only their relative sequence is. (#3495) The policy evaluation, pre-flight runner, quarantine containment reconciler, and schedule tick background threads’ gRPC dispatch calls now ride on the same 5 s gRPC shutdown deadline bucket above, not a separate stage —ServerImpl::stop()now cancels in-flight gRPC RPCs before joining those four threads (previously it cancelled them after, so a thread genuinely blocked inside a gRPC stream write had no bound at all — not covered by the ~130 s figure, and not fixable by raising the grace period, since nothing instop()would ever reach the cancellation that unblocks it). This is a strictly protective fix — no grace-period change is needed on upgrade, whether or not you ever hit the pre-#3495 gap in practice; it was a real but narrow window (a stalled agent stream during shutdown), not a routine occurrence. One narrower residual is NOT covered by this fix: the policy-evaluation thread’s join can also stall on its own Postgres claim call (PolicyStore::claim_due_policies), which isn’t a gRPC operation and isn’t bounded byShutdown(deadline)— tracked separately (#3706), not fixed here. The shipped docker-compose/systemd units already set a 210 s grace period (stop_grace_period/TimeoutStopSec), which comfortably covers this residual’s ordinary-contention case — but if you deploy under Kubernetes or another orchestrator, its default is frequently far shorter (Kubernetes’ own defaultterminationGracePeriodSecondsis 30), and a pod with slow-draining work in more than one of those stages can beSIGKILLed mid-sequence before the server finishes its own bounded teardown. Raise the grace period to comfortably exceed ~130 s rather than relying on the platform default. Two things a longer grace period does NOT fix: (1) the 30 s drain window re-queriesexecution_tracker_forrunningexecutions on each of its one-second iterations — so it also picks up work that starts mid-drain, not just what was already running whenSIGTERMarrived — but it never stops the HTTP listener from ACCEPTING new requests during that window, so a request that lands late in the drain is not bounded by the ~130 s figure at all; raising the grace period does not close this gap, because the gap is about admission, not about how long the drain itself waits. (2) if the 15 s HTTP-listener bound, the 60 s WebhookStore/OffloadTargetStore bound or the 15 sregistry.writeaudit-write bound IS exceeded, the server force-exits (std::_Exit(1)) on its OWN internal schedule, independent of whatever grace period the orchestrator was configured with — a longer external grace period only prevents the orchestrator fromSIGKILLing the process BEFORE that internal bound fires; it cannot prevent or delay the force-exit itself, and the force-exit skipsoffload_target_store_->flush_all()and any other still-pending teardown the same way aSIGKILLwould (this is also true of the 60 s webhook/ offload bound itself: batched offload events flushed into the delivery queue immediately before the wait are abandoned, not delivered, if that wait times out). Note the 60 s webhook/offload bound is a heuristic sized against this deployment’s overall shutdown budget, not a proven per- delivery ceiling — a slow-but-legitimate delivery target can legitimately push close to it under load; if you see this bound trip in production under otherwise-healthy conditions, that is a signal to look at your configured webhook/offload endpoints’ latency, not necessarily a wedged delivery. If any of these bounds is exceeded, the diagnostic line is written directly to stderr (not through the configured logger, and naming which store timed out for the webhook/offload case) before the process force-exits.stop()now runs on an ordinary thread, not inside the SIGTERM handler (#3007), sospdlogwould be legal here — the raw write stays anyway becausestd::_Exit()skips any buffered sink flush, and this line must reach the operator regardless. If you rely on the log file or a structured log sink rather than captured stderr, this one line will not appear there; check container/service stderr capture for it instead. - Confirm on first boot: no
RbacStoreopen/migrate errors, and that RBAC is enabled if you intend it to be — remember it does NOT carry over from a legacyrbac.db(Settings → RBAC, or check that confined operators see only their scoped fleet).
See docs/auth-architecture.md § “RbacStore — the authorization substrate” and
ADR-0041 for the full design.
⚠️ Breaking: local accounts + MFA enrolments reset (AuthDB/ScimStore → Postgres, ADR-0006)
AuthDB (local user accounts, MFA enrolments, enrollment tokens) and
ScimStore (SCIM-provisioned users/groups) move from the SQLite auth.db
file to the server’s PostgreSQL substrate in this release (ADR-0006 Wave 3),
schemas auth and scim_store. Like the earlier ApiTokenStore cutover,
this is a fresh-start cutover with no data migration: on first boot
against a Postgres database whose auth.users table is empty, the server
seeds exactly the config-file admin and never reads the old auth.db.
Sessions are (and always were) in-memory-only, so they are unaffected beyond
the usual “log in again after a restart.”
Who this affects: every local-password operator, everyone with TOTP MFA
enrolled, and every SCIM-provisioned user/group. This is not limited to
password auth — SSO/OIDC/SAML session establishment itself is unaffected
(the IdP still asserts identity), but any RBAC role/group mapping recorded
only in the old auth.db local-account tables is gone until re-established.
What happens on first PG boot:
- Prior local accounts, roles, and MFA enrolments that existed only in the
pre-cutover
auth.dbare gone. The server re-seeds a single admin account fromyuzu-server.cfg(the same config-as-seed-only behavior as the original v0.12.0 AuthDB bring-up). - SCIM-provisioned users/groups are not lost long-term:
ScimStoreself-heals on the IdP’s next scheduled sync cycle, which re-provisions everything from the IdP as the source of truth. There is a gap between first PG boot and that next sync during which SCIM-provisioned users cannot log in. - Anyone with TOTP MFA enrolled must re-enrol — the encrypted
mfa_totp_secretblob and enrolment state do not carry over. - The server logs a loud boot warning:
AUTH DATA RESET ON POSTGRES CUTOVER. If you do not see this line on first boot against the new Postgres database, the cutover did not happen as expected — investigate before assuming accounts are intact.
Remediate:
- Plan the upgrade in a maintenance window and notify every local-password operator, MFA-enrolled user, and SCIM administrator in advance — expect a batch of failed logins immediately after cutover, not a surprise incident.
- After upgrade, log in as the re-seeded admin using the credentials in
yuzu-server.cfg. - Re-create any other local operator accounts that are not SCIM-provisioned (Settings → Users), and have every user re-enrol MFA (Settings → Security and sign-in → Two-factor sign-in).
- For SCIM-provisioned users, either wait for the next scheduled IdP sync or trigger one manually if your SCIM setup supports it; do not manually re-create SCIM-managed users (they will be re-provisioned).
- Confirm the
AUTH DATA RESET ON POSTGRES CUTOVERline appeared in the server log at the boot where the cutover ran.
Not affected: the PgTestTemplate config, engine principals (a separate
store), and API/MCP bearer tokens (already migrated to Postgres in an
earlier release, see “⚠️ Breaking: API and MCP bearer tokens are invalidated
on upgrade” above — this AuthDB/ScimStore cutover is independent of that one
and does not re-invalidate already-reissued tokens).
See docs/auth-architecture.md § “AuthDB — persistent authentication store”
for the full design rationale, and
docs/ops-runbooks/auth-db-recovery.md
for lockout/break-glass recovery — that runbook has been rewritten for this
cutover and is Postgres-native throughout (psql "$YUZU_POSTGRES_DSN" against
the auth schema).
Notification feed moves to PostgreSQL (NotificationStore, ADR-0046)
NotificationStore — the dashboard toast/badge feed — moves from the SQLite
notifications.db file to the server’s PostgreSQL substrate in this release
(ADR-0006 Wave 2, ADR-0046), schema notification_store. No new flag or
environment variable is added (it reuses the shared server PgPool).
No legacy-SQLite migration path. No production fleet ever ran a pre-Postgres build
of this store, so there was no real notifications.db data to carry over — the
one-time mandatory backfill this section originally described was retired under
ADR-0009’s fresh-start-by-default amendment (see ADR-0046’s Update).
These are two SEPARATE failure/detection behaviors, not one — do not conflate them:
- A reachable Postgres database whose schema can’t migrate or open is a fatal
startup error (fail-closed, matching every other Postgres-migrated store’s
contract): the server logs
[PG] Refusing to startand refuses to serve at all. - A legacy
notifications.dbfile with real content does NOT fail startup and its content is never imported — the server opens it read-only, purely to count rows innotificationsfor a diagnostic warning, then boots fresh-started regardless of what it finds. If that table has rows, it logs aNotificationStorelegacy-row-count warning at WARN; boot proceeds unaffected either way. There is no automated recovery path for old unread/dismissed history — this is display-only state (never an authorization, targeting, or enforcement decision), so a fresh start is acceptable in a way it would not be for an authoritative store. - Fresh installs are unaffected — no legacy file, nothing to warn about.
Not affected: /api/notifications* request/response behavior is
unchanged — this is a storage-engine swap only, no API change.
Audit trail on PostgreSQL — legacy audit.db no longer imported (AuditStore, ADR-0040)
The audit log (AuditStore, the SOC 2 evidence chain) runs on the server’s
PostgreSQL substrate (ADR-0006 Wave 1.3, schema audit_store). No new flag or
environment variable is needed: the store reuses the shared --postgres-dsn /
YUZU_POSTGRES_DSN connection the rest of the server already requires, and
(like every server store) it fails closed at boot if Postgres is
unreachable — there is no SQLite fallback.
No legacy-SQLite migration path. This release retires AuditStore’s
one-time, mandatory, streamed backfill from a legacy audit.db (#3623,
chore/retire-migrate-from-sqlite-auditstore, ADR-0009’s hard-cutover Update)
— no production Yuzu fleet has ever run a pre-Postgres build, and this is a
hard cutover with no migration path held open for any store, including this
one, accepting permanent audit-trail loss as the failure mode if that premise
is ever wrong for a specific host. AuditStore had been the
one deliberate, permanent exception to ADR-0009’s fresh-start default — audit
evidence cannot be regenerated the way config or cache state can — until this
release withdrew it. These are two SEPARATE behaviors, not one — do not
conflate them:
- A reachable Postgres database whose schema can’t migrate or open is still a fatal startup error (fail-closed), unchanged from before.
- A legacy
audit.dbfile with real content does NOT fail startup and its content is never imported. Its permissions are forced to owner-only (0600, plus its-wal/-shm/-journalsidecars) if they aren’t already — this file may hold plaintext secret material from a since-fixed historical bug, same obligation as every other legacy-file-hardening store on this page — POSIX only, a no-op on Windows, #3593 — then the server opens it read-only, purely to count rows inaudit_eventsfor a diagnostic warning, then boots regardless of what it finds. Beyond that row count, its content is never read, and it is never renamed, moved, or written to. Fresh installs are unaffected — no legacy file, nothing to warn about.
If you see the legacy-row-count warning and the environment genuinely has real audit history to keep, there is no automated recovery path: the pre-cutover trail stays exactly where it is, at its configured path (0600 now enforced on POSIX, #3593; content otherwise untouched) — and is your operator-managed record going forward (export it, archive it, keep it under your own retention policy; Yuzu will never import it). The generic “reapply manually” phrasing in the boot warning fits config-shaped stores below (a webhook subscription, a notification preference) where reapplying means re-creating the row through the API; it does not apply here in that sense — an audit trail is historical evidence, not something you recreate, so “reapply” for AuditStore means export-and-archive, never re-import.
Rollback caution. Do not roll back to a build older than this release.
Unlike RbacStore above, this is not just a caution about a leftover legacy
file — an older binary’s own (now-removed-from-current-code) backfill logic
refuses to boot unconditionally, with or without a legacy audit.db
present: seeing this database already past this migration (the marker rows
are gone, but audit_retention_meta itself is not — see ADR-0040’s Update),
its own idempotency-marker write hits the SAME migration’s CHECK constraint
(audit_retention_meta_no_retired_backfill_markers) and fails with a
Postgres 23514 check_violation, table empty or not, legacy file present or
not. This is the same class of guarantee the DROP-TABLE-group stores above
get from their undefined_table error — a schema-level rejection, not a
live-row-count race — with one caveat: if a legacy audit.db is present,
the old binary’s row inserts into audit_events still run and commit before
this final write fails, so the refusal is loud and deterministic but not
zero-data-movement the way a first-touch undefined_table error is. “Just
roll back the binary” is not a supported recovery path once this migration
has run, the same as every DROP-TABLE-group store above. If AuditStore
runs as more than one replica, upgrade every replica and any
--mfa-reset/--break-glass-arm CLI image together during a staged
rollout — a replica or one-shot CLI still on the old binary that boots after
this migration has run on the shared database will refuse to start, same as
any other rollback attempt.
What to do: the YuzuAuditBackfillFailing alert is removed with this
release. If you copied it into your own alerting config instead of
re-importing docs/prometheus/yuzu-alerts.yml, remove it or it fires
permanently via absent_over_time on a stale local copy — a CRITICAL page
that never clears, pointing at boot-log remediation steps that no longer
exist.
Reads deny-on-degrade. An audit-store or connection-pool failure makes
GET /api/v1/audit* return 503 rather than an empty 200, so an
infrastructure blip can never be mistaken for “no audit activity.”
Not affected: the audit event vocabulary and REST/MCP query surface are
unchanged; SIEM export recipes keep working. One deliberate behaviour change: on
a multi-replica deployment an identical-magnitude repeat clock step no longer
re-emits yuzu_server_audit_clock_anomaly_skips_total on every pass (only a
distinct anomaly does) — if you alerted on that counter’s cadence, alert on a
sustained increase instead. See
Audit Log.
Management-group confinement config migrates to Postgres (ADR-0042)
The ManagementGroupStore — the confinement hierarchy that backs operator
scoping and the ADR-0017 authorize_list_read gate — moves from the SQLite
management-groups.db file to the server’s PostgreSQL substrate in this release
(ADR-0006 Wave 2.2), schema management_group_store. It reuses the existing
shared connection pool, so no new connection flag or config is required —
the same --postgres-dsn / YUZU_POSTGRES_DSN that every other server store
uses.
No legacy-SQLite migration path. No production fleet ever ran a
pre-Postgres build of this store, so there was no real management-groups.db
data to carry over — the mandatory backfill this section originally described
was retired under ADR-0009’s fresh-start-by-default amendment (see ADR-0042’s
Update). These are two SEPARATE behaviors, not one:
- A reachable Postgres database whose schema can’t migrate or open is still a fatal startup error (fail-closed), unchanged from before.
- A legacy
management-groups.dbfile with real content does NOT fail startup and its content is never imported — the server opens it read-only, purely to count rows inmanagement_groups,management_group_members, andmanagement_group_rolesfor a diagnostic warning, then boots regardless of what it finds (an empty confinement hierarchy, same as a fresh install). The over-deep/cyclic-tree refusal this section used to describe has no remaining trigger — the write path’s depth-5 cap is now the sole guard, since nothing backfills a legacy tree past it anymore.
If you see the legacy-row-count warning and the environment genuinely has real management groups to keep, there is no automated recovery path: re-author the equivalent groups/memberships/role assignments against the new Postgres-backed store via the REST API.
Rollback caution: see the InventoryStore section’s “Rollback caution” note above — the same unconditional rollback-refusal applies to this store (its own dropped marker table).
Operator-visible behaviour change (fail-closed reads). After cutover, a
confinement-feeding read that degrades (store not open, pool-acquire timeout, or
query error) now returns a distinguishable failure that the caller resolves to
DenyAll — a scoped operator sees a reduced or empty list rather than the
store silently returning an empty deny-set and under-restricting (the old
fail-open hazard). A re-parent (PUT /api/v1/management-groups/{id}) whose
hierarchy reads degrade now returns 503 rather than a misleading success.
Watch the new yuzu_server_mgmt_group_read_degrade_total{reason} counter — a
non-zero rate means scoped operators are seeing reduced/empty lists because the
confinement substrate is degraded, not that groups actually shrank (see
docs/user-manual/metrics.md § “Management group metrics” and the shipped
YuzuMgmtGroupReadDegraded alert).
Not affected: the confinement hierarchy’s semantics, the REST/MCP surface, and dynamic-group scope expressions are unchanged — only the storage substrate and the fail-closed read posture change.
Custom properties migrate to Postgres (ADR-0045)
The CustomPropertiesStore — operator-authored per-agent metadata (properties
and their optional type/validation schemas) used in scope expressions via
props.<key> — moves from the SQLite custom-properties.db file to the
server’s PostgreSQL substrate in this release (ADR-0006 Wave 2), schema
custom_properties_store. It reuses the existing shared connection pool —
no new connection flag or config is required.
No legacy-SQLite migration path. No production fleet ever ran a pre-Postgres build
of this store, so there was no real custom-properties.db data to carry over — the
one-time mandatory backfill this section originally described was retired under
ADR-0009’s fresh-start-by-default amendment (see ADR-0045’s Update).
These are two SEPARATE failure/detection behaviors, not one — do not conflate them:
- A reachable Postgres database whose schema can’t migrate or open is a fatal startup error (fail-closed), same as every other born-on-Postgres store.
- A legacy
custom-properties.dbfile with real content does NOT fail startup and its content is never imported — the server opens it read-only, purely to count rows acrosscustom_properties/custom_property_schemasfor a diagnostic warning, then boots fresh-started regardless of what it finds. If either table has rows, it logs aCustomPropertiesStorelegacy-row-count warning at WARN; boot proceeds unaffected either way. If you see this warning and the environment genuinely has real properties/schemas to keep, there is no automated recovery path: re-author the equivalent properties/schemas against the new Postgres-backed store viaPUT /api/agents/:id/properties/:key/POST /api/property-schemasbefore relying on it. - Fresh installs are unaffected — no legacy file, nothing to warn about.
Operator-visible behaviour change (fail-closed reads). After cutover, a
props.<key>-feeding read that degrades (store not open, pool-acquire
timeout, or query error) now aborts the whole scope evaluation rather than
silently resolving the property as absent — a props.<key>-scoped
dispatch/policy/push rule now matches nobody while degraded, instead of
(under a NOT/!= scope) silently matching everybody. GET /api/agents/:id/properties now returns 503 on a degraded read rather
than an empty list. Watch the new
yuzu_server_custom_properties_read_degrade_total{reason} counter — a
non-zero rate means props.<key>-scoped rules may be silently matching
nobody, not that operators removed the properties (see docs/user-manual/ metrics.md and the shipped YuzuCustomPropertiesReadDegraded alert).
Operator-visible behaviour change (fail-closed writes, 2026-08-14 follow-up).
PUT /api/agents/:id/properties/:key and POST /api/property-schemas now return
503 (instead of 400) when the failure is a genuine database/store outage rather
than caller-input or schema-validation error — previously every failure from either
write, including a transient Postgres blip, surfaced as the same 400 a caller could
not distinguish from their own bad input. A caller that branches specifically on 400
to mean “don’t retry” should treat the new 503s the same as any other transient
server error (retry with backoff); a caller that already treats any 5xx as retryable
is unaffected. See docs/user-manual/rest-api.md’s per-route notes for the exact
response shapes.
Not affected: the props.<key> scope-DSL syntax and property/schema semantics are
unchanged; the GET/DELETE property routes and GET schema route keep their prior
response shapes — only the two write routes’ failure-mode status codes changed, as
described above.
Network-discovered device data migrates to Postgres (DiscoveryStore, ADR-0044)
The DiscoveryStore — the network-discovered devices behind POST /api/discovery/scan
and GET /api/discovery/results — moves from the SQLite discovery.db file to the
server’s PostgreSQL substrate in this release (ADR-0006 Wave 2), schema
discovery_store. It reuses the existing shared connection pool — no new connection
flag or config is required.
No legacy-SQLite migration path. No production fleet ever ran a pre-Postgres build
of this store, so there was no real discovery.db data to carry over — the one-time
mandatory backfill this section originally described was retired under ADR-0009’s
fresh-start-by-default amendment (see ADR-0044’s Update).
These are two SEPARATE failure/detection behaviors, not one — do not conflate them:
- A reachable Postgres database whose schema can’t migrate or open is a fatal startup error (fail-closed), same as every other born-on-Postgres store.
- A legacy
discovery.dbfile with real content does NOT fail startup and its content is never imported — the server opens it read-only, purely to count rows indiscovered_devicesfor a diagnostic warning, then boots fresh-started regardless of what it finds. If that table has rows, it logs aDiscoveryStorelegacy-row-count warning at WARN; boot proceeds unaffected either way. If you see this warning and the environment genuinely has real discovered-device data (particularly the operator-setmanagedflag) to keep, there is no automated recovery path: re-runPOST /api/discovery/scanand re-applymark_managedagainst the new Postgres-backed store before relying on it. - Fresh installs are unaffected — no legacy file, nothing to warn about.
Operator-visible behaviour change (fail-closed reads). GET /api/discovery/results
now returns 503 on a degraded read (store not open, pool-acquire timeout, or query
error) instead of silently rendering an empty device list — previously, a local SQLite
read essentially never failed short of file corruption, so this failure mode was not
practically reachable. Watch the yuzu_server_discovery_read_degrade_total{reason}
counter — a non-zero rate means the discovery view is degraded, not that no devices
were found.
Breaking — POST /api/discovery/scan’s response contract changed. The endpoint no
longer always returns 200 {"status":"ok",...}: the response gains a devices_failed
count, status is "partial" when some but not all devices in a batch persisted, and
the endpoint returns 503 when every attempted device failed to persist (previously
this was silently reported as 200/"ok", with the discovery.scan audit row always
saying "success" regardless of outcome — the audit outcome is now
"success"/"partial"/"failure"). A caller that asserts a bare status == "ok", or
that treats any 5xx from this endpoint as a hard failure needing operator escalation,
should account for the new value and status code. Re-sending the exact same request body
is safe — upsert_device is idempotent per ip_address, so a byte-identical retry cannot
double-count or corrupt already-stored devices — but a fresh re-scan is not the same
thing: mac_address/subnet overwrite unconditionally on every upsert, so a re-scan that
fails to resolve a device’s MAC this time will blank a previously known-good value rather
than simply retry the earlier failure. See the REST API reference’s Network Discovery
section for the full response shapes.
Guardian quarantine records migrate to Postgres (QuarantineStore, ADR-0047)
The QuarantineStore — the Guardian device-quarantine bookkeeping behind
POST /api/v1/quarantine, DELETE /api/v1/quarantine/{agent_id}, and the MCP
quarantine_device tool — moves from the SQLite quarantine.db file to the server’s
PostgreSQL substrate in this release (ADR-0006 Wave 2), schema quarantine_store. It
reuses the existing shared connection pool — no new connection flag or config is
required.
No legacy-SQLite migration path. No production fleet ever ran a pre-Postgres
build of this store, so there was no real quarantine.db data to carry over — the
mandatory, fingerprint-verified backfill this section originally described (all of
the pre-upgrade checks, refusal conditions, and move-aside/fingerprint mechanics)
was retired under ADR-0009’s fresh-start-by-default amendment (see ADR-0047’s
Update). These are two SEPARATE behaviors, not one:
- A reachable Postgres database whose schema can’t migrate or open is still a fatal startup error (fail-closed), unchanged from before.
- A legacy
quarantine.dbfile with real content does NOT fail startup and its content is never imported — the server opens it read-only, purely to count rows inquarantine_recordsfor a diagnostic warning, then boots regardless of what it finds.
This is a stronger warning than it looks, and a deliberate choice despite the
stakes. Unlike most other stores retiring their legacy-SQLite path in this same
release, a genuinely non-empty quarantine.db here means a device an operator
quarantined is not re-quarantined in the server’s view after upgrade — the
server’s view of containment does not include whatever the warning names, though
agent-side firewall enforcement (out of scope for this store) may still be
independently in effect. This was reopened and explicitly reconsidered for this
store given the containment stakes, not defaulted by uniformity with the rest of
this retirement batch — see ADR-0047’s Update for the full reasoning. If you see
the legacy-row-count warning and the environment genuinely has real quarantine
records to keep, there is no automated recovery path: re-quarantine the affected
device(s) via POST /api/v1/quarantine after confirming which devices need it.
Rollback caution: see the InventoryStore section’s “Rollback caution” note above — the same unconditional rollback-refusal applies to this store (its own dropped marker table).
Operator-visible behaviour change (fail-closed reads). GET /api/v1/quarantine
can now return 503 for two DISTINCT reasons — the response body’s message
distinguishes them, since only one is covered by the existing metric:
"quarantine list unavailable — try again"— a genuine store/pool/query degrade (store not open, pool-acquire timeout, or query error) instead of silently rendering an empty quarantine list; previously, a local SQLite read essentially never failed short of file corruption, so this failure mode was not practically reachable. Watch theyuzu_server_quarantine_read_degrade_total{reason}counter — a non-zero rate means the quarantine view is degraded, not that no devices are quarantined."authorization check unavailable — try again"(superseded:GET /api/v1/quarantinenow makes oneSecurity:Readlist-gate decision per request instead of a per-record loop, so this second message no longer occurs; an unavailable authorization store now answers with the gate’s own 503) — the per-record admit-then-filter loop that scopes the list to the caller’s management groups hit an anomalous outcome (neither an explicit allow nor an explicit 403 deny) partway through, most commonly a transient engine-principal-store outage landing between the request’s initial auth check and a later per-record scope check. The whole list fails closed rather than silently omitting just the affected record(s) — the alternative is rendering a partial list that reads as complete. This cause does NOT incrementyuzu_server_quarantine_read_degrade_total— that counter is wired to the store-layer degrade above only; if you see 503s here with the authorization-check message and the counter isn’t moving, the RBAC/engine-principal path is what to investigate, not the quarantine store itself.
Verify: after the server reports ready, GET /api/v1/quarantine (quarantine has
no dashboard surface — REST/MCP only) shows the expected active quarantine records —
either already-live Postgres data on a redeploy, or an empty list plus the
legacy-row-count warning above on a fresh install with a real legacy file.
Not affected: the agent-side quarantine firewall enforcement (WFP/nftables/pf
block-all + exceptions) is untouched by this migration — only the server-side
bookkeeping’s storage substrate changes. POST /api/v1/quarantine and
DELETE /api/v1/quarantine/{agent_id}’s request/response shapes, and the MCP
quarantine_device tool’s ticket-then-recall approval flow, are unchanged.
Device tags migrate to Postgres (TagStore, ADR-0050)
The TagStore — device tags behind GET/PUT/DELETE /api/v1/tags, the legacy
/api/tags* routes, the MCP get_tags/set_tag/delete_tag/search_agents_by_tag
tools, and every tag:<key> scope expression — moves from the SQLite tags.db file
to the server’s PostgreSQL substrate in this release (ADR-0006 Wave 2 batch 3),
schema tag_store, on the existing shared pool. Tags are dispatch-critical:
scope expressions decide which agents a command reaches, and service-scoped API
tokens are confined by the service tag — which is why the read/write failure
modes below fail closed rather than degrading silently.
No legacy-SQLite migration path. No production fleet ever ran a pre-Postgres build
of this store, so there was no real tags.db data to carry over — the one-time
mandatory backfill this section originally described was retired under ADR-0009’s
fresh-start-by-default amendment (see ADR-0050’s Update).
These are two SEPARATE failure/detection behaviors, not one — do not conflate them:
- A reachable Postgres database whose schema can’t migrate or open is a fatal startup error (fail-closed), same as every other born-on-Postgres store.
- A legacy
tags.dbfile with real content does NOT fail startup and its content is never imported — the server opens it read-only, purely to count rows intagsfor a diagnostic warning, then boots fresh-started regardless of what it finds. If that table has rows, it logs aTagStorelegacy-row-count warning at WARN; boot proceeds unaffected either way. If you see this warning and the environment genuinely has real tags to keep, there is no automated recovery path: re-apply the equivalent tags against the new Postgres-backed store viaPUT /api/v1/tags/set_tagbefore relying ontag:-scoped targeting. - Fresh installs are unaffected — no legacy file, nothing to warn about.
Operator-visible behaviour changes (fail-closed reads/writes).
- A degraded tag store now returns 503 (
retry_after_ms: 5000) on the tag REST surfaces and-32603on the MCP tools — never an empty tag list, a falsedeleted:false, or a silent200over a failed write (the legacyPOST /api/tags/setpreviously reportedokeven when nothing was written). A caller whose error handling treats400as “don’t retry” should treat these503s as retryable. - A
tag:<key>-scoped dispatch fails the whole evaluation on a degraded tag read — the operation errors rather than reaching fewer or more devices than the expression names. Watchyuzu_server_tag_store_read_degrade_total{reason}(alertYuzuTagStoreReadDegraded): while it fires, the policy evaluator is also silently skippingtag:-scoped checks (last_check_atstops advancing). - Agent tag syncs are bounded: an agent reporting more than 256 tags in one
Register has the sync refused whole (logged; the agent keeps its prior tag
set). Realistic agents report 5–20. If an agent legitimately exceeds the
cap, reduce what it self-reports (its
scopable_tagscome from the agent’s own configuration/plugins) — there is no server-side override knob; treat a sustained refusal in the server log as an agent-configuration defect.
Verify: after the server reports ready, GET /api/v1/tags?agent_id=<id> (or
the device page) shows the expected tags (re-applied via the REST/MCP surface on a
fresh install, or already-live Postgres data on a redeploy).
⚠️ Behaviour change: quarantine containment now covers IPv6, and reports honestly (#3282, #3283, #3284, #3285, #3286, #3260)
Six issues close against the quarantine agent plugin across Windows, Linux and macOS. Every
one is the same class: an outcome reported as success when it was partial, failed, or unknown.
Read §1 before quarantining a dual-stack Linux host — it needs operator action.
The server-side dispatch enforcement (#881) and the MCP tool’s honesty fixes (#3127) ship separately and have their own upgrade note.
1. Linux containment now covers IPv6 (#3282)
The Linux leg installed its yuzu-quarantine chain in iptables only. On a dual-stack or
IPv6-capable host the device stayed fully reachable over IPv6 while reporting a clean
status|quarantined — containment that was not containing. The plugin now mirrors the chain into
ip6tables, and a failure on either family is reported (status|quarantined_partial), never
counted as applied.
Action required for dual-stack fleets. Whitelist entries are routed to the chain matching
their own family, so a v4-only whitelist now leaves IPv6 contained. There is no blanket
“keep existing connections alive” rule on either family — only a whitelisted address survives,
in any connection state. The agent automatically whitelists its own configured server address
(an IP literal directly, a hostname resolved to its address(es) once, at agent startup —
never at quarantine time, and using the endpoint’s own resolver), so most fleets need no manual
action here. Prefer an IP-literal --server config for endpoints where containment integrity
matters most, since that removes DNS from the equation entirely. If your agents reach the
server over IPv6 through a path DNS resolution of the configured address wouldn’t reproduce
(split-horizon DNS, a manually pinned route), or if the server’s address changes after an agent
was last started, add the server’s address to the whitelist explicitly. See
Security Hardening.
2. Reporting is more conservative again (#3283, #3285, #3286, #3260)
As with the Wave-2 argv migration before it, nothing got more broken — the reporting got more honest, so expect these to fire more often after upgrading:
- macOS: a blocking pf ruleset that is loaded while pf itself is disabled now reads
state|degraded(traffic is not actually blocked); a pf status read that returns nothing recognisable readsstate|uncertain(#3283). quarantine.statusexits non-zero ondegradedanduncertain, and sets the ABI result status toUNAVAILABLE/PARTIAL. A consumer that only inspects the return code can no longer read an unenforced or unreadable host as a clean status (#3285).- Windows: containment now sets the profile default policy rather than relying on rule
precedence alone, and the pre-quarantine profile policy is captured write-once — a
re-quarantine no longer overwrites the genuine capture with the quarantine’s own
block/block policy, which previously made release replay it and strand the host while
reporting
status|released(#3284). - Concurrent mutations are serialised — two overlapping
quarantine/unquarantine/whitelistactions on one host can no longer interleave firewall mutations (#3286).
No migration, no schema change
Rollback is data-safe. A mixed fleet is safe: an older agent simply keeps the older, less honest plugin reporting until it is upgraded.
Product packs migrate to Postgres (ProductPackStore, ADR-0054)
The ProductPackStore — operator-installed product packs behind POST/GET/DELETE /api/product-packs* — moves from the SQLite product-packs.db file to the server’s
PostgreSQL substrate in this release (ADR-0006), schema product_pack_store, on the
existing shared pool.
No legacy-SQLite migration path. No production fleet ever ran a pre-Postgres build
of this store, so there was no real product-packs.db data to carry over — the
one-time mandatory backfill this section originally described was retired under
ADR-0009’s fresh-start-by-default amendment (see ADR-0054’s Update).
These are two SEPARATE failure/detection behaviors, not one — do not conflate them:
- A reachable Postgres database whose schema can’t migrate or open is a fatal startup error (fail-closed), same as every other born-on-Postgres store.
- A legacy
product-packs.dbfile with real content does NOT fail startup and its content is never imported — the server opens it read-only, purely to count rows acrossproduct_packs/product_pack_itemsfor a diagnostic warning, then boots fresh-started regardless of what it finds. If either table has rows, it logs aProductPackStorelegacy-row-count warning at WARN; boot proceeds unaffected either way. If you see this warning and the environment genuinely has real installed packs to keep, there is no automated recovery path: re-install the equivalent packs against the new Postgres-backed store viaPOST /api/product-packsbefore relying on it. - Fresh installs are unaffected — no legacy file, nothing to warn about.
deleted_pack_ids(the uninstall tombstone table) and its coordination lock are unchanged by this retirement — they still exist anduninstall()still writes to them; whether they are now themselves vestigial (their original purpose was guarding against the now-retired backfill resurrecting an uninstalled pack) is a separate, undecided question.
Operator-visible behaviour changes.
GET /api/product-packs,GET /api/product-packs/{id}, andDELETE /api/product-packs/{id}now return HTTP 503 on a genuine database outage instead of a misleadingly-empty pack list or a false “not found”. Watch the newyuzu_server_product_pack_read_degrade_total{reason}counter (alertYuzuProductPackReadDegraded) — while it fires,GET /api/product-packs/GET /api/product-packs/{id}are failing closed rather than returning a silently-wrong result; seedocs/user-manual/metrics.md’s “Product pack store metrics” section for thereasonlabel vocabulary.DELETE /api/product-packs/{id}on a missing id now returns 404 (previously 400).- Breaking — the error body on a rejected
POST /api/product-packsorDELETE /api/product-packs/{id}is now the standard A4 envelope ({"error":{"code","message","correlation_id",...}}) instead of the previous flat{"error": "<message>"}— a client parsing the old flat shape must switch to readingerror.message. A genuine database error no longer echoes raw driver text to the caller (logged server-side instead; seedocs/user-manual/rest-api.md’s Product Packs section). - Installing a bundle whose documents assign the same item id twice now fails the whole install as a 400 validation error instead of silently discarding the duplicate item (a pre-migration bug, not a preserved behavior) — detected before any Postgres interaction, so unlike a genuine database error this is not retryable: the same bundle always fails the same way.
- No change to the
#802/W7.4 signed-pack enforcement default, the Ed25519 signature verification path, or the--allow-unsigned-packs/YUZU_ALLOW_UNSIGNED_PACKSoperator escape hatch.
Verify: after the server reports ready, GET /api/product-packs shows the expected
packs (re-installed via the REST API on a fresh install, or already-live Postgres data
on a redeploy).
Guardian Baselines migrate to Postgres (BaselineStore, ADR-0055)
BaselineStore — Guardian’s deployable Baseline unit (/guardian → Baselines,
GET /api/v1/guaranteed-state/device-compliance) — moves from the SQLite
guardian-baselines.db file to the server’s PostgreSQL substrate in this release,
schema baseline_store, on the existing shared pool. A Baseline is the only
deployable unit in Guardian: what a deploy enforces across the fleet is read from
each Baseline’s deployed_snapshot, never its live member set.
No legacy-SQLite migration path. No production fleet ever ran a pre-Postgres build
of this store, so there was no real guardian-baselines.db data to carry over — the
one-time backfill mechanism this section originally described was retired under
ADR-0009’s fresh-start-by-default amendment (see ADR-0055’s Update).
These are two SEPARATE failure/detection behaviors, not one — do not conflate them:
- A reachable Postgres database whose schema can’t migrate or open is a fatal startup error (fail-closed, matching the ladder’s “authoritative” posture for this store), same as every other born-on-Postgres store.
- A legacy
guardian-baselines.dbfile with real content does NOT fail startup and its content is never imported — the server opens it read-only, purely to count rows acrossguaranteed_state_baselines/guaranteed_state_baseline_rules/guaranteed_state_baseline_groupsfor a diagnostic warning, then boots fresh-started regardless of what it finds. If any of those tables has rows, it logs aBaselineStorelegacy-row-count warning at WARN; boot proceeds unaffected either way. If you see this warning and the environment genuinely has real Baselines to keep, there is no automated recovery path: re-author the equivalent Baselines against the new Postgres-backed store via the Guardian UI/REST API before relying on it. - Fresh installs are unaffected — no legacy file, nothing to warn about.
Operator-visible behaviour changes (fail-closed reads). A degraded read on
the enforcement-feeding path (deployed_member_rule_ids() — the source for the
push fan-out, the heartbeat reconcile, and the per-device compliance view) now
returns a distinguishable failure that the caller resolves to an explicit
abort/503, never a silent empty/“fully compliant” enforced set. GET /api/v1/guaranteed-state/device-compliance returns 503 rather than a
misleadingly-empty or false-compliant result when the underlying read degrades;
Guardian deploy/delete dashboard actions show a degraded-modal rather than
reporting success. There is no dedicated backfill-outcome metric for this store —
a schema-migration failure is boot-fatal and never serves /metrics, so the boot
log and /readyz are the channels to watch during an upgrade.
Verify: after the server reports ready, /guardian shows the expected Baselines
(re-authored via the Guardian UI/REST API on a fresh install, or already-live
Postgres data on a redeploy).
Guardian rules, events, and DEX observations retire their legacy-SQLite path (GuaranteedStateStore, ADR-0038)
GuaranteedStateStore — the live agent-side policy enforcement store (/guardian
rule authoring, drift/remediation events, DEX observations) — no longer copies a
legacy guaranteed-state.db file into Postgres on first boot.
No legacy-SQLite migration path. No production fleet ever ran a pre-Postgres
build of this store, so there was no real guaranteed-state.db data to carry
over — the one-time backfill mechanism this store originally shipped was retired
under ADR-0009’s fresh-start-by-default amendment (see ADR-0038’s Update).
These are two SEPARATE failure/detection behaviors, not one — do not conflate them:
- A reachable Postgres database whose schema can’t migrate or open is a fatal startup error (fail-closed), same as every other born-on-Postgres store.
- A legacy
guaranteed-state.dbfile with real content does NOT fail startup and its content is never imported — the server opens it read-only, purely to count rows acrossguaranteed_state_rules/guardian_meta/guardian_agent_rule_status/guaranteed_state_events/guardian_observationsfor a diagnostic warning, then boots fresh-started regardless of what it finds. If any of those tables has rows, it logs aGuaranteedStateStorelegacy-row-count warning at WARN; boot proceeds unaffected either way. - Fresh installs are unaffected — no legacy file, nothing to warn about.
This is a stronger warning than it looks. Unlike most other stores retiring
their legacy-SQLite path in this same release, a genuinely non-empty
guaranteed-state.db here means real Guardian rules an operator authored are
silently NOT loaded — enforcement resumes from an empty rule set, not the
operator’s prior policy. (Guardian Baselines carry the same consequence — see
the BaselineStore section above.) If you see the legacy-row-count warning above and the
environment genuinely has real rules to keep, there is no automated recovery
path: re-author the equivalent rules against the new Postgres-backed store via
the Guardian UI/REST API before relying on it, and confirm the expected rules
are actually present (see Verify below) rather than assuming they carried over.
Verify: after the server reports ready, /guardian shows the expected rules
(re-authored via the Guardian UI/REST API on a fresh install, or already-live
Postgres data on a redeploy) — do not rely on the absence of the legacy-row-count
warning alone as confirmation that nothing was lost.
Compliance policy engine moves to Postgres (PolicyStore, ADR-0056)
The PolicyStore — compliance policy fragments/policies behind POST/DELETE /api/policy-fragments*, POST/DELETE /api/policies*, POST /api/policies/{id}/{enable, disable,invalidate,evaluate,remediate}, GET /api/compliance*, and every dispatched
compliance check/fix/verify — runs on the server’s PostgreSQL substrate, schema
policy_store, on the existing shared pool. Six operator-authored tables (fragments,
policies, and their input/trigger/group associations, plus per-agent policy_status)
plus a new operational-only seventh table, policy_dispatch_state, which coordinates
fleet-wide compliance-check dispatch across replicas.
No legacy-SQLite migration path. No production fleet ever ran a pre-Postgres build
of this store, so there was no real policies.db data to carry over — the one-time
backfill mechanism this section originally described was retired shortly after this
store’s Postgres migration merged, under ADR-0009’s fresh-start-by-default amendment.
These are two SEPARATE failure/detection behaviors, not one — do not conflate them:
- A reachable Postgres database whose schema can’t migrate or open is a fatal startup error (fail-closed, matching the ladder’s “authoritative” posture for this store), same as every other born-on-Postgres store.
- A legacy
policies.dbfile with real content does NOT fail startup and its content is never imported — the server opens it read-only, purely to count rows for a diagnostic warning, then boots fresh-started regardless of what it finds. If either thepoliciesorpolicy_fragmentstable has rows, it logsA legacy policies.db (<path>) has <N> policy row(s) and <N> fragment row(s) but PolicyStore no longer backfills it...at WARN; if the file exists but its row counts can’t be read (corrupt, unreadable, or a locked file), it logs a similar countless warning instead. Either way, boot proceeds unaffected. If you see either warning and the environment genuinely has real compliance-policy data to keep, there is no automated recovery path: re-author the equivalent fragments and policies against the new Postgres-backed store viaPOST /api/policy-fragmentsandPOST /api/policiesbefore relying on it.
Operator-visible behaviour changes (fail-closed reads/writes).
- A genuine store degrade on any of the six policy/fragment mutator routes,
/evaluate, or/remediatenow returns 503 ({"error":{"code":503,"message":...}}) instead of collapsing into the same400/500a validation error or business rejection gets — the earlier behaviour leaked the internal error string into the response body and gave callers no way to distinguish “retry” from “don’t retry.” - Compliance-check dispatch is now a durable, fleet-wide single-sweeper claim
(
pg_try_advisory_xact_lock) rather than each replica’s own in-memory timer — a persistently-failing claim (compliance checks silently stop running fleet-wide) now logs aterrorand incrementsyuzu_server_policy_eval_errors_total{phase="claim"}, where it previously only warned with no counter. POST /api/policies/{id}/remediatenow rejects (409) a second call for the same policy while a remediation it already started is still in flight — same-process dedup only; a cross-replica race is a documented, tracked gap, not yet closed.- The compliance dashboard’s hero no longer claims a “Last evaluated: just now” freshness that the server was not actually tracking — no dashboard regression, since no timestamp was ever computed; the freshness claim itself has simply been removed rather than shipped false. A real evaluation-health signal is tracked as a follow-up.
Verify: after the server reports ready, GET /api/compliance returns the expected
policies and fleet compliance percentage.
Runtime configuration migrates to Postgres — OIDC client secret now encrypted at rest, config resets on cutover (RuntimeConfigStore, ADR-0060)
RuntimeConfigStore — the Settings-configurable overrides behind GET/PUT /api/config/:key (retention windows, log_level, DEX alert-routing knobs, and the
oidc_* OIDC parameters) — moves from the SQLite runtime-config.db file to the
server’s PostgreSQL substrate in this release, schema runtime_config_store.
WebhookStore and InstructionStore have both since merged; OffloadTargetStore is
now the only server store remaining on the Postgres migration ladder.
Before you upgrade, if you use OIDC/SSO configured through Settings (not
--oidc-client-secret/the environment): record your IdP client secret now. Yuzu
cannot recover it for you — GET /api/config never returns a secret’s real value — and
this cutover does not carry it forward (see below). Without it on hand, restoring SSO
after this upgrade means generating a new client secret with your identity provider.
- Your stored overrides do NOT carry over on this cutover. Per ADR-0009’s
fresh-start-by-default amendment, the legacy
runtime-config.dbis never copied — no production fleet has ever run a pre-Postgres Yuzu build, so the mandate to preserve real operator config across the cutover has nothing real to preserve for this migration. If yourruntime-config.dbDID hold real overrides, the server checks for this at boot and logs a warning naming the exact count found (e.g. “legacy runtime-config.db holds 2 override(s) that will NOT be carried over”). This check only ever inspects the legacy SQLite file — it has no way to know whether you have already reapplied the warned-about overrides, so it repeats on every boot for as long as that file still holds rows, not just the first one after cutover. Reapply your overrides via Settings once, then either delete the legacy file (see below) or ignore the repeated warning — it is expected until you do. Every key starts at its CLI/env default until you do. - This does NOT affect OIDC configured via
--oidc-client-secret/the environment. Only Settings-configured values are reset; a secret supplied at the process level is read at every boot regardless of this store and is untouched by this migration. oidc_client_secretis now encrypted at rest (SecretCodec envelope, AES-256-GCM) instead of plaintext, from the point you reapply it onward.- The legacy
runtime-config.db’s CONTENT is left in place, and may still contain your old OIDC client secret in plaintext. This release never reads its content beyond a boot-time row count, never moves it, never deletes it — but it DOES force the file’s permission bits (and any-wal/-shmsidecar’s) to 0600 on every boot as a defence-in-depth hardening step, so “untouched” applies to content only, not to permission bits. If you previously configuredoidc_client_secret, delete this file (or otherwise secure it) after confirming you no longer need it — it is not required for the server to run, and leaving it in place is a plaintext-credential-on-disk exposure this migration does not close on your behalf. GET/PUT /api/config/:keynow return an honest 503 on a genuine database error, rather than a response indistinguishable from “nothing configured” (GET) or a400validation failure (PUT). No change to either route’s success-path response shape.- Rolling back: because the legacy
runtime-config.db’s content is never moved or renamed (only its permission bits change, per above), reverting to a pre-cutover binary restores your pre-cutover overrides — including a plaintext OIDC client secret, if one was there — exactly as they were. Anything you set through Settings on the Postgres-backed binary lives only in Postgres and is lost on rollback (reapply it after rolling forward again). - No Settings UI or dashboard-visible change beyond the one-time reset. See
security-hardening.mdfor the pre-existing OIDC secret redaction behaviour (unchanged by this migration) anddocs/adr/0060-runtime-config-store-postgres-migration.mdfor the full design.
⚠️ Behaviour change: offload targets reset on Postgres cutover (ADR-0059)
OffloadTargetStore — the response-offload control plane behind
/api/v1/offload-targets — moves from the SQLite offload_targets.db file to the
server’s PostgreSQL substrate in this release, schema offload_target_store, on the
existing shared pool. Like ResponseStore/AnalyticsEventStore, this is a
fresh-start cutover with no data migration (ADR-0009’s 2026-08-25
fresh-start-by-default amendment — no production fleet has ever run a pre-Postgres
build of this store, so there is no legacy backfill to protect), so the legacy
offload_targets.db is never read on upgrade.
Before you upgrade, if you have offload targets configured: the non-secret fields
(name, url, auth_type, event_types, batch_size, enabled) are recoverable via
GET /api/v1/offload-targets — list them now to speed up re-registration after the
cutover. Credential values themselves were never GET-recoverable even before this
migration (auth_credential has always been redacted from every response), so there
is nothing new to lose there — only the non-secret configuration is worth capturing.
What happens on first PG boot:
- The server logs a one-time
OffloadTargetStore initialized (schema offload_target_store) — fresh start, no legacy backfillline. - Every previously-configured offload target and its delivery history is gone; targets
must be re-registered via
POST /api/v1/offload-targets(including credentials — they were never durably exportable in plaintext form to begin with). - No operator action required beyond re-registering targets.
Also in this release: auth_credential is now encrypted at rest app-side
(ADR-0010) rather than a plaintext column; GET/list() responses gain a
has_credential boolean alongside the already-redacted auth_credential. A delivery
whose target’s credential fails to decrypt (KEK unavailable, a tampered/corrupted
stored blob) is skipped entirely and logged with error=credential_unavailable — it is
never fired unsigned. A degraded read on GET /api/v1/offload-targets or
GET /api/v1/offload-targets/{id} now returns 503, distinguishable from a genuine
“no targets configured” (empty list) or “no such id” (404).
Rolling back: because the legacy offload_targets.db is never touched by the
Postgres-backed binary (not read, not moved, not deleted), reverting to a pre-cutover
binary restores whatever was in that file before the cutover, exactly as it was.
Any target registered through POST /api/v1/offload-targets on the Postgres-backed
binary lives only in Postgres and is lost on rollback (re-register it after rolling
forward again).
⚠️ Behaviour change: OTA package catalog resets on Postgres cutover (UpdateRegistry, ADR-0061)
UpdateRegistry — the OTA agent-update catalog behind the gRPC CheckForUpdate/
DownloadUpdate handlers and the Settings → Updates admin page — moves from the SQLite
update_packages.db file to the server’s PostgreSQL substrate in this release, schema
update_registry, on the existing shared pool. Like OffloadTargetStore/ResponseStore,
this is a fresh-start cutover with no data migration (ADR-0009’s 2026-08-25
fresh-start-by-default amendment), so the legacy update_packages.db is never read for
data migration/backfill on upgrade — the server does open it read-only once, at boot, to
count rows and warn if it finds any (see “What happens on first PG boot” below), but never
to carry any of them over.
Before you upgrade, if you have OTA packages configured: list them via the Settings →
Updates page first so you know what to re-upload. Package binaries already on disk under
your configured update_dir are untouched by this cutover — only the metadata row (sha256,
rollout percentage, mandatory flag) is lost — so re-uploading the same file after upgrading
reproduces the identical sha256/size.
What happens on first PG boot (OTA is on by default — see the note below if you’ve passed
--no-ota):
- The server logs a one-time
UpdateRegistry initialized (schema update_registry) — fresh start, no legacy backfillline. - Every previously-configured package’s metadata is gone; agents see “no update available”
until packages are re-uploaded via
POST /api/settings/updates/upload. - No operator action required beyond re-uploading packages.
Also in this release: construction is now fail-closed for OTA (on by default; disable with
--no-ota, not an opt-in flag) — a reachable database whose update_registry schema fails to
migrate/open now refuses to start the server, rather than silently serving with OTA dead (the
pre-migration code had no such check). ca_store/scim_store’s own fail-closed construction
is already unconditional on every deployment (only their probe checks are gated, on whether
you’re using default certs / have SCIM enabled) — UpdateRegistry is simply the first of the
three where that same construction-time posture applies with no opt-out flag set by
default. Plan accordingly if you’re upgrading a fleet you haven’t audited for Postgres
reachability — this now affects the ordinary default deployment, not an opt-in minority. UpdateRegistry now appears in /readyz’s check list and in
/health’s stores object (previously absent from both). On /readyz, a deployment running
with --no-ota sees no change — the check is vacuously true and never enters the failed list. On
/health//api/health, a new stores.update_registry: "ok"/"error" key appears in the JSON
body for every deployment, --no-ota or not.
Rolling back: because the legacy update_packages.db is never written by the
Postgres-backed binary (opened read-only at most once, at boot, for the row-count warning
above — never moved, never deleted, never modified), reverting to a pre-cutover binary
restores whatever was in that file before the cutover, exactly as it was. Any package
uploaded through the Postgres-backed binary lives only in Postgres and is lost on rollback
(re-upload it after rolling forward again) — but its binary on disk under update_dir
survives regardless, since the cutover never touches that directory.
⚠️ Behaviour change: quarantine is now enforced at instruction dispatch (#881, #3127)
Quarantine previously isolated a device’s network without stopping the control plane from
sending it commands. This release closes that, and separately removes two paths on which the
MCP quarantine_device tool claimed an isolation it had not achieved. Read this before
upgrading a deployment that quarantines devices — the alert-rule item needs operator action.
The agent-side plugin containment fixes (IPv6 coverage, and honest reporting on all three platforms) ship separately and have their own upgrade note.
A quarantined device is now refused at dispatch (#881)
Quarantine previously isolated a device’s network without stopping the control plane from sending it commands: the feature was enforced at the endpoint and nowhere else. Instruction dispatch now consults containment state at the server’s single dispatch chokepoint, and a quarantined device is dropped from every targeting arm — by id, by scope, by management group, and by broadcast — before the command reaches the agent.
- Denials increment
yuzu_server_dispatch_target_rejected_total{reason="quarantined"}and write aquarantine.dispatch_deniedaudit row (result=denied). - The quarantine plugin’s own four actions are exempt —
quarantine,unquarantine,status,whitelist— so release stays reachable on a contained host. The exemption is keyed on the action, not the plugin, so a future fifth action arrives gated rather than inheriting the bypass. - Three server-internal pushes are also exempt —
echo.fleet_snapshot,__guard__.push_rulesandasset_tags.sync. These are the server keeping its own state coherent, not operator dispatch, and gating the Guardian push would stop a contained device receiving the enforcement rules that make containment meaningful. If your threat model treats quarantine as a total boundary, note that it is not: those three channels stay open. They are a closed set in code and are counted byyuzu_server_system_reserved_push_total{capability,result}— counted, not per-event audited: there is no row tying a particular push to a particular contained device, so this is a fleet-level signal rather than per-device evidence. Nothing outside that set and the plugin’s own four actions reaches a quarantined device. - Operator-visible: automation that dispatched to quarantined devices and appeared to succeed will now receive denials. That is the correction, not a regression — those commands were never going to be safe to run on a device you had isolated.
Fail-closed under a degraded store. If containment state cannot be read, the server serves a last-known-good snapshot for up to 60 seconds; past that, or when the store is durably unavailable, dispatch fails closed and refuses every target fleet-wide rather than guess who is contained. A background refresher re-reads containment state every 20 seconds, so the snapshot is warm even on a server with no operator traffic — without it the budget would only have absorbed anything on a busy server, which is not when you need it.
The snapshot window cuts both ways, and the other direction is the quieter one. While a
stale snapshot is being served, a device quarantined after that snapshot was taken is absent
from it — so dispatch to that device is permitted, and audited as an ordinary success. For
up to the snapshot’s remaining budget, containment does not hold for a freshly-quarantined
device. The refresher bounds that window to roughly the refresh interval in the common case, and
YuzuQuarantineGateServingStaleState alerts on it; if you quarantine a device while
yuzu_server_quarantine_read_degrade_total is non-zero, confirm with quarantine.status
rather than assuming the dispatch gate is already holding.
There is no operator override. The 60-second budget is a fixed bound, not a tunable, and
there is no flag to disable enforcement — a knob that let an operator turn the gate off under
pressure would be the first thing reached for during exactly the incident it exists for. What
survives a store outage regardless: the quarantine plugin’s own four actions (they never read the
store), and the three server-internal pushes below. So containment, release and whitelist repair
all remain possible while the store is down; what stops is other operator dispatch. Alert on yuzu_server_quarantine_gate_total{outcome="fail_closed"} — it means
instruction dispatch has stopped, and it is an outage signal rather than a quarantine one. All four outcome values are pre-seeded at boot, so a zero is a measurement rather than an absent series — you can alert on fail_closed before it has ever fired. Absence of the family then means the server is not exporting it at all (an older build, or a scrape that is failing), not that the gate is idle: a booted server publishes all four from boot whether or not any dispatch has happened yet.
Alert-rule change (action may be required). YuzuDispatchTargetRejected now excludes
reason="quarantined". Without that exclusion, correct enforcement fires the alert: any looping
automation against one contained host clears its >3/15m threshold, and a fail-closed episode
increments by the whole fleet at once. If you maintain a fork of docs/prometheus/yuzu-alerts.yml,
apply the same exclusion — otherwise the rule gets silenced and the genuine #2500 near-miss signal
goes with it.
MCP quarantine_device no longer reports phantom isolation (#3127)
Two independent bugs made the tool claim containment it had not achieved. Both are fixed, and both are wire-visible to an MCP client:
agents_reached=0(the agent was offline) or a dispatch that threw previously returned a success envelope. It now answers-32603— “quarantine recorded but isolation was not confirmed” — withretry_after_ms: 5000on a first failure. A repeat failure against a device that already has a record backs off to60000: the device is offline rather than busy, and (at the time #3127 shipped) nothing changed until an operator re-issued the call once the device reconnected — a device quarantined while offline was contained at the control plane (dispatch to it was refused) but not at its own firewall until a quarantine dispatch actually reached it. #3425 closed that gap:QuarantineContainmentReconcilernow re-applies the stored whitelist automatically on reconnect (heartbeat-triggered, with a periodic tick backstop), so a manual re-issue is no longer required — it remains harmless if issued anyway. The record still persists; retry the same call to re-drive dispatch if you prefer not to wait for the automatic reconciler.- An already-quarantined device was a terminal error. It is now a retryable re-dispatch
against the stored
reason/whitelist, not the retry’s own arguments — a retry cannot silently rewrite a contained device’s allow-list with no store update and no audit trail.
dispatch_confirmed:true means the plugin registry accepted the frame, never that the device
is provably isolated (for a gateway-attached agent the frame is only queued). Confirming
containment still requires a follow-up status read returning state|active.
Note the REST twin has diverged on one case. POST /api/v1/quarantine records only — it has
never dispatched — so it still answers 400 on an already-quarantined device: there is nothing
for it to re-drive. The 400-vs-503 split is otherwise unchanged.
No migration, no schema change
Rollback is data-safe. A mixed fleet is safe: the dispatch gate is server-side and applies regardless of agent version.
⚠️ Behaviour change: raw dispatch now enforces InstructionDefinition approval governance (#1398)
An InstructionDefinition’s declared approval.mode (role-gated/always) was previously
enforced only on the governed POST /api/instructions/:id/execute path — raw dispatch
(POST /api/command, MCP execute_instruction/execute_bundle/quarantine_device, the
dashboard, workflow, schedules, and /auto Deploy) never resolved a definition, so a caller who
held plain RBAC permission on an action could bypass its declared review requirement entirely by
dispatching directly instead of through the governed path. This release closes that at the single
shared dispatch chokepoint. Read this before upgrading if any automation, integration, or
scripted workflow dispatches commands as a non-admin principal — an operation that previously
succeeded may now be refused.
What changed
Every dispatchable plugin.action pair now carries a compiled ExecuteGate
(None/AdminOrApproval/AlwaysApproval), derived strictest-wins from every shipped
InstructionDefinition targeting that pair. 42 pairs are gated as of this release —
including every script_exec.* action, filesystem.delete_lines/.write_content/.replace,
registry.set_value/.delete_key/.delete_value, interaction.*, network_actions.*,
wol.*, quarantine.quarantine/.unquarantine/.whitelist, rdp_control.set_state,
certificates.delete, storage.*, tags.clear, discovery.scan_subnet, chargen.chargen_start,
http_client.download, installed_apps.list_per_user, services.set_start_mode,
echo.configure, and content_dist.* — the complete, authoritative list is
tests/fixtures/1398_pair_gate_table.json (search for "AdminOrApproval"/"AlwaysApproval",
excluding the server/server_internal/_server-prefixed rows, which are server-internal and
never agent-dispatched).
A gated pair now denies a non-admin caller with no covering approval, regardless of which surface dispatched it:
- REST (
/api/command, and the legacychargen/procfetchroutes):403, naming the gate and pointing at the governed alternative ({"error":{"code":403,"message":"approval required for <plugin>.<action> — ... dispatch it via POST /api/instructions/{id}/execute instead, which supports the approval workflow"}}). Denials are audited (command.dispatch,result=denied,detail=reason=approval_required) and counted (yuzu_server_dispatch_denied_total{reason="approval_required"}). - MCP (
execute_instructionatoperatortier —supervisedtier already goes through the approval workflow): the denial is now a discriminated JSON-RPC error naming the reason (error.data.reason: "approval_required"), not theno_agents_reachedtool result an offline/unreachable agent gets (CLOSED by #3687 — ano_agents_reachedresult no longer needs a manual check for whether a pair is gated before you trust it, aside from the same narrow dry-run-then-real-check TOCTOU window every pre-dispatch authorization check in this codebase accepts).approval_requiredis one of six discriminated reasons, not the only one — the same fix discriminatesUnclassified/Ambiguous/AnonymousOperator/Forbidden/KillSwitchedtoo, so a caller that only added handling for this release’sapproval_requiredchange gets no warning thatForbidden(the most common denial in practice — a caller with no covering grant at all, not just an ungated approval gap) now arrives as the same kind of discriminated JSON-RPC error rather than the oldno_agents_reached/ambiguous-success shape. This coverage is no longerexecute_instruction-only: #3893 extended the identical pre-dispatch dry run (both the C8 pre-mint check and the main-handler backstop) toexecute_bundle(a denied step now refuses the whole bundle call up front — previously an entirely-denied bundle returned a false JSON-RPC success naming the requested step count) andquarantine_device(a denial now runs before the quarantine record is written, and before dispatch, instead of mapping every denial reason to the same “retry” hint an offline device gets). - Schedules: a schedule dispatching a gated pair now requires an approval ticket exactly like
the interactive governed path (
ScheduleRunnermints one and holds the occurrence until an admin approves it via the existing/api/approvalsworkflow) — this was already the behavior for schedules with their ownrequires_approvalflag set; it now ALSO applies whenever the target pair itself is gated, independent of that flag. /autoDeploy: content_dist dispatches are unaffected — the deployment pipeline’s own re-authorization, guarded state transitions, and audit trail are accepted as the equivalent control (ApprovalProvenance::GovernedPipeline), so no new ticket is required there.
How to check your exposure before upgrading
Query the audit log for command.dispatch rows (or your own automation’s recent activity) whose
plugin/action appears in the gated list above, dispatched by a non-admin principal. If you
find any, either grant the dispatching principal an admin role, or migrate that automation to
dispatch through the governed path (POST /api/instructions/:id/execute, or an MCP
supervised-tier token) so it goes through the approval workflow instead of raw dispatch.
Schedule approval tickets are now bound to their reviewed content
A separate hardening in this same release: a schedule’s approval ticket is now bound to the
specific plugin/action it was approved for, not just the definition’s id. If a definition’s
plugin/action is edited (PUT /api/instructions/{id}) after a schedule’s ticket for it was
approved but before the schedule’s next fire, the stale ticket no longer redeems — the schedule
correctly requests a fresh ticket for the new content instead of firing under review that was
never given for it. This is self-healing (no manual database intervention needed): approve the
new ticket the same way you approved the original. You would only notice this if you edit a
scheduled definition’s target action between approval and fire, which is unusual but not
unsupported.
Migration
ApprovalManager’s SQLite schema gains two additive columns (target_plugin, target_action,
migration v8) — no backfill, no data loss, safe on a large existing approvals table (constant
default, metadata-only ALTER TABLE). Rollback is safe: an older binary ignores the two new
columns on read and simply doesn’t populate them on write, which the new binary’s fail-closed
matching correctly treats as “must re-request” rather than a corruption state.
No config flag, no override
There is no flag to disable the new gate. An admin caller, or a caller dispatching via the governed path with a redeemed approval ticket, is not subject to it.
Behaviour change: a rule_id outside the documented charset is now rejected at creation (#4665)
POST /api/v1/guaranteed-state/rules and MCP create_guardian_rule now enforce the documented
rule_id charset ([A-Za-z0-9._-]+, at most 256 bytes) at creation — a request whose rule_id
violates it now gets 400 (REST) or an error (MCP), audited as guaranteed_state.rule.create
denied, instead of being accepted on any non-empty string. This closes an operator-authored
log-injection exposure (#4665): log lines across the Guardian/Spark subsystem now also neutralise
a rule id or Spark key before printing it, but a rule_id that never conformed to the documented
shape (a space, a newline, or any other disallowed byte) could previously still be created.
Who this affects: almost nobody — the charset was already documented before this release, just
not enforced, so create requests generated by the dashboard, the REST/MCP tooling, or any client
following the documented contract are unaffected. This only changes behaviour for a caller that was
relying on the previously-permissive accept-anything-non-empty behaviour to create a rule_id
outside [A-Za-z0-9._-]+ or longer than 256 bytes.
What to do — this is a hard cutover, not a migration, by deliberate decision: enforcement at
the server is create-only, so an existing rule whose rule_id predates this release and doesn’t
conform to the charset is never torn down by the creation check itself. But the server-side push
builder now excludes any such row from every push it builds (logged at error with a sampled
rate limit as Guardian push: rule ... has a rule_id that fails the .../256-byte charset check (#4665) — excluding it from this push, and counted in
yuzu_guardian_push_rule_excluded_total{reason="invalid_rule_id"}), the same way an over-depth
spec_json is already excluded — and the agent’s full_sync path unconditionally tears down and
rebuilds its ENTIRE active rule set from whatever the push actually contains. Put together: the
first full_sync an upgraded agent receives permanently disarms every rule with a non-conforming
rule_id (logged at warn on the agent, matching full_sync is disarming N rule(s) with a rule_id outside the [A-Za-z0-9._-]+/256-byte charset (#4665) -- hard cutover, not preserved). This
is not silent — the server-side exclusion is both logged AND metered
(yuzu_guardian_push_rule_excluded_total); the agent-side disarm is logged only, with no metric of
its own yet — but it IS a real, one-time loss of a previously-enforcing control if you don’t act
first. Every OTHER rule in scope is unaffected either way — this only ever touches rows that were
already unenforceable by the documented contract.
Finding a non-conforming legacy rule_id — do this BEFORE upgrading, not after: GET /api/v1/guaranteed-state/rules returns every rule regardless of its rule_id’s shape; filter the
response for any rule_id that doesn’t match ^[A-Za-z0-9._-]{1,256}$ — that’s the same predicate
the server now enforces at creation. For each one you find, either delete it (see below) and
recreate it under a conforming rule_id, or accept the loss consciously — there is no third option
that preserves it under its existing id.
Removing a non-conforming rule_id: the two delete surfaces have DIFFERENT restrictions, not
a simple REST-restricted/MCP-unrestricted split. REST DELETE /guaranteed-state/rules/{rule_id} routes on the same charset regex the create path now enforces
([A-Za-z0-9._-]+, no length bound), so it can’t reach a rule_id containing a byte outside
that charset, but it has no length cap of its own and so can reach one that’s charset-clean but
over 256 bytes (a shape only possible pre-#4665, when creation had no length bound either). MCP
delete_guardian_rule’s own input schema caps at maxLength: 256 with no charset pattern, so it
has the exact opposite gap: it can reach a charset-violating id (any length up to 256), but
can’t reach one that’s charset-clean and over 256 bytes — the request is rejected by schema
validation before the handler’s own logic ever runs. A rule_id that violates BOTH (wrong charset
AND over-length) is reachable by neither REST nor MCP; fall back to a direct database delete
(guaranteed_state_store.guaranteed_state_rules) for that case.
Behaviour change: preview_scope_targets / scope/preview now resolve from_result_set:/props. atoms correctly (#4981)
POST /api/v1/scope/preview and MCP preview_scope_targets previously evaluated a scope
expression against a bespoke attribute resolver that only understood
ostype/arch/hostname/agent_version/tag:<key> — a from_result_set:<id> or
props.<key> atom silently resolved to unset, so the atom’s comparison was always false, and
NOT from_result_set:<id> inverted that to match every agent the caller could see regardless
of the referenced set’s real membership. Both surfaces now route through the same
fail-closed evaluation ladder a real dispatch uses.
Who this affects: anyone who has called either surface with an expression containing
from_result_set: or props.. If you relied on the old (incorrect) match set for either atom
kind, re-check any automation built on that response before upgrading — the direction of the
correction depends on how the atom was used: a negated NOT from_result_set:<id> was, in
practice, matching your whole visible fleet, and the corrected match set will generally be
narrower and more accurate to what a real dispatch of the same expression would actually
target; a plain (non-negated) from_result_set:/props.<key> atom always evaluated false under
the old resolver, so its match set was previously stuck at 0 and the corrected set will
generally be broader, now actually populated with real members.
New error responses a strict client should handle:
- 403 (REST only) — a service-scoped API token calling
POST /api/v1/scope/previewnow gets denied outright, rather than admitted with a silently narrowed match set (closing the same cross-service-reach gap #4980 closed on a sibling result-set route). MCPpreview_scope_targetsis unaffected by this change — it already denied a service-scoped token outright before this release. - 404
RESULT_SET_NOT_FOUND(REST) /kInvalidParams(MCP) — afrom_result_set:<id>referencing a result set that is absent, expired, or not owned by the caller now aborts, instead of silently matching nothing (or, negated, everything). - 503, with a
retry_after_mshint — a degraded backend store or presence read now aborts instead of under- or over-reporting the match set.
A preview call no longer extends a referenced result set’s TTL as a side effect — it is now a
genuine read-only dry run, matching its documented readOnlyHint: true.
Fresh deployment only for 0.15.0. See the banner at the top of this page: the steps below are not a supported upgrade path from an earlier build’s database.
Upgrade Order
Always upgrade in this order:
- Server — new server versions accept connections from older agents. A server-only restart while a gateway stays connected recovers without operator action once the gateway is also at this version; see What happens when the server restarts (an older gateway keeps the previous behaviour described there)
- Gateway — updated to match server protocol changes
- Agents — can be upgraded via OTA or manually, in batches
Never upgrade agents before the server — the server must understand the agent’s protocol version.
Fresh deployment only for 0.15.0. See the banner at the top of this page: the steps below are not a supported upgrade path from an earlier build’s database.
Pre-Upgrade Checklist
Before upgrading any component:
- Back up all data (see Server Administration)
yuzu-server.cfg,auto-approve.cfg— enrollment tokens and pending agents are PostgreSQL-authoritative since HA WS-6 6.2 (auth.enrollment_tokens/auth.pending_agents); back them up as part of thepg_dumpbelow, not as local files. A pre-6.2 install’senrollment-tokens.cfg/pending-agents.cfgare still worth including in a manual backup taken before that upgrade — they are the one-time import source at the first 6.2 boot (see the rolling-upgrade note below)- The NVD cache
nvd_cves.dbin--data-dir(the one remaining server SQLite store) — usesqlite3 <path> ".backup ..."rather thancpagainst a live database - The blob directories
agent-updates/(or your--update-dir) andupload-blobs/in--data-dir— the database holds only the OTA-package and completed-upload records that point into them, so a restored dump without these directories leaves records with no files - The PostgreSQL database (ADR-0006 — bundled in the composes; provisioned natively by
install-server-postgres.sh) and the whole--ca-dir, taken at the same point in time — usepg_dump --format=custom; see Server Administration § PostgreSQL Substrate for the full backup/restore procedure and the ADR-0010 restore-pairing invariant (DB andKeyProviderkeys-dir backups restore together)
- Upgrading to HA WS-6 6.2 or later, running more than one server replica? Stop
every pre-6.2 replica before starting the first 6.2+ one. The one-time
enrollment-tokens.cfg/pending-agents.cfgimport runs at boot on whichever replica starts first and stamps a content-fingerprint marker inauth.import_meta; a second pre-6.2 replica started afterwards against the same--data-dirfiles would only ever match or mismatch that already-consumed marker, never contribute new state — its own.cfgfiles are simply skipped or, if they differ, refused (never merged). A restored pre-6.2 backup with a fingerprint that no longer matches the liveauth.import_metarow is likewise refused, not merged — look for aCRITICALlog line naming the mismatched file and theyuzu_server_enrollment_import_total{outcome="fingerprint_mismatch"}metric; see ADR-2002 §8 for the marker/fingerprint mechanics. A fingerprint mismatch does NOT block boot — unlike a PG error or read failure with the file present (which refuses to start, the same posture as the first-boot admin seed), a mismatch is logged and the server starts normally with whatever enrollment state is already in Postgres; the stale.cfgfile’s rows are simply never imported, and the file itself is left in place under its original name (not renamed to.imported, since nothing was imported). Recover by comparing the stale file’s rows against the currentauth.enrollment_tokens/auth.pending_agents(dashboard, orpsql), manually recreating anything genuinely missing (mint a new token / re-add the pending agent), then archiving or deleting the leftoverenrollment-tokens.cfg/pending-agents.cfgonce you’ve confirmed nothing in it is needed. - Verify the server’s clock before upgrading (
timedatectl statusorchronyc tracking; under Docker it is the host’s clock that matters). Rows already stamped cannot be protected retroactively by any setting, and a server upgraded to schema v3 while its clock was skewed FORWARD could delete expired audit rows unremarked on its first guarded pass - see Retention clock guards below and #2579. Fixed forward: that pass now declines instead, so the risk is to servers upgrading FROM an affected version with a wrong clock. - Check the CHANGELOG for breaking changes
- Verify disk space (at least 500 MB free for migration)
- Note current version:
yuzu-server --version/yuzu-agent --version - Plan a maintenance window (upgrades take < 5 minutes per component)
- Review new telemetry: this release adds DEX per-application
performance sampling (
procperf), a new usage-class data category. The release that introducedprocperfshipped it off by default; it is now on by default (see “Behaviour change: every Echo capture source is on by default” above) — disable it per host withprocperf_enabled=falseif it is not wanted. For an EU workforce, treat it as a works-council co-determination trigger. See the DEX per-application sampling upgrade note in Server Administration. - New device performance sampling on Linux: this release wires the Echo
perfcollector on Linux, and because it is default-ON, Linux agents begin recording on-device performance samples automatically on agent upgrade (device-level, no user identity; opt out per host withecho.configure perf_enabled=false). These rows carry no per-user or per-app identity, but the project treats the capability to observe as the works-council co-determination trigger, so EU deployments should note the new Linux coverage as they did for the Windows network facts below. Per-application sampling (procperf) is now also implemented on Linux (it shipped opt-in on every OS; it is now on by default — see the Echo behaviour change above). Details and caveats: the upgrade note in the Echo user manual. - New network telemetry on Windows: this release makes Windows agents
emit device-aggregate network facts (throughput + interval retransmit rate) on
the heartbeat, automatically on agent upgrade — gated by the existing
--dex-disable/YUZU_AGENT_DEX_DISABLEflag (no separate opt-in). These carry no per-user or per-application identity (lighter thanprocperf), but the project treats the capability to observe as the works-council co-determination trigger, so EU deployments should note the new Windows coverage as they did for the DEX signals. See Network Quality → Collection & privacy. - New daily installed-software sync (ADR-0016): on agent upgrade, agents
begin syncing their machine-wide installed-software inventory to the server
once per ~24 h over the existing gRPC channel (hash-skip keeps unchanged hosts
to a tiny hash, not the full list). Three operator-visible effects: (a) new daily
outbound
ReportInventorytraffic per agent — adjust egress baselines/firewall expectations; (b) the data lands in a new Postgres schema (software_inventory_store, auto-migrated at boot, fail-closed); (c) it requires theinstalled_appsplugin to be loaded — a build with-Dbuild_agent=false(or a plugin dir missing it) collects nothing, silently (agent logs only at debug). Machine-scope only, no end-user PII (no username collection) — but the data is device-attributable, and on personally-assigned devices installed- software enumeration may be works-council co-determination-relevant (see the works-council note in Installed-Software Inventory). To suppress collection entirely, pass--inventory-disable/ setYUZU_AGENT_INVENTORY_DISABLEon the agent (deploy-time opt-out). Reads are gated on the newInventory:ReadRBAC securable; today the data is queryable via direct SQL (see Installed-Software Inventory). On a non-English fleet, upgrading across #1662 changes stored names: app/publisher names that earlier builds mangled to?(cp1252) are rewritten to correct UTF-8 on each agent’s next daily sync, so any query automation that matched the corrupted?strings will return nothing afterward — see the non-ASCII troubleshooting note in Installed-Software Inventory for the force-resync path. -
installed_apps listrows carry two more fields (breaking for fixed-width parsers): the operatorlistaction now emits seven fields instead of five (…|install_date|install_location|bundle_id). Check any script, SIEM parser or export that readscrossplatform.software.inventoryoutput; agents not yet upgraded keep emitting five and do not escape|, so accept a row only when it has exactly 5 or exactly 7 escape-aware tokens, reading the sixth/seventh only from agents known to be on plugin 1.2.0+. See Server Administration. - New SparkEngine health telemetry (auto-on, engine-health only): on agent
upgrade, agents begin shipping SparkEngine posture tags on the existing
heartbeat (2 keys when quiescent), and the server exposes 11 new
yuzu_fleet_spark_*gauges. The engine is observe-only at this rung — nothing about Guard detection or enforcement changes — and the tags are pure engine-health counts (no user, process, or path identity; no works-council trigger). During a staged rollout, not-yet-upgraded agents are simply absent from the new gauges — expected, see the staged-rollout example in Metrics. Deploy-time opt-out:--spark-disable/YUZU_AGENT_SPARK_DISABLE(the opt-out itself stays visible asyuzu_fleet_spark_disabled). See Guaranteed State. - New Guardian claim-lifecycle health telemetry (auto-on, sparse; dormant in steady state until the Spark path is enabled, #5403, #5404, #4472): on
agent upgrade, agents gain 15 new
yuzu.guardian_*heartbeat tags (13 sparse count tags and 2 pending-age tags), and the server exposes the matchingyuzu_fleet_guardian_*gauges (13 fleet sums and 2 fleet maxima, the latter ending_max).- Steady state. No agent enables the Spark path today, so on an inert fleet every one of these is absent in steady state
(
yuzu.guardian_ack_maint_exceptionsis the one that can appear on an inert fleet: its push-apply source runs whether or not the Spark path is enabled, and ships only if that step throws); an absent gauge means nothing to report, never a measured0. Nothing about Guard detection or enforcement changes, and the tags carry no user, process or path identity. No alert rule ships with them. - Mixed versions. A not-yet-upgraded agent omits the tags, so the absence of a gauge from an old agent reads as healthy even when it is not; upgrade the server first so the gauges exist when the first upgraded agent reports (an older server stores the unknown tags and ignores them).
- At the Spark flip (not part of this upgrade), upgraded agents. They hold the policy generation and keep being re-pushed while a
wedged rule or a compensating teardown is outstanding (each re-push writes a
guaranteed_state.reconcileaudit row, about once per heartbeat per held agent; the agent suppresses the identical re-pushes and applies one in full about every 330 s; if a rule of the hung call’s mechanism type that the push changes, or that is not healthy, is in the push, every re-push after the first of those is applied in full (a healthy, unchanged rule is skipped since #4415); see the Same-type condition in the wedged-rule hold note of Guaranteed State). - At the Spark flip, agents without the #5459 retry suppression. Every agent built from PR #4529 up to the #5459 change carries the earlier three-re-apply waiver; builds from before PR #4529 (release 0.13.0 and earlier) have no waiver and no acknowledgment ledger at all. Such an agent can acknowledge the generation after three re-applies of a wedged rule (and, without the #4472 fix, while a compensating teardown is outstanding), which can leave the rule unarmed under an acknowledged generation if the hung arm later fails (see the wedged-rule hold note in Guaranteed State).
- Telling the two kinds of build apart. The base version number alone does not separate them: release 0.14.0 and its release
candidates, any 0.14.x hotfix cut from them, and dev or main builds from after PR #4529 and before the change all carry the waiver, and dev and main
builds report the base version set in
meson.build(0.14.0when this entry was written) before and after the change.yuzu-agent --versionand the agent’s start-up log print the full version (<version>+<build number>) and the short commit hash, and theyuzu.agent_versionheartbeat tag carries the same full version; a build from source carries the change only if its commit contains the #5459 change. The build number and commit hash are read from git when the build is configured, so a build from source that was not re-configured after pulling can report a stale commit hash. For a release build, use the release notes, which name the first release that carries the #5459 change. The build number isgit rev-list --count HEADat configure time and the release workflow’s checkouts do not set a fetch depth, so a release artifact’s+<build number>may not be a true commit count: compare build numbers only between builds from the same checkout depth, and identify a release build by its release notes and commit hash. - See Guaranteed State and Metrics.
- Steady state. No agent enables the Spark path today, so on an inert fleet every one of these is absent in steady state
(
- Changed agent signal handling (Linux/macOS): graceful shutdown now runs
on a dedicated watcher thread (fixes an abort/hang class on
SIGTERM), and a secondSIGTERM/SIGINTimmediately hard-exits the agent (exit 1) — by design, with no grace window: the second signal is read as “the stop is wedged”. Stop scripts that deliberately double-signal agents will now force-kill them; send one signal and wait instead. On Windows a second Ctrl-C also terminates promptly. See Stopping a wedged agent in Server Administration. - New agent shutdown watchdog (#2233 item 3, “S+”): on upgrade, both
AgentImpl::stop()and the agent’s ownrun()-exit teardown arm a 20-second internal deadline; if guardian/spark/DEX teardown or any other blocking step hasn’t returned within it, the agent self-terminates (hard_exit, exit code 4 — new, distinct from the existing codes 1 and 3 above) instead of hanging indefinitely. You do not need to send a second signal to recover from this class of wedge — sending one just makes the exit happen sooner (code 1) instead of after the 20s deadline (code 4); which code is actually reported is a race if both happen for the same wedge, so treat it as a hint, not a certain diagnosis. On Windows,sc stop/service shutdown is now also bounded by the same 20s watchdog; the agent’s--install-servicerecovery-actions configuration (#1822) auto-restarts on a watchdog fire similarly to the LinuxRestart=alwaysunit, but the exit shows up as a generic unexpected termination rather than one of the SCM’s own “specific error” buckets. During shutdown the agent now waits about 1 second per statement step (5 in normal operation) for akv_store.dbthat another process holds locked (#5604). 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. A lockedkv_store.dbno longer pushes the stage-by-stage shutdown past the deadline in the cases measured (6.03 s largest; see the limits in Guaranteed State); a shutdown-time write to that store can fail where the longer wait would have succeeded. Live in every agent, that can leave the legacy-sink loss counters showing their previous value after the restart, and can lose a plugin’s last shutdown-time storage write or a sync scheduler or license scan write made at shutdown. Once Spark detection is enabled it can also cost one Guardian baseline recapture at the next start (a file that drifted between the lost capture and that start is accepted as the baseline) and lose pending lifecycle-journal records; see the baseline note in Guaranteed State and, after an exit 4, Stopping a wedged agent in Server Administration.AgentImpl::stop()and the run()-exit teardown each arm this deadline separately, and in the worst case the two can compose sequentially to as much as ~40s before either one fires, past the 30s SCM hint — but that specific case is a slow clean stop, not a hang or a restart (a cleanSERVICE_STOPPEDreport doesn’t trigger the recovery actions above either). See Stopping a wedged agent in Server Administration. - Agent logging is now asynchronous, with a new self-exit code 5 (#4666
PR-2): on upgrade, the agent stops writing log lines synchronously on the
thread that produced them and instead hands them off to a dedicated
logging worker thread over a fixed-size, pre-allocated 8192-slot queue
(3.34 MB of RSS, paid regardless of how much is actually logged). Under
sustained overload the queue drops the oldest still-queued lines
(
overrun_oldest) rather than blocking or growing; there is no--log-syncopt-out. Not a breaking change: same log format, same--log-file/rotation behaviour, no new flags, with one exception: the “Received signal, shutting down…” line onSIGINT/SIGTERM/Ctrl-C used to be a raw stderr-only write and now goes through the same configured logger as everything else, so it also lands in--log-fileand picks up JSON formatting under--log-format json— and, since it’s now an ordinaryinfo-level line rather than an unconditional raw write, it is silently absent entirely at--log-level warnor above (previously it always printed regardless of level). The other new operator-visible surface is a fifth self-exit code: tearing down the async logger during shutdown either times out on a 2-second internal watchdog or fails outright, and either cause self-terminates with exit code 5, distinct from the existing 1, 3, and 4. A supervisor script or alert keyed to a fixed exit-code set should widen it to include 5. See Stopping a wedged agent in Server Administration. - Changed server signal handling (Linux/macOS, #3007): the identical fix
as above, now applied to the server — graceful shutdown runs on a dedicated
watcher thread (fixes the same abort/hang class on
SIGTERM, previously reproducible as a debug-buildSIGABRTfrom a deadlock detector), and a secondSIGTERM/SIGINTimmediately hard-exits the server (exit 1) with no grace window. Also new: aSIGTERM/SIGINTarriving before the server finishes starting up now exits promptly with code 1, instead of being silently ignored — a boot-time signal genuinely cannot be handled gracefully, so this is a fail-visible improvement, but it means a very-early stop attempt during a fast redeploy or a mistunedlivenessProbe.initialDelaySeconds(a failing readiness probe only pulls a pod from Service endpoints — it never sends a stop signal; a failing liveness probe is what triggers a kill-and-restart) will now observably exit rather than continue booting. Stop scripts that deliberately double-signal the server will now force-kill it; send one signal and wait instead. See Stopping a wedged server in Server Administration. - Non-English fleets — additional plugins (#1682). The same
Reg*A→Reg*Wencoding fix was extended to four more Windows plugins:vuln_scan(app DisplayName/Publisher/Version in vulnerability findings),os_info(OS ProductName / edition),sccm(client version), andwindows_updates(the WSUSWUServerURL). Unlikeinstalled_apps, these produce transient response data — no stored names are rewritten; each call simply delivers correct UTF-8 immediately after the agent upgrade. Any operator automation that matched previously-mangled non-ASCII strings in those plugins’ responses (e.g. a vuln finding filtered ontitle == "Caf?") will need updating.
Fresh deployment only for 0.15.0. See the banner at the top of this page: the steps below are not a supported upgrade path from an earlier build’s database.
Upgrading the Server
Linux (systemd)
# 1. Stop the server
sudo systemctl stop yuzu-server
# 2. Back up data
sudo cp /var/lib/yuzu/*.db /var/lib/yuzu/backup/
sudo cp /etc/yuzu/*.cfg /var/lib/yuzu/backup/
# ... and the Postgres database, if provisioned. The DSN lives only in the
# root-only env file (NOT in interactive shells) — load it, and keep the
# password off the argv via PGPASSWORD. Full recipe + restore procedure:
# server-admin.md § PostgreSQL Substrate.
sudo sh -c '. /etc/yuzu/yuzu-server.env
export PGPASSWORD="$(printf "%s\n" "$YUZU_POSTGRES_DSN" | sed -E "s!^[a-z]+://[^:/@]*:([^@]*)@.*\$!\1!")"
pg_dump --format=custom --file=/var/lib/yuzu/backup/yuzu-pg.dump \
"$(printf "%s\n" "$YUZU_POSTGRES_DSN" | sed -E "s!^([a-z]+://[^:/@]*):[^@]*@!\1@!")"'
# 3. Replace the binary
sudo cp yuzu-server /usr/local/bin/yuzu-server
sudo chmod +x /usr/local/bin/yuzu-server
# 4. Start the server (schema migrations run automatically)
sudo systemctl start yuzu-server
# 5. Verify
sudo systemctl status yuzu-server
curl -s http://localhost:8080/livez
Docker
Fresh deployment only for 0.15.0. The pull-and-restart upgrade and rollback steps below are historical and are not a supported path for an earlier build’s database or backups (see “Deployment contract (hard cutover)” above). The backup recipe under “Back up before upgrading” is current: it backs up this deployment; restore only a backup taken from this deployment, dump and keys archive together.
The reference deployment template lives at deploy/docker/docker-compose.reference.yml — copy it into your deployment directory next to a .env file, set YUZU_VERSION, and harden per the inline TLS checklist in the file header before exposing the stack to any untrusted network. The compose file declares two named volumes for the server that survive container replacement: server-data holds the remaining mutable state kept on disk — yuzu-server.cfg, auto-approve.cfg, the NVD cache SQLite database, and OTA binaries; certs holds /etc/yuzu/certs, the internal CA and the secrets KEK files that the Postgres data (on postgres-data) depends on. Enrollment tokens and pending agents are PostgreSQL-authoritative (HA WS-6 6.2, auth.enrollment_tokens / auth.pending_agents) — a pre-6.2 volume’s enrollment-tokens.cfg / pending-agents.cfg are imported once, automatically, at the first 6.2+ container’s boot, then renamed to <name>.cfg.imported.
An upgrade is a pull-and-restart:
# 1. Back up first (see below) — the old data is the only recovery path
# if a migration fails on your specific DB.
# 2. Pick the new release tag (use an .env file or export)
export YUZU_VERSION=0.10.1
# 3. Pull the new image and recreate the container
docker compose -f docker-compose.reference.yml pull server
docker compose -f docker-compose.reference.yml up -d server
# 4. Verify the new version came up and schema migrations ran
docker compose -f docker-compose.reference.yml logs server | tail -40
docker compose -f docker-compose.reference.yml ps server # should be "healthy"
Schema migrations execute automatically during the first up with the new image — look for MigrationRunner: <store> migrated to v<N> lines in the log (one per store on first upgrade, silent on subsequent restarts). The healthcheck used by depends_on: service_healthy probes /readyz, which returns 200 only after every store in the readiness conjunction has successfully migrated — so a healthy server container genuinely reflects migration success, not just liveness.
Back up before upgrading. Compose prefixes each volume name with the project name, which is the name of the directory holding the compose file unless you set one (-p, COMPOSE_PROJECT_NAME, or a top-level name:); from deploy/docker the volumes are docker_server-data and docker_certs. docker volume ls prints the full names. Set P to your project name, then run the block from a dedicated backup directory so $PWD is predictable. It checks that both volumes exist first, because docker run -v <name>:... silently creates an empty volume for a name that does not. Postgres is dumped first, then the keys: key files only accumulate, so a keys archive taken after the dump holds every key the dump references (the KEK pairing rule). pg_dump is consistent against a live database, so no stop is needed for it. The keys archive holds the CA private key, so it is written owner-only and handed to you; keep it outside any git checkout.
P=docker
mkdir -p ~/yuzu-backups && chmod 700 ~/yuzu-backups && cd ~/yuzu-backups
STAMP=$(date +%F)
docker volume inspect "${P}_server-data" "${P}_certs" > /dev/null \
&& docker exec yuzu-postgres pg_dump -U postgres --format=custom yuzu > "yuzu-pg-$STAMP.dump" \
&& docker run --rm -v "${P}_server-data":/data -v "$PWD":/backup alpine \
tar czf "/backup/yuzu-data-$STAMP.tar.gz" -C /data . \
&& docker run --rm -v "${P}_certs":/certs -v "$PWD":/backup alpine \
sh -c "umask 077; tar czf /backup/yuzu-keys-$STAMP.tar.gz -C / certs && chown $(id -u):$(id -g) /backup/yuzu-keys-$STAMP.tar.gz" \
&& echo "OK: backup written to $PWD"
Note: this recipe is a cold-ish backup — SQLite is running in WAL mode and a filesystem-level
tarof a live database may capture a torn snapshot. For strong consistency,docker compose -f docker-compose.reference.yml stop serverbefore backup (seconds of downtime) andstartafter. A fully hot backup via SQLite’s online-backup API is tracked in the roadmap. Thepg_dumphalf has no such caveat — logical dumps are transactionally consistent by construction. Never back up Postgres bytar-ing thepostgres-datavolume while the database is running; a torn copy ofpg_wal/is unrecoverable, which is why the procedure above dumps through the database instead.
Restore-pairing (ADR-0010): since 0.14.0 the Postgres dump contains envelope-encrypted secrets (ciphertext + wrapped DEKs only) and is unusable without the matching
KeyProviderkeys directory, thecertsvolume above. Back up and restore the two as a pair: capture order and the full procedure are in the KEK pairing rule; see also Server Administration § PostgreSQL Substrate.
Fresh deployment only for 0.15.0. The steps below are historical and are not a supported upgrade, rollback or restore path for an earlier build’s database or backups (see “Deployment contract (hard cutover)” in Upgrading).
Rollback if a migration fails (Docker). Set P to your compose project name, as in the backup above (docker volume ls prints the full volume names):
# 1. Stop the new server (KEEPING the named volume — do NOT use -v)
docker compose -f docker-compose.reference.yml down server
# 2. Restore the previous backup over the existing volume
docker volume inspect "${P}_server-data" > /dev/null && \
docker run --rm -v "${P}_server-data":/data -v "$PWD":/backup alpine \
sh -c 'rm -rf /data/* && tar xzf /backup/yuzu-data-YYYY-MM-DD.tar.gz -C /data'
# 2b. Restore the Postgres dump (postgres container still running)
docker exec -i yuzu-postgres pg_restore --clean --if-exists --no-owner \
--role=yuzu -U postgres --dbname=yuzu < "yuzu-pg-YYYY-MM-DD.dump"
# 3. Pin the previous release
export YUZU_VERSION=0.9.0
# 4. Start the previous version
docker compose -f docker-compose.reference.yml up -d server
Never run docker compose down -v unless you intend to delete server-data, the keys volume (certs in the reference composes, server-certs in the others: the CA and the secrets KEK), postgres-data (the PostgreSQL substrate — the server’s primary data store), and every bit of server state. down alone is safe; the -v flag removes named volumes.
Windows
# 1. Stop the service (if running as service) or kill the process
Stop-Service yuzu-server # or: taskkill /IM yuzu-server.exe /F
# 2. Back up data
Copy-Item C:\ProgramData\Yuzu\*.db C:\ProgramData\Yuzu\backup\
Copy-Item C:\ProgramData\Yuzu\*.cfg C:\ProgramData\Yuzu\backup\
# 3. Replace the binary
Copy-Item yuzu-server.exe "C:\Program Files\Yuzu\yuzu-server.exe"
# 4. Start
Start-Service yuzu-server # or start manually
Upgrading the Gateway
Breaking: the gateway’s
{grpcbox, ...}configuration moved. The listeners are now configured under{yuzu_gw, [{grpc_listeners, [...]}]}and the channel to the server under{yuzu_gw, [{upstream_client, ...}]}(the value of the old{grpcbox, [{client, ...}]}, unchanged). A gateway config that still has a{grpcbox, [{servers, ...}]}or a{grpcbox, [{client, ...}]}block refuses to start: the gateway no longer contains grpcbox, so nothing would read either block. The gateway image and itssys.configare an atomic pair: an old image with a new config starts, reports healthy and serves nothing. Read “Gateway listeners move togrpc_listeners” and “The upstream channel moves toupstream_client” under Upgrade notes by release below (key mapping, the one edit that covers both, pre-flight check, roll-forward and roll-back) before you replace the image or the config.
If the server was restarted while this gateway stayed connected, see What happens when the server restarts first. The server is upgraded first, so the server restart that ships this fix meets the OLD gateway, and agents behind it can read offline until the gateway is upgraded. Upgrade the gateway and restart it: this version’s gateway then recovers a later server restart by itself. A gateway restart disconnects every agent that gateway holds, so it is a fleet-wide reconnect for it, and released v0.13.0 and v0.14.0-rc6 agents with default settings stayed wedged after a graceful gateway restart in testing (v0.12.0 never re-registered; bug #2182, fixed by #5183, shipped in 0.15.0): upgrade the agents to a build that includes #5183 first, or expect to restart the agent service on those agents (see “Agent dependency” in the section linked above). Whether restarting only the gateway recovers agents stranded by an earlier server restart was not tested.
Linux (systemd)
Breaking (#659): the gateway refuses to start without a non-default Erlang distribution cookie.
.deb/.rpminstalls auto-generate/etc/yuzu/gateway.env; for tarball/manual installs create it once (see “Gateway distribution cookie now required” under Upgrade notes by release below) before the restart step.Breaking (
#4555): the cookie must additionally be 32+ characters (every documented generation path already clears this), and the gateway’s Erlang node short name changed fromyuzu_gw1to a sharedyuzu_gw— update any external tooling hardcoding the old full node name. See “Gateway distribution-cookie length floor + shared node identity” below.
The release directory holds the gateway’s only configuration file,
/opt/yuzu_gw/releases/<vsn>/sys.config, so replacing the directory deletes your config. Save
it first, restore it after, pre-flight it, and only then start (a config from before this
release must be converted first: see Upgrade notes by release below):
sudo systemctl stop yuzu-gateway
# 1. Save the operator-edited config; it lives inside the directory removed next
sudo cp /opt/yuzu_gw/releases/*/sys.config /root/yuzu-gw-sys.config.saved
# 2. Replace the release directory
sudo rm -rf /opt/yuzu_gw
sudo tar xzf yuzu-gateway-linux-x64.tar.gz -C /opt/
# 3. Restore the config (converted if the release needs it)
sudo cp /root/yuzu-gw-sys.config.saved /opt/yuzu_gw/releases/*/sys.config
# 4. Pre-flight the NEW release against that config, with the production YUZU_GW_*
# variables exported (for example: set -a; . /etc/yuzu/gateway.env). Go on only at exit 0.
R=/opt/yuzu_gw; V=$(basename $R/releases/*/); C=$R/releases/$V/sys.config
$R/erts-*/bin/erl -noshell -noinput -boot $R/releases/$V/start_clean -config "$C" \
-pa $R/lib/*/ebin -eval '
Load = [application:load(yuzu_gw)], yuzu_gw_app:apply_env_overrides(),
Results = Load ++ [yuzu_gw_app:check_legacy_grpcbox_servers(), yuzu_gw_app:check_legacy_grpcbox_client(),
yuzu_gw_app:check_grpc_listeners(), yuzu_gw_app:check_upstream_client_config(),
yuzu_gw_app:check_upstream_channel_mix(), yuzu_gw_app:check_upstream_tls_posture(),
yuzu_gw_app:check_mgmt_listener_posture()],
io:format("PREFLIGHT ~p~n", [Results]),
halt(case lists:all(fun(X) -> X =:= ok end, Results) of true -> 0; false -> 1 end).'
# 5. Start, then verify
sudo systemctl start yuzu-gateway
curl -s -o /dev/null -w '%{http_code}\n' localhost:8081/readyz # 200
ss -ltn | grep -E ':50051|:50063' # both listening
Step 4 is the pre-flight described under Upgrade notes by release. Skip it and a config the new release refuses shows up as a restart loop under systemd instead.
Packages (rpm -U, dpkg -i). A package upgrade replaces the operator-edited
/opt/yuzu_gw/releases/<vsn>/sys.config with the stock config, which is the plaintext dev
config (upstream http to 127.0.0.1:50055), and then restarts the gateway on it: the gateway
boots, /readyz answers 200, and every registration fails. This is how the package has always
behaved (the file is neither marked as configuration nor kept as a package conffile, so no
.rpmsave is made); the root cause is tracked in the repository issue tracker. Until it is
fixed, do the same by hand around the package step: copy releases/*/sys.config somewhere safe
before rpm -U / dpkg -i, copy it back after, run the pre-flight above (step 4) and
systemctl restart yuzu-gateway, then verify as in step 5. The release version directory
changes between releases, so copy the file to the new releases/<vsn>/ directory. /etc/yuzu/gateway.env
(the cookie) is not touched by the package.
Docker
docker pull ghcr.io/<owner>/yuzu-gateway:<version>
docker compose up -d yuzu-gateway
docker compose up -d is not a rolling update: it recreates every container of the service
that changed in one step, so a config the new image refuses (it exits 1 at once and restarts in a
loop) takes all of them down together. Mounted config and image change together or not at all.
Update one gateway at a time, as a service of its own, with docker compose up -d --no-deps <gateway-service>, and check curl -s localhost:8081/readyz and the logs before the next.
Kubernetes / OpenShift
Nothing here has been run on a cluster; every point below is reasoned from the image and the code and is unverified: test it on the OpenShift target before relying on it.
- Config and image change in one revision. Mount
sys.configfrom a ConfigMap whose name carries a version (yuzu-gateway-sys-config-<n>) and change the name in the same Deployment revision as the image tag. An edited ConfigMap under an unchanged name is not picked up until the pods restart, and a new image on an old config refuses to boot. - A refused boot keeps the old pods. The new pod exits 1 and goes to
CrashLoopBackOff, never becomes ready, and the rolling update does not remove old pods that are still ready (reasoned from Deployment behaviour, not run). Read the new pod’s log for theRefusing to startline, fix the ConfigMap, and roll out again;kubectl rollout undorestores image and ConfigMap name together. - Probes. Liveness is
GET /healthzand readinessGET /readyz, both on port 8081./readyzdoes not test that the server is reachable: it stays 200 while the server is down until the circuit breaker opens, and says nothing about a connection that is up but dead. Gate a rollout onyuzu_gw_agents_currentclimbing or on the error rate ofyuzu_gw_upstream_rpc_errors_total, not on readiness alone. - Security context (OpenShift
restricted-v2). The image runs as the named useryuzu-gw(USER yuzu-gw); the SCC assigns an arbitrary UID, so files that must be read through the user name will not be. Certificates are shared through the fixed groupyuzu-pki, gid 2000, which is probably outside the namespace’ssupplementalGroupsrange: setfsGroup, or give the certificate Secret volume adefaultModethe assigned UID can read, rather than relying on the image’s group membership.
Upgrading Agents
OTA (Recommended)
If the server has OTA updates enabled (--update-dir):
- Place the new agent binary in the server’s update directory
- Agents will check for updates on their configured interval (default: 6 hours)
- Monitor the fleet dashboard for version rollout progress
Manual
Follow the same stop/replace/start pattern as the server, per platform.
Batch Rollout
For large fleets, upgrade in stages:
- Upgrade a pilot group (5-10 agents) first
- Monitor for 24 hours
- Roll out to remaining agents in batches of 10-20%
Plugin Code Signing (vNEXT, #80)
A new operator-managed plugin trust bundle ships in this release. Default behaviour is unchanged: agents that don’t pass --plugin-trust-bundle and operators that don’t upload a bundle through Settings → Plugin Code Signing see identical behaviour to prior releases.
If you turn the feature on, three things change:
-
New on-disk artifact at
<cert-dir>/plugin-trust-bundle.pem. Linux/macOS:/etc/yuzu/certs/plugin-trust-bundle.pem; Windows:C:\ProgramData\Yuzu\certs\plugin-trust-bundle.pem. Add this path to your backup procedure alongside the SQLite databases. A backup that captures the DBs but not the cert dir restoresplugin_signing_required=true(inruntime_config) without the bundle, and require-mode agents reject every plugin until the bundle is restored. The Docker referencedocker-compose.reference.ymlmountsserver-dataandcerts(the cert dir); include both in the backup script. A compose of your own must keep the cert dir on a named volume too. -
Cert-dir filename collision check. The server now treats
plugin-trust-bundle.pemin the cert dir as authoritative. The filename was unused in prior releases, but if any deployment placed an unrelated PEM at that exact path for another purpose, it will be interpreted as the plugin trust bundle on first read after upgrade. Runls <cert-dir>/plugin-trust-bundle.pemon every server host before upgrading and rename any pre-existing file. -
DO NOT enable “Require signed plugins” yet. The Yuzu release pipeline does not currently sign the 44 in-tree plugins under
agents/plugins/. Enabling Require with an operator-only trust bundle will reject every Yuzu-shipped plugin on next agent restart — fleet-wide outage. Use the transitional mode (bundle uploaded, Require off) until you have signed every plugin your fleet uses, including the in-tree ones. The Settings card displays this warning inline.
The new audit actions (plugin_signing.bundle.uploaded / .cleared / .require.changed) and metric labels on yuzu_agent_plugin_rejected_total (signature_missing / signature_invalid / signature_untrusted_chain) are documented in audit-log.md and metrics.md respectively. SIEM and alert rules already filtering on the existing success/failure/denied audit vocabulary pick these up unchanged — no new vocabulary tokens were introduced.
Schema Migrations
Starting with v0.10.0, every server-side SQLite store is wired through a single MigrationRunner that tracks schema version per store and applies pending migrations in a transaction. Prior releases relied on CREATE TABLE IF NOT EXISTS plus silent ALTER TABLE ADD COLUMN statements, which made rollbacks opaque and left no audit trail of what had been applied.
How it works:
- Each store declares an ordered
std::vector<Migration>where each entry is{version, sql}. - On startup, the runner creates the
schema_metatable if missing, reads the current version for the store, and runs any migration with a higher version number inside aBEGIN IMMEDIATE/COMMITtransaction. - If a migration SQL statement fails, the transaction rolls back and the store stays at its previous version — the server logs
MigrationRunner: migration v<N> failed for <store>: <sqlite error>and the corresponding store constructor logs<Store>: schema migration failed. - Already-applied migrations are skipped; running the same server binary twice against the same database is a no-op.
- Multiple stores share one database connection but keep independent version counters.
Indexes are normally migration entries. audit_store’s retention index is a
deliberate, single exception (idx_audit_ttl_id, #2360) - every other index in
the server is created inside MigrationRunner, and new code should follow that
rule, not this one. Two conditions justify the exception here and both must hold
before it is copied: (a) a failed migration closes the store, and for
audit_store that means every audit write then fails, taking the SOC 2 trail
offline; and (b) the build is O(N log N) over an existing multi-million-row
table, unlike the v1 indexes which are created on an empty one. A best-effort
object may silently not exist, so nothing with a CORRECTNESS dependency may use
this path - the retention guard is correct without its index, only slower.
Cost on a large existing audit_events: a one-time build at first boot after
upgrade, measured at ~81 MB and ~1.8-3.3 s at 5M rows on NVMe. At 50M rows the
build reads a ~16 GB table - tens of seconds on local NVMe, and potentially
minutes on container overlayfs or network storage.
This runs synchronously, before the server binds its listeners, so nothing
answers /livez or /readyz until it finishes. If your orchestrator’s
kill-before-ready budget is shorter than the build you get a CRASH LOOP rather
than a slow boot: CREATE INDEX is one atomic statement, so every killed attempt
rolls back and the next boot re-pays it in full, indefinitely. Budget at least
5 minutes before the first liveness kill for an audit_events above ~20M
rows on non-NVMe storage. The shipped
deploy/docker/docker-compose.reference.yml sets start_period: 30s, which is
fine for a fresh install and NOT enough for a large first post-upgrade boot -
raise it before upgrading such a deployment. The
elapsed time is logged when it exceeds a second, and subsequent boots are a
no-op. If the build fails, retention still runs, but each pass then scans the
table AND sorts the whole expired backlog for its ORDER BY ... LIMIT (measured
2.0 s versus ~285-315 ms for the same capped pass WITH the index; the range
reflects different benchmark runs, not two different operations) - still far better than the unguarded code it
replaced, but the failure is logged as an error.
Upgrading from v0.9.x or earlier is data-preserving: the first 0.10.x startup stamps every database at schema v1. A small set of stores (api_token_store, instruction_store, patch_manager, policy_store, product_pack_store, response_store) also runs a one-time legacy compatibility shim that re-applies the historical ALTER TABLE statements before stamping, so databases from very old releases that never received those columns still converge to the latest schema. These shims are kept in code for one release cycle and can be removed after v0.11.
No manual migration steps are required. Just replace the binary (or pull the new image and up -d) and start the server. Migration progress is logged at info level as:
[info] MigrationRunner: rbac_store migrated to v1
[info] MigrationRunner: response_store migrated to v1
...
Expect a log burst on first startup after upgrade. 30+ MigrationRunner: <store> migrated to v1 info lines appear on the first run against a pre-v0.10 database — one per store. On every subsequent restart the runner is silent at info level. If your log-shipping pipeline has per-second rate limits, widen them for the upgrade window or filter this single line pattern.
Verifying migration state after startup, query the per-store audit trail directly:
docker exec -i yuzu-server sqlite3 /var/lib/yuzu/rbac.db \
"SELECT store, version, datetime(upgraded_at, 'unixepoch') FROM schema_meta ORDER BY upgraded_at;"
Every store that has ever run through the migration runner has a row here with its current version and the wall-clock timestamp of the last stamp. This is the operator-side audit trail for schema evolution.
The Postgres-backed stores are not in that file. Stores migrated onto the PostgreSQL
substrate — audit_store among them (ADR-0040) — run through PgMigrationRunner, log
PgMigrationRunner: <store> migrated to v<N>, and stamp public.schema_meta in
PostgreSQL. Query those there instead:
psql "$YUZU_POSTGRES_DSN" -c \
"SELECT store, version, to_timestamp(upgraded_at) FROM public.schema_meta ORDER BY upgraded_at;"
For audit_store specifically, a legacy audit.db — if one still exists — is never renamed,
moved, or modified: Yuzu only opens it read-only, at boot, to warn if it holds rows (see the
Audit trail section above). sqlite3 /var/lib/yuzu/audit.db still opens it normally on a
migrated server; there is nothing to restore or account for here.
If a migration fails:
- Check the log for
MigrationRunner: migration v<N> failed for <store>: <sqlite error>and note both the store name and the SQLite error. - The server will have closed the failing store’s database handle, so
/readyzreturns 503 with the failed store name in thefailed_storesbody field — the probe accurately reflects degraded state. Don’t rely on/livezfor readiness; it only checks process liveness, not schema integrity. - Stop the server and restore the affected database file from backup — not the whole data directory. Restoring all databases to fix one broken store wipes in-flight approvals, pending agents, and enrollment tokens. This step does not apply to a Postgres-backed store such as
audit_store: there is no per-store file to restore, so use the Postgres dump procedure under “Rollback if a migration fails” above. - Start the previous server version against the restored data.
- Open an issue with the full error line, the source/target version numbers, and the output of the
schema_metaquery above.
Upgrade notes by release
vNEXT — Decommission securable replaces the SLE/Inventory/GuaranteedState conjunction (breaking for custom roles)
DELETE /api/v1/sle/agents/{id} (the whole-device erasure cascade — see
REST API § Software Licensing (SLE)) used to gate on a
three-securable conjunction: SoftwareLicensing:Delete and Inventory:Delete and
GuaranteedState:Delete, all required together. A companion Wave 7 PR7.2 package adds a sixth
per-agent store (app_usage) to the cascade (and #5682 a seventh, offline_endpoint, for seven
in total), and rather than grow the conjunction to a fourth securable, ADR-0024 Decision 9 was
amended ahead of that landing to promote a single dedicated Decommission securable — the
route now gates on Decommission:Delete alone, replacing the old conjunction outright (not
adding to it).
Seeded roles are auto-preserved — the same INSERT OR IGNORE role-default seeding as the SLE
securable above runs on every boot, so Administrator (full CRUD via the generic per-securable
loop) and ITServiceOwner (a targeted Decommission:Delete grant) pick up the new securable
automatically on upgrade, with no operator action required.
A previously revoked decommission ability is NOT silently regained. If you had used
remove_permission() to revoke ITServiceOwner’s SoftwareLicensing:Delete,
Inventory:Delete, or GuaranteedState:Delete specifically to strip its decommission ability
under the old conjunction, the upgrade detects that revocation and carries it forward onto the new
Decommission:Delete grant automatically — ITServiceOwner stays unable to decommission a device
after upgrading, exactly as before. No operator action is needed for this case; it is the inverse
of the custom-role gap above (a seeded role losing access it deliberately had revoked is checked
for, not just a custom role gaining an unrelated 403).
This is breaking only for a custom role. Any custom role an operator built by hand-assembling
the old three-securable conjunction (SoftwareLicensing:Delete + Inventory:Delete +
GuaranteedState:Delete) specifically to reach this endpoint will start getting 403 Decommission:Delete
on its next call — the three old grants no longer suffice, because the route no longer checks
them at all. Audit your custom roles before upgrading:
SELECT pr.principal_type, pr.principal_id, pr.role_name
FROM rbac_store.principal_roles pr
JOIN rbac_store.role_permissions rp ON rp.role_name = pr.role_name
WHERE rp.securable_type IN ('SoftwareLicensing', 'Inventory', 'GuaranteedState')
AND rp.operation = 'Delete'
AND rp.effect = 'allow'
GROUP BY pr.principal_type, pr.principal_id, pr.role_name
HAVING COUNT(DISTINCT rp.securable_type) = 3;
The old gate was a strict three-way AND — all three grants required together, effect = 'allow'
on each. Matching on any ONE of the three (or including an explicit effect = 'deny' row, a legal
role_permissions state) over-reports: a role holding, say, only Inventory:Delete for an
unrelated purpose never had decommission ability under the old conjunction, and granting it
Decommission:Delete now would be a NEW grant, not a preserved one.
Grant Decommission:Delete to any custom role in that list that must retain the ability to erase a
decommissioned device’s data. SoftwareLicensing:Delete, Inventory:Delete, and
GuaranteedState:Delete continue to gate their own unrelated surfaces exactly as before — only the
erasure cascade’s gate moved.
Rolling upgrade (the seventh store, offline_endpoint). During a rolling upgrade, a DELETE
served by a replica that has not been upgraded runs the old cascade, returns 200, and does not
erase the presence row; a response whose stores map has no offline_endpoint key did not erase
it, and the sle.agent.decommission success audit row that replica writes lists its own stores
(no offline_endpoint), so it is not evidence the presence row was erased. Re-issue the DELETE
once every replica runs this version.
vNEXT — Hardware CI list requires Inventory:Read, not the old Devices page’s Infrastructure:Read (breaking for custom roles)
/devices now 302-redirects to /hardware (the Hardware CI list) and /device?id= redirects to
/hardware/ci?id= — bookmarked links and existing browser navigation keep working transparently,
since browsers follow redirects. The permission the list itself checks changed, though: the old
Devices page gated on Infrastructure:Read; the Hardware CI list gates on Inventory:Read (the
same securable the rest of the Inventory/Software surface already uses).
Seeded roles are unaffected. Every built-in role that held Infrastructure:Read (Administrator,
ITServiceOwner) also holds Inventory:Read, so no default-role regression on upgrade.
This is breaking only for a custom role granted Infrastructure:Read without Inventory:Read.
Such a role could see the device list via the old /devices page pre-upgrade and will get a 403
reaching the redirected /hardware page post-upgrade. Audit custom roles before upgrading:
SELECT pr.principal_type, pr.principal_id, pr.role_name
FROM rbac_store.principal_roles pr
JOIN rbac_store.role_permissions rp ON rp.role_name = pr.role_name
WHERE rp.securable_type = 'Infrastructure' AND rp.operation = 'Read' AND rp.effect = 'allow'
AND NOT EXISTS (
SELECT 1 FROM rbac_store.role_permissions rp2
WHERE rp2.role_name = pr.role_name
AND rp2.securable_type = 'Inventory' AND rp2.operation = 'Read' AND rp2.effect = 'allow'
);
Grant Inventory:Read to any custom role in that list that must retain device-list visibility.
Infrastructure:Read continues to gate the fleet-wide agent list (/api/agents) and other
infrastructure-tier reads exactly as before — only the Hardware CI list’s own gate moved.
Retention clock guards (#2360 server audit store, #2361 Echo agent warehouse, #2964 rotation sweep)
Both retention paths used to issue an unbounded DELETE driven by the local wall
clock, so one forward clock step could wipe a store in a single statement. They
are now guarded and capped. Two operator-visible consequences on upgrade:
- Audit retention is now a floor, not a ceiling. A pass that would expire
every datable row declines once, and every accepted pass is capped at 25,000
rows, so a large backlog ages out over hours rather than in one statement.
Watch
yuzu_server_audit_retention_cap_reached_totalalongsideyuzu_server_audit_rows_deleted_totalto see whether a backlog is draining. The cap paces at two different cadences depending on whether a backlog is forming — see Audit Log § Capacity for both figures; comparing your event rate against only the quiet-operation one is overly conservative by roughly three orders of magnitude. Note that changing--audit-retention-daysnever re-dates existing rows (ttl_expires_atis stamped at INSERT), so a reduction does not reclaim disk retroactively. Operator triage when the guard declines a pass: audit-log.md § The retention clock guard. audit_storegains schema v3 (a smallaudit_retention_metakey/value table holding the durable clock reading - one row, instant) plus the best-effort index build described under Schema Migrations above.YuzuAuditRetentionNotRunningnow fires for a crash-looping server (#2553, redesigned by #2854 rung D), and there is a newYuzuAuditRetentionNeverRanalert to route. The old rule’s young-server grace keyed on uptime, so a process restarting more often than the 3-hour alert window never accumulated enough uptime to leave the grace and was excused on every evaluation - silently, and for one of the leading causes of the exact condition the rule detects. The grace is now the restart-surviving last-pass stamp (yuzu_server_audit_retention_last_pass_unixtime, seeded from the durable anchor at startup): the rule excuses only a database no retention pass has EVER run against, fires at every restart cadence on an anchored one, and stays firing while the reaper stays dead. The excused never-ran state gets its own alert,YuzuAuditRetentionNeverRan(stamp still0after 3 hours) - a new alertname: give it an Alertmanager route. If you re-apply this rules file and have a crash-looping server, expect a new-to-you firing that reflects a pre-existing condition rather than a new fault - including on crash loops faster than the 60-minute first-pass sleep, a true positive the old grace hid. The rules file is a copy you apply yourself; the server does not upgrade it for you, and a stack that does not re-apply it keeps the old blind rule and never gains the new alert. In a staged rollout apply SERVERS first, rules second: an old, not-yet-upgraded server restarting under the new rules reads a0stamp even on an anchored database and pagesYuzuAuditRetentionNeverRanwith a fresh-install story that is false for it (see the runbook’s rollout note). The same re-apply also removes anon(instance)join from the liveness rule, so a server that was being silenced by an unrelated series sharing itsinstancevalue (a canary, an HA pair, a federated series) can now correctly fire too - the same new-to-you-firing shape as the crash-loop fix, for the same reason.- You must ADD a new rule by hand:
YuzuAuditRetentionMetricMissing(#2553).YuzuAuditRetentionNotRunningcannot detect its own input going missing -increase()over a metric with no series is an empty vector, so the rule selects nothing and never fires. A Prometheus holding these rules against a server that does not exportyuzu_server_audit_retention_passes_totaltherefore reports healthy forever while the audit reaper is entirely unmonitored, which is exactly the state you are in if you apply this rules file ahead of upgrading your servers. The new rule keys onabsent(...)and fires after 15m. It is fleet-wide by construction: it cannot see one server among many going quiet. Aup-based target-down alert does NOT close that gap — a server that is running an older build is alive and scraped, so itsupis 1; it simply does not export this counter. Measured (#2553): in a fleet where one server exports the counter and another does not, neither retention rule fires, for either server, indefinitely. During a staged upgrade the practical mitigation is to confirm coverage directly rather than to rely on an alert —count(yuzu_server_audit_retention_passes_total)against your expected server count — until every server is upgraded. A per-target rule for this is tracked separately. - The first guarded pass now declines when it has no stored reading and rows
are already expired (#2579). The stored clock reading (the anchor) is new in
schema v3, so every database starts its first guarded pass without one. An
ABSENT reading is not a statement about the clock - it is the ordinary
fresh-install case - which previously left one shape unguarded: a host already
skewed FORWARD, where rows written after the skew were still inside the window,
so the would-expire-everything test did not fire either, and the pass deleted up
to the 25,000-row cap unremarked. Such a pass now declines ONCE, warns, and
anchors the reading; the next pass proceeds normally, paced by the cap. A fresh
install with nothing expired does not decline at all. Expect at most one such
decline per server on this upgrade, counted by the new
yuzu_server_audit_retention_bootstrap_declines_totaland NOT byyuzu_server_audit_clock_anomaly_skips_total- so it does not fireYuzuAuditRetentionClockAnomaly, and you should not stand that alert down for it. Servers upgrading FROM an affected version with a wrong clock may already have lost rows; there is no reliable retrospective test, and the loss is not recoverable without a backup predating it. Detail: audit-log.md § The retention clock guard. - Expect a first-pass retention decline on the AGENT, and only conditionally on
the server. The two guards differ here and the difference matters:
- Echo declines on a missing anchor, by design. It checks per warehouse
table, so on an agent upgrade every enabled time-based table declines in that
same pass and
retention_guard_declines_totalrises by the number of those tables (5-10 on a default agent), not by 1. That is the benign bootstrap case, not a fleet of separate anomalies, and it needs no action. - The audit store’s triggers are NOT the same, so do not carry the agent’s
expectation across to it and do not pre-emptively stand down
YuzuAuditRetentionClockAnomalyon a fleet upgrade. If one fires, work it as an alert rather than assuming a benign bootstrap: audit-log.md § The retention clock guard states when the server’s guard declines, and the ops runbook is the response path.
- Echo declines on a missing anchor, by design. It checks per warehouse
table, so on an agent upgrade every enabled time-based table declines in that
same pass and
- Agents surface new
echo statuslines:storage_state,retention_guard_declines_total,retention_guard_failures_total, and per-table detail. On the healthy pathstorage_state|okis the first line, and existing consumers filter on theconfig|prefix and ignore unknown lines, so nothing breaks. On the offline path they do break: when the Echo database has been closed after a wedged rollback,echo statusreturns non-zero and emits only anerror|line followed bystorage_state|offline- norecord_count, noconfig|lines at all. Note the order is REVERSED there:error|comes first, because server and dashboard consumers key off the output starting witherror|. A consumer keyed on the presence ofrecord_countmust handle that. See echo.md. Echo persists its own clock reading inecho_config(retention_guard_last_pass) - no schema change. - Echo row-count retention is now paced. Its ceiling semantics are unchanged, but a large excess (after a long disable, or an upgrade backlog) drains over several 900 s rollup ticks rather than in one statement.
ApiTokenStoregains a THIRD, independent clock guard (#2964) — the rotation sweep that auto-revokes rotation predecessors. Same shape as the audit store’s, same schema-migration pattern (a newrotation_retention_metakey/value table holding the durable anchor), same bootstrap-decline consequence, but a much shorter cadence: this sweep ticks every 60 seconds, not hourly. If you are upgrading a populated store that already has elapsed, not-yet-swept rotation pairs — a predecessor whose overlap window has already ended, sitting there because nothing has run the guard against it before — expect the sweep’s first tick after the migration to decline once, the same “no durable anchor yet, and there is already something eligible” bootstrap case #2579 describes for the audit store. Both credentials in every affected pair simply stay active for that one tick; the very next tick has an anchor and proceeds normally, so at a 60-second cadence this resolves in roughly two minutes, not the audit store’s next-hourly-pass timescale. Nothing is lost or at risk here (an auto-revoke deferred by one tick is the intended failure mode, not the audit store’s deletion-risk case) — this is a heads-up so the log line and the one-off decline are not mistaken for something wrong with the upgrade. Seedocs/user-manual/metrics.md“Rotation-sweep clock guard metrics” anddocs/ops-runbooks/rotation-sweep-clock-guard.md.
New Prometheus alert rules ship in docs/prometheus/yuzu-alerts.yml, which is the
current set. The declined-pass and failed-pass
counters must be alerted on separately: both leave rows undeleted, so an audit
table that never shrinks looks identical either way. One rule,
YuzuAuditRetentionNotRunning, fires on the reaper NOT running - the state in
which none of the other counter-driven rules can fire, because they all key on a
counter rising.
SLE — the SoftwareLicensing securable auto-grants on upgrade (ADR-0024)
This release adds the SLE (Software Licensing & Entitlements) discovery surface,
gated on a new SoftwareLicensing RBAC securable. Per ADR-1005 the server ships
the discovery mechanism only: the per-device GET /api/v1/sle/agents/{id} drill, its
machine-scope MCP twin query_software_licenses, and the audited erasure
DELETE /api/v1/sle/agents/{id}. The licence compliance / entitlement / reclamation
views and the fleet posture reads (a /sle page and /api/v1/sle/summary ·
/licenses · the per-product device fan-out) interpret discovered facts and ship with
the future SAM use-case-engine module — they are not in this release, so a reader
who saw the earlier ADR expecting an in-server compliance page should not look for one yet.
Because RBAC role defaults are seeded with INSERT OR IGNORE on every boot, an
upgrading deployment silently auto-grants the new securable to the built-in roles the
first time it starts the new build:
| Role | Grant |
|---|---|
| Viewer, PlatformEngineer | Read |
| Operator | Read + Write |
| ITServiceOwner, Administrator | full CRUD |
| ApiTokenManager | none |
No action is required if that matches your intent — the securable gates the SLE
discovery reads/erasure (GET/DELETE /api/v1/sle/agents/{id} and the MCP twin); the
/inventory software catalog is unchanged and remains under Inventory:Read.
But the per-device drill exposes each endpoint’s detected-licence facts —
product, vendor, channel, status, expiry, and, on per-user surfaces, the per-user
user_ref identifier — to every Read holder, including Viewer. A deployment
that must restrict this visibility should deny or remove the SoftwareLicensing Read
grant from Viewer (and any other broad role) BEFORE enabling the SLE sources. A
deny rule wins over the seeded allow (deny-override), so an explicit deny is the
durable control — re-seeding on the next boot cannot re-open it. (Entitlement/purchase
cost metadata is not served in-server this release — it is the SAM UCE module’s,
governed by that module’s own RBAC when it ships.)
The SLE detection source also collects a new, suppressible per-user identifier
(user_ref) on per-user licence surfaces. It defaults to a per-device keyed-HMAC
pseudonym; see Software licence detection for the
--license-scan-user-ref flag, the honest limits of that default, and the
--inventory-disable source-level opt-out.
Gateway listeners move to grpc_listeners (the Cowboy edge) — BREAKING
Affects every operator who runs the Erlang gateway, from the first release containing the
yuzu_gw grpc_listeners shape. The gateway’s two listeners (agent :50051, management
:50063) are now served by the gateway’s own gRPC layer over Cowboy and Ranch instead of
grpcbox, and they are configured under {yuzu_gw, [{grpc_listeners, [...]}]} instead of
{grpcbox, [{servers, [...]}]}. The wire protocol, the protobuf contracts and the
management-plane peer pin are unchanged. The channel to the server moves in the same
release, with the same kind of refusal: see “The upstream channel moves to upstream_client”
below, and make both edits in one pass (that section has the combined before and after).
Every config and compose file shipped in this repository is converted. A config you wrote
yourself is not: it has to be converted before the new gateway will start.
The gateway image and its sys.config are an atomic pair. Never change one without the
other:
- New image + old config: the gateway refuses to start. It exits within seconds, logs
Refusing to start: {grpcbox, servers} is set(text below) or, once that block is gone,Refusing to start: {grpcbox, [{client, ...}]} is set(see the next section), systemd reportsfailedafter three tries in 60 s, Compose restart-loops it, andYuzuGatewayDownfires after two minutes. No listener is left bound. - Old image + new config: the gateway starts, reports healthy, and serves nothing. The old
image ignores
grpc_listeners, binds no listener and logs no warning;/healthzand/readyzreturn 200, so a Compose or load-balancer health check stays green while ports 50051 and 50063 refuse connections andyuzu_gw_agents_currentis 0. This pairing also happens on a development checkout:docker-compose.uat.yml,deploy/docker/docker-compose.reference-gateway.yml,docker-compose.demo.ymlandscripts/start-demo.shmount new-shape configs and, left at their default${YUZU_VERSION:-0.14.1}, pull an older gateway image. Pin a gateway image from the first release containing thegrpc_listenersshape (or build one from the tree) when you use them.
Key mapping. One listener at a time:
Old key (inside {grpcbox, [{servers, [...]}]}) | New key (inside {yuzu_gw, [{grpc_listeners, [...]}]}) |
|---|---|
grpc_opts => #{service_protos, services, auth_fun} | Dissolved: service_protos, services and auth_fun are keys of the listener entry itself. |
listen_opts => #{port, ip} | listen => #{port, ip} |
transport_opts => #{ssl => true, certfile, keyfile, cacertfile, verify, fail_if_no_peer_cert} | tls => #{certfile, keyfile, cacertfile, verify, fail_if_no_peer_cert}. ssl => true is gone: the tls map’s presence turns TLS on. A leftover transport_opts refuses boot. |
pool_opts => #{size => N} | acceptors => N |
| (none) | name: mandatory and unique. |
| (none) | origin, http2, limits: new and optional. |
{yuzu_gw, [{agent_listen_addr, _}, {agent_listen_port, _}, {mgmt_listen_addr, _}, {mgmt_listen_port, _}]} | Dead keys that no code reads; remove them or leave them. The bind is listen. |
grpc_listeners belongs in the yuzu_gw application block, not in grpcbox. A listener
entry may carry only the keys the gateway reads (name, service_protos, services,
listen, tls, auth_fun, origin, acceptors, http2, limits; limits takes max_connections, max_message_bytes and send_timeout_ms), and its tls,
listen, http2, limits and origin maps only theirs: any other key, a legacy key above
and a misspelled one alike, refuses boot and is named in the log. The legacy keys the gateway
names are transport_opts, listen_opts, grpc_opts, pool_opts and ssl.
Not carried over. The new maps are closed, and several things the old shape could carry have no equivalent. Check your old entries for them before you convert:
grpc_optsentries other thanservice_protos,servicesandauth_fun: not read.transport_optsSSL extras beyond the sixtlskeys (certfile,keyfile,cacertfile,verify,fail_if_no_peer_cert,ciphers):versions,depth,crl_check,partial_chainandpasswordare not supported and refuse boot. TLS is 1.2 only, and an encrypted private key has to be decrypted first (openssl pkey -in encrypted.key -out gateway.key; keep the file readable by the gateway user only). Supporting them is a follow-up. Decrypting removes the passphrase protection the key had at rest, so the answer to “are private keys encrypted at rest?” for this key changes: protect the file with permissions and the secret store that mounts it.listen_optssocket options: onlylisten => #{port, ip, socket_opts}remains, andsocket_optsmay not carryip,ifaddr,fd,portor{local, _}.pool_opts: only the acceptor count survives, asacceptors.
Worked example. Before, the agent listener with one-way TLS:
{grpcbox, [
{servers, [
#{grpc_opts => #{service_protos => [agent_pb],
services => #{'yuzu.agent.v1.AgentService' => yuzu_gw_agent_service}},
listen_opts => #{port => 50051, ip => {0,0,0,0}},
transport_opts => #{ssl => true,
certfile => "/etc/yuzu/certs/default-gateway.pem",
keyfile => "/etc/yuzu/certs/default-gateway.key",
cacertfile => "/etc/yuzu/certs/default-ca.pem",
verify => verify_none,
fail_if_no_peer_cert => false}}
]}
]}
After:
{yuzu_gw, [
{grpc_listeners, [
#{name => agent,
service_protos => [agent_pb],
services => #{'yuzu.agent.v1.AgentService' => yuzu_gw_agent_service},
listen => #{port => 50051, ip => {0,0,0,0}},
tls => #{certfile => "/etc/yuzu/certs/default-gateway.pem",
keyfile => "/etc/yuzu/certs/default-gateway.key",
cacertfile => "/etc/yuzu/certs/default-ca.pem",
verify => verify_none,
fail_if_no_peer_cert => false}}
]}
]}
with the {servers, ...} block deleted. The management listener keeps its
auth_fun => fun yuzu_gw_authz:check_mgmt_peer/1 and, in {yuzu_gw, ...}, its
mgmt_peer_pins. To keep strict mutual TLS on the management listener, omit verify and
fail_if_no_peer_cert from its tls map: the defaults are verify_peer and true, and
copying the agent listener’s verify_none onto it would leave the command plane without
transport authentication (the boot guard refuses a network-reachable management listener
that has it). gateway/config/sys.config.prod is the full reference.
The refusals. With a {servers, ...} block still present the gateway logs, at critical
level, and exits:
Refusing to start: {grpcbox, servers} is set. The gateway's listeners are configured in {yuzu_gw, grpc_listeners} (one entry per listener) and nothing reads {grpcbox, servers}: that block would configure no listener. Move the listener settings into grpc_listeners and remove the {servers, ...} block (see config/sys.config.prod and docs/user-manual/gateway.md).
The check runs before anything is bound, and the gateway contains no grpcbox to serve a
servers block, so no listener is opened for it. It is refused rather than ignored because an
operator who kept the block believes a listener is configured that is not. The gateway also refuses to start,
with a critical log line that names the defect, when: grpc_listeners is unset, empty or
[] (the old advice to delete the servers block and add nothing leaves a gateway that is
healthy and accepts nothing; {yuzu_gw, [{allow_no_listeners, true}]} is the lab
acknowledgement); an entry has a legacy or unknown key; a tls map lacks certfile,
keyfile or cacertfile; an auth_fun is set on a listener that serves the agent service (agent_pb in service_protos, or the AgentService itself in services); no entry routes the AgentService (an entry counts only if its services map has the key 'yuzu.agent.v1.AgentService'; service_protos => [agent_pb] with an empty services does not); a
listener has no name or reuses one, or reuses a fixed port; a proxy origin has no
sources; or socket_opts tries to move the bind (ip, ifaddr, fd, port, local). An entry defect is
printed as {Name, Term} (the entry’s name, or unknown when it has none) inside the
Refusing to start: invalid {yuzu_gw, grpc_listeners} entry/entries line (the
bad_grpc_listeners refusal); no_grpc_listeners and no_agent_listener are plain sentences
in the log. All three names are what the pre-flight below prints. The next table
says what to change for each term.
| Term in the log | Fix |
|---|---|
legacy_keys | A key of the old shape (transport_opts, listen_opts, grpc_opts, pool_opts, ssl): move it as in the key mapping. The term is {legacy_keys, Where, [{Key, Hint}]}: it names the key and where it went. |
unknown_keys | A misspelled key, or one the gateway does not read: use only the keys listed in gRPC listeners (the term lists the allowed ones). |
missing_keys | The entry lacks name, service_protos, services or listen. |
tls_incomplete | A tls map needs certfile, keyfile and cacertfile; omit the map for plaintext. |
bad_tls_values | verify is not verify_peer/verify_none, fail_if_no_peer_cert is not a boolean, or ciphers is not a proper, non-empty list of strings or tuples. |
auth_fun_on_agent_listener | An auth_fun on a listener that serves the agent service (agent_pb, or the AgentService itself in services). Remove it: it would cut agent enrollment, and only the management listener authenticates its peer. |
listen_socket_opts_set_the_bind | socket_opts carries ip, ifaddr, fd, port or {local, _}; the bind is listen alone. |
listen_socket_opts_set_tls_policy | socket_opts carries client_renegotiation or secure_renegotiate (#5695). Remove them: the gateway fixes both on every TLS listener (client renegotiation refused, secure renegotiation required). When socket_opts also moves the bind, the bind term is reported instead. |
bad_listen_ip | listen.ip is not an IP address tuple. |
bad_acceptors | acceptors is not a positive integer. |
{unknown_keys, limits, [BadKey], {allowed, [...]}} | A limits key the gateway does not read. Every unknown_keys term has this shape, with the map it was found in (entry, tls, listen, http2 or limits; the origin map has unknown_origin_options) as the second element and the allowed keys last; for limits they are max_connections, max_message_bytes and send_timeout_ms. |
bad_limits | limits is not a map, or a key’s value is not valid (max_connections and send_timeout_ms take a positive integer or infinity, max_message_bytes a positive integer); the line lists the keys with bad values. |
duplicate_name | Two entries share a name. |
duplicate_port | Two entries bind the same fixed port. |
no_grpc_listeners | grpc_listeners is unset or empty; add the listeners to the yuzu_gw block. |
no_agent_listener | No entry routes the AgentService: no entry’s services map has the key 'yuzu.agent.v1.AgentService'. service_protos => [agent_pb] with services => #{} does not count, because nothing would answer an agent. No agent could connect and /readyz would never be ready. Only the literal {allow_no_listeners, true} excuses it (a lab or test). |
entry_not_a_map | An element of grpc_listeners is not a map. |
grpc_listeners_not_a_list | grpc_listeners itself is not a list (the line prints it as {unknown, grpc_listeners_not_a_list}). |
bad_service_protos, bad_services | service_protos is not a list of proto module atoms (a list with an improper tail included), or services is not a map of service name to module. |
bad_auth_fun | auth_fun is set on a listener that does not serve the agent service but is not a one-argument fun. |
bad_listen_socket_opts | listen.socket_opts is not a list. |
bad_routes | The services map could not be turned into a route table: the line gives {unknown_service, Name} (a service name that none of the service_protos defines) or {module_not_loaded, Module} (the module is not in the release). |
{listen_failed, Reason} | Not a config-shape defect: the entry validated, but the listener could not start (the port is already bound, eaddrinuse; the address is not on this host; or a tls file is missing or unreadable by the gateway user). It appears in the crash report, not as a Refusing to start line, and the pre-flight does not detect it: check ss -ltn and the file permissions. |
bad_routes with {service_without_routes, Name} | A service in the services map whose module exports none of that service’s methods (the arity-2, snake_case functions, register/2, heartbeat/2, and so on). This is nearly always a mistyped module name: the listener would start, /readyz would report ready, and every RPC of that service would answer UNIMPLEMENTED. A module that exports only some of the methods is accepted, and a request for one it lacks answers UNIMPLEMENTED. Name the module that implements the service (yuzu_gw_agent_service for the agent, yuzu_gw_mgmt_service for management). |
origin_sources_required, bad_origin_sources | A proxy origin (proxy_protocol or forwarded_for) needs a non-empty sources list; each element is an IP address or {Ip, Prefix} (a prefix of 0, or above the address width, is refused); a sources that is not a proper list reports {bad_origin_sources, improper_list}. |
{ipv4_mapped_source_prefix_must_exceed_96, [...]} | A sources element {::ffff:a.b.c.d, P} with P of 96 or less (counted over the 128-bit address); use a prefix of 97 to 128. |
unknown_origin_mode, unknown_origin_options, origin_transport_takes_no_options, bad_origin | origin.mode is not transport, proxy_protocol or forwarded_for; the origin map has a key its mode does not take; transport takes no other key; or origin is not a map with a mode. |
bad_trusted_hops | origin.trusted_hops is not an integer from 0 to 31. |
auth_fun_without_tls | An auth_fun needs a tls map; add it. |
auth_fun_behind_terminating_proxy | An auth_fun on a forwarded_for listener: the check would run on the proxy’s certificate. Remove one of them. |
bad_listen, bad_tls, bad_http2, reserved_http2_options | A listen map without an integer port from 0 to 65535, a tls or http2 value that is not a map, or an http2 option the gateway owns. |
bad_grpc_listeners | The summary line for any of the entry defects above; the entries and their defects are printed with it. |
Environment variables. YUZU_GW_AGENT_PORT and YUZU_GW_MGMT_PORT changed nothing about
the listeners before; they now rewrite listen.port of the entry that serves agent_pb
and of the entry that serves management_pb, and only when exactly one entry serves the
proto (otherwise a warning is logged and the variable is ignored). Check the value before
you upgrade: a gateway container or unit that has either variable set to a number other
than its configured port (a host-port number copied from an .env file, for example) moves
its listener on the first start of the new image. The server’s settings page lists
YUZU_GW_MGMT_PORT with default 50052; the management listener is on 50063.
Pre-flight (offline, before you touch production). On the host, with the NEW release
unpacked beside the old one and the production YUZU_GW_* environment set, this runs the
gateway’s own config checks without starting a listener. It covers both moves in this
release: the legacy {grpcbox, [{servers, ...}]} and {grpcbox, [{client, ...}]} blocks,
the grpc_listeners entries, the upstream_client channels and the management-listener
posture. application:load(yuzu_gw) is required: without it the application environment is
not read and the check passes falsely, and the script exits 1 if the load fails (a yuzu_gw
that is not on the -pa path). A pre-flight script from the previous release does not work
on this one: it also loaded grpcbox, which is not in the release any more, so it exits 1
with {error,{"no such file or directory","grpcbox.app"}} before it checks anything. Use the
script below. It also runs the upstream TLS checks the boot runs: any https endpoint without
{verify, verify_peer} and a cacertfile / cacerts of its own, and a channel that mixes http
and https endpoints, exit 1 here (if YUZU_GW_ALLOW_UNVERIFIED_UPSTREAM=1 is exported the
unverified case passes with a warning, as it does at boot). It does not
check the distribution cookie, whether the endpoint carries a client certificate (the server
demands that), that the certificate files exist, or
that the ports can bind (a {listen_failed, Reason} in the crash report means one of those; see the table below). The config file you pass must be named *.config (a file called sys.config.prod is looked up as sys.config.prod.config and not found: copy it to a .config name). Proceed only on exit 0; a non-ok entry in the printed list is the refusal (legacy_grpcbox_servers, legacy_grpcbox_client, no_grpc_listeners, no_agent_listener, bad_grpc_listeners, no_upstream_channel, bad_upstream_client_config, mixed_upstream_endpoints, unverified_upstream_tls, or the management-posture defect), and the critical log line above it says why. On an image from before this change it fails
with undef and exits 1, which tells you the image is old.
R=/opt/yuzu_gw; C=$R/releases/0.2.0/sys.config
$R/erts-*/bin/erl -noshell -noinput -boot $R/releases/0.2.0/start_clean -config $C \
-pa $R/lib/*/ebin -eval '
Load = [application:load(yuzu_gw)], yuzu_gw_app:apply_env_overrides(),
Results = Load ++ [yuzu_gw_app:check_legacy_grpcbox_servers(), yuzu_gw_app:check_legacy_grpcbox_client(),
yuzu_gw_app:check_grpc_listeners(), yuzu_gw_app:check_upstream_client_config(),
yuzu_gw_app:check_upstream_channel_mix(), yuzu_gw_app:check_upstream_tls_posture(),
yuzu_gw_app:check_mgmt_listener_posture()],
io:format("PREFLIGHT ~p~n", [Results]),
halt(case lists:all(fun(X) -> X =:= ok end, Results) of true -> 0; false -> 1 end).'
Pre-flight for container operators. Run the same command in a throwaway container from the
NEW image, with the candidate sys.config mounted and the production YUZU_GW_* variables
passed with -e; bypass the entrypoint, which only prepares the node’s address. In the image
the release is unpacked at /opt/yuzu_gw and the release version directory is releases/0.2.0
(deploy/docker/Dockerfile.gateway copies _build/prod/rel/yuzu_gw/ there, and the reference
compose mounts the config at /opt/yuzu_gw/releases/0.2.0/sys.config; adapt R and the version
to your release root if yours differs). Save this as preflight.sh, which is the command
above with the config taken from its first argument:
#!/bin/sh
R=/opt/yuzu_gw; C=$1
$R/erts-*/bin/erl -noshell -noinput -boot $R/releases/0.2.0/start_clean -config "$C" \
-pa $R/lib/*/ebin -eval '
Load = [application:load(yuzu_gw)], yuzu_gw_app:apply_env_overrides(),
Results = Load ++ [yuzu_gw_app:check_legacy_grpcbox_servers(), yuzu_gw_app:check_legacy_grpcbox_client(),
yuzu_gw_app:check_grpc_listeners(), yuzu_gw_app:check_upstream_client_config(),
yuzu_gw_app:check_upstream_channel_mix(), yuzu_gw_app:check_upstream_tls_posture(),
yuzu_gw_app:check_mgmt_listener_posture()],
io:format("PREFLIGHT ~p~n", [Results]),
halt(case lists:all(fun(X) -> X =:= ok end, Results) of true -> 0; false -> 1 end).'
then run it in the new image:
docker run --rm --entrypoint sh -e YUZU_GW_AGENT_PORT -e YUZU_GW_MGMT_PORT \
-v "$PWD/preflight.sh:/preflight.sh:ro" -v "$PWD/gateway-sys.config:/pf.config:ro" \
NEW_GATEWAY_IMAGE /preflight.sh /pf.config
The exit status of docker run is the script’s. The erl command was run on a compiled tree
(exit 0 on the shipped sys.config.prod and deploy/docker/gateway-sys.config; exit 1, with
the critical line, on a config carrying a {grpcbox, [{client, ...}]} block, on one carrying a
{grpcbox, [{servers, ...}]} block, on one with no upstream_client, on one whose
upstream_client is not a channels map, and on a -pa path without yuzu_gw) and, in the
release layout above (erts-*/bin/erl, releases/0.2.0/start_clean, lib/*/ebin), on a
scratch production release (exit 0 on its shipped sys.config). The scan for a legacy block reads
the config files named by -config, so give the script the same file the node will start with. The two upstream checks added to the command (channel mix and TLS posture) were run on a compiled tree: exit 0 on the shipped sys.config.prod, exit 1 with the critical line on an https channel that has verify_peer and no cacertfile. The docker run wrapper (the entrypoint override, the mounts) was
written from the Dockerfile and the compose files and has not been run.
Old and new gateways side by side is untested. Rolling forward replaces one gateway at a time (below), so for a while the fleet runs both shapes. Nothing in the code ties the two together beyond the protocol, but a mixed fleet has not been run against a single server, which is one more reason to drain each gateway from the load balancer before you replace it.
Rolling forward. Back up first: the tarball flow above (rm -rf /opt/yuzu_gw) deletes
releases/*/sys.config with the old release and a package upgrade overwrites it (both
described under Linux (systemd)), so copy the old sys.config out and keep both files. Convert and pre-flight the new config, then upgrade one gateway at a time: drain it
from the load balancer, replace the image and the config together, and wait for
ss -ltn | grep -E ':50051|:50063' to show both ports, curl -s localhost:8081/readyz to
answer 200 (it now also requires each configured listener to be running) and
yuzu_gw_agents_current to climb. A gateway restart disconnects every agent that gateway
holds, and on agent builds that do not yet carry the #5183 reconnect fix (shipped in 0.15.0) a released agent may stay wedged after it: restart the agent service on the agents that
stay rejected only if they do. Then upgrade every gateway before you rely on source_ip
in the audit log: an older gateway still forwards the gateway_observed_peer the agent
sent, so a forged address passes through it.
Rolling back. Restore the old image and the old config together, then verify as above. Restoring only the image leaves the old-image-plus-new-config pairing, which serves nothing and reports healthy. Restoring only the config leaves the new-image-plus-old-config pairing, which refuses to start.
Symptoms of a wrong pairing.
| Symptom | Cause | Fix |
|---|---|---|
Exit 1 within seconds, Refusing to start: {grpcbox, servers} is set | New image, old config | Convert the config (above), or restore the old image. |
Exit 1 within seconds, Refusing to start: {grpcbox, [{client, ...}]} is set | New image, config with the upstream channel still under grpcbox (a listener-only conversion) | Move the value to {yuzu_gw, [{upstream_client, ...}]} (next section). |
Exit 1, Refusing to start: no upstream channel is configured | upstream_client is unset, empty, or has no channel named default_channel (channels under other names do not count) | Add the default_channel (next section). |
Exit 1, Refusing to start: the upstream channel configuration {yuzu_gw, upstream_client} is unusable (<Why>) | upstream_client has a shape error (bad_client_config, bad_channel, bad_endpoint, bad_host, bad_socket_options) | Fix the entry the shape names (next section). |
Exit 1, Refusing to start: the upstream channel uses TLS (https) WITHOUT {verify,verify_peer} and a {cacertfile,...} of its own | An https endpoint lacks verify_peer or a CA of its own | Add {verify, verify_peer} and a cacertfile to every https endpoint (or use http on a lab rig). |
Exit 1, Refusing to start: the upstream channel <name> lists both plaintext (http) and TLS (https) endpoints | One channel mixes schemes | Give the channel one scheme. |
VM exits at once with invalid config data: duplicate application config: yuzu_gw (before any gateway log line) | The upstream_client or grpc_listeners value was added as a second {yuzu_gw, [...]} tuple | Merge it into the existing {yuzu_gw, [...]} list; the pre-flight exits 1 on it too. |
Boot succeeds but every upstream call fails and the channel logs connection <n> down (...); agents do not register | The channel is unusable at run time: a CA or certificate path that does not exist ({options,tls} in the log, and no boot guard checks it, because the server may create the files after the gateway starts), or a name that does not resolve | Check the paths and the dialled name; see The upstream client. |
Process up, /readyz 200, ports 50051 and 50063 refuse connections, yuzu_gw_agents_current 0, no listener log | Old image, new config | Restore the old config, or deploy the new image. |
Exit 1, Refusing to start: {yuzu_gw, grpc_listeners} is unset or empty | The servers block was deleted and nothing added, or grpc_listeners sits in the grpcbox block | Add the listeners to the yuzu_gw block. |
Listeners up, every TLS handshake fails with no_suitable_ciphers | tls.ciphers is set to a list with no suite for the certificate’s key type (ECDSA suites with an RSA certificate, say) | Remove ciphers (the default follows the key type) or list suites that fit the key (below). |
Works, but the audit log shows many agents with one source_ip | A load balancer or router in front of the gateway and no origin configured | Configure origin (below). |
| Exit 1, a critical line naming the management listener and a defect | The management listener lacks the required posture (plaintext, missing_cert_material, verify_not_peer, peer_cert_not_required, missing_or_wrong_auth_fun, no_mgmt_peer_pins (mode pin), no_mgmt_peer_identity (mode identity), no_mgmt_peer_pins_or_identity (mode identity_or_pin), proxied_origin) | Fix the entry as in sys.config.prod; the lab-only override is allow_insecure_mgmt. |
Bring-your-own certificates. The TLS cipher list is chosen by the certificate’s key
type unless tls.ciphers is set: the ECDHE-ECDSA AEAD suites for an EC key (the
certificates the server generates), ECDHE-RSA AES-GCM suites for an RSA key. TLS 1.2 only,
as before. This is narrower than the OTP default list the grpcbox listeners used; an agent
that needs a suite outside the list needs tls.ciphers. FIPS mode is not claimed.
TLS behaviour changes. The new listeners set reuse_sessions => false and
honor_cipher_order => true; the grpcbox listeners took OTP’s defaults for both. With session
reuse off, every reconnect is a full TLS handshake, so a reconnect herd (every agent behind a
proxy that cut its idle connections, a gateway restart) costs more CPU than it did. The
gateway’s own cipher order now decides the suite, not the client’s. TLS stays 1.2 only.
Connection ceilings. sys.config.prod and the reference configs now set
limits => #{max_connections => 60000, max_message_bytes => 4194304} on the agent listener
and max_connections => 256 on the management listener (before, neither had a ceiling).
max_connections bounds file descriptors and is not protection against a hostile peer:
- Agent listener: a peer that completes the handshake and keeps sending (a
PINGresets Cowboy’s idle counter) can hold a connection indefinitely, so it can fill the 60000 ceiling; new connections then wait in the kernel backlog while connected agents are unaffected until they reconnect. This is an accepted residual risk of the unauthenticated agent port (it predates the ceiling, which only changed what the same flood exhausts); limit connections per source at the L4 balancer or firewall. - Management listener: about 256 held connections would stop the server making new
connections to the gateway (established ones are unaffected). Before the TLS handshake
anyone with layer-4 reach to the port can hold a slot (up to the 10 s handshake timeout);
after it, any holder of a certificate issued by the install CA (every enrolled agent has
one) can, because the SPKI pin runs later, per stream. This is a known limitation that is
not being fixed in the gateway: firewall the management port (
:50063by default) so that only the server replicas can reach it, set the listener’slisten.ipto the management interface’s address, and, where you can, multi-home the server so that its management- and gateway-facing interface is on a different network interface from its agent- and database-facing ones. The shipped composes do not publish it, and the gateway cannot check the firewall or the interface separation. Detection and recovery (thesscommands andyuzu_server_gateway_forward_total{status="unavailable"}) are in Connection ceilings and the unauthenticated ports.
Size max_connections under the gateway’s file-descriptor limit (60000 assumes
LimitNOFILE=65536). The compose files set no ulimits; add ulimits: nofile: 131072 to the
gateway service and raise the agent listener’s limits.max_connections with it (keep about
5500 descriptors below the limit): the 60000 ceiling assumes about 65536 descriptors, so a larger
descriptor limit alone does not move it. Check the hard limit in /proc/1/limits (the BEAM
raises its soft limit to it).
A proxy without origin. A gateway behind a load balancer or router that has no
origin configured attributes every agent to the proxy’s address and nothing flags it: the
audit source_ip is the proxy and there is no origin_observed=false. Before this change
the agent’s own (empty) value was forwarded, so the audit row carried origin_observed=false
in the same situation. Configure origin for the proxy you run (see
Client addresses behind a proxy).
Verify after the upgrade. ss -ltn | grep -E ':50051|:50063' shows both listeners;
curl -s localhost:8081/readyz returns 200; yuzu_gw_agents_current climbs to the fleet
size; behind a proxy, yuzu_gw_grpc_origin_ignored_total{reason=~"no_claim|bad_claim"} stays
flat and the audit source_ip is each agent’s own address. The server writes the gateway
origin to the audit log only on enrollment.token_consumed, so read source_ip from that
row, for an agent that enrolled with an enrollment token after the upgrade; a Register
without a token leaves no row to read.
The upstream channel moves to upstream_client — BREAKING
Affects every operator who runs the Erlang gateway; it ships in the same release as the
listener move above, so convert both in one edit. The gateway’s connection to the server
(GatewayUpstream, port 50055 by default) is now made by the gateway’s own gRPC client
(yuzu_grpc_client) over gun, and the gateway no longer contains grpcbox (nor chatterbox,
hpack, acceptor_pool or gproc), its patched vendored copy, or the grpcbox rebar3 plugin. The
wire protocol, the protobuf contracts and the server are unchanged: no server or agent
change. Every config and compose file shipped in this repository is converted; a config you
wrote yourself is not.
UPGRADE ACTION. Move the upstream channel value from the grpcbox application to the
yuzu_gw application. The value V does not change:
| Before | After |
|---|---|
{grpcbox, [{client, V}]} | {yuzu_gw, [{upstream_client, V}]} |
{grpcbox, [{servers, L}]} | {yuzu_gw, [{grpc_listeners, L2}]} (L2 is L converted, see the key mapping above) |
and remove the {grpcbox, ...} block. Merge the values into your existing {yuzu_gw, [...]} list:
a second {yuzu_gw, ...} tuple is a different mistake, and the VM refuses it before the gateway
starts (invalid config data: duplicate application config: yuzu_gw). The log lines in the
refusals below say config/sys.config, which is the file in the source tree; on an installed
gateway the file is /opt/yuzu_gw/releases/<vsn>/sys.config (a container’s mount point, or the
file your compose file mounts, otherwise). Before and after for a whole file, both moves at once:
%% BEFORE (the last release)
[
{grpcbox, [
{client, #{channels => [
{default_channel, [{https, "yuzu-server.example.com", 50055, [
{cacertfile, "/etc/yuzu/certs/default-ca.pem"},
{certfile, "/etc/yuzu/certs/default-gateway.pem"},
{keyfile, "/etc/yuzu/certs/default-gateway.key"},
{verify, verify_peer}]}], #{}}]}},
{servers, [ #{grpc_opts => ..., listen_opts => ..., transport_opts => ...} ]}
]},
{yuzu_gw, [ ... ]}
].
%% AFTER
[
{yuzu_gw, [
...,
{upstream_client, #{channels => [
{default_channel, [{https, "yuzu-server.example.com", 50055, [
{cacertfile, "/etc/yuzu/certs/default-ca.pem"},
{certfile, "/etc/yuzu/certs/default-gateway.pem"},
{keyfile, "/etc/yuzu/certs/default-gateway.key"},
{verify, verify_peer}]}], #{}}]}},
{grpc_listeners, [ #{name => agent, ...}, #{name => mgmt, ...} ]}
]}
].
Why the old key cannot be left in place. OTP applies a sys.config entry only to an
application that is loaded, and nothing loads one named grpcbox any more, so a block left
under that name would be silently invisible: the file would read as configured while the
gateway ignored its TLS client options. The gateway therefore refuses to boot instead,
before any listener is bound, when it finds {grpcbox, [{client, _}]} or
{grpcbox, [{servers, _}]}, either in the application environment or in any config file the
VM was started with (a file named by another config file is followed, a few levels deep). It
checks servers first, so a file that has both reports servers, and the client refusal
appears only once that block is gone; the pre-flight above reports both at once. The critical
log lines are:
Refusing to start: {grpcbox, [{client, ...}]} is set. The upstream channels are configured in {yuzu_gw, [{upstream_client, ...}]} and nothing reads the old key. Move the value across unchanged and remove the {grpcbox, ...} block (see config/sys.config and docs/user-manual/gateway.md).
The gateway also refuses to start when no channel is named default_channel (the registration
and heartbeat code calls that name; a gateway without it would register no agent and forward no
heartbeat while /readyz answered ready, because the upstream check only tests that a process
is alive):
Refusing to start: no upstream channel is configured. Set {yuzu_gw, [{upstream_client, #{channels => [{default_channel, [Endpoint], #{}}]}}]} (see config/sys.config and docs/user-manual/gateway.md); without it the gateway can register no agent and forward no heartbeat.
Only the literal {yuzu_gw, [{allow_no_upstream, true}]} excuses it (a gateway under test), and
a value that is not a channels map, or a channel, endpoint, host or socket_options of the wrong
shape, is refused with Refusing to start: the upstream channel configuration {yuzu_gw, upstream_client} is unusable (Why), where Why is one of bad_client_config, bad_channel,
bad_endpoint, bad_host or bad_socket_options and the shape of the offending term, never
its value (SSL options name key files). The same boot also refuses, unless
YUZU_GW_ALLOW_UNVERIFIED_UPSTREAM=1 (dev only) is set:
- any
httpsendpoint that lacks{verify, verify_peer}or acacertfile/cacertsof its own (without a CA of its own the OS trust store would be used); - a channel that lists both
httpandhttpsendpoints (mixed_upstream_endpoints; this one has no override).
Production is https, verify_peer, a CA-pinned cacertfile and a client certificate and key
(mutual TLS). The boot log says mutual-TLS only when every endpoint carries certfile and
keyfile, and server-authenticated TLS (no client certificate) otherwise.
What else changes (the client is described in The upstream client):
pool_sizeis available. The channel opens one HTTP/2 connection per endpoint by default, as the grpcbox client did, so nothing changes for a topology that relies on all of a gateway’s upstream calls reaching one server replica through a layer-4 VIP (see “Several core replicas” in What happens when the server restarts). It is an option of the channel ({default_channel, [Endpoint], #{pool_size => N}}, 1 to 64); if you raise it, calls go round-robin over the live connections, which behind such a VIP can reach different replicas, and the connections redial independently after a certificate change. Theupstream_pool_sizekey insys.configis read by nothing, before and after.- TCP_NODELAY is a first-class default (
upstream_tcp_nodelay, defaulttrue,sys.configonly). It is set on the TCP socket of each connection, forhttpandhttpsendpoints, and an endpoint’s ownsocket_optionsnodelayentry wins either way. The mechanism that restarted grpcbox’s channel at start is gone, and so is its log line (Upstream channel default_channel: could not restart it with TCP_NODELAY): an alert or a log filter on it never fires. On a live stack with TLS one measurement put theProxyRegistermean at about 3 ms, against about 45 ms with nodelay off. - Reconnects and failures. A lost connection is reopened after 100 ms doubling to 5 s plus up
to 20% jitter (so up to about 5.8 s; back to 100 ms only after a connection has stayed up for
5 s), and logs
Upstream channel <name> connection <n> down (<word>); retrying in <ms> ms. A call that finds no live connection waits, within its own timeout, for one that is already connecting, and otherwise fails at once with the last connection error. - Limits. Per-call timeout 5000 ms, covering the connection wait; the largest response message
16 MiB (
max_receive_message_bytes); messages are never compressed. The grpcbox-era channel optionsbalancer,encodingandsync_startare accepted and ignored. - Certificates. A connection that is up keeps the certificate it opened with; a reconnect
reads the files again (Erlang’s
sslcaches file contents and cleans that cache about every 120 s). The gateway has no connection-age cap. - Keys that do nothing.
upstream_addrandupstream_portare read by nothing; the endpoints ofupstream_clientare what is dialled. The environment variablesYUZU_GW_UPSTREAM_ADDR/YUZU_GW_UPSTREAM_PORTare now ignored with a boot warning (they used to log anENV overrideline as if they applied). Remove them, and do not rely on them to repoint a gateway. - Values replaced by defaults. A
pool_size,connect_timeoutormax_receive_message_bytesout of range (or a string such as"4") is replaced by the default, as it was before, but the gateway now logs one warning per option at boot (see Bad values replaced by defaults). An IPv6 literal host needsinet6in the endpoint’ssocket_options; the dial is IPv4 only otherwise. - A wrong CA or certificate file is now loud. It used to show only as
bad_tls_optionson every attempt, with/readyzgreen until five calls had failed. The gateway now warns at boot, logs an ERROR naming the file (not a key file’s path) at most once a minute, and/readyzanswers 503 (upstream_tls_files) until it is fixed. A directory where a file belongs refuses boot. A merely missing file does not, because the server may create it after the gateway starts (see A CA, certificate or key file that is wrong). - Log volume. A persistent TLS failure costs about 5.8 KB of log per attempt (DERIVED:
roughly 100 MB a day at
pool_size1). Bound Docker logs withloggingmax-size. Erlang’ssslprogress reports include key file paths, never the passphrase (probed on OTP 28.4.2).
Rolling forward and back are the listener move’s rules (above): replace the image and the
config together, one gateway at a time. A new image with the old config refuses to start. An old
image with the new config ignores both grpc_listeners and upstream_client: it starts, reports
healthy and binds nothing (see “Old image + new config” above). It also has no upstream channel,
because it reads no upstream_client (INFERRED from the old image’s use of {grpcbox, [{client, ...}]};
not tested). Rolling back restores the old image and the old config (which carries both grpcbox blocks) together.
Gateway distribution cookie now required (#659) — BREAKING
The Erlang gateway shipped a hardcoded default distribution cookie
(yuzu_gw_secret_change_me). The cookie is the sole authentication for
inter-node RPC, so a publicly-known value is unauthenticated remote code
execution for anyone who can reach EPMD (TCP 4369). The gateway now refuses to
boot with the default (or an empty/unsubstituted) cookie unless explicitly
overridden.
Before upgrading:
.deb/.rpminstalls auto-generate a unique cookie into/etc/yuzu/gateway.env(mode0640,root:yuzu-gw) on first install and never clobber it on upgrade — no action needed for a single node.- Tarball / manual systemd installs must create the env file once:
The systemd unit loads it viasudo install -d -m 0755 /etc/yuzu printf 'YUZU_GW_COOKIE=%s\n' "$(openssl rand -hex 32)" | sudo tee /etc/yuzu/gateway.env >/dev/null sudo chown root:yuzu-gw /etc/yuzu/gateway.env && sudo chmod 0640 /etc/yuzu/gateway.envEnvironmentFile=-/etc/yuzu/gateway.env. - Docker / Compose deployments must set
YUZU_GW_COOKIEin the gateway service environment (e.g.export YUZU_GW_COOKIE=$(openssl rand -hex 32)then reference it). Dev/CI may instead setYUZU_GW_ALLOW_DEFAULT_COOKIE=1. - Multi-node clusters: every node must share the same cookie — set an
identical
YUZU_GW_COOKIE(or write the same/etc/yuzu/gateway.env) on all nodes; the per-host auto-generated value will NOT match across hosts.
Recovery — gateway won’t start after upgrade:
| Symptom | Diagnose | Fix |
|---|---|---|
systemctl status yuzu-gateway shows start-limit-hit / failed | journalctl -t yuzu-gateway | grep -i cookie shows “insecure distribution cookie” (for manual/foreground or container runs, check stdout / gateway.log instead) | Create /etc/yuzu/gateway.env with YUZU_GW_COOKIE=$(openssl rand -hex 32) (see above), then systemctl reset-failed yuzu-gateway && systemctl start yuzu-gateway. Do not use YUZU_GW_ALLOW_DEFAULT_COOKIE=1 in production. |
| Gateway container restarts at boot on an AMX-capable Intel host (Sapphire Rapids+, AWS c7i/m7i/r7i) | Container log has sys_sigaltstack(): Internal error: Failed to set alternate signal stack; image is 0.13.0 through 0.14.0-rc4 (#2150) | Pull an image that carries the #2150 fix (0.14.0-rc5 or later). Until then, run the gateway on a host without AMX. A single-node gateway may use yuzu-gateway-chisel instead; a clustered one may not (it never joins the cluster). Do not roll back to 0.13.0 on that host: it is affected too. |
The generated
/etc/yuzu/gateway.envis intentionally preserved acrossapt purge/rpm -e(the/etc/yuzudirectory may be shared with other Yuzu packages). To remove the cookie after uninstall:sudo shred -u /etc/yuzu/gateway.env.
Never set
YUZU_GW_ALLOW_DEFAULT_COOKIE=1in production — it disables the guard and restores the unauthenticated-RPC surface. It exists only for ephemeral dev/CI stacks.
Gateway distribution-cookie length floor + shared node identity (HA WS-4 #4555) — BREAKING
Extends the #659 cookie requirement above with two further changes, both
shipped together as part of gateway multi-node cluster formation:
- Cookie length floor. The gateway now additionally refuses to boot with
a distribution cookie shorter than 32 characters, even a genuinely
custom (non-default) one. DNS-based cluster discovery means a node now
dials addresses it did not choose by hand, and the distribution
handshake’s INITIATOR sends the cookie hash first — a short cookie is
brute-forceable offline from a legitimately-dialing node. Every
documented cookie-generation path already clears this floor with room to
spare (
openssl rand -hex 32produces 64 characters) — no action needed if you followed the#659guidance above verbatim. Only an operator who hand-picked a short custom cookie is affected; the sameYUZU_GW_ALLOW_DEFAULT_COOKIE=1override (dev/CI only, never production) bypasses this check too. - Node identity change. The Erlang node short name changed from a
hardcoded
yuzu_gw1to a sharedyuzu_gw— every gateway replica in a cluster now advertises the SAME short name, distinguished only by an address resolved at boot (YUZU_GW_ADVERTISE_ADDR, auto-detected by default), so that identical replicas can boot from one image/env (a prerequisite fordocker compose up --scale gateway=N). A single-node deployment boots identically otherwise (the address defaults to127.0.0.1, matching the old hardcoded value) — but any external tooling that hardcodes the OLD full node name (yuzu_gw1@127.0.0.1) in aerl -remsh/reconscript, a health check, or a monitoring probe will silently stop matching after upgrade. Update any such tooling to matchyuzu_gw@(a prefix, since the address suffix may vary).
Recovery for the cookie-length case uses the same diagnostic path as
#659 above (journalctl -t yuzu-gateway | grep -i cookie / stdout /
gateway.log) — the log line names the specific failure (shorter than 32 characters) separately from the known-default rejection.
InstructionDefinition import signature enforcement now on-by-default (#1073 / W7.4 sibling-gap) — BREAKING
InstructionStore::import_definition_json (the storage path behind
POST /api/instructions/import) previously accepted unsigned JSON
envelopes without verification. After upgrade, unsigned imports are
rejected with:
instruction-import is unsigned and signature enforcement is enabled (set --allow-unsigned-definitions / YUZU_ALLOW_UNSIGNED_DEFINITIONS=1 to bypass)
This is intentional and closes the sibling-gap to #802. Without the
gate, any operator with InstructionDefinition:Write permission can
import a definition that dispatches an arbitrary plugin invocation on
every targeted agent — same fleet-RCE blast radius the pack-signing
default closed.
Two migration paths, in order of preference:
-
Sign your imports. Generate an Ed25519 keypair, sign the
yaml_sourcefield’s bytes with the private key, and wrap the envelope withsignature: <hex>+publicKey: <hex>top-level fields. The wire format mirrors ProductPack: signature is hex- encoded over the verbatimyaml_sourcebytes;publicKeyis the hex-encoded raw Ed25519 public key (32 bytes / 64 hex chars). Until the dedicated helper script lands (tracked as a follow-up issue covering both pack-signing and definition-signing tooling), use the rawopensslrecipe directly:# One-time: generate a keypair (rotate periodically per your policy). openssl genpkey -algorithm Ed25519 -out yuzu-signing.pem openssl pkey -in yuzu-signing.pem -pubout -outform DER \ | tail -c 32 | xxd -p -c 64 # → 64-hex publicKey # Per definition: sign yaml_source bytes, hex-encode the output. echo -n "$YAML_SOURCE" \ | openssl pkeyutl -sign -inkey yuzu-signing.pem -rawin \ | xxd -p -c 128 # → 128-hex signatureInject
signatureandpublicKeyas top-level string fields in the JSON envelope POSTed to/api/instructions/import. See the REST API reference (docs/user-manual/rest-api.md→POST /api/instructions/import) for the full signing-rules table and per-rejection error strings. -
Opt out temporarily (legacy environments only). Pass
--allow-unsigned-definitionstoyuzu-serveror setYUZU_ALLOW_UNSIGNED_DEFINITIONS=1in the service environment. The server emits anInstructionStore: signature enforcement DISABLED by configurationwarning on every start AND aserver.unsigned_definitions_allowedaudit row at boot, so the relaxed posture is recoverable from both operator logs and the audit store. Remove the flag as soon as the signing migration completes.
Pre-existing imported definitions are unaffected. The gate fires
only on the public import path. The bundled-content boot seed (the
kBundledDefinitions baked into yuzu-server at build time) routes
through an internal import_definition_json_trusted variant that
bypasses the gate; its authenticity comes from binary linkage, not
runtime signature.
Authoring surfaces are NOT gated. POST /api/instructions,
POST /api/instructions/yaml, and PUT /api/instructions/{id} — the
dashboard and CLI surfaces where operators author definitions in-
session — continue to trust InstructionDefinition:Write as the
author trust boundary (the operator IS the source; there is no
supply chain to authenticate). The --allow-unsigned-definitions
flag does NOT affect those surfaces; they have always accepted
unsigned author-time input and continue to. The architectural
question of whether authoring surfaces should ALSO require signed
envelopes is tracked as a follow-up issue with operator-decision-
required framing (UX trade-off: gating authoring would break in-
browser definition authoring).
Audit-trail evidence chain. Every rejection emits an
instruction.import / denied audit row with the store error string
in detail (stable SIEM-keyable tokens listed in the
audit-log.md reference). If the audit-store write itself fails
(locked DB, disk full), the response carries Sec-Audit-Failed: true
header AND audit_emitted: false in the JSON body, surfacing the
SOC 2 CC7.2 evidence gap to the operator immediately rather than
silently dropping the event.
Product pack signature enforcement now on-by-default (#802 / W7.4) — BREAKING
The ProductPackStore previously shipped with signature enforcement
disabled by default and the setter to enable it was never wired to
any operator-facing flag — the protection was effectively unreachable.
After upgrade, calls to install a ProductPack without a signature:
field are rejected with:
pack '<name>' is unsigned and signature enforcement is enabled (set --allow-unsigned-packs / YUZU_ALLOW_UNSIGNED_PACKS=1 to bypass)
This is intentional. Unsigned packs are a fleet-wide arbitrary-code-
execution surface: any operator with Pack:Install permission, or a
MITM on pack delivery, could install a pack containing
InstructionDefinition or plugin payloads that would then execute on
every enrolled agent.
Two migration paths, in order of preference:
-
Sign your packs. Generate an Ed25519 keypair, sign each pack’s non-metadata YAML content with the private key, and add
signature: <hex>+publicKey: <hex>fields to each pack’sProductPackmetadata document. The existing verify path (ProductPackStore::verify_signature) accepts the result. Pack install then succeeds and theverifiedcolumn in the store is set to true so a future “show only verified packs” query has the data it needs. -
Opt out temporarily (legacy environments only). Pass
--allow-unsigned-packstoyuzu-serveror setYUZU_ALLOW_UNSIGNED_PACKS=1in the service environment. The server emits a[SECURITY] product pack signature enforcement DISABLED by configurationwarning on every start and writes aserver.unsigned_packs_allowedaudit row, so the relaxed posture is recoverable from both operator logs and the audit store. Remove the flag as soon as the pack-signing migration completes; it is not intended as a permanent configuration.
Pre-existing installed packs are unaffected. The check fires only
on the install path (POST /api/product-packs). List, get, and
uninstall paths do not re-verify, so already-installed unsigned packs
remain queryable and uninstallable after upgrade.
vNEXT — Access-review CSV export gains a leading metadata line (breaking for fixed-column-index CSV consumers)
GET /api/v1/access-reviews/export?format=csv (SOC 2 CC6.2 evidence) now
emits one new line before the existing header row:
# rbac_enforcement=enabled
principal_type,principal_id,display_name,owner_or_email,roles,effective_permission_count,last_activity_ms,last_activity_kind,classification,lifecycle_state,source
user,alice,...
This line is unconditional — present even when the grant population is
empty — so it stamps whether RBAC was actually enforced when the file was
pulled, travelling with the retained/offline copy an auditor keeps (see
rest-api.md’s rbac_enforcement section for what the three values mean).
This breaks any consumer that assumes row 1 is the header. Verified empirically against this exact output shape (not a blanket claim about “CSV tools” — the actual split matters):
- Silently WRONG output, no error, no warning: Python’s
csv.DictReaderreads the new line 1 as a single-column header, then feeds the REAL header row (line 2) into the result set as if it were data — every field name and every row is now misaligned. Theawk -F, 'NR>1'idiom (and any hand-rolled “skip the first line” loop in another language) does the same thing: it now emits the real header row as a spurious first “data” row, ahead of the genuine data rows, which otherwise parse correctly. - Also silently WRONG, not a loud failure:
pandas.read_csv(path)with its default settings does not raiseParserErroron this shape — do not rely on pandas to “fail loud” here. Because every row past line 1 is uniformly wider than the 1-field metadata line, pandas’ documented “extra leading columns become an implicit index” heuristic kicks in: it silently produces a 1-column, tuple-indexedDataFramewith the metadata line as the sole column name and the leading fields folded into aMultiIndex, leaving only the last field (source) as the actual, mislabeledDataFramecolumn — wrong, but no exception. (Verified directly against pandas 3.0.6, both thecandpythonengines; on that version, neither engine’s default settings raise instead of silently misparsing — untested against older pandas majors, so treat “on 3.0.6” as the scope of this claim, not a guarantee for every pandas release.)
The fix is the same for every consumer class: skip exactly one line before treating the next line as the header, verified working against each tool above:
# csv.DictReader
with open(path, newline="") as f:
next(f) # skip the metadata line
reader = csv.DictReader(f) # now reads the real header correctly
# awk (was NR>1 under the old format; now NR>2)
awk -F, 'NR>2' access-review.csv
# pandas
df = pd.read_csv(path, skiprows=1)
A consumer that already treats the file as free-form text and looks for the
# rbac_enforcement= prefix, or that reads the rbac_enforcement field from
the sibling JSON export (GET .../export without ?format=csv, or the
frozen campaign row’s rbac_enforcement field), is unaffected either way.
Executions-history PR 2 — responses.execution_id exact correlation
PR 2 of the executions-history ladder closes a forensic-data correctness gap (UP-8) where two concurrent executions of the same definition to overlapping agent sets could show each other’s responses in the Instructions → Executions detail drawer. Operators upgrading to a release that includes PR 2 should expect the following:
Schema migration v2 on response_store. The migration adds an
execution_id TEXT NOT NULL DEFAULT '' column to the responses table
plus a partial index idx_resp_execution_ts ON responses(execution_id, timestamp) WHERE execution_id != ''. The migration is automatic and
data-preserving — MigrationRunner::run wraps the ALTER + CREATE INDEX
in a single transaction. Pre-upgrade rows are NOT deleted; they receive
the empty-string sentinel value ''. The migration is idempotent: a
pre-stamp probe at startup detects an already-altered DB and skips the
duplicate ALTER (mirrors the precedent at instruction_store.cpp v2).
Observable after upgrade:
SELECT execution_id, COUNT(*) FROM responses GROUP BY execution_id;
will show ALL pre-upgrade rows under the empty string ''. This is
expected — there’s no way to retroactively attribute pre-upgrade
responses to executions because the dispatch path didn’t record the
linkage. The Executions drawer detects empty-execution_id rows and
falls back to the legacy timestamp-window-plus-agent-set join so they
render correctly without operator action. No operator action
required for the migration itself.
Dispatch-path coverage gap (PR 2.x follow-ups). Only executions
dispatched via POST /api/instructions/:id/execute (the dashboard’s
Execute Instruction form goes through this path) get exact correlation
in PR 2. Three dispatch surfaces continue to write rows with
execution_id='' until follow-up PRs close them:
- MCP
execute_instruction— agent dispatches issued through the MCP protocol. - Workflow steps (
POST /api/workflows/:id/executestep dispatch viacmd_dispatchcallback) — multi-step workflows. - Scheduled / approval-triggered dispatches — the dispatch path
inside
schedule_engine/ approval-fired execution. - Reruns (
/api/executions/:id/rerun) —create_reruncreates the execution row but does not dispatch; the operator-triggered follow-up dispatch will be wired in PR 2.x.
For runs from these surfaces, the drawer’s responses section uses the legacy timestamp-window join. Cross-execution contamination (UP-8) is still possible if two such runs overlap on the same definition + agent set. Track via the executions-history follow-up issues.
Mixed-mode detail drawer behaviour. During an upgrade transition
window (executions in flight at restart time, or in-progress executions
that started pre-upgrade and finished post-upgrade), some responses for
a single execution may carry execution_id='' while others are
correctly tagged. The drawer prefers exact-correlation rows when they
exist and only falls back to the legacy join when zero exact rows are
returned — this means mixed-mode runs may show only the
post-upgrade subset of their responses in the drawer. Pre-upgrade
responses for those runs remain in the database and are queryable via
SQL; the upcoming admin backfill CLI (PR 2.1) will stamp them with
their correct execution_id once it ships.
Server restart caveat. The dispatch-time command_id → execution_id
mapping is held in memory inside AgentServiceImpl. If the server is
restarted while a command is in flight, the mapping is lost and any
agent responses arriving post-restart will be tagged execution_id=''
and use the legacy fallback. Avoid restarting the server during active
executions where possible, or accept that the affected runs will use
legacy correlation.
Admin backfill (planned in PR 2.1, not in this release). A
yuzu-server admin backfill-responses CLI is filed as a follow-up. It
will walk the executions table cross-store and stamp pre-upgrade
responses with their best-effort execution_id (timestamp + agent set
heuristic). Until it ships, pre-upgrade rows remain queryable via the
legacy fallback in the drawer; no operator action is required.
Verifying the migration. After upgrade, the server log should
contain MigrationRunner: response_store migrated to v2. To verify the
column directly:
sqlite3 /var/lib/yuzu/responses.db ".schema responses"
# Expected: execution_id TEXT NOT NULL DEFAULT ''
sqlite3 /var/lib/yuzu/responses.db ".schema idx_resp_execution_ts"
# Expected: CREATE INDEX idx_resp_execution_ts ON responses(...) WHERE execution_id != ''
v0.12.0 — AuthDB persistent authentication (#618)
v0.12.0 replaces the in-memory + on-config-flush authentication model
with a SQLite-backed auth.db that holds user accounts, sessions, and
enrollment tokens.
First boot after upgrade:
- The server probes
--data-dirforauth.db. If absent, it creates the file with mode0600(Linux) or restricted ACL (Windows), runs the initial schema migration viaMigrationRunner, then seeds users fromyuzu-server.cfg. Subsequent boots read fromauth.dbdirectly; the config file is no longer the live source of truth. - The seed is one-shot. Editing
yuzu-server.cfgafter first boot does NOT re-seed users intoauth.db— use the dashboard orPOST /api/settings/usersinstead. - Existing in-flight sessions are NOT preserved across the upgrade
(sessions live in memory before this release;
auth.dbstarts fresh on first boot). Operators must log in again.
role parameter ignored on POST /api/settings/users. New users
are always created as user (security finding C1). To assign or
change a role, use the dedicated POST /api/settings/users/{username}/role endpoint introduced in v0.12.0
(see REST API → Settings → User Management).
The dashboard exposes a Change Role button on each user row that
calls the new endpoint.
Live drawer updates via SSE. The Executions tab opens an
EventSource to /sse/executions/{id} for in-progress runs. Reverse
proxies in the request path must NOT buffer SSE — the server emits
X-Accel-Buffering: no and Cache-Control: no-cache but a poorly-
configured proxy can still chunk the stream. Verify with
curl -N https://<server>/sse/executions/<id> from inside your
network if the drawer freezes mid-execution.
LimitNOFILE=65536 on systemd units. v0.12.0 raises the systemd
file-descriptor limit from the default 1024 to 65536. SSE
connections + agent gRPC streams + SQLite WAL handles can saturate
the default soft limit on busy fleets. The new value matches Docker
compose’s ulimits.nofile so containerised and bare-metal deployments
behave identically.
Restart-loop guard. StartLimitIntervalSec=60 +
StartLimitBurst=3 in the systemd [Unit] section so a corrupt
auth.db failing the integrity check at startup puts the unit
cleanly into failed instead of spinning. Recovery procedure:
docs/ops-runbooks/auth-db-recovery.md.
Rollback to a pre-AuthDB release (only if you have not yet written user state via the new dashboard):
- Stop the v0.12.0 server.
- Move
auth.dbaside (mv auth.db auth.db.v0.12.0). - Restore
yuzu-server.cfgfrom your pre-upgrade backup. - Reinstall the prior release binary.
- Start the server. It reads
yuzu-server.cfgas before.
If you HAVE written user state via v0.12.0’s dashboard, that state
is in auth.db only — rolling back loses it. Export users via
GET /api/v1/settings/users first if rollback is required.
v0.12.0 — Guardian PR 2
Guardian PR 2 ships the Guaranteed State control plane + agent skeleton. Two items require operator awareness on upgrade:
Stale *:Push RBAC grants on deployments that ran pre-hardening Guardian PR 2 code. Between commits 7c83911 and 1f39401, the RBAC seed granted the Push operation to Administrator and ITServiceOwner on every securable type, not just GuaranteedState. The H-4 fix (commit 21c0ba4, hardening round 2) restricted the seed to GuaranteedState only going forward — but because seed_defaults() uses INSERT OR IGNORE, the stale cross-type grants already written to role_permissions are not removed on upgrade. These grants are semantically inert today (only the Guardian REST handlers consult Push), but become a latent privilege for any future release that adds a non-Guardian handler checking perm_fn(..., "Push").
Manual remediation until the auto-migration in issue #485 lands. Run each step in order:
-
Back up the RBAC database first. Destructive SQL with no rollback.
docker exec yuzu-server cp /var/lib/yuzu/rbac.db /var/lib/yuzu/rbac.db.bak.$(date +%Y%m%d) # or for a systemd install: cp /var/lib/yuzu/rbac.db /var/lib/yuzu/rbac.db.bak.$(date +%Y%m%d) -
Preview the rows that will be deleted. This should return only
AdministratorandITServiceOwnerrows on a fresh Guardian PR 2 upgrade. If it returns rows for any other principal_id, you have custom RBAC grants that the bulkDELETEbelow would silently wipe — in that case stop and prune by hand.docker exec -i yuzu-server sqlite3 /var/lib/yuzu/rbac.db \ "SELECT principal_id, securable_type FROM role_permissions \ WHERE operation = 'Push' AND securable_type != 'GuaranteedState' \ ORDER BY principal_id, securable_type;" -
Delete the stale grants. Scoped to the two seeded roles so custom grants are left alone:
docker exec -i yuzu-server sqlite3 /var/lib/yuzu/rbac.db \ "DELETE FROM role_permissions \ WHERE operation = 'Push' \ AND securable_type != 'GuaranteedState' \ AND principal_id IN ('Administrator', 'ITServiceOwner');" -
Confirm cleanup: re-run the preview query from step 2 — zero rows expected.
Safe on fresh installs (no matching rows). If you are upgrading from a v0.11.x release directly to v0.12.0 or later, skip this entire sub-section — your RBAC database never carried the stale grants.
Retention changes do not take effect on a runtime PUT, nor on a restart (set the flag or environment variable). BL-2 wired --guardian-event-retention-days (default 30) through RuntimeConfigStore + PUT /api/config/guardian_event_retention_days, matching the existing response_retention_days and audit_retention_days pattern. However, all three retention-bearing stores (AuditStore, ResponseStore, GuaranteedStateStore) capture their retention value at construction time and never re-read it — the runtime PUT mutates cfg_ and RuntimeConfigStore but the running reaper continues using the startup value. An operator who PUTs a new retention value sees 200 {"applied": true} but the store behaviour does not change, and a restart does not change it either: the stores copy cfg_ when they are constructed, before stored overrides are applied, so the enforced window comes only from the --*-retention-days flag or its environment variable (corrected later; see the ‘administrator-only’ Breaking note above). This is a systemic limitation shared across all three stores, not a Guardian-specific bug; it is tracked as issue #483.
Runtime config PUT now rejects non-numeric and negative integer values with HTTP 400. Hardening round 4 (UP-R5) added std::from_chars validation to PUT /api/config/<key> for heartbeat_timeout, response_retention_days, audit_retention_days, and guardian_event_retention_days. The previous handler silently wrote invalid strings to RuntimeConfigStore and swallowed the stoi error, leaving cfg_ unchanged. If your automation relied on setting retention to a negative value (e.g., "-1") to disable retention — which the store then treated as “never reap” via the <= 0 sentinel — that automation will now receive 400 {"error":{"code":400,"message":"value must be a non-negative integer"}}. Use "0" instead only for guardian_event_retention_days, where it preserves the disable-retention semantic and passes validation. For heartbeat_timeout, response_retention_days and audit_retention_days the store applies its own > 0 check after the route’s, so "0" is also rejected - with a different, bare-shaped body, {"error":"value must be a positive integer"}. There is no supported way to disable retention for those three through this endpoint. Automation that previously set non-numeric strings (anything other than a base-10 integer) was silently a no-op before this release — the 400 now surfaces the configuration error that had been hidden.
v0.12.0 — A3 UX ladder (#620, #622, #624)
Three operator-visible behaviour changes ship in the v0.12.0 A3 ladder. None require code changes on the operator side, but two of them require action if you maintain a local compose override:
1. Container healthchecks now pass (#622). The shipped docker-compose.uat.yml healthcheck blocks were updated to use tools available in each runtime image (bash + /dev/tcp for the server; busybox wget --spider for the gateway). After upgrade, docker compose ps reports (healthy) instead of (unhealthy).
If you maintain a local copy of the compose file (e.g.
docker-compose.local.ymlor a pinned vendored copy), your override still uses the broken pre-fix healthcheck pattern and will continue showing(unhealthy)until you sync the change. Replace your server-service healthcheck stanza with:healthcheck: test: - "CMD" - "bash" - "-c" - "exec 3<>/dev/tcp/localhost/8080 && printf 'GET /livez HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n' >&3 && grep -q '200 OK' <&3 ; rc=$? ; exec 3>&- ; exit $rc"And the gateway-service healthcheck stanza with:
healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:8081/healthz"]
2. /api/health is restored as an alias of /health (#620). The pre-#401 endpoint path is back. Monitoring integrations that point at /api/health work without reconfiguration; both URLs serve identical JSON. Both are exempt from rate limiting (a follow-up hardening over the bare /health behaviour; since superseded: credentialed requests to these two paths are now rate limited per source). For load-balancer probes that should drain in-flight traffic before stopping, continue using /readyz. /health and /api/health are intentionally not draining-aware (Kubernetes pattern: liveness/health probes are not draining-aware).
3. File-logger boot messages are now quieter (#624). The previous WARN: Could not create log directory /var/log/yuzu + ERROR: file logger setup failed pair on every container boot is replaced by a single INFO-level line when the default path cannot be created. The Docker server image now pre-creates /var/log/yuzu (mode 0750, owned by yuzu) so the path is writable out of the box. If your monitoring previously alerted on the WARN/ERROR lines as a misconfig signal, those signals will no longer fire — the failure mode is now a single INFO line. Operators who require explicit on-disk logs should pass --log-file <path>; explicit-path failures still log at ERROR and are not silently degraded.
quarantine plugin: reporting is now more conservative (Wave-2 argv migration)
The quarantine plugin’s Windows/Linux/macOS netsh/iptables/pfctl invocations were migrated off shell-outs onto a bounded argv runner. Alongside the mechanism change, two reporting bugs were fixed: a Linux chain-flush could previously be miscounted as an applied containment rule (status|quarantined|rules_applied|1 reported with zero rules actually installed), and macOS whitelist mutations could silently proceed on a failed prerequisite read or report success despite pfctl -e failing to enable pf.
If your automation alerts on status|failed/status|release_uncertain/status|update_uncertain from quarantine actions, you may see these fire more often after upgrading — this is expected. Nothing got more broken; the plugin’s failure reporting simply got more honest about cases it previously reported as clean success. No action is required beyond expecting the change.
Fresh deployment only for 0.15.0. See the banner at the top of this page: the steps below are not a supported upgrade path from an earlier build’s database.
Rollback
If an upgrade causes issues:
- Stop the new version
- Restore the backed-up
.dband.cfgfiles - Start the previous version binary
Important: Rolling back after a schema migration requires restoring the database files from backup. The old binary cannot read the new schema.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Server won’t start after upgrade | Schema migration failure | Check logs, restore backup, report issue |
| Agents can’t connect | Protocol version mismatch | Ensure server was upgraded first |
| Dashboard shows errors | Browser cache | Clear browser cache, hard refresh |
| Gateway disconnects | Version mismatch with server | Upgrade gateway to match server version |
| Slow startup | Large database migration | Normal for first start; wait for migration to complete |