Skip to content

Latest commit

 

History

History
433 lines (367 loc) · 37.8 KB

File metadata and controls

433 lines (367 loc) · 37.8 KB

Capabilities and support matrix

One page answering the three questions users actually ask: does pg-sprite support this change?, what is pg-sprite and why does it exist?, and what does pg-sprite deliberately not do? Every operation and object type lands in exactly one of three tiers, and every planned tier-2 item corresponds to a real roadmap item — nothing is vaguely "future work".

This page is the support matrix; the mechanics of each current refusal (what lock the refused form would take, what an operator who accepts a maintenance window can do) live in limitations.md.

Contents

A ✅ marks an implemented capability; check the front-door columns for CLI access. The atomic RLS executor is available through migrate --desired and the Go API; diff shows RLS changes for review without emitting executable SQL.

Query the matrix

The marker-delimited regions of this page are generated from pkg/capabilities/capabilities.yaml; the surrounding guidance is hand-written. Run pg-sprite capabilities for a compact table, or use pg-sprite capabilities --json for automation. Both forms read the embedded YAML data only and do not connect to PostgreSQL. The machine-readable capabilities contract defines the JSON schema and versioning rules. Array order is source order and stable for display, but select rows by id or by field, never by array position.

# Is this one operation supported today? Look a row up by its id.
pg-sprite capabilities --json | jq '.capabilities[] | select(.id == "add-column-no-default-or-constant-default")'

# All T2 rows.
pg-sprite capabilities --json | jq '.capabilities[] | select(.tier == "t2")'

# What is waiting on the copy engine, across tiers.
pg-sprite capabilities --json | jq '.capabilities[] | select(.engine_path == "copy_and_swap")'

# Everything diff refuses. Query the door you use: RLS changes are review-only
# in diff, but can execute through migrate --desired.
pg-sprite capabilities --json | jq '.capabilities[] | select(.front_doors.diff == "refused")'

# Rows another tool class owns: the ⚪ and 🔵 rows. The ❌ rows name no owner, because
# PostgreSQL offers no online mechanism for them, so this is not the full out-of-scope set.
pg-sprite capabilities --json | jq '.capabilities[] | select(.owning_tool_class != null)'

What pg-sprite is — and why it exists

pg-sprite is an online schema-change engine for PostgreSQL: it takes one table-shape change, classifies it against the live database, and either executes it through the safest known online pattern under bounded sessions, or refuses with a typed reason. The measure of the tool is not how many object types it models but whether a change it accepts can hurt a production workload. The full positioning is vision.md; how it differs from planners and imperative copy tools by problem class is architecture.md.

This is the Unix design philosophy applied to schema changes — do one thing, and do it perfectly. The one thing is online table-shape change under concurrent load; this whole page is the map of where that one thing ends and another tool's job begins.

Two consequences follow, and they explain most of this page:

  1. Tables and their indexes are the model, by design. Online safety is a readers-and-writers problem, and readers and writers touch tables. Objects with no concurrent-access problem (extensions, functions, grants) are not in scope — not because they are hard, but because there is nothing for an online engine to solve.
  2. A refusal is a feature, not a gap. migrate and its dry-run exit with code 0 only when the change is executable through an online-safe path; a refusal exits 2 with a typed reason. CI can gate on the exit code alone. That contract is only worth something if pg-sprite never executes what it cannot vouch for — see Why typed refusal, not passthrough. The one sanctioned exception keeps the gate honest: a blocking form the operator explicitly accepts runs under bounded budgets and exits 3, never 0, so a gate that fails on any non-zero status stays fail-closed (the full ladder).

The support model: three tiers

Tier Meaning What you see today
T1 — supported today The engine executes the change through an online-safe pattern Execution (or the safer rewritten sequence), exit 0; where migrate refuses a plain index-maintenance form and names its CONCURRENTLY idiom, --accept-blocking runs the plain form under budgets and exits 3
T2 — planned A known online pattern exists (or requires the copy-and-swap engine); building it is on the roadmap A typed refusal naming the reason, exit 2 — never a silent fallback to a blocking form
T3 — out of scope by design No online-safety problem to solve, solving it belongs to a different tool class, or PostgreSQL offers no online mechanism to build on A typed refusal or a parse-level rejection, with the reason stating why it is not planned

