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
5 changes: 3 additions & 2 deletions .agents/checks/review.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ the reviewer's distillation.
package-private constructors — never a
raw string or bool that a caller could fabricate. Core code re-verifies its own
preconditions; it never trusts that the planner or CLI checked.
- `statement.DesiredWithRowSecurity` is inspection-only: keep it separate from
`DesiredSchema` and refuse it at every live execution boundary.
- `statement.DesiredWithRowSecurity` stays separate from `DesiredSchema`. Its only
live consumer is `executor.ExecuteRowSecurity`, which must enforce RS-1..RS-4;
generic native and create executors must still refuse policy SQL.
- Invariant enforcement points carry a `// INV: <id>` comment matching
[docs/invariants.md](../../docs/invariants.md); violations use `ErrInvariantViolation`
naming the ID and abort fail-closed — never a warning, never retried.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ refusal — never a silently wrong or incomplete result:

- **Copy-and-swap** (genuine table rewrites) is not yet available — those
changes refuse rather than fall through to a blocking rewrite.
- **Row security** is included in exports when present, with reviewable before/after differences. Applying
policy changes is not supported yet. See the workflow and roadmap in
- **Row security** is included in exports when present, with reviewable before/after differences. The
[atomic Go executor](docs/atomic-row-security.md) can apply RLS-only changes to
existing tables; CLI execution and mixed table/policy changes remain unsupported. See the workflow and roadmap in
[declarative-row-security.md](docs/declarative-row-security.md).
- **Foreign keys** are out of the declarative model in either direction:
desired files cannot declare them, and export refuses both a table that
Expand Down
10 changes: 7 additions & 3 deletions SAFETY.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ The short version — the full rules live in [docs/tcb-model.md](docs/tcb-model.
`preflight.PrivilegedRole`, `preflight.CopySwapTarget`, `dbconn.TableLock`,
`checksum.VerifiedShadow`, and `checksum.CleanWatermark`); dangerous APIs accept only proof types —
e.g. the planned cutover swap will accept only a `VerifiedShadow`.
- `statement.DesiredWithRowSecurity` proves admission for rolled-back scratch
inspection only. It is distinct from `DesiredSchema` and must never be accepted
by a live executor.
- `statement.DesiredWithRowSecurity` proves declaration syntax, not execution safety. It stays distinct from
`DesiredSchema`. Only `executor.ExecuteRowSecurity` may consume it for live RLS:
that executor locks, checks table equality, and verifies convergence in one transaction.
- **Put a limit on everything.** Every loop bounded, every queue bounded, every retry counted,
every wait deadlined. An unbounded anything in a core package is a review-blocking defect.
- **Assert the positive and the negative space; pair assertions across boundaries.** Invariant
Expand Down Expand Up @@ -127,3 +127,7 @@ The short version — the full rules live in [docs/tcb-model.md](docs/tcb-model.
test-first with the invariant's named test obligation, small diffs, careful review.
- **Outside the core: more AI, less steering.** Iterate at inference speed; the boundary means
a bug in the periphery cannot corrupt data.

The atomic RLS executor also admits `pkg/schemadiff` scratch introspection, table
comparison, render admission, and catalog-derived RLS rendering into the core. Those calls refuse mixed or
unsupported table shapes; final catalog comparison gates commit (RS-1..RS-4).
86 changes: 86 additions & 0 deletions docs/atomic-row-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Atomic row security changes

The dedicated Go executor converges the complete RLS definition of one existing
ordinary table. It does not change columns, indexes, or constraints and does not
create missing tables. `diff` remains a preview; no new CLI flags are required.

## Call the Go API

Use `statement.ParseDesiredWithRowSecurity` on the same SQL you preview with
`diff`, then call the dedicated executor with an existing `dbconn` pool:

```go
desired, err := statement.ParseDesiredWithRowSecurity(`
CREATE TABLE documents (
id bigint PRIMARY KEY,
owner_id bigint NOT NULL
);
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY readers ON documents FOR SELECT USING (owner_id = 7);
`)
if err != nil {
return err
}
report, err := executor.ExecuteRowSecurity(ctx, pool, "public", desired, executor.Budget{
LockTimeout: 100 * time.Millisecond,
StatementTimeout: 5 * time.Second,
})
if err != nil {
return err
}
// report.Statements contains the committed statements; an empty slice means no change.
```

`StatementTimeout` also caps the entire attempt. There is no approval token or
saved fingerprint. Orchestrators retain their own replan and consent rules.
Changing an RLS definition can widen access even when no data is deleted.

## Execution contract

1. Validate the parsed declaration and nonzero budgets.
2. Begin one transaction with a deadline for the entire attempt and transaction-local
lock and statement timeouts. Materialize the desired SQL in a rolled-back savepoint
on the same connection before blocking the target.
3. Acquire `ACCESS EXCLUSIVE`, recheck privileges, and read the live definition.
Refuse unsupported table shapes or any table delta.
4. If RLS already matches, finish without policy DDL. Otherwise replace the complete
policy set (including unchanged policies), apply ENABLE/DISABLE and FORCE/NO FORCE, and preserve policy comments.
Live SQL is rendered from the inspected desired catalog, not replayed from the input.
5. Read back the complete definition. Commit only if it matches the desired model.

The lock blocks reads and writes briefly; this is bounded metadata DDL, not an
online copy. Other sessions never see the intermediate policy set. A failure before
commit rolls back every change. A lost commit response is an unknown outcome:
inspect the database before retrying. This is conservative: any commit error is
reported as unknown, even if cancellation may have prevented COMMIT from being sent.
There are no automatic retries.

Expiration of PostgreSQL’s lock timer reports `budget-lock-exceeded`. The statement
or whole-attempt deadline reports `budget-statement-exceeded`, including when the
whole-attempt deadline expires during a lock wait. The first limit reached wins. A missing target reports
`table-not-found`. Invalid declarations, unsupported targets, and insufficient
privileges report permanent `row-security-refused` outcomes, preserving the underlying
cause. This includes unresolved policy roles and qualified helper functions during
scratch inspection. Caller cancellation is kept separate from budget exhaustion.

The caller needs table-owner privileges and permission to create the temporary
scratch schema. Both privileges are checked before locking and checked again under
the lock. Roles and qualified helpers must already exist. Grants, role
membership, helper bodies, authentication, and Supabase-managed schemas are outside
this operation. Application authorization tests are still needed. Concurrent
administration of those dependencies is not serialized by the table lock.

## Invariants and tests

- **RS-1:** Read the live baseline only after taking the target lock; never accept a
caller-supplied diff as execution authority. Verify ownership before and after locking.
Refuse mixed changes and unsupported table shapes before live DDL.
- **RS-2:** All policy/settings changes and the final catalog comparison share one
transaction. Fault injection after live DDL must prove the original state survives.
- **RS-3:** Bound lock waits, individual statements, and the entire attempt. Cancellation
or lock exhaustion must leave the original policies intact.
- **RS-4:** Execute only admitted RLS statements against the qualified target, with a
pg_catalog-only search path. Scratch objects never persist. Verify convergence before commit.

These checks belong to the executor, regardless of whether a CLI or orchestrator
calls it. SchemaBot can retain its existing replan and consent workflow.
8 changes: 6 additions & 2 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ refused form would take, what an operator who accepts a maintenance window can d
- [Why typed refusal, not passthrough](#why-typed-refusal-not-passthrough)
- [Deliberately operator-owned](#deliberately-operator-owned)

A ✅ marks an implemented capability; check the front-door columns for CLI access.
The atomic RLS executor is currently a Go API only, with no `migrate` or `diff` execution.

## Query the matrix

The marker-delimited regions of this page are generated from
Expand Down Expand Up @@ -176,7 +179,7 @@ the canonical example.
> `make gen-capabilities`; do not edit the generated regions below by hand.

<!-- capabilities:begin summary -->
**53 operations: 18 supported today, 19 planned behind a typed refusal, 14 out of scope
**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.**
<!-- capabilities:end summary -->

Expand Down Expand Up @@ -269,7 +272,8 @@ review the object warrants) ·
| 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, row-level-security policies | 🔵 | — | 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. RLS execution remains unsupported. See the [workflow and roadmap](declarative-row-security.md). See [engine-role.md](engine-role.md) for the engine's own role |
| Grants, roles, and CLI 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](atomic-row-security.md) supports RLS-only changes on existing supported tables; it does not route through these CLI front doors. See the [workflow and roadmap](declarative-row-security.md). See [engine-role.md](engine-role.md) for the engine's own role |
| Complete table-local RLS definition (Go API only) | ✅ | native, safer sequence | Yes | The [atomic RLS executor](atomic-row-security.md) locks an existing supported table, refuses structural changes, replaces policies/settings, and verifies convergence before committing. Lock waits and the whole transaction are bounded. No CLI execution or new diff flags |
| 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) |
<!-- capabilities:end types_and_non_table_objects -->
Expand Down
27 changes: 15 additions & 12 deletions docs/declarative-row-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
You can export a table's RLS settings and policies, keep them alongside its SQL,
and verify that the live definition still matches. `pull` includes RLS when the
live table has settings or policies; ordinary tables get no extra SQL.
**Applying changes to RLS is not supported yet.** A difference produces a review
of the captured definitions and a refusal, not SQL to execute.
`diff` shows the captured definitions and refuses to emit execution SQL for RLS
changes. The dedicated [atomic Go executor](atomic-row-security.md) can apply an
RLS-only declaration to an existing supported table. No new CLI flags are needed.

