Skip to content

symfonic.services.privacy.guard

guard

The inward guard: what an in-flight invocation or a worker actually calls.

The acceptance criterion is explicit that in-flight invocations and workers use this port without importing platform, and it is not a stylistic preference. A background consolidation worker that had to import the HTTP layer to ask "is this subject deleted?" would drag FastAPI into every deployment that runs one — and, worse, would make the check skippable by anything that could not import it.

Three checks, one for each place PRIV-5 requires one:

  • :meth:require_admission — no new invocation starts for a tombstoned subject;
  • :meth:require_readable — reads are suppressed while any store is unconfirmed, and refused outright once the subject is erased;
  • :meth:guarded_write — every write commits through the dual-condition conditional write, or does not commit.

All three ask about the scope's lineage, not just its key. Erasure is subtree-scoped, so a tenant erasure has to refuse an invocation, a read, and a write at tenant/team1 — and the saga whose completion gates read suppression is recorded under the erased scope's key, not the reader's.

SubjectGuard

SubjectGuard(*, fence: ErasureFence, saga_store: ErasureSagaStore | None = None)

Constructor-injected, narrow, and holding no state of its own.

Source code in src/symfonic/services/privacy/guard.py
def __init__(self, *, fence: ErasureFence, saga_store: ErasureSagaStore | None = None) -> None:
    self._fence = fence
    self._saga_store = saga_store

guarded_write async

guarded_write(scope: SubjectScope, *, apply: Callable[[], Any]) -> WriteOutcome

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
async def guarded_write(
    self, scope: SubjectScope, *, apply: Callable[[], Any]
) -> WriteOutcome:
    """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.
    """
    generation = await self._fence.read_generation(scope.scope_key)
    return await self._fence.conditional_write(
        scope.scope_key,
        expected_generation=generation.generation,
        apply=apply,
    )

require_admission async

require_admission(scope: SubjectScope) -> None

SCOPE-14 step 2. Fail closed: a tombstoned subject does not run.

Source code in src/symfonic/services/privacy/guard.py
async def require_admission(self, scope: SubjectScope) -> None:
    """SCOPE-14 step 2. Fail closed: a tombstoned subject does not run."""
    tombstone = await self._tombstone(scope)
    if tombstone is not None:
        raise SubjectErasedError(
            f"subject {scope.scope_key!r} was erased"
            f"{_under(tombstone, scope)}; admitting an invocation for it "
            "is how deleted data comes back",
            scope_key=scope.scope_key,
        )

require_readable async

require_readable(scope: SubjectScope) -> None

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
async def require_readable(self, scope: SubjectScope) -> None:
    """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.
    """
    state = await self._incomplete_saga(scope)
    if state is not None:
        raise ReadSuppressedError(
            f"an erasure for {state.scope_key!r} has stores that have not "
            "confirmed absence; serving a read now could return data the "
            "subject asked to have destroyed",
            scope_key=scope.scope_key,
            unconfirmed=state.unconfirmed(),
        )
    tombstone = await self._tombstone(scope)
    if tombstone is not None:
        raise SubjectErasedError(
            f"subject {scope.scope_key!r} was erased"
            f"{_under(tombstone, scope)} and is not readable",
            scope_key=scope.scope_key,
        )

unconfirmed_stores async

unconfirmed_stores(scope: SubjectScope) -> tuple[str, ...]

Diagnostics: which participants still owe a confirmed absence.

Source code in src/symfonic/services/privacy/guard.py
async def unconfirmed_stores(self, scope: SubjectScope) -> tuple[str, ...]:
    """Diagnostics: which participants still owe a confirmed absence."""
    state = await self._incomplete_saga(scope)
    return () if state is None else state.unconfirmed()