@everystack/cli 0.4.45 → 0.4.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/cli/alter-type-dependents.ts +96 -0
  3. package/src/cli/apply-execute.ts +22 -8
  4. package/src/cli/authz-adoption-class.ts +75 -26
  5. package/src/cli/authz-canonical.ts +37 -5
  6. package/src/cli/authz-compile.ts +87 -37
  7. package/src/cli/authz-contract.ts +92 -19
  8. package/src/cli/authz-derive.ts +158 -33
  9. package/src/cli/authz-reconcile.ts +48 -6
  10. package/src/cli/aws.ts +32 -0
  11. package/src/cli/commands/db-apply.ts +82 -20
  12. package/src/cli/commands/db-authz.ts +9 -14
  13. package/src/cli/commands/db-backfill.ts +1 -1
  14. package/src/cli/commands/db-exec.ts +20 -1
  15. package/src/cli/commands/db-fingerprint.ts +54 -18
  16. package/src/cli/commands/db-generate.ts +11 -17
  17. package/src/cli/commands/db-plan.ts +89 -9
  18. package/src/cli/commands/db-pull.ts +16 -18
  19. package/src/cli/commands/db-reconcile.ts +19 -21
  20. package/src/cli/commands/db-refresh.ts +33 -5
  21. package/src/cli/commands/db-swap.ts +5 -4
  22. package/src/cli/commands/db-sync.ts +8 -5
  23. package/src/cli/commands/db.ts +2 -1
  24. package/src/cli/db-build.ts +2 -2
  25. package/src/cli/db-source.ts +56 -0
  26. package/src/cli/derived-introspect.ts +27 -26
  27. package/src/cli/derived-lint.ts +7 -8
  28. package/src/cli/edge-plan.ts +112 -16
  29. package/src/cli/exec-digest.ts +55 -13
  30. package/src/cli/git-descent.ts +16 -9
  31. package/src/cli/index.ts +3 -3
  32. package/src/cli/model-render.ts +56 -50
  33. package/src/cli/schema-compile.ts +6 -1
  34. package/src/cli/schema-diff.ts +1 -1
  35. package/src/cli/schema-fingerprint.ts +67 -7
  36. package/src/cli/schema-introspect.ts +44 -17
  37. package/src/cli/schema-source.ts +9 -0
  38. package/src/cli/session.ts +184 -0
  39. package/src/cli/stage-read-consistency.ts +145 -0
  40. package/src/cli/state-apply.ts +4 -2
  41. package/src/cli/swap-execute.ts +4 -3
  42. package/src/cli/search-path.ts +0 -51
@@ -18,14 +18,19 @@
18
18
  * is exact even on brownfield targets (mint predicted it with the
19
19
  * unmodeled tables merged in). A mismatch here is a real fault, loud.
20
20
  *
21
- * Needs a direct connection (--database-url / DATABASE_URL): apply writes.
21
+ * Two lanes. DIRECT (--database-url / --stage --direct) executes CLI-side over
22
+ * one session. STAGE (--stage) executes credential-free in the ops Lambda —
23
+ * and reads over `lambdaQueryRunner`, one Lambda invoke per catalog query, so a
24
+ * single introspection can be assembled from several containers holding
25
+ * connections to different databases. That lane therefore reads the stage TWICE
26
+ * and refuses a disagreement, reports agreement as a non-detection (it cannot
27
+ * verify read consistency), and refuses a DESTRUCTIVE plan outright — losing
28
+ * data on a read that cannot be verified is not a trade the ceremony can buy.
29
+ * See stage-read-consistency.ts.
22
30
  */
23
31
 
24
32
  import fs from 'node:fs/promises';
25
33
  import { type QueryRunner } from '../authz-contract.js';
26
- import { introspectSchema } from '../schema-introspect.js';
27
- import { introspectContract } from '../authz-contract.js';
28
- import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
29
34
  import { currentGitRef } from '../state-apply.js';
