# v0.86.0 — Product UX & Transparency

> **Status:** ✅ Implemented (2026-08-21)
> **Scope:** Large
> **User promise:** *"I understand what pg_trickle is doing."*
> **Blocked by:** [v0.82.0](v0.82.0.md), [v0.83.0](v0.83.0.md), [v0.84.0](v0.84.0.md), and [v0.85.0](v0.85.0.md)
> **Renumbered:** previously planned as v0.82.0.

## Theme

pg_trickle already has unusually broad SQL coverage, automatic FULL fallback,
DAGs, repair tooling, observability, PgBouncer support and Citus support. The
remaining gap is not capability — it is **legibility**. A user who runs
`create_stream_table()` today cannot easily answer: will this be incremental or
FULL? how expensive is each refresh? how stale is the table right now? what
will this cost my write path?

This release turns the existing machinery into a product surface. Everything
here is user-facing: a standard statistics view and an `explain()` function
that answers the questions users actually ask, in plain language, before and
after they commit to a query.

It also introduces `target_freshness` as an *accepted input* — deliberately
four releases before the closed-loop controller in [v0.90.0](v0.90.0.md) is
asked to honour it. The control that v1.0 freezes as the primary user-facing
knob should have been in users' hands for several releases before the freeze,
not introduced immediately before it.

Work starts only after the v0.82.0–v0.85.0 pre-scaling gates prove the current
frontier, DVM, catalog, privilege, upgrade, scheduler and resource contracts.
Everything below reports on those contracts; none of it may create a parallel
implementation of refresh finalization, error accounting or frontier selection.

## Items

### UX-2: `pgtrickle.explain(stream_table)` — the flagship diagnostic

A single function that answers "what is this thing doing, and is that
reasonable?" in human terms:

```
SELECT * FROM pgtrickle.explain('sales_by_region');
```

```
 Refresh mode:            DIFFERENTIAL
 Estimated changed rows:  2,400
 Dominant cost:           orders → customers join (est. 78% of refresh time)
 Expected refresh time:   ~40 ms
 Current lag:             620 ms
 Next scheduled refresh:  in 4.4 s
 FULL fallback threshold: 50% of base rows changed
 Write-path overhead:     ~3.1 µs per INSERT on orders (trigger CDC)
```

Output is available as a readable text form and as a structured `jsonb` form
(`pgtrickle.explain_json()`) for tooling. It is built from data that already
exists — the cost model summary, refresh history, OpTree classifier and CDC
metrics — rather than new instrumentation.

### UX-3: "Why did AUTO choose FULL?"

Every FULL refresh that occurred under `mode = 'auto'` records a machine-readable
reason code plus a one-sentence explanation, surfaced by `explain()` and by
`pgtrickle.refresh_history`:

| Reason code | Explanation shown to the user |
|-------------|-------------------------------|
| `DELTA_RATIO_EXCEEDED` | 62% of base rows changed; recomputing is cheaper than diffing |
| `CORRELATED_SUBQUERY_DELTA_QUADRATIC` | Correlated subquery would scan the base table once per changed row |
| `CASE_IN_LIST_DVM_DRIFT_FULL_FALLBACK` | CASE/IN-list aggregate shape is not provably incremental |
| `SOURCE_TRUNCATED` | A source table was truncated; the change buffer cannot describe the transition |
| `SCHEMA_CHANGED` | A source column used by the query changed type |
| `FIRST_REFRESH` | Initial population |

The reason codes shipped in v0.80.0 are extended to cover every FULL path, and
a CI test asserts that no code path can trigger FULL without a reason code.

### UX-4: Creation-time warnings for expensive or problematic queries

`create_stream_table()` and `preview_stream_table()` emit `WARNING`s at
creation time, before the user has invested in the object:

- query will always run FULL (with the reason)
- estimated refresh cost exceeds the requested refresh interval
- a source table has RLS enabled (already shipped in v0.79.0; folded into the
  unified warning surface)
- a source table has no primary key or replica identity
- estimated write-path overhead exceeds a configurable threshold
- the stream table would join more than `warn_join_sources` (default 6) sources

