@everystack/cli 0.4.45 → 0.4.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/cli/alter-type-dependents.ts +96 -0
  3. package/src/cli/apply-execute.ts +22 -8
  4. package/src/cli/authz-adoption-class.ts +75 -26
  5. package/src/cli/authz-canonical.ts +37 -5
  6. package/src/cli/authz-compile.ts +87 -37
  7. package/src/cli/authz-contract.ts +92 -19
  8. package/src/cli/authz-derive.ts +158 -33
  9. package/src/cli/authz-reconcile.ts +48 -6
  10. package/src/cli/aws.ts +32 -0
  11. package/src/cli/commands/db-apply.ts +82 -20
  12. package/src/cli/commands/db-authz.ts +9 -14
  13. package/src/cli/commands/db-backfill.ts +1 -1
  14. package/src/cli/commands/db-exec.ts +20 -1
  15. package/src/cli/commands/db-fingerprint.ts +54 -18
  16. package/src/cli/commands/db-generate.ts +11 -17
  17. package/src/cli/commands/db-plan.ts +89 -9
  18. package/src/cli/commands/db-pull.ts +16 -18
  19. package/src/cli/commands/db-reconcile.ts +19 -21
  20. package/src/cli/commands/db-refresh.ts +33 -5
  21. package/src/cli/commands/db-swap.ts +5 -4
  22. package/src/cli/commands/db-sync.ts +8 -5
  23. package/src/cli/commands/db.ts +2 -1
  24. package/src/cli/db-build.ts +2 -2
  25. package/src/cli/db-source.ts +56 -0
  26. package/src/cli/derived-introspect.ts +27 -26
  27. package/src/cli/derived-lint.ts +7 -8
  28. package/src/cli/edge-plan.ts +112 -16
  29. package/src/cli/exec-digest.ts +55 -13
  30. package/src/cli/git-descent.ts +16 -9
  31. package/src/cli/index.ts +3 -3
  32. package/src/cli/model-render.ts +56 -50
  33. package/src/cli/schema-compile.ts +6 -1
  34. package/src/cli/schema-diff.ts +1 -1
  35. package/src/cli/schema-fingerprint.ts +67 -7
  36. package/src/cli/schema-introspect.ts +44 -17
  37. package/src/cli/schema-source.ts +9 -0
  38. package/src/cli/session.ts +184 -0
  39. package/src/cli/stage-read-consistency.ts +145 -0
  40. package/src/cli/state-apply.ts +4 -2
  41. package/src/cli/swap-execute.ts +4 -3
  42. package/src/cli/search-path.ts +0 -51
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.45",
3
+ "version": "0.4.47",
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.10"
112
+ "@everystack/model": "0.4.11"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "@everystack/server": ">=0.4.0",
@@ -0,0 +1,96 @@
1
+ /**
2
+ * alter-type-dependents — the mint-time gate for a hard PostgreSQL rule:
3
+ * `ALTER COLUMN … SET DATA TYPE` fails when a view, materialized view, or
4
+ * rewrite rule references the column ("cannot alter type of a column used by
5
+ * a view or rule"), and it fails MID-TRANSACTION, at apply time.
6
+ *
7
+ * The plan lane's contract is "what it mints, it can apply" — so the mint
8
+ * refuses the plan up front, with the dependents NAMED, instead of letting the
9
+ * operator discover them from a rolled-back apply. (Proven live: the first-ever
10
+ * Z1a apply of a brownfield adoption plan failed exactly this way.) The route
11
+ * for a real type migration under dependents is deliberate: rebuild through
12
+ * `db:swap`, or drop and re-create the derived objects around the change.
13
+ */
14
+
15
+ import type { QueryRunner } from './authz-contract.js';
16
+
17
+ export interface AlterTypeTarget {
18
+ table: string;
19
+ column: string;
20
+ }
21
+
22
+ export interface BlockedAlterType extends AlterTypeTarget {
23
+ /** Qualified names of the views / matviews / rules that bind the column. */
24
+ dependents: string[];
25
+ }
26
+
27
+ /**
28
+ * The (table, column) pairs a plan's `SET DATA TYPE` statements touch — read
29
+ * from the emitted artifact itself, the same way the destructive classifier
30
+ * reads it: the statement is what gets applied, so it is what gets checked.
31
+ */
32
+ export function alterTypeStatementTargets(statements: readonly string[]): AlterTypeTarget[] {
33
+ const targets: AlterTypeTarget[] = [];
34
+ for (const s of statements) {
35
+ const m = s.match(/ALTER TABLE\s+(\S+)\s+ALTER COLUMN\s+"([^"]+)"\s+SET DATA TYPE\s/);
36
+ if (m) targets.push({ table: m[1], column: m[2] });
37
+ }
38
+ return targets;
39
+ }
40
+
41
+ const quoteLiteral = (s: string): string => `'${s.replace(/'/g, "''")}'`;
42
+
43
+ /**
44
+ * The dependents PostgreSQL itself would refuse over: rewrite-rule entries
45
+ * (views, matviews, rules) that depend on the specific column. Whole-table
46
+ * dependencies (refobjsubid 0 — every view's FROM clause) are deliberately
47
+ * excluded: a view that never touches the column does not block the change.
48
+ */
49
+ export function dependentsSql(target: AlterTypeTarget): string {
50
+ const rel = quoteLiteral(target.table);
51
+ return `
52
+ SELECT DISTINCT
53
+ CASE cl.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'rule' END AS kind,
54
+ n.nspname || '.' || cl.relname AS name
55
+ FROM pg_depend d
56
+ JOIN pg_rewrite r ON r.oid = d.objid
57
+ JOIN pg_class cl ON cl.oid = r.ev_class
58
+ JOIN pg_namespace n ON n.oid = cl.relnamespace
59
+ WHERE d.classid = 'pg_rewrite'::regclass
60
+ AND d.refclassid = 'pg_class'::regclass
61
+ AND d.refobjid = ${rel}::regclass
62
+ AND d.refobjsubid = (
63
+ SELECT attnum FROM pg_attribute
64
+ WHERE attrelid = ${rel}::regclass AND attname = ${quoteLiteral(target.column)}
65
+ )
66
+ AND cl.oid <> ${rel}::regclass
67
+ ORDER BY name;
68
+ `.trim();
69
+ }
70
+
71
+ /** Query the live catalog for each target; return only the blocked ones. */
72
+ export async function findAlterTypeDependents(
73
+ runner: QueryRunner,
74
+ targets: readonly AlterTypeTarget[],
75
+ ): Promise<BlockedAlterType[]> {
76
+ const blocked: BlockedAlterType[] = [];
77
+ for (const t of targets) {
78
+ const rows = await runner(dependentsSql(t));
79
+ if (rows.length > 0) {
80
+ blocked.push({ ...t, dependents: rows.map((r: any) => `${r.kind} ${r.name}`) });
81
+ }
82
+ }
83
+ return blocked;
84
+ }
85
+
86
+ /** The refusal, with every dependent named — actionable, not archaeological. */
87
+ export function renderAlterTypeRefusal(blocked: readonly BlockedAlterType[]): string {
88
+ const lines = blocked.map(
89
+ (b) => ` ${b.table}."${b.column}" is bound by: ${b.dependents.join(', ')}`,
90
+ );
91
+ return [
92
+ `This plan changes the type of ${blocked.length} column(s) that a view or rule depends on — PostgreSQL would refuse the ALTER mid-transaction ("cannot alter type of a column used by a view or rule"), so the mint refuses it now:`,
93
+ ...lines,
94
+ `Route the type change through db:swap (rebuild + atomic rename), or drop and re-create the dependent derived objects around it (db:reconcile) — deliberately, not as an apply-time surprise.`,
95
+ ].join('\n');
96
+ }
@@ -30,9 +30,11 @@
30
30
  */
