@everystack/cli 0.4.39 → 0.4.41

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.39",
3
+ "version": "0.4.41",
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.6"
112
+ "@everystack/model": "0.4.7"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "@everystack/server": ">=0.4.0",
@@ -70,12 +70,20 @@ export interface ApplyPlanOptions {
70
70
  * attest their own --snapshot-ref keep the ceremony the command shell enforces).
71
71
  */
72
72
  verifySnapshot?: (ctx: { planFrom: string }) => Promise<{ ok: true } | { ok: false; reason: string }>;
73
+ /**
74
+ * The ungoverned-grantee gate, re-verified AT APPLY (B4). db:plan already refuses to mint
75
+ * against a stage holding a foreign grantee the baseline does not record — but a plan is
76
+ * an artifact with a lifetime, and a role minted between minting and applying would
77
+ * otherwise ride through unnoticed. Same contract as the other gates: refusal recorded in
78
+ * schema_log. Omitted = no gate (callers with no baseline to check against).
79
+ */
80
+ verifyAuthzBaseline?: (ctx: { contract: AuthzContract }) => Promise<{ ok: true } | { ok: false; reason: string }>;
73
81
  /** Injectable clock for tests. */
74
82
  now?: () => number;
75
83
  }
76
84
 
77
85
  export interface ApplyPlanResult {
78
- status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'snapshot-refused' | 'verify-failed';
86
+ status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'snapshot-refused' | 'authz-refused' | 'verify-failed';
79
87
  liveFingerprint: string;
80
88
  reason?: string;
81
89
  logId?: number;
@@ -157,6 +165,17 @@ export async function executeApplyPlan(
157
165
  }
158
166
  }
159
167
 
