> **Plain-language companion:** [v0.19.0.md](v0.19.0.md)

## v0.19.0 — Production Gap Closure & Distribution

**Status: Released (2026-04-13).**

> **Release Theme**
> This release closes the most impactful correctness, security, stability, and
> performance gaps identified in the Phase 7 deep-dive and subsequent audits
> that v0.18.0 did not address. It removes the unsafe `delete_insert` merge
> strategy, adds ownership checks to all DDL-like API functions, hardens the
> WAL decoder path before it is promoted to production-ready, eliminates O(n²)
> scheduler dispatch overhead, and ships pg_trickle on standard package
> registries for the first time. The JOIN delta R₀ fix for simultaneous
> key-change + right-side delete is the highest-value correctness improvement
> remaining before 1.0. CDC ordering guarantees, parallel worker crash
> recovery, delta branch pruning for zero-change sources, and an index-aware
> MERGE path round out a release that strengthens every layer of the stack.
> Four to five weeks of focused work delivers measurable correctness
> improvements, privilege enforcement, catalog index optimizations, a PgBouncer
> transaction-mode compatibility fix, read-replica safety, and PGXN/apt/rpm
> distribution.

### Correctness

| ID | Title | Effort | Priority |
|----|-------|--------|----------|
| CORR-1 | Remove unsafe `delete_insert` merge strategy | XS | P0 |
| CORR-2 | JOIN delta R₀ fix — key change + right-side delete | M | P1 |
| CORR-3 | Track `ALTER TYPE` / `ALTER DOMAIN` DDL events | S | P1 |
| CORR-4 | Track `ALTER POLICY` DDL events for RLS source tables | S | P1 |
| CORR-5 | Fix keyless content-hash collision on identical-content rows | S | P1 |
| CORR-6 | Harden guarded `.unwrap()` calls in DVM operators | XS | P2 |
| CORR-7 | TRUNCATE + INSERT CDC ordering guarantee | S | P1 |
| CORR-8 | NULL join-key delta handling for INNER/OUTER joins | S | P1 |

**CORR-1 — Remove unsafe `delete_insert` merge strategy**

> **In plain terms:** The `delete_insert` strategy (set via
> `pg_trickle.merge_join_strategy = 'delete_insert'`) is semantically unsafe
> for aggregate and DISTINCT queries because the DELETE half executes against
> already-mutated state, producing phantom deletes. It is slower than standard
> MERGE for small deltas and incompatible with prepared statements. The `auto`
> strategy already covers its only legitimate use case.

| Item | Description | Effort |
|------|-------------|--------|
| CORR-1-1 | Remove `delete_insert` as a valid enum value; emit `ERROR` if set with hint to use `'auto'`. | XS |
| CORR-1-2 | Add upgrade SQL to detect old GUC value and log a NOTICE. | XS |

Verify: `SET pg_trickle.merge_join_strategy = 'delete_insert'` raises `ERROR`
with actionable hint. All existing benchmarks pass.
Dependencies: None. Schema change: No.

**CORR-2 — JOIN delta R₀ fix for simultaneous key-change + right-side delete**

> **In plain terms:** When a row's join key column is updated
> (`UPDATE orders SET cust_id = 5 WHERE cust_id = 3`) in the same refresh
> cycle as the old join partner (customer 3) is deleted, the DELETE half of
> the delta finds no match in `current_right` and is silently dropped, leaving
> a stale row in the stream table until the next full refresh. The fix applies
> the R₀ snapshot technique (pre-change right-side state via EXCEPT ALL)
> symmetrically with the existing L₀ already implemented for Part 2 of the
> delta. `build_snapshot_sql()` in `join_common.rs` already exists.

| Item | Description | Effort |
|------|-------------|--------|
| CORR-2-1 | Add `right_part1_source` / `use_r0` logic mirroring `use_l0` in `diff_inner_join`, `diff_left_join`, `diff_full_join`. | M |
| CORR-2-2 | Split Part 1 SQL into two `UNION ALL` arms for the `use_r0` case; update row ID hashing for Part 1b. | M |
| CORR-2-3 | Integration tests: co-delete scenario, UPDATE-then-delete, multi-cycle correctness, TPC-H Q07 regression. | M |

Verify: E2E test where `UPDATE orders SET cust_id = new_id` and
`DELETE FROM customers WHERE id = old_id` land in the same refresh cycle produces
correct stream table result without a forced full refresh.
Dependencies: EC-01 R₀ EXCEPT ALL pattern (shipped in v0.15.0). Schema change: No.

**CORR-3 — Track `ALTER TYPE` / `ALTER DOMAIN` DDL events**

> **In plain terms:** When a user-defined type or domain used by a source table
> column is altered (e.g., extending an enum, changing a domain constraint),
> the DDL event trigger fires but `hooks.rs` does not classify it as requiring
> downstream stream table invalidation. Fix: extend the DDL classifier to catch
> `ALTER TYPE` and `ALTER DOMAIN` and trigger cascade invalidation.

Verify: `ALTER TYPE my_enum ADD VALUE 'new_val'` on a type used by a source
column triggers the marked-for-reinit flag on dependent stream tables.
Dependencies: None. Schema change: No.

**CORR-4 — Track `ALTER POLICY` DDL events for RLS source tables**

> **In plain terms:** If an `ALTER POLICY` changes the USING expression on a
> source table, stream tables may silently return wrong results for sessions
> with active RLS. Fix: detect `ALTER POLICY` in the DDL classifier and mark
> dependent stream tables for conservative reinit.

Verify: `ALTER POLICY` on a source table with dependent stream tables triggers
invalidation. E2E test with RLS policy change confirms correct reinitialization.
Dependencies: None. Schema change: No.

**CORR-5 — Fix keyless content-hash collision on identical-content rows**

> **In plain terms:** The keyless table path uses a content hash to identify
> rows. If two rows have completely identical content, they hash to the same
> bucket. Under concurrent INSERT + DELETE of identical rows, the net-counting
> approach may attribute a delete to the wrong "copy" of the row, leaving
> incorrect counts. Fix: incorporate the change buffer's `(lsn, op_index)` pair
> into the hash to break ties between otherwise-identical rows.

Verify: E2E test with two identical rows — insert 2, delete 1 in same cycle;
stream table retains exactly 1 row.
Dependencies: EC-06 keyless path (shipped in prior release). Schema change: No.

**CORR-6 — Harden guarded `.unwrap()` calls in DVM operators**

> **In plain terms:** Several DVM operators use `.unwrap()` on values that are
> logically guaranteed by a prior `is_some()` guard, but the coupling is
> implicit and fragile — a refactor could silently break the invariant, causing
> a panic in SQL-reachable code. The most fragile instance is
> `ctx.st_qualified_name.as_deref().unwrap()` in `filter.rs` (line ~130),
> guarded by `has_st` which is derived from `is_some()` several lines earlier.
> Replace these patterns with `if let Some(…)` or `.unwrap_or_else(|| …)` to
> make the invariant structurally enforced rather than comment-documented.

Verify: `grep -rn '\.unwrap()' src/dvm/operators/` returns zero hits outside
test modules. All existing unit tests pass.
Dependencies: None. Schema change: No.

