Skip to content

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 key

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

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.

  • 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 by bind_tables are 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.

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.

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:

Terminal window
effaced lint myapp.models:Base

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

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.