@everystack/cli 0.4.72 → 0.4.74

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.
@@ -18,6 +18,7 @@
18
18
  import type { AuthzContract, TableContract, PolicyContract, SequenceGrantContract } from './authz-contract';
19
19
  import { effectivePolicyCheck, parenthesizeOnce, isPolicyDead, isPolicySubsumed } from './authz-contract';
20
20
  import { matchPolicies } from './authz-identity';
21
+ import { tablePoliciesEquivalent } from './authz-canonical';
21
22
  import { quoteQualified } from './pg-ident';
22
23
 
23
24
  /**
@@ -247,7 +248,8 @@ export function inheritedSequenceGrants(table: TableContract): Record<string, st
247
248
  function reconcileSequenceGrants(
248
249
  d: TableContract,
249
250
  liveSequences: readonly SequenceGrantContract[],
250
- out: string[],
251
+ narrow: string[],
252
+ widen: string[],
251
253
  governed: ReadonlySet<string>,
252
254
  ): void {
253
255
  const declared = inheritedSequenceGrants(d);
@@ -275,8 +277,8 @@ function reconcileSequenceGrants(
275
277
  const role = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
276
278
  const toRevoke = [...have].filter((p) => !want.has(p)).sort();
277
279
  const toGrant = [...want].filter((p) => !have.has(p)).sort();
278
- if (toRevoke.length) out.push(`REVOKE ${toRevoke.join(', ')} ON SEQUENCE ${target} FROM ${role};`);
279
- if (toGrant.length) out.push(`GRANT ${toGrant.join(', ')} ON SEQUENCE ${target} TO ${role};`);
280
+ if (toRevoke.length) narrow.push(`REVOKE ${toRevoke.join(', ')} ON SEQUENCE ${target} FROM ${role};`);
281
+ if (toGrant.length) widen.push(`GRANT ${toGrant.join(', ')} ON SEQUENCE ${target} TO ${role};`);
280
282
  }
281
283
  }
282
284
  }
@@ -293,6 +295,13 @@ export function emitReconcileSql(
293
295
  live: AuthzContract,
294
296
  opts: { governedRoles?: ReadonlySet<string> } = {},
295
297
  ): string[] {
298
+ // Condition C — two phases PER TABLE in one transaction: every access-REMOVING statement
299
+ // (policy DROP, REVOKE, RLS ENABLE/FORCE) before every access-ADDING one (policy CREATE, GRANT,
300
+ // RLS DISABLE/NO FORCE). The batch is atomic, so this is defence in depth — but it makes every
301
+ // prefix of the plan fail-safe: a partial application is never broader than both endpoints. A
302
+ // same-name policy replacement stays a DROP (narrow) then CREATE (widen), never co-existing.
303
+ // Per table, not global: grants and policies are table-scoped, so a widen on one table can
304
+ // never be exploited through another's narrow — and tables stay grouped, as the plan reads.
296
305
  const sql: string[] = [];
297
306
  const lTables = tableMap(live);
298
307
  // Default: govern exactly what the models name (plus the always-governed set). A caller
@@ -311,12 +320,15 @@ export function emitReconcileSql(
311
320
  // The contract keys on the bare `<schema>.<table>` (so the diff matches both producers);
312
321
  // the EMITTED SQL must quote it, or a reserved table name (`public.user`) is a syntax error.
313
322
  const table = quoteQualified(d.table);
314
- reconcileRls(table, d, l, sql);
315
- reconcilePolicies(table, d, l, sql, governed);
316
- reconcileGrants(table, d, l, sql, governed);
317
- reconcileColumnGrants(table, d, l, sql, governed);
323
+ const narrow: string[] = [];
324
+ const widen: string[] = [];
325
+ reconcileRls(table, d, l, narrow, widen);
326
+ reconcilePolicies(table, d, l, narrow, widen, governed);
327
+ reconcileGrants(table, d, l, narrow, widen, governed);
328
+ reconcileColumnGrants(table, d, l, narrow, widen, governed);
318
329
  // A6: the sequences behind this table's SERIAL columns inherit its grants (identity ones do not).
319
- reconcileSequenceGrants(d, sequencesByTable.get(d.table) ?? [], sql, governed);
330
+ reconcileSequenceGrants(d, sequencesByTable.get(d.table) ?? [], narrow, widen, governed);
331
+ sql.push(...narrow, ...widen);
320
332
  }
321
333
 
322
334
  return sql;
@@ -339,29 +351,37 @@ export function emitReconcileSql(
339
351
  * already-enabled table is a no-op; the dump carried no grants).
340
352
  */
