@everystack/cli 0.4.44 → 0.4.46

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 +314 -0
  5. package/src/cli/authz-baseline.ts +25 -3
  6. package/src/cli/authz-canonical.ts +178 -0
  7. package/src/cli/authz-compile.ts +130 -40
  8. package/src/cli/authz-contract.ts +212 -44
  9. package/src/cli/authz-derive.ts +244 -34
  10. package/src/cli/authz-identity.ts +222 -0
  11. package/src/cli/authz-ownership.ts +193 -0
  12. package/src/cli/authz-reconcile.ts +61 -27
  13. package/src/cli/aws.ts +32 -0
  14. package/src/cli/commands/db-apply.ts +60 -14
  15. package/src/cli/commands/db-authz.ts +9 -14
  16. package/src/cli/commands/db-fingerprint.ts +54 -18
  17. package/src/cli/commands/db-generate.ts +59 -15
  18. package/src/cli/commands/db-plan.ts +89 -9
  19. package/src/cli/commands/db-pull.ts +36 -19
  20. package/src/cli/commands/db-reconcile.ts +18 -20
  21. package/src/cli/commands/db-swap.ts +5 -4
  22. package/src/cli/commands/db-sync.ts +8 -5
  23. package/src/cli/db-build.ts +2 -2
  24. package/src/cli/db-source.ts +56 -0
  25. package/src/cli/derived-introspect.ts +27 -26
  26. package/src/cli/derived-lint.ts +7 -8
  27. package/src/cli/edge-plan.ts +125 -17
  28. package/src/cli/git-descent.ts +16 -9
  29. package/src/cli/index.ts +2 -18
  30. package/src/cli/model-render.ts +75 -52
  31. package/src/cli/output.ts +25 -3
  32. package/src/cli/parse-flags.ts +39 -0
  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 +154 -42
  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 +128 -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.44",
3
+ "version": "0.4.46",
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.9"
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
  }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * authz-adoption-class — WHY does this statement exist?
