@everystack/cli 0.4.45 → 0.4.47

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 +75 -26
  5. package/src/cli/authz-canonical.ts +37 -5
  6. package/src/cli/authz-compile.ts +87 -37
  7. package/src/cli/authz-contract.ts +92 -19
  8. package/src/cli/authz-derive.ts +158 -33
  9. package/src/cli/authz-reconcile.ts +48 -6
  10. package/src/cli/aws.ts +32 -0
  11. package/src/cli/commands/db-apply.ts +82 -20
  12. package/src/cli/commands/db-authz.ts +9 -14
  13. package/src/cli/commands/db-backfill.ts +1 -1
  14. package/src/cli/commands/db-exec.ts +20 -1
  15. package/src/cli/commands/db-fingerprint.ts +54 -18
  16. package/src/cli/commands/db-generate.ts +11 -17
  17. package/src/cli/commands/db-plan.ts +89 -9
  18. package/src/cli/commands/db-pull.ts +16 -18
  19. package/src/cli/commands/db-reconcile.ts +19 -21
  20. package/src/cli/commands/db-refresh.ts +33 -5
  21. package/src/cli/commands/db-swap.ts +5 -4
  22. package/src/cli/commands/db-sync.ts +8 -5
  23. package/src/cli/commands/db.ts +2 -1
  24. package/src/cli/db-build.ts +2 -2
  25. package/src/cli/db-source.ts +56 -0
  26. package/src/cli/derived-introspect.ts +27 -26
  27. package/src/cli/derived-lint.ts +7 -8
  28. package/src/cli/edge-plan.ts +112 -16
  29. package/src/cli/exec-digest.ts +55 -13
  30. package/src/cli/git-descent.ts +16 -9
  31. package/src/cli/index.ts +3 -3
  32. package/src/cli/model-render.ts +56 -50
  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 +67 -7
  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 +145 -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
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import { parsePgArray } from './security-catalog.js';
25
- import { withCanonicalSearchPath } from './search-path.js';
25
+ import { INTROSPECTION_SESSION, type SessionRunner } from './session.js';
26
26
  import { matchPolicies, roleSetEqual } from './authz-identity.js';
27
27
 
28
28
  // ---------------------------------------------------------------------------
@@ -137,6 +137,83 @@ export function holdsPrivilege(t: TableContract, role: string, privilege: string
137
137
  || Boolean(cols.public?.[privilege]?.length);
138
138
  }
139
139
 
