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
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ combination is the product is [vision.md](vision.md); start there.
| [plan-report.md](plan-report.md) | The **plan report contract** — the versioned JSON shape both front doors emit for dry-run plans: fields, closed vocabularies, the fingerprint identity, required consumer behavior for unknown versions/values, and one generated example per source (pinned by test). |
| [lint-report.md](lint-report.md) | The **lint report contract** — the versioned JSON shape `pg-sprite lint` emits for offline CI gating: finding fields (verbatim SQL, line/column), the codes table, severities and exit behavior, the offline-conservatism rules, and how the contract versions relative to the plan report. |
| [suggest-report.md](suggest-report.md) | The **suggest report contract** — the versioned JSON shape `pg-sprite suggest` emits for offline advice: the typed caveat vocabulary (what changes about how you must run a safer form, and what a failed step leaves behind), the typed guidance codes for rewrites the planner cannot construct, and the operation → safer form → caveats table (pinned by test). |
| [engine-role.md](engine-role.md) | The **engine-role provisioning contract** — the tiered minimum access a PostgreSQL user needs to run schema changes against tables it does not own: role membership for owner-gated DDL, schema `CREATE` for index builds and shadow objects, `SET ROLE` for owner-correct shadow creation, replication access for CDC, and the explicit list of powers the engine role must *not* have. Preflight refusals name the missing `GRANT` and point here. |
| [invalid-index-recovery.md](invalid-index-recovery.md) | The **operator runbook** for the one native-path outcome that needs a human — an invalid index the executor found or may have left. What each typed state licenses: when `DROP INDEX CONCURRENTLY` is proven safe, when the entry may be another actor's healthy in-flight build, and what to check when the executor could prove nothing. |
| [testing.md](testing.md) | The **test-suite guide** — how to run the suite (unit, per-major, all supported majors, compose database), current coverage, the remaining executor-phase test obligations, and the vanilla-PostgreSQL-matrix vs real-Aurora validation boundary. |
| [schemabot-integration.md](schemabot-integration.md) | The **single home for orchestrator integration** — how SchemaBot (the reference orchestrator) drives the engine: the pluggable-engine overview, the verb mappings, the concrete adapter contract, and the design constraints (OC-* invariants) the integration imposes on the core. |
Expand Down
98 changes: 98 additions & 0 deletions docs/engine-role.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# The engine role: what access schema changes actually need

This is the provisioning contract for the PostgreSQL role pg-sprite connects as — the
**engine role**. It answers one question precisely: *what is the minimum access a database
user needs to run schema changes against tables it does not own?* Every requirement here is
mechanically checkable, and the engine's preflight refuses with the exact missing `GRANT`
rather than failing mid-change.

The contract is deliberately tiered: a team that only ever needs in-place `ALTER TABLE` and
online index builds should not be asked to provision replication access it will never use.

## Why ownership, not privileges

PostgreSQL has no grantable "ALTER" privilege. `ALTER TABLE`, `CREATE INDEX` against a
table, and the rename-swap of copy-and-swap are all **owner-gated**: they require the
current role to *be* the table's owner — or to be a **member of the owning role**, because
ownership checks pass through role membership. Membership is therefore the mechanism this
contract is built on:

```sql
GRANT app_owner TO pgsprite_engine;
```

is the entire trick. The engine role never logs in as the application's user, never needs
the owner's password, and losing the membership fails closed — the next owner-gated
statement is refused by the server with `must be owner of table ...`.

Two facts about created objects complete the picture (both verified against a live server;
the version matrix in [postgresql-version-support.md](postgresql-version-support.md)
applies):

- **An index belongs to the table's owner**, regardless of which member role created it —
the native index path is ownership-correct automatically.
- **A new table belongs to the role that created it.** A shadow table created by the engine
role would be owned by the engine role — which the cutover fidelity checklist (see
[low-level-design.md](low-level-design.md)) would refuse to swap. The engine therefore
runs `SET ROLE <owner>` before creating shadow objects, so they are born with the correct
owner rather than repaired afterward.

## The tiers

Each tier includes everything above it. A schema change is admitted at the tier its plan
requires — nothing higher.

| Tier | Capability | Required access | Preflight check |
| --- | --- | --- | --- |
| 0 | Connect and resolve the target | `LOGIN`; `CONNECT` on the database; `USAGE` on the target schema (directly or via membership) | `has_database_privilege`, `has_schema_privilege(..., 'USAGE')` |
| 1 | In-place `ALTER TABLE` (the instant and fast native paths) | Inheritable **membership in the owning role** — sufficient on its own | `pg_has_role(current_user, <owner>, 'USAGE')` |
| 2 | Index builds (`CREATE INDEX [CONCURRENTLY]`) | Tier 1 + **`CREATE` on the target schema** — table ownership alone is refused with `permission denied for schema` | `has_schema_privilege(..., 'CREATE')` |
| 3 | Copy-and-swap | Tier 2 + membership usable with `SET ROLE` (for owner-correct shadow objects); for logical-decoding CDC: `rds_replication` membership (Aurora/RDS) or the `REPLICATION` attribute (self-managed) | `pg_has_role(..., 'MEMBER')` (14–15) / `pg_has_role(..., 'SET')` (16+); `pg_has_role(current_user, 'rds_replication', 'MEMBER')` |
| 4 | Planner scratch database (execute-and-introspect) | A pre-provisioned `pg_sprite_scratch` owned by the engine role, **or** `CREATEDB` | `pg_database` ownership or `pg_roles.rolcreatedb` |

