phase 1.6: add consent-record endpoint POST /biometric/subject/{id}/consent (Gate 2 backend)

Backs the candidate-facing consent flow specified in
docs/policies/consent/biometric_consent_template_v1.md §5. Without
this endpoint, no production code path could flip
consent.biometric.status from NeverCollected/Pending to Given —
meaning every photo upload would fail-closed at the consent gate
(403 consent_required) forever, even after counsel signs the
template. This unblocks the post-cutover intake flow.

Endpoint shape:
- POST /biometric/subject/{candidate_id}/consent
- Auth: legal-tier (X-Lakehouse-Legal-Token header)
- Body (JSON): {consent_version_hash, consent_collection_method,
  consent_collection_evidence_path?, operator_of_record}
- consent_collection_method constrained to closed set:
  electronic_signature / paper / click_acceptance — operator
  typo would silently weaken evidentiary defensibility
- consent_version_hash recorded but not gated against allowlist
  at runtime (avoids config-deployment dependency for v2 template
  rotation; counsel validates retroactively against
  consent_versions table)

State-machine semantics (mirrors upload's double-upload guard):
- NeverCollected / Pending → Given (happy path)
- Given → 409 consent_already_given (re-collect requires explicit
  erase + fresh grant under new template version)
- Withdrawn / Expired → 409 consent_post_withdrawal_requires_erase
  (explicit erase preserves audit-chain ordering of the cycle)
- Subject status Withdrawn / Erased / RetentionExpired → 403

Server-side authoritative timestamps:
- given_at = Utc::now() (operator-supplied would be tamperable)
- retention_until = given_at + 540 days (18 months per retention
  schedule v1 §4). If counsel changes the cap, schedule doc + code
  constant CONSENT_RETENTION_DAYS change in the same PR.

Audit row:
- accessor.kind = "biometric_consent_grant"
- accessor.purpose = "version=<hash>;method=<m>;operator=<op>;evidence=<path>"
- fields_accessed = ["consent.biometric.status",
                     "consent.biometric.retention_until"]
- result = "given"
- Transactional: manifest commit before audit append; rollback
  manifest if audit fails.

Tests added (12 new):
- consent_happy_path_flips_status_and_records_audit (full happy
  path + audit row inspection + retention_until math)
- consent_missing_token_rejected
- consent_wrong_token_rejected
- consent_subject_not_found_returns_404
- consent_missing_version_hash_400
- consent_missing_method_400
- consent_invalid_method_400
- consent_missing_operator_400
- consent_already_given_returns_409
- consent_post_withdrawal_requires_erase_returns_409
- consent_subject_inactive_returns_403
- consent_grant_then_upload_is_the_intended_intake_flow
  (end-to-end: grant → upload → verify chain has 2 rows
  consent_grant→biometric_collection in correct order, upload
  row chains off the grant's hmac)

71/71 catalogd lib tests + gateway crate compile clean.

Live verification post-restart:
- /audit/health + /biometric/health both 200
- Live POST returns 400 on missing/malformed body, 404 on ghost
  subject — auth + body parse + subject lookup ordering verified
- strings(target/release/gateway) contains: record_consent symbol,
  biometric_consent_response.v1, biometric_consent_grant,
  consent_already_given, consent_post_withdrawal_requires_erase,
  electronic_signature, /subject/{candidate_id}/consent route

Production posture: gateway running with the new endpoint live.
The candidate-facing consent UI is NOT yet built; that is a
separate session. This endpoint is the backend the UI will call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-05-05 13:21:12 -05:00
parent b2c34b80b3
commit 76cb5acb03

View File

@ -123,6 +123,7 @@ impl BiometricEndpointState {
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}/erase", post(erase_subject))
.route("/health", get(biometric_health))
.layer(DefaultBodyLimit::max(MAX_PHOTO_BYTES))
@ -779,6 +780,298 @@ pub async fn process_erase(
})
}
// ─── Consent recording (Gate 2 backend) ─────────────────────────────
//
// Backs the candidate-facing consent flow specified in
// `docs/policies/consent/biometric_consent_template_v1.md` §5. Every
// candidate whose photo is later uploaded MUST first have a manifest
// with `consent.biometric.status = Given` — produced by this endpoint.
//
// Auth: legal-tier (operator records on behalf of candidate). When the
// candidate-facing intake UI ships, this endpoint stays operator-side;
// the UI captures the signature + posts here. The candidate's
// browser/app NEVER touches this endpoint directly.
//
// Idempotency posture:
// - if subject is at NeverCollected / Pending → flip to Given (happy path)
// - if subject is at Given → 409 (already given; consent doesn't
// re-stamp). Re-collect requires explicit erase first.
// - if subject is at Withdrawn / Expired → 409 (must erase residue
// of the prior cycle before granting a fresh consent)
//
// Retention clock: 18 months from given_at, per retention schedule v1
// §4. If counsel changes the cap, this constant + the retention doc
// must change in the same PR (intentional friction).
const CONSENT_RESPONSE_SCHEMA: &str = "biometric_consent_response.v1";
const CONSENT_RETENTION_DAYS: i64 = 18 * 30; // 540 days ≈ 18 months per retention schedule §4
const ALLOWED_CONSENT_METHODS: &[&str] = &["electronic_signature", "paper", "click_acceptance"];
#[derive(serde::Deserialize, Debug, Clone)]
pub struct ConsentRequest {
/// SHA-256 of the consent template version the candidate signed
/// under. Required + non-empty. Recorded in the audit row for
/// legal traceability; counsel validates retroactively against the
/// known good consent_versions table. We deliberately do NOT gate
/// against an in-process allowlist — that would couple the endpoint
/// to a config-deployment dependency for every template rotation.
pub consent_version_hash: String,
/// Collection method per consent template §5. Constrained to a
/// closed set (electronic_signature / paper / click_acceptance)
/// because counsel needs to know what evidence shape was captured
/// and operator typo would silently weaken defensibility.
pub consent_collection_method: String,
/// Path or URL to the signed artifact (PDF receipt, e-sig audit
/// log, scanned paper, etc.). Recorded in the audit row for
/// chain-of-custody. May be empty for click_acceptance where the
/// "evidence" is the click + timestamp + IP captured upstream.
#[serde(default)]
pub consent_collection_evidence_path: String,
/// Operator initiating the consent record (the staffing coordinator
/// who collected the signature, or the intake-UI service principal).
/// Required for audit traceability — every grant must be attributable.
pub operator_of_record: String,
}
#[derive(Serialize, Debug)]
pub struct ConsentResponse {
pub schema: &'static str,
pub candidate_id: String,
pub status_after: String,
pub consent_version_hash: String,
pub given_at: chrono::DateTime<chrono::Utc>,
pub retention_until: chrono::DateTime<chrono::Utc>,
pub audit_row_hmac: String,
}
async fn record_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: ConsentRequest = match serde_json::from_slice(&body) {
Ok(r) => r,
Err(e) => return (StatusCode::BAD_REQUEST, Json(ErrorResponse {
error: "bad_request",
detail: format!(
"consent body must be JSON {{consent_version_hash, consent_collection_method, operator_of_record, ...}}: {e}"
),
consent_status: None,
})).into_response(),
};
match process_consent(&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 recording — same testable shape as
/// `process_upload` and `process_erase`. Inputs are the raw header
/// values + parsed body; output is either the success body or
/// (status, error body). Manifest + audit-writer interactions stay
/// in here so the test surface IS the behavior.
pub async fn process_consent(
state: &BiometricEndpointState,
candidate_id: &str,
legal_token: Option<&str>,
trace_id: &str,
req: ConsentRequest,
) -> Result<ConsentResponse, (StatusCode, ErrorResponse)> {
// Auth (mirrors process_upload / process_erase).
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 / whitespace-only fields would silently
// weaken defensibility (a consent record without a version hash is
// not a defensible consent record).
if req.consent_version_hash.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
error: "bad_request",
detail: "consent_version_hash is required (SHA-256 of the consent template version the candidate signed under)".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,
}));
}
let method = req.consent_collection_method.trim();
if method.is_empty() {
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
error: "bad_request",
detail: format!(
"consent_collection_method is required (one of: {})",
ALLOWED_CONSENT_METHODS.join(", "),
),
consent_status: None,
}));
}
if !ALLOWED_CONSENT_METHODS.contains(&method) {
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
error: "bad_request",
detail: format!(
"consent_collection_method must be one of: {} (got {})",
ALLOWED_CONSENT_METHODS.join(", "),
method,
),
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 be Active. Withdrawn/Erased/RetentionExpired subjects
// need explicit re-activation upstream before consent can be recorded.
if matches!(manifest.status, SubjectStatus::Withdrawn | SubjectStatus::Erased | SubjectStatus::RetentionExpired) {
return Err((StatusCode::FORBIDDEN, ErrorResponse {
error: "subject_inactive",
detail: format!("subject status {:?} — consent grant not permitted", manifest.status),
consent_status: None,
}));
}
// State-machine transitions. Mirrors the upload double-upload guard
// (refuse re-grant; force explicit erase + re-consent cycle).
match manifest.consent.biometric.status {
BiometricConsentStatus::Given => {
return Err((StatusCode::CONFLICT, ErrorResponse {
error: "consent_already_given",
detail: "subject already has biometric consent given; \
POST /biometric/subject/{id}/erase first if you intend to re-collect under a new consent version".into(),
consent_status: Some("Given".into()),
}));
}
BiometricConsentStatus::Withdrawn | BiometricConsentStatus::Expired => {
return Err((StatusCode::CONFLICT, ErrorResponse {
error: "consent_post_withdrawal_requires_erase",
detail: format!(
"subject biometric.status is {:?}; explicit erase required before fresh consent grant \
(so any residual collection state is cleared and the audit chain records the cycle).",
manifest.consent.biometric.status,
),
consent_status: Some(format!("{:?}", manifest.consent.biometric.status)),
}));
}
BiometricConsentStatus::NeverCollected | BiometricConsentStatus::Pending => {
// Happy path — proceed.
}
}
// Compute timestamps. given_at = now (server-side authoritative —
// operator-supplied timestamp would be tamperable). retention_until
// = given_at + 18 months per retention schedule v1 §4.
let given_at = chrono::Utc::now();
let retention_until = given_at + chrono::Duration::days(CONSENT_RETENTION_DAYS);
manifest.consent.biometric.status = BiometricConsentStatus::Given;
manifest.consent.biometric.retention_until = Some(retention_until);
manifest.updated_at = given_at;
// Transactional commit. Same posture as upload + erase: manifest
// commit before audit row, rollback manifest if audit fails.
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: given_at,
candidate_id: candidate_id.to_string(),
accessor: AuditAccessor {
kind: "biometric_consent_grant".into(),
daemon: "gateway".into(),
purpose: format!(
"version={};method={};operator={};evidence={}",
req.consent_version_hash, method, req.operator_of_record, req.consent_collection_evidence_path,
),
trace_id: trace_id.to_string(),
},
fields_accessed: vec![
"consent.biometric.status".into(),
"consent.biometric.retention_until".into(),
],
result: "given".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) => {
// Audit failed — roll back manifest to original state. The
// grant is best understood as "didn't happen" until the
// audit chain says it did. Rollback errors are logged but
// don't propagate; the user-facing failure is the audit.
tracing::error!("consent 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(ConsentResponse {
schema: CONSENT_RESPONSE_SCHEMA,
candidate_id: candidate_id.to_string(),
status_after: "Given".into(),
consent_version_hash: req.consent_version_hash,
given_at,
retention_until,
audit_row_hmac,
})
}
#[cfg(test)]
mod tests {
use super::*;
@ -1271,4 +1564,213 @@ mod tests {
assert_eq!(rows[1].prev_chain_hash, upload.audit_row_hmac);
assert_eq!(writer.verify_chain("WORKER-E9").await.unwrap(), 2);
}
// ─── Consent recording tests (Gate 2 backend) ──────────────────
fn fixture_consent_request() -> ConsentRequest {
ConsentRequest {
consent_version_hash: "abcdef0123456789".repeat(4), // 64-char fake SHA-256
consent_collection_method: "electronic_signature".into(),
consent_collection_evidence_path: "/tmp/sig_receipt_WORKER-X.pdf".into(),
operator_of_record: "OperatorA".into(),
}
}
#[tokio::test]
async fn consent_happy_path_flips_status_and_records_audit() {
// Subject starts at NeverCollected. Consent grant flips to Given,
// sets retention_until = given_at + 18 months, appends a
// biometric_consent_grant audit row, returns 200 with the grant
// metadata + chained hmac.
let state = fixture_state("consent_happy").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-C1", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
let writer = state.writer.clone();
let registry = state.registry.clone();
let resp = process_consent(&state, "WORKER-C1", Some(TEST_TOKEN), "trace-c1", fixture_consent_request())
.await.unwrap();
assert_eq!(resp.schema, "biometric_consent_response.v1");
assert_eq!(resp.candidate_id, "WORKER-C1");
assert_eq!(resp.status_after, "Given");
assert_eq!(resp.consent_version_hash.len(), 64);
assert!(!resp.audit_row_hmac.is_empty());
// retention_until is given_at + 18 months (540 days).
let elapsed = resp.retention_until - resp.given_at;
assert_eq!(elapsed.num_days(), CONSENT_RETENTION_DAYS);
// Manifest reflects the grant. Note: registry::put_subject
// assigns its OWN `updated_at = Utc::now()`, so the manifest's
// updated_at lands a few microseconds after the response's
// given_at — assert ordering, not equality.
let m = registry.get_subject("WORKER-C1").await.unwrap();
assert_eq!(m.consent.biometric.status, BiometricConsentStatus::Given);
assert_eq!(m.consent.biometric.retention_until, Some(resp.retention_until));
assert!(m.updated_at >= resp.given_at,
"manifest.updated_at ({}) should be >= response.given_at ({})", m.updated_at, resp.given_at);
// Audit row is correctly shaped.
assert_eq!(writer.verify_chain("WORKER-C1").await.unwrap(), 1);
let rows = writer.read_rows_in_range("WORKER-C1", None, None).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].accessor.kind, "biometric_consent_grant");
assert!(rows[0].accessor.purpose.contains("version="));
assert!(rows[0].accessor.purpose.contains("method=electronic_signature"));
assert!(rows[0].accessor.purpose.contains("operator=OperatorA"));
assert_eq!(rows[0].accessor.trace_id, "trace-c1");
assert_eq!(rows[0].fields_accessed, vec!["consent.biometric.status".to_string(), "consent.biometric.retention_until".to_string()]);
assert_eq!(rows[0].result, "given");
}
#[tokio::test]
async fn consent_missing_token_rejected() {
let state = fixture_state("consent_no_token").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-C2", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
let err = process_consent(&state, "WORKER-C2", None, "", fixture_consent_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
assert_eq!(err.1.error, "auth_failed");
}
#[tokio::test]
async fn consent_wrong_token_rejected() {
let state = fixture_state("consent_wrong_token").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-C3", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
let err = process_consent(&state, "WORKER-C3", Some("nope-not-the-token"), "", fixture_consent_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn consent_subject_not_found_returns_404() {
let state = fixture_state("consent_no_subject").await;
let err = process_consent(&state, "GHOST-WORKER", Some(TEST_TOKEN), "", fixture_consent_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::NOT_FOUND);
assert_eq!(err.1.error, "subject_not_found");
}
#[tokio::test]
async fn consent_missing_version_hash_400() {
let state = fixture_state("consent_no_version").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-C4", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
let mut req = fixture_consent_request();
req.consent_version_hash = " ".into();
let err = process_consent(&state, "WORKER-C4", Some(TEST_TOKEN), "", req)
.await.unwrap_err();
assert_eq!(err.0, StatusCode::BAD_REQUEST);
assert!(err.1.detail.contains("consent_version_hash"));
}
#[tokio::test]
async fn consent_missing_method_400() {
let state = fixture_state("consent_no_method").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-C5", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
let mut req = fixture_consent_request();
req.consent_collection_method = "".into();
let err = process_consent(&state, "WORKER-C5", Some(TEST_TOKEN), "", req)
.await.unwrap_err();
assert_eq!(err.0, StatusCode::BAD_REQUEST);
assert!(err.1.detail.contains("consent_collection_method"));
}
#[tokio::test]
async fn consent_invalid_method_400() {
// Methods are constrained to a closed set so operator typos
// can't silently weaken evidentiary defensibility (e.g.
// "electric_signature" vs "electronic_signature").
let state = fixture_state("consent_bad_method").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-C6", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
let mut req = fixture_consent_request();
req.consent_collection_method = "carrier_pigeon".into();
let err = process_consent(&state, "WORKER-C6", Some(TEST_TOKEN), "", req)
.await.unwrap_err();
assert_eq!(err.0, StatusCode::BAD_REQUEST);
assert!(err.1.detail.contains("must be one of"));
assert!(err.1.detail.contains("carrier_pigeon"));
}
#[tokio::test]
async fn consent_missing_operator_400() {
let state = fixture_state("consent_no_op").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-C7", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
let mut req = fixture_consent_request();
req.operator_of_record = "".into();
let err = process_consent(&state, "WORKER-C7", 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 consent_already_given_returns_409() {
// Re-granting consent when status is already Given is refused.
// The expected flow for re-collection is: erase → grant fresh
// consent under the new template version. This forces the
// erase audit row to land before any new grant, preserving
// chain ordering.
let state = fixture_state("consent_already").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-C8", BiometricConsentStatus::Given, SubjectStatus::Active)).await;
let err = process_consent(&state, "WORKER-C8", Some(TEST_TOKEN), "", fixture_consent_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::CONFLICT);
assert_eq!(err.1.error, "consent_already_given");
assert_eq!(err.1.consent_status.as_deref(), Some("Given"));
}
#[tokio::test]
async fn consent_post_withdrawal_requires_erase_returns_409() {
// After Withdrawn, fresh consent requires explicit erase first.
// Otherwise the audit chain wouldn't capture the cycle of
// withdraw → erase → grant; the grant would land directly on
// a manifest that still references the prior collection.
let state = fixture_state("consent_after_withdraw").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-C9", BiometricConsentStatus::Withdrawn, SubjectStatus::Active)).await;
let err = process_consent(&state, "WORKER-C9", Some(TEST_TOKEN), "", fixture_consent_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::CONFLICT);
assert_eq!(err.1.error, "consent_post_withdrawal_requires_erase");
assert_eq!(err.1.consent_status.as_deref(), Some("Withdrawn"));
}
#[tokio::test]
async fn consent_subject_inactive_returns_403() {
// Subject status takes precedence over consent status — an
// Erased subject can't be re-consented even if biometric.status
// happens to be NeverCollected (which it would be post-erasure).
let state = fixture_state("consent_inactive").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-C10", BiometricConsentStatus::NeverCollected, SubjectStatus::Erased)).await;
let err = process_consent(&state, "WORKER-C10", Some(TEST_TOKEN), "", fixture_consent_request())
.await.unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
assert_eq!(err.1.error, "subject_inactive");
}
#[tokio::test]
async fn consent_grant_then_upload_is_the_intended_intake_flow() {
// End-to-end check of the post-cutover flow: candidate signs
// consent (process_consent flips status to Given) → operator
// uploads photo (process_upload now passes the consent gate).
// Audit chain captures: consent_grant → biometric_collection.
let state = fixture_state("consent_then_upload").await;
let _ = state.registry.put_subject(fixture_manifest("WORKER-C11", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
let writer = state.writer.clone();
// 1. Consent grant.
let grant = process_consent(&state, "WORKER-C11", Some(TEST_TOKEN), "trace-grant", fixture_consent_request())
.await.unwrap();
// 2. Photo upload — should succeed now.
let upload = process_upload(&state, "WORKER-C11", Some(TEST_TOKEN), Some("image/jpeg"), &grant.consent_version_hash, "trace-upload", &jpeg_bytes())
.await.unwrap();
assert_eq!(upload.consent_version_hash, grant.consent_version_hash);
// Audit chain has 2 rows in the right order.
assert_eq!(writer.verify_chain("WORKER-C11").await.unwrap(), 2);
let rows = writer.read_rows_in_range("WORKER-C11", None, None).await.unwrap();
assert_eq!(rows[0].accessor.kind, "biometric_consent_grant");
assert_eq!(rows[1].accessor.kind, "biometric_collection");
// Upload row chains off the consent grant's hmac.
assert_eq!(rows[1].prev_chain_hash, grant.audit_row_hmac);
}
}