ADR-LII-001 — Subject rights across the ORACLE / LII cells


Context

The ORACLE / LII platform was already built and on main before this session. It is four Cloud Run cells under src/cells/cellNN/ (not services/cellNN_name/):

Cell Service Role SRPVDAL phase
33 intent-signal-ingest consent-gated signal + outcome ingestion into mizoki_intent SENSE
34 intent-scoring-api the Intent API: scores, cohorts, transitions, explain proxy, taxonomy REASON / serving
35 intent-graph intent graph + explanation paths (written as Neo4j; in-memory since the 2026-08-09 retirement — see the amendment above) REASON
36 intent-causal permanent holdouts, DR-Learner + CUPED estimators, incrementality VALIDATE / DECIDE evidence

(cell28 is an unrelated Sales Revenue Pipeline and has no part in this stack.)

Consent was already gated at ingest, satisfying the first half of CONSTITUTION.md Art. II.6. The second half — "GDPR/CCPA subject access and erasure are honored immediately" — had no implementation. Adding it touches all four cells, because each owns different stores:

Cell Stores holding subject data
33 intent_signals, intent_outcomes
34 intent_scores, intent_transitions
35 the subject's subgraph in the Cell 35 graph store (Neo4j as written; in-memory in every current deployment)
36 intent_outcomes, intent_holdouts

The forces in play:


Decision (a) — Extend cells 33–36 in place; do not create new cells

Owner decision, this session. The subject-rights surface is added to the four existing cells rather than shipped as a new "privacy cell" (which would have been cell 37+).

Why.

Consequences.


Decision (b) — Cell 34 is the cascade coordinator

POST /v1/intent/subject/{identity_id}:erase exists only on Cell 34 (src/cells/cell34/scoring_cell/main.py:293-310), which erases its own stores and then fans out to Cells 33, 35 and 36 (src/cells/cell34/scoring_cell/orchestrator.py:763-800).

Why Cell 34 and not one of the others.

Why every cell still keeps a local DELETE. The coordinator is a convenience over a uniform contract, not a privileged path. Each cell can be erased directly, which is what makes the cascade auditable: the coordinator's receipts and a direct call to the peer return the same receipt shape for the same store.

Fail-closed fan-out. An unset peer URL, an unreachable peer, and a peer that answers without receipts are all failed receipts, never skipped stores (orchestrator.py:693-711) — "this cell's copy of the subject may still exist, and the cascade must say so (TRUTH.md 5.4)". A peer receipt carrying an unrecognized status is coerced to failed (orchestrator.py:664-687): a peer cannot talk its way into a completed erasure.

Consequences.


Decision (c) — Contract shape: per-cell local endpoints + one coordinator

GET    /v1/intent/subject/{identity_id}          # all four cells — this cell's stores
DELETE /v1/intent/subject/{identity_id}          # all four cells — this cell's stores
POST   /v1/intent/subject/{identity_id}:erase    # Cell 34 only — the cascade

Why access is per-cell and deliberately NOT a fan-out. Each store answers for itself, so no cell can claim to speak for another's contents (orchestrator.py:581-586). A full subject file is assembled by calling all four GETs. An aggregating read would have to describe data it cannot see failing, which is exactly the honesty failure the erasure design exists to prevent.

Why one shared module. All receipt logic lives in src/shared/mizoki_intent/subject.py; each cell supplies only count / delete / select callables for its own stores. That is what makes the receipts byte-identical in shape across four cells with three different backends (BigQuery, Neo4j, in-memory), which is the property the coordinator relies on.

Why the subject-rights routes do NOT use the §6.1 response envelope. Every other Cell 34 data route wraps its payload in {data, model_version, calibration_version, claim_label, consent_basis, tenant_id, generated_at}. The receipt shape is a fixed cross-cell contract that Cells 33/35/36 return identically; wrapping only Cell 34's copy would break that symmetry (main.py:26-28).

Status semantics as part of the contract (subject.py:53-71):

Per-store status HTTP Meaning
erased 200 verified gone — the recount returned 0
pending_streaming_buffer 409 rows still present; retry after ~90 min. Not a success
failed 502 store unreachable / uncountable / undeletable, or peer unconfigured