**CORR-7 — TRUNCATE + INSERT CDC ordering guarantee**

> **In plain terms:** When a `TRUNCATE` and subsequent `INSERT` occur within
> the same transaction on a source table, the change buffer must preserve their
> ordering. If the refresh engine processes the INSERT before the TRUNCATE, the
> stream table loses all rows including the newly inserted ones. The trigger-
> based CDC path records operations in `ctid` order within a statement, but
> cross-statement ordering within a single transaction relies on the change
> buffer’s `op_seq` column. Verify that `op_seq` is monotonically increasing
> across statements and that the refresh engine applies TRUNCATE before INSERT.

Verify: E2E test: `BEGIN; TRUNCATE src; INSERT INTO src VALUES (1); COMMIT;`
followed by refresh — stream table contains exactly 1 row.
Dependencies: None. Schema change: No.

**CORR-8 — NULL join-key delta handling for INNER/OUTER joins**

> **In plain terms:** When a join key column contains NULL, the INNER JOIN
> delta should produce zero matching rows (NULL ≠ NULL in SQL), and LEFT/FULL
> OUTER JOIN deltas should produce NULL-extended rows. The v0.18.0 NULL GROUP
> BY fix addressed aggregate grouping but the JOIN delta path’s NULL-key
> behavior is exercised only indirectly by existing tests. Add explicit
> coverage: INSERT a row with NULL join key, UPDATE it to a non-NULL key,
> DELETE it — verify each delta cycle produces correct results under both
> INNER and LEFT JOIN.

Verify: E2E tests with NULL join keys for INNER JOIN, LEFT JOIN, and FULL
JOIN — all delta cycles produce correct results matching a full recompute.
Dependencies: None. Schema change: No.

### Security

| ID | Title | Effort | Priority |
|----|-------|--------|----------|
| SEC-1 | Add ownership checks to `drop_stream_table` / `alter_stream_table` | S | P0 |
| SEC-2 | SQL injection audit for dynamic refresh SQL | XS | P1 |

**SEC-1 — Add ownership checks to `drop_stream_table` / `alter_stream_table`**

> **In plain terms:** Currently, any role with EXECUTE privilege on
> `pgtrickle.drop_stream_table()` or `pgtrickle.alter_stream_table()` can
> modify or drop **any** stream table, regardless of who created it. PostgreSQL
> convention requires that only the owner (or a superuser) can DROP or ALTER
> an object. Fix: call `pg_class_ownercheck(stream_table_oid, GetUserId())`
> (or the pgrx-safe equivalent) at the top of both functions and raise
> `ERROR: must be owner of stream table "name"` if the check fails.
> `create_stream_table` already records the creating role as the table owner
> in `pg_class`.

Verify: Non-owner role calling `pgtrickle.drop_stream_table('other_users_st')`
receives `ERROR: must be owner of stream table "other_users_st"`. Superuser
can still drop any stream table. E2E test with two roles confirms.
Dependencies: None. Schema change: No.

**SEC-2 — SQL injection audit for dynamic refresh SQL**

> **In plain terms:** The refresh engine builds SQL strings dynamically using
> `format!()` with user-provided table names, column names, and schema names.
> While pgrx’s `quote_identifier()` and `quote_literal()` are used in most
> places, a focused audit of every `format!()` call site in `refresh.rs`,
> `diff.rs`, and the `operators/` directory ensures no path allows unquoted
> user input into executable SQL. This is a review-only item — fix any
> findings immediately as P0.

Verify: Audit checklist signed off — every `format!()` that incorporates
catalog-derived names uses `quote_identifier()` or parameterised SPI queries.
Zero unquoted interpolations outside test code.
Dependencies: None. Schema change: No.

### Stability

| ID | Title | Effort | Priority |
|----|-------|--------|----------|
| STAB-1 | PgBouncer transaction-mode compatibility guard | M | P1 |
| STAB-2 | Read-replica / hot-standby safety guard | S | P1 |
| STAB-3 | Elevate Semgrep to blocking in CI | XS | P1 |
| STAB-4 | `auto_backoff` GUC — double interval after 3 falling-behind cycles | S | P2 |
| STAB-5 | Harden `unwrap()` in scheduler hot path | XS | P2 |
| STAB-6 | Parallel worker crash recovery sweep | M | P1 |
| STAB-7 | Extension version mismatch detection at load | XS | P2 |

**STAB-1 — PgBouncer transaction-mode compatibility guard**

> **In plain terms:** In PgBouncer transaction mode, session-level state is
> lost between transactions because different backend connections may serve
> the same session. pg_trickle uses transaction-scoped advisory locks which
> are safe, but also uses prepared statements and `SET LOCAL` —  both of which
> fail silently in transaction mode, causing incorrect refresh behavior. Adding
> `pg_trickle.connection_pooler_mode` GUC (`none` / `session` / `transaction`)
> and disabling prepared statements in `transaction` mode prevents silent
> misbehavior.

Verify: integration test with PgBouncer transaction mode confirms refreshes
complete correctly without prepared statement errors.
`pg_trickle.connection_pooler_mode = 'transaction'` documented in
`docs/PRE_DEPLOYMENT.md`.
Dependencies: None. Schema change: No.

**STAB-2 — Read-replica / hot-standby safety guard**

> **In plain terms:** If pg_trickle's background worker accidentally starts on
> a streaming replica (hot standby), it attempts writes to the catalog and
> crash-loops. Fix: detect `pg_is_in_recovery()` at worker startup and exit
> gracefully with `LOG: pg_trickle background worker skipped: server is in
> recovery mode.`

Verify: integration test that simulates a replica environment; background
worker exits cleanly with the correct log message. No crash loop.
Dependencies: None. Schema change: No.

**STAB-3 — Elevate Semgrep to blocking in CI**

> **In plain terms:** CodeQL and cargo-deny are already blocking in CI; Semgrep
> runs as advisory-only. Before v1.0.0, all SAST tooling should be blocking.
> Verify zero findings across all current rules, then flip the CI step from
> `continue-on-error: true` to blocking.

Verify: CI step passes in blocking mode. Zero advisory-only bypasses remain.
Dependencies: None. Schema change: No.

**STAB-4 — `auto_backoff` GUC for scheduler overload**

> **In plain terms:** EC-11 shipped the `scheduler_falling_behind` alert but
> deferred auto-remediation. When a stream table has triggered the alert for
> 3 consecutive cycles, automatically double the effective refresh interval for
> that table until the next successful on-time cycle. Prevents a single heavy
> stream table from starving the rest of the queue.

Verify: E2E test with artificially slow stream table; effective interval
doubles after 3 consecutive falling-behind alerts; returns to original
interval after catching up.
Dependencies: EC-11 `scheduler_falling_behind` (shipped in v0.18.0). Schema change: No.

**STAB-5 — Harden `unwrap()` in scheduler hot path**

