Skip to content

executor: apply row security changes atomically - #123

Merged
aparajon merged 7 commits into
mainfrom
armand/atomic-row-security
Sep 23, 2026
Merged

aparajon merged 7 commits into
mainfrom
armand/atomic-row-security

Conversation

@aparajon

@aparajon aparajon commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Why

pg-sprite can compare row-level security (RLS) definitions but cannot yet apply them declaratively. Policy changes need to commit together so applications never see an intermediate set of access rules.

What

Add executor.ExecuteRowSecurity, a Go API that applies a complete RLS definition to an existing supported table. It manages ENABLE/DISABLE, FORCE/NO FORCE, policies, and policy comments. CLI execution remains a follow-up.

How

Inspect desired SQL in a disposable savepoint
    ↓
Lock target → recheck privileges → read live state
    ↓
Refuse structural changes → derive and apply RLS SQL
    ↓
Verify the resulting catalog → commit together

Everything uses one connection and a bounded transaction. Desired-state validation happens before the exclusive lock. Live SQL comes from the inspected catalog, and a matching definition performs no live DDL.

Risk

The exclusive lock blocks readers and writers; lock waits and the whole attempt have deadlines. Invalid declarations, unsupported table shapes, and insufficient privileges return permanent refusals. Failures before commit roll back the change; commit errors require catalog inspection before retrying.

A changed definition replaces the complete policy set, including unchanged policies. Grants, role membership, helper bodies, and authentication remain outside this operation. Orchestrators retain their existing replan and consent workflow.

Testing

Real PostgreSQL tests cover atomic rollback, concurrent changes, privilege checks, helper resolution, and unchanged catalog state after refusals. Fault injection verifies that policy/table divergence prevents commit and that commit failures preserve the unknown-outcome error. Removing each final verification guard or the commit-error wrapper makes its regression test fail.

Focused integration tests, unit/safety checks, lint, and pre-push race tests pass. No manual testing.

Bigger picture

Builds on #119's declarative RLS inspection and comparison. CLI execution and Supabase application-level validation are follow-ups.

Generated with Codex (GPT-6)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Budget and missing-table failures bypass the stable error taxonomy, and the live SQL generation boundary needs correction.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 4 Medium severity · 1 Low severity

Open (5)
What changed in this PR

Adds an atomic Go executor for applying complete row-level security definitions to existing tables.

Changes:

  • Adds bounded, transactional RLS replacement and convergence verification.
  • Adds statement qualification, scratch introspection, reports, and outcome codes.
  • Updates tests, capability metadata, safety contracts, and documentation.
File Description
SAFETY.md Extends the trusted-core model for RLS execution.
README.md Documents Go API support.
.agents/​checks/​review.md Updates review guidance for RLS execution.
pkg/​statement/​row_security_execution.go Generates qualified RLS statements.
pkg/​statement/​row_security_execution_test.go Tests RLS statement qualification.
pkg/​statement/​desired_rls.go Expands the declaration type’s execution scope.
pkg/​schemadiff/​row_security_roundtrip.go Adds transactional desired-state inspection.
pkg/​schemadiff/​desired.go Refactors scratch inspection around caller transactions.
pkg/​executor/​row_security.go Implements atomic RLS execution.
pkg/​executor/​row_security_test.go Tests input validation.
pkg/​executor/​row_security_integration_test.go Covers execution, rollback, locking, and convergence.
pkg/​executor/​code.go Adds an uncertain RLS commit outcome code.
pkg/​executor/​code_test.go Tests outcome-code classification.
pkg/​capabilities/​capabilities.yaml Records Go API RLS support.
pkg/​capabilities/​capabilities_test.go Updates capability validation expectations.
docs/​tcb-model.md Documents the RLS declaration proof boundary.
docs/​limitations.md Narrows remaining RLS limitations.
docs/​invariants.md Defines atomic RLS invariants.
docs/​execution-model.md Documents the new outcome code.
docs/​declarative-row-security.md Updates the RLS workflow and roadmap.
docs/​capabilities.md Regenerates the support matrix.
docs/​atomic-row-security.md Documents the new API and execution contract.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pkg/capabilities/capabilities.yaml Outdated
Comment thread pkg/executor/row_security.go
Comment thread pkg/executor/row_security.go
Comment thread pkg/statement/row_security_execution.go Outdated
Comment thread pkg/executor/row_security.go
Signed-off-by: Armand Parajon <armand@squareup.com>
@aparajon
aparajon marked this pull request as ready for review September 22, 2026 23:19
@Kiran01bm

