Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
carry a `guidance` field naming the typed manual path, drawn from the
suggest report's Guidance vocabulary. The fingerprint definition is
unchanged.
- **`ALTER COLUMN ... DROP NOT NULL` now classifies as destructive** in plan
reports (`destructive: true`): dropping `NOT NULL` discards the same
guarantee as dropping the equivalent constraint. The full destructive set
is a dropped column, constraint, index, or `NOT NULL`; `DROP DEFAULT` is
deliberately not destructive — a default guarantees nothing about existing
rows and is recreated by a metadata-only statement. A consumer gating on
`.statements[].destructive` now sees `DROP NOT NULL` flagged, and
desired-state execution refuses it like any other drop.
- **The suggest report is format version 2**: the Guidance vocabulary gains
`name-constraint-then-validate`, emitted for an unnamed `ADD CHECK` /
`ADD FOREIGN KEY`, and `unique-index-then-constraint`, covering an
Expand Down Expand Up @@ -102,6 +110,21 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- **Library-level desired-state execution: `migrate.RunDesired`** converges
one live table onto its parsed desired schema — derive the convergence
plan, admit it as a whole (table existence, destructive guard, routed
dispositions, optional `ExpectedFingerprint` pin), then run each planned
statement back through the same `migrate.Run` pipeline with fresh
introspection and classification, stopping at the first refusal or
failure. The result carries the plan, per-statement verdicts, and an
aggregate outcome with committed-prefix detail
([docs/execution-model.md](docs/execution-model.md)). Two new refusal
reasons enter the vocabulary: `destructive-change` (the plan discards
live structure — a dropped column, constraint, index, or `NOT NULL`;
desired-state execution never runs it) and
`plan-fingerprint-mismatch` (the plan derived at execution time is not
the pinned reviewed plan). Library-only for now — the `migrate --desired`
CLI flag follows separately.
- **A third verdict outcome, `failed`,** for execution failures (still exit
1 — refusals remain exit 2). The verdict carries the executor's stable
outcome code in `code`, and for a mid-sequence failure the 1-based
Expand Down
16 changes: 14 additions & 2 deletions SAFETY.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The invariant registry (invariant IDs referenced below) lives in
| `pkg/statement`, `pkg/planner`, `pkg/schemadiff`, `pkg/router`, `pkg/plan`, `pkg/lint`, `pkg/suggest` — classify/diff/route/report | ❌ periphery¹ | `pkg/statement` (parse boundary), `pkg/schemadiff` (introspect/diff via scratch execute-and-introspect), `pkg/planner` (classifier), `pkg/router` (backend assignment + availability policy), `pkg/plan` (versioned dry-run plan report), `pkg/lint` (offline typed findings), and `pkg/suggest` (advisory rewrites with typed caveats) exist (Phases 2.1–2.5) | (CO-7 holds at the parse boundary) |
| `pkg/verdict` — structured outcome contract, rendering, exit codes | ❌ periphery | exists (Phase 1) | — |
| `pkg/diffplan` — desired schema → routed convergence plan, the declarative front door as a library (the CLI `diff` and embedding orchestrators share it) | ❌ periphery | exists | — |
| `pkg/migrate` — one gated statement → resolve, classify, route, execute → one verdict; the imperative front door as a library (the CLI `migrate` and embedding orchestrators share it) | ❌ periphery² | exists | — |
| `pkg/migrate` — one gated statement → resolve, classify, route, execute → one verdict; the imperative front door as a library (the CLI `migrate` and embedding orchestrators share it), plus the desired-state execution loop (`RunDesired`: derive the convergence plan, admit it as a whole, run each planned statement back through the same pipeline) | ❌ periphery² | exists | — |
| `internal/cli` — CLI, flags, help, prompts | ❌ periphery | `migrate`, `status`, `diff`, `fmt`, `lint`, and `suggest` exist | — |
| `pkg/progress` — strategy-wide progress snapshots; the executors' observation seam (core imports it, so its locking discipline is core-critical); copy counters reserved for later | ✅ core | native progress exists | — |
| orchestrator adapter | ❌ periphery | planned (Phase 11) | OC-* hold *at* the boundary |
Expand All @@ -46,7 +46,19 @@ The core executors re-verify their own preconditions and never trust that the pl
pipeline — gate, resolve, preflight, execute — but every dangerous step it requests is enforced
by the core packages it calls: the executors re-verify admission and run under their own bounded
budgets, and preflight's proof types gate what may execute. A wrong sequencing decision in
`pkg/migrate` yields a refusal or a bounded failed attempt, never an unbounded lock.
`pkg/migrate` yields a refusal or a bounded failed attempt, never an unbounded lock. The
desired-state loop inherits that argument for every *execution-time* property: it executes
nothing itself — every planned statement goes back through `Run`, so each one is
re-introspected, re-classified, re-routed, and re-preflighted at execution time, and a plan
the loop wrongly admits still cannot make the core exceed a lock budget or skip a preflight.
**One admission check has no core backstop: the destructive guard.** The core has no concept
of destructiveness — `pkg/executor` and `pkg/preflight` never check it — so refusing a
destructive desired-state plan rests entirely on `RunDesired`'s admission gate and on the
classifier's `Destructive` derivation in `pkg/planner`, and its failure mode is data loss (a
falsely-admitted `DROP COLUMN` commits), not a refusal or a bounded failed attempt. Those two
sites are the exception to the periphery posture: treat `destructiveOp` and the desired-state
admission gate with the core's review bar — spec-first, test-first, small diffs — even though
their packages stay periphery for everything else they do.