T3 rows carry one of three marks, because they mean different things — and only one of them is a limitation:

  • ⚪ no online-safety problem to solve — the operation does no table scan and no rewrite; at most it takes a brief catalog lock. There is nothing for an online engine to add; run it through owner tooling or psql. Where that brief lock lands on a live table (a trigger, a view swap, a greenfield foreign key), the row says so: the statement queues behind long-running queries and blocks sessions behind it while it waits, so run it under a lock_timeout.
  • 🔵 a different tool class owns it — the job is real but belongs to another kind of tool (data-change runners, provisioning/IaC, convergence planners, expand/contract frameworks). The row's "Online-safety problem?" column names the class to look for.
  • ❌ no online mechanism exists — PostgreSQL itself provides no online pattern to build on, so pg-sprite refuses rather than silently run the blocking form. These are the only rows where "unsupported" is the honest reading.

Every matrix table carries an "Online-safety problem?" column: "Yes" means there is a readers-and-writers problem for an online engine to solve (pg-sprite solves it, plans to, or — ❌ — nothing can today); "No" states which tool class users should reach for instead.

The invariant: every T2 row is a tracked roadmap item; T3 rows deliberately have none. If a refusal message points at a "planned" capability, that plan exists — otherwise the change is out of scope by design, and this page — not the refusal text, which today is one undifferentiated unsupported-statement reason for everything outside the imperative front door — names the tool class that owns the job.

The engine path

