Contents

Plan: Architecture Decision Records

Date: 2026-02-24 Status: PROPOSED Last Updated: 2026-02-25


Overview

This plan proposes a comprehensive set of Architecture Decision Records (ADRs) for pg_trickle — covering both decisions already made during development and forward-looking decisions needed to achieve full PostgreSQL and SQL feature coverage.

The goal is to eventually support all relevant PostgreSQL and SQL features in both FULL and DIFFERENTIAL refresh modes. Each ADR documents the reasoning behind a significant technical choice, including alternatives considered and consequences.

ADR Format

Each ADR follows a standard template:

# ADR-NNN: <Title>

| Field         | Value                   |
|---------------|-------------------------|
| **Status**    | Accepted / Superseded / Proposed / Not Started |
| **Date**      | YYYY-MM-DD              |
| **Deciders**  | pg_trickle core team     |
| **Category**  | <area>                  |

## Context
## Decision
## Options Considered
## Consequences
## References

Numbering Convention

  • ADR-001–009: Core architecture (CDC, IVM engine, storage)
  • ADR-010–019: API & schema design
  • ADR-020–029: Scheduling & runtime
  • ADR-030–039: Tooling, testing, ecosystem
  • ADR-040–049: Performance & optimization
  • ADR-050–059: SQL feature coverage & operator design
  • ADR-060–069: PostgreSQL integration & compatibility
  • ADR-070–079: Correctness & safety guarantees

Part 1: Decisions Already Made

These ADRs document technical choices that have been implemented. The decisions are settled; the ADR documents capture the rationale so future contributors understand the “why.”

ADR-001: Row-Level Triggers as Default CDC Mechanism

Field Value
Status Accepted
Category CDC
Sources plans/sql/REPORT_TRIGGERS_VS_REPLICATION.md

Decision: Use row-level AFTER triggers as the default change-data-capture mechanism, avoiding the pg_create_logical_replication_slot() write-context restriction that prevents slot creation inside DDL transactions.

Key points: - Triggers can be created in the same transaction as CREATE TABLE — atomic stream table creation - No wal_level = logical requirement for basic operation - Changes are visible in buffer tables as soon as the source transaction commits - Trade-off: write-side overhead (~5-15% per DML on tracked tables)


ADR-002: Hybrid CDC — Trigger Bootstrap with WAL Steady-State

Field Value
Status Accepted
Category CDC
Sources plans/sql/PLAN_HYBRID_CDC.md, plans/sql/REPORT_TRIGGERS_VS_REPLICATION.md

Decision: After ADR-001 chose triggers as default, implement a hybrid approach: use triggers at creation time (zero-config, atomic), then transparently transition to logical replication for steady-state if wal_level = logical.

Key points: - Three CDC states: TRIGGER → TRANSITIONING → WAL - No-data-loss transition (trigger stays active until WAL catches up) - Graceful fallback if slot creation fails or WAL decoder doesn’t catch up within pg_trickle.wal_transition_timeout - Same buffer table schema regardless of CDC mode - pg_trickle.cdc_mode GUC for user control (auto/trigger/wal)


ADR-003: Query Differentiation via Operator Tree (DVM Engine Design)

Field Value
Status Accepted
Category IVM Engine
Sources plans/PLAN.md Phase 6, docs/DVM_OPERATORS.md, docs/ARCHITECTURE.md

Decision: Implement incremental view maintenance by parsing the defining query into an operator tree (OpTree) and applying per-operator differentiation rules (analogous to automatic differentiation in calculus) to generate delta SQL.

Alternatives considered: - Full recomputation only (simple but O(n) always) - Log-based delta replay (simpler operators, less SQL coverage) - DBSP-style Z-sets with explicit multiplicity tracking - pg_ivm’s approach (limited to single-table aggregates at the time)

Key points: - 21 OpTree variants: Scan, Filter, Project, InnerJoin, LeftJoin, FullJoin, Aggregate, Distinct, UnionAll, Intersect, Except, Subquery, CteScan, RecursiveCte, Window, LateralFunction, LateralSubquery, SemiJoin, AntiJoin, ScalarSubquery (+more planned) - Delta SQL is generated as CTEs, not materialized intermediates - Row identity via __pgt_row_id (xxHash) for diff-based delta application - Theoretical basis: DBSP (Budiu et al. 2023), Gupta & Mumick (1995)


ADR-004: xxHash Row IDs Instead of UUIDs

Field Value
Status Accepted
Category Storage / IVM Engine
Sources plans/PLAN.md Key Design Decisions, src/hash.rs, src/dvm/row_id.rs

Decision: Use 64-bit xxHash of the primary key as the __pgt_row_id column (stored as BIGINT) rather than UUIDs or composite-key matching.

Alternatives considered: - UUID v4 (128-bit, zero collision, 16 bytes per row) - Composite primary key matching (no extra column, but complex MERGE logic) - MD5/SHA hash (cryptographically stronger but slower)

Key points: - 8 bytes vs 16 bytes per row (significant at scale) - Collision probability: ~1 in 264 per unique key — acceptable for practical datasets - pg_trickle_hash() for single-column PKs, pg_trickle_hash_multi() for composites - Visible to users via SELECT * — a known tradeoff


ADR-005: Per-Table Change Buffer Tables Instead of In-Memory Queues

Field Value
Status Accepted
Category CDC / Storage
Sources plans/PLAN.md Key Design Decisions, src/cdc.rs

