@everystack/cli 0.4.93 → 0.4.95

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,31 @@ 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.95] - 2026-09-26
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ - descent reads schema_log and git ancestry, never historical models trees — no OOM, no minutes; when live has moved, the last recorded apply's commit must be in your history, and the schema_log note reads 'descent: drift-origin ...'
12
+ - a destructive db:apply that records no git commit is refused
13
+
14
+ ### Fixed
15
+
16
+ - read a schema_log written before fp_version and dirty existed
17
+
18
+ ## [0.4.94] - 2026-09-24
19
+
20
+ ### BREAKING CHANGES
21
+
22
+ - db:exec refuses to write the everystack schema
23
+
24
+ ### Fixed
25
+
26
+ - --baseline grant-checks what it adopts
27
+ - plain output names every grantee of a multi-grantee grant
28
+ - db:exec's DML-only guard sees rules, triggers, defaults, event triggers, extensions
29
+ - db:exec guard round 2 — deferred triggers, inherited ledgers, track_counts, search_path
30
+ - db:exec guard round 3 — shadowed catalogs, held cursors, re-deferred triggers
31
+
7
32
  ## [0.4.93] - 2026-09-24
8
33
 
9
34
  ### BREAKING CHANGES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.93",
3
+ "version": "0.4.95",
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>",
@@ -42,7 +42,7 @@ import {
42
42
  type QueryRunner,
43
43
  type AuthzContract,
44
44
  } from './authz-contract';
45
- import { INTROSPECTION_SESSION, rowsOrEmpty, type SessionRunner } from './session';
45
+ import { INTROSPECTION_SESSION, isSessionError, rowsOrEmpty, type SessionResult, type SessionRunner } from './session';
46
46
  import {
47
47
  introspectSchema,
48
48
  assembleSchema,
@@ -69,7 +69,7 @@ import {
69
69
  import { validationPendingReason, pendingValidationsAreSafe } from './apply-refusals';
70
70
  import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
71
71
  import { checkPlanPrecondition, planHash, defaultAclHash, type EdgePlan } from './edge-plan';
72
- import type { DescentLedgerRow } from './git-descent';
72
+ import type { DescentLedgerRow, LedgerRead } from './git-descent';
73
73
 
74
74
  /**
75
75
  * CALLER CONTRACT (fail-closed protection lives here, not inside executeApplyPlan):
@@ -108,6 +108,8 @@ export interface ApplyPlanOptions {
108
108
  * degrades toward REVERT/refusal, never a false pass — and the server re-verifies the lock
109
109
  * in its own one-session read. */
110
110
  ledger: DescentLedgerRow[];
111
+ /** What the same read proved about the ledger — absent, present (with counts), or unreadable. */
112
+ ledgerRead: LedgerRead;
111
113
  }) => Promise<{ ok: true } | { ok: false; reason: string }>;
112
114
  /**
113
115
  * Destructive authority (brick 9, decisions 11+12): called only when the
@@ -141,8 +143,8 @@ export interface ApplyPlanOptions {
141
143
  now?: () => number;
142
144
  /**
143
145
  * A note for the schema_log outcome (`applied — <note>`) carrying what descent decided: a
144
- * honoured `--attest-lineage-lost` waiver, or a PROVEN DRIFT origin (`descent: drift-origin
145
- * proven (never-declared | hand-edited since … | applied from a DIRTY worktree at …)`) — the record that makes this row the origin
146
+ * honoured `--attest-lineage-lost` waiver, or a DRIFT origin (`descent: drift-origin
147
+ * never-declared (…) | hand-edited since … | applied from a DIRTY worktree at …`) — the record that makes this row the origin
146
148
  * every later apply descends from. A getter, because the decision is made inside `checkDescent`
147
149
  * (which runs before the write) — the getter reads it at write time.
148
150
  */
@@ -257,6 +259,7 @@ export async function liveBaseFingerprint(session: SessionRunner): Promise<LiveB
257
259
  /** The live base fingerprint AND the descent ledger, read in ONE session. */
258
260
  export interface LiveBaseAndLedger extends LiveBaseFingerprint {
259
261
  ledger: DescentLedgerRow[];
262
+ ledgerRead: LedgerRead;
260
263
  }
261
264
 
262
265
  /**
@@ -270,12 +273,85 @@ export interface LiveBaseAndLedger extends LiveBaseFingerprint {
270
273
  * silently dropped every annotated apply from the ledger, which for a drift origin defeats the
271
274
  * entire point of recording it.
272
275
  */
