Completeness linting
effaced cannot find data you never declared — no library can. What it can do is point at every place such data could hide, so “we forgot to annotate the new column” becomes a build failure instead of a silent gap in your exports and erasures.
lint_completeness is the exact complement of
collect_data_map: every table in your metadata is either
in the data map, flagged whole (“this table carries no effaced
annotations”), or one of the effaced-owned effaced_* tables. Inside a
mapped table, every column is either annotated, a primary/foreign key, or
flagged. Nothing falls through.
from effaced import lint_completeness
for finding in lint_completeness(Base.metadata): print(finding.message)# table 'app_settings' carries no effaced annotations — its data is# invisible to export and erasure# column users.theme is not annotated and is neither a primary nor a# foreign keyA finding is a question, not a verdict: it marks data the manifest does not cover so a human can either annotate it or consciously exempt it. effaced never decides on your behalf that data is not personal — mechanisms, not determinations.
The CI gate
Section titled “The CI gate”Drop assert_data_map_complete into any test and the build fails the
moment a new table or column appears without an annotation:
from effaced.testing import assert_data_map_complete
def test_data_map_covers_the_schema() -> None: assert_data_map_complete( Base.metadata, exempt_tables=("app_settings",), # no personal data, judged so exempt_columns=("users.theme",), # a UI preference, not PII )Exemptions are conscious acknowledgements that live in your test code and
get reviewed like any other code — the judgement “this holds no personal
data” stays visible and stays yours. An exempt table silences all of its
findings; an exempt column ("table.column") silences just that field.
Stale exemptions fail too: if an exemption no longer matches any finding (the table was dropped, or finally annotated), the gate flags it for removal, so the list never outlives the schema it judged.
What is never flagged
Section titled “What is never flagged”- Primary and foreign keys — structural identity, covered by the subject graph rather than per-column annotations.
effaced_*tables — the audit, consent, and outbox tables mounted bybind_tablesare effaced-owned.
Everything else in a mapped table needs an annotation or an exemption.
The linter is read-only and needs no database connection — it walks the
same MetaData the collector does.
Reachability: annotated but unreachable
Section titled “Reachability: annotated but unreachable”A table can be fully annotated and still never be erased. Completeness
asks “is every column declared?”; it does not ask “can the planner route
this table back to a subject?”. A table whose subject_link path the
planner cannot walk — a wrong relationship name, an
un-mapped class, a path that stops short of the subject table — is
silently skipped at erasure time.
lint_reachability closes that gap. It is the exact inverse of subject-graph
resolution: it returns no findings if and only if
resolve_subject_graph succeeds. Where resolution raises on the first
unreachable table, the linter probes each table independently and returns
one finding per gap, so you see every problem at once.
from effaced import collect_data_map, lint_reachability
data_map = collect_data_map(Base.metadata)for finding in lint_reachability(data_map, Base.registry): print(finding.message)# table 'order_items' is unreachable from the subject: subject link path# 'order.buyer' ends at 'orders', not at the subject table 'users'Like a completeness finding, a reachability finding is a question, not a verdict — fix the path, map the class, or decide the table needs no path at all.
The effaced lint command
Section titled “The effaced lint command”Both linters run from one console script, for use in CI without writing a
test harness. Point it at your declarative Base (or a bare MetaData)
using the module.path:attribute form Alembic and Gunicorn use:
effaced lint myapp.models:BaseIt prints each finding’s one-line message and exits 0 when the data map
is clean, 1 when there are findings, and 2 on a usage or load error (a
malformed target, an un-importable module, or malformed annotations). A
bare MetaData has no ORM registry, so reachability is skipped and the
command notes it; completeness still runs.
The boundary
Section titled “The boundary”This proves the structural completeness of your data map — that every
table and column is either declared or consciously exempt, and that every
declared store has a subject path the planner can walk. It does not
prove your annotations are semantically correct: that a column really does
hold the category you tagged it with, or that a RETAIN reason is a valid
legal basis. Those judgements are yours. effaced ships mechanisms, never a
determination that you have met any legal obligation.