Tier answers whether pg-sprite stands behind a change; the Engine path column in each matrix table answers how — the route the change takes (or will take) through the engine:

  • native, as-is — the statement is already online-safe (metadata-only, or already the online idiom); executed directly under bounded sessions. The bound is lock_timeout/statement_timeout, except that a concurrent index build may instead run under an explicit caller-owned cancellable context with statement_timeout disabled. That bounds the client call, not the server statement: the executor refuses a context that cannot be cancelled, but a client that dies without cancelling leaves the build running server-side; Tracker.CancelBuild (or an operator's pg_cancel_backend) is the stop path.
  • native, safer sequence — the blocking form is substituted with the equivalent online sequence before execution; the rewrites are catalogued in safer-sequences.md.
  • native, planned flow — a native online pattern exists (or a modeling gap is being closed); the row is a typed refusal until that flow lands, and each such row is a tracked roadmap item.
  • copy-and-swap — the change is a genuine table rewrite and routes to the shadow-copy engine; a typed refusal until that engine lands (see optimistic-attempt.md for how the router proves the rewrite).
  • — — no engine path: either the job belongs to another tool class (⚪ / 🔵) or PostgreSQL offers no online mechanism to build on (❌).

Path and tier are orthogonal on purpose: a 🟡 row's path says what kind of work lifts the refusal — building a native flow or landing the copy engine — and a ✅ row's path says whether the engine ran your statement or substituted a safer one.

The two front doors

Support differs by front door, so the matrix marks the exceptions:

  • Imperative (migrate --alter, plan, lint, suggest): takes one DDL statement, classifies it, and executes the online form — rewriting a blocking statement into its safer sequence where one exists. This door has the broadest coverage.
  • Declarative (diff, pull, desired files): compares a desired CREATE TABLE file against the live table. This door depends on the canonical table model, which is deliberately narrower: a table the model cannot fully describe gets a typed refusal rather than a silently lossy description. Today that means tables that are partitioned (or are partitions), participate in classic table inheritance, own or are referenced by foreign keys, are unlogged, carry explicit collations, or take defaults from sequences they do not own.

An operation can therefore be T1 imperatively and T2 declaratively — foreign keys are the canonical example.

Support matrix

Editing the matrix: edit ../pkg/capabilities/capabilities.yaml, then run make gen-capabilities; do not edit the generated regions below by hand.

54 operations: 19 supported today, 19 planned behind a typed refusal, 14 out of scope by design, and 2 with no online mechanism in PostgreSQL to build on.

Status legend: ✅ T1 (supported today) · 🟡 T2 (planned; typed refusal today) · ⚪ T3 (out of scope; no online-safety problem — run directly, or through whatever review the object warrants) · 🔵 T3 (out of scope; a different tool class owns it) · ❌ T3 (out of scope; no online mechanism exists in PostgreSQL).

Column changes

Operation Status Engine path Online-safety problem? Behavior and why
ADD COLUMN (no default, or constant default) ✅ native, as-is Yes Metadata-only / fast default (PG 11+); executes instantly under bounded locks
ADD COLUMN with volatile default (now(), gen_random_uuid(), …) 🟡 copy-and-swap Yes Table rewrite; routes to copy-and-swap and is refused until that engine lands
ADD COLUMN ... GENERATED ... STORED 🟡 copy-and-swap Yes Table rewrite; copy-and-swap route. The copy engine must recompute, never copy, generated columns on the shadow table
ADD COLUMN with inline UNIQUE/PRIMARY KEY/REFERENCES/CHECK 🟡 native, planned flow Yes The inline constraint does its index build or validation scan under the ADD COLUMN's ACCESS EXCLUSIVE lock; refused with guidance to add the column first, then build the constraint online
DROP COLUMN ✅ native, as-is Yes Metadata-only; flagged destructive in the plan report
ALTER COLUMN TYPE, binary-coercible (proven against live column facts) ✅ native, as-is Yes Catalog relabel, e.g. varchar(50) → varchar(100), varchar → text; PostgreSQL itself refuses the change when a view, rule, or STORED generated column depends on the column — see binary-coercible-type-changes.md
ALTER COLUMN TYPE, general (or with USING) 🟡 copy-and-swap Yes Table rewrite; copy-and-swap route, refused today
SET DEFAULT / DROP DEFAULT / DROP NOT NULL ✅ native, as-is Yes Metadata-only
SET NOT NULL ✅ native, safer sequence Yes Executed as the native four-step pattern: ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID → online VALIDATE → SET NOT NULL (catalog flip, PG 12+) → drop the scaffold check
RENAME COLUMN / RENAME TABLE ✅ native, as-is Yes Metadata-only for PostgreSQL but app-breaking across deployed instances; executed with a typed reason so lint/plan consumers can steer away
SET TABLESPACE 🟡 copy-and-swap Yes Physical relocation is a rewrite; copy-and-swap route

Constraints

Operation Status Engine path Online-safety problem? Behavior and why
ADD PRIMARY KEY / ADD UNIQUE (plain key columns) ✅ native, safer sequence Yes Rewritten to the online sequence: CREATE UNIQUE INDEX CONCURRENTLY → ADD CONSTRAINT ... USING INDEX
ADD CHECK / ADD FOREIGN KEY (imperative) ✅ native, safer sequence Yes Rewritten to the online sequence: ADD CONSTRAINT ... NOT VALID (brief metadata lock) → VALIDATE CONSTRAINT (writes keep flowing during the scan)
ADD CONSTRAINT ... NOT VALID / ... USING INDEX / VALIDATE CONSTRAINT ✅ native, as-is Yes Already the online idiom; executed as-is
ADD FOREIGN KEY ... NOT VALID on a partitioned parent ✅ native, safer sequence Yes — server version 18 or later Supported on version 18 and later; refused on 14–17 with an environmental class
EXCLUDE constraints (and unrecognized constraint forms) ❌ — Yes — unsolvable today No online pattern exists in PostgreSQL — the build scans under ACCESS EXCLUSIVE with no NOT VALID/USING INDEX equivalent. Refused; revisit only if PostgreSQL grows one
DROP CONSTRAINT ✅ native, as-is Yes Metadata-only; flagged destructive

Indexes

Operation Status Engine path Online-safety problem? Behavior and why
CREATE [UNIQUE] INDEX on a plain table — including partial, expression, covering (INCLUDE), GIN/GiST/BRIN ✅ native, safer sequence Yes Executed as (or rewritten to) CREATE INDEX CONCURRENTLY, with validity verification, typed invalid-index outcomes, and a proven recovery for abandoned leftovers (RebuildAbandonedIndex, or DropAbandonedIndex to remove the leftover without rebuilding; both library-only; runbook)
DROP INDEX ✅ native, safer sequence Yes Rewritten to DROP INDEX CONCURRENTLY; flagged destructive. migrate refuses the plain form and names the idiom; a single-index DROP INDEX can be run as-is under bounded budgets with --accept-blocking SCHEMA.TABLE (exit 3, never 0)
REINDEX ✅ native, safer sequence Yes Rewritten to REINDEX ... CONCURRENTLY. migrate refuses the plain form and names the idiom; a plain REINDEX INDEX or REINDEX TABLE can be run as-is under bounded budgets with --accept-blocking SCHEMA.TABLE (exit 3, never 0). REINDEX SCHEMA, DATABASE, and SYSTEM stay refused
Index build on a partitioned parent 🟡 native, planned flow Yes PostgreSQL has no parent-level CONCURRENTLY; the blocking form is refused by policy (--force does not bypass it, and --accept-blocking does not reach it yet). The partition-aware flow — CREATE INDEX ON ONLY → per-partition CIC → ATTACH PARTITION, with crash-resume per leaf — is planned
ADD CONSTRAINT ... USING INDEX on a partitioned parent ❌ — Yes — unsolvable today PostgreSQL does not support adopting an index on a partitioned parent in any supported version; refused before execution

Partitioned tables

Operation Status Engine path Online-safety problem? Behavior and why
CREATE TABLE ... PARTITION OF 🟡 native, planned flow Yes Typed refusal at both doors: the imperative door does not take CREATE TABLE, and the declarative create path refuses the form at plan time and re-checks it at apply — 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 ✅ native, as-is 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] ✅ native, safer sequence Yes CONCURRENTLY is the idiom; the blocking form is rewritten to it
Partitioned parents in the declarative model 🟡 native, planned flow 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
Partitioned tables in copy-and-swap 🟡 copy-and-swap Yes Root-vs-leaf publication semantics and per-partition swap; sequenced after the copy engine core