140
+ /** The four row-level commands a policy can govern. `FOR ALL` fans out across them. */
141
+ export const POLICY_COMMANDS = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
142
+
143
+ /** Every command a policy governs — `ALL` fans out, so a live `FOR ALL` is compared per command. */
144
+ export function policyCommands(p: PolicyContract): string[] {
145
+ return p.command === 'ALL' ? [...POLICY_COMMANDS] : [p.command];
146
+ }
147
+
148
+ /**
149
+ * Is this policy DEAD — does it authorize nothing at all?
150
+ *
151
+ * PostgreSQL checks the GRANT before the policy, so a policy governing a privilege none of
152
+ * its roles holds is inert: it cannot admit a single row today, and removing it takes away
153
+ * access that was never there. A PUBLIC policy applies to every grantee, so it is dead only
154
+ * when NO grantee holds the privilege.
155
+ *
156
+ * ONE definition, four callers — the classifier (what class is this statement?), the
157
+ * reconciler (should I drop it?), the canonical form (is it part of the state?), and db:pull
158
+ * (what do I tell the adopter?). They were drifting apart, which is this milestone's recurring
159
+ * bug: two surfaces answering the same question differently. Note the deadness is a property
160
+ * of the CONTRACT, not the policy — it is recomputed against live grants every time, so a
161
+ * policy RESURRECTS the moment a grant makes it effective.
162
+ */
163
+ export function isPolicyDead(t: TableContract, p: PolicyContract): boolean {
164
+ const roles = p.roles.includes('public') || p.roles.includes('PUBLIC')
165
+ ? Object.keys(t.grants)
166
+ : p.roles;
167
+ if (!roles.length) return true;
168
+ // `holdsPrivilege`, not the table-level one: a column-scoped grant is real access, so a
169
+ // policy governing it is doing live work. See the two-predicate note above.
170
+ return !policyCommands(p).some((cmd) => roles.some((r) => holdsPrivilege(t, r, cmd)));
171
+ }
172
+
173
+ /** Does this policy name PostgreSQL's PUBLIC pseudo-role — the open set of every role? */
174
+ export function isPublicPolicy(p: PolicyContract): boolean {
175
+ return p.roles.some((r) => r.toUpperCase() === 'PUBLIC');
176
+ }
177
+
178
+ /**
179
+ * A policy's RULE, with the roles and the NAME deliberately absent — what it authorizes, not
180
+ * whom it names or what it is called. The same normalization the canonical form hashes by, so
181
+ * "these two policies say the same thing" has one answer.
182
+ */
183
+ export function policyRuleKey(p: PolicyContract): string {
184
+ return JSON.stringify([p.command, p.permissive, p.using ?? '', effectivePolicyCheck(p) ?? '']);
185
+ }
186
+
187
+ /**
188
+ * Is this policy SUBSUMED — does an identical rule already reach every role it names?
189
+ *
190
+ * PERMISSIVE policies OR together, and a policy TO PUBLIC applies to every role there is. So a
191
+ * role-scoped policy whose rule is character-for-character a PUBLIC policy's rule contributes
192
+ * nothing: every session it would admit is already admitted. Dropping it changes no access,
193
+ * which is why it is churn rather than a change — a brownfield database often carries both,
194
+ * one from the framework and one from a migration written years earlier.
195
+ *
196
+ * Two guards, and they are the whole safety of this:
197
+ *
198
+ * - **RESTRICTIVE policies are never subsumed.** They AND rather than OR, so a restrictive
199
+ * policy is the thing NARROWING access; removing it would widen. Only permissive policies
200
+ * can be redundant, and only a permissive PUBLIC policy can make them so.
201
+ * - **The rule must match exactly**, including the effective WITH CHECK. A PUBLIC policy with
202
+ * a laxer predicate does not subsume a stricter role-scoped one — under OR the lax rule
203
+ * already wins, but the two are not the same state, and hashing them equal would report
204
+ * MATCH while the reconciler still had work.
205
+ */
206
+ export function isPolicySubsumed(t: TableContract, p: PolicyContract): boolean {
207
+ if (!p.permissive || isPublicPolicy(p)) return false;
208
+ const key = policyRuleKey(p);
209
+ // No self-exclusion by NAME: `p` is asked about against BOTH the live and the declared
210
+ // contract (subsumed before and after), and the compiler routinely picks the same policy
211
+ // name the brownfield database already uses — so a name guard here would silently exclude
212
+ // the very PUBLIC policy doing the covering. It is not needed: `p` is non-PUBLIC by the
213
+ // line above and every candidate is PUBLIC, so `p` can never match itself.
214
+ return t.policies.some((o) => o.permissive && isPublicPolicy(o) && policyRuleKey(o) === key);
215
+ }
216
+
140
217
  /** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
141
218
  export function isWrappedExpression(s: string): boolean {
142
219
  if (!s.startsWith('(') || !s.endsWith(')')) return false;
@@ -516,27 +593,23 @@ export function assembleContract(rows: ContractRows): AuthzContract {
516
593
 
517
594
  /** Run every introspection query through one runner and assemble the contract. */