Decision: Store CDC changes in dedicated PostgreSQL tables (pgtrickle_changes.changes_<oid>) rather than in shared memory, message queues, or a single global changes table.

Alternatives considered: - Shared memory ring buffer (fast, but limited size, not crash-safe) - Single global changes table (simpler, but contention on high-write workloads) - External message queue (Kafka, NATS — unnecessary dependency)

Key points: - Crash-safe: survives backend/worker crashes - Queryable for debugging and monitoring - Per-table isolation avoids contention across independent source tables - Aggressive cleanup after each refresh cycle - Trade-off: extra I/O vs. durability and simplicity


ADR-006: Explicit DML for User Triggers Instead of Always-MERGE

Field Value
Status Accepted
Category Refresh Engine
Sources plans/sql/PLAN_USER_TRIGGERS_EXPLICIT_DML.md

Decision: When a stream table has user-defined triggers, decompose the MERGE into three explicit DML statements (DELETE, UPDATE, INSERT) so triggers fire with correct TG_OP, OLD, and NEW. When no user triggers exist, keep the fast single-MERGE path.

Alternatives considered: - Always use explicit DML (simpler code, but ~10-30% slower for the common case) - Always use MERGE + replay triggers after (complex, wrong TG_OP context) - Disallow user triggers on stream tables entirely

Key points: - has_user_triggers() detection at refresh time - CachedMergeTemplate extended with explicit DML templates - pg_trickle.user_triggers GUC (canonical auto / off, deprecated on alias) - FULL refresh: triggers suppressed via DISABLE TRIGGER USER + NOTIFY


ADR-007: Semi-Naive Evaluation for Recursive CTEs

Field Value
Status Accepted (Updated)
Category IVM Engine
Sources docs/DVM_OPERATORS.md, src/dvm/operators/recursive_cte.rs

Decision: Handle WITH RECURSIVE CTEs using three strategies in DIFFERENTIAL mode, selected automatically based on column compatibility and change type. FULL mode continues to execute the query as-is.

Key points: - FULL mode: query executes as-is (PostgreSQL handles recursion natively) - DIFFERENTIAL mode uses three strategies: 1. Semi-naive evaluation — INSERT-only changes: differentiate the base case, then propagate new rows through the recursive term via a nested WITH RECURSIVE 2. Delete-and-Rederive (DRed) — mixed INSERT/DELETE/UPDATE changes: insert propagation → over-deletion cascade → rederivation → combine 3. Recomputation fallback — when CTE columns ⊃ ST storage columns (column mismatch), re-execute the full query and diff against storage - Strategy selection is automatic: column match + INSERT-only → semi-naive; column match + mixed → DRed; column mismatch → recomputation - Non-linear recursion (multiple self-references in the recursive term) is rejected — PostgreSQL restricts the recursive term to reference the CTE at most once


ADR-008: Group-Rescan Strategy for Non-Algebraic Aggregates

Field Value
Status Accepted
Category IVM Engine
Sources docs/DVM_OPERATORS.md, src/dvm/operators/aggregate.rs

Decision: For aggregates that cannot be maintained algebraically (STRING_AGG, ARRAY_AGG, JSON_AGG, BOOL_AND/OR, statistical functions, etc.), use a NULL-sentinel approach: when any row in a group changes, return NULL for the aggregate value, triggering re-aggregation from source data.

Key points: - Algebraic: COUNT, SUM, AVG (maintained via auxiliary counters — O(1) per change) - Semi-algebraic: MIN, MAX (O(1) for non-extremum changes, rescan on extremum deletion) - Group-rescan: 17+ aggregates (STRING_AGG, ARRAY_AGG, JSON_AGG, BOOL_AND/OR, BIT_AND/OR/XOR, STDDEV/VAR, MODE, PERCENTILE_CONT/DISC, etc.) - Group-rescan is correct and handles arbitrary aggregates; trade-off is O(group) per affected group - Unified pattern: adding new group-rescan aggregates is a copy-paste exercise


ADR-010: SQL Functions Instead of DDL Syntax

Field Value
Status Accepted
Category API Design
Sources plans/PLAN.md Key Design Decisions

Decision: Expose the API as SQL functions (pgtrickle.create_stream_table(), etc.) rather than custom DDL syntax (CREATE STREAM TABLE ...).

Alternatives considered: - Custom DDL via PostgreSQL parser hooks or grammar extension - Foreign Data Wrapper interface - Hook-based interception of CREATE MATERIALIZED VIEW

Key points: - Works without PostgreSQL parser modifications - Clean extension boundary — standard CREATE EXTENSION installation - Idiomatic PostgreSQL extension pattern - Trade-off: less “native” feel, no \d-style psql integration


ADR-011: pgtrickle Schema with pgt_ Prefix Convention

Field Value
Status Accepted
Category API Design / Naming
Sources Code history (dt → st → pgt_ rename across 72 files)

Decision: All internal catalog objects use the pgtrickle schema and pgt_ column/table prefix. Change buffers live in a separate pgtrickle_changes schema.

Key points: - Original naming used dt_ (derived table), renamed to st_ (stream table), then to pgt_ (pg_trickle) for global uniqueness and consistency - Two schemas: pgtrickle (API + catalog) and pgtrickle_changes (buffer tables) - pgt_ prefix avoids collisions with user objects


ADR-012: PostgreSQL 18 as Sole Target

