SEC-PRIV-5 — the erasure contract, and its wiring into legacy deletion.
The recording store is governed tenant data, so it must be reachable from the
tenant's right-to-erasure path before it may hold a real recording. That
means two things, and this module owns both:
- a participant protocol and a process-level registry, so the existing
DELETE /tenants/me/data handler can erase stores it was never written to
know about; and
- an attestation produced by actually running an erase round-trip through that
registry — not by a boolean somebody set.
The contract here is the one T4.1.3 consumes unchanged when tenancy and
privacy move out of the legacy router: erase_tenant returns the number of
rows removed, count_for_tenant proves the removal, and neither takes a
transport object.
DeletionWiringAttestation
dataclass
DeletionWiringAttestation(participant_id: str, registry_id: str, verified_at: datetime, probe_tenant: str, seeded: int, residual: int)
Evidence that erasure actually reaches this participant.
ErasureParticipantRegistry
ErasureParticipantRegistry(registry_id: str = 'default')
The set of stores the tenant-erasure path must sweep.
Source code in src/symfonic/services/shadow/privacy.py
| def __init__(self, registry_id: str = "default") -> None:
self._registry_id = registry_id
self._participants: dict[str, TenantErasureParticipant] = {}
|
erase_all
async
erase_all(tenant_id: str) -> dict[str, int]
Sweep every participant. Returns participant_id -> rows removed.
Source code in src/symfonic/services/shadow/privacy.py
| async def erase_all(self, tenant_id: str) -> dict[str, int]:
"""Sweep every participant. Returns ``participant_id -> rows removed``."""
return (await self.sweep(tenant_id)).counts
|
sweep
async
sweep(tenant_id: str) -> ErasureSweep
Erase from every participant, surviving one that fails.
A participant is somebody else's store — a remote backend having a bad
minute must not abort the tenant's erasure, skip every participant
sorted after it, and lose the completion trail. Each failure is logged
and reported by id instead.
Source code in src/symfonic/services/shadow/privacy.py
| async def sweep(self, tenant_id: str) -> ErasureSweep:
"""Erase from every participant, surviving one that fails.
A participant is somebody else's store — a remote backend having a bad
minute must not abort the tenant's erasure, skip every participant
sorted after it, and lose the completion trail. Each failure is logged
and reported by id instead.
"""
result = ErasureSweep()
for participant in self.participants():
try:
result.counts[participant.participant_id] = await participant.erase_tenant(
tenant_id
)
except Exception as exc: # noqa: BLE001 - erasure must not abort mid-sweep
logger.warning(
"Erase: participant %s failed for tenant %s",
participant.participant_id,
tenant_id,
exc_info=True,
)
result.failed[participant.participant_id] = f"{type(exc).__name__}: {exc}"
return result
|
ErasureSweep
dataclass
ErasureSweep(counts: dict[str, int] = dict(), failed: dict[str, str] = dict())
The outcome of one sweep: what was erased, and what refused to be.
A sweep is deliberately not all-or-nothing. Erasure is the one operation
where a partial success must still be reported rather than rolled back —
the rows that went are gone — so a participant that raises is recorded by
id and the sweep continues to the rest.
TenantErasureParticipant
Bases: Protocol
A store that holds tenant-scoped data and can be erased on request.
count_for_tenant
async
count_for_tenant(tenant_id: str) -> int
How many rows this participant still holds for tenant_id.
Source code in src/symfonic/services/shadow/privacy.py
| async def count_for_tenant(self, tenant_id: str) -> int:
"""How many rows this participant still holds for ``tenant_id``."""
...
|
erase_tenant
async
erase_tenant(tenant_id: str) -> int
Remove every row owned by tenant_id; return how many went.
Source code in src/symfonic/services/shadow/privacy.py
| async def erase_tenant(self, tenant_id: str) -> int:
"""Remove every row owned by ``tenant_id``; return how many went."""
...
|
verify_deletion_wiring
async
verify_deletion_wiring(participant: TenantErasureParticipant, *, registry: ErasureParticipantRegistry = PRIVACY_DELETION_PARTICIPANTS, probe_tenant: str = 'deletion-wiring-probe', seed: int = 1) -> DeletionWiringAttestation
Run a real erase round-trip and attest to the result.
Raises when the participant is not registered — an attestation for a store
the deletion path cannot reach would be worse than none at all.
Source code in src/symfonic/services/shadow/privacy.py
| async def verify_deletion_wiring(
participant: TenantErasureParticipant,
*,
registry: ErasureParticipantRegistry = PRIVACY_DELETION_PARTICIPANTS,
probe_tenant: str = "deletion-wiring-probe",
seed: int = 1,
) -> DeletionWiringAttestation:
"""Run a real erase round-trip and attest to the result.
Raises when the participant is not registered — an attestation for a store
the deletion path cannot reach would be worse than none at all.
"""
if not registry.holds(participant.participant_id):
raise RecordingStoreError(
f"{participant.participant_id!r} is not registered with the "
f"{registry.registry_id!r} deletion path; wiring cannot be attested"
)
seeded = await participant.count_for_tenant(probe_tenant)
if seeded < seed:
raise RecordingStoreError(
f"the deletion-wiring probe needs at least {seed} seeded row(s) for "
f"{probe_tenant!r}; found {seeded}. Attesting against an empty store "
"would prove nothing."
)
await registry.erase_all(probe_tenant)
residual = await participant.count_for_tenant(probe_tenant)
return DeletionWiringAttestation(
participant_id=participant.participant_id,
registry_id=registry.registry_id,
verified_at=datetime.now(UTC),
probe_tenant=probe_tenant,
seeded=seeded,
residual=residual,
)
|