168
+ // Before ANY write: a foreign grantee that arrived after the plan was minted must stop
169
+ // the apply, not ride through it. The plan's own gates check the SCHEMA has not moved;
170
+ // this checks who can reach it.
171
+ if (options.verifyAuthzBaseline) {
172
+ const authz = await options.verifyAuthzBaseline({ contract: beforeAuthz });
173
+ if (!authz.ok) {
174
+ await recordRefusal(runner, plan, live, 'ungoverned grantee', authz.reason, options);
175
+ return { status: 'authz-refused', liveFingerprint: live, reason: authz.reason };
176
+ }
177
+ }
178
+
160
179
  if (plan.destructive > 0 && options.verifyAuthority) {
161
180
  const authority = await options.verifyAuthority();
162
181
  if (!authority.ok) {
@@ -0,0 +1,227 @@
1
+ /**
2
+ * authz-baseline — the foreign grantees that were already there, and the gate that makes a
3
+ * NEW one fail.
4
+ *
5
+ * The reconciler exempts a grantee the models do not govern rather than revoking it
6
+ * (authz-reconcile's `governedRoleSet` — a migrator or BI role must not have its access
7
+ * destroyed by a plan nobody read). That exemption is only half a design. Without the other
8
+ * half, an attacker-minted role holding SELECT on every table is reported forever and fails
9
+ * nothing, which is the failure mode the exemption was rejected for in the first place.
10
+ *
11
+ * So: record the foreign grantees present AT ADOPTION, per stage. After that, an ungoverned
12
+ * grantee that is not in the stage's baseline — or a baselined one that has GROWN — refuses
13
+ * the plan. A greenfield app's baseline is empty, so any foreign grantee is a failure from
14
+ * day one; a brownfield app's migrator is in it, so adoption proceeds with a warning that
15
+ * never goes away.
16
+ *
17
+ * Two deliberate decisions, both adjudicated:
18
+ *
19
+ * - The baseline lives in the REPO, not the database. Storing it in the database looks
20
+ * tamper-evident and is not: the attacker who minted the role owns the database and can
21
+ * write the baseline row in the same session. More importantly it would hide the
22
+ * exemption from the one human checkpoint that exists — a pull request. Adding a
23
+ * grantee is a diff line someone merges. The database keeps a `schema_log` memoir of
24
+ * the adoption event so a reviewer can check the claim against the observation.
25
+ * - It is FROZEN at adoption. There is no "it showed up in production, baseline it"
26
+ * path — that is the whole gate. Admitting a new role costs a PR line, on purpose.
27
+ *
28
+ * Per-stage, because stages legitimately differ (a BI role in production and nowhere else) —
29
+ * and scoped, so a production entry excuses that grantee in production only. The same role
30
+ * appearing in dev still fails there.
31
+ *
32
+ * Pure, with one exception named at the bottom: `readBaselineFile`, which every gate needs
33
+ * to agree on (including "the file is absent", which must behave exactly like an empty
34
+ * baseline rather than like a skipped check).
35
+ */
36
+
37
+ import fs from 'node:fs/promises';
38
+ import path from 'node:path';
39
+ import type { GrantExemption } from './authz-reconcile.js';
40
+
41
+ /** The repo-relative artifact. Generated — regenerating it must reproduce it byte for byte. */
42
+ export const BASELINE_FILE = 'db/authz-baseline.json';
43
+
44
+ /** Privileges that change data or structure — flagged loudly, because a reviewer skims. */
45
+ const WRITE_PRIVILEGES = new Set(['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER']);
46
+
47
+ export interface BaselineGrantee {
48
+ /** The union of table-level privileges held at adoption, sorted. */
49
+ privileges: string[];
50
+ /** The tables it held them on, sorted. */
51
+ tables: string[];
52
+ /** True when any privilege writes. Surfaced so a reviewer sees it without reading the list. */
53
+ write: boolean;
54
+ }
55
+
56
+ export interface BaselineStage {
57
+ /** When the observation was taken — provenance for the claim, not used by the gate. */
58
+ observedAt: string;
59
+ /** The live base fingerprint at observation time — what the claim was true OF. */
60
+ fingerprint: string;
61
+ grantees: Record<string, BaselineGrantee>;
62
+ }
63
+
64
+ export interface AuthzBaseline {
65
+ version: 1;
66
+ stages: Record<string, BaselineStage>;
67
+ }
68
+
69
+ export const EMPTY_BASELINE: AuthzBaseline = { version: 1, stages: {} };
70
+
71
+ /** Fold the observed exemptions into one entry per grantee. */
72
+ export function buildStageBaseline(
73
+ exemptions: readonly GrantExemption[],
74
+ meta: { observedAt: string; fingerprint: string },
75
+ ): BaselineStage {
76
+ const grantees: Record<string, BaselineGrantee> = {};
77
+ for (const e of exemptions) {
78
+ const g = (grantees[e.grantee] ??= { privileges: [], tables: [], write: false });
79
+ g.privileges = [...new Set([...g.privileges, ...e.privileges])].sort();
80
+ g.tables = [...new Set([...g.tables, e.table])].sort();
81
+ // A column-scoped privilege is still that privilege — it must count toward `write`,
82
+ // or a column-scoped UPDATE would read as a harmless reader in review.
83
+ const all = [...e.privileges, ...Object.keys(e.columnPrivileges ?? {})];
84
+ g.write ||= all.some((p) => WRITE_PRIVILEGES.has(p.toUpperCase()));
85
+ }
86
+ return { observedAt: meta.observedAt, fingerprint: meta.fingerprint, grantees };
87
+ }
88
+
89
+ /** Write one stage's entry into the file, leaving every other stage untouched. */
90
+ export function mergeBaseline(existing: AuthzBaseline | null, stage: string, entry: BaselineStage): AuthzBaseline {
91
+ return { version: 1, stages: { ...(existing?.stages ?? {}), [stage]: entry } };
92
+ }
93
+
94
+ /**
95
+ * The artifact's canonical text. Keys sorted at every level so regenerating an unchanged
96
+ * baseline is byte-identical — that equality IS the offline check, and a diff that reorders
97
+ * itself would make the review signal worthless.
98
+ */
99
+ export function renderBaseline(baseline: AuthzBaseline): string {
100
+ const stages: Record<string, BaselineStage> = {};
101
+ for (const stage of Object.keys(baseline.stages).sort()) {
102
+ const s = baseline.stages[stage];
103
+ const grantees: Record<string, BaselineGrantee> = {};
104
+ for (const g of Object.keys(s.grantees).sort()) {
105
+ const { privileges, tables, write } = s.grantees[g];
106
+ grantees[g] = { privileges: [...privileges].sort(), tables: [...tables].sort(), write };
107
+ }
108
+ stages[stage] = { observedAt: s.observedAt, fingerprint: s.fingerprint, grantees };
109
+ }
110
+ return JSON.stringify({ version: 1, stages }, null, 2) + '\n';
111
+ }
112
+
113
+ /** Parse the artifact, refusing anything malformed by name — a baseline that silently reads
114
+ * as empty would disable the gate, which is the one outcome worse than failing. */
115
+ export function parseBaseline(text: string, where = BASELINE_FILE): AuthzBaseline {
116
+ let raw: unknown;
117
+ try {
118
+ raw = JSON.parse(text);
119
+ } catch (err: any) {
120
+ throw new Error(`${where} is not valid JSON: ${err.message}`);
121
+ }
122
+ const b = raw as Partial<AuthzBaseline>;
123
+ if (b?.version !== 1) throw new Error(`${where}: unsupported version ${String(b?.version)} — expected 1.`);
124
+ if (b.stages == null || typeof b.stages !== 'object') throw new Error(`${where}: missing a "stages" object.`);
125
+ for (const [stage, s] of Object.entries(b.stages)) {
126
+ if (s?.grantees == null || typeof s.grantees !== 'object') {
127
+ throw new Error(`${where}: stage "${stage}" has no "grantees" object.`);
128
+ }
129
+ }
130
+ return b as AuthzBaseline;
131
+ }
132
+
133
+ /** One reason a live grantee fails the gate. */
134
+ export interface BaselineViolation {
135
+ grantee: string;
136
+ kind: 'unbaselined' | 'widened';
137
+ detail: string;
138
+ }
139
+
140
+ /**
141
+ * The gate. A stage passes when every ungoverned grantee is in its baseline AND holds
142
+ * nothing beyond what the baseline recorded.
143
+ *
144
+ * `widened` matters as much as `unbaselined`: a role baselined with SELECT that has since
145
+ * acquired UPDATE, or spread to new tables, is not the role that was reviewed. Freezing the
146
+ * NAME while letting the privileges grow would leave the gate defending nothing.
147
+ *
148
+ * An ABSENT stage entry is not an empty one — it means the stage was never adopted, and any
149
+ * foreign grantee there fails. That is deliberate: the safe reading of "no record" is "not
150
+ * reviewed", never "nothing to see".
151
+ */
152
+ export function checkAgainstBaseline(
153
+ baseline: AuthzBaseline | null,
154
+ stage: string,
155
+ exemptions: readonly GrantExemption[],
156
+ ): BaselineViolation[] {
157
+ const observed = buildStageBaseline(exemptions, { observedAt: '', fingerprint: '' }).grantees;
158
+ const recorded = baseline?.stages[stage]?.grantees ?? {};
159
+ const violations: BaselineViolation[] = [];
160
+
161
+ for (const grantee of Object.keys(observed).sort()) {
162
+ const now = observed[grantee];
163
+ const then = recorded[grantee];
164
+ if (!then) {
165
+ violations.push({
166
+ grantee,
167
+ kind: 'unbaselined',
168
+ detail: `holds ${now.privileges.join(', ') || 'column-scoped privileges'} on ${now.tables.length} table(s) and is not in the ${stage} baseline`,
169
+ });
170
+ continue;
171
+ }
172
+ const newPrivileges = now.privileges.filter((p) => !then.privileges.includes(p));
173
+ const newTables = now.tables.filter((t) => !then.tables.includes(t));
174
+ if (newPrivileges.length > 0 || newTables.length > 0) {
175
+ violations.push({
176
+ grantee,
177
+ kind: 'widened',
178
+ detail: [
179
+ newPrivileges.length ? `gained ${newPrivileges.join(', ')}` : null,
180
+ newTables.length ? `spread to ${newTables.join(', ')}` : null,
181
+ ].filter(Boolean).join('; ') + ` since the ${stage} baseline`,
182
+ });
183
+ }
184
+ }
185
+ return violations;
186
+ }
187
+
188
+ /** The refusal an operator reads. Names every violation — a count would not be actionable. */
189
+ export function baselineRefusal(stage: string, violations: readonly BaselineViolation[]): string {
190
+ const lines = violations.map((v) => ` - ${v.grantee} ${v.detail}`);
191
+ return [
192
+ `${violations.length} role(s) hold privileges on ${stage} that the models do not govern and the baseline does not record:`,
193
+ ...lines,
194
+ '',
195
+ `A role the models cannot name is left alone by the reconciler, so it must be reviewed instead of reconciled.`,
196
+ `If these are legitimate, add them deliberately: re-run \`db:pull --abilities live --stage ${stage}\` to regenerate ${BASELINE_FILE},`,
197
+ `review the diff (write privileges are flagged), and commit it. If they are NOT legitimate, revoke them in the database first.`,
198
+ ].join('\n');
199
+ }
200
+
201
+ /**
202
+ * Read the baseline artifact, or null when there is none.
203
+ *
204
+ * ABSENT is not an error and not a skip — it is an EMPTY baseline, so every foreign grantee
205
+ * fails the gate. That is the greenfield default and it is the safe direction: a project
206
+ * that never adopted a baseline has never reviewed a foreign role. A file that exists but
207
+ * cannot be parsed THROWS (see parseBaseline) — silently continuing past a corrupt baseline
208
+ * would disable the gate exactly when someone has been editing it.
209
+ */
210
+ export async function readBaselineFile(cwd = process.cwd()): Promise<AuthzBaseline | null> {
211
+ const file = path.resolve(cwd, BASELINE_FILE);
212
+ let text: string;
213
+ try {
214
+ text = await fs.readFile(file, 'utf8');
215
+ } catch {
216
+ return null;
217
+ }
218
+ return parseBaseline(text, BASELINE_FILE);
219
+ }
220
+
221
+ /** Write the artifact, creating `db/` if this is the first adoption. */
222
+ export async function writeBaselineFile(baseline: AuthzBaseline, cwd = process.cwd()): Promise<string> {
223
+ const file = path.resolve(cwd, BASELINE_FILE);
224
+ await fs.mkdir(path.dirname(file), { recursive: true });
225
+ await fs.writeFile(file, renderBaseline(baseline), 'utf8');
226
+ return file;
227
+ }
@@ -18,6 +18,104 @@
18
18
  import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
19
19
  import { quoteQualified } from './pg-ident.js';
20
20
 
21
+ /**
22
+ * Always governed, whatever the models say. `PUBLIC` because a grant to PUBLIC is the
23
+ * broadest privilege the database can express and must never be exemptible; the three
24
+ * vocabulary roles because they ARE the model's authz surface — letting a module drop one
25
+ * would be a way to hide a grant from the thing that audits it.
26
+ */
27
+ export const ALWAYS_GOVERNED = ['PUBLIC', 'admin', 'anon', 'authenticated'] as const;
28
+
29
+ /** One grantee the declared contract does not govern, with what it actually holds. */
30
+ export interface GrantExemption {
31
+ grantee: string;
32
+ table: string;
33
+ /** Table-level privileges. */
34
+ privileges: string[];
35
+ /** Column-scoped privileges, `privilege → columns`. Present only when there are any. */
36
+ columnPrivileges?: Record<string, string[]>;
37
+ }
38
+
39
+ /**
40
+ * The roles the declared authz governs: everything the models name, plus the always-governed
41
+ * set, plus whatever the modules widen it with. WIDEN-ONLY by construction — `declared` and
42
+ * {@link ALWAYS_GOVERNED} go in unconditionally, so `extra` can add and can never subtract.
43
+ *
44
+ * Grantee comparison is case-insensitive on `PUBLIC` only (PostgreSQL's pseudo-role, which
45
+ * the catalog reports uppercase and the models spell either way); real role names are
46
+ * case-sensitive because PostgreSQL's are.
47
+ */
48
+ export function governedRoleSet(declared: AuthzContract, extra: readonly string[] = []): Set<string> {
49
+ const governed = new Set<string>(ALWAYS_GOVERNED);
50
+ for (const t of declared.tables) {
51
+ for (const grantee of Object.keys(t.grants)) governed.add(grantee);
52
+ for (const grantee of Object.keys(t.columnGrants ?? {})) governed.add(grantee);
53
+ }
54
+ for (const role of extra) governed.add(role);
55
+ return governed;
56
+ }
57
+
58
+ const isGoverned = (governed: ReadonlySet<string>, grantee: string): boolean =>
59
+ governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
60
+
61
+ /**
62
+ * Every live grantee the declared contract does not govern, with its exact privileges —
63
+ * the enumeration that makes exempting them acceptable at all.
64
+ *
65
+ * The reconciler leaves these alone (a live migrator/ETL/BI role the model vocabulary
66
+ * cannot name must not have its access destroyed by a plan nobody read). That is only
67
+ * defensible because every artifact claiming to describe authz state reprints this list:
68
+ * exempt from reconciliation, never exempt from the report. Returns `grantee → table →
69
+ * privileges`, sorted, because a COUNT is not auditable.
70
+ */
71
+ export function ungovernedGrants(live: AuthzContract, governed: ReadonlySet<string>): GrantExemption[] {
72
+ const out: GrantExemption[] = [];
73
+ for (const t of live.tables) {
74
+ const grantees = new Set([...Object.keys(t.grants), ...Object.keys(t.columnGrants ?? {})]);
75
+ for (const grantee of [...grantees].sort()) {
76
+ if (isGoverned(governed, grantee)) continue;
77
+ const columnPrivileges = t.columnGrants?.[grantee];
78
+ out.push({
79
+ grantee,
80
+ table: t.table,
81
+ privileges: [...(t.grants[grantee] ?? [])].sort(),
82
+ ...(columnPrivileges && Object.keys(columnPrivileges).length > 0 ? { columnPrivileges } : {}),
83
+ });
84
+ }
85
+ }
86
+ return out.sort((a, b) => a.grantee.localeCompare(b.grantee) || a.table.localeCompare(b.table));
87
+ }
88
+
89
+ /**
90
+ * The exemption report, one line per grantee — the shared rendering every surface prints.
91
+ *
92
+ * Grouped by grantee (an operator asks "what does this role have?", not "what does this
93
+ * table give?") and always EXACT: role, privileges, and the tables. `3 ungoverned roles`
94
+ * would not be auditable, which is the whole reason the exemption is allowed to exist.
95
+ */
96
+ export function renderGrantExemptions(exemptions: readonly GrantExemption[]): string[] {
97
+ const byGrantee = new Map<string, GrantExemption[]>();
98
+ for (const e of exemptions) byGrantee.set(e.grantee, [...(byGrantee.get(e.grantee) ?? []), e]);
99
+
100
+ const lines: string[] = [];
101
+ for (const [grantee, entries] of [...byGrantee.entries()].sort(([a], [b]) => a.localeCompare(b))) {
102
+ // The privilege set is usually uniform across the tables; say it once and list the
103
+ // tables, and only fall back to per-table lines when it genuinely differs.
104
+ const shapes = new Set(entries.map((e) => e.privileges.join(',')));
105
+ if (shapes.size === 1 && entries.length > 1) {
106
+ lines.push(`${grantee}: ${entries[0].privileges.join(', ')} on ${entries.length} table(s) — ${entries.map((e) => e.table).sort().join(', ')}`);
107
+ } else {
108
+ for (const e of entries.sort((a, b) => a.table.localeCompare(b.table))) {
109
+ const cols = e.columnPrivileges
110
+ ? `; column-scoped ${Object.entries(e.columnPrivileges).map(([p, c]) => `${p} (${c.join(', ')})`).join(', ')}`
111
+ : '';
112
+ lines.push(`${grantee}: ${e.privileges.join(', ') || 'no table-level privileges'} on ${e.table}${cols}`);
113
+ }
114
+ }
115
+ }
116
+ return lines;
117
+ }
118
+
21
119
  /** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
22
120
  function isWrapped(s: string): boolean {
23
121
  if (!s.startsWith('(') || !s.endsWith(')')) return false;
@@ -72,9 +170,17 @@ function tableMap(c: AuthzContract): Map<string, TableContract> {
72
170
  * revoke/grant — so a replaced policy never briefly co-exists with its old form
73
171
  * and policies exist before grants reference the table.
74
172
  */
75
- export function emitReconcileSql(declared: AuthzContract, live: AuthzContract): string[] {
173
+ export function emitReconcileSql(
174
+ declared: AuthzContract,
175
+ live: AuthzContract,
176
+ opts: { governedRoles?: ReadonlySet<string> } = {},
177
+ ): string[] {
76
178
  const sql: string[] = [];
77
179
  const lTables = tableMap(live);
180
+ // Default: govern exactly what the models name (plus the always-governed set). A caller
181
+ // that passes nothing gets the safe-for-greenfield behaviour; a brownfield caller widens
182
+ // it with the modules' declared governedRoles.
183
+ const governed = opts.governedRoles ?? governedRoleSet(declared);
78
184
 
79
185
  for (const d of declared.tables) {
80
186
  const l = lTables.get(d.table);
@@ -83,8 +189,8 @@ export function emitReconcileSql(declared: AuthzContract, live: AuthzContract):
83
189
  const table = quoteQualified(d.table);
84
190
  reconcileRls(table, d, l, sql);
85
191
  reconcilePolicies(table, d, l, sql);
86
- reconcileGrants(table, d, l, sql);
87
- reconcileColumnGrants(table, d, l, sql);
192
+ reconcileGrants(table, d, l, sql, governed);
193
+ reconcileColumnGrants(table, d, l, sql, governed);
88
194
  }
89
195
 
90
196
  return sql;
@@ -108,6 +214,7 @@ export function emitReconcileSql(declared: AuthzContract, live: AuthzContract):
108
214
  */
109
215
  export function emitSwapAuthzSql(declared: AuthzContract): string[] {
110
216
  const sql: string[] = [];
217
+ const governed = governedRoleSet(declared);
111
218
  for (const d of declared.tables) {
112
219
  const table = quoteQualified(d.table);
113
220
  if (d.rls.enabled) sql.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
@@ -116,8 +223,10 @@ export function emitSwapAuthzSql(declared: AuthzContract): string[] {
116
223
  sql.push(policyDropSql(table, p.name)); // idempotent against the policy the dump restored
117
224
  sql.push(policyCreateSql(table, p));
118
225
  }
119
- reconcileGrants(table, d, undefined, sql);
120
- reconcileColumnGrants(table, d, undefined, sql);
226
+ // No live side here, so every grantee comes from the DECLARED contract and is
227
+ // governed by definition — the exemption can never fire on this path.
228
+ reconcileGrants(table, d, undefined, sql, governed);
229
+ reconcileColumnGrants(table, d, undefined, sql, governed);
121
230
  }
122
231
  return sql;
123
232
  }
@@ -145,9 +254,15 @@ function reconcilePolicies(table: string, d: TableContract, l: TableContract | u
145
254
  }
146
255
  }
147
256
 
148
- function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
257
+ function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
149
258
  const grantees = new Set([...Object.keys(d.grants), ...Object.keys(l?.grants ?? {})]);
150
259
  for (const grantee of [...grantees].sort()) {
260
+ // A live grantee the declared authz does not govern is LEFT ALONE. The union below
261
+ // would otherwise revoke every privilege it holds, because "no declaration" and "no
262
+ // privileges" are the same thing to a set difference — which is how a plan came to
263
+ // carry 32 revokes against a brownfield adopter's migration role. It is reported
264
+ // instead, by ungovernedGrants(), on every surface that describes authz state.
265
+ if (!isGoverned(governed, grantee)) continue;
151
266
  const declared = new Set(d.grants[grantee] ?? []);
152
267
  const live = new Set(l?.grants?.[grantee] ?? []);
153
268
  const target = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
@@ -165,10 +280,13 @@ function reconcileGrants(table: string, d: TableContract, l: TableContract | und
165
280
  * those live-but-not-declared. This is how `auth.users` exposes id/email/role (and only those)
166
281
  * to `authenticated` without a whole-table grant. Columns are sorted, so a re-pull is a no-op.
167
282
  */
168
- function reconcileColumnGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
283
+ function reconcileColumnGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
169
284
  const dcg = d.columnGrants ?? {};
170
285
  const lcg = l?.columnGrants ?? {};
171
286
  for (const grantee of [...new Set([...Object.keys(dcg), ...Object.keys(lcg)])].sort()) {
287
+ // Same exemption as the table-level grants — a column grant to an ungoverned role is
288
+ // still that role's access, and revoking it breaks the same pipeline.
289
+ if (!isGoverned(governed, grantee)) continue;
172
290
  const target = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
173
291
  const dPriv = dcg[grantee] ?? {};
174
292
  const lPriv = lcg[grantee] ?? {};
@@ -31,6 +31,11 @@ import { planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
31
31
  import { verifyDescent, type DescentVerdict, type LiveState } from '../git-descent.js';
32
32
  import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity, type AuthorityVerdict } from '../apply-authority.js';
33
33
  import { resolveModelsPath } from '../models-path.js';
34
+ import { loadModels } from './db-generate.js';
35
+ import { loadDeclaredDerived } from '../declared-derived.js';
36
+ import { compileTableContract } from '../authz-compile.js';
37
+ import { governedRoleSet, ungovernedGrants } from '../authz-reconcile.js';
38
+ import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline.js';
34
39
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
35
40
  import { resolveConfig, opsFunction } from '../config.js';
36
41
  import { invokeAction, lambdaQueryRunner } from '../aws.js';
@@ -341,6 +346,19 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
341
346
  actor: process.env.USER ?? null,
342
347
  gitRef: currentGitRef() ?? plan.gitRef,
343
348
  ...(verifyAuthority ? { verifyAuthority } : {}),
349
+ // Re-verified here, not just at mint: a plan is an artifact with a lifetime, and a
350
+ // foreign role granted between minting and applying would otherwise ride through.
351
+ verifyAuthzBaseline: async ({ contract }) => {
352
+ const stageName = flags.stage || 'local';
353
+ const models = await loadModels(resolveModelsPath(flags.models));
354
+ const governedRoles = (await loadDeclaredDerived(flags.models))?.governedRoles ?? [];
355
+ const declaredAuthz = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
356
+ const exemptions = ungovernedGrants(contract, governedRoleSet(declaredAuthz, governedRoles));
357
+ const violations = checkAgainstBaseline(await readBaselineFile(), stageName, exemptions);
358
+ return violations.length === 0
359
+ ? { ok: true as const }
360
+ : { ok: false as const, reason: baselineRefusal(stageName, violations) };
361
+ },
344
362
  ...(forceDescent === undefined ? {
345
363
  verifyDescent: async (live: LiveState & { fingerprint: string }) => {
346
364
  step('Descent: searching git for the commit that declares the target\'s state...');
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  import fs from 'node:fs/promises';
32
+ import path from 'node:path';
32
33
  import type { ModelDescriptor, DerivedDescriptor } from '@everystack/model';
33
34
  import { compileDeclaredState } from '../declared-diff.js';
34
35
  import { compileMigration } from '../migration-compile.js';
@@ -41,6 +42,7 @@ import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, t
41
42
  import type { SequenceDescriptor } from '@everystack/model';
42
43
  import type { SourceObject } from '../derived-source.js';
43
44
  import { findReadAuthzGaps } from '../authz-lint.js';
45
+ import { parseBaseline, renderBaseline, BASELINE_FILE } from '../authz-baseline.js';
44
46
  import { findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings } from '../derived-lint.js';
45
47
  import { step, success, fail, info, warn } from '../output.js';
46
48
 
@@ -82,6 +84,9 @@ export interface StaticCheckInput {
82
84
  * stream reads `export const models`; when the two disagree, the state layer builds a
83
85
  * different database than the modules declare — the split-brain gate below names it. */
84
86
  moduleModels?: ModelDescriptor[] | null;
87
+ /** Raw `db/authz-baseline.json`, or null when there is none. The ARTIFACT half of the
88
+ * ungoverned-grantee gate — see the check below for what it deliberately does not do. */
89
+ baselineSource?: string | null;
85
90
  }
86
91
 
87
92
  export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
@@ -155,6 +160,37 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
155
160
  }
156
161
  }
157
162
 
163
+ // The adoption baseline, ARTIFACT ONLY.
164
+ //
165
+ // This check cannot see the threat and must not pretend to. The foreign grantees it
166
+ // exists to catch live in a real database; db:check's ephemeral compose builds a scratch
167
+ // database that by construction has none of them. So what runs offline is: does the
168
+ // artifact parse, and does it regenerate byte-identically (a hand-edit that does not
169
+ // match the recorded observation shape is a fail). The gate that actually defends the
170
+ // database is on the LIVE path — db:plan refuses to mint and db:apply re-verifies.
171
+ if (input.baselineSource != null) {
172
+ try {
173
+ const parsed = parseBaseline(input.baselineSource);
174
+ if (renderBaseline(parsed) !== input.baselineSource) {
175
+ findings.push({
176
+ level: 'fail',
177
+ area: 'authz',
178
+ message: `${BASELINE_FILE} is not in generated form — re-run \`db:pull --abilities live --stage <name>\` and commit the result. (A hand-edited baseline is how a foreign role gets admitted without the observation that justifies it.)`,
179
+ });
180
+ } else {
181
+ const stages = Object.keys(parsed.stages).sort();
182
+ const total = stages.reduce((n, st) => n + Object.keys(parsed.stages[st].grantees).length, 0);
183
+ findings.push({
184
+ level: 'ok',
185
+ area: 'authz',
186
+ message: `${BASELINE_FILE} is in generated form — ${total} adopted foreign grantee(s) across ${stages.length} stage(s): ${stages.join(', ')} (the live gate runs at db:plan/db:apply, not here)`,
187
+ });
188
+ }
189
+ } catch (err: any) {
190
+ findings.push({ level: 'fail', area: 'authz', message: err.message });
191
+ }
192
+ }
193
+
158
194
  // The derived layer failing to COMPILE (reachability, cycles, undeclared trigger
159
195
  // functions) is a hard fail — CI cannot shrug at a compile error.
160
196
  if (input.derivedError) {
@@ -295,10 +331,19 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
295
331
  derivedError = err.message;
296
332
  }
297
333
 
334
+ // The baseline artifact, read raw so the check can compare it against its own
335
+ // regeneration. Absent is not a failure here — an app with no foreign grantees has
336
+ // nothing to adopt; the LIVE gate is what refuses an unadopted one.
337
+ let baselineSource: string | null = null;
338
+ try {
339
+ baselineSource = await fs.readFile(path.resolve(process.cwd(), BASELINE_FILE), 'utf8');
340
+ } catch { /* no baseline — see above */ }
341
+
298
342
  const findings = runStaticChecks({
299
343
  modelsPath, models, modelsError, artifactPath, artifactSource, sqlDirRetired,
300
344
  sequences: declaredDb?.sequences, derived: declaredDb?.derived, derivedError,
301
345
  moduleModels: declaredDb?.models ?? null,
346
+ baselineSource,
302
347
  });
303
348
  for (const f of findings) {
304
349
  (f.level === 'fail' || f.level === 'warn' ? warn : info)(`${MARK[f.level]} ${f.area}: ${f.message}`);
@@ -27,7 +27,9 @@ import { introspectSchema } from '../schema-introspect.js';
27
27
  import { compileDrizzleSource } from '../schema-source.js';
28
28
  import { compileModuleMigration } from '../migration-compile.js';
29
29
  import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, resolveSchemaOut, HELD_DROP_PREFIX, type Journal } from '../migration-generate.js';
30
- import { introspectContract, type QueryRunner } from '../authz-contract.js';
30
+ import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract.js';
31
+ import { compileTableContract } from '../authz-compile.js';
32
+ import { governedRoleSet, ungovernedGrants, renderGrantExemptions } from '../authz-reconcile.js';
31
33
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
32
34
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
33
35
  import { resolveModelsPath } from '../models-path.js';
@@ -86,7 +88,7 @@ async function loadModules(modelsPath: string): Promise<Module[]> {
86
88
  }
87
89
  if (Array.isArray(mod.modules)) return mod.modules;
88
90
  const models = mod.models ?? mod.default;
89
- if (Array.isArray(models)) return [{ models, extensions: [], sequences: [], derived: [], functions: [] }];
91
+ if (Array.isArray(models)) return [{ models, extensions: [], sequences: [], derived: [], governedRoles: [], functions: [] }];
90
92
  throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
91
93
  }
92
94
 
@@ -129,6 +131,26 @@ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaO
129
131
  }
130
132
 
131
133
  /** Read the journal if present (null = no history yet). */
134
+ /**
135
+ * Name every live grantee the declared authz does not govern, with its exact privileges.
136
+ *
137
+ * The reconciler EXEMPTS these from revocation — a migrator, ETL account or BI reader the
138
+ * model vocabulary cannot express would otherwise have its access destroyed by a plan
139
+ * nobody read (32 such revokes on the first brownfield schema this was measured against).
140
+ * The exemption is only defensible if it is never silent, so this prints on every generate,
141
+ * dry run or not, and names roles and privileges exactly rather than counting them.
142
+ */
143
+ function reportGrantExemptions(liveAuthz: AuthzContract | undefined, models: ModelDescriptor[], governedRoles: string[]): void {
144
+ if (!liveAuthz) return; // no live side — nothing can be ungoverned
145
+ const declared: AuthzContract = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
146
+ const exemptions = ungovernedGrants(liveAuthz, governedRoleSet(declared, governedRoles));
147
+ if (exemptions.length === 0) return;
148
+ const roles = new Set(exemptions.map((e) => e.grantee));
149
+ warn(`${roles.size} role(s) hold privileges this schema does not govern — left UNTOUCHED, never revoked:`);
150
+ for (const line of renderGrantExemptions(exemptions)) info(` ${line}`);
151
+ info(`Bring them under the reconciler with defineModule({ governedRoles: [...] }), or leave them exempt — either way they are listed here every run.`);
152
+ }
153
+
132
154
  async function readJournal(migrationsDir: string): Promise<Journal | null> {
133
155
  try {
134
156
  return JSON.parse(await fs.readFile(path.join(migrationsDir, 'meta', '_journal.json'), 'utf8'));
@@ -226,11 +248,15 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
226
248
 
227
249
  // One ordered migration carries both layers: data DDL first, then the authz reconcile
228
250
  // (RLS/policies/grants) the Models' abilities declare, diffed against the live contract.
229
- const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz, sequences: declaredDb?.sequences });
251
+ const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz, sequences: declaredDb?.sequences, governedRoles: declaredDb?.governedRoles });
230
252
  const unmodeled = unmodeledTables(models, current);
231
253
  if (unmodeled.length) {
232
254
  info(`${unmodeled.length} table(s) in the database are not declared by any model — left untouched (db:generate manages only declared tables).`);
233
255
  }
256
+ // Exemptions are enumerated on EVERY generate, dry run or not. The reconciler leaves an
257
+ // ungoverned grantee's privileges alone rather than revoking them; that is only
258
+ // defensible because the artifact says exactly whose access it chose not to govern.
259
+ reportGrantExemptions(liveAuthz, models, declaredDb?.governedRoles ?? []);
234
260
  console.log('');
235
261
  if (statements.length === 0) {
236
262
  success(`db:generate — the live database already matches the models. ${dryRun ? 'Nothing to preview.' : 'No migration written.'}`);
@@ -33,6 +33,10 @@ import { resolveModelsPath } from '../models-path.js';
33
33
  import { resolveConfig, opsFunction } from '../config.js';
34
34
  import { lambdaQueryRunner } from '../aws.js';
35
35
  import { loadModels } from './db-generate.js';
36
+ import { loadDeclaredDerived } from '../declared-derived.js';
37
+ import { governedRoleSet, ungovernedGrants } from '../authz-reconcile.js';
38
+ import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline.js';
39
+ import { compileTableContract } from '../authz-compile.js';
36
40
  import { reportPipelineLastRun } from './pipeline-run.js';
37
41
  import { step, success, fail, info, warn } from '../output.js';
38
42
 
@@ -73,6 +77,29 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
73
77
  step('Asking the target its fingerprint (introspecting state + authz)...');
74
78
  const snapshot = await introspectSchema(runner);
75
79
  const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
80
+ // The modules' widened governed-role set. A barrel that exports only `models` has none,
81
+ // which is the greenfield default: govern exactly what the models name.
82
+ let declaredGovernedRoles: string[] = [];
83
+ try {
84
+ declaredGovernedRoles = (await loadDeclaredDerived(flags.models))?.governedRoles ?? [];
85
+ } catch {
86
+ // The barrel's own compose errors surface on the paths that need the derived layer;
87
+ // a plan must not fail to mint because a module could not be read for this one field.
88
+ }
89
+
90
+ // THE GATE. The reconciler leaves an ungoverned grantee alone rather than revoking it,
91
+ // so the only thing standing between a brownfield exemption and a permanent silent hole
92
+ // is this: a foreign grantee must be in the stage's adoption baseline, and must not have
93
+ // grown since. An absent baseline is an EMPTY one, so greenfield gets the strong
94
+ // property with no flag to remember — a new foreign role refuses on day one.
95
+ const stageName = flags.stage || 'local';
96
+ const declaredAuthz = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
97
+ const exemptions = ungovernedGrants(contract, governedRoleSet(declaredAuthz, declaredGovernedRoles));
98
+ const violations = checkAgainstBaseline(await readBaselineFile(), stageName, exemptions);
99
+ if (violations.length > 0) {
100
+ fail(baselineRefusal(stageName, violations));
101
+ process.exit(1);
102
+ }
76
103
 
77
104
  let plan;
78
105
  try {
@@ -80,6 +107,9 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
80
107
  allowDrops: flags['allow-drops'] === 'true',
81
108
  gitRef: currentGitRef(),
82
109
  actor: process.env.USER ?? null,
110
+ // Without this the MINTED PLAN carries a REVOKE for every live grantee the models
111
+ // do not name — which is the artifact an operator actually applies.
112
+ governedRoles: declaredGovernedRoles,
83
113
  });
84
114
  } catch (err: any) {
85
115
  fail(`Mint refused: ${err.message}`);
@@ -30,6 +30,9 @@
30
30
  import fs from 'node:fs/promises';
31
31
  import path from 'node:path';
32
32
  import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, type ColumnRow, type ColumnSchema } from '../schema-introspect.js';
33
+ import { fingerprintLive } from '../schema-fingerprint.js';
34
+ import { ungovernedGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
35
+ import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline.js';
33
36
  import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
34
37
  import { introspectContract, type TableContract } from '../authz-contract.js';
35
38
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
@@ -126,6 +129,9 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
126
129
 
127
130
  let current;
128
131
  let liveAuthz: Map<string, TableContract> | undefined;
132
+ /** Adoption observation — the foreign grantees present, and what the claim is true OF. */
133
+ let pulledExemptions: GrantExemption[] = [];
134
+ let pulledFingerprint = '';
129
135
  let derivedCatalog: DerivedCatalog | undefined;
130
136
  let matviewColumns: Map<string, ColumnSchema[]> | undefined;
131
137
  let candidatesByIdentity: Map<string, string[]> | undefined;
@@ -167,6 +173,17 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
167
173
  note(`Grants exist for ${[...unmapped].sort().join(', ')} — not rendered: abilities name the roles the model knows (anon/authenticated/admin).`);
168
174
  detail(`These are left exactly as they are in the database. Add can(..., { role }) only if you want the models to own them.`);
169
175
  }
176
+ // ADOPTION: record the foreign grantees that were already here, per stage. The
177
+ // reconciler exempts them from revocation, so this artifact is what stops that
178
+ // exemption becoming permanent and silent — anything NEW, or anything that has since
179
+ // grown, refuses at db:plan. It lands as a reviewable diff, with writes flagged,
180
+ // because nothing mechanical can tell a legitimate BI role from an attacker's on day
181
+ // one; the defence is forcing the look and making it recur.
182
+ // The governed set at ADOPTION is the fixed vocabulary alone: the models being
183
+ // rendered here can only name anon/authenticated/admin, so every other grantee is
184
+ // foreign by construction — the same set `unmapped` just reported, with privileges.
185
+ pulledExemptions = ungovernedGrants(contract, new Set(ALWAYS_GOVERNED));
186
+ pulledFingerprint = fingerprintLive(current, contract).hash;
170
187
  }
171
188
  // --matviews-as-tables: the flip needs real fields — one extra catalog read for the
172
189
  // matview columns the derived layer (definition-only) doesn't carry.
@@ -299,5 +316,28 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
299
316
  } else {
300
317
  note(`Stamped '${abilities}' abilities into every model — review the generated stanzas; they are code, not defaults.`);
301
318
  }