341
353
  export function emitSwapAuthzSql(declared: AuthzContract): string[] {
354
+ // Same narrow-before-widen shape as emitReconcileSql (Condition C), per table: RLS enable/force
355
+ // and the idempotent policy DROPs narrow; the CREATEs and GRANTs widen. Drop-BEFORE-create per
356
+ // policy is preserved (drop in the narrow phase, create in the widen phase of the same table),
357
+ // so no CREATE lands on a policy the dump restored.
342
358
  const sql: string[] = [];
343
359
  const governed = governedRoleSet(declared);
344
360
  for (const d of declared.tables) {
345
361
  const table = quoteQualified(d.table);
346
- if (d.rls.enabled) sql.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
347
- if (d.rls.forced) sql.push(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;`);
362
+ const narrow: string[] = [];
363
+ const widen: string[] = [];
364
+ if (d.rls.enabled) narrow.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
365
+ if (d.rls.forced) narrow.push(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;`);
348
366
  for (const p of d.policies) {
349
- sql.push(policyDropSql(table, p.name)); // idempotent against the policy the dump restored
350
- sql.push(policyCreateSql(table, p));
367
+ narrow.push(policyDropSql(table, p.name)); // idempotent against the policy the dump restored
368
+ widen.push(policyCreateSql(table, p));
351
369
  }
352
370
  // No live side here, so every grantee comes from the DECLARED contract and is
353
371
  // governed by definition — the exemption can never fire on this path.
354
- reconcileGrants(table, d, undefined, sql, governed);
355
- reconcileColumnGrants(table, d, undefined, sql, governed);
372
+ reconcileGrants(table, d, undefined, narrow, widen, governed);
373
+ reconcileColumnGrants(table, d, undefined, narrow, widen, governed);
374
+ sql.push(...narrow, ...widen);
356
375
  }
357
376
  return sql;
358
377
  }
359
378
 
360
- function reconcileRls(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
361
- if (d.rls.enabled && !l?.rls.enabled) out.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
362
- if (d.rls.forced && !l?.rls.forced) out.push(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;`);
363
- if (!d.rls.enabled && l?.rls.enabled) out.push(`ALTER TABLE ${table} DISABLE ROW LEVEL SECURITY;`);
364
- if (!d.rls.forced && l?.rls.forced) out.push(`ALTER TABLE ${table} NO FORCE ROW LEVEL SECURITY;`);
379
+ function reconcileRls(table: string, d: TableContract, l: TableContract | undefined, narrow: string[], widen: string[]): void {
380
+ // ENABLE / FORCE deny by default — narrowing. DISABLE / NO FORCE lift a restriction — widening.
381
+ if (d.rls.enabled && !l?.rls.enabled) narrow.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
382
+ if (d.rls.forced && !l?.rls.forced) narrow.push(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;`);
383
+ if (!d.rls.enabled && l?.rls.enabled) widen.push(`ALTER TABLE ${table} DISABLE ROW LEVEL SECURITY;`);
384
+ if (!d.rls.forced && l?.rls.forced) widen.push(`ALTER TABLE ${table} NO FORCE ROW LEVEL SECURITY;`);
365
385
  }
366
386
 
367
387
  /**
@@ -375,12 +395,20 @@ function reconcileRls(table: string, d: TableContract, l: TableContract | undefi
375
395
  * The matcher requires full field equality, so this can only turn a DROP + CREATE pair into a
376
396
  * no-op — never emit DDL the name-keyed version would not have emitted.
377
397
  */
