@everystack/cli 0.4.43 → 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.
@@ -66,7 +66,11 @@ function claimExpr(claim: string): string {
66
66
  * uuid: (author_id = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))::uuid)
67
67
  * text: (user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))
68
68
  */
69
- function ownerPredicate(sqlColumn: string, type: string, claim: string): string {
69
+ function ownerPredicate(sqlColumn: string, type: string, claim: string, expr: string | null = null): string {
70
+ // A declared `ownerExpr` is the accessor the database already has (`auth.user_id()`),
71
+ // emitted verbatim and uncast: the function returns the owner column's type, and a cast
72
+ // the deparser would not print is a DROP + CREATE on every owner policy in the schema.
73
+ if (expr) return `(${sqlColumn} = ${expr})`;
70
74
  const ce = claimExpr(claim);
71
75
  if (type === 'text') return `(${sqlColumn} = ${ce})`;
72
76
  return `(${sqlColumn} = (${ce})::${castForType(type)})`;
@@ -89,7 +93,7 @@ function softDeleteGuard(sqlColumn: string): string {
89
93
  * WHERE (uploads.user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))))
90
94
  */
91
95
  function viaPredicate(v: ViaRef): string {
92
- const parentOwner = ownerPredicate(`${v.parentTable}.${v.parentOwner.sqlColumn}`, v.parentOwner.type, v.parentOwner.claim);
96
+ const parentOwner = ownerPredicate(`${v.parentTable}.${v.parentOwner.sqlColumn}`, v.parentOwner.type, v.parentOwner.claim, v.parentOwner.expr);
93
97
  return `(${v.fkColumn} IN ( SELECT ${v.parentTable}.${v.parentPk}\n FROM ${v.parentTable}\n WHERE ${parentOwner}))`;
94
98
  }
95
99
 
@@ -103,6 +107,8 @@ interface OwnerRef {
103
107
  type: string;
104
108
  /** JWT claim holding the owner id. */
105
109
  claim: string;
110
+ /** A declared accessor for the caller's id (`auth.user_id()`), or null for the inline claim. */
111
+ expr: string | null;
106
112
  }
107
113
 
108
114
  /**
@@ -135,6 +141,7 @@ function resolveOwner(model: ModelDescriptor): OwnerRef | null {
135
141
  sqlColumn: toSnakeCase(fieldKey),
136
142
  type: model.fields[fieldKey]?.spec.type ?? 'uuid',
137
143
  claim: ownerAbility.condition.userField ?? 'sub',
144
+ expr: rawExpr(ownerAbility.condition.ownerExpr, `${model.table}: can({ ownerExpr })`),
138
145
  };
139
146
  }
140
147
 
@@ -201,6 +208,31 @@ function isColumnRead(a: Ability): boolean {
201
208
  return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
202
209
  }
203
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
+
204
236
  /**
205
237
  * Column grants from the column-scoped read abilities: the read role (`authenticated`, since
206
238
  * the ability is owner-scoped, not role-gated) gets `SELECT (cols)`. Field keys are snake_cased
@@ -212,7 +244,7 @@ function compileColumnGrants(abilities: readonly Ability[]): Record<string, Reco
212
244
  const out: Record<string, Record<string, string[]>> = {};
213
245
  for (const a of abilities) {
214
246
  if (!isColumnRead(a)) continue;
215
- const role = a.condition.role ?? 'authenticated';
247
+ const role = granteeKey(a.condition.role ?? 'authenticated');
216
248
  const cols = [...a.condition.columns!].map(toSnakeCase).sort();
217
249
  (out[role] ??= {}).SELECT = cols;
218
250
  }
@@ -244,6 +276,23 @@ function rawPredicate(value: unknown, where: string): string | null {
244
276
  return parenthesizeOnce(text.trim());
245
277
  }
246
278
 
279
+ /**
280
+ * Unwrap a sql fragment to its text, UNparenthesized — for an expression that is not a
281
+ * predicate. `ownerExpr` is the right-hand side of `<col> = …`; parenthesizing it would
282
+ * emit `(user_id = (auth.user_id()))`, which is not what the deparser prints, and the
283
+ * whole point of declaring it is to match the live text exactly.
284
+ */
285
+ function rawExpr(value: unknown, where: string): string | null {
286
+ if (value == null) return null;
287
+ const text = (value as { sql?: unknown }).sql;
288
+ if (typeof text !== 'string' || !text.trim()) {
289
+ throw new Error(
290
+ `${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — the expression that decides who owns a row is authored, never stringly assembled.`,
291
+ );
292
+ }
293
+ return text.trim();
294
+ }
295
+
247
296
  /** AND-compose predicates, dropping the vacuous ones. A condition only ever NARROWS. */
