ADR 0020 — History/audit strategy: per-entity Type-4 snapshot tables, no SCD-2¶
- Status: Accepted
- Date: 2026-06-20
- Deciders: @TheurgicDuke771
- Related: ADR 0009, 0010 (least-privilege / secrets in the SecretStore), 0013 (BYOL portability); issues #310 (this decision), #308/#309 (the consistency-hardening follow-ups surfaced alongside it)
Context¶
The internal Postgres data has no general history/versioning framework. Alembic versions the schema (DDL), not the data — it cannot answer "what was this connection's config last Tuesday." A consistency/SCD review (2026-06-20) found:
- One real history mechanism exists:
check_versions— an immutable snapshot of a check's editable state written on create + every successful update (effectively SCD Type 4, a separate history table). It backs the check version-history drawer. - Everything else is SCD Type 1 (overwrite +
updated_at):connections(config/credential changes),suites,trigger_bindings,shares. No prior-value retention. - All deletes are hard deletes; no soft-delete (
deleted_at) anywhere; no SCD Type 2 (valid_from/valid_toversioned rows) anywhere.
The question: what history/audit posture should v1.x take, and specifically — do we adopt SCD Type 2?
Why SCD-2 is a poor fit here. This is a richly-linked OLTP graph (suites→connections, checks→suites, runs→suites, results→runs/checks, shares, trigger_bindings, the *_versions tables). True SCD-2 makes the entity id non-unique (one row per version), which breaks every foreign key that points at it. The only way to get Type-2 semantics without destroying the FK model is a separate history table per entity — which is Type 4, i.e. exactly what check_versions already is. Full SCD-2's cost is dominated by ongoing maintenance, not the build: every read needs a temporal predicate (valid_to IS NULL) or a current-only view, every new column must be mirrored, history grows unbounded (retention/partitioning), and the close-then-insert write adds its own concurrency surface. That tax is unjustified for a single-tenant DQ platform.
Decision¶
- No SCD Type 2. Reject versioned-row dimensions; they fight the FK model and carry a permanent maintenance tax.
- Per-entity Type-4 snapshot tables where config history has a concrete need. Extend the
check_versionspattern entity-by-entity rather than build a framework.connection_versionsis added now (the first such extension beyond checks) — connection name/config history, snapshot on create + name/config update. - Credentials are never snapshotted. Secrets live only in the SecretStore (referenced by the constant
conn-<id>pointer); history tables hold only editable, non-secret fields. A credential rotation (reauth, or a secret-only update) records no version — that is an audit-log concern, not config history (ADR 0010). - Cascade-delete is accepted: history is not retained past entity deletion. Both
check_versionsandconnection_versionsuseondelete=CASCADEon the entity FK; deleting the entity drops its history. (changed_byisSET NULLso a snapshot outlives its author, just not its entity.) Retention-past-delete would need a tombstone/soft-delete; explicitly out of scope for v1 — revisit only if an audit/compliance requirement appears. - No soft-delete in v1. Hard deletes stay. (A deleted connection orphaning the meaning of past runs is a known, accepted limitation.)
- A cross-entity audit log (
actor, entity, action, before/after, ts) is deferred, not rejected. It is the right tool if/when "who changed this credential/share, and to what" becomes a requirement — cheaper and more uniform than per-entity versioning for audit (vs config history). Tracked in #310.
Consequences¶
Positive - Smallest viable surface: history is additive Type-4 tables (backward-compatible migrations, no FK redesign, no read-path rewrite), reusing a proven pattern + UI shape. - Credentials provably stay out of history; the snapshot is safe to expose through the read API. - Portable for BYOL (ADR 0013): plain tables, no Postgres-temporal or extension dependency.
Negative / watch - History is lost on delete by design — if compliance later needs retention-past-delete, revisit (4). - Per-entity history means N decisions/migrations, one per entity that needs it (checks ✅, connections ✅; suites/triggers not yet) — deliberate, to avoid a premature framework. - No unified audit trail yet; credential-rotation events in particular are unrecorded until the audit log lands (#310).
Alternatives considered¶
- Full SCD-2 (versioned rows or a library like SQLAlchemy-Continuum): rejected — FK-model break + permanent query/maintenance tax, disproportionate for single-tenant v1.
- Generic audit log first: strong option (one hook, covers everything) and still on the table for audit; but it gives before/after events, not the first-class "version history" view the product already surfaces for checks (and now connections). The two are complementary — Type-4 for config history now, audit log when audit is required.
- Soft-delete everywhere: deferred — adds a
deleted_at IS NULLpredicate to every read for a need v1 doesn't have.