> **In plain terms:** The scheduler dispatch loop in `scheduler.rs` uses
> `eu_dag.units().find(|u| u.id == uid).unwrap()` at several call sites
> (lines ~1522, ~1680, ~1751, ~1811, ~1859, ~1885). While the IDs come from
> the same DAG and are expected to always match, a stale topo-order after a
> concurrent DDL change could cause a panic inside the background worker. Fix:
> replace with `.ok_or(PgTrickleError::InternalError("unit not found in DAG"))?`
> or use the HashMap introduced by PERF-5. This eliminates the last `unwrap()`
> cluster in the scheduler hot path.

Verify: `grep -n '\.unwrap()' src/scheduler.rs` returns zero hits outside
test-only code. All scheduler integration tests pass.
Dependencies: PERF-5 (HashMap replaces `.find().unwrap()` pattern). Schema change: No.

**STAB-6 — Parallel worker crash recovery sweep**

> **In plain terms:** If a background worker is killed (OOM, SIGKILL) or
> crashes mid-refresh, it may leave behind: (a) orphaned advisory locks that
> block the next refresh of that stream table, (b) partially consumed rows in
> the change buffer (consumed but not committed), or (c) incomplete catalog
> state. Add a startup recovery sweep to the scheduler: on launch, scan for
> advisory locks held by PIDs that no longer exist (`pg_stat_activity`), roll
> back any `xact_status = 'in progress'` from dead backends, and reset
> stream tables stuck in `REFRESHING` state with no active backend.

Verify: Integration test: kill a worker PID mid-refresh via
`pg_terminate_backend()`; restart the scheduler; the affected stream table
recovers without manual intervention within one scheduler cycle.
Dependencies: None. Schema change: No.

**STAB-7 — Extension version mismatch detection at load**

> **In plain terms:** Running `ALTER EXTENSION pg_trickle UPDATE` updates
> the SQL objects but the shared library (`pg_trickle.so`) remains loaded from
> the previous version until the server is restarted. This mismatch can cause
> subtle failures (wrong function signatures, missing struct fields). Add a
> version check in `_PG_init()` that compares the compiled-in version string
> against the SQL-level `extversion` from `pg_extension`. Emit a WARNING if
> they differ and refuse to start background workers until the server is
> reloaded.

Verify: After `ALTER EXTENSION pg_trickle UPDATE` without server restart,
the extension log shows `WARNING: pg_trickle shared library version (X)
does not match installed extension version (Y) — restart PostgreSQL`.
Background workers do not start.
Dependencies: None. Schema change: No.

### Performance

| ID | Title | Effort | Priority |
|----|-------|--------|----------|
| PERF-1 | Fix WAL decoder: `old_*` columns always NULL on UPDATE | S | P1 |
| PERF-2 | Fix WAL decoder: naive `pgoutput` action string parsing | S | P1 |
| PERF-3 | `EXPLAIN (ANALYZE, BUFFERS)` surface for delta SQL in `explain_st()` | S | P2 |
| PERF-4 | Add catalog indexes on `pgt_relid` and `pgt_dependencies(pgt_id)` | XS | P1 |
| PERF-5 | Eliminate O(n²) `units().find()` in scheduler dispatch | S | P1 |
| PERF-6 | Batch `has_table_source_changes()` into single query | S | P2 |
| PERF-7 | Delta branch pruning for zero-change sources | S | P1 |
| PERF-8 | Index-aware MERGE path selection | S | P2 |

**PERF-1 — Fix WAL decoder: `old_*` columns always NULL on UPDATE**

> **In plain terms:** In WAL-based CDC (`pg_trickle.wal_enabled = true`), the
> `old_col_*` values for UPDATE rows are always NULL because the decoder reads
> `new_tuple` for both old and new field positions. This breaks R₀ snapshot
> construction for the WAL path. Fix: correctly write `old_tuple` fields to
> the `old_col_*` buffer columns for UPDATE events. Currently dormant (only
> manifests with `wal_enabled = true`).

Verify: WAL decoder integration test: `UPDATE source SET pk = new_pk`; assert
`old_col_pk IS NOT NULL` in the change buffer and equals the pre-update value.
Dependencies: None. Schema change: No.

**PERF-2 — Fix WAL decoder: naive `pgoutput` action string parsing**

> **In plain terms:** The WAL decoder parses action type with `starts_with("I")`
> which incorrectly matches any string beginning with "I" (e.g., `"INSERT"`).
> Fix: use exact single-character comparison (`== "I"`) or parse the action
> byte directly from the pgoutput message buffer. Currently dormant (only
> manifests with `wal_enabled = true`).

Verify: WAL decoder unit tests for each action type using exact-match
assertion. Fuzz test with action strings longer than 1 character.
Dependencies: None. Schema change: No.

**PERF-3 — `EXPLAIN (ANALYZE, BUFFERS)` in `explain_st()`**

> **In plain terms:** `pgtrickle.explain_st(name)` returns the delta SQL
> template without execution statistics. Adding a `with_analyze BOOLEAN`
> parameter that runs `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` on the delta
> SQL gives operators plan + actual row counts + buffer hit/miss data — making
> slow refresh diagnosis much easier.

Verify: `pgtrickle.explain_st('my_st', with_analyze => true)` returns JSONB
with `Plan`, `Actual Rows`, and `Shared Hit Blocks` fields. Documented in
`docs/SQL_REFERENCE.md`.
Dependencies: None. Schema change: No.

**PERF-4 — Add catalog indexes on `pgt_relid` and `pgt_dependencies(pgt_id)`**

> **In plain terms:** `pgt_stream_tables` has an index on `status` but not on
> `pgt_relid`, which is used in hot-path lookups (`WHERE pgt_relid = $1`) by
> DDL hooks, CDC trigger installation, and refresh dependency resolution.
> `pgt_dependencies` has an index on `source_relid` but not on `pgt_id`, which
> is used when rebuilding a single stream table's dependency set. Adding these
> two B-tree indexes eliminates sequential scans on these catalog tables at
> scale.

Verify: `\di pgtrickle.idx_pgt_relid` and `\di pgtrickle.idx_deps_pgt_id`
exist after upgrade. `EXPLAIN` of `SELECT * FROM pgtrickle.pgt_stream_tables
WHERE pgt_relid = 12345` shows Index Scan.
Dependencies: None. Schema change: Yes (upgrade SQL adds CREATE INDEX).

**PERF-5 — Eliminate O(n²) `units().find()` in scheduler dispatch**

> **In plain terms:** The scheduler dispatch loop calls
> `eu_dag.units().find(|u| u.id == uid)` inside iteration over `topo_order`
> and `ready_queue`, causing O(n²) behavior per tick. At 500+ stream tables
> this adds measurable overhead. Fix: build a `HashMap<UnitId, &Unit>` once
> per tick and replace all `.find()` lookups with O(1) map access.

Verify: Benchmark with 500 stream tables shows tick latency < 1ms (currently
~5–10ms). `grep -n 'units().find' src/scheduler.rs` returns zero hits.
Dependencies: None. Schema change: No.

**PERF-6 — Batch `has_table_source_changes()` into single query**

> **In plain terms:** `has_table_source_changes()` executes N separate
> `SELECT EXISTS(SELECT 1 FROM changes_<oid> LIMIT 1)` SPI queries — one per
> source table per stream table per scheduler tick. For a stream table with 5
> sources, this is 5 SPI round-trips. Batching into a single
> `SELECT unnest(ARRAY[oid1, oid2, ...]) AS oid WHERE EXISTS(...)` or using
> a single `UNION ALL` subquery reduces this to 1 SPI call regardless of
> source count.

