Skip to content

Resolvers

Resolvers — reach PII in external systems through one interface.

Protocol — implement these members in your own class; do not subclass.

class AttestingResolver(Resolver, Protocol):
covered_surface: CoveredSurface

A Resolver that declares its covered surface.

Attesting is an optional capability: the base Resolver protocol stays additive-only, so implementing this sub-protocol is never required. A resolver that implements it publishes a CoveredSurface — the PII-bearing fields its export and erasure claim to reach, the fields it knowingly does not, and any asymmetries. Call sites narrow with isinstance; a registered resolver without covered_surface is skipped, never an error, exactly as with RectifyingResolver.

The declaration is a mechanism for making claimed coverage explicit and testable (the conformance suite checks exports stay within it and exclusions stay absent). It can never prove the external system holds no personal data the resolver does not reach, and is never a compliance determination.

Fields:

  • covered_surface (CoveredSurface): The PII this resolver claims to reach, plus its declared gaps. Returns: The CoveredSurface whose resolver equals this resolver’s name.
class CoveredField(BaseModel):
category: PiiCategory
field: str = Field(min_length=1)

One field, with its category, that a resolver claims to cover.

A covered field declares a single PII-bearing ExportRecord field the resolver reaches for export and erasure. The field is an fnmatch.fnmatch glob matched against ExportRecord.field<effaced.ExportRecord.field>, so a dynamic surface — an S3 object key, a per-object metadata entry, a payment-method id — is declared once rather than enumerated. For example object.*.metadata.* matches every user-metadata entry of every object the resolver lists.

The glob is matched case-sensitively against the whole field path: * spans any run of characters (including the . separators, so a single * covers a multi-segment key), ? matches one character, and [seq] a character class. A plain literal with no wildcard matches exactly one field.

Fields:

  • category (PiiCategory): The PiiCategory the matched field is declared to hold; the conformance suite checks a covered record carries the same category.
  • field (str): The fnmatch.fnmatch glob the covered field path must match.
class CoveredSurface(BaseModel):
exclusions: tuple[SurfaceExclusion, ...] = ()
fields: tuple[CoveredField, ...] = Field(min_length=1)
notes: tuple[str, ...] = ()
resolver: str = Field(min_length=1)

The PII a resolver claims to reach, declared and conformance-tested.

A covered surface is one resolver’s explicit answer to “which PII-bearing fields in the external system does this resolver’s export and erasure actually reach, and which does it knowingly not reach?”. It pairs the covered fields (CoveredField globs with categories) with the explicit SurfaceExclusion gaps, plus free-text notes for asymmetries that are not a per-field exclusion — for example that the S3 resolver exports the current object versions but erases all versions and delete markers.

Boundary. This makes the resolver’s claimed coverage explicit and testable: the conformance suite checks every exported record of a present subject matches a covered field of the same category (subset), no record matches an exclusion (absence), and — for a fully-populated fixture — every covered field is exercised (enumeration). It can never prove the external system holds no personal data the resolver does not reach; coverage of an opaque third-party system is unknowable from the outside. It is a mechanism for declaring reach, never a compliance determination.

Fields:

  • exclusions (tuple[SurfaceExclusion, ...]): Fields the resolver explicitly does not reach, each with a human reason; no exported record may match one.
  • fields (tuple[CoveredField, ...]): The covered fields — at least one. Each is a glob over ExportRecord.field<effaced.ExportRecord.field> with the category the matched field is declared to hold.
  • notes (tuple[str, ...]): Free-text caveats and asymmetries that are not a per-field exclusion — read alongside the fields, never machine-checked.
  • resolver (str): The Resolver.name<effaced.Resolver.name> this surface describes; the conformance suite checks it equals the resolver under test.

Protocol — implement these members in your own class; do not subclass.

class RectifyingResolver(Resolver, Protocol):
...

A Resolver that can also rectify (Art. 16).

Rectification is an optional capability: the base Resolver protocol stays additive-only, so implementing this sub-protocol is never required. Call sites narrow with isinstance — a registered resolver without rectify_subject is skipped and recorded, never an error (ADR 0013).

