@everystack/cli 0.4.44 → 0.4.46

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.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/cli/alter-type-dependents.ts +96 -0
  3. package/src/cli/apply-execute.ts +22 -8
  4. package/src/cli/authz-adoption-class.ts +314 -0
  5. package/src/cli/authz-baseline.ts +25 -3
  6. package/src/cli/authz-canonical.ts +178 -0
  7. package/src/cli/authz-compile.ts +130 -40
  8. package/src/cli/authz-contract.ts +212 -44
  9. package/src/cli/authz-derive.ts +244 -34
  10. package/src/cli/authz-identity.ts +222 -0
  11. package/src/cli/authz-ownership.ts +193 -0
  12. package/src/cli/authz-reconcile.ts +61 -27
  13. package/src/cli/aws.ts +32 -0
  14. package/src/cli/commands/db-apply.ts +60 -14
  15. package/src/cli/commands/db-authz.ts +9 -14
  16. package/src/cli/commands/db-fingerprint.ts +54 -18
  17. package/src/cli/commands/db-generate.ts +59 -15
  18. package/src/cli/commands/db-plan.ts +89 -9
  19. package/src/cli/commands/db-pull.ts +36 -19
  20. package/src/cli/commands/db-reconcile.ts +18 -20
  21. package/src/cli/commands/db-swap.ts +5 -4
  22. package/src/cli/commands/db-sync.ts +8 -5
  23. package/src/cli/db-build.ts +2 -2
  24. package/src/cli/db-source.ts +56 -0
  25. package/src/cli/derived-introspect.ts +27 -26
  26. package/src/cli/derived-lint.ts +7 -8
  27. package/src/cli/edge-plan.ts +125 -17
  28. package/src/cli/git-descent.ts +16 -9
  29. package/src/cli/index.ts +2 -18
  30. package/src/cli/model-render.ts +75 -52
  31. package/src/cli/output.ts +25 -3
  32. package/src/cli/parse-flags.ts +39 -0
  33. package/src/cli/schema-compile.ts +6 -1
  34. package/src/cli/schema-diff.ts +1 -1
  35. package/src/cli/schema-fingerprint.ts +154 -42
  36. package/src/cli/schema-introspect.ts +44 -17
  37. package/src/cli/schema-source.ts +9 -0
  38. package/src/cli/session.ts +184 -0
  39. package/src/cli/stage-read-consistency.ts +128 -0
  40. package/src/cli/state-apply.ts +4 -2
  41. package/src/cli/swap-execute.ts +4 -3
  42. package/src/cli/search-path.ts +0 -51
@@ -0,0 +1,178 @@
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, isPolicyDead, isPolicySubsumed } 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
+ /**
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.
88
+ */
89
+ export function canonicalPolicies(
90
+ policies: readonly PolicyContract[],
91
+ governed?: ReadonlySet<string>,
92
+ ): 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);
101
+ }
102
+
103
+ /**
104
+ * Keep only the grantees the models actually govern.
105
+ *
106
+ * The reconciler leaves an ungoverned grantee alone — a migrator, ETL or BI role the model
107
+ * vocabulary cannot name must not have its access destroyed by a plan nobody read. The hash
108
+ * has to make the same choice or it contradicts the plan: a live grant nothing will ever
109
+ * reconcile is not part of the state the models describe.
110
+ *
111
+ * `PUBLIC` compares case-insensitively (a pseudo-role with two catalog spellings); every other
112
+ * name compares exactly, because PostgreSQL role names are case-sensitive and `CREATE ROLE
113
+ * "Public"` is a real, distinct role.
114
+ */
115
+ function governedOnly<T>(
116
+ map: Record<string, T> | undefined,
117
+ governed: ReadonlySet<string> | undefined,
118
+ ): Record<string, T> {
119
+ if (!map) return {};
120
+ if (!governed) return map;
121
+ return Object.fromEntries(
122
+ Object.entries(map).filter(([grantee]) =>
123
+ governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'))),
124
+ );
125
+ }
126
+
127
+ /**
128
+ * The canonical authorization form for one table.
129
+ *
130
+ * `governed` is the set the models declare. Pass it whenever hashing a LIVE contract, or the
131
+ * hash counts grants the reconciler will never touch and the state can never converge.
132
+ */
133
+ export function canonicalAuthz(
134
+ contract: TableContract,
135
+ governed?: ReadonlySet<string>,
136
+ ): Record<string, unknown> {
137
+ const grants = governedOnly(contract.grants, governed);
138
+ const columnGrants = governedOnly(contract.columnGrants, governed);
139
+ return {
140
+ table: contract.table,
141
+ rls: { enabled: contract.rls.enabled, forced: contract.rls.forced },
142
+ grants: Object.fromEntries(
143
+ Object.entries(grants)
144
+ .map(([role, privs]) => [role, [...(privs as string[])].sort()] as const)
145
+ .sort(([a], [b]) => (a < b ? -1 : 1)),
146
+ ),
147
+ ...(Object.keys(columnGrants).length > 0
148
+ ? {
149
+ columnGrants: Object.fromEntries(
150
+ Object.entries(columnGrants)
151
+ .map(([role, byPriv]) => [
152
+ role,
153
+ Object.fromEntries(
154
+ Object.entries(byPriv as Record<string, string[]>)
155
+ .map(([priv, cols]) => [priv, [...cols].sort()] as const)
156
+ .sort(([a], [b]) => (a < b ? -1 : 1)),
157
+ ),
158
+ ] as const)
159
+ .sort(([a], [b]) => (a < b ? -1 : 1)),
160
+ ),
161
+ }
162
+ : {}),
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
+ ),
177
+ };
178
+ }
@@ -20,7 +20,7 @@
20
20
  * Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