## Rules inside the core

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ different levels of commitment:
| `pkg/suggest` | Offline advisory surface: maps risky DDL to the safer native form the engine would run, with typed caveats and manual-path guidance; emits the versioned suggest report ([suggest-report.md](suggest-report.md)) | exists |
| `pkg/plan` | Versioned machine-readable dry-run plan report — the one JSON contract both front doors emit and an orchestrator consumes | exists (Phase 2.5) |
| `pkg/diffplan` | The declarative front door as a library: desired schema in, routed `plan.Report` out — the CLI `diff` and embedding orchestrators share this one pipeline | exists |
| `pkg/migrate` | The imperative front door as a library: one parsed statement in — gate, resolve, classify, route, execute — one `verdict.Verdict` out; the CLI `migrate` and embedding orchestrators share this one pipeline | exists |
| `pkg/migrate` | The imperative front door as a library: one parsed statement in — gate, resolve, classify, route, execute — one `verdict.Verdict` out; the CLI `migrate` and embedding orchestrators share this one pipeline. Also the desired-state execution loop: `RunDesired` derives the convergence plan (`diffplan.Plan`), admits it as a whole (existence, destructive guard, dispositions, optional fingerprint pin), and runs each planned statement back through `Run` — per-statement verdicts, committed-prefix semantics | exists |
| `pkg/router` | Route classified statements to native / copy-and-swap / refuse dispositions; copy-and-swap reports unavailable until that backend lands | exists (Phase 2.4) |
| `pkg/executor` | Bounded optimistic native attempt, the concurrent index build, and the autocommit safer-sequence runner, with stable outcome codes; the full `Executor` contract (`Plan`/`Execute`/`Status`/`Abort`) arrives with the copy-and-swap backend | native execution exists |
| `pkg/progress` | Strategy-wide, pollable progress snapshots: native phase/elapsed time, sequence position, retry attempt, and server-reported concurrent-index work; optional copy counters are reserved for copy-and-swap | native progress exists |
Expand Down
20 changes: 20 additions & 0 deletions docs/cli-output-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ a verdict, not a plan report — and exit 2. The JSON report schema is
[plan-report.md](plan-report.md).

