All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.4.0 - 2026-08-03
Added
use AshReplicant.Checkpointaccepts anauthorizers:option, so the generated checkpoint resource can carryAsh.Policy.Authorizerand enforce host-declaredpolicies doblocks. The checkpoint is an internal watermark; a host that exposes its domain on a wire surface (JSON:API, MCP) previously had no way to lock the checkpoint down, because the macro emitted a resource with no authorizer andpoliciesis not a declarable section without one. The sink still reads/upserts withauthorize?: falseon both paths (Sink.Impl.read_checkpoint/1,upsert_checkpoint/2), so effect-once is unaffected by whatever policies the host declares — including none, which fail-closes the resource to every actor except the sink.authorizers:defaults to[], identical to the option Ash already defaults to, so existing hosts get the byte-for-byte prior resource (no behaviour change). Verified against live logical-replication Postgres:checkpoint_policy_test.exsproves the authorizer + policies are present, a non-system actor is denied (hardForbidden), a system actor is allowed, and the sink'sauthorize?: falsepath bypasses both.
0.3.3 - 2026-08-02
Fixed
- Tenant reassignment on the SCD2 path no longer leaves a double-current version. The
0.3.2 relocate fix covered only the SCD1 (upsert) path. On the SCD2 (validity-windowed)
path a tenant reassignment (same business key, new tenant) left the OLD-tenant version
OPEN forever while opening a fresh current version under the NEW tenant — the entity read
as "current" under BOTH tenants (a silent double-current split; a per-tenant open-uniq
index permits it, so no error surfaced).
Apply.Scd2now terminally closes the old-tenant version on a resolved-tenant change (in addition to a business-key change), relocating the row. Regression test:scd2_apply_test.exs"tenant-reassigning update terminally closes the OLD-tenant version and opens under the NEW tenant". - Value-free preserved on the tenant-change check. The reassignment predicate was
promoted to
Resolver.tenant_changed?/2and made scrub-safe: a raisingtenant_mfaresolver is caught inside the check and reported as "not changed" (never propagating an unscrubbed row value out of the pre-apply tenant comparison, which sits outside the per-op scrub boundary). Both apply paths share the one helper.
Notes
- Tenant-scoped mirrors REQUIRE
REPLICA IDENTITY FULLon the source for reassignment detection: the check needs the old row's tenant, which is key-only under the default replica identity. Without RIF a genuine reassignment falls through to the pre-0.3.2 behavior (SCD1: the colliding upsert; SCD2: the double-current) — the sameold_recorddependency already documented for tenant-scoped deletes/PK-changes. - Reassignment detection also depends on a NON-RAISING tenant resolver.
Resolver.tenant_changed?/2treats a raisingtenant_mfaas "not changed" (value-free), so an MFA that raises onold_recordmakes a reassignment invisible and the caller keeps its non-relocating path.tenant_attributeresolvers never raise; this affects onlytenant_mfaand is not a regression (no path handled reassignment before 0.3.2/0.3.3).
0.3.2 - 2026-08-02
Fixed
- Tenant reassignment no longer halts the mirror stream. When a source row's tenant
attribute changed (same PK, new
tenant_attributevalue), a resource declaring a tenant-scoped upsert identity (identity :source_pk, [pk]under attribute multitenancy → a(tenant, pk)unique index) would fall through to an INSERT under the new tenant that collided with the row's GLOBAL primary key (the still-present old-tenant row). The upsert raised, the sink transaction rolled back, and the checkpoint froze — a fail-closed poison pill that stalled delivery for ALL tenants and retained source WAL indefinitely.Applynow treats a resolved-tenant change like a PK change: destroy the old-tenant row (resolved fromold_record) then upsert under the new tenant, relocating the mirror row. Triggers only when both tenants resolve and differ, so non-multitenant resources and key-onlyold_recordupdates (no REPLICA IDENTITY FULL) are unchanged. Regression test:apply_test.exs"tenant-reassigning UPDATE … MOVES the row to the new tenant".
0.3.1 - 2026-08-02
Changed
- Widen the
replicantrequirement from~> 0.1.0to~> 0.3so consumers can adopt the current replicant line (0.3.x) without a resolver conflict. ash_replicant calls only replicant's stable core (Replicant.{Change, SchemaChange, Sink, Transaction, lsn},Replicant.start_link/1,Replicant.stop/1); verified compatible against replicant 0.3.1:mix compile --warnings-as-errorsclean andmix test90 passed / 0 failed (unit suite; the:integrationgate — 52 tests needing a livewal_level=logicalDB viaASH_REPLICANT_TEST_URL— is owed in CI before publish). No API or behavior change.
0.3.0 - 2026-07-14
Added
- SCD2 history mirroring — a per-resource opt-in (
history_strategy :scd2) that mirrors a source table into a host-defined validity-windowed version table (close-current + insert-version) instead of overwriting current state. Effect-once, fail-closed multitenancy, value-free boundaries, and Critical Rule 1 preserved; newValidateHistorycompile verifier;on_truncate :close. Audit-log needs remain served by AshPaperTrail on an SCD1 mirror.
Security
- Multitenancy fail-closed at compile time — both tenant sources.
ValidateMultitenancynow requires an Ashmultitenancyblock whenever atenant_attributeor atenant_mfais declared. Without a block Ash silently ignores thetenant:option the sink passes, so every tenant's rows mirror unscoped into one table — a proven fail-open with no runtime error. Thetenant_attributearm shipped 2026-07-10; the symmetrictenant_mfaarm closes the parallel hole (2026-07-14). Any strategy (:attributeor:context), including aglobal?block, satisfies the gate. See ADR-0001. - A
false-resolved tenant now fails closed.Resolver.resolve_tenant/2rejected onlynil/blank-string tenants; atenant_attributecolumn holding booleanfalseor atenant_mfareturningfalseresolved to{:ok, false}. Ash treats a falsy tenant as no scoping (neither force-set nor required), so the mirror write landed unscoped across tenants.falsenow returns:tenant_requiredlikenil(2026-07-14). - Sink-selected actions can no longer bypass tenancy. A new
ValidateActionMultitenancycompile verifier rejectsmultitenancy :bypass/:bypass_allon the host's primary read, create, destroy, and the SCD2 close action of a multitenant resource — Ash would otherwise ignore the tenant the sink passes and mirror every tenant unscoped (and a:bypassread would let abulk_update/bulk_destroymatch and mutate another tenant's rows), despite a valid multitenancy block.:enforceand:allow_globalremain permitted (2026-07-14). - The multitenancy discriminator column is now shape-checked. Under
strategy :attribute,ValidateMultitenancyrejects asensitive-classified or binary-storage-typed multitenancyattribute— Ash force-sets it to the plaintext tenant and filters reads on it, so an encrypted/binary column would store/compare a mismatched value and silently mis-scope (reads return empty). AshCloak-encrypted attributes are already rejected by Ash's own verifier (2026-07-14).
0.2.0 - 2026-07-09
Added
ValidateTenantSourcecompile-time verifier — a resource declaring non-global Ash multitenancy must declare areplicanttenant source (tenant_attributeortenant_mfa). Without one, every mirror write is attempted withtenant: niland halts fail-closed (:tenant_required) at runtime; this gate moves that failure to build time. It is the converse ofValidateMultitenancy(which checks the shape of a declared discriminator).
Fixed (closeout review, 2026-07-08 — /review-autopilot --fix)
- Snapshot fails closed on an empty resolver index —
handle_snapshot/3andhandle_snapshot_complete/2now share thehandle_transaction/2fail-closed guard (a degenerate/misloaded index no longer silently drops a backfill while advancing the checkpoint). on_truncate :mirrorclears tenant-blind — was aTenantRequireddead-end for non-global attribute-multitenant resources; now a quoted rawDELETEon the mirror table (matching the snapshot redo-safety clear).- Full telemetry contract — the
[:ash_replicant, :snapshot, :batch]/[:snapshot, :complete]events (previously never emitted),:sink,:haltederror_class, and:sink,:appliedchange_count+durationmeasurements are now emitted (change_countcounted single-pass). transaction?: falseon the per-record upsert (the sink owns the outer transaction the action joins).
Documented (closeout review)
- Tenant-scoped source tables must be
REPLICA IDENTITY FULL— a tenant-scoped delete / PK-changing update resolves the tenant fromold_record, which is key-only under the default replica identity (else the sink halts fail-closed:tenant_required). Documented in AGENTS Critical Rule 2, thetenant_attributeDSL doc, README, and usage-rules; locked by a key-only-old_recordred-gate.
Optimized (post-closeout, 2026-07-09)
- Snapshot bulk path computes its reflection once per batch — the non-tenant
bulk upsert derives the
{skip, cloak, attribute-name}reflection a single time (Resolver.upsert_reflection/1+Resolver.upsert_input/2) instead of re-deriving it per row;attrs_for_upsert/2is retained for single-record callers. Behavior unchanged (F13). - Delete path is a single atomic
bulk_destroy—Apply.destroy_by_pk/3issues oneDELETE ... WHERE pk(strategy: [:atomic, :stream],transaction: false) instead of read-then-destroy, falling back to per-record streaming when a host destroy action carries non-atomic changes. The nil-PK fail-closed guard, per-row tenant scoping, and idempotent-on-absent-row semantics are preserved (F14).
0.1.0 - 2026-07-08
First release: the complete Ash Replicant.Sink adapter with effect-once
semantics, fail-closed multitenancy, AshCloak integration, and compile-time
sensitive-column verification.
Added
Ash resource extension (
AshReplicant.Resource) — areplicant do ... endDSL section for marking AshPostgres resources as CDC mirror targets. Options:source_table,source_schema,tenant_attribute,tenant_mfa,sensitive,skip,on_truncate,on_schema_change,upsert_identity.Checkpoint macro (
AshReplicant.Checkpoint) — generates an AshPostgres resource backing theash_replicant_checkpointstable (one row per slot, tracking the durablecommit_lsnwatermark). Bound to the host's repo and domain at compile time.Sink-config macro (
AshReplicant.Sink) — generates aReplicant.Sinkimplementation with repo, domains, checkpoint resource, andslot_namebaked in. Theslot_nameis the single source of truth for the replication slot (not astart_linkoption) and keys the resolver index.Resource resolver (
AshReplicant.Resolver) — maps{schema, table}pairs to resources, built from the sink's domains. The index is cached in:persistent_termand accessed by the sink's transaction handler.Sink action applier (
AshReplicant.Apply) — applies changes to mirror resources: upsert by PK, destroy, truncate per policy. Actions are the host's own resource actions; the sink invokes them withauthorize?: falseat the boundary (the host's Ash policies still guard those actions for application callers; the flag exempts only the sink's in-transaction mirror writes from re-gating). Tenant is passed per-row.Compile-time verifiers — enforce critical rules:
ValidateSensitive: each sensitive column maps to an AshCloak-encrypted attribute, a binary-storage attribute, or is skipped.ValidateMultitenancy: a multitenant resource with atenant_attributehas a plaintext, declared discriminator; the tenant is never classified or skipped.
Value-free error & telemetry boundaries — sink failures and halt paths carry structure (error reason, table name, LSN) only. No row values, PKs, tenant names, or raw data appear in logs, errors, or telemetry. Column names are strings, never atoms.
Effect-once transaction model — each
Replicant.Transactionapplies in oneRepo.transaction: skip by commit-LSN watermark, apply rows, upsert checkpoint atomically. Failure rolls back; on resume, un-acked WAL re-streams and dedups against the durable checkpoint. Proven by crash-injection tests (loss = 0, effect-dup = 0).Documentation —
CLAUDE.md,AGENTS.md,README.md,CHANGELOG.md,usage-rules.md,CONTRIBUTING.md,LICENSE,NOTICE; tracked charter atdocs/CHARTER.md(only/docs/superpowers/lifecycle artifacts are local-only).