21
21
  */
22
22
 
23
- import type { ModelDescriptor, Ability } from '@everystack/model';
23
+ import { isColumnAbility, type ModelDescriptor, type Ability } from '@everystack/model';
24
24
  import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract.js';
25
25
  import { parenthesizeOnce } from './authz-contract.js';
26
26
 
@@ -198,32 +198,50 @@ function softDeleteColumn(model: ModelDescriptor): string | null {
198
198
  }
199
199
 
200
200
  /**
201
- * A column-scoped self read — `can('read', { owner, columns })`. It compiles to a COLUMN
202
- * grant (`GRANT SELECT (cols)`) plus a `<table>_select_self` policy, NOT a table grant and
203
- * NOT a `_select_own` policy: the row is scoped by the owner predicate, the fields by the
204
- * column list (`auth.users` id/email/role to the owner, never the password hash). It is
205
- * excluded from the table-grant and owner-read paths so the two never double-emit.
201
+ * `can(…, { role: 'public' })` names PostgreSQL's PUBLIC pseudo-role, and the two catalogs
202
+ * that describe it spell it differently both correctly:
203
+ *
204
+ * - `pg_policies.roles` renders it as the literal `{public}` (lowercase)
205
+ * - `aclexplode()` yields grantee OID 0, which GRANTS_SQL renders as `'PUBLIC'`
206
+ *
207
+ * So the compiler speaks each catalog's spelling on its own axis. Saying `public` on both
208
+ * made the grant set-difference see `public` and `PUBLIC` as two grantees and emit a REVOKE
209
+ * and a GRANT that cancel out — on every run, converging never.
210
+ *
211
+ * The fold is COMPILE-TIME ONLY, applied to the author's declared role. Nothing folds at
212
+ * comparison time: `CREATE ROLE "Public"` is a legal, distinct role, and a case-insensitive
213
+ * compare against a live grantee would silently conflate it with the pseudo-role. (The cost
214
+ * of that choice: a real role named `Public` cannot be named by `role:`, because a model
215
+ * string carries no way to say "quoted identifier".)
206
216
  */
207
- function isColumnRead(a: Ability): boolean {
208
- return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
209
- }
217
+ const isPseudoPublic = (role: string): boolean => role.toLowerCase() === 'public';
218
+
219
+ /** The grant-side spelling of a declared role — what `aclexplode` would report. */
220
+ const granteeKey = (role: string): string => (isPseudoPublic(role) ? 'PUBLIC' : role);
221
+
222
+ /** The policy-side spelling of a declared role — what `pg_policies` would report. */
223
+ const policyRole = (role: string): string => (isPseudoPublic(role) ? 'public' : role);
210
224
 
211
225
  /**
212
- * Column grants from the column-scoped read abilities: the read role (`authenticated`, since
213
- * the ability is owner-scoped, not role-gated) gets `SELECT (cols)`. Field keys are snake_cased
214
- * to SQL columns and sorted, matching the introspected contract. Returns undefined when no
215
- * ability declares columns — an introspected contract omits the key rather than carry an empty
216
- * object, so the compiled one must too (or the round-trip diff would false-fire).
226
+ * Column grants from column abilities. The action determines the PostgreSQL verb; the named role
227
+ * (or authenticated for an owner-only ability) is the grantee. Field keys are snake-cased and
228
+ * deduplicated, matching the introspected contract.
217
229
  */
