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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Changed — observable outcomes for automation callers

- **Desired-state execution now creates a table that does not exist yet**
instead of refusing the plan. `migrate.RunDesired` on a greenfield plan
verifies the target name is free and the role holds `CREATE` on the
schema, then runs the `CREATE TABLE` and the index builds as brief
bounded steps; a rerun converges to an empty plan. An occupied name is a
new typed refusal reason, **`create-collision`** (added to
`verdict.Reasons()`); `PARTITION OF` and `IF NOT EXISTS` shapes refuse
with `unsupported-statement` before anything runs. A caller that relied
on the previous greenfield `unsupported-statement` refusal now sees the
create execute. Desired-file statements are additionally ordered for
execution at parse — the `CREATE TABLE` first, indexes keeping their
input order after it — everywhere the file replays: the greenfield plan,
the create path's steps, and the scratch-schema introspection that
derives a diff once the table exists. The plan states execution order, a
greenfield plan's fingerprint changes when the desired file listed an
index before its table, and an index-first file converges on rerun.
- **Alter attempts now run with `search_path` pinned to the target
schema** (then `public`) whenever the statement is schema-qualified —
the same resolution the create path and introspection use. A statement's
unqualified secondary names — a column's type, an expression's
function — resolve in the target schema, where previously they resolved
via the session's ambient `search_path` and could silently bind a
same-named object in `public`. A caller that relied on ambient
resolution for secondary names must qualify them.
- **`diff` now exits 2 when the derived plan contains a statement execution
would refuse**, in all three output modes (default report, `--sql`,
`--json`) — the same CI-gate contract as `migrate --dry-run`. Previously
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ routes the statement, then executes the routed SQL — the planner's safer nativ
default when the submitted form blocks (reported in the verdict's `executed_sql`), a bounded
optimistic native attempt otherwise. A gated `--force` runs the submitted form as-is under the
same budgets. Changes without an available backend get a structured refusal (exit code 2).
Desired-state execution — converging a live table onto a `CREATE TABLE` file, including
creating the table when it does not exist yet — is a Go API today: `migrate.RunDesired`
in [`pkg/migrate`](pkg/migrate/desired.go); the CLI's `migrate` verb takes one imperative
statement.
The design docs and the phased
build plan live in [docs/](docs/) — start with
[docs/README.md](docs/README.md); the vision — what pg-sprite is and is not —
Expand Down Expand Up @@ -68,9 +72,10 @@ refusal — never a silently wrong or incomplete result:
- **Unlogged tables and explicit column collations** are outside the
declarative model: converging either is a table (or column) rewrite, so
export and diff refuse rather than plan one.
- **Greenfield `CREATE TABLE` apply** is not user-reachable yet: the
executor create path exists as a library building block, but the
declarative front door does not route to it.
- **Desired-state execution has no CLI verb yet** — `migrate.RunDesired`
(including the greenfield `CREATE TABLE` path for a table that does not
exist) is library-only; the CLI's `migrate` takes one imperative
statement.
- **Non-table objects** — views, standalone sequences, enums, domains,
extensions, functions, triggers — are outside the declarative model,
which covers one ordinary table plus its indexes per file.
Expand Down
4 changes: 2 additions & 2 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ Status legend: ✅ T1 (supported today) · 🟡 T2 (planned; typed refusal today

| Operation | Status | Online-safety problem? | Behavior and why |
| --- | --- | --- | --- |
| `CREATE TABLE ... PARTITION OF` | | Yes | Executed, with a typed warning: creating a partition takes a brief `ACCESS EXCLUSIVE` on the **parent** and queues behind long-running queries |
| `CREATE TABLE ... PARTITION OF` | 🟡 | Yes | Typed refusal at both doors: the imperative door does not take `CREATE TABLE`, and the declarative create path refuses the form — attaching a partition takes a brief `ACCESS EXCLUSIVE` on the **parent**, which the greenfield absence proof does not cover. The partition-aware flow is planned |
| `ATTACH PARTITION` | ✅ | Yes | Executed; the safer idiom (pre-prove the bound with a validated `CHECK` so the attach skips its scan) is surfaced as guidance. A classify-first flow that constructs the proof itself is planned |
| `DETACH PARTITION [CONCURRENTLY]` | ✅ | Yes | `CONCURRENTLY` is the idiom; the blocking form is rewritten to it |
| Partitioned parents in the **declarative model** | 🟡 | Yes | Typed refusal: the model does not yet carry partition keys, and rendering a partitioned parent as a plain `CREATE TABLE` would be silently wrong |
Expand All @@ -171,7 +171,7 @@ Status legend: ✅ T1 (supported today) · 🟡 T2 (planned; typed refusal today
| Unlogged tables | 🟡 | Yes | Typed refusal: persistence is not modeled, converging it (`SET LOGGED`) is a full rewrite, and rendering the table as plain `CREATE TABLE` would silently change crash-safety |
| Explicit column collations | 🟡 | Yes | Typed refusal: dropping a `COLLATE` clause from a rendered baseline silently changes sort order and index semantics; a collation delta cannot converge without a rewrite |
| Columns whose default uses a sequence the column does not own | 🟡 | Yes | Typed refusal: in a desired-state model that sequence exists only inside the scratch transaction, so no derived plan can reference it. Column-owned (`serial`-style) sequences are fine |
| Greenfield `CREATE TABLE` apply (the table does not exist yet — a fresh database or a new table in a live one) | 🟡 | Yes — a `REFERENCES` clause takes a brief `SHARE ROW EXCLUSIVE` on each **referenced** live table | Planned as an owned operation. The new table itself has no readers or writers to protect; the online-safety problem is the `REFERENCES` clause, whose lock on each referenced live table queues behind long-running queries and blocks writers behind it — exactly the run-it-under-a-bounded-`lock_timeout` job this engine owns and owner tooling does not do. The absence preflight (`CheckTableAbsent`), the creation-privilege preflight (`CheckCreatePrivileges`), and the executor create path (`ExecuteCreate` — plain `CREATE TABLE` plus plain index builds; `PARTITION OF`, `INHERITS`, `LIKE`, `OF`, and `IF NOT EXISTS` are typed refusals at admission, while `REFERENCES` and `CONCURRENTLY` are refused upstream at desired-file parse and re-checked at admission as defense in depth) are in place; the declarative front door does not route to them yet. `diff --sql` already emits the statement |
| Greenfield `CREATE TABLE` apply (the table does not exist yet — a fresh database or a new table in a live one) | ✅ | Yes — a `REFERENCES` clause would take a brief `SHARE ROW EXCLUSIVE` on each **referenced** live table, but desired files refuse foreign keys today, so no live table is locked | Desired-state execution creates the table: the absence preflight (`CheckTableAbsent`) verifies the name is free, `CheckCreatePrivileges` verifies the role can create in the schema, and the executor runs the `CREATE TABLE` and the index builds as brief bounded steps under the engine's `lock_timeout` / `statement_timeout` budgets. An occupied name is a typed `create-collision` refusal; `PARTITION OF`, `INHERITS`, `LIKE`, `OF`, `IF NOT EXISTS`, and in-set duplicate names are typed refusals before anything runs, while `REFERENCES` and `CONCURRENTLY` are refused upstream at desired-file parse and re-checked at admission as defense in depth |

### Types and non-table objects

Expand Down
3 changes: 2 additions & 1 deletion docs/cli-output-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ 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. |
| `unsupported-statement` | No safe path is known for the statement — only `ALTER TABLE` and `CREATE INDEX` reach classification — or a greenfield create plan carries a shape the create path refuses (`PARTITION OF`, `IF NOT EXISTS`). |
| `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)). |
Expand All @@ -87,6 +87,7 @@ in `detail`. The set is closed and pinned by test (`verdict.Reasons()`).
| `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)). |
| `create-collision` | The greenfield create plan's target name is already occupied — a relation or standalone type took it after the plan was derived. Nothing runs; re-derive the plan against the live catalog and review what it says now. |

## Migrate

Expand Down
13 changes: 9 additions & 4 deletions docs/execution-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,15 @@ autocommit-each-step has two shapes in the executor:
- **Brief catalog steps (step kind `brief`) and `VALIDATE CONSTRAINT` (step
kind `validate-constraint`)** each run as one short *explicit* transaction:
`BEGIN` → `SET LOCAL lock_timeout` / `statement_timeout` → the statement →
`COMMIT` (`pkg/executor`'s bounded runner). The explicit `BEGIN` exists
only because the budgets are applied with `SET LOCAL`, which is scoped to
that transaction — functionally it is still one statement, one
transaction, committed immediately, rolled back atomically on failure.
`COMMIT` (`pkg/executor`'s bounded runner). When the preflight proof
carries a schema, the same `SET LOCAL` pins `search_path` to that schema
then `public`, so the statement's unqualified secondary names — a
column's type, an expression's function — resolve in the target schema,
exactly as the introspection read path resolves them. The explicit
`BEGIN` exists only because the settings are applied with `SET LOCAL`,
which is scoped to that transaction — functionally it is still one
statement, one transaction, committed immediately, rolled back
atomically on failure.
- **`CREATE INDEX CONCURRENTLY` (step kind `concurrent-index-build`)** is
true autocommit on a dedicated budgeted session: it refuses to run inside
any transaction block and internally manages multiple transactions of its
Expand Down
15 changes: 15 additions & 0 deletions docs/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,20 @@ every desired statement's target against the absence proof the same way), `pkg/s
(proof construction).
*Source:* adversarial review of the optimistic front door.

### ST-8 — A desired schema's statements carry execution order in the proof

A `statement.DesiredSchema` orders its statements for execution at construction — the
`CREATE TABLE` first, the indexes keeping their input order after it — so every replay of
the file states the same order and the position mapping between a greenfield plan's
statements and the create path's step verdicts holds by construction, not by each replay
site re-deriving the rule. A set that does not lead with a `CREATE TABLE` means the proof
was forged or mutated, and every consumer refuses it fail-closed rather than reordering.
*Enforced:* `pkg/statement` (`ParseDesired` establishes the order), `pkg/diffplan`
(`qualifiedDesired` asserts it when rendering the greenfield plan), `pkg/executor`
(`admitCreateSteps` asserts it before anything runs); `pkg/schemadiff`'s scratch
materialization relies on it to run the `CREATE TABLE` before its indexes.
*Source:* adversarial review of the declarative front door.

## Refusals and preflight (RF)

Each refusal is a preflight **error with a stated reason** — never a warning, never attempted.
Expand Down Expand Up @@ -346,4 +360,5 @@ about **how we write and review the code**.
| ST-1, ST-2, ST-3, ST-4 | 8 | kill/resume, cross-version refuse, orphan-slot reap, failover reconcile |
| ST-6 | 1 onward, complete by 8 | preflight matrix |
| ST-7 | 1 | target-mismatch refusal + single-statement-by-construction tests |
| ST-8 | 2 (declarative model) | parse-time ordering + forged-proof refusal tests at every replay site |
| OC-1..OC-6 | shape APIs from 2; bind at 11 | engine-contract tests |
3 changes: 2 additions & 1 deletion docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ 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. |
| Greenfield table creation | Not user-reachable yet: the executor create path (`executor.ExecuteCreate`) exists as a library building block, but the declarative front door does not route to it. The path runs a plain `CREATE TABLE` plus plain index builds on the table born that run, and refuses at admission — before anything executes — every clause that binds to an existing object the absence proof does not cover: `PARTITION OF`, `INHERITS`, `LIKE`, and `OF`, plus `IF NOT EXISTS`. `REFERENCES` and `CONCURRENTLY` are refused upstream at desired-file parse (`statement.ParseDesired`); the create path's admission re-checks them as defense in depth. |
| Greenfield table creation | Reachable through desired-state execution (`migrate.RunDesired`, library-only today — no CLI verb): a plan whose table does not exist routes to the executor create path (`executor.ExecuteCreate`). The path runs a plain `CREATE TABLE` plus plain index builds on the table born that run, and refuses at admission — before anything executes — every clause that binds to an existing object the absence proof does not cover: `PARTITION OF`, `INHERITS`, `LIKE`, and `OF`, plus `IF NOT EXISTS`. `REFERENCES` and `CONCURRENTLY` are refused upstream at desired-file parse (`statement.ParseDesired`); the create path's admission re-checks them as defense in depth. |
| 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
Expand All @@ -41,6 +41,7 @@ composition of the model boundaries above with those gates. At a glance:

| Desired-file edit | Outcome today |
| --- | --- |
| A desired file whose table does not exist yet | Converges — the greenfield create path verifies the name is free and the role holds `CREATE` on the schema, then runs the `CREATE TABLE` and the index builds as brief bounded steps. An occupied name (a relation or standalone type) is a typed `create-collision` refusal; `PARTITION OF` and `IF NOT EXISTS` are typed refusals before anything runs. |
| 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. |
Expand Down
7 changes: 4 additions & 3 deletions docs/optimistic-attempt.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,10 @@ What happens to one statement, in order:
verb — [limitations.md](limitations.md)), and runs a whole-plan admission gate
before any statement enters the walk: the plan is refused all-or-nothing when the
plan derived at execution time is not the pinned one (`plan-fingerprint-mismatch`),
the target table does not exist (`unsupported-statement`), any planned statement
discards live structure (`destructive-change`), or the plan as a whole does not
route to execute. Past admission, each derived statement walks the same gates
any planned statement discards live structure (`destructive-change`), or the plan
as a whole does not route to execute. A plan whose table does not exist routes past
admission to the executor's greenfield create path — brief bounded steps, not the
per-statement walk below. Past admission, each derived statement walks the same gates
below — including the size guard, which is per-statement, never plan-level: a
multi-statement plan can be refused at statement 3 with statements 1 and 2
already committed (the committed prefix remains, Exit 7).
Expand Down
Loading
Loading