31
31
 
32
32
  import { introspectContract, type QueryRunner, type AuthzContract } from './authz-contract.js';
33
+ import type { SessionRunner } from './session.js';
33
34
  import { introspectSchema, type SchemaSnapshot } from './schema-introspect.js';
34
35
  import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog.js';
35
36
  import { fingerprintLive } from './schema-fingerprint.js';
37
+ import { governedLiveFingerprint } from './edge-plan.js';
36
38
  import { applyGeneratedStatements } from './state-apply.js';
37
39
  import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply.js';
38
40
  import { checkPlanPrecondition, planHash, type EdgePlan } from './edge-plan.js';
@@ -134,21 +136,30 @@ export interface LiveBaseFingerprint {
134
136
  * destructive-apply safety gate (backup.fp == plan.from). A second copy would silently drift the
135
137
  * fingerprint chain — the whole point of the gate.
136
138
  */
137
- export async function liveBaseFingerprint(runner: QueryRunner): Promise<LiveBaseFingerprint> {
138
- const snapshot = await introspectSchema(runner);
139
- const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
139
+ export async function liveBaseFingerprint(session: SessionRunner): Promise<LiveBaseFingerprint> {
140
+ const snapshot = await introspectSchema(session);
141
+ const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
140
142
  return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract };
141
143
  }
142
144
 
143
145
  /** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
144
146
  export async function executeApplyPlan(
145
147
  runner: QueryRunner,
148
+ session: SessionRunner,
146
149
  plan: EdgePlan,
147
150
  options: ApplyPlanOptions = {},
148
151
  ): Promise<ApplyPlanResult> {
149
- const { hash: live, snapshot: before, contract: beforeAuthz } = await liveBaseFingerprint(runner);
152
+ const { hash: live, snapshot: before, contract: beforeAuthz } = await liveBaseFingerprint(session);
150
153
 
151
- if (live === plan.toFingerprint) {
154
+ // Two flavors, matched to what each check compares (see EdgePlan): the RAW
155
+ // hash for live-vs-live (the lock, the backup chain), the GOVERNED hash for
156
+ // declared-vs-live (already-applied here, verify-after below) — computed with
157
+ // the exact set the mint stamped on the plan. A plan minted before the field
158
+ // existed verifies raw, as it was minted.
159
+ const governed = plan.governedRoles ? new Set(plan.governedRoles) : undefined;
160
+ const governedLive = governed ? governedLiveFingerprint(before, beforeAuthz, governed) : live;
161
+
162
+ if (governedLive === plan.toFingerprint) {
152
163
  return { status: 'already-applied', liveFingerprint: live };
153
164
  }
154
165
  const pre = checkPlanPrecondition(plan, live);
@@ -200,16 +211,19 @@ export async function executeApplyPlan(
200
211
  now: options.now,
201
212
  });
202
213
 
203
- const { hash: landed } = await liveBaseFingerprint(runner);
214
+ const { hash: landed, snapshot: after, contract: afterAuthz } = await liveBaseFingerprint(session);
204
215
  if (result.logId !== undefined) {
216
+ // The log stamps the RAW landed hash — the same flavor as `from`, so the
217
+ // schema_log chain reads as one consistent series of live states.
205
218
  await runner(renderSchemaLogFingerprintUpdate(result.logId, landed));
206
219
  }
207
220
 
208
- if (landed !== plan.toFingerprint) {
221
+ const governedLanded = governed ? governedLiveFingerprint(after, afterAuthz, governed) : landed;
222
+ if (governedLanded !== plan.toFingerprint) {
209
223
  return {
210
224
  status: 'verify-failed',
211
225
  liveFingerprint: landed,
212
- reason: `applied, but the target landed on ${landed.slice(0, 12)} — the plan predicted ${plan.toFingerprint.slice(0, 12)}. Investigate before touching this database again (db:fingerprint, db:generate).`,
226
+ reason: `applied, but the target landed on ${governedLanded.slice(0, 12)} — the plan predicted ${plan.toFingerprint.slice(0, 12)}. Investigate before touching this database again (db:fingerprint, db:generate).`,
213
227
  ...(result.logId !== undefined ? { logId: result.logId } : {}),
214
228
  };
215
229
  }
@@ -30,7 +30,7 @@
30
30
 
31
31
  import { matchPolicies, roleSetEqual } from './authz-identity.js';
32
32
  import { governedRoleSet } from './authz-reconcile.js';
33
- import { effectivePolicyCheck, holdsPrivilege } from './authz-contract.js';
33
+ import { effectivePolicyCheck, holdsPrivilege, isPolicyDead, policyCommands } from './authz-contract.js';
34
34
  import type { AuthzContract, PolicyContract, TableContract } from './authz-contract.js';
35
35
 
36
36
  export type AdoptionClass = 'convention' | 'capability' | 'narrowing' | 'dead' | 'unclassified';
@@ -86,30 +86,28 @@ function sameRule(a: PolicyContract, b: PolicyContract): boolean {
86
86
  && (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
87
87
  }
88
88
 
89
- /** Every command a policy governs — `ALL` fans out, so a live `FOR ALL` is compared per command. */
90
- function commandsOf(p: PolicyContract): string[] {
91
- return p.command === 'ALL' ? [...CRUD] : [p.command];
92
- }
93
-
94
89
  /**
95
- * A policy is DEAD when no role it applies to holds the privilege it polices. Postgres checks
96
- * the GRANT before the policy, so such a policy authorizes nothing — removing it takes away
97
- * access that was never there. A PUBLIC policy applies to every grantee, so it is dead only if
98
- * NO grantee holds the privilege at all.
90
+ * A policy's predicate, collapsed to one line for the report.
91
+ *
92
+ * Live predicates arrive from `pg_get_expr` with real newlines and indentation (an EXISTS
93
+ * subquery spans several lines), and the adoption report is one line per statement. Collapsing
94
+ * keeps the rule READABLE in place rather than referring the operator elsewhere to find it.
99
95
  */
100
- function isDead(t: TableContract, p: PolicyContract): boolean {
101
- const roles = p.roles.includes('public') ? Object.keys(t.grants) : p.roles;
102
- if (!roles.length) return true;
103
- // `holdsPrivilege`, not the table-level one: a column-scoped grant is real access, and
104
- // "dead" tells an operator the drop takes away nothing. Shared with db:pull so the two can
105
- // never answer this differently about one database.
106
- return !commandsOf(p).some((cmd) => roles.some((r) => holdsPrivilege(t, r, cmd)));
96
+ function pred(p: PolicyContract): string {
97
+ const using = (p.using ?? 'true').replace(/\s+/g, ' ').trim();
98
+ return using === 'true' ? 'true (unfiltered)' : using;
107
99
  }
108
100
 
101
+ // `commandsOf` and the dead-policy test live in authz-contract — ONE definition, shared with
102
+ // the reconciler, the canonical form, and db:pull.
103
+ const commandsOf = policyCommands;
104
+ const isDead = isPolicyDead;
105
+
109
106
  /** Classify the policy statements one table would emit. */
110
- function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedStatement[]): void {
107
+ function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedStatement[], governed: ReadonlySet<string>): void {
111
108
  const table = d.table;
112
109
  const m = matchPolicies(d.policies, l.policies);
110
+ const liveByName = new Map(l.policies.map((p) => [p.name, p]));
113
111
 
114
112
  // Adopted shapes — a rule-identical policy under another name, or a live multi-role policy
115
113
  // covering the declared per-role group — emit NOTHING, so there is nothing here to classify.
@@ -122,10 +120,16 @@ function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedSta
122
120
  for (const name of m.toDrop) {
123
121
  const live = l.policies.find((p) => p.name === name)!;
124
122
 
125
- if (isDead(l, live)) {
126
- out.push({ table, subject: name, cls: 'dead', why: `policed but never granted it authorizes nothing today` });
127
- continue;
128
- }
123
+ // The reconciler LEAVES a policy scoped entirely to ungoverned roles alone, so no statement
124
+ // is emitted for it and there is nothing here to classify. Counting one would describe a
125
+ // plan nobody is going to apply — the same reason classifyGrants skips those grantees.
126
+ if (live.roles.length > 0 && live.roles.every((r) => !isGovernedGrantee(governed, r))) continue;
127
+
128
+ // A DEAD policy is left alone by the reconciler now (it authorizes nothing, so dropping it
129
+ // changes no access), which means no statement exists to classify — same as the ungoverned
130
+ // case above, and counting one would describe a plan nobody is going to apply. The adopter
131
+ // still hears about it: db:pull writes the finding into the model file, where it persists.
132
+ if (isDead(l, live)) continue;
129
133
 
130
134
  // A replacement exists when some declared policy governs the same command. If those
131
135
  // replacements together cover FEWER roles than the live policy did, the plan is reducing
@@ -165,6 +169,37 @@ function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedSta
165
169
  continue;
166
170
  }
167
171
 
172
+ // The PREDICATE axis. Everything above this line decides on ROLE SETS alone, and that was
173
+ // the blind spot: a replacement holding the SAME roles with a different USING is a real
174
+ // access change that no role comparison can see. It fell to `unclassified` — a bucket with
175
+ // a count and, until E3, no detail — so the most dangerous shape this tool can emit was
176
+ // also its quietest. A live `(guard) AND (mine OR published)` read replaced by a
177
+ // `published`-only one is not silence; it is a replacement that READS as equivalent.
178
+ //
179
+ // Narrower vs. wider is not decidable here without a predicate lattice, so this takes the
180
+ // most severe reading, exactly as the destructive-statement partition does: an access change
181
+ // we cannot prove is identical is reported as a reduction, and BOTH predicates are carried
182
+ // so the operator reads the rule rather than the label.
183
+ const sameRoleReplacements = replacements.filter((r) => r.roles.some((role) => live.roles.includes(role)));
184
+ if (sameRoleReplacements.length && !sameRoleReplacements.some((r) => sameRule(r, live))) {
185
+ const shared = [...new Set(sameRoleReplacements.flatMap((r) => r.roles.filter((role) => live.roles.includes(role))))];
186
+ out.push({
187
+ table,
188
+ subject: name,
189
+ cls: 'narrowing',
190
+ why:
191
+ `live applies to ${shared.join(', ')} USING ${pred(live)}; the declared replacement `
192
+ + `${sameRoleReplacements.map((r) => `"${r.name}"`).join(', ')} keeps the same role(s) but states `
193
+ + `${sameRoleReplacements.map((r) => pred(r)).join(' / ')} — a different rule, not a different spelling`,
194
+ });
195
+ for (const r of sameRoleReplacements) {
196
+ if (consumed.has(r.name)) continue;
197
+ consumed.add(r.name);
198
+ out.push({ table, subject: r.name, cls: 'narrowing', why: `the differently-predicated replacement for "${name}"` });
199
+ }
200
+ continue;
201
+ }
202
+
168
203
  out.push({ table, subject: name, cls: 'unclassified', why: `dropped with a replacement that is neither the same rule nor narrower` });
169
204
  }
170
205
 
@@ -241,7 +276,7 @@ export function classifyAdoption(
241
276
  for (const d of declared.tables) {
242
277
  const l = liveByTable.get(d.table);
243
278
  if (!l) continue; // a brand-new table adds; nothing here is an adoption decision
244
- classifyPolicies(d, l, statements);
279
+ classifyPolicies(d, l, statements, governed);
245
280
  classifyGrants(d, l, governed, statements);
246
281
  }
247
282
 
@@ -250,8 +285,19 @@ export function classifyAdoption(
250
285
  return { statements, counts };
251
286
  }
252
287
 
253
- /** The report block for the plan surface. Terse: one line per class, then the offenders. */
254
- export function renderAdoptionReport(counts: AdoptionCounts): string[] {
288
+ /**
289
+ * The report block for the plan surface. Terse: one line per class, then the offenders.
290
+ *
291
+ * `unclassified` is the one class whose count cannot be acted on by itself — its whole meaning
292
+ * is "read these before applying", and a bare number gives the reader nothing to read. The
293
+ * adopter who hit this said it best: a count with no way to act on it is the same shape as the
294
+ * problem the bucket was created to solve. So every unclassified statement is NAMED here, and
295
+ * the per-statement detail rides on the artifact too (`adoption` on the plan).
296
+ *
297
+ * Only `unclassified` is expanded. The others are counts on purpose — 19 narrowings listed
298
+ * inline is a wall nobody reads, and they are each described by their class.
299
+ */
300
+ export function renderAdoptionReport(counts: AdoptionCounts, statements: readonly ClassifiedStatement[] = []): string[] {
255
301
  const lines = [
256
302
  ` convention ${counts.convention} — our spelling, not their schema (this must reach 0)`,
257
303
  ` capability ${counts.capability} — the model cannot express what is live`,
@@ -259,7 +305,10 @@ export function renderAdoptionReport(counts: AdoptionCounts): string[] {
259
305
  ` dead ${counts.dead} — policed but never granted`,
260
306
  ];
261
307
  if (counts.unclassified) {
262
- lines.push(` unclassified ${counts.unclassified} — NOT described; read these before applying`);
308
+ lines.push(` unclassified ${counts.unclassified} — NOT described; read these before applying:`);
309
+ for (const s of statements.filter((s) => s.cls === 'unclassified')) {
310
+ lines.push(` ${s.table} "${s.subject}" — ${s.why}`);
311
+ }
263
312
  }
264
313
  return lines;
265
314
  }
@@ -32,7 +32,7 @@
32
32
  */
33
33
 
34
34
  import type { PolicyContract, TableContract } from './authz-contract.js';
35
- import { effectivePolicyCheck } from './authz-contract.js';
35
+ import { effectivePolicyCheck, isPolicyDead, isPolicySubsumed } from './authz-contract.js';
36
36
 
37
37
  /** PostgreSQL's open role set, kept whole. */
38
38
  const PUBLIC_ROLE = 'public';
@@ -76,9 +76,28 @@ function multiset(entries: string[]): Array<[string, number]> {
76
76
  return [...counts.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
77
77
  }
78
78
 
79
- /** The canonical policy form: every policy expanded across its roles, as a sorted multiset. */
80
- export function canonicalPolicies(policies: readonly PolicyContract[]): Array<[string, number]> {
81
- return multiset(policies.flatMap(expandPolicy));
79
+ /**
80
+ * The canonical policy form: every policy expanded across its roles, as a sorted multiset.
81
+ *
82
+ * When a governed set is supplied (hashing a LIVE contract), per-role entries for roles the
83
+ * models do not govern are dropped — the SAME choice the reconciler makes when it leaves an
84
+ * ungoverned role's policy alone (the `reconcilePolicies` ungoverned escape). Hashing them
85
+ * would keep MATCH unreachable on any brownfield database whose previous stack left a policy
86
+ * TO a migrator/resolver role: zero statements, mismatched hash, forever. PUBLIC is a
87
+ * governed sentinel and always kept.
88
+ */
89
+ export function canonicalPolicies(
90
+ policies: readonly PolicyContract[],
91
+ governed?: ReadonlySet<string>,
92
+ ): Array<[string, number]> {
93
+ const entries = policies.flatMap(expandPolicy);
94
+ const kept = governed
95
+ ? entries.filter((e) => {
96
+ const role = e.slice(0, e.indexOf('|'));
97
+ return governed.has(role) || role === PUBLIC_ROLE || (role.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
98
+ })
99
+ : entries;
100
+ return multiset(kept);
82
101
  }
83
102
 
84
103
  /**
@@ -141,6 +160,19 @@ export function canonicalAuthz(
141
160
  ),
142
161
  }
143
162
  : {}),
144
- policies: canonicalPolicies(contract.policies),
163
+ // A DEAD policy is not part of the state. It authorizes nothing (Postgres refuses at the
164
+ // GRANT before consulting it), and the reconciler now leaves it alone — so counting it
165
+ // here would keep MATCH unreachable on exactly the databases that have them: brownfield
166
+ // ones, where a previous stack left policies behind after its grants were revoked. Both
167
+ // surfaces make the same choice, from the same function. Deadness is recomputed against
168
+ // these grants every time, so the policy re-enters the state the moment a grant revives it.
169
+ // …and neither is a SUBSUMED one: a permissive policy whose rule an identical PUBLIC
170
+ // policy already applies to every role admits no session the other does not. Both
171
+ // exclusions are the same idea — a policy that changes no access is not part of the
172
+ // state — and both are made in lockstep with the reconciler, from the same functions.
173
+ policies: canonicalPolicies(
174
+ contract.policies.filter((p) => !isPolicyDead(contract, p) && !isPolicySubsumed(contract, p)),
175
+ governed,
176
+ ),
145
177
  };
146
178
  }
@@ -20,7 +20,7 @@
20
20
  * Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
21
21
  */
22
22
 
23
- import type { ModelDescriptor, Ability } from '@everystack/model';
23
+ import { isColumnAbility, type ModelDescriptor, type Ability } from '@everystack/model';
24
24
  import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract.js';
25
25
  import { parenthesizeOnce } from './authz-contract.js';
26
26
 
@@ -197,17 +197,6 @@ function softDeleteColumn(model: ModelDescriptor): string | null {
197
197
  return model.softDelete ? 'deleted_at' : null;
198
198
  }
199
199
 
200
- /**
201
- * A column-scoped self read — `can('read', { owner, columns })`. It compiles to a COLUMN
202
- * grant (`GRANT SELECT (cols)`) plus a `<table>_select_self` policy, NOT a table grant and
203
- * NOT a `_select_own` policy: the row is scoped by the owner predicate, the fields by the
204
- * column list (`auth.users` → id/email/role to the owner, never the password hash). It is
205
- * excluded from the table-grant and owner-read paths so the two never double-emit.
206
- */
207
- function isColumnRead(a: Ability): boolean {
208
- return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
209
- }
210
-
211
200
  /**
212
201
  * `can(…, { role: 'public' })` names PostgreSQL's PUBLIC pseudo-role, and the two catalogs
213
202
  * that describe it spell it differently — both correctly:
@@ -234,21 +223,25 @@ const granteeKey = (role: string): string => (isPseudoPublic(role) ? 'PUBLIC' :
234
223
  const policyRole = (role: string): string => (isPseudoPublic(role) ? 'public' : role);
235
224
 
236
225
  /**
237
- * Column grants from the column-scoped read abilities: the read role (`authenticated`, since
238
- * the ability is owner-scoped, not role-gated) gets `SELECT (cols)`. Field keys are snake_cased
239
- * to SQL columns and sorted, matching the introspected contract. Returns undefined when no
240
- * ability declares columns — an introspected contract omits the key rather than carry an empty
241
- * object, so the compiled one must too (or the round-trip diff would false-fire).
226
+ * Column grants from column abilities. The action determines the PostgreSQL verb; the named role
227
+ * (or authenticated for an owner-only ability) is the grantee. Field keys are snake-cased and
228
+ * deduplicated, matching the introspected contract.
242
229
  */
243
230
  function compileColumnGrants(abilities: readonly Ability[]): Record<string, Record<string, string[]>> | undefined {
244
- const out: Record<string, Record<string, string[]>> = {};
231
+ const out: Record<string, Record<string, Set<string>>> = {};
245
232
  for (const a of abilities) {
246
- if (!isColumnRead(a)) continue;
233
+ if (!isColumnAbility(a)) continue;
247
234
  const role = granteeKey(a.condition.role ?? 'authenticated');
248
- const cols = [...a.condition.columns!].map(toSnakeCase).sort();
249
- (out[role] ??= {}).SELECT = cols;
235
+ const verb = a.action === 'read' ? 'SELECT' : 'UPDATE';
236
+ const cols = ((out[role] ??= {})[verb] ??= new Set<string>());
237
+ for (const column of a.condition.columns!) cols.add(toSnakeCase(column));
238
+ }
239
+ const grants: Record<string, Record<string, string[]>> = {};
240
+ for (const [role, verbs] of Object.entries(out)) {
241
+ grants[role] = {};
242
+ for (const [verb, columns] of Object.entries(verbs)) grants[role][verb] = [...columns].sort();
250
243
  }
251
- return Object.keys(out).length ? out : undefined;
244
+ return Object.keys(grants).length ? grants : undefined;
252
245
  }
253
246
 
254
247
  /**
@@ -335,13 +328,13 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
335
328
  * every other role can see.
336
329
  */
337
330
  const isRoleRead = (a: Ability): boolean =>
338
- a.action === 'read' && Boolean(a.condition.role) && !isColumnRead(a);
331
+ a.action === 'read' && Boolean(a.condition.role) && !isColumnAbility(a);
339
332
 
340
333
  // Author-supplied predicates, per action. `manage` carries no predicate of its own —
341
334
  // it is the admin bypass — so only the specific verbs are consulted.
342
335
  const predFor = (action: Ability['action']): string | null => {
343
336
  for (const a of model.abilities) {
344
- if (a.action !== action || isRoleRead(a)) continue;
337
+ if (a.action !== action || isRoleRead(a) || isColumnAbility(a)) continue;
345
338
  const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('${action}')`);
346
339
  if (p) return p;
347
340
  }
@@ -367,7 +360,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
367
360
  /** The predicate the OWNER read narrows ITSELF by — `can('read', { owner, sql })`. */
368
361
  const ownerReadPred = (): string | null => {
369
362
  for (const a of model.abilities) {
370
- if (a.action !== 'read' || !rowScoped(a) || isColumnRead(a)) continue;
363
+ if (a.action !== 'read' || !rowScoped(a) || isColumnAbility(a)) continue;
371
364
  const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read', { owner })`);
372
365
  if (p) return p;
373
366
  }
@@ -387,12 +380,18 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
387
380
  const abilities = model.abilities;
388
381
  const hasAdminManage = abilities.some((a) => a.action === 'manage' && a.condition.role === 'admin');
389
382
  const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !rowScoped(a));
383
+ // The audience is open when the public read says so. Read off the SAME abilities that make
384
+ // `hasPublicRead` true, so the two can never disagree about which read is being compiled.
385
+ const publicAudience = abilities.some(
386
+ (a) => a.action === 'read' && !a.condition.role && !rowScoped(a) && a.condition.policyRoles === 'public',
387
+ );
390
388
  // A column-scoped read is row-scoped but emits a self policy + column grant, not the
391
389
  // full-row owner read — so it is excluded here and handled by its own branch.
392
- const hasOwnerRead = abilities.some((a) => a.action === 'read' && rowScoped(a) && !isColumnRead(a));
393
- const hasColumnRead = abilities.some(isColumnRead);
390
+ const hasOwnerRead = abilities.some((a) => a.action === 'read' && rowScoped(a) && !isColumnAbility(a));
391
+ const hasColumnOwnerRead = abilities.some((a) => a.action === 'read' && isColumnAbility(a) && !a.condition.role && rowScoped(a));
394
392
  const canCreate = abilities.some((a) => a.action === 'create');
395
- const hasUpdateOwner = abilities.some((a) => a.action === 'update' && rowScoped(a));
393
+ const hasUpdateOwner = abilities.some((a) => a.action === 'update' && rowScoped(a) && !isColumnAbility(a));
394
+ const hasColumnUpdateOwner = abilities.some((a) => a.action === 'update' && isColumnAbility(a) && !a.condition.role && rowScoped(a));
396
395
  const hasDeleteOwner = abilities.some((a) => a.action === 'delete' && rowScoped(a));
397
396
 
398
397
  const t = model.table;
@@ -412,7 +411,6 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
412
411
  // also see their own (soft-deleted) rows, hence the OR'd owner check.
413
412
  const readPred = publicReadPred();
414
413
  const anonUsing = andPredicates(sdGuard, readPred) ?? 'true';
415
- policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
416
414
 
417
415
  let authedUsing: string;
418
416
  if (hasOwnerRead && rowPred && anonUsing !== 'true') {
@@ -441,12 +439,34 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
441
439
  ? andPredicates(`(${sdGuard} OR ${rowPred})`, readPred)!
442
440
  : anonUsing;
443
441
  }
444
- policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
442
+ // ONE policy TO PUBLIC, when the model says the audience is open (B1).
443
+ //
444
+ // The two spellings are NOT equivalent and the difference is the whole point: PUBLIC is
445
+ // an open set, `{anon, authenticated}` is closed. A brownfield schema that wrote the
446
+ // ordinary Postgres idiom — one policy TO PUBLIC — had no way to say so, so every plan
447
+ // proposed dropping it for the role-scoped pair. That is a real narrowing: any role
448
+ // outside the pair loses the read.
449
+ //
450
+ // Emitted under the model's own name; identity-by-meaning adopts the live policy whose
451
+ // rule matches, whatever it is called, so a schema already carrying `<t>_read_public`
452
+ // reaches ZERO statements rather than a rename's DROP + CREATE.
453
+ //
454
+ // GRANTS ARE UNTOUCHED — they stay exactly as derived for anon/authenticated. An open
455
+ // audience over a closed grant set widens nothing: Postgres checks the GRANT first, so a
456
+ // role holding no SELECT still reads no rows no matter who the policy names.
457
+ if (publicAudience) {
458
+ policy(`${t}_select_public`, 'SELECT', ['public'], anonUsing, null);
459
+ } else {
460
+ policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
461
+ policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
462
+ }
445
463
  } else if (hasOwnerRead && rowPred) {
446
464
  // owner-scoped read — no anon visibility; you see only the rows you own
447
465
  // (directly, or transitively through a `via:` parent).
448
466
  policy(`${t}_select_own`, 'SELECT', ['authenticated'], andPredicates(rowPred, predFor('read'))!, null);
449
- } else if (hasColumnRead && rowPred) {
467
+ }
468
+
469
+ if (hasColumnOwnerRead && rowPred) {
450
470
  // column-scoped self read — `authenticated` reads only its OWN row, and only the
451
471
  // granted columns (the GRANT scopes fields; this policy scopes rows). Named
452
472
  // `_select_self`, distinct from the full-row `_select_own`.
@@ -460,7 +480,9 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
460
480
  // the model read as though it could see its own. The declared predicate was computed and
461
481
  // discarded. That is the same failure `rawPredicate` throws over, one level up: a predicate
462
482
  // that evaporates is an authorization hole wearing the costume of a working rule.
463
- for (const a of abilities.filter(isRoleRead)) {
483
+ for (const a of abilities.filter((ability) => isRoleRead(ability) || (
484
+ isColumnAbility(ability) && ability.action === 'read' && Boolean(ability.condition.role)
485
+ ))) {
464
486
  // The policy-side spelling, so `role: 'PUBLIC'` still matches what pg_policies reports.
465
487
  const role = policyRole(a.condition.role!);
466
488
  const name = `${t}_select_${role}`;
@@ -471,7 +493,16 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
471
493
  + `Declare one or the other: permissive policies OR together, so the role-scoped rule could only ever widen — never the narrowing it reads as.`,
472
494
  );
473
495
  }
474
- policy(name, 'SELECT', [role], rawPredicate(a.condition.sql ?? a.condition.where, where) ?? 'true', null);
496
+ policy(
497
+ name,
498
+ 'SELECT',
499
+ [role],
500
+ andPredicates(
501
+ rowScoped(a) ? rowPred : null,
502
+ rawPredicate(a.condition.sql ?? a.condition.where, where),
503
+ ) ?? 'true',
504
+ null,
505
+ );
475
506
  }
476
507
 
477
508
  // owner-gated writes. INSERT checks ownership (you can only create rows you own);
@@ -491,6 +522,27 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
491
522
  if ((hasUpdateOwner || predFor('update')) && updatePred) {
492
523
  policy(`${t}_update_own`, 'UPDATE', ['authenticated'], updatePred, checkFor('update') ?? updatePred);
493
524
  }
525
+ else if (hasColumnUpdateOwner && updatePred) {
526
+ policy(`${t}_update_own`, 'UPDATE', ['authenticated'], updatePred, updatePred);
527
+ }
528
+ for (const a of abilities.filter((ability) =>
529
+ isColumnAbility(ability) && ability.action === 'update' && Boolean(ability.condition.role),
530
+ )) {
531
+ const role = policyRole(a.condition.role!);
532
+ const name = `${t}_update_${role}`;
533
+ const where = `${table}: can('update', { role: '${role}' })`;
534
+ if (policies.some((p) => p.name === name)) {
535
+ throw new Error(
536
+ `${where} collides with the ${name} policy already compiled from this model. `
537
+ + `Declare one update ability for that role: permissive policies OR together, so separate rules could only widen.`,
538
+ );
539
+ }
540
+ const using = andPredicates(
541
+ rowScoped(a) ? rowPred : null,
542
+ rawPredicate(a.condition.sql ?? a.condition.where, where),
543
+ ) ?? 'true';
544
+ policy(name, 'UPDATE', [role], using, rawPredicate(a.condition.check, `${where}, { check }`) ?? using);
545
+ }
494
546
  if ((hasDeleteOwner || predFor('delete')) && deletePred) {
495
547
  policy(`${t}_delete_own`, 'DELETE', ['authenticated'], deletePred, null);
496
548
  }
@@ -560,10 +612,8 @@ function compileGrants(
560
612
  for (const v of verbs) set.add(v);
561
613
  };
562
614
  for (const a of abilities) {
563
- // A column-scoped read grants `SELECT (cols)` (a column grant), not a table-level
564
- // privilege — emitted by compileColumnGrants, skipped here so it never leaks a
565
- // whole-table grant that would defeat the column scoping.
566
- if (isColumnRead(a)) continue;
615
+ // A column ability grants only its listed columns, never a table-level privilege.
616
+ if (isColumnAbility(a)) continue;
567
617
  const verbs = verbsFor(a.action);
568
618
  if (a.condition.role) {
569
619
  add(granteeKey(a.condition.role), verbs);