Appearance
Policy synchronisation, claim by claim
WIN-DLP-EPIC-004 asks for three things - fetch, verify and cache signed bundles; survive an offline period; never partially activate - and gives the reason to hold them to: a policy that half-applied is worse than no policy: it is an enforcement gap nobody can see.
Two of the three are built and well tested. The middle one has a cliff in it, and the cliff is seven days wide.
Implementation: agent/core/src/policy_runtime.rs for the endpoint, packages/crypto/src/bundle.rs for verification.
1. Fetch, verify and cache
Verification is a single function with an explicit acceptance context - tenant, device, group, anti-rollback floor, clock and skew tolerance - so every reason to refuse a bundle is in one place and testable without a server.
| Claim | Proved by |
|---|---|
| A valid bundle is accepted | bundle::tests::valid_bundle_is_accepted |
| A tampered payload is rejected | bundle::tests::tampered_payload_is_rejected |
| An unknown signing key is rejected | bundle::tests::unknown_signing_key_is_rejected |
| The envelope's key id must match the signed payload | bundle::tests::envelope_key_id_must_match_signed_payload |
| A non-Ed25519 algorithm is refused rather than attempted | bundle::tests::non_ed25519_algorithm_is_refused |
| A bundle for another tenant, device or group is refused | bundle::tests::wrong_tenant_is_rejected, …::bundle_for_another_device_is_refused, …::bundle_for_another_group_is_refused |
| An expired bundle is refused | bundle::tests::expired_bundle_is_refused |
| A future-dated bundle beyond the skew tolerance is refused | bundle::tests::future_dated_bundle_beyond_skew_is_refused |
| A downgrade is refused | bundle::tests::downgrade_is_refused, and policy_runtime::tests::a_downgraded_bundle_is_refused_and_the_current_one_survives |
The anti-rollback floor is the one worth two tests. Refusing the old bundle is half the requirement; the bundle in force surviving the attempt is the other half, and an implementation could pass the first while clearing itself on the way to failing.
The cache is written temp-then-rename, and only after the bundle has verified - so an interrupted write cannot leave a half-file where a bundle should be, and a bundle that does not verify is never persisted in the first place.
| Claim | Proved by |
|---|---|
| A cached bundle reloads across a restart | policy_runtime::tests::a_cached_bundle_reloads_across_a_restart |
| A tampered cache file is not loaded | policy_runtime::tests::a_tampered_cache_file_is_not_loaded |
| A missing cache is an empty runtime, not an error | policy_runtime::tests::a_missing_cache_is_an_empty_runtime_not_an_error |
| Accepting a bundle raises the anti-rollback floor, and the floor is persisted | policy_runtime::tests::a_valid_bundle_is_accepted_and_raises_the_floor; runner.rs saves the identity immediately after accept |
Signing-key rotation
The enrollment response establishes the initial policy verification key under the one-time enrollment credential. Every later replacement is a signed, monotonically versioned trust set delivered by check-in and persisted before a new bundle is fetched. Rotation is staged: the old key signs {old,new}, then the new key signs {new} after endpoints have observed the overlap. There is no unsigned update path and no direct old-to-new jump.
| Claim | Proved by |
|---|---|
| The overlap accepts a bundle signed by the new key | trust_set::tests::overlap_accepts_bundle_signed_by_new_key |
| Removing the old key rejects an old-key bundle | trust_set::tests::removal_rejects_bundle_signed_by_old_key |
| An empty set is rejected | trust_set::tests::empty_trust_set_is_rejected |
| A retired signer is rejected | trust_set::tests::trust_set_signed_by_retired_key_is_rejected |
| A trust-set rollback is rejected | trust_set::tests::trust_set_rollback_is_rejected |
The server stores the last published set and its revision in policy_trust_sets. Additions, revocations and expiry each advance the revision once. A server database restored behind the version an endpoint reports is treated as a conflict and never as permission to lower trust.
2. Survive an offline period
An endpoint offline for longer than the bundle's lifetime keeps enforcing what it last verified, and says it is stale.
DLP_BUNDLE_TTL_HOURS defaults to 168 - seven days. A cached bundle whose signature still verifies is loaded at any age (DLP-262, ADR-0018): past its expiry the endpoint reports Degraded with the reason and marks every incident evaluated_with_stale_policy, rather than discarding the rules and protecting nothing. policy_runtime::tests::an_endpoint_offline_past_the_bundle_ttl_keeps_enforcing_and_says_it_is_stale pins it.
The server re-signs before a bundle expires (DLP-1232). A bundle used to be signed only when something changed, so a tenant quiet for a week had no current bundle: endpoints in contact were told nothing newer existed and aged into staleness, and a newly enrolled endpoint was offered no policy at all and reported no verified policy bundle; nothing is being enforced. That was found in production on 2026-09-28. A background pass now re-signs each tenant's newest bundle once less than half its lifetime remains (every DLP_BUNDLE_RENEWAL_TICK_SECS, an hour by default, and once at start), so an endpoint in contact never holds an expired bundle. Staleness is now what being offline looks like, and nothing else. tests/bundle_renewal.sh asserts it.
| Claim | State |
|---|---|
| A cached bundle survives a restart | Tested |
| A cached bundle survives the server being unreachable | Tested - nothing in the load path contacts the server |
| An endpoint in contact never holds an expired bundle | Yes, the server re-signs at half-life (DLP-1232, tests/bundle_renewal.sh) |
| A cached bundle survives longer than its TTL | Yes, enforced and reported stale (DLP-262) |
3. Never partially activate
Two different mechanisms, and it is worth knowing which does what.
The bundle itself is all-or-nothing. It verifies completely before anything is written; the cache is replaced by a rename; the anti-rollback floor and the in-memory payload move together. There is no state in which half a bundle is in force.
A detector inside it can still fail to compile, and that is a partial activation - the policy referencing it is live while the detector it names is not. Refusing the whole bundle for one bad detector would be worse: one authoring mistake would disarm the fleet. So the requirement is met the other way, by making the gap impossible to miss:
| Claim | Proved by |
|---|---|
| A detector that will not compile is dropped, not silently counted | detector_runtime::tests::a_detector_that_will_not_compile_is_dropped_and_reported |
| The error names the detector, so it can be fixed | as above |
| The detectors that did compile still run | detector_runtime::tests::the_detectors_that_did_compile_still_run |
| Compile errors degrade the endpoint's health, so the server sees it | runner.rs health() - see §4 |
| Every bundled detector compiles on the endpoint | detector_runtime::tests::every_bundled_detector_compiles_on_the_endpoint |
The first two of those are new (DLP-118). The mechanism existed and nothing exercised it, which for a requirement phrased as "an enforcement gap nobody can see" is the one place a test was most worth having.
4. What the server is told
| Endpoint state | Health | Warning |
|---|---|---|
| No verified bundle | Impaired | no verified policy bundle; nothing is being enforced |
| Bundle expired while running | Degraded | the cached policy bundle has expired |
| Some detectors would not compile | Degraded | N detector(s) in the bundle failed to compile |
5. What is not covered
- The health mapping itself has no test. The three rows above are read from
runner.rsby inspection. Testing them needs a constructedAgent, which is heavier than the assertion is worth; the inputs to each rule are tested individually. Recorded here rather than implied. - Bundle fetch over the wire - retry, backoff, a server that answers 304 - is
transportand is tested there (backoff_grows_then_saturates_at_the_maximum,a_hostile_retry_after_cannot_park_the_agent_indefinitely), not re-audited here. - Policy evaluation is
WIN-DLP-EPIC-005and is a different document. - No Windows run. These are portable code paths with no
cfg(windows)branch, so the Linux run exercises the same lines; that is an argument, not a measurement.