@everystack/cli 0.4.41 → 0.4.43

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.41",
3
+ "version": "0.4.43",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -109,7 +109,7 @@
109
109
  "structured-headers": "1.0.1",
110
110
  "tsx": "4.21.0",
111
111
  "typescript": "5.9.3",
112
- "@everystack/model": "0.4.7"
112
+ "@everystack/model": "0.4.8"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "@everystack/server": ">=0.4.0",
@@ -22,6 +22,7 @@
22
22
 
23
23
  import type { ModelDescriptor, Ability } from '@everystack/model';
24
24
  import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract.js';
25
+ import { parenthesizeOnce } from './authz-contract.js';
25
26
 
26
27
  export interface CompileOptions {
27
28
  /** Schema the table lives in. Default: `public`. */
@@ -176,9 +177,17 @@ function resolveVia(model: ModelDescriptor): ViaRef | null {
176
177
  };
177
178
  }
178
179
 
179
- /** The conventional soft-delete column SQL name, when the model has one. */
180
+ /**
181
+ * The soft-delete column SQL name, when the model DECLARES one.
182
+ *
183
+ * Keyed on `softDelete`, never on the field's presence. A column named `deleted_at` is not a
184
+ * statement of visibility intent, and inferring the guard from it made the compiler author a
185
+ * security predicate nobody wrote — one that `db:pull` then handed to every brownfield model
186
+ * automatically, so the first plan narrowed a policy the adopter had written. `defineModel`
187
+ * refuses to guess: a model with the field and a public read must say which it means.
188
+ */
180
189
  function softDeleteColumn(model: ModelDescriptor): string | null {
181
- return 'deletedAt' in model.fields ? 'deleted_at' : null;
190
+ return model.softDelete ? 'deleted_at' : null;
182
191
  }
183
192
 
184
193
  /**
@@ -228,7 +237,11 @@ function rawPredicate(value: unknown, where: string): string | null {
228
237
  `${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — an RLS predicate is authored, never stringly assembled.`,
229
238
  );
230
239
  }
231
- return `(${text.trim()})`;
240
+ // Parenthesized exactly once. A pulled predicate arrives already fully parenthesized
241
+ // (that is how pg_get_expr deparses it), and wrapping it again made the compiled policy
242
+ // differ from the live one by a single layer of parens — enough for the reconciler to
243
+ // plan a DROP + CREATE that changed nothing.
244
+ return parenthesizeOnce(text.trim());
232
245
  }
233
246
 
234
247
  /** AND-compose predicates, dropping the vacuous ones. A condition only ever NARROWS. */
@@ -266,11 +279,20 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
266
279
  // A "row-scoped" condition is either a direct owner or a transitive via.
267
280
  const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
268
281
 
282
+ /**
283
+ * A read scoped to a named role — `can('read', { role })`. It compiles to its own
284
+ * `<table>_select_<role>` policy, so its predicate must NOT also be folded into the
285
+ * public read's predicate: a rule written for one role would otherwise narrow what
286
+ * every other role can see.
287
+ */
288
+ const isRoleRead = (a: Ability): boolean =>
289
+ a.action === 'read' && Boolean(a.condition.role) && !isColumnRead(a);
290
+
269
291
  // Author-supplied predicates, per action. `manage` carries no predicate of its own —
270
292
  // it is the admin bypass — so only the specific verbs are consulted.
271
293
  const predFor = (action: Ability['action']): string | null => {
272
294
  for (const a of model.abilities) {
273
- if (a.action !== action) continue;
295
+ if (a.action !== action || isRoleRead(a)) continue;
274
296
  const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('${action}')`);
275
297
  if (p) return p;
276
298
  }
@@ -336,6 +358,26 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
336
358
  policy(`${t}_select_self`, 'SELECT', ['authenticated'], rowPred, null);
337
359
  }
338
360
 
361
+ // Role-scoped reads — `can('read', { role })`, with or without a predicate. These match
362
+ // none of the branches above: the public read requires NO role, and the owner/column reads
363
+ // require a row-scoping condition. Before this they compiled to a SELECT grant and no
364
+ // policy at all — and since `rls.enabled` is unconditional, the role saw ZERO rows while
365
+ // the model read as though it could see its own. The declared predicate was computed and
366
+ // discarded. That is the same failure `rawPredicate` throws over, one level up: a predicate
367
+ // that evaporates is an authorization hole wearing the costume of a working rule.
368
+ for (const a of abilities.filter(isRoleRead)) {
369
+ const role = a.condition.role!;
370
+ const name = `${t}_select_${role}`;
371
+ const where = `${table}: can('read', { role: '${role}' })`;
372
+ if (policies.some((p) => p.name === name)) {
373
+ throw new Error(
374
+ `${where} collides with the ${name} policy already compiled from this model's public read. `
375
+ + `Declare one or the other: permissive policies OR together, so the role-scoped rule could only ever widen — never the narrowing it reads as.`,
376
+ );
377
+ }
378
+ policy(name, 'SELECT', [role], rawPredicate(a.condition.sql ?? a.condition.where, where) ?? 'true', null);
379
+ }
380
+
339
381
  // owner-gated writes. INSERT checks ownership (you can only create rows you own);
340
382
  // UPDATE gates + checks; DELETE gates. `rowPred` is the direct-owner or `via:` predicate.
341
383
  //
@@ -82,6 +82,51 @@ export interface PolicyContract {
82
82
  check: string | null;
83
83
  }
84
84
 