Copy link
Copy Markdown
Collaborator

🤖 Review findings - created by Kiran's code review agent - for pg-sprite/pull/123, 5761822.

Verdict: 4 findings — 1 blocking (refusals mis-typed as retryable), 2 non-blocking, 1 suggestion.

Blocking

Every RLS refusal escapes as an unmapped sibling-package sentinel, so OutcomeCode returns CodeExecutionFailed, which Permanent() reports as false. row_security.go:163 wraps schemadiff.ErrUnsupportedChange, but sentinelCode has no schemadiff/statement case and falls through to default: return CodeExecutionFailed, which Code.Permanent() does not list. An orchestrator therefore reads a permanent, author-must-fix refusal as a retryable operational error and can retry the identical call, each attempt taking ACCESS EXCLUSIVE on the live table. Same hole covers :49 (statement.ErrRowSecurityDeclaration), the ErrUnrenderable* family from Render at :154, and statement.ErrPolicyRelationDependency; TestExecuteRowSecurityRefusesMixedChanges asserts only ErrorIs, never OutcomeCode, so nothing pins it.

Non-blocking

The Render(live) admission gate has no test — deleting it leaves the whole new suite green. row_security.go:154 is the only refusal for partitioned parents, inheritance participants, FK-referenced tables and unlogged tables, yet every fixture builds the plain documents table and the mixed-changes test trips the Diff branch instead. The surviving schemadiff.Diff check never looks at ReferencedBy or InheritsParents, so removing lines 151-156 silently admits RLS on FK-referenced and inheritance tables. That leaves step 3 of docs/atomic-row-security.md unproven against AGENTS.md's "no behavior lands without a test that would fail without it".

ExecuteRowSecurity never re-verifies the ownership precondition its own doc states. row_security.go:44 takes a raw schema string, so a non-owner holding UPDATE can take ACCESS EXCLUSIVE at :94, replay the whole desired declaration, and only then fail at owner-only DROP POLICY with SQLSTATE 42501 — mapped to the fallback execution-failed, not a typed refusal. Ownership is decidable before the lock, and pkg/preflight already mints PrivilegedRole/PreflightedTable that ExecuteNative and ExecuteCreate both require. Rated PLAUSIBLE rather than confirmed: no in-repo caller exists and docs/atomic-row-security.md scopes grants and role membership out of this operation.

General suggestions

Five new error returns propagate bare errors against the repo's mandated wrapping style. row_security.go:103, :107, :117, :124 and :136 all return RowSecurityReport{}, err unwrapped, so a failure in the RS-4 SET LOCAL search_path statement surfaces as a bare pgx SQLSTATE with no mention of row security, schema or table. Lines 117 and 124 are the real gaps — LocalSearchPath produces no error of its own to inherit context from, and the sibling RenderWithRowSecurity wraps as fmt.Errorf("render row security for %q: %w", ...). The same function wraps correctly at 81, 90, 97, 99 and 129, so this is an internal inconsistency as well as an AGENTS.md deviation.

The one thing that could have broken, verified

The convergence gate could have been outside the lock or the transaction, letting a concurrent writer's policy change land unnoticed. It is not: admitRowSecurityTable plus DiffWithRowSecurity run against a fresh readback at row_security.go:134-143 before tx.Commit, wrapped in ErrInvariantViolation, which sentinelCode maps first to the permanent CodeInvariantViolation — so RS-2 is fail-closed.