Field Value
Status Accepted
Category API Design / Platform
Sources plans/PLAN.md Key Design Decisions

Decision: Target PostgreSQL 18 exclusively. No backward compatibility with PG 16 or PG 17.

Alternatives considered: - Multi-version support via conditional compilation (broader adoption, higher maintenance) - Target PG 17 as minimum (more users, but miss PG 18 features)

Key points: - PG 18 features used: custom cumulative statistics, improved logical replication, DSM improvements - Narrows user base but simplifies development and testing - pgrx 0.17.x provides PG 18 support


ADR-020: Canonical Scheduling Periods (48·2ⁿ Seconds)

Field Value
Status Accepted
Category Scheduling
Sources plans/PLAN.md Key Design Decisions, src/scheduler.rs

Decision: Use a discrete set of canonical refresh periods (48, 96, 192, … seconds) rather than arbitrary user-specified intervals.

Key points: - Guarantees data_timestamp alignment across stream tables with different schedules in the same DAG - User-specified schedule is snapped to the nearest (smaller) canonical period - NULL schedule = DOWNSTREAM (refresh only when triggered by a dependent) - Advisory locks prevent concurrent refreshes of the same ST


ADR-021: Single Background Worker Scheduler

Field Value
Status Accepted
Category Scheduling / Runtime
Sources src/scheduler.rs, src/shmem.rs, docs/ARCHITECTURE.md

Decision: Use a single background worker for scheduling, with shared memory for inter-process communication (PgLwLock<PgTrickleSharedState> and PgAtomic<AtomicU64> DAG rebuild signal).

Key points: - Wakes at pg_trickle.scheduler_interval_ms intervals - Detects DAG changes via atomic counter comparison (lock-free) - Topological refresh ordering within each wake cycle - SIGTERM graceful shutdown - pg_trickle.enabled GUC to disable without unloading


ADR-022: Replication Origin for Feedback Loop Prevention

Field Value
Status Accepted
Category Refresh Engine
Sources plans/PLAN.md Key Design Decisions, src/refresh.rs

Decision: Use PostgreSQL’s replication origin mechanism (pg_trickle_refresh) to tag refresh-generated writes, preventing CDC triggers from re-capturing changes made by the refresh itself (feedback loops).

Key points: - Standard PostgreSQL mechanism (pg_replication_origin_session_setup) - Reliable filtering in the trigger function - No user-visible side effects


ADR-023: Adaptive Full-Refresh Fallback

Field Value
Status Accepted
Category Refresh Engine / Performance
Sources docs/ARCHITECTURE.md, src/refresh.rs

Decision: When the change ratio exceeds pg_trickle.differential_max_change_ratio, automatically downgrade a DIFFERENTIAL refresh to FULL, since delta processing becomes more expensive than full recomputation at high change rates.

Key points: - Benchmarks show DIFFERENTIAL is slower than FULL at ~50% change rate - Automatic switching keeps the default experience fast - Per-stream-table auto_threshold in catalog allows tuning - last_full_ms tracks full-refresh cost for adaptive comparison


ADR-030: dbt Integration via Macro Package (Not Custom Adapter)

Field Value
Status Accepted
Category Ecosystem / Tooling
Sources plans/dbt/PLAN_DBT_MACRO.md, plans/dbt/PLAN_DBT_ADAPTER.md

Decision: Integrate with dbt via a Jinja macro package with a custom stream_table materialization, using the standard dbt-postgres adapter. Defer the full custom Python adapter as an upgrade path.

Key points: - ~15 hours effort (vs ~54 for adapter) - No Python code — pure Jinja SQL macros - Works with dbt Core ≥ 1.6 (for subdirectory in packages.yml) - Adapter plan exists as documented upgrade path in plans/dbt/PLAN_DBT_ADAPTER.md


ADR-031: dbt Package In-Repo (Subdirectory) Instead of Separate Repository

Field Value
Status Accepted
Category Ecosystem / Tooling
Sources plans/dbt/PLAN_DBT_MACRO.md, plans/ecosystem/PLAN_ECO_SYSTEM.md

Decision: Ship the dbt macro package as dbt-pgtrickle/ inside the main pg_trickle repository, not in a separate repo.

Key points: - SQL API changes validated against macros in the same PR (via CI) - Simpler contributor workflow — one repo, one PR - Users install via git: + subdirectory: in packages.yml - Extractable to separate repo later if needed


ADR-032: Testcontainers-Based Integration Testing

Field Value
Status Accepted
Category Testing
Sources AGENTS.md, plans/testing/STATUS_TESTING.md, tests/common/mod.rs

Decision: All integration and E2E tests use Docker containers via testcontainers-rs and a custom E2E Docker image. Tests never assume a local PostgreSQL installation.

Key points: - Custom Dockerfile.e2e builds PG 18 + pg_trickle from source - Deterministic, reproducible test environments - Three-tier test pyramid: unit (no DB) → integration (testcontainers) → E2E (full extension Docker image)


ADR-040: Aggregate Maintenance via Auxiliary Counter Columns

Field Value
Status Accepted
Category IVM Engine / Performance
Sources docs/DVM_OPERATORS.md, src/dvm/operators/aggregate.rs

Decision: Maintain algebraic aggregates incrementally by storing auxiliary counter columns alongside each aggregate result.

