@everystack/cli 0.4.100 → 0.4.103

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 (48) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/package.json +3 -3
  3. package/src/cli/authz-baseline.ts +8 -4
  4. package/src/cli/authz-compile.ts +91 -14
  5. package/src/cli/authz-contract.ts +29 -14
  6. package/src/cli/authz-derive.ts +284 -30
  7. package/src/cli/authz-reconcile.ts +6 -4
  8. package/src/cli/commands/db-check.ts +40 -0
  9. package/src/cli/commands/db-generate.ts +10 -9
  10. package/src/cli/commands/db-plan.ts +32 -13
  11. package/src/cli/commands/db-pull.ts +155 -63
  12. package/src/cli/commands/db-reconcile.ts +22 -3
  13. package/src/cli/commands/db-sync.ts +6 -5
  14. package/src/cli/commands/db.ts +91 -8
  15. package/src/cli/commands/update.ts +3 -2
  16. package/src/cli/db-build.ts +1 -0
  17. package/src/cli/declared-derived.ts +3 -1
  18. package/src/cli/declared-diff.ts +5 -1
  19. package/src/cli/derived-apply.ts +116 -10
  20. package/src/cli/derived-grants.ts +4 -2
  21. package/src/cli/derived-introspect.ts +23 -3
  22. package/src/cli/derived-plan.ts +37 -1
  23. package/src/cli/derived-render.ts +33 -34
  24. package/src/cli/edge-plan.ts +92 -9
  25. package/src/cli/generated-artifacts.ts +22 -0
  26. package/src/cli/generated-names.ts +108 -0
  27. package/src/cli/host-url.ts +48 -27
  28. package/src/cli/index-lock.ts +65 -0
  29. package/src/cli/index.ts +5 -5
  30. package/src/cli/lifecycle-canon.ts +37 -0
  31. package/src/cli/lifecycle-derived.ts +787 -0
  32. package/src/cli/lifecycle-pull.ts +252 -0
  33. package/src/cli/lifecycle-verify.ts +238 -0
  34. package/src/cli/migration-compile.ts +11 -5
  35. package/src/cli/migration-generate.ts +24 -3
  36. package/src/cli/model-api.ts +9 -0
  37. package/src/cli/model-map.ts +255 -0
  38. package/src/cli/model-render.ts +232 -76
  39. package/src/cli/plain-output.ts +211 -10
  40. package/src/cli/runbook/model-section.ts +8 -5
  41. package/src/cli/schema-compile.ts +64 -18
  42. package/src/cli/schema-diff.ts +166 -31
  43. package/src/cli/schema-fingerprint.ts +40 -6
  44. package/src/cli/schema-introspect.ts +97 -4
  45. package/src/cli/schema-source.ts +40 -9
  46. package/src/cli/schema-swap.ts +3 -2
  47. package/src/cli/state-apply.ts +10 -4
  48. package/src/cli/ts-template.ts +52 -0
@@ -28,6 +28,9 @@
28
28
  import { EXTRA_PRIVILEGES, sessionlessAudience } from '@everystack/model';
29
29
  import { holdsPrivilege, holdsTablePrivilege } from './authz-contract';
30
30
  import type { TableContract, PolicyContract } from './authz-contract';
31
+ import { templateBody, tsString, commentLines } from './ts-template';
32
+ import { refuseProtoKey, liveObject } from './generated-names';
33
+ import { normalizeDeparsedExpr } from './deparse-normal';
31
34
 
32
35
  /** One rendered decision: either an ability line, or a comment explaining the omission. */