Verified correct

  • ReadCommitted at :79 is right: the post-lock baseline sees the blocker's committed policy; RepeatableRead would have snapshotted pre-lock and broken RS-1.
  • RLS deparse is apples-to-apples: introspectRowSecurity at :63 sets pg_catalog itself and runs last, so all three models deparse under one path.
  • Savepoint nesting is sound: ROLLBACK TO SAVEPOINT discards the scratch schema and its SET LOCAL, while the parent's lock and timeouts survive.
  • Identifier quoting is complete: schema/table, DROP POLICY names and renderPolicy names/roles all go through pgx.Identifier.Sanitize(), with E-string comment quoting exercised end to end.
  • Budget.validate() rejects sub-millisecond bounds, so the .Milliseconds() truncation at :88 cannot emit an unbounded lock_timeout = 0.
  • Model mutation is copy-local: admitRowSecurityTable takes values and zeroes only its own fields, so the caller's policies survive for RenderRowSecurity.
  • Multi-statement Exec at :88 is safe: pgx v5 forces the simple protocol with no arguments, as accepted_blocking.go:108 already relies on.

This review was generated by Claude Code (claude-opus-5).

Signed-off-by: Armand Parajon <armand@squareup.com>
@aparajon

Copy link
Copy Markdown
Collaborator Author

🤖 Addressed the review findings in 1803dba.

  • Permanent refusals: Added row-security-refused, classified as permanent. Invalid declarations, table-shape/mixed-change admission failures, and insufficient privileges retain their underlying causes. Tests now assert outcome codes and retry classification, not just the underlying error.
  • Admission coverage: Added separate PostgreSQL tests for FK-referenced tables, inheritance parents and children, unlogged tables, and partitioned parents. Each verifies the specific refusal and unchanged catalog state.
  • Ownership: Check effective owner privileges before taking the exclusive lock, then recheck under the lock. A non-owner with UPDATE is refused even while a conflicting lock is held. A separate test verifies that a non-superuser with inherited ownership can apply successfully.
  • Error context: Added operation and target context to the inspection, search-path, rendering, admission, and verification errors.

Focused PostgreSQL tests, unit/safety checks, lint, and the pre-push race tests pass. CI is running on the new commit. The PR remains ready for review.

Generated with Codex (GPT-6)

@Kiran01bm

Copy link
Copy Markdown
Collaborator

🤖 Review findings - created by Kiran's code review agent - for pg-sprite/pull/123, 1803dba.
Re-review of the delta only: 57618227..1803dba0, since the last delivered review at 57618227.

Verdict: 3 findings — 1 blocking (untested under-lock recheck), 2 non-blocking.

Blocking

The new under-lock ownership recheck has no test — delete it and the suite still passes. pkg/executor/row_security.go:113 — removing lines 112-115 leaves make test green, because TestExecuteRowSecurityRefusesNonOwnerBeforeLock refuses at the pre-lock check and TestExecuteRowSecurityAcceptsInheritedOwnership passes both, so neither distinguishes one check from two. No test transfers ownership during the lock wait, so RS-1 in docs/invariants.md is unenforced by the merge gate and a later refactor can silently drop the recheck. The existing TestExecuteRowSecurityReadsStateAfterWaitingForLock harness is the fix: swap its CREATE POLICY for ALTER TABLE ... OWNER TO <other>.

Non-blocking

The doc promises permanent row-security-refused for every invalid declaration, but a nonexistent role or helper yields non-permanent execution-failed. docs/atomic-row-security.md:57 — CREATE POLICY readers ON documents FOR SELECT TO no_such_role ... parses (roles are not resolved client-side), the scratch replay fails with 42704, and rowSecurityError maps only ErrPolicyRelationDependency, ErrRowSecurityDeclaration and 42501, so sentinelCode falls through to CodeExecutionFailed with Permanent() false. An orchestrator trusting the claim retries a permanently-broken declaration forever — the loop Code.Permanent() exists to prevent. Same for a missing qualified helper (42883).