30
35
  import { planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
31
36
  import { verifyDescent, type DescentVerdict, type LiveState } from '../git-descent.js';
@@ -37,12 +42,14 @@ import { compileTableContract } from '../authz-compile.js';
37
42
  import { governedRoleSet, ungovernedGrants } from '../authz-reconcile.js';
38
43
  import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline.js';
39
44
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
45
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
40
46
  import { resolveConfig, opsFunction } from '../config.js';
41
- import { invokeAction, lambdaQueryRunner } from '../aws.js';
47
+ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
42
48
  import { executeApplyPlan, type ApplyPlanResult } from '../apply-execute.js';
43
49
  import { estimateOpsRuntimeFit } from '../ops-fit.js';
44
50
  import { resolveOperatorUrlViaStage } from '../direct-venue.js';
45
51
  import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
52
+ import { readStageStateTwice, stageDestructiveRefusal } from '../stage-read-consistency.js';
46
53
  import { step, success, fail, info, warn } from '../output.js';
47
54
 
48
55
  // The apply core lives in apply-execute.ts (shared with the ops Lambda's
@@ -128,20 +135,34 @@ async function applyPlanViaStage(
128
135
 
129
136
  info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
130
137
 
138
+ // THE STAGE LANE'S READ GUARD, before anything is dispatched. Each introspection is
139
+ // one session, but a full read is two of them (state, then authz) and they can be
140
+ // answered by two containers at two moments. Read the stage twice and refuse a
141
+ // disagreement — the apply never starts. Deliberately OUTSIDE the try below: this
142
+ // refusal must exit as itself, not as "Apply failed".
143
+ step('Reading the stage TWICE (state and authz are separate ops-Lambda invokes)...');
144
+ const readSession = lambdaSessionRunner(region, fn);
145
+ const readPair = await readStageStateTwice(readSession).catch((err: any) => {
146
+ fail(`Apply failed: could not read the stage — ${err.message}`);
147
+ process.exit(1);
148
+ });
149
+ if (!readPair.ok) {
150
+ fail(readPair.reason);
151
+ process.exit(1);
152
+ }
153
+ warn(readPair.warning);
154
+
131
155
  try {
132
156
  // Descent — the fast-forward rule needs the git checkout, so the CLI
133
- // decides it (the Lambda has no checkout). Read the stage's state read-only
134
- // via the ops Lambda's db:query action, run the rule locally, and hand the
135
- // verdict to the write action, which enforces + records it in order.
157
+ // decides it (the Lambda has no checkout). It runs against the state the
158
+ // double read already produced (no third trip), and the verdict goes to the
159
+ // write action, which enforces + records it in order.
136
160
  let descentVerdict: { ok: true } | { ok: false; reason: string };
137
161
  if (forceDescent !== undefined) {
138
162
  warn(`DESCENT FORCED — the fast-forward rule is bypassed for this apply. Snapshot on record: ${forceDescent}.`);
139
163
  descentVerdict = { ok: true };
140
164
  } else {
141
- step('Asking the stage its state (read-only via the ops Lambda)...');
142
- const readRunner = lambdaQueryRunner(region, fn);
143
- const snapshot = await introspectSchema(readRunner);
144
- const contract = await introspectContract(readRunner, contractFunctionRow, FUNCTIONS_SQL);
165
+ const { snapshot, contract } = readPair.state;
145
166
  step("Descent: searching git for the commit that declares the stage's state...");
146
167
  const verdict = await verifyDescent(resolveModelsPath(flags.models), { snapshot, contract }, {});
147
168
  switch (verdict.status) {
@@ -166,6 +187,11 @@ async function applyPlanViaStage(
166
187
  // and checks it against the stage's declared approver set (SSM). The safety
167
188
  // net is a VERIFIED backup: the Lambda auto-resolves the stage's newest (or
168
189
  // the id in --snapshot-ref) and refuses unless it covers this plan. --confirm always.
190
+ //
191
+ // CURRENTLY UNREACHABLE: dbApplyCommand refuses every destructive plan on the
192
+ // stage lane before it gets here (read consistency cannot be guaranteed on this
193
+ // lane). Kept intact and second in line, so lifting that refusal restores the
194
+ // full ceremony rather than silently shipping without one.
169
195
  let authorityVerdict: AuthorityVerdict | undefined;
170
196
  if (plan.destructive > 0) {
171
197
  const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
@@ -272,6 +298,16 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
272
298
  // Deployed stage, no direct URL: the write runs in the ops Lambda — the
273
299
  // operator never holds a database URL. --database-url stays local-only.
274
300
  if (dbSource.kind === 'stage') {
301
+ // A DESTRUCTIVE plan does not run on this lane at all — no ceremony lifts it.
302
+ // The stage lane's fingerprint (the concurrency lock) can be assembled from
303
+ // several ops-Lambda containers, and the double read below only DETECTS that;
304
+ // it cannot rule it out. Dropping data on a read that may be a collage is the
305
+ // one trade never worth making, so the destructive path is the direct lane's.
306
+ if (plan.destructive > 0) {
307
+ const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
308
+ fail(stageDestructiveRefusal(shape, planPath, flags.stage));
309
+ process.exit(1);
310
+ }
275
311
  // Will this edge fit the ops-Lambda's 900-second clock? If not, refuse up front and
276
312
  // name --direct (credential-free, unbounded) instead of burning 15 minutes to learn it.
277
313
  const fit = estimateOpsRuntimeFit(plan);
@@ -295,13 +331,29 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
295
331
  fail(`This plan is DESTRUCTIVE — ${plan.destructive} statement(s) lose data (${shape}). Explicit confirmation is required, always: re-run with --confirm.`);
296
332
  process.exit(1);
297
333
  }
298
- // A DIRECT apply is operator-attested: the operator holds the URL, so the safety net is a backup
299
- // they took and NAME here. (The credential-free path db:apply --plan --stage <name>, WITHOUT
300
- // --direct — auto-resolves and verifies the stage's latest backup server-side; a direct/bare
301
- // connection has no ops Lambda to verify against, so the ref is required and attested.)
334
+ // A DIRECT apply is operator-attested: the safety net is a backup the operator took and
335
+ // NAMES here, because this lane has no ops Lambda verifying one server-side.
336
+ //
337
+ // This used to end by offering "or drop --direct and let the credential-free --stage apply
338
+ // auto-verify your latest backup". THAT ADVICE CANNOT BE FOLLOWED. Auto-verification is
339
+ // real, but only for NON-destructive stage applies — and every plan reaching this block is
340
+ // destructive, so dropping --direct lands on the outright refusal above (the `--stage`
341
+ // destructive gate). It sent operators from a lane that works to one that refuses, and the
342
+ // refusal's own text then pointed them at --database-url. Two wrong signposts in a row are
343
+ // how a consumer ended up printing a privileged DSN to do something --direct already did.
344
+ //
345
+ // For a destructive plan there is exactly one remedy: take a safety point and name it.
302
346
  if (!flags['snapshot-ref']) {
303
- const takeIt = `everystack db:backup${flags.stage ? ` --stage ${flags.stage}` : ''}`;
304
- fail(`This plan is DESTRUCTIVE (${shape}) over a direct connection — the apply does not snapshot for you. Take a safety point (${takeIt}) and name it: --snapshot-ref <id>. Or drop --direct and let the credential-free --stage apply auto-verify your latest backup.`);
347
+ const stageArg = flags.stage ? ` --stage ${flags.stage}` : '';
348
+ const takeIt = `everystack db:backup${stageArg}`;
349
+ const lane = flags.stage
350
+ ? `${stageArg.trim()} --direct`
351
+ : '--database-url <url>';
352
+ fail(
353
+ `This plan is DESTRUCTIVE (${shape}) over a direct connection — the apply does not snapshot for you. `
354
+ + `Take a safety point (${takeIt}) and name it: everystack db:apply --plan ${planPath} ${lane} --confirm --snapshot-ref <id>. `
355
+ + 'A destructive plan always requires an attested --snapshot-ref; there is no lane that takes one for you.',
356
+ );
305
357
  process.exit(1);
306
358
  }
307
359
  if (flags.stage) {
@@ -328,7 +380,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
328
380
  }
329
381
 
330
382
  step(connectingVia(dbSource));
331
- const { runner, end } = await createUrlRunner(dbSource.url);
383
+ const { runner, session, end } = await createUrlRunner(dbSource.url);
332
384
 
333
385
  try {
334
386
  info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
@@ -342,7 +394,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
342
394
  const result = await withMutationLease(
343
395
  runner,
344
396
  { verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
345
- () => executeApplyPlan(runner, plan, {
397
+ () => executeApplyPlan(runner, session, plan, {
346
398
  actor: process.env.USER ?? null,
347
399
  gitRef: currentGitRef() ?? plan.gitRef,
348
400
  ...(verifyAuthority ? { verifyAuthority } : {}),
@@ -371,6 +423,16 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
371
423
  info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
372
424
  return { ok: true };
373
425
  case 'no-git':
426
+ // A DESTRUCTIVE plan may not ride an accidental waiver: the fast-forward
427
+ // rule's verdict must never depend on which directory the operator ran
428
+ // from. Unverifiable + destructive ⇒ the waiver must be the deliberate
429
+ // ceremony (--force-descent <snapshot-ref> --confirm), not a warning.
430
+ if (plan.destructive > 0) {
431
+ return {
432
+ ok: false,
433
+ reason: 'descent: not a git checkout, and this plan is DESTRUCTIVE — the fast-forward rule cannot be verified here, so waive it deliberately: re-run with --force-descent <snapshot-ref> --confirm.',
434
+ };
435
+ }
374
436
  warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
375
437
  return { ok: true };
376
438
  case 'diverged':
@@ -57,22 +57,14 @@ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
57
57
  import { resolveConfig, opsFunction } from '../config.js';
58
58
  import { resolveModelsPath } from '../models-path.js';
59
59
  import { resolveDbSource, connectingVia, createUrlProbeRunner } from '../db-source.js';
60
- import { invokeAction } from '../aws.js';
60
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
61
+ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
61
62
  import { step, success, fail, info, warn } from '../output.js';
62
63
  import { opsAdviceLines, IAM_ADVICE } from '../ops-advice.js';
63
64
  import { loadModels } from './db-generate.js';
64
65
 
65
66
  const DEFAULT_DIR = 'authz';
66
67
 
67
- /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
68
- function lambdaRunner(region: string, fn: string): QueryRunner {
69
- return async (sql: string) => {
70
- const result: any = await invokeAction(region, fn, 'db:query', { sql });
71
- if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
72
- return result?.rows ?? [];
73
- };
74
- }
75
-
76
68
  /**
77
69
  * Where an authz command runs. Both venues expose the same two capabilities, so the
78
70
  * commands never branch on venue after this point — that is what keeps the local
@@ -81,6 +73,8 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
81
73
  interface AuthzVenue {
82
74
  /** Read-only introspection. */
83
75
  runner: QueryRunner;
76
+ /** The consistent multi-statement read — what every introspection takes. */
77
+ session: SessionRunner;
84
78
  /** Self-reverting red-team probe (writes, always rolled back). */
85
79
  probe: (setup: string, read: string) => Promise<any[]>;
86
80
  /** Human-readable venue, printed with every verdict. */
@@ -97,15 +91,16 @@ async function resolveVenue(flags: Record<string, string>): Promise<AuthzVenue>
97
91
  const source = resolveDbSource(flags);
98
92
  if (source.kind === 'url') {
99
93
  step(connectingVia(source));
100
- const { runner, probe, end } = await createUrlProbeRunner(source.url);
101
- return { runner, probe, end, label: 'direct connection' };
94
+ const { runner, session, probe, end } = await createUrlProbeRunner(source.url);
95
+ return { runner, session, probe, end, label: 'direct connection' };
102
96
  }
103
97
  step('Resolving deployed config...');
104
98
  const config = await resolveConfig(flags.stage);
105
99
  const fn = opsFunction(config);
106
100
  info(`Region: ${config.region}, Function: ${fn}`);
107
101
  return {
108
- runner: lambdaRunner(config.region, fn),
102
+ runner: lambdaQueryRunner(config.region, fn),
103
+ session: lambdaSessionRunner(config.region, fn),
109
104
  probe: async (setup: string, read: string) => {
110
105
  const result: any = await invokeAction(config.region, fn, 'db:authz:probe', { setup, read });
111
106
  if (result?.error) throw new Error(result.error);
@@ -120,7 +115,7 @@ async function introspectVenue(flags: Record<string, string>): Promise<{ contrac
120
115
  const venue = await resolveVenue(flags);
121
116
  try {
122
117
  step('Introspecting authorization (rls + grants + policies + secdef)...');
123
- return { contract: await introspectContract(venue.runner, contractFunctionRow, FUNCTIONS_SQL), label: venue.label };
118
+ return { contract: await introspectContract(venue.session, contractFunctionRow, FUNCTIONS_SQL), label: venue.label };
124
119
  } finally {
125
120
  await venue.end();
126
121
  }
@@ -87,7 +87,7 @@ async function runBackfillViaStage(
87
87
  if (res?.error) {
88
88
  fail(`db:backfill failed: ${res.error}`);
89
89
  if (/Unknown action/i.test(String(res.error))) {
90
- info('The deployed handler predates the db:backfill ops action. Upgrade @everystack/server, or run direct: db:backfill --apply --database-url <url> (or --stage --direct).');
90
+ info('The deployed handler predates the db:backfill ops action. Upgrade @everystack/server, or run credential-free over the direct lane: db:backfill --apply --stage ' + (flags.stage ?? '<stage>') + ' --direct. (--database-url <url> is the local venue — against a deployed stage it puts a privileged connection string on argv.)');
91
91
  }
92
92
  process.exit(1);
93
93
  }
@@ -31,7 +31,18 @@ async function readStdin(): Promise<string> {
31
31
  return Buffer.concat(chunks).toString('utf8');
32
32
  }
33
33
 
34
- /** The app schemas the digest guard covers on the direct path (--schemas a,b; default public). */
34
+ /**
35
+ * The schemas argument the digest query still ACCEPTS but no longer scopes by.
36
+ *
37
+ * `--schemas` never reached the stage path at all — the ops invoke sent only `{ sql, actor,
38
+ * stage }`, so a consumer who passed `--schemas auth,public` had it silently dropped and the
39
+ * server fell back to its own `options.schemas ?? ['public']`. Their 21 `auth.*` functions were
40
+ * outside the projection and the guard stayed silent on 43 schema statements.
41
+ *
42
+ * Rather than plumb the flag through, the digest now covers EVERY non-system schema
43
+ * (exec-digest.ts): a guard that can be scoped can be scoped to blindness. This is kept so the
44
+ * call sites read unchanged, and `--schemas` is now a documented no-op rather than a silent one.
45
+ */
35
46
  function directSchemas(flags: Record<string, string>): string[] {
36
47
  if (flags.schemas && flags.schemas !== 'true') {
37
48
  return flags.schemas.split(',').map((s) => s.trim()).filter(Boolean);
@@ -39,6 +50,13 @@ function directSchemas(flags: Record<string, string>): string[] {
39
50
  return ['public'];
40
51
  }
41
52
 
53
+ /** Say it, rather than accept a flag that does nothing. */
54
+ function noteSchemasIsNoLongerNeeded(flags: Record<string, string>): void {
55
+ if (flags.schemas && flags.schemas !== 'true') {
56
+ info('--schemas is no longer needed and no longer narrows anything: the DML-only guard now covers every non-system schema. (It used to default to `public`, which is how a file of auth.* functions once passed it.)');
57
+ }
58
+ }
59
+
42
60
  function reportOk(res: { rowsAffected?: number[] }): void {
43
61
  const rows = res.rowsAffected ?? [];
44
62
  const total = rows.reduce((a, b) => a + b, 0);
@@ -70,6 +88,7 @@ export async function dbExecCommand(flags: Record<string, string>, file?: string
70
88
 
71
89
  const source = resolveDbSource(flags);
72
90
  const actor = process.env.USER ?? null;
91
+ noteSchemasIsNoLongerNeeded(flags);
73
92
 
74
93
  // --- Stage venue: ship the SQL to the ops Lambda's db:exec action (operator holds no URL). ---
75
94
  if (source.kind === 'stage') {
@@ -21,46 +21,70 @@ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
21
21
  import {
22
22
  fingerprintLive,
23
23
  fingerprintModels,
24
+ governedRolesForModels,
24
25
  mapUnfingerprintedRows,
25
26
  UNFINGERPRINTED_SQL,
26
27
  type UnfingerprintedObject,
27
28
  } from '../schema-fingerprint.js';
29
+ import { predictLiveFingerprint, governedLiveFingerprint } from '../edge-plan.js';
28
30
  import { resolveModelsPath } from '../models-path.js';
29
31
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
32
+ import { borrowedSessionRunner, INTROSPECTION_SESSION, type SessionRunner } from '../session.js';
30
33
  import { resolveConfig, opsFunction } from '../config.js';
31
- import { invokeAction } from '../aws.js';
34
+ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
32
35
  import { step, success, fail, info, warn } from '../output.js';
33
36
  import { loadModels } from './db-generate.js';
34
37
  import { loadDeclaredDerived } from '../declared-derived.js';
35
38
  import type { SequenceDescriptor } from '@everystack/model';
36
39
 
37
40
  export interface FingerprintStatus {
41
+ /** The RAW live hash — everything introspected. The flavor plan.from and the backup chain use. */
38
42
  live: string;
43
+ /**
44
+ * The GOVERNED live hash — the live state filtered through the models' governed-role set,
45
+ * i.e. "the state the models describe". Present only when models loaded; equals `live` when
46
+ * no foreign grantee holds anything. THIS is the half MATCH compares — a live grant nothing
47
+ * will ever reconcile must not keep MATCH unreachable (the v4 rule, wired to the verdict).
48
+ */
49
+ governedLive?: string;
39
50
  declared: string | null;
51
+ /**
52
+ * The predicted endpoint: models + unmodeled live tables riding through, governed —
53
+ * `db:generate`'s no-op state. Equals `declared` when nothing is unmodeled. MATCH is
54
+ * `predicted === governedLive` (the declares-test): the identity is with what generate
55
+ * MANAGES, and generate manages only declared tables.
56
+ */
57
+ predicted?: string;
40
58
  match: boolean | null;
41
59
  unfingerprinted: UnfingerprintedObject[];
42
60
  }
43
61
 
44
62
  /** The testable core: live fingerprint, declared fingerprint, verdict, honesty report. */
45
63
  export async function computeFingerprintStatus(
46
- runner: QueryRunner,
64
+ session: SessionRunner,
47
65
  models: ModelDescriptor[] | null,
48
66
  sequences?: SequenceDescriptor[],
67
+ governedExtras?: readonly string[],
49
68
  ): Promise<FingerprintStatus> {
50
- const snapshot = await introspectSchema(runner);
51
- const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
69
+ const snapshot = await introspectSchema(session);
70
+ const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
71
+ // Rides the SAME session policy as the two introspections above. It used to be issued
72
+ // bare, outside any search_path pin — the one read in this command that was not
73
+ // canonicalized, and on the stage lane its own separate invoke.
74
+ const [unfingerprintedRows] = await session([UNFINGERPRINTED_SQL], INTROSPECTION_SESSION);
52
75
  const live = fingerprintLive(snapshot, contract).hash;
53
76
  const declared = models ? fingerprintModels(models, { sequences }).hash : null;
54
- const unfingerprinted = mapUnfingerprintedRows((await runner(UNFINGERPRINTED_SQL)) as any[]);
55
- return { live, declared, match: declared === null ? null : live === declared, unfingerprinted };
56
- }
57
-
58
- /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
59
- function lambdaRunner(region: string, fn: string): QueryRunner {
60
- return async (sql: string) => {
61
- const result: any = await invokeAction(region, fn, 'db:query', { sql });
62
- if (result?.error) throw new Error(`Query failed: ${result.error}`);
63
- return result?.rows ?? [];
77
+ const governedRoles = models ? governedRolesForModels(models, governedExtras) : undefined;
78
+ const governedLive = governedRoles ? governedLiveFingerprint(snapshot, contract, governedRoles) : undefined;
79
+ const predicted = models ? predictLiveFingerprint(models, snapshot, contract, { governedRoles }) : undefined;
80
+ const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[]);
81
+ return {
82
+ live,
83
+ ...(governedLive !== undefined ? { governedLive } : {}),
84
+ declared,
85
+ ...(predicted !== undefined ? { predicted } : {}),
86
+ match: declared === null ? null : predicted === governedLive,
87
+ unfingerprinted,
64
88
  };
65
89
  }
66
90
 
@@ -76,9 +100,12 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
76
100
  const modelsPath = resolveModelsPath(flags.models);
77
101
  let models: ModelDescriptor[] | null = null;
78
102
  let sequences: SequenceDescriptor[] | undefined;
103
+ let governedExtras: string[] | undefined;
79
104
  try {
80
105
  models = await loadModels(modelsPath);
81
- sequences = (await loadDeclaredDerived(flags.models))?.sequences;
106
+ const declared = await loadDeclaredDerived(flags.models);
107
+ sequences = declared?.sequences;
108
+ governedExtras = declared?.governedRoles;
82
109
  } catch (err: any) {
83
110
  // Two very different situations used to land here identically.
84
111
  //
@@ -100,18 +127,21 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
100
127
  }
101
128
 
102
129
  let runner: QueryRunner;
130
+
131
+ let session: SessionRunner;
103
132
  let end: (() => Promise<void>) | undefined;
104
133
  if (dbSource.kind === 'url') {
105
134
  step(connectingVia(dbSource));
106
- ({ runner, end } = await createUrlRunner(dbSource.url));
135
+ ({ runner, session, end } = await createUrlRunner(dbSource.url));
107
136
  } else {
108
137
  step('Resolving deployed config...');
109
138
  const config = await resolveConfig(flags.stage);
110
- runner = lambdaRunner(config.region, opsFunction(config));
139
+ runner = lambdaQueryRunner(config.region, opsFunction(config));
140
+ session = lambdaSessionRunner(config.region, opsFunction(config));
111
141
  }
112
142
 
113
143
  try {
114
- const status = await computeFingerprintStatus(runner, models, sequences);
144
+ const status = await computeFingerprintStatus(session, models, sequences, governedExtras);
115
145
 
116
146
  if (flags.json === 'true') {
117
147
  console.log(JSON.stringify(status, null, 2));
@@ -120,7 +150,13 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
120
150
  if (status.declared === null) {
121
151
  warn(`declared: (no models barrel at ${modelsPath} — live-only)`);
122
152
  } else {
153
+ if (status.governedLive !== undefined && status.governedLive !== status.live) {
154
+ info(`governed: ${status.governedLive} (live state minus ungoverned grantees — the half MATCH compares)`);
155
+ }
123
156
  info(`declared: ${status.declared}`);
157
+ if (status.predicted !== undefined && status.predicted !== status.declared) {
158
+ info(`predicted: ${status.predicted} (declared + unmodeled tables riding through — generate's no-op state)`);
159
+ }
124
160
  if (status.match) success('MATCH — the database is the state the models declare.');
125
161
  else fail('MISMATCH — run `everystack db:generate` to see the difference as SQL.');
126
162
  }
@@ -34,26 +34,18 @@ import { classifyAdoption, renderAdoptionReport } from '../authz-adoption-class.
34
34
  import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership.js';
35
35
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
36
36
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
37
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
37
38
  import { resolveModelsPath } from '../models-path.js';
38
39
  import { loadDeclaredDerived, assertNoHoles, type DeclaredDerived } from '../declared-derived.js';
39
40
  import { asModelComposeError, opsAdviceLines } from '../ops-advice.js';
40
41
  import { applyStateAndVerify, classifyGeneratedStatements, renderStatementHistogram, currentGitRef, type StateSyncOutcome } from '../state-apply.js';
41
42
  import { resolveConfig, opsFunction } from '../config.js';
42
- import { invokeAction } from '../aws.js';
43
+ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
43
44
  import { step, success, fail, info, warn } from '../output.js';
44
45
 
45
46
  const DEFAULT_MIGRATIONS = 'drizzle';
46
47
  const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
47
48
 
48
- /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
49
- function lambdaRunner(region: string, fn: string): QueryRunner {
50
- return async (sql: string) => {
51
- const result: any = await invokeAction(region, fn, 'db:query', { sql });
52
- if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
53
- return result?.rows ?? [];
54
- };
55
- }
56
-
57
49
  /** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work).
58
50
  * Every failure is the operator's own barrel — ModelComposeError, never dressed as IAM. */
59
51
  export async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
@@ -164,11 +156,11 @@ function reportGrantExemptions(liveAuthz: AuthzContract | undefined, models: Mod
164
156
  function reportAdoptionClasses(liveAuthz: AuthzContract | undefined, models: ModelDescriptor[]): void {
165
157
  if (!liveAuthz) return; // greenfield — every statement is additive, nothing to adjudicate
166
158
  const declared: AuthzContract = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
167
- const { counts } = classifyAdoption(declared, liveAuthz);
159
+ const { counts, statements } = classifyAdoption(declared, liveAuthz);
168
160
  const total = Object.values(counts).reduce((a, b) => a + b, 0);
169
161
  if (total === 0) return;
170
162
  info(`${total} authorization statement(s), by why they exist:`);
171
- for (const line of renderAdoptionReport(counts)) info(line);
163
+ for (const line of renderAdoptionReport(counts, statements)) info(line);
172
164
  }
173
165
 
174
166
  /**
@@ -249,6 +241,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
249
241
  let liveAuthz;
250
242
  let liveOwners: TableOwner[] = [];
251
243
  let runner!: QueryRunner;
244
+ let session!: SessionRunner;
252
245
  let end: (() => Promise<void>) | undefined;
253
246
  try {
254
247
  step(`Loading models from ${modelsPath}...`);
@@ -270,17 +263,18 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
270
263
  }
271
264
  if (dbSource.kind === 'url') {
272
265
  step(connectingVia(dbSource));
273
- ({ runner, end } = await createUrlRunner(dbSource.url));
266
+ ({ runner, session, end } = await createUrlRunner(dbSource.url));
274
267
  } else {
275
268
  step('Resolving deployed config...');
276
269
  const config = await resolveConfig(flags.stage);
277
270
  info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
278
- runner = lambdaRunner(config.region, opsFunction(config));
271
+ runner = lambdaQueryRunner(config.region, opsFunction(config));
272
+ session = lambdaSessionRunner(config.region, opsFunction(config));
279
273
  }
280
274
  step('Introspecting live database (columns + constraints)...');
281
- current = await introspectSchema(runner);
275
+ current = await introspectSchema(session);
282
276
  step('Introspecting authorization (rls + grants + policies)...');
283
- liveAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
277
+ liveAuthz = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
284
278
  // Ownership rides the same connection: it is not part of the contract (it never
285
279
  // enters the fingerprint), but it decides whether the contract is actually in force.
286
280
  liveOwners = await introspectTableOwners(runner);
@@ -352,7 +346,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
352
346
  step(`Applying ${classified.executable.length} statement(s) as one transaction, then verifying by re-diff...`);
353
347
  let outcome: StateSyncOutcome;
354
348
  try {
355
- outcome = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
349
+ outcome = await applyStateAndVerify(runner, session, models, statements, current, liveAuthz, {
356
350
  allowDrops, sequences: declaredDb?.sequences, actor: process.env.USER ?? null, gitRef: currentGitRef(),
357
351
  });
358
352
  } catch (err: any) {