Skip to content

symfonic.services.privacy.ports

ports

Port registry rows 14 and 20, plus the saga's own state port.

All three are runtime-service-owned. Row 20's ownership is the load-bearing choice: an integration must be able to implement "erase this subject from my backend" without importing platform, for the same reason SEC-TEN-5 puts isolation enforcement in the backends. A platform-owned participant protocol would make every storage adapter depend on the HTTP layer.

Signatures follow CON-P: async-first, transport-neutral vocabulary, narrow enough that a test double is three methods rather than a mock framework.

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

complete_erasure(scope_key: str) -> ErasureGeneration

Advance the generation again on completion; the tombstone stays.

Source code in src/symfonic/services/privacy/ports.py
async def complete_erasure(self, scope_key: str) -> ErasureGeneration:
    """Advance the generation again on completion; the tombstone stays."""
    ...

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
async def conditional_write(
    self,
    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``.
    """
    ...

publish_tombstone async

publish_tombstone(scope_key: str, *, reason: str) -> ErasureGeneration

Plant the tombstone and advance the generation as ONE transition.

Source code in src/symfonic/services/privacy/ports.py
async def publish_tombstone(self, scope_key: str, *, reason: str) -> ErasureGeneration:
    """Plant the tombstone and advance the generation as ONE transition."""
    ...

read_generation async

read_generation(scope_key: str) -> ErasureGeneration

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
async def read_generation(self, scope_key: str) -> ErasureGeneration:
    """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.
    """
    ...

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

claim_generation_advance(scope_key: str) -> bool

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
async def claim_generation_advance(self, scope_key: str) -> bool:
    """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.
    """
    ...

incomplete_under async

incomplete_under(scope_key: str) -> SagaState | None

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
async def incomplete_under(self, scope_key: str) -> SagaState | None:
    """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.
    """
    ...

start async

start(scope_key: str, *, participants: Sequence[str]) -> SagaState

Create or resume; never restart a saga that is already under way.

Source code in src/symfonic/services/privacy/ports.py
async def start(self, scope_key: str, *, participants: Sequence[str]) -> SagaState:
    """Create or resume; never restart a saga that is already under way."""
    ...

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.

confirm_absent async

confirm_absent(scope: SubjectScope) -> bool

Prove it: is the subject's subtree gone from this store?

Source code in src/symfonic/services/privacy/ports.py
async def confirm_absent(self, scope: SubjectScope) -> bool:
    """Prove it: is the subject's subtree gone from this store?"""
    ...

describe

describe() -> ParticipantDescriptor

Declare what subject data this store holds (PRIV-1/PRIV-7).

Source code in src/symfonic/services/privacy/ports.py
def describe(self) -> ParticipantDescriptor:
    """Declare what subject data this store holds (PRIV-1/PRIV-7)."""
    ...

erase_subject async

erase_subject(scope: SubjectScope, fence: ErasureFence) -> ErasureReceipt

Erase the subject's subtree under the fence.

Source code in src/symfonic/services/privacy/ports.py
async def erase_subject(self, scope: SubjectScope, fence: ErasureFence) -> ErasureReceipt:
    """Erase the subject's subtree under the fence."""
    ...

export_subject async

export_subject(scope: SubjectScope) -> ExportFragment

Everything this store holds for the subject's subtree (SEC-PRIV-2).

Source code in src/symfonic/services/privacy/ports.py
async def export_subject(self, scope: SubjectScope) -> ExportFragment:
    """Everything this store holds for the subject's subtree (SEC-PRIV-2)."""
    ...

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.

Source code in src/symfonic/services/privacy/ports.py
async def write(
    self,
    scope: SubjectScope,
    record: Any,
    *,
    fence: ErasureFence,
    expected_generation: int,
) -> WriteOutcome:
    """Commit ``record`` for ``scope`` through the fence, or not at all."""
    ...