The overall status is the worst across stores. Only a fully verified erasure is 2xx — a partial cascade is deliberately non-2xx so no caller can mistake it for a completed one.

Idempotency over 404. Erasing an unknown or already-erased identity is a zero-count success, never a 404 — a 404 would leak whether an identity exists to any caller who can guess an id (subject.py:29-32). The zero-count success is not short-circuited from the pre-count: the delete is issued and the recount runs anyway, so even the idempotent case reports a measured result. See decision (d), "The recount guarantee, stated exactly".

Consequences.


Decision (d) — Erasure uses count → delete → RE-COUNT

erase_with_receipt (src/shared/mizoki_intent/subject.py:133-201) counts the subject's rows, deletes them, then counts again, and reports what actually happened. The delete and the recount are unconditional — see "The recount guarantee, stated exactly" below for what that does and does not buy.

Why the recount is not optional.

Every branch is honest, including the ones that fail (subject.py:157-201):

Situation Receipt Rationale
Pre-count fails failed cannot even see the store
Pre-count is 0 the delete is still issued and the recount still runs; a recount of 0 gives erased with zero counts idempotent zero-count success — but earned by a post-delete observation, not assumed from the pre-count
Delete raises a streaming-buffer error pending_streaming_buffer + retry_after_seconds: 5400 retryable incompleteness, not a failure and never a success
Delete raises anything else failed
Post-count fails failed, detail "delete issued but could not be verified" an erasure that cannot be verified is never claimed
Post-count > 0 pending_streaming_buffer with the surviving count rows survived; say so
Post-count == 0 erased with the erased count the only success

The recount guarantee, stated exactly

An earlier revision of this ADR asserted "there is no code path that reports erased without a verifying recount" two lines below a table row that described exactly such a path — Pre-count is 0 → erased, which returned before issuing the delete and before recounting. That was a self-contradiction, and the guarantee as written was false.

It is now true, because the code changed. Verified by reading src/shared/mizoki_intent/subject.py on 2026-08-09 (file md5 5fb48ca6…, 22483 bytes):

A related fail-open hole was closed with it. overall_status([]) previously returned erased — the fold's identity element — so an empty receipt list would have rendered as 200 erased: an erasure asserted across zero stores. overall_status now returns failed on an empty list (subject.py:204-221), and erasure_response attaches the detail "no store receipts — nothing was verified, so this erasure is unproven" (subject.py:590-597). An empty receipt list is now a non-2xx failure. Note this was never reachable from a route — all four cells build fixed, non-empty receipt lists and _erase_peer returns a failed receipt rather than an empty list on every non-answer (src/cells/cell34/scoring_cell/orchestrator.py:701-760) — so closing it removed a latent contract defect, not a live one.

The module docstring's sentence at subject.py:25-27 now matches the code.

Why DELETE and not UPDATE/tombstone. Bitemporal event columns are append-only facts and are never rewritten in place (AGENTS.md 6.2), so erasure removes the rows themselves (subject.py:515-522). A tombstone column would leave the subject's data in the table.

Why the audit stores a salted hash. Erasure is a governed action and must be auditable, but "an erasure audit that stored the erased identifier would defeat the erasure it records" (subject.py:34-36). The audit row carries subject_ref = sha256(salt | tenant_id | identity_id) (subject.py:312-323) — stable, so repeat DSARs for one subject stay correlatable, and one-way, so the audit cannot re-identify. The row schema is a hard allowlist re-applied in code (subject.py:342-345, 396), and the DDL carries no topic_id, no payload, no signal content (src/cells/cell33/schema/intent_bigquery_ddl.sql:184-216).

Why audit failures do not block erasure. The receipt returned to the caller is the primary record; the audit table is the durable governance copy. An audit insert error is logged and swallowed (subject.py:397-408) so a missing table cannot stop a subject exercising their rights. The consequence is the reverse dependency recorded as docs/lii/RUNBOOK.md §0 item 1: until the table-9 DDL is applied, audit inserts log an error and fall through.

Consequences.


Compliance and verification status

← All docsView source on GitHub →