phase 1.6: BIPA withdrawal endpoint + UI + retention sweep timer

Closes the four production gaps that were live after the consent
endpoint shipped (76cb5ac):

(1) Withdrawal endpoint POST /biometric/subject/{id}/withdraw
    backs the BIPA right of withdrawal that consent template v1 §2
    explicitly promises. Without it, the only way to honor a
    candidate's withdrawal request was the heavier /erase, which
    destroys immediately rather than starting the 30-day SLA clock
    that the consent template commits to. Side-effects:
      - manifest.consent.biometric.status: Given → Withdrawn
      - manifest.consent.biometric.retention_until: 18mo → 30d
      - audit row kind=biometric_consent_withdrawal, captures
        reason + operator_of_record + evidence_path
      - DOES NOT touch general_pii or subject.status — biometric
        is independently revocable
    State machine: Given→Withdrawn (happy), NeverCollected/Pending→
    409 nothing_to_withdraw, Withdrawn→409 already_withdrawn (won't
    advance the destruction clock), Expired→409 already_expired,
    subject Erased/RetentionExpired→403 subject_inactive.
    12 new unit tests covering happy path + all guards + a full
    grant→withdraw cycle that asserts retention_until is correctly
    accelerated and the audit chain has 2 rows in correct order.

(2) Withdraw UI at /biometric/withdraw (mcp-server-served HTML).
    3-screen flow: operator auth (token + name in sessionStorage),
    withdrawal form (candidate_id + free-text reason ≥10 chars +
    optional evidence path), confirmation showing the audit row
    HMAC + the 30-day retention_until clock + a curl recipe for
    /audit/subject/{id} verification. Same neo-brutalist styling
    as biometric_intake.html. Mounted at
    http://localhost:3700/biometric/withdraw and externally at
    https://devop.live/lakehouse/biometric/withdraw.