273
- export const SCHEMA_LOG_WITNESS_SQL = `SELECT id, to_fingerprint, git_ref, fp_version, dirty, applied_at, outcome
274
- FROM everystack.schema_log
275
- WHERE kind = 'state apply' AND outcome LIKE 'applied%' AND to_fingerprint IS NOT NULL
276
+ //
277
+ // `fp_version` and `dirty` are read through `to_jsonb` because a ledger written before they existed
278
+ // lacks the columns until the next apply adds them (ENSURE_RECONCILER_SQL). Such rows read NULL /
279
+ // false, which descent already skips as witnesses; naming the columns made the whole read fail.
280
+ const LEDGER_COLUMNS = `id, to_fingerprint, git_ref,
281
+ (to_jsonb(s) ->> 'fp_version')::int AS fp_version,
282
+ COALESCE((to_jsonb(s) ->> 'dirty')::boolean, false) AS dirty,
283
+ applied_at, outcome`;
284
+
285
+ export const SCHEMA_LOG_WITNESS_SQL = `SELECT ${LEDGER_COLUMNS}
286
+ FROM everystack.schema_log s
287
+ WHERE kind = 'state apply' AND outcome LIKE 'applied%'
276
288
  ORDER BY id DESC
277
289
  LIMIT 200`;
278
290
 
291
+ /**
292
+ * The newest successful apply that recorded a commit — the row descent's ancestry check anchors on.
293
+ * Read on its own, uncapped, because 200 newer no-git applies would push it out of the witness set
294
+ * and turn a REVERT into a drift. Ordered by id, never applied_at: ids survive a dump restore, and
295
+ * clocks on different containers do not agree.
296
+ */
297
+ export const SCHEMA_LOG_ANCHOR_SQL = `SELECT ${LEDGER_COLUMNS}
298
+ FROM everystack.schema_log s
299
+ WHERE kind = 'state apply' AND outcome LIKE 'applied%' AND git_ref IS NOT NULL
300
+ ORDER BY id DESC
301
+ LIMIT 1`;
302
+
303
+ /** A query that cannot fail on a missing ledger — so "absent" is never a read error in disguise. */
304
+ export const SCHEMA_LOG_PRESENT_SQL = `SELECT to_regclass('everystack.schema_log') IS NOT NULL AS present`;
305
+
306
+ /** Every successful state apply and every recorded refusal — ALL rows, not the capped witness set. */
307
+ export const SCHEMA_LOG_COUNTS_SQL = `SELECT
308
+ count(*) FILTER (WHERE kind = 'state apply' AND outcome LIKE 'applied%')::int AS applied,
309
+ count(*) FILTER (WHERE kind = 'state refusal')::int AS refusals
310
+ FROM everystack.schema_log`;
311
+
312
+ const LEDGER_STATEMENTS = [
313
+ { sql: SCHEMA_LOG_WITNESS_SQL, allowFailure: true },
314
+ { sql: SCHEMA_LOG_PRESENT_SQL, allowFailure: true },
315
+ { sql: SCHEMA_LOG_COUNTS_SQL, allowFailure: true },
316
+ { sql: SCHEMA_LOG_ANCHOR_SQL, allowFailure: true },
317
+ ];
318
+
319
+ /**
320
+ * The witness rows and what the read proved about the ledger. A failed read is UNREADABLE, never
321
+ * empty: only a clean read may say no apply landed, or that none recorded a commit.
322
+ */
323
+ export function ledgerFromSlots(
324
+ witness: SessionResult | undefined,
325
+ present: SessionResult | undefined,
326
+ counts: SessionResult | undefined,
327
+ anchor: SessionResult | undefined,
328
+ ): { ledger: DescentLedgerRow[]; ledgerRead: LedgerRead } {
329
+ // Rows that were read are always kept: a witness does not become less true because a sibling
330
+ // read failed. Only the claims "no apply landed" and "none anchors" need every read to succeed.
331
+ const ledger = mapLedgerRows(rowsOrEmpty(witness));
332
+ for (const row of mapLedgerRows(rowsOrEmpty(anchor))) {
333
+ if (!ledger.some((r) => r.id === row.id)) ledger.push(row);
334
+ }
335
+ const unreadable = (slot: SessionResult | undefined, what: string) => ({
336
+ ledger,
337
+ ledgerRead: { kind: 'unreadable' as const, reason: isSessionError(slot) ? slot.message : `no ${what} answer` },
338
+ });
339
+ if (!Array.isArray(present) || present.length !== 1) return unreadable(present, 'presence');
340
+ if (present[0].present !== true) return { ledger, ledgerRead: { kind: 'absent' } };
341
+ if (!Array.isArray(witness)) return unreadable(witness, 'witness');
342
+ if (!Array.isArray(counts) || counts.length !== 1) return unreadable(counts, 'count');
343
+ if (!Array.isArray(anchor)) return unreadable(anchor, 'anchor');
344
+ return {
345
+ ledger,
346
+ ledgerRead: {
347
+ kind: 'present',
348
+ applied: Number(counts[0].applied),
349
+ refusals: Number(counts[0].refusals),
350
+ anchored: anchor.length > 0,
351
+ },
352
+ };
353
+ }
354
+
279
355
  function mapLedgerRows(rows: any[]): DescentLedgerRow[] {
280
356
  return rows.map((r) => ({
281
357
  id: String(r.id),
@@ -288,13 +364,10 @@ function mapLedgerRows(rows: any[]): DescentLedgerRow[] {
288
364
  }));
289
365
  }
290
366
 
291
- /**
292
- * Just the descent ledger, over one session — the stage lane already has the live state from
293
- * its double read, and needs only the witness rows to prove the fast-forward CLI-side.
294
- */
295
- export async function readDescentLedger(session: SessionRunner): Promise<DescentLedgerRow[]> {
296
- const [rows] = await session([{ sql: SCHEMA_LOG_WITNESS_SQL, allowFailure: true }], INTROSPECTION_SESSION);
297
- return mapLedgerRows(rowsOrEmpty(rows));
367
+ /** The witness rows AND what the read proved about the ledger, in one session — what descent is handed. */
368
+ export async function readDescentLedgerRead(session: SessionRunner): Promise<{ ledger: DescentLedgerRow[]; ledgerRead: LedgerRead }> {
369
+ const [witness, present, counts, anchor] = await session(LEDGER_STATEMENTS, INTROSPECTION_SESSION);
370
+ return ledgerFromSlots(witness, present, counts, anchor);
298
371
  }
299
372
 
300
373
  /**
@@ -314,7 +387,7 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
314
387
  // The default-privilege posture the plan's `defaultAclHash` is checked against — read in
315
388
  // the SAME session as the lock's state, so the two gates describe one instant.
316
389
  DEFAULT_ACL_SQL,
317
- { sql: SCHEMA_LOG_WITNESS_SQL, allowFailure: true },
390
+ ...LEDGER_STATEMENTS,
318
391
  ],
319
392
  INTROSPECTION_SESSION,
320
393
  );
@@ -331,8 +404,8 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
331
404
  functions: (results[13] as any[]).map(contractFunctionRow),
332
405
  defaultAcls: results[14] as any[],
333
406
  });
334
- const ledger = mapLedgerRows(rowsOrEmpty(results[15]));
335
- return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract, ledger };
407
+ const { ledger, ledgerRead } = ledgerFromSlots(results[15], results[16], results[17], results[18]);
408
+ return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract, ledger, ledgerRead };
336
409
  }
337
410
 
338
411
  /** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
@@ -342,7 +415,7 @@ export async function executeApplyPlan(
342
415
  plan: EdgePlan,
343
416
  options: ApplyPlanOptions = {},
344
417
  ): Promise<ApplyPlanResult> {
345
- const { hash: live, snapshot: before, contract: beforeAuthz, ledger } = await liveBaseAndLedger(session);
418
+ const { hash: live, snapshot: before, contract: beforeAuthz, ledger, ledgerRead } = await liveBaseAndLedger(session);
346
419
 
347
420
  // The endpoint is FULL (it carries the ungoverned live authz through), so it is compared against
348
421
  // the RAW live hash directly — the same flavor as `from`. A plan without that flavor predates
@@ -381,8 +454,16 @@ export async function executeApplyPlan(
381
454
  return { status: 'refused', liveFingerprint: live, reason };
382
455
  }
383
456
 
457
+ // A destructive apply must record the commit it came from. Descent anchors on the newest apply
458
+ // WITH a commit, so a destructive row without one would be invisible to the next ancestry check.
459
+ if (plan.destructive > 0 && (options.gitRef ?? plan.gitRef) == null) {
460
+ const reason = 'a destructive apply must record the commit it came from, and this one has none — run db:plan and db:apply from the git checkout that declares your models.';
461
+ await recordRefusal(runner, plan, live, 'fast-forward rule', reason, options);
462
+ return { status: 'descent-refused', liveFingerprint: live, reason };
463
+ }
464
+
384
465
  if (options.checkDescent) {
385
- const descent = await options.checkDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live, ledger });
466
+ const descent = await options.checkDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live, ledger, ledgerRead });
386
467
  if (!descent.ok) {
387
468
  await recordRefusal(runner, plan, live, 'fast-forward rule', descent.reason, options);
388
469
  return { status: 'descent-refused', liveFingerprint: live, reason: descent.reason };
@@ -37,15 +37,16 @@ import { planHash, PLAN_VERSION, APPLY_CONTRACT_VERSION, declaredGranteesOf, typ
37
37
  import {
38
38
  proveDescent,
39
39
  decideDescentApply,
40
- describeDriftOrigin,
40
+ driftNotice,
41
+ newerOffHeadNotice,
41
42
  attestationNote,
42
43
  proveLineageLost,
43
44
  realLineageProbe,
44
45
  resolveAttestation,
45
46
  type DescentLedgerRow,
47
+ type LedgerRead,
46
48
  type LiveState,
47
49
  type ProveVerdict,
48
- type ProveDescentOptions,
49
50
  } from '../git-descent';
50
51
  import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity, type AuthorityVerdict } from '../apply-authority';
51
52
  import { decideSnapshotMode, type SnapshotModeRequest } from '../swap-snapshot';
@@ -68,7 +69,7 @@ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '
68
69
  import { borrowedSessionRunner, type SessionRunner } from '../session';
69
70
  import { resolveConfig, opsFunction } from '../config';
70
71
  import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws';
71
- import { executeApplyPlan, readDescentLedger, type ApplyPlanResult } from '../apply-execute';
72
+ import { executeApplyPlan, readDescentLedgerRead, type ApplyPlanResult } from '../apply-execute';
72
73
  import { estimateOpsRuntimeFit } from '../ops-fit';
73
74
  import { resolveOperatorUrlViaStage } from '../direct-venue';
74
75
  import { withMutationLease, MutationLeaseError } from '../mutation-lease';
@@ -412,24 +413,23 @@ function exitOnCatchUpFailure(report: CatchUpReport): asserts report is { ok: tr
412
413
  */
413
414
  export function describeDescentVerdict(verdict: ProveVerdict, opts: { destructive?: boolean } = {}): { level: 'info' | 'warn'; text: string } | null {
414
415
  switch (verdict.status) {
415
- case 'proven':
416
+ case 'proven': {
416
417
  // A squash proof names BOTH commits: the carrier that is an ancestor of HEAD, and the
417
418
  // deploying commit that is not — announcing the carrier alone as "an ancestor" hid that
418
419
  // the proof rests on tree identity (closing-gate review, 2026-09-07).
419
- return verdict.via === 'squash' && verdict.witness
420
- ? { level: 'info', text: `descent: proven via squash — ${verdict.commit.slice(0, 12)} (an ancestor of HEAD) carries the models tree of the deploying commit ${verdict.witness.slice(0, 12)}, which HEAD does not descend from; recorded in schema_log.` }
421
- : { level: 'info', text: `descent: proven via ${verdict.via} — ${verdict.commit.slice(0, 12)} is an ancestor of HEAD, fast-forward.` };
420
+ const text = verdict.via === 'squash' && verdict.witness
421
+ ? `descent: proven via squash — ${verdict.commit.slice(0, 12)} (an ancestor of HEAD) carries the models tree of the deploying commit ${verdict.witness.slice(0, 12)}, which HEAD does not descend from; recorded in schema_log.`
422
+ : `descent: proven via ${verdict.via} — ${verdict.commit.slice(0, 12)} is an ancestor of HEAD, fast-forward.`;
423
+ const newer = newerOffHeadNotice(verdict);
424
+ return newer ? { level: 'warn', text: `${text} ${newer}` } : { level: 'info', text };
425
+ }
422
426
  case 'fresh-target':
423
427
  return { level: 'info', text: 'descent: fresh target (no tables) — nothing to protect, bootstrapping.' };
424
428
  case 'no-git':
425
429
  // On a destructive plan the decision refuses with its own message; do not double-report.
426
430
  return opts.destructive ? null : { level: 'warn', text: 'descent: not a git checkout — the fast-forward rule cannot be verified here.' };
427
431
  case 'drift':
428
- return {
429
- level: 'warn',
430
- text: `NO COMMIT ON ANY REF DECLARES THIS DATABASE (${describeDriftOrigin(verdict.origin, { human: true })}) — this reviewed plan is the ORIGIN. The fast-forward rule has nothing merged to protect (proven over a full, current clone); the origin is recorded in schema_log, and every later apply descends from it.`,
431
- };
432
- case 'declared-elsewhere':
432
+ return { level: 'warn', text: `${driftNotice(verdict.origin)} This reviewed plan is the ORIGIN; it is recorded in schema_log, and every later apply descends from it.` };
433
433
  case 'revert':
434
434
  case 'unknown-commit':
435
435
  case 'unverifiable':
@@ -458,18 +458,17 @@ export async function proveDescentForApply(input: {
458
458
  modelsPath: string;
459
459
  live: LiveState;
460
460
  ledger: DescentLedgerRow[];
461
+ /** What the ledger read proved. */
462
+ ledgerRead: LedgerRead;
461
463
  destructive: boolean;
462
464
  attestation?: string;
463
465
  /** Where git runs. Default process.cwd(). */
464
466
  cwd?: string;
465
- /** Test seam: the barrel loader and materialize root (jest cannot dynamic-import a .ts barrel).
466
- * Production passes nothing and rides the real loader. */
467
- descent?: Pick<ProveDescentOptions, 'loader' | 'materializeRoot'>;
468
467
  }): Promise<{ descentVerdict: { ok: true } | { ok: false; reason: string }; descentAttested?: string }> {
469
468
  const cwd = input.cwd ?? process.cwd();
470
469
  const verdict = await proveDescent(input.modelsPath, input.live, input.ledger, {
471
470
  ...(input.cwd ? { cwd: input.cwd } : {}),
472
- ...(input.descent ?? {}),
471
+ ledgerRead: input.ledgerRead,
473
472
  });
474
473
  reportDescentVerdict(verdict, { destructive: input.destructive });
475
474
  // The attestation is resolved THROUGH git (a prefix, tag or HEAD~n → the full sha) before it is
@@ -569,12 +568,13 @@ async function applyPlanViaStage(
569
568
  // commit is gone from every ref.
570
569
  const { snapshot, contract } = readPair.state;
571
570
  step("Descent: reading the stage's schema_log ledger...");
572
- const ledger = await readDescentLedger(lambdaSessionRunner(region, fn));
571
+ const { ledger, ledgerRead } = await readDescentLedgerRead(lambdaSessionRunner(region, fn));
573
572
  step("Descent: proving the fast-forward rule against the ledger...");
574
573
  const { descentVerdict, descentAttested } = await proveDescentForApply({
575
574
  modelsPath: resolveModelsPath(flags.models),
576
575
  live: { snapshot, contract },
577
576
  ledger,
577
+ ledgerRead,
578
578
  destructive: plan.destructive > 0,
579
579
  ...(attestLineageLost ? { attestation: attestLineageLost } : {}),
580
580
  });
@@ -1119,12 +1119,13 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1119
1119
  },
1120
1120
  dirty: worktreeDirty(resolveModelsPath(flags.models)),
1121
1121
  descentNote: () => descentAttested,
1122
- checkDescent: async (live: LiveState & { fingerprint: string; ledger: DescentLedgerRow[] }) => {
1122
+ checkDescent: async (live: LiveState & { fingerprint: string; ledger: DescentLedgerRow[]; ledgerRead: LedgerRead }) => {
1123
1123
  step('Descent: proving the fast-forward rule against the schema_log ledger...');
1124
1124
  const result = await proveDescentForApply({
1125
1125
  modelsPath: resolveModelsPath(flags.models),
1126
1126
  live,
1127
1127
  ledger: live.ledger,
1128
+ ledgerRead: live.ledgerRead,
1128
1129
  destructive: plan.destructive > 0,
1129
1130
  ...(attestLineageLost ? { attestation: attestLineageLost } : {}),
1130
1131
  });
@@ -50,10 +50,10 @@ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
50
50
  import { readStageStateTwice } from '../stage-read-consistency';
51
51
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
52
52
  import { mintEdgePlan, planHash, buildPlanSummary, explainCarriedBy, declaredGranteesOf } from '../edge-plan';
53
- import { proveDescent, describeDriftOrigin, proveLineageLost, realLineageProbe, type ProveVerdict, type LineageProof } from '../git-descent';
53
+ import { proveDescent, driftNotice, newerOffHeadNotice, proveLineageLost, realLineageProbe, type ProveVerdict, type LineageProof } from '../git-descent';
54
54
  import { fingerprintLive, divergentSurfaces } from '../schema-fingerprint';
55
55
  import { predictLiveState } from '../edge-plan';
56
- import { readDescentLedger } from '../apply-execute';
56
+ import { readDescentLedgerRead } from '../apply-execute';
57
57
  import { planBackfills, readBackfillLog } from '../backfill';
58
58
  import { readSqlDirIfPresent } from './db-sync';
59
59
  import { currentGitRef, classifyGeneratedStatements, classifyDestructive } from '../state-apply';
@@ -115,25 +115,20 @@ export function describePlanDescent(
115
115
  return `db:apply from this checkout will refuse this plan — ${proof.reason}`;
116
116
  };
117
117
  switch (verdict.status) {
118
- case 'proven':
118
+ case 'proven': {
119
119
  // A squash proof is the one PROVEN shape whose deploying commit is not in the operator's
120
120
  // history — say so where a human reads the plan, as the apply record will.
121
- return verdict.via === 'squash' && verdict.witness
121
+ const lines = verdict.via === 'squash' && verdict.witness
122
122
  ? [`descent: fast-forward proven via a squashed successor — ${verdict.commit.slice(0, 12)} (an ancestor of HEAD) carries the models tree of the deploying commit ${verdict.witness.slice(0, 12)}, which HEAD does not descend from. db:apply will record that.`]
123
123
  : [];
124
+ const newer = newerOffHeadNotice(verdict);
125
+ return newer ? [...lines, newer] : lines;
126
+ }
124
127
  case 'fresh-target':
125
128
  case 'no-git':
126
129
  return [];
127
130
  case 'drift':
128
- return [
129
- `descent: NO COMMIT ON ANY REF DECLARES THIS DATABASE (${describeDriftOrigin(verdict.origin, { human: true })}) — proven over a full, current clone.`,
130
- 'db:apply will treat this reviewed plan as the ORIGIN: it applies, warns, and records the origin in schema_log; every later apply descends from it.',
131
- ];
132
- case 'declared-elsewhere':
133
- return [
134
- `descent: ${verdict.reason}`,
135
- 'db:apply from this checkout will refuse this plan — fetch and merge or rebase onto that commit, then re-mint.',
136
- ];
131
+ return [driftNotice(verdict.origin), 'db:apply will treat this reviewed plan as the ORIGIN: it applies, warns, and records the origin in schema_log; every later apply descends from it.'];
137
132
  case 'revert':
138
133
  return [`descent: ${verdict.reason}`, applyWillDo(verdict.commit)];
139
134
  case 'unknown-commit': {
@@ -149,9 +144,8 @@ export function describePlanDescent(
149
144
  ];
150
145
  }
151
146
  case 'unverifiable':
152
- // NOT "rebase first" — nothing is wrong with the checkout or the database. The search could
153
- // not finish (an unreadable tree, or a clone that is not full and current).
154
- return [`descent: ${verdict.reason}`, 'db:apply from this checkout will refuse this plan until that search completes — fetch or prune as it says, then re-mint.'];
147
+ // NOT "rebase first" — nothing is wrong with the checkout or the database. The ledger could not be read.
148
+ return [`descent: ${verdict.reason}`, 'db:apply will refuse this plan until the ledger can be read — retry, then re-mint.'];
155
149
  default: {
156
150
  // Compile-time exhaustiveness; at runtime (a verdict shape from outside this compile unit)
157
151
  // an empty warning list is the safe value — the caller iterates it.
@@ -433,8 +427,8 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
433
427
  // the very strand apply now PROVES via the ledger and applies, telling the operator the
434
428
  // opposite of what happens. proveDescent over the same ledger apply reads keeps them agreed.
435
429
  if (plan.statements.length > 0) {
436
- const ledger = await readDescentLedger(session);
437
- const verdict = await proveDescent(modelsPath, { snapshot, contract }, ledger, {});
430
+ const { ledger, ledgerRead } = await readDescentLedgerRead(session);
431
+ const verdict = await proveDescent(modelsPath, { snapshot, contract }, ledger, { ledgerRead });
438
432
  // A switch, not an `if (revert || drift)`. That form silently ignored every verdict it
439
433
  // did not name, so a new one would mint a plan with no warning and then be refused at
440
434
  // apply time with no hint of why. Exhaustive here means a new verdict cannot be forgotten.
@@ -54,8 +54,8 @@ import { introspectDerived, type DerivedCatalog } from '../derived-introspect';
54
54
  *
55
55
  * db:pull is the only way an existing database comes in. What happens next is decided by
56
56
  * db:plan, not by a verb: an EMPTY plan means the committed Models declare the database as it
57
- * is (adopted — descent proves the pull commit by recompile, and every later apply descends from
58
- * it); a NON-EMPTY plan is the hardening edge, reviewed and applied as the origin. db:adopt used
57
+ * is (adopted — nothing to apply; the first real change applies as the never-declared origin, and
58
+ * every later apply descends from it); a NON-EMPTY plan is the hardening edge, reviewed and applied as the origin. db:adopt used
59
59
  * to bolt a proof onto this render; the proof was "would the plan be empty", so the verb is gone
60
60
  * (cli 0.4.79) and this is what replaced it. Exported so a test pins the lines and the
61
61
  * refusal-runbook lint reads them.
@@ -509,7 +509,13 @@ export function planReconcile(
509
509
  // Rebaselined objects join the lens: their live catalog rows are as real as a skip's,
510
510
  // and running the checks NOW means a migration converges (grants ride the same apply)
511
511
  // instead of surfacing on the next plan.
512
- const lensSet = new Set([...skipped, ...rebaseline, ...backfill]);
512
+ //
513
+ // Baselined objects join it too. A baseline trusts the STRUCTURE it cannot verify (source text
514
+ // and live deparse live in different hash spaces); the grants it CAN verify, so it never trusts
515
+ // them. Without this, an object restored with default privileges — every function EXECUTE to
516
+ // PUBLIC, SECURITY DEFINER ones included — was adopted with its open grants recorded as fine.
517
+ // (needsBaseline objects stay out: nothing is adopted, so nothing is touched.)
518
+ const lensSet = new Set([...skipped, ...rebaseline, ...backfill, ...baseline]);
513
519
  for (const src of source.objects) {
514
520
  if (!lensSet.has(src.identity)) continue;
515
521
  if (src.kind === 'trigger' || src.kind === 'sql') continue; // no grants, no rewrite edges
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * exec-digest — the catalog content-address that makes db:exec DML-only WITHOUT a role, a
3
3
  * superuser, or a regex. A self-contained SQL query that md5s the schema-relevant catalog
4
- * (pg_class / pg_attribute / pg_constraint / pg_index / pg_proc / pg_type) for the given app
4
+ * (pg_class / pg_attribute / pg_constraint / pg_index / pg_proc / pg_type, plus the behaviour
5
+ * attached to relations — pg_rewrite / pg_trigger / pg_attrdef — and the database-wide
6
+ * pg_event_trigger / pg_extension) for the given app
5
7
  * schemas. db:exec takes this digest before and after the file inside one transaction; any
6
8
  * difference is a schema change — however it was caused (a CREATE/ALTER/DROP, a function, a DO
7
9
  * block, dynamic SQL) — and rolls the whole transaction back.
@@ -66,7 +68,7 @@ FROM (
66
68
  -- but CAN change who may read it is not a DML-only lane. None of the three move under DML.
67
69
  SELECT format('rel:%s.%s:%s:%s:%s:%s:%s', n.nspname, c.relname, c.relkind, c.relpersistence,
68
70
  coalesce(c.relacl::text, ''), c.relrowsecurity, c.relforcerowsecurity) AS line
69
- FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
71
+ FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
70
72
  WHERE ${NS} AND c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f')
71
73
  UNION ALL
72
74
  -- Row policies: a CREATE/ALTER/DROP POLICY rewrites who sees which rows and touches no other
@@ -74,32 +76,32 @@ FROM (
74
76
  SELECT format('pol:%s.%s.%s:%s:%s:%s:%s:%s', n.nspname, c.relname, pol.polname, pol.polcmd,
75
77
  pol.polpermissive,
76
78
  coalesce((SELECT string_agg(r.rolname, ',' ORDER BY r.rolname)
77
- FROM pg_roles r WHERE r.oid = ANY(pol.polroles)), 'PUBLIC'),
79
+ FROM pg_catalog.pg_roles r WHERE r.oid = ANY(pol.polroles)), 'PUBLIC'),
78
80
  coalesce(pg_get_expr(pol.polqual, pol.polrelid), ''),
79
81
  coalesce(pg_get_expr(pol.polwithcheck, pol.polrelid), ''))
80
- FROM pg_policy pol
81
- JOIN pg_class c ON c.oid = pol.polrelid
82
- JOIN pg_namespace n ON n.oid = c.relnamespace
82
+ FROM pg_catalog.pg_policy pol
83
+ JOIN pg_catalog.pg_class c ON c.oid = pol.polrelid
84
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
83
85
  WHERE ${NS}
84
86
  UNION ALL
85
87
  -- Columns: number, type, typmod, not-null — the shape ALTER TABLE moves, DML never.
86
88
  SELECT format('att:%s.%s.%s:%s:%s:%s:%s',
87
89
  n.nspname, c.relname, a.attname, a.attnum, a.atttypid, a.atttypmod, a.attnotnull)
88
- FROM pg_attribute a
89
- JOIN pg_class c ON c.oid = a.attrelid
90
- JOIN pg_namespace n ON n.oid = c.relnamespace
90
+ FROM pg_catalog.pg_attribute a
91
+ JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
92
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
91
93
  WHERE ${NS} AND a.attnum > 0 AND NOT a.attisdropped
92
94
  UNION ALL
93
95
  -- Constraints: the full textual definition (PK/FK/unique/check predicate).
94
96
  SELECT format('con:%s.%s:%s', n.nspname, con.conname, pg_get_constraintdef(con.oid))
95
- FROM pg_constraint con JOIN pg_namespace n ON n.oid = con.connamespace
97
+ FROM pg_catalog.pg_constraint con JOIN pg_catalog.pg_namespace n ON n.oid = con.connamespace
96
98
  WHERE ${NS}
97
99
  UNION ALL
98
100
  -- Indexes: the full textual definition (columns, uniqueness, partial WHERE).
99
101
  SELECT format('idx:%s.%s:%s', n.nspname, ic.relname, pg_get_indexdef(i.indexrelid))
100
- FROM pg_index i
101
- JOIN pg_class ic ON ic.oid = i.indexrelid
102
- JOIN pg_namespace n ON n.oid = ic.relnamespace
102
+ FROM pg_catalog.pg_index i
103
+ JOIN pg_catalog.pg_class ic ON ic.oid = i.indexrelid
104
+ JOIN pg_catalog.pg_namespace n ON n.oid = ic.relnamespace
103
105
  WHERE ${NS}
104
106
  UNION ALL
105
107
  -- Routines: name + arg types + a hash of the body, PLUS proacl (the EXECUTE grants) and
@@ -107,14 +109,57 @@ FROM (
107
109
  -- that opens one, is a privilege change and must trip the guard like any other.
108
110
  SELECT format('proc:%s.%s:%s:%s:%s:%s', n.nspname, p.proname, p.proargtypes::text,
109
111
  md5(coalesce(p.prosrc, '')), coalesce(p.proacl::text, ''), p.prosecdef)
110
- FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
112
+ FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
111
113
  WHERE ${NS}
112
114
  UNION ALL
115
+ -- Behaviour ATTACHED to a relation — no column, constraint or index moves, so each of these
116
+ -- passed the guard until the closing-gate review (2026-09-24) proved it: a RULE on the ledger
117
+ -- could retire every future restore witness at birth. Rules (view bodies included: a view
118
+ -- redefinition is DDL too), user triggers, and column defaults, by their full definitions.
119
+ SELECT format('rule:%s.%s.%s:%s', n.nspname, c.relname, r.rulename, md5(pg_get_ruledef(r.oid)))
120
+ FROM pg_catalog.pg_rewrite r
121
+ JOIN pg_catalog.pg_class c ON c.oid = r.ev_class
122
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
123
+ WHERE ${NS}
124
+ UNION ALL
125
+ SELECT format('trg:%s.%s.%s:%s:%s', n.nspname, c.relname, t.tgname, t.tgenabled, md5(pg_get_triggerdef(t.oid)))
126
+ FROM pg_catalog.pg_trigger t
127
+ JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid
128
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
129
+ WHERE ${NS} AND NOT t.tgisinternal
130
+ UNION ALL
131
+ SELECT format('def:%s.%s.%s:%s', n.nspname, c.relname, a.attname, pg_get_expr(d.adbin, d.adrelid))
132
+ FROM pg_catalog.pg_attrdef d
133
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
134
+ JOIN pg_catalog.pg_class c ON c.oid = d.adrelid
135
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
136
+ WHERE ${NS}
137
+ UNION ALL
138
+ -- Database-wide behaviour: event triggers fire on every DDL, extensions install whole schemas.
139
+ SELECT format('evt:%s:%s:%s:%s', e.evtname, e.evtevent, e.evtenabled, e.evtfoid::regproc::text)
140
+ FROM pg_catalog.pg_event_trigger e
141
+ UNION ALL
142
+ SELECT format('ext:%s:%s', x.extname, x.extversion)
143
+ FROM pg_catalog.pg_extension x
144
+ UNION ALL
145
+ -- TEMP triggers and functions. Temp TABLES stay out (ordinary scripts stage data in them), but a
146
+ -- DML file never needs a trigger or a function of its own — and one on a temp relation is how a
147
+ -- file runs code at COMMIT, after the checks (closing-gate review round 3).
148
+ SELECT format('ttrg:%s.%s:%s', c.relname, t.tgname, md5(pg_get_triggerdef(t.oid)))
149
+ FROM pg_catalog.pg_trigger t
150
+ JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid
151
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
152
+ WHERE n.nspname LIKE 'pg_temp%' AND NOT t.tgisinternal
153
+ UNION ALL
154
+ SELECT format('tproc:%s:%s', p.proname, md5(coalesce(p.prosrc, '')))
155
+ FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
156
+ WHERE n.nspname LIKE 'pg_temp%'
157
+ UNION ALL
113
158
  -- Types: kind + ordered enum labels. CREATE TYPE / ALTER TYPE ADD VALUE move it.
114
159
  SELECT format('type:%s.%s:%s:%s', n.nspname, t.typname, t.typtype,
115
160
  coalesce((SELECT string_agg(e.enumlabel, ',' ORDER BY e.enumsortorder)
116
- FROM pg_enum e WHERE e.enumtypid = t.oid), ''))
117
- FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
161
+ FROM pg_catalog.pg_enum e WHERE e.enumtypid = t.oid), ''))
162
+ FROM pg_catalog.pg_type t JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
118
163
  WHERE ${NS}
119
164
  ) s
120
165
  `.trim();