@everystack/cli 0.4.43 → 0.4.45

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.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Table OWNERSHIP — reported, never compared.
3
+ *
4
+ * A table's owner bypasses its own RLS policies unless the table is FORCEd. So the
5
+ * pair (owner, FORCE) decides whether the policies this repo argues about are actually
6
+ * in force for the principal that writes. Neither half is visible in the contract:
7
+ * `GRANTS_SQL` deliberately EXCLUDES the owner's self-grant because the owner is
8
+ * environment-specific (the dev user locally, the master/operator role when deployed),
9
+ * and committing it would make the contract non-portable and drift on every stage.
10
+ *
11
+ * That exclusion is right, and it is exactly why this module exists. The owner is a
12
+ * fact about the DEPLOYED database that no committed artifact can carry, so the only
13
+ * honest treatment is to NAME it on every surface that can see a live database, and
14
+ * let the human read it. Locally it is the dev user and nothing fires; on the stage it
15
+ * is somebody else, and that difference is the whole point.
16
+ *
17
+ * **The owner is NOT part of the contract.** It does not enter `TableContract`, it does
18
+ * not enter `canonicalAuthz`, and it therefore does not touch the fingerprint. Putting
19
+ * an environment-specific field into the content address would make the same declared
20
+ * state hash differently per stage — and adding any field to the canonical form is a
21
+ * FORMAT BUMP, which this does not need: it needs to be reported, not equated.
22
+ *
23
+ * Search_path: this query deparses nothing (`pg_get_expr` is not involved) — it reads
24
+ * catalog names directly — so it needs no canonical-path pinning to be stable.
25
+ */
26
+
27
+ import type { WrittenBy, ModelDescriptor } from '@everystack/model';
28
+ import { IGNORED_SCHEMAS, type QueryRunner, type AuthzContract } from './authz-contract.js';
29
+
30
+ /**
31
+ * Every base table with its owner. The exclusions are RLS_SQL's, verbatim — base
32
+ * relations only, no `pg_catalog` / `information_schema` / `pg_%`, no extension-owned
33
+ * objects — so the two queries agree about which tables exist and the report can be
34
+ * joined onto the contract without either side inventing a table the other cannot see.
35
+ */
36
+ export const TABLE_OWNERS_SQL = `
37
+ SELECT
38
+ n.nspname AS schema,
39
+ c.relname AS "table",
40
+ r.rolname AS owner
41
+ FROM pg_class c
42
+ JOIN pg_namespace n ON n.oid = c.relnamespace
43
+ JOIN pg_roles r ON r.oid = c.relowner
44
+ WHERE c.relkind = 'r'
45
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
46
+ AND n.nspname NOT LIKE 'pg_%'
47
+ AND NOT EXISTS (
48
+ SELECT 1 FROM pg_depend d
49
+ WHERE d.objid = c.oid AND d.deptype = 'e'
50
+ )
51
+ ORDER BY n.nspname, c.relname;
52
+ `.trim();
53
+
54
+ export interface TableOwnerRow {
55
+ schema: string;
56
+ table: string;
57
+ owner: unknown;
58
+ }
59
+
60
+ /** One live table's owner, keyed by the same schema-qualified identity the contract uses. */
61
+ export interface TableOwner {
62
+ /** Schema-qualified table, e.g. `public.posts`. */
63
+ table: string;
64
+ /** The role that owns it. */
65
+ owner: string;
66
+ }
67
+
68
+ /**
69
+ * Fold owner rows into the reportable set. Tooling schemas are dropped here rather than
70
+ * in SQL, matching `assembleContract` — one definition of "not the app's", applied the
71
+ * same way on both sides.
72
+ */
73
+ export function assembleOwners(rows: TableOwnerRow[]): TableOwner[] {
74
+ return rows
75
+ .filter((row) => !IGNORED_SCHEMAS.has(row.schema))
76
+ .map((row) => ({ table: `${row.schema}.${row.table}`, owner: String(row.owner ?? '') }))
77
+ .sort((a, b) => a.table.localeCompare(b.table));
78
+ }
79
+
80
+ /** Read the live table owners. Read-only, one query, no canonical-path pinning needed. */
81
+ export async function introspectTableOwners(run: QueryRunner): Promise<TableOwner[]> {
82
+ return assembleOwners((await run(TABLE_OWNERS_SQL)) as TableOwnerRow[]);
83
+ }
84
+
85
+ /** One governed table's ownership posture, with the model's own intent beside it. */
86
+ export interface OwnershipRow {
87
+ /** Schema-qualified table. */
88
+ table: string;
89
+ /** The live owner, or null when the ownership query did not return this table. */
90
+ owner: string | null;
91
+ /** Live `relforcerowsecurity` — whether the owner is subject to its own policies. */
92
+ forced: boolean;
93
+ /** The model's declared write principal, or null when there is no model (db:pull). */
94
+ writtenBy: WrittenBy | null;
95
+ /** The owner writes past every policy on this table, and the model did not ask for that. */
96
+ flagged: boolean;
97
+ }
98
+
99
+ /**
100
+ * Build the ownership report for the tables the models govern.
101
+ *
102
+ * The flag rule follows the FORCE axiom the compiler already encodes: FORCE iff the
103
+ * write principal is subject to its own policies.
104
+ *
105
+ * - `writtenBy: 'app'` + FORCE off -> FLAGGED. The app writes through the owner
106
+ * connection past policies the model believes are enforcing. A silent bypass.
107
+ * - `writtenBy: 'worker' | 'functions'` + FORCE off -> reported, NOT flagged. These
108
+ * write on the owner connection on purpose; a FORCEd table would block them (on RDS
109
+ * the owner is not a superuser). Correct, and still named.
110
+ * - FORCE on -> reported, not flagged, whatever the principal.
111
+ *
112
+ * Without models (`db:pull`, where the models do not exist yet) `writtenBy` is null and
113
+ * NOTHING is flagged: the intent that would make a bypass wrong has not been declared.
114
+ * The owner is still named — that half is a live fact the pull genuinely saw.
115
+ */
116
+ export function buildOwnershipReport(
117
+ owners: readonly TableOwner[],
118
+ live: AuthzContract,
119
+ opts: { models?: readonly ModelDescriptor[]; tables?: readonly string[] } = {},
120
+ ): OwnershipRow[] {
121
+ const ownerByTable = new Map(owners.map((o) => [o.table, o.owner]));
122
+ const liveByTable = new Map(live.tables.map((t) => [t.table, t]));
123
+
124
+ // Governed = the tables the models declare when there are models; else the caller's
125
+ // explicit subject list (the pull's schema-scoped tables); else every live table.
126
+ const subjects: { table: string; writtenBy: WrittenBy | null }[] = opts.models
127
+ ? opts.models.map((m) => ({ table: `${m.schema}.${m.table}`, writtenBy: m.writtenBy }))
128
+ : (opts.tables ?? live.tables.map((t) => t.table)).map((table) => ({ table, writtenBy: null }));
129
+
130
+ const rows: OwnershipRow[] = [];
131
+ for (const s of subjects) {
132
+ const l = liveByTable.get(s.table);
133
+ // A model with no live table is a CREATE — it has no owner yet, and nothing to report.
134
+ if (!l) continue;
135
+ const forced = l.rls.forced;
136
+ rows.push({
137
+ table: s.table,
138
+ owner: ownerByTable.get(s.table) ?? null,
139
+ forced,
140
+ writtenBy: s.writtenBy,
141
+ flagged: s.writtenBy === 'app' && !forced,
142
+ });
143
+ }
144
+ return rows.sort((a, b) => a.table.localeCompare(b.table));
145
+ }
146
+
147
+ /**
148
+ * Render the report. Grouped by owner, because one operator role usually owns the whole
149
+ * schema and thirty-three identical lines would bury the findings — the same reason
150
+ * `db:pull` says "grants exist for X" once instead of repeating it in every model.
151
+ *
152
+ * When ONE owner owns everything, that is a single line naming it. When there is more
153
+ * than one, every owner is named WITH its tables: a split ownership is itself the
154
+ * anomaly, and a count alone would not say which table changed hands.
155
+ */
156
+ export function renderOwnershipReport(rows: readonly OwnershipRow[]): string[] {
157
+ if (rows.length === 0) return [];
158
+
159
+ const byOwner = new Map<string, OwnershipRow[]>();
160
+ for (const r of rows) {
161
+ const key = r.owner ?? '(not returned by the ownership query)';
162
+ let group = byOwner.get(key);
163
+ if (!group) byOwner.set(key, (group = []));
164
+ group.push(r);
165
+ }
166
+ const owners = [...byOwner.keys()].sort();
167
+
168
+ const lines = [
169
+ `Ownership of ${rows.length} governed table(s) — the owner bypasses RLS unless the table is FORCEd:`,
170
+ ];
171
+ for (const owner of owners) {
172
+ const group = byOwner.get(owner)!;
173
+ const forced = group.filter((r) => r.forced).length;
174
+ const notForced = group.length - forced;
175
+ const tail = notForced > 0 ? `, ${notForced} NOT forced` : '';
176
+ lines.push(` ${owner} — ${group.length} table(s): ${forced} FORCE on${tail}`);
177
+ if (owners.length > 1) {
178
+ for (const r of group) lines.push(` ${r.table}${r.forced ? '' : ' (not forced)'}`);
179
+ }
180
+ }
181
+
182
+ const flagged = rows.filter((r) => r.flagged);
183
+ for (const r of flagged) {
184
+ lines.push(
185
+ ` ! ${r.table} — written by the app but NOT FORCEd: owner ${r.owner ?? '(unknown)'} writes past every policy on this table.`,
186
+ );
187
+ }
188
+ const unknown = rows.filter((r) => r.owner === null);
189
+ for (const r of unknown) {
190
+ lines.push(` ? ${r.table} — the ownership query did not return this table; its owner is unknown.`);
191
+ }
192
+ return lines;
193
+ }
@@ -17,6 +17,7 @@
17
17
 
