Skip to content

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

class RectificationResult(BaseModel):
completed_at: datetime
enqueued_external: tuple[str, ...] = ()
rectified: dict[str, int] = Field(default_factory=dict)
skipped_resolvers: tuple[str, ...] = ()
subject_id: ValidatedSubjectId

Outcome 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 no rectify_subject capability.
  • subject_id (ValidatedSubjectId): The subject whose data was rectified — echoed back from the call (single-column str or composite CompositeSubjectId).
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.

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.

def execute(session: Session, graph: SubjectGraph, step: RectificationStep, subject_id: SubjectIdentifier, value: str | int | float | bool) -> int

Run 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-column str or composite CompositeSubjectId).
  • value (str | int | float | bool): The corrected value to write.

Returns:

  • int — The number of rows the step matched.
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) -> None

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

def rectify_subject(session: Session, subject_id: SubjectIdentifier, corrections: tuple[Correction, ...], *, refs: tuple[SubjectRef, ...] = ()) -> RectificationResult

Apply 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. External
  • RectificationResult — outcomes land in the audit trail asynchronously.

Raises:

  • ConfigurationError — If the rectifier was built without an executor, outbox, or audit sink.
  • ValueError — If corrections is empty or repeats a category.
  • ResolverError — If a ref’s kind matches no registered resolver — a typo must not silently drop an external system from the rectification.