Each warning includes a `HINT` with the concrete remediation, and the full set
is returned by `preview_stream_table()` so users can see them without creating
anything.

### UX-5: `pg_stat_pgtrickle` statistics view

A virtual system view modelled on `pg_stat_user_tables`, exposing per-stream-table
cumulative statistics that standard monitoring tools (pgwatch, Datadog,
pg_stat_monitor, Prometheus exporters) can scrape without bespoke queries:

```sql
CREATE VIEW pgtrickle.pg_stat_pgtrickle AS
SELECT
    pgt_id, schema_name, table_name,
    total_refreshes, total_full_refreshes, total_diff_refreshes,
    total_delta_rows_processed,
    avg_refresh_duration_ms, p95_refresh_duration_ms, p99_refresh_duration_ms,
    last_refresh_at, current_lag_ms, target_freshness_ms,
    last_full_reason, last_error, last_error_at
FROM pgtrickle.pgt_cost_model_summary
JOIN pgtrickle.pgt_stream_tables USING (pgt_id);
```

`target_freshness_ms` reflects the declared target from UX-7 and is `NULL` when
none is set. The SLA evaluation columns (`p95_freshness_ms`, `sla_status`) are
added in [v0.90.0](v0.90.0.md) once there is a controller whose behaviour they
can describe.

Counters reset semantics follow PostgreSQL conventions
(`pgtrickle.stat_reset(pgt_id)`, `pgtrickle.stat_reset_all()`).

### UX-6: Freshness and cost in `EXPLAIN`

Extend PostgreSQL's own `EXPLAIN` output for queries that read a stream table
with a pg_trickle annotation line:

```
Seq Scan on sales_by_region  (cost=0.00..18.50 rows=850 width=44)
  pg_trickle: lag 620 ms, last refresh 2026-06-04 10:31:02, mode DIFFERENTIAL
```

Controlled by `pg_trickle.explain_annotations`, **default `off`**. Changing
`EXPLAIN` output by default would break plan-diffing tools, regression suites
and anything that parses `EXPLAIN` text, for a diagnostic most sessions do not
need. Users opt in per session when a number looks wrong.

### UX-7: `target_freshness` as an accepted control

Accept a declared freshness target on create and alter:

```sql
SELECT pgtrickle.create_stream_table(
    'sales_by_region',
    $$SELECT region, SUM(amount) FROM orders GROUP BY region$$,
    target_freshness => '2 seconds'
);

SELECT pgtrickle.alter_stream_table(
    'sales_by_region', target_freshness => '1 minute'
);
```

In this release the target is **declared, stored, reported and validated — not
yet controlled**. It is translated once into the existing schedule and mode
settings, surfaced by `explain()` and `pg_stat_pgtrickle`, and checked for
obvious infeasibility (SLA-5 from the original freshness-SLA plan) so users are
told immediately when a target cannot be met. The closed-loop controller that
owns the derived parameters continuously arrives in [v0.90.0](v0.90.0.md).

Special values: `'on_commit'` (existing IMMEDIATE mode) and `'manual'` (refresh
only when asked).

> There is no `CREATE STREAM TABLE ... WITH (...)` grammar. Declarative DDL was
> dropped from this release deliberately; `pgtrickle.exec_stream_ddl()` remains
> a thin `CREATE/DROP STREAM TABLE ... AS` shim with no option list, and the
> function API is the supported surface.

## Exit criteria

- [ ] `pgtrickle.explain()` returns all seven fields for every supported query
      shape in the DVM support matrix
- [ ] Every FULL refresh in the test suite carries a reason code; CI test asserts
      no reason-code-free FULL path exists
- [ ] Creation-time warnings fire for all six conditions, each with a `HINT`
- [ ] `pg_stat_pgtrickle` documented in `SQL_REFERENCE.md` and covered by the
      `pg_extern` docs lint
- [ ] `EXPLAIN` annotation appears for stream-table scans when enabled, and is
      off by default
- [ ] `target_freshness` accepted, stored, reported and infeasibility-checked;
      documented as "declared now, controlled from v0.90.0"
- [ ] Docs: a "Understanding your stream table" guide built entirely around
      `explain()` output