518
595
  export async function introspectContract(
519
- run: QueryRunner,
596
+ session: SessionRunner,
520
597
  mapFunctionRow: (row: any) => { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean },
521
598
  functionsSql: string,
522
599
  ): Promise<AuthzContract> {
523
- // Policy USING/WITH CHECK expressions deparse relative to the search_path pin the
524
- // canonical baseline so an ambient non-default path never reads as false authz drift.
525
- return withCanonicalSearchPath(run, async () => {
526
- const [rls, policies, grants, columnGrants, fnRows] = await Promise.all([
527
- run(RLS_SQL),
528
- run(POLICIES_SQL),
529
- run(GRANTS_SQL),
530
- run(COLUMN_GRANTS_SQL),
531
- run(functionsSql),
532
- ]);
533
- return assembleContract({
534
- rls: rls as RlsRow[],
535
- policies: policies as PolicyRow[],
536
- grants: grants as GrantRow[],
537
- columnGrants: columnGrants as ColumnGrantRow[],
538
- functions: (fnRows as any[]).map(mapFunctionRow),
539
- });
600
+ // ONE session: the five queries describe one moment under one pinned search_path.
601
+ // Policy USING / WITH CHECK expressions deparse relative to that path, so a read spread
602
+ // across connections can report authz drift that does not exist.
603
+ const [rls, policies, grants, columnGrants, fnRows] = await session(
604
+ [RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, functionsSql],
605
+ INTROSPECTION_SESSION,
606
+ );
607
+ return assembleContract({
608
+ rls: rls as RlsRow[],
609
+ policies: policies as PolicyRow[],
610
+ grants: grants as GrantRow[],
611
+ columnGrants: columnGrants as ColumnGrantRow[],
612
+ functions: (fnRows as any[]).map(mapFunctionRow),
540
613
  });
541
614
  }
542
615
 
@@ -240,6 +240,58 @@ function sqlFragment(pred: string | null): string | null {
240
240
  return `sql\`${t.replace(/`/g, '\\`')}\``;
241
241
  }
242
242
 
