Canonical-store subject-erasure surface — register item 27, build half

Date: 2026-08-18 Lane: canonical-erasure-it27 (branch claude/canonical-erasure-surface-it27) Spec executed: docs/reports/CANONICAL_ERASURE_LEG_2026-08-12.md §4 (all five requirements) Truth discipline: everything below is implemented unless labeled otherwise; the platform ceiling built, pre-benchmark applies. Nothing here is deployed.

What was built

  1. Store redaction primitive — Store.redact() in contracts/mizoki_contracts/store.py: tenant-checked (dotted-path support for nested tenant fields; a cross-tenant doc_id raises TenantMismatch before any write or audit record), overwrites with the caller's tombstoned body, then appends a doc.redact record to the audit chain. The chain is append-only and global, so erasure appends — never mutates — and verify_chain() stays intact (test-asserted). Absent docs return {"status": "absent"} (idempotent, no existence oracle).

  2. Erasure module — contracts/mizoki_contracts/erasure.py. The §4.4 design decision this lane owned: redaction, never deletion. Envelopes are content-addressed and the platform promises deterministic replay, so a matched document keeps its event_id, hashes, and time axes; payload becomes a tombstone, entity_ids are removed (conservative — a matched row is about the subject), and outbox/DLQ rows get envelope_json re-serialized from the redacted envelope so a later publish or DLQ replay ships the tombstone (both directions test-proved with a seeded PII marker). Receipt contract is the cell33 pattern (count → redact → recount; worst-wins fold; zero receipts = failure; nothing reports erased without the verifying recount). Firestore documents have no streaming-buffer analogue, so the tri-state collapses to erased/failed — anything unconfirmable is non-2xx.

  3. POST /api/v1/subjects/erase on services/service-canonical-ingestion/main.py — verify_caller + resolve_tenant, covers all three envelope copies (events, events_outbox, events_dlq, including the dead-row-in-both case), appends a subject.erase chain record carrying only the salted subject_ref and counts (the chain is immutable; raw identifiers written there could never themselves be erased — test-asserted). An audit-append failure downgrades the response to 502: an erasure the chain cannot attest is unproven. /health now declares subject_erasure: true and default_subject_salt_in_force (the CANONICAL_SUBJECT_SALT posture, same convention as Cell 38).

  4. Tests — tests/governance/test_canonical_subject_erasure.py, 23 tests, the first tests this service has ever had, deliberately placed in tests/governance/ because CI runs that suite today (tests/remediation/ is largely un-gated — the PR-689 lesson). Coverage both directions: three-copy redaction, tenant isolation (cross-tenant rows untouched and cross-tenant redaction hard-refused), idempotent re-erase, unknown-subject 200, empty-identifier 422, failure injection → 502 with failed receipts, scan-failure-before-verification → 502, corrupt outbox row cannot fake an erasure, redacted pending row publishes the tombstone, DLQ replay after redaction ships the tombstone, chain intact after everything, salt sensitivity.

Measured gates

Honest limits (open, with owners)

← All docsView source on GitHub →