Rectification
Art. 16 rectification — category-keyed corrections, atomic locally, saga-driven externally.
Corrections are keyed by PiiCategory, never by column;
erasure strategy does not gate them (RETAIN and ANONYMIZE columns
are rectified too); external fan-out reuses the outbox, whose payload is
cleared at terminal status; and the audit trail records names and counts
only — old and new values never appear in any event (ADR 0013).
RectificationResult
Section titled “RectificationResult”class RectificationResult(BaseModel): completed_at: datetime enqueued_external: tuple[str, ...] = () rectified: dict[str, int] = Field(default_factory=dict) skipped_resolvers: tuple[str, ...] = () subject_id: ValidatedSubjectIdOutcome of the local phase of a rectification.
External steps complete asynchronously; their outcomes land in the audit trail as the saga runner processes the outbox.
Fields:
- completed_at (
datetime): When the local phase finished (UTC); durable once the caller commits. - enqueued_external (
tuple[str, ...]): Resolver names whose rectification was enqueued. - rectified (
dict[str, int]): Rows updated, by table. A table matched by several corrections counts each step’s rows — the same row can be counted once per category that touched it. - skipped_resolvers (
tuple[str, ...]): Registered resolvers that received nothing — no matching ref, or norectify_subjectcapability. - subject_id (
ValidatedSubjectId): The subject whose data was rectified — echoed back from the call (single-columnstror compositeCompositeSubjectId).
RectificationStep
Section titled “RectificationStep”class RectificationStep(BaseModel): category: PiiCategory columns: tuple[str, ...] = Field(min_length=1) target: str = Field(min_length=1)One local table’s columns a correction will rewrite.
Steps are deliberately value-free: the corrected value travels
separately (see
execute), so
a plan of steps never carries PII and stays safely inspectable and
loggable.
Fields:
- category (
PiiCategory): The PII category whose correction the step applies. - columns (
tuple[str, ...]): The category’s annotated columns on the table — every one receives the same corrected value (ADR 0013). - target (
str): The table the step updates.
RectificationStepExecutor
Section titled “RectificationStepExecutor”Protocol — implement these members in your own class; do not subclass.
class RectificationStepExecutor(Protocol): ...Anything that can execute one local rectification step for one subject.
The storage-specific half of rectify_subject: it turns a step plus the subject graph’s hop chains
into one subject-scoped UPDATE in the caller’s open transaction. The
SQLAlchemy implementation is
RectificationExecutor.
The corrected value is passed separately from the step so steps stay value-free — a plan never carries PII.
RectificationStepExecutor.execute
Section titled “RectificationStepExecutor.execute”def execute(session: Session, graph: SubjectGraph, step: RectificationStep, subject_id: SubjectIdentifier, value: str | int | float | bool) -> intRun one local step scoped to one subject.
Implementations must never commit or roll back the session — the
step is durable exactly when the caller’s transaction is. Every
column the step names receives the same corrected value.
Args:
- session (
Session): The caller’s open session. - graph (
SubjectGraph): Resolved hop chains from each table to the subject. - step (
RectificationStep): The value-free local step to run. - subject_id (
SubjectIdentifier): The subject identifier (single-columnstror compositeCompositeSubjectId). - value (
str | int | float | bool): The corrected value to write.
Returns:
int— The number of rows the step matched.
Rectifier
Section titled “Rectifier”class Rectifier: def __init__(data_map: DataMap, graph: SubjectGraph, registry: ResolverRegistry | None = None, *, executor: RectificationStepExecutor | None = None, outbox: Outbox | None = None, audit_sink: AuditSink | None = None) -> NoneApplies category-keyed corrections across local and external data.
The local writes run in the caller’s transaction; external corrections cannot join it, so they are enqueued durably in the same transaction and fanned out afterwards by the saga runner — the half-rectified state gets the same cure as the half-erased one (ADR 0013).
Erasure strategy never gates rectification: RETAIN and
ANONYMIZE columns of a corrected category are rewritten too — an
inaccurate record retained under a legal duty is the worst of both
worlds, and Art. 16 does not defer to Art. 17 annotations. Changing
any of these semantics changes what gets written and is MAJOR under
widened SemVer.
Rectifier.rectify_subject
Section titled “Rectifier.rectify_subject”def rectify_subject(session: Session, subject_id: SubjectIdentifier, corrections: tuple[Correction, ...], *, refs: tuple[SubjectRef, ...] = ()) -> RectificationResultApply the corrections: atomic local phase + durable external enqueue.
Every annotated column whose category matches a correction — reachable from the subject through the graph’s hop chains — is updated to the corrected value, regardless of its erasure strategy. A category matching no local column is a complete answer, not an error. Local writes and the external entries’ outbox rows go through the same session, so the caller’s commit makes the whole rectification durable at once — and a rollback undoes every row change and every outbox entry together. This method never commits or rolls back the session itself; after it raises, do not commit the session.
Audit semantics (ADR 0013): RECTIFICATION_REQUESTED is
appended before the first step, one
RECTIFICATION_STEP_SUCCEEDED after each local step,
RECTIFICATION_STEP_FAILED on the first failure (then the
original exception re-raises), and
RECTIFICATION_LOCAL_COMPLETED last. Payloads carry table,
category, and resolver names plus counts — old and new values
never appear in any event. Validation failures raise before any
event.
Each ref is routed to the resolver whose name equals the
ref’s kind (ADR 0008). A registered resolver with no matching
ref — or without the rectify_subject capability — is skipped
and recorded in skipped_resolvers; a ref kind matching no
resolver fails loudly. Enqueued entries carry the corrections in
the outbox row’s payload (real PII, cleared at terminal status).
Args:
- session (
Session): An open database session; the local phase commits or rolls back as one unit together with the outbox entries. - subject_id (
SubjectIdentifier): Identifier on the subject table. - corrections (
tuple[Correction, ...]): One corrected value per category; duplicates are rejected. - refs (
tuple[SubjectRef, ...]): External-system references, routed by kind (ADR 0008).
Returns:
RectificationResult— The local-phase outcome with per-table row counts. ExternalRectificationResult— outcomes land in the audit trail asynchronously.
Raises:
ConfigurationError— If the rectifier was built without an executor, outbox, or audit sink.ValueError— Ifcorrectionsis empty or repeats a category.ResolverError— If a ref’skindmatches no registered resolver — a typo must not silently drop an external system from the rectification.