218
230
  function compileColumnGrants(abilities: readonly Ability[]): Record<string, Record<string, string[]>> | undefined {
219
- const out: Record<string, Record<string, string[]>> = {};
231
+ const out: Record<string, Record<string, Set<string>>> = {};
220
232
  for (const a of abilities) {
221
- if (!isColumnRead(a)) continue;
222
- const role = a.condition.role ?? 'authenticated';
223
- const cols = [...a.condition.columns!].map(toSnakeCase).sort();
224
- (out[role] ??= {}).SELECT = cols;
233
+ if (!isColumnAbility(a)) continue;
234
+ const role = granteeKey(a.condition.role ?? 'authenticated');
235
+ const verb = a.action === 'read' ? 'SELECT' : 'UPDATE';
236
+ const cols = ((out[role] ??= {})[verb] ??= new Set<string>());
237
+ for (const column of a.condition.columns!) cols.add(toSnakeCase(column));
238
+ }
239
+ const grants: Record<string, Record<string, string[]>> = {};
240
+ for (const [role, verbs] of Object.entries(out)) {
241
+ grants[role] = {};
242
+ for (const [verb, columns] of Object.entries(verbs)) grants[role][verb] = [...columns].sort();
225
243
  }
226
- return Object.keys(out).length ? out : undefined;
244
+ return Object.keys(grants).length ? grants : undefined;
227
245
  }
228
246
 
229
247
  /**
@@ -310,13 +328,13 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
310
328
  * every other role can see.
311
329
  */
312
330
  const isRoleRead = (a: Ability): boolean =>
313
- a.action === 'read' && Boolean(a.condition.role) && !isColumnRead(a);
331
+ a.action === 'read' && Boolean(a.condition.role) && !isColumnAbility(a);
314
332
 
315
333
  // Author-supplied predicates, per action. `manage` carries no predicate of its own —
316
334
  // it is the admin bypass — so only the specific verbs are consulted.
317
335
  const predFor = (action: Ability['action']): string | null => {
318
336
  for (const a of model.abilities) {
319
- if (a.action !== action || isRoleRead(a)) continue;
337
+ if (a.action !== action || isRoleRead(a) || isColumnAbility(a)) continue;
320
338
  const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('${action}')`);
321
339
  if (p) return p;
322
340
  }
@@ -342,7 +360,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
342
360
  /** The predicate the OWNER read narrows ITSELF by — `can('read', { owner, sql })`. */
343
361
  const ownerReadPred = (): string | null => {
344
362
  for (const a of model.abilities) {
345
- if (a.action !== 'read' || !rowScoped(a) || isColumnRead(a)) continue;
363
+ if (a.action !== 'read' || !rowScoped(a) || isColumnAbility(a)) continue;
346
364
  const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read', { owner })`);
347
365
  if (p) return p;
348
366
  }
@@ -362,12 +380,18 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
362
380
  const abilities = model.abilities;
363
381
  const hasAdminManage = abilities.some((a) => a.action === 'manage' && a.condition.role === 'admin');
364
382
  const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !rowScoped(a));
383
+ // The audience is open when the public read says so. Read off the SAME abilities that make
384
+ // `hasPublicRead` true, so the two can never disagree about which read is being compiled.
385
+ const publicAudience = abilities.some(
386
+ (a) => a.action === 'read' && !a.condition.role && !rowScoped(a) && a.condition.policyRoles === 'public',
387
+ );
365
388
  // A column-scoped read is row-scoped but emits a self policy + column grant, not the
366
389
  // full-row owner read — so it is excluded here and handled by its own branch.
367
- const hasOwnerRead = abilities.some((a) => a.action === 'read' && rowScoped(a) && !isColumnRead(a));
368
- const hasColumnRead = abilities.some(isColumnRead);
390
+ const hasOwnerRead = abilities.some((a) => a.action === 'read' && rowScoped(a) && !isColumnAbility(a));
391
+ const hasColumnOwnerRead = abilities.some((a) => a.action === 'read' && isColumnAbility(a) && !a.condition.role && rowScoped(a));
369
392
  const canCreate = abilities.some((a) => a.action === 'create');
370
- const hasUpdateOwner = abilities.some((a) => a.action === 'update' && rowScoped(a));
393
+ const hasUpdateOwner = abilities.some((a) => a.action === 'update' && rowScoped(a) && !isColumnAbility(a));
394
+ const hasColumnUpdateOwner = abilities.some((a) => a.action === 'update' && isColumnAbility(a) && !a.condition.role && rowScoped(a));
371
395
  const hasDeleteOwner = abilities.some((a) => a.action === 'delete' && rowScoped(a));
372
396
 
373
397
  const t = model.table;
@@ -387,7 +411,6 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
387
411
  // also see their own (soft-deleted) rows, hence the OR'd owner check.
