@everystack/cli 0.4.100 → 0.4.102

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 (46) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/package.json +3 -3
  3. package/src/cli/authz-baseline.ts +8 -4
  4. package/src/cli/authz-compile.ts +61 -7
  5. package/src/cli/authz-contract.ts +23 -14
  6. package/src/cli/authz-derive.ts +282 -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 +30 -13
  11. package/src/cli/commands/db-pull.ts +153 -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 +4 -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 +57 -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.ts +5 -5
  29. package/src/cli/lifecycle-canon.ts +37 -0
  30. package/src/cli/lifecycle-derived.ts +721 -0
  31. package/src/cli/lifecycle-pull.ts +177 -0
  32. package/src/cli/lifecycle-verify.ts +232 -0
  33. package/src/cli/migration-compile.ts +8 -3
  34. package/src/cli/migration-generate.ts +23 -3
  35. package/src/cli/model-map.ts +255 -0
  36. package/src/cli/model-render.ts +226 -76
  37. package/src/cli/plain-output.ts +198 -10
  38. package/src/cli/runbook/model-section.ts +8 -5
  39. package/src/cli/schema-compile.ts +35 -17
  40. package/src/cli/schema-diff.ts +166 -31
  41. package/src/cli/schema-fingerprint.ts +39 -6
  42. package/src/cli/schema-introspect.ts +64 -2
  43. package/src/cli/schema-source.ts +40 -9
  44. package/src/cli/schema-swap.ts +3 -2
  45. package/src/cli/state-apply.ts +10 -4
  46. 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,139 @@ 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
+ out.abilities.push(frag ? `can('${DML[priv]}', { role: ${tsString(role)}, sql: ${frag} })` : `can('${DML[priv]}', { role: ${tsString(role)} })`);
591
+ }
592
+ out.asAbility.add(priv);
593
+ out.usedPolicies.add(p.name);
594
+ }
595
+ return out;
596
+ }
597
+
429
598
  /**
430
599
  * Derive the ability stanza for ONE table from its live contract.
431
600
  *
@@ -433,18 +602,25 @@ function renderColumnAbility(
433
602
  * through to `notes` rather than guessing — the caller renders those as comments beside
434
603
  * the model, so the human sees the real rule and decides.
435
604
  *
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.
605
+ * `governRoles` (db:pull --govern-roles) is the operator's EXPLICIT decision to GOVERN a foreign
606
+ * role, per the doctrine on GOVERNED_VOCABULARY. Never inferred: a re-pull must not silently
607
+ * convert a rendering decision into an access decision. Its grants and policies render as
608
+ * role-scoped `can()` where they pair exactly (see deriveGovernedRole), else as raw
609
+ * `privileges` with a warning. Complete-or-refuse where the model cannot keep live access:
610
+ * a column-scoped grant with no exactly-paired policy THROWS (the next plan would REVOKE it),
611
+ * and so does a RESTRICTIVE policy TO the role (the next plan would DROP it, a widening).
442
612
  */