85
+ /**
86
+ * The WITH CHECK expression PostgreSQL will actually enforce.
87
+ *
88
+ * For `ALL` and `UPDATE`, an omitted `WITH CHECK` is not "no check" — the server reuses the
89
+ * `USING` expression. So a live `FOR ALL USING (true)` (which is what `CREATE POLICY … USING
90
+ * (true)` records, with `pg_policies.with_check` NULL) and a compiled `FOR ALL USING (true)
91
+ * WITH CHECK (true)` are the SAME authorization. Comparing the raw fields calls them
92
+ * different and plans a DROP + CREATE — the loudest possible way to say "no change", and it
93
+ * defeats a zero-statement bar even when the SQL is identical.
94
+ *
95
+ * `INSERT` has only a check, `SELECT`/`DELETE` have none — for those the field stands alone
96
+ * and no defaulting applies.
97
+ */
98
+ export function effectivePolicyCheck(p: PolicyContract): string | null {
99
+ if (p.command === 'ALL' || p.command === 'UPDATE') return p.check ?? p.using;
100
+ return p.check;
101
+ }
102
+
103
+ /** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
104
+ export function isWrappedExpression(s: string): boolean {
105
+ if (!s.startsWith('(') || !s.endsWith(')')) return false;
106
+ let depth = 0;
107
+ for (let i = 0; i < s.length; i++) {
108
+ if (s[i] === '(') depth++;
109
+ else if (s[i] === ')') {
110
+ depth--;
111
+ if (depth === 0 && i < s.length - 1) return false;
112
+ }
113
+ }
114
+ return depth === 0;
115
+ }
116
+
117
+ /**
118
+ * A predicate parenthesized exactly once — the normal form both producers must agree on.
119
+ *
120
+ * `pg_get_expr` already deparses a policy predicate fully parenthesized, so `db:pull` renders
121
+ * one into a model verbatim. Wrapping it again produced a compiled predicate identical to
122
+ * live but for one layer of parens, which the equality check called a difference and turned
123
+ * into a same-name DROP + CREATE that changed nothing. Emission already knew this rule; the
124
+ * comparison did not, so both go through this.
125
+ */
126
+ export function parenthesizeOnce(expr: string): string {
127
+ return isWrappedExpression(expr) ? expr : `(${expr})`;
128
+ }
129
+
85
130
  /**
86
131
  * One function, enumerated. The cross-table authorization that RLS does not hold lives
87
132
  * in SECURITY DEFINER functions as imperative gates; the contract records that they
@@ -581,7 +626,9 @@ function diffPolicies(table: string, d: TableContract, l: TableContract, out: Dr
581
626
  if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}→${lp.permissive}`);
582
627
  if (dp.roles.join(',') !== lp.roles.join(',')) changes.push(`roles [${dp.roles}]→[${lp.roles}]`);
583
628
  if ((dp.using ?? '') !== (lp.using ?? '')) changes.push(`USING changed`);
584
- if ((dp.check ?? '') !== (lp.check ?? '')) changes.push(`WITH CHECK changed`);
629
+ // Compared through the server's own defaulting rule, so this agrees with
630
+ // emitReconcileSql — otherwise db:check reports drift the plan does not carry.
631
+ if ((effectivePolicyCheck(dp) ?? '') !== (effectivePolicyCheck(lp) ?? '')) changes.push(`WITH CHECK changed`);
585
632
  if (changes.length) {
586
633
  out.push({ subject: table, kind: 'policy', detail: `policy "${name}": ${changes.join(', ')}` });
587
634
  }
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
19
+ import { effectivePolicyCheck, parenthesizeOnce } from './authz-contract.js';
19
20
  import { quoteQualified } from './pg-ident.js';
20
21
 
21
22
  /**
@@ -116,24 +117,8 @@ export function renderGrantExemptions(exemptions: readonly GrantExemption[]): st
116
117
  return lines;
117
118
  }
118
119
 
119
- /** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
120
- function isWrapped(s: string): boolean {
121
- if (!s.startsWith('(') || !s.endsWith(')')) return false;
122
- let depth = 0;
123
- for (let i = 0; i < s.length; i++) {
124
- if (s[i] === '(') depth++;
125
- else if (s[i] === ')') {
126
- depth--;
127
- if (depth === 0 && i < s.length - 1) return false;
128
- }
129
- }
130
- return depth === 0;
131
- }
132
-
133
120
  /** A USING/WITH CHECK clause expression, parenthesized exactly once. */
134
- function clause(expr: string): string {
135
- return isWrapped(expr) ? expr : `(${expr})`;
136
- }
121
+ const clause = parenthesizeOnce;
137
122
 
138
123
  /** A CREATE POLICY statement from a policy descriptor. */