378
- function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
398
+ function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, narrow: string[], widen: string[], governed: ReadonlySet<string>): void {
399
+ // Condition B — gate on the canonical equivalence the fingerprint uses, so this surface and
400
+ // that one can never disagree about the same database. A live `FOR ALL` with a dead DELETE arm
401
+ // and the granular pull that omits DELETE are equal here; emitting the DROP + CREATE the
402
+ // per-command matcher would produce is exactly the MATCH-but-nonzero split this closes. Only
403
+ // when the states genuinely differ do we fall back to the per-policy matcher below, which can
404
+ // only over-emit churn, never under-emit a real authorization difference.
405
+ if (l && tablePoliciesEquivalent(d, l, governed)) return;
379
406
  const live = l?.policies ?? [];
380
407
  const m = matchPolicies(d.policies, live);
381
408
  const byName = new Map(live.map((p) => [p.name, p]));
382
409
 
383
- // Drops first, so a replaced policy never briefly co-exists with its old form.
410
+ // Condition C: DROPs go to the narrow phase, CREATEs to the widen phase — a replaced policy is
411
+ // dropped (narrow) before it is re-created (widen), so it never co-exists with its old form.
384
412
  for (const name of m.toDrop) {
385
413
  // A live policy scoped ENTIRELY to roles the models do not govern is left alone, exactly
386
414
  // as reconcileGrants leaves that role's privileges alone. Without this the two lanes
@@ -411,7 +439,7 @@ function reconcilePolicies(table: string, d: TableContract, l: TableContract | u
411
439
  // drops the PUBLIC policy that was covering it, the role-scoped one stops being redundant
412
440
  // the moment the plan lands — and leaving it would keep access the model never declared.
413
441
  if (p && l && isPolicySubsumed(l, p) && isPolicySubsumed(d, p)) continue;
414
- out.push(policyDropSql(table, name));
442
+ narrow.push(policyDropSql(table, name));
415
443
  }
416
444
  // Symmetric with the drop rule above, and required by the same identity: a DECLARED policy
417
445
  // the declared grants do not back is inert — it would authorize nothing the moment it
@@ -420,11 +448,11 @@ function reconcilePolicies(table: string, d: TableContract, l: TableContract | u
420
448
  // moment the model grants the privilege it polices.
421
449
  for (const p of m.toCreate) {
422
450
  if (isPolicyDead(d, p) || isPolicySubsumed(d, p)) continue;
423
- out.push(policyCreateSql(table, p));
451
+ widen.push(policyCreateSql(table, p));
424
452
  }
425
453
  }
426
454
 
427
- function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
455
+ function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, narrow: string[], widen: string[], governed: ReadonlySet<string>): void {
428
456
  const grantees = new Set([...Object.keys(d.grants), ...Object.keys(l?.grants ?? {})]);
429
457
  for (const grantee of [...grantees].sort()) {
430
458
  // A live grantee the declared authz does not govern is LEFT ALONE. The union below
@@ -439,8 +467,9 @@ function reconcileGrants(table: string, d: TableContract, l: TableContract | und
439
467
 
440
468
  const toRevoke = [...live].filter((p) => !declared.has(p)).sort();
441
469
  const toGrant = [...declared].filter((p) => !live.has(p)).sort();
442
- if (toRevoke.length) out.push(`REVOKE ${toRevoke.join(', ')} ON ${table} FROM ${target};`);
443
- if (toGrant.length) out.push(`GRANT ${toGrant.join(', ')} ON ${table} TO ${target};`);
470
+ // REVOKE narrows, GRANT widens (Condition C).
471
+ if (toRevoke.length) narrow.push(`REVOKE ${toRevoke.join(', ')} ON ${table} FROM ${target};`);
472
+ if (toGrant.length) widen.push(`GRANT ${toGrant.join(', ')} ON ${table} TO ${target};`);
444
473
  }
445
474
  }
446
475
 
@@ -450,7 +479,7 @@ function reconcileGrants(table: string, d: TableContract, l: TableContract | und
450
479
  * those live-but-not-declared. This is how `auth.users` exposes id/email/role (and only those)
451
480
  * to `authenticated` without a whole-table grant. Columns are sorted, so a re-pull is a no-op.
452
481
  */
453
- function reconcileColumnGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
482
+ function reconcileColumnGrants(table: string, d: TableContract, l: TableContract | undefined, narrow: string[], widen: string[], governed: ReadonlySet<string>): void {
454
483
  const dcg = d.columnGrants ?? {};
455
484
  const lcg = l?.columnGrants ?? {};
456
485
  for (const grantee of [...new Set([...Object.keys(dcg), ...Object.keys(lcg)])].sort()) {
@@ -465,8 +494,8 @@ function reconcileColumnGrants(table: string, d: TableContract, l: TableContract
465
494
  const live = new Set(lPriv[priv] ?? []);
466
495
  const toRevoke = [...live].filter((c) => !declared.has(c)).sort();
467
496
  const toGrant = [...declared].filter((c) => !live.has(c)).sort();
468
- if (toRevoke.length) out.push(`REVOKE ${priv} (${toRevoke.join(', ')}) ON ${table} FROM ${target};`);
469
- if (toGrant.length) out.push(`GRANT ${priv} (${toGrant.join(', ')}) ON ${table} TO ${target};`);
497
+ if (toRevoke.length) narrow.push(`REVOKE ${priv} (${toRevoke.join(', ')}) ON ${table} FROM ${target};`);
498
+ if (toGrant.length) widen.push(`GRANT ${priv} (${toGrant.join(', ')}) ON ${table} TO ${target};`);
470
499
  }
471
500
  }
472
501
  }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `everystack db:adopt` — first-time adoption of a pre-Models database, by PROOF.
3
+ *
4
+ * db:adopt [--stage <name> | --database-url <url>] [--schema public] [--out db/models]
5
+ * [--govern-roles r1,r2]
6
+ *
7
+ * The problem it solves: a production database that never went through Models has no committed
8
+ * ancestor declaring it, so db:apply refuses it as unverifiable drift — and no waiver fits (there
9
+ * is nothing to attest a lineage FROM). db:adopt is the deliberate, auditable on-ramp. It renders
10
+ * Models from the live database (the same renderers db:pull uses), compiles them back, and PROVES
11
+ * the compiled Models declare the live state EXACTLY — predicted == governed live, the same
12
+ * declares-test descent and db:check use. Only then does it write the model files and print the
13
+ * `chore(db): adopt live baseline <fp>` commit: that commit legitimately declares the live state,
14
+ * so a later db:apply descends for real instead of hitting the drift refusal.
15
+ *
16
+ * The doctrine (Fable-ruled 2026-09-05): PROVE, not attest. There is NO bypass flag. When the
17
+ * round-trip is not exact, adoption REFUSES, names the surfaces that diverged, and prints the
18
+ * db:generate edge — because a baseline that papered over a difference is the bypass under another
19
+ * name. It writes NOTHING on refusal; --out is left untouched.
20
+ *
21
+ * The pure proof core is adopt.ts (proveAdoption); the shared render is live-models.ts
22
+ * (renderLiveModelFiles). This shell is the IO: connect, render into a sandbox to compile, prove,
23
+ * and either finalize or refuse.
24
+ */
25
+
26
+ import fs from 'node:fs/promises';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
29
+ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
30
+ import { introspectContract, type AuthzContract, type QueryRunner } from '../authz-contract';
31
+ import { introspectDerived } from '../derived-introspect';
32
+ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
33
+ import { renderLiveModelFiles } from '../live-models';
34
+ import { proveAdoption, type AdoptionProof } from '../adopt';
35
+ import { generateMigrationSql } from '../migration-generate';
36
+ import { loadModels } from './db-generate';
37
+ import type { ModelDescriptor } from '@everystack/model';
38
+ import type { RenderedModelFile } from '../model-render';
39
+ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source';
40
+ import type { SessionRunner } from '../session';
41
+ import { resolveConfig, opsFunction } from '../config';
42
+ import { lambdaQueryRunner, lambdaSessionRunner } from '../aws';
43
+ import { step, success, fail, info, warn } from '../output';
44
+ import { opsAdviceLines } from '../ops-advice';
45
+
46
+ const DEFAULT_OUT = 'db/models';
47
+
48
+ export interface AdoptResult {
49
+ /** The proof: clean means the compiled Models declare the live state exactly. */
50
+ proof: AdoptionProof;
51
+ /** The rendered model files (directory shape) — written to --out only when the proof is clean. */
52
+ files: RenderedModelFile[];
53
+ /** The live schema, as introspected. */
54
+ snapshot: SchemaSnapshot;
55
+ /** The live authorization contract, as introspected. */
56
+ contract: AuthzContract;
57
+ /** `chore(db): adopt live baseline <fp>` when clean; null on refusal. */
58
+ commitMessage: string | null;
59
+ /** On refusal, the db:generate edge (what the Models and the live database still differ by). */
60
+ refusalDiff: string[];
61
+ }
62
+
63
+ /**
64
+ * The adopt CORE: introspect the live database, render Models with the shared renderer, compile
65
+ * them in `sandboxDir`, and prove they declare the live state. Pure of policy — it does not decide
66
+ * to write --out or to exit; the caller does that from the verdict. The sandbox is where the
67
+ * rendered barrel is written so it can be dynamically imported (the compile the proof needs);
68
+ * --out is written by the shell only after the proof is clean.
69
+ */
70
+ export async function adoptFromLive(opts: {
71
+ session: SessionRunner;
72
+ runner: QueryRunner;
73
+ schema?: string | string[];
74
+ governRoles?: string[];
75
+ sandboxDir: string;
76
+ /** How to compile the rendered barrel back to descriptors. Defaults to loadModels (the barrel
77
+ * loader db:generate uses). Injectable so a test runner that cannot dynamic-import a `.ts` via
78
+ * a file:// URL (jest) can supply its own transforming import. */
79
+ loadBarrel?: (indexPath: string) => Promise<ModelDescriptor[]>;
80
+ }): Promise<AdoptResult> {
81
+ const { session, sandboxDir } = opts;
82
+ const schema = opts.schema ?? 'public';
83
+ const load = opts.loadBarrel ?? loadModels;
84
+
85
+ const snapshot = await introspectSchema(session);
86
+ const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
87
+ const derivedCatalog = await introspectDerived(session);
88
+
89
+ const rendered = renderLiveModelFiles(snapshot, contract, derivedCatalog, { schema, governRoles: opts.governRoles });
90
+
91
+ // Compile the rendered barrel: proveAdoption needs the loaded ModelDescriptors, and loading is a
92
+ // dynamic import, so the files must exist on disk under a tree where @everystack/model resolves.
93
+ await fs.mkdir(sandboxDir, { recursive: true });
94
+ for (const f of rendered.files) await fs.writeFile(path.join(sandboxDir, f.file), f.source, 'utf8');
95
+ const models = await load(path.join(sandboxDir, 'index.ts'));
96
+
97
+ const proof = proveAdoption(models, snapshot, contract, { schema: typeof schema === 'string' ? schema : undefined });
98
+ const commitMessage = proof.clean ? `chore(db): adopt live baseline ${proof.fingerprint}` : null;
99
+ // On refusal, show WHAT still differs — the same edge db:generate would mint against the live DB.
100
+ const refusalDiff = proof.clean ? [] : generateMigrationSql(models, snapshot, { liveAuthz: contract });
101
+
102
+ return { proof, files: rendered.files, snapshot, contract, commitMessage, refusalDiff };
103
+ }
104
+
105
+ /**
106
+ * Turn the adopt verdict into action — the SECURITY BOUNDARY, isolated so it is testable without a
107
+ * database. This is where "PROVE not attest, no bypass" is actually enforced: on refusal it writes
108
+ * NOTHING to --out and reports; on a clean proof it writes the baseline and prints the commit/push
109
+ * runbook. It never calls process.exit (the caller maps the returned code) — so a test can assert,
110
+ * against a synthetic verdict, that a dirty result leaves --out untouched and a clean one finalizes.
111
+ * A future edit that let the refusal path fall through to the write is caught by that test, red.
112
+ */
113
+ export async function applyAdoptVerdict(result: AdoptResult, outDir: string): Promise<{ exitCode: 0 | 1; wrote: boolean }> {
114
+ if (!result.proof.clean) {
115
+ // REFUSAL — the round trip is not exact. Name what diverged and show the edge; write NOTHING.
116
+ console.log('');
117
+ fail('db:adopt refused — the rendered Models do NOT declare the live database exactly.');
118
+ warn('A baseline that papered over this difference would be a bypass. Nothing was written to --out.');
119
+ info('Surfaces that diverge:');
120
+ for (const s of result.proof.divergentSurfaces) info(` • ${s}`);
121
+ if (result.refusalDiff.length) {
122
+ console.log('');
123
+ info(`What still differs (the db:generate edge, ${result.refusalDiff.length} statement(s)):`);
124
+ for (const stmt of result.refusalDiff) console.log(stmt.endsWith(';') ? stmt : `${stmt};`);
125
+ }
126
+ console.log('');
127
+ info('The renderer could not yet capture the surfaces above. Refine by hand until db:generate is empty, then commit the Models — a later db:apply will descend normally.');
128
+ return { exitCode: 1, wrote: false };
129
+ }
130
+
131
+ // CLEAN — finalize. Write the rendered barrel to --out, then print the runbook.
132
+ try {
133
+ await fs.mkdir(outDir, { recursive: true });
134
+ const written = new Set(result.files.map((f) => f.file));
135
+ const existing = (await fs.readdir(outDir)).filter((f) => f.endsWith('.ts') && !written.has(f));
136
+ for (const f of result.files) await fs.writeFile(path.join(outDir, f.file), f.source, 'utf8');
137
+ if (existing.length) {
138
+ warn(`${existing.length} existing .ts file(s) in ${path.relative(process.cwd(), outDir)} were not part of this adoption and were left untouched: ${existing.join(', ')}`);
139
+ }
140
+ } catch (err: any) {
141
+ fail(`Could not write ${outDir}: ${err.message}`);
142
+ return { exitCode: 1, wrote: false };
143
+ }
144
+
145
+ console.log('');
146
+ success(`db:adopt — the rendered Models declare the live database exactly (${result.files.length - 1} model(s)).`);
147
+ info(`Wrote ${path.relative(process.cwd(), outDir)} — the adopted baseline.`);
148
+ console.log('');
149
+ info('Finalize the baseline — commit, then PUSH before you apply:');
150
+ info(` git add ${path.relative(process.cwd(), outDir)}`);
151
+ info(` git commit -m "${result.commitMessage}"`);
152
+ info(' git push # an unpushed baseline is an unknown commit to teammates and the ops Lambda');
153
+ info(' everystack db:plan --stage <name> # empty — the live database is now declared');
154
+ return { exitCode: 0, wrote: true };
155
+ }
156
+
157
+ export async function dbAdoptCommand(
158
+ flags: Record<string, string>,
159
+ opts: {
160
+ /** Injected barrel loader (jest cannot file:// import a .ts). Default: adoptFromLive's loadModels. */
161
+ loadBarrel?: (indexPath: string) => Promise<ModelDescriptor[]>;
162
+ /** Injected sandbox dir, so a test can assert it is cleaned up. Default: a fresh mkdtemp. */
163
+ sandboxDir?: string;
164
+ } = {},
165
+ ): Promise<void> {
166
+ const schemas = (flags.schema || 'public').split(',').map((s) => s.trim()).filter(Boolean);
167
+ if (schemas.length === 0) fail('--schema needs at least one schema name.');
168
+ const schema = schemas.length === 1 ? schemas[0] : schemas;
169
+ const outDir = path.resolve(flags.out || DEFAULT_OUT);
170
+
171
+ // --govern-roles: the operator's EXPLICIT decision to transcribe foreign roles' grants as
172
+ // privileges (so a fresh build recreates them). Same vocabulary guard as db:pull.
173
+ const governRoles = (flags['govern-roles'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
174
+ for (const r of governRoles) {
175
+ if (['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r)) {
176
+ fail(`--govern-roles ${r}: the vocabulary roles and PUBLIC are always governed — name only foreign roles (an ops/connection role the migrations granted to).`);
177
+ process.exit(1);
178
+ }
179
+ }
180
+
181
+ let dbSource: DbSource;
182
+ try {
183
+ dbSource = resolveDbSource(flags);
184
+ } catch (err: any) {
185
+ fail(err.message);
186
+ process.exit(1);
187
+ }
188
+
189
+ const sandboxDir = opts.sandboxDir ?? await fs.mkdtemp(path.join(os.tmpdir(), 'everystack-adopt-'));
190
+ let exitCode: 0 | 1 = 1;
191
+ let end: (() => Promise<void>) | undefined;
192
+ try {
193
+ let runner: QueryRunner;
194
+ let session: SessionRunner;
195
+ if (dbSource.kind === 'url') {
196
+ step(connectingVia(dbSource));
197
+ ({ runner, session, end } = await createUrlRunner(dbSource.url));
198
+ } else {
199
+ step('Resolving deployed config...');
200
+ const config = await resolveConfig(flags.stage);
201
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
202
+ runner = lambdaQueryRunner(config.region, opsFunction(config));
203
+ session = lambdaSessionRunner(config.region, opsFunction(config));
204
+ }
205
+ step(`Introspecting live database (schema: ${schemas.join(', ')})...`);
206
+ const result = await adoptFromLive({ session, runner, schema, governRoles, sandboxDir, loadBarrel: opts.loadBarrel });
207
+ // Only after a proof do we act. A throw before here (connect, introspect, render, compile)
208
+ // never reaches applyAdoptVerdict, so --out is untouched — a bad baseline is impossible.
209
+ ({ exitCode } = await applyAdoptVerdict(result, outDir));
210
+ } catch (err: any) {
211
+ fail(err.message);
212
+ if (dbSource.kind === 'url') {
213
+ info('Check the connection string and that the database is accepting connections.');
214
+ } else {
215
+ for (const line of opsAdviceLines(err, [
216
+ 'Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.',
217
+ 'Adopting a local database instead? Pass --database-url or set DATABASE_URL.',
218
+ ])) info(line);
219
+ }
220
+ exitCode = 1;
221
+ } finally {
222
+ // The sandbox is the compile scratch, never the baseline — remove it on EVERY exit path.
223
+ await end?.();
224
+ await fs.rm(sandboxDir, { recursive: true, force: true });
225
+ }
226
+ process.exit(exitCode);
227
+ }
@@ -21,12 +21,14 @@ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
21
21
  import {
22
22
  fingerprintLive,
23
23
  fingerprintModels,
24
+ fingerprintState,
24
25
  governedRolesForModels,
26
+ divergentSurfaces,
25
27
  mapUnfingerprintedRows,
26
28
  UNFINGERPRINTED_SQL,
27
29
  type UnfingerprintedObject,
28
30
  } from '../schema-fingerprint';
29
- import { predictLiveFingerprint, governedLiveFingerprint } from '../edge-plan';
31
+ import { predictLiveState, governedLiveFingerprint } from '../edge-plan';
30
32
  import { resolveModelsPath } from '../models-path';
31
33
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source';
32
34
  import { borrowedSessionRunner, INTROSPECTION_SESSION, type SessionRunner } from '../session';
@@ -56,6 +58,9 @@ export interface FingerprintStatus {
56
58
  */
57
59
  predicted?: string;
58
60
  match: boolean | null;
61
+ /** On a MISMATCH, the surfaces that diverge (base schema / authorization / sequence ACLs, with
62
+ * the tables named) — so the report says WHERE to look. Absent when MATCH or no models. */
63
+ divergentSurfaces?: string[];
59
64
  unfingerprinted: UnfingerprintedObject[];
60
65
  }
61
66
 
@@ -77,8 +82,14 @@ export async function computeFingerprintStatus(
77
82
  const live = fingerprintLive(snapshot, contract).hash;
78
83
  const declared = models ? fingerprintModels(models, { sequences }).hash : null;
79
84
  const governedRoles = models ? governedRolesForModels(models, governedExtras) : undefined;
80
- const governedLive = governedRoles ? governedLiveFingerprint(snapshot, contract, governedRoles) : undefined;
81
- const predicted = models ? predictLiveFingerprint(models, snapshot, contract, { governedRoles }) : undefined;
85
+ // Compute the governed-live and predicted states as full canonical forms, so a MISMATCH can
86
+ // name the diverging surface — not just their hashes.
87
+ const governedLiveState = governedRoles
88
+ ? fingerprintState(snapshot, contract.tables, { governedRoles, sequenceGrants: contract.sequenceGrants })
89
+ : undefined;
90
+ const governedLive = governedLiveState?.hash;
91
+ const predictedState = models ? predictLiveState(models, snapshot, contract, { governedRoles }) : undefined;
92
+ const predicted = predictedState?.hash;
82
93
  // B5 — a declared owner IS hashed (derived-source prefixes it), so reporting it as
83
94
  // unfingerprinted would be a false claim in the one report whose whole job is honesty about
84
95
  // coverage. SQL cannot know what the models declare; the filter belongs here.
@@ -89,12 +100,17 @@ export async function computeFingerprintStatus(
89
100
  );
90
101
  const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[])
91
102
  .filter((u) => u.kind !== 'function owner' || !ownerDeclared.has(u.identity.split(' → ')[0]));
103
+ const match = declared === null ? null : predicted === governedLive;
104
+ const surfaces = match === false && predictedState && governedLiveState
105
+ ? divergentSurfaces(predictedState.canonical, governedLiveState.canonical)
106
+ : undefined;
92
107
  return {
93
108
  live,
94
109
  ...(governedLive !== undefined ? { governedLive } : {}),
95
110
  declared,
96
111
  ...(predicted !== undefined ? { predicted } : {}),
97
- match: declared === null ? null : predicted === governedLive,
112
+ match,
113
+ ...(surfaces && surfaces.length ? { divergentSurfaces: surfaces } : {}),
98
114
  unfingerprinted,
99
115
  };
100
116
  }
@@ -172,7 +188,10 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
172
188
  info(`predicted: ${status.predicted} (declared + unmodeled tables riding through — generate's no-op state)`);
173
189
  }
174
190
  if (status.match) success('MATCH — the database is the state the models declare.');
175
- else fail('MISMATCH — run `everystack db:generate` to see the difference as SQL.');
191
+ else {
192
+ const where = status.divergentSurfaces?.length ? ` — diverges on ${status.divergentSurfaces.join('; ')}` : '';
193
+ fail(`MISMATCH${where}. Run \`everystack db:generate\` to see the difference as SQL.`);
194
+ }
176
195
  }
177
196
  if (status.unfingerprinted.length > 0) {
178
197
  warn(`unfingerprinted (${status.unfingerprinted.length}) — outside the base fingerprint and the derived manifest:`);
@@ -106,24 +106,30 @@ export interface ObjectGrantDiff {
106
106
  }
107
107
 
108
108
  /**
109
- * Ownership context for a FUNCTION's grant diff. Absent for relations.
109
+ * Ownership context for an object's grant diff — passed for EVERY kind (functions AND relations).
110
110
  *
111
- * Owner-implicit EXECUTE is excluded from both grant reads (`grantee <> proowner`), which is
112
- * correct in isolation — an owner's privileges are not grants. But it means the SET of
113
- * non-owner grants is computed RELATIVE TO THE OWNER, so two sides with different owners
114
- * produce two different sets for the same function, and the differ reconciles a difference
115
- * that is purely ownership.
111
+ * Owner-implicit privileges are excluded from both grant reads (`grantee <> proowner` for
112
+ * functions, `<> relowner` for relations), which is correct in isolation — an owner's privileges
113
+ * are not grants. But it means the SET of non-owner grants is computed RELATIVE TO THE OWNER, so
114
+ * two sides with different owners produce two different sets for the same object, and the differ
115
+ * reconciles a difference that is purely ownership.
116
116
  *
117
- * Measured by a consumer, 2026-08-10: their local `resolve_user_slug` is owned by
117
+ * First measured on functions, 2026-08-10: a consumer's local `resolve_user_slug` is owned by
118
118
  * `outbound_migrator` (owner-implicit, so the pull rendered NO privilege), while on stage the
119
119
  * same function is owned by `slug_resolver` and `outbound_migrator` holds an EXPLICIT grant.
120
120
  * The differ planned `REVOKE EXECUTE ON api.resolve_user_slug FROM outbound_migrator` — a real
121
121
  * access removal minted from an ownership difference. They did not apply it.
122
+ *
123
+ * The relation half surfaced 2026-09-04: after `db:provision ADOPT` made `outbound_migrator` own a
124
+ * VIEW the model also lists in its grants, the live read excluded the owner's own rows, so the
125
+ * declared self-grant was minted on every reconcile and never converged. The relation call site
126
+ * used to pass no ownership context on the belief that a relation's grant set does not shift with
127
+ * its owner — true only while relacl is NULL. Both kinds now share this context.
122
128
  */
123
129
  export interface GrantOwnerContext {
124
130
  /** The owner the DECLARED grant set was computed against, when the models declare one. */
125
131
  declared?: string;
126
- /** The owner the LIVE grant set was computed against (`pg_get_userbyid(proowner)`). */
132
+ /** The owner the LIVE grant set was computed against (`pg_get_userbyid(proowner|relowner)`). */
127
133
  live?: string;
128
134
  }
129
135
 
@@ -131,8 +137,9 @@ export interface GrantOwnerContext {
131
137
  * The idempotent REVOKE/GRANT delta between the declared contract and the live ACLs —
132
138
  * the table path's reconcileGrants, per derived object. Empty when they match.
133
139
  *
134
- * When `owners` is supplied (functions) and the two sides cannot be shown to have been
135
- * computed against the SAME owner, REVOKEs are WITHHELD and reported. GRANTs still flow: a
140
+ * When `owners` is supplied (every kind — functions and relations) and the two sides cannot be
141
+ * shown to have been computed against the SAME owner, REVOKEs are WITHHELD and reported. GRANTs
142
+ * still flow: a
136
143
  * redundant GRANT is noise, a wrong REVOKE removes access that was really there. Failing to
137
144
  * revoke is the recoverable error and it is never silent.
138
145
  *
@@ -198,10 +205,10 @@ export function diffObjectGrants(
198
205
  out.push(`REVOKE ${toRevoke.join(', ')} ON ${target} FROM ${grantee}`);
199
206
  } else {
200
207
  const why = grantee === owners!.live
201
- ? `'${grantee}' is the function's live OWNER, whose implicit privileges are excluded from both grant reads — so this difference is ownership, not a grant`
202
- : `'${grantee}' is the function's declared owner (live owner: ${owners!.live}), and an owner's implicit privileges are excluded from both grant reads`;
208
+ ? `'${grantee}' is the object's live OWNER, whose implicit privileges are excluded from both grant reads — so this difference is ownership, not a grant`
209
+ : `'${grantee}' is the object's declared owner (live owner: ${owners!.live}), and an owner's implicit privileges are excluded from both grant reads`;
203
210
  suppressed.push(
204
- `${target}: withheld REVOKE ${toRevoke.join(', ')} FROM ${grantee} — ${why}. An owner's implicit privileges are excluded from both reads, so the two grant sets are not comparable and this revoke may remove access that is really there. Declare the function's owner to restore revokes.`,
211
+ `${target}: withheld REVOKE ${toRevoke.join(', ')} FROM ${grantee} — ${why}. An owner's implicit privileges are excluded from both reads, so the two grant sets are not comparable and this revoke may remove access that is really there. Declare the object's owner to restore revokes.`,
205
212
  );
206
213
  }
207
214
  }
@@ -482,11 +482,21 @@ export function planReconcile(
482
482
  );
483
483
  } else {
484
484
  const target = parsed.target ?? (src.kind === 'function' ? `FUNCTION ${src.identity}` : src.identity);
485
- // Functions carry ownership context; relations do not (relacl's NULL means owner-only
486
- // and aclexplode(NULL) is empty, so a relation's grant set does not shift with its owner).
487
- const owners = src.kind === 'function'
488
- ? { ...(src.owner !== undefined ? { declared: src.owner } : {}), ...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}) }
489
- : undefined;
485
+ // Ownership context is passed for EVERY kind. An owner's implicit privileges are excluded
486
+ // from the live read for relations too (`a.grantee <> c.relowner`), so a model that both
487
+ // owns an object and lists that owner in its grants declares a self-grant the read can
488
+ // never confirm — diffObjectGrants normalizes the owner grantee away. The earlier belief
489
+ // that a relation's grant set "does not shift with its owner" holds only while relacl is
490
+ // NULL; the first non-owner grant materializes the ACL and the owner's own rows drop out,
491
+ // exactly as for a function. (A consumer's ADOPT'd view, owned by a role its model also
492
+ // grants, looped forever — 2026-09-04.)
493
+ // KNOWN LIMIT: a privilege an owner has deliberately self-revoked is not restored — that
494
+ // is a failure to ADD access (availability), never to remove it, and unnormalized code did
495
+ // not converge on it either.
496
+ const owners = {
497
+ ...(src.owner !== undefined ? { declared: src.owner } : {}),
498
+ ...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}),
499
+ };
490
500
  const { statements, suppressed } = diffObjectGrants(target, parsed.grants, liveObj.grants, owners);