Verify: SPI call count for `has_table_source_changes()` is 1 regardless of
source table count. Scheduler integration tests pass.
Dependencies: None. Schema change: No.

**PERF-7 — Delta branch pruning for zero-change sources**

> **In plain terms:** In a multi-source JOIN stream table
> (`SELECT * FROM a JOIN b ON ...`), the delta has two arms: Δ_a ⋈ b and
> a ⋈ Δ_b. If only source `a` has changes, the second arm (a ⋈ Δ_b) reads
> an empty change buffer and produces zero rows — but the engine still
> executes the full SQL including the join against `a`. Short-circuit: check
> `has_table_source_changes()` per source before building each delta arm.
> Skip arms where the source has zero changes. For a 5-source star join with
> only 1 changing source, this eliminates 4 of 5 delta arms entirely.

Verify: Benchmark with 5-source JOIN where only 1 source changes; observe
4 of 5 delta arms skipped in `explain_st()` output. Refresh latency drops
proportionally.
Dependencies: PERF-6 (batched source-change check). Schema change: No.

**PERF-8 — Index-aware MERGE path selection**

> **In plain terms:** The MERGE statement used during differential refresh
> joins the delta against the stream table on `__pgt_row_id`. If the stream
> table has a covering index on the row ID column (which pg_trickle creates
> by default), the planner should use an index nested-loop join. However,
> PostgreSQL’s cost model sometimes prefers a hash join for large deltas. Add
> a targeted `SET LOCAL enable_hashjoin = off` within the refresh transaction
> when the delta cardinality is below a configurable threshold
> (`pg_trickle.merge_index_threshold`, default 10,000 rows) to steer the
> planner toward the index path for small deltas.

Verify: `EXPLAIN` of the MERGE with delta < 10,000 rows shows Index Nested
Loop instead of Hash Join. Benchmark shows improved P99 latency for small
deltas on large stream tables.
Dependencies: None. Schema change: No.

### Scalability

| ID | Title | Effort | Priority |
|----|-------|--------|----------|
| SCAL-1 | Read replica compatibility section in `docs/SCALING.md` | S | P1 |
| SCAL-2 | Multi-database GUC stub (`pg_trickle.database_list`) | S | P2 |
| SCAL-3 | CNPG operational runbook in `docs/SCALING.md` | S | P2 |
| SCAL-4 | Partitioned source table impact assessment | M | P2 |

**SCAL-1 — Read replica compatibility documentation**

> **In plain terms:** The background worker now safely skips on replicas
> (STAB-2), but the interaction with read replicas for query offloading deserves
> its own documentation section. Add `docs/SCALING.md §Read Replicas` covering:
> which queries are safe on a replica, how `pg_is_in_recovery()` is used by
> the extension, and the recommended architecture for OLAP read-offload
> alongside pg_trickle stream tables.

Verify: `docs/SCALING.md` has a dedicated replica section.
Dependencies: STAB-2. Schema change: No.

**SCAL-2 — Multi-database GUC stub**

> **In plain terms:** Post-1.0 multi-database support requires catalog changes.
> This item adds only the `pg_trickle.database_list TEXT` GUC declaration with
> a default of `''` (current database only) and a startup WARNING if set. This
> reserves the configuration namespace and lets operators test GUC surface
> before the full feature ships.

Verify: `SHOW pg_trickle.database_list` returns `''`. Setting a non-empty
value emits a WARNING: "pg_trickle.database_list is not yet implemented."
Dependencies: None. Schema change: No.

**SCAL-3 — CNPG operational runbook in `docs/SCALING.md`**

> **In plain terms:** The CNPG (CloudNativePG) smoke test in CI validates that
> pg_trickle loads and functions on a CNPG-managed cluster, but the operational
> patterns are not documented. Add a §CNPG / Kubernetes section to
> `docs/SCALING.md` covering: `cluster-example.yaml` annotations for loading
> the extension, pod restart behavior when the background worker crashes, WAL
> volume sizing for CDC, recommended `shared_preload_libraries` configuration,
> and health check integration with Kubernetes liveness/readiness probes.

Verify: `docs/SCALING.md` has a CNPG/Kubernetes section. Content reviewed
against actual CNPG deployment behavior.
Dependencies: None. Schema change: No.

**SCAL-4 — Partitioned source table impact assessment**

> **In plain terms:** Stream tables backed by partitioned source tables
> (inheritance or declarative partitioning) are untested and likely broken:
> CDC triggers may be installed only on the parent, change buffers may miss
> partition-routed inserts, and `ALTER TABLE ... ATTACH/DETACH PARTITION` DDL
> events are unhandled. This item is a time-boxed spike (2 days): create a
> partitioned source, attach a stream table, run INSERT/UPDATE/DELETE through
> various partitions, and document what works, what breaks, and what the fix
> scope is. Output: a `plans/PLAN_PARTITIONING_SPIKE.md` update.

Verify: Spike report documents concrete findings. At minimum: which operations
work, which fail, and a rough estimate for full partitioning support.
Dependencies: None. Schema change: No.

### Ease of Use

| ID | Title | Effort | Priority |
|----|-------|--------|----------|
| UX-1 | PGXN `release_status` → `"stable"` | XS | P1 |
| UX-2 | Automated Docker Hub release pipeline | S | P1 |
| UX-3 | apt/rpm packaging via PGDG | M | P1 |
| UX-4 | Connection pooler compatibility guide in `docs/PRE_DEPLOYMENT.md` | S | P1 |
| UX-5 | `pgtrickle.write_and_refresh(dml_sql TEXT, st_name TEXT)` | S | P2 |
| UX-6 | Change `drop_stream_table` cascade default to `false` | XS | P1 |
| UX-7 | Resolve OIDs to table names in error messages | S | P1 |
| UX-8 | Emit NOTICE when `refresh_stream_table` is skipped | XS | P1 |
| UX-9 | Fix CONFIGURATION.md TOC gaps for 3 undocumented GUCs | XS | P2 |
| UX-10 | TUI per-table refresh latency sparkline | S | P2 |
| UX-11 | `pgtrickle.version()` diagnostic function | XS | P2 |

**UX-1 — PGXN `release_status` → `"stable"`**

> **In plain terms:** pg_trickle's `META.json` uses `release_status: "testing"`.
> Flipping to `"stable"` signals production-readiness, enabling the extension
> to appear in the main PGXN package listing and in downstream package managers
> that consume the PGXN stable feed. One field change in `META.json`.

Verify: `META.json` `"release_status": "stable"`. Published PGXN listing
reflects the change after the next PGXN sync.
Dependencies: None. Schema change: No.

**UX-2 — Automated Docker Hub release pipeline**

> **In plain terms:** Automate publishing `pgtrickle/pg_trickle:<ver>-pg18`
> and `pgtrickle/pg_trickle:latest` on every tagged release. Wire the existing
> `Dockerfile.hub` into the GitHub Actions release workflow via
> `docker/build-push-action`. The `latest` tag tracks the highest
> non-prerelease version.