Implementations MUST be convergent: re-applying corrections the external system already reflects returns success with already_consistent=True, the rectification analogue of erasure’s already_absent. The same error taxonomy applies — ResolverError only for non-retryable failures; transient errors propagate untranslated for the saga runner to retry.

async def rectify_subject(ref: SubjectRef, corrections: tuple[Correction, ...]) -> ResolverRectification

Apply the corrections to the subject in the external system.

Args:

  • ref (SubjectRef): Opaque subject reference in this resolver’s namespace.
  • corrections (tuple[Correction, ...]): Category-keyed corrected values to apply.

Returns:

  • ResolverRectification — The outcome; already_consistent=True when the system
  • ResolverRectification — already reflected every correction — that is success, not an
  • ResolverRectification — error.
def registry_from_settings(specs: Sequence[ResolverSpec], settings: Mapping[str, str] | None = None) -> RegistryBuild

Build a ResolverRegistry from application settings.

Each spec names the settings keys a resolver needs; this evaluates the specs in order against settings and registers the resolvers whose required keys are all present. Adding a resolver becomes a configuration change — a Stripe key in config registers the Stripe resolver — rather than wiring code.

This is declarative wiring, not discovery. Registration stays explicit and auditable: every resolver that can register is named in a spec the application authored. There is no entry-point scanning, no import-time discovery, and no resolver is registered that no spec declared. The returned outcomes are the audit surface — log them at startup to record what was wired and what was skipped.

Presence rule: A key is present when it is in settings AND its value is non-blank after str.strip. A blank or whitespace-only value counts as ABSENT (the compose-file KEY= disable convention); the resulting skip is recorded in the outcomes, never silent.

Per spec, exactly one of:

  • All required keys present (or none required): build is called with a mapping containing EXACTLY the declared keys that are present — every required key plus any present optional keys, and nothing else, so a spec cannot quietly read undeclared settings — then the resolver is registered. The outcome is registered=True.
  • Zero required keys present: the spec is skipped and its outcome records registered=False with the missing required key names.
  • Some but not all required keys present: a ConfigurationError is raised naming the spec and the missing key NAMES. Key VALUES never appear in the message — partial configuration is an authoring error surfaced loudly, not a silent skip.

A spec with zero required keys always builds (its resolver needs no configuration). An empty spec list yields an empty registry and an empty outcomes tuple — both are valid.

Args:

  • specs (Sequence[ResolverSpec]): The resolver specs to evaluate, in priority order. Their order is the registration order and the outcomes order.
  • settings (Mapping[str, str] | None): The configuration mapping. When None, a snapshot of os.environ is used.

Returns:

  • RegistryBuild — class:~effaced.RegistryBuild holding the populated registry and
  • RegistryBuild — class:~effaced.SpecOutcome per spec, in spec order.

Raises:

  • ConfigurationError — If a spec has some but not all required keys present, or if a built resolver’s name does not equal the spec’s declared name (the declared list must match what registers).
  • ResolverError — Via ResolverRegistry.register if two specs build resolvers with the same name — silent replacement would falsify the audit picture.
class RegistryBuild(BaseModel):
outcomes: tuple[SpecOutcome, ...]
registry: ResolverRegistry

The result of building a registry from settings.

Returned by registry_from_settings. The outcomes tuple is the audit surface: one SpecOutcome per evaluated spec, in spec order, recording every registration and every skip.

Fields:

  • outcomes (tuple[SpecOutcome, ...]): One outcome per spec, in the order the specs were given.
  • registry (ResolverRegistry): The populated registry, ready to wire into the exporter and planner.

Protocol — implement these members in your own class; do not subclass.

class Resolver(Protocol):
name: str

One external system that holds personal data (Stripe, S3, a CRM).

Implementations MUST be idempotent: erasing a subject that is already gone returns success (already_absent=True), never an error. The saga runner retries failed calls; a non-idempotent resolver would turn retries into errors or double-effects.