388
412
  const readPred = publicReadPred();
389
413
  const anonUsing = andPredicates(sdGuard, readPred) ?? 'true';
390
- policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
391
414
 
392
415
  let authedUsing: string;
393
416
  if (hasOwnerRead && rowPred && anonUsing !== 'true') {
@@ -416,12 +439,34 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
416
439
  ? andPredicates(`(${sdGuard} OR ${rowPred})`, readPred)!
417
440
  : anonUsing;
418
441
  }
419
- policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
442
+ // ONE policy TO PUBLIC, when the model says the audience is open (B1).
443
+ //
444
+ // The two spellings are NOT equivalent and the difference is the whole point: PUBLIC is
445
+ // an open set, `{anon, authenticated}` is closed. A brownfield schema that wrote the
446
+ // ordinary Postgres idiom — one policy TO PUBLIC — had no way to say so, so every plan
447
+ // proposed dropping it for the role-scoped pair. That is a real narrowing: any role
448
+ // outside the pair loses the read.
449
+ //
450
+ // Emitted under the model's own name; identity-by-meaning adopts the live policy whose
451
+ // rule matches, whatever it is called, so a schema already carrying `<t>_read_public`
452
+ // reaches ZERO statements rather than a rename's DROP + CREATE.
453
+ //
454
+ // GRANTS ARE UNTOUCHED — they stay exactly as derived for anon/authenticated. An open
455
+ // audience over a closed grant set widens nothing: Postgres checks the GRANT first, so a
456
+ // role holding no SELECT still reads no rows no matter who the policy names.
457
+ if (publicAudience) {
458
+ policy(`${t}_select_public`, 'SELECT', ['public'], anonUsing, null);
459
+ } else {
460
+ policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
461
+ policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
462
+ }
420
463
  } else if (hasOwnerRead && rowPred) {
421
464
  // owner-scoped read — no anon visibility; you see only the rows you own
422
465
  // (directly, or transitively through a `via:` parent).
423
466
  policy(`${t}_select_own`, 'SELECT', ['authenticated'], andPredicates(rowPred, predFor('read'))!, null);
424
- } else if (hasColumnRead && rowPred) {
467
+ }
468
+
469
+ if (hasColumnOwnerRead && rowPred) {
425
470
  // column-scoped self read — `authenticated` reads only its OWN row, and only the
426
471
  // granted columns (the GRANT scopes fields; this policy scopes rows). Named
427
472
  // `_select_self`, distinct from the full-row `_select_own`.
@@ -435,8 +480,11 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
435
480
  // the model read as though it could see its own. The declared predicate was computed and
436
481
  // discarded. That is the same failure `rawPredicate` throws over, one level up: a predicate
437
482
  // that evaporates is an authorization hole wearing the costume of a working rule.
438
- for (const a of abilities.filter(isRoleRead)) {
439
- const role = a.condition.role!;
483
+ for (const a of abilities.filter((ability) => isRoleRead(ability) || (
484
+ isColumnAbility(ability) && ability.action === 'read' && Boolean(ability.condition.role)
485
+ ))) {
486
+ // The policy-side spelling, so `role: 'PUBLIC'` still matches what pg_policies reports.
487
+ const role = policyRole(a.condition.role!);
440
488
  const name = `${t}_select_${role}`;
441
489
  const where = `${table}: can('read', { role: '${role}' })`;
442
490
  if (policies.some((p) => p.name === name)) {
@@ -445,7 +493,16 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
445
493
  + `Declare one or the other: permissive policies OR together, so the role-scoped rule could only ever widen — never the narrowing it reads as.`,
446
494
  );
447
495
  }
448
- policy(name, 'SELECT', [role], rawPredicate(a.condition.sql ?? a.condition.where, where) ?? 'true', null);
496
+ policy(
497
+ name,
498
+ 'SELECT',
499
+ [role],
500
+ andPredicates(
501
+ rowScoped(a) ? rowPred : null,
502
+ rawPredicate(a.condition.sql ?? a.condition.where, where),
503
+ ) ?? 'true',
504
+ null,
505
+ );
449
506
  }
450
507
 
451
508
  // owner-gated writes. INSERT checks ownership (you can only create rows you own);
@@ -465,6 +522,27 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
465
522
  if ((hasUpdateOwner || predFor('update')) && updatePred) {
466
523
  policy(`${t}_update_own`, 'UPDATE', ['authenticated'], updatePred, checkFor('update') ?? updatePred);
467
524
  }
525
+ else if (hasColumnUpdateOwner && updatePred) {
526
+ policy(`${t}_update_own`, 'UPDATE', ['authenticated'], updatePred, updatePred);
527
+ }
528
+ for (const a of abilities.filter((ability) =>
529
+ isColumnAbility(ability) && ability.action === 'update' && Boolean(ability.condition.role),
530
+ )) {
531
+ const role = policyRole(a.condition.role!);
532
+ const name = `${t}_update_${role}`;
533
+ const where = `${table}: can('update', { role: '${role}' })`;
534
+ if (policies.some((p) => p.name === name)) {
535
+ throw new Error(
536
+ `${where} collides with the ${name} policy already compiled from this model. `
537
+ + `Declare one update ability for that role: permissive policies OR together, so separate rules could only widen.`,
538
+ );
539
+ }
540
+ const using = andPredicates(
541
+ rowScoped(a) ? rowPred : null,
542
+ rawPredicate(a.condition.sql ?? a.condition.where, where),
543
+ ) ?? 'true';
544
+ policy(name, 'UPDATE', [role], using, rawPredicate(a.condition.check, `${where}, { check }`) ?? using);
545
+ }
468
546
  if ((hasDeleteOwner || predFor('delete')) && deletePred) {
469
547
  policy(`${t}_delete_own`, 'DELETE', ['authenticated'], deletePred, null);
470
548
  }
@@ -478,7 +556,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
478
556
  // write on the owner connection and bypass RLS -> ENABLE-not-FORCE (on RDS the
479
557
  // owner is not a superuser, so a FORCEd table would block the owner's own writes).
480
558
  rls: { enabled: true, forced: model.writtenBy === 'app' },
481
- grants: compileGrants(abilities),
559
+ grants: compileGrants(abilities, model.privileges),
482
560
  ...(compileColumnGrants(abilities) ? { columnGrants: compileColumnGrants(abilities) } : {}),
483
561
  policies,
484
562
  };
