From 76cb5acb03bf1fdfd16f8b38a7e8ed54d067a891 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 5 May 2026 13:21:12 -0500 Subject: [PATCH] phase 1.6: add consent-record endpoint POST /biometric/subject/{id}/consent (Gate 2 backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=;method=;operator=;evidence=" - 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) --- crates/catalogd/src/biometric_endpoint.rs | 502 ++++++++++++++++++++++ 1 file changed, 502 insertions(+) diff --git a/crates/catalogd/src/biometric_endpoint.rs b/crates/catalogd/src/biometric_endpoint.rs index c4a0109..d50b1d5 100644 --- a/crates/catalogd/src/biometric_endpoint.rs +++ b/crates/catalogd/src/biometric_endpoint.rs @@ -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, + pub retention_until: chrono::DateTime, + pub audit_row_hmac: String, +} + +async fn record_consent( + State(state): State, + Path(candidate_id): Path, + 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 { + // 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); + } }