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 atdocs/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:
source_table/source_schema— defaults to the resource's own AshPostgres table/schema via reflection. Optionally override to map a different source.tenant_attribute— source column carrying the tenant. Resolved per row and passed astenant:to the mirror action. Fail-closed if nil/blank. The source table must beREPLICA IDENTITY FULLso a delete's / PK-changing update'sold_recordcarries the tenant column (key-only under the default identity).sensitive— source columns classified as sensitive. 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) or:mirror(direct in-transaction DELETE of the mirror table). Default:halt.on_schema_change—:halt_destructive(halt on destructive DDL) or:ignore. Default:halt_destructive.upsert_identity— identity name used for the upsert mirror write. Defaults to primary-key upsert when omitted; set an identity name to upsert by that identity instead.
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:
- The
slot_namecomes from the sink (not astart_linkoption). - The resolver index is built and cached in
:persistent_termkeyed byslot_name. - Rows arrive from the source's CDC stream and are upserted into the mirrors.
Effect-Once Semantics
Each transaction is applied in oneRepo.transaction:
- Skip any change whose
commit_lsn <= checkpoint(watermark dedup). - Apply each row change (upsert-by-PK, destroy, truncate per policy).
- 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
- Fail-closed: nil/blank tenant → error, never a base-tenant fallback.
- Per-row: each source row's tenant is resolved via
tenant_attributeortenant_mfa, then passed astenant:to the mirror action. Ash's multitenancy DSL validates it. - One layer up: multitenancy logic stays here;
replicantis tenant-blind.
Sensitive Data
Sensitive source columns must map to one of:
- AshCloak-encrypted — the
before_actionhook fires on upsert. - Binary storage — user-managed encryption (store and load encrypted values).
- 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.