- [Codes used in these examples](#codes-used-in-these-examples)
- [Refusal reasons](#refusal-reasons)
- [Migrate](#migrate)
- [Runs as written (`metadata-only`) — exit 0](#runs-as-written-metadata-only--exit-0)
- [Safer-sequence substitution (`safer-idiom`) — exit 0](#safer-sequence-substitution-safer-idiom--exit-0)
Expand Down Expand Up @@ -68,6 +69,25 @@ is a one-line summary; the linked reference entry is authoritative.
| [`destructive`](postgres-online-ddl-reference.md#destructive) | The change discards live data or structure (`DROP COLUMN`, `DROP TABLE`, truncating conversions). A warning alongside the routing decision, not a refusal. |
| [`blocking-idiom`](lint-report.md#codes-code) | Lint-only code: the submitted form blocks readers or writers and a safer native form exists; the finding's `suggestion` carries the safer SQL when the linter can construct it. |

## Refusal reasons

Every refusal verdict (`"outcome": "refused"`, exit 2) carries exactly one of
these typed `reason` tokens — the value automation switches on; prose belongs
in `detail`. The set is closed and pinned by test (`verdict.Reasons()`).

| Reason | Meaning |
|---|---|
| `unsupported-statement` | No safe path is known for the statement — only `ALTER TABLE` and `CREATE INDEX` reach classification — or a desired-state plan needs a table that does not exist yet. |
| `index-statement` | Index maintenance (`DROP INDEX`, `REINDEX`) has a native safe idiom (`CONCURRENTLY`) and is never attempted; the verdict's `safer_idiom` names it. |
| `not-native-safe-table-too-large` | The size guard skipped the optimistic attempt: the table exceeds the configured bound and the change is not provably metadata-only. |
| `insufficient-privileges` | The connected role lacks the access the change needs; `detail` names the exact missing GRANT (see [engine-role.md](engine-role.md)). |
| `unsupported-partitioned-parent` | The routed plan builds an index on a partitioned parent, where PostgreSQL cannot `CREATE INDEX CONCURRENTLY`. |
| `not-native-safe-budget-exceeded` | The optimistic attempt exceeded its lock or statement budget and was cancelled; the verdict's `cause` narrows which budget fired. |
| `not-native-safe-rewrite-required` | The submitted form blocks and must run as a safer native sequence, but none could be constructed. |
| `backend-unavailable` | The change routes to an execution strategy this build does not implement (copy-and-swap). |
| `destructive-change` | The desired-state plan discards live structure — a dropped column, constraint, index, or `NOT NULL` — and desired-state execution runs no destructive statement; run the drop deliberately instead ([execution model](execution-model.md)). |
| `plan-fingerprint-mismatch` | The plan recomputed at execution time does not carry the pinned fingerprint: the plan a reviewer approved is not the plan that would execute, so nothing runs ([execution model](execution-model.md)). |

## Migrate

The imperative front door: submit one DDL statement; pg-sprite classifies
Expand Down
9 changes: 9 additions & 0 deletions docs/execution-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,12 @@ desired state — must report the statements already committed, the one that
failed, and the ones never attempted. That is the strongest guarantee
available once PostgreSQL rules out atomicity for online DDL, and it is the
contract embedders should expose rather than paper over.

The engine implements that contract itself: `migrate.RunDesired` (in
[`pkg/migrate`](../pkg/migrate/desired.go)) derives the convergence plan for
a desired-state schema and executes each planned statement back through the
same pipeline — fresh introspection, classification, and routing per
statement — stopping at the first refusal or failure. Its result carries the
plan, one verdict per attempted statement, and a detail naming exactly which
planned statements committed and remain in effect: the committed prefix at
the plan level, statements instead of steps.
31 changes: 31 additions & 0 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,34 @@ with a typed refusal — never a silently wrong or incomplete result:
| Column collations | An explicit `COLLATE` on a column is not managed: converging a collation delta rewrites the column and its indexes. Export refuses a collated column — a baseline without the clause would silently change sort order and index semantics — and a collation delta (including on an added column) is a typed `diff` refusal. |
| Non-table objects | Views, materialized views, standalone sequences, enums, domains, extensions, functions, and triggers are outside the model. A serial column's owned sequence is the one exception: it round-trips through the `serial` pseudo-types — and ownership is verified through the catalog (`pg_depend`), so a hand-written `nextval` default on a standalone sequence that merely carries the serial-style name refuses rather than exporting as `serial` and silently privatizing a shared sequence. A column may *use* an unmanaged type (an enum, a domain) — the type text round-trips — but the type's definition is not managed. |
| Multiple tables per file | A desired file is single-table scoped: exactly one `CREATE TABLE` plus `CREATE INDEX` statements on it. Multi-table schemas are managed as one file per table. |
| Changed index or constraint definition | A redefinition diffs to drop-and-recreate, the drop is destructive, and desired-state execution refuses any plan containing a destructive statement — the whole plan, including the harmless recreate. Run the drop deliberately first (`DROP INDEX CONCURRENTLY` directly against the database; `ALTER TABLE ... DROP CONSTRAINT` through the imperative front door), then rerun — the remaining plan converges the recreate. |

## What desired-state execution converges today

Desired-state execution (`migrate.RunDesired`, library-only today) feeds
every planned statement back through the same gates as the imperative
front door, so the outcome of an ordinary desired-file edit is the
composition of the model boundaries above with those gates. At a glance:

| Desired-file edit | Outcome today |
| --- | --- |
| Add a column | Converges. Runs as a bounded attempt of the submitted form, so the table-size guard applies (below). |
| Widen a column type (`varchar(50)` → `varchar(255)`) | Converges — the same bounded attempt, under the same size guard. |
| Add an index | Converges via `CREATE INDEX CONCURRENTLY`. Not size-guarded: long online work on a large table is the pattern's purpose. |
| Add a constraint (`UNIQUE`, `CHECK`); `SET NOT NULL` | Converges via the safer online sequence; not size-guarded either. |
| Relax a `NOT NULL` | Refused, whole plan: dropping `NOT NULL` discards the same guarantee its constraint form would, so it is destructive. Run it deliberately through the imperative front door — it executes natively there — then rerun. |
| Change an index or constraint definition | Refused, whole plan — the drop-and-recreate row above. |
| Narrow a column type (`varchar(255)` → `varchar(50)`) | Refused: the change routes to copy-and-swap, which is not yet available. |
| A bounded-attempt edit on a table above the size threshold | Refused at that statement (`not-native-safe-table-too-large`): with the default 1 GiB threshold, adding a column to a larger table refuses until the threshold is raised. |

A destructive refusal is all-or-nothing: one destructive statement refuses
the whole plan, and the non-destructive statements beside it do not run —
the refusal detail says how many were skipped.

The size threshold is policy, not capability: `Options.MaxTableSizeBytes`
(the CLI's `--max-table-size`) defaults to 1 GiB, and the guard covers only
the blind bounded attempt of a submitted form — planner-proven online
sequences are exempt. On a table you operate deliberately, raising the
threshold is the sanctioned way to converge the bounded-attempt edits; the
refusal means pg-sprite cannot prove the change is instant at that size,
not that the change is unsafe.
2 changes: 1 addition & 1 deletion docs/plan-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ consumer rendering either into a shared surface must clamp and escape them.
|---|---|---|---|
| `sql` | string | always | The statement in the engine's **canonical rendering**: parsed and reprinted through the PostgreSQL deparser, whichever front door derived it. Never a verbatim echo of submitted text — the same change carries the same string through either door. Commented input is refused rather than silently stripped; optional noise words follow the grammar's canonical spelling. |
| `kind` | string | diff source only | Classifies a diff-derived statement (see Kinds) so a consumer can gate whole classes of change. Absent for the alter source: a submitted statement may carry several operations and has no single kind. |
| `destructive` | bool | always | Marks statements that discard live structure — a dropped column, constraint, or index. Derived from the classifier's decisions, so both sources report it identically by construction. Always emitted, never omitted: a safety flag a consumer gates on must be explicit even when false. |
| `destructive` | bool | always | Marks statements that discard live structure — a dropped column, constraint, index, or `NOT NULL`. Derived from the classifier's decisions, so both sources report it identically by construction. Always emitted, never omitted: a safety flag a consumer gates on must be explicit even when false. |
| `route` | string | always | The planner's aggregate route for the statement (see Routes). |
| `backend` | string | except refusals | The assigned execution strategy (see Backends); absent for refusals. |
| `disposition` | string | always | What execution would do with this statement now (see Dispositions). |
Expand Down
10 changes: 7 additions & 3 deletions docs/postgres-online-ddl-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,9 +408,13 @@ an unclassified statement is never executed.
|---|---|---|---|
| warning — does not change the routing decision | per the routing decision | per the routing decision | unchanged by this code |

The change discards live data or structure (`DROP COLUMN`, `DROP TABLE`,
truncating conversions). Emitted alongside the routing decision as a warning so
destructive intent is always visible in review.
The change discards live data or structure: `DROP COLUMN`, `DROP TABLE`,
truncating conversions, a dropped constraint or index, or `DROP NOT NULL`
(which discards the same guarantee as dropping the equivalent constraint;
`DROP DEFAULT` is deliberately not destructive — a default guarantees nothing
about existing rows and is recreated by a metadata-only statement). Emitted
alongside the routing decision as a warning so destructive intent is always
visible in review.

### `rewrite-required`

Expand Down
Loading
Loading