@everystack/cli 0.4.106 → 0.4.107

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/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ All notable changes to `@everystack/cli` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.107] - 2026-10-31
8
+
9
+ ### Added
10
+
11
+ - db:plan names db:provision first when a lifecycle plan needs it
12
+ - db:apply prints a wall time per phase
13
+ - a type change under a declared view or matview mints and applies
14
+
15
+ ### Fixed
16
+
17
+ - the lifecycle preflight accepts the grants the reviewed plan revokes
18
+ - a re-created view's apply errors name it as a view
19
+ - a grant the plan's REVOKE cannot remove stays a lifecycle finding
20
+ - a destroy_log grant from another role names db:provision's REVOKE
21
+
7
22
  ## [0.4.106] - 2026-10-31
8
23
 
9
24
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.106",
3
+ "version": "0.4.107",
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>",
@@ -13,6 +13,16 @@
13
13
  */
14
14
 
15
15
  import type { QueryRunner } from './authz-contract';
16
+ import type { SessionRunner } from './session';
17
+ import type { SourceObject } from './derived-source';
18
+ import type { StateDependent } from './edge-plan';
19
+ import { introspectDerived } from './derived-introspect';
20
+ import { planReconcile } from './derived-plan';
21
+ import {
22
+ builderOwnerMode, derivedSearchPath, ownerPreflightRefusal, ownerPreflightSql, ownerRequirements,
23
+ renderEnsureObjectSchemas, renderReconcileSql, type OwnerApplyMode,
24
+ } from './derived-apply';
25
+ import { quoteIdent, quoteQualified } from './pg-ident';
16
26
 
