@everystack/cli 0.4.51 → 0.4.53

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.51",
3
+ "version": "0.4.53",
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.13"
112
+ "@everystack/model": "0.4.14"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "@everystack/server": ">=0.4.0",
@@ -23,6 +23,7 @@
23
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
+ import { normalizeDeparsedExpr } from './deparse-normal.js';
26
27
 
27
28
  export interface CompileOptions {
28
29
  /** Schema the table lives in. Default: `public`. */
@@ -400,7 +401,14 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
400
401
  name: string, command: PolicyCommand, roles: string[],
401
402
  using: string | null, check: string | null,
402
403
  ): void => {
403
- policies.push({ name, command, roles, permissive: true, using, check });
404
+ // Same normalization the live producer applies (authz-contract's `predicate`): a model
405
+ // pulled on one PostgreSQL major carries that major's deparse spelling in its predicates,
406
+ // and it must compare equal against a stage on another. One rule table, two producers.
407
+ policies.push({
408
+ name, command, roles, permissive: true,
409
+ using: using == null ? null : normalizeDeparsedExpr(using),
410
+ check: check == null ? null : normalizeDeparsedExpr(check),
411
+ });
404
412
  };
405
413
 
406
414
  // admin-bypass — one ALL policy, true/true.
@@ -555,7 +563,12 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
555
563
  // policies. 'app' writes through its policies -> FORCE; 'worker'/'functions'
556
564
  // write on the owner connection and bypass RLS -> ENABLE-not-FORCE (on RDS the
557
565
  // owner is not a superuser, so a FORCEd table would block the owner's own writes).
558
- rls: { enabled: true, forced: model.writtenBy === 'app' },
566
+ // `rls: false` (grants-only table) declares the flag OFF — defineModel guarantees
567
+ // zero abilities and a non-'app' principal there. Compared against `false`, not
568
+ // truthiness: a descriptor minted by an older @everystack/model carries no `rls`
569
+ // key, and undefined must mean ENABLED — the pre-flag behavior — never a silent
570
+ // security downgrade via version skew.
571
+ rls: { enabled: model.rls !== false, forced: model.rls !== false && model.writtenBy === 'app' },
559
572
  grants: compileGrants(abilities, model.privileges),
560
573
  ...(compileColumnGrants(abilities) ? { columnGrants: compileColumnGrants(abilities) } : {}),
561
574
  policies,
@@ -24,6 +24,7 @@
24
24
  import { parsePgArray } from './security-catalog.js';
25
25
  import { INTROSPECTION_SESSION, type SessionRunner } from './session.js';
26
26
  import { matchPolicies, roleSetEqual } from './authz-identity.js';
27
+ import { normalizeDeparsedExpr } from './deparse-normal.js';
27
28
 
28
29
  // ---------------------------------------------------------------------------
29
30
  // The contract format — the frozen, reviewable, version-controlled shape.