The pre-lock gate covers table ownership only; the scratch-schema CREATE privilege is still discovered after ACCESS EXCLUSIVE is held. pkg/executor/row_security.go:120 — a role that owns the table but lacks CREATE on the database passes the gate, takes the table lock, then fails 42501 inside CREATE SCHEMA pgsprite_scratch_… (pkg/schemadiff/desired.go:54), so application traffic is frozen to discover a privilege a pre-lock has_database_privilege(...,'CREATE') probe could have caught. The outcome code is still correct, and docs/engine-role.md:58 records the unchecked privilege as a pre-existing repo-wide follow-up, so this is a widening of a known gap rather than a new bug.

The one thing that could have broken, verified

Whether the under-lock recheck is real protection or dead code: ALTER TABLE ... OWNER TO itself takes ACCESS EXCLUSIVE, so any owner change must commit before the executor's lock is granted, and the ReadCommitted per-statement snapshot makes the post-lock read observe it. The mechanism is sound — only its test coverage is missing (blocking finding above).

Verified correct

  • pg_has_role(current_user, c.relowner, 'USAGE') matches PostgreSQL's own has_privs_of_role DDL check, so the gate neither over- nor under-refuses.
  • TestExecuteRowSecurityAcceptsInheritedOwnership discriminates the predicate from a naive relowner = current_user::regrole equality.
  • No double-wrap of ErrRowSecurityRefused: the declaration guard at row_security.go:49 returns directly, never via rowSecurityError.
  • sentinelCode ordering is safe: ErrInvariantViolation still matches first, keeping RS-2 shape/convergence failures at invariant-violation.
  • The ErrNoRows branch wraps only ErrTableNotFound, so TestExecuteRowSecurityRefusesMissingTable keeps table-not-found.
  • Commit ambiguity preserved: *RowSecurityOutcomeUnknownError is checked before the new declaration and 42501 branches in both rowSecurityError and OutcomeCode.
  • Budget classification unshadowed: both new branches sit after cancellation, deadline and asBudgetError, so the 55P03 and 57014 tests keep their budget codes.
  • Two %w verbs at :125 and :141 wrap both sentinels, which is what makes requireRLSShapeRefusal's two assertions hold together.
  • The docs guard covers the new code: TestDocNamesEveryOutcomeCode, TestDocPermanentColumnMatchesCodePermanent and TestCodesEnumerateEveryDeclaredCode pin the constant, the Codes() entry and the docs row against each other.

This review was generated by Claude Code (claude-opus-5).

Signed-off-by: Armand Parajon <armand@squareup.com>
@aparajon

Copy link
Copy Markdown
Collaborator Author

🤖 Addressed all three follow-up findings in 64d16a9.

  • Under-lock ownership recheck: Added a real lock-queue test that transfers ownership while the executor waits. It uses an already-converged declaration and preserves SELECT/UPDATE privileges so a later PostgreSQL DDL refusal cannot mask a missing recheck. I temporarily removed the recheck: the test failed because the operation incorrectly succeeded. Restored the check afterward.
  • Unresolved declarations: Scratch SQLSTATE class 42 errors now return permanent row-security-refused, preserving the underlying PostgreSQL error. Separate tests cover a missing policy role (42704) and qualified helper (42883), including unchanged live catalog state. Operational failures remain unclassified by this admission mapping.
  • Scratch privileges: The pre-lock gate now checks database CREATE as well as effective ownership, and both are rechecked under lock. A table-owner test holds a conflicting lock and confirms that missing CREATE is refused before waiting for ACCESS EXCLUSIVE.

The focused PostgreSQL suite, unit/safety checks, lint, and pre-push race tests pass. CI is running on the new commit; the PR remains ready for review.

