root 848a4583da phase 1.6 Gate 5: erasure endpoint POST /biometric/subject/{id}/erase
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>
2026-05-03 05:23:54 -05:00
..
2026-04-22 02:41:15 -05:00