Skip to content

Resolvers

This content is for 0.1. Switch to the latest version for up-to-date documentation.

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: ...
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.

Implementations must treat “already gone” as success:

ResolverErasure(resolver="stripe", already_absent=True) # success, not an error

The 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 ResolverError only 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).

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.

The first-party StripeResolver (the effaced-stripe package) covers the external system almost everyone has — see the Stripe guide. Full protocol docs: API reference.