Implementations MUST raise ResolverError only for non-retryable failures; transient errors (timeouts, rate limits) should raise the underlying exception and let the runner retry.

Implementations MUST NOT bind event-loop-affine resources (async HTTP clients) at construction — create them inside the call. Resolver methods may be driven from different event loops: the exporter’s per-call loop and the saga runner’s loop (ADR 0006).

Resolvers that can also correct a subject’s data (Art. 16) implement the optional RectifyingResolver capability sub-protocol on top — this base protocol itself only ever grows additively.

Fields:

  • name (str): Stable, unique resolver name ("stripe"); recorded in audits.
async def erase_subject(ref: SubjectRef) -> ResolverErasure

Erase the subject from the external system (Art. 17).

Args:

  • ref (SubjectRef): Opaque subject reference in this resolver’s namespace.

Returns:

  • ResolverErasure — The outcome; already_absent=True when there was nothing to
  • ResolverErasure — erase — that is success, not an error.
async def export_subject(ref: SubjectRef) -> ResolverExport

Collect what the external system holds on the subject (Art. 15).

Args:

  • ref (SubjectRef): Opaque subject reference in this resolver’s namespace.

Returns:

  • ResolverExport — The system’s records for the subject; empty when it holds none.
class ResolverErasure(BaseModel):
already_absent: bool = False
detail: str | None = None
resolver: str = Field(min_length=1)

Outcome of one external erasure call.

Idempotency contract: erasing a subject the external system no longer knows is success (already_absent=True), never an error — saga retries depend on this.

Fields:

  • already_absent (bool): The subject was already gone — still success.
  • detail (str | None): Short human-readable note for the audit trail (no PII).
  • resolver (str): Name of the resolver that performed the erasure.
class ResolverExport(BaseModel):
records: tuple[ExportRecord, ...] = ()
resolver: str = Field(min_length=1)

What one external system holds on a subject.

Fields:

  • records (tuple[ExportRecord, ...]): The exported values with their metadata.
  • resolver (str): Name of the resolver that produced this.
class ResolverRectification(BaseModel):
already_consistent: bool = False
detail: str | None = None
resolver: str = Field(min_length=1)

Outcome of one external rectification call.

Idempotency contract — convergence: re-applying a correction the external system already reflects is success (already_consistent=True), never an error. It is the rectification analogue of erasure’s already_absent; saga retries depend on it.

Fields:

  • already_consistent (bool): The system already held the corrected values — still success.
  • detail (str | None): Short human-readable note for the audit trail (no PII).
  • resolver (str): Name of the resolver that performed the rectification.
class ResolverRegistry:
def __init__() -> None

Holds every resolver an application has wired in.

Registration is explicit on purpose — no auto-discovery, no entry-point magic. The registry doubles as the auditable declaration of every place this application holds PII outside its own database.

def all() -> tuple[Resolver, ...]

Every registered resolver, in registration order.

def get(name: str) -> Resolver

Return one resolver by name.

Args:

  • name (str): The resolver’s stable name.

Raises:

  • ResolverError — If no resolver with that name is registered.
def register(resolver: Resolver) -> None

Add one resolver.

Args:

  • resolver (Resolver): The resolver to add.

Raises:

  • ResolverError — If a resolver with the same name is already registered — silent replacement would falsify the audit picture.
class ResolverScheduledErasure(BaseModel):
already_absent: bool = False
detail: str | None = None
expires_at: AwareDatetime | None = None
resolver: str = Field(min_length=1)

Outcome of one external erasure that can only be scheduled (ADR 0022).

Returned by schedule_erasure for systems with no per-subject delete: the data is guaranteed to expire by expires_at, but nothing was deleted now. The saga runner parks the entry until the horizon and then re-verifies — it never records the schedule as a completed erasure.

Convergence contract: scheduling a subject the system no longer holds — never held, already expired, or purged early — is success (already_absent=True), the scheduling analogue of erasure’s already_absent. Re-scheduling an already-scheduled subject is success reporting the same-or-later horizon.

