Quickstart
effaced rides the SQLAlchemy models and migrations you already have. There is no separate config file to drift out of sync — the annotations are the data map.
-
Install. Not on PyPI yet — until 0.1.0 ships, install both packages straight from the repo:
Terminal window uv add "effaced @ git+https://github.com/jaylann/effaced#subdirectory=packages/effaced" \"effaced-stripe @ git+https://github.com/jaylann/effaced#subdirectory=packages/effaced-stripe"effacedis the core;effaced-stripeis the first-party Stripe resolver (skip it if you only erase locally). -
Annotate your models. Mark personal-data columns with
pii()and tell each table how its rows reach the data subject withsubject_link():from sqlalchemy import ForeignKeyfrom sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationshipfrom effaced import ErasureStrategy, PiiCategory, RetentionPolicy, pii, subject_linkclass Base(DeclarativeBase): ...class User(Base):__tablename__ = "users"__table_args__ = {"info": subject_link("")} # this IS the data subjectid: Mapped[int] = mapped_column(primary_key=True)email: Mapped[str] = mapped_column(info=pii(PiiCategory.CONTACT))class Invoice(Base):__tablename__ = "invoices"__table_args__ = {"info": subject_link("user")} # reaches the subject via .userid: Mapped[int] = mapped_column(primary_key=True)user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))user: Mapped[User] = relationship()billing_address: Mapped[str] = mapped_column(info=pii(PiiCategory.FINANCIAL,erasure=ErasureStrategy.RETAIN, # legally retained — never deleted,retention=RetentionPolicy(reason="§147 AO"), # and the audit trail says why))The full vocabulary is in annotations.
-
Collect the data map and mount the effaced tables.
from sqlalchemy.orm import sessionmakerfrom effaced import bind_tables, collect_data_map, resolve_subject_graphdata_map = collect_data_map(Base.metadata) # the manifest, derivedgraph = resolve_subject_graph(data_map, Base.registry) # FK-safe subject graphtables = bind_tables(Base.metadata) # effaced-owned tablessession_factory = sessionmaker(engine)bind_tablesdefineseffaced_audit_events,effaced_consent_records, andeffaced_outboxon yourMetaData, so they live in your database and ride your migration tooling (Alembic autogenerate picks them up; orBase.metadata.create_all(engine)without one). -
Wire the shared components and register resolvers.
from effaced import DatabaseAuditSink, Outbox, ResolverRegistryfrom effaced_stripe import StripeResolveraudit = DatabaseAuditSink(session_factory, tables.audit_events)outbox = Outbox(session_factory, tables.outbox)registry = ResolverRegistry()registry.register(StripeResolver(api_key="rk_live_...")) # explicit — the registry doubles# as your "where is my PII" list -
The three calls. Record consent (Art. 7), export a subject (Art. 15), erase a subject (Art. 17):
from datetime import UTC, datetimefrom effaced import (ConsentLedger, ConsentRecord, ErasureExecutor, ErasurePlanner,Exporter, SubjectRef,)ledger = ConsentLedger(tables.consent_records, audit)exporter = Exporter(data_map, graph, Base.metadata, audit, registry)planner = ErasurePlanner(data_map, graph, registry,executor=ErasureExecutor(Base.metadata),outbox=outbox,audit_sink=audit,)stripe_ref = SubjectRef(kind="stripe", value="cus_...") # kind == resolver namewith session_factory.begin() as session:ledger.record(session, ConsentRecord(subject_id="42", purpose="newsletter", policy_version="2026-01",granted=True, recorded_at=datetime.now(UTC), source="signup_form",))with session_factory() as session:bundle = exporter.export_subject(session, "42", refs=(stripe_ref,))with session_factory.begin() as session:result = planner.erase_subject(session, "42", refs=(stripe_ref,))Everything else — FK-safe ordering, anonymize-vs-delete, the durable outbox, retries, idempotency, the audit trail — is bookkeeping effaced does between those calls. See erasure and export for the exact contracts.
-
Run the saga runner.
erase_subjectenqueues external calls durably; a runner you drive fans them out with retries and idempotency:from effaced import SagaRunnerrunner = SagaRunner(registry, outbox, audit)# in your worker process, cron job, or background thread:await runner.run_once()Never call
run_onceon a serving event loop — it makes blocking database calls between awaits. Wiring examples cover FastAPI, a dedicated worker, and cron; the semantics are in the saga.