@@ -517,27 +595,39 @@ function verbsFor(action: Ability['action']): string[] {
517
595
  * table grants nothing, an owner-only table grants no anon SELECT, an admin-managed
518
596
  * table grants admin CRUD only. Deterministic: roles and privilege lists sorted,
519
597
  * so the output is byte-comparable with an introspected contract.
598
+ *
599
+ * `privileges` is the model's beyond-CRUD key — REFERENCES/TRIGGER/TRUNCATE, the table
600
+ * privileges `can()` has no verb for. They are UNIONED in, never subtracted: an admin that
601
+ * `can('manage')` and holds TRUNCATE keeps both. Without it, `manage` meant exactly CRUD and
602
+ * every live REFERENCES/TRIGGER/TRUNCATE read as drift, so the first plan against an existing
603
+ * schema revoked all three on every table — our spelling, not their schema.
520
604
  */
521
- function compileGrants(abilities: readonly Ability[]): Record<string, string[]> {
605
+ function compileGrants(
606
+ abilities: readonly Ability[],
607
+ privileges: Record<string, readonly string[]> = {},
608
+ ): Record<string, string[]> {
522
609
  const grants: Record<string, Set<string>> = {};
523
- const add = (role: string, verbs: string[]): void => {
610
+ const add = (role: string, verbs: readonly string[]): void => {
524
611
  const set = (grants[role] ??= new Set<string>());
525
612
  for (const v of verbs) set.add(v);
526
613
  };
527
614
  for (const a of abilities) {
528
- // A column-scoped read grants `SELECT (cols)` (a column grant), not a table-level
529
- // privilege — emitted by compileColumnGrants, skipped here so it never leaks a
530
- // whole-table grant that would defeat the column scoping.
531
- if (isColumnRead(a)) continue;
615
+ // A column ability grants only its listed columns, never a table-level privilege.
616
+ if (isColumnAbility(a)) continue;
532
617
  const verbs = verbsFor(a.action);
533
618
  if (a.condition.role) {
534
- add(a.condition.role, verbs);
619
+ add(granteeKey(a.condition.role), verbs);
535
620
  } else {
536
621
  add('authenticated', verbs);
537
622
  // A public read (no role, not owner/via-scoped) is anon-visible.
538
623
  if (a.action === 'read' && !a.condition.owner && !a.condition.via) add('anon', ['SELECT']);
539
624
  }
540
625
  }
626
+ // The role is spelled on the GRANT axis, so `privileges: { public: [...] }` reaches the
627
+ // same grantee `aclexplode` reports as `PUBLIC` — the fold the ability path already does.
628
+ for (const [role, privs] of Object.entries(privileges)) {
629
+ if (privs.length) add(granteeKey(role), privs);
630
+ }
541
631
  const out: Record<string, string[]> = {};
542
632
  for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
543
633
  return out;