The declarative model (desired files, diff, pull)

Table shape Status Engine path Online-safety problem? Behavior and why
Plain tables + their indexes ✅ native, as-is Yes diff, pull, and desired-file rendering round-trip the canonical model. The model carries each index's validity (pg_index.indisvalid): on a plain table an invalid entry is a concurrent build that did not finish — abandoned, or still running — so a live entry with the desired name and definition does not deliver the desired index, and diff plans it as a create-index change. diff never emits a drop for an invalid entry, whatever the desired file says about its name: a plain DROP INDEX blocks the table and cannot tell abandoned debris from a build still in progress. A pulled baseline of such a table therefore re-diffs to that one rebuild rather than to zero. On a partitioned parent an invalid index means unattached partition indexes, not an unfinished build, so validity plays no part in the parent's comparison
Classic table inheritance (INHERITS) 🟡 native, planned flow Yes Typed refusal for both parents and children: the model cannot express inheritance edges, and flattening inherited columns would produce a silently lossy baseline
Tables that own or are referenced by foreign keys 🟡 native, planned flow Yes Typed refusal on both sides — an incoming FK cannot be expressed in the table's own desired file, and a lossy description would be worse than none. Declarative FK support (composite keys as the primary case, two-phase NOT VALID → VALIDATE execution) is planned
Unlogged tables 🟡 native, planned flow 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 🟡 native, planned flow 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 🟡 native, planned flow 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) ✅ native, as-is 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: CheckTableAbsent verifies the table relation and composite-type name are free, the executor verifies every relation name the desired file states (explicit index names and first-choice constraint-index and column-sequence names) is free in the schema, and CheckCreatePrivileges verifies the role can create there. It then runs the CREATE TABLE and index builds as brief bounded steps under the engine's budgets. An occupied claimed name is a typed create-collision refusal before execution — drop or rename the occupant, name a constraint's index explicitly, or for a sequence use an explicitly named sequence or a non-serial column. Duplicate-name SQLSTATEs backstop races for explicit names; for server-chosen names, the probe narrows the race to the time-of-check window, and after the CREATE TABLE commits the executor reads the constraint-index and sequence names the table actually owns and compares them against the claimed first-choice names — a name taken inside the window makes the server pick a suffixed replacement, which surfaces as a typed create-name-mismatch failure at step 1 with the born table left in place for an operator to rename the relation or drop, then re-diff (a read of the owned names that does not complete is the same step-1 failure as create-names-unverified, never a pass). PARTITION OF, INHERITS, LIKE, OF, IF NOT EXISTS, and in-set duplicate names refuse at plan time and are re-checked at apply, 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

