@everystack/cli 0.4.44 → 0.4.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/cli/alter-type-dependents.ts +96 -0
  3. package/src/cli/apply-execute.ts +22 -8
  4. package/src/cli/authz-adoption-class.ts +314 -0
  5. package/src/cli/authz-baseline.ts +25 -3
  6. package/src/cli/authz-canonical.ts +178 -0
  7. package/src/cli/authz-compile.ts +130 -40
  8. package/src/cli/authz-contract.ts +212 -44
  9. package/src/cli/authz-derive.ts +244 -34
  10. package/src/cli/authz-identity.ts +222 -0
  11. package/src/cli/authz-ownership.ts +193 -0
  12. package/src/cli/authz-reconcile.ts +61 -27
  13. package/src/cli/aws.ts +32 -0
  14. package/src/cli/commands/db-apply.ts +60 -14
  15. package/src/cli/commands/db-authz.ts +9 -14
  16. package/src/cli/commands/db-fingerprint.ts +54 -18
  17. package/src/cli/commands/db-generate.ts +59 -15
  18. package/src/cli/commands/db-plan.ts +89 -9
  19. package/src/cli/commands/db-pull.ts +36 -19
  20. package/src/cli/commands/db-reconcile.ts +18 -20
  21. package/src/cli/commands/db-swap.ts +5 -4
  22. package/src/cli/commands/db-sync.ts +8 -5
  23. package/src/cli/db-build.ts +2 -2
  24. package/src/cli/db-source.ts +56 -0
  25. package/src/cli/derived-introspect.ts +27 -26
  26. package/src/cli/derived-lint.ts +7 -8
  27. package/src/cli/edge-plan.ts +125 -17
  28. package/src/cli/git-descent.ts +16 -9
  29. package/src/cli/index.ts +2 -18
  30. package/src/cli/model-render.ts +75 -52
  31. package/src/cli/output.ts +25 -3
  32. package/src/cli/parse-flags.ts +39 -0
  33. package/src/cli/schema-compile.ts +6 -1
  34. package/src/cli/schema-diff.ts +1 -1
  35. package/src/cli/schema-fingerprint.ts +154 -42
  36. package/src/cli/schema-introspect.ts +44 -17
  37. package/src/cli/schema-source.ts +9 -0
  38. package/src/cli/session.ts +184 -0
  39. package/src/cli/stage-read-consistency.ts +128 -0
  40. package/src/cli/state-apply.ts +4 -2
  41. package/src/cli/swap-execute.ts +4 -3
  42. package/src/cli/search-path.ts +0 -51
@@ -46,12 +46,13 @@ import {
46
46
  ENSURE_RECONCILER_SQL,
47
47
  } from '../derived-apply.js';
48
48
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
49
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
49
50
  import { resolveOperatorUrlViaStage } from '../direct-venue.js';
50
51
  import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
51
52
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
52
53
  import { currentGitRef } from '../state-apply.js';
53
54
  import { resolveConfig, opsFunction } from '../config.js';
54
- import { invokeAction } from '../aws.js';
55
+ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
55
56
  import { step, success, fail, info, warn } from '../output.js';
56
57
 
57
58
  // ---------------------------------------------------------------------------
