An Ash adapter for the replicant CDC framework — the "ash_postgres of
replicant."
- Is: an Ash-native CDC mirror / incremental-sync adapter. Resolves resources, enforces multitenancy per row, verifies sensitive-column encryption, and applies committed streaming transactions to Ash resources with durable effect-once semantics.
- Is not: the CDC transport itself. That is
replicant's job. AshReplicant consumes aReplicant.Sinkinterface and owns the Ash-layer semantics (resource resolution, multitenancy, encryption, and trusted system action execution) above it. The sink usesauthorize?: false, so host policies are not re-gated. - Is: integrated with AshCloak. Sensitive columns must be encrypted by AshCloak or stored as binary (user-managed), verified at compile time.
- Is not: tenant-aware in the transport — multitenancy is Ash-aware here.
replicantremains tenant-blind and can be used without Ash.
mix igniter.install ash_replicant (or, when the dependency is already present,
mix ash_replicant.install) generates the domain, checkpoint, sink, and pipeline
supervisor, registers the domain in :ash_domains, supervises the pipeline,
imports AshReplicant's formatter metadata, and queues
mix ash.codegen install_ash_replicant. Igniter is an optional dependency;
the manual equivalent is in the README under "Manual installation" and is tied
to the installer's real output by a test.
Two properties matter when reasoning about a generated project:
- The installer writes no connection, publication, source identity, or key
material. The generated
MyApp.Replicant.Pipelinetherefore supervises NOTHING untilconfig :my_app, MyApp.Replicant.Pipelinesupplies the operator's facts — a fresh install compiles and boots as a no-op. A configuration that is present but missing:connection,:publication, or:source_identityRAISES, naming the missing keys and never their values; supervising nothing would be a silent outage. - The installer refuses rather than guesses. Malformed module names; an illegal slot; a missing, ambiguous, unknown, or non-AshPostgres repo; incomplete project facts; a foreign target module; an unreadable existing binding; or a checkpoint, sink, or pipeline bound to another identity each stop the install having written nothing. Never route around a refusal by deleting the existing module: a sink's slot name keys its durable checkpoint identity, and re-keying it abandons the watermark.
Re-running the installer over an installed project changes nothing.
defmodule MyApp.ReplicantCheckpoint do
use AshReplicant.Checkpoint,
repo: MyApp.Repo,
domain: MyApp.Domain
endThis generates an AshPostgres resource backing ash_replicant_checkpoints: one
row per replication SOURCE and slot — keyed by (source_system_id, source_database, slot_name) from the actual replication session's identity —
carrying the durable commit_lsn watermark (see
ADR-0007).
The generated resource is default-deny (ADR-0014): it carries
Ash.Policy.Authorizer with an empty policy set, forbidding every external actor on
every action. The sink reads/upserts with authorize?: false, so policies never gate
effect-once. To grant access, declare a policies do block on the module; to
reproduce the earlier unguarded shape, pass authorizers: [].
defmodule MyApp.ReplicantSink do
use AshReplicant.Sink,
repo: MyApp.Repo,
domains: [MyApp.Shop, MyApp.Billing],
checkpoint_resource: MyApp.ReplicantCheckpoint,
slot_name: "shop_orders"
endThe slot_name is baked into the sink and is the single source of truth. It is
not a start_link option. Every row's mirror action is called with this slot
name as the index key for tenant/resource resolution.
pg_logical_emit_message output routes by prefix to host create actions
(ADR-0015):
use AshReplicant.Sink,
repo: MyApp.Repo,
domains: [MyApp.Shop, MyApp.Messaging],
checkpoint_resource: MyApp.ReplicantCheckpoint,
slot_name: "shop_orders",
message_routes: [{"mail", MyApp.MailOutbox, :record}],
ignored_message_prefixes: ["telemetry_noise"],
recovery_horizon: {24, :hour}Rules that are enforced, not advisory:
- Every routed action is a
:createaction carrying the closed AshOnetime message profile:strategy :idempotency,fail_closedstore, the privateoperation_key/content_digestarguments, an acceptedcontentstring attribute,fingerprint(arguments: [:content_digest]), and a declared positiveretention. An optionalexternal_effectmodule (three-state recovery) is admitted on message routes ONLY. A nonce is rejected — it must never gate WAL re-delivery. - Set
:ash_replicant, :message_digest_keys(list of{version, key}with unique positive versions and keys ≥ 16 bytes). The active version (highest) mints new digests; RETAIN the old versions through the claim/recovery lifetime — re-delivery replays through them after rotation. - Declare
recovery_horizon: {count, unit}(required whenevermessage_routesis non-empty on a state-mirror sink; rejected otherwise — append-log routes dedup structurally): the supported outage/replay window every route's retention must cover, or activation refuses:retention_below_recovery_horizon(ADR-0022). - Set
:ash_replicant, :horizon_provenance_keys(the same{version, key}shape) before starting a message-routed sink: the ORTHOGONAL family authenticating the checkpoint's digest-key witness. Retiring a message digest key version is safe only oncemax(route retention)has elapsed since it was last observed in the configured set — earlier removal halts:digest_key_horizon_violatedinstead of silently blocking replays. - A transactional message rides its transaction, interleaved with the row
changes by ordinal. A standalone message applies effect + claim +
watermark in one destination transaction (local route) or through recovery
with the watermark advancing only after finalized/replayed success (external
route). An unknown prefix halts the pipeline fail-closed
(
:message_prefix_unmapped); an ignored prefix acknowledges with no effect. - External-route concurrent retries (AshOnetime ≥ 1.4.0): the claim row is
taken
FOR UPDATEbefore any peer call, so an honest same-key retry racing an in-flight execute waits and replays, or returns:request_in_progress(terminal for that attempt) when the configured lock wait expires (external_lock_timeout_ms, default 2000 ms) — treat that code as wait-or-retry, like the pre-existing concurrent-retry semantics. The sink's contract is unchanged: the watermark advances only after finalized/replayed success.
Every resource that mirrors a source table adds the extension and a replicant do … end block:
defmodule MyApp.Shop.Order do
use Ash.Resource,
domain: MyApp.Shop,
data_layer: AshPostgres.DataLayer,
extensions: [AshReplicant.Resource]
postgres do
table "orders"
repo MyApp.Repo
end
replicant do
source_table "orders"
source_schema "public"
tenant_attribute :org_id
sensitive [:pan, :cvv]
on_truncate :mirror
on_schema_change :halt_destructive
upsert_identity :unique_pk
end
attributes do
attribute :id, :uuid, primary_key?: true, public?: true
attribute :org_id, :uuid, public?: true
attribute :amount, :decimal, public?: true
attribute :pan, :binary, public?: true # Sensitive: binary storage; stored as-is (host-managed encryption unless AshCloak is added here)
attribute :cvv, :binary, public?: true # Sensitive: binary storage; stored as-is (host-managed)
end
actions do
# The extension generates NO action. The mirror writes through this resource's
# own primary `:create` action (as an upsert) and its `:destroy` action — define
# them. `create: :*` gives a primary create accepting all public attributes.
defaults [:read, :destroy, create: :*, update: :*]
end
identities do
identity :unique_pk, [:id]
end
endDSL options:
source_table/source_schema— source table/schema (defaults to resource's own AshPostgres table/schema).tenant_attribute— source column carrying the tenant. Must be a plaintext, declared, non-sensitive attribute. Resolved per row and passed astenant:to the mirror action. The source table must beREPLICA IDENTITY FULL— a:delete, PK-changing:update, or same-PK tenant reassignment needs the tenant fromold_record, which is key-only under the default replica identity.tenant_mfa— alternative:{Module, :function, [extra_args]}applied asapply(Module, :function, [record | extra_args])yielding the tenant. It must be deterministic and non-raising for both the new and old record shapes. An absent/blank/falseold-side result halts:tenant_required(side=old) and a raising resolver halts:tenant_resolution_failed— fail-closed before any write (ADR-0001's B4 amendment).- Multitenancy block required for either source. Declaring
tenant_attributeortenant_mfarequires an Ashmultitenancyblock (any strategy —:attribute/:context, incl.global?);ValidateMultitenancyfails the build closed otherwise. Without a block Ash silently ignores thetenant:the sink passes and mirrors every tenant unscoped.:contextis the typical pairing fortenant_mfa. - Multitenancy
:attributemust be plaintext. Understrategy :attribute, the block's ownattributeis force-set to the plaintext tenant and filtered on read —ValidateMultitenancyrejects asensitive-classified or binary-storage-typed one (it would mis-scope). (An AshCloak-encrypted attribute is rejected by Ash's own multitenancy verifier.) - No sink action may bypass tenancy.
ValidateActionMultitenancyrejectsmultitenancy :bypass/:bypass_allon the sink-selected actions of a multitenant resource — primary read/create/destroy and the SCD2 close — since Ash would otherwise ignore the tenant on a write, or on thebulk_update/bulk_destroyread that matches rows to close/delete.:enforce(default) and:allow_globalare permitted. sensitive— source columns classified as sensitive. Each must map to an AshCloak-encrypted attribute, a binary-storage attribute, or be listed inskip. Never list thetenant_attribute.skip— source columns excluded from the mirror write.on_truncate—:halt(fail-closed, default) or:mirror(direct in-transaction DELETE of the mirror table).on_schema_change—:halt_destructive(default, halt on destructive DDL) or:ignore.upsert_identity— identity for the upsert write (defaults to primary-key upsert when omitted; set an identity name to upsert by that identity instead).
AshReplicant.start_link(
sink: MyApp.ReplicantSink,
connection: [hostname: "standby.example.com", database: "source_db"],
publication: "shop_orders_pub",
source_identity: [system_identifier: "7378697629483820647", database: "source_db"],
go_forward_only: true,
snapshot: false,
census: [interval_ms: 60_000, jitter_ratio: 0.1, timeout_ms: 10_000],
max_inflight_lag: 64 * 1024 * 1024,
max_command_retries: 5,
failover: false
)Options:
:sink— the sink module (required).:connection— Postgrex connection options (required). Point at a standby or replica to avoid load on the primary.:publication— Postgres publication name (required).:source_identity— expected actual replication-session identity (required), containing nonempty:system_identifierand:databasestrings. A mismatch rejects the session before checkpoint lookup with a value-free structural error.:go_forward_only— passed toReplicant.start_link/1.:snapshot—falsedisables snapshots,trueselects Replicant's v1 snapshot, and[mode: :incremental, chunk_rows: n, max_pending_chunks: n]selects sink-owned incremental backfill. Both modes are physically effect-once for resources declaringsnapshot_provenance true(ADR-0017); incremental activation requires every mapped resource to opt in.:streaming,:max_inflight_lag,:max_command_retries, and:failover— passed through unchanged to Replicant 1.x.:decoder,:replication_sets,:tables,:allow_keyless_tables, and:schema_check_interval— Replicant 1.4's logical-decoding output-plugin selection, passed through unchanged and ADMITTED for all three decoders (ADR-0026)::pgoutput(the default — unchanged),:pglogical(pglogical 2.x, PostgreSQL 9.6/12) or:wal2json(wal2json ≥ 2.6, PostgreSQL 9.6/12). The table-set key names the source for the adapter's own census:publication:for pgoutput,replication_sets:for pglogical,tables:({schema, table}pairs) for wal2json — a missing or mis-shaped one fails closed with{:error, :config_invalid}at start and inmix ash_replicant.preflight. The rest of the grammar is the transport's own validation: a key belonging to another decoder, a wal2json-only knob under pgoutput, or an unknown decoder atom surfaces as Replicant's own{:error, :config_invalid}, and a capability the chosen decoder cannot express (messages:on pglogical — relevant because a routed sink auto-startsmessages: true;streaming:/failover:off pgoutput) is refused by Replicant at start with{:error, :decoder_capability_unsupported}. On the plugin decoders the adapter's strict-coverage census, contract manifest, and doctor checks judge the decoder-scoped table set exactly as they judge a publication — and a stored pgoutput contract is untouched (a plugin set records its own decoder-scoped shape; switching decoders or sets on an existing checkpoint halts{:incompatible, :decoder}, an operator reset decision).:census— continuous invariant checks owned by thePipelineOwner. Closed keys:enabled?(defaulttrue),interval_ms(default60_000),jitter_ratio(default0.1),timeout_ms(default10_000), andmax_consecutive_faults(default3). Unknown keys/types and out-of-range bounds reject activation with:census_options_invalid. Exact bounds areinterval_ms: 50..86_400_000,jitter_ratio: 0.0..0.5,timeout_ms: 1..60_000, andmax_consecutive_faults: 1..1_000.
Key: the slot_name comes from the sink, not start_link options. It keys the
active resolver generation and the replication slot name. Start/stop activation is
serialized per slot so a duplicate cannot overwrite or erase the winner's generation.
The owner schedules one jittered census only after the previous run has finished or timed out. A run enters through the same admitted callback guard as delivery, including owner-liveness checks and dynamic-Repo pinning, then checks the live destination generation, rebuilt source contract, durable bound checkpoint/contract fingerprint, and full source publication/column/type/RIF coverage. There is no second poller or Replicant callback.
Any drift halts the temporary pipeline immediately and freezes checkpoint
advance. Source/Repo faults and checker timeouts are typed non-pass results; a
healthy run resets the counter, while the exact configured consecutive fault
halts with :census_unverifiable. Census telemetry carries only slot_name,
the structural check kind, and a frozen reason atom. It never carries contract
bytes, source identity values, row values, tenants, message prefixes, or client
exception text.
By default a resource mirrors current state (history_strategy :scd1 — upsert /
destroy, the default). Opt a resource into validity-windowed SCD2 history with
history_strategy :scd2: instead of overwriting, each change closes the current open
version (stamps its valid_to_lsn) and inserts a new version, so the mirror
becomes an append-only history table with one row per (business_key, valid_from_lsn).
replicant do
source_table "orders"
history_strategy :scd2
history_business_key [:order_id] # source natural key (composite supported)
upsert_identity :version_key # identity keys: [:order_id, :valid_from_lsn]
# window-column attributes default to :valid_from_lsn / :valid_to_lsn
on_truncate :close # optional; SCD2-only
endDSL options (all :scd2-only unless noted):
history_strategy—:scd1(default, current-state upsert/destroy) or:scd2(close-current + insert-version).history_business_key— the source natural key (composite supported). Should be the source primary key; a non-PK business key requiresREPLICA IDENTITY FULLon the source table (see below).history_valid_from_lsn_attribute— bigint attribute stamped with the change'scommit_lsnwhen a version opens. Default:valid_from_lsn.history_valid_to_lsn_attribute— nullable bigint attribute stamped with the closing change'scommit_lsn(nil while the version is open). Default:valid_to_lsn.history_valid_from_timestamp_attribute— optional nullable datetime stamped with the sourcecommit_timestampwhen a version opens. Omit to store LSN windows only.history_valid_to_timestamp_attribute— optional nullable datetime stamped with the closingcommit_timestamp.history_current_attribute— optional boolean kepttrueon the open version and setfalseon close.history_close_action— the host:updateaction that sets the window columns to close a version. Default:close_version.
Host version-table obligations. A compile-time verifier (ValidateHistory) checks
the DSL-visible shape; the index and action bodies are host obligations covered by
integration tests:
- A surrogate primary key disjoint from the business key — no business-key attribute may be part of the primary key. A primary key equal to or a subset of the business key caps the version table at one row per business key (collapsing SCD2); any other overlap couples the version identity to a business-key column, so the verifier requires a fully disjoint surrogate.
- Declared integer (Postgres bigint)
valid_from_lsn/valid_to_lsnwindow columns;valid_to_lsnmust beallow_nil?: true(an open version has novalid_toyet). A declared timestamp window column must likewise beallow_nil?: true. - A version identity named by
upsert_identitywhose keys are exactlyhistory_business_key ++ [valid_from_lsn](the insert-version upsert target). - The
history_close_action(:close_version):updateaction, which sets thevalid_towindow columns. - A partial-unique index enforcing one open version per business key —
UNIQUE (business_key…) WHERE valid_to_lsn IS NULL(a host DDL obligation, not DSL-checked).
REPLICA IDENTITY FULL for a non-PK business key. Mirroring a close needs the
business key from the change record; on a :delete (and a PK/business-key-changing
:update) that key is read from old_record, which under the Postgres-default replica
identity carries only the primary-key columns. If the SCD2 business key is not the
source primary key, set ALTER TABLE <src> REPLICA IDENTITY FULL so old_record
carries the business-key columns — the same requirement, and the same fail-closed
reason, as a non-PK tenant_attribute.
Tenant reassignment needs the old tenant. The SCD2 path closes the old tenant's
open version and opens under the new tenant even when the business key is unchanged;
the tenant does not need to be added to history_business_key. The source must expose
the old tenant (REPLICA IDENTITY FULL for a non-PK tenant column), and a tenant_mfa
must resolve both record shapes. If the old tenant is absent or the MFA raises, the
pipeline halts value-free — :tenant_required (side=old) / :tenant_resolution_failed
— rather than guessing a reassignment (ADR-0001's B4 amendment).
History is retained on delete (soft-close). A source delete closes the current
version (stamps valid_to_lsn); it never erases prior versions. SCD2 therefore does
not serve a point-erasure / right-to-be-forgotten need — for that, use an SCD1
mirror (which overwrites / destroys) or AshPaperTrail with a pruning policy.
on_truncate :close (SCD2 only). In place of :halt / :mirror, an SCD2 resource
may set on_truncate :close: an upstream TRUNCATE closes every open version
tenant-blind (stamps valid_to_lsn on all rows where it is NULL), retiring the whole
window without deleting history. on_truncate :close on a non-SCD2 resource is rejected
at compile time.
Every admitted destination resource uses the sink's literal AshPostgres Repo and
the same effective dynamic Repo. Admission recursively walks checkpoint, mapped,
relationship, cascade, SCD2, and declared auxiliary actions. It fails before
delivery on a foreign/dynamic Repo, non-Postgres resource, missing action, recursive
participant cycle, or mismatch between the discovered closure and the containing
action's touches_resources.
Generated callbacks call the admitted sink implementation directly and cannot be
overridden through an internal effect hook. Admission rejects SetContext values
that replace :data_layer (a dynamic/MFA context is admissible only when its
module declares DestinationParticipant), and AshOnetime-protected participants
must use AshOnetime.Cache.None so cache work cannot escape rollback.
Custom changes, validations, preparations, manual actions, callbacks, types, and
tenant resolvers implement AshReplicant.DestinationParticipant and return either
:no_database or literal resource/action references:
defmodule MyApp.UsageRulesReceiptParticipant do
@behaviour AshReplicant.DestinationParticipant
alias AshReplicant.DestinationParticipant.{ActionRef, Context, ReplayIdentity}
@impl AshReplicant.DestinationParticipant
def destination_participants(_opts, %Context{}) do
{:ok,
{:actions,
[
%ActionRef{
resource: MyApp.MirrorReceipt,
action: :record,
replay_identity: %ReplayIdentity{
participant: :mirror_receipt,
components: [
:source_system_identifier,
:source_database,
:slot_name,
:commit_lsn,
:ordinal,
:participant
]
}
}
]}}
end
endThe sink suppresses notification DISPATCH for mirrored changes, but not the dependency pre-load: Ash runs a notifier's load statement after every successful create/destroy regardless. A non-empty statement therefore executes host reads inside the sink's delivery transaction.
Ash re-derives that statement by calling load/2 again at delivery, so an
admission-time probe binds nothing on its own — a load/2 that reads
application config, process state, or the clock can be empty when the manifest
is built and non-empty when the sink delivers. A notifier attached to a
mirrored resource whose statement is non-empty must therefore do BOTH:
- declare
AshReplicant.DestinationParticipant(the:notifierkind), and - route its statement through
AshReplicant.Notifier— replaceload/2withpreload/2and let the wrapper defineload/2:
defmodule MyApp.UsageRulesOrderNotifier do
use Ash.Notifier
use AshReplicant.Notifier
@behaviour AshReplicant.DestinationParticipant
alias AshReplicant.DestinationParticipant.{ActionRef, Context}
@impl Ash.Notifier
def notify(_notification), do: :ok
@impl AshReplicant.Notifier
def preload(_resource, _action), do: [:total]
@impl AshReplicant.DestinationParticipant
def destination_participants(_opts, %Context{resource: resource, action: action})
when action != :read do
{:ok, {:actions, [%ActionRef{resource: resource, action: :read}]}}
end
def destination_participants(_opts, _context), do: {:ok, :no_database}
enduse Ash.Notifier must come first. Inside a sink delivery the generated
load/2 compares the statement — and the participant declaration's action
closure — against what the live generation admitted, and only then hands that
exact statement to Ash; drift halts before the dependency query runs. On the
host's own writes it is an ordinary Ash notifier. Admission verifies the live
callback entered that wrapper on both stability probes; do not make the
generated load/2 overridable, because a replacement is rejected even if the
module still carries the wrapper behavior marker.
Admission rejects the ways this can be wrong, naming resource, action, and
notifier: :destination_notifier_required (no declaration),
:destination_notifier_unwrapped (declared, but raw load/2), and
:destination_notifier_unstable (a statement or declaration that will not
reproduce itself between two consecutive calls). At delivery the sink's own
check adds {:invalid_destination_config, :notifier_load_drift | :notifier_load_unadmitted | :notifier_load_probe_failed} for what the wrapper
cannot see — chiefly a notifier whose statement was empty at admission and is
not any more.
A notifier with no load statement needs none of this and is unchanged.
Declarations are trusted metadata; they do not prove an arbitrary Elixir body.
Never conceal raw SQL, another Repo, asynchronous work, or an external effect
behind one. The containing action's touches_resources must exactly equal the
resources reached through all providers.
An auxiliary action that needs replay protection may use AshOnetime only as local
idempotency committed with the action in the admitted Repo. It must be
transactional, fail closed on store failure, accept a private non-null string
operation_key, use the exact versioned participant scope, and derive its key with
AshReplicant.DestinationParticipant.operation_key/2 — the context the sink
supplies carries a per-invocation label (invocation:) that the derivation
REQUIRES; manual minters (tests, replay probes) must pass the label the sink
mints at that effect site (:close_prior | :close_current | :open | :destroy_prior | :upsert | :message | :mark_seen | :retire_unseen | :append). AshOnetime one-time nonces
are rejected for WAL replay. Independent commits, external effects, opaque stores,
and incomplete replay identities are rejected.
V1 retry and incremental resume are physically effect-once for opted-in resources:
the keyed fingerprint suppresses repeated host actions, while incremental chunks
commit the exact progress token, row effects, membership, and durable ordinal cursor
in one checkpoint-locked destination transaction. Message (C1), sink-owned batch
delivery (C2 — batch_delivery routes flushes through handle_batch/1, one
destination transaction and one trailing watermark write per batch),
incremental snapshot progress (C3), and append-log delivery (C4 — sink_kind/0
plus handle_slot_origin/2) are live.
A generated sink is EXCLUSIVELY a state mirror or an append log; Replicant
exposes sink_kind/0 per SINK, not per resource, so a mixed resource set cannot
be represented by the transport callback and activation rejects it
(:sink_kind_mixed).
use AshReplicant.Sink,
repo: MyApp.Repo,
domains: [MyApp.Events],
checkpoint_resource: MyApp.ReplicantCheckpoint,
slot_name: "shop_events",
sink_kind: :append_log,
initial_state: :go_forward # or :snapshot — REQUIRED for an append sinkinitial_state is the sink's ONE declared initial-state intent and must agree
with the snapshot: start option, or activation fails
:initial_state_mismatch. :go_forward is what generates
handle_slot_origin/2.
This package generates NO event table, migration, or raw write path. Declare on your own AshPostgres resource:
replicant do
source_table "orders"
append_log true
on_truncate :append # `:halt` (default) or `:append`
endand provide, under the configured names (defaults shown):
| Option | Default | Storage | Role |
|---|---|---|---|
append_source_system_attribute |
:source_system_id |
:string |
identity axis |
append_source_database_attribute |
:source_database |
:string |
identity axis |
append_slot_attribute |
:slot_name |
:string |
identity axis |
append_commit_lsn_attribute |
:commit_lsn |
:integer (bigint) |
identity axis |
append_ordinal_attribute |
:ordinal |
:integer |
identity axis |
append_operation_attribute |
:operation |
:string |
source operation |
append_origin_attribute |
:origin |
:string |
delivery origin |
append_attempt_attribute |
:snapshot_attempt |
binary | backfill attempt |
append_message_prefix_attribute |
:message_prefix |
:string |
routed message prefix |
append_message_content_attribute |
:message_content |
binary | routed message content |
append_action |
:append |
— | the IMMUTABLE :create delivery action |
append_identity |
:append_identity |
— | identity over the five axes |
Plus the mapped payload attributes — ordinary source columns under the same
skip, sensitive and tenant rules a state mirror uses.
ValidateAppendLog rejects, at build time:
- a missing structural attribute, one whose storage is wrong for its role, one
marked
sensitive?: true, one listed insensitive, or a structural attribute other thansnapshot_attemptthat is nullable; - a missing append action, an append action that is not a
:create, one that declares its OWN upsert (that would replace the sink's conflict target and let a duplicate delivery mutate a stored event), or one that fails to accept a structural attribute; manual actions and arbitrary action or global create changes are rejected because they can replace the insert or rewrite an identity axis after validation (AshCloak encryption of non-structural payload is the sole admitted change); - an append identity that is missing, or whose keys are not EXACTLY the five axes — a narrower key makes two distinct same-transaction effects collide, a wider one lets a replay append twice;
- an attribute-multitenant target whose append identity omits
all_tenants? true(Ash would otherwise prepend the tenant discriminator to the conflict target, so it no longer matches the five-column unique index); history_strategy :scd2,snapshot_provenance true, oron_truncate :mirror/:closeon an append target — all of them UPDATE or DELETE rows a log must never touch;on_truncate :appendbeside a declared tenant source (a TRUNCATE is tenant-blind and carries no row to resolve a tenant from), andon_truncate :appendon a state-mirror resource.
One obligation is NOT compile-checkable: a source column sharing a name with a
structural attribute. The verifier does not know the live source columns, so
that collision halts value-free at delivery (:config_invalid) before any
append.
| change | operation |
origin |
payload |
|---|---|---|---|
| insert | "insert" |
"stream" |
the new record |
| update | "update" |
"stream" |
the new record |
| delete | "delete" |
"stream" |
the admitted OLD record |
| truncate | "truncate" |
"stream" |
none — structural |
| message | "message" |
"stream" |
routed prefix and binary content |
| snapshot | "snapshot" |
"snapshot" |
the row, plus the attempt id |
A delete appends the complete old record, so every append source table requires
REPLICA IDENTITY FULL, tenant-scoped or not. DEFAULT identity carries only
primary-key columns and would silently append an incomplete delete event.
For an append sink, each message_routes entry must target the configured
append action on a non-tenant append resource. The action must accept the
configured message prefix/content attributes; content must use binary storage.
Transactional messages use the transaction commit LSN and their shared ordinal;
standalone messages use their WAL LSN and ordinal zero. These two attributes are
destination-only and coverage never expects them on the source table.
State-mirror message routes retain ADR-0015's AshOnetime profile unchanged.
Delivery runs upsert?: true against append_identity with an EMPTY
upsert_fields, which AshPostgres renders as a no-op conflict clause. A
lawfully re-delivered WAL position appends ONCE and the stored payload is never
overwritten. Back the identity with a real unique index — it is the defensive
database constraint. Effect-once itself is unchanged: the append and the
checkpoint commit in ONE locked destination transaction.
A :go_forward append sink writes the slot origin it first started from to the
checkpoint's origin_floor, once, on its first admitted activation. No
completeness claim covers data before that floor. Later reconnect origins are
moving resume facts, never replacement floors:
- a slot CREATED this session arriving at a log that already claims a floor
halts
:append_origin_gap— the previous slot is gone, so PostgreSQL no longer retains the WAL between the log's frontier and the new consistent point; - an appended event above the durable checkpoint halts
:append_frontier_divergent— they commit together, so that can only be a torn write.
Replicant does not filtered-WAL idle-advance an :append_log sink. The gap check
therefore compares a reused origin directly to the durable watermark: any higher
origin was advanced by another consumer or operator and halts :append_origin_gap
before streaming. On a quiet append publication in a busy cluster, publish a
normal heartbeat transaction to advance the checkpoint and release retained WAL.
- An append sink cannot run INCREMENTAL snapshots: that mode requires
snapshot_provenance trueon every mapped resource, which an append target may not declare (:snapshot_unsupported). - A
strategy :contextappend target is refused on a go-forward sink (:append_frontier_unavailable): its per-tenant schema leaves no statically addressable table to read a tenant-spanning frontier from. - A
:snapshot-intent append sink needs:ash_replicant, :snapshot_provenance_keysconfigured — the backfill attempt id lives in the checkpoint's authenticated state envelope. Activation fails closed without it. - Separate mirror and append pipelines may use separate slots and checkpoints. One slot is not claimed to serve both sink kinds.
A snapshot retry must not repeat a host business effect for a row that did not change. Converging to the same final state is not enough: a create, destroy, or SCD2 close can carry an append-only local effect even when a later upsert converges. So a snapshot-managed resource stores a keyed fingerprint of the values its host action was given, plus a marker for the attempt that last saw the row (ADR-0017).
The V1 and incremental protocols that read this contract are live. V1 binds
one checkpoint-owned attempt per delivery run. Incremental snapshot_progress/0
arms one durable attempt before reader/stream start, persists exact progress and
the operation-key cursor with every chunk, stamps concurrent stream writes, and
uses a completion-token hash as a permanent no-rescan fence.
Opting in is what buys you retirement. No snapshot callback clears a resource any more (see the migration note at the end of this section). A snapshot-backed resource that does NOT declare
snapshot_provenance truekeeps rows the source has dropped, because there is no membership marker to retire them by.
replicant do
source_table "orders"
snapshot_provenance true
end
attributes do
# ... your mirrored columns ...
attribute :replica_fingerprint, :binary, public?: false, writable?: false
attribute :replica_seen_attempt, :binary, public?: false, writable?: false
end
actions do
defaults [:read, :destroy, create: :*, update: :*]
update :replicant_mark_seen do
public? false
accept []
require_atomic? false
change AshReplicant.Snapshot.MarkSeen
end
destroy :replicant_retire_unseen do
public? false
end
endsnapshot_provenance defaults to false, so a resource that does not back a
snapshot needs no change at all.
Under history_strategy :scd2 the retirement action is an :update (it closes
the open version) rather than a :destroy — closed history stays immutable:
update :replicant_retire_unseen do
public? false
accept [:valid_to_lsn]
endBoth action names are configurable via snapshot_mark_action and
snapshot_retire_action.
Under strategy :context multitenancy (non-global) you must also declare a
retained-scope enumerator. Completion retires per tenant scope and never
tenant-blind; attribute multitenancy enumerates itself from its discriminator
column, but context multitenancy has none, so your application is the only
authority on which scopes exist — including a tenant wholly absent from the
source snapshot:
replicant do
# ...
snapshot_provenance true
snapshot_tenant_scope_action :replicant_retained_scopes
end
actions do
action :replicant_retained_scopes, {:array, :string} do
public? false
run MyApp.RetainedTenants
end
endIt is a private generic action returning {:array, _}. Because it is custom
code the adapter cannot inspect, its run module declares
AshReplicant.DestinationParticipant like any other. A missing, raising, or
malformed enumeration — including one carrying a blank scope, which Ash would
treat as NO tenant — fails completion closed with
:snapshot_scope_incomplete.
AshReplicant.Resource.Verifiers.ValidateSnapshotProvenance fails the build
(a Spark diagnostic, build-blocking under --warnings-as-errors) unless:
- both protected attributes exist, are binary-storage,
public?: false,writable?: false, and are not classified sensitive (they carry no row value — never list them insensitive); - no action accepts either one, and no action declares an argument named for either one. Both are input paths, and either would let a host forge membership so a changed row looks "already seen";
- the mark action exists, is a private (
public? false):update, and is the only owner ofchange AshReplicant.Snapshot.MarkSeen; the verifier rejects that change on every other action and in the globalchangesblock, because either path could stamp ordinary host business updates; - the retirement action exists, is private, and is a
:destroyunder:scd1or an:updateunder:scd2.
On a multitenant resource, both actions are treated as sink-selected actions by
ValidateActionMultitenancy, so neither may declare multitenancy :bypass or
:bypass_all.
AshReplicant.Snapshot.MarkSeen is the only write path to the two attributes.
It reads them from the sink-supplied changeset context and writes through
Ash.Changeset.force_change_attribute/3; an absent or malformed context fails
the changeset closed rather than silently marking nothing.
The fingerprint is an HMAC-SHA-256 under host-managed keys, tagged with both the canonical encoding version and the key version:
config :ash_replicant,
snapshot_provenance_keys: [{1, System.fetch_env!("ASH_REPLICANT_PROVENANCE_KEY_V1")}]Same shape as :message_digest_keys: a non-empty list of
{positive_integer_version, binary_key} with unique versions and keys of at
least 16 bytes. The highest version is ACTIVE; lower versions are RETAINED.
Rotate by adding, never by replacing. Keep an old version as long as any row or incomplete attempt still names it. Dropping a key that rows still name fails closed — comparison returns an error rather than reporting every row as changed, because reporting "changed" would re-run every host business action.
If any mapped resource declares snapshot_provenance true, activation
preflights this configuration and refuses to start without it.
The checkpoint row carries one versioned, HMAC-authenticated snapshot_state
envelope: the attempt id, the delivery run that owns it, the admitted contract
digest, and the completion replay fence. Everything below happens under that
row's FOR UPDATE lock, so chunks, completion and retirement serialize against
each other across nodes.
- Binding. Each
AshReplicant.PipelineOwneractivation mints a random 256-bit delivery run. The first actualhandle_snapshot/2callback of a run binds it to a fresh random attempt; later callbacks reuse it. An operator-authorized re-export under a LATER owner rotates the attempt even when PostgreSQL hands back the same consistent point —first_for_table?plays no part in attempt identity, and authorizes no deletion. - Per row. Resolve the tenant and the current open target, recompute the fingerprint, and on a match invoke ONLY the private mark action. A changed or new row runs the normal host business action and is then stamped. Marking is an internal effect and is reported as one; it is not a host business effect.
- Completion. Check the replay fence first — a completion already recorded
for this delivery run and LSN returns BEFORE any row scan, so a redelivered
completion cannot retire a stream write that landed after the original.
Otherwise enumerate every destination tenant scope, retire the managed open
rows whose marker differs through your retire action with
tenant:set, advance the watermark, and commitcompletestate — one transaction.
An attempt is bound to the admitted contract (sink config, destination
manifest, code identity, source contract). Resuming under drift fails closed
with :snapshot_state_invalid; so does a tampered or undecodable envelope.
Under SCD2, retirement CLOSES the unseen open version and never touches closed
history.
Configure Replicant's sink-owned mode only after every mapped resource has the row contract above:
AshReplicant.start_link(
# sink, connection, publication, source_identity ...
go_forward_only: false,
snapshot: [mode: :incremental, chunk_rows: 1_000, max_pending_chunks: 4]
)- Prepare. Replicant calls
snapshot_progress/0before it can start the reader or stream. Under the checkpoint lock, absent state mints anarmedattempt and returns:backfill_pending; an armed/active attempt with no first chunk token returns the same marker after restart. Otherwise resume authenticates and returns the exact token already committed. A complete token returns without reactivating membership or re-checking the current admitted contract. Impossible progress/state pairings, incomplete-attempt drift, tamper, or a missing retained key fail closed. - Chunk. Each
handle_snapshot/2chunk reads the active attempt, applies fingerprint-gated rows, advances the durable operation-key ordinal cursor, and stores the exact opaquecontext.progressplus its authenticated hash in the same destination transaction. A fault commits none of those effects. - Concurrent stream. Every streamed insert/update committed while the attempt is armed or active runs the normal host action, then stamps its new fingerprint and the same membership marker before the stream watermark advances. Deletes remove or close normally. The Replicant collision window makes the later stream image win (the fetched 1.4.0-or-later contract bounds the window; see below).
- Complete. Replicant sends one empty
handle_snapshot/2callback withbackfill_complete?: true. Completion retires unseen open rows, stores the exact complete token and matching progress/completion hashes, and does not alter the stream watermark. Redelivery of that token returns before any scan, including after a later stream write or admitted-contract deployment.
The fetched Replicant contract (1.4.0 or later) bounds keyed and keyless contention at three discarded table attempts, distinguishes reconnect from contention, and applies pending-chunk backpressure. AshReplicant pins those behaviors with black-box tests rather than checking only for module/function presence.
The two attributes are ordinary bytea columns, so generate migrations the
normal way after adding them:
mix ash_postgres.generate_migrations --name add_snapshot_provenanceBoth are nullable by design: existing rows carry NULL until an attempt first
stamps them, and a NULL fingerprint simply means "no provenance recorded
yet". Nothing backfills them. A row carrying a NULL marker is treated as
unseen and IS retired by a completed attempt — that is what makes retirement
total within the managed scope.
The generated checkpoint resource also changed: the reserved-but-inert
snapshot_generation column is replaced by snapshot_state. Nothing ever wrote
it, so mix ash.codegen produces a drop of an always-NULL column and an add of
another; migrate normally, no data capture required.
If you rely on the old first-chunk clear. Before this release,
handle_snapshot/2 deleted every row of a resource on first_for_table?. It no
longer does — that wipe repeated every committed host business effect on a retry
and could erase a stream-applied row. Nothing is deleted that was not deleted
before, so the change is strictly less destructive, but a resource that relied
on it for redo-safety now keeps rows the source has dropped. Declare
snapshot_provenance true with the two attributes and the private mark/retire
actions to get retirement back, on the fenced completion path described above.
mix ash_replicant.preflight --pipeline MyApp.Replicant.Pipeline
mix ash_replicant.doctor --pipeline MyApp.Replicant.Pipeline --format jsonpreflight answers may this start? and reads no checkpoint, so it is correct
on a fresh install. doctor adds the durable-state classes a deployed pipeline
has: checkpoint state, contract drift, and runtime readiness. Both resolve the
generated pipeline's own admitted start options — never restate configuration
the application already carries. In-process, the same diagnosis is
AshReplicant.preflight/1 and AshReplicant.doctor/1. The Mix task checks the
generated-pipeline marker in the BEAM export table before loading the named
module, so an arbitrary module cannot run @on_load or start_options/0.
The commands perform no writes. Every source statement passes a fail-closed
read-only admission, the probe connection is opened
default_transaction_read_only=on (so PostgreSQL refuses a write the admission
missed), and the checkpoint is read through its :read action with no lock. The
commands never start a repo, a pipeline, or a service.
Rules for reading a report:
- Every class has its own reason. Missing privileges
(
privilege_replication_missing/privilege_select_missing/privilege_probe_missing), unknown checkpoint state (checkpoint_state_unknown), replica identity (source_replica_identity, judged independently of the rest of coverage), the retention horizon, contract drift, and version mismatch are never collapsed into one bucket. skippedis notpass. An unreachable source or an unavailable destination skips what it could not judge and says why; reachability itself still fails, so the verdict stays closed. A statement fault after a source connection succeeds keeps reachability passed and marks the affected checkssource_probe_failedinstead of calling a responding server unreachable.- Act on
retention_at_riskbeforeretention_lost. The first warns while the WAL is still there; the second means recovery is already impossible. A durable watermark whose slot has disappeared isretention_lost. - Exit codes:
0clean,1a failed check,2warnings only,3the invocation could not be diagnosed at all. Branch monitoring on3separately — it means the command was invoked wrong, not that the deployment is sick. - Runtime readiness is node-local.
:persistent_termis per-node, so the Mix command — its own OS process — always reportsgeneration_absent. CallAshReplicant.doctor/1from inside the running application for the real answer. - Output carries no connection option, publication name, source identity, slot name, watermark, or row value, in either format.
AshReplicant.status(MyApp.Replicant.Sink)
#=> :healthy | :catching_up | {:halted, reason} | {:misconfigured, reason} | :not_startedCall it from inside the running application (a remote console or a health endpoint) — the live evidence is node-local. The answer is derived from the owner's own facts, the generation entry, and the tombstone legs; it is never a stored flag that can drift.
Rules for reading it:
:healthyis a strong claim: live owner and pipeline, an enabled census whose last run passed, and no in-flight snapshot. A pipeline whose census has not run yet, is disabled, or is faulting below the halt budget reports:catching_up— owner liveness alone is insufficient.{:halted, :owner_lost}is a fault. A dead owner means mirroring has stopped (or stops at the next callback); restart explicitly. It is never reported as:not_started.{:misconfigured, reason}names the config/contract drift an operator fixes before a restart can succeed (identity rebinding, contract drift, source mapping gaps). Other halt causes report{:halted, reason}. Two decoder-surface entries (ADR-0026):{:misconfigured, :decoder_contract_incompatible}— the stored contract and the live one disagree on the decoder or its table set (a decoder switch re-targets the source wholesale), distinct from:publication_contract_incompatible(publication-shape drift); and{:misconfigured, :source_release_unsupported}— the source's release is below the configured decoder's floor (pgoutput 12+, plugin decoders 9.6+), enforced at activation BEFORE any 9.6-dependent probe runs, never left to the transport's connect halt.- A deliberate stop is
:not_started. The tombstone distinguishes it on the lifecycle surface (AshReplicant.Status.derive/2returns:stopped), but the public five-state set treats "cleanly stopped" and "never started" the same: nothing is wrong. - Terminal causes persist as value-free tombstones — closed reason
atoms, a class, and a timestamp on the checkpoint row
(
terminal_cause/terminal_class/terminal_at), cleared by every admitted checkpoint write. If the destination was unreachable at halt time, only the node-local leg survives; after a node restart such a slot reports:not_startedand the halt telemetry is the record. The durable leg is observable and bounded:[:ash_replicant, :status, :tombstone_write_attempted]fires at attempt time (before any destination work — observing it proves the write was undertaken, which is what distinguishes a lawful skip from a write that never ran), and the write's own pool/transaction deadline bounds when its terminal state settles — a refusal or failure fires[:ash_replicant, :status, :tombstone_write_failed](:destination_unavailable/:destination_write_failed); a lawful skip emits nothing after the attempt (no checkpoint row existed to carry the record). - Reasons never carry row values, message prefixes, or progress tokens, and
a foreign persisted cause decodes to
{:halted, :tombstone_unknown}. - A transport-initiated halt reports
{:halted, :pipeline_terminated}. Replicant discards the halt cause at teardown, so a pipeline it killed — including Replicant 1.4's connect-time decoder halts ({:decoder, :table_missing}for a configured pglogical/wal2json table absent on the server,{:decoder, :extension_missing}for a missing plugin build,{:decoder, :table_keyless}for a wal2json table with no replica-identity index andREPLICA IDENTITY ≠ FULLwithoutallow_keyless_tables: true, and{:config, :decoder_unsupported_on_server}for pgoutput on a pre-10 server) — leaves the generic cause. The precise reason rides Replicant's own[:replicant, :connection, :slot_invalidated]telemetry (reasonmetadata): that event is the first stop when a tombstone says:pipeline_terminated. Start-time refusals are different and synchronous —{:error, :config_invalid}for a decoder-grammar violation (including a missing table-set key) and{:error, :decoder_capability_unsupported}for a capability the chosen decoder cannot express.
The durable checkpoint row is keyed by the ACTUAL replication session's
identity — (source_system_id, source_database, slot_name) — with the session
timeline recorded beside it and the canonical contract manifest (what the
adapter maps) plus its fingerprint stored on the row
(ADR-0007).
On every connect, before any checkpoint read, the sink binds the row under the
per-slot lease: a foreign identity under the same slot name, a changed
timeline, a watermark beyond the session's WAL flush position, or an
incompatible contract transition halts the pipeline fail-closed with a
value-free reason and one [:ash_replicant, :checkpoint, :conflict] telemetry
event. Every halt is an explicit operator decision — there is no fail-open
path. A halted pipeline does not restart itself (:temporary); restart it
after resolving the halt and the identity gate re-binds.
Contract transitions follow a set-monotone rule: ADDING a relation, a brand-new column, or an ignore advances automatically (the manifest is replaced atomically, the watermark untouched); changing or removing anything already recorded — re-targeting a column, changing a type or tenant source, un-skipping a recorded skip, changing the publication — halts.
Operator recovery surfaces:
AshReplicant.adopt_checkpoint(sink, source_identity, commit_lsn)— adopt a preserved legacy watermark into a source-bound row (offline; idempotent; refuses when a different identity owns the slot name).AshReplicant.reset_checkpoint(sink, source_identity)— destroy the bound row (incompatible transition or identity rebind; you accept re-delivery or re-snapshot). On an SCD2 mirror, pair a reset with a full re-snapshot — bare re-delivery re-closes closed versions and halts mid-replay.AshReplicant.acknowledge_checkpoint_timeline(sink, source_identity, timeline_id)— after a:source_timeline_changedhalt on a same-primary crash restart (the new timeline replays the old WAL), re-bind the recorded timeline without touching the watermark. After a failover you cannot prove continuous, reset instead.
AshReplicant 0.4.0 keyed checkpoints only by slot_name. Those rows contain no
machine-derivable source identity, so the 1.0.0 upgrader never infers ownership
or provenance. The supported transition is exactly 0.4.0 -> 1.0.0; use the
package task, not a hand-written capture/delete/adopt sequence.
Before changing anything, back up the destination database and stop the
AshReplicant pipeline on every node. The database lock makes the schema
transition atomic, but it cannot prove an idle old node has been stopped.
Update the dependency to 1.0.0 and add Igniter as a development dependency if
the host does not already have it. Current code accepts 0.4.0's apply_ledger
option only as a compile-time upgrade marker and refuses to activate a sink
that still carries it.
Run a redacted dry-run first. Repeat --binding once for every configured sink;
each JSON object names the sink, the generated pipeline module, and the source
identity the operator has independently verified:
mix ash_replicant.upgrade 0.4.0 1.0.0 \
--repo MyApp.Repo \
--destination-database destination_db \
--binding '{"sink":"MyApp.ReplicantSink","pipeline":"MyApp.Replicant.Pipeline","source_system_id":"...","source_database":"..."}' \
--dry-runThe task reads the selected destination, verifies the exact legacy/current table shape, ties every populated row to exactly one binding, permits dormant configured sinks with no row, and reports only structural state and counts. It halts without source changes for missing, shared, ambiguous, foreign, or partially upgraded checkpoints; unreadable or dynamic legacy supervision; a non-default effective dynamic Repo; an unsupported version range; or a missing or foreign resource snapshot. Slot names, identities, watermarks, connection options, and source diffs are never printed.
Apply the same plan, inspect and commit the generated host migration, current
resource snapshot, pipeline module, supervision change, runtime configuration,
and removed apply_ledger marker, then run the migration while the all-node stop
assertion remains true:
mix ash_replicant.upgrade 0.4.0 1.0.0 \
--repo MyApp.Repo \
--destination-database destination_db \
--binding '{"sink":"MyApp.ReplicantSink","pipeline":"MyApp.Replicant.Pipeline","source_system_id":"...","source_database":"..."}' \
--yes
ASH_REPLICANT_PIPELINES_STOPPED=1 mix ecto.migrateThe required migration invocation is
ASH_REPLICANT_PIPELINES_STOPPED=1 mix ecto.migrate; the generated migration
refuses without that explicit assertion.
The migration takes a destination-scoped advisory transaction lock and an
ACCESS EXCLUSIVE checkpoint-table lock, records a checksummed private rollback
ledger, binds each legacy row to its declared identity, and converts the schema
in one transaction. An exact already-current database and matching generated
source are idempotent no-ops. After migration, deploy/start the 1.0.0 host and
verify the pipeline binds the expected source before accepting it as healthy.
Stop every pipeline node again and roll the generated database migration back before restoring the 0.4.0 host artifact or dependency:
ASH_REPLICANT_PIPELINES_STOPPED=1 mix ecto.rollback --step 1Rollback succeeds only when the checkpoint rows still match the migration's checksummed ledger and no 1.0-only timeline, contract, snapshot, origin, new-row, removed-row, or advanced-watermark state exists. Timestamp-only migration metadata does not block it. A missing, malformed, or tampered ledger also refuses. If 1.0 has written durable state, restore from backup or remain on 1.0; discarding that state to force a downgrade is unsupported. After a successful database rollback, restore the exact pre-upgrade host source/artifact and only then restart the old pipeline.
Every publication table must be mapped, explicitly ignored, or the pipeline refuses to start; every delivered column must be mapped or skipped, or the pipeline halts before writing (ADR-0008). The preflight runs at activation on a short-lived identity-verified source connection and re-runs the table-membership check at every reconnect.
- Partial publications (tables you do not mirror) must declare them:
use AshReplicant.Sink,
ignored_sources: ["public.audit_events"]
Strictly qualified
schema.tablestrings; bare names and duplicates are compile errors; an ignore colliding with a mapped resource fails activation. Column-level ignoring is the resource-levelskiplist. Ignores are standing intent — their hygiene is yours.mix ash_replicant.doctorreports a never-matching entry as a warning (ignore_never_matches) so an ignore that protects nothing does not stay silent. - Source columns must all be mapped or skipped. A source
ADD COLUMNhalts the first changed row (:source_column_unmapped) until you map or skip it. A declared column that vanishes, or askipnaming a column that does not exist, halts at activation. - Column types are checked against the target attribute at activation
(
:source_type_invalid); custom target types are not judged statically — the runtime cast stays per-row fail-closed. - REPLICA IDENTITY FULL is enforced at activation on every append-log
source, every tenant-scoped mapped table, and every SCD2 table whose business
key is not the source primary key (
:source_replica_identity); a mid-stream identity or type change always halts, never ignorable viaon_schema_change :ignore. - Boot ordering: a source unreachable at activation defers the coverage verdict (the pipeline starts and paces reconnects); the verdict renders on the first reachable connection — before any checkpoint advance.
On a tenant-scoped mirror, every update and delete resolves BOTH the old and
new tenant before any write. An absent, blank, false, or raising old-side
resolution halts (:tenant_required / :tenant_resolution_failed,
shape: "side=old") — an update whose old_record is missing entirely (the
DEFAULT-identity shape) halts too. Same-tenant updates apply normally;
reassignment relocates the row (SCD1) or terminally closes the old-tenant
version and opens the new one (SCD2). This is safe to require precisely
because the preflight enforces FULL identity, so old_record carries the
tenant on every update and delete (an unchanged TOASTed discriminator still
halts — do not TOAST your tenant column).
-
Route writes through Ash actions. The mirror writes through the host resource's OWN primary
:createaction (as an upsert) and its:destroyaction — the extension generates neither, you define them. The sink calls them withauthorize?: false, so AshCloak encryption and multitenancy scoping still fire (policies are not re-gated). Direct Ecto bypasses AshCloak and tenancy — never do it. -
Fail-closed multitenancy. A nil/
false/blank tenant on a multitenant resource is an error (falsetoo — Ash treats a falsy tenant as unscoped). No silent base-tenant fallback. The mirror action'stenant:option triggers Ash's multitenancy DSL; if tenant validation fails, the write fails and the transaction rolls back. A declaredtenant_attributeortenant_mfarequires an Ashmultitenancyblock —ValidateMultitenancyrejects a source with no block at compile time, since Ash would otherwise silently ignoretenant:and mirror every tenant unscoped. -
Sensitive = AshCloak-encrypted or binary or skip. Every source column listed in
sensitivemust map to one of: (1) an Ash attribute with AshCloak encryption (the verifier detects it), (2) a binary-storage attribute (user-managed encryption), or (3) listed inskip(excluded from mirror). The compile-time verifier enforces this; a violation is a build error, not a runtime surprise. AshCloak is the single encryption source of truth. -
No row value in error/log/telemetry. Assume every value is PII or a secret. Sink failures and halt paths carry structure (error reason, table name, LSN) only — never the column value, PK, tenant name, or offending data. Column names are strings, never atoms.
-
Streaming effect-once is one transaction + watermark dedup. Every committed streaming transaction applies in a single
Repo.transaction: skip any change whosecommit_lsn <= checkpoint, apply rows, upsert checkpoint atomically. On failure, the transaction rolls back; on resume, un-acked WAL re-streams and dedups. Whole-table snapshot RETRY is effect-once by a separate mechanism — the checkpoint-owned attempt and the row fingerprint (see "Snapshot provenance and retirement"), which is what an LSN watermark cannot give a COPY-based snapshot. Incremental chunks use the same row fingerprint plus atomic durable progress and stream membership stamping.
replicant is the CDC transport layer — tenant-blind, Ash-agnostic. It owns the
Postgres logical replication slot, the pgoutput protocol, transaction assembly,
WAL ordering, and acknowledgment after sink success. AshReplicant owns its durable
commit-LSN checkpoint and persists it with the Ash effects.
AshReplicant consumes a Replicant.Sink interface and layers Ash semantics on top:
resource resolution, tenant routing, sensitive verification, host actions, and the
atomic checkpoint. Host policies are not re-gated.
Never add multitenancy or classification logic to replicant. The split is the
reason they are separate libraries.
Decoder selection is passed through AND admitted — all three decoders
(ADR-0026). Replicant 1.4 added logical-decoding output-plugin selection
(decoder: :pgoutput | :pglogical | :wal2json, with the per-decoder table-set
keys publication: / replication_sets: / tables: and the wal2json-only
allow_keyless_tables: / schema_check_interval:). AshReplicant forwards
all five options unchanged — decoder selection is a tenant-blind transport
concern, the same split that keeps multitenancy here — and admits all three
decoders through one table-set home (AshReplicant.SourceSet): the census,
the contract manifest, the doctor, and the C01 re-check all judge the
decoder-scoped table set exactly as they always judged a publication.
AshReplicant.SourceSetTest, the decoder-lane integration suite, and the
decoder-old-majors CI cells carry the evidence: pgoutput on PostgreSQL 12
and 15–18, pglogical 2.x and wal2json ≥ 2.6 on PostgreSQL 9.6 and 12 — the
matrix CI actually runs. A stored pgoutput contract is byte-untouched
(upgrade-in-place); switching decoders or table sets on an existing
checkpoint halts {:incompatible, :decoder} — an explicit operator reset
decision.
AGENTS.md— the working guide with critical rules (binding).replicantusage-rules — the CDC framework contract, published with thereplicantHex package (no sibling checkout is required).CHANGELOG.md— version history.