Resolvers
Your database is not the only place you hold personal data: Stripe has the
billing profile, your CRM has the contact history, S3 has the uploads. A
resolver is one external system’s implementation of export and
erasure, and the Resolver protocol is effaced’s most stability-protected
public API — extended additively only, so a resolver written against 0.x
keeps working across its major version.
class Resolver(Protocol): @property def name(self) -> str: ... # "stripe" async def export_subject(self, ref: SubjectRef) -> ResolverExport: ... async def erase_subject(self, ref: SubjectRef) -> ResolverErasure: ...The registry is your PII inventory
Section titled “The registry is your PII inventory”registry = ResolverRegistry()registry.register(StripeResolver(api_key="rk_live_..."))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: grep your wiring code and you have the external half of your data inventory. Registering a second resolver under an existing name raises — silent replacement would falsify that picture.
Routing: ref.kind == resolver.name (ADR 0008)
Section titled “Routing: ref.kind == resolver.name (ADR 0008)”Engines receive a flat tuple of SubjectRefs; each ref is routed to the
resolver whose name equals the ref’s kind — one deterministic
equality, no extra configuration:
SubjectRef(kind="stripe", value="cus_9xKL...") # → the resolver named "stripe"- A ref kind matching no registered resolver fails loudly with
ResolverError, before any work or audit event — a typo must never silently drop an external system from an Art. 15/17 answer. - A registered resolver with no matching ref is skipped — “the subject has no identity in that system” is a complete answer, recorded in the operation’s completion audit payload, never an error.
- Several refs of one kind mean several calls to that resolver.
Resolver names are recorded in audit events and outbox rows, so renaming a resolver is a MAJOR change.
The idempotency contract
Section titled “The idempotency contract”Implementations must treat “already gone” as success:
ResolverErasure(resolver="stripe", already_absent=True) # success, not an errorThe saga runner retries failed calls and may re-execute after
a crash or an expired lease; re-runs of erase_subject re-enqueue
external work. A non-idempotent resolver would turn that convergence into
errors or double-effects. Two more contract points:
- Raise
ResolverErroronly for non-retryable failures — the runner abandons those immediately. Transient errors (timeouts, rate limits) should raise their underlying exception and be retried on backoff. - Don’t bind event-loop-affine resources (async HTTP clients) in
__init__— 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 (ADR 0006).
Capability sub-protocols
Section titled “Capability sub-protocols”Resolver itself never grows. Optional capabilities are separate
@runtime_checkable sub-protocols — implement one and the engines detect
it with isinstance, so a resolver written against the base protocol is
never broken by a new capability:
-
RectifyingResolveraddsrectify_subjectfor Art. 16 rectification; resolvers without it are skipped and recorded, never errored. -
RetentionOnlyResolver(ADR 0022) is for systems with no per-subject delete — call recordings, transcripts, vendors with fixed retention windows. Itsschedule_erasure(ref)performs whatever marking the system supports and returns aResolverScheduledErasure: either a timezone-awareexpires_athorizon (“guaranteed gone by T”) oralready_absent=Truefor data verifiably gone — exactly one, validator-enforced. The saga runner parks the entry until the horizon and then re-verifies; a schedule is never recorded as a completed erasure. -
AttestingResolverlets a resolver declare its covered surface: aCoveredSurfacenaming the PII-bearing fields its export and erasure claim to reach (CoveredFieldglobs overExportRecord.field, each with its category), the fields it knowingly does not reach (SurfaceExclusion, each with a human reason), and free-textnotesfor asymmetries — for example thatS3Resolverexports the current object versions but erases all versions and delete markers. The conformance suite checks every export of a present subject stays within the declared surface, never touches an exclusion, and — given a fully-populated fixture — exercises every declared field.A covered surface makes a resolver’s claimed coverage explicit and testable. 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 — and it is never a compliance determination. All five first-party resolvers attest one, built from the same field tuples their exporters use so declaration and implementation cannot drift.
A retention-only implementation’s structurally-required erase_subject
raises 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 — the routing rule above already handles
the split. InMemoryRetentionOnlyResolver in effaced.testing is the
reference implementation.
Writing your own
Section titled “Writing your own”A minimal sketch:
class CrmResolver: @property def name(self) -> str: return "crm"
async def export_subject(self, ref: SubjectRef) -> ResolverExport: records = await self._fetch(ref.value) # client created per call return ResolverExport(resolver=self.name, records=records)
async def erase_subject(self, ref: SubjectRef) -> ResolverErasure: try: await self._delete(ref.value) except NotFoundError: return ResolverErasure(resolver=self.name, already_absent=True) return ResolverErasure(resolver=self.name)Export records are ExportRecords like local ones — source is your
resolver name, and they merge straight into the
export bundle.
First-party resolvers ship for the systems most apps already have:
StripeResolver (the effaced-stripe package) — see the
Stripe guide —, the Supabase Auth and Storage
resolvers (the effaced-supabase package), S3Resolver (the
effaced-s3 package) for subject-owned objects in S3 — see the
S3 guide — and ResendResolver (the
effaced-resend package) for the subject’s email contact record in
Resend. Full protocol docs:
API reference.