33
36
  export interface DerivedAbilities {
@@ -55,6 +58,13 @@ export interface DerivedAbilities {
55
58
  * it once, for the whole pull.
56
59
  */
57
60
  unmappedRoles: string[];
61
+ /**
62
+ * What a GOVERNED custom role (--govern-roles) holds that `can()` cannot state exactly, one
63
+ * line each, naming the table and the role. db:pull prints them as WARNINGs and the model
64
+ * carries them as comments: a grant kept as a raw `privileges` entry, or a policy the model
65
+ * does not declare (so the next db:plan drops it). Never silently approximated.
66
+ */
67
+ warnings: string[];
58
68
  }
59
69
 
60
70
  const DML: Record<string, 'read' | 'create' | 'update' | 'delete'> = {
@@ -260,13 +270,43 @@ export function parsePublicOrOwn(
260
270
  */
261
271
  const granted = holdsTablePrivilege;
262
272
 
263
- /** The policies that apply to a role for a command (an `ALL` policy covers every command). */
264
- function policiesFor(contract: TableContract, role: string, command: string): PolicyContract[] {
273
+ /** The policies that name a role for a command (an `ALL` policy covers every command). */
274
+ function policiesNaming(contract: TableContract, role: string, command: string): PolicyContract[] {
265
275
  return contract.policies.filter(
266
276
  (p) => (p.command === command || p.command === 'ALL') && (p.roles.includes(role) || p.roles.includes('public')),
267
277
  );
268
278
  }
269
279
 
280
+ /**
281
+ * Can this policy admit any row for the command? PostgreSQL skips a policy whose expression is
282
+ * null: SELECT, UPDATE and DELETE read USING; INSERT reads WITH CHECK, and an ALL policy with no
283
+ * WITH CHECK falls back to its USING. A policy with neither admits NO row, and an ability with
284
+ * no predicate compiles USING (true): EVERY row.
285
+ */
286
+ function admitsRows(p: PolicyContract, command: string): boolean {
287
+ return (command === 'INSERT' ? p.check ?? p.using : p.using) != null;
288
+ }
289
+
290
+ /**
291
+ * The policies that apply to a role for a command and can admit a row. Every ability site reads
292
+ * this, so a policy that admits no row never becomes an ability; its grant is declared raw.
293
+ */
294
+ function policiesFor(contract: TableContract, role: string, command: string): PolicyContract[] {
295
+ return policiesNaming(contract, role, command).filter((p) => admitsRows(p, command));
296
+ }
297
+
298
+ /** Notes naming the permissive policies a role's dead grants sit under that admit no row. */
299
+ function noRowPolicyNotes(contract: TableContract, role: string, privs: readonly string[]): string[] {
300
+ const out: string[] = [];
301
+ for (const priv of privs) {
302
+ for (const p of policiesNaming(contract, role, priv)) {
303
+ if (!p.permissive || admitsRows(p, priv)) continue;
304
+ out.push(` policy "${p.name}" (${priv}) has no ${priv === 'INSERT' ? 'WITH CHECK' : 'USING'}, so it admits no row; an ability would admit every row.`);
305
+ }
306
+ }
307
+ return out;
308
+ }
309
+
270
310
  /**
271
311
  * Does this grant actually give the role rows?
272
312
  *
@@ -336,7 +376,7 @@ function sqlFragment(pred: string | null): string | null {
336
376
  if (!pred) return null;
337
377
  const t = pred.trim();
338
378
  if (t === 'true' || t === '(true)') return null;
339
- return `sql\`${t.replace(/`/g, '\\`')}\``;
379
+ return `sql\`${templateBody(t)}\``;
340
380
  }
341
381
 
342
382
  /** The policy catalog spells the PUBLIC pseudo-role lower-case; grants spell it upper-case. */
@@ -391,7 +431,7 @@ function renderColumnAbility(
391
431
  // Owner-only uses the model's authenticated default. Every other grantee must be named so the
392
432
  // compiler emits the policy TO the live role, not a role-less public ability — and a differing
393
433
  // check names it too, to escape the F-A12 refusal above.
394
- if (!owner || role !== 'authenticated' || distinctCheck) parts.push(`role: '${role}'`);
434
+ if (!owner || role !== 'authenticated' || distinctCheck) parts.push(`role: ${tsString(role)}`);
395
435
  if (owner) {
396
436
  parts.push(`owner: '${modelFieldKey(owner.column)}'`);
397
437
  if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
@@ -422,10 +462,141 @@ function renderColumnAbility(
422
462
  notes.push(` ${policy.check}`);
423
463
  }
424
464
  }
425
- parts.push(`columns: [${columns.map((column) => `'${modelFieldKey(column)}'`).join(', ')}]`);
465
+ parts.push(`columns: [${columns.map((column) => tsString(modelFieldKey(column))).join(', ')}]`);
426
466
  return `can('${action}', { ${parts.join(', ')} })`;
427
467
  }
428
468
 
469
+ /**
470
+ * A named owner read emits both SELECT policies. Recover that declaration only when
471
+ * the complete pair agrees. Differing rules remain two reads; unsupported pairs refuse.
472
+ */
473
+ function deriveOwnerReadPair(contract: TableContract): string[] | null {
474
+ const table = contract.table.split('.').pop()!;
475
+ const named = contract.policies.find(p => p.name === `${table}_select_authenticated`);
476
+ const own = contract.policies.find(p => p.name === `${table}_select_own`);
477
+ if (!named || !own) return null;
478
+
479
+ const refuse = (): never => {
480
+ throw new Error(`${contract.table}: owner-read pair cannot be represented faithfully; keep the live policies and normalize them before pulling.`);
481
+ };
482
+ const reads = policiesNaming(contract, 'authenticated', 'SELECT');
483
+ if (reads.length !== 2 || [named, own].some(p => p.command !== 'SELECT' || !p.permissive
484
+ || p.roles.length !== 1 || p.roles[0] !== 'authenticated' || p.check != null || p.using == null)) {
485
+ return refuse();
486
+ }
487
+ const namedPred = normalizeDeparsedExpr(named.using!);
488
+ const ownPred = normalizeDeparsedExpr(own.using!);
489
+ const exactOwner = parseOwnerPredicate(ownPred);
490
+ const split = splitOwnerPredicate(ownPred);
491
+ const owner = exactOwner ?? split.owner;
492
+ if (!owner) return refuse();
493
+ const parts = [`owner: '${modelFieldKey(owner.column)}'`];
494
+ if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
495
+ if (owner.expr) parts.push(`ownerExpr: sql\`${templateBody(owner.expr)}\``);
496
+ if (namedPred === ownPred && exactOwner) {
497
+ return [`can('read', { role: 'authenticated', ${parts.join(', ')} })`];
498
+ }
499
+ // A named SQL read does not inherit the model's owner gate. The own read does,
500
+ // so carry only its remaining guard there, avoiding a second copy of the gate.
501
+ return [
502
+ `can('read', { role: 'authenticated', sql: sql\`${templateBody(namedPred)}\` })`,
503
+ `can('read', { ${parts.join(', ')}, sql: sql\`${templateBody(split.guard ?? 'true')}\` })`,
504
+ ];
505
+ }
506
+
507
+ /**
508
+ * A GOVERNED custom role (`--govern-roles`) on a table with row security ON: each table-level
509
+ * CRUD grant renders as a role-scoped `can()` when exactly one permissive policy TO that role
510
+ * alone states its row rule in a spelling the compiler emits back unchanged.
511
+ *
512
+ * Why: governing a role reconciles its policies as well as its grants. Transcribing only the
513
+ * grants as `privileges` (all governing did before F4) declared no policy, so the next plan
514
+ * DROPPED every live policy TO the role. An ability declares the grant and the policy together.
515
+ *
516
+ * Exact or not at all. Each shape compiles back to the live rule, never to a wider one:
517
+ * - manage: all four grants, ONE `FOR ALL` policy with USING `true` and WITH CHECK `true` or
518
+ * none, and no per-command policy. Compiles to one ALL policy USING true WITH CHECK true.
519
+ * - read / delete: the USING text rides verbatim as `sql:` (none when it is `true`).
520
+ * - update: as read, and the WITH CHECK must be absent or equal to USING, because the
521
+ * compiler sets WITH CHECK to the same predicate.
522
+ * - create: the WITH CHECK must be exactly `true`.
523
+ * A policy with no USING (or an INSERT policy with no WITH CHECK) admits NO row. An ability
524
+ * without a predicate admits EVERY row, so that shape falls back like any other.
525
+ *
526
+ * Fallback: the grant stays a raw `privileges` entry (kept, never revoked) and a warning says
527
+ * why. A role with no policy of its own for a grant needs no warning: the raw grant is exact.
528
+ */
529
+ function deriveGovernedRole(contract: TableContract, role: string): {
530
+ abilities: string[];
531
+ /** The CRUD privileges stated by an ability, so they are NOT also transcribed raw. */
532
+ asAbility: Set<string>;
533
+ /** The policies those abilities declare. */
534
+ usedPolicies: Set<string>;
535
+ warnings: string[];
536
+ } {
537
+ const out = { abilities: [] as string[], asAbility: new Set<string>(), usedPolicies: new Set<string>(), warnings: [] as string[] };
538
+ if (!contract.rls.enabled) return out;
539
+ const own = (command: string): PolicyContract[] => contract.policies.filter(
540
+ (p) => p.permissive && p.roles.length === 1 && p.roles[0] === role && p.command === command,
541
+ );
542
+ const held = CRUD_PRIVS.filter((p) => granted(contract, role, p));
543
+ const fallback = (priv: string, why: string): void => {
544
+ out.warnings.push(`${contract.table}: --govern-roles ${role}: ${priv} is kept as privileges (a raw grant), not can() — ${why}.`);
545
+ };
546
+
547
+ const all = own('ALL');
548
+ if (all.length) {
549
+ const a = all[0];
550
+ if (all.length === 1 && held.length === CRUD_PRIVS.length && CRUD_PRIVS.every((c) => own(c).length === 0)
551
+ && a.using === 'true' && (a.check == null || a.check === 'true')) {
552
+ out.abilities.push(`can('manage', { role: ${tsString(role)} })`);
553
+ for (const p of CRUD_PRIVS) out.asAbility.add(p);
554
+ out.usedPolicies.add(a.name);
555
+ return out;
556
+ }
557
+ const names = all.map((p) => `"${p.name}"`).join(', ');
558
+ for (const priv of held) {
559
+ fallback(priv, `FOR ALL policy ${names} is not the plain manage shape (all four grants, one FOR ALL USING true, no per-command policy)`);
560
+ }
561
+ return out;
562
+ }
563
+
564
+ for (const priv of held) {
565
+ const pols = own(priv);
566
+ if (pols.length === 0) continue;
567
+ if (pols.length > 1) {
568
+ fallback(priv, `${pols.length} permissive ${priv} policies TO ${role} (${pols.map((p) => `"${p.name}"`).join(', ')}); can() states one`);
569
+ continue;
570
+ }
571
+ const p = pols[0];
572
+ if (priv === 'INSERT') {
573
+ if (p.check !== 'true') {
574
+ fallback(priv, p.check == null
575
+ ? `INSERT policy "${p.name}" has no WITH CHECK, so it admits no row; can('create') would admit every row`
576
+ : `INSERT policy "${p.name}" has WITH CHECK ${p.check}; can('create', { role }) states only WITH CHECK (true)`);
577
+ continue;
578
+ }
579
+ out.abilities.push(`can('create', { role: ${tsString(role)} })`);
580
+ } else {
581
+ if (p.using == null) {
582
+ fallback(priv, `${priv} policy "${p.name}" has no USING, so it admits no row; can('${DML[priv]}') would admit every row`);
583
+ continue;
584
+ }
585
+ if (priv === 'UPDATE' && p.check != null && p.check !== p.using) {
586
+ fallback(priv, `UPDATE policy "${p.name}": WITH CHECK differs from USING (USING ${p.using}; WITH CHECK ${p.check})`);
587
+ continue;
588
+ }
589
+ const frag = sqlFragment(p.using);
590
+ // A lifecycle read without the guard (or with deleted_at IS NOT NULL), marked by the fold.
591
+ const deleted = priv === 'SELECT' && p.deleted ? `, deleted: '${p.deleted}'` : '';
592
+ out.abilities.push(frag ? `can('${DML[priv]}', { role: ${tsString(role)}, sql: ${frag}${deleted} })` : `can('${DML[priv]}', { role: ${tsString(role)}${deleted} })`);
593
+ }
594
+ out.asAbility.add(priv);
595
+ out.usedPolicies.add(p.name);
596
+ }
597
+ return out;
598
+ }
599
+
429
600
  /**
430
601
  * Derive the ability stanza for ONE table from its live contract.
431
602
  *
@@ -433,18 +604,25 @@ function renderColumnAbility(
433
604
  * through to `notes` rather than guessing — the caller renders those as comments beside
434
605
  * the model, so the human sees the real rule and decides.
435
606
  *
436
- * `governRoles` (db:pull --govern-roles) is the operator's EXPLICIT decision to transcribe a
437
- * foreign role's grants into `privileges` — which GOVERNS the role, per the doctrine on
438
- * GOVERNED_VOCABULARY. Never inferred: a re-pull must not silently convert a rendering
439
- * decision into an access decision. Transcription is complete-or-refuse — a governed role
440
- * holding a column-scoped grant THROWS, because the model cannot express it for a foreign
441
- * role and governing the role would make the next plan REVOKE it.
607
+ * `governRoles` (db:pull --govern-roles) is the operator's EXPLICIT decision to GOVERN a foreign
608
+ * role, per the doctrine on GOVERNED_VOCABULARY. Never inferred: a re-pull must not silently
609
+ * convert a rendering decision into an access decision. Its grants and policies render as
610
+ * role-scoped `can()` where they pair exactly (see deriveGovernedRole), else as raw
611
+ * `privileges` with a warning. Complete-or-refuse where the model cannot keep live access:
612
+ * a column-scoped grant with no exactly-paired policy THROWS (the next plan would REVOKE it),
613
+ * and so does a RESTRICTIVE policy TO the role (the next plan would DROP it, a widening).
442
614
  */
443
615
  export function deriveAbilities(
444
616
  contract: TableContract,
445
617
  opts: { governRoles?: ReadonlySet<string> } = {},
446
618
  ): DerivedAbilities {
447
619
  const govern = opts.governRoles ?? new Set<string>();
620
+ // A role is a KEY in every accumulator below (and in the rendered `privileges`), and a key
621
+ // named __proto__ sets the prototype instead. Introspection keeps such a role (null-prototype
622
+ // maps), so a live grant to "__proto__" reaches here and the pull refuses, naming it.
623
+ for (const role of [...Object.keys(contract.grants), ...Object.keys(contract.columnGrants ?? {})]) {
624
+ refuseProtoKey(role, liveObject('role', role));
625
+ }
448
626
  /** One classification pass; every ability-vs-grant decision below reads this tag. */
449
627
  const abilitySurfaces = classifyAbilitySurfaces(contract);
450
628
  const isAbilitySurface = (role: string, priv: string): boolean => abilitySurfaces.has(`${role}:${priv}`);
@@ -505,6 +683,7 @@ export function deriveAbilities(
505
683
  notes.push(
506
684
  ` an ability: an ability would ADD the policy the database does not have.`,
507
685
  );
686
+ notes.push(...noRowPolicyNotes(contract, 'admin', adminDead));
508
687
  }
509
688
 
510
689
  // The same accounting for the app roles. A grant they hold with no policy behind it is
@@ -516,6 +695,7 @@ export function deriveAbilities(
516
695
  deadGrants[role] = dead;
517
696
  notes.push(`${role} holds ${dead.join(', ')} but no policy covers ${role} —`);
518
697
  notes.push(` the grant is live in the catalog and gives no rows; declared as a grant, not an ability.`);
698
+ notes.push(...noRowPolicyNotes(contract, role, dead));
519
699
  }
520
700
 
521
701
  // --- policed but not granted at the table level ---------------------------------------
@@ -555,8 +735,11 @@ export function deriveAbilities(
555
735
  // not have. Same defect as the admin branch above; the round-trip gate found it here.
556
736
  const anonRead = isAbilitySurface('anon', 'SELECT');
557
737
  const authedRead = isAbilitySurface('authenticated', 'SELECT');
738
+ const ownerPair = !anonRead && authedRead ? deriveOwnerReadPair(contract) : null;
558
739
 
559
- if (anonRead) {
740
+ if (ownerPair) {
741
+ abilities.push(...ownerPair);
742
+ } else if (anonRead) {
560
743
  // Public read. If the live policy narrows it (a soft-delete guard, a published flag),
561
744
  // carry that predicate — a public read with a filter is still a public read.
562
745
  const anonPol = policiesFor(contract, 'anon', 'SELECT')[0];
@@ -643,7 +826,7 @@ export function deriveAbilities(
643
826
  // DISCLOSURE, not refusal: `verbatim-sql:` is a stable marker so an adoption that
644
827
  // reached zero through escape hatches can be counted apart from one that reached zero
645
828
  // through vocabulary. Fidelity achieved is not expressiveness achieved.
646
- abilities.push(`can('read', { role: 'authenticated', sql: sql\`${authedPred!.replace(/`/g, '\\`')}\` })`);
829
+ abilities.push(`can('read', { role: 'authenticated', sql: sql\`${templateBody(authedPred!)}\` })`);
647
830
  notes.push(
648
831
  `verbatim-sql: policy "${authedPol!.name}" (SELECT) grants authenticated MORE than the anon read.`,
649
832
  );
@@ -753,27 +936,74 @@ export function deriveAbilities(
753
936
  // exactly one permissive policy TO the same role proves the row gate. Anything else stays a
754
937
  // note: guessing the pairing could grant the right columns under the wrong row policy.
755
938
  const renderedColumnRoles = new Set<string>();
939
+
940
+ // --- custom roles the operator chose to GOVERN (--govern-roles): their row rules (F4) ---
941
+ const governedCustom = [...govern].filter((r) => !GOVERNED_VOCABULARY.has(r)).sort();
942
+ const governedAsAbility = new Map<string, Set<string>>();
943
+ const usedPolicies = new Set<string>();
944
+ const warnings: string[] = [];
945
+ for (const role of governedCustom) {
946
+ const g = deriveGovernedRole(contract, role);
947
+ abilities.push(...g.abilities);
948
+ warnings.push(...g.warnings);
949
+ governedAsAbility.set(role, g.asAbility);
950
+ for (const name of g.usedPolicies) usedPolicies.add(name);
951
+ }
952
+
756
953
  for (const grantee of Object.keys(contract.columnGrants ?? {}).sort()) {
757
954
  const byPriv = contract.columnGrants![grantee];
758
955
  if (govern.has(grantee)) {
956
+ // A governed role's column grant renders ONLY as an exact column ability: SELECT or
957
+ // UPDATE, row security on, exactly one permissive policy TO the role alone, and no
958
+ // table-level ability for the same privilege (two policies of one name would collide).
959
+ // `columns:` keeps it column-scoped; it never becomes a table-level grant.
960
+ // A paired policy with NO USING admits no row (PostgreSQL 16: a SELECT policy without
961
+ // USING, or an UPDATE policy with only a WITH CHECK); a column ability compiles USING (true),
962
+ // every row. `privileges` has no column axis, so there is no raw fallback that keeps the
963
+ // grant without widening it to the table: this refuses, as main did.
964
+ const tableLevel = governedAsAbility.get(grantee) ?? new Set<string>();
965
+ const noUsing: string[] = [];
966
+ const pairs = Object.keys(byPriv).sort().filter((priv) => (byPriv[priv] ?? []).length).map((priv) => {
967
+ const action = priv === 'SELECT' ? 'read' : priv === 'UPDATE' ? 'update' : null;
968
+ let policy = action && contract.rls.enabled && !tableLevel.has(priv) ? columnPolicyFor(contract, grantee, priv) : null;
969
+ if (policy && policy.using == null) {
970
+ noUsing.push(`its ${priv} policy "${policy.name}" has no USING, so it admits no row and a column ability would admit every row`);
971
+ policy = null;
972
+ }
973
+ return { policy, ability: action && policy ? renderColumnAbility(action, grantee, byPriv[priv]!, policy, notes) : null };
974
+ });
975
+ if (pairs.length && pairs.every((p) => p.ability)) {
976
+ for (const p of pairs) {
977
+ abilities.push(p.ability!);
978
+ usedPolicies.add(p.policy!.name);
979
+ }
980
+ renderedColumnRoles.add(grantee);
981
+ continue;
982
+ }
759
983
  // Complete-or-refuse. Governing this role reconciles ALL its grants, and `privileges`
760
- // has no column axis for a foreign role — a partial transcription would leave this
761
- // grant undeclared on a governed role, and the very next plan would REVOKE it.
984
+ // has no column axis — a partial transcription would leave this grant undeclared on a
985
+ // governed role, and the very next plan would REVOKE it.
762
986
  const held = Object.entries(byPriv)
763
987
  .filter(([, cols]) => (cols ?? []).length)
764
988
  .map(([priv, cols]) => `${priv}(${(cols ?? []).join(', ')})`);
765
989
  throw new Error(
766
990
  `${table}: --govern-roles ${grantee} refused — the role holds a COLUMN-scoped grant here: ${held.join('; ')}. ` +
767
- `Governing a role transcribes and reconciles ALL of its grants, and the model cannot express a ` +
768
- `column-scoped grant for a foreign role, so the next plan would revoke it. Normalize the grant to ` +
769
- `table-wide in the database first, or leave the role ungoverned.`,
991
+ `Governing a role reconciles ALL of its grants, and the model states a column-scoped grant only as a ` +
992
+ `column ability: SELECT or UPDATE, row security on, and exactly one permissive policy of that command ` +
993
+ `TO ${grantee} alone, with a USING. Without it the next plan would revoke the grant. ` +
994
+ `${noUsing.length ? `Here ${noUsing.join('; ')}. ` : ''}` +
995
+ `Add that policy or normalize the grant in the database first, or leave the role ungoverned.`,
770
996
  );
771
997
  }
772
998
  for (const priv of Object.keys(byPriv).sort()) {
773
999
  const cols = byPriv[priv] ?? [];
774
1000
  if (!cols.length) continue;
775
1001
  const action = priv === 'SELECT' ? 'read' : priv === 'UPDATE' ? 'update' : null;
776
- const policy = action ? columnPolicyFor(contract, grantee, priv) : null;
1002
+ const paired = action ? columnPolicyFor(contract, grantee, priv) : null;
1003
+ // The governed rule, here too: a paired policy with no USING admits no row, and a column
1004
+ // ability compiles USING (true), every row. Not rendered; the note below names the policy.
1005
+ const noUsing = paired && paired.using == null ? paired : null;
1006
+ const policy = noUsing ? null : paired;
777
1007
  const ability = action && policy ? renderColumnAbility(action, grantee, cols, policy, notes) : null;
778
1008
  if (ability) {
779
1009
  abilities.push(ability);
@@ -782,11 +1012,33 @@ export function deriveAbilities(
782
1012
  }
783
1013
  notes.push(`${grantee} holds a COLUMN-scoped ${priv} on ${cols.length} column(s): ${cols.join(', ')}.`);
784
1014
  notes.push(
785
- action
786
- ? ` NOT rendered — it needs exactly one permissive ${priv} policy TO ${policyRoleForGrantee(grantee)}.`
787
- : ` NOT rendered — the model supports column-scoped SELECT and UPDATE only.`,
1015
+ noUsing
1016
+ ? ` NOT rendered — its ${priv} policy "${noUsing.name}" has no USING, so it admits no row; a column ability would admit every row.`
1017
+ : action
1018
+ ? ` NOT rendered — it needs exactly one permissive ${priv} policy TO ${policyRoleForGrantee(grantee)}.`
1019
+ : ` NOT rendered — the model supports column-scoped SELECT and UPDATE only.`,
1020
+ );
1021
+ }
1022
+ }
1023
+
1024
+ // --- every policy TO a governed custom role is declared, warned, or refused -------------
1025
+ // The plan reconciles a governed role's policies, so a policy no ability above declares is
1026
+ // DROPPED. Dropping a permissive policy can only narrow, so it is a warning. Dropping a
1027
+ // RESTRICTIVE one widens (it subtracts rows), and the model cannot declare one: refuse.
1028
+ for (const p of contract.policies) {
1029
+ const roles = p.roles.filter((r) => governedCustom.includes(r));
1030
+ if (!roles.length || usedPolicies.has(p.name)) continue;
1031
+ if (!p.permissive) {
1032
+ throw new Error(
1033
+ `${table}: --govern-roles ${roles.join(', ')} refused — RESTRICTIVE policy "${p.name}" (${p.command}) applies to it. ` +
1034
+ `The model cannot declare a restrictive policy, so governing the role would make the next plan DROP it ` +
1035
+ `and WIDEN the role's access. Leave the role ungoverned.`,
788
1036
  );
789
1037
  }
1038
+ warnings.push(
1039
+ `${table}: --govern-roles ${roles.join(', ')}: policy "${p.name}" (${p.command} TO ${p.roles.join(', ')}) is not ` +
1040
+ `declared by the model; the next db:plan DROPS policy "${p.name}".`,
1041
+ );
790
1042
  }
791
1043
 
792
1044
  // --- roles the compiler has no vocabulary for ----------------------------------------
@@ -794,7 +1046,7 @@ export function deriveAbilities(
794
1046
  (r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public' && !renderedColumnRoles.has(r) && !govern.has(r),
795
1047
  );
796
1048
 
797
- if (!abilities.length && !notes.length && !unmappedRoles.length) {
1049
+ if (!abilities.length && !notes.length && !unmappedRoles.length && !warnings.length) {
798
1050
  notes.push(`no grants found — this table is internal (nothing reaches it through the data API).`);
799
1051
  }
800
1052
 
@@ -808,17 +1060,18 @@ export function deriveAbilities(
808
1060
 
809
1061
  // --- roles the operator chose to GOVERN (--govern-roles) ------------------------------
810
1062
  //
811
- // The whole grant, verbatim — DML and beyond-CRUD alike. These are the grants a fresh
812
- // build must recreate (the migrations being deleted are what used to create them); a
813
- // declared grant with no policy stays subject to the naked-grant WARN, which on an
814
- // rls: false table correctly names it as live, deliberate, table-wide access.
1063
+ // Every grant an ability above did not state, verbatim — DML and beyond-CRUD alike. These
1064
+ // are the grants a fresh build must recreate (the migrations being deleted are what used to
1065
+ // create them); a declared grant with no policy stays subject to the naked-grant WARN,
1066
+ // which on an rls: false table correctly names it as live, deliberate, table-wide access.
815
1067
  for (const role of Object.keys(contract.grants).sort()) {
816
1068
  if (!govern.has(role)) continue;
817
- const held = (contract.grants[role] ?? []).sort();
1069
+ const asAbility = governedAsAbility.get(role) ?? new Set<string>();
1070
+ const held = (contract.grants[role] ?? []).filter((p) => !asAbility.has(p)).sort();
818
1071
  if (held.length) privileges[role] = [...new Set([...(privileges[role] ?? []), ...held])].sort();
819
1072
  }
820
1073
 
821
- return { abilities, notes, unmappedRoles, privileges };
1074
+ return { abilities, notes, unmappedRoles, privileges, warnings };
822
1075
  }
823
1076
 
824
1077
  /** An object key, quoted only when the role name is not a bare JS identifier. */
@@ -833,7 +1086,8 @@ export function renderDerivedAbilities(d: DerivedAbilities): string {
833
1086
  // deparses a subquery across several lines. One `//` for the first line left the rest
834
1087
  // as bare SQL in the middle of a TypeScript object — a generated file that would not
835
1088
  // parse. Commenting per line here means no note, present or future, can do that.
836
- for (const n of d.notes) for (const line of String(n).split('\n')) out.push(` // ${line}`);
1089
+ for (const n of d.notes) for (const line of commentLines(String(n))) out.push(` // ${line}`);
1090
+ for (const w of d.warnings) for (const line of commentLines(String(w))) out.push(` // WARNING: ${line}`);
837
1091
  if (d.abilities.length) {
838
1092
  out.push(` abilities: [${d.abilities.join(', ')}],`);
839
1093
  } else {
@@ -118,15 +118,17 @@ export function carryUngovernedAuthz(
118
118
  ): TableContract {
119
119
  const foreign = (role: string): boolean => !isGoverned(governed, role);
120
120
 
121
- const grants: Record<string, string[]> = { ...declared.grants };
121
+ // Null-prototype copies: a carried role is a live name, and `grants['__proto__'] = …` on a
122
+ // plain object would set the prototype and drop the grant from the predicted endpoint.
123
+ const grants: Record<string, string[]> = Object.assign(Object.create(null), declared.grants);
122
124
  for (const [role, privileges] of Object.entries(live.grants)) {
123
125
  if (foreign(role)) grants[role] = [...privileges];
124
126
  }
125
127
 
126
- const columnGrants: Record<string, Record<string, string[]>> = { ...(declared.columnGrants ?? {}) };
128
+ const columnGrants: Record<string, Record<string, string[]>> = Object.assign(Object.create(null), declared.columnGrants);
127
129
  for (const [role, byPrivilege] of Object.entries(live.columnGrants ?? {})) {
128
130
  if (!foreign(role)) continue;
129
- const kept: Record<string, string[]> = {};
131
+ const kept: Record<string, string[]> = Object.create(null);
130
132
  for (const [privilege, columns] of Object.entries(byPrivilege)) {
131
133
  const present = columns.filter((c) => presentColumns.has(c));
132
134
  if (present.length > 0) kept[privilege] = present;
@@ -296,7 +298,7 @@ function tableMap(c: AuthzContract): Map<string, TableContract> {
296
298
  export const SEQUENCE_PRIVILEGES_FOR_INSERT = ['USAGE'] as const;
297
299
 
298
300
  export function inheritedSequenceGrants(table: TableContract): Record<string, string[]> {
299
- const out: Record<string, string[]> = {};
301
+ const out: Record<string, string[]> = Object.create(null); // keyed by a role name
300
302
  for (const [grantee, privileges] of Object.entries(table.grants)) {
301
303
  if (privileges.includes('INSERT')) out[grantee] = [...SEQUENCE_PRIVILEGES_FOR_INSERT];
302
304
  }
@@ -34,6 +34,7 @@ import type { ModelDescriptor, DerivedDescriptor } from '@everystack/model';
34
34
  import { compileDeclaredState } from '../declared-diff';
35
35
  import { compileMigration } from '../migration-compile';
36
36
  import { compileDrizzleSource } from '../schema-source';
37
+ import { modelMapPath, renderModelMap } from '../model-map';
37
38
  import { currentGitRef } from '../state-apply';
38
39
  import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '../db-build';
39
40
  import { resolveModelsPath } from '../models-path';
@@ -115,6 +116,15 @@ export interface StaticCheckInput {
115
116
  /** Raw `db/authz-baseline.json`, or null when there is none. The ARTIFACT half of the
116
117
  * ungoverned-grantee gate — see the check below for what it deliberately does not do. */
117
118
  baselineSource?: string | null;
119
+ /** Where the model map lives: beside `artifactPath` (model-map.ts `modelMapPath`). */
120
+ modelMapPath?: string;
121
+ /**
122
+ * The committed model map, read raw. null = no file there (a NOTE, like the schema artifact at
123
+ * its default path: an app that upgraded and has not run db:generate yet is not broken; the
124
+ * map's path is never named on its own, so its absence is never a claim that vanished).
125
+ * Absent (undefined) = the caller did not read it (older callers): no map finding at all.
126
+ */
127
+ modelMapSource?: string | null;
118
128
  }
119
129
 
120
130
  export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
@@ -303,6 +313,27 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
303
313
  });
304
314
  }
305
315
 
316
+ // F8 — the model map, the same regenerate-and-compare gate as the drizzle schema. Pure: the
317
+ // map is a function of the models, so nothing here reads a database (T2).
318
+ if (input.modelMapSource !== undefined) {
319
+ const mapPath = input.modelMapPath ?? modelMapPath(input.artifactPath);
320
+ if (input.modelMapSource === null) {
321
+ findings.push({
322
+ level: 'note',
323
+ area: 'artifact',
324
+ message: `no model map at ${mapPath} — nothing to match (\`everystack db:generate\` writes it beside the drizzle schema)`,
325
+ });
326
+ } else if (input.modelMapSource === renderModelMap(input.models)) {
327
+ findings.push({ level: 'ok', area: 'artifact', message: `${mapPath} matches regeneration byte-for-byte` });
328
+ } else {
329
+ findings.push({
330
+ level: 'fail',
331
+ area: 'artifact',
332
+ message: `${mapPath} does NOT match regeneration — the models moved without db:generate, or the artifact was hand-edited (generated artifacts are never edited). Run \`everystack db:generate\` (it rewrites the map from the models before it reads any database) and commit ${mapPath}.`,
333
+ });
334
+ }
335
+ }
336
+
306
337
  // B7 — db/sql is retired. Files still present would be silently unmanaged: fail
307
338
  // with the migration path. (Absence is the normal state; nothing to report.)
308
339
  if (input.sqlDirRetired) {
@@ -407,6 +438,14 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
407
438
  } catch {
408
439
  artifactSource = null;
409
440
  }
441
+ // F8 — the model map lives beside the drizzle schema, so it follows the same resolution.
442
+ const mapPath = modelMapPath(artifactPath);
443
+ let modelMapSource: string | null = null;
444
+ try {
445
+ modelMapSource = await fs.readFile(mapPath, 'utf8');
446
+ } catch {
447
+ modelMapSource = null;
448
+ }
410
449
  // B7 tombstone — a retired db/sql directory still carrying .sql files is a FAIL
411
450
  // finding (the message carries the migration path), never a silent skip. The retired
412
451
  // --sql-dir flag is itself a FAIL finding here (the sibling verbs refuse it outright);
@@ -438,6 +477,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
438
477
  sequences: declaredDb?.sequences, derived: declaredDb?.derived, derivedError,
439
478
  moduleModels: declaredDb?.models ?? null,
440
479
  baselineSource,
480
+ modelMapPath: mapPath, modelMapSource,
441
481
  });
442
482
  // Per-finding detail is the --verbose report. The quiet default answers the one question a
443
483
  // human asked — is anything pending? — and names the verb that shows it.
@@ -25,7 +25,8 @@ import { pathToFileURL } from 'node:url';
25
25
  import type { ModelDescriptor, Module } from '@everystack/model';
26
26
  import { linkModels } from '@everystack/model';
27
27
  import { introspectSchema } from '../schema-introspect';
28
- import { compileDrizzleSource } from '../schema-source';
28
+ import { writeGeneratedArtifacts } from '../generated-artifacts';
29
+ import { modelMapPath } from '../model-map';
29
30
  import { compileModuleMigration } from '../migration-compile';
30
31
  import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, resolveSchemaOut, HELD_DROP_PREFIX, type Journal } from '../migration-generate';
31
32
  import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract';
@@ -103,8 +104,8 @@ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaO
103
104
  const models = modules.flatMap((m) => m.models);
104
105
  info(`${modules.length} module(s), ${models.length} model(s).`);
105
106
 
106
- step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
107
- await fs.writeFile(schemaOut, compileDrizzleSource(models, { derived: modules.flatMap((m) => m.derived) }), 'utf8');
107
+ step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)} and the model map → ${path.relative(process.cwd(), modelMapPath(schemaOut))}...`);
108
+ await writeGeneratedArtifacts(schemaOut, models, modules.flatMap((m) => m.derived));
108
109
 
109
110
  const statements = compileModuleMigration(modules);
110
111
 
@@ -252,13 +253,13 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
252
253
  models = await loadModels(modelsPath);
253
254
  declaredDb = await loadDeclaredDerived(flags.models);
254
255
  info(`${models.length} model(s).`);
255
- // The drizzle runtime schema is a derived artifact — refresh it from the models
256
- // every run (it needs no DB), so a relation-only change with no DDL diff still
257
- // updates it. The drift-guard test asserts this file matches the models (and the
258
- // typed derived relations — B6a). A dry run refreshes NOTHING — not even this.
256
+ // The drizzle runtime schema and the model map are derived artifacts — refresh them
257
+ // from the models every run (they need no DB), so a relation-only change with no DDL
258
+ // diff still updates them. db:check asserts both match the models byte for byte (the
259
+ // schema with the typed derived relations — B6a). A dry run refreshes NOTHING.
259
260
  if (!dryRun) {
260
- step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
261
- await fs.writeFile(schemaOut, compileDrizzleSource(models, { derived: declaredDb?.derived }), 'utf8');
261
+ step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)} and the model map → ${path.relative(process.cwd(), modelMapPath(schemaOut))}...`);
262
+ await writeGeneratedArtifacts(schemaOut, models, declaredDb?.derived);
262
263
  }
263
264
  const dbSource: DbSource = resolveDbSource(flags);
264
265
  if (apply && dbSource.kind !== 'url') {