Key points: - COUNT(*) maintained via __pgt_count counter - SUM(x) maintained via __pgt_sum_x + __pgt_count for correctness when group shrinks to zero - AVG(x) derived from SUM/COUNT at read time - MIN/MAX uses semi-algebraic approach (CASE/LEAST/GREATEST with NULL sentinel for extremum deletion) - Hidden auxiliary columns increase storage but enable O(1) aggregate updates


ADR-041: LATERAL Diff via Row-Scoped Recomputation

Field Value
Status Accepted
Category IVM Engine
Sources plans/sql/PLAN_LATERAL_JOINS.md, src/dvm/operators/lateral_function.rs

Decision: Differentiate LATERAL subqueries (and SRFs in FROM) by row-scoped recomputation: when an outer row changes, re-execute the correlated subquery for that specific row only.

Key points: - Handles both implicit LATERAL (comma-syntax) and explicit LEFT JOIN LATERAL - Supports top-N per group, correlated aggregation, multi-column derived values - Correctness relies on re-executing the subquery in the context of the changed outer row — not on incremental maintenance of the inner query


Part 2: Forward-Looking ADRs — SQL Feature Coverage

These ADRs address decisions that have not yet been made but are needed to achieve comprehensive PostgreSQL and SQL support. They cover features currently rejected, partially supported, or not yet considered.

Current State Summary

  • 49+ of 52 original SQL gaps resolved (see plans/sql/GAP_SQL_PHASE_4.md)
  • Zero P0 (silent corruption) or P1 (incorrect semantics) issues remain
  • 25 aggregate functions in DIFFERENTIAL mode; 17 recognized-but-rejected
  • All rejected constructs have clear error messages with rewrite suggestions

ADR-050: Non-Deterministic Function Handling Strategy

Field Value
Status Not Started
Category SQL Feature / Correctness
Sources plans/sql/PLAN_NON_DETERMINISM.md
Effort Medium (3-5 sessions)

Context: Volatile functions (random(), gen_random_uuid(), clock_timestamp(), now()) break delta computation in DIFFERENTIAL mode because the DVM engine assumes expressions are deterministic. The same expression can produce different values across refreshes, causing phantom changes, missed changes, and broken row identity hashes.

Decision needed: How to handle volatile, stable, and immutable functions.

Options: 1. Reject volatile functions in DIFFERENTIAL mode (safest; clear error with suggestion to use FULL mode) — simplest, zero correctness risk 2. Warn but allow — user accepts phantom-change risk 3. Snapshot volatile values at change-capture time — store the computed value in the change buffer so it’s stable across refreshes. Complex but correct. 4. Auto-downgrade to FULL mode when volatile functions detected 5. Classify as stable-safe / volatile-unsafe — allow now() (same within statement) but reject random()

Recommendation: Option 1 as default with Option 4 as a GUC-controlled override. Adds lookup_function_volatility() using pg_catalog.pg_proc and a recursive Expr tree scanner.

Scope: - Volatility lookup infrastructure (SPI query to pg_proc.provolatile) - Recursive expression scanner for worst_volatility() computation - Integration into parser validation at create_stream_table() time - GUC: pg_trickle.volatile_function_policy (reject/warn/allow) - Handle overloaded functions (multiple proname entries with different volatility)


ADR-051: GROUPING SETS / CUBE / ROLLUP Full Implementation

Field Value
Status Not Started
Category SQL Feature / Aggregation
Sources plans/sql/GAP_SQL_PHASE_4.md (item S1)
Effort High (10-15 hours)

Context: Currently rejected with a clear error suggesting separate stream tables + UNION ALL. GROUPING SETS produce multiple aggregation levels in a single query — each grouping set is essentially a separate GROUP BY.

Decision needed: Whether and how to implement in DIFFERENTIAL mode.

Options: 1. Keep rejection — the UNION ALL rewrite is a viable workaround and avoids significant complexity 2. Expand to multiple Aggregate operators — one per grouping set, combined with UNION ALL internally. Each grouping set maps to a separate auxiliary counter set in storage. 3. Rewrite to UNION ALL at parse time — transparently decompose the query into multiple GROUP BY queries combined with UNION ALL before building the OpTree

Recommendation: Option 3 — query rewrite at parse time is cleanest and reuses existing infrastructure. Option 1 is acceptable if demand is low.


ADR-052: DISTINCT ON Full Implementation

Field Value
Status Not Started
Category SQL Feature / Deduplication
Sources plans/sql/GAP_SQL_PHASE_4.md (item S2)
Effort Medium (6-8 hours)

Context: DISTINCT ON (expr) is a PostgreSQL-specific extension that selects the first row per group (based on ORDER BY within the group). Currently rejected with suggestion to use ROW_NUMBER() OVER (...) = 1.

Decision needed: Whether to implement natively or via automatic rewrite.

Options: 1. Keep rejection — the ROW_NUMBER() rewrite works and is portable SQL 2. Auto-rewrite to window function — at parse time, transparently convert DISTINCT ON (expr) ORDER BY expr, col to a subquery with ROW_NUMBER() OVER (PARTITION BY expr ORDER BY col) = 1 3. Native DISTINCT ON operator — new OpTree variant tracking per-group “first row” across refreshes

Recommendation: Option 2 — automatic rewrite to window function is cleanest, reuses the existing Window operator, and requires minimal new code.


ADR-053: Circular References in the Stream Table DAG

Field Value
Status Not Started
Category SQL Feature / DAG Architecture
Sources plans/sql/PLAN_CIRCULAR_REFERENCES.md
Effort Very High (~20-30 hours)