248
297
  function andPredicates(...parts: (string | null)[]): string | null {
249
298
  const real = parts.filter((p): p is string => Boolean(p) && p !== '(true)' && p !== 'true');
@@ -270,7 +319,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
270
319
  // parent subquery. Both drive the same owner-gated policies (select/insert/
271
320
  // update/delete_own); only the predicate text differs.
272
321
  const rowPred = owner
273
- ? ownerPredicate(owner.sqlColumn, owner.type, owner.claim)
322
+ ? ownerPredicate(owner.sqlColumn, owner.type, owner.claim, owner.expr)
274
323
  : via
275
324
  ? viaPredicate(via)
276
325
  : null;
@@ -298,6 +347,33 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
298
347
  }
299
348
  return null;
300
349
  };
350
+ /**
351
+ * The predicate on the PUBLIC read — a read ability with neither a role nor a row scope.
352
+ *
353
+ * Read separately from {@link ownerReadPred} because the two are different BRANCHES of
354
+ * the same authenticated policy. Taking both from one ordered lookup made the anon
355
+ * policy depend on the order the abilities happened to be declared in: put the owner
356
+ * read first and `anon` inherited the owner branch's guard as its whole public rule.
357
+ */
358
+ const publicReadPred = (): string | null => {
359
+ for (const a of model.abilities) {
360
+ if (a.action !== 'read' || a.condition.role || rowScoped(a)) continue;
361
+ const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read')`);
362
+ if (p) return p;
363
+ }
364
+ return null;
365
+ };
366
+
367
+ /** The predicate the OWNER read narrows ITSELF by — `can('read', { owner, sql })`. */
368
+ const ownerReadPred = (): string | null => {
369
+ for (const a of model.abilities) {
370
+ if (a.action !== 'read' || !rowScoped(a) || isColumnRead(a)) continue;
371
+ const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read', { owner })`);
372
+ if (p) return p;
373
+ }
374
+ return null;
375
+ };
376
+
301
377
  /** The WRITE half, when the author declared one distinct from the read half. */