(3) Retention sweep systemd timer. crates/catalogd/bin/retention_sweep
    binary already existed; this commit schedules it. Daily 03:00 UTC,
    Persistent=true so a missed boot triggers on next start. Service
    runs as oneshot with --apply (writes a date-stamped JSONL to
    data/_catalog/subjects/_retention_sweep_<date>.jsonl ONLY when
    overdue subjects exist, per the binary's existing semantics).
    install.sh updated to handle .timer + paired .service correctly:
    enables the timer, skips direct start of the oneshot service
    (the timer pulls it in). One-shot manual test confirmed clean:
    100 subjects scanned, 0 overdue (all backfill subjects within
    their 4-year general retention window).

(4) operator_of_record bug fix in intake UI. Previously the page
    hardcoded the literal string 'intake_ui_operator' as the
    operator_of_record sent to /consent — meaning every audit row
    captured the same useless placeholder, defeating the whole
    point of operator traceability. Fixed by adding an operator
    name field to the token-paste step (sessionStorage-backed),
    passed through to consent + photo POSTs as the actual operator.

Verified live post-restart:
- gateway /audit/health + /biometric/health both 200
- mcp-server /biometric/intake + /biometric/withdraw both 200
- Live withdraw probes: 401 (no token), 400 (empty body), 404
  (ghost subject), 409 nothing_to_withdraw on WORKER-1 (which
  is NeverCollected per backfill default) — all expected
- Binary strings contain: process_withdraw, withdraw_consent,
  biometric_consent_withdrawal, biometric_withdraw_response.v1,
  nothing_to_withdraw, already_withdrawn, already_expired,
  /subject/{candidate_id}/withdraw route
- systemd: lakehouse-retention-sweep.timer active+enabled,
  next fire Tue 2026-05-05 22:00 CDT (= 03:00 UTC May 6)
- Manual one-shot of retention sweep service: exit 0/SUCCESS,
  100 subjects loaded, 0 overdue

83/83 catalogd lib tests + 46/46 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:09:32 -05:00
co-authored by Claude Opus 4.7
parent 7f0f500050
commit 68d226c314
7 changed files with 844 additions and 8 deletions
+476
View File
@@ -124,6 +124,7 @@ pub fn router(state: BiometricEndpointState) -> Router {
Router::new()
.route("/subject/{candidate_id}/photo", post(upload_photo))
.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("/health", get(biometric_health))
.layer(DefaultBodyLimit::max(MAX_PHOTO_BYTES))
@@ -1072,6 +1073,275 @@ pub async fn process_consent(
})
}
// ─── Consent withdrawal (BIPA right of withdrawal) ────────────────
//
// Backs the candidate's BIPA right to withdraw biometric consent at
// any time. Required by 740 ILCS 14/15(b) (consent must be revocable)
// and explicitly promised in
// docs/policies/consent/biometric_consent_template_v1.md §2.
//
// Withdrawal is NOT erasure. It's a state change that:
// - Stops further biometric processing (uploads will 403 again
// because the consent gate flips back to non-Given)
// - Starts a 30-day SLA clock for destruction (retention_until is
// accelerated from the 18-month default to 30 days from now)
// - The retention sweep + operator-driven erase honor the SLA;
// this endpoint does not delete anything itself
//
// Why a separate endpoint from /erase:
// - /erase destroys immediately (full BIPA destruction at counsel's
// direction or court order — biometric_only or full scope)
// - /withdraw flips the state + sets the SLA clock so destruction
// can happen on schedule with a defensible audit trail of the
// candidate's request preceding the destruction by ≤30 days
//
// State machine:
// - Given → Withdrawn (happy path)
// - NeverCollected/Pending → 409 nothing_to_withdraw
// - Withdrawn → 409 already_withdrawn (idempotent via /audit read)
// - Expired → 409 already_expired
// Subject status:
// - Erased / RetentionExpired → 403 subject_inactive
// - Otherwise → proceed (biometric can be withdrawn even if
// general_pii is mid-flow)
const WITHDRAW_RESPONSE_SCHEMA: &str = "biometric_withdraw_response.v1";
const WITHDRAW_SLA_DAYS: i64 = 30; // per consent template v1 §2
#[derive(serde::Deserialize, Debug, Clone)]
pub struct WithdrawRequest {
/// Reason for withdrawal. Captured to the audit row as part of
/// accessor.purpose. Required + non-empty — counsel may need to
/// distinguish candidate-initiated vs. operator-initiated
/// withdrawals (e.g., candidate request vs. employer policy).
pub reason: String,
/// Operator initiating the withdrawal record (the staffing
/// coordinator who fielded the candidate's request). Required for
/// audit traceability — every state change must be attributable.
pub operator_of_record: String,
/// Optional: path or URL to a signed artifact proving the
/// candidate authorized withdrawal (signed paper, recorded call
/// transcript, email thread). Recorded for chain-of-custody.
#[serde(default)]
pub evidence_path: String,
}
#[derive(Serialize, Debug)]
pub struct WithdrawResponse {
pub schema: &'static str,
pub candidate_id: String,
pub status_after: String,
pub withdrawn_at: chrono::DateTime<chrono::Utc>,
pub retention_until: chrono::DateTime<chrono::Utc>,
pub audit_row_hmac: String,
}
async fn withdraw_consent(
State(state): State<BiometricEndpointState>,
Path(candidate_id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let auth_token = headers
.get(LEGAL_TOKEN_HEADER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let trace_id = headers
.get(TRACE_ID_HEADER)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let req: WithdrawRequest = match serde_json::from_slice(&body) {
Ok(r) => r,
Err(e) => return (StatusCode::BAD_REQUEST, Json(ErrorResponse {
error: "bad_request",
detail: format!(
"withdraw body must be JSON {{reason, operator_of_record, evidence_path?}}: {e}"
),
consent_status: None,
})).into_response(),
};
match process_withdraw(&state, &candidate_id, auth_token.as_deref(), &trace_id, req).await {
Ok(resp) => (StatusCode::OK, Json(resp)).into_response(),
Err((status, err)) => (status, Json(err)).into_response(),
}
}
/// Pure logic for consent withdrawal — same testable shape as
/// process_consent + process_upload + process_erase.
pub async fn process_withdraw(
state: &BiometricEndpointState,
candidate_id: &str,
legal_token: Option<&str>,
trace_id: &str,
req: WithdrawRequest,
) -> Result<WithdrawResponse, (StatusCode, ErrorResponse)> {
// Auth.
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 },
));
}
if candidate_id.is_empty() {
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
error: "bad_request", detail: "candidate_id is empty".into(), consent_status: None,
}));
}
// Body validation. Empty fields silently weaken defensibility.
if req.reason.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
error: "bad_request",
detail: "reason is required (free-text rationale captured to audit row)".into(),
consent_status: None,
}));
}
if req.operator_of_record.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
error: "bad_request",
detail: "operator_of_record is required".into(),
consent_status: None,
}));
}
// Load original manifest.
let original_manifest = state.registry.get_subject(candidate_id).await.ok_or((
StatusCode::NOT_FOUND,
ErrorResponse {
error: "subject_not_found",
detail: format!("no SubjectManifest for {candidate_id}"),
consent_status: None,
},
))?;
let mut manifest = original_manifest.clone();
use shared::types::{BiometricConsentStatus, SubjectStatus};
// Subject must not be already destroyed.
if matches!(manifest.status, SubjectStatus::Erased | SubjectStatus::RetentionExpired) {
return Err((StatusCode::FORBIDDEN, ErrorResponse {
error: "subject_inactive",
detail: format!("subject status {:?} — withdrawal not applicable", manifest.status),
consent_status: None,
}));
}
// Biometric state machine.
match manifest.consent.biometric.status {
BiometricConsentStatus::Given => {
// Happy path — proceed.
}
BiometricConsentStatus::NeverCollected | BiometricConsentStatus::Pending => {
return Err((StatusCode::CONFLICT, ErrorResponse {
error: "nothing_to_withdraw",
detail: format!(
"biometric consent status is {:?} — nothing has been given to withdraw. \
If consent is mid-flow and the candidate is rejecting, the consent record \
should be discarded by the intake operator rather than withdrawn here.",
manifest.consent.biometric.status,
),
consent_status: Some(format!("{:?}", manifest.consent.biometric.status)),
}));
}
BiometricConsentStatus::Withdrawn => {
return Err((StatusCode::CONFLICT, ErrorResponse {
error: "already_withdrawn",
detail: "biometric consent is already withdrawn; \
confirm via GET /audit/subject/{id} to see the prior withdrawal row".into(),
consent_status: Some("Withdrawn".into()),
}));
}
BiometricConsentStatus::Expired => {
return Err((StatusCode::CONFLICT, ErrorResponse {
error: "already_expired",
detail: "biometric retention window already passed; \
data is awaiting destruction sweep — withdrawal is moot".into(),
consent_status: Some("Expired".into()),
}));
}
}
// Compute timestamps. withdrawn_at is server-side authoritative;
// retention_until is accelerated from the prior 18-month default
// to 30 days from now per consent template v1 §2.
let withdrawn_at = chrono::Utc::now();
let retention_until = withdrawn_at + chrono::Duration::days(WITHDRAW_SLA_DAYS);
manifest.consent.biometric.status = BiometricConsentStatus::Withdrawn;
manifest.consent.biometric.retention_until = Some(retention_until);
manifest.updated_at = withdrawn_at;
// NOTE: deliberately do NOT mutate manifest.status or
// manifest.consent.general_pii — withdrawal is biometric-scoped.
// A subject can keep their general PII processing while opting
// out of biometrics. Subject-level withdrawal is a separate
// (out-of-scope) endpoint.
// Transactional: manifest commit before audit; rollback on audit failure.
if let Err(e) = state.registry.put_subject(manifest.clone()).await {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
ErrorResponse { error: "manifest_update_failed", detail: e, consent_status: None },
));
}
let row = SubjectAuditRow {
schema: "subject_audit.v1".into(),
ts: withdrawn_at,
candidate_id: candidate_id.to_string(),
accessor: AuditAccessor {
kind: "biometric_consent_withdrawal".into(),
daemon: "gateway".into(),
purpose: format!(
"reason={};operator={};evidence={}",
req.reason, req.operator_of_record, req.evidence_path,
),
trace_id: trace_id.to_string(),
},
fields_accessed: vec![
"consent.biometric.status".into(),
"consent.biometric.retention_until".into(),
],
result: "withdrawn".into(),
prev_chain_hash: String::new(),
row_hmac: String::new(),
};
let audit_row_hmac = match state.writer.append(row).await {
Ok(h) => h,
Err(e) => {
tracing::error!("withdraw audit row failed for {candidate_id}: {e}; rolling back");
if let Err(re_err) = state.registry.put_subject(original_manifest).await {
tracing::error!("rollback: could not revert manifest for {candidate_id}: {re_err}");
}
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
ErrorResponse {
error: "audit_write_failed",
detail: format!("audit row failed; manifest rolled back: {e}"),
consent_status: None,
},
));
}
};
Ok(WithdrawResponse {
schema: WITHDRAW_RESPONSE_SCHEMA,
candidate_id: candidate_id.to_string(),
status_after: "Withdrawn".into(),
withdrawn_at,
retention_until,
audit_row_hmac,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1773,4 +2043,210 @@ mod tests {
// Upload row chains off the consent grant's hmac.
assert_eq!(rows[1].prev_chain_hash, grant.audit_row_hmac);
}
// ─── Consent withdrawal tests (BIPA right of withdrawal) ───────
fn fixture_withdraw_request() -> WithdrawRequest {
WithdrawRequest {
reason: "candidate emailed asking to remove their photo".into(),
operator_of_record: "OperatorB".into(),
evidence_path: "/tmp/withdrawal_email_thread_WORKER-X.eml".into(),
}
}
#[tokio::test]
async fn withdraw_happy_path_flips_status_and_starts_30day_clock() {
// Subject at consent.biometric.status=Given, retention_until set
// to ~18 months out. Withdrawal flips status to Withdrawn AND
// accelerates retention_until to 30 days from now (per consent
// template v1 §2 SLA), appends biometric_consent_withdrawal
// audit row, returns 200.
let state = fixture_state("withdraw_happy").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-W1", BiometricConsentStatus::Given, SubjectStatus::Active)).await;
let writer = state.writer.clone();
let registry = state.registry.clone();
let resp = process_withdraw(&state, "WORKER-W1", Some(TEST_TOKEN), "trace-w1", fixture_withdraw_request())
.await.unwrap();
assert_eq!(resp.schema, "biometric_withdraw_response.v1");
assert_eq!(resp.candidate_id, "WORKER-W1");
assert_eq!(resp.status_after, "Withdrawn");
assert!(!resp.audit_row_hmac.is_empty());
// 30-day clock from withdrawn_at to retention_until.
let elapsed = resp.retention_until - resp.withdrawn_at;
assert_eq!(elapsed.num_days(), WITHDRAW_SLA_DAYS);
// Manifest reflects the withdrawal.
let m = registry.get_subject("WORKER-W1").await.unwrap();
assert_eq!(m.consent.biometric.status, BiometricConsentStatus::Withdrawn);
assert_eq!(m.consent.biometric.retention_until, Some(resp.retention_until));
// general_pii NOT touched — withdrawal is biometric-scoped only.
// (default fixture has NeverCollected on biometric, but this
// assertion would catch a bug where withdraw mutated
// general_pii.status alongside biometric.)
assert_eq!(m.status, SubjectStatus::Active);
// Audit row.
assert_eq!(writer.verify_chain("WORKER-W1").await.unwrap(), 1);
let rows = writer.read_rows_in_range("WORKER-W1", None, None).await.unwrap();
assert_eq!(rows[0].accessor.kind, "biometric_consent_withdrawal");
assert!(rows[0].accessor.purpose.contains("reason="));
assert!(rows[0].accessor.purpose.contains("operator=OperatorB"));
assert_eq!(rows[0].accessor.trace_id, "trace-w1");
assert_eq!(rows[0].fields_accessed, vec!["consent.biometric.status".to_string(), "consent.biometric.retention_until".to_string()]);
assert_eq!(rows[0].result, "withdrawn");
}
#[tokio::test]
async fn withdraw_missing_token_rejected() {
let state = fixture_state("withdraw_no_token").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-W2", BiometricConsentStatus::Given, SubjectStatus::Active)).await;
let err = process_withdraw(&state, "WORKER-W2", None, "", fixture_withdraw_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
assert_eq!(err.1.error, "auth_failed");
}
#[tokio::test]
async fn withdraw_wrong_token_rejected() {
let state = fixture_state("withdraw_wrong_token").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-W3", BiometricConsentStatus::Given, SubjectStatus::Active)).await;
let err = process_withdraw(&state, "WORKER-W3", Some("nope"), "", fixture_withdraw_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn withdraw_subject_not_found_returns_404() {
let state = fixture_state("withdraw_no_subject").await;
let err = process_withdraw(&state, "GHOST-WORKER", Some(TEST_TOKEN), "", fixture_withdraw_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::NOT_FOUND);
assert_eq!(err.1.error, "subject_not_found");
}
#[tokio::test]
async fn withdraw_missing_reason_400() {
let state = fixture_state("withdraw_no_reason").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-W4", BiometricConsentStatus::Given, SubjectStatus::Active)).await;
let mut req = fixture_withdraw_request();
req.reason = " ".into();
let err = process_withdraw(&state, "WORKER-W4", Some(TEST_TOKEN), "", req)
.await.unwrap_err();
assert_eq!(err.0, StatusCode::BAD_REQUEST);
assert!(err.1.detail.contains("reason"));
}
#[tokio::test]
async fn withdraw_missing_operator_400() {
let state = fixture_state("withdraw_no_op").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-W5", BiometricConsentStatus::Given, SubjectStatus::Active)).await;
let mut req = fixture_withdraw_request();
req.operator_of_record = "".into();
let err = process_withdraw(&state, "WORKER-W5", Some(TEST_TOKEN), "", req)
.await.unwrap_err();
assert_eq!(err.0, StatusCode::BAD_REQUEST);
assert!(err.1.detail.contains("operator_of_record"));
}
#[tokio::test]
async fn withdraw_when_never_collected_returns_409() {
// Cannot withdraw consent that was never given. If consent is
// mid-flow, the consent record should be discarded by the
// intake operator rather than withdrawn.
let state = fixture_state("withdraw_never").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-W6", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
let err = process_withdraw(&state, "WORKER-W6", Some(TEST_TOKEN), "", fixture_withdraw_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::CONFLICT);
assert_eq!(err.1.error, "nothing_to_withdraw");
assert_eq!(err.1.consent_status.as_deref(), Some("NeverCollected"));
}
#[tokio::test]
async fn withdraw_when_pending_returns_409() {
let state = fixture_state("withdraw_pending").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-W7", BiometricConsentStatus::Pending, SubjectStatus::Active)).await;
let err = process_withdraw(&state, "WORKER-W7", Some(TEST_TOKEN), "", fixture_withdraw_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::CONFLICT);
assert_eq!(err.1.error, "nothing_to_withdraw");
}
#[tokio::test]
async fn withdraw_when_already_withdrawn_returns_409() {
// Idempotency-resilient: refuses re-withdrawal so operator
// doesn't accidentally push retention_until back. Operator
// can confirm prior withdrawal via GET /audit/subject/{id}.
let state = fixture_state("withdraw_already").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-W8", BiometricConsentStatus::Withdrawn, SubjectStatus::Active)).await;
let err = process_withdraw(&state, "WORKER-W8", Some(TEST_TOKEN), "", fixture_withdraw_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::CONFLICT);
assert_eq!(err.1.error, "already_withdrawn");
assert_eq!(err.1.consent_status.as_deref(), Some("Withdrawn"));
}
#[tokio::test]
async fn withdraw_when_expired_returns_409() {
let state = fixture_state("withdraw_expired").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-W9", BiometricConsentStatus::Expired, SubjectStatus::Active)).await;
let err = process_withdraw(&state, "WORKER-W9", Some(TEST_TOKEN), "", fixture_withdraw_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::CONFLICT);
assert_eq!(err.1.error, "already_expired");
}
#[tokio::test]
async fn withdraw_subject_inactive_returns_403() {
// Subject status takes precedence — withdrawal on Erased
// subject is moot.
let state = fixture_state("withdraw_erased_subject").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-W10", BiometricConsentStatus::Given, SubjectStatus::Erased)).await;
let err = process_withdraw(&state, "WORKER-W10", Some(TEST_TOKEN), "", fixture_withdraw_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
assert_eq!(err.1.error, "subject_inactive");
}
#[tokio::test]
async fn withdraw_full_cycle_consent_grant_then_withdraw() {
// End-to-end: consent grant → withdraw. Verifies retention_until
// is correctly accelerated from the 18-month consent default to
// 30 days from withdrawal. Audit chain has 2 rows in correct
// order; withdraw row chains off the grant's hmac.
let state = fixture_state("withdraw_cycle").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-W11", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
let writer = state.writer.clone();
let registry = state.registry.clone();
// 1. Grant consent.
let grant = process_consent(&state, "WORKER-W11", Some(TEST_TOKEN), "trace-grant", fixture_consent_request())
.await.unwrap();
let grant_retention = grant.retention_until;
// 2. Withdraw.
let withdraw = process_withdraw(&state, "WORKER-W11", Some(TEST_TOKEN), "trace-withdraw", fixture_withdraw_request())
.await.unwrap();
// retention_until was accelerated — withdraw's retention_until
// must be earlier than the 18-month grant retention.
assert!(withdraw.retention_until < grant_retention,
"withdraw retention {} must be earlier than grant retention {}",
withdraw.retention_until, grant_retention);
// Manifest reflects the withdrawal.
let m = registry.get_subject("WORKER-W11").await.unwrap();
assert_eq!(m.consent.biometric.status, BiometricConsentStatus::Withdrawn);
assert_eq!(m.consent.biometric.retention_until, Some(withdraw.retention_until));
// Audit chain.
assert_eq!(writer.verify_chain("WORKER-W11").await.unwrap(), 2);
let rows = writer.read_rows_in_range("WORKER-W11", None, None).await.unwrap();
assert_eq!(rows[0].accessor.kind, "biometric_consent_grant");
assert_eq!(rows[1].accessor.kind, "biometric_consent_withdrawal");
// Withdraw row chains off the grant's hmac.
assert_eq!(rows[1].prev_chain_hash, grant.audit_row_hmac);
}
}