Exactly one of the two facts holds: either the data is already gone (already_absent=True, no horizon) or a horizon is named (expires_at set). A schedule without a horizon is not an honest fact, and a horizon for data already gone is not a fact at all.

Fields:

  • already_absent (bool): The subject’s data is already gone — verified expiry, still success.
  • detail (str | None): Short human-readable note for the audit trail (no PII).
  • expires_at (AwareDatetime | None): The retention horizon — the instant by which the subject’s data is guaranteed to be gone (timezone-aware). Required unless already_absent=True.
  • resolver (str): Name of the resolver that scheduled the erasure.
class ResolverSpec(BaseModel):
build: Callable[[Mapping[str, str]], Resolver]
name: str = Field(min_length=1)
optional_keys: tuple[str, ...] = ()
settings_keys: tuple[str, ...] = ()

A declarative rule that turns configuration into one registered resolver.

A spec names the settings keys a resolver needs and how to build it from their values. registry_from_settings evaluates a sequence of specs against an application’s settings and registers the resolvers whose required keys are all present — so adding a resolver becomes a configuration change, not wiring code.

This is declarative wiring, not discovery: every resolver an application can register is named in a spec the application authors by hand. There is no entry-point scanning and no import-time magic — the spec list stays the auditable “where could my PII go” declaration.

Fields:

  • build (Callable[[Mapping[str, str]], Resolver]): A callable that receives a mapping containing exactly the declared keys that were present (every required key plus any present optional keys) and returns the resolver to register.
  • name (str): The resolver’s stable name. It MUST equal the .name of the resolver that build returns; the mismatch is rejected so the declared spec list always matches what actually registers.
  • optional_keys (tuple[str, ...]): Settings keys the resolver can use when present but does not require. Present optional keys are passed to build; absent ones simply are not.
  • settings_keys (tuple[str, ...]): The settings keys this resolver requires, treated all-or-nothing. An empty tuple means the resolver is always built (it needs no configuration).
class ResolverVerification(BaseModel):
checked_at: AwareDatetime = Field(default_factory=_now)
confirmed_absent: bool
detail: str | None = None
resolver: str = Field(min_length=1)

Outcome of one independent post-erasure read-back (ADR 0027).

Returned by verify_absent after the saga runner has erased a subject and marked the entry succeeded. It is an independent confirmation, separate from the resolver’s own ResolverErasure report: the resolver re-queries the external system and states whether the subject’s data is in fact gone.

confirmed_absent=True is execution-fidelity evidence that the record is no longer present. confirmed_absent=False records a discrepancy — the erase reported success, but the read-back still finds the subject. A negative verification is audited loudly (ERASURE_EXTERNAL_VERIFICATION_FAILED); it does not by itself revert the erase or re-open the settled outbox entry (ADR 0027).

This is never a determination that the external system holds no personal data: verify_absent re-queries the same surface the resolver reaches, so a confirmed absence proves the resolver’s own erasure took effect, not that the provider is free of the subject everywhere.

Fields:

  • checked_at (AwareDatetime): When the read-back was performed (timezone-aware).
  • confirmed_absent (bool): The read-back found the subject gone (True) or still present despite a successful erase (False).
  • detail (str | None): Short human-readable note for the audit trail (no PII).
  • resolver (str): Name of the resolver that performed the read-back.

Protocol — implement these members in your own class; do not subclass.

class RetentionOnlyResolver(Resolver, Protocol):
...

A Resolver that can only schedule erasure (ADR 0022).

For external systems with no per-subject delete — call recordings, transcripts, vendors with fixed retention windows — the only honest erasure outcome is a retention horizon: “guaranteed gone by T”. Implementing this sub-protocol routes the saga runner’s erase entries to schedule_erasure; the entry parks until the horizon and is then re-verified, and ERASURE_COMPLETED fires only once the data is verifiably gone. A schedule is recorded as ERASURE_EXPIRY_SCHEDULED — never as a completed erasure.

The structurally-required erase_subject MUST raise ResolverError: returning a fabricated success would record a deletion that did not happen. The saga never calls it for these resolvers; the raise protects direct callers. A vendor that can delete some data on demand but only expire the rest is modeled as two resolvers with two ref kinds (ADR 0008 routing).