319
+
320
+ // The adoption baseline. Only under --abilities live (it is an OBSERVATION of the live
321
+ // grants, so there is nothing to record without having read them) and only for a named
322
+ // stage, because the gate is per-stage: a production entry must not excuse the same role
323
+ // in dev. Written even when EMPTY — an empty baseline for a stage is a real, reviewed
324
+ // claim ("this stage had no foreign grantees"), and it is what makes a later arrival fail.
325
+ if (abilities === 'live') {
326
+ const stageName = flags.stage;
327
+ if (!stageName) {
328
+ note(`No --stage, so ${BASELINE_FILE} was not written. The foreign grantees above are recorded per stage; re-run with --stage <name> to adopt them, or db:plan will refuse until you do.`);
329
+ } else {
330
+ const entry = buildStageBaseline(pulledExemptions, { observedAt: new Date().toISOString(), fingerprint: pulledFingerprint });
331
+ const merged = mergeBaseline(await readBaselineFile(), stageName, entry);
332
+ const written = await writeBaselineFile(merged);
333
+ const names = Object.keys(entry.grantees);
334
+ const writers = names.filter((n) => entry.grantees[n].write);
335
+ ok(`Wrote ${path.relative(process.cwd(), written)} — ${names.length} foreign grantee(s) recorded for stage '${stageName}'.`);
336
+ if (writers.length) {
337
+ caution(`${writers.length} of them hold WRITE privileges: ${writers.join(', ')}. Review the diff before committing — this is the moment those roles get looked at.`);
338
+ }
339
+ note(`Committing this file ADOPTS those roles. Anything not in it — or anything that grows beyond it — refuses at db:plan.`);
340
+ }
341
+ }
302
342
  process.exit(0);