@@ -111,9 +112,10 @@ export function isTransactionalBatch(statements: string[]): boolean {
111
112
 
112
113
  export async function executeReconcile(
113
114
  runner: QueryRunner,
115
+ session: SessionRunner,
114
116
  options: ExecuteOptions = {},
115
117
  ): Promise<ReconcileRun> {
116
- const live = await introspectDerived(runner);
118
+ const live = await introspectDerived(session);
117
119
  const parsed = { objects: options.declared ?? [], warnings: [] as string[] };
118
120
  const plan = planReconcile(parsed, live, options);
119
121
  const rendered = renderReconcileSql(plan, parsed.objects);
@@ -189,11 +191,14 @@ export async function executeReconcile(
189
191
  }
190
192
  }
191
193
 
192
- // Re-read the catalog so provenance records the def hashes of what NOW exists, not what we hoped
193
- // would exist. Inside the transaction this reads the batch's own not-yet-committed writes.
194
- // introspectDerived pins the canonical search_path itself, so the defHash it records is stable
195
- // regardless of the create-time wide path above (or any ambient override) no manual reset here.
196
- const after = await introspectDerived(runner);
194
+ // Re-read the catalog so provenance records the def hashes of what NOW exists, not what we
195
+ // hoped would exist. Inside the transaction this reads the batch's own not-yet-committed
196
+ // writes, so the read must BORROW that transaction: a session of its own would open a nested
197
+ // BEGIN and COMMIT this one out from under the bookkeeping below. The borrowed session pins
198
+ // the canonical search_path inside a savepoint it always rolls back, so the def hashes are
199
+ // stable regardless of the create-time wide path above — and that wide path survives the read.
200
+ // The unwrapped path has no transaction to borrow, so it takes a session of its own.
201
+ const after = await introspectDerived(atomic ? borrowedSessionRunner(runner) : session);
197
202
  const liveById = new Map(after.objects.map((o) => [o.identity, o]));
198
203
  const srcById = new Map(parsed.objects.map((o) => [o.identity, o]));
199
204
 
@@ -347,15 +352,6 @@ export function checkFails(plan: ReconcilePlan): boolean {
347
352
  // The CLI shell.
348
353
  // ---------------------------------------------------------------------------
349
354
 
350
- /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
351
- function lambdaRunner(region: string, fn: string): QueryRunner {
352
- return async (sql: string) => {
353
- const result: any = await invokeAction(region, fn, 'db:query', { sql });
354
- if (result?.error) throw new Error(`Query failed: ${result.error}`);
355
- return result?.rows ?? [];
356
- };
357
- }
358
-
359
355
  export async function dbReconcileCommand(flags: Record<string, string>): Promise<void> {
360
356
  const apply = flags.apply === 'true';
361
357
  const check = flags.check === 'true';
@@ -493,25 +489,27 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
493
489
  run = { plan: result.plan, applied: result.applied, statements: result.statements ?? [], refusal: result.refusal ?? undefined };
494
490
  } else {
495
491
  let runner: QueryRunner;
492
+ let session: SessionRunner;
496
493
  // A write over a direct connection (--database-url or --direct) takes the mutation
497
494
  // lease; a read-only stage dry-run over the ops Lambda does not (reads never lease).
498
495
  let leased = false;
499
496
  if (dbSource.kind === 'url') {
500
497
  step(connectingVia(dbSource));
501
- ({ runner, end } = await createUrlRunner(dbSource.url));
498
+ ({ runner, session, end } = await createUrlRunner(dbSource.url));
502
499
  leased = apply;
503
500
  } else {
504
501
  step('Resolving deployed config...');
505
502
  const config = await resolveConfig(flags.stage);
506
- runner = lambdaRunner(config.region, opsFunction(config));
503
+ runner = lambdaQueryRunner(config.region, opsFunction(config));
504
+ session = lambdaSessionRunner(config.region, opsFunction(config));
507
505
  }
508
506
  run = leased
509
507
  ? await withMutationLease(
510
508
  runner,
511
509
  { verb: 'db:reconcile', actor: process.env.USER ?? 'unknown' },
512
- () => executeReconcile(runner, reconcileOptions),
510
+ () => executeReconcile(runner, session, reconcileOptions),
513
511
  )
514
- : await executeReconcile(runner, reconcileOptions);
512
+ : await executeReconcile(runner, session, reconcileOptions);
515
513
  }
516
514
 
517
515
  if (flags.json === 'true') {
@@ -31,6 +31,7 @@ import { pairedDerivedSchemas, renderPairedDerivedBuild, renderSwapSchemaUsage,
31
31
  import { introspectDerived } from '../derived-introspect.js';
32
32
  import { legacyFunctionIdentity } from '../pg-argtypes.js';
33
33
  import { createUrlRunner } from '../db-source.js';
34
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
34
35
  import type { QueryRunner } from '../authz-contract.js';
35
36
  import { executeSwap, type SwapVerdict } from '../swap-execute.js';
36
37
  import { startHeartbeat, humanElapsed } from '../swap-heartbeat.js';
@@ -626,14 +627,14 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
626
627
  }
627
628
  const artifactFingerprint = artifact.fingerprint;
628
629
 
629
- const { runner, end } = await createUrlRunner(url);
630
+ const { runner, session, end } = await createUrlRunner(url);
630
631
  try {
631
632
  step(`Swapping ${schema} — gate, land incoming, atomic swap, verify...`);
632
633
  // One operator mutates a database at a time — the swap is a whole-schema replacement.
633
634
  const res = await withMutationLease(
634
635
  runner,
635
636
  { verb: 'db:swap', actor: process.env.USER ?? 'unknown' },
636
- () => executeSwap(runner, {
637
+ () => executeSwap(runner, session, {
637
638
  models, schema,
638
639
  artifactFingerprint,
639
640
  declaredFingerprint,
@@ -659,8 +660,8 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
659
660
  // later, for an unrelated edit — sees the whole layer as drift and rebuilds it under
660
661
  // ACCESS EXCLUSIVE. The objects are correct; only the bookkeeping was missing.
661
662
  recordProvenance: paired.length
662
- ? async (r) => {
663
- const live = await introspectDerived(r);
663
+ ? async (r, s) => {
664
+ const live = await introspectDerived(s);
664
665
  const prov = renderPairedProvenance(declaredDerivedObjects, live.objects, schema, paired);
665
666
  for (const st of prov.statements) await r(st);
666
667
  if (prov.recorded.length === 0) {
@@ -48,6 +48,7 @@ import { fingerprintModels } from '../schema-fingerprint.js';
48
48
  import { compileDrizzleSource } from '../schema-source.js';
49
49
  import type { SourceFile } from '../derived-source.js';
50
50
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
51
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
51
52
  import { planBackfills, readBackfillLog } from '../backfill.js';
52
53
  import { resolveModelsPath } from '../models-path.js';
53
54
  import { executeReconcile, buildReconcileReport, type ReconcileRun } from './db-reconcile.js';
@@ -113,26 +114,27 @@ export interface SyncRun {
113
114
  */
114
115
  export async function executeSync(
115
116
  runner: QueryRunner,
117
+ session: SessionRunner,
116
118
  models: ModelDescriptor[],
117
119
  options: SyncOptions = {},
118
120
  hooks: SyncHooks = {},
119
121
  ): Promise<SyncRun> {
120
- const current = await introspectSchema(runner);
121
- const liveAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
122
+ const current = await introspectSchema(session);
123
+ const liveAuthz = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
122
124
  const statements = generateMigrationSql(models, current, {
123
125
  allowDrops: options.allowDrops, liveAuthz, sequences: options.sequences,
124
126
  governedRoles: options.governedRoles,
125
127
  });
126
128
  hooks.onStatePlan?.(statements, classifyGeneratedStatements(statements));
127
129
 
128
- const state = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
130
+ const state = await applyStateAndVerify(runner, session, models, statements, current, liveAuthz, {
129
131
  allowDrops: options.allowDrops, sequences: options.sequences,
130
132
  actor: options.actor, gitRef: options.gitRef, now: options.now,
131
133
  });
132
134
  hooks.onStateDone?.(state);
133
135
 
134
136
  // The declared derived layer is the one compute stream; nothing declared = skipped.
135
- const compute = !options.declared?.length ? null : await executeReconcile(runner, {
137
+ const compute = !options.declared?.length ? null : await executeReconcile(runner, session, {
136
138
  apply: true,
137
139
  baseline: options.baseline,
138
140
  rebaseline: options.rebaseline,
@@ -303,11 +305,12 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
303
305
  }
304
306
 
305
307
  step(connectingVia(dbSource));
306
- const { runner, end } = await createUrlRunner(dbSource.url);
308
+ const { runner, session, end } = await createUrlRunner(dbSource.url);
307
309
 
308
310
  try {
309
311
  const run = await executeSync(
310
312
  runner,
313
+ session,
311
314
  models,
312
315
  {
313
316
  declared: declaredDb?.objects,
@@ -107,10 +107,10 @@ export async function buildIntoDatabase(
107
107
  models: ModelDescriptor[],
108
108
  options: BuildOptions = {},
109
109
  ): Promise<BuildResult> {
110
- const { runner, end } = await createUrlRunner(url);
110
+ const { runner, session, end } = await createUrlRunner(url);
111
111
  try {
112
112
  const createdRoles = await ensureContractRoles(runner, models);
113
- const run = await executeSync(runner, models, {
113
+ const run = await executeSync(runner, session, models, {
114
114
  declared: options.declared,
115
115
  sequences: options.sequences,
116
116
  actor: options.actor ?? 'db-build',
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import type { QueryRunner } from './authz-contract.js';
28
+ import { buildSearchPathSql, type SessionRunner, type SessionResult } from './session.js';
28
29
 
29
30
  export type DbSource =
30
31
  | { kind: 'url'; url: string; from: 'flag' | 'env' | 'admin-env' | 'operator' }
@@ -65,10 +66,61 @@ export function connectingVia(source: Extract<DbSource, { kind: 'url' }>): strin
65
66
 
66
67
  export interface UrlRunner {
67
68
  runner: QueryRunner;
69
+ /**
70
+ * The direct lane's `SessionRunner` — N statements in ONE postgres.js transaction on
71
+ * the single connection. The stage lane's twin is `lambdaSessionRunner` (one invoke,
72
+ * one container); both honor the same ordering, `SET LOCAL` and `allowFailure` rules,
73
+ * so a caller typed against `SessionRunner` reads identically on either venue.
74
+ */
75
+ session: SessionRunner;
68
76
  /** Close the client so the process can exit cleanly. */
69
77
  end: () => Promise<void>;
70
78
  }
71
79
 
80
+ /**
81
+ * Build a `SessionRunner` over a postgres.js client whose pool is a single connection.
82
+ *
83
+ * `sql.begin` holds that one connection for the whole callback, so every statement here
84
+ * shares a session by construction. The `searchPath` pin is `SET LOCAL` (it reverts with
85
+ * the transaction, never outliving its command) and consumes no result slot.
86
+ *
87
+ * An `allowFailure` statement rides `tx.savepoint`, the DRIVER's savepoint — not a
88
+ * hand-issued `SAVEPOINT` / `ROLLBACK TO SAVEPOINT` pair. postgres.js records any query
89
+ * error raised inside a transaction scope and re-throws it after the callback returns, so
90
+ * catching the error ourselves recovers the database and still loses the session. Its
91
+ * savepoint opens a nested scope with its own error bookkeeping, which is the only shape
92
+ * that survives. (Proven on a live PostgreSQL: the hand-rolled version passed against a
93
+ * fake and failed against the driver.)
94
+ */
95
+ export function sessionRunnerOver(sql: any): SessionRunner {
96
+ return async (statements, opts) => {
97
+ const stmts = statements.map((s) => (typeof s === 'string' ? { sql: s } : s));
98
+ const results: SessionResult[] = [];
99
+ await sql.begin(async (tx: any) => {
100
+ if (opts?.isolation) await tx.unsafe(`SET TRANSACTION ISOLATION LEVEL ${opts.isolation}`);
101
+ if (opts?.readOnly) await tx.unsafe('SET TRANSACTION READ ONLY');
102
+ if (opts?.searchPath !== undefined) {
103
+ await tx.unsafe(buildSearchPathSql(String(opts.searchPath)));
104
+ }
105
+ for (const stmt of stmts) {
106
+ if (!stmt.allowFailure) {
107
+ results.push(Array.from(await tx.unsafe(stmt.sql)));
108
+ continue;
109
+ }
110
+ try {
111
+ results.push(
112
+ await tx.savepoint(async (sp: any) => Array.from(await sp.unsafe(stmt.sql))),
113
+ );
114
+ } catch (err: any) {
115
+ results.push({ everystackSessionError: true, message: err?.message || String(err) });
116
+ }
117
+ }
118
+ });
119
+ return results;
120
+ };
121
+ }
122
+
123
+
72
124
  /**
73
125
  * Load the postgres.js driver, or explain how to get it. Shared by every direct-connection
74
126
  * runner below so the missing-driver instructions can never drift between them.
@@ -101,6 +153,7 @@ export async function createUrlRunner(
101
153
  const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => {}, ...sslDefaults(url) });
102
154
  return {
103
155
  runner: async (query: string) => Array.from(await sql.unsafe(query)),
156
+ session: sessionRunnerOver(sql),
104
157
  end: () => sql.end({ timeout: 5 }),
105
158
  };
106
159
  }
@@ -135,6 +188,8 @@ export async function createUrlPipelineRunner(
135
188
  export interface UrlProbeRunner {
136
189
  /** Read-only introspection, for the contract pull/diff. */
137
190
  runner: QueryRunner;
191
+ /** N statements on this one connection in one transaction — see sessionRunnerOver. */
192
+ session: SessionRunner;
138
193
  /** The self-reverting red-team probe. See `probe` below. */
139
194
  probe: (setup: string, read: string) => Promise<any[]>;
140
195
  /** Close the client so the process can exit cleanly. */
@@ -166,6 +221,7 @@ export async function createUrlProbeRunner(
166
221
  const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => {}, ...sslDefaults(url) });
167
222
  return {
168
223
  runner: async (query: string) => Array.from(await sql.unsafe(query)),
224
+ session: sessionRunnerOver(sql),
169
225
  probe: async (setup: string, read: string) => {
170
226
  let rows: any[] = [];
171
227
  try {
@@ -20,7 +20,7 @@
20
20
  import { createHash } from 'node:crypto';
21
21
  import { IGNORED_SCHEMAS, type QueryRunner } from './authz-contract.js';
22
22
  import { normalizeSql, type DerivedKind } from './derived-source.js';
23
- import { withCanonicalSearchPath } from './search-path.js';
23
+ import { INTROSPECTION_SESSION, rowsOrEmpty, type SessionRunner } from './session.js';
24
24
 
25
25
  export interface LiveObject {
26
26
  kind: DerivedKind;
@@ -591,30 +591,31 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
591
591
  * injected runner; a missing provenance table (first run against a database
592
592
  * the reconciler has never touched) folds to an empty claims list.
593
593
  */
594
- export async function introspectDerived(run: QueryRunner): Promise<DerivedCatalog> {
595
- // View/matview/function bodies deparse relative to the search_path (`pg_get_viewdef`
596
- // renders a ref bare while its schema is in scope, qualified once off). Pin the
597
- // canonical baseline so the recorded def hash is stable across runs and paths this
598
- // also subsumes the reconcile apply's pre-introspection reset. See search-path.ts.
599
- return withCanonicalSearchPath(run, async () => {
600
- const [relations, functions, indexes, depends, triggers, grants] = await Promise.all([
601
- run(DERIVED_RELATIONS_SQL), run(DERIVED_FUNCTIONS_SQL), run(MATVIEW_INDEXES_SQL), run(DERIVED_DEPENDS_SQL),
602
- run(DERIVED_TRIGGERS_SQL), run(DERIVED_GRANTS_SQL),
603
- ]);
604
- let provenance: ProvenanceRawRow[] = [];
605
- try {
606
- provenance = (await run(PROVENANCE_SQL)) as ProvenanceRawRow[];
607
- } catch {
608
- // everystack.derived_provenance does not exist yet — nothing has been reconciled.
609
- }
610
- return assembleDerivedCatalog({
611
- relations: relations as RelationRow[],
612
- functions: functions as FunctionRow[],
613
- indexes: indexes as IndexDefRow[],
614
- depends: depends as DependsRow[],
615
- triggers: triggers as TriggerRow[],
616
- grants: grants as GrantAclRow[],
617
- provenance,
618
- });
594
+ export async function introspectDerived(session: SessionRunner): Promise<DerivedCatalog> {
595
+ // ONE session: view/matview/function bodies deparse relative to the search_path
596
+ // (`pg_get_viewdef` renders a ref bare while its schema is in scope, qualified once off),
597
+ // so the recorded def hash is only stable if every body is captured under one pinned path
598
+ // at one moment.
599
+ //
600
+ // The provenance read is `allowFailure`: `everystack.derived_provenance` does not exist
601
+ // until something has been reconciled, and its absence is an ANSWER (no claims), not a
602
+ // failure. Before the session it was a try/catch, which is the same intent expressed the
603
+ // only way a one-statement-at-a-time runner could — by being willing to lose the session.
604
+ const [relations, functions, indexes, depends, triggers, grants, provenance] = await session(
605
+ [
606
+ DERIVED_RELATIONS_SQL, DERIVED_FUNCTIONS_SQL, MATVIEW_INDEXES_SQL, DERIVED_DEPENDS_SQL,
607
+ DERIVED_TRIGGERS_SQL, DERIVED_GRANTS_SQL,
608
+ { sql: PROVENANCE_SQL, allowFailure: true },
609
+ ],
610
+ INTROSPECTION_SESSION,
611
+ );
612
+ return assembleDerivedCatalog({
613
+ relations: relations as RelationRow[],
614
+ functions: functions as FunctionRow[],
615
+ indexes: indexes as IndexDefRow[],
616
+ depends: depends as DependsRow[],
617
+ triggers: triggers as TriggerRow[],
618
+ grants: grants as GrantAclRow[],
619
+ provenance: rowsOrEmpty(provenance) as ProvenanceRawRow[],
619
620
  });
620
621
  }
@@ -26,9 +26,10 @@
26
26
  * grant.
27
27
  */
28
28
 
29
+ import { isColumnAbility } from '@everystack/model';
29
30
  import type {
30
31
  ModelDescriptor, DerivedDescriptor, ViewDescriptor, MaterializedViewDescriptor,
31
- FunctionDescriptor, DependsOnRef, Ability,
32
+ FunctionDescriptor, DependsOnRef,
32
33
  } from '@everystack/model';
33
34
  import { parseQualified } from './derived-source.js';
34
35
 
@@ -47,11 +48,6 @@ function isModelRef(ref: DependsOnRef): ref is ModelDescriptor {
47
48
  return 'table' in ref;
48
49
  }
49
50
 
50
- /** The column-scoped self read — same predicate as the table grant compiler. */
51
- function isColumnRead(a: Ability): boolean {
52
- return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
53
- }
54
-
55
51
  /**
56
52
  * Does the table's grant compile give `role` SELECT (full or column-scoped)?
57
53
  *
@@ -65,8 +61,11 @@ function isColumnRead(a: Ability): boolean {
65
61
  */
66
62
  export function tableReaches(m: ModelDescriptor, role: string): boolean {
67
63
  for (const a of m.abilities) {
68
- if (isColumnRead(a)) {
69
- if ((a.condition.role ?? 'authenticated') === role) return true;
64
+ if (isColumnAbility(a)) {
65
+ // Only the READ half reaches: a column-scoped update compiles to `UPDATE (cols)`
66
+ // and no SELECT, so counting it as reach would pass an invoker view the database
67
+ // then refuses at runtime — the false-negative this gate's docstring warns about.
68
+ if (a.action === 'read' && (a.condition.role ?? 'authenticated') === role) return true;
70
69
  continue;
71
70
  }
72
71
  if (a.action !== 'read' && a.action !== 'manage') continue;
@@ -31,7 +31,7 @@ import type { AuthzContract } from './authz-contract.js';
31
31
  import { generateMigrationSql, unmodeledTables } from './migration-generate.js';
32
32
  import { compileTableContract } from './authz-compile.js';
33
33
  import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply.js';
34
- import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
34
+ import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, FINGERPRINT_VERSION } from './schema-fingerprint.js';
35
35
  import { compileDeclaredState } from './declared-diff.js';
36
36
  import { compileTableRenames, compileTableMoves } from './schema-compile.js';
37
37
 
@@ -61,8 +61,27 @@ export interface EdgePlan {
61
61
  v: number;
62
62
  /** The target's exact live fingerprint at mint time — the lock. */
63
63
  fromFingerprint: string;
64
- /** The predicted live fingerprint after apply — exact, unmodeled-aware. */
64
+ /**
65
+ * The FINGERPRINT_VERSION the endpoints were computed under. Stored beside the hashes so a
66
+ * plan minted under an older canonical form REFUSES as a format change rather than reporting
67
+ * drift that no edit could explain. Absent on plans minted before v4.
68
+ */
69
+ fpVersion?: number;
70
+ /**
71
+ * The predicted live fingerprint after apply — exact, unmodeled-aware, and
72
+ * GOVERNED: hashed through the models' governed-role set (`governedRoles`
73
+ * below), because it is a declared-vs-live claim. Compare it only against
74
+ * `governedLiveFingerprint` computed with the same set — never against the
75
+ * raw `from` flavor.
76
+ */
65
77
  toFingerprint: string;
78
+ /**
79
+ * The governed-role set `to` was hashed under, sorted — carried on the plan
80
+ * so every apply venue verifies with the exact set the mint used. Absent on
81
+ * plans minted before this field existed (those predate the governed `to`
82
+ * and verify raw, as they were minted).
83
+ */
84
+ governedRoles?: string[];
66
85
  /** The models-only fingerprint — context for the strong claim. Equals `to` when nothing is unmodeled. */
67
86
  declaredFingerprint: string;
68
87
  /** The edge, in db:generate's statement grammar (no held drops — mint refuses them). */
@@ -72,6 +91,21 @@ export interface EdgePlan {
72
91
  destructive: number;
73
92
  classification: PlanClassification;
74
93
  notices: number;
94
+ /**
95
+ * WHY each authorization statement exists, one entry per statement — the per-statement half
96
+ * of the aggregate the summary prints.
97
+ *
98
+ * Optional because it is re-derived against LIVE at mint time, so a plan minted without a
99
+ * reachable database (or before this field existed) simply has none. Never trusted as a
100
+ * stored claim: like the counts, it describes the target as it was when the plan was cut.
101
+ */
102
+ adoption?: { table: string; subject: string; cls: string; why: string }[];
103
+ /**
104
+ * Tables this plan leaves with no surviving read path — resolved at mint against the LIVE
105
+ * contract, which is the only moment both halves of that question are in hand. Absent on
106
+ * plans minted before this field existed; the summary falls back to the text-only reading.
107
+ */
108
+ dark?: string[];
75
109
  /** Live tables no model declares — untouched by the edge, riding into `to` as-is. */
76
110
  unmodeled: string[];
77
111
  gitRef: string | null;
@@ -108,8 +142,14 @@ export function predictLiveFingerprint(
108
142
  models: ModelDescriptor[],
109
143
  snapshot: SchemaSnapshot,
110
144
  contract: AuthzContract,
111
- opts: { schema?: string } = {},
145
+ opts: { schema?: string; governedRoles?: ReadonlySet<string> } = {},
112
146
  ): string {
147
+ // The prediction is a DECLARED-vs-live comparison, so it must hash through the
148
+ // models' governed set — the live side of any comparison against it must use
149
+ // the same set (see governedLiveFingerprint). Unfiltered, every ride-through
150
+ // table's foreign grants poison the endpoint and verify-after can never pass
151
+ // on a brownfield target.
152
+ const governedRoles = opts.governedRoles ?? governedRolesForModels(models);
113
153
  const declared = compileDeclaredState(models, { schema: opts.schema });
114
154
  const declaredTables = new Set(declared.snapshot.tables.map((t) => t.table));
115
155
  const currentNames = new Set(snapshot.tables.map((t) => t.table));
@@ -144,7 +184,21 @@ export function predictLiveFingerprint(
144
184
  ...declared.contract.tables,
145
185
  ...contract.tables.filter((t) => ridesThrough(t.table)),
146
186
  ];
147
- return fingerprintState(mergedSnapshot, mergedAuthz).hash;
187
+ return fingerprintState(mergedSnapshot, mergedAuthz, { governedRoles }).hash;
188
+ }
189
+
190
+ /**
191
+ * The live half of every declared-vs-live comparison: the target's state hashed
192
+ * through the SAME governed set as the prediction. `predicted === governedLive`
193
+ * is the declares-test; comparing a prediction against an UNFILTERED live hash
194
+ * is a category error that can never converge on a brownfield database.
195
+ */
196
+ export function governedLiveFingerprint(
197
+ snapshot: SchemaSnapshot,
198
+ contract: AuthzContract,
199
+ governedRoles: ReadonlySet<string>,
200
+ ): string {
201
+ return fingerprintState(snapshot, contract.tables, { governedRoles }).hash;
148
202
  }
149
203
 
150
204
  /**
@@ -187,11 +241,21 @@ export function mintEdgePlan(
187
241
  const breakdown = partitionStatements(classified.executable, { declaredGrantees });
188
242
  const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
189
243
 
244
+ // The plan's two endpoints are two different FLAVORS, deliberately:
245
+ // - `from` is the RAW live hash (everything introspected) — the concurrency
246
+ // lock and the backup-stamp chain (backup.fp == plan.from) compare
247
+ // live-vs-live and must stay computable with no models in hand.
248
+ // - `to` is the GOVERNED hash — a declared-vs-live claim, filtered through
249
+ // the models' governed set, which rides ON the plan so every venue
250
+ // (direct, ops Lambda) verifies with the set the mint used.
251
+ const governedRoles = governedRolesForModels(models, opts.governedRoles);
190
252
  return {
191
253
  v: PLAN_VERSION,
192
254
  fromFingerprint: fingerprintLive(snapshot, contract).hash,
193
- toFingerprint: predictLiveFingerprint(models, snapshot, contract, { schema: opts.schema }),
255
+ fpVersion: FINGERPRINT_VERSION,
256
+ toFingerprint: predictLiveFingerprint(models, snapshot, contract, { schema: opts.schema, governedRoles }),
194
257
  declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
258
+ governedRoles: [...governedRoles].sort(),
195
259
  statements,
196
260
  executable: classified.executable.length,
197
261
  destructive,
@@ -206,6 +270,10 @@ export function mintEdgePlan(
206
270
  ...(breakdown.unclassified.length > 0 ? { unclassified: breakdown.unclassified.length } : {}),
207
271
  },
208
272
  notices: classified.notices.length,
273
+ // Resolved HERE because this is the only place that holds both the edge and the live
274
+ // contract. The summary is rendered later from the plan alone, and answering "does a read
275
+ // survive?" from statement text is what produced three false positives on a real plan.
276
+ dark: tablesLeftWithoutARead(statements, contract),
209
277
  unmodeled: unmodeledTables(models, snapshot),
210
278
  gitRef: opts.gitRef ?? null,
211
279
  mintedBy: opts.actor ?? null,
@@ -220,25 +288,58 @@ export function mintEdgePlan(
220
288
  * enabled and the grant is still there, so the table returns ZERO rows to that role. On a real
221
289
  * adoption plan that was 11 tables, including the users table, and it printed no notice at all.
222
290
  *
223
- * Deliberately conservative: it only names a table when the plan drops a read-admitting policy
224
- * and adds none back for that table. It cannot know what other policies exist live, so it
225
- * under-reports rather than crying wolf.
291
+ * Deliberately conservative: it names a table only when NO read path survives the plan for any
292
+ * role, so it under-reports rather than crying wolf. Pass the LIVE contract to get that
293
+ * question answered properly without it, a `DROP POLICY` statement does not say which
294
+ * command it policed and the fallback can only count drops.
226
295
  */
227
- export function tablesLeftWithoutARead(statements: readonly string[]): string[] {
228
- const dropped = new Map<string, number>();
229
- const created = new Set<string>();
296
+ export function tablesLeftWithoutARead(statements: readonly string[], live?: AuthzContract): string[] {
297
+ const droppedByTable = new Map<string, Set<string>>();
298
+ const createdRead = new Set<string>();
299
+ const touched = new Set<string>();
300
+
230
301
  for (const statement of statements) {
231
302
  const head = (statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? '').trim();
232
- let m = /^DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?\S+\s+ON\s+(\S+?);?$/i.exec(head);
303
+ let m = /^DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?(\S+)\s+ON\s+(\S+?);?$/i.exec(head);
233
304
  if (m) {
234
- dropped.set(m[1], (dropped.get(m[1]) ?? 0) + 1);
305
+ touched.add(m[2]);
306
+ const names = droppedByTable.get(m[2]) ?? new Set<string>();
307
+ names.add(m[1]);
308
+ droppedByTable.set(m[2], names);
235
309
  continue;
236
310
  }
237
311
  m = /^CREATE\s+POLICY\s+\S+\s+ON\s+(\S+)/i.exec(head);
238
312
  // Only a SELECT-admitting policy restores a read; an INSERT-only policy does not.
239
- if (m && /\bFOR\s+(SELECT|ALL)\b/i.test(head)) created.add(m[1]);
313
+ if (m && /\bFOR\s+(SELECT|ALL)\b/i.test(head)) createdRead.add(m[1]);
314
+ }
315
+
316
+ // Without the live contract this can only count drops, and a DROP POLICY statement does not
317
+ // carry the command it policed. That is the old behaviour, kept for plans minted before the
318
+ // contract was threaded through: it OVER-reports, naming a table whose dropped policy was an
319
+ // UPDATE and whose reads were never touched.
320
+ if (!live) return [...droppedByTable.keys()].filter((t) => !createdRead.has(t)).sort();
321
+
322
+ // With it, ask the real question: does ANY policy admitting SELECT survive this plan? A
323
+ // table is dark only when none does — not merely because some policy on it was dropped.
324
+ //
325
+ // Three false positives on a real adoption plan came from the cheaper question: two tables
326
+ // lost only an UPDATE policy, and one lost a single role-scoped read while anon and
327
+ // authenticated reads survived untouched. A safety detector that cries wolf spends the
328
+ // credibility of its true positives, so this deliberately under-reports instead.
329
+ const byTable = new Map(live.tables.map((t) => [t.table, t]));
330
+ const dark: string[] = [];
331
+ for (const table of touched) {
332
+ if (createdRead.has(table)) continue;
333
+ const contract = byTable.get(table);
334
+ if (!contract) continue; // not a live table — nothing to go dark
335
+ if (!contract.rls?.enabled) continue; // RLS off: policies do not gate the read at all
336
+ const dropped = droppedByTable.get(table) ?? new Set<string>();
337
+ const readSurvives = contract.policies.some(
338
+ (p) => (p.command === 'SELECT' || p.command === 'ALL') && !dropped.has(p.name),
339
+ );
340
+ if (!readSurvives) dark.push(table);
240
341
  }
241
- return [...dropped.keys()].filter((t) => !created.has(t)).sort();
342
+ return dark.sort();
242
343
  }
243
344
 
244
345
  /** The plan's content address — recorded as `plan_ref` on the schema_log row. */
@@ -250,7 +351,12 @@ export type PlanPrecondition = { ok: true } | { ok: false; reason: string };
250
351
 
251
352
  /** The lock: apply only when the target is exactly where the plan started. */
252
353
  export function checkPlanPrecondition(plan: EdgePlan, liveFingerprint: string): PlanPrecondition {
253
- if (liveFingerprint === plan.fromFingerprint) return { ok: true };
354
+ const cmp = compareStoredFingerprint({ hash: plan.fromFingerprint, v: plan.fpVersion }, liveFingerprint);
355
+ if (cmp.kind === 'match') return { ok: true };
356
+ // A plan minted under an older canonical form cannot be compared at all. Saying "the state
357
+ // moved (a concurrent apply or a hand edit)" would send the operator hunting a change that
358
+ // never happened — plans are ephemeral by design, so the honest answer is re-mint.
359
+ if (cmp.kind === 'format-changed') return { ok: false, reason: formatChangedMessage(cmp) };
254
360
  return {
255
361
  ok: false,
256
362
  reason:
@@ -287,7 +393,9 @@ export function buildPlanSummary(plan: EdgePlan): string[] {
287
393
  // rows, and a plan that drops a table's only read policy leaves that table returning nothing.
288
394
  // 11 tables went dark in a real adoption plan that printed "0 notice(s)".
289
395
  const { authzRemovals, unclassified } = partitionStatements(classifyGeneratedStatements(plan.statements).executable);
290
- const dark = tablesLeftWithoutARead(plan.statements);
396
+ // Prefer what the mint resolved against live. Recomputing here would have only the statement
397
+ // text and would re-introduce the false positives the mint-time answer exists to avoid.
398
+ const dark = plan.dark ?? tablesLeftWithoutARead(plan.statements);
291
399
  if (authzRemovals.length > 0) {
292
400
  lines.push(`! ${authzRemovals.length} statement(s) REMOVE authorization (policies, grants, RLS). No data is lost; who can read it changes.`);
293
401
  if (dark.length > 0) {