Resolvers
Resolvers — reach PII in external systems through one interface.
AttestingResolver
Section titled “AttestingResolver”Protocol — implement these members in your own class; do not subclass.
class AttestingResolver(Resolver, Protocol): covered_surface: CoveredSurfaceA 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: TheCoveredSurfacewhoseresolverequals this resolver’sname.
CoveredField
Section titled “CoveredField”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): ThePiiCategorythe matched field is declared to hold; the conformance suite checks a covered record carries the same category. - field (
str): Thefnmatch.fnmatchglob the covered field path must match.
CoveredSurface
Section titled “CoveredSurface”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 overExportRecord.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): TheResolver.name<effaced.Resolver.name>this surface describes; the conformance suite checks it equals the resolver under test.
RectifyingResolver
Section titled “RectifyingResolver”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.
RectifyingResolver.rectify_subject
Section titled “RectifyingResolver.rectify_subject”async def rectify_subject(ref: SubjectRef, corrections: tuple[Correction, ...]) -> ResolverRectificationApply 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=Truewhen the systemResolverRectification— already reflected every correction — that is success, not anResolverRectification— error.
registry_from_settings
Section titled “registry_from_settings”def registry_from_settings(specs: Sequence[ResolverSpec], settings: Mapping[str, str] | None = None) -> RegistryBuildBuild 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
settingsAND its value is non-blank afterstr.strip. A blank or whitespace-only value counts as ABSENT (the compose-fileKEY=disable convention); the resulting skip is recorded in the outcomes, never silent.
Per spec, exactly one of:
- All required keys present (or none required):
buildis 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 isregistered=True. - Zero required keys present: the spec is skipped and its outcome
records
registered=Falsewith the missing required key names. - Some but not all required keys present: a
ConfigurationErroris 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. WhenNone, a snapshot ofos.environis used.
Returns:
RegistryBuild— class:~effaced.RegistryBuildholding the populated registry andRegistryBuild— class:~effaced.SpecOutcomeper spec, in spec order.
Raises:
ConfigurationError— If a spec has some but not all required keys present, or if a built resolver’snamedoes not equal the spec’s declaredname(the declared list must match what registers).ResolverError— ViaResolverRegistry.registerif two specs build resolvers with the same name — silent replacement would falsify the audit picture.
RegistryBuild
Section titled “RegistryBuild”class RegistryBuild(BaseModel): outcomes: tuple[SpecOutcome, ...] registry: ResolverRegistryThe 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.
Resolver
Section titled “Resolver”Protocol — implement these members in your own class; do not subclass.
class Resolver(Protocol): name: strOne 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.
Resolver.erase_subject
Section titled “Resolver.erase_subject”async def erase_subject(ref: SubjectRef) -> ResolverErasureErase 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=Truewhen there was nothing toResolverErasure— erase — that is success, not an error.
Resolver.export_subject
Section titled “Resolver.export_subject”async def export_subject(ref: SubjectRef) -> ResolverExportCollect 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.
ResolverErasure
Section titled “ResolverErasure”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.
ResolverExport
Section titled “ResolverExport”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.
ResolverRectification
Section titled “ResolverRectification”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.
ResolverRegistry
Section titled “ResolverRegistry”class ResolverRegistry: def __init__() -> NoneHolds 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.
ResolverRegistry.all
Section titled “ResolverRegistry.all”def all() -> tuple[Resolver, ...]Every registered resolver, in registration order.
ResolverRegistry.get
Section titled “ResolverRegistry.get”def get(name: str) -> ResolverReturn one resolver by name.
Args:
- name (
str): The resolver’s stable name.
Raises:
ResolverError— If no resolver with that name is registered.
ResolverRegistry.register
Section titled “ResolverRegistry.register”def register(resolver: Resolver) -> NoneAdd 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.
ResolverScheduledErasure
Section titled “ResolverScheduledErasure”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 unlessalready_absent=True. - resolver (
str): Name of the resolver that scheduled the erasure.
ResolverSpec
Section titled “ResolverSpec”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.nameof the resolver thatbuildreturns; 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 tobuild; 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).
ResolverVerification
Section titled “ResolverVerification”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.
RetentionOnlyResolver
Section titled “RetentionOnlyResolver”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.
RetentionOnlyResolver.schedule_erasure
Section titled “RetentionOnlyResolver.schedule_erasure”async def schedule_erasure(ref: SubjectRef) -> ResolverScheduledErasureSchedule 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=Truemeans verifiedResolverScheduledErasure— expiry — that is success, not an error.
SpecOutcome
Section titled “SpecOutcome”class SpecOutcome(BaseModel): missing_keys: tuple[str, ...] = () name: str = Field(min_length=1) registered: boolThe 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, ...]): WhenregisteredisFalsebecause 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.Truemeans its required keys were all present and it joined the registry; a skipped spec isFalse.
SurfaceExclusion
Section titled “SurfaceExclusion”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): Thefnmatch.fnmatchglob 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.
VerifyingResolver
Section titled “VerifyingResolver”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.
VerifyingResolver.verify_absent
Section titled “VerifyingResolver.verify_absent”async def verify_absent(ref: SubjectRef) -> ResolverVerificationRe-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=Truemeans theResolverVerification— subject’s data is verifiably gone,Falserecords aResolverVerification— discrepancy with the erase’s reported success.