@@ -270,6 +271,15 @@ export interface FunctionContract {
270
271
  export interface AuthzContract {
271
272
  tables: TableContract[];
272
273
  functions: FunctionContract[];
274
+ /**
275
+ * Schema-level ACLs (pg_namespace.nspacl), schema → grantee → sorted privileges
276
+ * (USAGE | CREATE). `PUBLIC` is the pseudo-role entry. A schema with a NULL acl is
277
+ * ABSENT — owner-default, no explicit grants — so absence means "the grant does not
278
+ * exist" and an emitter may emit it. Optional: contracts assembled from older recorders
279
+ * or hand-built fixtures simply carry no schema knowledge, and consumers must treat
280
+ * that as "unknown", never as "no grants".
281
+ */
282
+ schemaAcls?: Record<string, Record<string, string[]>>;
273
283
  }
274
284
 
275
285
  export type PolicyCommand = 'ALL' | 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE';
@@ -398,6 +408,82 @@ WHERE c.relkind = 'r'
398
408
  ORDER BY n.nspname, c.relname;
399
409
  `.trim();
400
410
 
411
+ /**
412
+ * Schema ACLs, for the emitters that grant schema USAGE. The consumer bug this closes: the
413
+ * usage phase emitted `GRANT USAGE ON SCHEMA` with no live read at all, so every plan with
414
+ * any authz statement re-granted what the database already held — two false statements on
415
+ * every stage plan, forever. `nspacl` casts to its array-literal text; NULL stays NULL
416
+ * (owner default — no explicit grants — which the parser must NOT read as an empty grant
417
+ * list on purpose: for nspacl the two mean the same emittable thing, but the distinction
418
+ * is kept so the contract says what the catalog said).
419
+ */
420
+ export const SCHEMA_ACL_SQL = `
421
+ SELECT n.nspname AS schema, n.nspacl::text AS acl
422
+ FROM pg_namespace n
423
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
424
+ AND n.nspname NOT LIKE 'pg_%'
425
+ ORDER BY n.nspname;
426
+ `.trim();
427
+
428
+ export interface SchemaAclRow {
429
+ schema: string;
430
+ acl: unknown;
431
+ }
432
+
433
+ /** The two privileges a schema ACL can carry, in aclitem letter form. */
434
+ const SCHEMA_PRIV_LETTERS: Record<string, string> = { U: 'USAGE', C: 'CREATE' };
435
+
436
+ /**
437
+ * Parse one `aclitem[]::text` literal — `{postgres=UC/postgres,authenticator=U/postgres}` —
438
+ * into grantee → privileges. An empty grantee (`=U/postgres`) is PUBLIC. A quoted grantee
439
+ * (`"odd,role"=U/postgres`) is unwrapped with its doubled-quote escapes. A `*` (grant
440
+ * option) rides the letter before it and is dropped — holding WITH GRANT OPTION still
441
+ * holds the privilege. Unparseable input returns null: the caller treats it as unknown,
442
+ * never as "no grants".
443
+ */
444
+ export function parseSchemaAcl(text: string | null | undefined): Record<string, string[]> | null {
445
+ if (text == null) return null;
446
+ const s = String(text).trim();
447
+ if (!s.startsWith('{') || !s.endsWith('}')) return null;
448
+ const body = s.slice(1, -1);
449
+ if (body.trim() === '') return {};
450
+ const out: Record<string, string[]> = {};
451
+ // Split items at top-level commas — a quoted region may contain commas.
452
+ const items: string[] = [];
453
+ let start = 0;
454
+ for (let j = 0; j < body.length; j++) {
455
+ if (body[j] === '"') {
456
+ for (j++; j < body.length; j++) {
457
+ if (body[j] !== '"') continue;
458
+ if (body[j + 1] === '"') { j++; continue; }
459
+ break;
460
+ }
461
+ } else if (body[j] === ',') {
462
+ items.push(body.slice(start, j));
463
+ start = j + 1;
464
+ }
465
+ }
466
+ items.push(body.slice(start));
467
+ for (const item of items) {
468
+ // grantee=letters/grantor — grantee may be quoted; the grantor half is irrelevant here.
469
+ const eq = item.indexOf('=', item.startsWith('"') ? item.indexOf('"', 1) + 1 : 0);
470
+ if (eq === -1) return null;
471
+ let grantee = item.slice(0, eq);
472
+ if (grantee.startsWith('"') && grantee.endsWith('"')) grantee = grantee.slice(1, -1).replace(/""/g, '"');
473
+ if (grantee === '') grantee = 'PUBLIC';
474
+ const slash = item.indexOf('/', eq);
475
+ const letters = item.slice(eq + 1, slash === -1 ? undefined : slash);
476
+ const privs = new Set<string>();
477
+ for (const ch of letters) {
478
+ if (ch === '*') continue;
479
+ const p = SCHEMA_PRIV_LETTERS[ch];
480
+ if (p) privs.add(p);
481
+ }
482
+ if (privs.size) out[grantee] = [...privs].sort();
483
+ }
484
+ return out;
485
+ }
486
+
401
487
  // ---------------------------------------------------------------------------
402
488
  // Pure mappers — one catalog row -> one descriptor.
403
489
  // ---------------------------------------------------------------------------
@@ -411,7 +497,10 @@ function truthy(v: unknown): boolean {
411
497
  function predicate(v: unknown): string | null {
412
498
  if (v == null) return null;
413
499
  const s = String(v).trim();
414
- return s.length > 0 ? s : null;
500
+ // Normalized at the PRODUCER, so the matcher, the canonical hash, and the drift detail
501
+ // all see one spelling — a live tree deparsed by an older major must compare equal to
502
+ // the same predicate parsed on a newer one. See deparse-normal.ts for the rule table.
503
+ return s.length > 0 ? normalizeDeparsedExpr(s) : null;
415
504
  }
416
505
 
417
506
  export interface PolicyRow {
@@ -524,6 +613,8 @@ export interface ContractRows {
524
613
  policies: PolicyRow[];
525
614
  grants: GrantRow[];
526
615
  columnGrants?: ColumnGrantRow[];
616
+ /** pg_namespace ACL rows (SCHEMA_ACL_SQL) — optional; absent means schema ACLs unknown. */
617
+ schemaAcls?: SchemaAclRow[];
527
618
  /** Already-mapped function descriptors (from security-catalog's FUNCTIONS_SQL). */
528
619
  functions: { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean }[];
529
620
  }
@@ -585,9 +676,22 @@ export function assembleContract(rows: ContractRows): AuthzContract {
585
676
  }))
586
677
  .sort((a, b) => a.name.localeCompare(b.name));
587
678
 
679
+ // Schema ACLs, keyed only when the read ran (absent = unknown, per the contract's doc).
680
+ // A NULL acl (owner default) contributes an empty entry so "we looked, nothing granted"
681
+ // is distinguishable from "we never looked".
682
+ let schemaAcls: Record<string, Record<string, string[]>> | undefined;
683
+ if (rows.schemaAcls) {
684
+ schemaAcls = {};
685
+ for (const row of rows.schemaAcls) {
686
+ if (IGNORED_SCHEMAS.has(row.schema)) continue;
687
+ schemaAcls[row.schema] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ?? {};
688
+ }
689
+ }
690
+
588
691
  return {
589
692
  tables: [...tables.values()].sort((a, b) => a.table.localeCompare(b.table)),
590
693
  functions,
694
+ ...(schemaAcls ? { schemaAcls } : {}),
591
695
  };
592
696
  }
593
697
 
@@ -597,11 +701,11 @@ export async function introspectContract(
597
701
  mapFunctionRow: (row: any) => { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean },
598
702
  functionsSql: string,
599
703
  ): Promise<AuthzContract> {
600
- // ONE session: the five queries describe one moment under one pinned search_path.
704
+ // ONE session: the six queries describe one moment under one pinned search_path.
601
705
  // Policy USING / WITH CHECK expressions deparse relative to that path, so a read spread
602
706
  // 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],
707
+ const [rls, policies, grants, columnGrants, schemaAcls, fnRows] = await session(
708
+ [RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, SCHEMA_ACL_SQL, functionsSql],
605
709
  INTROSPECTION_SESSION,
606
710
  );
607
711
  return assembleContract({
@@ -609,6 +713,7 @@ export async function introspectContract(
609
713
  policies: policies as PolicyRow[],
610
714
  grants: grants as GrantRow[],
611
715
  columnGrants: columnGrants as ColumnGrantRow[],
716
+ schemaAcls: schemaAcls as SchemaAclRow[],
612
717
  functions: (fnRows as any[]).map(mapFunctionRow),
613
718
  });
614
719
  }
@@ -326,8 +326,19 @@ function renderColumnAbility(
326
326
  * Deliberately conservative. Every branch that cannot prove what it would emit falls
327
327
  * through to `notes` rather than guessing — the caller renders those as comments beside
328
328
  * the model, so the human sees the real rule and decides.
329
+ *
330
+ * `governRoles` (db:pull --govern-roles) is the operator's EXPLICIT decision to transcribe a
331
+ * foreign role's grants into `privileges` — which GOVERNS the role, per the doctrine on
332
+ * GOVERNED_VOCABULARY. Never inferred: a re-pull must not silently convert a rendering
333
+ * decision into an access decision. Transcription is complete-or-refuse — a governed role
334
+ * holding a column-scoped grant THROWS, because the model cannot express it for a foreign
335
+ * role and governing the role would make the next plan REVOKE it.
329
336
  */
330
- export function deriveAbilities(contract: TableContract): DerivedAbilities {
337
+ export function deriveAbilities(
338
+ contract: TableContract,
339
+ opts: { governRoles?: ReadonlySet<string> } = {},
340
+ ): DerivedAbilities {
341
+ const govern = opts.governRoles ?? new Set<string>();
331
342
  const abilities: string[] = [];
332
343
  const notes: string[] = [];
333
344
  const table = contract.table;
@@ -599,6 +610,20 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
599
610
  const renderedColumnRoles = new Set<string>();
600
611
  for (const grantee of Object.keys(contract.columnGrants ?? {}).sort()) {
601
612
  const byPriv = contract.columnGrants![grantee];
613
+ if (govern.has(grantee)) {
614
+ // Complete-or-refuse. Governing this role reconciles ALL its grants, and `privileges`
615
+ // has no column axis for a foreign role — a partial transcription would leave this
616
+ // grant undeclared on a governed role, and the very next plan would REVOKE it.
617
+ const held = Object.entries(byPriv)
618
+ .filter(([, cols]) => (cols ?? []).length)
619
+ .map(([priv, cols]) => `${priv}(${(cols ?? []).join(', ')})`);
620
+ throw new Error(
621
+ `${table}: --govern-roles ${grantee} refused — the role holds a COLUMN-scoped grant here: ${held.join('; ')}. ` +
622
+ `Governing a role transcribes and reconciles ALL of its grants, and the model cannot express a ` +
623
+ `column-scoped grant for a foreign role, so the next plan would revoke it. Normalize the grant to ` +
624
+ `table-wide in the database first, or leave the role ungoverned.`,
625
+ );
626
+ }
602
627
  for (const priv of Object.keys(byPriv).sort()) {
603
628
  const cols = byPriv[priv] ?? [];
604
629
  if (!cols.length) continue;
@@ -621,7 +646,7 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
621
646
 
622
647
  // --- roles the compiler has no vocabulary for ----------------------------------------
623
648
  const unmappedRoles = Object.keys(contract.grants).filter(
624
- (r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public' && !renderedColumnRoles.has(r),
649
+ (r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public' && !renderedColumnRoles.has(r) && !govern.has(r),
625
650
  );
626
651
 
627
652
  if (!abilities.length && !notes.length && !unmappedRoles.length) {
@@ -636,6 +661,18 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
636
661
  privileges[role] = [...new Set([...(privileges[role] ?? []), ...privs])].sort();
637
662
  }
638
663
 
664
+ // --- roles the operator chose to GOVERN (--govern-roles) ------------------------------
665
+ //
666
+ // The whole grant, verbatim — DML and beyond-CRUD alike. These are the grants a fresh
667
+ // build must recreate (the migrations being deleted are what used to create them); a
668
+ // declared grant with no policy stays subject to the naked-grant WARN, which on an
669
+ // rls: false table correctly names it as live, deliberate, table-wide access.
670
+ for (const role of Object.keys(contract.grants).sort()) {
671
+ if (!govern.has(role)) continue;
672
+ const held = (contract.grants[role] ?? []).sort();
673
+ if (held.length) privileges[role] = [...new Set([...(privileges[role] ?? []), ...held])].sort();
674
+ }
675
+
639
676
  return { abilities, notes, unmappedRoles, privileges };
640
677
  }
641
678
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * authz-lint — the "force-RLS with no read authz" gate.
3
3
  *
4
- * Every modeled table gets RLS enabled (`compileTableContract` emits `rls.enabled: true`), and the
4
+ * A modeled table gets RLS enabled unless it declares `rls: false` (grants-only), and the
5
5
  * model is default-deny: no matching `can()` means no policy. So an EXPOSED table that declares no
6
6
  * read ability is a superuser-drop landmine — it reads fine while a bypassing role (a superuser
7
7
  * api) is in front, then returns empty for every app role the instant that role becomes
@@ -37,11 +37,18 @@ export function findReadAuthzGaps(models: readonly ModelDescriptor[]): ReadAuthz
37
37
  gaps.push({
38
38
  schema: m.schema,
39
39
  table: m.table,
40
- message:
41
- `${m.schema}.${m.table} is exposed and RLS-enabled but declares no read ability every app role ` +
42
- `reads empty once it is RLS-subject (e.g. after dropping a superuser api). Declare a read: ` +
43
- `can('read') for public data, can('read', { owner: '<col>' }) for private, or mark the model ` +
44
- `private() if it is not part of the data API.`,
40
+ // An rls: false table cannot take the "declare a read" cure — defineModel refuses
41
+ // abilities there so its message names the two options that exist. db:pull never
42
+ // authors this shape (it renders private: true beside rls: false); only a hand
43
+ // author can, and this is the line that stops them.
44
+ message: m.rls === false
45
+ ? `${m.schema}.${m.table} declares rls: false and is exposed to the generic data API — grants ` +
46
+ `are its only gate, so every granted role reads every row, unfiltered. A grants-only table ` +
47
+ `is operational, not API surface: mark it private(), or drop rls: false and declare abilities.`
48
+ : `${m.schema}.${m.table} is exposed and RLS-enabled but declares no read ability — every app role ` +
49
+ `reads empty once it is RLS-subject (e.g. after dropping a superuser api). Declare a read: ` +
50
+ `can('read') for public data, can('read', { owner: '<col>' }) for private, or mark the model ` +
51
+ `private() if it is not part of the data API.`,
45
52
  });
46
53
  }
47
54
  return gaps;
@@ -63,12 +70,12 @@ export function findReadAuthzGaps(models: readonly ModelDescriptor[]): ReadAuthz
63
70
  *
64
71
  * Severity is a WARNING, never fatal, and that is a considered narrowing of the original ruling.
65
72
  * The ruling asked for an ERROR on a table without RLS, where a naked grant is an unrestricted
66
- * table-wide privilege. That case cannot arise from models: `compileTableContract` emits
67
- * `rls: { enabled: true }` UNCONDITIONALLY for every modeled table (authz-compile.ts:558). So a
68
- * declared naked grant is always dead-on-arrival and only becomes live if someone later disables
69
- * RLS on that table a real risk, but a future one, and failing CI over a faithful rendering of
70
- * a database the adopter is trying to adopt would make `db:pull` unusable on exactly the schemas
71
- * it exists for.
73
+ * table-wide privilege. Since `rls: false` landed, that case CAN arise from models a
74
+ * grants-only table declares exactly that shape, deliberately: grants ARE its whole
75
+ * authorization, and there is no policy for the grant to be naked of. So the two RLS postures
76
+ * get two messages (dead grant vs live grants-only access), and both stay WARNINGs: failing CI
77
+ * over a faithful rendering of a database the adopter is trying to adopt would make `db:pull`
78
+ * unusable on exactly the schemas it exists for.
72
79
  */
73
80
  export interface NakedGrant {
74
81
  schema: string;
@@ -95,13 +102,20 @@ export function findNakedGrants(models: readonly ModelDescriptor[]): NakedGrant[
95
102
  table: m.table,
96
103
  role,
97
104
  privileges: dml,
98
- message:
99
- `${m.schema}.${m.table}: '${role}' holds ${dml.join(', ')} as a grant with no policy beside it. ` +
100
- `It is dead while RLS is on — the role reads zero rows — and becomes an unrestricted ` +
101
- `table-wide privilege the day RLS is disabled or a broad policy is added. If this came from ` +
102
- `db:pull it is a faithful reading of the database; decide whether to give it a policy ` +
103
- `(can(...)) or revoke it. If you wrote it by hand, you almost certainly want can() instead, ` +
104
- `which decides the grant and the policy together.`,
105
+ // Two postures, two truths. Compared against literal `false`: a descriptor from an
106
+ // older @everystack/model has no rls key, and undefined means enabled (see
107
+ // authz-compile's identical guard).
108
+ message: m.rls === false
109
+ ? `${m.schema}.${m.table}: '${role}' holds ${dml.join(', ')} with rls: false this is LIVE, ` +
110
+ `unrestricted table-wide access, not a dead grant: no row filter applies to '${role}' on ` +
111
+ `this table. That is what a grants-only table declares, so confirm it is deliberate; if ` +
112
+ `'${role}' should see only some rows, drop rls: false and declare can(...) instead.`
113
+ : `${m.schema}.${m.table}: '${role}' holds ${dml.join(', ')} as a grant with no policy beside it. ` +
114
+ `It is dead while RLS is on — the role reads zero rows — and becomes an unrestricted ` +
115
+ `table-wide privilege the day RLS is disabled or a broad policy is added. If this came from ` +
116
+ `db:pull it is a faithful reading of the database; decide whether to give it a policy ` +
117
+ `(can(...)) or revoke it. If you wrote it by hand, you almost certainly want can() instead, ` +
118
+ `which decides the grant and the policy together.`,
105
119
  });
106
120
  }
107
121
  }
@@ -433,7 +433,11 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
433
433
  } else if (failed) {
434
434
  fail('db:check FAILED — the merged declared state is not shippable as-is (findings above).');
435
435
  } else if (ephemeral !== null) {
436
- success(`db:check passed — declared state composes from scratch and lands MATCH at ${ephemeral.fingerprint.slice(0, 12)}.`);
436
+ success(`db:check passed — the declared state composes from scratch and lands on its own fingerprint (${ephemeral.fingerprint.slice(0, 12)}).`);
437
+ // Named because it kept being read as the stronger claim (a consumer, 2026-08-07): this
438
+ // ring proves the checkout against ITSELF, on an empty database. Whether an existing
439
+ // database IS this state is a different question with a different verb.
440
+ info('This proves the checkout is self-consistent and buildable — not that any existing database matches it. For that claim, run db:fingerprint against the database.');
437
441
  } else {
438
442
  success('db:check passed (static ring only — no database provided for the ephemeral compose).');
439
443
  }
@@ -42,7 +42,8 @@ import { introspectContract, type TableContract, type AuthzContract } from '../a
42
42
  import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership.js';
43
43
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
44
44
  import { renderDerivedSource, renderDerivedFile, type DerivedRenderResult } from '../derived-render.js';
45
- import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
45
+ import { renderModelSource, renderModelFiles, pullableTables, skippedInfrastructureTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
46
+ import { deriveAbilities } from '../authz-derive.js';
46
47
  import type { QueryRunner } from '../authz-contract.js';
47
48
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
48
49
  import { borrowedSessionRunner, type SessionRunner } from '../session.js';
@@ -132,6 +133,24 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
132
133
  process.exit(1);
133
134
  }
134
135
 
136
+ // --govern-roles: the operator's EXPLICIT decision to transcribe these foreign roles'
137
+ // grants into `privileges` — which governs them. Required for migration deletion when
138
+ // the migrations created grants to roles outside the vocabulary: a fresh build from
139
+ // models must recreate them. Never inferred from the database (a re-pull must not turn
140
+ // a rendering decision into an access decision); complete-or-refuse per role — a listed
141
+ // role holding a column-scoped grant fails the pull (see deriveAbilities).
142
+ const governRoles = (flags['govern-roles'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
143
+ if (governRoles.length && abilities !== 'live') {
144
+ fail(`--govern-roles transcribes LIVE grants, so it requires --abilities live.`);
145
+ process.exit(1);
146
+ }
147
+ for (const r of governRoles) {
148
+ if (['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r)) {
149
+ fail(`--govern-roles ${r}: the vocabulary roles and PUBLIC are always governed — name only foreign roles (an ops/connection role the migrations granted to).`);
150
+ process.exit(1);
151
+ }
152
+ }
153
+
135
154
  let dbSource: DbSource;
136
155
  try {
137
156
  dbSource = resolveDbSource(flags);
@@ -196,12 +215,15 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
196
215
  const unmapped = new Set<string>();
197
216
  for (const t of contract.tables) {
198
217
  for (const r of Object.keys(t.grants)) {
199
- if (!['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r)) unmapped.add(r);
218
+ if (!['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r) && !governRoles.includes(r)) unmapped.add(r);
200
219
  }
201
220
  }
221
+ if (governRoles.length) {
222
+ note(`--govern-roles ${governRoles.join(', ')}: their grants are transcribed as privileges — the models now OWN them, and a fresh build recreates them.`);
223
+ }
202
224
  if (unmapped.size) {
203
225
  note(`Grants exist for ${[...unmapped].sort().join(', ')} — not rendered: abilities name the roles the model knows (anon/authenticated/admin).`);
204
- detail(`These are left exactly as they are in the database. Add can(..., { role }) only if you want the models to own them.`);
226
+ detail(`These are left exactly as they are in the database. Add can(..., { role }) to own them, or re-pull with --govern-roles to transcribe their grants as privileges.`);
205
227
  }
206
228
  // ADOPTION: record the foreign grantees that were already here, per stage. The
207
229
  // reconciler exempts them from revocation, so this artifact is what stops that
@@ -209,10 +231,10 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
209
231
  // grown, refuses at db:plan. It lands as a reviewable diff, with writes flagged,
210
232
  // because nothing mechanical can tell a legitimate BI role from an attacker's on day
211
233
  // one; the defence is forcing the look and making it recur.
212
- // The governed set at ADOPTION is the fixed vocabulary alone: the models being
213
- // rendered here can only name anon/authenticated/admin, so every other grantee is
214
- // foreign by construction the same set `unmapped` just reported, with privileges.
215
- pulledExemptions = ungovernedGrants(contract, new Set(ALWAYS_GOVERNED));
234
+ // The governed set at ADOPTION is the fixed vocabulary plus any --govern-roles: a
235
+ // governed role's grants are DECLARED (transcribed as privileges), so recording them
236
+ // as exemptions too would double-book them declared and exempted at once.
237
+ pulledExemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
216
238
  pulledFingerprint = fingerprintLive(current, contract).hash;
217
239
  }
218
240
  // --matviews-as-tables: the flip needs real fields — one extra catalog read for the
@@ -255,6 +277,33 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
255
277
  process.exit(1);
256
278
  }
257
279
 
280
+ // The denylist's consequence, stated at the moment it applies: a migration tool's
281
+ // bookkeeping is never modeled, so it can never enter the declared state — and a
282
+ // fully-declared database therefore cannot contain it.
283
+ const skippedInfra = skippedInfrastructureTables(current, schema);
284
+ if (skippedInfra.length) {
285
+ caution(
286
+ `${skippedInfra.length} migration-tool table(s) skipped, never modeled: ${skippedInfra.join(', ')} — `
287
+ + `a migration journal is the tool's own state, not the app's. To reach a fully-declared database, `
288
+ + `DROP them once the tool that owns them is retired.`,
289
+ );
290
+ }
291
+
292
+ // The --govern-roles complete-or-refuse gate, run BEFORE any file is written: a refusal
293
+ // mid-render would leave a half-written models directory. Pure re-derivation, pulled
294
+ // tables only — a governed role's column grant on an UNPULLED table is safe (that table
295
+ // is not declared, so nothing reconciles it).
296
+ if (governRoles.length && liveAuthz) {
297
+ const pulledNames = new Set(pulled.map((t) => t.table));
298
+ try {
299
+ const govern = new Set(governRoles);
300
+ for (const [name, c] of liveAuthz) if (pulledNames.has(name)) deriveAbilities(c, { governRoles: govern });
301
+ } catch (err: any) {
302
+ fail(err.message);
303
+ process.exit(1);
304
+ }
305
+ }
306
+
258
307
  // WHO owns the tables being pulled. Nothing is FLAGGED here: flagging needs a declared
259
308
  // write principal to contradict, and the models this pull is about to write do not exist
260
309
  // yet. Naming the owner is the half the pull genuinely saw — and the half that vanishes
@@ -328,7 +377,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
328
377
  if (flags.out && !flags.out.endsWith('.ts')) {
329
378
  // A directory: one file per model + index.ts — the default shape for a real app.
330
379
  const dir = path.resolve(flags.out);
331
- const files = renderModelFiles(current, { schema, abilities, liveAuthz, derived: embeddedDerived, externalDerived });
380
+ const files = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, derived: embeddedDerived, externalDerived });
332
381
  try {
333
382
  await fs.mkdir(dir, { recursive: true });
334
383
  const written = new Set(files.map((f) => f.file));
@@ -345,7 +394,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
345
394
  source = files.map((f) => f.source).join('\n');
346
395
  } else if (flags.out) {
347
396
  const outPath = path.resolve(flags.out);
348
- source = renderModelSource(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
397
+ source = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, derived: embeddedDerived });
349
398
  try {
350
399
  await fs.mkdir(path.dirname(outPath), { recursive: true });
351
400
  await fs.writeFile(outPath, source, 'utf8');
@@ -355,7 +404,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
355
404
  }
356
405
  ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
357
406
  } else {
358
- source = renderModelSource(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
407
+ source = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, derived: embeddedDerived });
359
408
  process.stdout.write(source);
360
409
  ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
361
410
  }
@@ -391,6 +440,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
391
440
  const stageName = flags.stage;
392
441
  if (!stageName) {
393
442
  note(`No --stage, so ${BASELINE_FILE} was not written. The foreign grantees above are recorded per stage; re-run with --stage <name> to adopt them, or db:plan will refuse until you do.`);
443
+ note(`--stage composes with --database-url: the URL stays the connection, the stage only LABELS the baseline entry — a local clone can adopt for a deployed stage without touching it.`);
394
444
  } else {
395
445
  const entry = buildStageBaseline(pulledExemptions, { observedAt: new Date().toISOString(), fingerprint: pulledFingerprint });
396
446
  const merged = mergeBaseline(await readBaselineFile(), stageName, entry);
@@ -0,0 +1,137 @@
1
+ /**
2
+ * deparse-normal — cross-version normalization of `pg_get_expr` output.
3
+ *
4
+ * A policy predicate's stored tree is built by whichever server PARSED the DDL, and
5
+ * different PostgreSQL majors const-fold the same source differently. The measured case
6
+ * (a consumer's stage, 2026-08-08): an older major stores an array cast OUTSIDE the array,
7
+ *
8
+ * (ARRAY['a'::character varying, 'b'::character varying])::text[]
9
+ *
10
+ * while PG17 distributes it over the elements at parse time,
11
+ *
12
+ * ARRAY[('a'::character varying)::text, ('b'::character varying)::text]
13
+ *
14
+ * Text-identity across venues is then unreachable: models pulled on one major can never
15
+ * equal a stage on another, the differ plans DROP+CREATE forever (the stage re-deparses in
16
+ * its own spelling), and the fingerprint gate cannot MATCH. Identity must not depend on the
17
+ * two venues sharing a parser.
18
+ *
19
+ * THE RULE TABLE IS DELIBERATELY SMALL. Each rewrite must be provably semantics-preserving,
20
+ * because this text feeds the policy matcher, whose safety property is "misses are safe,
21
+ * false matches are the disaster". One rule today:
22
+ *
23
+ * ARRAY-CAST DISTRIBUTION: `(ARRAY[e1, …, en])::T[] == ARRAY[(e1)::T, …, (en)::T]`
24
+ * for n ≥ 1. PostgreSQL defines an array-to-array cast element-wise (parse_coerce), so
25
+ * the two expressions denote the same value for every input; wrapping an element in
26
+ * parentheses is parse-neutral. The empty array is EXCLUDED: `ARRAY[]` without a cast has
27
+ * no type, so its cast is load-bearing and stays.
28
+ *
29
+ * Anything the scanner does not positively recognize is left byte-for-byte unchanged — an
30
+ * unrecognized spelling is a MISS (drop + create, the pre-existing behavior), never a guess.
31
+ * Escalation is named, not implied: the SECOND cross-version variant found in the field is
32
+ * the trigger to adopt libpg_query and move identity onto normalized ASTs (Ty, 2026-08-09) —
33
+ * a growing rule table over raw text is where scanning stops being defensible.
34
+ *
35
+ * Applied at the PRODUCERS (live introspection's `predicate()`, the compiler's policy
36
+ * assembly), so every downstream comparison — the identity matcher, the canonical hash, the
37
+ * drift detail — sees normalized text without holding its own copy of this rule.
38
+ */
39
+
40
+ /** `::text[]` / `::character varying[]` — the cast suffix after `(ARRAY[…])`. */
41
+ const ARRAY_CAST_SUFFIX = /^::([A-Za-z_][A-Za-z0-9_]*(?:\s[A-Za-z_][A-Za-z0-9_]*)*)\[\]/;
42
+
43
+ /**
44
+ * From an opening single or double quote, the index of its closing quote.
45
+ * SQL escapes a quote by doubling it; a doubled quote is content, not a close.
46
+ * Returns -1 on an unterminated literal (the caller then leaves the text alone).
47
+ */
48
+ function skipQuoted(s: string, at: number): number {
49
+ const q = s[at];
50
+ for (let j = at + 1; j < s.length; j++) {
51
+ if (s[j] !== q) continue;
52
+ if (s[j + 1] === q) { j++; continue; }
53
+ return j;
54
+ }
55
+ return -1;
56
+ }
57
+
58
+ /** The index of the `]` closing the `[` at `open`, honoring nesting and quoted regions. */
59
+ function matchBracket(s: string, open: number): number {
60
+ let sq = 0;
61
+ let par = 0;
62
+ for (let j = open; j < s.length; j++) {
63
+ const c = s[j];
64
+ if (c === "'" || c === '"') {
65
+ j = skipQuoted(s, j);
66
+ if (j < 0) return -1;
67
+ } else if (c === '[') sq++;
68
+ else if (c === ']') { sq--; if (sq === 0 && par === 0) return j; }
69
+ else if (c === '(') par++;
70
+ else if (c === ')') par--;
71
+ }
72
+ return -1;
73
+ }
74
+
75
+ /** Split on top-level commas (both nesting depths zero), or null on an unterminated literal. */
76
+ function splitTopLevel(s: string): string[] | null {
77
+ const out: string[] = [];
78
+ let start = 0;
79
+ let sq = 0;
80
+ let par = 0;
81
+ for (let j = 0; j < s.length; j++) {
82
+ const c = s[j];
83
+ if (c === "'" || c === '"') {
84
+ j = skipQuoted(s, j);
85
+ if (j < 0) return null;
86
+ } else if (c === '[') sq++;
87
+ else if (c === ']') sq--;
88
+ else if (c === '(') par++;
89
+ else if (c === ')') par--;
90
+ else if (c === ',' && sq === 0 && par === 0) {
91
+ out.push(s.slice(start, j));
92
+ start = j + 1;
93
+ }
94
+ }
95
+ out.push(s.slice(start));
96
+ return out;
97
+ }
98
+
99
+ /** One left-to-right pass: rewrite the first recognized `(ARRAY[…])::T[]`, or null if none. */
100
+ function distributeOnce(s: string): string | null {
101
+ let i = 0;
102
+ while ((i = s.indexOf('(ARRAY[', i)) !== -1) {
103
+ const open = i + 6; // the '['
104
+ const close = matchBracket(s, open);
105
+ if (close === -1) return null; // unparseable text — leave everything alone
106
+ if (s[close + 1] === ')') {
107
+ const m = ARRAY_CAST_SUFFIX.exec(s.slice(close + 2));
108
+ if (m) {
109
+ const inner = s.slice(open + 1, close);
110
+ const elements = splitTopLevel(inner);
111
+ // Empty array excluded: its cast carries the type. A failed split leaves the text alone.
112
+ if (elements !== null && inner.trim().length > 0) {
113
+ const t = m[1];
114
+ const rewritten = `ARRAY[${elements.map((e) => `(${e.trim()})::${t}`).join(', ')}]`;
115
+ return s.slice(0, i) + rewritten + s.slice(close + 2 + m[0].length);
116
+ }
117
+ }
118
+ }
119
+ i = open;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Normalize one deparsed expression. Idempotent; unrecognized text returns unchanged.
126
+ * Fixpoint-bounded: nesting deeper than the cap returns its progress, which is still
127
+ * deterministic — both sides of every comparison run this same function.
128
+ */
129
+ export function normalizeDeparsedExpr(text: string): string {
130
+ let s = text;
131
+ for (let n = 0; n < 64; n++) {
132
+ const next = distributeOnce(s);
133
+ if (next === null) return s;
134
+ s = next;
135
+ }
136
+ return s;
137
+ }
package/src/cli/index.ts CHANGED
@@ -359,7 +359,7 @@ Usage:
359
359
  everystack db:export --schema <name> [--stage <name> | --database-url <url> [--out <file.dump>]] [--models <barrel>] Schema-scoped pg_dump artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp). --stage dumps the stage's private DB via the ops Lambda → S3; --database-url (explicit flag, never the env) dumps a reachable DB to a local .dump + .meta.json — the build-locally → publish → swap on-ramp
360
360
  everystack db:swap --schema <name> --from <artifact.dump | artifact-id> [--stage <name> --direct | --database-url <url>] --confirm [--fingerprint <hash>] [--snapshot physical|logical|none] [--snapshot-ref <id>] [--rebuild-derived] [--dump-build <file.json>] Land a schema artifact atomically: fingerprint gate → pre-flight refusals → CONFIRMED snapshot → restore into <schema>_incoming (COPY-safe rewrite) → build the paired derived layer → one txn (drop+rename+recreate app→schema FKs, re-apply authz + schema USAGE) → assertions → drop retiring. Refresh-free; app.* untouched; the derived layer is never absent. DESTRUCTIVE. The venue is EXPLICIT — DATABASE_URL in the env is refused, and --stage requires --direct (a multi-GB restore exceeds the ops-Lambda 900s clock). The rollback point defaults to a PHYSICAL RDS snapshot on a stage that exposes databaseInstanceId (no locks, no pg_dump contending with the restore) and a WAITED logical db:backup otherwise; a bare --database-url refuses without --snapshot-ref <id> or --snapshot none. docs/schema-swap.md
361
361
  everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
362
- everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
362
+ everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --govern-roles <r1,r2> (with --abilities live) transcribes the named FOREIGN roles' grants into privileges — the models then OWN them, so a fresh build recreates them; required for migration deletion when the migrations created grants to an ops/connection role. Explicit only (a re-pull must not silently govern), complete-or-refuse (a listed role holding a column-scoped grant fails the pull before anything is written). --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
363
363
  Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
364
364
  everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
365
365
  everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--only a,b] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
@@ -90,7 +90,11 @@ function holdDrop(sql: string): string {
90
90
  */
91
91
  export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
92
92
  const schema = opts.schema ?? 'public';
93
- const declared = new Set(models.map((m) => `${schema}.${m.table}`));
93
+ // The model's OWN schema first — the same rule the compiler applies. Qualifying every
94
+ // model with the call default read a multi-schema checkout's non-public models as
95
+ // unmodeled: a consumer's plan listed three modeled tables as riding through untouched,
96
+ // which is a false statement about what the plan governs.
97
+ const declared = new Set(models.map((m) => `${m.schema ?? schema}.${m.table}`));
94
98
  // A pending table rename's OR move's source is ours — declared under its new (qualified)
95
99
  // name; without this it reads as an undeclared orphan (F1) and the move/rename silently
96
100
  // degrades to CREATE + leave-behind, the exact bug the markers exist to prevent.
@@ -233,6 +237,20 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
233
237
  // The from-scratch path had this from the start; the diff path did not, so a non-public
234
238
  // model reached through db:sync/db:generate was unreachable by every role that did not
235
239
  // pick up USAGE some other way. The example app's analytics schema is what surfaced it.
240
+ // Diffed against the LIVE schema ACLs when the contract carries them. This phase used to
241
+ // emit unconditionally, so any plan with an authz statement re-granted USAGE the database
242
+ // already held — two false statements on every one of a consumer's stage plans (measured,
243
+ // catalog-verified, 2026-08-08). A role holds USAGE when its own nspacl entry says so or
244
+ // when PUBLIC's does (PUBLIC reaches every role). Membership-derived usage is invisible
245
+ // here and stays emitted — idempotent, and rarer than the direct grants that were the bug.
246
+ // No schemaAcls (fresh compose, older recorder) = unknown = emit, exactly as before.
247
+ const liveSchemaAcls = liveAuthzRenamed?.schemaAcls;
248
+ const hasLiveUsage = (s: string, role: string): boolean => {
249
+ const acl = liveSchemaAcls?.[s];
250
+ if (!acl) return false;
251
+ const key = role.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : role;
252
+ return (acl[key] ?? []).includes('USAGE') || (acl.PUBLIC ?? []).includes('USAGE');
253
+ };
236
254
  const usagePhase = authzPhase.length
237
255
  ? [...new Set(desiredContracts.map((c) => c.table.split('.')[0]))]
238
256
  .filter((s) => s !== 'public')
@@ -244,8 +262,9 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
244
262
  for (const r of Object.keys(c.grants)) roles.add(r);
245
263
  for (const r of Object.keys(c.columnGrants ?? {})) roles.add(r);
246
264
  }
247
- if (!roles.size) return [];
248
- const targets = [...roles].sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
265
+ const missing = [...roles].filter((r) => !hasLiveUsage(s, r));
266
+ if (!missing.length) return [];
267
+ const targets = missing.sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
249
268
  return [`GRANT USAGE ON SCHEMA "${s}" TO ${targets}`];
250
269
  })
251
270
  : [];
@@ -79,6 +79,14 @@ export interface RenderOptions {
79
79
  * authz-derive.ts for the rule (policy presence is not effective privilege).
80
80
  */
81
81
  liveAuthz?: Map<string, TableContract>;
82
+ /**
83
+ * `--govern-roles` — the operator's explicit decision to transcribe these foreign roles'
84
+ * grants into `privileges`, which GOVERNS them. Required for migration deletion when the
85
+ * migrations created grants to roles outside the vocabulary (an ops/connection role): a
86
+ * fresh build from models must recreate them or the deletion loses the ops lane its
87
+ * access. Never inferred — see deriveAbilities. Complete-or-refuse per role.
88
+ */
89
+ governRoles?: string[];
82
90
  /** The rendered derived layer (B5) — rides the barrel: block after the models,
83
91
  * sequences/derived arrays on the module wrapper, symbols on the import header. */
84
92
  derived?: DerivedRenderResult;
@@ -124,7 +132,7 @@ export function isPublicReadAbility(expr: string): boolean {
124
132
  * abilities, not a regex over the joined text (a live predicate can span lines and carry its
125
133
  * own braces). An unknown preset throws — grants are authored, never guessed.
126
134
  */
127
- function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>): { text: string; publicRead: boolean } {
135
+ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>): { text: string; publicRead: boolean } {
128
136
  if (mode === 'live') {
129
137
  const contract = table && liveAuthz?.get(table.table);
130
138
  if (!contract) {
@@ -137,8 +145,20 @@ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<stri
137
145
  publicRead: false,
138
146
  };
139
147
  }
140
- const derived = deriveAbilities(contract);
141
- return { text: renderDerivedAbilities(derived), publicRead: derived.abilities.some(isPublicReadAbility) };
148
+ const derived = deriveAbilities(contract, { governRoles });
149
+ const lines = [renderDerivedAbilities(derived)];
150
+ // A grants-only table: live RLS is OFF and no ability rendered. Transcribe the flag —
151
+ // without it the compiler declares RLS enabled and the first plan after adoption
152
+ // proposes ENABLE ROW LEVEL SECURITY, which with zero policies denies every non-owner
153
+ // role a table it already uses (a consumer's ops lane, measured 2026-08-07). Never
154
+ // rendered beside abilities: defineModel refuses the pair, and a live-off table whose
155
+ // grants DO derive abilities is adopt-mode territory, not a flag transcription.
156
+ // The writtenBy stanza always accompanies this line (live-off is never FORCEd), which
157
+ // is what lets the rendered model load — rls: false with the 'app' default is refused.
158
+ if (!derived.abilities.length && !contract.rls.enabled) {
159
+ lines.push(` rls: false, // live reality: row security is OFF — authorization here is grants-only.`);
160
+ }
161
+ return { text: lines.join('\n'), publicRead: derived.abilities.some(isPublicReadAbility) };
142
162
  }