Verify: After a test release tag, Docker Hub shows the correct image.
`docker pull pgtrickle/pg_trickle:0.19.0-pg18` succeeds and passes the
smoke test.
Dependencies: `Dockerfile.hub` (already exists). Schema change: No.

**UX-3 — apt/rpm packaging via PGDG**

> **In plain terms:** PostgreSQL users install extensions via
> `apt install postgresql-18-pg-trickle` or `dnf install pg_trickle_18`.
> Submit package specs to `pgrpms.org` (rpm) and the PGDG apt repository (deb).
> Generate packages from the GitHub release tarball. This is the most impactful
> distribution improvement possible.

Verify: `apt install postgresql-18-pg-trickle` works on Ubuntu 24.04.
`dnf install pg_trickle_18` works on RHEL 9. Both pass `verify_install.sql`.
Dependencies: None. Schema change: No.

**UX-4 — Connection pooler compatibility guide**

> **In plain terms:** Add a dedicated section to `docs/PRE_DEPLOYMENT.md`
> covering: PgBouncer session mode (fully compatible), PgBouncer transaction
> mode (set `pg_trickle.connection_pooler_mode = 'transaction'`), pgpool-II
> (session mode only), PgCat (session mode only). Include a compatibility
> matrix and `postgresql.conf` + PgBouncer config snippets.

Verify: PRE_DEPLOYMENT.md pooler section reviewed by a DBA familiar with
PgBouncer. All described modes are tested or explicitly marked "untested."
Dependencies: STAB-1. Schema change: No.

**UX-5 — `pgtrickle.write_and_refresh()` convenience function**

> **In plain terms:** In DIFFERENTIAL mode, a write followed by
> `refresh_stream_table()` requires two API calls. A single function that
> executes the DML and triggers a refresh atomically simplifies
> read-your-writes patterns for applications that need immediate consistency
> without the overhead of IMMEDIATE mode.

Verify: `SELECT pgtrickle.write_and_refresh('INSERT INTO src VALUES (1)', 'my_st')`
executes the INSERT and refreshes the stream table. Documented in
`docs/SQL_REFERENCE.md`.
Dependencies: None. Schema change: No.

**UX-6 — Change `drop_stream_table` cascade default to `false`**

> **In plain terms:** `pgtrickle.drop_stream_table(name, cascade)` currently
> defaults `cascade` to `true`. This violates the PostgreSQL convention where
> `DROP` defaults to `RESTRICT` and `CASCADE` must be explicit. A user calling
> `SELECT pgtrickle.drop_stream_table('my_st')` may inadvertently cascade-drop
> dependent stream tables. Fix: change the default to `false` (RESTRICT). This
> is a behavior change — existing scripts that rely on the implicit cascade
> must add `cascade => true` explicitly.

Verify: `SELECT pgtrickle.drop_stream_table('parent_st')` returns an error
when `parent_st` has dependents. `SELECT pgtrickle.drop_stream_table('parent_st',
cascade => true)` succeeds. Documented in CHANGELOG as a breaking change.
Dependencies: None. Schema change: No (function signature change only).

**UX-7 — Resolve OIDs to table names in error messages**

> **In plain terms:** `UpstreamTableDropped(u32)` and
> `UpstreamSchemaChanged(u32)` display raw PostgreSQL OIDs (e.g., `"upstream
> table dropped: OID 16384"`). Users cannot easily map OIDs to table names.
> Fix: resolve the OID to `schema.table` via `pg_class` at error-construction
> time or store the name alongside the OID. If the table is already dropped,
> fall back to `"OID <oid> (table no longer exists)"`.

Verify: `UpstreamTableDropped` error message shows `"upstream table dropped:
public.orders"` instead of raw OID. Fallback tested with a pre-dropped table.
Dependencies: None. Schema change: No.

**UX-8 — Emit NOTICE when `refresh_stream_table` is skipped**

> **In plain terms:** When `refresh_stream_table()` encounters a
> `RefreshSkipped` condition (e.g., no changes detected, another refresh
> already in progress), it currently logs at `debug1` level and returns
> success — invisible to the caller at default log levels. Fix: emit a
> PostgreSQL `NOTICE` (visible to the calling session) in addition to the
> `debug1` log, so the caller knows the refresh did not execute.

Verify: `SELECT pgtrickle.refresh_stream_table('my_st')` with no pending
changes emits `NOTICE: refresh skipped for "my_st": no changes detected`.
Visible in `psql` output.
Dependencies: None. Schema change: No.

**UX-9 — Fix CONFIGURATION.md TOC gaps**

> **In plain terms:** Three GUCs (`delta_work_mem_cap_mb`,
> `volatile_function_policy`, `unlogged_buffers`) have full documentation
> sections in `docs/CONFIGURATION.md` but are missing from the table of
> contents navigation at the top of the file. Additionally, there is a
> duplicate "Guardrails" entry in the TOC. Fix: add the missing TOC entries
> and remove the duplicate.

Verify: All `### pg_trickle.*` headings in CONFIGURATION.md have a
corresponding TOC link. No duplicate entries.
Dependencies: None. Schema change: No.

**UX-10 — TUI per-table refresh latency sparkline**

> **In plain terms:** The `pgtrickle` TUI dashboard shows each stream table’s
> current status and last refresh duration, but operators cannot see at a
> glance whether latency is trending up or down. Add a sparkline column (last
> 20 refresh latencies, ~80 chars wide) to the stream table list view. The
> data is already available in `pgt_refresh_history`; the TUI polls it on each
> tick. This makes performance degradation and recovery immediately visible
> without switching to Grafana.

Verify: TUI stream table view shows a sparkline column. Sparkline updates
after each refresh cycle. Values match `pgt_refresh_history` entries.
Dependencies: None. Schema change: No.

**UX-11 — `pgtrickle.version()` diagnostic function**

> **In plain terms:** A `SELECT pgtrickle.version()` function that returns the
> installed extension version, the shared library version, and the target
> PostgreSQL major version as a composite record. This is standard practice
> for PostgreSQL extensions (cf. `postgis_full_version()`) and simplifies
> remote diagnostics — support can ask a user to run one query instead of
> checking `pg_available_extensions`, `pg_config`, and `SHOW server_version`
> separately.

Verify: `SELECT * FROM pgtrickle.version()` returns three fields:
`extension_version`, `library_version`, `pg_major_version`. Values match the
installed state.
Dependencies: None. Schema change: No.

### Test Coverage

| ID | Title | Effort | Priority |
|----|-------|--------|----------|
| TEST-1 | E2E tests for CORR-2 (JOIN delta R₀ fix) | S | P1 |
| TEST-2 | E2E tests for DDL tracking gaps (CORR-3 / CORR-4) | S | P1 |
| TEST-3 | WAL decoder unit tests for PERF-1 / PERF-2 | S | P1 |
| TEST-4 | PgBouncer transaction-mode integration smoke test | M | P1 |
| TEST-5 | Read-replica guard integration test | S | P1 |
| TEST-6 | Ownership-check privilege tests for SEC-1 | S | P1 |
| TEST-7 | Scheduler dispatch benchmark (500+ STs) | S | P1 |
| TEST-8 | Upgrade E2E tests (`e2e_migration_tests.rs`) | M | P1 |
| TEST-9 | Extract unit-testable logic from E2E-only paths | M | P1 |
| TEST-10 | TPC-H scale factor coverage (SF-1, SF-10) | S | P2 |

