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
-
Store redaction primitive —
Store.redact()incontracts/mizoki_contracts/store.py: tenant-checked (dotted-path support for nested tenant fields; a cross-tenant doc_id raisesTenantMismatchbefore any write or audit record), overwrites with the caller's tombstoned body, then appends adoc.redactrecord to the audit chain. The chain is append-only and global, so erasure appends — never mutates — andverify_chain()stays intact (test-asserted). Absent docs return{"status": "absent"}(idempotent, no existence oracle). -
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 itsevent_id, hashes, and time axes;payloadbecomes a tombstone,entity_idsare removed (conservative — a matched row is about the subject), and outbox/DLQ rows getenvelope_jsonre-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 reportserasedwithout the verifying recount). Firestore documents have no streaming-buffer analogue, so the tri-state collapses toerased/failed— anything unconfirmable is non-2xx. -
POST /api/v1/subjects/eraseonservices/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 asubject.erasechain record carrying only the saltedsubject_refand 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./healthnow declaressubject_erasure: trueanddefault_subject_salt_in_force(theCANONICAL_SUBJECT_SALTposture, same convention as Cell 38). -
Tests —
tests/governance/test_canonical_subject_erasure.py, 23 tests, the first tests this service has ever had, deliberately placed intests/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
tests/governance(CI-gated): 207 passed (184 pre-existing + 23 new), fresh py3.14 venv,pip install -e contracts+ CI's pins.tests/remediation/test_execution_adapters.py(the pre-deploy gate of the workflow this merge dispatches): passed in the same venv.tests/remediation/test_data002_outbox.py: passed.test_contracts_hardening.py: 1 failure, reproduced byte-identical on untouchedorigin/main(pre-existing, not CI-gated; venv lacks google-cloud libs — recorded, not chased in this lane).- Hygiene greps (V1/V2) clean on every touched file;
claude_memory.py check --strictPASS.
Honest limits (open, with owners)
- NOT deployed.
service-canonical-ingestionhas no CI deploy workflow (measured; it ships only viaops/remediation/deploy_all.sh, which deploys all ten governance services together). Merging this lane deploys the action-runner (its workflow watchescontracts/**) but not the canonical service. The serving canonical revision keeps answering 404 on the new route until an operator deploy. Rule 04 says the fix for a missing CI path is a workflow — that is a.github/**protected-path change deliberately left out of this lane; it needs its own human-merged PR. - Gateway leg untouched, deliberately. The
canonical_eraserclient injection behindCANONICAL_ERASER_URLinservice-marketing-connectorsis the small, already-designed follow-up — left out because that path is claimed by the livecre-p2-takeoverlane. Thecustomers_redactcompliance leg keeps reportingpending_in_build(test-pinned in both connector suites) until the client lands and the canonical service is deployed. - Scale: subject matching scans via
list_alland filters in-process — correct and honest at today's measured near-zero canonical volume (register item 26: no BQ landing table, zero bus subscribers), stated in the module docstring; an indexed identifier query is required before high-volume ingest. CANONICAL_SUBJECT_SALTis an operator secret; until set,default_subject_salt_in_force: trueis served on/healthand stamped in every erasure response.- Any future BigQuery landing table joins the three-copy list (§4.3 of the spec) — the erasure module's
SUBJECT_COLLECTIONSis the single place to extend.