Replicant — binding invariants for a sink author

Copy Markdown View Source

These five invariants are the contract between replicant and any sink / consumer. They are the published form of the Critical Rules that govern the library's own implementation (the full narrative lives in the contributor guide). A sink that violates one is a bug in the sink; a library change that violates one is a bug in the library.

1. No row value in an error, log, or telemetry event

Assume every value is PII or a secret. The library guarantees that no row value ever reaches an error, a log line, a telemetry event, or a crash dump through its own surfaces — decode faults are scrubbed to a value-free Replicant.Error{reason, shape}, telemetry metadata is allowlisted to LSNs / table names / counts / durations / error-class atoms, and there is no Logger or IO.* usage in lib/. The sink must uphold the same boundary: do not log record values, do not embed them in error reasons, do not emit them in telemetry. (Governing ADR: 0003.)

A logical-decoding message's content and prefix are user bytes — they are treated exactly like row values under this rule.

2. Validate identifiers before they reach SQL

Slot and publication names pass through Replicant.Identifier.validate/1 (a strict [a-z_][a-z0-9_]{0,62} Postgres-identifier allowlist) before any SQL interpolation; a multi-publication list validates every name and fails closed on a missing publication. Column names reaching SQL are server-quoted (format('%I') / quote_ident). A sink that interpolates catalog-sourced identifiers into its own SQL must apply the same discipline.

3. Exactly-once is at-least-once + a transaction-watermark-idempotent sink

The honest construction. The unit of delivery and of the watermark is the transaction, keyed by its single commit_lsn (every row in a pgoutput proto-v1 transaction shares one commit LSN). A sink MUST skip any transaction whose commit_lsn <= checkpoint and upsert rows by table PK. The guarantee is stated per mode, never as a naked exactly-once:

  • Transactional sink + transactional path → effect-once (dup=0, loss=0): the sink persists rows + checkpoint atomically in one DB transaction; re-delivery upserts to zero net effect.
  • Non-transactional sink / handle_message/2 non-transactional messages / lib-mode incremental snapshot chunks → at-least-once, duplicate-bounded: no dedup key, so duplicates are possible on reconnect. State this guarantee honestly to your consumers; do not claim effect-once for these.

The slot ack advances only after the sink durably commits (ack-after-checkpoint). (Governing ADR: 0004.)

Sink-side admission for an Ash sink — ash_onetime. The idempotency obligation above is the sink's half of Rule 3; replicant owns at-least-once delivery and the commit_lsn dedup key, it does NOT own the sink's effect-once admission. For an Ash/Postgres sink, ash_onetime is the authoritative admission layer: protect the apply action with strategy :idempotency keyed on the transaction's commit_lsn, and the Postgres unique constraint (not a hand-rolled "did this run?" pre-check) decides the replay within the retention boundary — the clean split ash_onetime itself draws between local admission and end-to-end delivery. The same mechanism upgrades the at-least-once handle_message/2 delivery path to effect-once at the sink: strategy :one_time_nonce keyed on the message's {lsn, ordinal} makes the effect once while replicant still states that path's delivery honestly as at-least-once.

4. Unchanged TOAST is a sentinel, not a value

An UPDATE that does not touch a TOASTed column sends a sentinel, not the value. The library surfaces it as a first-class unchanged: [col] list on Replicant.Change; the sentinel never appears in record. A sink MUST leave those columns untouched on upsert (do not overwrite them with NULL or a placeholder). A change of replica identity or a dropped column classifies as :destructive and halts fail-closed.

5. Stay tenant-blind

This library is deliberately tenant-blind and classification-blind. There is no multitenancy, scope, or classification logic in lib/ — that boundary is the whole reason replicant and ash_replicant are separate packages. Do not add tenant/scope/classification concerns to a sink that targets the core library; put them one layer up.


Reference: delivery modes and their guarantees

ModeWhenGuarantee
Sink-owned, per-transaction (default)handle_transaction/1effect-once (transactional sink) — dup=0, loss=0
Sink-owned batch deliverybatch_delivery: + handle_batch/1effect-once (atomic multi-txn write) — dup=0, loss=0
Lib-owned checkpoint storecheckpoint_store:at-least-once, dup-bounded to one transaction — never loss
Non-transactional messagemessages: true + handle_message/2at-least-once — duplicates possible on reconnect
Transactional messagemessages: true, rides %Transaction.messageseffect-once (inherits the txn's commit_lsn dedup)
Initial snapshot (snapshot: true)handle_snapshot/2sink-owned effect-once chunks; lib-mode dup ≤ 1 chunk
Incremental snapshotsnapshot: [mode: :incremental]sink-owned effect-once chunks; lib-mode dup ≤ 1 chunk
Spilled oversized transactionstreaming: [spill: [...]]inherits the active checkpoint-mode guarantee (effect-once sink-owned; at-least-once lib-mode — spill adds no duplicates), delivered as a single-pass lazy changes

See the README for the full configuration reference and the ROADMAP for the feature tracker.