302
378
  const checkFor = (action: Ability['action']): string | null => {
303
379
  for (const a of model.abilities) {
@@ -334,18 +410,37 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
334
410
  if (hasPublicRead) {
335
411
  // public read on a soft-delete table — split per role; authenticated owners
336
412
  // also see their own (soft-deleted) rows, hence the OR'd owner check.
337
- const readPred = predFor('read');
413
+ const readPred = publicReadPred();
338
414
  const anonUsing = andPredicates(sdGuard, readPred) ?? 'true';
339
415
  policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
340
416
 
341
- // The owner predicate may only ever WIDEN a public read (the soft-delete OR:
342
- // owners also see their own deleted rows). It must never REPLACE it — an owner
343
- // condition on a WRITE ability used to narrow the authenticated SELECT to
344
- // own-rows-only, so anon saw the whole table and a signed-in user lost it.
345
- // Without a soft-delete guard, `true OR owner` is just `true`.
346
- const authedUsing = sdGuard && rowPred
347
- ? andPredicates(`(${sdGuard} OR ${rowPred})`, readPred)!
348
- : anonUsing;
417
+ let authedUsing: string;
418
+ if (hasOwnerRead && rowPred && anonUsing !== 'true') {
419
+ // PUBLIC **OR** MINE. Two read abilities on one model a public read and an
420
+ // owner read are two branches of one authenticated policy, not a contest the
421
+ // public one wins. They used to be exactly that contest: `hasPublicRead` took
422
+ // the branch and the owner read was computed and discarded, so a signed-in
423
+ // author could not see their own non-public rows. On a real brownfield schema
424
+ // that hid ~13k legacy `pending` rows from the people who wrote them.
425
+ //
426
+ // The owner branch escapes the public predicate (that is the whole point) and
427
+ // carries its OWN guard — `can('read', { owner: 'userId', sql: … })` — so the
428
+ // author says what "mine" means without loosening what "public" means.
429
+ const ownBranch = andPredicates(rowPred, ownerReadPred())!;
430
+ authedUsing = `(${anonUsing} OR ${ownBranch})`;
431
+ } else {
432
+ // The owner predicate may only ever WIDEN a public read (the soft-delete OR:
433
+ // owners also see their own deleted rows). It must never REPLACE it — an owner
434
+ // condition on a WRITE ability used to narrow the authenticated SELECT to
435
+ // own-rows-only, so anon saw the whole table and a signed-in user lost it.
436
+ // Without a soft-delete guard, `true OR owner` is just `true` — and an
437
+ // unfiltered public read already contains every row an owner could add, which
438
+ // is why `anonUsing === 'true'` skips the disjunction rather than emitting a
439
+ // branch that can never change the answer.
440
+ authedUsing = sdGuard && rowPred
441
+ ? andPredicates(`(${sdGuard} OR ${rowPred})`, readPred)!
442
+ : anonUsing;
443
+ }
349
444
  policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
350
445
  } else if (hasOwnerRead && rowPred) {
351
446
  // owner-scoped read — no anon visibility; you see only the rows you own
@@ -366,7 +461,8 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
366
461
  // discarded. That is the same failure `rawPredicate` throws over, one level up: a predicate
367
462
  // that evaporates is an authorization hole wearing the costume of a working rule.
368
463
  for (const a of abilities.filter(isRoleRead)) {
369
- 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!);
370
466
  const name = `${t}_select_${role}`;
371
467
  const where = `${table}: can('read', { role: '${role}' })`;
372
468
  if (policies.some((p) => p.name === name)) {
@@ -408,7 +504,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
408
504
  // write on the owner connection and bypass RLS -> ENABLE-not-FORCE (on RDS the
409
505
  // owner is not a superuser, so a FORCEd table would block the owner's own writes).
410
506
  rls: { enabled: true, forced: model.writtenBy === 'app' },
411
- grants: compileGrants(abilities),
507
+ grants: compileGrants(abilities, model.privileges),
412
508
  ...(compileColumnGrants(abilities) ? { columnGrants: compileColumnGrants(abilities) } : {}),
413
509
  policies,
414
510
  };
@@ -447,10 +543,19 @@ function verbsFor(action: Ability['action']): string[] {
447
543
  * table grants nothing, an owner-only table grants no anon SELECT, an admin-managed
448
544
  * table grants admin CRUD only. Deterministic: roles and privilege lists sorted,
449
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.
450
552
  */
451
- 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[]> {
452
557
  const grants: Record<string, Set<string>> = {};
453
- const add = (role: string, verbs: string[]): void => {
558
+ const add = (role: string, verbs: readonly string[]): void => {
454
559
  const set = (grants[role] ??= new Set<string>());
455
560
  for (const v of verbs) set.add(v);
456
561
  };
@@ -461,13 +566,18 @@ function compileGrants(abilities: readonly Ability[]): Record<string, string[]>
461
566
  if (isColumnRead(a)) continue;
462
567
  const verbs = verbsFor(a.action);
463
568
  if (a.condition.role) {
464
- add(a.condition.role, verbs);
569
+ add(granteeKey(a.condition.role), verbs);
465
570
  } else {
466
571
  add('authenticated', verbs);
467
572
  // A public read (no role, not owner/via-scoped) is anon-visible.
468
573
  if (a.action === 'read' && !a.condition.owner && !a.condition.via) add('anon', ['SELECT']);
469
574
  }
470
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
+ }
471
581
  const out: Record<string, string[]> = {};
472
582
  for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
473
583
  return out;
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { parsePgArray } from './security-catalog.js';
25
25
  import { withCanonicalSearchPath } from './search-path.js';
26
+ import { matchPolicies, roleSetEqual } from './authz-identity.js';
26
27
 
27
28
  // ---------------------------------------------------------------------------
28
29
  // The contract format — the frozen, reviewable, version-controlled shape.
@@ -100,6 +101,42 @@ export function effectivePolicyCheck(p: PolicyContract): string | null {
100
101
  return p.check;
101
102
  }
102
103
 
104
+ /**
105
+ * Does a role hold a privilege at the TABLE level? — the question "may we emit an ability?"
106
+ *
107
+ * `can('update')` compiles to a policy PLUS a table-wide `GRANT UPDATE`. So a role that holds
108
+ * UPDATE only on three columns does NOT satisfy this: rendering the ability would widen a
109
+ * column grant into a whole-table one. PUBLIC counts, in both catalog spellings, because a
110
+ * grant to PUBLIC really is held by every role.
111
+ */
112
+ export function holdsTablePrivilege(t: TableContract, role: string, privilege: string): boolean {
113
+ const direct = t.grants[role] ?? [];
114
+ const pub = t.grants.PUBLIC ?? t.grants.public ?? [];
115
+ return direct.includes(privilege) || pub.includes(privilege);
116
+ }
117
+
118
+ /**
119
+ * Does a role hold a privilege AT ALL? — the question "does this policy authorize anything?"
120
+ *
121
+ * The same question one layer wider: a COLUMN-scoped grant counts. `GRANT UPDATE (body) ON t
122
+ * TO authenticated` really does let that role update, so a policy governing UPDATE is doing
123
+ * live work even though the role holds no table-level UPDATE.
124
+ *
125
+ * TWO PREDICATES ON PURPOSE, and they answer different questions — that is why they are here
126
+ * together rather than one being written twice. `db:pull` used the narrow one to answer the
127
+ * wide question and told adopters a live, column-granted UPDATE policy was "dead code today,
128
+ * declaring it would ADD a privilege the database does not give". Both halves were false, and
129
+ * it was printed beside the model they were about to trust. Use {@link holdsTablePrivilege} to
130
+ * decide what to EMIT; use this to decide what is DEAD.
131
+ */
132
+ export function holdsPrivilege(t: TableContract, role: string, privilege: string): boolean {
133
+ if (holdsTablePrivilege(t, role, privilege)) return true;
134
+ const cols = t.columnGrants ?? {};
135
+ return Boolean(cols[role]?.[privilege]?.length)
136
+ || Boolean(cols.PUBLIC?.[privilege]?.length)
137
+ || Boolean(cols.public?.[privilege]?.length);
138
+ }
139
+
103
140
  /** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
104
141
  export function isWrappedExpression(s: string): boolean {
105
142
  if (!s.startsWith('(') || !s.endsWith(')')) return false;
@@ -509,6 +546,24 @@ export async function introspectContract(
509
546
 
510
547
  export type DriftSeverity = 'drift';
511
548
 
549
+ /** A live policy kept under its own name because it already carries the declared rule. */
550
+ export interface PolicyAdoption {
551
+ /** Schema-qualified table. */
552
+ subject: string;
553
+ /** The policy name(s) the model would have created. */
554
+ declared: string[];
555
+ /** The name the database uses, and keeps. */
556
+ live: string;
557
+ /** Why it was accepted, in plain words. */
558
+ reason: string;
559
+ }
560
+
561
+ /** Drift, plus the adoptions that are deliberately NOT drift. */
562
+ export interface ContractComparison {
563
+ findings: DriftFinding[];
564
+ adoptions: PolicyAdoption[];
565
+ }
566
+
512
567
  export interface DriftFinding {
513
568
  /** Schema-qualified table, or `fn:<name>` for a function finding. */
514
569
  subject: string;
@@ -528,7 +583,20 @@ function tableMap(c: AuthzContract): Map<string, TableContract> {
528
583
  * Returns every discrepancy; an empty list means the live DB matches the declaration.
529
584
  */
530
585
  export function diffContracts(declared: AuthzContract, live: AuthzContract): DriftFinding[] {
586
+ return compareContracts(declared, live).findings;
587
+ }
588
+
589
+ /**
590
+ * The full comparison: drift AND the name divergences accepted by rule.
591
+ *
592
+ * `diffContracts` returns only the findings, because every caller treats a finding as drift
593
+ * and exits non-zero. An adoption is the opposite of drift, so it cannot travel in that list —
594
+ * but it must still be SHOWN, or the mapping between the model's name and the database's name
595
+ * becomes tribal knowledge.
596
+ */
597
+ export function compareContracts(declared: AuthzContract, live: AuthzContract): ContractComparison {
531
598
  const findings: DriftFinding[] = [];
599
+ const adoptions: PolicyAdoption[] = [];
532
600
  const dTables = tableMap(declared);
533
601
  const lTables = tableMap(live);
534
602
 
@@ -546,7 +614,7 @@ export function diffContracts(declared: AuthzContract, live: AuthzContract): Dri
546
614
  }
547
615
  diffGrants(name, d, l, findings);
548
616
  diffColumnGrants(name, d, l, findings);
549
- diffPolicies(name, d, l, findings);
617
+ diffPolicies(name, d, l, findings, adoptions);
550
618
  }
551
619
  for (const name of lTables.keys()) {
552
620
  if (!dTables.has(name)) {
@@ -575,7 +643,7 @@ export function diffContracts(declared: AuthzContract, live: AuthzContract): Dri
575
643
  }
576
644
  }
577
645
 
578
- return findings;
646
+ return { findings, adoptions };
579
647
  }
580
648
 
581
649
  function diffGrants(table: string, d: TableContract, l: TableContract, out: DriftFinding[]): void {
@@ -612,31 +680,58 @@ function diffColumnGrants(table: string, d: TableContract, l: TableContract, out
612
680
  }
613
681
  }
614
682
 
615
- function diffPolicies(table: string, d: TableContract, l: TableContract, out: DriftFinding[]): void {
616
- const dPol = new Map(d.policies.map((p) => [p.name, p]));
617
- const lPol = new Map(l.policies.map((p) => [p.name, p]));
618
- for (const [name, dp] of dPol) {
619
- const lp = lPol.get(name);
620
- if (!lp) {
621
- out.push({ subject: table, kind: 'policy', detail: `policy "${name}" declared but missing from the live database` });
622
- continue;
623
- }
683
+ /**
684
+ * Diff policies through the SHARED matcher — the same equivalence `emitReconcileSql` and the
685
+ * fingerprint use. A rule-identical live policy under a different name is not drift: the
686
+ * database already authorizes exactly what the model declares.
687
+ *
688
+ * Adoptions travel on their own channel, not as findings. A finding means drift and drift
689
+ * means exit 1, while an adoption is agreement. It is never SILENT though — once a live policy
690
+ * keeps its own name, the name the model shows and the name a human greps for have diverged,
691
+ * and that mapping has to stay machine-derived and visible.
692
+ */
693
+ function diffPolicies(
694
+ table: string,
695
+ d: TableContract,
696
+ l: TableContract,
697
+ out: DriftFinding[],
698
+ adoptions: PolicyAdoption[],
699
+ ): void {
700
+ const m = matchPolicies(d.policies, l.policies);
701
+
702
+ for (const a of m.adopted) {
703
+ adoptions.push({ subject: table, declared: [a.declared], live: a.live, reason: 'same rule, different name' });
704
+ }
705
+ for (const g of m.adoptedGroups) {
706
+ adoptions.push({ subject: table, declared: g.declared, live: g.live, reason: 'one live policy covers the declared per-role group' });
707
+ }
708
+
709
+ // A name on BOTH sides whose rule moved is a CHANGE, not a remove-and-add. Saying which
710
+ // field moved is the difference between a finding an operator can act on and one they have
711
+ // to go read the catalog to understand.
712
+ const createdByName = new Map(m.toCreate.map((p) => [p.name, p]));
713
+ const changed = new Set<string>();
714
+ for (const name of m.toDrop) {
715
+ const dp = createdByName.get(name);
716
+ if (!dp) continue;
717
+ const lp = l.policies.find((p) => p.name === name)!;
718
+ changed.add(name);
624
719
  const changes: string[] = [];
625
- if (dp.command !== lp.command) changes.push(`command ${dp.command}→${lp.command}`);
626
- if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}→${lp.permissive}`);
627
- if (dp.roles.join(',') !== lp.roles.join(',')) changes.push(`roles [${dp.roles}][${lp.roles}]`);
628
- if ((dp.using ?? '') !== (lp.using ?? '')) changes.push(`USING changed`);
629
- // Compared through the server's own defaulting rule, so this agrees with
630
- // emitReconcileSql otherwise db:check reports drift the plan does not carry.
631
- if ((effectivePolicyCheck(dp) ?? '') !== (effectivePolicyCheck(lp) ?? '')) changes.push(`WITH CHECK changed`);
632
- if (changes.length) {
633
- out.push({ subject: table, kind: 'policy', detail: `policy "${name}": ${changes.join(', ')}` });
634
- }
720
+ if (dp.command !== lp.command) changes.push(`command ${dp.command}\u2192${lp.command}`);
721
+ if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}\u2192${lp.permissive}`);
722
+ if (!roleSetEqual(dp.roles, lp.roles)) changes.push(`roles [${dp.roles}]\u2192[${lp.roles}]`);
723
+ if ((dp.using ?? '') !== (lp.using ?? '')) changes.push('USING changed');
724
+ if ((effectivePolicyCheck(dp) ?? '') !== (effectivePolicyCheck(lp) ?? '')) changes.push('WITH CHECK changed');
725
+ out.push({ subject: table, kind: 'policy', detail: `policy "${name}": ${changes.join(', ')}` });
635
726
  }
636
- for (const name of lPol.keys()) {
637
- if (!dPol.has(name)) {
638
- out.push({ subject: table, kind: 'policy', detail: `policy "${name}" exists live but is not declared (undeclared policy)` });
639
- }
727
+
728
+ for (const p of m.toCreate) {
729
+ if (changed.has(p.name)) continue;
730
+ out.push({ subject: table, kind: 'policy', detail: `policy "${p.name}" declared but missing from the live database` });
731
+ }
732
+ for (const name of m.toDrop) {
733
+ if (changed.has(name)) continue;
734
+ out.push({ subject: table, kind: 'policy', detail: `policy "${name}" exists live but is not declared (undeclared policy)` });
640
735
  }
641
736
  }
642
737