**TEST-1 — E2E tests for CORR-2 (JOIN delta R₀ fix)**

> **In plain terms:** The co-delete scenario (UPDATE join key + DELETE join
> partner in same cycle) is currently untested. Add three E2E tests:
> (a) simultaneous key change + right-side delete; (b) UPDATE key + DELETE
> multiple right-side rows; (c) multi-cycle correctness after the scenario.

Verify: 3 E2E tests in `e2e_join_tests.rs`. All pass; intermediate full
refresh not required for correctness.
Dependencies: CORR-2. Schema change: No.

**TEST-2 — E2E tests for DDL tracking (CORR-3 / CORR-4)**

> **In plain terms:** Add E2E tests verifying that `ALTER TYPE`, `ALTER DOMAIN`,
> and `ALTER POLICY` DDL events correctly trigger stream table invalidation.

Verify: 3 E2E tests (one per DDL type). Stream table state after reinit is
correct.
Dependencies: CORR-3, CORR-4. Schema change: No.

**TEST-3 — WAL decoder unit tests**

> **In plain terms:** Add WAL decoder unit tests that explicitly enable
> `wal_enabled = true` and verify: (a) `old_col_*` values are non-NULL for
> UPDATE rows; (b) `pk_hash` is non-zero for keyless tables; (c) action string
> parsing uses exact comparison.

Verify: 5+ unit tests in `tests/wal_decoder_tests.rs` using Testcontainers
with WAL mode enabled.
Dependencies: PERF-1, PERF-2. Schema change: No.

**TEST-4 — PgBouncer transaction-mode smoke test**

> **In plain terms:** Start PgBouncer in transaction mode via Testcontainers,
> connect pg_trickle through it, and run a basic refresh cycle. Verifies
> `connection_pooler_mode = 'transaction'` correctly disables prepared
> statements and refreshes complete without errors.

Verify: integration test passes with PgBouncer transaction mode container.
Dependencies: STAB-1. Schema change: No.

**TEST-5 — Read-replica guard integration test**

> **In plain terms:** Start a streaming replica via Testcontainers, install
> pg_trickle on the replica, and verify the background worker exits cleanly
> with the correct log message rather than crash-looping.

Verify: worker log contains "pg_trickle background worker skipped: server is
in recovery mode." No ERROR or FATAL in replica logs.
Dependencies: STAB-2. Schema change: No.

**TEST-6 — Ownership-check privilege tests for SEC-1**

> **In plain terms:** Add E2E tests with two PostgreSQL roles: role A creates
> a stream table, role B (non-superuser, non-owner) attempts to drop and alter
> it. Verify that role B receives `ERROR: must be owner of stream table`. Also
> verify that a superuser can drop/alter any stream table regardless of
> ownership.

Verify: 3 E2E tests (non-owner drop, non-owner alter, superuser override).
Dependencies: SEC-1. Schema change: No.

**TEST-7 — Scheduler dispatch benchmark (500+ STs)**

> **In plain terms:** Add a Criterion benchmark that creates a mock DAG with
> 500+ stream tables and measures per-tick dispatch latency. This gates
> PERF-5 (HashMap optimization) and provides a regression baseline for future
> scheduler changes. The benchmark should run in the existing `benches/`
> framework.

Verify: `cargo bench --bench scheduler_bench` runs and reports P50/P99 tick
latency. Baseline saved for Criterion regression gate.
Dependencies: PERF-5. Schema change: No.

**TEST-8 — Upgrade E2E tests (`e2e_migration_tests.rs`)**

> **In plain terms:** The upgrade path from 0.18.0 → 0.19.0 is currently
> tested only by verifying `ALTER EXTENSION pg_trickle UPDATE` runs without
> error. There are no tests that verify (a) existing stream tables continue to
> function after upgrade, (b) the new catalog schema items (DB-2 FK, DB-3
> version table, DB-5 history retention) are present and correct, or (c)
> stream table data is preserved. Add a Testcontainers-based upgrade E2E test.

Verify: `tests/e2e_migration_tests.rs` tests: fresh install, upgrade from
previous version with populated stream tables, catalog integrity check,
post-upgrade refresh cycle. All pass.
Dependencies: DB-1, DB-2, DB-3. Schema change: No (tests existing schema).

**TEST-9 — Extract unit-testable logic from E2E-only paths**

> **In plain terms:** Several core functions in `refresh.rs` and `scheduler.rs`
> are currently exercised only through end-to-end tests that require a
> PostgreSQL container. Extracting pure logic from SPI-dependent code and
> adding direct unit tests makes regressions detectable in seconds instead of
> minutes. Target: identify 5+ functions (refresh strategy selection, delta
> cardinality estimation, backoff calculation, topo-sort cycle detection, merge
> strategy costing) that operate on plain Rust data structures and can be
> tested with `#[cfg(test)]` modules.

Verify: 5+ new `#[cfg(test)]` unit tests in `src/refresh.rs` or
`src/scheduler.rs`. `just test-unit` runs them in < 5 seconds.
Dependencies: None. Schema change: No.

**TEST-10 — TPC-H scale factor coverage (SF-1, SF-10)**

> **In plain terms:** The v0.18.0 TPC-H regression guard runs all 22 queries
> at a single scale factor. Real-world correctness bugs sometimes only
> manifest at higher cardinalities where hash collisions, sort spill, and
> parallel execution change the code path. Add nightly runs at SF-1 (6M rows)
> and SF-10 (60M rows) alongside the existing default. The SF-10 run doubles
> as a performance soak test — flag any query whose refresh time regresses by
> more than 20% compared to the previous nightly.

Verify: CI nightly job runs TPC-H at SF-1 and SF-10. All 22 queries produce
correct results at both scales. SF-10 timing baseline saved for regression
detection.
Dependencies: None. Schema change: No.

### Schema Stability

| ID | Title | Effort | Priority |
|----|-------|--------|----------|
| DB-1 | Fix duplicate `'DIFFERENTIAL'` in two CHECK constraints | XS | P0 |
| DB-2 | Add `ON DELETE CASCADE` FK on `pgt_refresh_history.pgt_id` | XS | P0 |
| DB-3 | Add `pgtrickle.pgt_schema_version` version tracking table | XS | P0 |
| DB-4 | Rename `pgtrickle_refresh` NOTIFY channel → `pg_trickle_refresh` | XS | P0 |
| DB-5 | `pg_trickle.history_retention_days` GUC + scheduler daily cleanup | S | P1 |
| DB-6 | Document public API stability contract in `docs/SQL_REFERENCE.md` | XS | P1 |
| DB-7 | Add migration script template to `sql/` | XS | P1 |
| DB-8 | Validate orphan cleanup in `drop_stream_table` | XS | P1 |
| DB-9 | `pgtrickle.migrate()` utility function | S | P2 |