Two cluster-level *facts* — settings, not grants — accompany Tier 3 and are checked in the
same preflight: `wal_level = logical` (`rds.logical_replication = 1` on Aurora/RDS, a
static parameter requiring a reboot), and free `max_replication_slots` /
`max_wal_senders` headroom.

## Provisioning

For a target whose tables are owned by `app_owner` in schema `app`:

```sql
-- Tier 1: owner-gated DDL through membership
GRANT app_owner TO pgsprite_engine;

-- Tier 2: index builds and shadow objects live in the schema
GRANT USAGE, CREATE ON SCHEMA app TO app_owner; -- if the owner lacks it

-- Tier 3: only when copy-and-swap with logical decoding is in play (Aurora/RDS)
GRANT rds_replication TO pgsprite_engine;
```

One membership grant per owning role: a database where every schema is owned by one
application role needs exactly one `GRANT`. On PostgreSQL 16+ the membership defaults
include `SET TRUE` and `INHERIT TRUE`, which this contract relies on; grants issued with
`WITH SET FALSE` or to a `NOINHERIT` engine role break Tiers 1 and 3 and are caught by the
preflight checks above.

## What the engine role must not have

The contract is as much about what is absent:

- **No superuser.** Aurora does not offer it, and nothing here needs it.
- **No `rds_superuser`.** It does not bypass ownership checks and adds unrelated power.
- **No application login.** The engine role is its own identity; audit logs distinguish
engine DDL from application traffic.
- **No ownership transfer.** The application role keeps owning its objects before, during,
and after every schema change; the engine borrows owner rights through membership and
`SET ROLE`, both revocable with one statement.
- **No `GRANT OPTION` / no `CREATEROLE`.** The engine never grants anything to anyone.

## How the engine enforces this

Preflight resolves the target table's owner from the catalog (`pg_class.relowner`), then
evaluates the tier checks for the change's planned strategy. A missing requirement is a
typed refusal naming the exact `GRANT` statement that would satisfy it — the same
fail-closed posture as every other refusal in the engine, and the reason this page exists:
the refusal points here, and this page says what to provision and why it is safe.
2 changes: 1 addition & 1 deletion docs/low-level-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ matrix is part of the "decisions, not options" philosophy.
| --- | --- | --- |
| `rds.logical_replication = 1` (static → reboot) ⇒ `wal_level = logical` | logical-decoding CDC path | Fall back to **trigger-based** CDC |
| `rds_replication` role granted (Aurora gives no `SUPERUSER`) | creating slot / starting replication | Use trigger fallback, or request the grant |
| Ownership / `CREATE` on the schema | shadow table, triggers, swap | Migration cannot run |
| Owning-role membership + schema `CREATE` (the tiered [engine-role contract](engine-role.md)) | all owner-gated DDL: in-place `ALTER`, index builds, shadow table / triggers / swap | Preflight refuses, naming the missing `GRANT` |
| `max_replication_slots` / `max_wal_senders` headroom | concurrent migrations | Serialize migrations |
| `REPLICA IDENTITY` = PK (default) or `FULL` | correct UPDATE/DELETE capture; unchanged-TOAST columns | v1 requires a PK, so default identity suffices |

Expand Down
8 changes: 8 additions & 0 deletions docs/schemabot-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ implementation time):
there and cannot use a hot standby (the live table is never written). This is a
credential-posture question for every deployment — raise it while the adapter is a design,
not during a least-privilege review.
- **The engine role's access is tiered and per-target.** Target databases routinely have
their DDL owned by a role that is not the orchestrator's connection user; the engine does
not need to *be* that owner — it needs membership in the owning role, plus schema
`CREATE` and replication access depending on the strategy (the full contract, including
what the role must *not* have, is [engine-role.md](engine-role.md)). The adapter surfaces
this per target: each configured database names its engine-role credentials, and a target
whose grants stop at Tier 1 can still run in-place changes while copy-and-swap refuses
with the exact missing `GRANT`.
- **Fan-out is per table.** A `DesiredSchema` is one `CREATE TABLE` plus its indexes, while
SchemaBot's declarative roots are directories of many tables: the adapter loops `Plan` per
table and merges into one `PlanResult`. One pool serves the whole fan-out (`Plan` does not
Expand Down
Loading