3
+ *
4
+ * A brownfield adopter's first plan is long, and the length is not the problem. The problem is
5
+ * that nobody can tell which statements are the tool imposing its own spelling and which are
6
+ * real differences between the model and the database. This classifier answers that, per
7
+ * statement, mechanically.
8
+ *
9
+ * FOUR CLASSES, and the whole point is that the first one should reach zero:
10
+ *
11
+ * - `convention` — exists ONLY because we spell a semantically identical rule differently.
12
+ * Every one of these is our bug. Phase A's success criterion is zero.
13
+ * - `capability` — the model cannot express what is live, so the plan would remove it.
14
+ * Real, and a gap in everystack, not in their schema.
15
+ * - `narrowing` — a genuine reduction of access. Must ship, labeled, and be approved by a
16
+ * human before it is applied.
17
+ * - `dead` — the live object is dead code: policed but never granted, so it authorizes
18
+ * nothing today.
19
+ *
20
+ * NAME COLLISION, stated once so nobody conflates them. `edge-plan.ts` already has
21
+ * `PlanClassification.narrowings`, which means a DATA-LOSSY `ALTER COLUMN … TYPE`. This
22
+ * `narrowing` means an ACCESS reduction. They are two orthogonal axes on the same statement —
23
+ * one asks "what does it cost?", the other asks "why is it here?" — and both are correct in
24
+ * their own frame. This module deliberately does NOT extend `PlanClassification`.
25
+ *
26
+ * READ-ONLY. Nothing here changes what is emitted. It reasons over the same declared and live
27
+ * contracts the emitter does, and a test pins its statement list to the emitter's actual
28
+ * output so the two can never drift apart.
29
+ */
30
+
31
+ import { matchPolicies, roleSetEqual } from './authz-identity.js';
32
+ import { governedRoleSet } from './authz-reconcile.js';
33
+ import { effectivePolicyCheck, holdsPrivilege, isPolicyDead, policyCommands } from './authz-contract.js';
34
+ import type { AuthzContract, PolicyContract, TableContract } from './authz-contract.js';
35
+
36
+ export type AdoptionClass = 'convention' | 'capability' | 'narrowing' | 'dead' | 'unclassified';
37
+
38
+ export interface ClassifiedStatement {
39
+ table: string;
40
+ /** The statement's subject — a policy name, or `grantee` for a privilege change. */
41
+ subject: string;
42
+ cls: AdoptionClass;
43
+ /** One line, in plain words, for the plan's report. */
44
+ why: string;
45
+ }
46
+
47
+ export interface AdoptionCounts {
48
+ convention: number;
49
+ capability: number;
50
+ narrowing: number;
51
+ dead: number;
52
+ unclassified: number;
53
+ }
54
+
55
+ /**
56
+ * The reconciler's own exemption rule, mirrored: a grantee the declared authz does not govern
57
+ * is left alone. `PUBLIC` compares case-insensitively because it is a pseudo-role with two
58
+ * catalog spellings; every other name compares exactly.
59
+ */
60
+ function isGovernedGrantee(governed: ReadonlySet<string>, grantee: string): boolean {
61
+ return governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
62
+ }
63
+
64
+ const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
65
+
66
+ /**
67
+ * Does `outer` cover every role in `inner`?
68
+ *
69
+ * PUBLIC (`['public']`) covers everything — it is an open set. That fact is used HERE ONLY, to
70
+ * decide whether a live policy's replacement is narrower (a label). It is deliberately NOT
71
+ * available to the matcher: letting PUBLIC compare equal to an enumerated set there would be
72
+ * the false match the whole phase exists to prevent. Labeling cannot widen access; matching
73
+ * can.
74
+ */
75
+ function covers(outer: readonly string[], inner: readonly string[]): boolean {
76
+ if (outer.includes('public')) return true;
77
+ const o = new Set(outer);
78
+ return inner.every((r) => o.has(r));
79
+ }
80
+
81
+ /** Same authorization on every axis EXCEPT which roles it names — the role-split test. */
82
+ function sameRule(a: PolicyContract, b: PolicyContract): boolean {
83
+ return a.command === b.command
84
+ && a.permissive === b.permissive
85
+ && (a.using ?? '') === (b.using ?? '')
86
+ && (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
87
+ }
88
+
89
+ /**
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.
95
+ */
96
+ function pred(p: PolicyContract): string {
97
+ const using = (p.using ?? 'true').replace(/\s+/g, ' ').trim();
98
+ return using === 'true' ? 'true (unfiltered)' : using;
99
+ }
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
+
106
+ /** Classify the policy statements one table would emit. */
107
+ function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedStatement[], governed: ReadonlySet<string>): void {
108
+ const table = d.table;
109
+ const m = matchPolicies(d.policies, l.policies);
110
+ const liveByName = new Map(l.policies.map((p) => [p.name, p]));
111
+
112
+ // Adopted shapes — a rule-identical policy under another name, or a live multi-role policy
113
+ // covering the declared per-role group — emit NOTHING, so there is nothing here to classify.
114
+ // This is what driving `convention` toward zero looks like: the statements stop existing
115
+ // rather than getting a nicer label.
116
+
117
+ const declaredByName = new Map(m.toCreate.map((p) => [p.name, p]));
118
+ const consumed = new Set<string>();
119
+
120
+ for (const name of m.toDrop) {
121
+ const live = l.policies.find((p) => p.name === name)!;
122
+
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;
133
+
134
+ // A replacement exists when some declared policy governs the same command. If those
135
+ // replacements together cover FEWER roles than the live policy did, the plan is reducing
136
+ // access — a narrowing, which must be approved rather than merely applied.
137
+ const replacements = m.toCreate.filter(
138
+ (c) => commandsOf(c).some((cmd) => commandsOf(live).includes(cmd)),
139
+ );
140
+ if (!replacements.length) {
141
+ out.push({ table, subject: name, cls: 'capability', why: `no declared policy governs ${commandsOf(live).join('/')} — the model cannot express this rule` });
142
+ continue;
143
+ }
144
+
145
+ const replacementRoles = [...new Set(replacements.flatMap((r) => r.roles))];
146
+
147
+ // The role split: one live policy covering several roles, against the per-role policies the
148
+ // compiler emits. Same predicate, and the declared roles union to EXACTLY the live role set,
149
+ // so the two authorize identically for every session. Pure spelling — ours to fix (A5).
150
+ // Note this is recognized here for LABELING before the matcher can act on it; the two must
151
+ // agree once A5 lands, which is what the emitter-parity test pins.
152
+ if (roleSetEqual(live.roles, replacementRoles) && replacements.every((r) => sameRule(r, live))) {
153
+ out.push({ table, subject: name, cls: 'convention', why: `live covers ${live.roles.join(' + ')} in one policy; we split it per role with the same predicate` });
154
+ for (const r of replacements) {
155
+ if (consumed.has(r.name)) continue;
156
+ consumed.add(r.name);
157
+ out.push({ table, subject: r.name, cls: 'convention', why: `the per-role half of "${name}"` });
158
+ }
159
+ continue;
160
+ }
161
+
162
+ if (covers(live.roles, replacementRoles) && !roleSetEqual(live.roles, replacementRoles)) {
163
+ out.push({ table, subject: name, cls: 'narrowing', why: `live applies to ${live.roles.join(', ')}; the declared replacement covers only ${replacementRoles.join(', ')}` });
164
+ for (const r of replacements) {
165
+ if (consumed.has(r.name)) continue;
166
+ consumed.add(r.name);
167
+ out.push({ table, subject: r.name, cls: 'narrowing', why: `the narrower replacement for "${name}"` });
168
+ }
169
+ continue;
170
+ }
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
+
203
+ out.push({ table, subject: name, cls: 'unclassified', why: `dropped with a replacement that is neither the same rule nor narrower` });
204
+ }
205
+
206
+ for (const [name] of declaredByName) {
207
+ if (consumed.has(name)) continue;
208
+ out.push({ table, subject: name, cls: 'unclassified', why: `created with no live counterpart` });
209
+ }
210
+ }
211
+
212
+ /** Classify the privilege statements one table would emit. */
213
+ function classifyGrants(
214
+ d: TableContract,
215
+ l: TableContract,
216
+ governed: ReadonlySet<string>,
217
+ out: ClassifiedStatement[],
218
+ ): void {
219
+ const table = d.table;
220
+
221
+ for (const grantee of Object.keys(l.grants).sort()) {
222
+ // The reconciler LEAVES ungoverned grantees alone — a migrator or BI reader the model
223
+ // vocabulary cannot name keeps its access, reported instead of revoked. It emits nothing
224
+ // for them, so neither may we: counting statements that are never emitted would describe
225
+ // a plan nobody is going to apply.
226
+ if (!isGovernedGrantee(governed, grantee)) continue;
227
+ const declared = new Set(d.grants[grantee] ?? []);
228
+ const live = l.grants[grantee] ?? [];
229
+ const revoked = live.filter((p) => !declared.has(p));
230
+ if (!revoked.length) continue;
231
+
232
+ // Every revoke here is now a NARROWING, including the beyond-CRUD trio.
233
+ //
234
+ // It used to be `convention` for REFERENCES/TRIGGER/TRUNCATE: `manage` means exactly CRUD,
235
+ // so live admin holding those three read as drift and the plan revoked them — 28 statements
236
+ // on the reference schema, none of which anybody asked for. The model has a word for them
237
+ // now (`privileges`), and `db:pull` transcribes what is live, so a pulled model emits
238
+ // nothing. A model that does NOT declare them is choosing to remove them, which is a real
239
+ // access reduction and gets labeled as one. The fix is at the source, not in the label.
240
+ out.push({ table, subject: grantee, cls: 'narrowing', why: `revokes ${revoked.join(', ')} from ${grantee}` });
241
+ }
242
+
243
+ // Column grants: the model expresses column scoping for READ only, so a live column-scoped
244
+ // UPDATE has no declaration and the plan revokes it. That is a gap in everystack.
245
+ const lcg = l.columnGrants ?? {};
246
+ const dcg = d.columnGrants ?? {};
247
+ for (const grantee of Object.keys(lcg).sort()) {
248
+ if (!isGovernedGrantee(governed, grantee)) continue;
249
+ for (const priv of Object.keys(lcg[grantee]).sort()) {
250
+ const declaredCols = new Set(dcg[grantee]?.[priv] ?? []);
251
+ const revoked = (lcg[grantee][priv] ?? []).filter((c) => !declaredCols.has(c));
252
+ if (!revoked.length) continue;
253
+ out.push({ table, subject: `${grantee}/${priv}`, cls: 'capability', why: `column-scoped ${priv} on ${revoked.length} column(s) — the model declares columns for read only` });
254
+ }
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Classify every authorization statement the plan would emit, per table.
260
+ *
261
+ * Live tables no model declares are skipped: the plan does not touch them, so they contribute
262
+ * no statements to classify.
263
+ */
264
+ export function classifyAdoption(
265
+ declared: AuthzContract,
266
+ live: AuthzContract,
267
+ opts: { governedRoles?: ReadonlySet<string> } = {},
268
+ ): {
269
+ statements: ClassifiedStatement[];
270
+ counts: AdoptionCounts;
271
+ } {
272
+ const governed = opts.governedRoles ?? governedRoleSet(declared);
273
+ const liveByTable = new Map(live.tables.map((t) => [t.table, t]));
274
+ const statements: ClassifiedStatement[] = [];
275
+
276
+ for (const d of declared.tables) {
277
+ const l = liveByTable.get(d.table);
278
+ if (!l) continue; // a brand-new table adds; nothing here is an adoption decision
279
+ classifyPolicies(d, l, statements, governed);
280
+ classifyGrants(d, l, governed, statements);
281
+ }
282
+
283
+ const counts: AdoptionCounts = { convention: 0, capability: 0, narrowing: 0, dead: 0, unclassified: 0 };
284
+ for (const s of statements) counts[s.cls]++;
285
+ return { statements, counts };
286
+ }
287
+
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[] {
301
+ const lines = [
302
+ ` convention ${counts.convention} — our spelling, not their schema (this must reach 0)`,
303
+ ` capability ${counts.capability} — the model cannot express what is live`,
304
+ ` narrowing ${counts.narrowing} — real access reduction, approve before applying`,
305
+ ` dead ${counts.dead} — policed but never granted`,
306
+ ];
307
+ if (counts.unclassified) {
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
+ }
312
+ }
313
+ return lines;
314
+ }
@@ -36,6 +36,7 @@
36
36
 
37
37
  import fs from 'node:fs/promises';
38
38
  import path from 'node:path';
39
+ import { FINGERPRINT_VERSION } from './schema-fingerprint.js';
39
40
  import type { GrantExemption } from './authz-reconcile.js';
40
41
 
41
42
  /** The repo-relative artifact. Generated — regenerating it must reproduce it byte for byte. */
@@ -58,6 +59,15 @@ export interface BaselineStage {
58
59
  observedAt: string;
59
60
  /** The live base fingerprint at observation time — what the claim was true OF. */
60
61
  fingerprint: string;
62
+ /**
63
+ * The FINGERPRINT_VERSION that hash was computed under.
64
+ *
65
+ * Stored beside the hash rather than only mixed into it, because a bare hash cannot say WHY
66
+ * it differs. Without this a baseline recorded under an older canonical form reads as drift,
67
+ * and an operator goes looking for a change nobody made instead of running one re-baseline.
68
+ * Absent on baselines written before v4.
69
+ */
70
+ fpVersion?: number;
61
71
  grantees: Record<string, BaselineGrantee>;
62
72
  }
63
73
 
@@ -71,7 +81,7 @@ export const EMPTY_BASELINE: AuthzBaseline = { version: 1, stages: {} };
71
81
  /** Fold the observed exemptions into one entry per grantee. */
72
82
  export function buildStageBaseline(
73
83
  exemptions: readonly GrantExemption[],
74
- meta: { observedAt: string; fingerprint: string },
84
+ meta: { observedAt: string; fingerprint: string; fpVersion?: number },
75
85
  ): BaselineStage {
76
86
  const grantees: Record<string, BaselineGrantee> = {};
77
87
  for (const e of exemptions) {
@@ -83,7 +93,12 @@ export function buildStageBaseline(
83
93
  const all = [...e.privileges, ...Object.keys(e.columnPrivileges ?? {})];
84
94
  g.write ||= all.some((p) => WRITE_PRIVILEGES.has(p.toUpperCase()));
85
95
  }
86
- return { observedAt: meta.observedAt, fingerprint: meta.fingerprint, grantees };
96
+ return {
97
+ observedAt: meta.observedAt,
98
+ fingerprint: meta.fingerprint,
99
+ fpVersion: meta.fpVersion ?? FINGERPRINT_VERSION,
100
+ grantees,
101
+ };
87
102
  }
88
103
 
89
104
  /** Write one stage's entry into the file, leaving every other stage untouched. */
@@ -105,7 +120,14 @@ export function renderBaseline(baseline: AuthzBaseline): string {
105
120
  const { privileges, tables, write } = s.grantees[g];
106
121
  grantees[g] = { privileges: [...privileges].sort(), tables: [...tables].sort(), write };
107
122
  }
108
- stages[stage] = { observedAt: s.observedAt, fingerprint: s.fingerprint, grantees };
123
+ // `fpVersion` rides through the render, or the stamp is lost on the first rewrite and the
124
+ // baseline silently becomes unstamped — which reads as a format change forever after.
125
+ stages[stage] = {
126
+ observedAt: s.observedAt,
127
+ fingerprint: s.fingerprint,
128
+ ...(s.fpVersion != null ? { fpVersion: s.fpVersion } : {}),
129
+ grantees,
130
+ };
109
131
  }
110
132
  return JSON.stringify({ version: 1, stages }, null, 2) + '\n';
111
133
  }