Skip to content

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.

ClaimProved by
A valid bundle is acceptedbundle::tests::valid_bundle_is_accepted
A tampered payload is rejectedbundle::tests::tampered_payload_is_rejected
An unknown signing key is rejectedbundle::tests::unknown_signing_key_is_rejected
The envelope's key id must match the signed payloadbundle::tests::envelope_key_id_must_match_signed_payload
A non-Ed25519 algorithm is refused rather than attemptedbundle::tests::non_ed25519_algorithm_is_refused
A bundle for another tenant, device or group is refusedbundle::tests::wrong_tenant_is_rejected, …::bundle_for_another_device_is_refused, …::bundle_for_another_group_is_refused
An expired bundle is refusedbundle::tests::expired_bundle_is_refused
A future-dated bundle beyond the skew tolerance is refusedbundle::tests::future_dated_bundle_beyond_skew_is_refused
A downgrade is refusedbundle::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.

ClaimProved by
A cached bundle reloads across a restartpolicy_runtime::tests::a_cached_bundle_reloads_across_a_restart
A tampered cache file is not loadedpolicy_runtime::tests::a_tampered_cache_file_is_not_loaded
A missing cache is an empty runtime, not an errorpolicy_runtime::tests::a_missing_cache_is_an_empty_runtime_not_an_error
Accepting a bundle raises the anti-rollback floor, and the floor is persistedpolicy_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.

ClaimProved by
The overlap accepts a bundle signed by the new keytrust_set::tests::overlap_accepts_bundle_signed_by_new_key
Removing the old key rejects an old-key bundletrust_set::tests::removal_rejects_bundle_signed_by_old_key
An empty set is rejectedtrust_set::tests::empty_trust_set_is_rejected
A retired signer is rejectedtrust_set::tests::trust_set_signed_by_retired_key_is_rejected
A trust-set rollback is rejectedtrust_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.

ClaimState
A cached bundle survives a restartTested
A cached bundle survives the server being unreachableTested - nothing in the load path contacts the server
An endpoint in contact never holds an expired bundleYes, the server re-signs at half-life (DLP-1232, tests/bundle_renewal.sh)
A cached bundle survives longer than its TTLYes, 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:

ClaimProved by
A detector that will not compile is dropped, not silently counteddetector_runtime::tests::a_detector_that_will_not_compile_is_dropped_and_reported
The error names the detector, so it can be fixedas above
The detectors that did compile still rundetector_runtime::tests::the_detectors_that_did_compile_still_run
Compile errors degrade the endpoint's health, so the server sees itrunner.rs health() - see §4
Every bundled detector compiles on the endpointdetector_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 stateHealthWarning
No verified bundleImpairedno verified policy bundle; nothing is being enforced
Bundle expired while runningDegradedthe cached policy bundle has expired
Some detectors would not compileDegradedN 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.rs by inspection. Testing them needs a constructed Agent, 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 transport and 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-005 and 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.

Statements here describe what is built and tested; planned work is marked as planned.