## Export and compare

Expand Down Expand Up @@ -106,8 +107,8 @@ Equal definitions do not prove equal access: grants, role membership, helper
function bodies, and authentication configuration are outside this comparison.

Library callers use `RenderWithRowSecurity`, `ParseDesiredWithRowSecurity`, and
`diffplan.PlanWithRowSecurity`. The parser returns a separate inspection-only type
that the existing live executors cannot accept.
`diffplan.PlanWithRowSecurity`. The parser returns a separate declaration type. Only the dedicated atomic RLS
executor accepts it for live execution; generic native and create executors do not.

## Review a difference

Expand Down Expand Up @@ -183,7 +184,7 @@ complete support matrix.
pg-sprite already compares a live table with desired SQL materialized inside a
rolled-back scratch transaction. This work extends that model instead of importing another
schema engine. PostgreSQL should resolve SQL and supply its catalog representation.
Before enabling policy execution, settle these boundaries:
The execution contract preserves these boundaries:

- **Explicit ownership.** Existing table-only files keep access control separately
managed. A caller must opt into managing a table's complete RLS definition.
Expand All @@ -202,7 +203,7 @@ Before enabling policy execution, settle these boundaries:
removing a restrictive one, disabling RLS, or changing a role can widen access
without deleting data. A data-destruction label is not a complete authorization
contract. Do not claim to prove arbitrary predicates equivalent.
- **Atomic transitions.** Recheck the reviewed state under the appropriate lock
- **Atomic transitions.** Derive the change from live state under the appropriate lock
and apply a table's policy transition in one bounded transaction. Replacement
must not leave a committed intermediate access rule. Refuse mixed table/policy
plans until their execution strategy preserves that guarantee.
Expand All @@ -217,11 +218,11 @@ this table-scoped work.
incomplete export. Implemented here; table-only diff behavior stays intact.
2. **Round-trip the declaration.** Admit and export SQL under explicit RLS scope;
materialize it in scratch and prove the unchanged definition produces an empty
diff. Implemented through automatic export and SQL declarations; execution remains refused,
including greenfield creation.
3. **Plan and execute transitions.** Add typed security changes, exact-state
revalidation, lock budgets, atomic application, dependency handling, and reports
suitable for users and orchestrators.
diff. Implemented through automatic export and SQL declarations; greenfield
creation remains refused.
3. **Execute transitions atomically.** The Go executor now locks, derives, applies,
and verifies one table's RLS state in one bounded transaction. Mixed changes and
policy relation dependencies remain unsupported. CLI integration is a follow-up.
4. **Prove application behavior.** Extend the local Supabase harness with real
authenticated and anonymous requests, two users, allowed and denied writes,
and interrupted transitions. Then validate hosted connection and privilege
Expand All @@ -231,7 +232,9 @@ The [inspection tests](../pkg/schemadiff/row_security_integration_test.go),
[round-trip tests](../pkg/schemadiff/row_security_roundtrip_integration_test.go), and
[Supabase auth test](../integration/supabase/row_security_test.go) use real databases
and readable DDL. They prove catalog fidelity, round trips, and refusal boundaries,
**not support for applying policies**. The existing PostgreSQL CI matrix and
**not support for applying policies**. The separate
[executor tests](../pkg/executor/row_security_integration_test.go) cover atomic
application, rollback, lock waits, and non-owner access on PostgreSQL. The existing PostgreSQL CI matrix and
Supabase compatibility job both run `pkg/schemadiff`; no separate runner is needed.
Hosted validation is not a prerequisite for the local steps, nor replaced by them.