443
613
  export function deriveAbilities(
444
614
  contract: TableContract,
445
615
  opts: { governRoles?: ReadonlySet<string> } = {},
446
616
  ): DerivedAbilities {
447
617
  const govern = opts.governRoles ?? new Set<string>();
618
+ // A role is a KEY in every accumulator below (and in the rendered `privileges`), and a key
619
+ // named __proto__ sets the prototype instead. Introspection keeps such a role (null-prototype
620
+ // maps), so a live grant to "__proto__" reaches here and the pull refuses, naming it.
621
+ for (const role of [...Object.keys(contract.grants), ...Object.keys(contract.columnGrants ?? {})]) {
622
+ refuseProtoKey(role, liveObject('role', role));
623
+ }
448
624
  /** One classification pass; every ability-vs-grant decision below reads this tag. */
449
625
  const abilitySurfaces = classifyAbilitySurfaces(contract);
450
626
  const isAbilitySurface = (role: string, priv: string): boolean => abilitySurfaces.has(`${role}:${priv}`);
@@ -505,6 +681,7 @@ export function deriveAbilities(
505
681
  notes.push(
506
682
  ` an ability: an ability would ADD the policy the database does not have.`,
507
683
  );
684
+ notes.push(...noRowPolicyNotes(contract, 'admin', adminDead));
508
685
  }
509
686
 
510
687
  // The same accounting for the app roles. A grant they hold with no policy behind it is
@@ -516,6 +693,7 @@ export function deriveAbilities(
516
693
  deadGrants[role] = dead;
517
694
  notes.push(`${role} holds ${dead.join(', ')} but no policy covers ${role} —`);
518
695
  notes.push(` the grant is live in the catalog and gives no rows; declared as a grant, not an ability.`);
696
+ notes.push(...noRowPolicyNotes(contract, role, dead));
519
697
  }
520
698
 
521
699
  // --- policed but not granted at the table level ---------------------------------------
@@ -555,8 +733,11 @@ export function deriveAbilities(
555
733
  // not have. Same defect as the admin branch above; the round-trip gate found it here.
556
734
  const anonRead = isAbilitySurface('anon', 'SELECT');
557
735
  const authedRead = isAbilitySurface('authenticated', 'SELECT');
736
+ const ownerPair = !anonRead && authedRead ? deriveOwnerReadPair(contract) : null;
558
737
 
559
- if (anonRead) {
738
+ if (ownerPair) {
739
+ abilities.push(...ownerPair);
740
+ } else if (anonRead) {
560
741
  // Public read. If the live policy narrows it (a soft-delete guard, a published flag),
561
742
  // carry that predicate — a public read with a filter is still a public read.
562
743
  const anonPol = policiesFor(contract, 'anon', 'SELECT')[0];
@@ -643,7 +824,7 @@ export function deriveAbilities(
643
824
  // DISCLOSURE, not refusal: `verbatim-sql:` is a stable marker so an adoption that
644
825
  // reached zero through escape hatches can be counted apart from one that reached zero
645
826
  // through vocabulary. Fidelity achieved is not expressiveness achieved.
646
- abilities.push(`can('read', { role: 'authenticated', sql: sql\`${authedPred!.replace(/`/g, '\\`')}\` })`);
827
+ abilities.push(`can('read', { role: 'authenticated', sql: sql\`${templateBody(authedPred!)}\` })`);
647
828
  notes.push(
648
829
  `verbatim-sql: policy "${authedPol!.name}" (SELECT) grants authenticated MORE than the anon read.`,
649
830
  );
@@ -753,27 +934,74 @@ export function deriveAbilities(
753
934
  // exactly one permissive policy TO the same role proves the row gate. Anything else stays a
754
935
  // note: guessing the pairing could grant the right columns under the wrong row policy.
755
936
  const renderedColumnRoles = new Set<string>();
937
+
938
+ // --- custom roles the operator chose to GOVERN (--govern-roles): their row rules (F4) ---
939
+ const governedCustom = [...govern].filter((r) => !GOVERNED_VOCABULARY.has(r)).sort();
940
+ const governedAsAbility = new Map<string, Set<string>>();
941
+ const usedPolicies = new Set<string>();
942
+ const warnings: string[] = [];
943
+ for (const role of governedCustom) {
944
+ const g = deriveGovernedRole(contract, role);
945
+ abilities.push(...g.abilities);
946
+ warnings.push(...g.warnings);
947
+ governedAsAbility.set(role, g.asAbility);
948
+ for (const name of g.usedPolicies) usedPolicies.add(name);
949
+ }
950
+
756
951
  for (const grantee of Object.keys(contract.columnGrants ?? {}).sort()) {
757
952
  const byPriv = contract.columnGrants![grantee];
758
953
  if (govern.has(grantee)) {
954
+ // A governed role's column grant renders ONLY as an exact column ability: SELECT or
955
+ // UPDATE, row security on, exactly one permissive policy TO the role alone, and no
956
+ // table-level ability for the same privilege (two policies of one name would collide).
957
+ // `columns:` keeps it column-scoped; it never becomes a table-level grant.
958
+ // A paired policy with NO USING admits no row (PostgreSQL 16: a SELECT policy without
959
+ // USING, or an UPDATE policy with only a WITH CHECK); a column ability compiles USING (true),
960
+ // every row. `privileges` has no column axis, so there is no raw fallback that keeps the
961
+ // grant without widening it to the table: this refuses, as main did.
962
+ const tableLevel = governedAsAbility.get(grantee) ?? new Set<string>();
963
+ const noUsing: string[] = [];
964
+ const pairs = Object.keys(byPriv).sort().filter((priv) => (byPriv[priv] ?? []).length).map((priv) => {
965
+ const action = priv === 'SELECT' ? 'read' : priv === 'UPDATE' ? 'update' : null;
966
+ let policy = action && contract.rls.enabled && !tableLevel.has(priv) ? columnPolicyFor(contract, grantee, priv) : null;
967
+ if (policy && policy.using == null) {
968
+ noUsing.push(`its ${priv} policy "${policy.name}" has no USING, so it admits no row and a column ability would admit every row`);
969
+ policy = null;
970
+ }
971
+ return { policy, ability: action && policy ? renderColumnAbility(action, grantee, byPriv[priv]!, policy, notes) : null };
972
+ });
973
+ if (pairs.length && pairs.every((p) => p.ability)) {
974
+ for (const p of pairs) {
975
+ abilities.push(p.ability!);
976
+ usedPolicies.add(p.policy!.name);
977
+ }
978
+ renderedColumnRoles.add(grantee);
979
+ continue;
980
+ }
759
981
  // 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.
982
+ // has no column axis — a partial transcription would leave this grant undeclared on a
983
+ // governed role, and the very next plan would REVOKE it.
762
984
  const held = Object.entries(byPriv)
763
985
  .filter(([, cols]) => (cols ?? []).length)
764
986
  .map(([priv, cols]) => `${priv}(${(cols ?? []).join(', ')})`);
765
987
  throw new Error(
766
988
  `${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.`,
989
+ `Governing a role reconciles ALL of its grants, and the model states a column-scoped grant only as a ` +
990
+ `column ability: SELECT or UPDATE, row security on, and exactly one permissive policy of that command ` +
991
+ `TO ${grantee} alone, with a USING. Without it the next plan would revoke the grant. ` +
992
+ `${noUsing.length ? `Here ${noUsing.join('; ')}. ` : ''}` +
993
+ `Add that policy or normalize the grant in the database first, or leave the role ungoverned.`,
770
994
  );
771
995
  }
772
996
  for (const priv of Object.keys(byPriv).sort()) {
773
997
  const cols = byPriv[priv] ?? [];
774
998
  if (!cols.length) continue;
775
999
  const action = priv === 'SELECT' ? 'read' : priv === 'UPDATE' ? 'update' : null;
776
- const policy = action ? columnPolicyFor(contract, grantee, priv) : null;
1000
+ const paired = action ? columnPolicyFor(contract, grantee, priv) : null;
1001
+ // The governed rule, here too: a paired policy with no USING admits no row, and a column
1002
+ // ability compiles USING (true), every row. Not rendered; the note below names the policy.
1003
+ const noUsing = paired && paired.using == null ? paired : null;
1004
+ const policy = noUsing ? null : paired;
777
1005
  const ability = action && policy ? renderColumnAbility(action, grantee, cols, policy, notes) : null;
778
1006
  if (ability) {
779
1007
  abilities.push(ability);
@@ -782,11 +1010,33 @@ export function deriveAbilities(
782
1010
  }
783
1011
  notes.push(`${grantee} holds a COLUMN-scoped ${priv} on ${cols.length} column(s): ${cols.join(', ')}.`);
784
1012
  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.`,
1013
+ noUsing
1014
+ ? ` NOT rendered — its ${priv} policy "${noUsing.name}" has no USING, so it admits no row; a column ability would admit every row.`
1015
+ : action
1016
+ ? ` NOT rendered — it needs exactly one permissive ${priv} policy TO ${policyRoleForGrantee(grantee)}.`
1017
+ : ` NOT rendered — the model supports column-scoped SELECT and UPDATE only.`,
1018
+ );
1019
+ }
1020
+ }
1021
+
1022
+ // --- every policy TO a governed custom role is declared, warned, or refused -------------
1023
+ // The plan reconciles a governed role's policies, so a policy no ability above declares is
1024
+ // DROPPED. Dropping a permissive policy can only narrow, so it is a warning. Dropping a
1025
+ // RESTRICTIVE one widens (it subtracts rows), and the model cannot declare one: refuse.
1026
+ for (const p of contract.policies) {
1027
+ const roles = p.roles.filter((r) => governedCustom.includes(r));
1028
+ if (!roles.length || usedPolicies.has(p.name)) continue;
1029
+ if (!p.permissive) {
1030
+ throw new Error(
1031
+ `${table}: --govern-roles ${roles.join(', ')} refused — RESTRICTIVE policy "${p.name}" (${p.command}) applies to it. ` +
1032
+ `The model cannot declare a restrictive policy, so governing the role would make the next plan DROP it ` +
1033
+ `and WIDEN the role's access. Leave the role ungoverned.`,
788
1034
  );
789
1035
  }
1036
+ warnings.push(
1037
+ `${table}: --govern-roles ${roles.join(', ')}: policy "${p.name}" (${p.command} TO ${p.roles.join(', ')}) is not ` +
1038
+ `declared by the model; the next db:plan DROPS policy "${p.name}".`,
1039
+ );
790
1040
  }
791
1041
 
792
1042
  // --- roles the compiler has no vocabulary for ----------------------------------------
@@ -794,7 +1044,7 @@ export function deriveAbilities(
794
1044
  (r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public' && !renderedColumnRoles.has(r) && !govern.has(r),
795
1045
  );
796
1046
 
797
- if (!abilities.length && !notes.length && !unmappedRoles.length) {
1047
+ if (!abilities.length && !notes.length && !unmappedRoles.length && !warnings.length) {
798
1048
  notes.push(`no grants found — this table is internal (nothing reaches it through the data API).`);
799
1049
  }
800
1050
 
@@ -808,17 +1058,18 @@ export function deriveAbilities(
808
1058
 
809
1059
  // --- roles the operator chose to GOVERN (--govern-roles) ------------------------------
810
1060
  //
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.
1061
+ // Every grant an ability above did not state, verbatim — DML and beyond-CRUD alike. These
1062
+ // are the grants a fresh build must recreate (the migrations being deleted are what used to
1063
+ // create them); a declared grant with no policy stays subject to the naked-grant WARN,
1064
+ // which on an rls: false table correctly names it as live, deliberate, table-wide access.
815
1065
  for (const role of Object.keys(contract.grants).sort()) {
816
1066
  if (!govern.has(role)) continue;
817
- const held = (contract.grants[role] ?? []).sort();
1067
+ const asAbility = governedAsAbility.get(role) ?? new Set<string>();
1068
+ const held = (contract.grants[role] ?? []).filter((p) => !asAbility.has(p)).sort();
818
1069
  if (held.length) privileges[role] = [...new Set([...(privileges[role] ?? []), ...held])].sort();
819
1070
  }
820
1071
 
821
- return { abilities, notes, unmappedRoles, privileges };
1072
+ return { abilities, notes, unmappedRoles, privileges, warnings };
822
1073
  }
823
1074
 
824
1075
  /** An object key, quoted only when the role name is not a bare JS identifier. */
@@ -833,7 +1084,8 @@ export function renderDerivedAbilities(d: DerivedAbilities): string {
833
1084
  // deparses a subquery across several lines. One `//` for the first line left the rest
834
1085
  // as bare SQL in the middle of a TypeScript object — a generated file that would not
835
1086
  // 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}`);
1087
+ for (const n of d.notes) for (const line of commentLines(String(n))) out.push(` // ${line}`);
1088
+ for (const w of d.warnings) for (const line of commentLines(String(w))) out.push(` // WARNING: ${line}`);
837
1089
  if (d.abilities.length) {
838
1090
  out.push(` abilities: [${d.abilities.join(', ')}],`);
839
1091
  } 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') {