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>
Adds the Gate 2 frontend that operators use to capture candidate
biometric consent + photo at intake. Without this, the
/biometric/subject/{id}/consent endpoint shipped in 76cb5ac has
no caller — operators would have to build curl scripts by hand.
Surfaces:
- mcp-server serves /biometric/intake as static HTML (mounted at
http://localhost:3700/biometric/intake; externally accessible via
nginx at https://devop.live/lakehouse/biometric/intake)
- URL must include ?candidate_id=WORKER-XXX
Page flow (4 screens):
- Step 1 — operator authentication. Pastes legal-tier audit token
into a password field; stored in sessionStorage (cleared on tab
close), never localStorage, never cookies.
- Step 2 — consent. Renders the v1 consent template text inline
(Disclosures 1/2/3 + plain-language summary, all matching
docs/policies/consent/biometric_consent_template_v1.md as of
this commit). Captures candidate printed name + checkbox accept;
computes SHA-256 of the rendered consent block as
consent_version_hash; computes SHA-256 of the click-acceptance
evidence (method + name + ts + user_agent + page_origin) and
records it as inline:sha256=<hash> evidence path. POSTs to
gateway /biometric/subject/{id}/consent.
- Step 3 — photo. Two paths: file upload OR getUserMedia camera
capture. Preview before submit. Skip-photo button for
consent-only intake (e.g. consent collected before equipment
available). POSTs to gateway /biometric/subject/{id}/photo.
- Step 4 — confirmation. Displays the audit row HMACs from both
endpoint responses + the verify_biometric_erasure.sh command
the operator can run for chain attestation.
Design choices:
- No framework, no build step. Single self-contained HTML file
~22KB. Matches the existing mcp-server precedent
(onboard.html, console.html, etc.).
- Neo-brutalist dark style matching mcp-server's other pages
(tracked dark surfaces, monospace technical metadata, sharp
borders). Consistent with j's UI preferences.
- Server-authoritative timestamps. The page sends its own UA +
click ts as evidence, but the canonical given_at on the
manifest comes from the gateway's Utc::now() (per
process_consent). Page displays whatever the gateway returns.
- Gateway URL configurable via ?gw= query param; defaults to
http://localhost:3100 for the same-box workstation pattern.
External-access deployment requires a separate nginx route
for the gateway (out of scope for v1 to avoid touching the
devop.live nginx config).
Verified live:
- GET http://localhost:3700/biometric/intake?candidate_id=WORKER-100
returns 200 + 22KB HTML body
- GET https://devop.live/lakehouse/biometric/intake?candidate_id=WORKER-100
returns 200 (nginx /lakehouse/ route proxies to :3700)
- Gateway CORS preflight for POST /biometric/subject/{id}/consent
from origin http://localhost:3700: 200 with allow-methods/headers/origin: *
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>