@everystack/cli 0.3.31 → 0.4.1

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 (43) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/src/.pdr-tmp-20277-1783706384842.ts +59 -0
  4. package/src/.roundtrip-tmp-20275-1783706385459.ts +121 -0
  5. package/src/cli/authz-compile.ts +5 -2
  6. package/src/cli/aws.ts +12 -3
  7. package/src/cli/backfill.ts +1 -1
  8. package/src/cli/commands/db-authz.ts +3 -17
  9. package/src/cli/commands/db-branch.ts +28 -4
  10. package/src/cli/commands/db-check.ts +84 -33
  11. package/src/cli/commands/db-fingerprint.ts +7 -2
  12. package/src/cli/commands/db-generate.ts +102 -34
  13. package/src/cli/commands/db-pull.ts +82 -6
  14. package/src/cli/commands/db-reconcile.ts +132 -41
  15. package/src/cli/commands/db-sync.ts +54 -19
  16. package/src/cli/commands/db.ts +7 -6
  17. package/src/cli/commands/update.ts +2 -1
  18. package/src/cli/db-build.ts +9 -3
  19. package/src/cli/db-source.ts +5 -1
  20. package/src/cli/declared-derived.ts +173 -0
  21. package/src/cli/derived-apply.ts +40 -6
  22. package/src/cli/derived-compile.ts +377 -0
  23. package/src/cli/derived-grants.ts +96 -0
  24. package/src/cli/derived-introspect.ts +258 -21
  25. package/src/cli/derived-lint.ts +261 -0
  26. package/src/cli/derived-plan.ts +176 -10
  27. package/src/cli/derived-render.ts +366 -0
  28. package/src/cli/derived-source.ts +53 -125
  29. package/src/cli/edge-plan.ts +4 -1
  30. package/src/cli/git-descent.ts +15 -2
  31. package/src/cli/index.ts +6 -6
  32. package/src/cli/migration-compile.ts +38 -7
  33. package/src/cli/migration-generate.ts +43 -4
  34. package/src/cli/model-render.ts +125 -16
  35. package/src/cli/models-path.ts +1 -1
  36. package/src/cli/ops-advice.ts +66 -0
  37. package/src/cli/pipeline-path.ts +1 -1
  38. package/src/cli/schema-compile.ts +41 -10
  39. package/src/cli/schema-diff.ts +123 -17
  40. package/src/cli/schema-fingerprint.ts +28 -18
  41. package/src/cli/schema-introspect.ts +175 -8
  42. package/src/cli/schema-source.ts +75 -6
  43. package/src/cli/state-apply.ts +52 -1