Object / operation Status Engine path Online-safety problem? Behavior and why
Enum-typed columns on plain tables 🟡 native, planned flow Yes Tolerance end to end (introspection already canonicalizes via format_type; desired-file admission and transaction-scoped scratch-schema mechanics are being verified)
ALTER TYPE ... ADD VALUE 🟡 native, planned flow Yes Metadata-only and online-safe (PG 14+ allows it in a transaction; the value is usable after commit) — planned as an owned operation. No peer online executor owns it
Enum value rename / removal 🟡 copy-and-swap Yes PostgreSQL has no DROP VALUE; this is a type swap + table rewrite — routes to a typed refusal toward copy-and-swap
Enum/domain type creation and drop ⚪ — No — owner tooling (psql, shipped with the code change) Bootstrap/catalog work with no concurrent-access problem; owner tooling applies it in the same change that ships the code
Views, materialized views (create and replace) ⚪ — No — owner tooling Transactional catalog work, but CREATE OR REPLACE VIEW takes a brief ACCESS EXCLUSIVE on the view and queues behind in-flight readers — run it under a lock_timeout
REFRESH MATERIALIZED VIEW 🔵 — No — data jobs / owner tooling A data operation, not catalog work: the plain form holds ACCESS EXCLUSIVE on the matview for the whole rebuild (CONCURRENTLY needs a unique index and trades the lock for churn). Scheduling refreshes belongs to data jobs
PL/pgSQL function bodies (CREATE OR REPLACE FUNCTION) ⚪ — No — owner tooling Transactional catalog work that takes no lock on any relation; nothing for an online engine to add. No peer online executor owns it either
Triggers (CREATE TRIGGER) ⚪ — No — owner tooling Catalog work — no scan, no rewrite — but it takes a brief SHARE ROW EXCLUSIVE on the table, queues behind long-running queries, and blocks writers while it waits — run it under a lock_timeout
Extensions (CREATE EXTENSION) ⚪ — No — owner tooling Same: catalog bootstrap, owner tooling
Grants, roles, and imperative policy DDL 🔵 — No — provisioning / IaC Access control changes remain with provisioning. Export includes RLS when present; explicit RLS declarations support comparison and review-only deltas with advisory access warnings. Plain table files leave access control separately managed. The separate atomic RLS Go executor supports RLS-only changes on existing supported tables; migrate --desired uses this executor; imperative policy statements remain refused. See the workflow and roadmap. See engine-role.md for the engine's own role
Complete table-local RLS definition (Go API and migrate --desired) ✅ native, safer sequence Yes The atomic RLS executor locks an existing supported table, refuses structural changes, replaces policies/settings, and verifies convergence before committing. Go callers can preview the ordered SQL and require it to match under lock before execution. This is a SQL review contract, not a catalog snapshot. Lock waits and the whole transaction are bounded. Available through migrate --desired without RLS-specific flags; diff remains a review-only surface
Standalone sequences ⚪ — No — owner tooling Transactional catalog work on an object with no readers-and-writers problem
Publications, subscriptions 🔵 — No — replication provisioning / IaC Replication provisioning, not table shape (ALTER PUBLICATION ... ADD TABLE also takes SHARE UPDATE EXCLUSIVE on the table)

Data and whole-table operations