Expand Down
2 changes: 2 additions & 0 deletions docs/execution-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@ concurrently is.
| --- | --- | --- |
| `budget-lock-exceeded` | no | The lock was not granted within `lock_timeout`; nothing executed |
| `budget-statement-exceeded` | no | The statement ran past `statement_timeout` and was cancelled |
| `row-security-refused` | yes | Change the declaration, unsupported target shape, or privileges before retrying |
| `row-security-outcome-unknown` | no | The atomic RLS commit response is uncertain; inspect the catalog before retrying |
| `blocking-outcome-unknown` | no | The accepted blocking transaction reached an ambiguous client boundary; inspect the catalog before retrying |
| `invalid-blocking-budget` | yes | An accepted blocking bound is disabled or cannot be represented by PostgreSQL |
| `unsupported-accepted-blocking` | yes | The statement is outside the accepted blocking executor's narrow index-maintenance set, or the server will not run it inside the engine-owned transaction (`REINDEX` on a partitioned relation, SQLSTATE `25001`) |
Expand Down
12 changes: 12 additions & 0 deletions docs/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ several of these unrepresentable, and the in-TCB engineering rules live in
- [Correctness (CO)](#correctness-co)
- [Locking and concurrency (LK)](#locking-and-concurrency-lk)
- [Accepted blocking execution (AB)](#accepted-blocking-execution-ab)
- [Atomic row security (RS)](#atomic-row-security-rs)
- [State, checkpoint, and resume (ST)](#state-checkpoint-and-resume-st)
- [Refusals and preflight (RF)](#refusals-and-preflight-rf)
- [Orchestration / control-plane (OC)](#orchestration--control-plane-oc)
Expand Down Expand Up @@ -396,6 +397,17 @@ Every acceptance is audited at warn level before execution, regardless of `--deb
`TestMigrateAcceptBlockingRunsDropIndex`, `demo/tour.sh` (`execute_accepted`). *Source:*
[lock-budgeted passthrough](lock-budgeted-passthrough.md#exit-codes).

## Atomic row security (RS)

The [atomic RLS contract](atomic-row-security.md) defines these executor obligations:

| ID | Must hold | Enforcement and test obligation |
| --- | --- | --- |
| RS-1 | Lock the live target before deriving the change; refuse mixed table changes | `ExecuteRowSecurity`; contention and mixed-change tests |
| RS-2 | Policy changes and convergence verification commit together or roll back | `ExecuteRowSecurity`; real DDL fault injection restores original policies |
| RS-3 | Bound lock waits, statements, and the whole attempt | `ExecuteRowSecurity`; lock contention and deadline cancellation |
| RS-4 | Run only admitted, qualified RLS DDL; keep scratch disposable | `RenderRowSecurity` from the scratch catalog and executor readback; qualified-helper, quoted-name, and scratch-cleanup tests |

## State, checkpoint, and resume (ST)

### ST-1 — The checkpoint is one row per target, written atomically
Expand Down
Loading
Loading