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:
@@ -87,10 +87,12 @@ canvas{display:none}
|
||||
<!-- Step 1: operator auth -->
|
||||
<div id="screen-token" class="screen active">
|
||||
<h2>Operator authentication</h2>
|
||||
<p class="lede">Paste the legal-tier audit token. Stored in this tab's session only; cleared on close. Never persists to disk.</p>
|
||||
<p class="lede">Paste the legal-tier audit token + your name. Both stored in this tab's session only; cleared on close. Never persists to disk. Your name is recorded as <code>operator_of_record</code> in the audit row for legal traceability.</p>
|
||||
<div class="card">
|
||||
<label for="token-input">Legal audit token</label>
|
||||
<input type="password" id="token-input" placeholder="44-char token from /etc/lakehouse/legal_audit.token" autocomplete="off">
|
||||
<label for="op-name" style="margin-top:14px">Your name (operator of record)</label>
|
||||
<input type="text" id="op-name" placeholder="First Last" autocomplete="off">
|
||||
<div style="margin-top:14px">
|
||||
<button id="token-submit">Continue →</button>
|
||||
</div>
|
||||
@@ -241,6 +243,7 @@ const CANDIDATE_ID = (function(){
|
||||
// ── State ─────────────────────────────────────────────────────────
|
||||
const state = {
|
||||
token: null, // sessionStorage-backed
|
||||
operator: null, // operator_of_record — captured at token paste
|
||||
consentVersionHash: null, // SHA-256 of the rendered consent template
|
||||
consentResp: null, // server response from /consent
|
||||
photoBlob: null, // captured/selected photo
|
||||
@@ -277,12 +280,17 @@ async function sha256Hex(text) {
|
||||
}
|
||||
document.getElementById('cid-display').textContent = CANDIDATE_ID;
|
||||
|
||||
// Restore token from sessionStorage if previously set in this tab.
|
||||
// Restore token + operator from sessionStorage if previously set in this tab.
|
||||
const saved = sessionStorage.getItem('lh_legal_token');
|
||||
const savedOp = sessionStorage.getItem('lh_operator_name');
|
||||
if (saved) {
|
||||
state.token = saved;
|
||||
document.getElementById('token-input').value = '••••••••';
|
||||
}
|
||||
if (savedOp) {
|
||||
state.operator = savedOp;
|
||||
document.getElementById('op-name').value = savedOp;
|
||||
}
|
||||
|
||||
// Compute consent template hash (SHA-256 of the rendered consent block).
|
||||
// This is what we'll send as consent_version_hash. Counsel later
|
||||
@@ -293,18 +301,25 @@ async function sha256Hex(text) {
|
||||
document.getElementById('version-hash-display').textContent = state.consentVersionHash.substring(0,16) + '…';
|
||||
})();
|
||||
|
||||
// ── Step 1: token ──────────────────────────────────────────────────
|
||||
// ── Step 1: token + operator ───────────────────────────────────────
|
||||
document.getElementById('token-submit').addEventListener('click', () => {
|
||||
clearErr('token-error');
|
||||
const v = document.getElementById('token-input').value.trim();
|
||||
// If user kept the masked placeholder, use the saved token.
|
||||
const tok = v === '••••••••' ? state.token : v;
|
||||
const op = document.getElementById('op-name').value.trim();
|
||||
if (!tok || tok.length < 32) {
|
||||
err('token-error', 'Token must be ≥32 characters.');
|
||||
return;
|
||||
}
|
||||
if (!op || op.length < 2) {
|
||||
err('token-error', 'Operator name is required (recorded as operator_of_record in audit row).');
|
||||
return;
|
||||
}
|
||||
state.token = tok;
|
||||
state.operator = op;
|
||||
sessionStorage.setItem('lh_legal_token', tok);
|
||||
sessionStorage.setItem('lh_operator_name', op);
|
||||
show('screen-consent');
|
||||
});
|
||||
|
||||
@@ -336,7 +351,7 @@ document.getElementById('grant-btn').addEventListener('click', async () => {
|
||||
consent_version_hash: state.consentVersionHash,
|
||||
consent_collection_method: 'click_acceptance',
|
||||
consent_collection_evidence_path: 'inline:sha256=' + evidenceHash,
|
||||
operator_of_record: 'intake_ui_operator',
|
||||
operator_of_record: state.operator,
|
||||
};
|
||||
const r = await fetch(`${GATEWAY}/biometric/subject/${encodeURIComponent(CANDIDATE_ID)}/consent`, {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user