@everystack/cli 0.4.72 → 0.4.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@ All notable changes to `@everystack/cli` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.74] - 2026-09-06
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ - P1.6 — bump fingerprint format to v11 (command fan + membership deadness + sequence ACLs)
12
+
13
+ ### Added
14
+
15
+ - P2a (core) — proveAdoption, the prove-don't-attest heart of db:adopt
16
+ - P2a-shell — db:adopt command (prove-don't-attest first adoption)
17
+
18
+ ### Fixed
19
+
20
+ - P1.1 — canonical authz fans the command dimension
21
+ - P1.3 — reconciler and drift gate agree with the fingerprint on the command fan
22
+ - P1.2 — deadness folds role membership on the live side (Condition A)
23
+ - P1.5 — serial-sequence ACLs enter the fingerprint (Bug 3)
24
+ - P1.4 — reconcile fallback orders narrowing before widening (Condition C)
25
+ - P2b — an ancestor with no models barrel declares nothing, not a compile failure
26
+ - P2c/P2d — drift refusal becomes the adopt runbook; base fingerprint excludes everystack schema
27
+
28
+ ## [0.4.73] - 2026-09-04
29
+
30
+ ### Fixed
31
+
32
+ - db:reconcile converges an owned relation's self-grant
33
+
7
34
  ## [0.4.72] - 2026-09-04
8
35
 
9
36
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.72",
3
+ "version": "0.4.74",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -0,0 +1,58 @@
1
+ /**
2
+ * adopt — the PROVE core of `db:adopt` (P2a). Pure: live introspection + compiled Models in, a
3
+ * verdict out. The command shell (commands/db-adopt.ts) does the IO — introspect, render, load,
4
+ * write files, print the commit.
5
+ *
6
+ * The doctrine (Fable-ruled 2026-09-05): PROVE, not attest. There is no bypass flag. db:adopt
7
+ * renders Models from a live database and may finalize them ONLY when the compiled Models declare
8
+ * that database EXACTLY — the same declares-test descent and db:check use (predicted == governed
9
+ * live). A commit that carries such Models legitimately declares the live state, so a later
10
+ * db:apply descends for real (proveDescent finds the adopt commit) instead of being forced past a
11
+ * drift refusal. When the round-trip is not exact, adoption REFUSES and names what diverged —
12
+ * because a baseline that silently papered over a difference is the bypass under another name.
13
+ */
14
+
15
+ import type { ModelDescriptor } from '@everystack/model';
16
+ import type { SchemaSnapshot } from './schema-introspect';
17
+ import type { AuthzContract } from './authz-contract';
18
+ import { governedRolesForModels, fingerprintState, fingerprintLive, divergentSurfaces } from './schema-fingerprint';
19
+ import { predictLiveState, governedLiveFingerprint } from './edge-plan';
20
+
21
+ export interface AdoptionProof {
22
+ /** True when the compiled Models declare the live state exactly — safe to finalize the baseline. */
23
+ clean: boolean;
24
+ /** The RAW live fingerprint — the baseline id that names the adopt commit (`adopt live baseline <fp>`). */
25
+ fingerprint: string;
26
+ /** When not clean, the surfaces that still diverge (base schema / authorization / sequence ACLs,
27
+ * with the tables named), so the refusal says exactly what the render could not capture. Empty when clean. */
28
+ divergentSurfaces: string[];
29
+ }
30
+
31
+ /**
32
+ * Does this compiled Models set declare the live state exactly? The declares-test, governed on
33
+ * both sides through the SAME set the Models imply — a governed prediction against a raw live
34
+ * hash can never converge on a brownfield database with foreign roles. Reuses the very functions
35
+ * descent (`evaluateTree`) and db:fingerprint compare with, so "adoption is clean" and "the plan
36
+ * is empty" are one answer, not two that can disagree.
37
+ */
38
+ export function proveAdoption(
39
+ models: ModelDescriptor[],
40
+ snapshot: SchemaSnapshot,
41
+ contract: AuthzContract,
42
+ opts: { schema?: string } = {},
43
+ ): AdoptionProof {
44
+ const governedRoles = governedRolesForModels(models);
45
+ const predicted = predictLiveState(models, snapshot, contract, { schema: opts.schema, governedRoles });
46
+ const governedLiveHash = governedLiveFingerprint(snapshot, contract, governedRoles);
47
+ const clean = predicted.hash === governedLiveHash;
48
+ return {
49
+ clean,
50
+ fingerprint: fingerprintLive(snapshot, contract).hash,
51
+ divergentSurfaces: clean
52
+ ? []
53
+ : divergentSurfaces(
54
+ predicted.canonical,
55
+ fingerprintState(snapshot, contract.tables, { governedRoles, sequenceGrants: contract.sequenceGrants }).canonical,
56
+ ),
57
+ };
58
+ }
@@ -36,6 +36,7 @@ import {
36
36
  POLICIES_SQL,
37
37
  GRANTS_SQL,
38
38
  COLUMN_GRANTS_SQL,
39
+ EFFECTIVE_PRIVILEGES_SQL,
39
40
  SCHEMA_ACL_SQL,
40
41
  SEQUENCE_ACLS_SQL,
41
42
  type QueryRunner,
@@ -245,7 +246,7 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
245
246
  const results = await session(
246
247
  [
247
248
  COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL, EXTENSIONS_SQL,
248
- RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, FUNCTIONS_SQL,
249
+ RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, EFFECTIVE_PRIVILEGES_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, FUNCTIONS_SQL,
249
250
  { sql: SCHEMA_LOG_WITNESS_SQL, allowFailure: true },
250
251
  ],
251
252
  INTROSPECTION_SESSION,
@@ -254,12 +255,15 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
254
255
  columns: results[0] as any[], constraints: results[1] as any[], enums: results[2] as any[],
255
256
  indexes: results[3] as any[], sequences: results[4] as any[], extensions: results[5] as any[],
256
257
  });
258
+ // The authz block gained EFFECTIVE_PRIVILEGES_SQL (index 10) so this fingerprint uses the SAME
259
+ // membership-folded deadness the plan's endpoints do — the concurrency lock compares them.
257
260
  const contract = assembleContract({
258
261
  rls: results[6] as any[], policies: results[7] as any[], grants: results[8] as any[],
259
- columnGrants: results[9] as any[], schemaAcls: results[10] as any[], sequenceAcls: results[11] as any[],
260
- functions: (results[12] as any[]).map(contractFunctionRow),
262
+ columnGrants: results[9] as any[], effectivePrivileges: results[10] as any[],
263
+ schemaAcls: results[11] as any[], sequenceAcls: results[12] as any[],
264
+ functions: (results[13] as any[]).map(contractFunctionRow),
261
265
  });
262
- const ledger = mapLedgerRows(rowsOrEmpty(results[13]));
266
+ const ledger = mapLedgerRows(rowsOrEmpty(results[14]));
263
267
  return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract, ledger };
