Enterprise Readiness (SOC 2)
Version: 1.0 Date: 2026-04-04 Audience: Engineering, Security, Product, Operations, GTM, Executive Leadership
1) Executive Summary
Yuzu has strong product depth (agent/server/gateway architecture, RBAC, policy engine, OTA, and broad plugin coverage), but SOC 2 Type II and enterprise procurement require controls and evidence beyond feature completeness. This plan defines the capabilities, process controls, artifacts, and timeline needed to:
- Pass a SOC 2 Type II audit window.
- Successfully onboard and support a first large enterprise customer.
Guiding principle: Prioritize controls and evidence pipelines that satisfy both SOC 2 and real enterprise customer security reviews, so every investment serves both outcomes.
2) Outcomes and Success Criteria
2.1 SOC 2 Type II Outcomes
- Audit scope and system description finalized.
- Control set implemented across Security, Availability, and Confidentiality trust criteria (minimum), with optional Processing Integrity where required.
- Evidence collection automated for at least 80% of recurring controls.
- Clean internal readiness assessment and successful external audit period completion.
2.2 First Enterprise Customer Outcomes
- Security questionnaire and architecture review passed.
- Customer-required controls (SSO, least privilege, auditability, encryption, incident response) demonstrably in place.
- Production onboarding completed with documented runbooks and named escalation paths.
- Contractual security commitments (SLAs/SLOs, incident notification, vulnerability remediation windows) accepted and operationalized.
3) Strategic Workstreams
3.1 Workstream A — Governance, Risk, and Compliance (GRC)
Deliverables
- Control framework: SOC 2 control matrix mapped to owners, systems, and evidence sources.
- Risk register: Product and operational risks ranked by likelihood/impact with mitigations and review cadence.
- Policy set: Security policy suite (access control, change management, incident response, vendor management, backup/DR, secure SDLC).
- Audit evidence index: Single source listing each control, evidence artifact, generation cadence, and owner.
Implementation Steps
- Define audit boundary and in-scope systems.
- Select GRC tooling (or structured repo + ticket workflow if lightweight).
- Appoint control owners (Engineering, Security, IT, People, Legal).
- Establish monthly control health reviews.
3.2 Workstream B — Identity, Access, and Administrative Security
Target State
- SSO enforcement for admin users.
- Role-based least privilege and separation of duties.
- MFA requirements for privileged actions.
- Periodic access reviews with manager/security attestation.
Required Features / Controls
- Enforce OIDC SSO for production admin access.
- Disable local-password fallback in hardened mode (or tightly constrain break-glass account policy).
- Add 2FA/TOTP for high-risk approvals (aligned with roadmap hardening).
- Session management controls: revocation shipped (
DELETE /api/v1/sessionsadmin force-logout,DELETE /api/v1/sessions/meself-revoke including API tokens; audit actionssession.revoke_all/session.revoke_all.self; Prometheus counteryuzu_auth_sessions_revoked_total). Expiration in place via the existing 8-hour cookie max-age. An inactivity (idle) timeout is shipped as--session-inactivity-secs(YUZU_SESSION_INACTIVITY_SECS) but is off by default (0disables it), so it takes effect only where an operator sets it. The explicit secure-cookie-attribute review remains open. - API token governance: scoped permissions, expiration defaults, rotation process, token inventory.
- Local-password policy (CC6.1, #5342) — scope it honestly in a questionnaire: local accounts can change and reset passwords in the product, enforced only by a 12–1024-byte length bound; there is no complexity rule, password-history/reuse check (a same-password change is accepted), expiry/maximum age, or must-change-at-next-sign-in flag. Answer those CAIQ/CC6.1 items as not implemented (SSO enforcement above is the compensating control).
- Residual (#1836): OIDC IdP-group→RBAC deprovisioning (#1832) propagates on the user’s next SSO login, not immediately on IdP-side group removal (a Settings save that changes the admin group does stop a sign-in that was in flight from being minted under the old group; see the OIDC known limitations) — a live session/cookie or an already-issued token retains its prior roles until re-authentication. Session revocation (above) is the operator’s manual mitigation in the interim; automatic mid-session role re-check is tracked in #1836.
- Residual (#5692): This statement covers v0.13.0 and later builds only; earlier releases were not examined. From v0.13.0 until the #5692 fix, OIDC sign-in could not complete in a production configuration: the server did not learn
jwks_uri, so every ID token failed signature verification and sign-in failed closed. Do not cite OIDC SSO as an operating control for that window; SAML is a separate implementation, not verified by this change; its known gaps are tracked in #5704. Since the fix it is verified end to end against Keycloak 26.4.0 only (Linux test host, plainhttpandhttpsvia a TLS-terminating proxy); Microsoft Entra ID is supported by the standard, not verified by the project. - OIDC egress control (#5692): the server’s OIDC requests (discovery, JWKS, Test Connection, token POST) go to hosts named by the IdP’s discovery document or typed into Test Connection. They are resolved by the server, every address is classified, and the connection is pinned to a permitted address: link-local and cloud-metadata addresses are always refused; loopback, RFC 1918, carrier-grade NAT and unique-local addresses are refused unless the process operator starts the server with
--oidc-allow-private-networks(not settable from the dashboard or REST). Disclose: no per-host allow-list, no HTTP proxy on the discovery and JWKS path (the Windows token POST uses the system default proxy), the Windows token POST is only pre-checked, strictly (a mixed answer with any refused address is refused outright; DNS rebinding between the check and WinHTTP’s own lookup is not covered; it does not follow redirects and accepts only HTTP status 200), and anhttp://issuer or--oidc-skip-tls-verifyremoves the integrity of the JWKS trust root (the server warns for a non-loopbackhttp://issuer; plainhttp://to a PUBLIC address is refused outright, buthttp://to a loopback or private address is allowed with--oidc-allow-private-networksand stays exposed to anyone on that network path). Seedocs/user-manual/authentication.md“Network and trust”. - UCE surface (forward ref): the use-case-engine host inherits SSO transitively through this server’s OIDC (
docs/uce-host-requirements.md§4.6/NF-9 — Yuzu-as-identity-provider). This doubles #1836’s blast surface: a stale IdP group also gates UCE artifact-minting until the operator’s Yuzu session is force-revoked or the ≤5-min artifact TTL / NF-9(d) liveness floor expires, and it adds a second login-event stream outside the server’s audit perimeter (uce-host-requirements.mdNF-6). An access-control reviewer working from this section must follow NF-9 for the second surface.
Evidence
- SSO configuration records, role assignment exports, access review sign-offs, and sampled auth logs.
- MFA step-up gate (CC6.6, REST/dashboard only, 37 routes):
yuzu_auth_mfa_step_up_gate_total{outcome,source,surface}counts every evaluation, and every refusal by the central check, except the defensive no-session401(counted on the metric only), writes a best-effortmfa.step_up.requiredaudit row (result=error;reason=or, for a stale proof,age_secs=/window_secs=;cid=equal to the response’sX-Correlation-Id;stage=pre_authz; seedocs/user-manual/audit-log.md). Caveat for a questionnaire: MCP tools and the 73 REST routes still classified pending are not enforced (in particular the legacyPUT /api/config/{key}writes the same runtime-configuration keys as the step-up gatedPUT /api/v1/config/{key}and is not itself gated, and so is the MCP toolset_runtime_config); the gate passes un-enrolled accounts and API-token and MCP-token callers by design, and with--mfa-step-up-window-secsat0or less no stale proof is refused (so none is recorded) and SAML sessions pass. - Gateway-peer authorization (gateway-upstream service): refusals of peers whose certificate parsed are written as
session.gateway_peer_denied, which is inside theauth-sampleexport but is a bounded sample; the complete count is the Prometheus counteryuzu_server_gateway_peer_denied_total, and refusals with no key to attribute are counter and log only (docs/user-manual/audit-log.md). The audit rowserver.gateway_peer_authz_disabled(written when the server starts with--insecure-gateway-peer) is not in the export, whose action prefixes areauth.,mfa.,session.andengine_principal.; pull it by action. Pins are fixed at boot, so there is no pin-change audit row: a pin change is a server restart and a configuration change. - RBAC enforcement-state changes (A1): every
PUT /api/v1/rbac/enforcement/set_rbac_enforcementtoggle attempt either persists an attributablerbac.enforcement_changedrow (docs/user-manual/audit-log.md) naming the caller, the before/after state, and the post-transition administrator count, or fails closed rather than silently succeeding unaudited (REST503; MCPaudit_persisted:false) — theyuzu_server_rbac_enforcement_enabledgauge transition serves as an independent trace of the same event during that window. A refused attempt — whether it is denied structurally BEFORE the admin gate ever runs (a service-scoped token or an engine session, both refused unconditionally) or denied by the gate/store-level guard itself after clearing those structural checks (not a durable Administrator, the caller-authority/regime check, or the destination-survival check) — is ALSO captured as adeniedrow with cause, not silently dropped — exactly the lockout-risk-relevant evidence this section exists to promote. Theyuzu_server_rbac_enforcement_enabledgauge plus the threshold-freeYuzuRbacEnforcementChangedalert and its direction-awareYuzuRbacEnforcementDisabledwarning-severity companion (docs/prometheus/yuzu-alerts.yml) give a real-time, cross-replica-convergent signal independent of the audit log for the same event.
Addendum — the access-review export was readable by any authenticated user on a
default install until #2376 (CC6.2/CC6.1). Recorded here because this section cites
“role assignment exports, access review sign-offs” as CC6.2 evidence, and that evidence
surface carried a real access-control gap for the period before #2376 shipped. RBAC
ships disabled by default, and with it disabled the pre-RBAC legacy fallback in
require_permission allowed every Read to any authenticated non-engine session —
so GET /api/v1/access-reviews/export, which produces the complete fleet-wide grant
population, was readable by a plain user on the shipped default posture, despite being
nominally gated on the dedicated AccessReview:Read securable that #2324 cut for exactly
this purpose. The same was true of GET /api/v1/rbac/roles and the engine-principal
grant graph.
#2376 closes it: those three reads now require an admin effective role regardless of
the RBAC toggle, enforced at a single chokepoint (authz_topology_floor.hpp), with a
dedicated yuzu_auth_topology_floor_denied_total{permission} counter as the queryable
CC7.2 signal. An auditor testing this control should attempt each read with a non-admin
session on an RBAC-off install and expect 403. For any assessment covering a period
before #2376, treat the grant-population export as having been accessible to all
authenticated principals unless RBAC was explicitly enabled on that deployment. Full
decision record: docs/security-reviews/authz-topology-floor-2026-08-05.md.
Addendum — the access-review export now states whether RBAC was actually enforced
(CC6.2, RBAC delivery plan A3). #2376 above closed who can read the grant-population
export; it did not address a separate gap this section’s own evidence framing had:
listing every grant proves nothing about whether that population governed real access at
all, since RBAC ships disabled by default. GET /api/v1/access-reviews/export (and its
MCP twin export_access_review) now carry a top-level rbac_enforcement field —
enabled | disabled | degraded — and POST /api/v1/access-reviews freezes the same
value onto the campaign row at open time, so a closed review campaign records what RBAC’s
state actually was when the population was certified, not just the population itself.
Caveat an assessor should know: the stamp is a point-in-time read, not a continuously
monitored guarantee — a transient RBAC-store partition can produce a degraded reading
(the read couldn’t confirm state; gates still denied defensively) or, on a cached-enabled
replica, a stale-but-unflagged enabled reading, and a closed campaign’s frozen stamp
never self-corrects on a later read the way the live export does on its next pull. Full
discussion, including the operator-facing metric to correlate against
(yuzu_server_rbac_read_degrade_total{reason=~"generation_refresh_failed.*"}):
docs/user-manual/rbac.md → “The access-review export’s rbac_enforcement stamp
inherits this same degrade-vs-outage ambiguity”; field reference:
docs/user-manual/rest-api.md → GET /api/v1/access-reviews/export.
Addendum — service-scoped token fleet-wide confinement, durably closed
(CC6.1/CC6.3, guardian-confinement-2298 PR 3 — “the flip”, 2026-08-18).
require_permission’s service-scoped branch previously admitted any
operation the ITServiceOwner role granted — since that role holds broad
CRUD across most securables, a token bound to one IT service’s agents
could, in practice, reach fleet-wide data with no per-agent narrowing,
across every route gated this way (not an enumerated list). The
ITServiceOwner ceiling described here applied on those two gates only; the
fleet-read gate (require_fleet_read) did not apply it until the #3526 addendum
below. This PR
inverts the default: a (securable, operation) pair must now also clear a
server-side allow-list that ships empty, so a route not yet migrated
to real per-request confinement denies a service-scoped token outright
rather than admitting it unfiltered — including routes not yet found.
Mirrored at the MCP tools/call layer via ServiceScopeClass. This
closes the confinement class the “Narrowed 2026-08-14” clause of “The
machine-health audit exemption” bullet above (Workstream E) narrowed
piecemeal, and is the durable fix that docs/user-manual/server-admin.md’s
matching PR2 Upgrade Notes entry’s own “This note does not claim the
underlying class is fully closed” caveat anticipated. For any
assessment covering a period before this PR, treat a service-scoped token
as having had the reach of its minting principal’s ITServiceOwner
grants, not the reach of its own service tag, on any route gated solely
by require_permission/require_scoped_permission that lacked its own
per-request confinement. Three items this PR identifies but does not
itself close, to be filed as follow-up issues at this PR’s landing: (1)
whoever can set an agent’s service tag effectively moves that agent’s
scope membership — this PR does not harden that write path (ADR-1006
Consequences); (2) a known remainder of the per-file deny_service_scoped_*
family (PreflightRoutes, schedule_routes.*, and inline checks in
device/network/inventory/Echo routes) still names a .permission that
cannot admit a service-scoped caller — the same false-self-remediation
defect this PR’s own hardening round found and fixed in five sibling
sites (.claude/routed-concerns-access-control.md clause 5); (3) the ~15 pre-existing per-file
deny_service_scoped_* helpers and the 7 MCP double-deny sites this
default-deny makes redundant are scheduled for Phase 2 consolidation, not
retired here. Full design: docs/adr/1006-service-scope-default-deny.md;
closed route inventory:
docs/security-reviews/service-scope-flip-route-inventory-2026-08.md.
Addendum — POST /api/v1/result-sets/from-inventory-query service-scope
regression, closed (CC6.1/CC6.3, #4980, 2026-09-25). The addendum above’s
closed route inventory (row 25) correctly reported this route’s
service-scope gap as closed on 2026-08-18 — that closure was real. An
unrelated confinement fix on 2026-09-12 (606b9ec72, closing a real
management-group scoping gap on this same route) replaced the route’s
require_permission gate with the ADR-0017 fleet_read_fn admit-then-filter
chokepoint, whose service-scope branch admits-and-confines a service-scoped
caller UNDER RBAC-ON rather than denying it outright (it still hard-403s
under RBAC-off, same as require_permission) — silently reopening the
cross-service-reach class the flip had closed, on this one route, in
RBAC-enabled deployments only. #4980 closed it again with a dedicated
deny_fleet_wide_service_scoped call that no longer depends on which
underlying gate this route uses, matching the route’s 8 non-dispatch
siblings. For any assessment covering the period 2026-09-12 through
#4980’s merge, on a deployment with RBAC enabled, treat this one route as
NOT covered by the flip’s confinement guarantee above — a service-scoped
token holding Inventory:Read could, during that window, mint a result
set that its minting principal’s other tokens/session could then read
back (owner-scoping keys on the minting principal’s identity, not the
token’s own service tag). RBAC-off deployments (the default) were never
exposed by this regression. No other route in the closed inventory is
affected; this is a single-route regression-and-fix, not a reopening of
the flip’s broader closure. Correction also recorded in the route
inventory doc itself.
Addendum — POST /api/v1/scope/preview service-scope gap, closed (CC6.1/CC6.3,
#4981, 2026-09-28). This REST route did not exist at the time of the 2026-08-18
service-scope sweep (docs/security-reviews/service-scope-flip-route-inventory-2026-08.md)
that produced the closed route inventory above — it was added on 2026-09-12
(b00d3cadc), after the sweep, and was never audited into it. It shipped from day
one without the deny-first pattern the sweep established for every sibling
result-set route: a service-scoped API token was admitted and its match set
silently narrowed, rather than denied outright, the same shape #4980 (the addendum
above) fixed on a different route. For any assessment covering 2026-09-12 through
#4981’s merge, treat POST /api/v1/scope/preview (REST only) as NOT covered by the
flip’s confinement guarantee above. The MCP twin, preview_scope_targets, is NOT
part of this gap — it has existed since 2026-03-25 and has been structurally denied
to a service-scoped token since C8’s default-deny landed on 2026-08-18 (the same day
as the sweep), via its kToolSecurity entry’s default ServiceScopeClass::denied;
#4981 made no service-scope change on the MCP side. #4981 also fixed a separate,
more severe, non-service-scope defect shared by BOTH surfaces: a bespoke per-agent
attribute resolver did not evaluate from_result_set:/props. scope atoms, so a
negated reference (NOT from_result_set:<id>) silently matched the caller’s entire
visible fleet regardless of the referenced set’s real membership — a preview tool
giving a misleadingly broad blast-radius answer before a real dispatch. That defect,
and the REST service-scope gap, are both now closed: the REST route is denied
outright via the shared deny_fleet_wide_service_scoped chokepoint (MCP’s own,
pre-existing structural C8 default-deny needed no change), and the match evaluation
on both surfaces now runs through the same fail-closed ladder real dispatch uses.
The sibling, deliberately out-of-scope gap on /api/scope/estimate (owner-check
failure there degrades to “zero members” rather than aborting) remains open, tracked
as #5003. Correction also recorded in the route inventory doc itself.
Addendum — service-scoped tokens on GET /api/v1/upload-grants and the
ITServiceOwner ceiling on every fleet-read route, closed (CC6.1/CC6.3, #3526).
Two gaps in the confinement guarantee above, both closed by one change.
(1) GET /api/v1/upload-grants (operator-facing list, UploadGrant:Read) has
been session-authenticated since the route was added (2026-08-14, 82319412d,
git log -S'"/api/v1/upload-grants"'), and its only gate resolved the list view from
the MINTING user’s username. A service-scoped API token therefore inherited its
minter’s UploadGrant:Read view, the whole fleet for a minter holding a global
grant, and the route was not covered by the flip’s closed inventory (the sweep
exempted “upload-grant planes” as a class; the correction is recorded in
docs/security-reviews/service-scope-flip-route-inventory-2026-08.md). The MCP twin
list_upload_grants refused a service token throughout. For any assessment
covering 2026-08-14 up to the point this change is deployed, treat
GET /api/v1/upload-grants as NOT covered by the flip’s confinement guarantee.
The route now refuses a service-scoped session outright (403, audited as
upload_grant.list.access_denied). (2) The ITServiceOwner ceiling on
require_fleet_read. That gate admitted a service-scoped token on its minter’s
grant plus the service-tag narrowing alone, without checking that ITServiceOwner
holds the (securable, operation) pair, so an operator who had revoked a pair from
ITServiceOwner still saw service tokens served on every fleet-read route. The gate
now applies the same ceiling as require_permission and
require_scoped_permission; a definitive miss is a 403
and a failed read is a retryable 503, both audited as auth.fleet_read_required.
Under the seeded defaults the ceiling admits the same requests as before for every
pair ITServiceOwner holds, so the observable change is limited to deployments that
revoked a pair and to pairs the role does not hold (the Enrollment fleet reads are
the documented case; see docs/user-manual/upgrading.md). Breaking for
service-scoped tokens only; human sessions, engine principals and MCP tiers are
unchanged. Evidence for this entry is the governance ledger for #3526.
Addendum — machine-identity resource-bounding (CC6.6, PR 4.4). Engine
principals (ADR-1005 class) resolve authority from default-deny RBAC, and
assignment refuses the admin / Administrator role names and the built-in
roles. A custom role is not blocked, including one that covers every
permission (maintainer ruling 2026-10-23, #2485): that case is detected
afterwards by the no-admin audit route, which is a pull-only, point-in-time
check. ok:true means no role held by an engine principal covers the whole
permission grid at the time of the run; a role granting all but one pair is not
reported, and nothing schedules or alerts on the check. It evaluates three
separate reads (grants, roles, role permissions), not one snapshot. The cadence is
the operator’s, and the engine_principal.audit.no_admin run rows (violations=N scope=all|active, then up to ten detected principals) are the evidence the
control operated. A dropped success-row write is signalled only by
Sec-Audit-Failed on REST (the MCP tool gives no signal). The Tokens page also
runs the check incidentally, in the browser, when a viewer holding AuditLog:Read
loads the principals panel; that is not a schedule and not a control, so a
period with no rows is not evidence that the check ran or that it passed. A machine-identity
access review must therefore include the engine grants and a no-admin run. The
route and its MCP twin fail closed when a read cannot be answered, so a degraded
store is never reported as clean. Risk to be recorded in the risk register: a custom wildcard role on a
machine identity is detected, not prevented, with pull-only detection. See the
engine-principal routed-concern row in .claude/routed-concerns-access-control.md.
PR 4.4 adds the complementary resource-bounding control: a per-principal
in-flight concurrency cap and request-rate cap enforced at the server’s
single pre-routing chokepoint, so a compromised or malfunctioning engine
principal cannot exhaust server capacity via unbounded concurrent or
high-rate requests. Credit this pairing (RBAC-bounded identity with a detective
check, plus a bounded-resource machine credential) as a CC6.6 control for any
privileged/machine-identity access review. See
docs/user-manual/engine-principals.md “Per-principal quota cap” for the
operator-facing reference.
Addendum — engine-credential rotation confirm pinning (CC6.1/CC6.3, #2384).
The overlap-pair rotation’s maker-checker confirm step is pinned to the exact
credential being confirmed: the confirm call (REST and MCP) requires the
successor token_id the rotate response returned, a stale or mismatched id is
rejected with no state change, and the success audit row records
token_id=<confirmed id> — binding the attestation evidence to one specific
credential. This closes a rotation-confirm replay hazard (a blind retry of an
old confirm landing after a second rotation started could previously revoke
that later rotation’s still-live credential). Credit as a CC6.3
credential-rotation control with per-credential attestation evidence; the
operator-facing flow is docs/user-manual/engine-principals.md §4 and the
audit contract is the engine_principal.credential.confirm row in
docs/user-manual/audit-log.md.
Addendum — engine-principal revocation latency for LIVE streams (CC6.2, #2367). Answering “how quickly is a revoked machine identity cut off?” now has three distinct figures, and they should be quoted separately:
- New sessions and new delegations: immediate. Session synthesis and the REST/MCP on-behalf-of target checks read the principal store authoritatively on every call. A revoked engine principal cannot obtain a new session or a new delegation at any point after the revoke commits.
- Live held-open streams, same server: next heartbeat tick (~3 s). The per-tick liveness re-check is served from a 15 s cache, but the revoking server invalidates that cache synchronously as part of the revoke write.
- Live held-open streams, other replicas: bounded by the 15 s cache TTL plus a tick. The cache is per-process, so a peer replica honours its own entry until it expires. This is the same class of residual property the 60 s API token cache already carries, and is the figure to quote for a multi-replica deployment.
These figures apply to streams authenticated by an engine principal. Streams authenticated by an ordinary API token have a different, currently-open profile: a token-cache hit is still treated as a fresh confirmation, so for those the cache residency and the outage grace window can still add — the non-additive guarantee below is engine-specific (pre-existing, tracked as #2447). Do not generalise the engine figures to all streams when answering a questionnaire.
An auth store that is unreachable is deliberately not treated as a
revocation (it would cut every stream on the fleet at once); such streams ride
a bounded grace window and then close with a distinct auth_unavailable
reason. For engine streams that window is measured from the last authoritative
confirmation, so cached answers cannot extend total survival beyond it. Credit
as a CC6.2 revocation control with the bounds above; the operator-facing
reference is docs/mcp-server.md “Revocation.” and the upgrade note in
docs/user-manual/server-admin.md.
Addendum — per-device dispatch confinement, one seam for every operator
surface (CC6.1/CC6.3, #1788/ADR-0033). Every operator-facing dispatch path —
POST /api/command, MCP execute_instruction/execute_bundle, REST
POST /api/v1/bundles, the dashboard execute route, Echo purge_source,
POST /api/instructions|workflows/{id}/execute, and /auto deployment
staging/execution (DeploymentRoutes, joined this release — previously
dispatched under system authority with no per-device confinement at all) —
now intersects its resolved
target set against ONE derived visibility decision (derive_exec_visible →
dispatch_confined_arms) before sending, instead of four independently
hand-rolled arm-classification copies. A service-scoped token is confined to
devices tagged with its own service on every one of those surfaces; a global
administrator, a JIT-elevated session, or a legacy-RBAC-disabled admin remains
full-fleet (their actual authority, not a bypass); the derivation fails
closed (a present-empty visible set, never “unfiltered”) if the underlying
store is unavailable. (2026-08-17: “unavailable” now also covers a mid-scan
read failure on an otherwise-reachable tag store, not just prepare-time/
connection failures — TagStore::agents_with_tag_checked, closed by the
PR #3216’s hardening round.) Credit as a CC6.1
least-privilege control and a CC6.3 access-enforcement control with
consistent enforcement across every listed surface; operator-facing
reference is docs/authz-model.md. Residual, not
yet closed, for THIS seam specifically: a purely management-group-confined
operator (no global grant) reaching one of the surfaces above is narrowed but
not yet reachable — that principal is still denied earlier by the base
require_permission gate (the ADR-0017 “correct-but-unreachable” World-A gap)
until the admit-then-filter list gate lands (#1714/#1715); do not cite
management-group-scoped dispatch on THESE surfaces as an affirmative
CAIQ/CC6.1 answer until that lands. The BACKGROUND dispatch paths (scheduler,
Guardian push, the policy evaluator) dispatch as system, not on behalf of an
operator, and are explicitly out of scope for this control — tracked with the
core-owned dispatch chokepoint (#1714/#1715).
REST /api/v1/quarantine (POST/DELETE/GET) is a SEPARATE control, already
fully reachable — do not fold it into the residual above. It gates on the
per-target scoped_perm_fn chokepoint (check_scoped_permission), not
require_permission → dispatch_confined_arms, and is EXEMPT from the
ADR-0017 World-A gap: a management-group-confined operator with
Security:Execute/Security:Read only through a group succeeds on all three
routes TODAY for devices in their group(s), not merely “narrowed” for a future
gate. Credit affirmatively as a CC6.1/CC6.3 control now, citing
docs/authz-model.md’s quarantine_device paragraph and the MCP
quarantine_device twin as the reference implementation.
Amended (#6065 PR-Q): POST and DELETE /api/v1/quarantine are no longer scoped-gate only. The
paragraph above describes the route before #6065. Since PR-Q, POST runs the shared
dispatch chokepoint (classify_and_authorize_dispatch for quarantine.quarantine, an
AdminOrApproval pair) AFTER the scoped gate and BEFORE the attempt audit row and the
store write. Evidence to cite for CC6.1/CC6.3: with RBAC on, a non-administrator holding
Security:Execute is refused 403 approval_required and a group-only holder 403
forbidden, each writing a quarantine.enable row with result=denied and
detail reason=<label> securable=Security, plus
yuzu_server_dispatch_denied_total{reason}; with RBAC off the legacy gate already requires
the admin role, so there is no change. A group-confined operator no longer “succeeds” on
POST or DELETE; GET is unchanged. DELETE /api/v1/quarantine/{agent_id} (release the server record) runs the same check for the
quarantine.unquarantine pair, so a caller who cannot create a record cannot end one; a refusal
leaves the record active and writes a quarantine.disable row with result=denied. Residual,
stated so a questionnaire answer is not overstated: a release still ends the server record
only (the DELETE handler dispatches no unquarantine, so the endpoint firewall stays applied
until the plugin’s own release action runs), and REST has no approval-ticket path, so this is a
refusal, not a four-eyes control; activation of the
four-eyes feature is a separate, postponed decision (epic #6060).
Addendum: who can list the issued certificates (CC6.1/CC6.3, 2026-10-23). The issued-certificate
inventory (GET /api/v1/ca/issued, filtered or not, MCP list_issued_certs, and the list in the
Settings certificate panel) carries every enrolled agent’s subject, SAN and issuing identity. Before
this change any Security:Read holder was served it, a management-group-confined one included. It is
now withheld from a confined caller and from one with no Inventory:Read grant, and every API and MCP call is audited as crypto.ca.certificates.view.
Who is served, stated plainly so a questionnaire answer is not rounded up: with RBAC enforcement off
(the shipped default), every Security:Read session, unchanged; with RBAC on, a caller with a global
Inventory:Read grant or an elevated (JIT) session. Refused (403): a caller whose Inventory:Read
is granted only for management groups, and a Security:Read holder with no Inventory:Read grant
at all (holding no grant is not an affirmative admission to a fleet-wide list, and removing a caller’s
last scoped grant never widens what it can see). A caller whose access check hits a read failure
partway through is answered 503 and is never served the list. Detective control, with its limits:
on the route and the MCP tool each success row ends standing=available, each refusal is a denied
row ending withheld=confined or withheld=refused, and each failed check is a failure row; the
drill’s success rows carry no standing token, the audit row can be missing during the same outage
(correlate with yuzu_server_audit_emit_failed_total), and no certificate-specific alert fires on a failed check:
the only signals are the shared read-degrade counters and breaker (YuzuRbacReadDegraded,
YuzuRbacBreakerOpen for RBAC reads; yuzu_server_mgmt_group_read_degrade_total and
YuzuMgmtGroupReadDegraded for management-group reads); a management-group store that is not
wired raises no counter, and one that is not open raises none on the CA routes (the fleet gate answers
before reading it) but is counted as store_not_open on list-gated routes. The Settings
panel and the /crypto drill write no row for a refused caller (confined or holding no grant). Do not state that confined
operators cannot read the certificate inventory without these qualifications.
Addendum: the last-Administrator guard for account lifecycle (CC6.1/CC6.2,
#4966). Deactivating, deleting or demoting the last active local administrator
account is now refused on the Settings user delete and role change and on every SCIM
deprovision and group-derived demotion site (a 409 on SCIM, audited as
scim.user.last_administrator_refused). Read it as what it is: a recovery rule
that keeps the fleet from losing its last administrator through an ordinary
operation, not proof that anyone can sign in (a counted administrator may have no
usable credential or step-up factor), and not an HA claim. Limits an assessor
should know: administrators who sign in only through SSO are not counted until #5182;
a rolling upgrade or a rollback runs without the guard on the binaries that lack it;
direct SQL against the database is outside the rule; the SCIM Group role recompute has
documented known limits and no alert rule ships for it yet (tracked in #5673; the
interim SIEM keys are in docs/user-manual/scim-provisioning.md, “Pilot playbook”).
Operator remedy and the break-glass exit: docs/user-manual/rbac.md, “If the guard
refuses an action you intend”.
Risk-register entry: an identity provider that removes a person who is the fleet’s only
administrator leaves that person active (loud: audit row, counter, critical event),
until an operator promotes a second administrator and demotes the first.
Addendum: approver assurance for approve and reject (CC6.1/CC6.3, #6061, #6079). Approving or
rejecting an approval request requires an interactive human session (local or OIDC), a human
principal that is not an engine principal, Approval:Approve, a reviewer whose account name differs from the
submitter’s (residual (e); this is not a check that two different people were involved), and an enrolled MFA factor with a fresh proof. API-token and MCP sessions are refused
with a typed 403 (MCP: -32003). Two checks apply, in this order. The central MFA step-up check
(37 named routes; docs/user-manual/authentication.md) runs first and passes an account that has no
factor, so on top of it the approver’s own factor rule applies: a local account must have an enrolled
TOTP factor and a proof no older than the approver window that was not made before its current
enrolment; an OIDC session needs the identity provider to have asserted an MFA-method amr on an ID
token issued within the window; a SAML session is refused (#4935; with the step-up window above 0 the
central check answers it first with its generic 403, otherwise the approver rule answers with the typed
refusal; a SAML-only deployment has no working approver path until that is decided, tracked in #6098). The window is min(--mfa-step-up-window-secs, 300)
and 300 when that is 0 or negative, independent of --mfa-enforcement, so neither a mode nor
--mfa-step-up-window-secs=0 removes the requirement. Break-glass accounts get no exemption. On a
default install (RBAC enforcement off, --mfa-enforcement=optional) the only approvers are
administrators and none is forced to enrol, so every administrator who has not enrolled a factor in
Settings is refused until it does.
Residual risks: (a) an OIDC “fresh proof” means the provider issued an ID token with an MFA-method amr
within the window; the MFA itself may predate the token, because the provider can reuse its own
session (the server sends no prompt=login or max_age), and no second factor is presented again
at approval time; any single MFA-method amr value the gate accepts counts; real re-authentication (auth_time with max_age) is a planned
follow-up; (b) clearing an account’s MFA factor ends that account’s dashboard sessions, in a second database
commit and not for API tokens. yuzu-server --mfa-reset and an administrator’s own Disable in Settings
(which needs a fresh step-up proof unless --mfa-step-up-window-secs is 0 or less, and signs the
caller out too, except when it only cancels an enrolment that was never verified) clear the factor in
one commit and delete the account’s sessions in another, so the pair is not atomic. Both audit the
clear in two phases: an attempted intent row (mfa.reset.breakglass or mfa.disabled) is written
first and fails closed, and an outcome row follows the work. No factor is cleared unless the intent
row is durable (on the Settings route this relies on the audit store having opened and the audit
writer being wired into the route, which server boot requires; the CLI checks the write result
directly); an attempted row with no outcome row marks a clear that was requested and whose outcome
is unknown; the residual, until the clear and the session delete share one transaction, is the loss
of the outcome row (a process that dies after the clear, or an outcome write that fails after it),
which leaves the factor cleared, the sessions possibly live and an attempted row with nothing after
it. A process that dies between the intent write and the clear leaves the factor intact and the same
lone attempted row, so that row alone does not prove the factor was cleared: check
auth.users.mfa_enrolled_at. If the session delete fails the factor
stays cleared and the sessions stay live until an operator ends them: Settings records mfa.disabled
with result=partial, --mfa-reset records mfa.reset.breakglass with result=partial and exits
non-zero (both with sessions_ended=failed), and the Settings page offers no retry control (an
administrator calls DELETE /api/v1/sessions?username=; the recovery runbook has a single-transaction
SQL fallback, which writes no audit row). If the clear itself fails the result is error and the
state is unknown, because the commit acknowledgement may have been lost and the clear may have
landed: Settings still ends the sessions, and --mfa-reset ends none and exits non-zero. A sign-in in
flight when the delete runs can leave a session the delete did not cover; and a replica or CLI image
still on an older build ends none and writes no attempted row. sessions_ended counts the durable
session rows deleted, expired rows not yet reaped included. Other replicas drop their cached sessions
within about one second in normal operation and within 30 seconds while a replica’s session-generation
poll is failing; neither bound holds after a database restore or a failover to a lagging standby
(runbook, “Sessions are durable”). The outcome row cannot undo the clear, so a failed outcome write
after it leaves only the intent row: yuzu-server --mfa-reset then exits non-zero, and Settings logs
an error and completes the request. The comparison of the proof with the current
enrolment (an older proof is refused after re-enrolment, proof_predates_enrolment) is the second
line of defence: it stops an older proof being reused after re-enrolment and assumes the database
primary clock does not step backward. ADR-2002 section 4 treats that clock as a security dependency,
and keeping the primary’s time source correct is the customer’s responsibility; the shipped detector
is the YuzuSessionReapClockAnomaly alert, which sees a backward step only when it exceeds the time
since the last accepted session reap pass (nominally about 15 minutes while the session store is
open, not a guarantee, plus the alert’s 5 minute hold) and a forward step only when it exceeds about a
year. The approver gate’s comparison of the stored proof with the database clock also makes that clock an availability
dependency as well as a security one: a database clock behind the sign-in replica refuses OIDC approvals closed with the
clock-skew 503 (docs/ops-runbooks/auth-db-recovery.md, “OIDC approvers refused with a clock-skew 503”); (c) a non-admin
cannot enrol itself under --mfa-enforcement=optional or admin-only (this refuses the approver
mfa_not_enrolled only with RBAC enforcement on; with enforcement off, the shipped default, a non-admin
is refused 403 “admin role required” before the MFA gate, so enrolment achieves nothing until
enforcement is turned on), so each non-admin approver depends
on an administrator-run enrolment sign-in window or on a promote, enrol, demote sequence
(docs/ops-runbooks/auth-db-recovery.md, “Enrolling a non-admin approver”), and self-service
enrolment is a planned follow-up. Both are trust-on-first-use: whoever completes the enrolment first
becomes the account’s enrolled factor, so the runbook requires an out-of-band password handover
beforehand and a match of each mfa.enroll.verified row to the intended person afterwards, recorded in
the change ticket; an administrator who enrols itself in Settings is in the same position: that enrolment is
exempt from step-up and asks for no password, so a stolen session of an administrator who has never enrolled
could enrol an attacker-held authenticator and then satisfy the approver rule; --mfa-enforcement=admin-only
or required makes sign-in enrol administrators, which narrows this to sessions that already exist when the
mode is switched on; (d) the control is active
only while every replica serving /api/approvals/* runs a build that has it, a rollback is a
security-policy downgrade, and tickets decided before the upgrade gain no retroactive assurance; mixed-version admission during a rolling upgrade is a documented known limitation; the Yuzu project maintainer accepted it as inherent to a rolling upgrade (2026-10-28), recorded in the ADR-1005 exception ledger (entry of 2026-10-28) as a working-session statement, not a signed decision. The compensating record is the change ticket’s per-replica upgrade timestamps, the marker-query extract with its positive control (docs/user-manual/upgrading.md, “Mixed-version admission”) and the load balancer log excerpt. Running more than one server replica is unsupported today (the safe-to-scale gate), so this describes the rolling-upgrade case and is not a statement that multi-replica operation is supported; (e) the reviewer check compares account names only, so one person who holds two identities (a local and an SSO account, or two SSO identities) counts as two reviewers; this is a maintainer-accepted exception (ADR-1005 addendum of 2026-10-19), not a four-eyes guarantee across identities, and the compensating controls are narrowing the identity sources in use, an identity-to-HR-record check at the access review, and periodic sampling of reviewer and submitter pairs. All three are customer-operated: Yuzu neither enforces nor evidences them. The approval.approve audit row carries no submitted_by, so sampling reviewer and submitter pairs needs the per-ticket GET /api/v1/approvals/{id}, which returns it.
Running --mfa-enforcement=required is no longer needed to enforce the approver factor, but it remains
the only built-in way to enrol non-admin approvers without promoting them, so note its
fleet-wide cost if you choose it for the enrolment window: the identity provider must assert an
MFA-method amr, and an OIDC session without one gets 401 and loops through re-sign-in at EVERY
step-up endpoint (verify amr before enabling; see
MFA on SSO sessions and the
recovery runbook).
Second residual, CC8.1 (maker-checker on scheduled runs), closed by #6096: every approval stamp,
submission time included, is now database time, and a scheduled ticket is marked used when its
occurrence is queued, so one approval runs at most one occurrence regardless of server clocks, once every replica runs the fixed binary. Further small
residuals (database-clock steps expiring approved tickets, a stale approved
status on lists until the next sweep, no approval or schedule alert rules) are tracked in #6097 and
#6098. Operator-facing detail: docs/user-manual/instructions.md, “Who can
approve”. The MCP half is a recorded exception to the both-surfaces rule: ADR-1005’s
exception-ledger entry of 2026-10-18 (docs/adr/1005-headless-platform-use-case-engines.md,
revisit 2027-01-18, a re-confirmation date rather than a deadline). Rows an auditor can sample for
CC6.3: approval.approve / approval.reject carry (from #6061) an attempted row before the decision and an
outcome row (success, or denied with the store’s reason) after it, plus denied rows whose
detail starts with approver_assurance: for a bearer or MCP credential that reached the
decision surface (an engine credential tried over REST is refused by the central step-up check and
recorded as mfa.step_up.required with reason=engine_principal_denied), or for an approver refused for an MFA reason that the
approver gate (rather than the central step-up check) answers (mfa_not_enrolled, mfa_proof_missing,
saml_mfa_unsupported, account_unavailable, mfa_proof_stale; with the central check active, a stale
proof, a SAML session, an engine principal or a deleted or deactivated account is refused earlier, as mfa.step_up.required with
stage=pre_authz and no ticket-specific row; the central rows carry the route label in target_id, not the
ticket id, so they show who was refused, when and on which route, but not which ticket; only the
approver_assurance: rows and the decision rows name the ticket; the user= value of every
mfa.step_up.required row is neutralised, a space, =, , or control byte written as _, so a name cannot add a
reason= token), result=error rows with detail=approver_assurance:mfa_store_unavailable (the approver’s MFA state or stored proof could not be read) or approver_assurance:mfa_proof_clock_skew (an OIDC proof dated after the database clock), each written before any attempted row, and mcp.approve_request / mcp.reject_request refusals (during a mixed-version window, also result=success rows for those actions from a replica not yet upgraded).
The attempted and success rows of a decision made under the control carry
detail=approver_assurance:enrolled_fresh_mfa, which marks a decision made by a replica that had the control (it
dates the first upgraded replica, not the last old one, so keep per-replica upgrade timestamps in the change
ticket); it records that the check
passed, not the factor type or proof age, which are evidenced by the adjacent mfa.login.verified /
mfa.step_up.passed / auth.oidc_login rows, or, for a proof stamped by a Settings enrolment confirm, by
mfa.enroll.verified with step_up_proof=stamped. Evidence for residual (b) is a pair of rows per clear, ordered by the audit id: an attempted
intent row written before the factor is touched, then an outcome row. The mfa.disabled rows carry the
acting session’s principal, role and session id; partial or sessions_ended=failed marks an incomplete
clear, and error marks a clear whose state is unknown. The mfa.reset.breakglass rows carry the OS
identity that ran the command as principal; its outcome is success, partial (the session delete
failed, detail sessions_ended=failed) or error. Both are inside the auth-sample export. An
attempted row with no outcome row marks a clear that was requested and whose outcome is unknown: the
process died, or the outcome write failed, after the factor was cleared, or the process died before the
clear and the factor is intact. The reconciliation query that lists them is under “Finding a clear whose
outcome is unknown” in docs/ops-runbooks/auth-db-recovery.md, which also has the query for outcome rows
that ended error or partial; the row definitions are in
docs/user-manual/audit-log.md.
3.3 Workstream C — Application and Infrastructure Security
Target State
- Secure-by-default deployment profile.
- Defense-in-depth for data-in-transit, data-at-rest, and supply chain.
- Measurable vulnerability management lifecycle.
Required Features / Controls
- TLS/mTLS enforcement in production profiles.
- Security headers baseline (CSP, frame protections, strict transport settings where applicable).
- Hardened deployment templates (systemd/container) with least privilege and filesystem protections.
- Dependency and image scanning gates in CI.
- Signed release artifacts and provenance attestation.
- Formal secure coding standard + threat modeling for high-risk subsystems.
- MCP tool dispatch validates its internal authorization-registration tables at startup and refuses to boot on drift, rather than silently serving an under-governed tool (fail-closed on internal misconfiguration; #2383).
- Cookie-session writes require a same-site
OriginorReferer(CC6.1 logical access, CC6.6 boundary protection, CC7.2 monitoring): one server-wide check, fail-closed, with an audit row and a counter per refusal and shipped alert rules; cookie-session requests that dispatch work, persist state or force a refresh of server state (the guarded reads) are held to the same rule through an explicit reviewed list: aSec-Fetch-Siteheader that is present decides alone, and theOrigin/Refererrule applies only when it is absent. Design record:docs/security-reviews/central-same-site-write-gate-2026-10.md; operator description:docs/user-manual/security-hardening.md“CSRF protection”.
Evidence
- CI security scan logs, release signing attestations, config baselines, and change approvals.
- Same-site write check:
csrf.deniedrows retrievable through the audit API, theyuzu_server_csrf_denied_totalcounter with theyuzu-web-writesalert rules, and the CI tests that pin the decision, the pipeline behaviour and the call site. Residuals at the time of writing: behind a TLS-terminating proxy without a declaredhttps://trusted origin, anhttp://Originfor the same host and port is still treated as same-site (the remedy is declaring the public origin with its scheme; a server that terminates TLS itself requireshttps://), the per-IP rate limiter is the only bound on a refusal flood, refusals are not yet coalesced into fewer audit rows, and the Windows and macOS legs of the tests have not been run (update when those legs have run). For the guarded reads, fetch metadata cannot distinguish a browser’s own prefetch, prerender or link preview from a user action, a client that sends neither fetch metadata norOrigin/Refereris refused, a request not on the reviewed list is not checked (a source-text test is the best-effort guard, not proof), cookie-reachable reads outside the list remain there by decision, and a plain-HTTP non-loopback listener (or Safari before 16.4) gets no fetch metadata, so those requests depend onRefererand ano-referrerpolicy would break them.
3.4 Workstream D — Reliability, Availability, and Operational Readiness
Target State
- Predictable service reliability with enterprise-grade runbooks and incident response.
- Demonstrated recovery objectives (RTO/RPO).
- On-call coverage and escalation protocols.
Required Features / Controls
- SLO definitions for API availability, command dispatch latency, and job success rates.
- Alerting and escalation for SLO burn, error spikes, and capacity thresholds.
- Backup/restore automation plus periodic restore drills.
- Incident response lifecycle: detection, triage, containment, customer communications, postmortem.
- Capacity plans for 1k/5k/10k+ agents and documented scaling decision points.
Evidence
- Monitoring dashboards, incident tickets/postmortems, backup logs, restore drill reports.
3.5 Workstream E — Data Governance and Privacy
Target State
- Clear data classification and retention policy tied to product settings.
- Customer-facing transparency about what is collected, retained, and exported.
Required Features / Controls
- Data inventory by table/store/event type.
- Retention defaults and customer-configurable retention policies with enforcement checks.
- Documented deletion workflows and verification procedures.
- Encryption and key management requirements for sensitive data stores.
Evidence
- Data flow diagrams, retention configs, deletion run records, and quarterly data governance reviews.
Data Inventory — server-side SQLite stores
| Store | File | Data class | Retention | Deletion mechanism | Configurable via |
|---|---|---|---|---|---|
| Audit trail (SOC 2 evidence chain) | audit_store (audit_events, audit_retention_meta) | Security-relevant activity: operator actions, plus agent enrolment, fleet-topology events and background schedule execution | 365 days (a floor, not a ceiling - deletion is paced, never retroactive) | Migrated from the SQLite audit.db (ADR-0040): construction fails CLOSED with no SQLite fallback. migrate_from_sqlite and its backfill_complete/backfill_source_fingerprint marker rows were RETIRED (2026-09-04, #3623, ADR-0009 hard-cutover Update) — fresh-start-by-default, never backfilled; server.cpp now runs legacy_sqlite_probe::warn_if_legacy_rows over audit_events at boot instead, log-only, never blocking. A legacy audit.db with real rows is therefore only warned about, never imported — the accepted risk given no production Yuzu fleet has ever run a pre-Postgres build. Writes fail HARD (503 + Sec-Audit-Failed, never a silent drop); reads DENY on degrade (503, never a false-empty). AuditStore::cleanup_once - clock-guarded and capped (#2360), now single-sweeper fleet-wide: every replica runs the thread but only the holder of the pg_try_advisory_xact_lock('audit_store:reap') advisory lease sweeps per tick, and the clock-guard dedup state lives durably in audit_retention_meta (survives restart/failover across replicas). The pass declines and warns when it would expire every datable row, when the persisted clock reading is unusable, or when the gap since the previous pass exceeds a fixed 7 days (a clock jump OR an outage that long - elapsed time cannot separate them), and deletes at most 25,000 rows per pass, oldest first. ttl_expires_at is stamped at INSERT and never rewritten. The decision rule is classify() plus the fact construction in AuditStore::cleanup_once, pinned by tests; operator triage is docs/user-manual/audit-log.md#the-retention-clock-guard. Series: the eight retention signals (seven counters plus one gauge — yuzu_server_audit_clock_anomaly_skips_total, ..._cleanup_failed_total, ..._retention_cap_reached_total, ..._rows_deleted_total, ..._retention_persist_failed_total, the liveness counter ..._retention_passes_total, ..._retention_bootstrap_declines_total, and the gauge ..._retention_last_pass_unixtime), the two write-path signals ..._events_total{result} and ..._emit_failed_total, plus the new ADR-0040 counter ..._read_degrade_total{reason} (deny-on-degrade reads: store_not_open/pool_acquire_timeout/query_error); the one-time ..._backfill_total{result} counter retired with migrate_from_sqlite (ADR-0009 hard-cutover Update, 2026-09-04). Detective-control coverage, stated because it is the honest answer to a security-questionnaire item (#2553): the liveness signal above is consumed by YuzuAuditRetentionNotRunning, which was blind to a crash-looping server - the leading cause of a stopped reaper - and by a companion YuzuAuditRetentionMetricMissing covering the case where the liveness metric is not scraped at all (an absent series leaves the liveness rule unfirable by construction). As of #2854 rung D the pair is BEHAVIOUR-CHECKED AND COVERAGE-MEASURED, with the measurement as the evidence artifact rather than prose: the liveness rule’s grace is now a range read of the restart-surviving seeded stamp (unless (max_over_time(..._last_pass_unixtime[3h]) == 0 and ...) - verbatim the never-ran rule’s own firing condition, so the pair’s mutual exclusion and instant-by-instant joint ownership of dead-reaper states is structural; one measured temporal corner remains open and filed - a phase-locked oscillation between the two owners faster than either for: accumulates, requiring a never-anchored database, a dead reaper, and a scrape phase-locked to a sub-interval boot - see the rules-file comment), and the previously-named residual gaps resolved as follows. (a) CLOSED - the 164-195 minute blind band, and the wider intermittent-firing gap from ~90 minutes that the rung B instrument (inverted to assert the alert stays CONTINUOUSLY firing) additionally found, both measure ZERO uncovered restart cadences at every swept evaluation interval; the evidence artifact is tests/prometheus/blind_band_manifest.json (empty uncovered sets), regenerated by docs/prometheus/blind-band-measurement.md’s method and re-checked on every PR via the Prometheus alert rules CI check, which since #2949 also verifies the recorded alertname and alert-present provenance so a renamed or ineffective alert cannot pass on matching cadence sets (non-blocking today — that check is not a required status check, per the scope note below). (b) CLOSED STRUCTURALLY - with the stamp grace there is no restart cadence at which the expression oscillates, so the alert stays firing while an anchored reaper stays dead and a RESOLVED normally evidences recovery; the remaining Alertmanager caveat is the ~45-minute false page after a Prometheus restart/fresh-TSDB/first-apply (#2856, open, unchanged by the redesign), so confirming recovery against ..._retention_passes_total rising remains the stated practice. (c) OPEN, unchanged - a server that is scraped and alive but does not export the liveness counter at all - an older build, since the rules file is applied by the operator and routinely runs ahead of the servers - is covered by neither rule, and an up-based target-down alert does not cover it either because that server’s up is 1 (#2855). (d) CLOSED - YuzuAuditRetentionNeverRan shipped (rung D): a database that has never had a retention pass at all now has its own alert with a first-boot-sized grace, mutually exclusive with (a)‘s anchored-but-dead-reaper case while the target is scraped, and the instrument’s fresh scenario measures it at zero uncovered cadences rather than uncovered-by-definition. The same PR’s governance round found - and fixed, red-first - a MIXED-SEED regime outside the instrument’s two constant scenarios (a never-anchored crash loop whose anchor reads intermittently fail, alternating 0 with the unreadable-anchor sentinel) that silenced both rules as first shipped; the hardened expressions (a range-form grace on the liveness rule; a sentinel-aware never-ran arm) page it, and a second measured regime from the Gate 8 re-review (zeros alternating with a dead-CMOS negative anchor) drove the grace to the structural form; both pinned pointwise by promtool CASES 39-40 and 44-46, since the sweep’s scenario grid deliberately stays two constant regimes. All coverage claims here are measured under CONTINUOUS scraping - a scrape gap exceeding the [3h] windows suspends both rules until fresh samples land, which is scrape-health territory (up, YuzuAuditRetentionMetricMissing), not a restart-cadence hole. Note the alert rules ship as a file operators apply themselves and no shipped stack loads it (#2857), so a deployment that does not re-apply docs/prometheus/yuzu-alerts.yml keeps the old blind rule and never gains YuzuAuditRetentionNeverRan at all. Scope of “the alert rules are tested”: every shipped rule is PARSE-checked on each pull request (promtool check rules) and the retention-liveness pair is additionally BEHAVIOUR-checked (promtool test rules, each threshold, window, for duration and label join red against a mutation of itself). As of #2854 rung C, the crash-loop signal that used to live only as a grace-exclusion inside the retention-liveness rule is a third behaviour-checked alert in this row (and #2854 rung D’s YuzuAuditRetentionNeverRan a fourth, pinned by CASES 32-34, 36-46: fire/clear timing, its [3h] range, negative-stamp handling, the sentinel semantics, the mixed-seed regimes, and mutual exclusivity with the liveness rule): YuzuServerRestartLoop (resets(yuzu_server_uptime_seconds[3h]) > 3, for: 15m), pinned by CASE 25-31 in tests/prometheus/yuzu-alerts.test.yml (threshold boundary, window choice, for: duration, standalone-from-the-retention-metric, and its documented disagreement with the retention rule at 2 or 3 resets — collectively mutation-tested against real promtool, with individual per-case attribution confirmed for the threshold, boundary, and for: cases specifically; the remaining cases were confirmed via the full suite reddening under mutation, not each individually isolated). It has its own documented, not-yet-closed gaps: a resets() false-negative window after a Prometheus restart/fresh-TSDB/first-apply (mirrors (a) above in direction), permanent blindness to a crash loop faster than the metrics-sweep tick with no dead-man’s-switch companion rule (tracked: #2956), and no blind_band_sweep.py-style coverage-measurement instrument of its own yet (tracked: #2958). Elsewhere in the same rules file, other alerts outside this audit-trail row also carry behaviour cases (YuzuMcpStreamedPinSlotsWedged #2792, and the YuzuResponse* family) - not for an audit-trail purpose, so they do not add to this row’s coverage. Every other rule in this audit-trail row carries no behaviour cases, and prometheus-rules is not a required status check, so a green run on those proves they parse, not that they can fire. | audit_retention_days |
| Response store | response_store (responses, response_facets) | Agent command/instruction results | 90 days | MIGRATED TO POSTGRESQL (ADR-0039) — see the PostgreSQL-stores table below for the current mechanism. Historical: ResponseStore cleanup thread (TTL at insert), not clock-guarded | response_retention_days |
| Guaranteed-state rules | guaranteed-state.db (guaranteed_state_rules) | Rule definitions (configuration) | Indefinite — lifecycle via explicit delete | MIGRATED TO POSTGRESQL (ADR-0038) — see the PostgreSQL-stores table below for the current mechanism. Historical: REST DELETE / delete_rule | n/a |
| Guaranteed-state events | guaranteed-state.db (guaranteed_state_events) | Drift/remediation telemetry (high-volume operational) | 30 days default | MIGRATED TO POSTGRESQL (ADR-0038) — see the PostgreSQL-stores table below for the current mechanism. Historical (pre-migration): GuaranteedStateStore::run_cleanup thread — bare DELETE … WHERE ttl_expires_at > 0 AND ttl_expires_at < now, no clock guard | guardian_event_retention_days |
| DEX observations (projection) | guaranteed-state.db (guardian_observations) | Behavioral telemetry / PII — per-device reliability signals (114-type display catalogue — 110 Windows event-log types + 4 poll-derived; app crashes/hangs, boot/resume durations, service/network/identity/power/driver failures; authoritative count in docs/dex-signal-catalog.md). Keyed by agent_id → device → person; the per-device history reveals which applications a person runs. | Lockstep with parent events — the projection row carries the SAME ttl_expires_at as its source event and is reaped in the same cleanup pass; a projection can never outlive its source row | MIGRATED TO POSTGRESQL (ADR-0038) — see the PostgreSQL-stores table below for the current mechanism. Historical: same GuaranteedStateStore::run_cleanup pass — parallel bare DELETE FROM guardian_observations WHERE ttl_expires_at > 0 AND ttl_expires_at < now | guardian_event_retention_days (one knob governs both — by design, so event and projection retention cannot diverge) |
| PKI cert inventory | ca_store (ca_root, ca_issued, ca_crl_versions) | Internal-CA cert inventory + revocation/CRL state (security-relevant). CA root private key is NOT stored here — it is a 0600 file referenced by an opaque key_ref. | Indefinite — cert records must outlive the cert for audit | MIGRATED TO POSTGRESQL (ADR-0053) — see the PostgreSQL-stores table below for the current mechanism. Historical: None by design (revocation flips status; no reaper) | n/a |
| Analytics events (sink-side) | ClickHouse / JSONL | Telemetry + usage, once drained | Customer-controlled (external sink) | Sink-side retention | clickhouse_*, analytics_jsonl_path |
| Analytics events (server-side buffer) | was per-process SQLite analytics.db | Telemetry + usage, pre-drain | n/a | MIGRATED TO POSTGRESQL (ADR-0049, governance Gate 6 finding, 2026-08-16 — this row previously described only the sink-side copy above, omitting the server-side buffer entirely) — see the PostgreSQL-stores table below for the current mechanism. | --no-analytics |
| Fleet visualization cache | FleetTopologyStore (in-memory) | Aggregated echo.fleet_snapshot topology — per-machine process records (pid, ppid, process name, OS user, category) and connection edges | 60 s TTL, LRU-of-2 cache slots (one per include_vuln value) | Time-based eviction in-process; never persisted to disk | --viz-disable to disable entirely; echo.configure process_enabled=false per-agent to suppress process collection upstream |
Audit capacity ceiling (Workstream D + G). The audit trail’s paced drain (see the PostgreSQL-stores table below) is now single-sweeper fleet-wide (advisory lease), so the cap is one drain rate, never N replicas × 25,000. It paces at TWO cadences, not one — do not quote only the first figure to a prospect, it understates real capacity by roughly three orders of magnitude: a quiet-operation threshold of ~6.9 audit events/second (25,000 rows/hourly pass, ~600,000 rows/day, yuzu_server_audit_retention_cap_reached_total stays at 0 below this), and a backlog-recovery ceiling of up to ~5,000 audit events/second once that counter starts moving (the guard re-arms every 5 seconds instead of hourly while a backlog persists, still capped at 25,000 rows/pass). The audit store only grows WITHOUT BOUND above the second, much higher figure — a rising cap_reached_total alone means the guard is draining faster, not falling behind; sustained capping with rows_deleted_total not keeping pace is the actual signal. At the 365-day default the QUIET threshold alone is also a storage commitment on the order of 219M rows / ~44 GB. Both cadences are engineering constants, not configuration, so a prospect whose SUSTAINED audit rate exceeds the backlog-recovery ceiling needs a scoping conversation; one that merely exceeds the quiet threshold does not. The storage figure is an ESTIMATE built on an assumed ~200 bytes/row and varies with principal, action and detail length — quote rates as limits, the size as an order of magnitude. Derivation, both cadences’ full reasoning, and the caveats on every figure here: docs/user-manual/audit-log.md (“Capacity”) — this paragraph is a summary, that section is the one home.
Data Inventory — server-side PostgreSQL stores (ADR-0006/0008)
As of the Postgres substrate flip (ADR-0006), born-on-Postgres stores are the server’s primary data home and carry the same data-classification obligations as the SQLite table above. Each new server store registers here.
| Store | Schema (PostgreSQL) | Data class | Retention | Deletion mechanism | Configurable via |
|---|---|---|---|---|---|
| Audit trail (SOC 2 evidence chain) | audit_store (audit_events, audit_retention_meta) | Security-relevant activity: operator actions, plus agent enrolment, fleet-topology events and background schedule execution | 365 days (a floor, not a ceiling - deletion is paced, never retroactive) | Migrated from the SQLite audit.db (ADR-0040): construction fails CLOSED with no SQLite fallback. migrate_from_sqlite and its backfill_complete/backfill_source_fingerprint marker rows were RETIRED (2026-09-04, #3623, ADR-0009 hard-cutover Update) — fresh-start-by-default, never backfilled; server.cpp now runs legacy_sqlite_probe::warn_if_legacy_rows over audit_events at boot instead, log-only, never blocking. A legacy audit.db with real rows is therefore only warned about, never imported — the accepted risk given no production Yuzu fleet has ever run a pre-Postgres build. Writes fail HARD (503 + Sec-Audit-Failed, never a silent drop); reads DENY on degrade (503, never a false-empty). AuditStore::cleanup_once - clock-guarded and capped (#2360), now single-sweeper fleet-wide: every replica runs the thread but only the holder of the pg_try_advisory_xact_lock('audit_store:reap') advisory lease sweeps per tick, and the clock-guard dedup state lives durably in audit_retention_meta (survives restart/failover across replicas). The pass declines and warns when it would expire every datable row, when the persisted clock reading is unusable, or when the gap since the previous pass exceeds a fixed 7 days (a clock jump OR an outage that long - elapsed time cannot separate them), and deletes at most 25,000 rows per pass, oldest first. ttl_expires_at is stamped at INSERT and never rewritten. The decision rule is classify() plus the fact construction in AuditStore::cleanup_once, pinned by tests; operator triage is docs/user-manual/audit-log.md#the-retention-clock-guard. Series: the eight retention signals (seven counters plus one gauge — yuzu_server_audit_clock_anomaly_skips_total, ..._cleanup_failed_total, ..._retention_cap_reached_total, ..._rows_deleted_total, ..._retention_persist_failed_total, the liveness counter ..._retention_passes_total, ..._retention_bootstrap_declines_total, and the gauge ..._retention_last_pass_unixtime), the two write-path signals ..._events_total{result} and ..._emit_failed_total, plus the new ADR-0040 counter ..._read_degrade_total{reason} (deny-on-degrade reads: store_not_open/pool_acquire_timeout/query_error); the one-time ..._backfill_total{result} counter retired with migrate_from_sqlite (ADR-0009 hard-cutover Update, 2026-09-04). | audit_retention_days |
| PKI cert inventory | ca_store (ca_root, ca_issued, ca_crl_versions) | Internal-CA cert inventory + revocation/CRL state (security-relevant): root metadata, issued-certificate inventory, CRL version history. CA root private key is NOT stored here — it is a 0600 file referenced by an opaque key_ref (KeyProvider seam, unaffected by this migration). | Indefinite — cert records must outlive the cert for audit; no reaper by design (revocation flips status, never deletes). | Migrated from the SQLite ca.db (ADR-0053): construction fails CLOSED with no SQLite fallback. No legacy-data migration path — no production fleet ever ran a pre-Postgres build, so the mandatory fingerprint-verified three-table backfill this store originally shipped with was retired (#3623, ADR-0053’s Update); a legacy file is never imported, only counted for a diagnostic warning at boot. is_revoked() — the mTLS-accept gate — stays a plain fail-closed bool on any degradation (never a silent “not revoked”); every other read/write surfaces a genuine database error as a 503/error response instead of a silently-empty/-false result. A first-boot CA-root establishment race, structurally impossible under per-instance SQLite, is resolved race-safe via try_insert_root() (ON CONFLICT DO NOTHING RETURNING — every caller reads back the same canonical row; a losing replica discards its own freshly-generated, unusable key material rather than clobbering the winner). No reaper (unchanged from the SQLite original; revocation is a status flip, not a delete). | n/a |
| Guaranteed-state rules/events/DEX observations | guaranteed_state_store (guaranteed_state_rules, guaranteed_state_events, guardian_observations, guardian_meta, guardian_agent_rule_status, gc_meta) | Rules: configuration. Events: drift/remediation telemetry (high-volume operational, non-regenerable — the agent has already reported and moved on). DEX observations (projection): behavioral telemetry / PII — per-device reliability signals (114-type catalogue, docs/dex-signal-catalog.md), keyed agent_id → device → person. | Rules: indefinite, explicit delete. Events + DEX observations: 30 days default, guardian_event_retention_days — the projection carries the SAME ttl_expires_at as its source event and is reaped in the same guarded pass (lockstep; a projection can never outlive its source row). | Migrated from the SQLite guaranteed-state.db (ADR-0038): construction fails CLOSED with no SQLite fallback. migrate_from_sqlite and its sqlite_backfill marker table were RETIRED (2026-09-02, #3623, ADR-0038 Update) — fresh-start-by-default (ADR-0009 amendment: no production fleet has ever run a pre-Postgres build), never backfilled; server.cpp now runs legacy_sqlite_probe::warn_if_legacy_rows over the legacy tables at boot instead, log-only, never blocking. A legacy guaranteed-state.db with real rules is therefore only warned about, never enforced — the same enforcement-class consequence BaselineStore (also in this retirement) carries for a legacy Baseline, and unlike the remaining 10 stores in the same retirement, where the warn-only fallback drops merely operational/cosmetic state, an unenforced Guardian rule is a policy-enforcement gap; see ADR-0038’s Update for the full reasoning. GuaranteedStateStore::reap_expired() — clock-guarded and capped (#2496/#2579/#2663), single-sweeper via pg_try_advisory_xact_lock('guaranteed_state_store:reap', 0), dedup state durable in gc_meta (survives restart/failover across replicas). The pass declines and warns when it would expire every datable row, when the persisted clock reading is unusable, or when no prior pass has ever reached a verdict on this database (the #2579 missing-anchor trigger — the same answer as AuditStore: decline, since this is non-regenerable evidence, not ResultSetStore’s reproducible scratch data), and deletes at most kReapCapPerPass (10,000) events + observations per pass, oldest first, in lockstep. Every retention decision reads PostgreSQL’s own clock, never a replica’s process clock (#2663, fjarvis review — mirrors AuditStore::cleanup_once’s #2360/1d fix: a replica’s own clock skew previously could poison the shared gc_meta anchor and misclassify a still-live row as expired, with zero anomaly recorded; a replica’s process clock now feeds only a cheap pre-transaction plausibility check and cannot move a verdict). Series: yuzu_server_guardian_reap_passes_total{result} (swept/noop/declined/declined_no_anchor/failed/skipped_lock; declined_no_anchor is alerted via YuzuGuardianReapAnchorNotSurviving on a climbing count — unlike AuditStore, this store ships no dedicated dead-reaper liveness pair (YuzuAuditRetentionNotRunning/..._MetricMissing); a fully-stalled reaper emitting zero passes of any kind is not covered by the alert above, only docs/user-manual/guaranteed-state.md’s suggested operator-authored absent_over_time query), yuzu_server_guardian_events_reaped_total / ..._observations_reaped_total (disposal evidence), yuzu_server_guardian_events_ingest_errors_total, and yuzu_server_guardian_read_degrade_total{reason,source} (deny-on-degrade DEX/Guardian reads). Full detail: docs/user-manual/guaranteed-state.md (“Retention”). | guardian_event_retention_days |
| Per-application performance, centralized (B1) | app_perf_daily_store (app_perf_daily) | Usage-class telemetry — per-device daily app-performance summary: (app image name, version, UTC day) with CPU/working-set averages + peaks + a sample count. The centralized projection of the on-device $ProcPerf_* tiers (below), shipped by the app_perf daily-sync source. Names-only — no command lines, no user attribution, no SID/username/user-path. Same legal class as $ProcPerf_* and a step more exposing than installed-software (it reveals not just which apps are present but how heavily each runs over time, device-attributable via agent_id) → works-council co-determination-relevant (capability to monitor; BetrVG §87(1)(6)) and personal data under GDPR if treated as such on personally-assigned devices. Resource-significant (procperf top-N) app-versions only — not a complete usage census. | 31-day rolling retention — apply_daily prunes rows older than 31 UTC days per agent on each sync (AppPerfDailyStore::kRetentionDays). A device that stops reporting retains ≤31 days, then ages out; delete_agent clears on removal. | Per-agent prune is automatic (time-based). Whole-device decommission purge is WIRED (ADR-0024) — AppPerfDailyStore::delete_agent is fanned by the AgentDecommission cascade behind the audited DELETE /api/v1/sle/agents/{id} route, so a decommissioned device’s rows are durably erased and the store reports its committed delete status (a rolled-back delete is reported failed, never a false erasure). Row-level / per-subject DSAR (Art. 17) erasure remains unwired — the cascade is whole-device only; that residual gap is #1666. | procperf_enabled=false (per-app collection switched off — no procperf data → no B1 data) and --inventory-disable / YUZU_AGENT_INVENTORY_DISABLE (the daily-sync master switch). Windows + Linux-fed today (Linux rows carry version='' and are kernel 15-char comms, which may include kernel-thread names — system infrastructure, not user-app usage); macOS planned. |
| Per-application performance, fleet aggregate (B2) | app_perf_fleet_store (app_perf_fleet) | Fleet-aggregate app performance per (app, version, UTC day) — device count, CPU/working-set sums + maxima, and a per-bucket device-count histogram (the trend/regression substrate). Carries NO agent_id — no per-device attribution. It is a derived aggregate with no device identifier, so it is lower sensitivity than B1 (above); because the cohort floor is one device (2026-10-14), a bucket with device_count 1 is that one device’s exact figures and is published by the un-audited fleet app-performance read, so it is not non-identifying where a single device runs an app/version. (Collection is still gated upstream: it is derived from B1, so procperf_enabled=false / --inventory-disable empties it.) | 180-day retention — the roll-up thread prunes day < now − 180d. | Time-based prune (automatic). No per-device purge applies (no device dimension); a DSAR erase targets B1, not this aggregate. | Derived — gated upstream by B1’s procperf_enabled + --inventory-disable. |
| Generic plugin inventory | inventory_store (inventory_data) | Device-attributable (agent_id) per-plugin JSON blobs for sources not promoted to a typed store. Sensitivity is plugin-dependent: custom plugins may collect identifiers, configuration, paths, or other customer-defined data, so this store must not be represented as categorically non-PII or secret-safe. Typed installed_software, app_perf, device_ci, and software_licensing sources are excluded and remain under their own securables: the control is the read filter (every generic read excludes the typed sources in SQL, so a stray row is never readable here), while the background boot purge is hygiene that reclaims the space. The filter is per server: during a rolling upgrade, a server not yet at this release has no read filter, so a stray typed row stays readable through that server until it is upgraded. | Current-state only — each (agent_id, plugin) report replaces the prior blob; no time-based reaper, so a device that stops reporting retains its last state. | Whole-device purge is WIRED through AgentDecommission, but PostgreSQL-only: InventoryStore::delete_agent erases the agent’s rows from inventory_store.inventory_data and reports failure unless that commits. No legacy-data migration path exists (#3623, ADR-0037’s Update) — this store never had a “retained legacy copy” to also erase, so a legacy inventory.db file, if one exists at all, is never read and never scrubbed by decommission; an operator with a genuine compliance reason to remove one must delete it manually and record that as separate evidence. No row-level/per-subject DSAR API (#1666). | --inventory-disable / YUZU_AGENT_INVENTORY_DISABLE disables daily inventory collection; individual custom-plugin collection is governed by that plugin’s own configuration. |
| Installed-software inventory | software_inventory_store (inventory_state, installed_software) | Installed-software asset inventory per device — name, version, publisher, install date — collected by the agent daily-sync framework (ADR-0016). Machine-scope; no end-user PII — per-user enumeration is deliberately not used (ADR-0016 §8): no logged-in-user attribution, no usernames. Lower behavioral sensitivity than the process-performance tiers (no run-time, no CPU/memory attribution). However: the data is device-attributable (agent_id), and on personally-assigned devices installed-software enumeration may still be works-council co-determination-relevant under national law (e.g. BetrVG §87(1)(6)) — co-determination is triggered by the capability to monitor, not by username presence, the same basis on which $ProcPerf_Live (above) is collected. Consult works-council counsel before deploying in EU collective-bargaining jurisdictions; an erasure obligation (GDPR Art. 17) attaches if the data is treated as personal in such a deployment. | Current-state only — each sync replaces the device’s rows; there is no time-based reaper. Last-known state is retained after a device stops reporting (mirrors the offline-endpoint posture); inventory_state.last_seen marks activity. | Per-device purge is WIRED (ADR-0024). SoftwareInventoryStore::delete_agent is fanned by the AgentDecommission cascade behind the audited DELETE /api/v1/sle/agents/{id} route (scoped Decommission:Delete — a device-level erasure securable authorizing for the cascade’s whole blast radius, replacing the earlier per-store conjunction (ADR-0024 Decision 9, amended Wave 7 PR7.2); audit-before-erase fail-closed), so a decommissioned device’s rows are durably erased with truthful per-store committed status. Stale-device exclusion remains a query-time last_seen filter, not a delete — an aged-out device that was never decommissioned keeps its rows. Row-level / per-subject DSAR (Art. 17) erasure is still unwired — the cascade erases a whole device, not one subject’s rows (tracked in #1666). | --inventory-disable / YUZU_AGENT_INVENTORY_DISABLE (agent deploy-time opt-out — collect nothing); no per-store retention knob yet (#1666) |
| Device configuration-item (CI) inventory | device_inventory_store (device_ci) | Device hardware/OS identity — manufacturer, model, serial number, system UUID, BIOS, CPU/RAM, primary + all MAC addresses, NIC count, OS name/version/build, architecture. Collected by the agent daily-sync framework (ADR-0016 source #3), 1:1 per agent (current-state only). Machine-scope (no username/SID/user-path), but serial/system_uuid/primary_mac are stable device-persistent identifiers — personal data under GDPR when a device is person-assigned, and works-council co-determination-relevant on the capability to track a device (and by association its user) over time, regardless of per-user data (ADR-0016). Operator-visible via the Hardware CI list and record (/hardware, /hardware/ci?id=), their REST routes (GET /api/v1/hardware, GET /api/v1/hardware/{id}) and their MCP twins (the tools list_hardware_ci and get_hardware_ci; tool results go to whichever model provider the agentic client uses, an operator data-processing decision) — every read gates on Inventory:Read and audits at the behavioural-PII tier (emit_behavioral_audit, Sec-Audit-Failed on persist failure; the MCP tools write the same two verbs through try_persist_audit plus a generic mcp.<tool> row and fail closed, withholding the data when either row cannot persist): inventory.devices (fleet list, wired to require_fleet_read — the ADR-0017 admit-then-filter gate, so per-operator confinement is effective) and inventory.device.ci (per-device drill, scoped at the gate, three-state found/absent/degraded). | Current-state only — each sync replaces the device’s row; no time-based reaper. | Per-device purge is WIRED (ADR-0024) — DeviceInventoryStore::delete_agent is fanned by the same AgentDecommission cascade behind DELETE /api/v1/sle/agents/{id} as software_inventory_store above. Row-level / per-subject DSAR (Art. 17) erasure remains unwired (#1666). | --inventory-disable / YUZU_AGENT_INVENTORY_DISABLE (agent deploy-time opt-out); no per-store retention knob yet (#1666). |
| Offline-endpoint state | offline_endpoint_store (schema endpoint_state) | Per-device last-seen record: agent_id, hostname, OS, architecture, agent version, last heartbeat and a presence session id. Not PII-free: hostname (often user-named) and agent_id are stable device-persistent identifiers — personal data under GDPR when a device is person-assigned, the same class as the device_ci and discovery rows above. Drives the offline-inclusive /hardware roster (30 days) and stale-flagging of departed hosts in /viz/fleet (7 days). Device-attributable (agent_id). | Current-state upsert per heartbeat; a graceful disconnect clears only the presence session id and keeps the row (#5678). A clock-guarded hourly prune deletes rows unseen for 37 days (30-day roster window + 7-day margin; per replica, at most 12,000 rows per hour, so a large backlog drains over hours). | Per-device purge is WIRED (#5682) — OfflineEndpointStore::delete_agent is fanned by the same AgentDecommission cascade behind DELETE /api/v1/sle/agents/{id} as software_inventory_store above, and deletes the row whatever its session_id (the store’s other write, remove_if_session, only clears the presence session id on a graceful disconnect, which a decommissioned agent never sends). A rolled-back delete is reported failed, never a false erasure. Erasure is point-in-time: an offline_endpoint=deleted entry in the success audit row’s store breakdown proves the row was deleted at commit, not that it stayed deleted. A still-enrolled agent re-creates it on its next heartbeat, so stop the agent first (stop or uninstall it; revocation cannot be undone through the API, since the server refuses to issue a new certificate for that agent id while the revoked certificate is unexpired (audit ca.cert.reissue_blocked), and it does not cut a gateway-connected agent off, #5578) and then issue the DELETE; the stop-first steps and the confirmation caveats are in docs/user-manual/rest-api.md under DELETE /api/v1/sle/agents/{agent_id}. Devices that are never decommissioned age out through the 37-day prune in the previous column (#5678, #1545). Row-level / per-subject erasure remains unwired (#1666). | n/a |
| Pre-flight run results | preflight_run_store (runs, run_device) | /auto pre-flight check configuration + results — device scope, the per-device pass/fail/warn check grid, summary counts. Operational / machine-health: asset-inventory-class facts (free disk, OS version, reboot state, target-app version). Device-attributable (agent_id / hostname); no end-user PII, no behavioral telemetry. | 14-day rolling — best-effort prune_older_than on the background pre-flight runner thread; owner can also delete a run. | Owner-scoped delete_run (manual) + time-based prune (automatic). No wired DSAR / decommission purge (the platform #1666 gap). | No per-store retention knob. |
| Deployment run state | deployment_run_store (deployments, deployment_device) | The /auto deploy stage — artifact delivery spec (download URL, filename, expected SHA-256, install args) + per-device deployment execution state (step, exit code, error, hostname, OS). Operational / machine-health: device-attributable (agent_id / hostname); no end-user PII, no behavioral telemetry. The artifact URL/args are operator-supplied and may carry internal infrastructure detail (file-server host, paths). | 14-day rolling — best-effort prune_older_than piggy-backed on the pre-flight runner thread; owner can also delete a deployment. | Owner-scoped delete_deployment (manual) + time-based prune (automatic). No wired DSAR / decommission purge (#1666). | No per-store retention knob. |
| Execution tracker | execution_tracker (executions, agent_exec_status, command_execution, event_outbox) | Operational execution records: which definition was dispatched by whom to which scope (dispatched_by, scope_expression, parameter_values, operator-supplied and possibly holding internal detail), and per-agent status, exit code and failure text (error_detail, agent-supplied; for a command of a Forensics-tier plugin written since #4990 it is kept only when the agent’s message already has the bounded token shape scope:cause[:detail] (at most 96 bytes), and is otherwise replaced by the fixed value forensics_error), the 24-hour dispatch record (command_execution: command id to dispatched plugin and execution id, the source of a stored response’s plugin label) and the durable event outbox. Device-attributable (agent_id); no behavioral telemetry. | command_execution and event_outbox: 24 hours, clock-guarded capped reaps (reap_command_execution_mappings, reap_event_outbox). executions and agent_exec_status carry no retention sweep of their own, so execution records and per-agent failure text are kept indefinitely (the failure text of a Forensics-tier command in its reduced form; the deployment contract in the upgrade guide, a fresh database with no data from earlier builds, is what makes that the whole population). | No wired DSAR / decommission purge (not in the ADR-0024 cascade; the platform #1666 gap). | No retention knob. |
| Policy compliance | policy_store (policies, policy_fragments, policy_inputs, policy_triggers, policy_groups, policy_status, policy_dispatch_state) | Operator-authored compliance policies and the per-(policy, agent) compliance status, including check_result (text derived from the check instruction’s output; for a check that runs a Forensics-tier plugin it is empty and the status unknown since #4990). Device-attributable through policy_status.agent_id; the check output is whatever the operator-authored instruction returns. | Policy configuration: indefinite, operator lifecycle. policy_status rows live as long as their policy and are overwritten only when that (policy, agent) pair is next evaluated; there is no age-based sweep. | DELETE /api/policies/{id} (Policy:Delete) cascades the policy’s status rows. Not wired into the ADR-0024 AgentDecommission cascade, so a decommissioned agent’s status rows persist (the #1666-class gap). | No retention knob. |
| Software-licensing detection inventory | software_licensing_store (agent_license_state, agent_licenses) | Detected software-licence facts per device — per detected product: product, vendor, version, license_type, channel, status, expiry_at, confidence, and exe_hints, collected by the new software_licensing daily-sync source (ADR-0024). Deliberately deviates from ADR-0016’s machine-scope / no-PII posture on exactly two fields (Decision 11, maintainer-signed-off): per-user licence surfaces (loaded per-user HKU hives + per-profile licence files, e.g. JetBrains / Adobe) emit user_scope=user + user_ref. user_ref is pseudonymous personal data — default mode hash is a per-agent keyed-HMAC pseudonym HMAC-SHA256(k_agent, profile) truncated to 16 hex (personal data under GDPR Recital 26; a per-device pseudonym, not anonymisation), never a SID / email / directory identity. Machine-scope licence rows are asset-class; the two per-user fields are the only personal data. Device-attributable (agent_id) and, on person-assigned devices, works-council co-determination-relevant on the capability to attribute software to an individual. | Current-state only — each sync full-replaces the device’s rows (raw-blob hash-skip suppresses re-send while nothing changes); no time-based reaper. agent_license_state.first_seen / last_seen are server receipt times and mark activity. | Flipping the identifier knob to --license-scan-user-ref=omit purges user_ref within one 24 h sync cycle for syncing agents (offline agents on reconnect) — the knob-flip full-replace is the usable erasure path today. The agent-decommission cascade (AgentDecommission fanning SoftwareLicensingStore::delete_agent across the eight stores in the cascade, built by ADR-0024) is LIVE, triggered by DELETE /api/v1/sle/agents/{id}: gated on the per-device scoped Decommission:Delete securable (ADR-0024 Decision 9, amended Wave 7 PR7.2) — one grant authorizing for the cascade’s whole blast radius, in place of the earlier per-store conjunction, audit-before-erase fail-closed (an attempt row that cannot persist means no erasure — 503 + Sec-Audit-Failed, because an unaudited erasure destroys its own evidence), and honest per-store outcomes (each delete_agent returns committed status, so a rolled-back store is reported Failed → HTTP 500, never a false decommissioned:true; the cascade is idempotent, so re-issue the DELETE). This is the wired whole-device erasure path for those eight stores (GDPR Art. 17); per-agent stores outside the cascade (see their rows) are not erased by it (#1666). No row-level erasure API — stated gap (the cascade erases a whole device, not one subject’s rows). | --inventory-disable / YUZU_AGENT_INVENTORY_DISABLE (source-level opt-out — collect nothing) and --license-scan-user-ref (collect / hash / omit, default hash; omit suppresses the identifier but not the per-user probe, collect sends raw profile names). |
| App-usage inventory | app_usage_store (usage_state, agent_last_used) | Per-device app-usage facts — per executable: exe_key, retained-window first_seen/last_seen, and a trailing-30-day run_count/total_seconds, derived on the agent from Echo’s usage_daily fold (ADR-0016 §5, Wave 7 PR7.2). Machine-scope — no user identity: no username, no per-user attribution, and no user_ref-shaped field of any kind (distinct from the software_licensing deviation above). first_seen/last_seen are agent-observed values bounded by Echo’s usage retention window (31 days default, operator-tunable) — never a full run-history, so absence from a report means “not seen inside the retained window,” not “never run.” Served under the Forensics securable, held by default only by Administrator and the read-only ForensicsAnalyst role (absent from the seeded Viewer read-list) and audited (app_usage.agent.view, fail-closed) on every read. | Until decommission/erasure via the cascade — no independent time-based reaper of its own; the store persists exactly what the agent reports each sync (a raw-byte hash-skip suppresses re-send while nothing changes). usage_state.first_seen/last_seen/updated_at are server receipt times; the per-row first_seen/last_seen are the agent-observed retained-window values (the payload, not a freshness stamp). | The agent-decommission cascade (DELETE /api/v1/sle/agents/{id}, the sixth of eight stores) erases both usage_state and agent_last_used in one transaction. Also swept at boot from the generic InventoryStore (defence-in-depth: is_typed_inventory_source excludes app_usage from ever landing there; the boot purge cleans up a newer-agent/older-server window). No row-level erasure API — stated gap, same as the other typed daily-sync stores. | --inventory-disable / YUZU_AGENT_INVENTORY_DISABLE (source-level opt-out — collect nothing). |
| Fleet certificate inventory | certificate_inventory_store (schema certificate_inventory_store: agent_state, store_state, cert_location, cert_fact, cert_san, cert_eku, cert_cdp) | Per-device X.509 certificate facts read from machine-scope trust stores (ADR-0070 D12, slice-2 PR 2a-2a): fingerprints, serial, subject and issuer DNs, validity, key and signature algorithms, SANs, EKUs, CRL distribution points and store/path locations. Machine-scope only — no per-user store is read and no row carries a user_ref. Subject DNs and SANs can carry host names and, for a machine certificate, occasionally a person’s name or e-mail address, so the read surface (PR 2a-2b) is a dedicated admin-floored securable. Never private-key material (the optional public DER and key-association metadata are stored; key bytes never). Personal data: subject DNs and SANs can carry a person’s name, e-mail or UPN; facts are agent-asserted, not server-verified (the server does not re-parse DER in this change), so the inventory is not completeness or integrity evidence on its own, and a store the agent could not fully read is stored as constrained/unreadable, never as empty. | Until decommission/erasure via the cascade (this PR); the retention prune for stores unread for 30 days is PR 2a-2d. Every *_at/*_seen is a server receipt time, from the database clock. | The agent-decommission cascade (DELETE /api/v1/sle/agents/{id}, the eighth of eight stores) erases every table’s rows for the agent in one transaction under a per-agent lock. Erasure is NOT durable yet: there is no decommission tombstone until a later change, so a still-running decommissioned agent repopulates its rows on its next report; stop the agent first and re-run the decommission. A device whose report is refused (over 3 MiB, unknown major version, bad header, over the store/certificate caps) is acknowledged but not stored, so its inventory is frozen at its last good report until fixed (alert YuzuCertInventoryReportsRejected; a report that always exceeds its time budget freezes a device the same way). Report times use the plain database clock and no ordering against a previous report is assumed, so a clock step cannot latch or freeze anything. No row-level erasure API — stated gap, same as the other typed daily-sync stores. | --inventory-disable / YUZU_AGENT_INVENTORY_DISABLE (source-level opt-out — collect nothing); the collector itself ships in a later PR. |
| Product registry (software catalog) | product_registry_store (products, product_aliases) | Server-derived software-product catalog — normalised product identity (norm_key, vendor, title, edition, platform) plus raw-name→product aliases that back licence and entitlement matching (ADR-0024). No device attribution and no PII — a normalisation / reference table derived from detected product names, carrying no agent_id and no per-user data. | Indefinite — catalog reference data; soft product keys are re-resolved every evaluator cycle (matcher improvements self-heal), so rows are lifecycle-managed by matching, not a reaper. | None by design (reference data; no agent_id dimension, so no per-device / DSAR purge applies — explicit DELETE is the only disposal, matching the PKI cert-inventory convention above). | n/a — populated only while the software_licensing source is active (gated upstream by --inventory-disable). |
| Authorization config (RBAC) | rbac_store (roles, role_permissions, principal_roles, groups, group_members, rbac_meta) | Authorization configuration — role definitions, role→permission grants, principal→role assignments, RBAC groups + membership, and the global rbac_enabled flag (in rbac_meta, alongside the cross-replica write_generation coherence token). Principals are operator/IdP-supplied usernames or group names (potentially name-shaped personal data on person-named accounts); no secrets, no credentials, no behavioural telemetry — this is the access-control policy, not identity or activity. The authz substrate the whole platform enforces through (require_permission / require_scoped_permission / authorize_list_read, ADR-0041, highest blast radius). | Indefinite — authoritative operator-authored config; lifecycle via explicit operator delete (role/grant/group/membership removal), never a time-based reaper. | Explicit operator delete only (REST/dashboard role & group management). No agent_id dimension, so no per-device / DSAR cascade applies. | RBAC is enabled/disabled via the rbac_enabled flag (Settings → RBAC); no retention knob. |
| Response store | response_store (responses, response_facets) | Agent command/instruction results powering the executions drawer + /echo dashboard (ADR-0039, migrated from SQLite responses.db). Device-attributable (agent_id). Sensitivity is plugin-dependent — most results are machine-scope operational output, but some carry usage-class behavioral PII: the per-application panel persists per-app image names, and the device-page Get live info cards persist process tree / active connections / logged-in users / DNS resolver cache (device.live.*). output/error_detail are agent-supplied bytes, UTF-8-sanitized to U+FFFD at ingest and finalize; no secret-class column. Rows of Forensics-tier plugins (app_usage, browser_inventory, execution_artifacts, privacy_permissions, remote_access_audit) are served at read only to callers holding Forensics:Read on top of Response:Read (#4990): the store’s reads take a required tier filter applied in SQL, a read that serves such rows writes the separately countable response.forensics.read audit verb, and the stored per-agent failure text of those plugins is reduced to a bounded token. The same tier covers the server’s own readers of these rows (policy checks, result-set membership, bundle collation, the live output stream); bundle collation, which writes the same response.forensics.read row (surface=bundle) when it serves such a step. | 90-day default (response_retention_days). Clock-guarded + capped reap (ADR-0039, parity with AuditStore’s #2360 guard): reap_expired() reads/persists gc_meta, takes pg_try_advisory_xact_lock('response_store:reap') for single-sweeper semantics across replicas, declines-once via audit_retention::classify on a would-wipe / clock-ahead pass (implausibly-ahead bound ~120 d, sized above the 90 d horizon), and deletes at most 10,000 rows per pass. ttl_expires_at stamped at INSERT, never rewritten. Series: yuzu_server_response_reap_passes_total{result} plus ..._ingest_dropped_total{reason} / ..._facets_dropped_total{reason} / ..._read_degrade_total{reason,source}, all zero-seeded at boot. | No wired DSAR / decommission purge — the platform #1666 gap. The future F3 DSAR/erasure path must treat the usage-class device.live.* categories, per-app-panel results, installed_apps list/list_per_user rows (install paths that can name an account’s home; per-account software), privacy_permissions.permissions rows (per-user grants qualified by the profile/home folder name, plus per-app last-used times on Windows), remote_access_audit.list rows (per-account key fingerprints and the home-directory account name), browser_inventory.profiles rows (the local OS account name and a browser-supplied display_name that can be a real name), execution_artifacts rows (executed-binary paths that can embed a profile name, with hashes and run times) and app_usage instruction results (per-executable first/last seen and run counts; the daily-sync copy lives in app_usage_store, which has its own cascade) in this store as an erasure source (see the behavioral-read channel notes below). TTL reap is the only automatic disposal today. | response_retention_days |
| Management-group confinement config | management_group_store (management_groups, group_members, group_role_assignments) | Confinement / access-segregation configuration — the operator scoping hierarchy that backs RBAC management-group confinement and the ADR-0017 authorize_list_read gate (ADR-0042). Holds the group tree (name, parent, membership type, scope expression), static membership records, and group→role assignments. No end-user PII and no secrets — it is access-control metadata. Device-attributable only via a membership row’s agent_id (which device sits in which scope), never carrying device inventory or behavioral data itself. | Indefinite / operator-lifecycle — confinement config persists until an operator changes it; there is no time-based reaper (a stale scope is a mis-configuration to fix, not data to age out). | Explicit group/member CRUD — DELETE /api/v1/management-groups/{id} cascade-deletes child groups, their membership records, and their role assignments; individual members are removed via the members sub-resource. No automatic reaper and (by design) no per-subject DSAR purge — this is access-control config, not subject data. | Managed entirely through the /api/v1/management-groups REST + MCP surface (ManagementGroup:Read / :Write); no deploy-time collection knob. |
| Network-discovered devices | discovery_store (discovered_devices) | Network-scan asset-discovery inventory (Issue 7.18, ADR-0044) — IP address, MAC address, hostname, the operator-set managed flag + associated agent_id once promoted to an enrolled agent, and first-seen (discovered_at/discovered_by) provenance. No secrets. MAC address, hostname, and IP address are device-persistent identifiers — the same class device_ci (above) already classifies as personal data under GDPR when device-attributable to a person, and works-council co-determination-relevant on the capability to discover/track a device over a network (BetrVG §87(1)(6) class), independent of whether the device is ever promoted to managed. Unlike device_ci, rows here are agent-less by default (a discovered-but-unmanaged device has no agent_id) — agent_id populates only once an operator confirms managed=true via mark_managed. | Indefinite — no time-based reaper. A discovered device’s row persists until an operator explicitly clears it; there is no current-state-replace-and-age-out behavior like the daily-sync stores above. | clear_results (all rows, or subnet-scoped) is the only bulk-delete path — operator/internal-API only, no REST route today (ADR-0044 Follow-ups). Not wired into the ADR-0024 AgentDecommission cascade — decommissioning a managed device does not remove its discovery_store row, and there is no row-level/per-subject DSAR (Art. 17) erasure path (same #1666-class gap as the stores above). | No deploy-time collection knob — discovery scanning is driven by the calling agent/instruction, not a store-level flag. |
| Device tags (scope-targeting config) | tag_store (tags) | Targeting / confinement configuration — per-agent key→value tags (operator/API/MCP-authored plus agent-self-reported, source-tracked with #1411 precedence) that back tag:<key> scope-DSL resolution, service-scoped API-token confinement, and dynamic service-group membership (ADR-0050, migrated from SQLite tags.db). Device-attributable (agent_id). No secrets and no structured PII; free-text values (max 448 bytes) may incidentally carry a name-shaped label an operator typed (same free-text posture as custom properties / group descriptions). Reads are typed degrade-distinguishable — a store failure fails scope evaluation closed, never resolving to fewer/more devices. | Indefinite / operator-lifecycle — tag config persists until changed; agent-sourced rows are full-replaced on each agent Register sync; no time-based reaper. | Explicit tag CRUD (DELETE /api/v1/tags/{agent}/{key}, MCP delete_tag); delete_all_tags(agent_id) exists at the store seam but is NOT wired into the ADR-0024 AgentDecommission cascade — a decommissioned device’s tag rows persist (tracked follow-up; same #1666-class gap as the sibling stores outside the cascade). | No deploy-time collection knob — operator-authored config plus the agent’s own scopable_tags report (bounded at 256 tags per sync). |
| Notification feed | notification_store (notifications) | Operational display state — the dashboard toast/badge feed (level, title, message, read/dismissed), migrated from SQLite notifications.db (ADR-0046). Free-text title/message may incidentally carry a hostname or username as display content (e.g. “Agent enrolled: workstation-jsmith”; a failure notification for a Forensics-tier plugin carries a bounded token, never the plugin’s raw failure text) but the store is not authoritative for any decision — display-only, never fed into a grant/target/enforce/skip check. No secrets, no structured device/behavioral telemetry. | Indefinite — no time-based reaper. Unbounded growth is a known, documented gap (not this store’s SOC 2 posture, a forward-looking operational note), and there is no delete endpoint, so a notification is kept for the life of the database (a Forensics-tier failure notification carries only the bounded token); any future retention pass must follow the CLAUDE.md clock-guarded-retention contract. | No wired per-device / DSAR purge (mark_read/dismiss are the only mutations besides create; no delete endpoint exists). Same #1666-class gap as the other per-agent stores lacking a decommission hook. | No deploy-time collection knob — populated by internal server events (agent enrollment, execution failures, DEX alerts), not agent-supplied data. |
| Webhook delivery configuration | webhook_store (webhooks, webhook_deliveries) | Operator-configured outbound integration config + delivery history — webhook URL, event-type filter, enabled flag, plus a per-webhook HMAC signing secret. The secret is SecretCodec-envelope-encrypted at rest (ADR-0010) — this store never persists plaintext; the second production SecretCodec consumer after AuthDB’s mfa_totp_secret, and the template PR for RuntimeConfigStore (now also migrated, ADR-0060, its own row below) and OffloadTargetStore (now also migrated, ADR-0059, its own row below). Delivery records carry the event payload sent to the URL — no PII beyond what an operator’s own payload happens to include; no device/behavioral telemetry. | Webhook config: indefinite, explicit operator delete (cascades its own deliveries). Deliveries: no time-based reaper today — tracked follow-up (#3561); the error of a delivery payload for a Forensics-tier plugin is a bounded token, never the plugin’s raw failure text, and deliveries are removed only when their webhook is deleted. | Migrated from the SQLite webhooks.db (ADR-0057): construction fails CLOSED with no SQLite fallback. No legacy-data migration path — no production fleet ever ran a pre-Postgres build, so the mandatory fingerprint-gated backfill this store originally shipped with was retired (#3623, ADR-0057’s Update); a legacy file is never imported, only hardened to 0600 POSIX-only (main file + -wal/-shm/-journal sidecars) and counted for a diagnostic warning at boot. A legacy file left on disk still holds the pre-cutover PLAINTEXT signing secret(s) and is never automatically deleted or rotated (tracked follow-up: #3562). DELETE /api/webhooks/{id} (gated Infrastructure:Write) cascades its own deliveries via FK CASCADE. Not wired into the ADR-0024 AgentDecommission cascade — webhooks are operator-scoped integration config, not device-scoped, so there is no agent_id dimension to decommission against. | No deploy-time collection knob — operator-authored REST config (Infrastructure:Read/:Write). |
| Runtime configuration overrides | runtime_config_store (runtime_config, runtime_config_secrets) | Operator-authored config behind GET/PUT /api/config/:key, their /api/v1/config twins and the MCP tools get_runtime_config/set_runtime_config (a supervised-tier token may write only the five keys that are not administrator-only; every read is scoped to the keys the caller could write) — retention windows, log_level, DEX alert-routing knobs and the oidc_* OIDC parameters (ADR-0060, migrated from SQLite runtime-config.db). The customer DEX score policy document (dex_score_policy) is stored in the same table but is read-only on those routes (a PUT is refused) and is written only through PUT/DELETE /api/v1/dex/score-policy. One key, oidc_client_secret, is a credential: it is SecretCodec-envelope-encrypted at rest in a dedicated table (runtime_config_secrets), never plaintext — the third production SecretCodec consumer after AuthDB’s mfa_totp_secret and webhook_store’s signing secret. No device attribution, no behavioral telemetry — this is server-wide config, not per-agent data. | Indefinite / operator-lifecycle — persists until an operator changes it (via Settings; the dex_score_policy document has no Settings panel and changes only through its API route); no time-based reaper (this is live configuration, not aging telemetry). | Explicit operator overwrite/clear only — the dex_score_policy document is cleared by DELETE /api/v1/dex/score-policy (the shipped defaults apply again); an empty PUT /api/config/:key value behaves differently by key: oidc_issuer, oidc_client_id, and the secret-backed oidc_client_secret genuinely revert to their CLI/env default (the boot-time apply step only overwrites the default when the stored value is non-empty); exactly these keys reject an empty value outright with a 400: heartbeat_timeout, the retention-day keys, log_level, auto_approve_enabled, plugin_signing_required; other string keys (e.g. oidc_redirect_uri, oidc_admin_group) accept an empty value but store it literally rather than reverting to a default. Fresh-start-by-default on this migration (ADR-0009 amendment, not backfilled): a pre-cutover runtime-config.db is never copied; the legacy file’s content is left in place (may still hold a plaintext oidc_client_secret) — its permission bits are forced to 0600 (main file + -wal/-shm sidecars) on every boot as a defence-in-depth step, same idiom as webhook_store’s legacy-file hardening above — and a boot-time check warns with a row count if it holds real overrides. No agent_id dimension, so no per-device / DSAR cascade applies. | Managed through GET/PUT /api/config/:key (Infrastructure:Read/:Write); no deploy-time collection knob. The one document-shaped key, dex_score_policy (the customer DEX score policy), is written only through PUT/DELETE /api/v1/dex/score-policy (Infrastructure:Write, If-Match required, audit-first: dex.score_policy.update/.reset/.validate); a direct database write to that row bypasses application audit and is covered by the database-administration controls, not by the audit log. A direct write of a text no success row recorded shows as a break in the audit chain (the next write’s prior_revision matches no earlier success row’s revision); a delete, or a restore of a text an earlier row recorded, does not. The rows that evidence a change are kept for the audit retention period (365 days by default) while the document itself persists until it is changed. Writing the oidc_* keys, six DEX/plugin-signing keys (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 (by design, since it is the stored audit-retention setting; no Settings page writes it; a PUT stores the value only, the enforced window comes from --audit-retention-days or YUZU_AUDIT_RETENTION_DAYS plus a restart, and changing that flag is a deploy-configuration change) is administrator-class on that route: the caller must be an administrator account (the Settings pages where one exists, or an administrator API token that is not MCP-tier), and an MCP-tier or service-scoped token is refused (oidc_* additionally refuses a temporary elevation; the account’s own legacy role must be admin, an RBAC Administrator grant alone does not count); the other keys stay under Infrastructure:Write. A refusal is audited config.update / denied. |
| Offload-target delivery configuration | offload_target_store (offload_targets, offload_deliveries) | Operator-configured outbound integration config + delivery history — target URL, event-type filter, batch size, enabled flag, plus an optional auth credential (Bearer token / Basic user:pass / HMAC shared secret) (ADR-0059, migrated from SQLite offload_targets.db). The credential is SecretCodec-envelope-encrypted at rest (ADR-0010) — this store never persists plaintext; the fourth production SecretCodec consumer after AuthDB’s mfa_totp_secret, webhook_store’s signing secret, and runtime_config_store’s oidc_client_secret — the last Wave-3 secret-gated store, closing out the migration ladder’s secret-gated set. Delivery records carry the event payload sent to the URL — no PII beyond what an operator’s own payload happens to include; no device/behavioral telemetry. | Target config: indefinite, explicit operator delete (cascades its own deliveries via FK). Deliveries: no time-based reaper today — same characteristic as webhook_deliveries above, not yet tracked as a separate follow-up for this store; the error of a delivery for a Forensics-tier plugin is a bounded token, never the plugin’s raw failure text, and deliveries are removed only when their target is deleted. | Fresh-start-by-default on this migration (ADR-0009 amendment, not backfilled) — unlike webhook_store’s mandatory fingerprint-gated backfill above, no production fleet has ever run a pre-Postgres build of this store, so the pre-cutover offload_targets.db is never read or copied: every target and its full delivery history is lost on upgrade and must be re-registered via POST /api/v1/offload-targets (a documented Breaking change, docs/user-manual/upgrading.md). DELETE /api/v1/offload-targets/{id} (gated Infrastructure:Write) cascades its own deliveries via FK CASCADE. Not wired into the ADR-0024 AgentDecommission cascade — offload targets are operator-scoped integration config, not device-scoped, so there is no agent_id dimension to decommission against. | No deploy-time collection knob — operator-authored REST config (Infrastructure:Read/:Write). |
/crypto dashboard layouts | crypto_dashboard_layout_store (layouts) | Per-operator UI preference (ADR-0071): which /crypto dashboard widgets an operator placed, in order (widget_ids), a save time and a version. Personal data: the row is keyed by the operator’s user name (or a linked oidc:/saml: principal); nothing else about the person is stored, and logs never carry the name. No device data, no secret. | No retention pass: one row per operator, kept until the operator is deprovisioned | Erased on deprovision (SCIM deactivate/delete, local user delete) by a best-effort post-deprovision hook outside the ADR-2001 revoke chokepoint; a failed erasure is logged at warn (reason only), counted on yuzu_server_crypto_layout_erase_failures_total and alerted (YuzuCryptoLayoutEraseFailed); the remedy is the periodic stored-username check in docs/user-manual/crypto.md “Deprovision erasure” (also covers an in-flight save re-inserting a row, an accepted residual). Covered by the whole-database pg_dump; loss-tolerant (operators fall back to the default layout). A restore can resurrect erased rows (a backup taken before a deprovision still holds them): run the same check after any restore and delete the rows of principals deprovisioned since the backup. The store degrades rather than refusing boot (ADR-0071 decision 2) | The operator’s own Reset to default / PUT /api/v1/crypto/dashboard/layout (replaces, does not delete) |
| Analytics event outbox | analytics_event_store (analytics_buffer) | Outbox/spool, not operator state — analytics telemetry for login/MFA/OIDC/SAML/role-elevation events and agent/gateway command-lifecycle events (ADR-0049, migrated from per-process SQLite analytics.db). Free-text attributes/payload carry usernames, source IPs, reason strings, method/status labels, durations, byte/exit-code counts — no plaintext password, TOTP code, or API-token fragment. session_id is AuthManager::sha256_hex(cookie), never the raw bearer token (governance Gate 2 finding, 2026-08-16 — a prior draft of this migration stored it raw, which under this store’s own indefinite retention and its Infrastructure:Read-gated read route would have been a durable, widely-readable session-hijack vector; see changelog.d/20260816-analytics-session-id-hash.security.md). Some events carry agent_id; the write path is fail-soft (a dropped event never fails the request that emitted it). | Indefinite — deliberately no reaper. Drained rows (already delivered to every configured sink) are kept forever (the error_message of a Forensics-tier plugin’s event is the bounded token, never the raw failure text), unlike every retention-guarded store elsewhere in this table — a DELIBERATE decision (ADR-0049 §Retention), not an oversight: this store’s own growth caveat is recorded as a follow-up (adjacent to #2508), not solved by this migration. Undrained-at-cutover events were a one-time, bounded loss at the SQLite→Postgres cutover only. | None wired. No per-device / DSAR purge, no decommission-cascade hook, no time-based prune — same #1666-class gap as the other per-agent stores above, compounded by the no-reaper retention decision. A future retention pass, if added, must follow the CLAUDE.md clock-guarded-retention contract like every sibling store in this table. | --no-analytics (disables collection entirely — no store, no buffer); --analytics-drain-interval / --analytics-batch-size (drain pacing only, not retention); clickhouse_* / analytics_jsonl_path (sink configuration, downstream of this buffer). |
| Registry write evidence | rows in audit_store (registry.write) and response_store (set_value, delete_value, delete_key, set_user_value, delete_user_value and apply responses) | Written by the registry plugin from 2.0.0 on (set_user_value, delete_user_value and apply, one row per plan item attempted, from 2.1.0; items skipped after a stop have none, and the reply’s summary row gives the totals), on agents that stamp the action (the first release containing the audit binding, later than 0.14.1; older agents mint no rows). The server records what any enrolled agent asserts, up to a budget of 600 rows per agent id per fixed 60-second window on each server process (agents beyond the first 262,144 in a window share 65,536 allowances; a restart resets it). Content: the key path and value name, and for every value write or delete (set_value, delete_value, the per-user pair and apply value items) the old AND new value as the plugin renders them as raw hex for every value type, 256 bytes at most then ~<total>, so a row can contain secrets (an autologon password, a product key, an agent token). Owner risk acceptance: old and new values are recorded as raw hex and can contain secrets; the product owner accepted this risk on 2026-10-20 and masking is a planned later change. A row is evidence of a change (except a dry_run=1 row, which records that a vet ran), not a restore point. A delete_key row carries counts and no values (`old=- | new=-). Key paths and value names can name personal data (user SIDs, profile paths). The plugin that gates the rows is the one the server recorded at dispatch (a registryresponse with no dispatch record mints no row), but the endpoint supplies the content of the lines, and an endpoint can replay a registry id the server issued to it. Readable by every role holdingAuditLog:ReadorResponse:Read(Viewer, Operator, PlatformEngineer, ITServiceOwner and Administrator), by MCPquery_audit_log, and, through the legacy /eventsstream, by any authenticated session that is not service-scoped, regardless of RBAC; with RBAC off, any authenticated session can also read the storedaudit|text throughResponse:Read(the authorization-topology floor coversAuditLog:Read, not Response:Read). The write parameters (valueanddata_hexofset_valueandset_user_value, and the whole apply plan) are also stored unredacted in executions.parameter_values(the executions table is outside the retention settings: no server pass prunes it), the responses carry the old values, and anapplyreply's undo manifest holds the previous values (up to 4,096 bytes per value, 512 KiB per reply) forResponse:Readholders, so the registry secrets this plugin can handle are readable there by their own readers. Nothing is persisted on the endpoint beyond the agent's own log, which names the key path of an access to the HKLM Run and Services keys (not values). Rows are lost, uncounted, when a stream drops after a mutation and, for a gateway-connected agent, when a command finishes more than 300 seconds after dispatch, for frames that arrive once shutdown has closed the audit hook (just before the server releases its audit store), and for a registry command with no dispatch record (an id the server never issued, a failed record write, a record reaped after 24 hours or a lookup that failed twice: the response is stored~unverifiedand mints no row, and no reason of the registry drop counter moves;yuzu_server_response_provenance_total{outcome}andyuzu_exec_correlation_write_degrade_totaldo). Binding, bounds and budget:docs/user-manual/audit-log.md (registry.write`). | 365 days by default (audit trail, --audit-retention-days); 90 days by default (response store, --response-retention-days); no retention pass for the executions table | Time-based expiry only; there is no per-subject erasure or decommission purge for these rows (see the audit-trail rows above) |
| Threat-graph recommendations (proposed, capability §28.9) | (proposed; schema not yet assigned — born-on-PG per ADR-0006, formerly sketched as recommendations.db) | Agentic-AI-produced hardening suggestions awaiting operator action; carries customer_id, generator id, target node/edge keys, rationale, status (open / accepted / dismissed / applied) | Indefinite while open; 90 days default after dismissed/applied | RecommendationStore::run_cleanup thread (planned) — DELETE … WHERE status IN ('dismissed','applied') AND closed_at < now - retention | recommendation_retention_days |
| VirusTotal hash cache (proposed, capability §28.8) | (proposed; schema not yet assigned — born-on-PG per ADR-0006, formerly sketched as virustotal_cache.db) | Rate-limited SHA-256 → verdict lookup cache; verdict, scanned_at, engine_hits JSON | 7 days default | TTL at insert; opportunistic eviction on lookup | virustotal_cache_ttl_days |
Every PostgreSQL store in this table fails closed on an unreachable database (ADR-0007 — the server refuses to boot), and is gated into /readyz and /healthz. Beyond boot, software_inventory_store reads are authoritative (ADR-0016 §7): a transient query-time degradation after a healthy boot (pool-acquire timeout, query error) surfaces as a kInternalError from query_installed_software (and a 503 A4 envelope from the REST sibling GET /api/v1/inventory/software), never a successful empty result — so a fleet vulnerability query can never read a transient backend hiccup as “installed nowhere”. The MCP query_installed_software and REST GET /api/v1/inventory/software channels carry a per-agent management-group drop filter, and as of #3290 this confinement is verified effective and CAN be cited as an affirmative CAIQ / CC6.1 answer for these two channels: both migrated onto AuthRoutes::require_fleet_read (ADR-0017’s admit-then-filter primitive) as their sole authorization gate — a management-group-confined operator now gets a genuinely filtered result (never an unfiltered one, never a 403 before the filter runs), and a service-scoped API token’s result is additionally intersected with its own service-tag scope. Real end-to-end coverage against a live RbacStore/ManagementGroupStore exists in tests/unit/server/test_rest_inventory_software.cpp’s [e2e] cases. The third channel reading the same store — the /software page’s devices expansion (/fragments/inventory/software/devices, inventory_routes.cpp) — remains unmigrated: it still gates on the global Inventory:Read permission, under which the per-agent filter does not narrow results (a management-group-confined operator is denied at the gate before the filter runs; a global operator’s filter is a no-op) — the same inert-list-scoping class documented in ADR-0017, tracked as that ADR’s still-open devices-expansion conversion item. Until that channel migrates, answer the CAIQ confidentiality question for the /software page reads (the catalogue, the version drill and the devices expansion) specifically as “designed, not yet verified”; the Hardware list and CI record on /hardware are the affirmative require_fleet_read case above — the REST/MCP answer above is now affirmative. Per-device decommission erasure is now wired for the eight stores in the ADR-0024 AgentDecommission cascade (inventory, software_inventory, device_inventory, app_perf_daily, software_licensing, app_usage, offline_endpoint, certificate_inventory) via the audited DELETE /api/v1/sle/agents/{id}. What #1666 still tracks is the residue: row-level / per-subject DSAR erasure (the cascade is whole-device only) and the per-agent stores outside the cascade, including response_store, preflight_run_store, deployment_run_store, discovery_store, tag_store, vuln_finding_store (its delete_agent exists but is not wired) and guaranteed_state_store; where a store’s row states its erasure status that row is authoritative, and a store absent from the eight listed here is not erased by the cascade. The offline_endpoint_store age-out prune is #1545. For a device that ages out without being decommissioned, last_seen remains the canonical “is this device still active” filter for asset-management counts. guaranteed_state_store’s GET /api/v1/guaranteed-state/status fleet-wide aggregate is CONFINED and this can be cited affirmatively for CC6.1 on this specific surface — unlike the software_inventory_store gap above, this route’s SOLE authorization gate is AuthRoutes::require_list_read (ADR-0017 admit-then-filter): a global GuaranteedState:Read grant sees the fleet-wide count, a management-group-confined grant sees errored_rules scoped to that operator’s visible agents only (applied in SQL before the aggregate, INV-3), and a caller with no grant anywhere is refused with 403. An earlier version of this wiring (commit 9269b5636) stacked a direct RbacStore::authorize_list_read call BEHIND the pre-existing flat require_permission gate rather than replacing it — that composition was a no-op for every management-group-confined caller (require_permission’s RBAC check never consults ManagementGroupStore, so a confined caller was denied before authorize_list_read was ever reached) and additionally broke JIT-elevated sessions; it was corrected before this affirmative claim became true. require_list_read is RbacStore::authorize_list_read’s first production call site (previously chartered but unbuilt, #3038 — reopened after the premature 9269b5636 closure above, and reclosed once this fix is verified/merged, not before) — the software_inventory_store list-read gap documented above is a SEPARATE, still-open surface and this fact does not resolve it. total_rules on this route is deliberately never confined: it is the size of the global rule-definition catalogue, which carries no agent or management-group dimension to confine. As of #1712, the same require_fleet_read primitive covers three more surfaces, and this confinement CAN be cited affirmatively for CC6.1 on each: the dashboard /fragments/results results table, the workflow executions-drawer detail route (both its per-agent responses section and its status grid/table), and MCP get_agent_details — all three migrated onto require_fleet_read as their sole authorization gate, replacing a flat permission check that previously either showed a confined operator the whole fleet’s data or denied them outright. get_agent_details on an out-of-scope agent returns the identical “not found” response a genuinely nonexistent agent would, so the tool cannot be used to enumerate the fleet outside a caller’s scope. Two adjacent surfaces on the same dashboard page remain unmigrated and must NOT be covered by this claim: the results table’s sibling sidecar routes — /fragments/results/filter-bar, /fragments/create-group-form, and POST /api/dashboard/group-from-results — still gate on the flat, unscoped permission (tracked #3489; #3525 tracked the
same finding and was closed as its duplicate), and GET /api/v1/execution-statistics/agents (with its MCP twin get_execution_statistics_by_agent) carried the same unscoped shape (tracked #3526) and is now confined on the same chokepoint (global grants unchanged; the by-definition and fleet-summary aggregates carry no agent identity and keep the plain gate). That confinement is effective only with RBAC enforcement on: with RBAC off (the shipped default) the per-agent rollup is unfiltered for every authenticated non-service, non-engine caller, so answer CAIQ for it as “designed, verified for RBAC-on, unconfined in the default posture”. A management-group-scoped-only operator, refused before, is now admitted as a confined view (admission widened, rows narrowed). The workflow executions LIST fragment (formerly listed with it) and MCP summarize_working_set kind=execution are now on the fleet-read chokepoint like the DETAIL route. An author-run reachability probe (run by a subagent of the authoring session, not independently adjudicated; the JIT-elevated case was checked by code reading only) found they had not been a reachable leak through the old gate, only a capability gap for group-scoped operators, while summarize_working_set kind=fleet/result_set still return an unscoped whole-registry agent count (tracked #4753). Confinement on these two surfaces is effective only with RBAC enforcement on. With RBAC off (the shipped default) both are unconfined for every authenticated non-service, non-engine caller whose tier allows it (an engine principal is refused with 403 with RBAC off), identical to GET /api/v1/executions. Answer CAIQ “designed, verified for RBAC-on, unconfined in the default posture”. Service-scoped-token confinement of execution data is not cited affirmatively until #5557 closes: a service-scoped token’s session username is the account that minted it, so the owner disjunct still shows the minter’s executions outside the service scope on the other execution read surfaces. Answer the CAIQ confidentiality question affirmatively for the three #1712 surfaces specifically; for the dashboard results page and workflow drawer as a whole operator workflow, the sidecar gaps mean “designed for, partially verified effective” until #3489 closes. Update (#1634, closed) — the SSE gap #3699 tracked is now fixed and CAN be cited affirmatively for ordinary callers; service-scoped-token confinement of /sse/executions/{id} and /api/v1/events is not cited affirmatively until #5557 closes (both still carry the minter-keyed owner disjunct). The live SSE channels for the identical execution/agent data the #1712 surfaces above confine on the request/response path — /sse/executions/{id} (dashboard) and /api/v1/events (REST), both backed by ExecutionEventBus — are now on require_fleet_read/fleet_read_fn, the same primitive as every other surface in this section. Both routes 404-collapse an invisible execution to the same shape a genuinely nonexistent one gets (no existence oracle), and share one event projector (execution_event_scope.hpp): agent-transition events are filtered by agent_id before delivery, execution-wide execution-progress counts are dropped for a confined subscriber rather than disclosed, and the terminal execution-completed event is sanitized (real status preserved, counts/identifiers stripped) rather than dropped outright, so the client still closes its stream cleanly. Same round also migrated GET /api/v1/executions/{id} (REST final-state lookup) and MCP get_execution_status/query_responses/aggregate_responses/the legacy /api/responses/* family onto the same gate. These legacy routes are confined and write a durable success audit row on every served read, failing closed on audit-persist failure like their v1 twins (#4644; the earlier gap is recorded in #5556). Residual, not yet closed: MCP list_executions has a weaker mechanism than every other surface in this section — execution rows carry no single agent_id to filter by, so a confined caller is restricted to their own dispatches (ExecutionQuery::dispatched_by) rather than a genuine per-row visible-agent intersection; do not cite this specific tool as fully equivalent to the others until a batched (non-N+1) per-row check replaces that interim confinement. Full account: docs/auth-architecture.md’s “Third migration (#1634)” section. GET/PUT/DELETE /api/agents/:id/properties[/:key] (server.cpp) previously read and wrote per-agent custom-properties data behind bare require_permission("Infrastructure","Read"/"Write") with no per-agent scope filter at all, including the WRITE path — now fixed (#3700): all three routes gate on require_scoped_permission("Infrastructure","Read"/"Write",agent_id), the same primitive the Tag and per-device routes already used, verified by gate-level coverage against a live PostgreSQL-backed RbacStore/ManagementGroupStorePg plus a source-text tripwire pinning the route handlers to the scoped gate (tests/unit/server/test_agent_properties_scope_authz.cpp) and this repo’s /governance pipeline. This can be cited affirmatively for CC6.1 on the custom-properties surface specifically — a distinct data class from the execution/agent-status data #3699 still gates. Caveat carried forward from #3700’s own PR review, not yet closed: POST /api/scope/estimate (workflow_routes.cpp) evaluates caller-supplied scope expressions — including property:key=value atoms — against the whole fleet with no management-group confinement, returning a matched count; any authenticated non-service principal can guess-and-confirm an out-of-scope agent’s property value via the 0→1 count flip, a side channel that survives the request/response confinement this PR just closed. Tracked #3753 — do not treat the CC6.1 affirmative claim above as covering this read-side channel until it closes. Precision note, not a scope narrowing: the fix restores confinement for a management-group-scoped-only operator (previously denied outright on every agent — “inert confinement,” not over-broad access); a caller holding a global Infrastructure:Read/Write grant retains unconditional fleet-wide access on this surface before and after, identical to every other require_scoped_permission call site in this codebase — that is by design, not a residual gap of this fix. The #3564 audit-detail unification (closing the get_agent_details existence-oracle) is a deliberate compliance tradeoff, not merely a security one: the audit trail can no longer distinguish “agent doesn’t exist” from “agent exists, out of caller’s scope” in any durable record (query_audit_log-readable detail field) — the distinguishing fact survives only in a server-side spdlog::debug line, not typically enabled in production. If a SOC-2 auditor asks for forensic evidence separating these two cases from the audit log alone, the honest answer is “not recoverable from durable audit records by design” — the collapse is what prevents the audit trail itself from re-disclosing the existence-oracle this fix closes on the response channel.
Authorization substrate (rbac_store, ADR-0041) — a CAIQ / CC6.x posture note. In addition to the boot fail-closed above, rbac_store authorization reads fail closed (deny-on-degrade): a store-not-open, pool-acquire timeout, or query error denies the request (403/503, never a silent allow) rather than falling through to an engaged allow. This is a positive change over the prior SQLite rbac.db posture, where a corrupt/unreadable RBAC store could fail open at some call sites — an affirmative improvement to cite for the access-control confidentiality questions (CC6.1/CC6.3). The rbac_enabled flag is durable and read-back-verified; an unreadable flag refuses boot, so a fleet that enabled RBAC can never silently boot RBAC-off. One honest caveat for CAIQ: across multiple server replicas, a role/permission/enabled-flag change is propagated within a bounded ~1 s window (a durable rbac_meta.write_generation coherence token, refreshed ≤ once/second), so cross-replica revocation is near-real-time but not strictly instantaneous — an accepted, gate-recorded residual risk (well inside the fleet’s minutes-scale session/token revocation envelope; LISTEN/NOTIFY is the named follow-up to drive the window to zero).
Availability posture under PostgreSQL degradation (#2703 Gate 7 merge-slice, availability-hardening pass) — assess alongside Workstream D, do not read as availability resilience. The deny-on-degrade behaviour above is not instantaneous on every blip, and two independent mechanisms govern it — they must not be conflated. (1) Cache validity is governed solely by a bounded ~5 s window past the last confirmed-good cross-replica refresh: an already-cached authorization decision continues being served from that cache for up to ~5 s of backend degradation regardless of what else is failing, a deliberate bounded-staleness-for-continuity tradeoff layered underneath the ~1 s propagation window above; once that 5 s bound is exceeded, the cache is invalidated wholesale and every subsequent check requires a fresh answer. This holds even when no refresh attempt ever completes — a query genuinely stuck in flight (e.g. blocked on the ACCESS EXCLUSIVE-class lock described below, which can hold for the full ~10 s lock_timeout) previously left the cache trusted and unbounded-stale for the whole stall, since nothing else ever ran to invalidate it; the staleness check now measures elapsed time directly rather than depending on a refresh attempt’s own completion (closed 2026-08-11, same-day, pre-merge, never shipped — G11-CPPEXPERT-B2). (2) A fail-fast breaker independently governs pool access, not cache validity: it does not clear the cache and does not shorten the 5 s window, but once it observes 2 consecutive pool-acquire/query failures, any check that is not already a cache hit (a first-time lookup, or any lookup once the 5 s bound has passed) denies immediately without attempting a pool acquire, rather than blocking for the 250 ms per-attempt acquire budget first — and it paces recovery to one probe per ~1 s while open. How fast the breaker actually trips depends on WHY the backend is degrading — two measured modes, not one: when connections are unavailable (pool exhaustion — no connection to acquire), each attempt fails within the 250 ms acquire budget, so 2 consecutive failures trip the breaker in well under a second (suite-verified: [rbac]’s breaker test asserts elapsed < 150 ms against injected failures). But when a query acquires a connection and then blocks on a PostgreSQL-side lock rather than failing to acquire one, the 250 ms acquire budget does not apply — that block is bounded instead by PgPool’s lock_timeout (10 s default), and 2 such attempts before the breaker trips measured ~18.5 s live against a held ACCESS EXCLUSIVE lock on rbac_meta (tracked: #3016). Correction (verified empirically 2026-08-11, live against postgres:18): an earlier draft of this note claimed ordinary role/grant writes could trigger this lock-contention mode. That is wrong and has been corrected — PostgreSQL’s MVCC means a plain UPDATE/INSERT takes only a row-level lock, which does not block a concurrent plain SELECT (confirmed: a SELECT against a row held open by an uncommitted UPDATE returned in 19ms, reading the pre-UPDATE snapshot, exactly as MVCC predicts). The generation-refresh query is a plain SELECT with no locking clause, so ordinary role/grant/group-membership writes cannot trigger this mode. What DOES trigger it is specifically an ACCESS EXCLUSIVE-class lock (confirmed: an explicit LOCK TABLE ... IN ACCESS EXCLUSIVE MODE blocked a concurrent SELECT for the held duration) — i.e. genuinely DDL-shaped events: a schema migration, or an operator issuing an explicit table lock / equivalent maintenance command. The original #3016 characterization (“a schema migration or a manual maintenance/vacuum-full-class event, not routine load”) stands as written; it is not a routine-traffic-reachable condition. A third, unmeasured (analysis, not empirical) mode — call it the dark-network mode — is worth naming for completeness: a backend that is reachable but silent — a dropped connection on a network path with no RST, e.g. behind a stateful firewall or NAT that drops the session without signaling it — is bounded not by lock_timeout but by the TCP-level tcp_user_timeout (10 s default, this same hardening pass) layered on top of keepalives_idle (30 s default, the keepalive clock only starting once the connection goes idle after being ACKed); working through that arithmetic gives roughly ~40 s for 2 such attempts, notably worse than the measured lock-contention figure — this number has not been empirically reproduced the way the ~18.5 s figure was, and should be treated as an upper-bound estimate, not a measurement. Both measured modes converge on the same outcome — a fail-closed deny, never an allow — only the time to get there differs by roughly 40x depending on the degradation’s mechanism. Net effect under a sustained outage: previously-seen decisions keep answering from cache for up to ~5 s regardless of breaker state; a new/uncached decision during that window denies once the breaker has tripped, at a speed set by which of the modes above is occurring; and once the 5 s bound elapses, the store denies every check, fleet-wide, until the backend recovers. Flapping — repeated short degrade/recover cycles, not one clean outage — is a materially different and arguably worse case for CAIQ purposes than a sustained outage: the breaker’s failure streak resets to 0 on a single success, so each brief recovery reopens it to full concurrency rather than easing back in gradually; a backend cycling between degraded and briefly-healthy (the shape a managed-Postgres failover or an intermittent network partition actually produces) can therefore drive repeated full-concurrency retry bursts against a still-struggling backend rather than the single clean trip-and-stay-open a sustained outage produces (redesign tracked as a follow-up to #3016 — a half-open concurrency cap, not shipped in this pass). No operator action is required for recovery in either mode — the breaker self-heals: the very next probe that succeeds (at most one cooldown, ~1 s, after the backend genuinely recovers) closes it again automatically. Blast radius while the breaker is open is bounded by the shared connection pool, not by which table degraded: RbacStore maintains one breaker per replica process, shared across every authorization call site (direct permission checks, scoped checks, the ADR-0017 admit-then-filter list-read helpers, and the ITServiceOwner ceiling read that service-scoped-token requests make on the fleet-read gate and on the two sibling gates, which is one uncached PostgreSQL query per service-token request that reaches the ceiling check) — a degrade touching one table denies checks against every securable type on that replica while open, not only the one that degraded. The CAIQ-relevant consequence: this is a confidentiality-over-continuity design, not an availability guarantee. Sustained PostgreSQL saturation or loss still ends in every gated operation becoming unavailable — a control-plane availability impact, not evidence of resilience to it — it just does not happen on the first millisecond of degradation. This posture must be assessed together with Workstream D (reliability) controls and PostgreSQL’s own high-availability posture, never cited on its own as an availability answer. The same posture applies to gateway session resolution (HA WS-5 slice 2, ADR-2002 §7f): BatchHeartbeat resolution is fail-soft on a degraded directory read (a locally hinted pair is still acked and its lease renewed; an unreadable id with no local hint is neither acked nor listed), while NotifyStreamStatus CONNECTED on a degraded read is accepted only for a session this replica holds locally and is otherwise refused (connected_unverified); evidence is metrics and logs only, with no per-event audit row, except the announce_connected/session_held_by_other_agent refusal (#5683), which writes a gateway.session_held_by_other_agent audit row.
Investigating a denied authorization check during a PostgreSQL degradation window. The audit trail records the denial itself (an authorization failure event) but does not, at that same record, distinguish “the breaker was open and this was denied without a pool touch” from “the store was healthy and this was a genuine no-grant denial” — both are recorded as an ordinary denied check. To tell them apart during an incident review, cross-reference the denial’s timestamp against yuzu_server_rbac_breaker_open (was the breaker open on this replica at that time?) and yuzu_server_rbac_read_degrade_total{reason="pool_acquire_timeout"} (did a degrade fire in that window?) in the metrics history, rather than the audit event alone. This is an investigative recipe, not an automated correlation — the audit record and the metrics are two separate systems and a future improvement could join them at write time; until then, a reviewer doing incident forensics needs both.
Confinement substrate posture (management_group_store, ADR-0042) — affirmatively citable for CC6.1. Unlike the software_inventory_store list-read caveat above (whose per-agent filter is inert under the current global gate), the management-group hierarchy reads that feed confinement are now degrade-distinguishable and fail closed to DenyAll: a store-not-open / pool-acquire-timeout / query-error on an ancestor/descendant/agent-groups read resolves to deny, not to a silently-empty deny-set. This closes the deny-set fail-open hazard (a degraded confinement read can no longer silently under-restrict an operator’s scope) and is surfaced operationally via yuzu_server_mgmt_group_read_degrade_total{reason} + the YuzuMgmtGroupReadDegraded alert. This behaviour of the confinement substrate itself can be cited affirmatively in a CC6.1 (logical access — least privilege / segregation) answer. Note the distinction from the ADR-0017 admit-then-filter gate on list surfaces (still landing per the caveat above): what is affirmatively true here is that the confinement data plane fails safe.
Result-set store posture (result_set_store, ADR-0036) — affirmatively citable for CC6.1/CC7.2. As of #4306, the per-owner quota check (count_for_owner_checked) and the membership/list/lineage reads (members_checked/list_by_owner_checked/lineage_checked) that back result-set dispatch targeting and materialisation are now degrade-distinguishable and fail closed: a store-not-open / pool-acquire-timeout / query-error resolves to a std::expected DbError — surfaced as 503/500 at the REST and MCP surfaces — never a silently-empty or silently-truncated container standing in for a genuine answer. This closes the same class of hazard the management_group_store row above closes for confinement reads: a degraded quota read previously returned 0, indistinguishable from a genuinely-empty owner, letting an over-quota dispatch fire before the authoritative in-transaction recheck (which runs after dispatch) ever got a chance to refuse it; a degraded membership/lineage page previously truncated silently, materialising an incomplete member set that future dispatches would target as if it were complete. This behaviour can be cited affirmatively for CC6.1 (least-privilege quota enforcement is no longer bypassable by a transient backend blip) and CC7.2 (a degraded read is surfaced to the caller as an error, never silently masked as a complete/successful answer) on the async result-set producers and the GET /api/v1/result-sets family specifically.
Addendum — result-set device_ids had no existence/scope/authorization check on any of three surfaces, closed (CC6.1/CC7.2, #4983, 2026-09-30). POST /api/v1/result-sets, MCP create_result_set, and the dashboard’s own CSV-paste import (POST /fragments/result-sets/create) accepted an arbitrary caller-supplied device_ids array with only a type check and an array-size cap — no RBAC gate of any kind ran on this field, and no entry was checked for existing or being within the caller’s own scope. A nonexistent id, or a real id outside the caller’s management-group/service-scope confinement, was silently accepted as a member. All three surfaces now gate a non-empty device_ids through the admit-then-filter Infrastructure:Read chokepoint (fleet_read_fn, ADR-0017) and validate every entry against the presence-merged fleet (HA WS-5) plus the caller’s own visible scope, rejecting the whole request (oracle-safe — a nonexistent id and an out-of-scope id are indistinguishable in the response) with an audited denial on every surface. This is a genuine breaking change under RBAC-enabled enforcement: of the 7 seeded built-in roles, only Administrator and ITServiceOwner hold Infrastructure:Read — disclosed in docs/user-manual/server-admin.md’s vNEXT entry, independently verified against RbacStore::seed_defaults()’s actual grant lists.
Daily-sync PII carve-out (ADR-0024 Decision 11). The ADR-0016 daily-sync framework was specified machine-scope / no-end-user-PII, and the installed_software and device_ci rows above still hold to that (no usernames, no per-user attribution — those statements remain true). The software_licensing source deliberately deviates — and only that source: at the owner’s explicit direction it probes per-user licence surfaces, so its records may carry user_scope=user + a pseudonymous user_ref (see the software_licensing_store row above). This is a contained, maintainer-signed-off deviation confined to two fields on this one source. Default hash mode keeps user_ref pseudonymous (still personal data under GDPR Recital 26); --license-scan-user-ref=omit suppresses the identifier; --inventory-disable turns the source off entirely. It does not relax the machine-scope posture of any other daily-sync source (installed-software, device-CI, app-performance). Full per-surface disclosure — exactly what is probed on Windows / Linux / macOS, and the honest limits of the hash pseudonym — is in docs/user-manual/software-licensing.md, the works-council-reviewable transparency evidence.
UCE host stores (engines/, own PostgreSQL — separate deployable, ADR-1005)
Use-case-engine (UCE) modules run in a separately-deployed host with its
own PostgreSQL database, never the server’s connection pool (ADR-1005 plan
Decision 11). Data a UCE re-serves or derives sits outside the server’s audit
perimeter (ADR-1005 Decision 5), so the UCE host carries its own
audit/compliance controls for it. Topology (2026-07-12, accepted trade-off):
the UCE data layer is a separate database on the server’s own PostgreSQL
instance — not a separate instance — with a dedicated role/grants, cross-database
access forbidden and mechanically revoked, and TLS-only remote access from the UCE
VM (full design + isolation mechanics: docs/uce-deployment-topology-design.md);
so this row’s “separate database” MUST NOT be read as instance-level isolation
for CAIQ/CC6.1 purposes. This row registers the derived store here for
inventory completeness. Entries are forward-declared — the store ships at the
vuln module’s store-ship milestone (requirements: docs/uce-host-requirements.md §7 + F-12, exec-plan
item 2c) — following the recommendations.db (proposed) precedent above.
Confinement maturity caveat (same posture as the server-store note at the top
of this section): the findings-view management-group confinement this store
depends on (uce-host-requirements.md §6) resolves through the ADR-0017
admit-then-filter chokepoint, which is chartered but unbuilt today — so
findings-view operator confinement is designed, not yet verified effective,
and MUST NOT be cited as an affirmative CAIQ / CC6.1 answer until M3(d) verifies
it (uce-host-requirements.md F-5/F-15). The same “designed, not verified”
caveat applies to the operator login / SSO surface (uce-host-requirements.md
NF-9, Verify = “stack ADR (T2b) / security-guardian” — not yet built): “UCE
inherits Yuzu SSO” MUST NOT be cited as an affirmative CAIQ/CC6.x answer until
T2b ships and security-guardian signs off. There is no collection-level off-switch
row for this store because the UCE vuln module is itself a separately-deployed,
opt-in artifact — a customer without works-council sign-off simply does not
stand it up; the only per-store knob is retention.
| Store | Schema (PostgreSQL) | Data class | Retention | Deletion mechanism | Configurable via |
|---|---|---|---|---|---|
| Vulnerability findings (UCE vuln module) (forward-declared — exec-plan 2c commitment; ships at the module’s store-ship milestone, F-12) | UCE host PostgreSQL (own database; table names settled at store-ship / stack ADR) | Derived vulnerability findings — sensitive security data, device-attributable. Per-device CVE exposure (agent_id × CVE × matched product/version, with severity/exploitability). Enumerates the fleet’s exploitable attack surface, so treated as more sensitive than the installed-software inventory it derives from — a breach discloses what is attackable, not merely what is installed. No direct username/SID (machine-scope source, ADR-0016 §8), but device-attributable and therefore personal data under GDPR when the device is person-assigned — hence the subject-erasure path in the deletion column; device-attributability keeps the works-council capability-to-monitor posture of its source. Sits outside the server audit perimeter (ADR-1005 Decision 5) — governed by the UCE host’s own controls. | Open findings retained while current (superseded/refreshed each sync cycle); resolved/superseded findings 90 days default (mirrors recommendations.db). | Module-owned resolved-finding reaper and device-decommission purge, both wired at the store-ship milestone, plus a subject-erasure (DSAR/Art. 17) path distinct from decommission and a legal-hold-aware reaper (uce-host-requirements.md F-14) — explicitly not inheriting the #1666 unwired-decommission gap the server inventory stores carry. | vuln_findings_resolved_retention_days (name provisional; final at store-ship) |
Retention-guard coverage is partial, deliberately. On the server, the audit trail (#2360), the Guardian-event store (
guaranteed_state_store, ADR-0038/#2496/#2579/#2663), and the response store (ADR-0039, this PR — the #2508 treatment for that store) are clock-guarded and capped; on the agent, the Echo edge warehouse is (#2361 - see the retention note under the agent-side section below). The remaining stores queued for the same #2508 treatment (app_perf_*,PreflightRunStore,DeploymentRunStore) still issue a bareDELETE ... WHERE ttl_expires_at < nowdriven by the server’s wall clock, so a forward clock step can empty one of them in one statement. They are lower-stakes than the evidence trail (operational telemetry, not the SOC 2 control record). An auditor reading this table should not infer that “clock-guarded” applies to every row in it. Nor that “clock-guarded” means fully guarded in every state: until #2579 the audit trail’s own guard had no missing-anchor trigger, so a server upgraded to schema v3 while its clock was ALREADY skewed forward could delete expired rows with no decline and no counter. That is now closed - such a pass declines once and anchors the reading. The honest answer to “can the audit trail be silently truncated?” is therefore not on a current build; yes on a build predating #2579, under a forward-skewed clock at upgrade - and there is no reliable retrospective test to confirm whether a given database was affected, nor recovery without a backup predating the pass.
Agent-side edge warehouse (echo.db, per device — federated, ADR-0004)
Most device telemetry stays on the endpoint in the Echo edge warehouse and is queried on demand (operator SQL, the /dex device-perf panel) rather than centralised. The performance tiers added in this release:
| Store / tier | Table | Data class | Default | Retention | Configurable via |
|---|---|---|---|---|---|
| Device performance | $Perf_Live / $Perf_Hourly | Device-level resource telemetry — CPU %, memory/commit %, disk latency/throughput, network B/s. No per-application or per-user identity. | On (perf_enabled=true) | 7 d raw / 31 d hourly | perf_enabled, perf_interval_seconds |
| Per-application performance | $ProcPerf_Live / $ProcPerf_Hourly | Usage-class telemetry — top-N applications by CPU + working set, by image name (no command lines, no user attribution). Reveals which applications run on a device → works-council-relevant. | On (procperf_enabled=true) | 7 d raw / 31 d hourly | procperf_enabled, perf_interval_seconds |
| Process activity | $Process_Live / $Process_Hourly / _Daily / _Monthly | Behavioral telemetry (PII) — every process start/stop with image name, pid/ppid, exit code, and (live capture only) the owning user. No command lines (Windows ETW + the privacy posture). Reveals per-user process activity → works-council-relevant. | On (process_enabled=true) | 100 000-row raw cap (row-capped, not time-based — see note); count rollups carry the long tail (24 h / 31 d / 12 mo) | process_enabled (row cap not yet operator-tunable — tracked follow-up) |
| Software install/uninstall | $Software_Live / $Software_Daily / $Software_Monthly | Asset-management inventory — no PII. Install/remove/upgrade events — app name, version, publisher. Machine scope only (HKLM Uninstall, 64-bit + WOW6432Node): an event is the host’s installed software, never attributed to a Windows profile, so there is no user/profile-name column and no personal data (asset / vuln-inventory data, like service). The _Daily/_Monthly rollups aggregate per name. Names/versions/publisher only — no command lines, no usage/launch data. | On (software_enabled=true) | 5 000-row raw cap / 31 d daily / 12 mo monthly | software_enabled, software_interval |
| DNS resolver cache (ADR-0015) | $DNS_Live / $DNS_Hourly | Usage-class telemetry — device DNS resolver-cache state (resolved domain names + record type/data/TTL). Device-level — no per-process / per-user attribution (the cache carries no pid). Reveals which domains a host resolved → works-council-relevant. Cache-only reads (DNS_QUERY_NO_WIRE_QUERY), never a wire query. | On (dns_enabled=true) | 5 000-row raw cap / 24 h hourly | dns_enabled (Windows-only today; Linux/macOS planned) |
| ARP / neighbour table (ADR-0015) | $ARP_Live / $ARP_Hourly | Network topology — IP↔MAC bindings per interface (Layer-2 adjacency for ARP-spoofing forensics). No per-user / per-process identity — lower sensitivity than DNS. | On (arp_enabled=true) | 5 000-row raw cap / 24 h hourly | arp_enabled (Windows and Linux full; macOS constrained) |
| Per-connection TCP quality (ADR-0020) | $NetQual_Live / $NetQual_Boot | Usage-class telemetry — per-ESTABLISHED-connection RTT/jitter/loss + lifetime retrans/segs context, joined to the owning process (image name only). Only a coarse destination CLASS (remote_bucket: loopback/private/public/unknown) is stored — the raw remote address/host is dropped at the collector edge and never persisted. Linux (inetdiag) + Windows (estats, elevated-only; non-elevated records nothing, netqual_capture_method=none). $NetQual_Boot adds one since-boot host-wide counter row per boot (no per-connection/per-process attribution). Reveals per-app connection quality → works-council-relevant, same class as $ProcPerf_*. | On (netqual_enabled=true) | 100 000-row raw cap (Live) / 400-row cap (Boot) | netqual_enabled |
| Connectivity transitions (ADR-0020) | $NetConn_Live | Usage-class / behavioral-adjacent — OS-logged network + Wi-Fi connect/disconnect and internet-capability changes, as closed enum tokens + numeric reason codes only (action/channel/category/capability/iface_kind/reason_code). No SSID, BSSID, profile name, interface GUID, MAC, or address is ever extracted. Device-level (no pid/user). The timing of connect/disconnect events is a presence/working-hours proxy → works-council co-determination-relevant (capability to monitor). Windows (wevtapi, EvtQuery over NetworkProfile/NCSI/WLAN-AutoConfig); the first read retroactively backfills OS-retained history from before enablement, bounded by netconn_lookback_seconds (default 7 days; 0 = forward-only, no retrospective read). | On (netconn_enabled=true) | 20 000-row raw cap | netconn_enabled, netconn_lookback_seconds (retroactive reach / off) |
| Power transitions (Wave 6) | $Power_Live | Usage-class / behavioral-adjacent — AC attach/detach + sleep/wake transitions (closed action enum + OS reason text; no user/pid). The timing of sleep/wake and AC events is a presence/working-hours proxy → works-council co-determination-relevant (same basis as $NetConn_Live). Retrospective reach is bounded by power_lookback_seconds (default 7 days; 0 = forward-only), and applies on macOS only — the Windows and Linux legs are live subscriptions with no history API, so they have no retrospective reach at all. The control fails closed: a lookback value that cannot be read is treated as 0, never as the default. The collector ships in this release, so an empty table means no transitions occurred, not that collection has not started. | On by default (power_enabled=true) — every capture source now ships enabled, so the co-determination consideration applies at upgrade, not at an operator’s opt-in. | 20 000-row raw cap | power_enabled, power_lookback_seconds |
| Removable media (Wave 6) | $Removable_Live | Usage/identity-class — removable/USB attach+detach with device identity (vendor/product/serial) plus executed-from-removable evidence (executable path + pid). Device serials and executed-binary paths are identity/usage-class data → works-council co-determination-relevant. Bounded by removable_lookback_seconds (default 7 days; 0 = forward-only). The collector ships in this release, so an empty table means nothing was attached, not that collection has not started. Windows reads the OS event log and has real retrospective reach (removable_lookback_seconds); Linux and macOS are live-only with no history API, so an attach/detach while the agent is stopped or disabled is unrecoverable there and is recorded as a capture_gap, never backfilled. | On by default (removable_enabled=true) — see the $Power_Live note; this source additionally records a per-process executable path and pid, so the consideration is stronger here. | 20 000-row raw cap | removable_enabled, removable_lookback_seconds |
| App usage (Wave 7) | $Usage_Live / $Usage_Daily | Usage-class / behavioral-adjacent telemetry — per-executable run-count/duration derived from process start/stop pairing, plus a per-day distinct_users COUNT. usage_live persists the owning pid and user for each open run, and usage_daily_user persists the raw username per (day, executable) — all three tables (usage_live/usage_daily/usage_daily_user) are now DENIED to the generic echo.sql action (Infrastructure:Read), closing the prior over-disclosure (#4260). Wave 7 PR7.2 shipped the dedicated Forensics-gated reader (GET /api/v1/forensics/agents/{id}/app-usage + MCP get_agent_app_usage, Forensics:Read held by default only by Administrator and the read-only ForensicsAnalyst role) — but it serves only the DEVICE-SCOPED, no-identity projection (per-executable first_seen/last_seen/trailing-30-day run_count/total_seconds), never usage_live’s per-run pid/user or usage_daily_user’s raw per-day username; those two identity-bearing slices currently have no query surface at all (locked down, not migrated to a gated reader) — see the access-limitations note in docs/user-manual/echo.md. No command lines are ever captured. Reveals which applications run on a device, when, and by whom → works-council co-determination-relevant, same basis as $Power_Live/$Removable_Live. Forward-only: coverage begins once the source reaches its Active lifecycle state after enablement or upgrade, never a retrospective fold. | On by default (usage_enabled=true) — like every other capture source. | 31 d (usage_daily, mirrored by usage_daily_user; retention is frozen, not accelerated, while the source is paused or disabled — same as every other Echo source, #539); usage_live bounded by the fold’s own 20 000-run open-run cap (capped closures accounted in expired_runs), not the table’s generic row-count backstop | usage_enabled |
| Boot-window process trace | procboot.etl (kernel AutoLogger file, Windows) | Boot-window process start/stop, names-only, no user. Source for the one-time boot backfill into $Process_Live. | Configured by the production installer (advanced component) and the dev install script | Circular 16 MB file (not retention_days-governed); removed on uninstall (installer [UninstallRun] + dev script), which also stops the running session | process_enabled gates the backfill insert; file lifecycle via the installer / install-agent-user.ps1 |
These tiers are device-local; raw rows never leave the endpoint except via an operator-initiated, permission-gated query (the /dex device-perf panel runs a live $Perf_Hourly query, Execute-gated and audited dex.device.perf.query). The perf tiers use RetentionType::kTimeBased; $Process_Live uses RetentionType::kRowCount (100 000 rows) — see the retention-control caveat below.
Retention numbers are inline defaults; time-based stores expose a retention_days constructor argument so a customer can tighten them without a code change (retention_days = 0 disables the reaper — intended for forensic freezes; requires a compensating manual-export process to avoid unbounded growth). Exception: $Process_Live is row-capped, not time-based, and the cap is not yet operator-configurable — on a busy endpoint 100 000 rows can be days of history; making the process raw cap (or its conversion to time-based retention) operator-tunable is a tracked follow-up so the per-category retention commitment holds for process data too. The $DNS_Live / $ARP_Live tiers (ADR-0015) are likewise row-capped (5 000) on the Live tier with a time-based 24 h Hourly tier; the DNS-cache PII erasure path today is disable the source (dns_enabled=false) + ring-wrap / Hourly reaper (no dedicated per-subject DELETE) — and since DNS rows are device-level with no pid, they are not subject-attributable, so a per-subject DSAR maps to the device, not an individual. A dedicated DSAR/erase path is the same tracked roadmap gap noted below.
Every retention number above is a floor, not a ceiling (#2361). The reaper runs on the 900 s echo.rollup tick, and is now clock-guarded and paced:
- Time-based tiers are clock-guarded. Before deleting, each table is probed for whether the cutoff would expire every datable row it holds. If it would, the pass declines that table once and records the decline, rather than emptying a forensic window in one statement because the endpoint’s clock jumped. A reading persisted in
echo_config(retention_guard_last_pass) makes the guard survive agent restarts; a stored reading that is ahead of the current clock AT ALL, or a jump of more than 30 days between passes, is itself treated as implausible; a pass that finds NO stored reading (the first after an agent upgrade or restore) also declines, once, and is the one trigger that does not spend the latch (the 24 h slack is a separate rule, and applies to row timestamps, not to the stored reading). The guard paces deletion, it does not block it: after the one declined pass the latch releases the table to the capped delete below, so a genuinely wrong clock still drains its backlog at 5 000 rows/table/tick. What the guard buys is a recorded decline and the time to notice, not indefinite preservation. - Both tier kinds are paced. A single pass deletes at most 5 000 rows per table, time-based and row-capped alike. A large backlog (a long-powered-off laptop, or an upgrade that lowers a retention setting) drains over successive rollup ticks instead of one multi-second write transaction. Rows therefore survive past their nominal window while the backlog drains - deliberately, and bounded by the tick rate.
- The evidence surface is the
echo statusaction, since the agent has no/metricsendpoint. It emitsretention_guard_declines_total|<n>andretention_guard_failures_total|<n>on every call (including zeros, so an old agent is distinguishable from a healthy one) plus aretention_guard|<table>|<n>/retention_guard_failed|<table>|<n>line for each affected table. A non-zero failures total means retention has stopped for that table - a probe or delete is erroring - and must be read together with the declines total, not instead of it. These counters are in-memory and reset on agent restart, and are not aggregated fleet-wide today; reading them is a per-device query. - Failure mode with a retention consequence: if a retention transaction cannot be rolled back and the endpoint’s database is left mid-transaction, the agent closes the
echo.dbwrite connection rather than reporting durable writes it will lose. Echo collection and retention both stop until the agent is restarted; historical rows stay readable through the separate read-only connection (echo sql), andecho statusreportserror|...followed bystorage_state|offline. While an endpoint is in that state its retention commitment is not being met - nothing is deleted until it restarts. There is no automatic recovery today (tracked follow-up).
Agent-side kv_store.db namespaces
kv_store.db is the agent’s general-purpose key-value store (rule definitions, sync
state, plugin data); the namespaces below carry security-relevant audit or integrity data
rather than device telemetry and are inventoried here on that basis.
| Namespace | Data class | Default | Retention | Deletion mechanism | Configurable via |
|---|---|---|---|---|---|
Guardian lifecycle-audit journal (__guardian_journal__, ADR-0021 Stage 2 item 7) | Security-relevant activity — guard.armed / guard.disarmed events for spark-backed rules (plus guard.errored as of #2818, the subscription-death path only), durable across process crash/restart so they can be re-sent until delivery is evidenced or retention expires them. No behavioral/usage telemetry; carries rule identity + agent-generated event metadata, not device or user content. At agent shutdown the last flush waits at most 1 s for a locked kv_store.db (#5604); records still pending then are lost with the process (logged once, with a count). | On whenever prefer_spark is active (inert — zero writes, zero paging/pruning — while the flag is false) | Three bounds enforced together, oldest evicted first: 7 days, 1000 batches, 32 MiB (kJournalRetentionDays/kMaxJournalBatches/kMaxJournalBytes). Every eviction is classified and counted (see docs/yuzu-guardian-design-v1.1.md §25) — retention and quarantine are the only deletion paths. | Automatic, time/count/byte-bounded pass on the journal maintenance tick; quarantine (corrupt/unparseable batches) is separately capped at 100 batches. No manual delete API — the namespace holds audit evidence, not subject data, so no DSAR path applies. | No operator-facing retention knob today (constants are compile-time; a tracked follow-up if a customer needs a shorter/longer window). |
Guardian per-rule file-hash baselines (__guardian__, keys baseline:<rule_id>, #4021 legacy path / #4045 Spark path) | Integrity reference data for file-hash-equals rules with no authored expected_hash: the rule_id, a CLEARTEXT fingerprint file-hash-equals|<authored path> (an operator-authored path can embed a local username, so treat it as potentially personal data), and the SHA-256 of the file’s content. No file content is stored. Unsigned and not MAC’d. On the Spark path only (the legacy path writes at capture time), a capture still waiting at agent shutdown can be lost when the store is locked (the shutdown wait is 1 s, #5604); the next start recaptures the file’s then-current content, and drift in between is accepted as the baseline. | On on Windows agents (the only platform where a file-hash guard runs) whenever such a rule is armed under the legacy backend; under the Spark backend only while prefer_spark is active (inert while false) | Life of the agent data directory: no pruning, and a record for a removed rule stays dormant (it re-seeds the rule if it returns on the same path). | Delete the record or the agent data directory (re-baselines the rule); there is no API or reset action, and no DSAR path beyond that. Tamper boundary: the permissions on the agent data directory, which the Windows installer does not restrict beyond adding administrator and SYSTEM grants (see “Where the baseline lives” in docs/user-manual/guaranteed-state.md); and the control plane, where a rule push that changes the rule’s path or authors an expected_hash re-baselines it without touching the endpoint (the server audits the rule create, update and push calls: who and when; the create and update rows name the rule, the push row records the rule count, scope and agent count, and none of them records the changed values). | No operator-facing knob. |
The fleet visualization cache (FleetTopologyStore) is the highest-resolution endpoint telemetry surfaced in the dashboard. It holds at most two snapshots in memory at any time (single-flight refill, no SQLite persistence) and each snapshot is invalidated 60 seconds after the agent dispatch completes; restarting the server purges all cached topology. Process-level fields (name, user, category) are agent-controlled strings rendered after HTML escape and length clamp; the category field is computed server-side from a typed enum (process_category.hpp) so agents cannot inject arbitrary palette keys. Privacy-sensitive customers can suppress process collection on specific agents via echo.configure process_enabled=false (the corresponding cubes in the visualization render with no interior dots) or disable the whole feature with --viz-disable / YUZU_VIZ_DISABLE.
The Guardian events table is sized for ~10k events/s during a fleet-wide incident (design doc §9.1), i.e. ~864M rows/day. The 30-day default is the retention/recovery trade-off: long enough to correlate an incident across the standard forensic window, short enough to keep steady-state disk under ~25GB per million endpoints at typical drift rates. Tenants with longer forensic SLAs should raise guardian_event_retention_days and provision storage — the product does not auto-trim disk.
Agent-side, transient (no durable store)
Read results that are never written to a database on either side — computed fresh per invocation and streamed to the caller. Listed here so a data-inventory pass finds them instead of assuming an omission.
| Source | Data class | Where it lives | Retention | Deletion mechanism |
|---|---|---|---|---|
autoruns.list (agent-side, transient) | Persistence-source metadata: file paths, registry key names/values, task/service names, command lines and arguments; user-scope rows carry a user field, but its content differs by source — Windows (win_run_hku/win_runonce_hku/win_startup_approved) and macOS (~/Library/LaunchAgents) carry the real profile/account name, lnx_user_crontabs carries the real username (the crontab filename), but lnx_systemd_timers_user and lnx_xdg_autostart_user carry a bare numeric uid (owner_uid_string, autoruns_linux.cpp:244) rather than a resolved username | Not stored on the agent between runs — computed fresh per invocation, streamed to the caller | None on the agent (nothing persisted). If the result transits the response store (an operator running this via the instruction engine), it is retained under the standard response retention (response_retention_days, default 90 d) — the same annotation pattern as the per-application-image-names row (docs/enterprise-readiness-soc2-first-customer.md:477) — because a list row can name a specific user’s autorun entries, not just machine-wide facts. | Response-store rows follow that store’s existing erasure path; no agent-side erasure needed since nothing is retained there. |
privacy_permissions.permissions (agent-side, transient) | Usage-class per-app sensitive-permission grants (camera, microphone, location, full-disk-access): whether each app on one named machine currently holds each capability. Per-user rows (macOS per-user TCC.db, Windows profiles) are qualified with the profile or home folder name (normally the account name; never a SID); Linux rows are the agent’s own session and unqualified. App ids can embed profile paths (a Windows NonPackaged app id is an executable path). macOS per-user rows report what that user’s own, user-writable TCC.db records, not necessarily what tccd enforces (MDM precedence is not modelled) — works-council co-determination-relevant. Default-off (plugin-config kill switch), single-target, Forensics:Read + AdminOrApproval. The Windows leg (each profile’s ConsentStore plus the HKLM mirror) additionally carries per-app last-used times, a usage-class presence proxy, and reads a logged-off profile’s NTUSER.DAT through an offline hive mount (which alters that hive’s sequence number and hash). | Not stored on the agent between runs — computed fresh per invocation, streamed to the caller | None on the agent (nothing persisted). If the result transits the response store, it is retained under the standard response retention (response_retention_days, default 90 d) and readable only by a caller who also holds Forensics:Read (#4990; every row-returning read that serves it writes response.forensics.read) | Response-store rows follow that store’s existing erasure path (no per-user erasure yet); no agent-side erasure needed since nothing is retained there. |
remote_access_audit.list (agent-side, transient) | Identity material: per-account SHA-256 fingerprints of the public keys that let someone log in over SSH to one named machine (plus a certificate’s embedded key), the option NAMES each key carries (command, from, no-pty, …), the account (the home directory’s name) and a few sshd_config values as written (PermitRootLogin, PasswordAuthentication, …). A key comment, an option VALUE (command=, from=, principals=), a principals list and every key-file path (including the AuthorizedKeysFile config row, which reports only default/none/custom:<n>) are never emitted. Every walked home is reported as an account, keyless ones included, and a key’s fingerprint is the same on every host that trusts it, so results from different machines can be joined on it — works-council co-determination-relevant. Default-off (plugin-config kill switch), single-target, Forensics:Read + AdminOrApproval. | Not stored on the agent between runs — computed fresh per invocation, streamed to the caller | None on the agent (nothing persisted). If the result transits the response store, it is retained under the standard response retention (response_retention_days, default 90 d) and readable only by a caller who also holds Forensics:Read (#4990; every row-returning read that serves it writes response.forensics.read) | Response-store rows follow that store’s existing erasure path (no per-user erasure yet); no agent-side erasure needed since nothing is retained there. |
browser_inventory.profiles (agent-side, transient) | Per-user identity-adjacent: for one named machine, which Chromium-family browsers and browser profiles exist on each local account, with the local OS account name and a browser-supplied profile display_name that can be the signed-in person’s real name (an e-mail-shaped value is redacted whole; no gaia_id, e-mail address, history, cookie or bookmark is ever emitted); works-council co-determination-relevant. Default-off (plugin-config kill switch), single-target, Forensics:Read + AdminOrApproval. | Not stored on the agent between runs, computed fresh per invocation, streamed to the caller | None on the agent (nothing persisted). If the result transits the response store, it is retained under the standard response retention (response_retention_days, default 90 d) and readable only by a caller who also holds Forensics:Read (#4990; every row-returning read that serves it writes response.forensics.read) | Response-store rows follow that store’s existing erasure path (no per-user erasure yet); no agent-side erasure needed since nothing is retained there. |
execution_artifacts.* (agent-side, transient) | Execution evidence for one named Windows machine: ShimCache, AmCache and Prefetch rows: executable paths (which can embed a profile or user-name segment), AmCache SHA-1 hashes and product/publisher identity, Prefetch run counts and last-run times; never file contents. Together they reconstruct what ran on the machine and when; works-council co-determination-relevant. Default-off (plugin-config kill switch), single-target, Forensics:Read + AdminOrApproval. | Not stored on the agent between runs (the AmCache scratch copy is per-dispatch and removed before return), computed fresh per invocation, streamed to the caller | None on the agent (nothing persisted). If the result transits the response store, it is retained under the standard response retention (response_retention_days, default 90 d) and readable only by a caller who also holds Forensics:Read (#4990; every row-returning read that serves it writes response.forensics.read) | Response-store rows follow that store’s existing erasure path (no per-user erasure yet); no agent-side erasure needed since nothing is retained there. |
app_usage.* instruction results (agent-side, transient) | Usage-class per-executable facts for one named machine, read from Echo’s usage fold: first/last seen inside the retained window and run counts. Machine-scope, no user identity, yet a working-hours presence proxy. The same data is also pushed by the daily-sync source into app_usage_store (its own row above); this row is the on-demand instruction result only. Single-target, Forensics:Read + AdminOrApproval on all three actions (unlike the other four Forensics-tier plugins it has no default-off plugin-config kill switch). | Not stored on the agent between runs (the plugin only reads echo.db), computed fresh per invocation, streamed to the caller | None on the agent. If the result transits the response store, it is retained under the standard response retention (response_retention_days, default 90 d) and readable only by a caller who also holds Forensics:Read (#4990; every row-returning read that serves it writes response.forensics.read) | Response-store rows follow that store’s existing erasure path (no per-user erasure yet); no agent-side erasure needed since nothing is retained there. |
Behavioral telemetry (DEX) — PII posture and works-council / co-determination
The DEX read model (guardian_observations + the /dex dashboard) is the first surface where Yuzu telemetry is identifiably behavioral: a device’s signal history reveals which applications a person runs and when they fail. In jurisdictions with employee co-determination — Germany (§87(1)(6) BetrVG), Austria, the Netherlands (WOR), France (CSE), the Nordics — the works council’s right is triggered by a system’s capability to monitor behavior or performance, not by the operator’s intent. The controls below are therefore deliberate design decisions, documented here as the evidence base for the DPA/security addendum (workstream G) and customer privacy review:
- Minimisation at the edge (collection time, not display time). The agent’s catalogue extractors never read user content from events that carry it: DNS queried hostnames (browsing behavior), print document names and owners, profile/logon/VPN/RDP usernames, RDP client addresses, file paths under user profiles (image paths, ESENT database paths, inaccessible-file paths, boot-degradation paths), Defender detection paths and detection users, AAD error-message texts (can embed UPNs), and .NET stack frames are dropped on the device — what is never extracted can never be exfiltrated, subpoenaed, or mis-scoped. Each drop is pinned by a
[privacy]-tagged unit test. Process/application names ARE collected — they are the irreducible core of a reliability signal — and are the reason the per-device view is classified PII even though no username field exists in the store. - Per-application performance sampling (
procperf) is a separate legal basis, and ships ON by default. The reliability justification above covers crash/hang/failure events. Continuous per-application CPU + working-set sampling (the$ProcPerf_*edge-warehouse tiers, 30 s cadence) is performance/usage monitoring of which applications a person runs and how heavily — not a reliability signal, and not covered by that basis. It ships on by default (procperf_enabled=true; an operator can disable it per host), image-names-only, and is the candidate collector for the per-category collection toggle (first roadmap gap below). Device-level performance ($Perf_*, no per-app identity) is a distinct, lower-sensitivity category and stays on by default. Enablingprocperffor an EU workforce should be treated as a works-council co-determination trigger. - Software install/uninstall (
software): machine-scope asset-management class, no PII, on by default. Recording install/remove/upgrade events (app name, version, publisher — no command lines, no launch/usage data) is asset-management and vulnerability-relevance data, the same lower-sensitivity class as the Services and User-session sources — distinct fromprocperf’s continuous usage sampling. The source is machine scope only (Windows HKLM Uninstall, 64-bit + WOW6432Node): an event is the host’s installed software, never attributed to a Windows profile, so there is nouser/profile-name column and no personal data. It ships on by default (software_enabled=true) and an operator can disable it per host (existing rows stay queryable on disable). The_Daily/_Monthlyrollups aggregate pername. Because the source carries no per-user identity, it raises no works-council per-user concern; the response store (PostgreSQL schemaresponse_store) still retains software query results (machine-scope app names) under the standard response retention. - Per-connection network quality (
netqual) + connectivity transitions (netconn): usage-class, on by default, with a novel retroactive-reach consideration (ADR-0020).$NetQual_Liverecords per-connection RTT/jitter/loss joined to the owning app (image name only; the destination is reduced to a coarseremote_bucketclass — the raw address is never stored) — the same usage-class posture as$ProcPerf_*, and on Windows it needs an elevated agent (non-elevated records nothing,netqual_capture_method=none).$NetConn_Liverecords OS-logged network/Wi-Fi connect/disconnect and internet-capability changes as closed enum tokens + numeric reason codes only — no SSID/BSSID/profile/GUID/MAC/address. Both are device-level (no per-user column). The distinct consideration vs the forward-looking sources above: enablingnetconn(and the$NetQual_Bootbaseline) triggers a one-shot retrospective read that ingests OS-retained history from before Echo — or the agent — existed on the box (up tonetconn_lookback_seconds, default 7 days). On a person-assigned device the timing of network/Wi-Fi transitions is a presence/working-hours proxy — behavioral personal data under GDPR even with identifiers stripped — so collecting a window that predates the monitoring notice is a distinct lawfulness/transparency (GDPR Art. 13-14) and co-determination question. Mitigations: the retroactive reach is operator-configurable and can be set tonetconn_lookback_seconds=0(forward-only, no pre-notice read) where retrospective collection is not lawful; enum-tokens-only storage. Enablingnetconnfor an EU/co-determination workforce should be raised with the works-council/DPO before enablement, and the retroactive window set per that agreement (ADR-0020 privacy note). - Aggregate-by-default, individual-by-exception. The
/dexheadline is a fleet rollup. The per-device drill-down (the individual-identifying view) is separately permission-gated (GuaranteedState:Read) and every open is audit-logged (dex.device.viewfor the device history, anddex.observation.viewfor the per-event detail panel — which records the event’s obs_type so usage-class opens stay separately countable — both in the audit store (audit_storeschema, PostgreSQL), 365-day retention) — access transparency for the individual-level read path. The individual read path has two channels with identical gating and audit: the/devicedashboard DEX lens AND the machine-readable REST endpointsGET /api/v1/dex/devices/{id}(emitsdex.device.view) andPOST /api/v1/dex/devices/{id}/live(emitsdevice.live.uptime/device.live.processes,result=requested, audited before dispatch). Both REST endpoints are fail-closed: a non-persistable audit row returns503+Sec-Audit-Failed: trueand serves no PII / dispatches no probe. Both channels are in the data inventory for this read path, so a Workstream-E review of “what can trigger an individual behavioral read” must list REST, not only the dashboard. - Guardian per-device compliance is a third behavioral-read channel (
guardian.device.view).GET /api/v1/guaranteed-state/device-compliance?baseline={name}&agent_id={id}(the ServiceNow/CMDB CI-sync read) returns a named device’s per-Guard compliance verdicts — individually-identifying behavioral data — gated on per-device-scopedGuaranteedState:Readand audit-logged on every open asguardian.device.view(audit store,audit_storeschema, PostgreSQL, 365-day retention), the same verb the/devicedashboard Guardian lens emits (one SIEM filter covers both surfaces). Fail-closed: a non-persistable audit row returns503+Sec-Audit-Failed: trueand serves no compliance PII (CC7.2), with503returned before404(no name-existence oracle without durable evidence). It belongs in the Workstream-E “what can trigger an individual behavioral read” inventory alongside thedex.device.viewchannels above. - Device configuration-item (CI) inventory is a fourth behavioral-adjacent read channel (
inventory.device.ci, andinventory.devicesfor the fleet-list variant). The Hardware CI list columns + per-device CI record (/hardware,/hardware/ci?id=,GET /api/v1/hardware,GET /api/v1/hardware/{id}and the MCP toolslist_hardware_ci/get_hardware_ci) (serial, system UUID, primary/all MAC addresses, BIOS/CPU/RAM, OS version — device-persistent identifiers, ADR-0016) are gated onInventory:Readand audited at the same behavioural-PII tier (emit_behavioral_audit) asdex.device.view/guardian.device.view. Unlike those two, the fleet-list variant (inventory.devices) audits a BULK read (the whole roster it renders in one event, not per-device) — see the Data Inventory row above for the full classification. It belongs in the same Workstream-E “individual behavioral read” inventory as thedex.device.view/guardian.device.viewchannels. - Software-licensing per-agent drill is a fifth individual-identifying read channel (
sle.agent.view).GET /api/v1/sle/agents/{id}(the SLE single-agent licence drill, ADR-0024) returns a named device’s detected licence rows — including per-useruser_refpseudonyms wherever per-user surfaces were probed — individually-identifying personal data (GDPR Recital 26). The SLE fleet-aggregate surfaces (/sle/summary,/sle/licenses) are not built in-server at all — they are the SAM use-case-engine module’s interpretation surface (ADR-0024 / ADR-1005). The one in-server per-agent drill takes the ancestor-aware per-device scopedSoftwareLicensing:Readgate from day one (403 outside scope — thedevice_routesprecedent; it is Decision 11’s privacy-verification surface, so it gets real confinement immediately) and is audit-logged on every open assle.agent.viewat the behavioural-PII tier (emit_behavioral_audit, audit store,audit_storeschema, PostgreSQL, 365-day retention). Fail-closed: a non-persistable audit row returns503+Sec-Audit-Failed: trueand serves no licence PII (CC7.2). It belongs in the same Workstream-E “individual behavioral read” inventory as thedex.device.view/guardian.device.view/inventory.device.cichannels above. - The machine-health audit exemption (named policy decision, F2a 2026-06-12; extended to the network surface N1 2026-06-15; extended to app-performance-over-time aggregates 2026-06-28; narrowed 2026-08-14 — see below). Aggregate fleet-performance and cohort-benchmarking reads (the
/dexPerformance tab’s fleet/cohort views,GET /api/v1/dex/perf/{fleet,cohorts,cohort-diff}, the matching MCP toolsget_dex_perf_fleet/get_dex_perf_cohorts/get_dex_perf_cohort_diff), the application-performance-over-time aggregates (GET /api/v1/dex/perf/{apps,app,group,tag}, the MCP toolslist_dex_perf_apps/get_dex_app_perf/get_dex_group_app_perf/get_dex_tag_app_perf, the/fragments/dex/perf/{apps,app}dashboard fragments, and the Apps listGET /api/v1/dex/apps/list_dex_apps), the fleet network-quality now-stats (GET /api/v1/network/fleet, the MCP toolget_network_fleet, and the/fragments/network/overviewdashboard fragment), and the DEX family-catalogue rollup (GET /api/v1/dex/catalogue, the MCP toolget_dex_catalogue— WS-A4 PR-1, the same aggregate class as the already-exemptedcatalogue/groupdashboard fragment: a fleet-wide health-score/coverage rollup by signal family, noagent_id, no per-device row) are deliberately not per-read audited: they carry no behavioral history, no event detail, nodetail_json, noagent_id— only device-state / app-aggregate telemetry (CPU/commit/disk levels; per-app CPU/working-set by version; device-aggregate RTT/retransmit/throughput link health) and operator tag values, which are individually-identifying when a cohort holds a single device; the exempt reads also publish per-application and per-version crash and hang counts, distinct-device counts and last-seen times, as aggregates with noagent_id. The app-perf aggregates additionally apply a cohort floor on read (kDexCohortFloor) to both the fleet (/perf/app) and group (/perf/group) surfaces, and the cohort benchmarks (/perf/cohorts,/perf/cohort-diff) apply the same constant. Maintainer ruling 2026-10-14 (absolute): that floor is one device (previously 10). Every(version, day)point, cohort and figure with at least one contributing device is published; only an empty point or cohort is reduced to a count. The privacy consequence, stated plainly: a point or cohort covering a single device — including a named management-group, device-model or device-tag cohort of one — now publishes that device’s exact CPU/memory aggregate on these un-audited reads. The opt-in per-cohort Prometheus export (yuzu_fleet_perf_cohort_*) likewise exports a one-device cohort’s exact figures, labelled with the tag value, to whatever scrapes/metrics; that channel is bounded by the/metricsauthentication setting (loopback is always unauthenticated; remote scrapes need any authenticated session, with noGuaranteedState:Readcheck, unless--metrics-no-authis set) and the admin-gated export key, not byGuaranteedState:Read. What bounds it: the figures are fleet-wide aggregates by application, version or cohort value only, noagent_idis emitted (a cohort value is operator-chosen or agent-synced and may itself identify a device), every API, MCP and dashboard read stays behind the access-reviewedGuaranteedState:Read, and the audited per-device drilldex.device.app_perf.view(above) is not the only route to per-device figures. The same one-device floor governs the per-version crash and hang join on/perf/app(versions[]); the Apps list read (GET /api/v1/dex/apps, MCPlist_dex_apps) is likewise a per-application and per-version aggregate, noagent_id, behindGuaranteedState:Read. This is an accepted residual under the maintainer’s ruling, recorded in the governance ledger, not a gap awaiting a fix. Narrowed 2026-08-14 (fleet-wide confinement fix): the/devicesvariant of both families —GET /api/v1/dex/perf/devices(MCPlist_dex_perf_devices, fragment/fragments/dex/perf/devices) andGET /api/v1/network/devices(MCPlist_network_devices, fragment/fragments/network/devices) — is no longer in this exemption: each row names anagent_id, which IS individually-identifying, and a pre-existing gap let a service-scoped API token read it fleet-wide under a role-only permission check that never confined the token’s own service scope. Both surfaces are now access-audited (dex.perf.device.view/network.device.view, three emitters each — REST fails closed, the dashboard fragment and the MCP tool are set-and-proceed) and deny a service-scoped token outright; see the audit-log user manual. The audited boundary is unchanged otherwise: the individual device behavioral view (dex.device.view), the machine-health device query (dex.device.perf.query), and the usage-class per-application panel (dex.device.procperf.query— whose result polls require the audited dispatch’scommand_id, so no usage-class read escapes the count). Compensating control: un-audited REST reads on the remaining machine-health surfaces are covered at the web-server access-log layer (request path, principal, status, timestamp); the MCP path additionally emits the genericmcp.<tool>audit event per invocation. Accepted residual risk: a principal holdingGuaranteedState:Readcan enumerate fleet topology and cohort (e.g. location) membership without a per-read SIEM trail via the remaining aggregate surfaces — accepted for the machine-health class becauseGuaranteedState:Readis an access-reviewed role and the compensating controls above apply; this is not new (it predates N1 for DEX-perf and the/networkfragments). This paragraph is the citable policy line for CC7.2 evidence reviews. - Per-app names transit the server response store. When an operator runs the per-application panel, the device’s reply (per-application image names — usage-class) is persisted in the response store (PostgreSQL schema
response_store, ADR-0039) under the standard response retention (response_retention_days, default 90 d) — i.e. longer than the 31-day on-device tier. The data-inventory row forresponse_storecarries this annotation, and the future F3 DSAR path must includeresponse_storeas an erasure source alongsideguardian_observations.detail_json. - Device live-snapshot reads transit the server response store (extends the per-app note). The device-page Get live info cards dispatch read-only queries whose replies persist in the response store (
response_store) under the same retention. Several carry usage-class behavioral PII: the process tree (device.live.process_tree— process names + parent→child graph + SHA-256 + per-process remote connection endpoints), active connections (device.live.connections), logged-in users (device.live.users), and the DNS resolver cache (device.live.dns_cache). The machine-health cards (uptime/services/adapters/ARP/listening/capture-sources) carry no individually-identifying data. The F3 DSAR/erasure path must treat all four usage-classdevice.live.*categories asresponse_storeerasure sources; each dispatch is per-read audited under its own verb (works-council separately-countable). - Stored responses of Forensics-tier plugins are a separately gated, separately counted read channel (
response.forensics.read, #4990). The rows ofapp_usage,browser_inventory,execution_artifacts,privacy_permissionsandremote_access_auditare served from the response store only to a caller holdingForensics:Readin addition toResponse:Read; to any other caller they are absent from the query, export, aggregate and MCP response routes, with no marker (an empty result does not prove no data exists; the one surface that does say so is the Hardware Actions-lens result poll, which shows a notice that the reply needsForensics:Readinstead of reporting a timeout), and a read that cannot tell whether the caller may see them gets a retryable error rather than a partial answer (yuzu_server_forensics_tier_withheld_total, alertYuzuForensicsTierUnresolved; this covers sessions, API tokens, MCP tokens and engine principals, and an unavailable globalForensics:Readcheck never collapses into a definitive denial; a service-scoped token is default-denied instead, and a whole-store outage that fails the surface’s earlier admission gate first is answered by that gate and not counted here; there is no management-group scoped read tier on the stored-response surfaces, only a global grant admits Forensics rows there, while the per-device application-usage read keeps its own scoped gate). A row-returning read that serves at least one such row writes oneresponse.forensics.readrow (forensics_rows=<n> plugins=<names> surface=<s>; REST fails closed, MCP and the dashboard set-and-proceed) so those reads stay separately countable for works-council purposes, like thedevice.live.*verbs. The count is of read requests, not viewings or rows (an open executions drawer on a running execution writes a row per refresh; an export’s count is taken before the byte cap trims the file). Not counted: aggregate, facet-option and count reads write no row, and the dashboard filter bar (GET /fragments/results/filter-bar) serves facet option values, which can list column values of Forensics-tier rows, to aForensics:Readholder unaudited, and the dashboard create-group-from-results action derives group membership from the matching agents of those rows and writesgroup.create_from_resultsbut noresponse.forensics.readrow (a documented gap in audit coverage); an interactive cookie session that lacks the grant leaves no denial row, so its attempts are not visible.Forensics:Readis held by default by Administrator and the read-onlyForensicsAnalystrole. Bundle collation (RESTGET /api/v1/bundles/{id}, MCPget_bundle_result) writes the same row withsurface=bundlewhen it serves a Forensics-tier step’s output, next to its existingbundle.collaterow. A caller authenticating with an API token, an MCP token or an engine principal that lacks a globalForensics:Readalso leaves oneauth.permission_requireddenied row per read attempt; an interactive browser sign-in does not. The failure text of a Forensics-tier command is reduced to a bounded token before it reaches the execution record, the event outbox and stream, the failure notification, the analytics event and the webhook and offload payload. The plugin that decides the tier is the one the server recorded when it dispatched the command (command_execution.plugin, one row per dispatch), never the command id an agent echoes; a response with no server record is stored~unverified, which is part of the Forensics set (yuzu_server_response_provenance_total{outcome}). The record proves the server issued that id for that plugin, not who sent the frame: an endpoint that was sent a command can replay its id on a frame of its own, and frame content is not validated. Deployment contract (hard cutover; a scope decision of the project owner, 2026-10-24, not an acceptance of any residual risk): this release supports initial deployment into a fresh database only, with no data imported from earlier builds, every server writer and serving replica on the new build and earlier binaries excluded; recovery is rolling forward, or restoring this build’s own backups into a fresh deployment, and a database or backup written by an earlier build is unsupported; the executable does not enforce this, so provisioning and exclusion are deployment responsibilities, and data written by earlier builds is outside the supported population. Stated gap: a separate follow-up (#6065) tracks the dispatch-side authorization of policy checks, and until it lands the check instruction of a Forensics-tier policy is still dispatched to endpoints and its response row stored (readable only through the tier), with only the verdict andcheck_resultwithheld. WhatForensicsAnalystreaches:Response:Readis fleet-wide for every plugin’s stored responses (including the usage-classdevice.live.*results) andInventory:Readreaches the device configuration-item inventory (serial, UUID, MAC) fleet-wide;Vieweralready holds both, so assign the role as a high-sensitivity read. Risk-register entries: the deployment contract above is a procedural control, not an enforced one (nothing in the executable verifies the provenance of the database or excludes an earlier binary); the provenance replay and unverified-record limits above; monitoring of the dispatch record, where a sustained write failure, a record lookup that fails twice and a reap running at its cap for a day are alerted (YuzuDispatchRecordWriteFailing,YuzuResponseProvenanceLookupDegraded,YuzuExecCorrelationReapSaturated), while theunrecordedoutcome has no alert by design (it is non-zero on a healthy fleet) and no per-principal or per-address limit on dispatch inflow exists yet (a separate follow-up tracks it); the uncounted aggregate, facet and filter-bar reads and uncounted filtered attempts above; the dispatch-side gap above; andresponse_storeoutside the decommission cascade (#1666). A user holding onlyForensicsAnalystcannot dispatch (the dispatch routes refuse for want ofExecution:Execute; on the MCPexecute_instructiontool at the supervised tier the approval step runs before the tool’s own permission check, an ordering that already applied to any caller lacking that permission). The tier is per plugin, not per kind of data: other plugins return comparable information under their own securables. No evidence artifact beyond the audit rows and the metric exists for this control yet. - Operator tag values are a data category (classification: operator/API- or agent-sourced, variable sensitivity; an operator/API value overrides an agent-synced one, and a value is bounded to 448 bytes). Tags drive scoping AND, since F2a, the opt-in per-cohort Prometheus export, where the chosen key’s values become metric labels scraped by external monitoring stacks. Risk-register entry: tag values of variable sensitivity may flow to third-party monitoring if cohort export is enabled (mitigations: opt-in + admin-gated + audited key changes + the top-50 cardinality cap + documented key-selection guidance in
metrics.md/the Settings panel; the cohort floor is one device, so a cohort of one device is exported with that device’s exact figures, and the cap limits how many cohorts export, not how small they are). - Lockstep retention. Projection rows inherit the parent event’s TTL and are reaped in the same cleanup pass (single operator knob,
guardian_event_retention_days); 30-day default. - Deploy-time opt-out.
--dex-disable/YUZU_AGENT_DEX_DISABLEprevents the observer from ever arming — no DEX signal telemetry is collected on that endpoint, and the heartbeat tags are omitted so fleet rollups reflect genuine state. - Named roadmap gaps (required before EU-works-council deployments; tracked, not yet built): per-category collection toggles (fleet-wide and per-management-group), a kill switch for the individual-identifying drill-down, a pseudonymization mode (device IDs not resolvable to a person except by a narrow role), operator-set per-store retention exposed in the dashboard rather than via constructor argument, a dedicated
DEX:Readsecurable (today the per-device behavioral view is gated onGuaranteedState:Read, which also covers Guardian config reads — separating them lets a least-privilege access review grant DEX behavioral access as its own decision; the audit log per individual open is the compensating control until then), and a targeted per-subject deletion (DSAR) path (bulk TTL alone does not satisfy a subject-access erasure request in GDPR-adjacent jurisdictions;detail_jsonon the source events would support a future backfill/erase script).
3.6 Workstream F — Secure SDLC and Change Management
Target State
- Every production change is traceable, reviewed, tested, and auditable.
Required Features / Controls
- Branch protection and mandatory code review.
- Required CI checks (build/test/security scans) before merge.
- Change ticket linkage for release-impacting modifications.
- Release checklist with rollback strategy and approvals.
- Segregation between development, staging, and production environments.
Evidence
- PR review records, CI logs, deployment approvals, and release notes/change tickets.
Addendum — the gateway Common Test CI check reported success while executing nothing,
2026-05-13 to #4800 (CC8.1/CC7.2). Recorded here because this section cites “Required
CI checks … before merge” and “CI logs” as change-management evidence, and one of those
checks did not operate for that period. When the gateway’s Common Test suites moved into
gateway/apps/yuzu_gw/test/ct/ (335670056, 2026-05-13), the Meson gateway ct test
kept invoking rebar3 ct without that directory, so it discovered zero suites, printed
All 0 tests passed. and passed on every CI leg that ran it. CI logs from that window show
gateway ct … OK with no Common Test case executed, so they are not evidence that the
gateway’s integration, end-to-end, metrics or performance suites passed. Two real defects
shipped behind it on dev (#4707, the gateway /metrics endpoint returning HTTP 500, and
#4708, a stale test). #4707 reached tagged releases: it was in 0.14.0-rc1 through rc3 and
was fixed by the hotfix #5177/#5199. #4708 is a test-only defect.
The gateway’s EUnit check was unaffected and did run.
#4800 closes it: the check now runs the suites on the Linux, Windows and macOS legs (52
Common Test cases were measured on Linux and on Windows on 2026-09-23; the macOS count
was not measured), and the wrapper fails any run that exits 0 without executing a test. /test’s EUnit gate
and the release workflow’s EUnit step were brought under the same rule. A hermetic
self-test (tests/test_gateway_test_summary.py) pins the directory and the rule. The same
change closes a second, older gap (#4841): the macOS CI leg never had Erlang, so Meson
silently skipped the gateway there and the leg passed without building or testing it. The
macOS leg now installs Erlang/OTP 28, and every CI leg that runs meson test (ci.yml, nightly, sanitizer-tests, fork-dynamic-review.yml) checks, right after
configure, that the gateway tests were registered (scripts/ci/assert-gateway-tests.py),
so a runner missing the gateway toolchain fails instead of passing without the gateway.
3.7 Workstream G — Customer Assurance Package (Enterprise Sales Enablement)
Deliverables
- Security whitepaper and architecture overview.
- Standard CAIQ-style questionnaire responses.
- Pen-test executive summary and remediation statement.
- DPA/security addendum templates.
- Shared responsibility matrix (vendor vs customer responsibilities).
- Forward pointer: the MCP surface gains a session concept (in-memory only, principal-bound ≥128-bit ids, TTL/caps, revocation cuts live streams) via ADR-1005 execution-plan Decision 15 / track 2f — fold its security pre-commitments into the questionnaire/whitepaper once 2f ships.
- Forward pointer: track 2g (ADR-1005 Decision 16,
docs/agentic-first-principle.mdA5) delivers a near-fully self-describing MCP tool/resource surface — typed schemas, truthful safety annotations, a handshake orientation blob, and OpenAPI/scope-DSL specs discoverable viaresources/listrather than tool-by-tool, with one narrow tracked residual (streamed-final fallback envelope, #2990 —retry_after_msclosed 2026-08 by #3344). Cite this in the security whitepaper / CAIQ responses as evidence of a machine-verifiable, not merely documented, agentic contract — a differentiator against peers whose AI integrations (if any) are undocumented or prose-only; disclose the residuals rather than overclaiming completeness to a technical reviewer. - Forward pointer: the plugin README standard (
docs/plugin-readme-standard.md) makes each agent plugin self-describing as data — its per-OS mechanism, the privilege it runs at with a measured date, its securable/operation/risk tier, output columns and where the rows land — served atGET /api/v1/discover/plugin-docsand theyuzu://plugin-docsresource. Cite it as the per-plugin answer to “what does the agent collect, at what privilege” (theSensitivity is plugin-dependentrows above point here for the plugins that have adopted it); state the coverage honestly — two pilots today, the remaining plugins in the retrospective sweep, held by a README-existence ratchet in thedocstest suite. - Known-limitations disclosure (#3481, gov Gate 6 enterprise-readiness, 2026-08-24): two tracked, filed, non-blocking
ProductPackStoreresiduals worth surfacing in a technical security/reliability review rather than presenting only as public GH issues — #3523 (two concurrent installs of an identical product-pack bundle can cross-couple aPolicyto an unrelated pack’sFragment; no attacker required, an automation/GitOps race is sufficient) and #3524 (a large duplicate-item-id bundle drives an unbounded, synchronous compensation pass over sibling-store locks — aProductPack:Write-privileged-caller resource-exhaustion question a CAIQ-style review would reasonably ask). Both are data-integrity/availability class, not credential/RCE; neither derived BLOCKING under this repo’s severity schema. - Known-limitations disclosure (#4966): the last-Administrator guard is a recovery rule, not proof of sign-in and not an HA claim; SSO-only administrators are not counted until #5182, direct SQL is outside it, a rolling upgrade or rollback drops it, and the SCIM Group role recompute has documented limits with no shipped alert rules (tracked in #5673). State these alongside the control in the questionnaire/whitepaper rather than presenting the guard as absolute.
- Known-limitations disclosure (#5692): state these alongside the OIDC SSO control in a questionnaire.
- Signing keys are fetched afresh for every sign-in and 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 no sign-in while the JWKS endpoint is down; an IdP that rate-limits
jwks_uri(429/5xx) fails that sign-in with no retry, and under--auth-mode=sso-onlyan outage locks out every operator without an armed break-glass account. A token naming akidthe key set lacks is refused; a token with nokidis checked against each usable signing key in turn (at most the first 16, in the order published). An RSA signing key whose modulus is under 2048 bits, or whose public exponent is below 3, even or over 32 bits, is skipped when the key set is parsed, so such a key cannot verify a token and an IdP that signs with one cannot sign anyone in; under--auth-mode=sso-onlythat locks out every operator without an armed break-glass account, and the skip is recorded in the server log only (no metric, no audit row; the failed sign-in itself is the genericauth.oidc_login_failedrow). These checks are defence in depth against an IdP publishing a weak or degenerate key; they do not defend a tampered or hostile JWKS, whose publisher can serve a key of their own that passes them, and no setting lowers them. JWKS fetches are not single-flight (a burst makes N parallel fetches; #1900, #5983). - A discovery failure at
/auth/oidc/startis logged only: no audit row and no counter (#5704). A callback’s discovery or JWKS failure shares the generic failure row and counter. - Discovery and JWKS requests have a total bound of 2 x the per-operation timeout (10 s by default), including the TLS handshake. The token-exchange POST has per-operation timeouts only (Windows: WinHTTP defaults) and no total bound. A SIGTERM during a stalled token POST ends in a 15 s wait and a forced exit.
- A Settings save does not wait for sign-ins: they take the OIDC lock only to pick up the current provider and copy their settings. A sign-in whose exchange finishes after a save replaced the provider is refused (audit
auth.oidc_login_failed, reasonprovider_replaced) before any group, role or session work, and the callback checks again right after the session is minted, deleting that session if a save replaced the provider in between (auditreason=provider_replaced;post_mint=true), so no usable session is created under the replaced admin group or issuer once the save has returned; a save that lands after the second check is ordered after that sign-in. A session that is already live is not revoked by a save (revoke it withDELETE /api/v1/sessions?username=oidc:<iss>#<sub>or Settings > User Management > Revoke sessions; a restart does not end sessions). Any save rebuilds the provider, so every sign-in in flight, and every flow started before the save, must start again; the new provider takes effect in the process that handled the save, and another replica picks up the stored settings at its next restart. A replaced provider that still had a request is destroyed at the next Settings save or at server stop; beyond 16 such providers (token POSTs have no total bound, #5983; the database work after the exchange is bounded by the statement and pool timeouts) a save’s reply waits for the oldest. A sign-in holds one HTTP worker for up to about 10 s on the shared pool while its JWKS request runs. - The egress policy decodes every RFC 6052 NAT64 layout for prefixes it learns from RFC 7050 (
ipv4only.arpa, re-queried after 10 minutes; learned prefixes are only added, never forgotten, at most 64, and an answer past that refuses the fetch) or from--oidc-nat64-prefix(trusted to describe a translator), and classifies such an address by the IPv4 address embedded in it. Teredo, IPv6 benchmarking and the other non-global parts of2001::/23are refused unless the private-network switch is set (the registry’s globally reachable sub-ranges excepted). On Windows a name lookup the resolver does not finish even after cancellation is held in a bounded ledger (16) and freed on completion, and further lookups fail closed while it is full. What remains is translators the server cannot see (a prefix neither discoverable nor configured, a proxy, a VPN, CLAT/464XLAT outside the host); an address such a path maps to a private or link-local destination can classify as public. TLS verification still uses the host name, so overhttpssuch a path is limited to a blind TCP connect, and plainhttpto a public-looking address is refused by the cleartext rule. - No HTTP proxy on the discovery and JWKS path. An
httpsOIDC sign-in on a Windows server is unverified. There is no operator override of the discovered endpoints, so an IdP that does not serve its discovery document cannot sign in. - In the callback handler, the
OIDC login deniedand group-provisioning log lines print the principal as received, the provisioning-failure log line prints the raw database error text, and the provisioning-failure audit detail carries that text inreason=; the audit detail sanitizer neutralizes control characters,;and=but not NEL, U+2028 or U+2029; and the SAML sign-in log lines print the principal, display name and e-mail as received (tracked in #5704).
- Signing keys are fetched afresh for every sign-in and 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 no sign-in while the JWKS endpoint is down; an IdP that rate-limits
- Forward pointer: Reflex (agent-local Spark-bound automated response,
docs/reflex-design.md, ADR-0021,docs/roadmap.mdPhase 20) is design-only as of 2026-09-17 — zero code. Once built it is a CC6/CC8-relevant control (digest-bound two-person deploy approval on any dangerous Reaction) and a CC7.2 evidence source (thereflex_outcomesjournal, subject to its own stated evidentiary retention floor). Cite it in the whitepaper/questionnaire only once shipped; this bullet exists so the design-stage capability is not silently absent from Workstream G’s forward-looking inventory. - Forward pointer: enrollment tokens and the pending-agent queue are now durable across restarts and shared fleet-wide via a single Postgres-authoritative store, with atomic race-safe token consumption (HA WS-6 6.2, ADR-2002) — for whoever eventually writes the customer-facing whitepaper/CAIQ, this closes a previously implicit per-replica-state gap in the availability/consistency story for agent enrollment.
First-Customer Readiness Milestones
- Technical due-diligence packet ready.
- Security review Q&A runbook and owners assigned.
- Pilot onboarding playbook (networking, certs, SSO, agent rollout).
- Executive escalation path and customer success governance cadence.
4) SOC 2 Type II Control Implementation Backlog
4.1 Priority 0 (0–30 days)
- Finalize SOC 2 scope, system boundaries, and control owners.
- Ratify core policies and approval workflows.
- Establish evidence repository structure and naming conventions.
- Enforce mandatory review + CI gates on all production-bound code.
- Define incident severity model and notification SLAs.
4.2 Priority 1 (31–90 days)
- Implement MFA/step-up auth for privileged approvals.
- Complete security headers and hardened deployment baseline.
- Integrate vulnerability scanning with remediation SLA tracking.
- Run first tabletop incident response exercise.
- Execute first documented backup restore drill.
4.3 Priority 2 (91–180 days)
- Run operational metrics program against formal SLOs.
- Complete external penetration test and close critical findings.
- Validate high-scale customer deployment architecture.
- Launch customer trust center artifacts and standard security packet.
4.4 Priority 3 (Audit window)
- Freeze control design changes (unless risk-critical).
- Monitor control performance and evidence completeness weekly.
- Perform internal pre-audit and remediation sprint.
- Complete audit period and respond to auditor requests quickly.
5) First Enterprise Customer Plan (Go-to-Production)
5.1 Pre-Sales to Contract
- Complete security questionnaire and architecture deep dive.
- Confirm mandatory integrations (IdP, SIEM, ticketing, endpoint rollout tooling).
- Agree shared security responsibility and data-handling terms.
- Define commercial and operational SLAs.
5.2 Pilot Phase
- Deploy to non-production tenant/environment.
- Validate SSO, RBAC, logging, and approval flows end-to-end.
- Run controlled command/compliance/policy workflows at target scale subset.
- Joint success criteria with customer IT/SecOps leads.
5.3 Production Rollout
- Phased rollout with rollback checkpoints.
- Daily health reviews during initial launch window.
- Weekly governance meeting (engineering + security + customer success).
- 30/60/90-day value and risk review.
6) Metrics and Readiness Scorecard
6.1 Control Health KPIs
- % controls implemented vs planned.
- % controls with automated evidence.
- % evidence artifacts generated on schedule.
-
open high/critical audit readiness gaps.
6.2 Security and Reliability KPIs
- Mean time to remediate critical vulnerabilities.
- MFA adoption coverage for privileged users.
- Incident MTTR and recurrence rate.
- SLO attainment (%).
6.3 Enterprise Customer KPIs
- Security review turnaround time.
- Time to pilot completion.
- Time to production acceptance.
-
post-launch escalations in first 90 days.
7) RACI (Condensed)
- Security Lead: Control design, risk register, audit coordination.
- Engineering Lead: Feature/control implementation, SDLC controls, reliability.
- DevOps/SRE: Infrastructure hardening, observability, DR drills, on-call.
- Product: Prioritization and customer requirement alignment.
- Customer Success / Sales Engineering: Enterprise onboarding execution and stakeholder management.
- Legal/Finance: Contractual security terms and compliance vendor management.
8) Immediate Next 10 Actions
- Nominate control owners and publish SOC 2 scope memo.
- Open an “Enterprise Readiness” epic with workstream sub-epics.
- Baseline current control coverage against SOC 2 criteria.
- Define production-hardening profile and default secure configuration.
- Implement privileged-action MFA requirement.
- Stand up evidence automation jobs (access reviews, CI outputs, backups, vulnerability reports).
- Author incident response and customer notification playbooks.
- Schedule first tabletop + restore drill.
- Prepare standard security questionnaire responses and architecture packet.
- Select target design-partner customer and execute a controlled pilot plan.
9) Appendix — Suggested Artifacts to Maintain
- SOC 2 control matrix and evidence index.
- Network/data-flow diagrams and trust boundaries.
- Secure configuration baseline (server/agent/gateway).
- Incident response runbook + communications templates.
- Backup/restore runbook and drill history.
- Access review records and privileged access logs.
- Change management and release approval records.
- Vulnerability management reports and remediation logs.