Generated with Codex (GPT-6)

@morgo morgo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reviewed on Morgan's behalf, at 64d16a97. Approving. The design is the right one — one transaction, lock first, derive the change from the locked catalog rather than from caller input, verify before commit — and the details hold up under attack: the inverted DiffWithRowSecurity contract is used correctly at both call sites, the shared search_path discipline is consistent across all three introspections, and the errgroup-free single-connection story is real. Five of seven genuine fault injections were caught.

The two that were not caught are both on the commit boundary, and that is what the notes below are about. Neither blocks.

1. The RS-2 convergence verification is unpinned — deleting it leaves the suite green

pkg/executor/row_security.go:156:

if _, err := schemadiff.DiffWithRowSecurity(schema, actual, wanted); err != nil {
    return RowSecurityReport{}, fmt.Errorf("%w: RS-2: row security did not converge: %w", ErrInvariantViolation, err)
}

Removing those three lines leaves every test in pkg/executor passing, including all 26 RowSecurity tests. grep confirms it: no test in the package asserts ErrInvariantViolation from this executor at all — the ErrInvariantViolation assertions in optimistic_integration_test.go and create_integration_test.go belong to the other two executors.

This matters more than an ordinary missing test for three reasons.

It is the check that makes the rendered SQL trustworthy. Everything executed here comes from RenderRowSecurity re-rendering the scratch catalog, not from replaying the caller's input. That is the right call, but it makes correctness depend on a render → execute → catalog round trip that nothing proves is lossless. The read-back is the only thing standing between a render bug and committing a policy set that is not the one that was asked for. The sibling check one line up (admitRowSecurityTable(schema, actual, wanted), "RS-2: target shape changed") has the same exposure.

What it protects is access control. A silently-wrong policy set is not a failed migration an operator notices; it is rows becoming visible or invisible to the wrong roles, committed, with a success report listing statements that all succeeded. RowSecurityReport would name exactly the DDL that ran, and every one of those statements would have returned without error.

The PR itself declares this as a test obligation. docs/invariants.md gives RS-2 as "Policy changes and convergence verification commit together or roll back | ExecuteRowSecurity; real DDL fault injection restores original policies". The atomicity half is genuinely covered — TestExecuteRowSecurityRollsBackAfterLiveDDLFailure and TestExecuteRowSecurityDeadlineRollsBackLiveDDL both prove the original policies survive. The verification half has nothing. A test that seeds a divergence between the applied result and wanted (a stubbed renderer, or a policy the re-render cannot reproduce) and asserts ErrInvariantViolation plus an unchanged live policy set would close it.

2. The unknown-commit-outcome wrapping is unpinned too

row_security.go:159:

if err := tx.Commit(ctx); err != nil {
    return RowSecurityReport{}, &RowSecurityOutcomeUnknownError{Err: err}
}

Replacing that with a bare return RowSecurityReport{}, err also leaves the whole package green. All three tests that mention the type — row_security_internal_test.go:42, code_test.go:27, code_test.go:167 — construct the error by hand and check how it classifies. None drives executeRowSecurity to a failing commit, so nothing proves the one site that produces it still does.

The consequence is the difference between the two pieces of advice the executor can give. docs/atomic-row-security.md says "A lost commit response is an unknown outcome: inspect the database before retrying. There are no automatic retries." Downgraded to an ordinary error, the same event reads as a plain failure that an orchestrator may retry — and a retry after a commit that actually landed re-derives from a converged target, so it is a harmless no-op. That is why this is a note rather than a finding: the failure mode is a misleading message and a lost instruction to go look, not a wrong policy set. It is still the one error in this file whose whole purpose is to change what a human does next.

3. The desired-state materialization runs inside the blocking window, and does not need to

executeRowSecurity orders the work: privilege check → LOCK TABLE ... ACCESS EXCLUSIVE → privilege recheck → introspect live → IntrospectDesiredWithRowSecurityTx → admit → DDL → verify → commit.