Operation Status Engine path Online-safety problem? Behavior and why
DROP TABLE ⚪ — No — owner tooling, through a reviewed process Discards the table and its data in one brief ACCESS EXCLUSIVE step: there is nothing online for an engine to make safer, only an irreversible decision an operator must own. Both front doors refuse it — the imperative door as an unsupported statement kind, and the declarative diff is single-table scoped, so a live table with no desired file is not in its view. pg-sprite never plans or executes it; accounting for undeclared tables is the whole-schema owner's job, described under Deliberately operator-owned
Data backfills, UPDATE/DELETE batches, DML of any kind 🔵 — No — data-change runners, application batch jobs pg-sprite changes table shape, never table contents. Versioned-script runners and application jobs own data changes
Column-transform expressions during a copy-and-swap rewrite 🟡 copy-and-swap Yes The one principled exception: when a rewrite is already copying every row, deriving a new column's value by expression is part of the shape change, not a data job. Planned as part of the copy engine
Online table rebuild with no shape change (bloat reclamation) 🟡 copy-and-swap Yes A copy-and-swap with an identical target shape — the pg_repack use case with checksum-gated cutover and crash-resume. Planned once the copy engine lands
Whole-schema convergence (apply a directory of desired files, dependency-ordered) 🔵 — No — convergence planners (pg-schema-diff, pgschema, pgdelta) Convergence planning across objects is a planner's job; pg-sprite stays the execution engine for the table-shape subset
Versioned schema-change-file workflow (Flyway-style ordered scripts) 🔵 — No — versioned-script runners (Flyway-style) Declarative-only by design; see vision.md
Expand/contract dual-schema versions (pgroll/reshape style) 🔵 — No — pgroll/reshape own this model Rejected: application invisibility is a core invariant; see vision.md

Peers share these limits — for different reasons

Every tool in this space draws a line around what it models. What differs is why the line sits where it does, and what happens when you cross it:

  • Imperative online executors (pg-osc, pg_repack; gh-ost and Spirit in MySQL) never create enums, extensions, or triggers because the question never arises: they execute exactly the ALTER handed to them. The scope limit is implicit and undocumented — you discover it when raw SQL errors out mid-change.
  • pgroll models a JSON operation vocabulary, and everything outside it goes through a raw-SQL passthrough: executed verbatim, with none of pgroll's safety machinery applied. That is passthrough, not support — the tool runs what it cannot analyze.
  • Declarative planners (pg-schema-diff, pgschema, pgdelta) model far more breadth — enums, views, functions — because their product is a DDL artifact, not an execution. Breadth is cheap when you don't own what happens under concurrent load.

pg-sprite's position: model narrowly, execute what the model covers with provable online safety, and make every boundary a typed refusal, with this page stating its tier — planned (with a real plan) or out of scope (with the tool class that owns the job). The scope limit is explicit, documented on this page, and machine-checkable via exit codes.

Why typed refusal, not passthrough

The obvious middle ground — refuse, then offer --apply-anyway — is deliberately not implemented. The trade was weighed, not overlooked:

What passthrough would buy. One pipeline and one audit trail for every change; no side-channel psql sessions; lower adoption friction when users hit a boundary. pgroll demonstrates the demand is real.

Why it loses. The exit-code contract — 0 means this ran through an online-safe path — is the product. A passthrough mode means pg-sprite executed something it cannot vouch for, and every incident that follows lands on the tool's reputation, not the flag. It also breaks the ownership model that crash recovery depends on: the executor can reason about an interrupted change (see invalid-index-recovery.md) only because it knows exactly what it runs; arbitrary SQL has no resume semantics. And refusals are the forcing function that gets real capabilities built — a passthrough is where demand signals go to die.

What you get instead. Every refusal names the classification, the reason, and — where one exists — the exact safer sequence or the statement an operator can run deliberately, outside the engine, in a maintenance window. The operator stays in control; the engine stays honest. --force never bypasses a policy refusal.

A constrained variant exists: migrate --accept-blocking SCHEMA.TABLE, an explicit, dedicated acceptance (distinct from --force, and not combinable with it) executes an otherwise-refused change through the engine's own bounded lock_timeout / statement_timeout session ("unsafe DDL under a bounded lock budget", which raw psql does not give you), with the refusal analysis still produced before execution and the verdict unmistakably marked as executed without an online-safety guarantee (outcome: executed-without-online-safety, blocking_passthrough: true, exit 3 — never 0). The value must name the table whose lock you accept — the owning table of the index — resolved from the catalog, and --statement-timeout must be spelled on the same command line. Only changes the engine understands but cannot run safely are eligible: today a single-relation plain DROP INDEX, REINDEX INDEX, or REINDEX TABLE. Refusals for unrecognized SQL never are, and the refused statement's blocking_passthrough_eligible field on the plan report says whether a refusal qualifies. An exhausted lock budget is still a refusal (exit 2, nothing ran); a statement cancelled by its budget is a failure (exit 1). The design is lock-budgeted-passthrough.md.

