AshReplicant

An Ash Framework adapter for replicant — the framework-agnostic Postgres CDC consumer. Mirrors a source Postgres database's committed changes into AshPostgres resources with effect-once semantics (dup = 0, loss = 0), resolving resource, tenant, and classification in the Ash layer while keeping replicant tenant-blind.

AshReplicant is the "ash_postgres of replicant": define Ash resources backed by a Postgres source's CDC stream, with multitenancy, sensitive-data encryption verification, and policies enforced Ash-natively. It executes through the replicant client (the transport — the "postgrex of CDC").

Status: v0.2.0. Full working library with effect-once guarantees, fail-closed multitenancy, and AshCloak integration. Working rules are in AGENTS.md — read it first. A fuller project charter (architecture, scope, and the resolved effect-once model) is tracked at docs/CHARTER.md. Only the /docs/superpowers/ lifecycle artifacts (specs, plans, handoffs) are local-only.

Layering

Ash core multitenancy DSL, policies, the tenant concept
AshReplicant ← HERE Ash resource extension: tenant routing, sensitive verification,
│ resource mapping, mirror actions
replicant Postgres logical replication (pgoutput), exactly-once watermark
Postgres logical decoding output (pgoutput protocol)

Multitenancy lives here, not in replicant — exactly as ash_postgres (not postgrex) owns schema-based tenancy. This split is verified by the separate Replicant.Sink behaviour and the dual library structure.

Installation

Add ash_replicant to your dependencies in mix.exs:

# mix.exs
{:ash_replicant, "~> 0.2.0"}

It pulls in replicant (the CDC transport) as a transitive dependency.

Quick Start

1. Define the checkpoint resource

defmodule MyApp.ReplicantCheckpoint do
use AshReplicant.Checkpoint,
repo: MyApp.Repo,
domain: MyApp.Domain
end

This generates an AshPostgres resource backed by the ash_replicant_checkpoints table (one row per replication slot, tracking the durable commit LSN watermark).

2. Define the sink

defmodule MyApp.ReplicantSink do
use AshReplicant.Sink,
repo: MyApp.Repo,
domains: [MyApp.Shop, MyApp.Billing],
checkpoint_resource: MyApp.ReplicantCheckpoint,
slot_name: "shop_orders"
end

Key:slot_name is baked into the sink — it is the single source of truth for the replication slot name and is used to key the resolved index. It is NOT a start_link option.

3. Mark source resources with the extension

defmodule MyApp.Shop.Order do
use Ash.Resource,
domain: MyApp.Shop,
data_layer: AshPostgres.DataLayer,
extensions: [AshReplicant.Resource] # ← HERE
postgres do
table "orders"
repo MyApp.Repo
end
replicant do
source_table "orders"
source_schema "public"
tenant_attribute :org_id
sensitive [:pan, :cvv]
skip [:internal_field]
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 # Tenant column; resolved per row and passed as tenant:
attribute :amount, :decimal, public?: true
attribute :pan, :binary, public?: true # Sensitive: binary storage; stored as-is (host-managed encryption unless this resource also uses AshCloak)
attribute :cvv, :binary, public?: true # Sensitive: binary storage; stored as-is (host-managed)
attribute :internal_field, :string, public?: true # Skipped; not mirrored from source
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, so you
# must define them. `create: :*` gives a primary create accepting all public
# attributes — the upsert target; `:destroy` handles mirrored deletes/truncate.
defaults [:read, :destroy, create: :*, update: :*]
end
identities do
identity :unique_pk, [:id]
end
end

About the DSL:

4. Start the pipeline

AshReplicant.start_link(
sink: MyApp.ReplicantSink,
connection: [hostname: "standby.example.com", database: "source_db"],
publication: "shop_orders_pub",
go_forward_only: true,
snapshot: false
)

Key points:

Effect-Once Semantics

Each transaction is applied in oneRepo.transaction:

  1. Skip any change whose commit_lsn <= checkpoint (watermark dedup).
  2. Apply each row change (upsert-by-PK, destroy, truncate per policy).
  3. Upsert the checkpoint (commit_lsn) in the same transaction.

On failure (schema change, multitenancy error, write fault), the entire transaction rolls back. The un-acked WAL re-streams on resume and dedups against the checkpoint.

Result: dup = 0, loss = 0 across restarts and crashes (proven by crash-injection tests against real PG16).

Multitenancy & Classification

Sensitive Data

Sensitive source columns must map to one of:

  1. AshCloak-encrypted — the before_action hook fires on upsert.
  2. Binary storage — user-managed encryption (store and load encrypted values).
  3. Skipped — excluded from the mirror (listed in skip).

The verifier runs at compile time and rejects a resource if a sensitive column violates one of these rules.

Development

mix deps.get
mix test
mix quality # format --check-formatted + credo --strict + dialyzer

All gates pass before commit. Update CHANGELOG.md under [Unreleased].

License

MIT — see LICENSE.