303
343
  }
@@ -75,6 +75,9 @@ export interface SyncOptions {
75
75
  declared?: SourceObject[];
76
76
  /** Standalone sequences the modules declare (state — created before tables, fingerprinted). */
77
77
  sequences?: SequenceDescriptor[];
78
+ /** Roles the modules govern beyond the ones the models name — a grantee outside the set
79
+ * is exempted from revocation and enumerated instead (authz-reconcile's governedRoleSet). */
80
+ governedRoles?: string[];
78
81
  /** Pending table renames — trigger provenance migrates instead of drop+create. */
79
82
  renamedTables?: Record<string, string>;
80
83
  /** Injectable clock for tests. */
@@ -118,6 +121,7 @@ export async function executeSync(
118
121
  const liveAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
119
122
  const statements = generateMigrationSql(models, current, {
120
123
  allowDrops: options.allowDrops, liveAuthz, sequences: options.sequences,
124
+ governedRoles: options.governedRoles,
121
125
  });
122
126
  hooks.onStatePlan?.(statements, classifyGeneratedStatements(statements));
123
127
 
@@ -12,6 +12,7 @@ import path from 'node:path';
12
12
  import fs from 'node:fs/promises';
13
13
  import { pathToFileURL } from 'node:url';
14
14
  import type { Module, ModelDescriptor, SequenceDescriptor, DerivedDescriptor } from '@everystack/model';
15
+ import { moduleGovernedRoles } from '@everystack/model';
15
16
  import { compileDerived } from './derived-compile.js';
16
17
  import { compileTableRenames, compileTableMoves } from './schema-compile.js';
17
18
  import type { SourceObject } from './derived-source.js';
@@ -30,6 +31,9 @@ export interface DeclaredDerived {
30
31
  /** The models the modules compose — db:check's split-brain gate compares these against
31
32
  * the barrel's `export const models` (the array every verb's state stream reads). */
32
33
  models: ModelDescriptor[];
34
+ /** Roles the modules declare as governed beyond the ones the models name. A live grantee
35
+ * outside the governed set is exempted from reconciliation and ENUMERATED instead. */
36
+ governedRoles: string[];
33
37
  }
34
38
 
35
39
  /**
@@ -64,7 +68,7 @@ export async function loadModulesFrom(modelsPath: string): Promise<Module[]> {
64
68
  const models = mod.models ?? mod.default;
65
69
  if (Array.isArray(models)) {
66
70
  assertNoHoles(modelsPath, 'models', models);
67
- return [{ models, extensions: [], sequences: [], derived: [], functions: [] }];
71
+ return [{ models, extensions: [], sequences: [], derived: [], governedRoles: [], functions: [] }];
68
72
  }
69
73
  throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
70
74
  } catch (err) {
@@ -173,6 +177,7 @@ export function composeDeclaredDerived(modules: Module[], modelsPath: string): D
173
177
  // ... SET SCHEMA alike, so the trigger-provenance migration is identical — feed both.
174
178
  renamedTables: { ...compileTableRenames(models, {}), ...compileTableMoves(models, {}) },
175
179
  models,
180
+ governedRoles: moduleGovernedRoles(modules),
176
181
  };
177
182
  } catch (err) {
178
183
  throw asModelComposeError(modelsPath, err);
@@ -29,6 +29,7 @@ import type { ModelDescriptor } from '@everystack/model';
29
29
  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
+ import { compileTableContract } from './authz-compile.js';
32
33
  import { classifyGeneratedStatements, classifyDestructive, renderStatementHistogram } from './state-apply.js';
33
34
  import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
34
35
  import { compileDeclaredState } from './declared-diff.js';
@@ -36,7 +37,7 @@ import { compileTableRenames, compileTableMoves } from './schema-compile.js';
36
37
 
37
38
  export const PLAN_VERSION = 2;
38
39
 
39
- /** Classification counts (brick 9, decision 11): destructive = drops + narrowings. */
40
+ /** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
40
41
  export interface PlanClassification {
41
42
  /** Executable statements that lose no data. */
42
43
  additive: number;
@@ -44,6 +45,9 @@ export interface PlanClassification {
44
45
  drops: number;
45
46
  /** Lossy/risky `ALTER COLUMN … TYPE` — data loss wearing an ALTER. */
46
47
  narrowings: number;
48
+ /** REVOKEs against a grantee no model declares — nothing re-derives that access.
49
+ * Optional: a plan minted before this classification existed has no field for it. */
50
+ strips?: number;
47
51
  }
48
52
 
49
53
  export interface EdgePlan {
@@ -72,6 +76,12 @@ export interface MintOptions {
72
76
  allowDrops?: boolean;
73
77
  gitRef?: string | null;
74
78
  actor?: string | null;
79
+ /**
80
+ * Roles the modules declare as governed beyond the ones the models name. A live grantee
81
+ * outside the governed set is left untouched rather than revoked — without this the
82
+ * MINTED PLAN carries those revokes, which is where they would actually be applied.
83
+ */
84
+ governedRoles?: string[];
75
85
  }
76
86
 
77
87
  /**
@@ -145,6 +155,7 @@ export function mintEdgePlan(
145
155
  schema: opts.schema,
146
156
  allowDrops: opts.allowDrops,
147
157
  liveAuthz: contract,
158
+ governedRoles: opts.governedRoles,
148
159
  });
149
160
  const classified = classifyGeneratedStatements(statements);
150
161
  if (classified.heldDrops.length > 0) {
@@ -158,8 +169,16 @@ export function mintEdgePlan(
158
169
  // (the authz phase is data-safe by construction); dropping a table,
159
170
  // column, or type is not — and neither is a narrowing type change, which
160
171
  // is data loss wearing an ALTER.
161
- const breakdown = classifyDestructive(classified.executable);
162
- const destructive = breakdown.drops.length + breakdown.narrowings.length;
172
+ // The grantees the models actually declare — the discriminator for a STRIP (a revoke
173
+ // against a role no model grants to has nothing to restore it from, so it carries the
174
+ // same ceremony as a dropped column).
175
+ const declaredGrantees = new Set<string>();
176
+ for (const t of models.map((m) => compileTableContract(m, { schema: opts.schema }))) {
177
+ for (const g of Object.keys(t.grants)) declaredGrantees.add(g);
178
+ for (const g of Object.keys(t.columnGrants ?? {})) declaredGrantees.add(g);
179
+ }
180
+ const breakdown = classifyDestructive(classified.executable, { declaredGrantees });
181
+ const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
163
182
 
164
183
  return {
165
184
  v: PLAN_VERSION,
@@ -173,6 +192,7 @@ export function mintEdgePlan(
173
192
  additive: classified.executable.length - destructive,
174
193
  drops: breakdown.drops.length,
175
194
  narrowings: breakdown.narrowings.length,
195
+ ...(breakdown.strips.length > 0 ? { strips: breakdown.strips.length } : {}),
176
196
  },
177
197
  notices: classified.notices.length,
178
198
  unmodeled: unmodeledTables(models, snapshot),
@@ -20,7 +20,7 @@ import type { SchemaSnapshot } from './schema-introspect.js';
20
20
  import type { AuthzContract } from './authz-contract.js';
21
21
  import { compileTableSchema, compileRenames, compileTableRenames, compileTableMoves, compileCreateTable, compileEnums, compileSequences } from './schema-compile.js';
22
22
  import { compileTableContract } from './authz-compile.js';
23
- import { emitReconcileSql } from './authz-reconcile.js';
23
+ import { emitReconcileSql, governedRoleSet } from './authz-reconcile.js';
24
24
  import { diffSchema, emitSchemaSql, type SchemaChange } from './schema-diff.js';
25
25
 
26
26
  export interface GenerateOptions {
@@ -44,6 +44,13 @@ export interface GenerateOptions {
44
44
  * removed table/enum becomes a drop (still held by the `allowDrops` gate).
45
45
  */
46
46
  scope?: 'declared' | 'full';
47
+ /**
48
+ * Roles the modules declare as governed BEYOND the ones the models name
49
+ * (`moduleGovernedRoles`). A live grantee outside the governed set is left untouched
50
+ * rather than revoked — see authz-reconcile's `governedRoleSet`. Widen-only: this can
51
+ * never stop a model-named role being governed.
52
+ */
53
+ governedRoles?: string[];
47
54
  /**
48
55
  * The live authorization contract (`introspectContract`). When provided, the migration
49
56
  * carries the authz layer too — the Models' `abilities` compiled to RLS/policies/grants,
@@ -208,8 +215,11 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
208
215
  }
209
216
  : opts.liveAuthz;
210
217
  const desiredContracts = models.map((m) => compileTableContract(m, { schema }));
218
+ const desiredAuthz: AuthzContract = { tables: desiredContracts, functions: [] };
211
219
  const authzPhase = liveAuthzRenamed
212
- ? emitReconcileSql({ tables: desiredContracts, functions: [] }, liveAuthzRenamed)
220
+ ? emitReconcileSql(desiredAuthz, liveAuthzRenamed, {
221
+ governedRoles: governedRoleSet(desiredAuthz, opts.governedRoles ?? []),
222
+ })
213
223
  : [];
214
224
 
215
225
  // 0a-bis (the diff analog of compileMigration's). A role cannot reach a table in a
@@ -226,7 +226,13 @@ function renderDefault(expr: string): string | null {
226
226
  const str = n.match(/^'([\s\S]*)'$/);
227
227
  if (str) return `.default(${JSON.stringify(str[1].replace(/''/g, "'"))})`;
228
228
  if (n === 'true' || n === 'false') return `.default(${n})`;
229
- if (/^-?\d+(\.\d+)?$/.test(n)) return `.default(${n})`;
229
+ // A numeric default only survives as a JS number when the number renders back to the
230
+ // SAME text. `0.0` is the number 0, so `.default(0.0)` stores 0 and compiles to
231
+ // `DEFAULT 0` against a live `DEFAULT 0.0` — drift on every apply, forever (21 statements
232
+ // on the first real schema this was measured against). Same for `1.50`, `0.10`, `1e3`.
233
+ // Where the text cannot round-trip, fall through to `.defaultSql()`, which carries the
234
+ // live spelling verbatim. The exact-integer and exact-decimal cases are unchanged.
235
+ if (/^-?\d+(\.\d+)?$/.test(n)) return String(Number(n)) === n ? `.default(${n})` : null;
230
236
  return null;
231
237
  }
232
238
 
@@ -71,22 +71,59 @@ export function classifyGeneratedStatements(statements: string[]): ClassifiedSta
71
71
  *
72
72
  * Recoverable metadata is never destructive: policies, grants, constraints,
73
73
  * defaults, nullability all re-declare from the models without touching a row.
74
+ *
75
+ * strips — the ONE exception to that rule. A REVOKE against a grantee NO model
76
+ * declares is not recoverable from the models: there is no declaration to
77
+ * re-derive it from, and the role simply loses its access. This is the
78
+ * deliberate case left over once ungoverned grantees stopped being revoked
79
+ * by accident (authz-reconcile's governedRoleSet) — someone put a role in
80
+ * `governedRoles` and then granted it nothing, which reads as "strip it".
81
+ * Deliberate removal deserves the same ceremony as a dropped column.
82
+ * Framework roles (anon/authenticated/admin/PUBLIC) are excluded: revoking
83
+ * from those is ordinary authz churn and re-declaring an ability restores it.
74
84
  */
75
85
  export interface DestructiveBreakdown {
76
86
  drops: string[];
77
87
  narrowings: string[];
88
+ /** REVOKEs that leave an undeclared grantee with nothing to restore it from. */
89
+ strips: string[];
90
+ }
91
+
92
+ /** `REVOKE … ON <table> FROM <grantee>;` → the grantee, or null when it is not a revoke. */
93
+ export function revokeTarget(statement: string): string | null {
94
+ // A quoted identifier can hold anything (`"Odd-Role"`, a reserved word, mixed case), and
95
+ // reading one as null would silently UNDER-classify a strip — the failure direction that
96
+ // matters, since it skips the ceremony rather than adding one.
97
+ const m = /^REVOKE\s+[\s\S]+?\sFROM\s+("[^"]*"|[A-Za-z_][A-Za-z0-9_$]*)\s*;?\s*$/i.exec(statement.trim());
98
+ return m ? m[1].replace(/^"|"$/g, '') : null;
78
99
  }
79
100
 
80
- export function classifyDestructive(executable: string[]): DestructiveBreakdown {
101
+ export function classifyDestructive(
102
+ executable: string[],
103
+ opts: { declaredGrantees?: ReadonlySet<string> } = {},
104
+ ): DestructiveBreakdown {
81
105
  const drops: string[] = [];
82
106
  const narrowings: string[] = [];
107
+ const strips: string[] = [];
83
108
  for (const statement of executable) {
84
109
  if (/\bDROP\s+(TABLE|COLUMN|TYPE)\b/.test(statement)) drops.push(statement);
85
110
  else if (/\bSET DATA TYPE\b/.test(statement) && /\bUSING\b/.test(statement)) narrowings.push(statement);
111
+ else if (opts.declaredGrantees) {
112
+ // Only classified when the caller supplied the declared side — without it there is
113
+ // no way to tell a strip from ordinary churn, and guessing would newly force
114
+ // --confirm on plans that never needed it.
115
+ const grantee = revokeTarget(statement);
116
+ if (grantee && !opts.declaredGrantees.has(grantee) && !FRAMEWORK_ROLES.has(grantee.toUpperCase())) {
117
+ strips.push(statement);
118
+ }
119
+ }
86
120
  }
87
- return { drops, narrowings };
121
+ return { drops, narrowings, strips };
88
122
  }
89
123
 
124
+ /** Roles the framework itself owns — revoking from these re-declares from an ability. */
125
+ const FRAMEWORK_ROLES = new Set(['PUBLIC', 'ANON', 'AUTHENTICATED', 'ADMIN']);
126
+
90
127
  /**
91
128
  * The kind histogram — what a plan IS, before anyone reads its SQL. A real edge can
92
129
  * run to four digits of statements (a consumer's adoption edge was 1,671), and the