**DB-1 — Fix duplicate `'DIFFERENTIAL'` in CHECK constraints**

> **In plain terms:** Both `pgt_stream_tables.refresh_mode` and
> `pgt_refresh_history.action` have `'DIFFERENTIAL'` listed twice in their
> CHECK constraints. While logically harmless, it signals sloppiness and
> produces confusing output in dumps. Both from `REPORT_DB_SCHEMA_STABILITY.md §3.1`.

Verify: `\d+ pgtrickle.pgt_stream_tables` and `\d+ pgtrickle.pgt_refresh_history`
show their CHECK constraints with no duplicate values.
Dependencies: None. Schema change: Yes (upgrade SQL drops/recreates constraints).

**DB-2 — Add `ON DELETE CASCADE` FK on `pgt_refresh_history.pgt_id`**

> **In plain terms:** `pgt_refresh_history.pgt_id` references
> `pgt_stream_tables.pgt_id` logically but has no formal FK. When a stream
> table is dropped, orphan history rows accumulate indefinitely. Adding
> `FOREIGN KEY (pgt_id) REFERENCES pgtrickle.pgt_stream_tables(pgt_id)
> ON DELETE CASCADE` cleans up automatically.

Verify: Drop a stream table; `SELECT count(*) FROM pgtrickle.pgt_refresh_history
WHERE pgt_id = <dropped_id>` returns 0.
Dependencies: None. Schema change: Yes.

**DB-3 — Add `pgtrickle.pgt_schema_version` version tracking table**

> **In plain terms:** There is currently no way for migration scripts to
> verify which schema version is installed before applying changes. Add a
> `pgt_schema_version(version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ,
> description TEXT)` table seeded with the current version. Every future
> migration script will check this table and insert its target version.

Verify: `SELECT version FROM pgtrickle.pgt_schema_version ORDER BY applied_at DESC
LIMIT 1` returns the current extension version after upgrade.
Dependencies: None. Schema change: Yes.

**DB-4 — Rename `pgtrickle_refresh` NOTIFY channel → `pg_trickle_refresh`**

> **In plain terms:** Two existing NOTIFY channels use `pg_trickle_*` naming
> (`pg_trickle_alert`, `pg_trickle_cdc_transition`). The third uses
> inconsistent `pgtrickle_refresh` (no separator). Rename before 1.0 while
> still pre-1.0. Any external `LISTEN pgtrickle_refresh` in application code
> must be updated. Document as a breaking change in CHANGELOG.

Verify: `LISTEN pg_trickle_refresh` receives notifications on refresh events.
`LISTEN pgtrickle_refresh` receives none.
Dependencies: None. Schema change: No (code change only).

**DB-5 — `pg_trickle.history_retention_days` GUC + scheduler cleanup**

> **In plain terms:** `pgt_refresh_history` has no retention policy.
> Production deployments running daily refreshes on 100+ stream tables will
> accumulate millions of rows within months. Add a GUC (default: 30 days)
> and a daily cleanup step in the scheduler: `DELETE FROM
> pgtrickle.pgt_refresh_history WHERE start_time < now() - make_interval(...)`.

Verify: `SET pg_trickle.history_retention_days = 1` and run the cleanup;
rows older than 1 day are removed. Default retains 30 days.
Dependencies: None. Schema change: No (new GUC + cleanup logic only).

**DB-6 — Document public API stability contract**

> **In plain terms:** The stability contract defined in
> `REPORT_DB_SCHEMA_STABILITY.md §5` (Tier 1/2/3 surfaces) is not yet
> published anywhere users can find it. Add a "Stability Guarantees" section
> to `docs/SQL_REFERENCE.md` covering: which function signatures are stable,
> which view columns can be added without a major version, and which internal
> objects may change with migration scripts.

Verify: `docs/SQL_REFERENCE.md` has a §Stability Guarantees section linked
from the TOC.
Dependencies: None. Schema change: No.

**DB-7 — Add migration script template to `sql/`**

> **In plain terms:** The `sql/pg_trickle--0.18.0--0.19.0.sql` file is
> currently empty (stub). Populate it with: (a) the DB-1 CHECK constraint
> fixes, (b) the DB-2 FK addition, (c) the DB-3 schema version table
> creation, and (d) the DB-4 NOTIFY channel rename notice. Also create a
> reusable migration script template comment header for future versions.

Verify: `ALTER EXTENSION pg_trickle UPDATE` on a 0.18.0 instance applies
all schema changes correctly. `check_upgrade_completeness.sh` passes.
Dependencies: DB-1, DB-2, DB-3, DB-4. Schema change: Yes (this IS the migration script).

**DB-8 — Validate orphan cleanup in `drop_stream_table`**

> **In plain terms:** When a stream table is dropped, `pgt_change_tracking`
> rows with the dropped `pgt_id` in `tracked_by_pgt_ids` (a `BIGINT[]`
> column) may not be cleaned up if the array contains other IDs. Add an
> explicit sweep: remove the dropped `pgt_id` from all `tracked_by_pgt_ids`
> arrays; delete rows where the array becomes empty.

Verify: Create a shared-source ST pair, drop one; `SELECT * FROM
pgtrickle.pgt_change_tracking` shows correct state.
Dependencies: None. Schema change: No.

**DB-9 — `pgtrickle.migrate()` utility function**

> **In plain terms:** Add a `pgtrickle.migrate()` SQL function that iterates
> over all registered stream tables and applies any pending dynamic object
> migrations (change buffer schema updates, CDC trigger function regeneration).
> This is called automatically at the end of `ALTER EXTENSION UPDATE` and can
> also be called manually after an upgrade to repair STs that were being
> refreshed during the upgrade window.

Verify: `SELECT pgtrickle.migrate()` completes without error on a fresh
install and after a version upgrade. Returns a summary of migrated objects.
Dependencies: DB-3 (uses schema version to determine needed migrations). Schema change: No.

> **v0.19.0 total: ~4–5 weeks**