243
+ /** The policy catalog spells the PUBLIC pseudo-role lower-case; grants spell it upper-case. */
244
+ function policyRoleForGrantee(grantee: string): string {
245
+ return grantee === 'PUBLIC' ? 'public' : grantee;
246
+ }
247
+
248
+ /** `db:pull` renders fields camel-cased; abilities must name those same model keys. */
249
+ function modelFieldKey(column: string): string {
250
+ return column.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase());
251
+ }
252
+
253
+ /** One column grant is renderable only when exactly one declared-style policy gates it. */
254
+ function columnPolicyFor(contract: TableContract, grantee: string, command: string): PolicyContract | null {
255
+ const role = policyRoleForGrantee(grantee);
256
+ const matches = contract.policies.filter((p) =>
257
+ p.command === command && p.permissive && p.roles.length === 1 && p.roles[0] === role,
258
+ );
259
+ return matches.length === 1 ? matches[0] : null;
260
+ }
261
+
262
+ /** Render one precise column grant plus its exactly-paired policy as one ability. */
263
+ function renderColumnAbility(
264
+ action: 'read' | 'update',
265
+ grantee: string,
266
+ columns: readonly string[],
267
+ policy: PolicyContract,
268
+ ): string | null {
269
+ const using = policy.using ?? null;
270
+ const owner = parseOwnerPredicate(using);
271
+ const parts: string[] = [];
272
+ const role = policyRoleForGrantee(grantee);
273
+
274
+ // Owner-only uses the model's authenticated default. Every other grantee must be named so
275
+ // the compiler emits the policy TO the live role, rather than a role-less public ability.
276
+ if (!owner || role !== 'authenticated') parts.push(`role: '${role}'`);
277
+ if (owner) {
278
+ parts.push(`owner: '${modelFieldKey(owner.column)}'`);
279
+ if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
280
+ if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
281
+ } else {
282
+ const fragment = sqlFragment(using);
283
+ if (fragment) parts.push(`sql: ${fragment}`);
284
+ }
285
+
286
+ if (action === 'update' && policy.check && policy.check !== using) {
287
+ const fragment = sqlFragment(policy.check);
288
+ if (!fragment) return null;
289
+ parts.push(`check: ${fragment}`);
290
+ }
291
+ parts.push(`columns: [${columns.map((column) => `'${modelFieldKey(column)}'`).join(', ')}]`);
292
+ return `can('${action}', { ${parts.join(', ')} })`;
293
+ }
294
+
243
295
  /**
244
296
  * Derive the ability stanza for ONE table from its live contract.
245
297
  *
@@ -274,10 +326,8 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
274
326
  if (!live.length || !p.roles.some((r) => r !== 'admin')) continue;
275
327
  const viaColumns = live.filter((r) => holdsPrivilege(contract, r, priv));
276
328
  if (viaColumns.length) {
277
- notes.push(
278
- `policy "${p.name}" (${cmd}) is live through the ${viaColumns.join(', ')} COLUMN grant below, not a table grant.`,
279
- );
280
- notes.push(` NOT dead. Not rendered either: an ability would grant ${priv} on the whole table.`);
329
+ // The column-grant pass below carries this pair into a column ability, or names the
330
+ // exact missing pairing. Do not pre-judge it here as dead or unrenderable.
281
331
  continue;
282
332
  }
283
333
  notes.push(
@@ -299,7 +349,6 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
299
349
  const anonPol = policiesFor(contract, 'anon', 'SELECT')[0];
300
350
  const anonPred = anonPol?.using ?? null;
301
351
  const frag = sqlFragment(anonPred);
302
- abilities.push(frag ? `can('read', { sql: ${frag} })` : `can('read')`);
303
352
 
304
353
  // The authenticated SELECT is a SEPARATE policy and routinely says MORE than the anon
305
354
  // one — `(public) OR (mine)`, so an author sees their own not-yet-public rows. Reading
@@ -310,25 +359,94 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
310
359
  ? policiesFor(contract, 'authenticated', 'SELECT').find((p) => p.name !== anonPol?.name)
311
360
  : undefined;
312
361
  const authedPred = authedPol?.using ?? null;
313
- if (authedPol && (authedPred ?? 'true') !== (anonPred ?? 'true')) {
314
- const own = parsePublicOrOwn(authedPred, anonPred);
315
- if (own) {
316
- const parts = [`owner: '${own.owner.column}'`];
317
- if (own.owner.claim !== 'sub') parts.push(`userField: '${own.owner.claim}'`);
318
- if (own.owner.expr) parts.push(`ownerExpr: sql\`${own.owner.expr}\``);
319
- const guard = sqlFragment(own.ownerSql);
320
- if (guard) parts.push(`sql: ${guard}`);
321
- abilities.push(`can('read', { ${parts.join(', ')} })`);
322
- } else {
323
- // P6: it must SAY SO. An unexpressible predicate becomes a comment naming the
324
- // policy and its text, never a model that quietly means less.
362
+ const diverges = !!authedPol && (authedPred ?? 'true') !== (anonPred ?? 'true');
363
+ const own = diverges ? parsePublicOrOwn(authedPred, anonPred) : null;
364
+
365
+ // A role-less `can('read')` fans out to anon AND authenticated — one ability, two policies
366
+ // and two GRANTS. That is only honest when both roles genuinely hold a table-level SELECT
367
+ // and the same rule. Two separate ways it was not:
368
+ //
369
+ // - WIDENING: `authedRead` is a TABLE-level check, so a live authenticated SELECT held
370
+ // only through COLUMN grants reads as false. The fan-out then granted authenticated
371
+ // SELECT on the WHOLE table — the model meaning MORE than the database, which is the
372
+ // one thing this project never does. Now the read is scoped to anon alone.
373
+ // - NARROWING: when the authenticated rule diverges and cannot be decomposed, the
374
+ // fan-out pushed the ANON predicate onto authenticated, silently replacing a live
375
+ // owner-admitting read with a public-only one. Now each role carries its own rule.
376
+ // The fan-out is honest in exactly two cases, and both need authenticated to hold a real
377
+ // table-level SELECT:
378
+ // - the two roles carry the SAME rule, so one ability states it once; or
379
+ // - the rule DECOMPOSES, and the `owner:` ability below ADDS the owner branch on top of
380
+ // this public one. Scoping this read to anon there would strip authenticated of the
381
+ // public half — a narrowing introduced by the fix itself.
382
+ const fanOutIsHonest = authedRead && (!diverges || !!own);
383
+
384
+ // The live read is ONE policy TO PUBLIC — the ordinary Postgres idiom, and an OPEN
385
+ // audience. Rendering it as the role-scoped pair would mean strictly less than the
386
+ // database does: every role outside {anon, authenticated} loses the read. `policyRoles`
387
+ // says the audience without touching the grants, so this transcribes rather than narrows
388
+ // (B1). Only when PUBLIC is the whole audience — a policy naming public ALONGSIDE a
389
+ // named role is a different shape and is left to the branches below.
390
+ const publicAudience = !!anonPol && anonPol.roles.length === 1 && anonPol.roles[0] === 'public';
391
+
392
+ if (publicAudience) {
393
+ abilities.push(frag ? `can('read', { policyRoles: 'public', sql: ${frag} })` : `can('read', { policyRoles: 'public' })`);
394
+
395
+ // An OPEN audience must say who it actually opens to, ON THIS TABLE.
396
+ //
397
+ // `policyRoles: 'public'` reads like "anyone", and the model vocabulary only names
398
+ // anon/authenticated/admin — so a reviewer has no way to know that a migrator or an ETL
399
+ // role also holds SELECT here and therefore reads rows through this very policy. The
400
+ // pull already names ungoverned grantees once, globally, on stdout; that is the wrong
401
+ // place and the wrong granularity for a decision recorded per table in a committed file.
402
+ // Adopting an open audience without seeing its real membership is exactly "inert must
403
+ // never mean blind".
404
+ const openTo = Object.keys(contract.grants)
405
+ .filter((r) => !GOVERNED_VOCABULARY.has(r) && holdsPrivilege(contract, r, 'SELECT'))
406
+ .sort();
407
+ if (openTo.length) {
325
408
  notes.push(
326
- `policy "${authedPol.name}" (SELECT) grants authenticated MORE than the anon read,`,
409
+ `policyRoles: 'public' is an OPEN audience it also admits ${openTo.join(', ')}, which hold`,
327
410
  );
328
- notes.push(` and its shape is not expressible as abilities. Live USING:`);
329
- notes.push(` ${authedPred}`);
330
- notes.push(` Declare it by hand, or leave the table adopted. NOT rendered above.`);
411
+ notes.push(` SELECT on this table. The grants are unchanged; this names who the policy reaches.`);
331
412
  }
413
+ } else if (fanOutIsHonest) {
414
+ abilities.push(frag ? `can('read', { sql: ${frag} })` : `can('read')`);
415
+ } else {
416
+ abilities.push(frag ? `can('read', { role: 'anon', sql: ${frag} })` : `can('read', { role: 'anon' })`);
417
+ }
418
+
419
+ if (diverges && own) {
420
+ const parts = [`owner: '${own.owner.column}'`];
421
+ if (own.owner.claim !== 'sub') parts.push(`userField: '${own.owner.claim}'`);
422
+ if (own.owner.expr) parts.push(`ownerExpr: sql\`${own.owner.expr}\``);
423
+ const guard = sqlFragment(own.ownerSql);
424
+ if (guard) parts.push(`sql: ${guard}`);
425
+ abilities.push(`can('read', { ${parts.join(', ')} })`);
426
+ } else if (diverges && authedRead) {
427
+ // TRANSCRIBE, never synthesize. The structured parse failed, so the live predicate is
428
+ // carried VERBATIM and scoped to exactly the role that holds it — meaning preserved
429
+ // bit for bit, at the cost of a model that expresses little. The note below is
430
+ // DISCLOSURE, not refusal: `verbatim-sql:` is a stable marker so an adoption that
431
+ // reached zero through escape hatches can be counted apart from one that reached zero
432
+ // through vocabulary. Fidelity achieved is not expressiveness achieved.
433
+ abilities.push(`can('read', { role: 'authenticated', sql: sql\`${authedPred!.replace(/`/g, '\\`')}\` })`);
434
+ notes.push(
435
+ `verbatim-sql: policy "${authedPol!.name}" (SELECT) grants authenticated MORE than the anon read.`,
436
+ );
437
+ notes.push(` Its shape does not decompose, so it is transcribed VERBATIM above, scoped to`);
438
+ notes.push(` authenticated. The model does not interpret it. Restructure by hand into`);
439
+ notes.push(` owner:/via: when you can — until then this table is adopted, not expressed.`);
440
+ } else if (diverges) {
441
+ // Diverges, and authenticated does NOT hold a table-level SELECT — a column-scoped grant,
442
+ // or none. Rendering the read either way would widen it to the whole table, so we refuse
443
+ // and say so. The residual statements in db:plan ARE the visibility here.
444
+ notes.push(
445
+ `policy "${authedPol!.name}" (SELECT) grants authenticated MORE than the anon read,`,
446
+ );
447
+ notes.push(` but authenticated holds no TABLE-level SELECT — rendering it would widen a`);
448
+ notes.push(` column-scoped grant to the whole table. NOT rendered. Live USING:`);
449
+ notes.push(` ${authedPred}`);
332
450
  }
333
451
  } else if (authedRead) {
334
452
  const pol = policiesFor(contract, 'authenticated', 'SELECT')[0];
@@ -380,30 +498,37 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
380
498
  abilities.push(`can('${action}'${parts.length ? `, { ${parts.join(', ')} }` : ''})`);
381
499
  }
382
500
 
383
- // --- the column grants, which nothing above renders -----------------------------------
501
+ // --- column grants ---------------------------------------------------------------------
384
502
  //
385
- // The model expresses column scoping for READ only (`can('read', { owner, columns })`), and
386
- // even that pairs with an owner policy this deriver does not attempt to reconstruct. So
387
- // every live column grant is a privilege the rendered model does not carry, and the first
388
- // plan REVOKES it — 11 statements on the reference schema, none of which had a word beside
389
- // the model the adopter reviews.
390
- //
391
- // Named exactly: grantee, privilege, and the columns. A count is not auditable, and "some
392
- // column grants were not captured" is the shape of silence this note exists to break.
503
+ // A column grant is live access, not a lesser table grant. Render SELECT and UPDATE when
504
+ // exactly one permissive policy TO the same role proves the row gate. Anything else stays a
505
+ // note: guessing the pairing could grant the right columns under the wrong row policy.
506
+ const renderedColumnRoles = new Set<string>();
393
507
  for (const grantee of Object.keys(contract.columnGrants ?? {}).sort()) {
394
- if (!GOVERNED_VOCABULARY.has(grantee)) continue; // ungoverned: left alone, never revoked
395
508
  const byPriv = contract.columnGrants![grantee];
396
509
  for (const priv of Object.keys(byPriv).sort()) {
397
510
  const cols = byPriv[priv] ?? [];
398
511
  if (!cols.length) continue;
512
+ const action = priv === 'SELECT' ? 'read' : priv === 'UPDATE' ? 'update' : null;
513
+ const policy = action ? columnPolicyFor(contract, grantee, priv) : null;
514
+ const ability = action && policy ? renderColumnAbility(action, grantee, cols, policy) : null;
515
+ if (ability) {
516
+ abilities.push(ability);
517
+ renderedColumnRoles.add(grantee);
518
+ continue;
519
+ }
399
520
  notes.push(`${grantee} holds a COLUMN-scoped ${priv} on ${cols.length} column(s): ${cols.join(', ')}.`);
400
- notes.push(` NOT rendered — the model has no way to declare it. The next plan REVOKES it.`);
521
+ notes.push(
522
+ action
523
+ ? ` NOT rendered — it needs exactly one permissive ${priv} policy TO ${policyRoleForGrantee(grantee)}.`
524
+ : ` NOT rendered — the model supports column-scoped SELECT and UPDATE only.`,
525
+ );
401
526
  }
402
527
  }
403
528
 
404
529
  // --- roles the compiler has no vocabulary for ----------------------------------------
405
530
  const unmappedRoles = Object.keys(contract.grants).filter(
406
- (r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public',
531
+ (r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public' && !renderedColumnRoles.has(r),
407
532
  );
408
533
 
409
534
  if (!abilities.length && !notes.length && !unmappedRoles.length) {
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
19
- import { effectivePolicyCheck, parenthesizeOnce } from './authz-contract.js';
19
+ import { effectivePolicyCheck, parenthesizeOnce, isPolicyDead, isPolicySubsumed } from './authz-contract.js';
20
20
  import { matchPolicies } from './authz-identity.js';
21
21
  import { quoteQualified } from './pg-ident.js';
22
22
 
@@ -169,7 +169,7 @@ export function emitReconcileSql(
169
169
  // the EMITTED SQL must quote it, or a reserved table name (`public.user`) is a syntax error.
170
170
  const table = quoteQualified(d.table);
171
171
  reconcileRls(table, d, l, sql);
172
- reconcilePolicies(table, d, l, sql);
172
+ reconcilePolicies(table, d, l, sql, governed);
173
173
  reconcileGrants(table, d, l, sql, governed);
174
174
  reconcileColumnGrants(table, d, l, sql, governed);
175
175
  }
@@ -230,11 +230,53 @@ function reconcileRls(table: string, d: TableContract, l: TableContract | undefi
230
230
  * The matcher requires full field equality, so this can only turn a DROP + CREATE pair into a
231
231
  * no-op — never emit DDL the name-keyed version would not have emitted.
232
232
  */