491
501
  if (statements.length > 0) regrants.push({ identity: src.identity, statements });
492
502
  // Never silent: a withheld revoke is reported every run until the owner is declared.
@@ -31,7 +31,7 @@ import type { AuthzContract } from './authz-contract';
31
31
  import { generateMigrationSql, unmodeledTables } from './migration-generate';
32
32
  import { compileTableContract } from './authz-compile';
33
33
  import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply';
34
- import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, FINGERPRINT_VERSION } from './schema-fingerprint';
34
+ import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
35
35
  import { compileDeclaredState } from './declared-diff';
36
36
  import { compileTableRenames, compileTableMoves } from './schema-compile';
37
37
 
@@ -164,6 +164,20 @@ export function predictLiveFingerprint(
164
164
  contract: AuthzContract,
165
165
  opts: { schema?: string; governedRoles?: ReadonlySet<string> } = {},
166
166
  ): string {
167
+ return predictLiveState(models, snapshot, contract, opts).hash;
168
+ }
169
+
170
+ /**
171
+ * The predicted endpoint as a full {@link Fingerprint} (hash AND canonical form). The hash is
172
+ * the plan's `to` and the declares-test; the canonical form lets a MISMATCH report name WHICH
173
+ * surface diverged (base schema, authorization, sequence ACLs) against the live one.
174
+ */
175
+ export function predictLiveState(
176
+ models: ModelDescriptor[],
177
+ snapshot: SchemaSnapshot,
178
+ contract: AuthzContract,
179
+ opts: { schema?: string; governedRoles?: ReadonlySet<string> } = {},
180
+ ): Fingerprint {
167
181
  // The prediction is a DECLARED-vs-live comparison, so it must hash through the
168
182
  // models' governed set — the live side of any comparison against it must use
169
183
  // the same set (see governedLiveFingerprint). Unfiltered, every ride-through
@@ -204,7 +218,12 @@ export function predictLiveFingerprint(
204
218
  ...declared.contract.tables,
205
219
  ...contract.tables.filter((t) => ridesThrough(t.table)),
206
220
  ];
207
- return fingerprintState(mergedSnapshot, mergedAuthz, { governedRoles }).hash;
221
+ // Sequence ACLs (v11): declared tables DERIVE their reconciled (inherited) target, so pass
222
+ // measured ACLs ONLY for rides-through tables — the ones reconciliation never touches, whose
223
+ // live ACLs carry through unchanged. Passing all measured would make a declared table's
224
+ // predicted endpoint read its pre-reconcile ACL and never converge with the plan it mints.
225
+ const ridesThroughSeqGrants = (contract.sequenceGrants ?? []).filter((s) => ridesThrough(s.table));
226
+ return fingerprintState(mergedSnapshot, mergedAuthz, { governedRoles, sequenceGrants: ridesThroughSeqGrants });
208
227
  }
209
228
 
210
229
  /**
@@ -218,7 +237,9 @@ export function governedLiveFingerprint(
218
237
  contract: AuthzContract,
219
238
  governedRoles: ReadonlySet<string>,
220
239
  ): string {
221
- return fingerprintState(snapshot, contract.tables, { governedRoles }).hash;
240
+ // A true live read: measure every table's serial-sequence ACLs (v11), so this half sees a
241
+ // sequence REVOKE the predicted endpoint's derived target does not.
242
+ return fingerprintState(snapshot, contract.tables, { governedRoles, sequenceGrants: contract.sequenceGrants }).hash;
222
243
  }
223
244
 
224
245
  /**