17
27
  export interface AlterTypeTarget {
18
28
  table: string;
@@ -94,3 +104,192 @@ export function renderAlterTypeRefusal(blocked: readonly BlockedAlterType[]): st
94
104
  `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
105
  ].join('\n');
96
106
  }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Declared dependents: dropped and re-created inside the state transaction.
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Every view, matview or rule the targets' type changes force out: the ones that bind a target
114
+ * column, and, transitively, every view or matview whose rule reads one of those (a view on a
115
+ * view). PostgreSQL cannot drop the first without the second.
116
+ */
117
+ export function dependentClosureSql(targets: readonly AlterTypeTarget[]): string {
118
+ const direct = targets.map((t) => {
119
+ const rel = quoteLiteral(t.table);
120
+ return `(d.refobjid = ${rel}::regclass AND d.refobjsubid = (SELECT attnum FROM pg_catalog.pg_attribute
121
+ WHERE attrelid = ${rel}::regclass AND attname = ${quoteLiteral(t.column)}))`;
122
+ }).join(' OR ');
123
+ return `WITH RECURSIVE closure(oid) AS (
124
+ SELECT r.ev_class FROM pg_catalog.pg_depend d JOIN pg_catalog.pg_rewrite r ON r.oid = d.objid
125
+ WHERE d.classid = 'pg_catalog.pg_rewrite'::regclass AND d.refclassid = 'pg_catalog.pg_class'::regclass
126
+ AND r.ev_class <> d.refobjid AND (${direct})
127
+ UNION
128
+ SELECT r.ev_class FROM closure c JOIN pg_catalog.pg_depend d ON d.refobjid = c.oid
129
+ AND d.refclassid = 'pg_catalog.pg_class'::regclass AND d.classid = 'pg_catalog.pg_rewrite'::regclass
130
+ JOIN pg_catalog.pg_rewrite r ON r.oid = d.objid
131
+ WHERE r.ev_class <> c.oid
132
+ )
133
+ SELECT DISTINCT cl.oid::bigint AS oid,
134
+ CASE cl.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'rule' END AS kind,
135
+ n.nspname::text || '.' || cl.relname::text AS identity,
136
+ pg_catalog.pg_total_relation_size(cl.oid)::bigint AS bytes
137
+ FROM closure JOIN pg_catalog.pg_class cl ON cl.oid = closure.oid JOIN pg_catalog.pg_namespace n ON n.oid = cl.relnamespace
138
+ ORDER BY 3`;
139
+ }
140
+
141
+ /**
142
+ * What else depends on the closure, which a plain DROP would refuse over (a function returning
143
+ * the view's row type) or remove silently (a trigger on a view). The closure's own rules and
144
+ * members are not counted: they are dropped and re-created with it.
145
+ */
146
+ export function closureBlockersSql(oids: readonly number[]): string {
147
+ const list = `ARRAY[${oids.map((oid) => String(Math.trunc(oid))).join(', ')}]::oid[]`;
148
+ return `SELECT DISTINCT pg_catalog.pg_describe_object(d.classid, d.objid, d.objsubid) AS what,
149
+ pg_catalog.pg_describe_object(d.refclassid, d.refobjid, 0) AS on_what
150
+ FROM pg_catalog.pg_depend d
151
+ WHERE ((d.refclassid = 'pg_catalog.pg_class'::regclass AND d.refobjid = ANY(${list}))
152
+ OR (d.refclassid = 'pg_catalog.pg_type'::regclass AND d.refobjid IN (SELECT reltype FROM pg_catalog.pg_class WHERE oid = ANY(${list}))))
153
+ AND ((d.deptype = 'n'
154
+ AND NOT (d.classid = 'pg_catalog.pg_rewrite'::regclass AND (SELECT ev_class FROM pg_catalog.pg_rewrite WHERE oid = d.objid) = ANY(${list}))
155
+ AND NOT (d.classid = 'pg_catalog.pg_class'::regclass AND d.objid = ANY(${list})))
156
+ OR d.classid = 'pg_catalog.pg_trigger'::regclass)
157
+ ORDER BY 1`;
158
+ }
159
+
160
+ /** `1.2 MB`, for the plan's matview line. */
161
+ export function formatBytes(bytes: number): string {
162
+ const units = ['B', 'kB', 'MB', 'GB', 'TB'];
163
+ let value = bytes;
164
+ let unit = 0;
165
+ while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
166
+ return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
167
+ }
168
+
169
+ /** The plan's line for one dependent. */
170
+ export function renderStateDependentLine(d: Pick<StateDependent, 'identity' | 'kind' | 'bytes'>): string {
171
+ const cost = d.kind === 'materialized view' ? ` (repopulates ${formatBytes(d.bytes ?? 0)})` : '';
172
+ return `~ ${d.kind} ${d.identity}: dropped before the type change and re-created from the checkout in the same transaction${cost}`;
173
+ }
174
+
175
+ export type StateDependentsMint = { dependents: StateDependent[] } | { refusal: string };
176
+
177
+ /**
178
+ * The declared views and matviews the targets' type changes force out, rendered for the state
179
+ * transaction, or a refusal naming what the platform cannot re-create:
180
+ *
181
+ * - a dependent the models do not declare (nothing can re-create it from the checkout):
182
+ * declare it in the models, which db:pull renders;
183
+ * - a declared one whose live definition the reconciler cannot vouch for (drift, or never
184
+ * reconciled): db:reconcile has the verb for each;
185
+ * - something else depending on the closure (a function over a view's row type, a trigger on a
186
+ * view), which a plain DROP would refuse over or silently remove.
187
+ *
188
+ * Read-only. Each create is the SQL a derived apply renders for that object.
189
+ */
190
+ export async function mintStateDependents(
191
+ runner: QueryRunner,
192
+ session: SessionRunner,
193
+ targets: readonly AlterTypeTarget[],
194
+ declared: readonly SourceObject[],
195
+ ): Promise<StateDependentsMint> {
196
+ if (targets.length === 0) return { dependents: [] };
197
+ const rows = await runner(dependentClosureSql(targets));
198
+ if (rows.length === 0) return { dependents: [] };
199
+ const byIdentity = new Map(declared.map((o) => [o.identity, o]));
200
+ const where = targets.map((t) => `${t.table}."${t.column}"`).join(', ');
201
+ const undeclared = rows.filter((r: any) => r.kind === 'rule' || byIdentity.get(String(r.identity))?.kind !== r.kind);
202
+ if (undeclared.length > 0) {
203
+ return {
204
+ refusal: `This plan changes the type of ${where}, and PostgreSQL refuses that while a view or rule binds the column. `
205
+ + 'db:apply drops and re-creates the declared ones in the same transaction, but these are not declared, so nothing can re-create them:\n'
206
+ + undeclared.map((r: any) => ` ${r.kind} ${r.identity} is not declared`).join('\n')
207
+ + '\nFix: declare it in the models (db:pull renders it), then re-run db:plan.',
208
+ };
209
+ }
210
+ const blockers = await runner(closureBlockersSql(rows.map((r: any) => Number(r.oid))));
211
+ if (blockers.length > 0) {
212
+ return {
213
+ refusal: `This plan changes the type of ${where}. db:apply would drop and re-create ${rows.map((r: any) => `${r.kind} ${r.identity}`).join(', ')} around it, `
214
+ + 'but other objects depend on them, and a plain DROP would refuse over them or remove them:\n'
215
+ + blockers.map((b: any) => ` ${b.what} depends on ${b.on_what}`).join('\n')
216
+ + '\nRoute this type change through db:swap, or change the models so nothing else depends on these views first.',
217
+ };
218
+ }
219
+
220
+ // Creation order is the declared layer's dependency order; the drops run in reverse.
221
+ const order = new Map(declared.map((o, i) => [o.identity, i]));
222
+ const closure = rows
223
+ .map((r: any) => ({ identity: String(r.identity), kind: r.kind as StateDependent['kind'], bytes: Number(r.bytes) }))
224
+ .sort((a, b) => order.get(a.identity)! - order.get(b.identity)!);
225
+ const ids = new Set(closure.map((c) => c.identity));
226
+
227
+ const live = await introspectDerived(session);
228
+ const parsed = { objects: [...declared], warnings: [] as string[] };
229
+ // The reconciler's own verdict first: a hand-edited or never-reconciled view is not ours to
230
+ // re-create silently, and db:reconcile already has the verb for each.
231
+ const verdict = planReconcile(parsed, live, { only: [...ids] });
232
+ const drifted = verdict.drift.filter((d) => ids.has(d.identity)).map((d) => d.identity);
233
+ const unbaselined = verdict.needsBaseline.filter((id) => ids.has(id));
234
+ if (drifted.length + unbaselined.length > 0) {
235
+ return {
236
+ refusal: `This plan changes the type of ${where}, which db:apply handles by re-creating the declared views and matviews that bind it, `
237
+ + 'but the reconciler cannot vouch for some of them:\n'
238
+ + [...drifted.map((id) => ` ${id} differs from its record (hand-edited). Fix: db:reconcile --apply --overwrite-drift, or declare the live definition.`),
239
+ ...unbaselined.map((id) => ` ${id} was never reconciled. Fix: db:reconcile --baseline once you have verified it matches the models.`)].join('\n')
240
+ + '\nThen re-run db:plan.',
241
+ };
242
+ }
243
+
244
+ const liveById = new Map(live.objects.map((o) => [o.identity, o]));
245
+ // Rendered as the creates a derived apply would run against the catalog as it is after the
246
+ // drops: the closure absent.
247
+ const absent = {
248
+ ...live,
249
+ objects: live.objects.filter((o) => !ids.has(o.identity)),
250
+ edges: live.edges.filter((e) => !ids.has(e.dependent) && !ids.has(e.referenced)),
251
+ };
252
+ const combined = planReconcile(parsed, absent, { only: [...ids] });
253
+ const requirements = ownerRequirements(combined, parsed.objects);
254
+ let ownerMode: OwnerApplyMode = 'set-role';
255
+ if (requirements.length > 0) {
256
+ const preflight = await runner(ownerPreflightSql(requirements));
257
+ const refusal = ownerPreflightRefusal(preflight as any[]);
258
+ if (refusal) return { refusal: `could not plan the re-create of the views a type change binds: ${refusal}` };
259
+ ownerMode = builderOwnerMode(preflight as any[]);
260
+ }
261
+ const schemas = derivedSearchPath([...declared]);
262
+ const searchPath = schemas.length === 1 && schemas[0] === 'public' ? null : schemas.map(quoteIdent).join(', ');
263
+
264
+ return {
265
+ dependents: closure.map((c) => {
266
+ const source = byIdentity.get(c.identity)!;
267
+ const rendered = renderReconcileSql(planReconcile(parsed, absent, { only: [c.identity] }), parsed.objects, ownerMode);
268
+ if (rendered.record.length !== 1 || rendered.record[0] !== c.identity) {
269
+ throw new Error(`could not render the re-create of ${c.identity}: the target changed while minting; re-run db:plan.`);
270
+ }
271
+ const drop = `DROP ${c.kind === 'view' ? 'VIEW' : 'MATERIALIZED VIEW'} ${quoteQualified(c.identity)}`;
272
+ const owner = ownerMode === 'set-role' ? source.owner : undefined;
273
+ return {
274
+ identity: c.identity,
275
+ kind: c.kind,
276
+ expectedDefHash: liveById.get(c.identity)!.defHash,
277
+ dropSql: owner ? [`SET ROLE ${owner}`, drop, 'RESET ROLE'].join('\n;\n') : drop,
278
+ sql: [...renderEnsureObjectSchemas([source]), ...rendered.statements].join('\n;\n'),
279
+ searchPath,
280
+ grantContract: {
281
+ attachments: source.attachments.filter((a) => a.kind === 'grant'),
282
+ ...(source.owner ? { owner: source.owner } : {}),
283
+ ownerMode,
284
+ },
285
+ provenance: {
286
+ srcHash: source.hash,
287
+ ...(source.bodyHash ? { bodyHash: source.bodyHash } : {}),
288
+ kind: c.kind,
289
+ ...(source.drop ? { dropSql: source.drop } : {}),
290
+ },
291
+ ...(c.kind === 'materialized view' ? { bytes: c.bytes } : {}),
292
+ };
293
+ }),
294
+ };
295
+ }
@@ -189,6 +189,12 @@ export interface ApplyPlanResult {
189
189
  liveFingerprint: string;
190
190
  reason?: string;
191
191
  logId?: number;
192
+ /**
193
+ * Wall time, in ms, of the two phases this function runs, on the statuses that reached the
194
+ * state transaction: everything before it (the lock, the gates, descent; `verifyMs`), and the
195
+ * transaction plus the landed-fingerprint read (`stateMs`). Optional on the wire.
196
+ */
197
+ phases?: { verifyMs: number; stateMs: number };
192
198
  /**
193
199
  * The keys the row check found orphans on, if any — data, for the caller's advisory (see
194
200
  * fk-orphan-advisory). Present on the paths that got as far as running the check, empty
@@ -465,6 +471,7 @@ export async function executeApplyPlan(
465
471
  plan: EdgePlan,
466
472
  options: ApplyPlanOptions = {},
467
473
  ): Promise<ApplyPlanResult> {
474
+ const started = Date.now();
468
475
  const { hash: live, snapshot: before, contract: beforeAuthz, ledger, ledgerRead } = await liveBaseAndLedger(session);
469
476
 
470
477
  // The endpoint is FULL (it carries the ungoverned live authz through), so it is compared against
@@ -596,16 +603,23 @@ export async function executeApplyPlan(
596
603
  // rewritten — the plan is an artifact under a content hash, and `planHash(plan)` below has to
597
604
  // keep naming the file the operator reviewed.
598
605
  const { statements: toRun, withheld } = withheldValidations(plan.statements, orphans);
606
+ if (options.stateOnly && plan.stateDependents?.length) {
607
+ const reason = `this plan re-creates ${plan.stateDependents.map((d) => `${d.kind} ${d.identity}`).join(', ')} around a column type change, but --state-only skips derived writes. Apply the reviewed plan without --state-only.`;
608
+ await recordRefusal(runner, plan, live, 'state dependents', reason, options);
609
+ return { status: 'refused', liveFingerprint: live, reason };
610
+ }
599
611
  if (options.stateOnly && plan.statePrerequisites?.length) {
600
612
  const reason = 'this plan needs state prerequisites, but --state-only skips function writes. Apply the reviewed plan without --state-only, or satisfy the prerequisites and re-mint.';
601
613
  await recordRefusal(runner, plan, live, 'state prerequisites', reason, options);
602
614
  return { status: 'refused', liveFingerprint: live, reason };
603
615
  }
604
616
 
617
+ const stateStarted = Date.now();
605
618
  let result: StateApplyResult;
606
619
  try {
607
620
  result = await applyGeneratedStatements(runner, toRun, {
608
621
  statePrerequisites: plan.statePrerequisites,
622
+ stateDependents: plan.stateDependents,
609
623
  transaction: options.transaction,
610
624
  ...(withheld.length > 0 ? { skippedValidations: withheld } : {}),
611
625
  actor: options.actor,
@@ -630,6 +644,7 @@ export async function executeApplyPlan(
630
644
  }
631
645
 
632
646
  const { hash: landed } = await liveBaseFingerprint(session);
647
+ const phases = { verifyMs: stateStarted - started, stateMs: Date.now() - stateStarted };
633
648
  if (result.logId !== undefined) {
634
649
  // The log stamps the RAW landed hash — the same flavor as `from` AND as the full `to`, so the
635
650
  // schema_log chain reads as one consistent series of live states.
@@ -645,6 +660,7 @@ export async function executeApplyPlan(
645
660
  return {
646
661
  status: 'validation-pending',
647
662
  liveFingerprint: landed,
663
+ phases,
648
664
  // The next move follows the CAUSE: bad rows are a data fix, a check that was cut off by a
649
665
  // clock is a lane change. See validationPendingReason.
650
666
  reason: validationPendingReason(result.pendingValidations),
@@ -660,6 +676,7 @@ export async function executeApplyPlan(
660
676
  return {
661
677
  status: 'verify-failed',
662
678
  liveFingerprint: landed,
679
+ phases,
663
680
  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).`,
664
681
  ...(result.logId !== undefined ? { logId: result.logId } : {}),
665
682
  fkOrphans: orphans,
@@ -668,6 +685,7 @@ export async function executeApplyPlan(
668
685
  return {
669
686
  status: 'applied',
670
687
  liveFingerprint: landed,
688
+ phases,
671
689
  ...(result.logId !== undefined ? { logId: result.logId } : {}),
672
690
  fkOrphans: orphans,
673
691
  };
@@ -238,6 +238,30 @@ export function catchUpRerunCommand(flags: Record<string, string>): string {
238
238
  return parts.join(' ');
239
239
  }
240
240
 
241
+ /** Wall time, in ms, of each db:apply phase that ran. */
242
+ export interface ApplyPhaseTimings {
243
+ verify?: number;
244
+ state?: number;
245
+ derived?: number;
246
+ postChecks?: number;
247
+ }
248
+
249
+ /**
250
+ * One line: the wall time of each phase that ran, in order, or null when none did. Printed beside
251
+ * the receipt, never inside it, so the receipt's contract is unchanged.
252
+ */
253
+ export function renderPhaseTimings(phases: ApplyPhaseTimings): string | null {
254
+ const named: Array<[string, number | undefined]> = [
255
+ ['descent and fingerprint verify', phases.verify],
256
+ ['state transaction', phases.state],
257
+ ['derived catch-up', phases.derived],
258
+ ['post-checks', phases.postChecks],
259
+ ];
260
+ const ran = named.filter((entry): entry is [string, number] => entry[1] !== undefined);
261
+ if (ran.length === 0) return null;
262
+ return `phases: ${ran.map(([name, ms]) => `${name} ${(ms / 1000).toFixed(2)}s`).join(', ')}`;
263
+ }
264
+
241
265
  /** What the catch-up did, or what to say about the fact that it did not. */
242
266
  type CatchUpReport = { ok: true; statements: string[] } | { ok: false; lines: string[] };
243
267
 
@@ -261,10 +285,12 @@ async function runCatchUp(
261
285
  applied: number,
262
286
  onRefusal: 'exit' | 'throw',
263
287
  declared?: Awaited<ReturnType<typeof loadDeclaredDerived>>,
288
+ phases?: ApplyPhaseTimings,
264
289
  ): Promise<CatchUpReport> {
265
290
  const verbose = flags.verbose === 'true';
266
291
  try {
267
292
  const outcome = await catchUpDerived(venue, {
293
+ ...(phases ? { onPhase: (phase: 'derived' | 'post-checks', ms: number) => { phases[phase === 'derived' ? 'derived' : 'postChecks'] = ms; } } : {}),
268
294
  ...(declared !== undefined ? { declared } : {}),
269
295
  ...(flags.models ? { models: flags.models } : {}),
270
296
  onStart: () => step('Catching the functions, views and triggers up to the checkout...'),
@@ -336,6 +362,7 @@ async function catchUpAfterApply(
336
362
  venue: CatchUpVenue,
337
363
  flags: Record<string, string>,
338
364
  declared?: Awaited<ReturnType<typeof loadDeclaredDerived>>,
365
+ phases?: ApplyPhaseTimings,
339
366
  ): Promise<CatchUpReport> {
340
367
  if (!LANDED.has(result.status)) return { ok: true, statements: [] };
341
368
  if (flags['state-only'] === 'true') return { ok: true, statements: [] };
@@ -345,7 +372,7 @@ async function catchUpAfterApply(
345
372
  const applied = result.status === 'already-applied'
346
373
  ? 0
347
374
  : classifyGeneratedStatements((plan.statements ?? []).filter((s) => !unapplied.has(s))).executable.length;
348
- return runCatchUp(venue, flags, applied, 'throw', declared);
375
+ return runCatchUp(venue, flags, applied, 'throw', declared, phases);
349
376
  }
350
377
 
351
378
  /**
@@ -521,7 +548,9 @@ async function applyPlanViaStage(
521
548
  const declared = await loadDeclaredDerived(flags.models);
522
549
  const unclassified = unclassifiedGranteesWarning(declared?.models ?? [], lifecycleRolesOf(declared));
523
550
  if (unclassified) warn(unclassified);
524
- const lifecycleRefusal = await lifecycleApplyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? [], lifecycleRolesOf(declared))
551
+ // The plan's own REVOKEs resolve the grant findings they remove: the state transaction runs them
552
+ // before the catch-up creates any lifecycle function, and the post-check reads the catalog after.
553
+ const lifecycleRefusal = await lifecycleApplyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? [], lifecycleRolesOf(declared), plan.statements)
525
554
  ?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? []) : null);
526
555
  if (lifecycleRefusal) {
527
556
  fail(lifecycleRefusal);
@@ -537,6 +566,10 @@ async function applyPlanViaStage(
537
566
  // answered by two containers at two moments. Read the stage twice and refuse a
538
567
  // disagreement — the apply never starts. Deliberately OUTSIDE the try below: this
539
568
  // refusal must exit as itself, not as "Apply failed".
569
+ // Wall times as this CLI sees them: the reads, descent and authority before the write; the write
570
+ // invoke (the ops Lambda's own lock, gates and transaction); then the catch-up's two halves.
571
+ const phases: ApplyPhaseTimings = {};
572
+ const verifyStarted = Date.now();
540
573
  step('Reading the stage TWICE (state and authz are separate ops-Lambda invokes)...');
541
574
  const readSession = lambdaSessionRunner(region, fn);
542
575
  const readPair = await readStageStateTwice(readSession).catch((err: any) => {
@@ -628,6 +661,8 @@ async function applyPlanViaStage(
628
661
  }
629
662
 
630
663
  step('Applying in the ops Lambda (credential-free — the operator never holds a database URL)...');
664
+ const stateStarted = Date.now();
665
+ phases.verify = stateStarted - verifyStarted;
631
666
  const result: any = await invokeAction(region, fn, 'db:apply', {
632
667
  plan,
633
668
  // q1-contract-version direction A: the server refuses on exact mismatch, so a payload-key or
@@ -643,6 +678,7 @@ async function applyPlanViaStage(
643
678
  ...(flags['snapshot-ref'] ? { snapshotRef: flags['snapshot-ref'] } : {}),
644
679
  stage: flags.stage,
645
680
  });
681
+ phases.state = Date.now() - stateStarted;
646
682
  if (result?.error) {
647
683
  fail(`Apply failed: ${result.error}`);
648
684
  process.exit(1);
@@ -670,8 +706,10 @@ async function applyPlanViaStage(
670
706
  }
671
707
 
672
708
  // Then the functions, views and triggers — on the same stage, credential-free, always.
673
- const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags, declared);
709
+ const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags, declared, phases);
674
710
  exitOnCatchUpFailure(catchUp);
711
+ const timings = LANDED.has(landedResult!.status) ? renderPhaseTimings(phases) : null;
712
+ if (timings) info(timings);
675
713
  const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
676
714
  const advisoryModels = await modelsForAdvisory(landedResult!, flags);
677
715
  reportApplyResult(landedResult!, plan, {
@@ -960,7 +998,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
960
998
  // Read-only, before the rollback point: a refused apply must not first take a snapshot, backup or dump.
961
999
  const unclassified = unclassifiedGranteesWarning((await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()));
962
1000
  if (unclassified) warn(unclassified);
963
- const lifecycleRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()))
1001
+ // As on the stage lane: a finding this plan's own REVOKE removes is not a refusal.
1002
+ const lifecycleRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()), plan.statements)
964
1003
  ?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(runner, (await declaredDb())?.models ?? []) : null);
965
1004
  if (lifecycleRefusal) {
966
1005
  fail(lifecycleRefusal);
@@ -1087,6 +1126,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1087
1126
 
1088
1127
  let landed: ApplyPlanResult | undefined;
1089
1128
  let catchUp: CatchUpReport = { ok: true, statements: [] };
1129
+ const phases: ApplyPhaseTimings = {};
1090
1130
  try {
1091
1131
  // The edge as hashes — internal bookkeeping, not what a reader came for. Verbose only.
1092
1132
  if (flags.verbose === 'true') info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
@@ -1117,7 +1157,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1117
1157
  { verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
1118
1158
  async () => {
1119
1159
  // Re-checked under the lease, where the writes happen.
1120
- const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()));
1160
+ const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()), plan.statements);
1121
1161
  if (leasedRefusal) {
1122
1162
  fail(leasedRefusal);
1123
1163
  process.exit(1);
@@ -1179,7 +1219,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1179
1219
  // they read, and no second operator can slip in between the two halves. Its failure
1180
1220
  // is carried out as a report, never thrown: the plan landed, and "rolled back" would
1181
1221
  // be a lie.
1182
- const report = await catchUpAfterApply(result, plan, { kind: 'direct', runner, session }, flags);
1222
+ if (result.phases) Object.assign(phases, { verify: result.phases.verifyMs, state: result.phases.stateMs });
1223
+ const report = await catchUpAfterApply(result, plan, { kind: 'direct', runner, session }, flags, undefined, phases);
1183
1224
  return { result, catchUp: report };
1184
1225
  },
1185
1226
  );
@@ -1199,6 +1240,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1199
1240
  }
1200
1241
 
1201
1242
  exitOnCatchUpFailure(catchUp);
1243
+ const timings = LANDED.has(landed!.status) ? renderPhaseTimings(phases) : null;
1244
+ if (timings) info(timings);
1202
1245
  const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
1203
1246
  const advisoryModels = await modelsForAdvisory(landed!, flags);
1204
1247
  reportApplyResult(landed!, plan, {
@@ -63,7 +63,7 @@ import { resolveModelsPath } from '../models-path';
63
63
  import { resolveConfig, opsFunction } from '../config';
64
64
  import { resolveOperatorUrlViaStage } from '../direct-venue';
65
65
  import { lambdaQueryRunner, lambdaSessionRunner } from '../aws';
66
- import { lifecycleApplyRefusal, lifecycleRolesOf, unclassifiedGranteesWarning } from '../lifecycle-preflight';
66
+ import { lifecycleApplyReport, lifecycleProvisionFirst, lifecycleRolesOf, unclassifiedGranteesWarning } from '../lifecycle-preflight';
67
67
  import { loadModels } from './db-generate';
68
68
  import { loadDeclaredDerived, type DeclaredDerived } from '../declared-derived';
69
69
  import { introspectDerived } from '../derived-introspect';
@@ -71,7 +71,7 @@ import { planReconcile, type ReconcilePlan } from '../derived-plan';
71
71
  import { checkFails, buildReconcileReport } from './db-reconcile';
72
72
  import { governedRoleSet, ungovernedGrants } from '../authz-reconcile';
73
73
  import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline';
74
- import { alterTypeStatementTargets, findAlterTypeDependents, renderAlterTypeRefusal } from '../alter-type-dependents';
74
+ import { alterTypeStatementTargets, mintStateDependents, renderStateDependentLine } from '../alter-type-dependents';
75
75
  import { compileTableContract } from '../authz-compile';
76
76
  import { reportPipelineLastRun } from './pipeline-run';
77
77
  import { renderPlanReport } from '../plain-output';
@@ -241,11 +241,13 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
241
241
  // The modules' role declaration, as db:apply reads it. A barrel that does not compose leaves it
242
242
  // undeclared here (the check as before); the paths that need the derived layer report the error.
243
243
  const lifecycleRoles = lifecycleRolesOf(await loadDeclaredDerived(flags.models).catch(() => null));
244
- const lifecycleNote = await lifecycleApplyRefusal(runner, models, lifecycleRoles);
244
+ // First, before anything else is said: what db:provision must create or fix. Nothing below
245
+ // can apply until it has run, and the fix is a verb, not SQL.
246
+ const provisionFirst = await lifecycleProvisionFirst(runner, models,
247
+ flags.stage ? `everystack db:provision --stage ${flags.stage}` : 'everystack db:provision --database-url <this database>');
248
+ if (provisionFirst) warn(provisionFirst);
245
249
  const unclassified = unclassifiedGranteesWarning(models, lifecycleRoles);
246
250
  if (unclassified) warn(unclassified);
247
- // db:plan only reads, so it mints the plan anyway; db:apply runs the same check and refuses.
248
- if (lifecycleNote) warn(`db:apply will refuse this plan until each lifecycle finding below is fixed.\n${lifecycleNote}`);
249
251
  let snapshot: SchemaSnapshot;
250
252
  let contract: AuthzContract;
251
253
  if (dbSource.kind === 'url') {
@@ -318,22 +320,34 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
318
320
  process.exit(1);
319
321
  }
320
322
 
323
+ // The lifecycle catalog check db:apply runs, over THIS plan: a finding the plan's own REVOKE
324
+ // removes is listed as resolved; the rest will block the apply. db:plan only reads, so it
325
+ // mints the plan anyway. Read after the mint because the plan is half of the answer.
326
+ const lifecycle = await lifecycleApplyReport(runner, models, lifecycleRoles, plan.statements);
327
+ if (lifecycle.resolved) info(lifecycle.resolved);
328
+ if (lifecycle.refusal) warn(`db:apply will refuse this plan until each lifecycle finding below is fixed.\n${lifecycle.refusal}`);
329
+
321
330
  // The edge is pure, but this decision belongs to mint: only this command holds both the
322
331
  // checkout's declared functions and the live catalog. Embedding the exact missing CREATEs
323
332
  // lets every apply lane enact the reviewed answer without reading source again.
324
333
  plan.statePrerequisites = await mintStatePrerequisites(runner, session, plan.statements, declaredDerived?.objects ?? []);
325
334
 
326
- // The plan lane's contract: what it mints, it can apply. A SET DATA TYPE on a
327
- // column a view/matview/rule binds would fail MID-TRANSACTION at apply
328
- // ("cannot alter type of a column used by a view or rule") — refuse at mint,
329
- // with the dependents named, while there is still a live catalog to ask.
335
+ // The plan lane's contract: what it mints, it can apply. A SET DATA TYPE on a column a view
336
+ // or matview binds fails at apply ("cannot alter type of a column used by a view or rule").
337
+ // The declared ones ride on the plan: the state transaction drops them, changes the type and
338
+ // re-creates them from this checkout before COMMIT. Anything the platform cannot re-create
339
+ // refuses here, by name, while there is still a live catalog to ask.
330
340
  const alterTargets = alterTypeStatementTargets(plan.statements);
331
341
  if (alterTargets.length > 0) {
332
- const blockedAlters = await findAlterTypeDependents(runner, alterTargets);
333
- if (blockedAlters.length > 0) {
334
- fail(`Mint refused: ${renderAlterTypeRefusal(blockedAlters)}`);
342
+ const dependents = await mintStateDependents(runner, session, alterTargets, declaredDerived?.objects ?? []);
343
+ if ('refusal' in dependents) {
344
+ fail(`Mint refused: ${dependents.refusal}`);
335
345
  process.exit(1);
336
346
  }
347
+ if (dependents.dependents.length > 0) {
348
+ plan.stateDependents = dependents.dependents;
349
+ for (const dependent of dependents.dependents) info(renderStateDependentLine(dependent));
350
+ }
337
351
  }
338
352
 
339
353
  // The class rides on the ARTIFACT, not only the terminal — a reviewer reading
@@ -31,7 +31,7 @@ import fs from 'node:fs/promises';
31
31
  import path from 'node:path';
32
32
  import type { ModelDescriptor } from '@everystack/model';
33
33
  import { introspectContract, type QueryRunner } from '../authz-contract';
34
- import { catalogInputsFromModels, lifecycleApplyRefusal, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecycleRolesOf, type LifecycleRoleDeclaration } from '../lifecycle-preflight';
34
+ import { catalogInputsFromModels, lifecycleApplyReport, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecycleRolesOf, type LifecycleRoleDeclaration } from '../lifecycle-preflight';
35
35
  import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
36
36
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
37
37
  import { generateMigrationSql, unmodeledTables } from '../migration-generate';
@@ -98,6 +98,8 @@ export interface SyncHooks {
98
98
  onStatePlan?: (statements: string[], classified: ClassifiedStatements, pass: number) => void;
99
99
  /** Fires after the state layer applied and verified, before the compute reconcile. */
100
100
  onStateDone?: (state: StateSyncOutcome) => void;
101
+ /** Fires before anything executes when the state diff's own REVOKEs resolve lifecycle findings (one line). */
102
+ onLifecycleResolved?: (line: string) => void;
101
103
  }
102
104
 
103
105
  export interface SyncRun {
@@ -140,6 +142,7 @@ async function syncOnce(
140
142
  options: SyncOptions,
141
143
  hooks: SyncHooks,
142
144
  pass: number,
145
+ preflight?: (statements: readonly string[]) => Promise<void>,
143
146
  ): Promise<SyncRun> {
144
147
  const current = await introspectSchema(session);
145
148
  const liveAuthz = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
@@ -147,6 +150,8 @@ async function syncOnce(
147
150
  allowDrops: options.allowDrops, liveAuthz, sequences: options.sequences,
148
151
  governedRoles: options.governedRoles, extensions: options.extensions,
149
152
  });
153
+ // Read-only, before anything is written: it needs the statements this pass will run.
154
+ await preflight?.(statements);
150
155
  const classified = classifyGeneratedStatements(statements);
151
156
  hooks.onStatePlan?.(statements, classified, pass);
152
157
  // A held drop of a key into a lifecycle table leaves a key the after-state catalog check refuses,
@@ -244,13 +249,18 @@ export async function executeSync(
244
249
  hooks: SyncHooks = {},
245
250
  ): Promise<SyncRun> {
246
251
  // State runs before compute, so without this a lifecycle table would land with no transition trigger.
247
- const lifecycleRefusal = await lifecycleApplyRefusal(runner, models, options.lifecycleRoles);
248
- if (lifecycleRefusal) throw new Error(lifecycleRefusal);
249
- // Before anything is written, so before both places a pass creates derived objects (the
250
- // functions the state batch calls, then the reconcile).
251
- const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false, roles: options.lifecycleRoles });
252
- if (ownerRefusal) throw new LifecycleFindingsError(ownerRefusal);
253
- const first = await syncOnce(runner, session, models, options, hooks, 1);
252
+ // It reads the first pass's statements: a finding their own REVOKE removes is resolved, not refused
253
+ // (the state batch runs before the reconcile creates any lifecycle function, which re-checks).
254
+ const preflight = async (statements: readonly string[]): Promise<void> => {
255
+ const lifecycle = await lifecycleApplyReport(runner, models, options.lifecycleRoles, statements);
256
+ if (lifecycle.refusal) throw new Error(lifecycle.refusal);
257
+ if (lifecycle.resolved) hooks.onLifecycleResolved?.(lifecycle.resolved);
258
+ // Before anything is written, so before both places a pass creates derived objects (the
259
+ // functions the state batch calls, then the reconcile).
260
+ const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false, roles: options.lifecycleRoles });
261
+ if (ownerRefusal) throw new LifecycleFindingsError(ownerRefusal);
262
+ };
263
+ const first = await syncOnce(runner, session, models, options, hooks, 1, preflight);
254
264
  // The trigger is the STATE bar, not the combined verdict. The cause above is state-layer
255
265
  // only, so a compute refusal or an unadopted object cannot be settled by looking again —
256
266
  // retrying on those would make every brownfield sync pay a second full read (and print its
@@ -487,6 +497,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
487
497
  onStateDone: () => {
488
498
  if (declaredDb?.objects.length) step('Reconciling the declared derived layer...');
489
499
  },
500
+ onLifecycleResolved: (line) => info(line),
490
501
  },
491
502
  );
492
503
 
@@ -83,6 +83,8 @@ export interface CatchUpOptions {
83
83
  * Required for the same reason as `onRefusal`: only the caller knows what it wrote.
84
84
  */
85
85
  stateApplied: boolean;
86
+ /** Wall time, in ms, of each phase that ran: the owner gate and reconcile, then the post-check. */
87
+ onPhase?: (phase: 'derived' | 'post-checks', ms: number) => void;
86
88
  }
87
89
 
88
90
  export interface CatchUpOutcome {
@@ -124,6 +126,7 @@ export async function catchUpDerived(
124
126
  gitRef: currentGitRef(),
125
127
  };
126
128
 
129
+ const derivedStarted = Date.now();
127
130
  // Before the first derived write, judge the owners the reconcile will give the declared trigger
128
131
  // functions on lifecycle tables.
129
132
  const config = venue.kind === 'stage' ? await resolveConfig(venue.stage) : undefined;
@@ -171,11 +174,14 @@ export async function catchUpDerived(
171
174
  // A refusal is not a soft outcome here. db:reconcile prints it and exits 1; the catch-up did
172
175
  // not land, so db:apply must say so and exit the same way.
173
176
  if (run.refusal) throw new CatchUpError(run.refusal);
177
+ opts.onPhase?.('derived', Date.now() - derivedStarted);
174
178
 
175
179
  // Verification: the live catalog check again, over what the reconcile just made live. On the
176
180
  // operator session, where the commit is: db:query may read a replica that has not caught up.
181
+ const postStarted = Date.now();
177
182
  const post = await lifecyclePostApplyRefusal(operator, declared?.models ?? [], lifecycleRolesOf(declared));
178
183
  if (post) throw new LifecycleFindingsError(post);
184
+ opts.onPhase?.('post-checks', Date.now() - postStarted);
179
185
 
180
186
  return { declared: objects.length, run, statements: run.applied ? (run.statements ?? []) : [] };
181
187
  }
@@ -53,8 +53,10 @@ import { compileTableRenames, compileTableMoves, compileRenames } from './schema
53
53
  * A v3 reader does not know how to enact or record those prerequisites, so the file formats are
54
54
  * not compatible. The existing db:apply version refusal makes the mismatch a re-mint before any
55
55
  * write, rather than letting a peer silently omit part of the reviewed artifact.
56
+ * v7 adds `stateDependents`: declared views and matviews a column type change binds, dropped and
57
+ * re-created inside the state transaction. A v6 reader would skip them and the ALTER would fail.
56
58
  */
57
- export const PLAN_VERSION = 6;
59
+ export const PLAN_VERSION = 7;
58
60
 
59
61
  /**
60
62
  * The db:apply WIRE CONTRACT between the CLI and the ops Lambda — distinct from PLAN_VERSION,
@@ -119,8 +121,12 @@ export const PLAN_VERSION = 6;
119
121
  *
120
122
  * v10 applies the reviewed prerequisite search path and restores the transaction's original
121
123
  * path before state DDL and grant verification. A v9 stage could bind defaults to other schemas.
124
+ *
125
+ * v11 (milestone upgrade-no-hand-sql): the plan gained `stateDependents`, which the server drops
126
+ * before the state edge and re-creates after it, in the same transaction, with provenance. A v10
127
+ * stage would skip them, and its ALTER COLUMN TYPE would fail on the view that binds the column.
122
128
  */
123
- export const APPLY_CONTRACT_VERSION = 10;
129
+ export const APPLY_CONTRACT_VERSION = 11;
124
130
 
125
131
  /** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
126
132
  export interface PlanClassification {
@@ -167,6 +173,30 @@ export type StatePrerequisite = {
167
173
  };
168
174
  };
169
175
 
176
+ /**
177
+ * A declared view or matview that binds a column this plan changes the type of. PostgreSQL refuses
178
+ * `ALTER COLUMN ... TYPE` while it exists, so the state transaction drops it (dependents first),
179
+ * runs the edge, and re-creates it from the reviewed checkout SQL before COMMIT: it is never absent.
180
+ * Listed in creation (dependency) order; the drops run in reverse.
181
+ */
182
+ export type StateDependent = {
183
+ /** `schema.name`. */
184
+ identity: string;
185
+ kind: 'view' | 'materialized view';
186
+ /** The live definition hash read at mint. db:apply refuses, before any write, unless it still holds. */
187
+ expectedDefHash: string;
188
+ /** The exact DROP, without CASCADE: anything the mint did not see that depends on it fails the transaction. */
189
+ dropSql: string;
190
+ /** The exact schema preparation, CREATE, ownership, grant, comment and index SQL db:apply runs. */
191
+ sql: string;
192
+ /** As StatePrerequisite.searchPath. */
193
+ searchPath: string | null;
194
+ grantContract: StatePrerequisite['grantContract'];
195
+ provenance: { srcHash: string; bodyHash?: string; kind: 'view' | 'materialized view'; dropSql?: string };
196
+ /** Matview only: pg_total_relation_size at mint. The re-create repopulates it, inside the transaction. */
197
+ bytes?: number;
198
+ };
199
+
170
200
  export interface EdgePlan {
171
201
  v: number;
172
202
  /** The target's exact live fingerprint at mint time — the lock. */
@@ -234,6 +264,8 @@ export interface EdgePlan {
234
264
  * before this field existed have none; an absent field means no prerequisite was recorded.
235
265
  */
236
266
  statePrerequisites?: readonly StatePrerequisite[];
267
+ /** Declared views and matviews the state transaction drops and re-creates around a type change. */
268
+ stateDependents?: readonly StateDependent[];
237
269
  executable: number;
238
270
  /** Executable statements that LOSE DATA (drops + narrowing type changes) — red, gated at apply. */
239
271
  destructive: number;
@@ -6,6 +6,8 @@ import { compileTableContract } from './authz-compile';
6
6
  import { reconcileOwner, triggerFunctionOf } from './derived-apply';
7
7
  import { isSessionError, type SessionRunner } from './session';
8
8
  import { parseQualified, type SourceObject } from './derived-source';
9
+ import { quoteQualified } from './pg-ident';
10
+ import { ALWAYS_GOVERNED } from './authz-reconcile';
9
11
  import { lifecycleDerived, lifecycleManifestOf, lifecycleRole } from './lifecycle-derived';
10
12
 
11
13
  export const LIFECYCLE_LOG_REFUSAL = 'everystack.destroy_log is missing; run db:provision before db:apply';
@@ -32,20 +34,96 @@ export function lifecycleLogRowRefusal(log: { present?: unknown; current?: unkno
32
34
  /**
33
35
  * The log's shape first (its refusals are unchanged), then the live catalog facts the soft delete
34
36
  * rests on (lifecycleCatalogRefusal). Pure SELECTs: the --stage lane runs them over db:query.
37
+ *
38
+ * `planStatements`: the reviewed plan this check runs before. A finding the plan resolves is not
39
+ * a refusal (see planRevokedGrants). The plan's state transaction commits before the derived
40
+ * catch-up creates any lifecycle function, and the post-check reads the catalog again after it.
35
41
  */
36
42
  export async function lifecycleApplyRefusal(
37
43
  runner: QueryRunner,
38
44
  models: readonly ModelDescriptor[],
39
45
  roles: LifecycleRoleDeclaration = {},
46
+ planStatements: readonly string[] = [],
40
47
  ): Promise<string | null> {
41
- if (!models.some(model => model.lifecycle === true)) return null;
48
+ return (await lifecycleApplyReport(runner, models, roles, planStatements)).refusal;
49
+ }
50
+
51
+ /** lifecycleApplyRefusal, plus the findings the plan resolves (one line, or null when none). */
52
+ export async function lifecycleApplyReport(
53
+ runner: QueryRunner,
54
+ models: readonly ModelDescriptor[],
55
+ roles: LifecycleRoleDeclaration = {},
56
+ planStatements: readonly string[] = [],
57
+ ): Promise<{ refusal: string | null; resolved: string | null }> {
58
+ if (!models.some(model => model.lifecycle === true)) return { refusal: null, resolved: null };
42
59
  const [log] = await runner(LIFECYCLE_LOG_SQL);
60
+ const shape = lifecycleLogRowRefusal(log);
61
+ if (shape) return { refusal: shape, resolved: null };
43
62
  // Before the plan: a modeled table's keys are the plan's to converge (the models declare each
44
63
  // one, or refuse to compile), so rule 6 reports only keys from tables the models do not have.
45
64
  const modeledTables = [...new Set(models.map(model => `${model.schema}.${model.table}`))];
46
- return lifecycleLogRowRefusal(log) ?? lifecycleCatalogRefusal(runner, { ...catalogInputsFromModels(models, roles), modeledTables });
65
+ const report = await lifecycleCatalogFindings(runner, { ...catalogInputsFromModels(models, roles), modeledTables },
66
+ { planRevokes: planRevokedGrants(planStatements) });
67
+ return { refusal: catalogRefusalText(report), resolved: resolvedLine(report.resolved) };
68
+ }
69
+
70
+ /**
71
+ * What `db:provision` must do before a lifecycle plan can apply, as one line naming the command,
72
+ * or null. The same reads the apply refuses on: everystack.destroy_log missing or out of date
73
+ * (lifecycleLogRowRefusal), and the lifecycle role missing or holding LOGIN or BYPASSRLS (the
74
+ * attributes db:provision resets). `command` is the db:provision invocation for this target.
75
+ */
76
+ export async function lifecycleProvisionFirst(
77
+ runner: QueryRunner,
78
+ models: readonly ModelDescriptor[],
79
+ command: string,
80
+ options: { lifecycleRole?: string } = {},
81
+ ): Promise<string | null> {
82
+ if (!models.some(model => model.lifecycle === true)) return null;
83
+ const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
84
+ const reasons: string[] = [];
85
+ const [log] = await runner(LIFECYCLE_LOG_SQL);
86
+ if (log?.present !== true) reasons.push('everystack.destroy_log is missing');
87
+ else if (log.current !== true) reasons.push('everystack.destroy_log is out of date');
88
+ const [role] = await runner(roleSql(lc));
89
+ if (!truthy(role?.present)) reasons.push(`role ${lc} is missing`);
90
+ else {
91
+ const wrong = (['login', 'bypassrls'] as const).filter(key => truthy(role[key])).map(key => key.toUpperCase());
92
+ if (wrong.length > 0) reasons.push(`role ${lc} has ${wrong.join(', ')}`);
93
+ }
94
+ if (reasons.length === 0) return null;
95
+ return `run db:provision first: ${reasons.join('; ')}. Run: ${command}`;
96
+ }
97
+
98
+ /**
99
+ * The grants a reviewed plan revokes, one key per (table, grantee, privilege, column) — the exact
100
+ * statements the reconciler emits (authz-reconcile.ts reconcileGrants, reconcileColumnGrants):
101
+ *
102
+ * REVOKE REFERENCES, TRIGGER, TRUNCATE ON public.t FROM admin;
103
+ * REVOKE REFERENCES (a, b) ON public.t FROM admin;
104
+ *
105
+ * Anything else (a sequence, a statement this does not recognise) resolves nothing, so an
106
+ * unrecognised form can only leave a finding standing, never hide one.
107
+ */
108
+ export function planRevokedGrants(statements: readonly string[]): ReadonlySet<string> {
109
+ const keys = new Set<string>();
110
+ for (const statement of statements) {
111
+ const column = /^REVOKE ([A-Z]+) \(([^()]+)\) ON (.+) FROM ([^;]+);$/.exec(statement);
112
+ if (column) {
113
+ for (const name of column[2].split(', ')) keys.add(revokeKey(column[3], column[4], column[1], name));
114
+ continue;
115
+ }
116
+ const table = /^REVOKE ([A-Z]+(?:, [A-Z]+)*) ON (.+) FROM ([^;]+);$/.exec(statement);
117
+ if (!table || table[2].startsWith('SEQUENCE ')) continue;
118
+ for (const privilege of table[1].split(', ')) keys.add(revokeKey(table[2], table[3], privilege));
119
+ }
120
+ return keys;
47
121
  }
48
122
 
123
+ /** `table` as the reconciler emits it (quoteQualified of the contract's `schema.table`). */
124
+ const revokeKey = (table: string, grantee: string, privilege: string, column?: string): string =>
125
+ JSON.stringify([table, grantee, privilege, column ?? null]);
126
+
49
127
  /**
50
128
  * What the modules declare about the roles, beside the models (DeclaredDerived). The catalog check
51
129
  * reads it to tell a definer role the models grant to apart from a role a request can become.
@@ -129,6 +207,12 @@ export interface LifecycleCatalogInputs {
129
207
  requestRoles?: readonly string[];
130
208
  /** Every declared derived-object owner. Read only when `requestRoles` is present. */
131
209
  declaredOwners?: readonly string[];
210
+ /**
211
+ * The roles a fresh plan governs (ALWAYS_GOVERNED and every grantee of the models): the plan
212
+ * revokes any REFERENCES, TRIGGER or TRUNCATE such a role holds on a lifecycle table, since a
213
+ * lifecycle model cannot declare one. Absent (a db:reconcile payload): every rule-3 fix is SQL.
214
+ */
215
+ governedRoles?: readonly string[];
132
216
  }
133
217
 
134
218
  /** A declared dependent: the child's `schema.table`, its foreign-key column (SQL name), the lifecycle parent's `schema.table`. */
@@ -175,15 +259,17 @@ const notApplicationRole = (lc: string) => (role: string): boolean => role !== '
175
259
  export function catalogInputsFromModels(
176
260
  models: readonly ModelDescriptor[],
177
261
  declaration: LifecycleRoleDeclaration = {},
178
- ): { lifecycleTables: string[]; applicationRoles: string[]; dependentKeys: LifecycleDependentKey[]; requestRoles?: string[]; declaredOwners?: string[] } {
262
+ ): { lifecycleTables: string[]; applicationRoles: string[]; dependentKeys: LifecycleDependentKey[]; requestRoles?: string[]; declaredOwners?: string[]; governedRoles: string[] } {
179
263
  const roles = new Set<string>();
264
+ const governed = new Set<string>(ALWAYS_GOVERNED);
180
265
  for (const model of models) {
181
266
  const contract = compileTableContract(model);
182
- for (const role of Object.keys(contract.grants)) roles.add(role);
183
- for (const role of Object.keys(contract.columnGrants ?? {})) roles.add(role);
267
+ for (const role of Object.keys(contract.grants)) { roles.add(role); governed.add(role); }
268
+ for (const role of Object.keys(contract.columnGrants ?? {})) { roles.add(role); governed.add(role); }
184
269
  for (const policy of contract.policies) for (const role of policy.roles) roles.add(role);
185
270
  }
186
271
  return {
272
+ governedRoles: [...governed].sort(),
187
273
  lifecycleTables: [...new Set(models.filter(model => model.lifecycle === true).map(model => `${model.schema}.${model.table}`))].sort(),
188
274
  applicationRoles: [...roles].filter(notApplicationRole(LIFECYCLE_OWNER_ROLE)).sort(),
189
275
  dependentKeys: models.some(model => model.lifecycle === true) ? dependentKeysFromDeclared(lifecycleDerived(models)) : [],
@@ -392,23 +478,27 @@ const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH
392
478
  * Rule 3. TRIGGER, REFERENCES or TRUNCATE on a lifecycle table, or REFERENCES on one of its
393
479
  * columns, held by anyone but its owner. On everystack.destroy_log, any privilege but SELECT
394
480
  * (table or column) held by anyone but its owner: a writer could forge or remove a marker.
481
+ * One row per grantor: PostgreSQL revokes only the grants made by the role REVOKE runs as, and
482
+ * runs a member's (or a superuser's) REVOKE as the owner, so who granted it decides what removes it.
395
483
  */
396
484
  const aclSql = (tables: string[]): string => `SELECT n.nspname::text || '.' || c.relname::text AS "table", ${granteeName('a.grantee')} AS grantee,
397
- a.privilege_type AS privilege, NULL::text AS "column"
485
+ a.privilege_type AS privilege, NULL::text AS "column", a.grantor = c.relowner AS owner_granted,
486
+ pg_catalog.pg_get_userbyid(a.grantor)::text AS grantor
398
487
  FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
399
488
  CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) a
400
489
  WHERE c.relkind IN ('r', 'p') AND a.grantee <> c.relowner AND (
401
490
  ((n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)}) AND a.privilege_type IN ('TRIGGER', 'REFERENCES', 'TRUNCATE'))
402
491
  OR (c.oid = pg_catalog.to_regclass('everystack.destroy_log') AND a.privilege_type <> 'SELECT'))
403
492
  UNION ALL
404
- SELECT n.nspname::text || '.' || c.relname::text, ${granteeName('a.grantee')}, a.privilege_type, t.attname::text
493
+ SELECT n.nspname::text || '.' || c.relname::text, ${granteeName('a.grantee')}, a.privilege_type, t.attname::text,
494
+ a.grantor = c.relowner, pg_catalog.pg_get_userbyid(a.grantor)::text
405
495
  FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
406
496
  JOIN pg_catalog.pg_attribute t ON t.attrelid = c.oid AND t.attnum > 0 AND NOT t.attisdropped
407
497
  CROSS JOIN LATERAL pg_catalog.aclexplode(t.attacl) a
408
498
  WHERE c.relkind IN ('r', 'p') AND a.grantee <> c.relowner AND (
409
499
  ((n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)}) AND a.privilege_type = 'REFERENCES')
410
500
  OR (c.oid = pg_catalog.to_regclass('everystack.destroy_log') AND a.privilege_type <> 'SELECT'))
411
- ORDER BY 1, 4 NULLS FIRST, 3, 2`;
501
+ ORDER BY 1, 4 NULLS FIRST, 3, 2, 6`;
412
502
 
413
503
  /**
414
504
  * Rule 4. Each marker function: SECURITY DEFINER, owned by destroy_log's owner, EXECUTE for the
@@ -520,24 +610,52 @@ export async function lifecycleCatalogRefusal(
520
610
  inputs: LifecycleCatalogInputs,
521
611
  options: { lifecycleRole?: string } = {},
522
612
  ): Promise<string | null> {
523
- const findings = await lifecycleCatalogFindings(runner, inputs, options);
613
+ return catalogRefusalText(await lifecycleCatalogFindings(runner, inputs, options));
614
+ }
615
+
616
+ const catalogRefusalText = ({ findings, resolved }: CatalogFindings): string | null => {
524
617
  if (findings.length === 0) return null;
525
- return `the live catalog breaks the lifecycle soft delete (${findings.length} finding${findings.length === 1 ? '' : 's'}); `
618
+ const also = resolved.length === 0 ? '' : ` (the plan resolves ${resolved.length} more)`;
619
+ return `the live catalog breaks the lifecycle soft delete (${findings.length} finding${findings.length === 1 ? '' : 's'})${also}; `
526
620
  + `fix each, then re-run:\n${findingLines(findings)}`;
527
- }
621
+ };
622
+
623
+ /**
624
+ * The resolved rule-3 findings as one line: per grantee and privilege, the tables the plan
625
+ * revokes it on. Null when the plan resolves none.
626
+ */
627
+ const resolvedLine = (resolved: readonly ResolvedGrant[]): string | null => {
628
+ if (resolved.length === 0) return null;
629
+ const byGrant = new Map<string, string[]>();
630
+ for (const r of resolved) {
631
+ const grant = `${r.privilege}${r.column ? ` (${r.column})` : ''} from ${r.grantee}`;
632
+ byGrant.set(grant, [...(byGrant.get(grant) ?? []), r.table]);
633
+ }
634
+ const parts = [...byGrant].map(([grant, tables]) => `${grant} on ${[...new Set(tables)].sort().join(', ')}`);
635
+ return `the plan resolves ${countFindings(resolved.length)}: it revokes ${parts.join('; ')}, before any lifecycle function is created.`;
636
+ };
528
637
 
529
638
  const findingLines = (findings: readonly string[]): string => findings.map(finding => ` - ${finding}`).join('\n');
530
639
  const countFindings = (n: number): string => `${n} lifecycle finding${n === 1 ? '' : 's'}`;
531
640
 
532
- /** lifecycleCatalogRefusal's findings, one string each, without the heading. */
641
+ /** A rule-3 finding the reviewed plan's own REVOKE removes. */
642
+ interface ResolvedGrant { table: string; grantee: string; privilege: string; column: string | null }
643
+ interface CatalogFindings { findings: string[]; resolved: ResolvedGrant[] }
644
+
645
+ /**
646
+ * lifecycleCatalogRefusal's findings, one string each, without the heading. `planRevokes`
647
+ * (planRevokedGrants): a rule-3 grant on a lifecycle table that the plan revokes, exactly that
648
+ * privilege from that grantee on that table (and column), moves to `resolved`. Nothing else does.
649
+ */
533
650
  async function lifecycleCatalogFindings(
534
651
  runner: QueryRunner,
535
652
  inputs: LifecycleCatalogInputs,
536
- options: { lifecycleRole?: string } = {},
537
- ): Promise<string[]> {
653
+ options: { lifecycleRole?: string; planRevokes?: ReadonlySet<string> } = {},
654
+ ): Promise<CatalogFindings> {
538
655
  const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
539
656
  const tables = [...new Set(inputs.lifecycleTables)].sort();
540
- if (tables.length === 0) return [];
657
+ const resolved: ResolvedGrant[] = [];
658
+ if (tables.length === 0) return { findings: [], resolved };
541
659
  const set = await applicationRoleSet(runner, inputs, lc);
542
660
  const apps = set.apps;
543
661
  const findings: string[] = [...set.findings];
@@ -578,11 +696,36 @@ async function lifecycleCatalogFindings(
578
696
  findings.push(`${kind} ${row.name} is owned by ${who}; ${hazard}. Fix: ${fix}${cause ? ` ${cause}` : ''}`);
579
697
  }
580
698
 
699
+ const governed = new Set(inputs.governedRoles ?? []);
581
700
  for (const row of await runner(aclSql(tables))) {
701
+ const column = row.column == null ? null : String(row.column);
582
702
  const on = row.column == null ? '' : ` (${ident(String(row.column))})`;
703
+ // Granted by another role WITH GRANT OPTION: the plan's REVOKE (run as the owner) and a fresh
704
+ // plan both leave it, so it is never resolved. No verb removes it today (item U6).
705
+ if (!truthy(row.owner_granted)) {
706
+ const table = quoteLabel(String(row.table));
707
+ const grantor = ident(String(row.grantor));
708
+ const who = row.grantee === 'PUBLIC' ? 'PUBLIC' : ident(String(row.grantee));
709
+ // On destroy_log the revoke comes from db:provision, not from a plan.
710
+ const revoker = row.table === 'everystack.destroy_log' ? "db:provision's REVOKE" : "the plan's REVOKE";
711
+ findings.push(`${row.grantee} holds ${row.privilege}${on} on ${row.table}, granted by ${row.grantor} with grant option; `
712
+ + `${revoker} runs as the owner and cannot remove it. Fix: REVOKE ${row.privilege}${on} ON TABLE ${table} FROM ${who} GRANTED BY ${grantor}; `
713
+ + `(as ${row.grantor}, a member of it, or a superuser), or REVOKE GRANT OPTION FOR ${row.privilege}${on} ON TABLE ${table} FROM ${grantor} CASCADE;`);
714
+ continue;
715
+ }
716
+ if (options.planRevokes && tables.includes(String(row.table))
717
+ && options.planRevokes.has(revokeKey(quoteQualified(String(row.table)), String(row.grantee), String(row.privilege), column ?? undefined))) {
718
+ resolved.push({ table: String(row.table), grantee: String(row.grantee), privilege: String(row.privilege), column });
719
+ continue;
720
+ }
583
721
  const who = row.grantee === 'PUBLIC' ? 'PUBLIC' : ident(String(row.grantee));
584
- findings.push(`${row.grantee} holds ${row.privilege}${on} on ${row.table}. `
585
- + `Fix: REVOKE ${row.privilege}${on} ON TABLE ${quoteLabel(String(row.table))} FROM ${who};`);
722
+ // A verb where one exists: db:provision revokes every grant on destroy_log but its owner's
723
+ // (ENSURE_DESTROY_LOG_SQL); a fresh plan revokes a governed role's grant on a lifecycle table.
724
+ // SQL only for a role no plan governs, which the plan leaves alone by design.
725
+ const fix = row.table === 'everystack.destroy_log' ? 'run db:provision.'
726
+ : governed.has(String(row.grantee)) ? 'mint a plan (db:plan) and db:apply it; the plan revokes this grant.'
727
+ : `REVOKE ${row.privilege}${on} ON TABLE ${quoteLabel(String(row.table))} FROM ${who};`;
728
+ findings.push(`${row.grantee} holds ${row.privilege}${on} on ${row.table}. Fix: ${fix}`);
586
729
  }
587
730
 
588
731
  for (const row of await runner(markerSql(lc))) {
@@ -641,7 +784,7 @@ async function lifecycleCatalogFindings(
641
784
  + 'if the models declare this key nullify, upgrade the CLI (@everystack/cli) and re-run.' : ''));
642
785
  }
643
786
 
644
- return findings;
787
+ return { findings, resolved };
645
788
  }
646
789
 
647
790
  /**
@@ -728,7 +871,7 @@ export async function lifecyclePostApplyRefusal(
728
871
  if (isSessionError(slot)) throw new Error(slot.message);
729
872
  return slot ?? [];
730
873
  };
731
- const findings = await lifecycleCatalogFindings(read, catalogInputsFromModels(models, roles));
874
+ const { findings } = await lifecycleCatalogFindings(read, catalogInputsFromModels(models, roles));
732
875
  if (findings.length === 0) return null;
733
876
  return `${countFindings(findings.length)} after reconcile; the objects are live; the next apply refuses until fixed:\n${findingLines(findings)}`;
734
877
  }
@@ -37,7 +37,7 @@ import { introspectDerived } from './derived-introspect';
37
37
  import { objectGrantDelta } from './derived-plan';
38
38
  import { isPureComment, statementBody, statementHead } from './sql-statement';
39
39
  import { splitFunctionIdentity } from './pg-argtypes';
40
- import type { StatePrerequisite } from './edge-plan';
40
+ import type { StateDependent, StatePrerequisite } from './edge-plan';
41
41
 
42
42
  const NOTICE_PREFIX = '-- NOTICE';
43
43
  export const STATE_PREREQUISITE_DRIFT_PREFIX = 'state prerequisite drift:';
@@ -366,6 +366,11 @@ export interface StateApplyOptions {
366
366
  * back both, rather than leaving a derived object ahead of the state it serves.
367
367
  */
368
368
  statePrerequisites?: readonly StatePrerequisite[];
369
+ /**
370
+ * Reviewed declared views and matviews a type change in this batch binds: dropped (dependents
371
+ * first) before it and re-created after it, in the same transaction, so they are never absent.
372
+ */
373
+ stateDependents?: readonly StateDependent[];
369
374
  /**
370
375
  * An explicit write transaction over one pinned connection. Prerequisites need this because
371
376
  * their DDL, the state edge, and their catalog-derived provenance must commit or roll back
@@ -518,6 +523,7 @@ export async function applyGeneratedStatements(
518
523
  ): Promise<StateApplyResult> {
519
524
  const classified = classifyGeneratedStatements(statements);
520
525
  const prerequisites = options.statePrerequisites ?? [];
526
+ const dependents = options.stateDependents ?? [];
521
527
  // Checks the caller withheld before calling. They are outstanding from the first line of this
522
528
  // function: the outcome written before the batch already counts them, and they come back in the
523
529
  // result whether or not any check actually ran here.
@@ -583,7 +589,7 @@ export async function applyGeneratedStatements(
583
589
  const validationSql = validations.join('\n;\n');
584
590
  const started = now();
585
591
  try {
586
- if (prerequisites.length > 0) {
592
+ if (prerequisites.length > 0 || dependents.length > 0) {
587
593
  if (!options.transaction) {
588
594
  throw new Error('state prerequisites require a pinned write transaction; this apply lane cannot prove atomic provenance.');
589
595
  }
@@ -591,39 +597,65 @@ export async function applyGeneratedStatements(
591
597
  const [settings] = await transactionRunner("SELECT pg_catalog.current_setting('search_path') AS search_path");
592
598
  const originalPath = settings?.search_path;
593
599
  if (typeof originalPath !== 'string') throw new Error('could not capture prerequisite search_path; no state was applied.');
600
+ const reread = async () => new Map((await introspectDerived(borrowedSessionRunner(transactionRunner)))
601
+ .objects.map((object) => [object.identity, object]));
602
+ // A dependent is re-created from the reviewed SQL, so it must still be the object the mint
603
+ // read. Checked inside the transaction, before any write.
604
+ if (dependents.length > 0) {
605
+ const before = await reread();
606
+ for (const dependent of dependents) {
607
+ if (before.get(dependent.identity)?.defHash !== dependent.expectedDefHash) {
608
+ throw new Error(`${STATE_PREREQUISITE_DRIFT_PREFIX} ${dependent.kind} ${dependent.identity} is not the one this plan was minted against; the target changed since minting. Re-mint with db:plan.`);
609
+ }
610
+ }
611
+ }
594
612
  // Defaults and SQL-standard bodies bind names at CREATE time. Use the full declared
595
613
  // path recorded at mint, then restore the exact connection setting; the state DDL
596
- // that follows pins the canonical path itself.
597
- batchSql = ['SET LOCAL check_function_bodies = off', ...prerequisites.flatMap((prerequisite) => [
598
- renderStatePrerequisiteGuard(prerequisite),
599
- ...(prerequisite.searchPath === null ? [] : [renderLocalSearchPath(prerequisite.searchPath)]),
600
- prerequisite.sql,
601
- ...(prerequisite.searchPath === null ? [] : [renderLocalSearchPath(originalPath)]),
602
- ]), ...stateBatch].join('\n;\n');
614
+ // that follows pins the canonical path itself. Dependents drop before the edge, children
615
+ // first, and come back after it, parents first, under their declared path.
616
+ batchSql = [
617
+ ...(prerequisites.length > 0 ? ['SET LOCAL check_function_bodies = off'] : []),
618
+ ...prerequisites.flatMap((prerequisite) => [
619
+ renderStatePrerequisiteGuard(prerequisite),
620
+ ...(prerequisite.searchPath === null ? [] : [renderLocalSearchPath(prerequisite.searchPath)]),
621
+ prerequisite.sql,
622
+ ...(prerequisite.searchPath === null ? [] : [renderLocalSearchPath(originalPath)]),
623
+ ]),
624
+ ...[...dependents].reverse().map((dependent) => dependent.dropSql),
625
+ ...stateBatch,
626
+ ...dependents.flatMap((dependent) => [renderLocalSearchPath(dependent.searchPath ?? originalPath), dependent.sql]),
627
+ ...(dependents.length > 0 ? [renderLocalSearchPath(originalPath)] : []),
628
+ ].join('\n;\n');
603
629
  await transactionRunner(batchSql);
604
630
  // `defHash` is PostgreSQL's canonical deparse, so source text cannot predict it. Read it
605
631
  // after the CREATE through the OPEN transaction, then write provenance before COMMIT.
606
- const reread = async () => new Map((await introspectDerived(borrowedSessionRunner(transactionRunner)))
607
- .objects.map((object) => [object.identity, object]));
632
+ const created: Array<{ identity: string; kind: 'function' | StateDependent['kind']; grantContract: StatePrerequisite['grantContract'];
633
+ provenance: { srcHash: string; bodyHash?: string; kind: string; dropSql?: string } }> = [
634
+ ...prerequisites.map((p) => ({ identity: p.identity, kind: 'function' as const, grantContract: p.grantContract, provenance: p.provenance })),
635
+ ...dependents.map((d) => ({ identity: d.identity, kind: d.kind, grantContract: d.grantContract, provenance: d.provenance })),
636
+ ];
608
637
  let liveByIdentity = await reread();
609
- const grantDelta = (prerequisite: StatePrerequisite) => {
638
+ // A function is a state prerequisite; a re-created dependent is named as the view it is.
639
+ const objectLabel = (object: (typeof created)[number]): string =>
640
+ `${object.kind === 'function' ? 'state prerequisite' : object.kind} ${object.identity}`;
641
+ const grantDelta = (prerequisite: (typeof created)[number]) => {
610
642
  const live = liveByIdentity.get(prerequisite.identity);
611
643
  if (!live || live.grants === undefined) {
612
- throw new Error(`state prerequisite ${prerequisite.identity} has no readable grant catalog; no provenance was written.`);
644
+ throw new Error(`${objectLabel(prerequisite)} has no readable grant catalog; no provenance was written.`);
613
645
  }
614
646
  const contract = prerequisite.grantContract;
615
647
  if (contract.owner && live.owner !== contract.owner) {
616
- throw new Error(`state prerequisite ${prerequisite.identity} owner did not converge to ${contract.owner}; no provenance was written.`);
648
+ throw new Error(`${objectLabel(prerequisite)} owner did not converge to ${contract.owner}; no provenance was written.`);
617
649
  }
618
- const delta = objectGrantDelta({ ...contract, identity: prerequisite.identity, kind: 'function' }, live);
650
+ const delta = objectGrantDelta({ ...contract, identity: prerequisite.identity, kind: prerequisite.kind }, live);
619
651
  if (!delta || delta.columnScoped || delta.suppressed.length > 0) {
620
- throw new Error(`state prerequisite ${prerequisite.identity} grants cannot be verified; no provenance was written.`);
652
+ throw new Error(`${objectLabel(prerequisite)} grants cannot be verified; no provenance was written.`);
621
653
  }
622
654
  return delta.statements;
623
655
  };
624
656
  // CREATE inherits default ACLs, and ALTER OWNER can remove a declared builder grant.
625
657
  // Settle from the actual catalog using the same grant diff as derived reconcile.
626
- const settle = prerequisites.flatMap((prerequisite) => {
658
+ const settle = created.flatMap((prerequisite) => {
627
659
  const statements = grantDelta(prerequisite);
628
660
  const { owner, ownerMode } = prerequisite.grantContract;
629
661
  return statements.length > 0 && owner && ownerMode === 'set-role'
@@ -636,16 +668,16 @@ export async function applyGeneratedStatements(
636
668
  liveByIdentity = await reread();
637
669
  }
638
670
  // A successful REVOKE can be a warning-only no-op. Only a second catalog read proves it.
639
- for (const prerequisite of prerequisites) {
671
+ for (const prerequisite of created) {
640
672
  const remaining = grantDelta(prerequisite);
641
673
  if (remaining.length > 0) {
642
- throw new Error(`state prerequisite ${prerequisite.identity} grants did not converge: still needs ${remaining.join('; ')}. No provenance was written.`);
674
+ throw new Error(`${objectLabel(prerequisite)} grants did not converge: still needs ${remaining.join('; ')}. No provenance was written.`);
643
675
  }
644
676
  }
645
- const provenanceSql = prerequisites.map((prerequisite) => {
677
+ const provenanceSql = created.map((prerequisite) => {
646
678
  const liveObject = liveByIdentity.get(prerequisite.identity);
647
679
  if (!liveObject) {
648
- throw new Error(`state prerequisite ${prerequisite.identity} was created but is not introspectable before commit; provenance was not recorded.`);
680
+ throw new Error(`${objectLabel(prerequisite)} was created but is not introspectable before commit; provenance was not recorded.`);
649
681
  }
650
682
  return renderProvenanceUpsert(
651
683
  prerequisite.identity,