That fifth step is a full materialization of the declaration: CREATE SCHEMA, a search_path switch, every statement of the declaration replayed one at a time, a complete introspection of the scratch table (columns, constraints, indexes, policies), and a savepoint rollback. It runs with every reader and writer of the production table blocked — and it depends on nothing but desired. wanted never reads the live table; admitRowSecurityTable is the first thing that needs both.

Hoisting it above the LOCK TABLE would take roughly half the statements out of the blocking window at no cost to any invariant. RS-1 is about the live baseline and the derived change ("Read the live baseline only after taking the target lock"), which would still hold: live, the comparison, and every live statement stay after the lock. The savepoint's own locks are released on rollback either way, so nothing about the parent's ACCESS EXCLUSIVE changes.

The stronger argument is the one this PR already makes for itself one line above the lock:

// Reject missing owner or scratch privileges before taking an application-blocking lock.
if err := checkRowSecurityPrivileges(ctx, tx, schema, desired.Table()); err != nil {

An invalid declaration is a far more likely refusal than a missing privilege, and it is far more expensive to discover. Today a typo'd column name, a missing role, or an unresolvable helper is found by classifyRowSecurityDesiredError after the application has already been blocked — and the blocking lasts until whichever statement fails. The same sentence that justifies the privilege pre-check justifies moving this above the lock with more force.

Notes

  • The new capability row is the only ✅/t1 row in the matrix that no front door can reach. I scanned all 54 rows: declarative-row-security-library is the sole entry with tier: t1, status_mark: "✅" and both migrate and diff set to refused. Every other ✅ row is CLI-reachable, so ✅ has meant "the CLI does this" everywhere until now, and in the rendered matrix the mark is the scannable column while "(Go API only)" and "No CLI execution" live in the operation and notes text. The validator has no rule tying reachability to the mark, so nothing will flag it. Worth deciding deliberately rather than by omission — 🟡 with the notes as written would read closer to the truth for a CLI user, at the cost of understating it for a library consumer.
  • Budget.validate() does not require LockTimeout <= StatementTimeout, and the doc's error-code promise depends on it. With the ordering inverted (say LockTimeout: 30s, StatementTimeout: 5s) the attempt context expires while the LOCK TABLE is still waiting, so rowSecurityError takes the context.DeadlineExceeded branch at line 69 before asBudgetError ever sees a 55P03, and lock contention reports budget-statement-exceeded. docs/atomic-row-security.md says "Lock exhaustion reports budget-lock-exceeded". Only a misconfigured budget reaches it — the documented example (100ms / 5s) is fine — but validate() is already the place that refuses undecidable budgets, and this one is decidable there.
  • A deadline that expires before the commit is sent still reports row-security-outcome-unknown. rowSecurityError checks the unknown wrapper first, before the deadline classification, so a tx.Commit(ctx) on an already-expired context — where pgx never puts a COMMIT on the wire and rollback is certain — sends the operator to inspect the catalog anyway. Failing toward "unknown" is the right direction and I would not change the ordering; noting it only because the over-report is invisible from the error text.

What I checked rather than took on trust

  • The inverted DiffWithRowSecurity usage, which reads like a bug and is not. Both call sites act on err != nil rather than on a change set. That is correct: the function returns an error whenever the two models differ at all (row_security_roundtrip.go:160, :169), so err == nil ⟺ fully converged, and a non-empty-but-nil-error result is unrepresentable. By line 128 admitRowSecurityTable has already excluded ErrDifferentTables and the mixed-change error, so the only error reachable there is the RLS one.
  • search_path consistency across the three introspections, which is where a spurious "did not converge" would come from. It holds by construction rather than by luck: introspectRowSecurity sets pg_catalog-only itself (row_security.go:64) and reads through pg_catalog.pg_get_expr, so policy expressions render identically whatever the caller's path was. SET LOCAL inside the savepoint is undone by its rollback, so the desired inspection cannot leak its scratch path into the verification.
  • RS-4's pg_catalog-only replay path, twice, because my first attempt proved nothing. Swapping LocalSearchPath("pg_catalog") for LocalSearchPath(schema, "public") leaves every test green — but that is a correct green: PostgreSQL searches pg_catalog implicitly first when it is not named, and dbconn.LocalSearchPath strips a trailing pg_catalog for exactly this reason (CO-9), so the shadowing case is not reachable through the helper at all. Bypassing the helper with a raw SET LOCAL search_path = <schema>, pg_catalog does reach it, and TestExecuteRowSecurityPreservesHelperResolution catches it immediately. The guard is real and it is tested; it is defended twice over.
  • The privilege query is the right one. pg_has_role(current_user, c.relowner, 'USAGE') is the same test PostgreSQL's own ownership check uses, and has_database_privilege(..., 'CREATE') is what CREATE SCHEMA actually needs — the error message names the scratch schema as the reason, which is accurate.
  • Fault injection — five of seven. Post-lock privilege recheck removed → TestExecuteRowSecurityRechecksOwnerAfterLockWait; the ACCESS EXCLUSIVE lock never taken → ReadsStateAfterWaitingForLock + RechecksOwnerAfterLockWait; admitRowSecurityTable disabled → four refusal tests; the DROP POLICY sweep removed so policies are added rather than replaced → five tests; pg_catalog demoted behind the target schema → PreservesHelperResolution. The RS-2 verification and the unknown-commit wrapper are the two above. (Two ~65s tests in unrelated files, TestRunSequenceWithProgressTracksStepsAndFinishes and TestExecuteCreateRefusesImplicitIndexNameCollision, failed intermittently during the injection sweep and pass at baseline — that is my harness running suites back to back against one database, not this branch.)
  • go build ./..., go vet ./..., and pkg/executor, pkg/schemadiff, pkg/statement, pkg/capabilities are all green at this head against a live PostgreSQL, with no skips — all 26 RowSecurity tests actually ran. CI: 16 checks, 0 failures, 0 pending. The branch is BEHIND main and wants an update before merge.

@aparajon

Copy link
Copy Markdown
Collaborator Author

🤖 Addressed the commit-boundary and blocking-window suggestions from this review in 17290fb.

  • Added real PostgreSQL event-trigger tests that introduce policy divergence and a new incoming foreign key after live policy creation. Both assert ErrInvariantViolation, a zero report, and restoration of the original catalog. Removing each corresponding final verification check makes its test fail.
  • Added a deferred constraint-trigger failure at COMMIT. It exercises the executor's actual commit path and asserts RowSecurityOutcomeUnknownError, its outcome code, the original SQLSTATE, and rollback. Replacing the wrapper with a bare error makes this test fail too. These tests use real server behavior, without renderer or transaction mocks.
  • Moved desired-state materialization ahead of the target lock. Live introspection, admission, DDL, and verification remain under the lock. The missing-role test now holds a conflicting lock and proves the declaration is rejected before waiting for it.

For the other notes: retained ✅ for the implemented Go API and added prominent guidance that CLI reachability comes from the front-door columns. 🟡 currently means planned/tier 2 in the matrix contract, which would mislabel the implemented API. Clarified that the first timer reached wins, including an overall deadline during a lock wait, rather than adding a new restriction to the shared Budget type. Also documented the deliberately conservative classification of every commit error as unknown, including cancellation before transmission.

Focused PostgreSQL tests, unit/safety checks, lint, and pre-push race tests pass. All three guard-removal experiments failed as expected, and the guards are restored. CI is running on the new commit. The latest main merge is included.

Generated with Codex (GPT-6)

@aparajon
aparajon merged commit c2b81a3 into main Sep 23, 2026
16 checks passed
@aparajon
aparajon deleted the armand/atomic-row-security branch September 23, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants