phase 1.6: ops dashboard + consent_versions allowlist + subject timeline tool

Closes the afternoon's "all four" wave (per J's request to do all the
items in one pass instead of pick-one-of-options):

(1) Live demo on WORKER-100 — full lifecycle exercised end-to-end
    against the running gateway. 3 audit rows landed in correct
    order (consent_grant → biometric_collection →
    consent_withdrawal), chain_verified=true, photo on disk at
    data/biometric/uploads/WORKER-100/1778011967957907731_027b6bb1.jpg
    (180 bytes JFIF). retention_until=2026-06-04 (30d from
    withdrawal per consent template v1 §2).

(2) GET /biometric/stats — read-only aggregate over all subjects.
    Returns counts by biometric.status + subject.status, photo
    count, oldest_active_retention_until, and the last 20
    state-change events (consent_grant / collection / withdrawal /
    erasure — validator_lookup and other noise filtered out).
    Walks per-subject audit logs via the existing writer; cheap
    for 100 subjects, would want an event-stream index at 100k.
    Legal-tier auth (same posture as /audit). 4 unit tests.

(3) /biometric/dashboard mcp-server frontend. Auto-refreshes
    /biometric/stats every 15s, neo-brutalist tile layout for
    the per-status counts + retention horizon block + recent
    events table with kind badges + event-kind breakdown pills.
    sessionStorage-backed token; logout button clears state.
    DOM-built throughout (textContent + createElement) — never
    innerHTML on audit-row values, since trace_id et al. could
    in theory carry operator-supplied strings.

(4) consent_versions allowlist. BiometricEndpointState gains
    `allowed_consent_versions: Option<Arc<HashSet<String>>>`,
    loaded at startup from /etc/lakehouse/consent_versions.json
    (override via LH_CONSENT_VERSIONS_FILE). process_consent
    refuses unknown hashes with HTTP 400 consent_version_unknown
    when configured. Resolution semantics:
      - Missing file → permissive (v1 compat, warn-log)
      - Parse error → permissive (error-log; broken config
        silently going strict would be worse)
      - Empty array → strict, refuse all (deliberate freeze
        mode for "counsel hasn't signed v1 yet")
      - Populated → strict, lowercase-normalized comparison
    5 unit tests (known/unknown/case/empty/none-permissive).
    Example template at ops/consent_versions.example.json with
    a counsel-tier deployment note.

(5) scripts/staffing/subject_timeline.sh — operator one-shot
    pretty-print of any subject's full BIPA lifecycle. Curls
    /audit/subject/{id} with legal token; renders manifest
    summary + on-disk photo state + chronological audit chain
    with kind badges + chain verification status. Smoke-tested
    on WORKER-100 (3 rows verified).

(6) STATE_OF_PLAY.md refresh. New section "afternoon wave"
    captures all four commits (76cb5ac, 7f0f500, 68d226c, this
    one) + the live demo evidence + the v1 endpoint matrix +
    UI/CLI inventory + the production-cutover blocking set
    (counsel calendar only — eng substrate is done).