Context: The dependency graph currently enforces a strict DAG. Creating a stream table that would form a cycle is rejected. Some use cases naturally involve mutual dependencies (e.g., ST A references ST B and vice versa).

Decision needed: Whether and how to support cycles in the ST dependency graph.

Options: 1. Keep DAG enforcement — no cycles, users restructure their queries 2. SCC-based fixed-point iteration — decompose the graph into Strongly Connected Components (Tarjan’s algorithm), create a condensation DAG, and iterate SCCs to fixed point 3. Stratified evaluation — partition cycles into monotone strata (safe to iterate) and non-monotone strata (rejected or user-opted-in with iteration limit)

Recommendation: Option 3 (stratified evaluation) — aligns with Datalog theory and DBSP. Only monotone cycles (JOINs, UNIONs, filters) are automatically iterable. Non-monotone cycles (aggregates, EXCEPT) warn and require explicit user opt-in.

Key design points from existing plan: - Replace check_for_cycles() with SCC decomposition - Replace topological_order() with condensation-DAG ordering - Add max_iterations GUC per SCC (default 100) - Static monotonicity analysis at create_stream_table() time - Convergence guarantee for monotone-only SCCs


ADR-054: NATURAL JOIN Support

Field Value
Status Not Started
Category SQL Feature / Joins
Sources plans/sql/GAP_SQL_OVERVIEW.md (Gap 2.3)
Effort Medium (6-8 hours)

Context: NATURAL JOIN is currently rejected with a clear error suggesting explicit JOIN ... ON. PostgreSQL’s raw parser does not resolve NATURAL JOIN column lists — the quals field is NULL, and resolution happens during analysis. The DVM parser would need catalog access to resolve common columns.

Decision needed: Whether to implement or continue rejecting.

Options: 1. Keep rejection — NATURAL JOIN is generally considered poor practice; explicit JOINs are clearer and less fragile to schema changes 2. Catalog-resolved rewrite — at parse time, query pg_attribute for both tables, find common column names, and synthesize an equi-join condition 3. Query analysis pass — use pg_analyze_and_rewrite() to get the resolved join quals, then extract the condition

Recommendation: Option 1 — rejection is appropriate. NATURAL JOIN is fragile (adding a column to either table silently changes the join condition). The error message already suggests the correct alternative.


ADR-055: Remaining Aggregate Functions (Regression, Hypothetical-Set, XMLAGG)

Field Value
Status Not Started
Category SQL Feature / Aggregation
Sources plans/sql/GAP_SQL_PHASE_4.md (items A3, E5, E6)
Effort Low-Medium (6-10 hours total)

Context: 17 aggregate functions are recognized but rejected in DIFFERENTIAL mode. All follow the proven group-rescan pattern — implementation is mechanical.

Decision needed: Priority and scope of remaining aggregate support.

Aggregates to consider: - Regression (11 functions): CORR, COVAR_POP, COVAR_SAMP, REGR_AVGX, REGR_AVGY, REGR_COUNT, REGR_INTERCEPT, REGR_R2, REGR_SLOPE, REGR_SXX, REGR_SXY — all use group-rescan (~4-6 hours) - Hypothetical-set (4 functions): RANK, DENSE_RANK, PERCENT_RANK, CUME_DIST as aggregates — almost always used as window functions; rare as aggregates (~4-6 hours) - XML: XMLAGG — very niche (~1-2 hours)

Recommendation: Implement regression aggregates on demand. Keep rejection for hypothetical-set and XMLAGG — extremely rare use cases.


ADR-056: Mixed UNION / UNION ALL Support

Field Value
Status Not Started
Category SQL Feature / Set Operations
Sources plans/sql/GAP_SQL_PHASE_4.md (item S3)
Effort Medium (4-6 hours)

Context: Queries mixing UNION and UNION ALL in the same query are currently rejected. The DVM parser handles sequences of the same set operation but not mixed sequences.

Decision needed: How to handle mixed set operations.

Options: 1. Keep rejection — users rewrite to uniform set operations 2. Per-arm dedup flag — extend the OpTree set operation nodes with per-branch metadata indicating whether deduplication applies to each arm 3. Parse-time rewrite — decompose A UNION B UNION ALL C into (A UNION B) UNION ALL C by nesting set operation nodes

Recommendation: Option 3 — PostgreSQL’s parser already produces a nested tree structure for mixed set ops; the DVM parser should respect this nesting rather than flattening.


ADR-057: Multiple PARTITION BY Clauses in Window Functions

Field Value
Status Not Started
Category SQL Feature / Window Functions
Sources plans/sql/GAP_SQL_PHASE_4.md (item S4)
Effort High (8-10 hours)

Context: Queries with window functions using different PARTITION BY clauses are currently rejected in DIFFERENTIAL mode. The Window operator recomputes entire partitions when any row in the partition changes. Multiple partitioning schemes would require multiple recomputation passes.

Decision needed: How to handle queries with heterogeneous window partitions.

Options: 1. Keep rejection — users split into multiple stream tables 2. Multi-pass recomputation — for each distinct PARTITION BY, run a separate recomputation pass. The superset of affected partitions across all passes determines the final delta. 3. Finest-grain partition — find the coarsest common partition (intersection of all PARTITION BY keys) and recompute at that granularity 4. Auto-rewrite to subqueries — split each window function into a separate subquery with its own partitioning, then join the results