143
163
  if (mode === 'commented') {
144
164
  return {
@@ -257,6 +277,21 @@ export function pullableTables(snapshot: SchemaSnapshot, schema: string | string
257
277
  });
258
278
  }
259
279
 
280
+ /**
281
+ * The infrastructure tables a pull SKIPPED, qualified — so the command can NAME them.
282
+ *
283
+ * The denylist has a consequence nothing used to state: these tables are never modeled,
284
+ * so an adopter with legacy bookkeeping (a retired Rails app's `schema_migrations`) can
285
+ * only reach a fully-declared database by DROPPING them. A consumer did that archaeology
286
+ * by hand (2026-08-07); one line at pull time is what it should have cost.
287
+ */
288
+ export function skippedInfrastructureTables(snapshot: SchemaSnapshot, schema: string | string[]): string[] {
289
+ const schemas = new Set(Array.isArray(schema) ? schema : [schema]);
290
+ return snapshot.tables
291
+ .map((t) => t.table)
292
+ .filter((table) => schemas.has(schemaOf(table)) && INFRASTRUCTURE_TABLES.has(bareName(table)));
293
+ }
294
+
260
295
  /** The schema a qualified (or bare) table lives in — bare means public. */
261
296
  function schemaOf(table: string): string {
262
297
  const dot = table.indexOf('.');
@@ -515,7 +550,7 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
515
550
  }
