Per docs/runbooks/BIPA_DESTRUCTION_RUNBOOK.md. BIPA-defensible
erasure: clears biometric collection (and optionally full PII record),
unlinks the photo file, records the destruction in the per-subject
HMAC chain. The audit row is the legal proof of compliant destruction
even after the underlying data is gone.
Two scopes:
biometric_only (default): clears biometric_collection field, unlinks
the photo, sets consent.biometric.status = withdrawn. Subject
remains active.
full: above PLUS sets status = erased and consent.general_pii.status
= withdrawn. Manifest preserved (proof of destruction); subject
record is logically erased.
Triggers (recorded but not validated against a closed set):
retention_expiry | consent_withdrawal | rtbf | court_order
Body shape:
{
"trigger": "<token>",
"trigger_evidence_path": "<optional path>",
"operator_of_record": "<name>",
"witness": "<name>",
"scope": "biometric_only|full"
}
Response (biometric_erase_response.v1):
candidate_id, scope, trigger, erased_at, fields_cleared,
photo_unlinked, photo_unlink_error, status_after,
biometric_status_after, general_pii_status_after, audit_row_hmac
Order matters for BIPA defensibility:
1. Snapshot original manifest (rollback target)
2. Update manifest (logical erasure)
3. Append audit row (LEGAL proof of intent + scope + operator)
4. Best-effort secure overwrite + unlink photo file (irreversible last)
If audit append fails, manifest is rolled back to original state and
500 returned — the alternative (manifest erased without legal record)
is exactly the silent-failure mode the spec exists to prevent.
If photo unlink fails AFTER audit commits, the response carries
photo_unlinked=false + the error string; operator must manually shred.
Tracing logs the inconsistency loudly.
Tests: 21 unit tests now pass (10 erasure-specific):
- missing token / missing subject / 404
- missing trigger / missing operator / invalid scope (400)
- biometric_only happy path (file unlinked, fields cleared, audit kind=biometric_erasure)
- full scope (status=Erased, general_pii withdrawn, audit kind=full_erasure)
- idempotent on already-erased (audit row records "already_erased" result)
- no-photo case (photo_unlinked=true with no unlink error)
- chain links off prior audit row's row_hmac (NOT GENESIS)
Live verification (post-restart):
- POST /biometric/subject/WORKER-2/erase with consent_withdrawal trigger
→ 200 with all expected fields_cleared + photo_unlinked=true
- Manifest reflects: biometric_collection=null, consent.biometric.status=withdrawn
- GET /audit/subject/WORKER-2: chain_verified=true, 4 rows total,
latest kind=biometric_erasure with operator + trigger in purpose field
- Cross-runtime parity probe: 6/6 byte-identical post-change
Known follow-up (separate bug): photo upload endpoint overwrites
biometric_collection without handling a prior file's data_path —
multiple uploads for the same candidate orphan earlier files. The
erasure endpoint correctly unlinks what the manifest knows about;
operator must shred orphans manually until the upload endpoint
either rejects re-upload (preferred) or maintains a list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>