ADR-0002: Multi-publication per pipeline (discovery union + fail-closed existence check)

Copy Markdown View Source

Status: Accepted Date: 2026-07-14 Deciders: replicant maintainer (feature A3; spec 2026-07-11-replicant-messages-multipub-design.md §5.3)

Context

A replicant pipeline consumed exactly one publication (:publication was a single string). Real deployments need to consume multiple publications through one replication slot (pgoutput accepts a comma-list of publication_names). Adding this touches three security- and correctness-sensitive surfaces:

  1. The identifier interpolation surface (Critical Rule 2 — the replication simple-query protocol cannot bind $1, so publication names are interpolated and must be allowlist-validated).
  2. Snapshot/PK/column discovery queries, which enumerate the tables to snapshot and their PK and column sets from pg_publication_tables.
  3. The failure mode when a requested publication does not exist on the server.

Two facts were probe-established on live PG16 during design:

  • START_REPLICATION with a missing publication does NOT error — it silently streams the existing subset. So a typo'd publication would silently under-replicate.
  • A table in two publications produces two pg_publication_tables rows, and the pg_class/pg_index/pg_attribute joins fan out per pubname-row, so a bare pubname = ANY($1) duplicates array_agg PK/column entries (["id","id"]).

Decision

  • Column-list correction (October 10, 2026). Table membership is a union; column lists are not. PostgreSQL 15+ column discovery retains DISTINCT effective attnames per table and projects only those attributes in attnum order. Identical lists collapse; conflicting lists halt :snapshot_column_list_conflict before snapshot delivery in either mode. The pre-15 query remains version-selected because that catalog column does not exist. OBSERVED on PostgreSQL 18.6 via pg_logical_slot_peek_binary_changes: differing lists, including a partial list plus an unrestricted publication, produce cannot use different column lists for table "public.rep_column_probe" in different publications. The live regression is test/integration/snapshot_columns_test.exs.
  • Row-filter correction (October 10, 2026). Initial snapshots in both checkpoint modes and incremental chunks use PG15+ pg_publication_tables.rowfilter. For a shared table, an unfiltered publication means no predicate; otherwise the server-rendered predicates combine with OR. Every expression is parenthesized before it is composed with a keyset bound. No client predicate is interpolated. The connected regression is test/integration/snapshot_rows_test.exs.
  • PG15 dropped-column collapse — documentation only. OBSERVED in the PG15 pgoutput_column_list_init source: an explicit list is collapsed to NULL only when its bitmap member count equals RelationGetNumberOfAttributes, which includes dropped columns. An implicit list is already NULL. Consequently, a table with dropped attributes can have identical effective attnames for an implicit list and an explicit list of every live column, yet PG15 pgoutput rejects that combination. Snapshot discovery accepts those effective lists; the stream refuses them fail-closed, never exposing an unpublished column. No matching early refusal was added: attnames plus pg_attribute cannot distinguish explicit and implicit lists with the same names. Refusing all identical lists on a table with dropped attributes would also reject combinations pgoutput accepts. Operators can use consistently explicit or consistently implicit lists on PG15.
  • publication: String | [String]. A single string is the byte-unchanged default path (the published 0.1.0 START_REPLICATION is reproduced exactly); a list enables multi-publication. Every name is validated via Identifier.validate/1 and normalized to a list. (config.ex.)

  • Table and PK discovery queries drive from a DISTINCT table set: (SELECT DISTINCT schemaname, tablename FROM pg_publication_tables WHERE pubname = ANY($1)) p THEN join — collapsing the pubname dimension before the joins so array_agg never duplicates PK/column entries (decision #19; probe-proven pk_raw = ["id"] for a shared table). (query_builder.ex publication_tables/1, pk_columns/0; column discovery follows the correction above.)
  • A new fail-closed connect-chain :publication_check step. After the recovery check and before slot invalidation/classification, run publication_exists (pubname = ANY(...)) and halt if the found set ≠ the requested set — because START_REPLICATION would otherwise silently stream the subset. publication_exists interpolates a validated IN (...) list (the connect-chain simple-query protocol can't bind $1), each name allowlist-guarded. (connection.ex :publication_check; query_builder.ex publication_exists/1.)

Options Considered

Option A: Comma-string only (publication: "p1,p2")

Cons: No validation boundary per name; pushes the parsing/validation burden onto the user and makes the identifier-allowlist guarantee (Rule 2) harder to enforce. Rejected in favor of an explicit list normalized + validated in Config.

Option B: Bare pubname = ANY($1) on pg_publication_tables (no DISTINCT)

Cons: A table in two publications duplicates array_agg PK/column entries via the per-pubname join fan-out — probe-disproven (["id","id"]). Would corrupt PK-based dedup. Rejected.

Option C: Rely on START_REPLICATION to reject a missing publication

Cons: Probe-disproven — it silently streams the existing subset. A typo would silently under-replicate with no error. Rejected in favor of an explicit connect-time existence check.

Option D: Validated list + DISTINCT discovery + fail-closed existence check (adopted)

DimensionAssessment
ComplexityMedium (one new connect step, four discovery queries reworked)
SecurityHigh — every interpolated name allowlist-validated (Rule 2)
CorrectnessHigh — DISTINCT prevents discovery duplication; fail-closed prevents silent subset streaming

Trade-off Analysis

The adopted design pays for one new connect-chain step and a discovery-query rework in exchange for (a) no silent under-replication on a bad publication name and (b) no PK/column duplication for tables shared across publications. The rejected simpler options each fail silently — exactly the class of failure this library's fail-closed posture exists to prevent.

Consequences

  • Easier: One slot can consume many publications; overlapping tables deliver once (pgoutput dedups streaming; the DISTINCT driving set dedups discovery); a typo'd publication fails fast at START with a :publication_missing signal instead of silently streaming a subset.
  • Harder: The connect chain has one more fail-closed gate to reason about; discovery queries must keep the DISTINCT driving set (a future edit that reverts to a bare ANY($1) reintroduces the duplication bug).
  • Revisit if: per-publication routing/filtering (delivering a change tagged by which publication carried it) is ever required — the current design intentionally deduplicates and does not preserve the pubname dimension past discovery.

Verification (closeout 2026-07-14)

Proven at runtime against live PG16 by test/integration/multipub_test.exs: both publications' changes deliver overlap-deduped in commit order; snapshot discovery unions across the list with the shared table snapshotted exactly once (DISTINCT); a missing publication halts fail-closed at START (:publication_missing).