Recommendation: Option 2 — multi-pass is correct and bounded. Option 1 is acceptable until demand is demonstrated.


ADR-058: Subquery Expressions in Complex Positions

Field Value
Status Not Started
Category SQL Feature / Subqueries
Sources plans/sql/GAP_SQL_PHASE_4.md (items E1, E2, E3)
Effort High (18-24 hours total for all 3)

Context: Three subquery patterns are currently rejected in DIFFERENTIAL mode:

  1. Scalar subquery in WHEREWHERE col > (SELECT avg(x) FROM t) — requires value-change tracking per row
  2. SubLinks inside ORWHERE EXISTS(...) OR col = 1 — requires OR-to-UNION rewrite for delta correctness
  3. ALL (subquery)WHERE col > ALL(SELECT x FROM t) — dual of ANY; anti-join with universal quantification

Decision needed: Priority and approach for each.

Options: 1. Keep rejection with rewrite suggestions — all three have documented workarounds (JOINs, CTEs, NOT EXISTS) 2. Implement incrementally — E3 (ALL subquery) is simplest (anti-join pattern); E1 (scalar in WHERE) is hardest (needs value-change tracking); E2 (OR + SubLinks) is architecturally complex (OR-to-UNION rewrite) 3. Auto-rewrite at parse time — transform these patterns into supported equivalents before building the OpTree

Recommendation: Implement E3 (ALL subquery) as it follows the existing AntiJoin pattern; defer E1 and E2 due to high complexity relative to benefit.


ADR-059: ROWS FROM() with Multiple Set-Returning Functions

Field Value
Status Not Started
Category SQL Feature / LATERAL
Sources plans/sql/GAP_SQL_PHASE_4.md (item S5)
Effort Low (3-4 hours)

Context: ROWS FROM(func1(...), func2(...)) zips the output of multiple set-returning functions into a single result set. Currently rejected.

Decision needed: Whether to implement.

Recommendation: Keep rejection — extremely rare construct. Single SRF in FROM + LATERAL covers all practical use cases.


Part 3: Forward-Looking ADRs — PostgreSQL Integration & Compatibility

ADR-060: Citus Distributed Table Compatibility

Field Value
Status Not Started
Category PostgreSQL Integration
Sources plans/infra/PLAN_CITUS.md
Effort Very High (~6 months)

Context: pg_trickle has zero multi-node awareness. Every core module assumes a single PostgreSQL instance with local OIDs, local WAL, local triggers, and a single background worker. Citus compatibility requires addressing 6 major incompatibilities.

Decision needed: Architecture for Citus support.

Key incompatibilities: 1. OID-based change buffer naming (OIDs not globally unique across nodes) 2. pg_current_wal_lsn() as change frontier (independent WAL per worker) 3. Triggers on distributed tables (DML goes to workers, bypassing coordinator triggers) 4. MERGE statement compatibility (limited Citus MERGE support) 5. Shared memory & background worker (coordinator-local) 6. System catalog & row estimates (coordinator shard is empty for distributed tables)

Options: 1. Single-node only — document incompatibility, no Citus support 2. Reference tables only — support Citus reference tables (triggers fire on coordinator) but not distributed tables 3. Full Citus support — 7-phase plan: stable naming, distributed sequence frontiers, worker-propagated triggers, INSERT ON CONFLICT instead of MERGE, coordinator-only scheduler, catalog-based locks, LISTEN/NOTIFY signaling

Recommendation: Option 2 as near-term (reference tables); Option 3 as a long-term roadmap item. Proceed with runtime auto-detection of Citus availability.


ADR-061: Multi-Version PostgreSQL Support

Field Value
Status Not Started
Category PostgreSQL Integration
Effort High (ongoing)

Context: ADR-012 chose PostgreSQL 18 as the sole target. As PG 19 and future versions release, a strategy for multi-version support is needed.

Decision needed: How to support new PG versions while maintaining backward compatibility.