Deliberately operator-owned

Three related jobs stay with humans on purpose:

  • Dropping a table nobody declares any more. The declarative diff is single-table scoped and whole-schema convergence is a planner's job (see the 🔵 row in the matrix), so pg-sprite has no view of which tables a schema should contain — only of each table's shape. Under a declarative model the only convergence for a live table with no desired file is DROP TABLE, and pg-sprite never plans or executes one. Whoever owns the whole schema — an orchestrator, or an operator running diff per file — therefore owns the set of tables: decide how far the declared files are authoritative, enumerate the live tables, and treat each undeclared one as a destructive divergence to resolve rather than as up to date. Declare it (pull or export writes the desired file) or drop it through a reviewed process; whether the owner blocks on it or quarantines it first (ALTER TABLE … SET SCHEMA / RENAME TO) is the owner's policy, not pg-sprite's. A schema being onboarded is entirely undeclared tables, so declare the existing tables first (pull writes one desired file per table), or scope the owner's authority to the schemas it manages. schemadiff.ListManagedTables implements the catalog query used by pull and is available to owners that need the same enumeration. It lists the tables a schema directory is expected to account for, not the files pull can write: partitions are represented through their parent's PARTITION BY and extension members belong to their extension, so neither is listed, while a listed table whose shape export refuses (the declarative model's limits, under The two front doors) is still undeclared and still the owner's to resolve; pull reports each refusal by table. The exclusions matter, because every false positive blocks a table nobody touched:

    SELECT c.relname
    FROM pg_catalog.pg_class c
    JOIN pg_catalog.pg_namespace n
      ON n.oid OPERATOR(pg_catalog.=) c.relnamespace
    WHERE n.nspname OPERATOR(pg_catalog.=) 'app'
      AND (c.relkind OPERATOR(pg_catalog.=) 'r'
           OR c.relkind OPERATOR(pg_catalog.=) 'p')    -- ordinary and partitioned tables only
      AND NOT c.relispartition                         -- partitions belong to their parent
      AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend d
                      WHERE d.classid OPERATOR(pg_catalog.=)
                              'pg_catalog.pg_class'::pg_catalog.regclass
                        AND d.objid OPERATOR(pg_catalog.=) c.oid
                        AND d.deptype OPERATOR(pg_catalog.=) 'e') -- extension-owned tables have no file to write
    ORDER BY c.relname;

    Qualify every relation, operator, and type with pg_catalog.: under a user-first search_path, an unqualified relation or = joins nothing and returns an empty — passing — result, and an unqualified regclass cast stops matching the extension dependency and lists extension members as undeclared. Views, materialized views, foreign tables, and sequences are outside the model and are not undeclared tables. The planner's scratch objects live in a schema of their own (pgsprite_scratch_<random>) inside a transaction that is always rolled back, so a listing scoped to the owner's schema never sees them.

  • Deciding to recover an invalid index. A failed CREATE INDEX CONCURRENTLY leaves an invalid index; an in-flight healthy build looks identical. The build never drops an index itself — PostgreSQL drops by name, not identity, so a drop on the way in could destroy another actor's build. Recovery is a separate, explicit call (executor.RebuildAbandonedIndex, or executor.DropAbandonedIndex when the caller must not rebuild; both library-only) that removes an entry only after proving it abandoned under the table lock and by catalog identity (LK-5), and refuses an in-flight build, another table's entry, or an entry the server will not drop concurrently — a partitioned table's index, an index partition, or a constraint's index is never debris and is never touched. The typed ownership states, which of them the recovery handles, and what the rest license a human to do is invalid-index-recovery.md.

  • Index maintenance (REINDEX automation, bloat-driven rebuild scheduling). The engine executes REINDEX CONCURRENTLY when asked (see matrix); deciding when an index needs rebuilding is monitoring-and-operations territory. Automating it is considered but on hold for the same ownership reasons as above.


Keeping this page honest: any change that adds, lifts, or re-tiers a refusal must update this matrix in the same PR, and each release's notes link here. A support question this page cannot answer is a bug in this page.