The error taxonomy is unchanged: ResolverError only for non-retryable failures; transient errors propagate untranslated for the saga runner to retry.

async def schedule_erasure(ref: SubjectRef) -> ResolverScheduledErasure

Schedule the subject’s erasure and report the retention horizon.

Performs whatever marking the external system supports (a tombstone, a lifecycle tag, nothing at all) and reports the instant by which the data is guaranteed to expire. MUST be convergent: a subject the system no longer holds returns success with already_absent=True; re-scheduling reports the same-or-later horizon, never an error.

Args:

  • ref (SubjectRef): Opaque subject reference in this resolver’s namespace.

Returns:

  • ResolverScheduledErasure — The schedule outcome; already_absent=True means verified
  • ResolverScheduledErasure — expiry — that is success, not an error.
class SpecOutcome(BaseModel):
missing_keys: tuple[str, ...] = ()
name: str = Field(min_length=1)
registered: bool

The auditable record of evaluating one ResolverSpec.

registry_from_settings emits one outcome per spec, in spec order, so a startup audit can show which configured resolvers were registered and which were skipped for missing configuration. A skip is recorded here — never silent.

Fields:

  • missing_keys (tuple[str, ...]): When registered is False because configuration was absent, the required key names that were missing. Empty for a registered spec.
  • name (str): The spec’s declared resolver name.
  • registered (bool): Whether the resolver was registered. True means its required keys were all present and it joined the registry; a skipped spec is False.
class SurfaceExclusion(BaseModel):
field: str = Field(min_length=1)
reason: str = Field(min_length=1)

A field a resolver explicitly does not reach, with the reason why.

An exclusion makes a known gap honest instead of implicit: a caller-defined metadata blob the resolver cannot read, an event payload the vendor retains beyond the deletion API, a value the external API never exposes. The field is an fnmatch.fnmatch glob (same semantics as CoveredField); the conformance suite checks no exported record matches it, so an exclusion that is silently breached fails loudly.

An exclusion is a declaration, never a compliance determination: it records what this resolver’s mechanism does not cover, not that the excluded data is absent or lawful to retain.

Fields:

  • field (str): The fnmatch.fnmatch glob the resolver does not cover; no exported record may match it.
  • reason (str): A human-readable reason the field is out of reach — why the gap exists, in plain words.

Protocol — implement these members in your own class; do not subclass.

class VerifyingResolver(Resolver, Protocol):
...

A Resolver that can re-confirm absence (ADR 0027).

Verification is an optional capability: the base Resolver protocol stays additive-only, so implementing this sub-protocol is never required. A resolver that implements it offers an independent read-back — after the saga runner erases a subject and marks the entry succeeded, it narrows with isinstance and awaits verify_absent, recording the verdict. A registered resolver without verify_absent is skipped, never an error, exactly as with RectifyingResolver and AttestingResolver.

The verdict is recorded but never acted on automatically: a ResolverVerification with confirmed_absent=False is audited loudly (ERASURE_EXTERNAL_VERIFICATION_FAILED) as a discrepancy for an operator, but does not revert the erase or re-open the settled outbox entry (ADR 0027).

Verification is a mechanism for an independent confirmation that the resolver’s own erasure took effect — it re-queries the same surface the resolver reaches. It can never prove the external system holds no personal data the resolver does not reach, and is never a compliance determination.

async def verify_absent(ref: SubjectRef) -> ResolverVerification

Re-query the external system and confirm the subject is gone.

Called by the saga runner only after a successful on-demand erasure of the same subject. MUST be a read-back only — it never mutates the external system; it re-queries and reports.

Args:

  • ref (SubjectRef): Opaque subject reference in this resolver’s namespace.

Returns:

  • ResolverVerification — The read-back verdict; confirmed_absent=True means the
  • ResolverVerification — subject’s data is verifiably gone, False records a
  • ResolverVerification — discrepancy with the erase’s reported success.