233
- function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
234
- const m = matchPolicies(d.policies, l?.policies ?? []);
233
+ function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
234
+ const live = l?.policies ?? [];
235
+ const m = matchPolicies(d.policies, live);
236
+ const byName = new Map(live.map((p) => [p.name, p]));
237
+
235
238
  // Drops first, so a replaced policy never briefly co-exists with its old form.
236
- for (const name of m.toDrop) out.push(policyDropSql(table, name));
237
- for (const p of m.toCreate) out.push(policyCreateSql(table, p));
239
+ for (const name of m.toDrop) {
240
+ // A live policy scoped ENTIRELY to roles the models do not govern is left alone, exactly
241
+ // as reconcileGrants leaves that role's privileges alone. Without this the two lanes
242
+ // disagree about the same role: it keeps its GRANT and loses its POLICY, and on an
243
+ // RLS-enabled table that combination reads zero rows. A bounded read-only role added for
244
+ // one indexable query is the shape this bites — the model has no word for it, so "no
245
+ // declaration" and "no access" collapsed into the same thing. Same failure as the
246
+ // ungoverned-grantee bug, one lane over.
247
+ //
248
+ // Only when EVERY role is ungoverned. A policy naming anon alongside such a role still
249
+ // governs anon, and leaving it would be the reconciler declining to do its job.
250
+ const p = byName.get(name);
251
+ if (p && p.roles.length > 0 && p.roles.every((r) => !isGoverned(governed, r))) continue;
252
+ // A DEAD policy authorizes nothing — no role it names holds the privilege it polices, so
253
+ // Postgres refuses at the GRANT before ever consulting it. Dropping it changes no access,
254
+ // which makes it churn: a statement an adopter must read, approve and apply to reach zero,
255
+ // whose only effect is to tidy a catalog. Leave it, exactly as the canonical form leaves
256
+ // it out of the state.
257
+ //
258
+ // DEAD BEFORE **AND AFTER**, and the second half is the whole safety of this. Deadness is
259
+ // a property of the contract, not the policy: if THIS plan grants the privilege the policy
260
+ // polices, the policy wakes up the moment the plan lands — undeclared, ungoverned, and
261
+ // granting access the model never asked for. Leaving it would be a widening the plan
262
+ // performs on itself. So it may only be left alone when the DECLARED state keeps it just
263
+ // as inert as the live one does.
264
+ if (p && l && isPolicyDead(l, p) && isPolicyDead(d, p)) continue;
265
+ // Subsumed, before AND after, for exactly the reason deadness needs both: if this plan
266
+ // drops the PUBLIC policy that was covering it, the role-scoped one stops being redundant
267
+ // the moment the plan lands — and leaving it would keep access the model never declared.
268
+ if (p && l && isPolicySubsumed(l, p) && isPolicySubsumed(d, p)) continue;
269
+ out.push(policyDropSql(table, name));
270
+ }
271
+ // Symmetric with the drop rule above, and required by the same identity: a DECLARED policy
272
+ // the declared grants do not back is inert — it would authorize nothing the moment it
273
+ // landed. The canonical form already leaves it out of the declared state, so creating it
274
+ // would emit a statement for a difference the hash says does not exist. It appears the
275
+ // moment the model grants the privilege it polices.
276
+ for (const p of m.toCreate) {
277
+ if (isPolicyDead(d, p) || isPolicySubsumed(d, p)) continue;
278
+ out.push(policyCreateSql(table, p));
279
+ }
238
280
  }
