yuzu

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:

  1. Pass a SOC 2 Type II audit window.
  2. 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

2.2 First Enterprise Customer Outcomes


3) Strategic Workstreams

3.1 Workstream A — Governance, Risk, and Compliance (GRC)

Deliverables

Implementation Steps

  1. Define audit boundary and in-scope systems.
  2. Select GRC tooling (or structured repo + ticket workflow if lightweight).
  3. Appoint control owners (Engineering, Security, IT, People, Legal).
  4. Establish monthly control health reviews.

3.2 Workstream B — Identity, Access, and Administrative Security

Target State

Required Features / Controls

Evidence

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:

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

Required Features / Controls

Evidence


3.4 Workstream D — Reliability, Availability, and Operational Readiness

Target State

Required Features / Controls

Evidence


3.5 Workstream E — Data Governance and Privacy

Target State

Required Features / Controls

Evidence

Data Inventory — server-side SQLite stores

StoreFileData classRetentionDeletion mechanismConfigurable 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 execution365 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 storeresponse_store (responses, response_facets)Agent command/instruction results90 daysMIGRATED TO POSTGRESQL (ADR-0039) — see the PostgreSQL-stores table below for the current mechanism. Historical: ResponseStore cleanup thread (TTL at insert), not clock-guardedresponse_retention_days
Guaranteed-state rulesguaranteed-state.db (guaranteed_state_rules)Rule definitions (configuration)Indefinite — lifecycle via explicit deleteMIGRATED TO POSTGRESQL (ADR-0038) — see the PostgreSQL-stores table below for the current mechanism. Historical: REST DELETE / delete_rulen/a
Guaranteed-state eventsguaranteed-state.db (guaranteed_state_events)Drift/remediation telemetry (high-volume operational)30 days defaultMIGRATED 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 guardguardian_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 rowMIGRATED 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 < nowguardian_event_retention_days (one knob governs both — by design, so event and projection retention cannot diverge)
PKI cert inventoryca_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 auditMIGRATED 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 / JSONLTelemetry + usage, once drainedCustomer-controlled (external sink)Sink-side retentionclickhouse_*, analytics_jsonl_path
Analytics events (server-side buffer)was per-process SQLite analytics.dbTelemetry + usage, pre-drainn/aMIGRATED 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 cacheFleetTopologyStore (in-memory)Aggregated echo.fleet_snapshot topology — per-machine process records (pid, ppid, process name, OS user, category) and connection edges60 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.

StoreSchema (PostgreSQL)Data classRetentionDeletion mechanismConfigurable 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 execution365 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 inventoryca_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 observationsguaranteed_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 inventoryinventory_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 inventorysoftware_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) inventorydevice_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 stateoffline_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 resultspreflight_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 statedeployment_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 trackerexecution_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 compliancepolicy_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 inventorysoftware_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 inventoryapp_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 inventorycertificate_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 storeresponse_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 configmanagement_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 devicesdiscovery_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 feednotification_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 configurationwebhook_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 overridesruntime_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 configurationoffload_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 layoutscrypto_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 deprovisionedErased 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 outboxanalytics_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 evidencerows 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 tableTime-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/appliedRecommendationStore::run_cleanup thread (planned) — DELETE … WHERE status IN ('dismissed','applied') AND closed_at < now - retentionrecommendation_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 JSON7 days defaultTTL at insert; opportunistic eviction on lookupvirustotal_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.

StoreSchema (PostgreSQL)Data classRetentionDeletion mechanismConfigurable 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 bare DELETE ... WHERE ttl_expires_at < now driven 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 / tierTableData classDefaultRetentionConfigurable via
Device performance$Perf_Live / $Perf_HourlyDevice-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 hourlyperf_enabled, perf_interval_seconds
Per-application performance$ProcPerf_Live / $ProcPerf_HourlyUsage-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 hourlyprocperf_enabled, perf_interval_seconds
Process activity$Process_Live / $Process_Hourly / _Daily / _MonthlyBehavioral 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_MonthlyAsset-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 monthlysoftware_enabled, software_interval
DNS resolver cache (ADR-0015)$DNS_Live / $DNS_HourlyUsage-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 hourlydns_enabled (Windows-only today; Linux/macOS planned)
ARP / neighbour table (ADR-0015)$ARP_Live / $ARP_HourlyNetwork 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 hourlyarp_enabled (Windows and Linux full; macOS constrained)
Per-connection TCP quality (ADR-0020)$NetQual_Live / $NetQual_BootUsage-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_LiveUsage-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 capnetconn_enabled, netconn_lookback_seconds (retroactive reach / off)
Power transitions (Wave 6)$Power_LiveUsage-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 cappower_enabled, power_lookback_seconds
Removable media (Wave 6)$Removable_LiveUsage/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 capremovable_enabled, removable_lookback_seconds
App usage (Wave 7)$Usage_Live / $Usage_DailyUsage-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 backstopusage_enabled
Boot-window process traceprocboot.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 scriptCircular 16 MB file (not retention_days-governed); removed on uninstall (installer [UninstallRun] + dev script), which also stops the running sessionprocess_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:

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.

NamespaceData classDefaultRetentionDeletion mechanismConfigurable 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.

SourceData classWhere it livesRetentionDeletion 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 usernameNot stored on the agent between runs — computed fresh per invocation, streamed to the callerNone 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 callerNone 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 callerNone 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 callerNone 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 callerNone 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 callerNone 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:


3.6 Workstream F — Secure SDLC and Change Management

Target State

Required Features / Controls

Evidence

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

First-Customer Readiness Milestones

  1. Technical due-diligence packet ready.
  2. Security review Q&A runbook and owners assigned.
  3. Pilot onboarding playbook (networking, certs, SSO, agent rollout).
  4. Executive escalation path and customer success governance cadence.

4) SOC 2 Type II Control Implementation Backlog

4.1 Priority 0 (0–30 days)

4.2 Priority 1 (31–90 days)

4.3 Priority 2 (91–180 days)

4.4 Priority 3 (Audit window)


5) First Enterprise Customer Plan (Go-to-Production)

5.1 Pre-Sales to Contract

5.2 Pilot Phase

5.3 Production Rollout


6) Metrics and Readiness Scorecard

6.1 Control Health KPIs

6.2 Security and Reliability KPIs

6.3 Enterprise Customer KPIs


7) RACI (Condensed)


8) Immediate Next 10 Actions

  1. Nominate control owners and publish SOC 2 scope memo.
  2. Open an “Enterprise Readiness” epic with workstream sub-epics.
  3. Baseline current control coverage against SOC 2 criteria.
  4. Define production-hardening profile and default secure configuration.
  5. Implement privileged-action MFA requirement.
  6. Stand up evidence automation jobs (access reviews, CI outputs, backups, vulnerability reports).
  7. Author incident response and customer notification playbooks.
  8. Schedule first tabletop + restore drill.
  9. Prepare standard security questionnaire responses and architecture packet.
  10. Select target design-partner customer and execute a controlled pilot plan.

9) Appendix — Suggested Artifacts to Maintain