@@ -0,0 +1,261 @@
1
+ /**
2
+ * derived-lint — the B2 gates over the declared derived layer
3
+ * (docs/plans/read-model-everywhere.md, "Rejection rules and gates").
4
+ *
5
+ * The table gate (authz-lint's findReadAuthzGaps) has a derived mirror: a declared
6
+ * relation nobody can read exists superuser-only from the moment it is created. Two more
7
+ * gates close the function surface (SECURITY DEFINER with undeclared callers) and the
8
+ * matview trap (a snapshot taken with the refresher's eyes over row-scoped sources), and
9
+ * one closes the invoker-view gap: `securityInvoker: true` means the CALLER's rights are
10
+ * checked against every relation the body touches — a view can be granted and still
11
+ * unreadable, one property over.
12
+ *
13
+ * Reachability walks DECLARED deps only — the declared graph paying for itself
14
+ * (`dependsOn`: declared, then verified; reconcile checks the declaration against the
15
+ * live catalog). An empty `dependsOn` is an empty closure, not a pass on a technicality:
16
+ * the drift report names the missing declaration.
17
+ *
18
+ * Audience semantics mirror the table grant compiler (authz-compile), and the mirroring
19
+ * is PINNED by a differential test against compileTableContract (derived-lint.test.ts) —
20
+ * asserted-in-parallel implementations drift exactly where the examples don't look. The
21
+ * shapes: bare read → anon + authenticated; owner/via-scoped read → authenticated only;
22
+ * column-scoped self read (`owner` + `columns`) → its role (or authenticated); `{ role }`
23
+ * narrows to that role; manage → its role (or authenticated); model-private does NOT
24
+ * zero the audience (private models still compile grants — the auth.users pattern). Row
25
+ * scoping (RLS) filters rows, not reach — a grant that returns only your rows is still a
26
+ * grant.
27
+ */
28
+
29
+ import type {
30
+ ModelDescriptor, DerivedDescriptor, ViewDescriptor, MaterializedViewDescriptor,
31
+ FunctionDescriptor, DependsOnRef, Ability,
32
+ } from '@everystack/model';
33
+ import { parseQualified } from './derived-source.js';
34
+
35
+ export interface DerivedAuthzGap {
36
+ /** `schema.name` of the descriptor that fails the gate. */
37
+ identity: string;
38
+ message: string;
39
+ }
40
+
41
+ function identityOf(d: DerivedDescriptor): string {
42
+ const { schema, name } = parseQualified(d.name);
43
+ return `${schema}.${name}`;
44
+ }
45
+
46
+ function isModelRef(ref: DependsOnRef): ref is ModelDescriptor {
47
+ return 'table' in ref;
48
+ }
49
+
50
+ /** The column-scoped self read — same predicate as the table grant compiler. */
51
+ function isColumnRead(a: Ability): boolean {
52
+ return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
53
+ }
54
+
55
+ /**
56
+ * Does the table's grant compile give `role` SELECT (full or column-scoped)?
57
+ *
58
+ * Exported for the differential pin against compileTableContract — this math must agree
59
+ * with the ACTUAL grant compiler for every ability shape, or the gate refuses views the
60
+ * database would serve (or worse, passes ones it won't). Note `private` on a MODEL is
61
+ * deliberately not consulted: private means "not exposed via the generic API", and its
62
+ * authz still compiles into real grants (the auth.users pattern — private + admin
63
+ * manage + column-scoped self read). The red team caught the gate treating model-private
64
+ * as zero-reach, which bricked every invoker view over a private table.
65
+ */
66
+ export function tableReaches(m: ModelDescriptor, role: string): boolean {
67
+ for (const a of m.abilities) {
68
+ if (isColumnRead(a)) {
69
+ if ((a.condition.role ?? 'authenticated') === role) return true;
70
+ continue;
71
+ }
72
+ if (a.action !== 'read' && a.action !== 'manage') continue;
73
+ if (a.condition.role) {
74
+ if (a.condition.role === role) return true;
75
+ continue;
76
+ }
77
+ if (role === 'authenticated') return true;
78
+ if (role === 'anon' && a.action === 'read' && !a.condition.owner && !a.condition.via) return true;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ /** The roles a relation's grants reach — bare read is anon + authenticated (the table precedent). */
84
+ function relationRoles(d: ViewDescriptor | MaterializedViewDescriptor): Set<string> {
85
+ const roles = new Set<string>();
86
+ for (const a of d.abilities) {
87
+ if (a.condition.role) roles.add(a.condition.role);
88
+ else {
89
+ roles.add('anon');
90
+ roles.add('authenticated');
91
+ }
92
+ }
93
+ return roles;
94
+ }
95
+
96
+ function functionRoles(fn: FunctionDescriptor): Set<string> {
97
+ // define-time already rejected bare can('execute') — every ability carries a role.
98
+ return new Set(fn.abilities.map((a) => a.condition.role!));
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Gate: derived read-gap (db:check fail)
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Every declared relation makes its read decision or fails: an exposed view/matview with
107
+ * no read ability and no `private: true` is superuser-only from birth — the derived
108
+ * mirror of the table landmine findReadAuthzGaps catches.
109
+ */
110
+ export function findDerivedReadGaps(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
111
+ const gaps: DerivedAuthzGap[] = [];
112
+ for (const d of derived) {
113
+ if (d.kind !== 'view' && d.kind !== 'materialized view') continue;
114
+ if (d.private || d.abilities.length > 0) continue;
115
+ const identity = identityOf(d);
116
+ gaps.push({
117
+ identity,
118
+ message:
119
+ `${identity} is a declared ${d.kind} with no read ability and no private: true — no app role can ` +
120
+ `read it, so it exists superuser-only. Declare can('read') for public data, can('read', { columns }) ` +
121
+ `to scope fields, or private: true if it is not part of the data API.`,
122
+ });
123
+ }
124
+ return gaps;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Gate: SECURITY DEFINER with no declared caller (db:check fail)
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /**
132
+ * A definer function runs with its owner's rights; compiling one with no `can('execute')`
133
+ * is privileged code with an undeclared audience — callable by nobody today (PUBLIC is
134
+ * revoked unconditionally) and by whoever gets a grant tomorrow, with no declaration to
135
+ * review. Invoker functions may stay dark: unprivileged, owner-callable, honest.
136
+ */
137
+ export function findSecdefExecuteGaps(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
138
+ const gaps: DerivedAuthzGap[] = [];
139
+ for (const d of derived) {
140
+ if (d.kind !== 'function' || d.security !== 'definer' || d.abilities.length > 0) continue;
141
+ const identity = identityOf(d);
142
+ gaps.push({
143
+ identity,
144
+ message:
145
+ `${identity} is SECURITY DEFINER with no declared caller — privileged code whose audience nobody ` +
146
+ `reviewed. Declare who calls it with can('execute', { role: '…' }), or make it security: 'invoker'.`,
147
+ });
148
+ }
149
+ return gaps;
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Gate: matview snapshot over row-scoped sources (db:check warn)
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** Walk the declared closure through views (a matview's own snapshot is its own decision). */
157
+ function closureModels(refs: readonly DependsOnRef[], seen = new Set<DependsOnRef>()): ModelDescriptor[] {
158
+ const models: ModelDescriptor[] = [];
159
+ for (const ref of refs) {
160
+ if (seen.has(ref)) continue;
161
+ seen.add(ref);
162
+ if (isModelRef(ref)) models.push(ref);
163
+ else if (ref.kind === 'view') models.push(...closureModels(ref.dependsOn, seen));
164
+ }
165
+ return models;
166
+ }
167
+
168
+ function rowScoped(m: ModelDescriptor): boolean {
169
+ return m.abilities.some((a) => a.condition.owner || a.condition.via);
170
+ }
171
+
172
+ /**
173
+ * A matview's rows are whatever its refresher could see, served flat to every granted
174
+ * role: over a row-scoped source, a superuser refresher snapshots EVERYTHING (broad), and
175
+ * a FORCE-RLS-subject owner silently snapshots a SUBSET (filtered) — both wrong in a
176
+ * different direction, neither visible in the schema. Warn, don't fail: an aggregating
177
+ * body (counts, sums) is the legitimate case, and only the author knows.
178
+ */
179
+ export function findMatviewSnapshotWarnings(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
180
+ const warnings: DerivedAuthzGap[] = [];
181
+ for (const d of derived) {
182
+ if (d.kind !== 'materialized view' || d.private || d.abilities.length === 0) continue;
183
+ const scoped = closureModels(d.dependsOn).filter(rowScoped);
184
+ if (scoped.length === 0) continue;
185
+ const identity = identityOf(d);
186
+ const sources = scoped.map((m) => `${m.schema}.${m.table}`).join(', ');
187
+ warnings.push({
188
+ identity,
189
+ message:
190
+ `${identity} snapshots row-scoped source(s) ${sources} with the refresher's eyes — a superuser ` +
191
+ `refresh serves EVERY row flat to every granted role; a FORCE-RLS-subject owner silently snapshots ` +
192
+ `a subset. Confirm the body aggregates/filters away the per-row data, or declare private: true.`,
193
+ });
194
+ }
195
+ return warnings;
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Gate: invoker-view grant reachability (compile fail)
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /** The first ref in the closure `role` cannot reach, or null. */
203
+ function findUnreachable(role: string, refs: readonly DependsOnRef[], seen: Set<DependsOnRef>): string | null {
204
+ for (const ref of refs) {
205
+ if (seen.has(ref)) continue;
206
+ seen.add(ref);
207
+ if (isModelRef(ref)) {
208
+ if (!tableReaches(ref, role)) return `${ref.schema}.${ref.table}`;
209
+ continue;
210
+ }
211
+ switch (ref.kind) {
212
+ case 'view': {
213
+ if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
214
+ // An invoker dep re-checks the caller one level down; a definer dep reads as its owner.
215
+ if (ref.securityInvoker) {
216
+ const deeper = findUnreachable(role, ref.dependsOn, seen);
217
+ if (deeper) return deeper;
218
+ }
219
+ break;
220
+ }
221
+ case 'materialized view':
222
+ // A matview is its own snapshot: SELECT on the matview is the whole requirement.
223
+ if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
224
+ break;
225
+ case 'function':
226
+ // The body calls it with the caller's rights — EXECUTE is part of reach.
227
+ if (!functionRoles(ref).has(role)) return identityOf(ref);
228
+ break;
229
+ case 'sql':
230
+ break; // opaque — never a false positive
231
+ }
232
+ }
233
+ return null;
234
+ }
235
+
236
+ /**
237
+ * `securityInvoker: true` + a grant to role R is a PROMISE that R can read the body's
238
+ * relations — PostgreSQL checks R, not the owner, against every one of them. This gate
239
+ * makes the promise static: every granted role must have reach through the declared
240
+ * `dependsOn` closure, or the view is granted-but-unreadable and the compile refuses.
241
+ */
242
+ export function findInvokerReachabilityGaps(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
243
+ const gaps: DerivedAuthzGap[] = [];
244
+ for (const d of derived) {
245
+ if (d.kind !== 'view' || !d.securityInvoker || d.private || d.abilities.length === 0) continue;
246
+ const identity = identityOf(d);
247
+ for (const role of [...relationRoles(d)].sort()) {
248
+ const unreachable = findUnreachable(role, d.dependsOn, new Set());
249
+ if (unreachable) {
250
+ gaps.push({
251
+ identity,
252
+ message:
253
+ `view '${identity}' grants ${role} but its dependency '${unreachable}' gives ${role} no reach — ` +
254
+ `granted-but-unreadable (an invoker view reads with the caller's rights). Grant '${unreachable}' ` +
255
+ `to ${role}, seal the view with securityInvoker: false, or narrow the view's roles.`,
256
+ });
257
+ }
258
+ }
259
+ }
260
+ return gaps;
261
+ }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * derived-plan — the reconciler's pure decision core.
3
3
  *
4
- * Joins the three inputs on identity — source objects (db/sql), the live
4
+ * Joins the three inputs on identity — source objects (descriptor-compiled), the live
5
5
  * catalog, and the provenance claims — and decides, per object: skip, create,
6
- * replace, refresh, drop, baseline, or prune. Hand-edited live objects are
6
+ * replace, refresh, drop, baseline, rebaseline, or prune. Hand-edited live objects are
7
7
  * DRIFT: reported, never silently overwritten (`overwriteDrift` makes the
8
8
  * overwrite explicit, and the drift stays in the report so the operator sees
9
9
  * what they overwrote). Never-reconciled matches are `needsBaseline`: the
@@ -24,10 +24,12 @@
24
24
  * drift report, not a silent skip.
25
25
  */
26
26
 
27
- import type { ParsedSources, SourceObject, DerivedKind } from './derived-source.js';
27
+ import { type ParsedSources, type SourceObject, type DerivedKind } from './derived-source.js';
28
28
  import type { DerivedCatalog, LiveObject } from './derived-introspect.js';
29
+ import { triggerDropSql } from './derived-apply.js';
30
+ import { parseGrantAttachments, diffObjectGrants } from './derived-grants.js';
29
31
 
30
- export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'prune';
32
+ export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'rebaseline' | 'prune';
31
33
 
32
34
  export interface ReconcileAction {
33
35
  action: ReconcileVerb;
@@ -37,6 +39,9 @@ export interface ReconcileAction {
37
39
  /** Rebuild-cost signal from the live catalog (matviews). */
38
40
  bytes?: number;
39
41
  rows?: number;
42
+ /** Drops for the structural kinds — a trigger's `DROP TRIGGER … ON table`, a 'sql'
43
+ * object's recorded/declared drop. Legacy kinds keep the keyword rendering. */
44
+ dropSql?: string;
40
45
  }
41
46
 
42
47
  export interface DriftFinding {
@@ -44,6 +49,21 @@ export interface DriftFinding {
44
49
  detail: string;
45
50
  }
46
51
 
52
+ /** Grant drift on an up-to-date object — the idempotent REVOKE/GRANT delta that converges it. */
53
+ export interface RegrantFinding {
54
+ identity: string;
55
+ statements: string[];
56
+ }
57
+
58
+ /** Declared `dependsOn` vs the live catalog's actual edges (B4: declared, then verified). */
59
+ export interface DependencyDriftFinding {
60
+ identity: string;
61
+ /** Live edges the source never declared — the reachability gate walked past these. */
62
+ missing: string[];
63
+ /** Declared refs with no live edge — stale declarations. */
64
+ stale: string[];
65
+ }
66
+
47
67
  export interface BlockedFinding {
48
68
  identity: string;
49
69
  reason: string;
@@ -66,6 +86,15 @@ export interface ReconcilePlan {
66
86
  warnings: string[];
67
87
  /** Sum of live bytes over rebuilt + refreshed matviews — the headline cost estimate. */
68
88
  totalRebuildBytes: number;
89
+ /** Provenance identity migrations — a table rename carried its triggers (same object,
90
+ * new identity); the bookkeeping follows instead of drop+create-ing a live trigger. */
91
+ migrations: Array<{ from: string; to: string }>;
92
+ /** Grant drift on up-to-date objects: a hand-run GRANT/REVOKE touches no content hash,
93
+ * so this is the only place it surfaces. Applied without ceremony — declared authz is
94
+ * authoritative, and REVOKE/GRANT loses nothing but the drift itself. */
95
+ regrants: RegrantFinding[];
96
+ /** Declared-vs-actual dependency drift — check-fails (fix the declaration), never blocks apply. */
97
+ dependencyDrift: DependencyDriftFinding[];
69
98
  }
70
99
 
71
100
  export interface ReconcileOptions {
@@ -75,9 +104,28 @@ export interface ReconcileOptions {
75
104
  overwriteDrift?: boolean;
76
105
  /**
77
106
  * Rebuild never-reconciled source↔live matches from source (drop+create) instead of trusting
78
- * them via baseline — the guarantee that live == db/sql when first contact can't verify it.
107
+ * them via baseline — the guarantee that live == source when first contact can't verify it.
79
108
  */
80
109
  rebuild?: boolean;
110
+ /**
111
+ * Re-record provenance for objects whose SOURCE hash changed while the LIVE object is
112
+ * verifiably untouched (live defHash == provenance defHash) — bookkeeping instead of a
113
+ * rebuild. The migration verb for a source re-render that produces the same object:
114
+ * the db/sql-era → descriptor move re-renders every object in the compiler's dialect
115
+ * (grant attachments on functions, pinned dollar-tags, plain CREATE), so the source
116
+ * hash changes even when the SQL is semantically identical. The live side is VERIFIED
117
+ * (defHash proves the object is exactly what the reconciler last applied); the source
118
+ * side is TRUSTED (a descriptor that actually differs will never deploy — its change
119
+ * is recorded as applied). Catalog-invisible 'sql'-kind objects are excluded: with no
120
+ * defHash to verify, they keep their recorded-drop rebuild.
121
+ */
122
+ rebaseline?: boolean;
123
+ /**
124
+ * Pending table renames (new qualified table → old qualified table, from the models'
125
+ * `renamedFrom` markers). PostgreSQL carries triggers through ALTER TABLE RENAME, so
126
+ * their provenance identity migrates with them.
127
+ */
128
+ renamedTables?: Record<string, string>;
81
129
  }
82
130
 
83
131
  const isRelation = (kind: DerivedKind): boolean => kind !== 'function';
@@ -91,6 +139,23 @@ export function planReconcile(
91
139
  const liveById = new Map(live.objects.map((o) => [o.identity, o]));
92
140
  const provById = new Map(live.provenance.map((p) => [p.identity, p]));
93
141
 
142
+ // Rename pre-pass: a pending table rename (renamedFrom) carried its triggers along in
143
+ // PostgreSQL, so a provenance row recorded under the OLD table is the SAME object as
144
+ // the source trigger under the NEW one — the bookkeeping migrates; nothing drops.
145
+ const migrations: Array<{ from: string; to: string }> = [];
146
+ for (const [newTable, oldTable] of Object.entries(options.renamedTables ?? {})) {
147
+ for (const prov of [...provById.values()]) {
148
+ if (prov.kind !== 'trigger' || !prov.identity.startsWith(`${oldTable}.`)) continue;
149
+ const to = `${newTable}.${prov.identity.slice(oldTable.length + 1)}`;
150
+ if (!srcById.has(to) || provById.has(to)) continue;
151
+ provById.delete(prov.identity);
152
+ provById.set(to, { ...prov, identity: to });
153
+ migrations.push({ from: prov.identity, to });
154
+ }
155
+ }
156
+ // Every provenance consumer below reads the POST-MIGRATION view.
157
+ const provRows = [...provById.values()];
158
+
94
159
  const skipped: string[] = [];
95
160
  const drift: DriftFinding[] = [];
96
161
  const needsBaseline: string[] = [];
@@ -106,16 +171,37 @@ export function planReconcile(
106
171
  /** Managed objects the source removed. */
107
172
  const drop = new Map<string, string>();
108
173
  const baseline: string[] = [];
174
+ const rebaseline: string[] = [];
109
175
  const prune: string[] = [];
110
176
 
111
177
  // -------------------------------------------------------------------------
112
178
  // The decision table.
113
179
  // -------------------------------------------------------------------------
114
180
 
181
+ /** identity → { statement, reason } for the provenance-tracked ('sql') kinds — these
182
+ * never pass the live-existence filter, so they assemble their own drop actions. */
183
+ const sqlDrops = new Map<string, { dropSql: string; reason: string }>();
184
+ const extraWarnings: string[] = [];
185
+
115
186
  for (const src of source.objects) {
116
187
  const liveObj = liveById.get(src.identity);
117
188
  const prov = provById.get(src.identity);
118
189
 
190
+ // 'sql' objects (aggregates, domains, …) never join the live catalog — PROVENANCE is
191
+ // the live proxy. The documented contract of the escape hatch: provenance-tracked,
192
+ // no live drift detection; a change rebuilds through the recorded drop.
193
+ if (src.kind === 'sql') {
194
+ if (!prov) {
195
+ create.set(src.identity, 'new in source (provenance-tracked kind — invisible to the catalog)');
196
+ } else if (src.hash !== prov.srcHash) {
197
+ sqlDrops.set(src.identity, { dropSql: prov.dropSql ?? src.drop ?? '', reason: 'source changed' });
198
+ create.set(src.identity, 'source changed (rebuilt through the recorded drop)');
199
+ } else {
200
+ skipped.push(src.identity);
201
+ }
202
+ continue;
203
+ }
204
+
119
205
  if (!liveObj) {
120
206
  create.set(src.identity, prov
121
207
  ? 'missing from the database (recorded as applied — dropped by hand?)'
@@ -155,7 +241,11 @@ export function planReconcile(
155
241
  continue;
156
242
  }
157
243
  if (srcChanged) {
158
- if (isRelation(src.kind)) rebuild.set(src.identity, 'source changed');
244
+ // The live object is verifiably untouched here (liveChanged was handled above), so
245
+ // under rebaseline a source re-render is bookkeeping: re-record the source hash,
246
+ // rebuild nothing, cascade nothing.
247
+ if (options.rebaseline) rebaseline.push(src.identity);
248
+ else if (isRelation(src.kind)) rebuild.set(src.identity, 'source changed');
159
249
  else fnReplace.set(src.identity, 'source changed');
160
250
  continue;
161
251
  }
@@ -168,8 +258,68 @@ export function planReconcile(
168
258
  else unmanaged.push(liveObj.identity);
169
259
  }
170
260
 
171
- for (const prov of live.provenance) {
172
- if (!srcById.has(prov.identity) && !liveById.has(prov.identity)) prune.push(prov.identity);
261
+ for (const prov of provRows) {
262
+ if (srcById.has(prov.identity) || liveById.has(prov.identity)) continue;
263
+ // A removed 'sql' object is invisible to the catalog — pruning would silently orphan
264
+ // it in the database. The recorded drop_sql removes it (the C1 closure); only a
265
+ // legacy row with no recorded drop falls back to prune, and says so.
266
+ if (prov.kind === 'sql') {
267
+ if (prov.dropSql) {
268
+ sqlDrops.set(prov.identity, { dropSql: prov.dropSql, reason: 'removed from source (dropped via the recorded drop_sql)' });
269
+ } else {
270
+ prune.push(prov.identity);
271
+ extraWarnings.push(`${prov.identity}: a 'sql'-kind object recorded without drop_sql (legacy row) — bookkeeping pruned; drop the object by hand if it still exists.`);
272
+ }
273
+ continue;
274
+ }
275
+ prune.push(prov.identity);
276
+ }
277
+
278
+ // -------------------------------------------------------------------------
279
+ // B4 — live drift the content hashes can't see, over the UP-TO-DATE objects
280
+ // only: created/replaced objects re-run their attachments, drifted objects
281
+ // are owned by the drift refusal.
282
+ // -------------------------------------------------------------------------
283
+
284
+ const regrants: RegrantFinding[] = [];
285
+ const dependencyDrift: DependencyDriftFinding[] = [];
286
+ // Rebaselined objects join the lens: their live catalog rows are as real as a skip's,
287
+ // and running the checks NOW means a migration converges (grants ride the same apply)
288
+ // instead of surfacing on the next plan.
289
+ const lensSet = new Set([...skipped, ...rebaseline]);
290
+ for (const src of source.objects) {
291
+ if (!lensSet.has(src.identity)) continue;
292
+ if (src.kind === 'trigger' || src.kind === 'sql') continue; // no grants, no rewrite edges
293
+ const liveObj = liveById.get(src.identity);
294
+
295
+ // Grant drift — only when the catalog actually reported ACLs (absent ≠ empty).
296
+ // Every object is descriptor-compiled (db/sql retired, B7), and the compiler
297
+ // ALWAYS declares authz — a private relation is declared-empty, not undeclared —
298
+ // so every up-to-date object is grant-checked.
299
+ if (liveObj?.grants !== undefined) {
300
+ const parsed = parseGrantAttachments(src.attachments);
301
+ if (parsed.hasColumnGrants) {
302
+ extraWarnings.push(
303
+ `${src.identity}: column-scoped grants are applied at create but not drift-checked (attacl introspection is future work) — hand edits to them are invisible here.`,
304
+ );
305
+ } else {
306
+ const target = parsed.target ?? (src.kind === 'function' ? `FUNCTION ${src.identity}` : src.identity);
307
+ const statements = diffObjectGrants(target, parsed.grants, liveObj.grants);
308
+ if (statements.length > 0) regrants.push({ identity: src.identity, statements });
309
+ }
310
+ }
311
+
312
+ // Dependency drift — declared, then verified. Only descriptor-home relations carry a
313
+ // declaration, and only relations have rewrite-rule edges for the catalog to answer with.
314
+ if (src.declaredDeps !== undefined && (src.kind === 'view' || src.kind === 'materialized view')) {
315
+ const liveDeps = new Set(live.edges.filter((e) => e.dependent === src.identity).map((e) => e.referenced));
316
+ const declaredSet = new Set(src.declaredDeps);
317
+ const missing = [...liveDeps].filter((d) => !declaredSet.has(d)).sort();
318
+ const stale = [...declaredSet].filter((d) => !liveDeps.has(d)).sort();
319
+ if (missing.length > 0 || stale.length > 0) {
320
+ dependencyDrift.push({ identity: src.identity, missing, stale });
321
+ }
322
+ }
173
323
  }
174
324
 
175
325
  // -------------------------------------------------------------------------
@@ -260,11 +410,23 @@ export function planReconcile(
260
410
  const cost = liveObj?.kind === 'materialized view'
261
411
  ? { ...(liveObj.bytes !== undefined ? { bytes: liveObj.bytes } : {}), ...(liveObj.rows !== undefined ? { rows: liveObj.rows } : {}) }
262
412
  : {};
263
- return { action, identity: id, kind, reason, ...cost };
413
+ // A trigger's drop needs its table — composed from structure (source or live), never
414
+ // from the identity string (the red-team's C4: quoting a compound identity mangles it).
415
+ const structural = action === 'drop' && kind === 'trigger'
416
+ ? (() => {
417
+ const o = src ?? liveObj;
418
+ return o?.table ? { dropSql: triggerDropSql(o.name, o.table) } : {};
419
+ })()
420
+ : {};
421
+ return { action, identity: id, kind, reason, ...cost, ...structural };
264
422
  };
265
423
 
266
424
  const actions: ReconcileAction[] = [
267
425
  ...orderDrops(dropSet).map((id) => actionFor(id, 'drop', rebuild.get(id) ?? drop.get(id) ?? '')),
426
+ // 'sql'-kind drops (rebuilds + removals) — invisible to the live catalog, so they
427
+ // carry their own statement; ordered with the other drops, before every create.
428
+ ...[...sqlDrops.entries()].sort((a, b) => a[0].localeCompare(b[0]))
429
+ .map(([id, d]) => ({ action: 'drop' as const, identity: id, kind: 'sql' as DerivedKind, reason: d.reason, dropSql: d.dropSql })),
268
430
  ...[...fnReplace.entries()].sort((a, b) => seqOf(a[0]) - seqOf(b[0]))
269
431
  .map(([id, reason]) => actionFor(id, 'replace', reason)),
270
432
  ...createSet.sort((a, b) => seqOf(a) - seqOf(b))
@@ -272,6 +434,7 @@ export function planReconcile(
272
434
  ...[...refresh.entries()].sort((a, b) => seqOf(a[0]) - seqOf(b[0]))
273
435
  .map(([id, reason]) => actionFor(id, 'refresh', reason)),
274
436
  ...baseline.sort().map((id) => actionFor(id, 'baseline', 'recording provenance for an existing match (trusted once, explicitly)')),
437
+ ...rebaseline.sort().map((id) => actionFor(id, 'rebaseline', 'source re-rendered — live verified untouched (defHash match); provenance re-recorded, no rebuild')),
275
438
  ...prune.sort().map((id) => ({
276
439
  action: 'prune' as const, identity: id, kind: 'view' as DerivedKind,
277
440
  reason: 'stale provenance — object gone from both source and database',
@@ -289,7 +452,10 @@ export function planReconcile(
289
452
  needsBaseline: needsBaseline.sort(),
290
453
  unmanaged: unmanaged.sort(),
291
454
  blocked,
292
- warnings: [...source.warnings],
455
+ warnings: [...source.warnings, ...extraWarnings],
293
456
  totalRebuildBytes,
457
+ migrations,
458
+ regrants: regrants.sort((a, b) => a.identity.localeCompare(b.identity)),
459
+ dependencyDrift: dependencyDrift.sort((a, b) => a.identity.localeCompare(b.identity)),
294
460
  };
295
461
  }