516
551
 
517
552
  /** One `export const X = defineModel(...)` block for a table. */
518
- export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>): string {
553
+ export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>): string {
519
554
  // A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
520
555
  // the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
521
556
  const validates = new Map<string, string>();
@@ -532,7 +567,7 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
532
567
  // The authz decision renders FIRST — before fields — because it is the first thing a
533
568
  // reviewer must resolve about a model (and where the field-report consumer's codemod
534
569
  // put it, proving the position is mechanical-edit-friendly).
535
- const stanza = abilitiesStanza(abilities, table, liveAuthz);
570
+ const stanza = abilitiesStanza(abilities, table, liveAuthz, governRoles);
536
571
  const writtenBy = writtenByStanza(table, liveAuthz);
537
572
  const softDelete = softDeleteStanza(table, stanza.publicRead);
538
573
 
@@ -654,7 +689,7 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
654
689
  const known = new Set(tables.map((t) => bareName(t.table)));
655
690
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
656
691
 
657
- const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz));
692
+ const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles)));
658
693
  // The derived layer (B5): sequences + views/matviews/functions, after the models they
659
694
  // reference, before the module wrapper that composes all three.
660
695
  if (opts.derived?.block) blocks.push(opts.derived.block);
@@ -722,7 +757,7 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
722
757
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
723
758
 
724
759
  const files: RenderedModelFile[] = tables.map((t) => {
725
- const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz);
760
+ const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles));
726
761
  const crossImports = referencedTables(t, known).map(
727
762
  (target) => `import { ${modelVarName(target)} } from '${modelImportPath(target)}';`,
728
763
  );
@@ -110,7 +110,7 @@ import { normalizeDefault, normalizeCheck, indexKey } from './schema-diff.js';
110
110
  // it from the canonical form AND the reconciler leaves it alone, before and after. Restrictive
111
111
  // policies are never subsumed (they AND, so removing one would WIDEN), and the rule must match
112
112
  // exactly including the effective WITH CHECK.
113
- export const FINGERPRINT_VERSION = 8;
113
+ export const FINGERPRINT_VERSION = 9;
114
114
 
115
115
  // ---------------------------------------------------------------------------
116
116
  // Canonical form.