@everystack/cli 0.4.44 → 0.4.45

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.44",
3
+ "version": "0.4.45",
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>",
@@ -109,7 +109,7 @@
109
109
  "structured-headers": "1.0.1",
110
110
  "tsx": "4.21.0",
111
111
  "typescript": "5.9.3",
112
- "@everystack/model": "0.4.9"
112
+ "@everystack/model": "0.4.10"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "@everystack/server": ">=0.4.0",
@@ -0,0 +1,265 @@
1
+ /**
2
+ * authz-adoption-class — WHY does this statement exist?
3
+ *
4
+ * A brownfield adopter's first plan is long, and the length is not the problem. The problem is
5
+ * that nobody can tell which statements are the tool imposing its own spelling and which are
6
+ * real differences between the model and the database. This classifier answers that, per
7
+ * statement, mechanically.
8
+ *
9
+ * FOUR CLASSES, and the whole point is that the first one should reach zero:
10
+ *
11
+ * - `convention` — exists ONLY because we spell a semantically identical rule differently.
12
+ * Every one of these is our bug. Phase A's success criterion is zero.
13
+ * - `capability` — the model cannot express what is live, so the plan would remove it.
14
+ * Real, and a gap in everystack, not in their schema.
15
+ * - `narrowing` — a genuine reduction of access. Must ship, labeled, and be approved by a
16
+ * human before it is applied.
17
+ * - `dead` — the live object is dead code: policed but never granted, so it authorizes
18
+ * nothing today.
19
+ *
20
+ * NAME COLLISION, stated once so nobody conflates them. `edge-plan.ts` already has
21
+ * `PlanClassification.narrowings`, which means a DATA-LOSSY `ALTER COLUMN … TYPE`. This
22
+ * `narrowing` means an ACCESS reduction. They are two orthogonal axes on the same statement —
23
+ * one asks "what does it cost?", the other asks "why is it here?" — and both are correct in
24
+ * their own frame. This module deliberately does NOT extend `PlanClassification`.
25
+ *
26
+ * READ-ONLY. Nothing here changes what is emitted. It reasons over the same declared and live
27
+ * contracts the emitter does, and a test pins its statement list to the emitter's actual
28
+ * output so the two can never drift apart.
29
+ */
30
+
31
+ import { matchPolicies, roleSetEqual } from './authz-identity.js';
32
+ import { governedRoleSet } from './authz-reconcile.js';
33
+ import { effectivePolicyCheck, holdsPrivilege } from './authz-contract.js';
34
+ import type { AuthzContract, PolicyContract, TableContract } from './authz-contract.js';
35
+
36
+ export type AdoptionClass = 'convention' | 'capability' | 'narrowing' | 'dead' | 'unclassified';
37
+
38
+ export interface ClassifiedStatement {
39
+ table: string;
40
+ /** The statement's subject — a policy name, or `grantee` for a privilege change. */
41
+ subject: string;
42
+ cls: AdoptionClass;
43
+ /** One line, in plain words, for the plan's report. */
44
+ why: string;
45
+ }
46
+
47
+ export interface AdoptionCounts {
48
+ convention: number;
49
+ capability: number;
50
+ narrowing: number;
51
+ dead: number;
52
+ unclassified: number;
53
+ }
54
+
55
+ /**
56
+ * The reconciler's own exemption rule, mirrored: a grantee the declared authz does not govern
57
+ * is left alone. `PUBLIC` compares case-insensitively because it is a pseudo-role with two
58
+ * catalog spellings; every other name compares exactly.
59
+ */
60
+ function isGovernedGrantee(governed: ReadonlySet<string>, grantee: string): boolean {
61
+ return governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
62
+ }
63
+
64
+ const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
65
+
66
+ /**
67
+ * Does `outer` cover every role in `inner`?
68
+ *
69
+ * PUBLIC (`['public']`) covers everything — it is an open set. That fact is used HERE ONLY, to
70
+ * decide whether a live policy's replacement is narrower (a label). It is deliberately NOT
71
+ * available to the matcher: letting PUBLIC compare equal to an enumerated set there would be
72
+ * the false match the whole phase exists to prevent. Labeling cannot widen access; matching
73
+ * can.
74
+ */
75
+ function covers(outer: readonly string[], inner: readonly string[]): boolean {
76
+ if (outer.includes('public')) return true;
77
+ const o = new Set(outer);
78
+ return inner.every((r) => o.has(r));
79
+ }
80
+
81
+ /** Same authorization on every axis EXCEPT which roles it names — the role-split test. */
82
+ function sameRule(a: PolicyContract, b: PolicyContract): boolean {
83
+ return a.command === b.command
84
+ && a.permissive === b.permissive
85
+ && (a.using ?? '') === (b.using ?? '')
86
+ && (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
87
+ }
88
+
89
+ /** Every command a policy governs — `ALL` fans out, so a live `FOR ALL` is compared per command. */
90
+ function commandsOf(p: PolicyContract): string[] {
91
+ return p.command === 'ALL' ? [...CRUD] : [p.command];
92
+ }
93
+
94
+ /**
95
+ * A policy is DEAD when no role it applies to holds the privilege it polices. Postgres checks
96
+ * the GRANT before the policy, so such a policy authorizes nothing — removing it takes away
97
+ * access that was never there. A PUBLIC policy applies to every grantee, so it is dead only if
98
+ * NO grantee holds the privilege at all.
99
+ */
100
+ function isDead(t: TableContract, p: PolicyContract): boolean {
101
+ const roles = p.roles.includes('public') ? Object.keys(t.grants) : p.roles;
102
+ if (!roles.length) return true;
103
+ // `holdsPrivilege`, not the table-level one: a column-scoped grant is real access, and
104
+ // "dead" tells an operator the drop takes away nothing. Shared with db:pull so the two can
105
+ // never answer this differently about one database.
106
+ return !commandsOf(p).some((cmd) => roles.some((r) => holdsPrivilege(t, r, cmd)));
107
+ }
108
+
109
+ /** Classify the policy statements one table would emit. */
110
+ function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedStatement[]): void {
111
+ const table = d.table;
112
+ const m = matchPolicies(d.policies, l.policies);
113
+
114
+ // Adopted shapes — a rule-identical policy under another name, or a live multi-role policy
115
+ // covering the declared per-role group — emit NOTHING, so there is nothing here to classify.
116
+ // This is what driving `convention` toward zero looks like: the statements stop existing
117
+ // rather than getting a nicer label.
118
+
119
+ const declaredByName = new Map(m.toCreate.map((p) => [p.name, p]));
120
+ const consumed = new Set<string>();
121
+
122
+ for (const name of m.toDrop) {
123
+ const live = l.policies.find((p) => p.name === name)!;
124
+
125
+ if (isDead(l, live)) {
126
+ out.push({ table, subject: name, cls: 'dead', why: `policed but never granted — it authorizes nothing today` });
127
+ continue;
128
+ }
129
+
130
+ // A replacement exists when some declared policy governs the same command. If those
131
+ // replacements together cover FEWER roles than the live policy did, the plan is reducing
132
+ // access — a narrowing, which must be approved rather than merely applied.
133
+ const replacements = m.toCreate.filter(
134
+ (c) => commandsOf(c).some((cmd) => commandsOf(live).includes(cmd)),
135
+ );
136
+ if (!replacements.length) {
137
+ out.push({ table, subject: name, cls: 'capability', why: `no declared policy governs ${commandsOf(live).join('/')} — the model cannot express this rule` });
138
+ continue;
139
+ }
140
+
141
+ const replacementRoles = [...new Set(replacements.flatMap((r) => r.roles))];
142
+
143
+ // The role split: one live policy covering several roles, against the per-role policies the
144
+ // compiler emits. Same predicate, and the declared roles union to EXACTLY the live role set,
145
+ // so the two authorize identically for every session. Pure spelling — ours to fix (A5).
146
+ // Note this is recognized here for LABELING before the matcher can act on it; the two must
147
+ // agree once A5 lands, which is what the emitter-parity test pins.
148
+ if (roleSetEqual(live.roles, replacementRoles) && replacements.every((r) => sameRule(r, live))) {
149
+ out.push({ table, subject: name, cls: 'convention', why: `live covers ${live.roles.join(' + ')} in one policy; we split it per role with the same predicate` });
150
+ for (const r of replacements) {
151
+ if (consumed.has(r.name)) continue;
152
+ consumed.add(r.name);
153
+ out.push({ table, subject: r.name, cls: 'convention', why: `the per-role half of "${name}"` });
154
+ }
155
+ continue;
156
+ }
157
+
158
+ if (covers(live.roles, replacementRoles) && !roleSetEqual(live.roles, replacementRoles)) {
159
+ out.push({ table, subject: name, cls: 'narrowing', why: `live applies to ${live.roles.join(', ')}; the declared replacement covers only ${replacementRoles.join(', ')}` });
160
+ for (const r of replacements) {
161
+ if (consumed.has(r.name)) continue;
162
+ consumed.add(r.name);
163
+ out.push({ table, subject: r.name, cls: 'narrowing', why: `the narrower replacement for "${name}"` });
164
+ }
165
+ continue;
166
+ }
167
+
168
+ out.push({ table, subject: name, cls: 'unclassified', why: `dropped with a replacement that is neither the same rule nor narrower` });
169
+ }
170
+
171
+ for (const [name] of declaredByName) {
172
+ if (consumed.has(name)) continue;
173
+ out.push({ table, subject: name, cls: 'unclassified', why: `created with no live counterpart` });
174
+ }
175
+ }
176
+
177
+ /** Classify the privilege statements one table would emit. */
178
+ function classifyGrants(
179
+ d: TableContract,
180
+ l: TableContract,
181
+ governed: ReadonlySet<string>,
182
+ out: ClassifiedStatement[],
183
+ ): void {
184
+ const table = d.table;
185
+
186
+ for (const grantee of Object.keys(l.grants).sort()) {
187
+ // The reconciler LEAVES ungoverned grantees alone — a migrator or BI reader the model
188
+ // vocabulary cannot name keeps its access, reported instead of revoked. It emits nothing
189
+ // for them, so neither may we: counting statements that are never emitted would describe
190
+ // a plan nobody is going to apply.
191
+ if (!isGovernedGrantee(governed, grantee)) continue;
192
+ const declared = new Set(d.grants[grantee] ?? []);
193
+ const live = l.grants[grantee] ?? [];
194
+ const revoked = live.filter((p) => !declared.has(p));
195
+ if (!revoked.length) continue;
196
+
197
+ // Every revoke here is now a NARROWING, including the beyond-CRUD trio.
198
+ //
199
+ // It used to be `convention` for REFERENCES/TRIGGER/TRUNCATE: `manage` means exactly CRUD,
200
+ // so live admin holding those three read as drift and the plan revoked them — 28 statements
201
+ // on the reference schema, none of which anybody asked for. The model has a word for them
202
+ // now (`privileges`), and `db:pull` transcribes what is live, so a pulled model emits
203
+ // nothing. A model that does NOT declare them is choosing to remove them, which is a real
204
+ // access reduction and gets labeled as one. The fix is at the source, not in the label.
205
+ out.push({ table, subject: grantee, cls: 'narrowing', why: `revokes ${revoked.join(', ')} from ${grantee}` });
206
+ }
207
+
208
+ // Column grants: the model expresses column scoping for READ only, so a live column-scoped
209
+ // UPDATE has no declaration and the plan revokes it. That is a gap in everystack.
210
+ const lcg = l.columnGrants ?? {};
211
+ const dcg = d.columnGrants ?? {};
212
+ for (const grantee of Object.keys(lcg).sort()) {
213
+ if (!isGovernedGrantee(governed, grantee)) continue;
214
+ for (const priv of Object.keys(lcg[grantee]).sort()) {
215
+ const declaredCols = new Set(dcg[grantee]?.[priv] ?? []);
216
+ const revoked = (lcg[grantee][priv] ?? []).filter((c) => !declaredCols.has(c));
217
+ if (!revoked.length) continue;
218
+ out.push({ table, subject: `${grantee}/${priv}`, cls: 'capability', why: `column-scoped ${priv} on ${revoked.length} column(s) — the model declares columns for read only` });
219
+ }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Classify every authorization statement the plan would emit, per table.
225
+ *
226
+ * Live tables no model declares are skipped: the plan does not touch them, so they contribute
227
+ * no statements to classify.
228
+ */
229
+ export function classifyAdoption(
230
+ declared: AuthzContract,
231
+ live: AuthzContract,
232
+ opts: { governedRoles?: ReadonlySet<string> } = {},
233
+ ): {
234
+ statements: ClassifiedStatement[];
235
+ counts: AdoptionCounts;
236
+ } {
237
+ const governed = opts.governedRoles ?? governedRoleSet(declared);
238
+ const liveByTable = new Map(live.tables.map((t) => [t.table, t]));
239
+ const statements: ClassifiedStatement[] = [];
240
+
241
+ for (const d of declared.tables) {
242
+ const l = liveByTable.get(d.table);
243
+ if (!l) continue; // a brand-new table adds; nothing here is an adoption decision
244
+ classifyPolicies(d, l, statements);
245
+ classifyGrants(d, l, governed, statements);
246
+ }
247
+
248
+ const counts: AdoptionCounts = { convention: 0, capability: 0, narrowing: 0, dead: 0, unclassified: 0 };
249
+ for (const s of statements) counts[s.cls]++;
250
+ return { statements, counts };
251
+ }
252
+
253
+ /** The report block for the plan surface. Terse: one line per class, then the offenders. */
254
+ export function renderAdoptionReport(counts: AdoptionCounts): string[] {
255
+ const lines = [
256
+ ` convention ${counts.convention} — our spelling, not their schema (this must reach 0)`,
257
+ ` capability ${counts.capability} — the model cannot express what is live`,
258
+ ` narrowing ${counts.narrowing} — real access reduction, approve before applying`,
259
+ ` dead ${counts.dead} — policed but never granted`,
260
+ ];
261
+ if (counts.unclassified) {
262
+ lines.push(` unclassified ${counts.unclassified} — NOT described; read these before applying`);
263
+ }
264
+ return lines;
265
+ }
@@ -36,6 +36,7 @@
36
36
 
37
37
  import fs from 'node:fs/promises';
38
38
  import path from 'node:path';
39
+ import { FINGERPRINT_VERSION } from './schema-fingerprint.js';
39
40
  import type { GrantExemption } from './authz-reconcile.js';
40
41
 
41
42
  /** The repo-relative artifact. Generated — regenerating it must reproduce it byte for byte. */
@@ -58,6 +59,15 @@ export interface BaselineStage {
58
59
  observedAt: string;
59
60
  /** The live base fingerprint at observation time — what the claim was true OF. */
60
61
  fingerprint: string;
62
+ /**
63
+ * The FINGERPRINT_VERSION that hash was computed under.
64
+ *
65
+ * Stored beside the hash rather than only mixed into it, because a bare hash cannot say WHY
66
+ * it differs. Without this a baseline recorded under an older canonical form reads as drift,
67
+ * and an operator goes looking for a change nobody made instead of running one re-baseline.
68
+ * Absent on baselines written before v4.
69
+ */
70
+ fpVersion?: number;
61
71
  grantees: Record<string, BaselineGrantee>;
62
72
  }
63
73
 
@@ -71,7 +81,7 @@ export const EMPTY_BASELINE: AuthzBaseline = { version: 1, stages: {} };
71
81
  /** Fold the observed exemptions into one entry per grantee. */
72
82
  export function buildStageBaseline(
73
83
  exemptions: readonly GrantExemption[],
74
- meta: { observedAt: string; fingerprint: string },
84
+ meta: { observedAt: string; fingerprint: string; fpVersion?: number },
75
85
  ): BaselineStage {
76
86
  const grantees: Record<string, BaselineGrantee> = {};
77
87
  for (const e of exemptions) {
@@ -83,7 +93,12 @@ export function buildStageBaseline(
83
93
  const all = [...e.privileges, ...Object.keys(e.columnPrivileges ?? {})];
84
94
  g.write ||= all.some((p) => WRITE_PRIVILEGES.has(p.toUpperCase()));
85
95
  }
86
- return { observedAt: meta.observedAt, fingerprint: meta.fingerprint, grantees };
96
+ return {
97
+ observedAt: meta.observedAt,
98
+ fingerprint: meta.fingerprint,
99
+ fpVersion: meta.fpVersion ?? FINGERPRINT_VERSION,
100
+ grantees,
101
+ };
87
102
  }
88
103
 
89
104
  /** Write one stage's entry into the file, leaving every other stage untouched. */
@@ -105,7 +120,14 @@ export function renderBaseline(baseline: AuthzBaseline): string {
105
120
  const { privileges, tables, write } = s.grantees[g];
106
121
  grantees[g] = { privileges: [...privileges].sort(), tables: [...tables].sort(), write };
107
122
  }
108
- stages[stage] = { observedAt: s.observedAt, fingerprint: s.fingerprint, grantees };
123
+ // `fpVersion` rides through the render, or the stamp is lost on the first rewrite and the
124
+ // baseline silently becomes unstamped — which reads as a format change forever after.
125
+ stages[stage] = {
126
+ observedAt: s.observedAt,
127
+ fingerprint: s.fingerprint,
128
+ ...(s.fpVersion != null ? { fpVersion: s.fpVersion } : {}),
129
+ grantees,
130
+ };
109
131
  }
110
132
  return JSON.stringify({ version: 1, stages }, null, 2) + '\n';
111
133
  }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * authz-canonical — ONE definition of "these two authorization states are the same".
3
+ *
4
+ * Three surfaces answer that question and they must never answer it differently:
5
+ *
6
+ * - `authz-reconcile` — emits the SQL that closes the gap (statements == 0 means same)
7
+ * - `authz-contract` — reports drift (no findings means same)
8
+ * - `schema-fingerprint` — content-addresses the state (equal hashes means same)
9
+ *
10
+ * They were two-and-a-half hand-mirrored copies, and that is exactly how the identity broke:
11
+ * the reconciler learned to exempt an ungoverned grantee, the fingerprint did not, and every
12
+ * brownfield adopter landed on "nothing to do" and "you have drifted" at the same time. The
13
+ * fingerprint is not a third opinion — it is this equivalence relation, cached. When the
14
+ * relation changes, the cache format changes with it.
15
+ *
16
+ * Two normalizations live here, and both are load-bearing:
17
+ *
18
+ * ROLE EXPANSION. A policy `TO a, b` and two identical-predicate policies `TO a` and `TO b`
19
+ * are applicable to precisely the same sessions, because Postgres selects policies per session
20
+ * role by membership. Expanding every policy to one entry per role makes those two states hash
21
+ * equal, which is what makes the reconciler's adoption of a role split legible to the gate.
22
+ *
23
+ * PUBLIC NEVER EXPANDS. It is an open set — every role that exists or ever will. Enumerating
24
+ * it at hash time against today's roles would rebuild, inside the fingerprint, exactly the
25
+ * contingent equivalence the matcher refuses: equal today, silently wrong the moment a role is
26
+ * created. It stays a single sentinel entry.
27
+ *
28
+ * MULTISET, NEVER A SET. Entries carry counts. If duplicates collapsed, two live policies with
29
+ * the same rule and different names would hash as one, the fingerprint would report MATCH, and
30
+ * the matcher — which adopts one and drops the other — would still emit statements. That is
31
+ * the same identity break in the opposite direction, and it is the harder one to notice.
32
+ */
33
+
34
+ import type { PolicyContract, TableContract } from './authz-contract.js';
35
+ import { effectivePolicyCheck } from './authz-contract.js';
36
+
37
+ /** PostgreSQL's open role set, kept whole. */
38
+ const PUBLIC_ROLE = 'public';
39
+
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.
43
+ */
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}`);
48
+ }
49
+
50
+ /**
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.
58
+ *
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.
62
+ */
63
+ function stableRule(p: PolicyContract): string {
64
+ return JSON.stringify([
65
+ p.command,
66
+ p.permissive,
67
+ p.using ?? '',
68
+ effectivePolicyCheck(p) ?? '',
69
+ ]);
70
+ }
71
+
72
+ /** Sorted multiset: every entry kept, duplicates counted, order irrelevant. */
73
+ function multiset(entries: string[]): Array<[string, number]> {
74
+ const counts = new Map<string, number>();
75
+ for (const e of entries) counts.set(e, (counts.get(e) ?? 0) + 1);
76
+ return [...counts.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
77
+ }
78
+
79
+ /** The canonical policy form: every policy expanded across its roles, as a sorted multiset. */
80
+ export function canonicalPolicies(policies: readonly PolicyContract[]): Array<[string, number]> {
81
+ return multiset(policies.flatMap(expandPolicy));
82
+ }
83
+
84
+ /**
85
+ * Keep only the grantees the models actually govern.
86
+ *
87
+ * The reconciler leaves an ungoverned grantee alone — a migrator, ETL or BI role the model
88
+ * vocabulary cannot name must not have its access destroyed by a plan nobody read. The hash
89
+ * has to make the same choice or it contradicts the plan: a live grant nothing will ever
90
+ * reconcile is not part of the state the models describe.
91
+ *
92
+ * `PUBLIC` compares case-insensitively (a pseudo-role with two catalog spellings); every other
93
+ * name compares exactly, because PostgreSQL role names are case-sensitive and `CREATE ROLE
94
+ * "Public"` is a real, distinct role.
95
+ */
96
+ function governedOnly<T>(
97
+ map: Record<string, T> | undefined,
98
+ governed: ReadonlySet<string> | undefined,
99
+ ): Record<string, T> {
100
+ if (!map) return {};
101
+ if (!governed) return map;
102
+ return Object.fromEntries(
103
+ Object.entries(map).filter(([grantee]) =>
104
+ governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'))),
105
+ );
106
+ }
107
+
108
+ /**
109
+ * The canonical authorization form for one table.
110
+ *
111
+ * `governed` is the set the models declare. Pass it whenever hashing a LIVE contract, or the
112
+ * hash counts grants the reconciler will never touch and the state can never converge.
113
+ */
114
+ export function canonicalAuthz(
115
+ contract: TableContract,
116
+ governed?: ReadonlySet<string>,
117
+ ): Record<string, unknown> {
118
+ const grants = governedOnly(contract.grants, governed);
119
+ const columnGrants = governedOnly(contract.columnGrants, governed);
120
+ return {
121
+ table: contract.table,
122
+ rls: { enabled: contract.rls.enabled, forced: contract.rls.forced },
123
+ grants: Object.fromEntries(
124
+ Object.entries(grants)
125
+ .map(([role, privs]) => [role, [...(privs as string[])].sort()] as const)
126
+ .sort(([a], [b]) => (a < b ? -1 : 1)),
127
+ ),
128
+ ...(Object.keys(columnGrants).length > 0
129
+ ? {
130
+ columnGrants: Object.fromEntries(
131
+ Object.entries(columnGrants)
132
+ .map(([role, byPriv]) => [
133
+ role,
134
+ Object.fromEntries(
135
+ Object.entries(byPriv as Record<string, string[]>)
136
+ .map(([priv, cols]) => [priv, [...cols].sort()] as const)
137
+ .sort(([a], [b]) => (a < b ? -1 : 1)),
138
+ ),
139
+ ] as const)
140
+ .sort(([a], [b]) => (a < b ? -1 : 1)),
141
+ ),
142
+ }
143
+ : {}),
144
+ policies: canonicalPolicies(contract.policies),
145
+ };
146
+ }
@@ -208,6 +208,31 @@ function isColumnRead(a: Ability): boolean {
208
208
  return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
209
209
  }
210
210
 
211
+ /**
212
+ * `can(…, { role: 'public' })` names PostgreSQL's PUBLIC pseudo-role, and the two catalogs
213
+ * that describe it spell it differently — both correctly:
214
+ *
215
+ * - `pg_policies.roles` renders it as the literal `{public}` (lowercase)
216
+ * - `aclexplode()` yields grantee OID 0, which GRANTS_SQL renders as `'PUBLIC'`
217
+ *
218
+ * So the compiler speaks each catalog's spelling on its own axis. Saying `public` on both
219
+ * made the grant set-difference see `public` and `PUBLIC` as two grantees and emit a REVOKE
220
+ * and a GRANT that cancel out — on every run, converging never.
221
+ *
222
+ * The fold is COMPILE-TIME ONLY, applied to the author's declared role. Nothing folds at
223
+ * comparison time: `CREATE ROLE "Public"` is a legal, distinct role, and a case-insensitive
224
+ * compare against a live grantee would silently conflate it with the pseudo-role. (The cost
225
+ * of that choice: a real role named `Public` cannot be named by `role:`, because a model
226
+ * string carries no way to say "quoted identifier".)
227
+ */
228
+ const isPseudoPublic = (role: string): boolean => role.toLowerCase() === 'public';
229
+
230
+ /** The grant-side spelling of a declared role — what `aclexplode` would report. */
231
+ const granteeKey = (role: string): string => (isPseudoPublic(role) ? 'PUBLIC' : role);
232
+
233
+ /** The policy-side spelling of a declared role — what `pg_policies` would report. */
234
+ const policyRole = (role: string): string => (isPseudoPublic(role) ? 'public' : role);
235
+
211
236
  /**
212
237
  * Column grants from the column-scoped read abilities: the read role (`authenticated`, since
213
238
  * the ability is owner-scoped, not role-gated) gets `SELECT (cols)`. Field keys are snake_cased
@@ -219,7 +244,7 @@ function compileColumnGrants(abilities: readonly Ability[]): Record<string, Reco
219
244
  const out: Record<string, Record<string, string[]>> = {};
220
245
  for (const a of abilities) {
221
246
  if (!isColumnRead(a)) continue;
222
- const role = a.condition.role ?? 'authenticated';
247
+ const role = granteeKey(a.condition.role ?? 'authenticated');
223
248
  const cols = [...a.condition.columns!].map(toSnakeCase).sort();
224
249
  (out[role] ??= {}).SELECT = cols;
225
250
  }
@@ -436,7 +461,8 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
436
461
  // discarded. That is the same failure `rawPredicate` throws over, one level up: a predicate
437
462
  // that evaporates is an authorization hole wearing the costume of a working rule.
438
463
  for (const a of abilities.filter(isRoleRead)) {
439
- const role = a.condition.role!;
464
+ // The policy-side spelling, so `role: 'PUBLIC'` still matches what pg_policies reports.
465
+ const role = policyRole(a.condition.role!);
440
466
  const name = `${t}_select_${role}`;
441
467
  const where = `${table}: can('read', { role: '${role}' })`;
442
468
  if (policies.some((p) => p.name === name)) {
@@ -478,7 +504,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
478
504
  // write on the owner connection and bypass RLS -> ENABLE-not-FORCE (on RDS the
479
505
  // owner is not a superuser, so a FORCEd table would block the owner's own writes).
480
506
  rls: { enabled: true, forced: model.writtenBy === 'app' },
481
- grants: compileGrants(abilities),
507
+ grants: compileGrants(abilities, model.privileges),
482
508
  ...(compileColumnGrants(abilities) ? { columnGrants: compileColumnGrants(abilities) } : {}),
483
509
  policies,
484
510
  };
@@ -517,10 +543,19 @@ function verbsFor(action: Ability['action']): string[] {
517
543
  * table grants nothing, an owner-only table grants no anon SELECT, an admin-managed
518
544
  * table grants admin CRUD only. Deterministic: roles and privilege lists sorted,
519
545
  * so the output is byte-comparable with an introspected contract.
546
+ *
547
+ * `privileges` is the model's beyond-CRUD key — REFERENCES/TRIGGER/TRUNCATE, the table
548
+ * privileges `can()` has no verb for. They are UNIONED in, never subtracted: an admin that
549
+ * `can('manage')` and holds TRUNCATE keeps both. Without it, `manage` meant exactly CRUD and
550
+ * every live REFERENCES/TRIGGER/TRUNCATE read as drift, so the first plan against an existing
551
+ * schema revoked all three on every table — our spelling, not their schema.
520
552
  */
521
- function compileGrants(abilities: readonly Ability[]): Record<string, string[]> {
553
+ function compileGrants(
554
+ abilities: readonly Ability[],
555
+ privileges: Record<string, readonly string[]> = {},
556
+ ): Record<string, string[]> {
522
557
  const grants: Record<string, Set<string>> = {};
523
- const add = (role: string, verbs: string[]): void => {
558
+ const add = (role: string, verbs: readonly string[]): void => {
524
559
  const set = (grants[role] ??= new Set<string>());
525
560
  for (const v of verbs) set.add(v);
526
561
  };
@@ -531,13 +566,18 @@ function compileGrants(abilities: readonly Ability[]): Record<string, string[]>
531
566
  if (isColumnRead(a)) continue;
532
567
  const verbs = verbsFor(a.action);
533
568
  if (a.condition.role) {
534
- add(a.condition.role, verbs);
569
+ add(granteeKey(a.condition.role), verbs);
535
570
  } else {
536
571
  add('authenticated', verbs);
537
572
  // A public read (no role, not owner/via-scoped) is anon-visible.
538
573
  if (a.action === 'read' && !a.condition.owner && !a.condition.via) add('anon', ['SELECT']);
539
574
  }
540
575
  }
576
+ // The role is spelled on the GRANT axis, so `privileges: { public: [...] }` reaches the
577
+ // same grantee `aclexplode` reports as `PUBLIC` — the fold the ability path already does.
578
+ for (const [role, privs] of Object.entries(privileges)) {
579
+ if (privs.length) add(granteeKey(role), privs);
580
+ }
541
581
  const out: Record<string, string[]> = {};
542
582
  for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
543
583
  return out;