Packages

An Ash-native Replicant.Sink adapter: effect-once CDC mirroring into AshPostgres resources.

Current section

Files

Jump to
ash_replicant CHANGELOG.md
Raw

CHANGELOG.md

# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.0] - 2026-08-25
### Changed
- **The guard-mutation battery runs its cells in parallel** (`--jobs N`,
default 4): the runner fans the deterministic selection out to N
self-reinvoked shard children, each in its own isolated workspace, so
sharding changes wall-clock only — every selected cell still runs and
still RED-proves its sentinel, the parent still validates duplicate ids
and (for the full-matrix modes) the complete cell inventory, and a
failing shard is reported by ordinal and exit code, value-free. A
release-slice push that previously serialized ~130 sentinels in one
runner now divides by the shard count (the 4-vCPU CI runner and a local
workstation alike). `--jobs 1` is the legacy path; the self-test pins
the partition (complete, disjoint, order-stable) and both parallel
outcomes green and red.
- **Path scoping no longer forces the FULL mutation matrix for every
`scripts/` edit** — only the mutation runner itself is evidence
machinery (mutation children execute `mix compile` / `mix test`
directly; no other script's bytes can change what a sentinel observes).
Contract/checker/wrapper script edits now select zero mutation cells;
`config/`, `test/support/`, `.github/`, `priv/`, and the build-identity
files keep forcing the full matrix.
### Added
- **`scripts/prepush.sh`** — one fail-fast command mirroring every
CI-runnable gate in cheapest-first order (format, warnings-as-errors
compile, release checkers, release contract, Credo, deps.audit, the
no-database structural suite, Dialyzer, docs, hex.build), plus the full
live lane (ecto create/migrate, migration drift gate, live suite) when
`ASH_REPLICANT_TEST_URL` is set. A red push costs seconds locally
instead of a CI round-trip.
## [1.1.0] - 2026-08-25
### Changed
- **The AshOnetime requirement moves from `~> 0.6.0` to
`>= 1.1.0 and < 2.0.0-0` and the lock moves to 1.2.1** (with in-range
transitive re-locks ash_sql 0.6.9 and ecto 3.14.2; Ash stays at the 3.31.3
lock). The prior constraint excluded AshOnetime 1.x entirely, forcing
consumers that pin AshOnetime 1.x directly into an unsatisfiable resolution
against AshReplicant. The floor is 1.1.0, not 0.6.0, because 1.1.0's
logical-partition store schema is the shape this package's test fixtures and
migration content contract (`scripts/test-ash-onetime-migration-checker.sh`)
pin — the exact-floors CI cell proves that floor. Nothing AshReplicant
admits or inspects changed shape across the range: the
`AshOnetime.Resource.Protection` entity is field-identical from 0.6.0
through 1.2.1, so contract manifest digests are unchanged by the upgrade
(0.7.0's only break — the Ash floor at 3.31.3 — is already this package's
floor). CI now pins the 1.1.0 floor in the exact-floors cell and floats latest
1.x in the latest-compatible cell, mirroring the Ash/Replicant legs; the
release contract and its self-test teach the new matrix, job-env, and
resolve-step pins plus the AshOnetime foundation lines. One
schema consequence for hosts: AshOnetime 1.1.0 added the
`logical_partition` column to its admission store, so a host that serves
message routes from its own AshOnetime store installed before 1.1.0 must
run AshOnetime's `mix ash_onetime.gen.logical_partitions` upgrade before
serving traffic on 1.1+ (AshOnetime 1.2.0's `mix ash_onetime.doctor --live`
catches the upgrade-without-migrating case). This repo's vendored
test-store install migration is regenerated from the 1.2.1 renderer, and
the full live battery runs green against the upgraded store shape.
### Fixed
- **The tombstone, horizon, and census control-plane paths bind the admitted
dynamic repo** (cross-vendor full-range review, codex peer — CONFIRMED).
Runtime delivery pins its data-layer context, but the O02/O03 writers
reconstructed the static sink config (status) or passed the layer map
unwrapped (horizon), so a host running an owned dynamic repo instance
could read/write control state in the wrong database, miss a halt's
durable record, or waive the `:retention_horizon_crossed` resume gate.
The binding now has ONE home — `Destination.action_context` (the callable MODULE in the Ash
context) and `Destination.with_repo_binding` (the admitted instance
through the process dictionary, wrapping every destination transaction entry and control-plane
read: delivery's eleven transaction sites, the census checkpoint read, the
tombstone write/read legs, the digest-key witness, and the resume gate —
which now also receives the admitted instance at activation). Red-first
proven on a dedicated second-database dynamic instance
(`dynamic_destination_binding_test.exs`, 4 legs).
- **A digest-key set larger than the witness envelope cap (16 versions) is
refused at preflight** with `:config_invalid` instead of failing at bind
as an opaque encode error (claude peer; `rebind_key_state/1` also no
longer raises `WithClauseError` on an observation error).
- **The durable `:operator_stopped` tombstone is written after the pipeline
is down** (claude peer): `stop_supervised/1` captures the stop fact,
waits out the destination lease via `safe_stop`, then records — the census
halt path's D3 ordering — so an admitted checkpoint write committing during
the stop window can no longer erase the stop's own durable record.
## [1.0.0] - 2026-08-24
First stable release. The public API, DSL, error reasons, telemetry
events, and package contents are frozen by ADR-0023
(`test/ash_replicant/public_api_freeze_test.exs`); everything inside the
frozen inventory is internal-by-convention. Supported foundation:
Elixir 1.20.3 / OTP 29, Ash `>= 3.31.3 and < 4.0.0-0`, Replicant
`>= 1.2.3 and < 2.0.0-0`, PostgreSQL 16–18.
### Changed
- **Support matrix narrowed to PostgreSQL 16–18 (PG15 dropped)** and the
live CI matrix extended to match: the compatibility job gains pinned
`postgres:17` and `postgres:18` cells (current-lock dependency range
alongside the existing three PG16 cells — dependency ranges stay
fully covered on 16, and the version axis runs current-lock per
major). Roadmap D6, README, CONTRIBUTING, and the Charter note are
amended; image digests stay pinned through the release contract's
exact matrix match.
### Added
- **Measured performance bounds (B01 / issue #16 / ADR-0021).**
`test/integration/performance_bounds_test.exs` measures every delivery
mode — per-transaction streaming, sink-owned batch, SCD2 versioning,
append-log — through the real pipeline on the live substrate, and
carries wide CI sentinel floors (~1/4 of measured steady state: they
catch class regressions — a lost single-pass stream, an O(n²) apply,
an unbounded materialization, lost batch amortization — never percent
drift) plus a total-BEAM anti-blowup ceiling. With
`ASH_REPLICANT_BENCH_RECEIPT=<path>` the same legs append the
reproducible baseline receipt; `bench/BASELINES.md` records the
2026-08-24 receipt (59–84 rows/s across modes, Apple-silicon laptop,
pinned PG16 image) and the dominant-cost finding: per-change
generation-guard re-validation, with batch amortization visible
(throughput +~40%, statements −~25% at equal rows). Changing a
protected bound is an ADR amendment, not a test tweak.
- **Data-plane fault recovery states documented (F02 / issue #15).** The
batch, message, snapshot, and append fault legs already carried
live-tested zero-net-effect proofs from their landing slices; this adds
their recovery-state rows to `docs/RECOVERY.md` (whole-batch rollback
and re-delivery skip; message claim replay, external-peer AshOnetime
three-state ambiguity recovery — which requires an explicit restart —
and the retention-window re-execution with the `:retention_horizon_crossed`
resume refusal; v1 and incremental snapshot fault semantics; append
origin-floor halts and crash-resume exactly-once) plus a fresh
red-capability receipt for the one previously-unpinned guard (the
batch whole-skip arm).
- **Path-scoped guard-mutation evidence in CI.** The no-database job's
guard-mutation gate (ADR-0003) now runs per-push only the cells the push
makes relevant — a diff touching a cell's guard file or its named focused
test selects exactly those cells (a pure rename lists both paths, so a
renamed guard file fails loudly at the stale anchor rather than silently
selecting nothing); docs/README/CHANGELOG-only pushes select none. The
scoping fails safe: a diff touching the evidence machinery (the runner's
`FULL_MATRIX_PREFIXES`/`FULL_MATRIX_FILES` set) or an unusable diff base
(first push, force push, non-push events) runs the FULL matrix, every
`--diff-base` path still runs the runner's self-test battery first, and
the local developer battery is unchanged (always full). Twelve runner
self-test scenarios pin the partition in both directions (completeness:
a changed guard file selects every cell anchored to it; the gate script
itself forces FULL; a rename lists both paths), each proven red-capable
by tamper.
- **Fault-recovery matrix — live proofs and documented recovery states**
(roadmap F01 / issue #14). `test/integration/fault_recovery_test.exs`
exercises the control-plane crash/reconnect matrix against a live
PostgreSQL: source disconnect mid-stream (forced in-process walsender kill
— continuation is exactly-once, the owner and generation untouched), owner
death (the next delivery fails closed, the pipeline halts itself, the
surfaced state is the tombstone cause, and an explicit re-activation reaps
the dead generation and resumes with no duplication or loss), and a
persistent checkpoint read fault injected mid-run (the connect-position
read is fail-open by transport design, so safety rests on the delivery
admission's locked re-read failing closed — no effect proceeds, the
watermark is byte-identical, repair plus explicit restart recovers
exactly-once). Each leg is red-capable: a paired mutation of its guard
turns the test red (receipts in the F01 design note; the watermark skip's
own proof remains the direct-LSN suite). `docs/RECOVERY.md` documents the
full fault → surfaced state → pipeline behavior → operator action matrix
(both owner-death windows, the absent-slot fail-closed carve-out, and the
two telemetry/tombstone names one halt can carry), linked from the README
status section.
- **Typed operations telemetry, part 1 — executable examples and per-key
mutation matrix** (roadmap D3 / issue #13 / O03). `AshReplicant.Telemetry`'s
moduledoc now ships two operator-copyable example blocks — a dependency-free
metrics reporter (`:telemetry` + `:counters` attaching over
`emitted_event_names/0`) and an OpenTelemetry bridge whose handler runs
without the optional OTel dep while its mapping table stays pinned complete
against the emitted inventory — both extracted verbatim and executed by
`test/ash_replicant/telemetry_examples_test.exs`. The data-boundary mutation
matrix gains the value-free telemetry family: 28 cells (one garbage-type
mutant per typed metadata key, one lax mutant per off-type-tested key, the
shared non-negativity clause mutants, and one removal mutant per measurement
key), with a completeness tripwire test forcing every future telemetry key
to ship with its mutant.
- **Recovery horizon, part 1 — declaration and activation enforcement**
(issue #13 / O03). Sinks with claim-backed `message_routes` must declare
`recovery_horizon: {count, unit}` (the operator's supported outage/replay
window; normalized to seconds at compile). Activation refuses with the new
closed reason `:retention_below_recovery_horizon` (misconfigured class)
when any routed create's declared AshOnetime retention does not cover the
horizon — an in-window outage would otherwise expire a standalone message's
only dedup while its WAL is still recoverable. Route-less and `:append_log`
sinks are exempt (append routes dedup structurally through the append
identity); a stray horizon in either posture is a compile error.
Classification lives in the new `AshReplicant.Horizon` — one body the
doctor and census will delegate to.
- **Typed operations telemetry, part 2 — the recovery-horizon alert legs**
(roadmap D3 / issue #13 / O03 / ADR-0022). Three legs because the census
dies with the pipeline and the primary horizon scenario is halted: (1) the
census classifies slot facts through the doctor's own probe —
`wal_status` `lost` halts `:source_wal_lost`, `unreserved`/exhausted
`safe_wal_size` emits the new `[:ash_replicant, :retention, :at_risk]`
event (meta only) and continues; (2) a new **activation resume gate**
refuses `:retention_horizon_crossed` when re-activating after a halt
longer than the shortest claim retention while the slot still retains
WAL — the operator reconciles instead of silently re-executing
expired-claim standalone messages; (3) the doctor's new
`:retention_horizon` check delegates to the same body (static
retention-vs-horizon plus halted-duration classes; `:skipped
:no_claim_routes` for route-less sinks, never `:pass`). The slot
statement itself (`sql_replication_slot/0`) gained `safe_wal_size` so the
runtime and doctor share ONE SQL home (rule 11).
- **Digest-key horizon witness** (issue #13 / O03 / ADR-0022). The
checkpoint gains a nullable `digest_key_state` column (run
`mix ash.codegen` after upgrading): an authenticated envelope recording
the last-observed message-digest key set under the orthogonal
`:ash_replicant, :horizon_provenance_keys` family (configure it — same
`{version, key}` shape — before starting a message-routed sink). Because
the claim stores only a hash of the versioned digest, the witness is the
only durable bound on "a key version still backs potentially-live
claims": it rebinds at bind and on every census-observed key-set change
(claims mint continuously — a bind-only witness goes blind on
long-lived connections), and removing a version within `max(route
retention)` of the last observation containing it halts
`:digest_key_horizon_violated`; tampered/undecodable/clock-regressed
state fails closed as `:digest_key_state_invalid`. The README's new
"Observability and recovery horizons" section carries the alert table
and the operator runbook (the doctor on the operator's scheduler is the
periodic pull channel while halted).
- **Coherent runtime status and lifecycle tombstones** (roadmap D3-status /
issue #12 / ADR-0019). `AshReplicant.status/1` answers with the closed
five-value contract `:healthy | :catching_up | {:halted, reason} |
{:misconfigured, reason} | :not_started`, derived — never stored — from the
live `PipelineOwner`'s own facts (census health, pipeline liveness), the
node-local generation entry, the tombstone legs, and, when the repo is
running, the durable checkpoint evidence. `AshReplicant.Status.derive/2`
exposes the six-state generation lifecycle underneath (`:activating`,
`:ready`, `:degraded`, `:halted`, `:stopped`, `:superseded`) with typed
value-free evidence. Healthy requires a live owner and pipeline, an enabled
census whose last run passed, and no in-flight snapshot — owner liveness
alone is insufficient, and a dead or stale generation can never report
healthy (it is the fault `{:halted, :owner_lost}`).
When a generation ends, the party that knows the cause records a terminal
tombstone: every error leaving a sink callback (Replicant halts the
pipeline on any non-ok sink return, so the scrubbed reason riding the
return is recorded at that one boundary — covering the halt funnels and
the bind/session-identity/slot-origin error paths alike), the owner's
census halt, and an operator stop (`:operator_stopped`). The tombstone is
bounded (latest per slot) and value-free (closed reason atoms, a class, a
timestamp); its durable leg lives in three new nullable checkpoint columns
(`terminal_cause`, `terminal_class`, `terminal_at` — regenerate with
`mix ash.codegen` and migrate), written only when the row already exists.
Every admitted checkpoint write (bind — including the otherwise
verify-only steady-state reconnect — and advance) clears the durable leg,
so a stale cause cannot outlive the generation that superseded it. A halt
while the destination is unreachable persists only the node-local leg; a
host-tree shutdown writes no tombstone; an unexplained pipeline death
records the generic `{:halted, :pipeline_terminated}` (Replicant discards
halt reasons at teardown). Status/tombstone reasons never carry row,
message, prefix, or progress-token bytes, and a foreign persisted cause
decodes to the closed fallback `{:halted, :tombstone_unknown}` without
minting atoms.
- **Read-only operator preflight and doctor commands** (roadmap D2).
`mix ash_replicant.preflight --pipeline MyApp.Replicant.Pipeline` answers
whether a pipeline may start — dependency requirements, sink configuration,
destination admission, source reachability, PostgreSQL release, the
connecting role's REPLICATION and per-table `SELECT` privileges, source
identity, strict coverage, replica identity, slot shape, and the retention
horizon — reading no checkpoint, so it is correct on a fresh install.
`mix ash_replicant.doctor` adds the durable-state classes: checkpoint state,
contract drift, and runtime readiness. Both resolve the generated pipeline's
own admitted start options, and the same diagnosis is available in-process as
`AshReplicant.preflight/1` and `AshReplicant.doctor/1`. The Mix task admits
the generated marker from the BEAM export table before loading the named
module, so an arbitrary module cannot execute `@on_load` or
`start_options/0` through the read-only command.
The commands perform **no writes**, on three independent legs: a fail-closed
read-only statement admission (leading `SELECT` only, no separator, no write
verb, no row lock, no session-escaping function such as `set_config` or
`dblink*`); a probe connection opened `default_transaction_read_only=on`, so
PostgreSQL itself refuses a write the admission missed; and a destination
checkpoint read through its `:read` action with `authorize?: false` and no
lock. They start only Postgrex's client runtime dependencies; they never
start the host application, a repo, a pipeline, or a service.
Machine (`--format json`) and operator output are both total functions of one
canonical `AshReplicant.Doctor.Report`, so they cannot disagree. Missing
privileges, unknown checkpoint state, replica identity, the retention horizon
(`retention_extended``retention_at_risk``retention_lost`), contract
drift, and the dependency/source version axes each carry their own reason
atom. Reasons are a closed vocabulary and `detail` is a fail-closed allowlist
of catalog identifiers, so no connection option, publication name, source
identity, slot name, watermark, or row value reaches either format. A leg
that could not be judged is reported `skipped` with the reason, never passed.
Exit codes are `0` clean, `1` a failed check, `2` warnings only, and `3` an
invocation that could not be diagnosed at all — distinct from `1` so a
monitoring caller can tell an unhealthy deployment from a bad invocation.
A never-matching `ignored_sources` entry now warns (`ignore_never_matches`).
A permission or catalog-statement fault after connection is now reported as
`privilege_probe_missing` / `source_probe_failed` while reachability remains
passed, rather than misclassifying a responding server as unreachable.
Coverage rules are reused, never re-implemented: `Coverage.evaluate/3`,
the new `Coverage.replica_identity_check/2` and
`Coverage.probe_identity_check/2` delegates to the existing private rules
(so replica identity is reported even when an earlier rule short-circuits),
and `Identity.classify_stored_contract/3` for drift.
Missing declared tables remain a concrete `source_table_missing` coverage
failure instead of being masked as an unjudgeable census, and replica
identity is skipped independently when its relation does not exist. The
identity probe now reads `pg_control_system().system_identifier` on every
supported PostgreSQL 15 through 18 release rather than weakening PG15–16 to
a database-name-only comparison.
- **Guarded 0.4.0 to 1.0.0 package upgrade and rollback.**
`mix ash_replicant.upgrade 0.4.0 1.0.0` requires explicit per-sink source
identity bindings, classifies the selected destination without printing
identity-bearing source diffs, converts static 0.4 supervision to the
generated `AshReplicant.Pipeline`, removes only the compile-time
`apply_ledger` marker, and writes the guarded host migration plus the exact
current AshPostgres resource snapshot (including a Repo-configured custom
snapshot path). The migration upgrades populated or empty legacy tables in
one locked transaction, refuses shared/ambiguous/foreign/interrupted state,
and retains a checksummed rollback ledger. Down refuses after any durable
1.0-only state or watermark change; the published procedure requires database
rollback before package downgrade and otherwise says to restore from backup
or remain on 1.0. A real consumer-process fixture proves redacted dry-run,
apply, migration up/down, and no host application or pipeline start.
- **Data-boundary guard-mutation gates** (roadmap D7 / SEC01, ADR-0003).
`scripts/run-mutation-gates.py` proves the fail-closed data-boundary guards
are OBSERVED by the no-database focused tests: each of its 53 matrix cells
removes exactly one production guard, removes one sibling call site of a
shared guard, or moves one notifier guard after its first effect — across
tenant absence, tenant reassignment, replica identity,
sensitive type shape, sink-action multitenancy bypass, dynamic destination
participants, notifier load drift, snapshot fingerprint collisions, and
append identity, and requires the named focused selector to go red with a
property-specific fingerprint. Mutants run serially in an isolated
temporary project copy (dependencies copied and made read-only, mutant and
restored BEAM digests proving build identity, `ASH_REPLICANT_TEST_URL` and
Mix path redirections deleted from every child, zero `TestRepo` starts
asserted); runner output is structural and value-free, SIGINT/SIGTERM
teardown kills and confirms the child process group before scratch cleanup,
and a sentinel self-test drives every
failure class — missing/duplicate anchors, stale builds, restoration
drift, baseline failures, vacuous mutants, wrong reds, timeouts with live
descendants, and internal errors — proving no child byte reaches the
runner's own output. The matrix runs once in the no-database CI job and is
pinned by the release contract.
- The pure B4 tri-modal tenant-transition tests moved from the
`:integration`-tagged apply suite into `resolver_test.exs` so the
reassignment guards have a no-database observation channel, gaining a
tripwire for an update with NO old tuple on a tenant-scoped resource;
live source pins now hold the `Apply`/`Apply.Scd2` tenant-pair preludes
and extend the notifier-load guard count to the snapshot mark and retire
call sites.
- **Fresh-install Igniter path with a tied-out manual equivalent** (roadmap
D1/I01). `mix ash_replicant.install` — reachable as
`mix igniter.install ash_replicant` — generates the Ash domain, the checkpoint
resource, the sink, and the pipeline supervisor; registers the domain in
`:ash_domains`; supervises the pipeline; imports AshReplicant's public DSL
formatter metadata; and queues
`mix ash.codegen install_ash_replicant`, so the checkpoint migration comes
from the host's own resource snapshots rather than a shipped template that
could drift. `--repo`, `--slot`, `--domain`, `--checkpoint`, `--sink`, and
`--pipeline` override discovery and naming. Igniter is an **optional**
dependency: without it the task prints the instruction to add it, and no
shipped library code depends on it.
- **Nothing speculative is written.** The installer emits no connection,
publication, source identity, or key material — a plausible-looking
placeholder is worse than an absent one — so a fresh install compiles and
boots as a no-op. Re-running it over an installed project changes nothing.
- **`AshReplicant.Pipeline`** is the new generated operator wiring: a host
supervisor that supervises no children until
`config :otp_app, MyApp.Replicant.Pipeline` supplies `:connection`,
`:publication`, and `:source_identity`, and that RAISES on a
present-but-incomplete configuration rather than supervising nothing (a
silent outage). Its owner child stays `:temporary`, so a halt remains
permanent (ADR-0014). Its messages name missing KEYS, never their values.
- **A closed structural refusal set, each writing nothing:** malformed module
names; an illegal PostgreSQL slot; no, ambiguous, unknown, or non-AshPostgres
repo; incomplete project facts; a foreign target module; an unreadable
existing binding; and checkpoint, sink, or pipeline identity drift.
Re-keying a live sink would abandon its durable checkpoint row and
re-deliver from the new slot's position, so the installer stops and names
the resolving flag or structural fact.
- The README's "Manual installation" block is tied to the installer's actual
output by a test comparing parsed module/`use` contracts, so the documented
hand path cannot drift from the generated one. Red-capable tripwires cover
every refusal, the fail-closed pipeline admission, exact AST ownership,
formatter import/export, the idempotency short-circuits, and the doc tie-out.
- **Continuous invariant census** (ADR-0019, roadmap C5/C01). The existing
temporary `PipelineOwner` now schedules one jittered, bounded worker that
enters through the same owner-liveness, destination-generation, and pinned
dynamic-Repo guard as delivery callbacks. It rechecks the live destination
and source contract, the durable source-bound checkpoint plus authenticated
contract, and the full publication/column/type/RIF coverage even on a quiet
stream. Drift halts immediately; timeout/unreachable/checker faults are typed
non-pass states and the exact consecutive-fault budget halts
`:census_unverifiable`. Schedule-after-settle timing, worker teardown, and
mutation gates prevent overlap, orphan work, and vacuous green checks. A
drift result that races its timeout still halts immediately, and stored
contract terms decode in safe mode so checkpoint bytes cannot intern atoms.
- **Immutable append-log delivery** (ADR-0018, roadmap C4). A generated sink is
now exclusively `sink_kind: :state_mirror` (the default — every existing host
is unchanged) or `:append_log`, and an append sink records inserts, updates,
deletes, truncates, logical messages, snapshot rows and batches as immutable
events through a host-owned Ash create action.
- `use AshReplicant.Sink` accepts `sink_kind:` and, for an append sink, the
required `initial_state: :snapshot | :go_forward` — its ONE declared
initial-state intent. Activation rejects a sink whose mapped resources
disagree with its kind (`:sink_kind_mixed`) and an intent that contradicts
the `snapshot:` start option (`:initial_state_mismatch`).
- `replicant do append_log true end` marks a host AshPostgres resource an
append target. It declares its own structural attributes (source system,
source database, slot, commit LSN, ordinal, operation, origin, snapshot
attempt), optional message prefix/content attributes, the immutable create
action (`append_action`, default `:append`),
and the append identity (`append_identity`) — verified at compile time by
`ValidateAppendLog`, which also refuses SCD2 history, snapshot provenance,
and the state-mirror truncate policies on an append target. `on_truncate`
gains `:append` (record the structural truncate event), admitted only on an
append target and only when no tenant source is declared.
- The append identity is exactly `(source system, database, slot, commit LSN,
ordinal)`. Delivery upserts against it with an EMPTY `upsert_fields`, which
renders as a no-op conflict clause: a re-delivered WAL position appends ONCE
and never overwrites the stored payload. Effect-once still rests on the
append and the checkpoint committing in one locked destination transaction.
Every non-attempt structural attribute is non-null, and the verifier rejects
manual append actions or arbitrary action/global create changes that could
rewrite the admitted identity after input validation.
- Operation shapes are explicit and value-safe. A delete appends the admitted
old record; a truncate is a structural event with no payload; a backfill row
carries origin `"snapshot"` plus the checkpoint-owned attempt id, reusing
none of the state-mirror row-provenance attributes. A routed message appends
through the same action with operation `"message"`: transactional messages
use the commit LSN/shared ordinal and standalone messages use their WAL LSN
with ordinal zero. Payload mapping, tenancy and sensitive-type classification
are the state-mirror rules unchanged. Every append source is activation-gated
on `REPLICA IDENTITY FULL`, so a delete can never silently degrade to a
primary-key-only event.
- A go-forward append sink implements `handle_slot_origin/2` and persists the
log's IMMUTABLE origin floor on its first admitted activation (new checkpoint
attribute `origin_floor`; nullable, always NULL for a state mirror, so the
regenerated migration is additive and needs no data capture). Later reconnect
origins are resume facts: a slot CREATED this session arriving at a log that
already claims a floor halts `:append_origin_gap`, and an appended event
above the durable checkpoint halts `:append_frontier_divergent`.
Replicant append sinks do not idle-advance over filtered WAL; a reused origin
above the durable checkpoint therefore halts as an out-of-band gap. Quiet
append publications use a normal published heartbeat to release retained WAL.
- New closed error reasons: `:append_origin_gap`, `:append_origin_invalid`,
`:append_frontier_divergent`. New AshOnetime invocation label `:append`.
The public Replicant floor is now 1.2.3, whose append callback lifecycle
retains filtered WAL until an actual append delivery or heartbeat advances
the durable checkpoint.
- **Sink-owned incremental snapshots are restartable and physically
effect-once** (ADR-0017, roadmap S03). Generated sinks now expose
`snapshot_progress/0`; activation accepts Replicant 1.2.2 incremental mode
only when every mapped resource declares `snapshot_provenance true`.
- `snapshot_progress/0` locks the source-bound checkpoint and durably arms a
random attempt before Replicant can start its reader and stream. Before the
first chunk it returns `:backfill_pending`, so a crash after slot creation
resumes the reader instead of abandoning the backfill. Owner or transport
restart returns the exact previously committed opaque token and reuses the
attempt.
- Each bounded chunk commits its host effects, row fingerprint/membership,
exact progress token, and durable operation-key ordinal cursor in one
destination transaction. A failed chunk advances none of them.
- Streamed inserts and updates during an armed/active backfill run the normal
host action and stamp the active marker before advancing the stream
watermark; deletes remove/close normally. Sink-owned transaction batches
apply the same rule under their one trailing watermark write.
- An SCD2 stream version committed before its table's snapshot window remains
the current comparison target even when it opened above the snapshot floor.
A matching current-read snapshot row coalesces through provenance instead
of attempting a second open version.
- Incremental completion is Replicant's empty at-least-once
`handle_snapshot/2` call. It retires unseen rows per destination tenant
scope, stores the exact token plus its SHA-256 replay fence, and never
regresses the stream watermark. Matching redelivery no-ops before any scan.
- In-flight progress is hash-bound inside the authenticated state envelope,
so a valid replacement token cannot skip work. State/progress tamper,
impossible pairing, incomplete-attempt contract drift, and missing key
versions fail closed. A completed fence survives later admitted contract
drift so deployment cannot brick streaming. A durable rewrite rotates the
envelope to the active retained key; after completion the old key can be
removed.
- Black-box gates over the fetched Replicant artifact pin current-read
collision filtering (insert/update/delete/key change), keyed three-attempt
contention exhaustion, keyless redo signaling, reconnect accounting, and
pending-chunk backpressure.
- **Whole-table (V1) snapshot retry is now physically effect-once** (ADR-0017,
roadmap S02). A retry no longer repeats a committed host business effect for
a row that did not change. Converging to the same final state was never proof
of this: a create, destroy, or SCD2 close can carry an append-only local
effect even when a later upsert converges to identical bytes.
- **One checkpoint-owned attempt.** The checkpoint row carries a versioned,
HMAC-authenticated, strictly decoded `AshReplicant.Snapshot.State` envelope:
mode, status (armed/active/complete), a cryptographically random 256-bit
attempt id, the V1 delivery-run id, the bound contract digest, the
provenance key version, and the completion replay fence. It is read and
written under the checkpoint row lock, which serializes chunks, completion
and retirement against each other across nodes. Undecodable, tampered,
impossible, or contract-drifted state fails CLOSED
(`:snapshot_state_invalid`) — never a silently fresh attempt.
- **One delivery run per activation.** `AshReplicant.PipelineOwner` activation
mints a 256-bit `delivery_run`. The first actual `handle_snapshot/2`
callback of a run binds it to a fresh attempt; later callbacks in that run
reuse it; an operator-authorized re-export under a LATER owner rotates the
attempt even when PostgreSQL returns the same consistent point.
`first_for_table?` carries no attempt identity.
- **Fingerprint-gated row effects.** For a `snapshot_provenance true`
resource, each row resolves its tenant and current open target, recomputes
the canonical fingerprint, and — on a match — invokes ONLY the private mark
action. A changed or absent row goes through the normal host business action
and is then stamped. An UNKNOWN comparison answer (dropped key, unknown
encoding version, non-deterministic value) halts
(`:snapshot_provenance_unavailable`) rather than degrading to "changed".
Key rotation re-stamps unchanged rows under the active version.
- **Fenced completion and tenant-scoped retirement.** Completion locks the
checkpoint, checks the permanent V1 replay fence BEFORE any row scan (same
delivery run and LSN → no-op, so a redelivered completion cannot retire a
later stream write), then enumerates every destination tenant scope and
retires only managed open rows whose marker differs, through the host's own
retire action with `tenant:` set. Attribute multitenancy enumerates the
verifier-approved discriminator with `SELECT DISTINCT` through the admitted
Repo; global and non-multitenant resources take one scoped pass. SCD2
retirement closes the open version and never touches closed history.
- **`snapshot_tenant_scope_action`** (new DSL option) is REQUIRED for a
non-global `strategy :context` multitenant resource with
`snapshot_provenance true`: a private generic action returning the array of
retained tenant contexts. Context multitenancy has no discriminator column,
so the host is the only authority on which scopes exist — including one
wholly absent from the source attempt. A missing, raising, or malformed
enumeration fails CLOSED (`:snapshot_scope_incomplete`).
- The snapshot mark, retire, and tenant-scope actions are now admitted
manifest roots, walked for participants and tied out like every other mapped
action. Two new invocation labels (`:mark_seen`, `:retire_unseen`) give them
their own operation identity.
- Three new reasons join the frozen taxonomy (additive per ADR-0011):
`:snapshot_state_invalid`, `:snapshot_provenance_unavailable`,
`:snapshot_scope_incomplete`.
- Incremental mode reuses this envelope and row contract; its progress,
stream-membership, ordinal cursor, and token-hash fence are now live.
### Changed
- **No snapshot callback clears a resource any more** (ADR-0017). Pre-S02,
`handle_snapshot/2` issued a whole-resource `DELETE` on `first_for_table?` for
redo-safety. That repeated every committed host business effect on a retry and
could erase a stream-applied row, so it is gone — `first_for_table?`
authorizes no deletion.
- For a resource with `snapshot_provenance true`, stale rows are retired at
fenced completion instead, per tenant scope, through the host retire action.
- **A snapshot-backed resource that does NOT opt into `snapshot_provenance`
now keeps rows the source has dropped.** There is no membership marker to
retire them by. Nothing is deleted that was not deleted before — the change
is strictly less destructive — but a host relying on the old wipe for
redo-safety must declare `snapshot_provenance true` (plus the two protected
attributes and the private mark/retire actions) to get retirement back. See
`usage-rules.md`.
- **Checkpoint schema: `snapshot_generation` is replaced by `snapshot_state`.**
Nothing ever wrote `snapshot_generation` (it was reserved and inert), so the
regenerated migration drops an always-NULL column and adds an always-NULL one.
Hosts run `mix ash.codegen` and migrate; no data capture is required.
`snapshot_progress` is now written atomically by incremental chunks and
completion.
- **Snapshot provenance and retirement contract** (ADR-0017, roadmap S01). A
mirror resource can opt in with `snapshot_provenance true`, declaring two
protected internal attributes — `replica_fingerprint` and
`replica_seen_attempt` — plus a private mark action and a private retirement
action. A snapshot retry can then tell an unchanged row from a changed one
and skip repeating the host's append-only business effects.
- `AshReplicant.Snapshot.Provenance` computes the fingerprint: an
HMAC-SHA-256 over a canonical, type-tagged, length-prefixed encoding of the
mapped action inputs plus the resolved tenant and resource identity, tagged
with both the encoding version and the key version. Keys come from
`:ash_replicant, :snapshot_provenance_keys` (same validated shape as
`:message_digest_keys`). Rotation retains old versions; a missing key,
unknown encoding version, or non-deterministic value fails CLOSED rather
than reporting the row as changed.
- `AshReplicant.Snapshot.MarkSeen` is the only write path to the two
attributes, stamping them from the sink-supplied changeset context via
`Ash.Changeset.force_change_attribute/3`. A missing or malformed context
fails the changeset closed.
- `AshReplicant.Resource.Verifiers.ValidateSnapshotProvenance` moves the whole
contract to build time: attribute shape, and the guarantee that NO action
accepts either attribute or declares an argument named for one. It also
rejects `MarkSeen` in the global `changes` block or on any action other than
the configured private mark action, leaving one provenance-write owner.
`ValidateActionMultitenancy` now also rejects `multitenancy :bypass` /
`:bypass_all` on the two provenance actions.
- Activation preflights the key configuration whenever a mapped resource opts
in, and refuses to start without it.
- `snapshot_provenance` defaults to `false`: an existing resource compiles and
behaves exactly as before. V1 uses the contract for effect-once retry and
incremental mode requires it for activation.
### Documented
- The guided tour notebook gains sections for the 1.0 operations surface:
the continuous census (§12), runtime status & tombstones with a runnable
`AshReplicant.Status.classify/1` demo (§13), typed telemetry & recovery
horizons with the complete `emitted_event_names/0` inventory (§14), and
the fault → state → operator matrix with the `docs/RECOVERY.md` link
(§15). Version references (README banner, install snippet, notebook)
move to 1.0.0.
- Record the approved 1.0 release design as ADRs 0017-0021 and pending hardening
amendments to ADR-0010/0015. ADR-0017's snapshot restart protocol is now live;
the later append-log, continuous assurance, install/doctor, expanded support,
and publication records remain proposed release gates.
### Breaking
- A notifier attached to a mirrored resource whose load statement is
non-empty must now route it through the new `AshReplicant.Notifier`
wrapper in addition to declaring `AshReplicant.DestinationParticipant`.
Replace `load/2` with `preload/2` and add `use AshReplicant.Notifier`
after `use Ash.Notifier`; the wrapper defines `load/2`. Admission proves the
live callback actually enters that wrapper on both stability probes — a
retained behaviour marker cannot hide an overridden `load/2`. It rejects
an unwrapped one with
`{:destination_notifier_unwrapped, resource, action, notifier}`, and a
statement or declaration that will not reproduce itself between two
consecutive calls with
`{:destination_notifier_unstable, resource, action, notifier}`. Notifiers
with no load statement are unchanged and need nothing. Ash re-derives the
statement at delivery, so the declaration alone bound nothing: only the
wrapper sits in that call path (ADR-0010's 1.0 amendment, now landed).
- The generated checkpoint resource is now **default-deny** (roadmap B7 /
ADR-0014): `use AshReplicant.Checkpoint` generates `Ash.Policy.Authorizer`
with an empty policy set, forbidding every external actor on every action.
The sink and operator paths run `authorize?: false` (unchanged,
effect-once is unaffected). Hosts that read or write the checkpoint from
their own code without an authorize bypass must now declare a `policies
do` block granting that access, or pass `authorizers: []` to reproduce
the earlier unguarded shape.
- `AshReplicant.start_link/1` now returns the `AshReplicant.PipelineOwner`
pid (previously the Replicant pipeline pid); both are opaque handles and
`stop_supervised/1` is unchanged. The owner is not linked to the caller
(a caller finishing never takes a live pipeline down), but a
host-supervision-tree shutdown now also stops the pipelines owned by
that tree.
### Added
- **Bound notifier load statements (ADR-0010 1.0 amendment).** The
destination manifest now carries, for every admitted action, each
notifier's load-statement digest and declared action-closure digest
(`Manifest.notifier_loads`, inside `Manifest.digest`). `AshReplicant.Notifier`
compares both before handing Ash the statement; admission and delivery also
verify the live `load/2` still routes through that comparison, and
`AshReplicant.Apply.Context.verify_notifier_loads!/4` re-checks immediately
before every sink-driven host action — mirror upsert and destroy, SCD2
close and open, snapshot `bulk_create`, and message routes — for what the
wrapper cannot see. Three additive reasons join the frozen taxonomy
(ADR-0011) under the existing destination tuple:
`{:invalid_destination_config, :notifier_load_drift}`,
`{:invalid_destination_config, :notifier_load_unadmitted}`, and
`{:invalid_destination_config, :notifier_load_probe_failed}`. All are
value-free and survive the sink's scrub, so operators can branch on them.
- Require fetched Replicant `>= 1.2.3 and < 2.0.0-0` and pin the release lock to
public Hex 1.2.3. The dependency contract now proves slot-origin rejection,
typed telemetry value-shape enforcement without value leakage, and bounded
keyed-snapshot contention plus pre-first-chunk pending recovery; CI's exact
floor and rollback guidance use 1.2.3.
- **Atomic sink-owned batch delivery (roadmap C2 / ADR-0016).** The generated
sink now implements `handle_batch/1` unconditionally, and
`AshReplicant.start_link/1` accepts and forwards `:batch_delivery`
(`[max_transactions: n, max_delay_ms: ms]`; a malformed value fails closed
`{:error, :config_invalid}` at start — previously the option was silently
withheld and batching was unreachable). With `batch_delivery` set, delivery
routes through one `handle_batch/1` call per flushed batch: the ascending
transactions and their transactional messages apply through the same
single-pass core as `handle_transaction/1` (lazy spilled streams are never
materialized), the checkpoint advances to the batch's highest LSN in the
SAME destination transaction after all effects, and any frontier at/below
the watermark skips — effect-once (dup = 0) holds across a mid-batch
teardown. New telemetry: `[:ash_replicant, :sink, :batch_applied]` (one
event per flush) and the typed `txn_count` metadata key; the per-transaction
`:applied` event does not fire in batch mode.
- **Logical-message actions (roadmap C1 / ADR-0015).** `use AshReplicant.Sink`
accepts `message_routes` (`{"prefix", Resource, :action}` triples routing
`pg_logical_emit_message` output to host create actions) and
`ignored_message_prefixes`. A sink declaring either exposes
`handle_message/2` and the pipeline starts with `messages: true`
automatically. Every routed action must carry the closed AshOnetime message
profile (idempotency claim keyed on source+slot+LSN with a versioned
host-keyed content digest as the fingerprint — configure
`:ash_replicant, :message_digest_keys`; an optional `external_effect` module
rides AshOnetime's three-state recovery). Transactional messages ride their
transaction interleaved with the row changes by ordinal; standalone messages
apply effect+claim+watermark atomically (local) or finalize-then-watermark
(external). An unknown prefix halts fail-closed
(`:message_prefix_unmapped`, a new closed reason); `byte_size` joins the
typed telemetry measurement set for `[:ash_replicant, :message, :applied]`
and `transactional` joins the typed metadata keys.
- `AshReplicant.PipelineOwner` (roadmap B7 / ADR-0014): one owner per live
resolver generation. It runs the full activation chain (same validation,
preflights, lock, and synchronous error shapes), records itself as the
generation's owner, and monitors the Replicant pipeline — when the
pipeline exits (fail-closed halt, crash, external stop, host-tree
shutdown) it erases only its own generation and clears the snapshot
ordinals, so a halted slot is immediately re-activatable instead of
wedged on `:slot_already_active`. `child_spec/1` starts it as a
`:temporary` child (id `{AshReplicant.PipelineOwner, slot_name}`, so a
duplicate slot in one tree fails at supervisor start). A generation
whose owner has died fails closed at callback entry (the pipeline halts
itself) and is replaced — reaping any orphan pipeline — by the next
activation or offline operator function; offline adopt/reset/acknowledge
no longer wedge on a stale entry. Replicant retains transport ownership
throughout.
- ADR-0014 (`docs/adr/0014-internal-trust-and-lifecycle-ownership.md`)
governs the internal-trust and lifecycle-ownership frame (and retires
the gap-list row "Tenant-blind layering and pipeline ownership").
- `AshReplicant.DestinationParticipant.operation_key/2` now requires an
`:invocation` label in the operation context (the sink mints it; manual
minters — tests, replay probes — must carry the mint-site label). Closes the
intra-change AshOnetime key collision where one change's two SCD2 closes
shared a key and the second close replayed the first's stored response
(ADR-0010). Upgrade note: sink-side claims live and die in the SAME
transaction as their effects and the checkpoint (atomic), so no sink-side
durable claim can survive across the upgrade — but any HOST-side store
holding manually-minted keys under the old 6-component encoding must be
drained before upgrading; admission already rejects independent-commit
claims, so this only affects out-of-band host tooling.
- A notifier whose `load/2` returns a non-empty statement must implement
`AshReplicant.DestinationParticipant` (`:notifier` kind): its dependency
pre-load read executes inside the admitted transaction regardless of notify
gates (ADR-0010).
### Added
- Completed value-free boundary: `catch :throw`/`:exit` at all six sink bodies;
the schema-change fault path fires the sink's own `:halted` with the
structural reason (previously mislabeled `:decode_failure` by the framework
wrapper) (ADR-0009).
- Typed telemetry: per-key metadata types, a closed measurement-key set
(`byte_size` reserved for C1), count-only off-allowlist raises, and a
conformance integration gate over every emitted event name.
- `AshReplicant.Sql`: the one identifier quoting home (PG-canonical doubling,
control-character rejection) — admission-time identifier validation in the
manifest walk; all raw-SQL sites routed through it (ADR-0009).
- The action-contract freeze table (live-reflection source-pin test), the
reason-space enumeration, and the Ash-bump grep procedure (CONTRIBUTING).
- Release-contract absence gates: `apply_ledger` (exactly the two allowlisted
fail-closed lines) and a secret-literal scan over `lib/`.
- `AshReplicant.Apply.Context.invocation_labels/0` and
`AshReplicant.DestinationParticipant.operation_components/0` (the single
component-list home).
- Activation source-catalog preflight (roadmap B3): missing expected tables,
unignored publication tables, unmapped columns, missing declared columns,
stale skips, invalid source types, and wrong replica identity halt before
any checkpoint advance; the check re-runs at every reconnect; an
unreachable source defers the verdict. `ignored_sources` declares
intentional partial publications. ADR-0008.
- Tenant reassignment is fail-closed (roadmap B4): both tenants resolve
before any write; an absent/blank/false/raising old side — or a missing
`old_record` — halts value-free; SCD1 relocates, SCD2 terminally closes
under the old tenant. ADR-0001 amended.
- `AshReplicant.adopt_checkpoint/3`, `AshReplicant.reset_checkpoint/2`, and
`AshReplicant.acknowledge_checkpoint_timeline/3` operator recovery surfaces,
plus `AshReplicant.Checkpoint.Identity.refuse_ambiguous_legacy_rows!/1` /
`legacy_checkpoint_row_count/1` for the slot-only upgrade path.
- ADR-0007 records the source-bound, serialized checkpoint decision.
- Adopt Replicant `>= 1.0.0 and < 2.0.0-0` from Hex, with current 1.1.0 and
exact-floor 1.0.0 compatibility gates. Generated sinks now require and compare
the actual replication-session system/database identity before checkpoint lookup.
- ADR-0011 pins the closed error-reason set and the telemetry event-name
inventory as public contract (additive growth only; removal/rename is
breaking with migration notes), and ADR-0012 governs the snapshot
run-scoped ordinal space — the axis the per-invocation operation keys
depend on. Both record shipped, freeze-tested decisions and assign the
roadmap ownership the gap list was missing.
- Add live Replicant 1.1.0 proofs for actual-session ordering, v1 snapshot-to-stream
convergence, post-handoff restart, and operator-reset retry after an incomplete snapshot.
- Add AshOnetime 0.6.0 as the governed idempotency dependency for the logical-message
actions planned for 1.0.0 and for admitted local auxiliary actions that need a
WAL replay guard. The permanent commit-LSN checkpoint remains the transaction
replay and resume authority.
- Add a deterministic recursive destination manifest covering checkpoint, mapped,
framework-reached, SCD2, and declared auxiliary actions. It rejects foreign or
dynamic Repos, non-Postgres resources, missing/opaque/cyclic participants, and
`touches_resources` mismatches before delivery, then pins one effective Repo and
generation through commit or rollback.
- Add `AshReplicant.DestinationParticipant` and a WAL-safe AshOnetime admission
profile for local auxiliary actions: exact source/database/slot/LSN/ordinal/
participant identity, private operation key, same-transaction fail-closed store,
and explicit rejection of nonce, independent, external, and opaque profiles.
- Add independent CI paths for no-database tests, exact-floor/current-lock/latest-Ash
live PostgreSQL integration, migration drift, Dialyzer, warnings-as-errors docs,
and selector-free Hex package inspection. Checked-in assertions reject missing,
skipped, invalid, excluded-without-authorization, or failing test evidence.
- Pin third-party CI actions to immutable commits, assert the executing
Elixir/OTP identity, capture raw test failures without publishing values, and
mutation-test the release-evidence, workflow, documentation, and migration
checkers against masking and constant-accept regressions.
### Changed
- **Breaking:** an unmapped publication table now HALTS
(`:source_table_unmapped`) instead of the silent partial-publication
skip — declare `ignored_sources` for intentional partial coverage. An
unaccounted delivered column halts (`:source_column_unmapped`).
`:replica_identity_changed` and `:type_changed` schema changes always
halt regardless of `on_schema_change :ignore`.
- **Removed:** `Resolver.writable_target/2` (dead code) and
`Resolver.tenant_changed?/2` (its fail-open caveat is the class B4
closed).
- **Breaking:** the generated checkpoint resource is source-bound — the
composite primary key is `(source_system_id, source_database, slot_name)`
from the actual replication session, with `source_timeline`,
`publication_contract`/`publication_fingerprint`, nullable-until-first-commit
`commit_lsn`, reserved snapshot-frontier columns, timestamps, the
`:source_slot` identity, and a named `:operator_reset` destroy action. The
slot-only `:unique_slot` identity is gone and the shape change requires a
migration; use `mix ash_replicant.upgrade 0.4.0 1.0.0` and follow the
rollback boundary in `usage-rules.md`.
- The sink binds the checkpoint row inside `handle_session_identity/2` (now a
mutating, lease-held callback) on every connect: a foreign same-slot
identity, a changed timeline, a watermark ahead of the session's WAL flush
position, or an incompatible contract transition halts fail-closed with
value-free reasons and a `[:ash_replicant, :checkpoint, :conflict]` event.
- Admission takes the checkpoint row `FOR UPDATE` inside the destination
transaction; lower/equal LSNs never regress or reapply and concurrent
callers produce one effect. An absent row at admission is a permanent
`:checkpoint_unbound` halt (restart re-binds). The v1 snapshot handoff no
longer regresses the watermark on a re-delivered consistent point.
- Serialize resolver activation per slot, generation-check rejected-start cleanup,
and forward Replicant's safe `streaming`, `max_inflight_lag`,
`max_command_retries`, and `failover` transport options. Incremental snapshots,
batch delivery, and logical messages are now live under their owning contracts.
- Extend the physical effect-once guarantee from committed streaming transactions
and atomic snapshot batches to V1 and incremental snapshot retry through the
checkpoint-owned provenance protocol and permanent completion fences.
- Require Elixir 1.20.3/OTP 29 and the AshPostgres 2.11 dependency family for the
1.0.0 release line.
### Security
- Raise the 1.0.0 dependency floor to audit-clean Ash 3.31.3 and exclude Ash 4
prereleases; resolve Postgrex 0.22.4 and ymlr 5.1.6 so `mix hex.audit` and
`mix deps.audit` report no known advisories.
- Make generated delivery callbacks invoke the admitted sink implementation
directly; remove the overridable effect hook that could acknowledge WAL without
applying rows/checkpointing. Reject data-layer `SetContext` redirection and
behavior-conforming external AshOnetime caches at destination admission.
- Give v1 snapshots ONE continuing operation-ordinal space per run (keyed by the
exported consistent point). A per-table space minted identical operation keys
for table A row i and table B row i whenever two mapped resources shared one
participant atom, and AshOnetime replayed A's stored response for B — silently
suppressing B's declared auxiliary effect. Proven live by a two-table
same-participant snapshot marquee.
- Reject unknown or removed `use AshReplicant.Sink` options at compile time
(including the removed `apply_ledger`) instead of silently dropping them — a
host upgrading across the removal now gets a compile failure, not a
silently-gone ledger.
- Admit the dynamic (MFA) `set_context` form only when the module behind the MFA
declares its effects through `AshReplicant.DestinationParticipant`; static-map
contexts still may not replace `:data_layer`. The core-code fingerprint now
covers every delivery-reachable module including the value-free telemetry
allowlist enforcer.
- Close four cross-vendor-admission bypasses found by the peer review: a
`set_context` may no longer touch `:shared` (Ash promotes it over the whole
context, so a nested `shared.data_layer` redirected the destination) or the
sink-owned `:ash_replicant_operation` identity (a forged operation context
would mint one replay key for every row); `prepare build(context: ...)` is
rejected (Ash.Query.build's context option redirects `data_layer`); and every
admitted action — including declared auxiliary participants — must keep its
tenant scoping (`multitenancy :bypass`/`:bypass_all` is rejected, closing
cross-tenant auxiliary effects). Streaming transactions carry an explicit
120s timeout (the per-change generation guards can exceed DBConnection's 15s
default at scale, deterministically wedging replication), and a sink callback
defined BEFORE `use AshReplicant.Sink` now also fails compilation (it would
win dispatch and skip the generation guard, activation lock, and repo pin).
- Completed the cross-vendor value-free closure over every error-shaped input:
`Error.scrub`/`Error.scrub_caught` trust no field of an incoming error
struct — a forged `:reason` or `:shape`, via raise, throw, or the rollback
verb, survives only as the closed typed reason — and the reason space is
pinned to the library's finite set. The telemetry `span/3` is hand-rolled
so an exception event carries the class only and no malformed-result path
renders a value. `:bulk_create` joins the sink-owned context keys (a host
`SetContext` over the framework's per-row snapshot index would alias a
whole batch onto ONE operation key — the replay-suppression class), the
generation code-fingerprint covers `AshReplicant.Sql`, and the legacy-row
count probe routes through the quoting home. Gate-integrity pins: live
mint-site label equality, a bidirectional mint-site ↔ frozen-label
inventory, strict-walk cycle detection on the destination manifest, and a
load-realistic await for the concurrent-start race.
### Added
- ADR-0013 governs the sensitive type-shape classification (AshCloak cloak
attribute ∪ binary-storage attribute ∪ `skip`, verified by type shape at
compile — never ciphertext; the tenant discriminator is never sensitive).
The ADR gap list had retired this row by mis-attribution to ADR-0009
(which governs the value-free boundary); the retirement paragraph now
points at ADR-0013 and the corpus entry is registered.
- The public `@type reason` is pinned to `@closed_reasons` by a source-level
test — the type had lagged ADR-0011's frozen set by 8 atoms
(Dialyzer-silent) and narrowed the destination tuple variant to one atom;
both fixed, and the next added reason must update type and set together
or the pin goes red.
- Published-docs alignment sweep: the four doubled-path ADR links (README ×2,
usage-rules ×2 — `docs/adr/docs/adr/…` → 404s) repaired; the checkpoint
intro in `usage-rules.md` now states the source-bound triple (was the
pre-0.4.0 slot-only shape); `docs/ROADMAP.md` is explicitly status-free
(status of record = commit history + this changelog's `[Unreleased]`; the
dead host-pinned derivation command removed); the frozen 2026-07-09
closeout handoff carries a banner noting its commit IDs predate the
history rewrite and do not resolve; `AGENTS.md`'s docs policy names the
tracked notebook and all gitignored tool-state directories; `CLAUDE.md`
names `../replicant` as the only sibling (ash_postgres is a Hex dep);
the charter's Task-15 proof and ADR-0002's lock list are marked as
point-in-time snapshots with derive-don't-state notes; the substrate
version and battery URL in README/CONTRIBUTING are examples to derive
from, not current-state claims; `AshReplicant.Sql`'s sanctioned-site
enumeration includes the checkpoint legacy-probe count.
### Fixed
- The batteries' intermittent reds were fixed-budget flakes, closed by
making the affected waits load-proportional (no product change — the
pipeline behaved correctly in every observed red; only test budgets were
miscalibrated to an idle host). Three instances: the v1-snapshot restart
marquee's re-snapshot wait (a row-proportional phase: ~8-12s idle, 55s
observed under concurrent host load against a ~24s poll budget — the
postgres log's slot/stream timestamps pin the divergence; reproduced red
under synthetic CPU load and green under the same load after the fix), the
destination-lease test's 1s callback-entry assert (entry re-validates the
admitted generation — a manifest walk plus a bytecode fingerprint whose
file reads go through the serialized code server; ~4ms warm, observed >1s
once under battery IO load coincident with a substrate checkpoint), and
the concurrent-start race test's awaits (its own documented 5s flake of
2026-08-15 re-appeared once at 15s in the no-DB battery; the ceiling now
only bounds waiting — the start-gate asserts fire on the tasks' return
values). The negative lease window (the 50ms `Task.yield`) is unchanged —
load only makes a held lease more likely to hold.
- The value-free battery receipt now locates the failing ASSERTION: the
structural formatter's `FAILED:` line appends the first stack frame from
the repo's own code (`file:line`, identified by module namespace —
dependency sources also live under `lib/`-shaped paths). Test name alone
could not say which assert of a multi-assert marquee tripped; this
investigation's third red stayed undiagnosable past the name exactly
because of that gap. Structural only — authored names and file:line,
never row values (ADR-0009).
- CI's from-clean `MIX_ENV=test` compile-WAE is green again (warm local
builds had masked three fixture-warning classes shipped in the unpushed
span): the marquee's auxiliary-carrying `close_version` declares
`require_atomic? false` (its effect-recording change is non-atomic by
design), the destination-walk loop fixtures are registered in their own
domain, and `RaisingMfaOrder` moved to test support so a clean compile
can verify the domain that references it.
- Admit the census preflight connection BEFORE starting a pool: a
postgrex-UNRESOLVABLE `:connection` database (absent key with no
`PGDATABASE`, or an explicit nil — postgrex discovers the missing
`:database` key only inside the pool's async connect) now defers as the
unreachable class immediately, instead of starting a pool that can never
connect, logging `[error] missing the :database key` on every backoff
retry while burning the checkout queue-timeout on every bind. The gate
mirrors postgrex's own resolution (`Utils.default_opts/1` — the same
resolution the replication stream applies), so a `PGDATABASE`-configured
source is censused, never deferred, and the census and the stream cannot
disagree. This is what made the live structural battery fail its own
no-error gate with every test passing; a host misconfiguring the
connection keyword hit the same doomed-pool burn at runtime. Sync-start
failures return the structural `{:error, :unreachable}` (callers defer
through their census-fault branch and never query or stop a placeholder
connection), and the dead-for-pools `sync_connect: true` merge is dropped
(only the replication connection reads that option).
- The forced-reconnect marquee captures the replication connection's
expected `[error]` reconnect line (the `start_link_test` precedent), which
otherwise order-dependently trips the same battery gate.
- Dialyzer green again: `legacy_checkpoint_row_count/2`'s spec now admits
the `{:error, :checkpoint_probe_failed}` probe-failure leg its body
already returns (the caller handled it), and the census preflight's
catch-all scrub — unreachable once admission returns error-tagged
connections — is removed (the closed-case shape `reconnect_check/5`
already uses).
- The structural-test gate prints the offending lines on an
uncontrolled-error trip (its raw capture is otherwise trap-deleted,
leaving an intermittent trip undiagnosable when it does not
re-reproduce).
- Reconcile public policy, snapshot, tenancy, notifier, and release-history
documentation with live code. Host policies are not re-gated, the current
adapter supports v1 snapshots only, and tenant reassignment requires the old
tenant record shape.
- Split checkpoint policy introspection from its six live enforcement cases so
`mix test --exclude integration` runs successfully without starting TestRepo.
- Cover SCD2 resources in migration drift and observe TestRepo start attempts in
the same VM as the database-free suite.
- Decode release workflows as YAML, require independently executable gate steps,
bind compatibility selection to its exact unlock/assert block, and validate
visible non-contradictory runtime documentation. Independent mutations now
cover every protected release-evidence decision.
- Reconciled the published docs with shipped code: the tenant-reassignment
old-side fail-closed guard is SHIPPED (`:tenant_required` `side=old` /
`:tenant_resolution_failed`, fail-closed before any write), not open
roadmap work (README, usage-rules, CHARTER now say so); the live
integration gate's PostgreSQL statement records the actual split (CI pins
16, the local substrate is 18, the support matrix is PG15–18); relative
ADR links normalize to the canonical GitHub form; and the cloak-upsert
spike's PG16 note reads as the historical proof it is.
`AshReplicant.Sql`, `AshReplicant.Apply.Context`, and
`AshReplicant.Checkpoint.Identity` carry real moduledocs (their
comment-docs, promoted) so the reference-checked docs gate is green over
the surfaces the docs already advertised.
## [0.4.0] - 2026-08-03
### Added
- **`use AshReplicant.Checkpoint` accepts an `authorizers:` option**, so the generated
checkpoint resource can carry `Ash.Policy.Authorizer` and enforce host-declared
`policies do` blocks. 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 and `policies` is not a
declarable section without one. The sink still reads/upserts with `authorize?: false`
on 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.exs` proves the authorizer + policies are present, a non-system
actor is denied (hard `Forbidden`), a system actor is allowed, and the sink's
`authorize?: false` path bypasses both.
## [0.3.3] - 2026-08-02
> **Release provenance:** 0.3.3 was published to
> [Hex](https://hex.pm/packages/ash_replicant/0.3.3). Every packaged
> source/document byte matches commit
> [`3b61d3a`](https://github.com/baselabs/ash_replicant/commit/3b61d3a9ae553fb96ff26e9fcf581416af723843).
> The publishing checkout itself is not independently recorded. No `v0.3.3` Git
> tag or GitHub Release was created; current documentation does not invent one.
### 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.Scd2` now 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?/2` and made scrub-safe: a raising `tenant_mfa`
resolver 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 FULL` on 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 same `old_record`
dependency already documented for tenant-scoped deletes/PK-changes.
- Reassignment detection also depends on a NON-RAISING tenant resolver. `Resolver.tenant_changed?/2`
treats a raising `tenant_mfa` as "not changed" (value-free), so an MFA that raises on
`old_record` makes a reassignment invisible and the caller keeps its non-relocating path.
`tenant_attribute` resolvers never raise; this affects only `tenant_mfa` and 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_attribute` value), 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. `Apply`
now treats a resolved-tenant change like a PK change: destroy the old-tenant row (resolved
from `old_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-only
`old_record` updates (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 `replicant` requirement from `~> 0.1.0` to `~> 0.3` so 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-errors` clean and `mix test` 90 passed / 0 failed (unit suite;
the `:integration` gate — 52 tests needing a live `wal_level=logical` DB via
`ASH_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; new
`ValidateHistory` compile 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.**
`ValidateMultitenancy` now requires an Ash `multitenancy` block whenever a
`tenant_attribute` **or** a `tenant_mfa` is declared. Without a block Ash silently
ignores the `tenant:` option the sink passes, so every tenant's rows mirror **unscoped**
into one table — a proven fail-open with no runtime error. The `tenant_attribute` arm
shipped 2026-07-10; the symmetric `tenant_mfa` arm closes the parallel hole (2026-07-14).
Any strategy (`:attribute` or `:context`), including a `global?` block, satisfies the gate.
See [ADR-0001](https://github.com/baselabs/ash_replicant/blob/main/docs/adr/0001-fail-closed-multitenancy.md).
- **A `false`-resolved tenant now fails closed.** `Resolver.resolve_tenant/2` rejected only
`nil`/blank-string tenants; a `tenant_attribute` column holding boolean `false` or a
`tenant_mfa` returning `false` resolved to `{:ok, false}`. Ash treats a falsy tenant as
**no scoping** (neither force-set nor required), so the mirror write landed **unscoped**
across tenants. `false` now returns `:tenant_required` like `nil` (2026-07-14).
- **Sink-selected actions can no longer bypass tenancy.** A new
`ValidateActionMultitenancy` compile verifier rejects `multitenancy :bypass` / `:bypass_all`
on 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 `:bypass` read would let a `bulk_update`/`bulk_destroy` match and mutate
another tenant's rows), despite a valid multitenancy block. `:enforce` and `:allow_global`
remain permitted (2026-07-14).
- **The multitenancy discriminator column is now shape-checked.** Under `strategy :attribute`,
`ValidateMultitenancy` rejects a `sensitive`-classified or binary-storage-typed multitenancy
`attribute` — 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
- **`ValidateTenantSource` compile-time verifier** — a resource declaring
non-global Ash multitenancy must declare a `replicant` tenant source
(`tenant_attribute` or `tenant_mfa`). Without one, every mirror write is
attempted with `tenant: nil` and halts fail-closed (`:tenant_required`) at
runtime; this gate moves that failure to build time. It is the converse of
`ValidateMultitenancy` (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/3` and
`handle_snapshot_complete/2` now share the `handle_transaction/2` fail-closed guard
(a degenerate/misloaded index no longer silently drops a backfill while advancing
the checkpoint).
- **`on_truncate :mirror` clears tenant-blind** — was a `TenantRequired` dead-end for
non-global attribute-multitenant resources; now a quoted raw `DELETE` on the mirror
table (matching the snapshot redo-safety clear).
- **Full telemetry contract** — the `[:ash_replicant, :snapshot, :batch]` /
`[:snapshot, :complete]` events (previously never emitted), `:sink,:halted`
`error_class`, and `:sink,:applied` `change_count` + `duration` measurements are now
emitted (`change_count` counted single-pass).
- **`transaction?: false`** on 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 from `old_record`, which is key-only
under the default replica identity (else the sink halts fail-closed
`:tenant_required`). Documented in AGENTS Critical Rule 2, the `tenant_attribute`
DSL doc, README, and usage-rules; locked by a key-only-`old_record` red-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/2` is retained for single-record callers. Behavior
unchanged (F13).
- **Delete path is a single atomic `bulk_destroy`**`Apply.destroy_by_pk/3` issues
one `DELETE ... 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`) — a `replicant do ... end`
DSL 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 the `ash_replicant_checkpoints` table (one row per slot,
tracking the durable `commit_lsn` watermark). Bound to the host's repo and
domain at compile time.
- **Sink-config macro** (`AshReplicant.Sink`) — generates a `Replicant.Sink`
implementation with repo, domains, checkpoint resource, and `slot_name` baked in.
The `slot_name` is the single source of truth for the replication slot (not a
`start_link` option) 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_term` and 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 with `authorize?: false` at 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 a `tenant_attribute` has
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.Transaction` applies in one
`Repo.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 at
`docs/CHARTER.md` (only `/docs/superpowers/` lifecycle artifacts are local-only).
[Unreleased]: https://github.com/baselabs/ash_replicant/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/baselabs/ash_replicant/tree/v1.0.0
[0.4.0]: https://github.com/baselabs/ash_replicant/tree/v0.4.0
[0.3.3]: https://github.com/baselabs/ash_replicant/commit/3b61d3a9ae553fb96ff26e9fcf581416af723843
[0.3.2]: https://github.com/baselabs/ash_replicant/tree/v0.3.2
[0.3.1]: https://github.com/baselabs/ash_replicant/tree/v0.3.1
[0.3.0]: https://github.com/baselabs/ash_replicant/tree/v0.3.0
[0.2.0]: https://github.com/baselabs/ash_replicant/tree/v0.2.0
[0.1.0]: https://github.com/baselabs/ash_replicant/tree/v0.1.0