139
124
  function policyCreateSql(table: string, p: PolicyContract): string {
@@ -150,13 +135,19 @@ function policyDropSql(table: string, name: string): string {
150
135
  return `DROP POLICY IF EXISTS ${name} ON ${table};`;
151
136
  }
152
137
 
153
- /** Two policies are the same authorization when every diffed field matches. */
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
+ */
154
145
  function policiesEqual(a: PolicyContract, b: PolicyContract): boolean {
155
146
  return a.command === b.command
156
147
  && a.permissive === b.permissive
157
148
  && a.roles.join(',') === b.roles.join(',')
158
149
  && (a.using ?? '') === (b.using ?? '')
159
- && (a.check ?? '') === (b.check ?? '');
150
+ && (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
160
151
  }
161
152
 
162
153
  function tableMap(c: AuthzContract): Map<string, TableContract> {
@@ -40,7 +40,7 @@ export interface ProbeResult {
40
40
  }
41
41
 
42
42
  export interface RedTeamFinding {
43
- severity: 'hole' | 'broken' | 'unprobed' | 'inconclusive' | 'inherited';
43
+ severity: 'hole' | 'broken' | 'unprobed' | 'inconclusive' | 'inherited' | 'elevated';
44
44
  role: string;
45
45
  table: string;
46
46
  command: ProbeCommand;
@@ -244,6 +244,9 @@ export interface InheritedGrant {
244
244
  table: string; // schema-qualified
245
245
  command: ProbeCommand;
246
246
  via: string; // the ancestor role(s) supplying it
247
+ /** True when an ancestor is SUPERUSER or BYPASSRLS — the role bypasses RLS entirely,
248
+ * which makes every probe result for it vacuous. Drives the severity split. */
249
+ elevated: boolean;
247
250
  }
248
251
 
249
252
  /**
@@ -273,7 +276,8 @@ WITH RECURSIVE anc AS (
273
276
  SELECT r.rolname AS role,
274
277
  (c.relnamespace::regnamespace::text || '.' || c.relname) AS "table",
275
278
  p.priv AS command,
276
- string_agg(DISTINCT ar.rolname, ', ' ORDER BY ar.rolname) AS via
279
+ string_agg(DISTINCT ar.rolname, ', ' ORDER BY ar.rolname) AS via,
280
+ bool_or(ar.rolsuper OR ar.rolbypassrls) AS elevated
277
281
  FROM pg_class c
278
282
  CROSS JOIN (VALUES ('SELECT'),('INSERT'),('UPDATE'),('DELETE')) AS p(priv)
279
283
  JOIN pg_roles r ON r.rolname = ANY(${pgArrayLiteral(roles)})
@@ -296,6 +300,10 @@ export function toInheritedGrant(row: any): InheritedGrant {
296
300
  table: String(row.table),
297
301
  command: String(row.command).toUpperCase() as ProbeCommand,
298
302
  via: String(row.via),
303
+ // An ancestor that is SUPERUSER or BYPASSRLS makes every probe result for this role
304
+ // vacuous — default-deny cannot be falsified and RLS assertions are void. That is a
305
+ // different finding from inheriting an ordinary role's grants, and it is graded so.
306
+ elevated: row.elevated === true || row.elevated === 't' || row.elevated === 'true',
299
307
  };
300
308
  }
301
309
 
@@ -329,8 +337,8 @@ export function evaluateRedTeam(
329
337
  const findings: RedTeamFinding[] = [];
330
338
  // Privileges explained by role membership rather than a direct grant. Keyed so the
331
339
  // per-result lookup is exact; reported once per role, not once per table × command.
332
- const inheritedBy = new Map(inherited.map((g) => [`${g.role}${g.table}${g.command}`, g]));
333
- const inheritedRoles = new Map<string, { via: string; count: number }>();
340
+ const inheritedBy = new Map(inherited.map((g) => [`${g.role}\u0000${g.table}\u0000${g.command}`, g]));
341
+ const inheritedRoles = new Map<string, { via: string; count: number; elevated: boolean }>();
334
342
 
335
343
  for (const r of results) {
336
344
  const table = tablesByName.get(r.table);
@@ -347,12 +355,12 @@ export function evaluateRedTeam(
347
355
  findings.push({ severity: 'inconclusive', role: r.role, table: r.table, command: r.command,
348
356
  detail: `${r.command} on ${r.table} failed before the privilege check — this probe proves nothing about ${r.role}` });
349
357
  } else if (allowed && !granted) {
350
- const via = inheritedBy.get(`${r.role}${r.table}${r.command}`);
358
+ const via = inheritedBy.get(`${r.role}\u0000${r.table}\u0000${r.command}`);
351
359
  if (via) {
352
360
  // Real, but it is one fact about a role, not N facts about N tables.
353
361
  const seen = inheritedRoles.get(r.role);
354
- if (seen) seen.count += 1;
355
- else inheritedRoles.set(r.role, { via: via.via, count: 1 });
362
+ if (seen) { seen.count += 1; seen.elevated ||= via.elevated; }
363
+ else inheritedRoles.set(r.role, { via: via.via, count: 1, elevated: via.elevated });
356
364
  } else {
357
365
  findings.push({ severity: 'hole', role: r.role, table: r.table, command: r.command,
358
366
  detail: `${r.role} can ${r.command} ${r.table} but the contract grants no such privilege (enforcement exceeds declaration)` });
@@ -366,9 +374,23 @@ export function evaluateRedTeam(
366
374
  // One line per inheriting role. The membership IS the finding — a role that reaches
367
375
  // tables through `GRANT parent TO child` is worth stating out loud once, and worth not
368
376
  // stating N times.
369
- for (const [role, { via, count }] of inheritedRoles) {
370
- findings.push({ severity: 'inherited', role, table: '', command: 'SELECT',
371
- detail: `${role} holds ${count} undeclared privilege(s) INHERITED via membership in ${via} — not a direct grant, so the contract cannot see them. Intentional for a migrator/owner role; a finding if it is an application role` });
377
+ for (const [role, { via, count, elevated }] of inheritedRoles) {
378
+ // The severity axis is WHAT the ancestor is, not that inheritance happened.
379
+ //
380
+ // An ordinary ancestor's grants are finite, enumerable, and still RLS-subject: the
381
+ // database enforces declared ∪ inherited, which is a completeness note.
382
+ //
383
+ // A SUPERUSER/BYPASSRLS ancestor is categorically different. The role bypasses every
384
+ // policy, its effective access is unbounded, and every probe result for it is vacuous —
385
+ // this tool cannot vouch for the role at all. Reporting that at the same level as
386
+ // "could not SET ROLE into this role" is what let an adopter read the run as clean.
387
+ if (elevated) {
388
+ findings.push({ severity: 'elevated', role, table: '', command: 'SELECT',
389
+ detail: `${role} inherits ${via}, which is SUPERUSER or BYPASSRLS — it bypasses every RLS policy and holds privileges no ACL lists (${count} seen here). Every probe result for ${role} is vacuous: this run cannot vouch for it` });
390
+ } else {
391
+ findings.push({ severity: 'inherited', role, table: '', command: 'SELECT',
392
+ detail: `${role} holds ${count} undeclared privilege(s) INHERITED via membership in ${via} — not a direct grant, so the contract cannot see them. Intentional for a migrator/owner role; a finding if it is an application role` });
393
+ }
372
394
  }
373
395
  return findings;
374
396
  }
@@ -252,12 +252,20 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
252
252
  const unprobed = findings.filter((f) => f.severity === 'unprobed');
253
253
  const inconclusive = findings.filter((f) => f.severity === 'inconclusive');
254
254
  const inheritedFindings = findings.filter((f) => f.severity === 'inherited');
255
+ // An ancestor that is SUPERUSER/BYPASSRLS makes every probe for that role vacuous. It is
256
+ // NOT an enforcement hole — the database enforces exactly what the grants say — so it does
257
+ // not fail the gate, which would go red on the many legitimate elevated migration roles
258
+ // and teach adopters to stop running it. But it is warn-grade, and it must qualify the
259
+ // success line: people read the checkmark and stop, which is exactly how the first adopter
260
+ // came away thinking a real finding had been dismissed.
261
+ const elevatedFindings = findings.filter((f) => f.severity === 'elevated');
255
262
  const gaps = gapRows.map(toGrantGap);
256
263
 
257
264
  console.log('');
258
265
  for (const f of holes) fail(`[HOLE] ${f.detail}`);
259
266
  for (const f of broken) warn(`[BROKEN] ${f.detail}`);
260
267
  for (const g of gaps) fail(`[GRANT-GAP] ${g.secdef} calls ${g.helper} but its owner cannot EXECUTE it (42501 in prod once ownership is normalized)`);
268
+ for (const f of elevatedFindings) warn(`[ELEVATED] ${f.detail}`);
261
269
  for (const f of inheritedFindings) info(`[inherited] ${f.detail}`);
262
270
  if (inconclusive.length) {
263
271
  // Never a pass and never a failure — a probe that proved nothing, said out loud so it
@@ -273,7 +281,15 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
273
281
  console.log('');
274
282
 
275
283
  if (holes.length === 0 && broken.length === 0 && gaps.length === 0) {
276
- success(`db:authz:test ${venueLabel} enforces the contract (${contract.tables.length} tables probed, default-deny holds, SECDEF grants complete)`);
284
+ // "default-deny holds" is a FALSE UNIVERSAL when a role bypasses RLS. Any claim this run
285
+ // could not verify for a role is excluded from the claim in the sentence that makes it,
286
+ // so a reader who sees only the green line still learns one role is outside it.
287
+ const exempt = elevatedFindings.map((f) => f.role).sort();
288
+ const scope = exempt.length
289
+ ? `${contract.tables.length} tables probed; default-deny holds EXCEPT for ${exempt.join(', ')} — `
290
+ + `${exempt.length === 1 ? 'that role bypasses RLS by inheritance and this run cannot vouch for it' : 'those roles bypass RLS by inheritance and this run cannot vouch for them'}`
291
+ : `${contract.tables.length} tables probed, default-deny holds, SECDEF grants complete`;
292
+ success(`db:authz:test — ${venueLabel} enforces the contract (${scope})`);
277
293
  process.exit(0);
278
294
  }
279
295
  fail(`db:authz:test — ${holes.length} enforcement hole(s), ${broken.length} broken grant(s), ${gaps.length} SECDEF grant gap(s). ${venueLabel} does not enforce the contract.`);
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import fs from 'node:fs/promises';
22
+ import { spawnSync } from 'node:child_process';
22
23
  import type { ModelDescriptor } from '@everystack/model';
23
24
  import { introspectContract, type QueryRunner } from '../authz-contract.js';
24
25
  import { introspectSchema } from '../schema-introspect.js';
@@ -42,6 +43,23 @@ import { step, success, fail, info, warn } from '../output.js';
42
43
 
43
44
  const DEFAULT_OUT = 'db.plan.json';
44
45
 
46
+ /**
47
+ * True when git would ignore `file` — false when it would happily commit it, and ALSO false
48
+ * outside a repo or when git is unavailable, because the warning is only ever advice and a
49
+ * missing git must not turn a plan mint into a failure.
50
+ *
51
+ * `check-ignore` exits 0 for ignored, 1 for not-ignored, 128 for "not a repo".
52
+ */
53
+ function isGitIgnored(file: string): boolean {
54
+ try {
55
+ const r = spawnSync('git', ['check-ignore', '-q', file], { stdio: 'ignore' });
56
+ if (r.error || r.status === 128) return true; // no repo / no git — nothing to warn about
57
+ return r.status === 0;
58
+ } catch {
59
+ return true;
60
+ }
61
+ }
62
+
45
63
  export async function dbPlanCommand(flags: Record<string, string>): Promise<void> {
46
64
  let dbSource: DbSource;
47
65
  try {
@@ -161,6 +179,12 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
161
179
  if (out !== '-') {
162
180
  success(`Wrote ${out} — review it, then \`everystack db:apply --plan ${out}\`.`);
163
181
  warn('Plans are ephemeral release artifacts — attach to the run, do NOT commit.');
182
+ // "Do NOT commit" is advice; git is what enforces it. The default lands in the app
183
+ // root, so an adopter following the happy path gets an untracked, unignored artifact
184
+ // sitting next to their source with only a log line between it and a `git add .`.
185
+ if (!isGitIgnored(out)) {
186
+ warn(`${out} is NOT gitignored — add it, or the next \`git add .\` commits the plan: echo '${out}' >> .gitignore`);
187
+ }
164
188
  }
165
189
  } finally {
166
190
  await end?.();
@@ -30,7 +30,7 @@ import type { SchemaSnapshot } from './schema-introspect.js';
30
30
  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
- import { classifyGeneratedStatements, classifyDestructive, renderStatementHistogram } from './state-apply.js';
33
+ import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply.js';
34
34
  import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
35
35
  import { compileDeclaredState } from './declared-diff.js';
36
36
  import { compileTableRenames, compileTableMoves } from './schema-compile.js';
@@ -39,7 +39,8 @@ export const PLAN_VERSION = 2;
39
39
 
40
40
  /** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
41
41
  export interface PlanClassification {
42
- /** Executable statements that lose no data. */
42
+ /** Statements that positively ADD. Counted from a matcher, never `total - destructive` —
43
+ * computing it by subtraction made "safe" the default for anything unrecognized. */
43
44
  additive: number;
44
45
  /** `DROP TABLE/COLUMN/TYPE` — the data is gone. */
45
46
  drops: number;
@@ -48,6 +49,12 @@ export interface PlanClassification {
48
49
  /** REVOKEs against a grantee no model declares — nothing re-derives that access.
49
50
  * Optional: a plan minted before this classification existed has no field for it. */
50
51
  strips?: number;
52
+ /** `DROP POLICY` / `REVOKE` / `DISABLE RLS` — authorization removed, no row lost. Not
53
+ * destructive (it re-declares from the models) and emphatically not additive. */
54
+ authzRemovals?: number;
55
+ /** Statements the classifier does not recognize. Never additive — a plan carrying these
56
+ * has not been fully described and a human should read them before it is applied. */
57
+ unclassified?: number;
51
58
  }
52
59
 
53
60
  export interface EdgePlan {
@@ -177,7 +184,7 @@ export function mintEdgePlan(
177
184
  for (const g of Object.keys(t.grants)) declaredGrantees.add(g);
178
185
  for (const g of Object.keys(t.columnGrants ?? {})) declaredGrantees.add(g);
179
186
  }
180
- const breakdown = classifyDestructive(classified.executable, { declaredGrantees });
187
+ const breakdown = partitionStatements(classified.executable, { declaredGrantees });
181
188
  const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
182
189
 
183
190
  return {
@@ -189,10 +196,14 @@ export function mintEdgePlan(
189
196
  executable: classified.executable.length,
190
197
  destructive,
191
198
  classification: {
192
- additive: classified.executable.length - destructive,
199
+ // Counted, never inferred by subtraction — see partitionStatements. A statement nobody
200
+ // recognized is `unclassified`, and it must never be able to present as additive.
201
+ additive: breakdown.additive.length,
193
202
  drops: breakdown.drops.length,
194
203
  narrowings: breakdown.narrowings.length,
195
204
  ...(breakdown.strips.length > 0 ? { strips: breakdown.strips.length } : {}),
205
+ ...(breakdown.authzRemovals.length > 0 ? { authzRemovals: breakdown.authzRemovals.length } : {}),
206
+ ...(breakdown.unclassified.length > 0 ? { unclassified: breakdown.unclassified.length } : {}),
196
207
  },
197
208
  notices: classified.notices.length,
198
209
  unmodeled: unmodeledTables(models, snapshot),
@@ -201,6 +212,35 @@ export function mintEdgePlan(
201
212
  };
202
213
  }
203
214
 
215
+ /**
216
+ * Tables this plan leaves with no SELECT-admitting policy — the "goes dark" set.
217
+ *
218
+ * A `DROP POLICY` is cheap to reverse and loses no row, which is why it is not gated. But when
219
+ * a plan drops every policy that admitted a read and creates none in its place, RLS is still
220
+ * enabled and the grant is still there, so the table returns ZERO rows to that role. On a real
221
+ * adoption plan that was 11 tables, including the users table, and it printed no notice at all.
222
+ *
223
+ * Deliberately conservative: it only names a table when the plan drops a read-admitting policy
224
+ * and adds none back for that table. It cannot know what other policies exist live, so it
225
+ * under-reports rather than crying wolf.
226
+ */
227
+ export function tablesLeftWithoutARead(statements: readonly string[]): string[] {
228
+ const dropped = new Map<string, number>();
229
+ const created = new Set<string>();
230
+ for (const statement of statements) {
231
+ const head = (statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? '').trim();
232
+ let m = /^DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?\S+\s+ON\s+(\S+?);?$/i.exec(head);
233
+ if (m) {
234
+ dropped.set(m[1], (dropped.get(m[1]) ?? 0) + 1);
235
+ continue;
236
+ }
237
+ m = /^CREATE\s+POLICY\s+\S+\s+ON\s+(\S+)/i.exec(head);
238
+ // Only a SELECT-admitting policy restores a read; an INSERT-only policy does not.
239
+ if (m && /\bFOR\s+(SELECT|ALL)\b/i.test(head)) created.add(m[1]);
240
+ }
241
+ return [...dropped.keys()].filter((t) => !created.has(t)).sort();
242
+ }
243
+
204
244
  /** The plan's content address — recorded as `plan_ref` on the schema_log row. */
205
245
  export function planHash(plan: EdgePlan): string {
206
246
  return createHash('sha256').update(stableStringify(plan)).digest('hex');
@@ -243,6 +283,24 @@ export function buildPlanSummary(plan: EdgePlan): string[] {
243
283
  lines.push(`! ${ddl.trim()}`);
244
284
  }
245
285
  }
286
+ // Authorization removed loses no row, so it is not gated — but it decides who can SEE the
287
+ // rows, and a plan that drops a table's only read policy leaves that table returning nothing.
288
+ // 11 tables went dark in a real adoption plan that printed "0 notice(s)".
289
+ const { authzRemovals, unclassified } = partitionStatements(classifyGeneratedStatements(plan.statements).executable);
290
+ const dark = tablesLeftWithoutARead(plan.statements);
291
+ if (authzRemovals.length > 0) {
292
+ lines.push(`! ${authzRemovals.length} statement(s) REMOVE authorization (policies, grants, RLS). No data is lost; who can read it changes.`);
293
+ if (dark.length > 0) {
294
+ lines.push(`! ${dark.length} table(s) end this plan with NO read policy and RLS still on — they will return zero rows: ${dark.join(', ')}`);
295
+ }
296
+ }
297
+ if (unclassified.length > 0) {
298
+ lines.push(`! ${unclassified.length} statement(s) could NOT be classified — read them before applying. They are not counted as additive:`);
299
+ for (const statement of unclassified) {
300
+ const ddl = statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? statement;
301
+ lines.push(`! ${ddl.trim()}`);
302
+ }
303
+ }
246
304
  if (plan.unmodeled.length > 0) {
247
305
  lines.push(`· ${plan.unmodeled.length} unmodeled table(s) ride through untouched: ${plan.unmodeled.join(', ')}`);
248
306
  }
@@ -88,40 +88,74 @@ export interface RenderOptions {
88
88
  * dominant brownfield shape (public reference data, admin-managed). Anything else is a
89
89
  * per-model edit — the decision belongs in the file, not in a flag grammar.
90
90
  */
91
- export const ABILITY_PRESETS: Record<string, string> = {
92
- 'public-read': `abilities: [can('read'), can('manage', { role: 'admin' })],`,
91
+ export const ABILITY_PRESETS: Record<string, string[]> = {
92
+ 'public-read': [`can('read')`, `can('manage', { role: 'admin' })`],
93
93
  };
94
94
 
95
95
  /**
96
- * The scaffold stanza for one model: the commented decision surface (guide text says the
97
- * same thing the db:check gate failure says one voice, two doorways), or a preset
98
- * stamped uncommented. An unknown preset throws grants are authored, never guessed.
96
+ * A rendered ability that is a PUBLIC read anon-visible, the only shape the soft-delete
97
+ * guard ever applied to. Matches `defineModel`'s own rule: action `read`, with no `role`,
98
+ * `owner`, or `via` narrowing it. Tested per-ability (never against the whole joined stanza)
99
+ * so one ability's `role:` can never mask another's public read.
99
100
  */
100
- function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>): string {
101
+ function isPublicReadAbility(expr: string): boolean {
102
+ return /^can\('read'/.test(expr.trim()) && !/\b(role|owner|via)\s*:/.test(expr);
103
+ }
104
+
105
+ /**
106
+ * The scaffold stanza for one model, plus whether it declares a public read — the renderer
107
+ * needs the second fact to decide the `softDelete` line, and it must come from the STRUCTURED
108
+ * abilities, not a regex over the joined text (a live predicate can span lines and carry its
109
+ * own braces). An unknown preset throws — grants are authored, never guessed.
110
+ */
111
+ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>): { text: string; publicRead: boolean } {
101
112
  if (mode === 'live') {
102
113
  const contract = table && liveAuthz?.get(table.table);
103
114
  if (!contract) {
104
- return [
105
- ' // no live authorization found for this table — nothing was granted, so nothing is',
106
- ' // rendered. Author the read model deliberately, or leave it internal:',
107
- ' // private: true,',
108
- ].join('\n');
115
+ return {
116
+ text: [
117
+ ' // no live authorization found for this table — nothing was granted, so nothing is',
118
+ ' // rendered. Author the read model deliberately, or leave it internal:',
119
+ ' // private: true,',
120
+ ].join('\n'),
121
+ publicRead: false,
122
+ };
109
123
  }
110
- return renderDerivedAbilities(deriveAbilities(contract));
124
+ const derived = deriveAbilities(contract);
125
+ return { text: renderDerivedAbilities(derived), publicRead: derived.abilities.some(isPublicReadAbility) };
111
126
  }
112
127
  if (mode === 'commented') {
113
- return [
114
- ' // Declare the read model — db:check fails this model until its authz is authored:',
115
- ` // abilities: [can('read')], // public data`,
116
- ` // abilities: [can('read', { owner: '<column>' })], // rows owned by a user`,
117
- ' // private: true, // not part of the data API',
118
- ].join('\n');
128
+ return {
129
+ text: [
130
+ ' // Declare the read model — db:check fails this model until its authz is authored:',
131
+ ` // abilities: [can('read')], // public data`,
132
+ ` // abilities: [can('read', { owner: '<column>' })], // rows owned by a user`,
133
+ ' // private: true, // not part of the data API',
134
+ ].join('\n'),
135
+ // Nothing is stamped uncommented, so the model declares no read at all yet.
136
+ publicRead: false,
137
+ };
119
138
  }
120
139
  const preset = ABILITY_PRESETS[mode];
121
140
  if (!preset) {
122
141
  throw new Error(`Unknown --abilities preset '${mode}' — known: ${Object.keys(ABILITY_PRESETS).join(', ')} (or omit the flag for the commented scaffold).`);
123
142
  }
124
- return ` ${preset}`;
143
+ return { text: ` abilities: [${preset.join(', ')}],`, publicRead: preset.some(isPublicReadAbility) };
144
+ }
145
+
146
+ /**
147
+ * The `softDelete` line, when the model would otherwise fail to define.
148
+ *
149
+ * `defineModel` refuses to guess for a table that has `deleted_at` AND a public read: the
150
+ * guard decides what anonymous users can see, and neither default is safe. A pull renders
151
+ * `false` — LIVE reality is the truth being transcribed, and no live policy carries a guard
152
+ * nobody wrote. The comment says how to get the other one, so the decision is visible in the
153
+ * file rather than buried in a compiler convention.
154
+ */
155
+ function softDeleteStanza(table: TableSchema, publicRead: boolean): string {
156
+ const hasColumn = table.columns.some((c) => c.name === 'deleted_at');
157
+ if (!hasColumn || !publicRead) return '';
158
+ return ` softDelete: false, // live reality: no policy filters deleted_at. true excludes soft-deleted rows from public reads.\n`;
125
159
  }
126
160
 
127
161
  /** `format_type` → the `field.*()` factory that produces it (the non-parameterized types). */
@@ -448,8 +482,9 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
448
482
  // reviewer must resolve about a model (and where the field-report consumer's codemod
449
483
  // put it, proving the position is mechanical-edit-friendly).
450
484
  const stanza = abilitiesStanza(abilities, table, liveAuthz);
485
+ const softDelete = softDeleteStanza(table, stanza.publicRead);
451
486
 
452
- return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza}\n fields: {\n${fields}\n },${constraints}\n});`;
487
+ return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza.text}\n${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
453
488
  }
454
489
 
455
490
  /** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
@@ -223,12 +223,35 @@ function defaultExpr(spec: FieldSpec): string | null {
223
223
  }
224
224
 
225
225
  /**
226
- * A Postgres array literal for a default `[]` `'{}'`, `['a','b']` `'{"a","b"}'`.
226
+ * True when PostgreSQL's own `array_out` would quote this element: it is empty, it is the
227
+ * literal `NULL` (which unquoted means the SQL null), or it contains a delimiter, a brace, a
228
+ * quote, a backslash, or whitespace.
229
+ */
230
+ function needsArrayQuote(v: string): boolean {
231
+ return v === '' || /^NULL$/i.test(v) || /[{},"\\\s]/.test(v);
232
+ }
233
+
234
+ /**
235
+ * A Postgres array literal for a default — `[]` → `'{}'`, `['User']` → `'{User}'`,
236
+ * `['a b']` → `'{"a b"}'`.
237
+ *
238
+ * QUOTING MATTERS: this must match `array_out` exactly, because the value round-trips
239
+ * through the live catalog. Quoting every element unconditionally produced `'{"User"}'`
240
+ * where PostgreSQL deparses `'{User}'`, so the declared and live defaults never compared
241
+ * equal and `db:generate` re-emitted the same `SET DEFAULT` on every run, forever.
242
+ *
243
+ * Backslashes escape before quotes, or `\` would become `\\"` — the escaping bug the
244
+ * previous version also carried (it escaped `"` and left `\` alone).
245
+ *
227
246
  * The introspected form carries a `::type[]` cast (`'{}'::text[]`) that `normalizeDefault`
228
247
  * strips, so the bare literal round-trips.
229
248
  */
230
249
  function arrayLiteral(arr: unknown[]): string {
231
- const elems = arr.map((v) => (typeof v === 'string' ? `"${v.replace(/"/g, '\\"')}"` : String(v)));
250
+ const elems = arr.map((v) => {
251
+ if (typeof v !== 'string') return String(v);
252
+ if (!needsArrayQuote(v)) return v;
253
+ return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
254
+ });
232
255
  return `'{${elems.join(',')}}'`;
233
256
  }
234
257
 
@@ -220,6 +220,20 @@ function argsWiden(from: number[], to: number[]): boolean {
220
220
  return to.every((v, i) => from[i] == null || v >= from[i]);
221
221
  }
222
222
 
223
+ /**
224
+ * Types whose entire meaning survives a trip through `text` — scalars and strings whose
225
+ * printed form IS their value. Everything else (postgis geometry, tsvector, hstore, ltree,
226
+ * arrays, composites, ranges, and any extension type) carries structure the cast discards,
227
+ * so it is deliberately NOT listed: the default for an unknown base is "not free".
228
+ */
229
+ const TEXT_ROUND_TRIPS = new Set([
230
+ 'text', 'character varying', 'character', 'citext', 'name', 'uuid', 'boolean',
231
+ 'smallint', 'integer', 'bigint', 'numeric', 'decimal', 'real', 'double precision',
232
+ 'date', 'time', 'time without time zone', 'time with time zone',
233
+ 'timestamp', 'timestamp without time zone', 'timestamp with time zone',
234
+ 'json', 'jsonb', 'inet', 'cidr', 'macaddr', 'bytea', 'interval',
235
+ ]);
236
+
223
237
  /**
224
238
  * Classify a column type change. Conservative by construction — anything not provably safe
225
239
  * or merely lossy falls through to `risky`, so a dangerous conversion is never mistaken for
@@ -230,7 +244,18 @@ export function classifyTypeChange(from: string, to: string): TypeChangeRisk {
230
244
  const f = parseType(from);
231
245
  const t = parseType(to);
232
246
 
233
- if (t.base === 'text') return 'safe'; // text is the universal sink: every type assignment-casts to it losslessly
247
+ // `→ text` is only free from a type text can round-trip. It IS the universal sink for the
248
+ // printable VALUE — but a structured type loses its meaning, not its characters: postgis
249
+ // `geometry` → text takes every spatial index and `ST_*` call with it, `tsvector` → text
250
+ // kills the full-text index, `hstore` → text destroys the key/value structure. None of that
251
+ // is recoverable by casting back.
252
+ //
253
+ // This mattered far past taxonomy. A `safe` verdict emits a BARE `SET DATA TYPE` with no
254
+ // WARNING, and the plan classifier only counts a narrowing when it sees the `USING` cast a
255
+ // non-safe verdict adds — so 48 postgis/tsvector/hstore/`timestamp(6)` rewrites in a real
256
+ // adoption plan were reported as additive with `destructive: 0`, which is also the number
257
+ // that gates `--confirm` + snapshot. Calling these lossy is what arms the gate.
258
+ if (t.base === 'text') return TEXT_ROUND_TRIPS.has(f.base) ? 'safe' : 'lossy';
234
259
 
235
260
  const fr = NUMERIC_RANK[f.base];
236
261
  const tr = NUMERIC_RANK[t.base];
@@ -89,6 +89,94 @@ export interface DestructiveBreakdown {
89
89
  strips: string[];
90
90
  }
91
91
 
92
+ /**
93
+ * Statements that positively ADD — the only ones a plan may call additive.
94
+ *
95
+ * `additive` used to be computed as `executable.length - destructive`, which made it the
96
+ * DEFAULT rather than a finding: any statement the classifier did not recognize was reported
97
+ * as safe. A real adoption plan carrying 38 DROP POLICY, 43 REVOKE and 48 column rewrites
98
+ * described itself as `additive: 158, destructive: 0`, and since `destructive > 0` is what
99
+ * gates `--confirm` + snapshot + the approver set, the false zero did not merely mislabel the
100
+ * plan — it disarmed the gate.
101
+ *
102
+ * So the default is inverted. A statement is additive only when it matches one of these; a
103
+ * statement nobody recognized is `unclassified`, and `unclassified` is never additive. This is
104
+ * the same rule the reconciler already applies to grants: absence of knowledge means LEAVE IT
105
+ * ALONE and say so, never "assume it is fine".
106
+ */
107
+ const ADDITIVE_MATCHERS: RegExp[] = [
108
+ /^CREATE\s+(TABLE|POLICY|INDEX|UNIQUE\s+INDEX|SEQUENCE|EXTENSION|TYPE|SCHEMA)\b/,
109
+ /^GRANT\b/,
110
+ /^COMMENT\s+ON\b/,
111
+ /^ALTER\s+TABLE\s+[\s\S]+?\sADD\s+(COLUMN|CONSTRAINT|PRIMARY\s+KEY)\b/,
112
+ // Enabling/forcing RLS only ever RESTRICTS; it cannot widen access or lose a row.
113
+ /^ALTER\s+TABLE\s+[\s\S]+?\s(ENABLE|FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/,
114
+ // Relaxing nullability and setting a default add capability; they remove nothing.
115
+ /^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sDROP\s+NOT\s+NULL\b/,
116
+ /^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sSET\s+DEFAULT\b/,
117
+ ];
118
+
119
+ /**
120
+ * Statements that REMOVE an authorization without losing a row.
121
+ *
122
+ * Deliberately NOT folded into `destructive`, which means data loss and whose taxonomy is
123
+ * defended above — a dropped policy or grant re-declares from the models without touching a
124
+ * row. But it is emphatically not ADDITIVE either, and that was the lie: a `DROP POLICY` that
125
+ * removes a table's only read policy leaves the table returning zero rows, which is the single
126
+ * highest-consequence thing an adoption plan can carry and it was reported as an addition.
127
+ */
128
+ const AUTHZ_REMOVAL_MATCHERS: RegExp[] = [
129
+ /^DROP\s+POLICY\b/,
130
+ /^REVOKE\b/,
131
+ /^ALTER\s+TABLE\s+[\s\S]+?\s(DISABLE|NO\s+FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/,
132
+ ];
133
+
134
+ /** The full partition of an executable stream. Every statement lands in exactly one bucket. */
135
+ export interface StatementPartition {
136
+ additive: string[];
137
+ drops: string[];
138
+ narrowings: string[];
139
+ strips: string[];
140
+ /** Authorization removed, no data lost — visible, never additive. */
141
+ authzRemovals: string[];
142
+ /** Not positively recognized. NEVER additive; needs a human before this plan is applied. */
143
+ unclassified: string[];
144
+ }
145
+
146
+ /** The first non-comment line, upper-cased — WARNING prologues must not hide the verb. */
147
+ function statementHead(statement: string): string {
148
+ return (statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? '')
149
+ .trim()
150
+ .toUpperCase();
151
+ }
152
+
153
+ /**
154
+ * Partition an executable stream into exactly one bucket per statement.
155
+ *
156
+ * Order matters: the data-loss buckets are consulted FIRST so a statement that both drops and
157
+ * revokes is counted at its most severe reading, and `unclassified` is the fallthrough rather
158
+ * than `additive`.
159
+ */
160
+ export function partitionStatements(
161
+ executable: string[],
162
+ opts: { declaredGrantees?: ReadonlySet<string> } = {},
163
+ ): StatementPartition {
164
+ const { drops, narrowings, strips } = classifyDestructive(executable, opts);
165
+ const destructive = new Set([...drops, ...narrowings, ...strips]);
166
+ const additive: string[] = [];
167
+ const authzRemovals: string[] = [];
168
+ const unclassified: string[] = [];
169
+
170
+ for (const statement of executable) {
171
+ if (destructive.has(statement)) continue;
172
+ const head = statementHead(statement);
173
+ if (AUTHZ_REMOVAL_MATCHERS.some((re) => re.test(head))) authzRemovals.push(statement);
174
+ else if (ADDITIVE_MATCHERS.some((re) => re.test(head))) additive.push(statement);
175
+ else unclassified.push(statement);
176
+ }
177
+ return { additive, drops, narrowings, strips, authzRemovals, unclassified };
178
+ }
179
+
92
180
  /** `REVOKE … ON <table> FROM <grantee>;` → the grantee, or null when it is not a revoke. */
93
181
  export function revokeTarget(statement: string): string | null {
94
182
  // A quoted identifier can hold anything (`"Odd-Role"`, a reserved word, mixed case), and