**Exit criteria:**
- [x] CORR-1: `delete_insert` strategy removed; `ERROR` raised on old GUC value
- [x] CORR-2: JOIN delta R₀ fix: `UPDATE key + DELETE partner` in same cycle produces correct stream table result
- [x] CORR-3: `ALTER TYPE` / `ALTER DOMAIN` DDL events trigger stream table invalidation
- [x] CORR-4: `ALTER POLICY` DDL events trigger stream table invalidation
- [x] CORR-5: Keyless content-hash collision test passes with two identical-content rows
- [x] CORR-6: Zero `.unwrap()` in `src/dvm/operators/` outside test modules
- [x] SEC-1: Non-owner `drop_stream_table`/`alter_stream_table` raises `ERROR: must be owner`
- [x] STAB-1: `pg_trickle.connection_pooler_mode` GUC added; transaction mode disables prepared statements
- [x] STAB-2: Background worker exits cleanly on hot standby with correct log message
- [x] STAB-3: Semgrep elevated to blocking; zero findings verified
- [x] STAB-4: `auto_backoff` GUC: interval doubles after 3 consecutive falling-behind alerts
- [x] STAB-5: Zero `.unwrap()` in scheduler hot path outside test modules
- [x] PERF-1: WAL decoder writes correct `old_col_*` values for UPDATE rows
- [x] PERF-2: WAL decoder uses exact action string comparison
- [x] PERF-4: Catalog indexes on `pgt_relid` and `pgt_dependencies(pgt_id)` exist after upgrade
- [x] PERF-5: Zero `units().find()` in scheduler; HashMap-based O(1) lookup
- [x] PERF-6: `has_table_source_changes()` executes single SPI query regardless of source count
- [x] SCAL-1: `docs/SCALING.md` replica section added
- [x] UX-1: `META.json` `release_status` → `"stable"`; PGXN listing updated
- [x] UX-2: Docker Hub release automation wired in GitHub Actions
- [ ] UX-3: apt/rpm packages available via PGDG
- [x] UX-4: `docs/PRE_DEPLOYMENT.md` connection pooler compatibility guide added
- [x] UX-6: `drop_stream_table` defaults to `cascade => false`
- [x] UX-7: `UpstreamTableDropped`/`UpstreamSchemaChanged` show table name instead of raw OID
- [x] UX-8: `refresh_stream_table` emits NOTICE when refresh is skipped
- [x] UX-9: CONFIGURATION.md TOC complete; no duplicate entries
- [x] TEST-1: 3 JOIN delta R₀ E2E tests pass
- [x] TEST-2: 3 DDL tracking E2E tests pass
- [x] TEST-3: 5+ WAL decoder unit tests pass with `wal_enabled = true`
- [x] TEST-4: PgBouncer transaction-mode integration test passes
- [x] TEST-5: Read-replica guard integration test passes
- [x] TEST-6: 3 ownership-check privilege E2E tests pass
- [x] TEST-7: Scheduler dispatch benchmark baseline saved
- [x] TEST-8: Upgrade E2E tests pass (pre- and post-upgrade stream table correctness)
- [x] DB-1: No duplicate `'DIFFERENTIAL'` in CHECK constraints
- [x] DB-2: `pgt_refresh_history.pgt_id` FK with `ON DELETE CASCADE` added
- [x] DB-3: `pgtrickle.pgt_schema_version` table present and seeded
- [x] DB-4: `pgtrickle_refresh` channel renamed to `pg_trickle_refresh`
- [x] DB-5: `pg_trickle.history_retention_days` GUC active; daily cleanup deletes old rows
- [x] DB-6: `docs/SQL_REFERENCE.md` stability contract section published
- [x] DB-7: `sql/pg_trickle--0.18.0--0.19.0.sql` applies DB-1 through DB-4 changes
- [x] DB-8: `drop_stream_table` leaves no orphan rows in `pgt_change_tracking`
- [x] CORR-7: TRUNCATE + INSERT in same transaction — stream table correct after refresh
- [x] CORR-8: NULL join-key delta correct for INNER, LEFT, and FULL JOIN
- [x] SEC-2: SQL injection audit complete — zero unquoted interpolations in refresh SQL
- [x] STAB-6: Worker crash recovery sweep cleans orphaned locks and stuck REFRESHING state
- [x] STAB-7: Version mismatch WARNING emitted after `ALTER EXTENSION` without restart
- [x] PERF-7: Delta branch pruning skips zero-change source arms in multi-JOIN
- [x] PERF-8: Index-aware MERGE uses nested loop for small deltas on indexed tables
- [x] SCAL-3: `docs/SCALING.md` CNPG/Kubernetes section published
- [x] SCAL-4: Partitioning spike report written with concrete findings
- [x] UX-10: TUI sparkline column visible for refresh latency trend
- [x] UX-11: `pgtrickle.version()` returns extension, library, and PG versions
- [x] TEST-9: 5+ unit tests extracted from E2E-only refresh/scheduler logic
- [x] TEST-10: TPC-H nightly runs at SF-1 and SF-10 with correct results
- [ ] Extension upgrade path tested (`0.18.0 → 0.19.0`)
- [ ] `just check-version-sync` passes

### Conflicts & Risks

1. **CORR-1 is a user-visible breaking change.** Any deployment with
   `merge_join_strategy = 'delete_insert'` in `postgresql.conf` will error
   at startup after upgrade. Requires a prominent CHANGELOG entry and a
   NOTICE during the upgrade migration.

2. **CORR-2 touches high-traffic diff operators.** `diff_inner_join` and
   `diff_left_join` are the most commonly used operators. Gate the merge
   behind TPC-H regression suite + TEST-1. Do not merge without both passing.

3. **STAB-1 introduces a new GUC.** The `pg_trickle.connection_pooler_mode`
   GUC must be mirrored in upgrade migration SQL, `CONFIGURATION.md`, and
   `check-version-sync` validation.

4. **PERF-1/PERF-2 are currently dormant.** Changes to `wal_decoder.rs`
   must be tested with `wal_enabled = true` explicitly. The default
   trigger-based CDC is unaffected — keep WAL tests behind an explicit
   env var to avoid slowing down the default test run.

5. **UX-3 (apt/rpm packaging)** depends on PGDG maintainer availability
   (~8–12h) and can be cut without impacting correctness if it risks
   delaying the release.

6. **SEC-1 changes privilege semantics.** Existing deployments where
   non-owner roles call `drop_stream_table` or `alter_stream_table` will
   break. Requires a CHANGELOG entry and, optionally, a
   `pg_trickle.skip_ownership_check` GUC (default `false`) for a transition
   period.

7. **UX-6 changes the cascade default.** Scripts relying on implicit
   `cascade => true` will silently change behavior — DROP will error instead
   of cascading. Ship alongside SEC-1 and document both breaking changes
   together.

8. **PERF-4 requires upgrade SQL.** The two `CREATE INDEX` statements must
   be added to `sql/pg_trickle--0.18.0--0.19.0.sql`. Index creation on a
   busy system may briefly lock the catalog tables (millisecond-range for
   small catalogs; document in upgrade notes).

9. **DB-4 renames the `pgtrickle_refresh` NOTIFY channel.** Any application
   code using `LISTEN pgtrickle_refresh` will stop receiving notifications
   after upgrade. The old channel name ceases to exist. Document prominently
   in CHANGELOG and UPGRADING.md.

10. **DB-2 adds a CASCADE FK.** If any external tooling holds open
    transactions when a stream table is dropped, the cascade may fail under
    lock. Test in upgrade E2E (TEST-8) before shipping.

11. **STAB-6 touches the scheduler startup path.** A bug in the recovery
    sweep could incorrectly reset a stream table that is still being
    refreshed on a live backend. The sweep must verify that the PID is truly
    dead via `pg_stat_activity` before taking corrective action.

12. **PERF-8 disables `hashjoin` within the refresh transaction.** If the
    threshold is set too high, large deltas will use a slower nested-loop
    path. Make the `merge_index_threshold` GUC tunable and document clearly
    that it only affects the MERGE step, not the delta SQL.

13. **SCAL-4 (partitioning spike) may uncover scope too large for v0.19.0.**
    If the spike reveals that full partitioning support requires CDC
    architectural changes, defer the implementation to a later release and
    document findings in the spike report.


---