239
281
 
240
282
  function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
package/src/cli/aws.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { isOpsHandlerCrash, formatOpsCrashReport, fetchDiagnosisWithRetry } from './ops-diagnostics.js';
9
9
  import { tagInvokeTransport } from './ops-advice.js';
10
+ import type { SessionRunner } from './session.js';
10
11
 
11
12
  let s3Client: InstanceType<typeof import('@aws-sdk/client-s3').S3Client> | null = null;
12
13
  let lambdaClient: InstanceType<typeof import('@aws-sdk/client-lambda').LambdaClient> | null = null;
@@ -302,6 +303,37 @@ export function lambdaQueryRunner(
302
303
  };
303
304
  }
304
305
 
306
+ /**
307
+ * A `SessionRunner` backed by the ops Lambda's `db:session` action — the stage lane's
308
+ * only consistent read.
309
+ *
310
+ * `lambdaQueryRunner` above sends one invoke per statement, so a multi-statement read is
311
+ * answered by however many containers happen to be warm, each on its own connection.
312
+ * This sends the statements TOGETHER: one invoke, one container, one connection, one
313
+ * transaction. The response arrives gzipped (catalog JSON compresses 10-20x, which is
314
+ * what keeps a real schema inside the 6 MB invoke-response limit).
315
+ */
316
+ export function lambdaSessionRunner(
317
+ region: string,
318
+ functionName: string,
319
+ invoke: typeof invokeAction = invokeAction,
320
+ ): SessionRunner {
321
+ return async (statements, opts) => {
322
+ const result: any = await invoke(region, functionName, 'db:session', {
323
+ statements,
324
+ ...(opts ?? {}),
325
+ });
326
+ if (result?.error) throw new Error(`Session failed: ${result.error}`);
327
+ if (typeof result?.gzip !== 'string') {
328
+ throw new Error(
329
+ 'the ops Lambda did not answer db:session. Deploy a server build that ships the db:session action — the stage lane cannot read consistently without it.',
330
+ );
331
+ }
332
+ const { gunzipSync } = await import('node:zlib');
333
+ return JSON.parse(gunzipSync(Buffer.from(result.gzip, 'base64')).toString('utf8'));
334
+ };
335
+ }
336
+
305
337
  export async function invokeAction(
306
338
  region: string,
307
339
  functionName: string,