264
268
  }
265
269
 
@@ -32,41 +32,61 @@
32
32
  */
33
33
 
34
34
  import type { PolicyContract, TableContract } from './authz-contract';
35
- import { effectivePolicyCheck, isPolicyDead, isPolicySubsumed } from './authz-contract';
35
+ import { holdsPrivilegeEffective, policyCommands } from './authz-contract';
36
36
 
37
37
  /** PostgreSQL's open role set, kept whole. */
38
38
  const PUBLIC_ROLE = 'public';
39
39
 
40
40
  /**
41
- * One policy as (role, rule) entries — the form in which two equivalent states look equal.
42
- * A PUBLIC policy yields exactly one entry; every other policy yields one per named role.
41
+ * One (role, command) arm of a policy — the grain the canonical form compares at.
42
+ *
43
+ * A single `FOR ALL` policy governs four commands; the compiler that pulls it back writes one
44
+ * granular policy per command the role can exercise. Comparing the `command` field verbatim
45
+ * made those two states — the same authorization, written two ways — read as different, so a
46
+ * pulled brownfield table never round-tripped. Fanning the command dimension the way roles are
47
+ * already fanned is what makes them converge.
43
48
  */
44
- function expandPolicy(p: PolicyContract): string[] {
45
- const rule = stableRule(p);
46
- if (p.roles.includes(PUBLIC_ROLE)) return [`${PUBLIC_ROLE}|${rule}`];
47
- return [...p.roles].sort().map((r) => `${r}|${rule}`);
49
+ interface PolicyArm {
50
+ /** Expanded role name, or the `public` sentinel. */
51
+ role: string;
52
+ /** The single command this arm governs — never `ALL`; `FOR ALL` fans to four arms. */
53
+ command: string;
54
+ permissive: boolean;
55
+ /** The USING predicate PG enforces FOR THIS COMMAND (`''` where the command has none). */
56
+ using: string;
57
+ /** The WITH CHECK predicate PG enforces FOR THIS COMMAND (`''` where the command has none). */
58
+ check: string;
48
59
  }
49
60
 
50
61
  /**
51
- * The rule, with the NAME deliberately absent.
52
- *
53
- * Names were the policy's identity in the v3 format, on the reasoning that the compiler emits
54
- * them deterministically. That is true of a greenfield database and false of every brownfield
55
- * one, where the previous migration tool chose the names. Hashing them made a live policy that
56
- * carries the declared authorization under its own name read as a different state — so an
57
- * adopter could reach zero statements and never reach MATCH.
62
+ * Fan one policy into its (role, command) arms, each carrying the predicate in exactly the slot
63
+ * PostgreSQL enforces for that command — the NAME deliberately absent (a brownfield tool chose
64
+ * it, so hashing it made a live policy carrying the declared authorization read as a different
65
+ * state) and `ALL` expanded across the four row-level commands (so a live `FOR ALL` and the
66
+ * granular per-command policies a pull produces look equal).
58
67
  *
59
- * The check goes through the server's own defaulting rule, so a live `FOR ALL USING (true)`
60
- * and a compiled `FOR ALL USING (true) WITH CHECK (true)` — the same authorization, written
61
- * two ways — hash the same.
68
+ * The per-command slots, and why each is what it is:
69
+ * - SELECT / DELETE have NO `WITH CHECK` — check is `''`.
70
+ * - INSERT has NO `USING` — using is `''`; its check reuses `USING` when the source is a
71
+ * `FOR ALL` with no explicit check (`p.check ?? p.using`), the server's own defaulting rule.
72
+ * - UPDATE carries both, its check defaulting to `USING` the same way.
73
+ * This makes a `FOR ALL USING (pred)` fan to an INSERT arm whose check is `pred` — identical to
74
+ * the granular `INSERT ... WITH CHECK (pred)` a pull emits.
62
75
  */
63
- function stableRule(p: PolicyContract): string {
64
- return JSON.stringify([
65
- p.command,
66
- p.permissive,
67
- p.using ?? '',
68
- effectivePolicyCheck(p) ?? '',
69
- ]);
76
+ function policyArms(p: PolicyContract): PolicyArm[] {
77
+ const roles = p.roles.includes(PUBLIC_ROLE) ? [PUBLIC_ROLE] : [...p.roles].sort();
78
+ const arms: PolicyArm[] = [];
79
+ for (const command of policyCommands(p)) {
80
+ const using = command === 'INSERT' ? '' : (p.using ?? '');
81
+ const check = command === 'SELECT' || command === 'DELETE' ? '' : ((p.check ?? p.using) ?? '');
82
+ for (const role of roles) arms.push({ role, command, permissive: p.permissive, using, check });
83
+ }
84
+ return arms;
85
+ }
86
+
87
+ /** The arm's canonical string — `role|[command,permissive,using,check]`, name and roles absent. */
88
+ function armKey(a: PolicyArm): string {
89
+ return `${a.role}|${JSON.stringify([a.command, a.permissive, a.using, a.check])}`;
70
90
  }
71
91
 
72
92
  /** Sorted multiset: every entry kept, duplicates counted, order irrelevant. */
@@ -76,28 +96,86 @@ function multiset(entries: string[]): Array<[string, number]> {
76
96
  return [...counts.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
77
97
  }
78
98
 
99
+ /** Keep only the arms whose role the models govern; PUBLIC is a governed sentinel, always kept. */
100
+ function governedArm(governed: ReadonlySet<string>, role: string): boolean {
101
+ return governed.has(role) || role === PUBLIC_ROLE || (role.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
102
+ }
103
+
79
104
  /**
80
- * The canonical policy form: every policy expanded across its roles, as a sorted multiset.
81
- *
82
- * When a governed set is supplied (hashing a LIVE contract), per-role entries for roles the
83
- * models do not govern are dropped — the SAME choice the reconciler makes when it leaves an
84
- * ungoverned role's policy alone (the `reconcilePolicies` ungoverned escape). Hashing them
85
- * would keep MATCH unreachable on any brownfield database whose previous stack left a policy
86
- * TO a migrator/resolver role: zero statements, mismatched hash, forever. PUBLIC is a
87
- * governed sentinel and always kept.
105
+ * The canonical policy form: every policy fanned into its (role, command) arms, as a sorted
106
+ * multiset. NO deadness or subsumption is applied here — this is the pure fan, used where the
107
+ * grants are not in hand (predicate-normalization equivalence). {@link canonicalAuthz} carries
108
+ * the contract and does the per-arm deadness and subsumption the state needs.
109
+ *
110
+ * When a governed set is supplied (hashing a LIVE contract), arms for roles the models do not
111
+ * govern are dropped — the SAME choice the reconciler makes when it leaves an ungoverned role's
112
+ * policy alone (the `reconcilePolicies` ungoverned escape). Hashing them would keep MATCH
113
+ * unreachable on any brownfield database whose previous stack left a policy TO a migrator role:
114
+ * zero statements, mismatched hash, forever. PUBLIC is a governed sentinel and always kept.
88
115
  */
89
116
  export function canonicalPolicies(
90
117
  policies: readonly PolicyContract[],
91
118
  governed?: ReadonlySet<string>,
92
119
  ): Array<[string, number]> {
93
- const entries = policies.flatMap(expandPolicy);
94
- const kept = governed
95
- ? entries.filter((e) => {
96
- const role = e.slice(0, e.indexOf('|'));
97
- return governed.has(role) || role === PUBLIC_ROLE || (role.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
98
- })
99
- : entries;
100
- return multiset(kept);
120
+ const arms = policies.flatMap(policyArms);
121
+ const kept = governed ? arms.filter((a) => governedArm(governed, a.role)) : arms;
122
+ return multiset(kept.map(armKey));
123
+ }
124
+
125
+ /**
126
+ * Is this arm DEAD — does its (role, command) pair authorize nothing?
127
+ *
128
+ * PostgreSQL checks the GRANT before the policy, so an arm governing a command the role does not
129
+ * hold is inert. Per ARM, not per policy: a `FOR ALL` on a role holding SELECT/INSERT/UPDATE but
130
+ * not DELETE keeps three live arms and drops the dead DELETE one — which is exactly what makes it
131
+ * converge with a 3-granular pull, while a role that DOES hold DELETE keeps four and stays
132
+ * distinct. A PUBLIC arm applies to every grantee, so it is dead only when NO grantee holds the
133
+ * command. `holdsPrivilegeEffective` folds role membership + PUBLIC + column grants on the live
134
+ * side (and the direct grants on a declared one), the same oracle the reconciler uses.
135
+ */
136
+ function armDead(t: TableContract, a: PolicyArm): boolean {
137
+ if (a.role === PUBLIC_ROLE || a.role.toUpperCase() === 'PUBLIC') {
138
+ const grantees = t.effectivePrivileges
139
+ ? Object.keys(t.effectivePrivileges)
140
+ : [...new Set([...Object.keys(t.grants), ...Object.keys(t.columnGrants ?? {})])];
141
+ return !grantees.some((r) => holdsPrivilegeEffective(t, r, a.command));
142
+ }
143
+ return !holdsPrivilegeEffective(t, a.role, a.command);
144
+ }
145
+
146
+ /**
147
+ * Is this arm SUBSUMED — does an identical PUBLIC arm already reach its role?
148
+ *
149
+ * Permissive arms OR together and a PUBLIC arm applies to every role, so a role-scoped arm whose
150
+ * (command, using, check) is character-for-character a live PUBLIC arm's admits no session the
151
+ * other does not. Only among ALIVE arms — a dead PUBLIC arm covers nothing. Restrictive arms
152
+ * are never subsumed (they AND, so removing one would WIDEN); a PUBLIC arm is never subsumed
153
+ * (it is the coverer).
154
+ */
155
+ function armSubsumed(a: PolicyArm, alive: readonly PolicyArm[]): boolean {
156
+ if (!a.permissive || a.role === PUBLIC_ROLE || a.role.toUpperCase() === 'PUBLIC') return false;
157
+ return alive.some((o) =>
158
+ (o.role === PUBLIC_ROLE || o.role.toUpperCase() === 'PUBLIC')
159
+ && o.permissive && o.command === a.command && o.using === a.using && o.check === a.check);
160
+ }
161
+
162
+ /**
163
+ * The canonical policy form WITH the state exclusions: fan to arms, drop the dead ones (an arm
164
+ * whose grant does not exist), drop the subsumed ones (a role-scoped arm a PUBLIC arm already
165
+ * covers), then filter to the governed roles. Both exclusions are the same idea the reconciler
166
+ * makes in lockstep — an arm that changes no access is not part of the state — now made per
167
+ * (role, command) instead of per whole policy, so a `FOR ALL` with one dead command arm and the
168
+ * granular pull that omits that command hash equal.
169
+ */
170
+ function canonicalPolicyArms(
171
+ contract: TableContract,
172
+ governed?: ReadonlySet<string>,
173
+ ): Array<[string, number]> {
174
+ const alive = contract.policies.flatMap(policyArms).filter((a) => !armDead(contract, a));
175
+ const kept = alive
176
+ .filter((a) => !armSubsumed(a, alive))
177
+ .filter((a) => (governed ? governedArm(governed, a.role) : true));
178
+ return multiset(kept.map(armKey));
101
179
  }
102
180
 
103
181
  /**
@@ -124,6 +202,25 @@ function governedOnly<T>(
124
202
  );
125
203
  }
126
204
 
205
+ /**
206
+ * Are two tables' policies the SAME authorization under the canonical fan?
207
+ *
208
+ * The equivalence relation the fingerprint hashes, exposed as a boolean for the reconciler and
209
+ * the drift gate. Gating those two on THIS — rather than a per-command field compare — is what
210
+ * keeps all three surfaces from ever contradicting each other about the same database: a live
211
+ * `FOR ALL` with a dead DELETE arm and the granular pull that omits DELETE are equal here, so
212
+ * the reconciler emits nothing and the differ reports no drift, exactly as the fingerprint says
213
+ * MATCH. When it is false the callers fall back to the per-policy matcher, which can only ever
214
+ * over-emit (churn), never under-emit (a silent authorization difference).
215
+ */
216
+ export function tablePoliciesEquivalent(
217
+ a: TableContract,
218
+ b: TableContract,
219
+ governed?: ReadonlySet<string>,
220
+ ): boolean {
221
+ return JSON.stringify(canonicalPolicyArms(a, governed)) === JSON.stringify(canonicalPolicyArms(b, governed));
222
+ }
223
+
127
224
  /**
128
225
  * The canonical authorization form for one table.
129
226
  *
@@ -160,19 +257,14 @@ export function canonicalAuthz(
160
257
  ),
161
258
  }
162
259
  : {}),
163
- // A DEAD policy is not part of the state. It authorizes nothing (Postgres refuses at the
164
- // GRANT before consulting it), and the reconciler now leaves it alone — so counting it
165
- // here would keep MATCH unreachable on exactly the databases that have them: brownfield
166
- // ones, where a previous stack left policies behind after its grants were revoked. Both
167
- // surfaces make the same choice, from the same function. Deadness is recomputed against
168
- // these grants every time, so the policy re-enters the state the moment a grant revives it.
169
- // …and neither is a SUBSUMED one: a permissive policy whose rule an identical PUBLIC
170
- // policy already applies to every role admits no session the other does not. Both
171
- // exclusions are the same idea — a policy that changes no access is not part of the
172
- // state — and both are made in lockstep with the reconciler, from the same functions.
173
- policies: canonicalPolicies(
174
- contract.policies.filter((p) => !isPolicyDead(contract, p) && !isPolicySubsumed(contract, p)),
175
- governed,
176
- ),
260
+ // Policies fan to (role, command) ARMS; a DEAD arm (its grant does not exist) and a
261
+ // SUBSUMED arm (a PUBLIC arm already covers it) are not part of the state, so both are
262
+ // dropped before hashing — the same choices the reconciler makes, now made per command
263
+ // instead of per whole policy. That per-command grain is what makes a live `FOR ALL` with
264
+ // one dead command arm hash equal to the granular pull that omits that command, while a
265
+ // `FOR ALL` whose every arm is alive stays distinct from a set that denies one command.
266
+ // Deadness is recomputed against these grants every time, so an arm re-enters the state the
267
+ // moment a grant revives it.
268
+ policies: canonicalPolicyArms(contract, governed),
177
269
  };
178
270
  }
@@ -25,6 +25,11 @@ import { parsePgArray } from './security-catalog';
25
25
  import { INTROSPECTION_SESSION, type SessionRunner } from './session';
26
26
  import { matchPolicies, roleSetEqual } from './authz-identity';
27
27
  import { normalizeDeparsedExpr } from './deparse-normal';
28
+ // A function-scoped cycle: authz-canonical imports the small pure helpers below, and this module
29
+ // imports its equivalence relation. Both cross-references live inside function bodies, never at
30
+ // module-init, so CJS resolves them at call time. The alternative — a second copy of the
31
+ // equivalence relation here — is the exact drift this module system exists to prevent.
32
+ import { tablePoliciesEquivalent } from './authz-canonical';
28
33
 
29
34
  // ---------------------------------------------------------------------------
30
35
  // The contract format — the frozen, reviewable, version-controlled shape.
@@ -50,6 +55,21 @@ export interface TableContract {
50
55
  * it is first-class in the contract — a table-level grant view alone would miss it.
51
56
  */
52
57
  columnGrants?: Record<string, Record<string, string[]>>;
58
+ /**
59
+ * EFFECTIVE privileges per role — the folded, membership-and-PUBLIC-aware truth, measured on
60
+ * the LIVE database (`has_table_privilege` OR `has_any_column_privilege`, which Postgres
61
+ * evaluates through role membership and PUBLIC). Consulted by DEADNESS only; `grants` stays
62
+ * the DIRECT ACL because that is what the grant diff reconciles.
63
+ *
64
+ * Present ONLY on an introspected live contract. A declared contract (compiled from the Model)
65
+ * has none, and deadness falls back to its `grants` — which ARE its effective set, since the
66
+ * model grants to the same role it polices. That absence is the signal: "no measurement, use
67
+ * the direct grants", exactly as an older recorder that never ran the effective read behaves.
68
+ *
69
+ * `role -> the commands (SELECT/INSERT/UPDATE/DELETE) the role effectively holds`. A role that
70
+ * holds nothing is omitted, not stored empty.
71
+ */
72
+ effectivePrivileges?: Record<string, string[]>;
53
73
  policies: PolicyContract[];
54
74
  /**
55
75
  * Column exposure (hiddenColumns / `.private()`, protectedFields / `.readonly()`).
@@ -138,6 +158,24 @@ export function holdsPrivilege(t: TableContract, role: string, privilege: string
138
158
  || Boolean(cols.public?.[privilege]?.length);
139
159
  }
140
160
 
161
+ /**
162
+ * Does a role EFFECTIVELY hold a privilege? — the deadness question, folding role membership.
163
+ *
164
+ * The direct-ACL {@link holdsPrivilege} answers "does the catalog name this role as a grantee?"
165
+ * A brownfield role chain breaks that question: a role that is a MEMBER of a role holding the
166
+ * grant, or that holds it through PUBLIC, effectively has the privilege while the direct ACL
167
+ * never names it. Deadness computed from the direct ACL then calls a live, working policy dead.
168
+ *
169
+ * When a live contract carries {@link TableContract.effectivePrivileges} — the measured
170
+ * `has_table_privilege`/`has_any_column_privilege` matrix, which Postgres folds through
171
+ * membership and PUBLIC — this reads it. Absent (a declared contract, or an older recorder), it
172
+ * falls back to the direct grants, which for a declared contract ARE the effective set.
173
+ */
174
+ export function holdsPrivilegeEffective(t: TableContract, role: string, privilege: string): boolean {
175
+ if (t.effectivePrivileges) return (t.effectivePrivileges[role] ?? []).includes(privilege);
176
+ return holdsPrivilege(t, role, privilege);
177
+ }
178
+
141
179
  /** The four row-level commands a policy can govern. `FOR ALL` fans out across them. */
142
180
  export const POLICY_COMMANDS = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
143
181
 
@@ -162,13 +200,17 @@ export function policyCommands(p: PolicyContract): string[] {
162
200
  * policy RESURRECTS the moment a grant makes it effective.
163
201
  */
164
202
  export function isPolicyDead(t: TableContract, p: PolicyContract): boolean {
203
+ // A PUBLIC policy applies to every grantee, so it is dead only when NO grantee holds any of
204
+ // its commands. Which grantees to test: the effective matrix's roles when measured (it names
205
+ // every role a membership could reach), else the direct grantees. Folding PUBLIC across the
206
+ // effective roles is what lets `has_table_privilege` decide a PUBLIC-arm's aliveness.
165
207
  const roles = p.roles.includes('public') || p.roles.includes('PUBLIC')
166
- ? Object.keys(t.grants)
208
+ ? Object.keys(t.effectivePrivileges ?? t.grants)
167
209
  : p.roles;
168
210
  if (!roles.length) return true;
169
- // `holdsPrivilege`, not the table-level one: a column-scoped grant is real access, so a
170
- // policy governing it is doing live work. See the two-predicate note above.
171
- return !policyCommands(p).some((cmd) => roles.some((r) => holdsPrivilege(t, r, cmd)));
211
+ // `holdsPrivilegeEffective`, not the direct one: a column-scoped grant is real access AND a
212
+ // membership-held privilege is real access, so a policy governing either is doing live work.
213
+ return !policyCommands(p).some((cmd) => roles.some((r) => holdsPrivilegeEffective(t, r, cmd)));
172
214
  }
173
215
 
174
216
  /** Does this policy name PostgreSQL's PUBLIC pseudo-role — the open set of every role? */
@@ -448,6 +490,79 @@ WHERE a.attacl IS NOT NULL
448
490
  ORDER BY n.nspname, c.relname, grantee, privilege, a.attname;
449
491
  `.trim();
450
492
 
493
+ /**
494
+ * The EFFECTIVE per-(table, role) privilege matrix — what a role can actually do, folding role
495
+ * membership and PUBLIC, which the direct `aclexplode(relacl)` of GRANTS_SQL cannot see.
496
+ *
497
+ * `has_table_privilege` / `has_any_column_privilege` answer the folded question Postgres itself
498
+ * enforces: a role inherits the grants of every role it is a (INHERIT) member of, and every
499
+ * PUBLIC grant. This feeds DEADNESS only — a policy governing a command its role holds only
500
+ * through a parent role is doing live work, and dropping it is a real authorization change.
501
+ *
502
+ * Scoped to the roles that matter: a role NAMED IN A POLICY (its deadness is the question) or a
503
+ * direct grantee (needed to answer a PUBLIC arm's "does anyone hold this?"). Probing every role
504
+ * in the cluster would be needless work and would name roles no policy ever consults. Column
505
+ * privileges fold in via `has_any_column_privilege` (SELECT/INSERT/UPDATE only — DELETE is never
506
+ * column-scoped). Extension tables excluded to match the other reads.
507
+ */
508
+ export const EFFECTIVE_PRIVILEGES_SQL = `
509
+ WITH candidate AS (
510
+ SELECT DISTINCT r.oid, r.rolname
511
+ FROM pg_roles r
512
+ WHERE EXISTS (SELECT 1 FROM pg_policy p WHERE r.oid = ANY (p.polroles))
513
+ OR EXISTS (
514
+ SELECT 1 FROM pg_class c2
515
+ CROSS JOIN LATERAL aclexplode(c2.relacl) a2
516
+ WHERE a2.grantee = r.oid AND c2.relkind IN ('r', 'v', 'm')
517
+ )
518
+ OR EXISTS (
519
+ SELECT 1 FROM pg_attribute at2
520
+ CROSS JOIN LATERAL aclexplode(at2.attacl) a3
521
+ WHERE a3.grantee = r.oid AND at2.attnum > 0 AND NOT at2.attisdropped
522
+ )
523
+ )
524
+ SELECT
525
+ n.nspname AS schema,
526
+ c.relname AS "table",
527
+ cr.rolname AS role,
528
+ ARRAY(
529
+ SELECT p FROM unnest(ARRAY['SELECT', 'INSERT', 'UPDATE', 'DELETE']) AS p
530
+ WHERE has_table_privilege(cr.oid, c.oid, p)
531
+ OR (p <> 'DELETE' AND has_any_column_privilege(cr.oid, c.oid, p))
532
+ ) AS privileges
533
+ FROM pg_class c
534
+ JOIN pg_namespace n ON n.oid = c.relnamespace
535
+ CROSS JOIN candidate cr
536
+ WHERE c.relkind = 'r'
537
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
538
+ AND n.nspname NOT LIKE 'pg_%'
539
+ AND NOT EXISTS (
540
+ SELECT 1 FROM pg_depend d
541
+ WHERE d.objid = c.oid AND d.deptype = 'e'
542
+ )
543
+ ORDER BY n.nspname, c.relname, cr.rolname;
544
+ `.trim();
545
+
546
+ export interface EffectivePrivilegeRow {
547
+ schema: string;
548
+ table: string;
549
+ role: unknown;
550
+ privileges: unknown;
551
+ }
552
+
553
+ /** Map one effective-privilege row to its schema-qualified table + role + folded privileges. */
554
+ export function effectivePrivilegeRowToDescriptor(row: EffectivePrivilegeRow): {
555
+ table: string;
556
+ role: string;
557
+ privileges: string[];
558
+ } {
559
+ return {
560
+ table: `${row.schema}.${row.table}`,
561
+ role: String(row.role),
562
+ privileges: (parsePgArray(row.privileges) ?? []).map((p) => p.toUpperCase()),
563
+ };
564
+ }
565
+
451
566
  /**
452
567
  * Every base table with its RLS posture — the table enumerator for the contract.
453
568
  * `relrowsecurity` / `relforcerowsecurity` give enabled/forced. A table absent from
@@ -750,6 +865,9 @@ export interface ContractRows {
750
865
  policies: PolicyRow[];
751
866
  grants: GrantRow[];
752
867
  columnGrants?: ColumnGrantRow[];
868
+ /** EFFECTIVE_PRIVILEGES_SQL rows — the membership-folded matrix deadness reads. Optional:
869
+ * absent means the effective read never ran, and deadness stays on the direct grants. */
870
+ effectivePrivileges?: EffectivePrivilegeRow[];
753
871
  /** pg_namespace ACL rows (SCHEMA_ACL_SQL) — optional; absent means schema ACLs unknown. */
754
872
  schemaAcls?: SchemaAclRow[];
755
873
  /** SEQUENCE_ACLS_SQL. Optional — absent means not measured, never "no sequences". */
@@ -798,6 +916,19 @@ export function assembleContract(rows: ContractRows): AuthzContract {
798
916
  (byPriv[privilege] ??= []).push(column);
799
917
  }
800
918
 
919
+ // The effective matrix (membership + PUBLIC folded) — deadness only. A role holding nothing is
920
+ // omitted, so absence reads as "no effective privileges", never as "unmeasured". The map is
921
+ // attached to a table ONLY when the effective read ran, so a declared/older contract keeps
922
+ // deadness on its direct grants.
923
+ if (rows.effectivePrivileges) {
924
+ for (const row of rows.effectivePrivileges) {
925
+ const { table, role, privileges } = effectivePrivilegeRowToDescriptor(row);
926
+ const t = tables.get(table);
927
+ if (!t || privileges.length === 0) continue;
928
+ (t.effectivePrivileges ??= {})[role] = [...privileges].sort();
929
+ }
930
+ }
931
+
801
932
  for (const t of tables.values()) {
802
933
  t.policies.sort((a, b) => a.name.localeCompare(b.name));
803
934
  for (const byPriv of Object.values(t.columnGrants ?? {})) {
@@ -844,11 +975,11 @@ export async function introspectContract(
844
975
  mapFunctionRow: (row: any) => { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean },
845
976
  functionsSql: string,
846
977
  ): Promise<AuthzContract> {
847
- // ONE session: the six queries describe one moment under one pinned search_path.
978
+ // ONE session: the queries describe one moment under one pinned search_path.
848
979
  // Policy USING / WITH CHECK expressions deparse relative to that path, so a read spread
849
980
  // across connections can report authz drift that does not exist.
850
- const [rls, policies, grants, columnGrants, schemaAcls, sequenceAcls, fnRows] = await session(
851
- [RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, functionsSql],
981
+ const [rls, policies, grants, columnGrants, effectivePrivileges, schemaAcls, sequenceAcls, fnRows] = await session(
982
+ [RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, EFFECTIVE_PRIVILEGES_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, functionsSql],
852
983
  INTROSPECTION_SESSION,
853
984
  );
854
985
  return assembleContract({
@@ -856,6 +987,7 @@ export async function introspectContract(
856
987
  policies: policies as PolicyRow[],
857
988
  grants: grants as GrantRow[],
858
989
  columnGrants: columnGrants as ColumnGrantRow[],
990
+ effectivePrivileges: effectivePrivileges as EffectivePrivilegeRow[],
859
991
  schemaAcls: schemaAcls as SchemaAclRow[],
860
992
  sequenceAcls: sequenceAcls as SequenceAclRow[],
861
993
  functions: (fnRows as any[]).map(mapFunctionRow),
@@ -1029,6 +1161,8 @@ function diffPolicies(
1029
1161
  ): void {
1030
1162
  const m = matchPolicies(d.policies, l.policies);
1031
1163
 
1164
+ // Adoptions are recorded whether or not the states are canonically equal \u2014 a name divergence
1165
+ // is agreement, not drift, and its mapping must stay visible even on an equivalent table.
1032
1166
  for (const a of m.adopted) {
1033
1167
  adoptions.push({ subject: table, declared: [a.declared], live: a.live, reason: 'same rule, different name' });
1034
1168
  }
@@ -1036,6 +1170,16 @@ function diffPolicies(
1036
1170
  adoptions.push({ subject: table, declared: g.declared, live: g.live, reason: 'one live policy covers the declared per-role group' });
1037
1171
  }
1038
1172
 
1173
+ // Condition B \u2014 gate drift on the SAME canonical equivalence the reconciler and the fingerprint
1174
+ // use, so no surface can report drift the plan does not carry. A live `FOR ALL` with a dead
1175
+ // DELETE arm equals the granular pull that omits DELETE; the per-command matcher below would
1176
+ // call it "undeclared policy" + "declared but missing". No governed set is threaded through the
1177
+ // differ, so this compares ALL arms \u2014 a strictly HARDER equality than the reconciler's
1178
+ // governed-filtered one, which means the differ can only ever be MORE willing to report drift,
1179
+ // never silent where the reconciler emits. Only when the states genuinely differ do the
1180
+ // per-policy findings run.
1181
+ if (tablePoliciesEquivalent(d, l)) return;
1182
+
1039
1183
  // A name on BOTH sides whose rule moved is a CHANGE, not a remove-and-add. Saying which
1040
1184
  // field moved is the difference between a finding an operator can act on and one they have
1041
1185
  // to go read the catalog to understand.