Verified live post-restart:
- /audit/health + /biometric/health both 200
- /biometric/stats returns 100 subjects, 2 withdrawn (WORKER-2 from
  earlier scrum + WORKER-100 from today's demo), 1 photo on record,
  6 recent state-change events
- /biometric/intake + /biometric/withdraw + /biometric/dashboard
  all 200 on mcp-server :3700
- subject_timeline.sh on WORKER-100: chain_verified=true,
  chain_root=a47563ff937d50de…
- 88/88 catalogd lib tests + 55/55 biometric_endpoint tests green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-05 15:27:52 -05:00
co-authored by Claude Opus 4.7
parent 68d226c314
commit 87b034f5f9
7 changed files with 1070 additions and 5 deletions
+479 -1
View File
@@ -69,6 +69,22 @@ pub struct BiometricEndpointState {
/// Default: `<repo>/data/biometric/uploads`. Set per host via
/// LH_BIOMETRIC_STORAGE_ROOT env var.
pub storage_root: PathBuf,
/// Optional allowlist of accepted `consent_version_hash` values.
/// `None` = permissive (any non-empty hash accepted, the v1
/// behavior). `Some(set)` = strict (refuse anything not in the
/// set). Loaded from a JSON config file at startup if one exists.
/// Counsel-tier production deployments should populate this so
/// operator typos / stale templates / drift can't silently land
/// invalid consent records.
pub allowed_consent_versions: Option<Arc<std::collections::HashSet<String>>>,
}
#[derive(serde::Deserialize)]
struct ConsentVersionsConfig {
/// Array of accepted SHA-256 hash strings. Each must be 64 hex
/// chars (lowercase). Comments / metadata can ride on the JSON
/// object itself; the parser only consumes "versions".
versions: Vec<String>,
}
impl BiometricEndpointState {
@@ -116,7 +132,76 @@ impl BiometricEndpointState {
let _ = std::fs::set_permissions(&storage_root, std::fs::Permissions::from_mode(0o700));
}
}
Self { registry, writer, legal_token, storage_root }
Self {
registry,
writer,
legal_token,
storage_root,
allowed_consent_versions: None,
}
}
/// Load a JSON allowlist of accepted consent_version_hash values.
/// File shape:
/// `{"versions": ["<sha256-hex>", "<sha256-hex>", ...]}`
///
/// Resolution rules:
/// - File missing or unreadable → permissive (None). Logged as
/// a warning so the operator sees the deployment is in v1
/// compatibility mode.
/// - File present but parse fails → permissive (None) + ERROR
/// log (broken config silently degrading to permissive is
/// wrong; flag loudly).
/// - File present + parses + has ≥1 entry → strict mode
/// (Some(set)). Each hash is normalized to lowercase before
/// insertion to prevent operator-case drift.
/// - File present + empty `versions` array → strict (refuse
/// all consent grants). This is "intentionally locked,
/// waiting for counsel to publish v1 hash" mode.
pub async fn with_consent_versions(mut self, path: &std::path::Path) -> Self {
match tokio::fs::read_to_string(path).await {
Ok(body) => {
match serde_json::from_str::<ConsentVersionsConfig>(&body) {
Ok(cfg) => {
let set: std::collections::HashSet<String> = cfg
.versions
.into_iter()
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
.collect();
tracing::info!(
"biometric endpoint: consent_versions allowlist loaded from {} ({} entries)",
path.display(), set.len()
);
self.allowed_consent_versions = Some(Arc::new(set));
}
Err(e) => {
tracing::error!(
"biometric endpoint: consent_versions file at {} failed to parse ({e}); \
running in PERMISSIVE mode (any non-empty hash accepted). \
Fix the file or remove it to silence this warning.",
path.display()
);
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::warn!(
"biometric endpoint: consent_versions allowlist not found at {} — \
running in PERMISSIVE mode (any non-empty hash accepted). \
For counsel-tier deployment, create the file with the signed v1 hash.",
path.display()
);
}
Err(e) => {
tracing::error!(
"biometric endpoint: consent_versions file at {} unreadable ({e}); \
running in PERMISSIVE mode.",
path.display()
);
}
}
self
}
}
@@ -126,6 +211,7 @@ pub fn router(state: BiometricEndpointState) -> Router {
.route("/subject/{candidate_id}/consent", post(record_consent))
.route("/subject/{candidate_id}/withdraw", post(withdraw_consent))
.route("/subject/{candidate_id}/erase", post(erase_subject))
.route("/stats", get(stats_handler))
.route("/health", get(biometric_health))
.layer(DefaultBodyLimit::max(MAX_PHOTO_BYTES))
.with_state(state)
@@ -920,6 +1006,26 @@ pub async fn process_consent(
consent_status: None,
}));
}
// Allowlist enforcement (counsel-tier deployment safety). When
// `allowed_consent_versions` is configured, refuse any hash not
// in the set. Compared lowercase to defeat operator-case drift.
// Permissive mode (None) keeps v1 compatibility for deployments
// that haven't published a signed allowlist yet.
if let Some(allow) = state.allowed_consent_versions.as_ref() {
let normalized = req.consent_version_hash.trim().to_ascii_lowercase();
if !allow.contains(&normalized) {
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
error: "consent_version_unknown",
detail: format!(
"consent_version_hash {} not in the configured allowlist; \
this gateway accepts only hashes published in /etc/lakehouse/consent_versions.json. \
Contact the deployment operator if a new template version needs to be authorized.",
normalized,
),
consent_status: None,
}));
}
}
if req.operator_of_record.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
error: "bad_request",
@@ -1342,6 +1448,193 @@ pub async fn process_withdraw(
})
}
// ─── Stats endpoint (ops dashboard backend) ──────────────────────
//
// Read-only aggregate over all subjects. Returns counts by biometric
// + subject status, total photo count, oldest active retention_until,
// and the last 20 audit events across all subjects (consent_grant /
// biometric_collection / consent_withdrawal / erasure rows). Used by
// the /biometric/dashboard ops UI page.
//
// Auth: legal-tier (same posture as /audit/subject). The aggregate
// numbers leak nothing per-subject by themselves, but the "recent
// events" list contains candidate_ids — counsel-tier protection
// is appropriate.
const STATS_RESPONSE_SCHEMA: &str = "biometric_stats_response.v1";
const STATS_RECENT_EVENTS_LIMIT: usize = 20;
#[derive(Serialize, Debug, Default)]
pub struct StatsCounts {
pub never_collected: usize,
pub pending: usize,
pub given: usize,
pub withdrawn: usize,
pub expired: usize,
}
#[derive(Serialize, Debug, Default)]
pub struct SubjectStatusCounts {
pub pending_consent: usize,
pub active: usize,
pub withdrawn: usize,
pub retention_expired: usize,
pub erased: usize,
}
#[derive(Serialize, Debug)]
pub struct StatsEvent {
pub ts: chrono::DateTime<chrono::Utc>,
pub candidate_id: String,
pub kind: String,
pub result: String,
pub trace_id: String,
}
#[derive(Serialize, Debug)]
pub struct StatsResponse {
pub schema: &'static str,
pub generated_at: chrono::DateTime<chrono::Utc>,
pub total_subjects: usize,
pub biometric_status: StatsCounts,
pub subject_status: SubjectStatusCounts,
pub photos_on_record: usize,
pub oldest_active_retention_until: Option<chrono::DateTime<chrono::Utc>>,
pub upcoming_destruction_window_days: Option<i64>,
pub recent_events: Vec<StatsEvent>,
pub recent_event_counts: std::collections::BTreeMap<String, usize>,
}
async fn stats_handler(
State(state): State<BiometricEndpointState>,
headers: HeaderMap,
) -> impl IntoResponse {
let auth_token = headers
.get(LEGAL_TOKEN_HEADER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
match process_stats(&state, auth_token.as_deref()).await {
Ok(resp) => (StatusCode::OK, Json(resp)).into_response(),
Err((status, err)) => (status, Json(err)).into_response(),
}
}
pub async fn process_stats(
state: &BiometricEndpointState,
legal_token: Option<&str>,
) -> Result<StatsResponse, (StatusCode, ErrorResponse)> {
// Auth (same posture as upload/erase/consent/withdraw).
let configured = state.legal_token.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
ErrorResponse { error: "auth_failed", detail: "no legal token configured".into(), consent_status: None },
))?;
let provided = legal_token.ok_or((
StatusCode::UNAUTHORIZED,
ErrorResponse { error: "auth_failed", detail: "missing X-Lakehouse-Legal-Token".into(), consent_status: None },
))?;
if !constant_time_eq(provided.as_bytes(), configured.as_bytes()) {
return Err((
StatusCode::UNAUTHORIZED,
ErrorResponse { error: "auth_failed", detail: "X-Lakehouse-Legal-Token mismatch".into(), consent_status: None },
));
}
use shared::types::{BiometricConsentStatus, SubjectStatus};
let now = chrono::Utc::now();
let subjects = state.registry.list_subjects().await;
let mut bio = StatsCounts::default();
let mut subj = SubjectStatusCounts::default();
let mut photos_on_record = 0usize;
let mut oldest_active_retention: Option<chrono::DateTime<chrono::Utc>> = None;
for s in &subjects {
match s.consent.biometric.status {
BiometricConsentStatus::NeverCollected => bio.never_collected += 1,
BiometricConsentStatus::Pending => bio.pending += 1,
BiometricConsentStatus::Given => bio.given += 1,
BiometricConsentStatus::Withdrawn => bio.withdrawn += 1,
BiometricConsentStatus::Expired => bio.expired += 1,
}
match s.status {
SubjectStatus::PendingConsent => subj.pending_consent += 1,
SubjectStatus::Active => subj.active += 1,
SubjectStatus::Withdrawn => subj.withdrawn += 1,
SubjectStatus::RetentionExpired => subj.retention_expired += 1,
SubjectStatus::Erased => subj.erased += 1,
}
if s.biometric_collection.is_some() {
photos_on_record += 1;
}
// Oldest-active = earliest retention_until among Given subjects
// (these are the subjects whose data is currently retained
// under canonical consent — Withdrawn subjects also have
// retention_until set but as a 30-day countdown to destruction,
// surfaced separately).
if matches!(s.consent.biometric.status, BiometricConsentStatus::Given) {
if let Some(t) = s.consent.biometric.retention_until {
oldest_active_retention = Some(match oldest_active_retention {
Some(prev) if prev < t => prev,
_ => t,
});
}
}
}
let upcoming_destruction_window_days = oldest_active_retention.map(|t| (t - now).num_days());
// Walk every subject's audit log; collect erasure / consent /
// upload / withdrawal rows. Sort by ts desc, keep first
// STATS_RECENT_EVENTS_LIMIT. For 100 subjects this is cheap;
// future-proofing against 100k would want a separate event-stream
// index, out of scope for v1.
let mut events: Vec<StatsEvent> = Vec::new();
let mut event_counts: std::collections::BTreeMap<String, usize> = Default::default();
for s in &subjects {
let rows = state
.writer
.read_rows_in_range(&s.candidate_id, None, None)
.await
.unwrap_or_default();
for r in rows {
// Filter: only state-changing events (skip validator_lookup
// / gateway_lookup / etc. — they're noise for an ops dash).
match r.accessor.kind.as_str() {
"biometric_consent_grant"
| "biometric_collection"
| "biometric_consent_withdrawal"
| "biometric_erasure"
| "full_erasure" => {}
_ => continue,
}
*event_counts.entry(r.accessor.kind.clone()).or_insert(0) += 1;
events.push(StatsEvent {
ts: r.ts,
candidate_id: r.candidate_id.clone(),
kind: r.accessor.kind.clone(),
result: r.result.clone(),
trace_id: r.accessor.trace_id.clone(),
});
}
}
events.sort_by(|a, b| b.ts.cmp(&a.ts));
events.truncate(STATS_RECENT_EVENTS_LIMIT);
Ok(StatsResponse {
schema: STATS_RESPONSE_SCHEMA,
generated_at: now,
total_subjects: subjects.len(),
biometric_status: bio,
subject_status: subj,
photos_on_record,
oldest_active_retention_until: oldest_active_retention,
upcoming_destruction_window_days,
recent_events: events,
recent_event_counts: event_counts,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1377,6 +1670,7 @@ mod tests {
writer,
legal_token: Some(Arc::new(TEST_TOKEN.into())),
storage_root,
allowed_consent_versions: None,
}
}
@@ -2249,4 +2543,188 @@ mod tests {
// Withdraw row chains off the grant's hmac.
assert_eq!(rows[1].prev_chain_hash, grant.audit_row_hmac);
}
// ─── Stats endpoint tests ──────────────────────────────────────
#[tokio::test]
async fn stats_missing_token_rejected() {
let state = fixture_state("stats_no_token").await;
let err = process_stats(&state, None).await.unwrap_err();
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn stats_aggregates_subjects_by_status() {
let state = fixture_state("stats_aggregate").await;
// Plant a mix of states.
for (id, bs, ss) in [
("WORKER-S1", BiometricConsentStatus::NeverCollected, SubjectStatus::Active),
("WORKER-S2", BiometricConsentStatus::NeverCollected, SubjectStatus::Active),
("WORKER-S3", BiometricConsentStatus::Given, SubjectStatus::Active),
("WORKER-S4", BiometricConsentStatus::Given, SubjectStatus::Active),
("WORKER-S5", BiometricConsentStatus::Given, SubjectStatus::Active),
("WORKER-S6", BiometricConsentStatus::Withdrawn, SubjectStatus::Active),
("WORKER-S7", BiometricConsentStatus::Pending, SubjectStatus::PendingConsent),
("WORKER-S8", BiometricConsentStatus::Expired, SubjectStatus::Erased),
] {
let _ = state.registry.put_subject(fixture_manifest(id, bs, ss)).await;
}
let stats = process_stats(&state, Some(TEST_TOKEN)).await.unwrap();
assert_eq!(stats.total_subjects, 8);
assert_eq!(stats.biometric_status.never_collected, 2);
assert_eq!(stats.biometric_status.given, 3);
assert_eq!(stats.biometric_status.withdrawn, 1);
assert_eq!(stats.biometric_status.pending, 1);
assert_eq!(stats.biometric_status.expired, 1);
assert_eq!(stats.subject_status.active, 6);
assert_eq!(stats.subject_status.pending_consent, 1);
assert_eq!(stats.subject_status.erased, 1);
}
#[tokio::test]
async fn stats_recent_events_filters_state_changes_only() {
// The stats endpoint surfaces only state-changing event kinds
// (consent_grant / collection / withdrawal / erasure) — not
// gateway_lookup / validator_lookup / etc. which are noise
// for an ops dashboard.
let state = fixture_state("stats_event_filter").await;
// Subject must start at NeverCollected so process_consent can flip
// it to Given (otherwise the consent endpoint returns 409 already_given).
let _ = state.registry.put_subject(fixture_manifest("WORKER-EV", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
// Plant a noisy validator_lookup row + a state-change row.
use shared::types::AuditAccessor;
let noise_row = SubjectAuditRow {
schema: "subject_audit.v1".into(),
ts: chrono::Utc::now(),
candidate_id: "WORKER-EV".into(),
accessor: AuditAccessor {
kind: "validator_lookup".into(),
daemon: "gateway".into(),
purpose: "noise".into(),
trace_id: String::new(),
},
fields_accessed: vec!["exists".into()],
result: "not_found".into(),
prev_chain_hash: String::new(),
row_hmac: String::new(),
};
let _ = state.writer.append(noise_row).await;
// grant a consent (state change) so chain has at least one
// event the dashboard should surface.
let _ = process_consent(&state, "WORKER-EV", Some(TEST_TOKEN), "trace-ev", fixture_consent_request())
.await.unwrap();
let stats = process_stats(&state, Some(TEST_TOKEN)).await.unwrap();
// recent_events must NOT contain the validator_lookup row.
assert!(stats.recent_events.iter().all(|e| e.kind != "validator_lookup"));
// The consent grant SHOULD be present.
assert!(stats.recent_events.iter().any(|e| e.kind == "biometric_consent_grant" && e.candidate_id == "WORKER-EV"));
// event_counts breakdown must exclude the noise.
assert!(!stats.recent_event_counts.contains_key("validator_lookup"));
assert_eq!(stats.recent_event_counts.get("biometric_consent_grant"), Some(&1));
}
// ─── Consent version allowlist tests ──────────────────────────
async fn fixture_state_with_allowlist(name: &str, hashes: Vec<String>) -> BiometricEndpointState {
let mut s = fixture_state(name).await;
let set: std::collections::HashSet<String> = hashes.into_iter().collect();
s.allowed_consent_versions = Some(Arc::new(set));
s
}
#[tokio::test]
async fn consent_allowlist_accepts_known_hash() {
// Allowlist contains the fixture's consent_version_hash —
// grant succeeds.
let allowed = "abcdef0123456789".repeat(4); // matches fixture_consent_request
let state = fixture_state_with_allowlist("al_known", vec![allowed]).await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-AL1", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
let resp = process_consent(&state, "WORKER-AL1", Some(TEST_TOKEN), "trace-al", fixture_consent_request())
.await.unwrap();
assert_eq!(resp.status_after, "Given");
}
#[tokio::test]
async fn consent_allowlist_rejects_unknown_hash() {
// Allowlist contains a different hash; the fixture's hash
// is rejected with consent_version_unknown.
let other = "0011223344556677".repeat(4);
let state = fixture_state_with_allowlist("al_unknown", vec![other]).await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-AL2", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
let err = process_consent(&state, "WORKER-AL2", Some(TEST_TOKEN), "trace-al", fixture_consent_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::BAD_REQUEST);
assert_eq!(err.1.error, "consent_version_unknown");
}
#[tokio::test]
async fn consent_allowlist_normalizes_case() {
// Operator might paste an uppercase hash; allowlist comparison
// is lowercase to defeat that drift.
let allowed_lower = "abcdef0123456789".repeat(4);
let state = fixture_state_with_allowlist("al_case", vec![allowed_lower]).await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-AL3", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
let mut req = fixture_consent_request();
req.consent_version_hash = req.consent_version_hash.to_uppercase();
let resp = process_consent(&state, "WORKER-AL3", Some(TEST_TOKEN), "trace-al", req)
.await.unwrap();
assert_eq!(resp.status_after, "Given");
}
#[tokio::test]
async fn consent_allowlist_empty_set_refuses_all() {
// Strict-locked mode: operator created the file but populated
// it with an empty array. Refuses every grant — used as a
// deliberate freeze (e.g., counsel hasn't signed v1 yet).
let state = fixture_state_with_allowlist("al_empty", vec![]).await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-AL4", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
let err = process_consent(&state, "WORKER-AL4", Some(TEST_TOKEN), "trace-al", fixture_consent_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::BAD_REQUEST);
assert_eq!(err.1.error, "consent_version_unknown");
}
#[tokio::test]
async fn consent_allowlist_none_is_permissive() {
// The default fixture has allowed_consent_versions=None.
// Grant succeeds with any non-empty hash (v1 compat behavior).
let state = fixture_state("consent_allowlist_none").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-AL5", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
let resp = process_consent(&state, "WORKER-AL5", Some(TEST_TOKEN), "trace-al", fixture_consent_request())
.await.unwrap();
assert_eq!(resp.status_after, "Given");
}
#[tokio::test]
async fn stats_oldest_retention_picks_earliest_given() {
let state = fixture_state("stats_retention").await;
let now = chrono::Utc::now();
// Three Given subjects with different retention dates.
let later = now + chrono::Duration::days(540); // 18 months
let middle = now + chrono::Duration::days(300);
let early = now + chrono::Duration::days(60); // earliest
for (id, ret) in [("WORKER-R1", later), ("WORKER-R2", middle), ("WORKER-R3", early)] {
let mut m = fixture_manifest(id, BiometricConsentStatus::Given, SubjectStatus::Active);
m.consent.biometric.retention_until = Some(ret);
let _ = state.registry.put_subject(m).await;
}
// Plus a Withdrawn subject with an even earlier retention date —
// should NOT count for "oldest active" because it's not Given.
let mut withdrawn = fixture_manifest("WORKER-R4", BiometricConsentStatus::Withdrawn, SubjectStatus::Active);
withdrawn.consent.biometric.retention_until = Some(now + chrono::Duration::days(15));
let _ = state.registry.put_subject(withdrawn).await;
let stats = process_stats(&state, Some(TEST_TOKEN)).await.unwrap();
// The oldest_active should be the earliest Given retention (WORKER-R3, ~60d).
// (Equality on DateTime<Utc> doesn't suffer the put_subject
// microsecond-skew issue here because retention_until is a
// pre-computed value we set ourselves; it round-trips intact.)
assert_eq!(stats.oldest_active_retention_until, Some(early));
let days = stats.upcoming_destruction_window_days.unwrap();
// Allow ±1-day fudge for the now() taken inside process_stats.
assert!((59..=61).contains(&days),
"expected ~60 days, got {} (early={} now-ish={:?})", days, early, stats.generated_at);
}
}