18
18
  import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
19
19
  import { effectivePolicyCheck, parenthesizeOnce } from './authz-contract.js';
20
+ import { matchPolicies } from './authz-identity.js';
20
21
  import { quoteQualified } from './pg-ident.js';
21
22
 
22
23
  /**
@@ -135,20 +136,9 @@ function policyDropSql(table: string, name: string): string {
135
136
  return `DROP POLICY IF EXISTS ${name} ON ${table};`;
136
137
  }
137
138
 
138
- /**
139
- * Two policies are the same authorization when every diffed field matches. The check is
140
- * compared through {@link effectivePolicyCheck}, i.e. the server's own defaulting rule
141
- * a live `FOR ALL USING (true)` and a compiled `FOR ALL USING (true) WITH CHECK (true)`
142
- * authorize identically, and reconciling them would emit a DROP + CREATE that changes
143
- * nothing.
144
- */
145
- function policiesEqual(a: PolicyContract, b: PolicyContract): boolean {
146
- return a.command === b.command
147
- && a.permissive === b.permissive
148
- && a.roles.join(',') === b.roles.join(',')
149
- && (a.using ?? '') === (b.using ?? '')
150
- && (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
151
- }
139
+ // Policy equality lives in authz-identity.ts and nowhere else. The copy that used to sit here
140
+ // compared roles as `roles.join(',')`, which made `['a,b']` equal `['a','b']` a false match
141
+ // on a legal role name. Two implementations of one predicate is how these surfaces drift.
152
142
 
153
143
  function tableMap(c: AuthzContract): Map<string, TableContract> {
154
144
  return new Map(c.tables.map((t) => [t.table, t]));
@@ -229,20 +219,22 @@ function reconcileRls(table: string, d: TableContract, l: TableContract | undefi
229
219
  if (!d.rls.forced && l?.rls.forced) out.push(`ALTER TABLE ${table} NO FORCE ROW LEVEL SECURITY;`);
230
220
  }
231
221
 
222
+ /**
223
+ * Reconcile policies through the SHARED matcher — the same equivalence the differ and the
224
+ * fingerprint use, so the three can never contradict each other about one database.
225
+ *
226
+ * A live policy carrying the declared authorization under a different name, or covering in one
227
+ * policy the roles the compiler splits apart, is ADOPTED: it stays exactly as it is and
228
+ * nothing is emitted. Renaming it would be DDL that buys spelling.
229
+ *
230
+ * The matcher requires full field equality, so this can only turn a DROP + CREATE pair into a
231
+ * no-op — never emit DDL the name-keyed version would not have emitted.
232
+ */
232
233
  function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
233
- const dMap = new Map(d.policies.map((p) => [p.name, p]));
234
- const lMap = new Map((l?.policies ?? []).map((p) => [p.name, p]));
235
-
236
- // Drops first: policies removed, or changed (dropped then recreated below).
237
- for (const [name, lp] of lMap) {
238
- const dp = dMap.get(name);
239
- if (!dp || !policiesEqual(dp, lp)) out.push(policyDropSql(table, name));
240
- }
241
- // Creates: policies added, or changed (recreated to the declared form).
242
- for (const [name, dp] of dMap) {
243
- const lp = lMap.get(name);
244
- if (!lp || !policiesEqual(dp, lp)) out.push(policyCreateSql(table, dp));
245
- }
234
+ const m = matchPolicies(d.policies, l?.policies ?? []);
235
+ // Drops first, so a replaced policy never briefly co-exists with its old form.
236
+ for (const name of m.toDrop) out.push(policyDropSql(table, name));
237
+ for (const p of m.toCreate) out.push(policyCreateSql(table, p));
246
238
  }
247
239
 
248
240
  function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
@@ -30,6 +30,8 @@ import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrati
30
30
  import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract.js';
31
31
  import { compileTableContract } from '../authz-compile.js';
32
32
  import { governedRoleSet, ungovernedGrants, renderGrantExemptions } from '../authz-reconcile.js';
33
+ import { classifyAdoption, renderAdoptionReport } from '../authz-adoption-class.js';
34
+ import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership.js';
33
35
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
34
36
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
35
37
  import { resolveModelsPath } from '../models-path.js';
@@ -151,6 +153,48 @@ function reportGrantExemptions(liveAuthz: AuthzContract | undefined, models: Mod
151
153
  info(`Bring them under the reconciler with defineModule({ governedRoles: [...] }), or leave them exempt — either way they are listed here every run.`);
152
154
  }
153
155
 
156
+ /**
157
+ * WHY does each authorization statement exist? — printed on every brownfield generate.
158
+ *
159
+ * A first plan against an existing database is long, and the length is not the problem: the
160
+ * problem is that nobody can tell which statements are everystack imposing its own spelling
161
+ * and which are real differences. `convention` is the count that indicts US, and driving it to
162
+ * zero is the whole point — so it is named first and never hidden behind a flag.
163
+ */
164
+ function reportAdoptionClasses(liveAuthz: AuthzContract | undefined, models: ModelDescriptor[]): void {
165
+ if (!liveAuthz) return; // greenfield — every statement is additive, nothing to adjudicate
166
+ const declared: AuthzContract = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
167
+ const { counts } = classifyAdoption(declared, liveAuthz);
168
+ const total = Object.values(counts).reduce((a, b) => a + b, 0);
169
+ if (total === 0) return;
170
+ info(`${total} authorization statement(s), by why they exist:`);
171
+ for (const line of renderAdoptionReport(counts)) info(line);
172
+ }
173
+
174
+ /**
175
+ * WHO owns each governed table, and is that owner subject to its own RLS? — printed
176
+ * on every generate that has a live database.
177
+ *
178
+ * The owner never appears in the contract or the fingerprint: it is the dev user locally
179
+ * and the operator role on a stage, so committing it would drift on every environment.
180
+ * That is precisely why it must be SAID. A deployed owner that differs from the one the
181
+ * author has in mind is invisible in every artifact, and an unforced table lets that owner
182
+ * write past every policy in this repo. The report names the owner even when nothing is
183
+ * wrong, because silence here is what makes the mismatch impossible to notice.
184
+ */
185
+ function reportOwnership(
186
+ liveAuthz: AuthzContract | undefined,
187
+ owners: readonly TableOwner[],
188
+ models: ModelDescriptor[],
189
+ ): void {
190
+ if (!liveAuthz) return; // greenfield — nothing owns anything yet
191
+ const rows = buildOwnershipReport(owners, liveAuthz, { models });
192
+ for (const line of renderOwnershipReport(rows)) {
193
+ if (line.trimStart().startsWith('!')) warn(line.trim());
194
+ else info(line);
195
+ }
196
+ }
197
+
154
198
  async function readJournal(migrationsDir: string): Promise<Journal | null> {
155
199
  try {
156
200
  return JSON.parse(await fs.readFile(path.join(migrationsDir, 'meta', '_journal.json'), 'utf8'));
@@ -203,6 +247,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
203
247
  let declaredDb: DeclaredDerived | null = null;
204
248
  let current;
205
249
  let liveAuthz;
250
+ let liveOwners: TableOwner[] = [];
206
251
  let runner!: QueryRunner;
207
252
  let end: (() => Promise<void>) | undefined;
208
253
  try {
@@ -236,6 +281,9 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
236
281
  current = await introspectSchema(runner);
237
282
  step('Introspecting authorization (rls + grants + policies)...');
238
283
  liveAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
284
+ // Ownership rides the same connection: it is not part of the contract (it never
285
+ // enters the fingerprint), but it decides whether the contract is actually in force.
286
+ liveOwners = await introspectTableOwners(runner);
239
287
  if (!apply) await end?.();
240
288
  } catch (err: any) {
241
289
  fail(err.message);
@@ -257,6 +305,8 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
257
305
  // ungoverned grantee's privileges alone rather than revoking them; that is only
258
306
  // defensible because the artifact says exactly whose access it chose not to govern.
259
307
  reportGrantExemptions(liveAuthz, models, declaredDb?.governedRoles ?? []);
308
+ reportAdoptionClasses(liveAuthz, models);
309
+ reportOwnership(liveAuthz, liveOwners, models);
260
310
  console.log('');
261
311
  if (statements.length === 0) {
262
312
  success(`db:generate — the live database already matches the models. ${dryRun ? 'Nothing to preview.' : 'No migration written.'}`);
@@ -22,6 +22,8 @@ import fs from 'node:fs/promises';
22
22
  import { spawnSync } from 'node:child_process';
23
23
  import type { ModelDescriptor } from '@everystack/model';
24
24
  import { introspectContract, type QueryRunner } from '../authz-contract.js';
25
+ import { classifyAdoption, renderAdoptionReport } from '../authz-adoption-class.js';
26
+ import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport } from '../authz-ownership.js';
25
27
  import { introspectSchema } from '../schema-introspect.js';
26
28
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
27
29
  import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan.js';
@@ -39,7 +41,7 @@ import { governedRoleSet, ungovernedGrants } from '../authz-reconcile.js';
39
41
  import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline.js';
40
42
  import { compileTableContract } from '../authz-compile.js';
41
43
  import { reportPipelineLastRun } from './pipeline-run.js';
42
- import { step, success, fail, info, warn } from '../output.js';
44
+ import { step, success, fail, info, warn, reserveStdoutForData } from '../output.js';
43
45
 
44
46
  const DEFAULT_OUT = 'db.plan.json';
45
47
 
@@ -61,6 +63,12 @@ function isGitIgnored(file: string): boolean {
61
63
  }
62
64
 
63
65
  export async function dbPlanCommand(flags: Record<string, string>): Promise<void> {
66
+ const out = flags.out || DEFAULT_OUT;
67
+ // `--out -` makes stdout the artifact. Declared before anything prints, so the summary,
68
+ // the adoption report and the ownership report land on stderr and the plan JSON is the
69
+ // only thing on stdout — otherwise `db:plan --out - | jq` reads a report as its input.
70
+ if (out === '-') reserveStdoutForData();
71
+
64
72
  let dbSource: DbSource;
65
73
  try {
66
74
  dbSource = resolveDbSource(flags);
@@ -95,6 +103,9 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
95
103
  step('Asking the target its fingerprint (introspecting state + authz)...');
96
104
  const snapshot = await introspectSchema(runner);
97
105
  const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
106
+ // Not part of the plan and not part of either fingerprint — the owner is
107
+ // environment-specific. It is read here so the REVIEW surface can name it.
108
+ const owners = await introspectTableOwners(runner);
98
109
  // The modules' widened governed-role set. A barrel that exports only `models` has none,
99
110
  // which is the greenfield default: govern exactly what the models name.
100
111
  let declaredGovernedRoles: string[] = [];
@@ -134,7 +145,6 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
134
145
  process.exit(1);
135
146
  }
136
147
 
137
- const out = flags.out || DEFAULT_OUT;
138
148
  const body = JSON.stringify(plan, null, 2) + '\n';
139
149
  if (out === '-') {
140
150
  console.log(body);
@@ -145,6 +155,28 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
145
155
  for (const line of buildPlanSummary(plan)) info(line);
146
156
  info(`plan_ref: ${planHash(plan).slice(0, 12)}`);
147
157
 
158
+ // WHY does each authorization statement exist? — the same report db:generate prints,
159
+ // on the artifact a human actually reviews before applying.
160
+ //
161
+ // Re-DERIVED from live every mint, never stored: a recorded claim about what the models
162
+ // fail to capture goes stale the moment someone closes the gap, and a stale note is worse
163
+ // than none. `capability` is the count that says "this plan removes something the model
164
+ // cannot express" — the operator reading this is the last one who can catch it.
165
+ const adoption = classifyAdoption(declaredAuthz, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles) });
166
+ const adoptionTotal = Object.values(adoption.counts).reduce((a, b) => a + b, 0);
167
+ if (adoptionTotal > 0) {
168
+ info(`${adoptionTotal} authorization statement(s), by why they exist:`);
169
+ for (const line of renderAdoptionReport(adoption.counts)) info(line);
170
+ }
171
+
172
+ // WHO owns the tables this plan authorizes, and does that owner obey the policies it
173
+ // is about to write? Re-derived live every mint, never stored — the owner is a fact
174
+ // about THIS target, and the target is the one thing a committed artifact cannot carry.
175
+ for (const line of renderOwnershipReport(buildOwnershipReport(owners, contract, { models }))) {
176
+ if (line.trimStart().startsWith('!')) warn(line.trim());
177
+ else info(line);
178
+ }
179
+
148
180
  // The fast-forward rule, warn-only at mint time (db:apply enforces it):
149
181
  // a stale checkout mints a legal-looking plan whose edge REVERTS merged
150
182
  // work — say so on the review surface, where a human still reads it.
@@ -34,7 +34,8 @@ import { fingerprintLive } from '../schema-fingerprint.js';
34
34
  import { ungovernedGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
35
35
  import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline.js';
36
36
  import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
37
- import { introspectContract, type TableContract } from '../authz-contract.js';
37
+ import { introspectContract, type TableContract, type AuthzContract } from '../authz-contract.js';
38
+ import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership.js';
38
39
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
39
40
  import { renderDerivedSource, renderDerivedFile, type DerivedRenderResult } from '../derived-render.js';
40
41
  import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
@@ -83,6 +84,24 @@ export function keyCandidates(row: Record<string, unknown>, cols: string[]): str
83
84
  return cols.filter((_, i) => Number(row[`c${i}`]) === n && Number(row[`d${i}`]) === n);
84
85
  }
85
86
 
87
+ /**
88
+ * The specifier the generated `index.ts` uses to import the `--derived-out` file.
89
+ *
90
+ * Both paths are resolved against CWD first, so this works whichever way either was written
91
+ * (`db/models` + `db/models/derived.ts`, or absolute, or `./db/models/`). Extension stripped
92
+ * (the barrel's own imports are extensionless), separators normalized for Windows, and a
93
+ * same-directory result gets the explicit `./` a bare `derived` would lack.
94
+ *
95
+ * `--out` may also name a single `.ts` file, in which case the barrel IS that file and the
96
+ * specifier is relative to its directory.
97
+ */
98
+ export function derivedImportSpecifier(out: string, derivedOut: string): string {
99
+ const barrelDir = out.endsWith('.ts') ? path.dirname(path.resolve(out)) : path.resolve(out);
100
+ const target = path.resolve(derivedOut).replace(/\.ts$/, '');
101
+ const rel = path.relative(barrelDir, target).split(path.sep).join('/');
102
+ return rel.startsWith('.') ? rel : `./${rel}`;
103
+ }
104
+
86
105
  /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
87
106
  function lambdaRunner(region: string, fn: string): QueryRunner {
88
107
  return async (sql: string) => {
@@ -132,6 +151,9 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
132
151
  /** Adoption observation — the foreign grantees present, and what the claim is true OF. */
133
152
  let pulledExemptions: GrantExemption[] = [];
134
153
  let pulledFingerprint = '';
154
+ /** Live ownership — reported, never rendered into a model (it is environment-specific). */
155
+ let liveContract: AuthzContract | undefined;
156
+ let liveOwners: TableOwner[] = [];
135
157
  let derivedCatalog: DerivedCatalog | undefined;
136
158
  let matviewColumns: Map<string, ColumnSchema[]> | undefined;
137
159
  let candidatesByIdentity: Map<string, string[]> | undefined;
@@ -158,6 +180,11 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
158
180
  if (abilities === 'live') {
159
181
  note('Introspecting live authorization (grants + policies)...');
160
182
  const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
183
+ liveContract = contract;
184
+ // The owner is NOT rendered into the models — it is the dev user here and the
185
+ // operator role on a stage, so a model that declared it would drift everywhere.
186
+ // It is read to be SAID, on the one surface that is looking at the database.
187
+ liveOwners = await introspectTableOwners(runner);
161
188
  liveAuthz = new Map(contract.tables.map((t) => [t.table, t]));
162
189
  detail(`${liveAuthz.size} table(s) carry authorization.`);
163
190
  // Roles outside the model vocabulary (anon/authenticated/admin) are usually ONE
@@ -225,6 +252,16 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
225
252
  process.exit(1);
226
253
  }
227
254
 
255
+ // WHO owns the tables being pulled. Nothing is FLAGGED here: flagging needs a declared
256
+ // write principal to contradict, and the models this pull is about to write do not exist
257
+ // yet. Naming the owner is the half the pull genuinely saw — and the half that vanishes
258
+ // from every artifact afterwards, because the contract excludes the owner on purpose.
259
+ if (liveContract) {
260
+ for (const line of renderOwnershipReport(
261
+ buildOwnershipReport(liveOwners, liveContract, { tables: pulled.map((t) => t.table) }),
262
+ )) note(line);
263
+ }
264
+
228
265
  // The derived layer (B5): rendered from the live catalog with dependsOn from the real
229
266
  // edge graph. Everything inexpressible is a stderr warning AND an inline FIXME.
230
267
  const knownTables = new Map(pulled.map((t) => [t.table, modelVarName(t.table)]));
@@ -269,15 +306,26 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
269
306
  process.exit(0);
270
307
  }
271
308
  }
272
- // With --derived-out, the embedded copy would duplicate every descriptor — the
273
- // models output carries models only.
309
+ // With --derived-out, the embedded copy would duplicate every descriptor — the models
310
+ // output carries models only, and the barrel IMPORTS the layer from its own file. It used
311
+ // to carry neither, so the same command that wrote 120 descriptors emitted a
312
+ // `defineModule({ models })` that excluded every one of them, and db:plan then compared
313
+ // against a fraction of the database while printing a confident statement count.
274
314
  const embeddedDerived = derivedOut ? undefined : derived;
315
+ const externalDerived = derivedOut && flags.out
316
+ ? {
317
+ specifier: derivedImportSpecifier(flags.out, derivedOut),
318
+ sequences: derived.sequenceNames.length > 0,
319
+ materializedTables: derived.materializedTableNames.length > 0,
320
+ derived: derived.names.length > 0,
321
+ }
322
+ : undefined;
275
323
 
276
324
  let source: string;
277
325
  if (flags.out && !flags.out.endsWith('.ts')) {
278
326
  // A directory: one file per model + index.ts — the default shape for a real app.
279
327
  const dir = path.resolve(flags.out);
280
- const files = renderModelFiles(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
328
+ const files = renderModelFiles(current, { schema, abilities, liveAuthz, derived: embeddedDerived, externalDerived });
281
329
  try {
282
330
  await fs.mkdir(dir, { recursive: true });
283
331
  const written = new Set(files.map((f) => f.file));
@@ -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, 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,6 +61,12 @@ 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
+ /**
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;
64
70
  /** The predicted live fingerprint after apply — exact, unmodeled-aware. */
65
71
  toFingerprint: string;
66
72
  /** The models-only fingerprint — context for the strong claim. Equals `to` when nothing is unmodeled. */
@@ -190,6 +196,7 @@ export function mintEdgePlan(
190
196
  return {
191
197
  v: PLAN_VERSION,
192
198
  fromFingerprint: fingerprintLive(snapshot, contract).hash,
199
+ fpVersion: FINGERPRINT_VERSION,
193
200
  toFingerprint: predictLiveFingerprint(models, snapshot, contract, { schema: opts.schema }),
194
201
  declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
195
202
  statements,
@@ -250,7 +257,12 @@ export type PlanPrecondition = { ok: true } | { ok: false; reason: string };
250
257
 
251
258
  /** The lock: apply only when the target is exactly where the plan started. */
252
259
  export function checkPlanPrecondition(plan: EdgePlan, liveFingerprint: string): PlanPrecondition {
253
- if (liveFingerprint === plan.fromFingerprint) return { ok: true };
260
+ const cmp = compareStoredFingerprint({ hash: plan.fromFingerprint, v: plan.fpVersion }, liveFingerprint);
261
+ if (cmp.kind === 'match') return { ok: true };
262
+ // A plan minted under an older canonical form cannot be compared at all. Saying "the state
263
+ // moved (a concurrent apply or a hand edit)" would send the operator hunting a change that
264
+ // never happened — plans are ephemeral by design, so the honest answer is re-mint.
265
+ if (cmp.kind === 'format-changed') return { ok: false, reason: formatChangedMessage(cmp) };
254
266
  return {
255
267
  ok: false,
256
268
  reason:
package/src/cli/index.ts CHANGED
@@ -45,27 +45,11 @@ import { auditCommand } from './commands/audit.js';
45
45
  import { uiAuditCommand } from './commands/ui-audit.js';
46
46
  import { runbookCommand } from './commands/runbook.js';
47
47
  import { fail } from './output.js';
48
+ import { parseFlags } from './parse-flags.js';
48
49
 
49
50
  const args = process.argv.slice(2);
50
51
  const command = args[0];
51
52
 
52
- function parseFlags(args: string[]): Record<string, string> {
53
- const flags: Record<string, string> = {};
54
- for (let i = 0; i < args.length; i++) {
55
- // Handle both --flag and -flag
56
- if (args[i].startsWith('--')) {
57
- const key = args[i].slice(2);
58
- const value = args[i + 1] && !args[i + 1].startsWith('-') ? args[++i] : 'true';
59
- flags[key] = value;
60
- } else if (args[i].startsWith('-') && args[i].length > 1) {
61
- const key = args[i].slice(1);
62
- const value = args[i + 1] && !args[i + 1].startsWith('-') ? args[++i] : 'true';
63
- flags[key] = value;
64
- }
65
- }
66
- return flags;
67
- }
68
-
69
53
  /**
70
54
  * Auto-detect HOST_URL from SST outputs.
71
55
  * SST writes .sst/outputs.json after every deploy with { routerUrl, apiUrl, ... }.