symfonic.services.privacy¶
privacy ¶
Subject privacy: the erasure fence, the coverage registry, and the guard.
Runtime-service layer (LAY-ADR §1.4), and deliberately so. Port-registry rows
14 (ErasureFence) and 20 (SubjectDataStore) are owned here rather than by
platform because every storage adapter has to implement them, and SEC-TEN-5's
rule — backends enforce isolation without importing platform — applies with
even more force to erasure: a background worker must be able to ask "is this
subject deleted?" without dragging the HTTP layer into the process.
What lives here:
- :class:
InMemoryErasureFence— the atomic dual-condition commit (EFX-ER-3) and the tombstone-plus-generation transition (PRIV-4); - :class:
SubjectDataStoreRegistry— coverage as a registry (PRIV-1); - :class:
InMemorySubjectDataStore— the reference adapter (CON-S-4); - :class:
InMemoryErasureSagaStore— durable per-participant completion (PRIV-3); - :class:
SubjectGuard— the inward admission / read / write checks (PRIV-5); - :func:
run_subject_data_store_suite— CS-20, runnable against any adapter; - :class:
LegacyParticipantAdapter— the migration-window bridge for the stores already registered with the shipped erasure sweep (SEC-PRIV-5).
What does not live here: the saga that drives the registry to completion.
That is a platform service (symfonic.platform.erasure), because deciding to
erase a subject is an administrative act and this package is the machinery it
acts through.
ErasureFence ¶
Bases: Protocol
Registry row 14 — the erasure generation and the subject tombstone.
The write side is one method on purpose. Exposing "check" and "write" separately would make the time-of-check/time-of-use gap a supported usage of the port, and EFX-F-3 requires the gap to be closed by the atomic operation rather than by callers agreeing to be careful.
An implementation resolves a scope key to the nearest tombstone over its
lineage, not to its exact row. Erasure is subtree-scoped, so a tenant
tombstone governs every scope beneath it, and an exact-key implementation
fails open for sub-scopes — the sub-scope write commits, and the
subtree-scoped export reads the resurrected data straight back out.
:func:symfonic.services.privacy.lineage.scope_key_lineage is the walk.
complete_erasure
async
¶
conditional_write
async
¶
conditional_write(scope_key: str, *, expected_generation: int, apply: Callable[[], Any]) -> WriteOutcome
Commit apply only if the generation matches AND no tombstone exists.
Both conditions and the write happen in one linearizable operation — a transaction with a predicate, a conditional update, or a single-lock section, per backend. Never as separate steps.
"No tombstone exists" means none over the scope's lineage: a write at
tenant/team1 is refused by the tombstone published for tenant.
Source code in src/symfonic/services/privacy/ports.py
publish_tombstone
async
¶
read_generation
async
¶
What a writer observes at admission (EFX-ER-2).
The returned scope_key names the scope the tombstone was published
for, which may be an ancestor of the one asked about.
Source code in src/symfonic/services/privacy/ports.py
ErasureFenceDenied ¶
ErasureFenceDenied(message: str, *, reason: str, scope_key: str, expected_generation: int | None = None, observed_generation: int | None = None)
Bases: PrivacyError
A conditional write was refused by the fence (EFX-ER-4).
Carries reason — generation_mismatch or subject_tombstoned —
because the two are operationally different: the first says "somebody else
moved", the second says "this subject is gone and retrying is
resurrection".
Source code in src/symfonic/services/privacy/errors.py
ErasureGeneration
dataclass
¶
ErasureGeneration(scope_key: str, generation: int = 0, tombstoned: bool = False, tombstoned_at: float | None = None, reason: str = '')
EFX-ER-2 — what a writer observes before it commits.
The pair is read together and, at publication, advanced together: PRIV-4 makes the tombstone and the generation bump ONE linearizable transition, so there is no interval in which an observer can see a new generation without the tombstone that came with it.
ErasureReceipt
dataclass
¶
ErasureReceipt(participant_id: str, erased: int, generation: int, confirmed_absent: bool, detail: Mapping[str, Any] = dict())
What one store did, and whether absence was then verified.
erased is reporting (PRIV-6). confirmed_absent is the proof, and it
is a separate field because a store that returns a confident count and
still holds rows is precisely the failure SEC-PRIV-3 exists to catch.
ErasureSagaError ¶
ErasureSagaStore ¶
Bases: Protocol
The saga's durable per-participant completion state (PRIV-3).
Runtime-service-owned because the guard reads it — an in-flight invocation asks "is any store still unconfirmed?" before serving a read, and the guard may not import platform to find out.
claim_generation_advance
async
¶
Compare-and-set: True exactly once per saga, for the caller that won.
There is no plain "mark it advanced" setter on this port, on purpose. Exactly-once completion cannot be assembled from a read plus a write — the await between them is where an operator retry and a scheduled resume both decide to advance, and the generation ends two ahead of the erasure that caused it. The store owns the transition because the store is the only thing both runs share.
Source code in src/symfonic/services/privacy/ports.py
incomplete_under
async
¶
Any saga at or below scope_key that still has a store unconfirmed.
The descendant half of read suppression, and it is not symmetry for its
own sake. Reads match subtree-wide with narrows, so a reader at
tenant sees the rows an in-flight erasure of tenant/team1 has
not destroyed yet. Walking ancestors alone suppresses the readers at or
below the erased scope and serves the one above it — the same rows,
through a wider query.
Matching is segment-wise (SEC-TEN-1): acme-evil is not under
acme, whatever a string comparison would say.
Source code in src/symfonic/services/privacy/ports.py
start
async
¶
ExportFragment
dataclass
¶
ExportFragment(participant_id: str, records: tuple[Mapping[str, Any], ...] = (), exportable: bool = True, note: str = '')
One store's contribution to a subject export (SEC-PRIV-2).
FenceAuditBindingError ¶
Bases: PrivacyError
Something tried to take over a fence's denial recorder (HOST-3).
Two hosts in one process — a test suite, a sidecar, an adopter mounting two agents — can share a fence. A silent rebind sends the first host's EFX-ER-4 denials to the second host's sink, which is worse than either host having no audit: the events exist, in the wrong place, and nobody is looking for them there. Rebinding is therefore an explicit act, not a constructor side effect.
FenceDenial
dataclass
¶
FenceDenial(scope_key: str, reason: DenialReason, expected_generation: int, observed_generation: int)
One refused write, kept so the audit seam has something to emit.
InMemoryErasureFence ¶
InMemoryErasureFence(state: MutableMapping[str, ErasureGeneration] | None = None, *, on_denial: DenialRecorder | None = None, denial_buffer: int = _DENIAL_BUFFER)
Registry row 14, reference implementation.
state is injectable so a durable backend can own the bytes while this
class owns the rule. A restart over the same mapping keeps refusing, which
is what makes the tombstone persistent rather than a process fact.
Source code in src/symfonic/services/privacy/fence.py
has_denial_recorder
property
¶
Whether a host has already claimed this fence's denial stream.
complete_erasure
async
¶
Advance once more, and keep the tombstone forever (PRIV-7).
Source code in src/symfonic/services/privacy/fence.py
conditional_write
async
¶
conditional_write(scope_key: str, *, expected_generation: int, apply: Callable[[], Any]) -> WriteOutcome
The atomic dual-condition commit (EFX-ER-3).
apply must be synchronous. An awaitable would suspend inside the
very section whose indivisibility is the contract, and "atomic except
while it awaits" is not atomic.
Source code in src/symfonic/services/privacy/fence.py
denials ¶
drain_denials ¶
Take the window and clear it, for a host that batches its own emit.
publish_tombstone
async
¶
One transition: tombstone planted, generation advanced (PRIV-4).
Idempotent, because a resumed saga re-publishes and a second advance would invalidate every generation an in-flight reader holds for no reason at all.
Source code in src/symfonic/services/privacy/fence.py
set_denial_recorder ¶
Late-bind the audit seam (EFX-ER-4). Once, unless told otherwise.
A fence is usually built before the service that audits it — the host
wires storage first — so the recorder is attachable rather than
constructor-only. What it is not is silently replaceable: two hosts in
one process can share a fence, and a second attachment would redirect
the first host's denials into the second host's sink without either of
them saying so. replace=True is how a host that means it says so.
Source code in src/symfonic/services/privacy/fence.py
InMemoryErasureSagaStore ¶
Reference implementation (CON-S-4); state is injectable for durability.
The lock is a thread lock, not a per-loop asyncio.Lock: an operator
retry and a scheduled resume can land in different loops (or threads), and
:meth:claim_generation_advance is only a compare-and-set if the compare
and the set are indivisible for every caller, not just for co-scheduled
ones. Nothing awaits inside a section, so a plain lock cannot deadlock.
Source code in src/symfonic/services/privacy/saga_store.py
claim_generation_advance
async
¶
Compare-and-set the completion transition; True for the winner only.
This is the only way to make the transition — there is deliberately no
plain "mark it advanced" setter to reach for. The saga cannot do it as
"read the flag, then advance": an operator retry racing a scheduled
resume — a race PrivacyService.resume explicitly invites — would
have both runs observe False across the await and both call
complete_erasure, advancing the generation twice. The decision has to
be one indivisible step in the store, which is the only place both runs
meet.
Source code in src/symfonic/services/privacy/saga_store.py
incomplete_under
async
¶
The descendant half of read suppression (see the port's docstring).
A scan, because the reference store is a mapping: a durable backend indexes the tenant segment and answers the same question with a query. Keys that are not scope keys are compared exactly rather than skipped — an unparseable key still names a saga somebody started, and dropping it would fail open for whoever recorded it.
Source code in src/symfonic/services/privacy/saga_store.py
mark_exhausted
async
¶
Bounded retry ran out. Recorded, never raised.
Raising here would abort the sweep and skip every participant sorted after the broken one — the exact failure mode the shipped legacy sweep already learned to avoid.
Source code in src/symfonic/services/privacy/saga_store.py
start
async
¶
Create, or resume and widen.
Widen, because PRIV-1's registry is live: a store registered while the saga was running still holds the subject's data, and a saga that captured its participant list at t=0 would leave it holding it.
Source code in src/symfonic/services/privacy/saga_store.py
InMemorySubjectDataStore ¶
InMemorySubjectDataStore(participant_id: str, *, holds: str = 'records', replica_of: str | None = None, backup_retention_days: int | None = None)
A dictionary of rows keyed by scope key, behind the row-20 port.
Source code in src/symfonic/services/privacy/reference.py
put ¶
The raw effect. Only ever called inside a conditional write.
write
async
¶
write(scope: SubjectScope, record: Mapping[str, Any], *, fence: ErasureFence, expected_generation: int) -> WriteOutcome
EFX-ER-3: the store never commits except through the fence.
Source code in src/symfonic/services/privacy/reference.py
LegacyParticipantAdapter ¶
A TenantErasureParticipant seen through the row-20 port.
The legacy contract is keyed by tenant_id alone, so the adapter passes
the subject's tenant segment and documents the consequence: erasing a
sub-tenant through a legacy participant erases the whole tenant's rows in
that store. That is the conservative direction — over-erasure of the
caller's own subtree, never under-erasure — and it is recorded here rather
than discovered later.
Source code in src/symfonic/services/privacy/legacy.py
ParticipantDescriptor
dataclass
¶
ParticipantDescriptor(participant_id: str, holds: str, durable: bool = True, replica_of: str | None = None, backup_retention_days: int | None = None, exportable: bool = True)
PRIV-1/PRIV-7 — what a store declares about the subject data it holds.
replica_of and backup_retention_days exist so PRIV-7 is checkable
rather than aspirational: a read replica names its primary, and a backup set
names the horizon the tombstone must outlive for a restore to re-apply the
erasure before the data is served again.
ParticipantProgress
dataclass
¶
ParticipantProgress(participant_id: str, confirmed: bool = False, exhausted: bool = False, attempts: int = 0, erased: int = 0, last_error: str = '')
One store's place in the saga.
confirmed means absence was verified (SEC-PRIV-3), not that an erase
call returned. exhausted means the bounded retry ran out — recorded
rather than raised, because one unreachable backend must not abort the
other eight.
ParticipantRegistrationError ¶
Bases: PrivacyError
A store is missing from, or duplicated in, the coverage registry.
PRIV-1 makes coverage checkable by making it declared. A silent overwrite would drop a store out of the erasure path with no diagnostic at all.
PrivacyError ¶
ReadSuppressedError ¶
Bases: PrivacyError
An erasure is in flight and at least one store is unconfirmed.
Distinct from :class:SubjectErasedError on purpose: this one is a
transient fail-closed state an operator can act on by driving the saga to
completion, and it maps to a different status than "this subject is gone".
Source code in src/symfonic/services/privacy/errors.py
SagaState
dataclass
¶
SagaState(scope_key: str, started_at: float, progress: dict[str, ParticipantProgress] = dict(), generation_advanced: bool = False)
SubjectDataStore ¶
Bases: Protocol
Registry row 20 — one store's side of export and erasure.
confirm_absent is what turns SEC-PRIV-3's verification-by-absence from a
claim into a call. A store that only reports how many rows it deleted
cannot answer the question an auditor actually asks.
SubjectDataStoreRegistry ¶
The set of stores the erasure saga enumerates.
An instance, not a module global: HOST-3 forbids process-global mutable state for per-deployment facts, and two hosts in one process (a test suite, a sidecar, an adopter mounting two agents) must not share an erasure surface neither of them declared.
Source code in src/symfonic/services/privacy/registry.py
participants ¶
Stable order, so a resumed saga sweeps in the same sequence.
register ¶
Add a participant. A duplicate id is an error, not an overwrite.
Silently replacing would drop a live store out of the erasure path and leave a registry that looks complete — the worst of both.
Source code in src/symfonic/services/privacy/registry.py
SubjectDataStoreWriter ¶
Bases: Protocol
Row 20's write obligation, for the stores that accept writes.
Separate from :class:SubjectDataStore rather than folded into it, because
not every participant has a write path — a legacy erase-and-count adapter
implements the four methods above and nothing else, and demanding a writer
of it would misreport a complete participant as a broken one.
Where it does exist, EFX-ER-3 says what it must be: the commit goes
through :meth:ErasureFence.conditional_write, never straight to the
backend. CS-20 asks for this entry point by name, so a store that owns
subject rows and does not declare it fails the suite's first write case
instead of crashing it.
write
async
¶
write(scope: SubjectScope, record: Any, *, fence: ErasureFence, expected_generation: int) -> WriteOutcome
Commit record for scope through the fence, or not at all.
SubjectErasedError ¶
Bases: PrivacyError
The subject carries a tombstone: no admission, no read, no write.
Terminal by design. There is no "the erasure finished so it is safe again" branch, because that branch is exactly how a restored backup serves data the subject asked to have destroyed (PRIV-7).
Source code in src/symfonic/services/privacy/errors.py
SubjectGuard ¶
Constructor-injected, narrow, and holding no state of its own.
Source code in src/symfonic/services/privacy/guard.py
guarded_write
async
¶
Observe the generation, then commit under both conditions.
Reading the generation first is not a check-then-write: the read is EFX-ER-2's admission observation, and the decision is made inside the conditional write against whatever is true at commit time. A writer that loses the race is denied there, not here.
A denied write is never retried. EFX-ER-4 is unambiguous: retrying against the new generation is resurrection, and a caller that wants to proceed must re-derive from post-erasure sources instead.
Source code in src/symfonic/services/privacy/guard.py
require_admission
async
¶
SCOPE-14 step 2. Fail closed: a tombstoned subject does not run.
Source code in src/symfonic/services/privacy/guard.py
require_readable
async
¶
Reads: suppressed while any store is unconfirmed, refused once erased.
Both outcomes deny; the distinction is what an operator can do about it. An incomplete saga is a transient state somebody can clear by driving the remaining stores to confirmed absence, so it reports which stores are outstanding. A completed erasure is terminal — finishing the saga does not reopen the subject, it closes it — so the tombstone check is the fallthrough rather than the first gate.
Source code in src/symfonic/services/privacy/guard.py
unconfirmed_stores
async
¶
Diagnostics: which participants still owe a confirmed absence.
Source code in src/symfonic/services/privacy/guard.py
WriteOutcome
dataclass
¶
WriteOutcome(committed: bool, generation: int, denial_reason: DenialReason | None = None, result: Any = None)
The result of the atomic dual-condition commit (EFX-ER-3).
committed is the whole contract. A caller that wants to know why it
failed reads denial_reason; a caller that retries on
subject_tombstoned is performing resurrection and EFX-ER-4 forbids it.
adapt_legacy_registry ¶
Register every legacy participant with into; return the ids added.
Takes the registry structurally (participants()) so this module does not
import the shadow package — the erasure path must not acquire a dependency
on the cutover machinery in order to sweep it.
Source code in src/symfonic/services/privacy/legacy.py
run_subject_data_store_suite
async
¶
run_subject_data_store_suite(store_factory: Callable[[], SubjectDataStore], *, fence_factory: Callable[[], ErasureFence]) -> SuiteReport
Run every CS-20 case against a freshly built store, and report.
A fresh store per case: sharing one would let an earlier case's leftovers decide a later one, and a suite whose verdict depends on execution order is not evidence.
Source code in src/symfonic/services/privacy/suite.py
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 | |
scope_key_lineage ¶
:func:scope_lineage for a raw key.
A key that is not a scope key gets a one-element lineage rather than an exception: the fence is a safety control, and refusing to answer "is this tombstoned?" for an odd key would fail open at every call site that only wanted a boolean.
Source code in src/symfonic/services/privacy/lineage.py
scope_lineage ¶
scope's own key, then every ancestor key, most specific first.