Options: 1. Track latest only — always target the newest PG version exclusively 2. N-1 support — support current and previous major version via conditional compilation (#[cfg(feature = "pg18")]) 3. N-2 support — broader compatibility at higher maintenance cost

Recommendation: Option 2 — support N and N-1 via pgrx’s built-in conditional compilation. Drop the oldest when a new PG version releases.


ADR-062: Schema Evolution and DDL Propagation

Field Value
Status Not Started
Category PostgreSQL Integration
Sources src/hooks.rs, docs/ARCHITECTURE.md
Effort High (10-15 hours)

Context: The current DDL tracking (_on_ddl_end, _on_sql_drop) detects source table schema changes and marks affected stream tables for reinitialization. This is a coarse approach — any column change triggers a full reinitialization even if the changed column isn’t used by the stream table.

Decision needed: How to handle schema evolution more gracefully.

Options: 1. Keep full reinitialization — correct but heavy-handed; any ALTER TABLE on a source table forces a full rebuild 2. Column-level tracking — only reinitialize if claimed columns are affected. The columns_used field in pgt_dependencies already tracks this; use it to filter DDL events. 3. Transparent ALTER propagation — when a source table gets a new column, automatically add it to the stream table if the defining query uses SELECT * 4. Online schema migration — apply schema changes to the storage table without full reinitialization using ALTER TABLE ... ADD/DROP COLUMN

Recommendation: Option 2 as near-term improvement; Option 3 for SELECT * queries. Option 4 is complex and deferred.


ADR-063: Extension Upgrade / Migration Strategy

Field Value
Status Not Started
Category PostgreSQL Integration
Effort Medium (5-8 hours)

Context: As the extension evolves, catalog schema changes, new operators, and behavioral changes need a migration strategy. PostgreSQL supports ALTER EXTENSION ... UPDATE with versioned migration SQL scripts.

Decision needed: Versioning and migration approach.

Options: 1. pgrx-managed migrations — rely on pgrx’s SQL generation for each version 2. Manual migration scripts — hand-written pg_trickle--1.0--1.1.sql files with explicit ALTER TABLE, data migrations, etc. 3. Hybrid — pgrx for function signatures + manual scripts for catalog schema changes

Recommendation: Option 3 — pgrx handles function registration; manual scripts handle catalog table changes, index additions, and data migrations.


Part 4: Forward-Looking ADRs — Correctness & Safety

ADR-070: TRUNCATE Capture in CDC

Field Value
Status Not Started
Category Correctness / CDC
Effort Medium (4-6 hours)

Context: TRUNCATE on a source table is not currently captured by the row-level AFTER trigger (PostgreSQL does not fire row-level triggers on TRUNCATE). If a source table is truncated, the stream table becomes stale with no automatic mechanism to detect or recover.

Decision needed: How to detect and handle TRUNCATE on tracked tables.

Options: 1. Event trigger on TRUNCATE — use a DDL event trigger or statement-level trigger on TRUNCATE to detect the operation and mark affected stream tables for reinitialization 2. TRUNCATE trigger — PostgreSQL supports BEFORE/AFTER TRUNCATE triggers (statement-level only); fire a function that marks affected STs 3. Row-count verification — before each refresh, verify that source table row count hasn’t unexpectedly dropped to 0 4. Replication-based detection — WAL-mode CDC naturally captures TRUNCATE via logical decoding messages

Recommendation: Option 2 — AFTER TRUNCATE trigger is the most direct and reliable solution for trigger-mode CDC. Option 4 handles WAL mode automatically.


ADR-071: Type Coercion and Implicit Cast Handling

Field Value
Status Not Started
Category Correctness / Expressions
Effort Medium (4-6 hours)

Context: PostgreSQL performs implicit type coercions in many contexts (comparisons, function arguments, INSERT targets). The DVM parser handles explicit CAST(x AS type) and x::type but may not preserve implicit coercions that PostgreSQL’s analyzer adds. This could lead to type mismatches in generated delta SQL.

Decision needed: Whether to re-analyze delta SQL or preserve coercions from the parse tree.

Options: 1. Rely on PostgreSQL’s implicit coercion in generated SQL — trust that the database engine will apply the same coercions when executing delta SQL 2. Explicit coercion insertion — when generating delta SQL, add explicit casts where the source query has implicit coercions 3. Use analyzed (post-rewrite) parse tree — parse with pg_analyze_and_rewrite() instead of raw_parser() to get a fully resolved tree

Recommendation: Option 1 for now — PostgreSQL’s implicit coercion in generated delta SQL matches the defining query’s behavior. Monitor for edge cases and switch to Option 3 if type mismatches surface.


ADR-072: Row Identity for Keyless Tables

Field Value
Status Not Started
Category Correctness / Storage
Effort Medium (6-8 hours)

Context: The current __pgt_row_id is computed from the primary key of source tables. For defining queries that involve aggregations, expressions, or joins, the row ID is derived from GROUP BY keys, join keys, or synthetic identifiers. But what if a source table has no primary key?

Decision needed: How to identify rows when source tables lack primary keys.

Options: 1. Require primary keys — reject create_stream_table() if any source table lacks a PK. Simple but restrictive. 2. Use ctid — PostgreSQL’s physical row ID. Not stable across VACUUM, but usable within a single refresh window. 3. Use all columns — hash all column values to generate a row ID. Works but may not be unique for duplicate rows. 4. Require REPLICA IDENTITY FULL — for WAL mode, this provides all column values in the change record. For trigger mode, the trigger already captures to_jsonb(NEW).

Recommendation: Option 1 as default (require PK). Support Option 3 via opt-in for tables where duplicates are acceptable or impossible.


ADR-073: Consistent Snapshot Isolation for Multi-Source Refreshes

Field Value
Status Not Started
Category Correctness / Refresh Engine
Effort High (8-12 hours)

Context: When a stream table references multiple source tables, the delta query reads changes from each source’s buffer. These changes may represent different transaction visibility windows. The current frontier system uses LSN ranges per source, but concurrent transactions may cause subtle inconsistencies if changes from one source are captured at a different snapshot boundary than another.

Decision needed: Whether and how to enforce cross-source snapshot consistency during refresh.

Options: 1. Accept eventual consistency — each source is independently tracked by LSN; minor transient inconsistencies self-correct on next refresh 2. Transaction-ID-based windows — use xid ranges instead of LSN ranges to ensure only committed transactions within the same window are processed 3. Serializable refresh transactions — run the delta query in a SERIALIZABLE transaction to enforce a consistent view

Recommendation: Option 1 — the current frontier approach provides “eventual consistency within one refresh cycle” which is acceptable for the DVS (Delayed View Semantics) guarantee.


Priority Order

Tier 1 — High Priority (write first)

Priority ADR Rationale
1 ADR-003 Core IVM engine — the heart of the extension
2 ADR-001 Foundational CDC decision
3 ADR-002 Hybrid CDC — major architectural evolution
4 ADR-010 SQL functions vs DDL — shapes user experience
5 ADR-004 xxHash row IDs — storage and correctness
6 ADR-005 Change buffer design — CDC pipeline foundation
7 ADR-050 Non-deterministic functions — open correctness gap
8 ADR-070 TRUNCATE capture — open correctness gap

Tier 2 — Medium Priority

Priority ADR Rationale
9 ADR-020 Canonical scheduling — non-obvious design choice
10 ADR-023 Adaptive fallback — performance characteristics
11 ADR-006 User triggers — real-world usability
12 ADR-007 Recursive CTE strategy — non-trivial IVM decision
13 ADR-008 Group-rescan strategy — foundational aggregate pattern
14 ADR-040 Aggregate counters — performance detail
15 ADR-053 Circular references — major DAG architecture decision
16 ADR-062 Schema evolution — operational concern

Tier 3 — Lower Priority

Priority ADR Rationale
17 ADR-012 PG 18 only — scoping decision
18 ADR-021 Single scheduler — straightforward
19 ADR-022 Replication origin — safety mechanism
20 ADR-030 dbt macro — ecosystem decision
21 ADR-041 LATERAL diff — specialized IVM detail
22 ADR-051 GROUPING SETS — structural enhancement
23 ADR-052 DISTINCT ON — auto-rewrite
24 ADR-060 Citus — long-term infrastructure

Tier 4 — Document When Relevant

Priority ADR Rationale
25 ADR-011 Naming — historical
26 ADR-031 In-repo dbt — minor
27 ADR-032 Testcontainers — testing infra
28 ADR-055 Remaining aggregates — incremental
29 ADR-056 Mixed UNION — edge case
30 ADR-057 Multiple PARTITION BY — edge case
31 ADR-058 Complex subquery positions — edge case
32 ADR-059 ROWS FROM — very niche
33 ADR-061 Multi-PG-version — ongoing
34 ADR-063 Extension upgrades — operational
35 ADR-071 Type coercion — monitor for issues
36 ADR-072 Keyless tables — restrictive edge
37 ADR-073 Snapshot isolation — theoretical
38 ADR-054 NATURAL JOIN — keep rejection

Effort Estimate

Batch ADRs Scope Estimated Effort
Batch 1 — Core (Accepted) ADR-001 through 008, 010 Document past decisions ~5 hours
Batch 2 — Runtime (Accepted) ADR-011, 012, 020-023 Document past decisions ~3 hours
Batch 3 — Ecosystem (Accepted) ADR-030, 031, 032, 040, 041 Document past decisions ~2.5 hours
Batch 4 — SQL Features (New) ADR-050 through 059 Propose new decisions ~4 hours
Batch 5 — PG Integration (New) ADR-060 through 063 Propose new decisions ~2 hours
Batch 6 — Correctness (New) ADR-070 through 073 Propose new decisions ~2 hours
Total 38 ADRs 22 accepted + 16 forward-looking ~18.5 hours

File Naming Convention

plans/adrs/
├── PLAN_ADRS.md                                              ← this file
│
│ ── Core Architecture (001-009) ──
├── adr-001-trigger-based-cdc.md
├── adr-002-hybrid-cdc.md
├── adr-003-dvm-operator-tree.md
├── adr-004-xxhash-row-ids.md
├── adr-005-per-table-change-buffers.md
├── adr-006-explicit-dml-user-triggers.md
├── adr-007-semi-naive-recursive-cte.md
├── adr-008-group-rescan-aggregates.md
│
│ ── API & Schema Design (010-019) ──
├── adr-010-sql-functions-not-ddl.md
├── adr-011-pgtrickle-schema-naming.md
├── adr-012-postgresql-18-only.md
│
│ ── Scheduling & Runtime (020-029) ──
├── adr-020-canonical-scheduling-periods.md
├── adr-021-single-background-worker.md
├── adr-022-replication-origin-feedback-prevention.md
├── adr-023-adaptive-full-refresh-fallback.md
│
│ ── Tooling & Ecosystem (030-039) ──
├── adr-030-dbt-macro-package.md
├── adr-031-dbt-in-repo-subdirectory.md
├── adr-032-testcontainers-testing.md
│
│ ── Performance & Optimization (040-049) ──
├── adr-040-aggregate-auxiliary-counters.md
├── adr-041-lateral-row-scoped-recomputation.md
│
│ ── SQL Feature Coverage (050-059) ──
├── adr-050-non-deterministic-function-handling.md
├── adr-051-grouping-sets-implementation.md
├── adr-052-distinct-on-rewrite.md
├── adr-053-circular-references-scc.md
├── adr-054-natural-join-rejection.md
├── adr-055-remaining-aggregates.md
├── adr-056-mixed-union-support.md
├── adr-057-multiple-partition-by.md
├── adr-058-complex-subquery-positions.md
├── adr-059-rows-from-multi-srf.md
│
│ ── PostgreSQL Integration (060-069) ──
├── adr-060-citus-compatibility.md
├── adr-061-multi-version-pg-support.md
├── adr-062-schema-evolution-ddl.md
├── adr-063-extension-upgrade-migration.md
│
│ ── Correctness & Safety (070-079) ──
├── adr-070-truncate-capture.md
├── adr-071-type-coercion-handling.md
├── adr-072-keyless-table-row-identity.md
└── adr-073-snapshot-isolation-multi-source.md