@everystack/cli 0.4.77 → 0.4.79

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.
@@ -85,9 +85,13 @@ export interface ModelTreeCandidate {
85
85
 
86
86
  const short = (ref: string): string => ref.slice(0, 12);
87
87
 
88
+ /** Every git command this module runs is bounded: in a partial clone even `cat-file` and
89
+ * `merge-base` can fetch lazily over the network, and a hang must become a null (refuse-closed). */
90
+ const GIT_TIMEOUT_MS = 60_000;
91
+
88
92
  function git(args: string[], cwd: string): string | null {
89
93
  try {
90
- return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
94
+ return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: GIT_TIMEOUT_MS }).toString();
91
95
  } catch {
92
96
  return null;
93
97
  }
@@ -100,6 +104,44 @@ const defaultLoader: ModelsLoader = async (barrel) => {
100
104
  return models;
101
105
  };
102
106
 
107
+ /**
108
+ * The SQUASHED SUCCESSOR of a witness commit HEAD does not descend from: an ancestor of HEAD that
109
+ * carries the witness's models tree UNCHANGED (same tree oid) and is not itself an ancestor of
110
+ * the witness. Deploy from a PR branch, squash-merge, deploy the next stage — the ledger names the
111
+ * PR commit, main carries its tree under a new sha, and every line of the declaring commit is
112
+ * inside HEAD. Refusing that as a revert is false (consumer cutover, cli 0.4.77, 2026-09-07).
113
+ *
114
+ * The second clause is the whole safety. In an S→T→S episode the pre-episode ancestor carries S's
115
+ * tree too — byte-identical — and a checkout branched there must still be refused: the witness
116
+ * (the re-declaration of S) DESCENDS from that carrier, so the carrier is the state the episode
117
+ * started from, not a successor. A squash commit is never an ancestor of the branch it squashed.
118
+ * Tree identity is exact and cheap (`rev-parse <sha>:<dir>`, no materialization); a state-level
119
+ * recompile is deliberately NOT used here, because it cannot tell those two shapes apart.
120
+ *
121
+ * One carrier that IS an ancestor of the witness still counts: the one with NO models change
122
+ * between it and the witness. That is the app-only PR — the deploy changed no models, the
123
+ * witness's tree is the base tree, and `rev-list --full-history -- <dir>` lists only the base as a
124
+ * carrier (closing-gate review, 2026-09-07). The pre-episode carrier never qualifies: the T
125
+ * episode sits between it and the witness, so `rev-list carrier..witness -- <dir>` is non-empty.
126
+ */
127
+ function squashedSuccessor(witness: string, head: string, modelsDir: string, repoRoot: string): string | null {
128
+ const spec = modelsDir === '.' ? `${witness}^{tree}` : `${witness}:${modelsDir}`;
129
+ const tree = git(['rev-parse', '--verify', '--quiet', spec], repoRoot)?.trim();
130
+ if (!tree) return null;
131
+ const carriers = enumerateModelTrees(modelsDir, repoRoot, { ancestorsOf: head }).find((c) => c.tree === tree)?.commits ?? [];
132
+ const modelsUnchangedBetween = (carrier: string): boolean => {
133
+ const changes = git(['rev-list', `${carrier}..${witness}`, '--', modelsDir], repoRoot);
134
+ return changes !== null && changes.trim() === '';
135
+ };
136
+ // Refuse-closed on BOTH git questions: an ancestry git could not answer is not "not an
137
+ // ancestor" (that would accept the carrier), and a rev-list that failed is not "no changes".
138
+ return carriers.find((c) => {
139
+ const anc = ancestry(c, witness, repoRoot);
140
+ if (anc === null) return false;
141
+ return !anc || modelsUnchangedBetween(c);
142
+ }) ?? null;
143
+ }
144
+
103
145
  /**
104
146
  * Every historical state of the models directory across ALL refs, newest
105
147
  * first, grouped by tree oid. `--full-history` keeps every !TREESAME commit
@@ -132,6 +174,7 @@ export function enumerateModelTrees(
132
174
  cwd,
133
175
  input: specs.join('\n') + '\n',
134
176
  stdio: ['pipe', 'pipe', 'ignore'],
177
+ timeout: GIT_TIMEOUT_MS,
135
178
  }).toString();
136
179
  } catch {
137
180
  return [];
@@ -195,8 +238,9 @@ export function materializeTree(tree: string, dest: string, cwd: string): void {
195
238
  type TreeEval =
196
239
  | { kind: 'declares' }
197
240
  | { kind: 'no' }
198
- | { kind: 'compile-failed'; detail: string }
199
- | { kind: 'unevaluated'; detail: string };
241
+ /** `detail` is the one-line record (`<tree> (at <commit>): <error>`); `error` is the bare cause. */
242
+ | { kind: 'compile-failed'; detail: string; error: string }
243
+ | { kind: 'unevaluated'; detail: string; error: string };
200
244
 
201
245
  interface EvalContext {
202
246
  repoRoot: string;
@@ -225,7 +269,8 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
225
269
  fs.rmSync(dest, { recursive: true, force: true });
226
270
  ctx.materialize(candidate.tree, dest, ctx.repoRoot);
227
271
  } catch (err: any) {
228
- return { kind: 'unevaluated', detail: `${where}: could not extract — ${firstLine(err.message)}` };
272
+ const error = `could not extract — ${firstLine(err.message)}`;
273
+ return { kind: 'unevaluated', detail: `${where}: ${error}`, error };
229
274
  }
230
275
  }
231
276
 
@@ -233,8 +278,9 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
233
278
  // not a failure to read. Every pre-Models repo has such ancestors: the models directory
234
279
  // scaffolded (or present for another reason) before its barrel existed. Letting the loader
235
280
  // throw on the absent file would classify it `compile-failed`, poisoning the verdict to
236
- // UNVERIFIABLE — which sends a never-adopted database to "retry/upgrade" instead of to
237
- // db:adopt. So the missing barrel is decided here, before the loader is ever consulted.
281
+ // UNVERIFIABLE — which sends a never-adopted database to "retry/upgrade" instead of to the
282
+ // first-adoption lane (db:pull → commit → db:plan). So the missing barrel is decided here,
283
+ // before the loader is ever consulted.
238
284
  const barrelPath = path.join(dest, ctx.barrel);
239
285
  if (!fs.existsSync(barrelPath)) return { kind: 'no' };
240
286
 
@@ -259,8 +305,100 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
259
305
  }
260
306
  return declares ? { kind: 'declares' } : { kind: 'no' };
261
307
  } catch (err: any) {
262
- return { kind: 'compile-failed', detail: `${where}: ${firstLine(err.message)}` };
308
+ const error = firstLine(err.message);
309
+ return { kind: 'compile-failed', detail: `${where}: ${error}`, error };
310
+ }
311
+ }
312
+
313
+ /** One tree the walk could not read, with where it sits in history — the refusal names it. */
314
+ interface UnreadableTree {
315
+ candidate: ModelTreeCandidate;
316
+ /** Carried by a commit HEAD descends from — no ref prune removes it. */
317
+ ancestor: boolean;
318
+ error: string;
319
+ }
320
+
321
+ /** The git verb that removes one ref, or null for a ref shape we do not prescribe deleting. */
322
+ export function pruneCommand(ref: string): string | null {
323
+ const head = ref.match(/^refs\/heads\/(.+)$/);
324
+ if (head) return `git branch -D ${head[1]}`;
325
+ const remote = ref.match(/^refs\/remotes\/([^/]+)\/(.+)$/);
326
+ if (remote) return `git push ${remote[1]} --delete ${remote[2]}`;
327
+ const tag = ref.match(/^refs\/tags\/(.+)$/);
328
+ if (tag) return `git tag -d ${tag[1]}`;
329
+ return null;
330
+ }
331
+
332
+ /**
333
+ * The unreadable-tree refusal, built from what the walk KNOWS: each tree, the refs that carry it,
334
+ * whether HEAD descends from it, and what the target's ledger says about who deployed what.
335
+ *
336
+ * Consumer cutover, cli 0.4.77 (2026-09-07): four August trees carried only by one backup branch
337
+ * on origin refused a production apply, and the message led with "upgrade/pin the CLI that last
338
+ * deployed this state" — on a database no CLI had ever deployed (schema_log was empty). The
339
+ * operator reconstructed the carrying ref with `git rev-list --all -- db/models` and a per-tree
340
+ * grep. Every fact in that reconstruction was already in this function's hands.
341
+ *
342
+ * The verdict does not change here; only the account of it. A tree we cannot read may still
343
+ * describe live, so the walk stays refuse-closed; the ledger is a memoir and never decides
344
+ * (decision 7). What the ledger DOES decide is which fix comes first: with no recorded apply,
345
+ * no CLI deployed these trees and "pin the CLI" is false, so bundle + prune leads; with one, pin
346
+ * the CLI only when the recorded commit is itself among the unreadable trees.
347
+ */
348
+ function unreadableRunbook(
349
+ unreadable: readonly UnreadableTree[],
350
+ lastApplied: DescentLedgerRow | undefined,
351
+ probe: GitLineageProbe,
352
+ ): string {
353
+ // Bounded on both axes, because this reason is RECORDED in schema_log and each ref lookup is a
354
+ // full ref walk: at most MAX_TREES trees are named, and a tree's refs are the union over its
355
+ // first MAX_COMMITS carrying commits (a tree is often carried by several commits on several
356
+ // branches — naming one commit's refs under-named the prune; closing-gate review, 2026-09-07).
357
+ const MAX_TREES = 10;
358
+ const MAX_COMMITS = 5;
359
+ const listed = unreadable.slice(0, MAX_TREES);
360
+ const refsOf = new Map<string, string[]>();
361
+ for (const u of listed) {
362
+ const refs = new Set<string>();
363
+ for (const commit of u.candidate.commits.slice(0, MAX_COMMITS)) {
364
+ for (const ref of probe.refsContaining(commit)) if (ref !== 'refs/stash') refs.add(ref);
365
+ }
366
+ refsOf.set(u.candidate.tree, [...refs].sort());
367
+ }
368
+ const lines = listed.map((u) => {
369
+ const refs = refsOf.get(u.candidate.tree)!;
370
+ const carried = refs.length === 0
371
+ ? 'carried by no ref'
372
+ : `carried by ${refs.slice(0, 3).join(', ')}${refs.length > 3 ? ` and ${refs.length - 3} more` : ''}`;
373
+ const where = u.ancestor ? "on your checkout's own history" : 'not an ancestor of your checkout';
374
+ return ` ${short(u.candidate.tree)} at ${short(u.candidate.commits[0])} — ${carried} (${where}) — ${scrubPaths(u.error)}`;
375
+ });
376
+ if (unreadable.length > listed.length) {
377
+ lines.push(` … and ${unreadable.length - listed.length} more unreadable tree(s) not listed — their refs are not in the prune list below; re-run after pruning to see them`);
378
+ }
379
+
380
+ const prunable = listed.filter((u) => !u.ancestor);
381
+ const ancestors = unreadable.filter((u) => u.ancestor);
382
+ const pruneRefs = [...new Set(prunable.flatMap((u) => refsOf.get(u.candidate.tree)!))];
383
+ const pruneCmds = pruneRefs.map(pruneCommand).filter((c): c is string => c !== null);
384
+ const prune = pruneRefs.length === 0
385
+ ? ''
386
+ : `Keep them if you want (git bundle create unreadable-trees.bundle ${pruneRefs.join(' ')}), then prune: ${pruneCmds.join('; ')}; then re-run.`;
387
+ const rewrite = ancestors.length === 0
388
+ ? ''
389
+ : `${ancestors.length === unreadable.length ? 'These trees are' : `${ancestors.length} of them ${ancestors.length === 1 ? 'is' : 'are'}`} on your checkout's own history, which no ref prune removes; the walk needs every ancestor tree readable. Rewriting that history is the git-native remedy — keep it first (git bundle create everything.bundle --all).`;
390
+
391
+ let lead: string;
392
+ if (!lastApplied) {
393
+ lead = 'No everystack apply is recorded on this target, so no CLI deployed these trees.';
394
+ } else {
395
+ const at = lastApplied.gitRef ? `at ${short(lastApplied.gitRef)} ` : '';
396
+ const among = lastApplied.gitRef !== null && unreadable.some((u) => u.candidate.commits.includes(lastApplied.gitRef!));
397
+ lead = `everystack last applied here ${at}on ${shortDate(lastApplied.appliedAt)}.`
398
+ + (among ? ' That commit is among the unreadable trees — pin the CLI that applied it (the version in that commit\'s lockfile) and re-run.' : '');
399
+ if (among) return `${lines.join('\n')}\n${lead}`;
263
400
  }
401
+ return `${lines.join('\n')}\n${[lead, prune, rewrite].filter(Boolean).join(' ')}`;
264
402
  }
265
403
 
266
404
  /**
@@ -296,7 +434,7 @@ const firstLine = summarizeCompileError;
296
434
  * in a monorepo the app (`apps/expo/db/models`) has `apps/expo/node_modules`, and the root
297
435
  * `node_modules` may not carry the package at all (closing-gate review round 4, reproduced). No
298
436
  * `node_modules` anywhere up to the repo root → the repo root itself (a nested checkout still walks
299
- * up to its host's). Never the OS temp dir — that resolves nothing (the db:adopt 0.4.74 bug).
437
+ * up to its host's). Never the OS temp dir — that resolves nothing (the 0.4.74 sandbox-resolution bug).
300
438
  */
301
439
  export function materializeBase(repoRoot: string, modelsAbs: string): string {
302
440
  let dir = path.dirname(modelsAbs);
@@ -331,17 +469,32 @@ function resolveRepoContext(
331
469
  /** `git cat-file -e <ref>^{commit}` — is the commit present in THIS clone (not a shallow gap)? */
332
470
  function commitPresent(ref: string, cwd: string): boolean {
333
471
  try {
334
- execFileSync('git', ['cat-file', '-e', `${ref}^{commit}`], { cwd, stdio: 'ignore' });
472
+ execFileSync('git', ['cat-file', '-e', `${ref}^{commit}`], { cwd, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
335
473
  return true;
336
474
  } catch {
337
475
  return false;
338
476
  }
339
477
  }
340
478
 
341
- /** `git merge-base --is-ancestor <ancestor> <head>` — does HEAD descend from the commit? */
479
+ /**
480
+ * `git merge-base --is-ancestor`, three-valued: true / false / null when git could not answer
481
+ * (a timeout, a missing object, a broken repo). Every caller whose SAFE answer is "not an
482
+ * ancestor" may use {@link isAncestor}; a caller whose safe answer is the OTHER way — the squash
483
+ * guard accepts a carrier that is NOT an ancestor of the witness — must read null as "refuse".
484
+ */
485
+ function ancestry(ancestor: string, head: string, cwd: string): boolean | null {
486
+ try {
487
+ execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
488
+ return true;
489
+ } catch (err: any) {
490
+ return err?.status === 1 ? false : null; // exit 1 = "not an ancestor"; anything else = could not tell
491
+ }
492
+ }
493
+
494
+ /** `git merge-base --is-ancestor <ancestor> <head>` — does HEAD descend from the commit? False when git cannot tell. */
342
495
  function isAncestor(ancestor: string, head: string, cwd: string): boolean {
343
496
  try {
344
- execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore' });
497
+ execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
345
498
  return true;
346
499
  } catch {
347
500
  return false;
@@ -378,6 +531,11 @@ export interface GitLineageProbe {
378
531
  remotes(): string[] | null;
379
532
  /** `git ls-remote --heads --tags <remote>` advertised tip shas; null when the remote is unreachable. */
380
533
  lsRemote(remote: string): string[] | null;
534
+ /**
535
+ * Why the last `lsRemote(remote)` returned null — git's own first stderr line, paths scrubbed —
536
+ * or null when nothing was captured. Optional: a probe without it still names the remote.
537
+ */
538
+ lsRemoteError?(remote: string): string | null;
381
539
  /**
382
540
  * Does this object peel to a commit (`<sha>^{commit}`)? `null` when the object is absent from
383
541
  * the clone. An advertised tip that is present but is a tag on a blob or tree can carry no
@@ -387,9 +545,41 @@ export interface GitLineageProbe {
387
545
  peelsToCommit?(sha: string): boolean | null;
388
546
  }
389
547
 
548
+ /**
549
+ * The runbook for a remote `git ls-remote` could not read. Three things it must do, each learned
550
+ * from a consumer's production cutover (cli 0.4.77, 2026-09-07) where the remote was named `db`
551
+ * and "could not read db" was read as the database being unreachable: name the remote AS a git
552
+ * remote, carry git's own error so the cause is on the surface, and name the verb that clears a
553
+ * dead one — `git remote remove` — next to the one that fixes a live one.
554
+ */
555
+ function unreadableRemote(remote: string, git: GitLineageProbe): string {
556
+ const err = git.lsRemoteError?.(remote);
557
+ return `git remote '${remote}' could not be read: \`git ls-remote --heads --tags ${remote}\` failed${err ? ` — ${err}` : ''}. `
558
+ + `Fix the remote or remove it (git remote remove ${remote}), then re-run.`;
559
+ }
560
+
561
+ /**
562
+ * git's stderr, reduced to what a refusal may carry: the first non-empty line, absolute paths
563
+ * and file:// URLs replaced by `<path>` (the reason is recorded, and a home directory is not
564
+ * part of the diagnosis), capped so a chatty transport cannot flood the surface.
565
+ */
566
+ export function firstErrorLine(stderr: string): string | null {
567
+ const line = stderr.split('\n').map((l) => l.trim()).find(Boolean);
568
+ if (!line) return null;
569
+ return scrubPaths(line).slice(0, 200);
570
+ }
571
+
572
+ /** Absolute paths and file:// URLs replaced by `<path>` — a recorded reason never carries a home directory. */
573
+ export function scrubPaths(s: string): string {
574
+ return s
575
+ .replace(/file:\/\/\S+/g, 'file://<path>')
576
+ .replace(/(^|[\s'"(])\/[^\s'"()]+/g, '$1<path>');
577
+ }
578
+
390
579
  export type LineageProof =
391
580
  | { proven: true; subcase: 'revert-unreachable' | 'unknown-commit-proven-absent' }
392
- | { proven: false; kind: 'shallow' | 'partial' | 'reachable' | 'behind-remote' | 'remote-unreachable' | 'remote-list-failed' | 'no-remotes'; reason: string };
581
+ /** `ref` is set for `reachable`: the ref (or `HEAD`, or a remote name) that still holds the commit. */
582
+ | { proven: false; kind: 'shallow' | 'partial' | 'reachable' | 'behind-remote' | 'remote-unreachable' | 'remote-list-failed' | 'no-remotes'; reason: string; ref?: string };
393
583
 
394
584
  /**
395
585
  * Prove the declaring commit `sha` is reachable from NO ref, local or remote. Refuse-closed: every
@@ -410,10 +600,10 @@ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProo
410
600
  if (present) {
411
601
  const containing = git.refsContaining(sha);
412
602
  if (containing.length > 0) {
413
- return { proven: false, kind: 'reachable', reason: `commit ${short(sha)} is still reachable (${containing[0]}) — revert it in git and re-plan instead of attesting it lost.` };
603
+ return { proven: false, kind: 'reachable', ref: containing[0], reason: `commit ${short(sha)} is still reachable (${containing[0]}) — revert it in git and re-plan instead of attesting it lost.` };
414
604
  }
415
605
  if (git.isAncestorOf(sha, 'HEAD')) {
416
- return { proven: false, kind: 'reachable', reason: `commit ${short(sha)} is still reachable (an ancestor of HEAD) — revert it in git and re-plan instead of attesting it lost.` };
606
+ return { proven: false, kind: 'reachable', ref: 'HEAD', reason: `commit ${short(sha)} is still reachable (an ancestor of HEAD) — revert it in git and re-plan instead of attesting it lost.` };
417
607
  }
418
608
  }
419
609
 
@@ -430,14 +620,14 @@ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProo
430
620
  for (const remote of remotes) {
431
621
  const tips = git.lsRemote(remote);
432
622
  if (tips === null) {
433
- return { proven: false, kind: 'remote-unreachable', reason: `could not read ${remote} to confirm ${short(sha)} is not on it — check connectivity and retry, or fetch the remote first.` };
623
+ return { proven: false, kind: 'remote-unreachable', reason: `cannot confirm ${short(sha)} is not on ${remote}: ${unreadableRemote(remote, git)}` };
434
624
  }
435
625
  for (const tip of tips) {
436
626
  if (!git.objectExists(tip)) {
437
627
  return { proven: false, kind: 'behind-remote', reason: `this clone is behind ${remote} (it advertises ${short(tip)}, which you do not have) — fetch it first; the declaring commit may still be on the remote.` };
438
628
  }
439
629
  if (present && git.isAncestorOf(sha, tip)) {
440
- return { proven: false, kind: 'reachable', reason: `commit ${short(sha)} is still reachable (on ${remote}) — revert it in git and re-plan instead of attesting it lost.` };
630
+ return { proven: false, kind: 'reachable', ref: remote, reason: `commit ${short(sha)} is still reachable (on ${remote}) — revert it in git and re-plan instead of attesting it lost.` };
441
631
  }
442
632
  }
443
633
  }
@@ -464,9 +654,12 @@ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProo
464
654
 
465
655
  /** The real probe: each method is one git command run in `cwd`. */
466
656
  export function realLineageProbe(cwd: string): GitLineageProbe {
657
+ const lsRemoteErrors = new Map<string, string>();
658
+ // A hung remote must become a refusal, not a hang: every probe command is bounded. A timeout
659
+ // throws, so it reads as null — unreachable — and the proof refuses closed as it would offline.
467
660
  const run = (args: string[]): string | null => {
468
661
  try {
469
- return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
662
+ return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: GIT_TIMEOUT_MS }).toString();
470
663
  } catch {
471
664
  return null;
472
665
  }
@@ -489,10 +682,20 @@ export function realLineageProbe(cwd: string): GitLineageProbe {
489
682
  return out === null ? null : out.split('\n').map((l) => l.trim()).filter(Boolean);
490
683
  },
491
684
  lsRemote: (remote) => {
492
- const out = run(['ls-remote', '--heads', '--tags', remote]);
493
- if (out === null) return null;
685
+ // stderr is KEPT here, unlike `run`: the refusal this feeds must say why git could not read
686
+ // the remote, or "could not read db" is the database to whoever reads it.
687
+ let out: string;
688
+ try {
689
+ out = execFileSync('git', ['ls-remote', '--heads', '--tags', remote], { cwd, stdio: ['ignore', 'pipe', 'pipe'], timeout: GIT_TIMEOUT_MS }).toString();
690
+ } catch (e) {
691
+ const stderr = (e as { stderr?: Buffer | string }).stderr?.toString() ?? '';
692
+ lsRemoteErrors.set(remote, firstErrorLine(stderr) ?? firstErrorLine((e as Error).message ?? '') ?? 'no error output');
693
+ return null;
694
+ }
695
+ lsRemoteErrors.delete(remote);
494
696
  return out.split('\n').map((l) => l.split('\t')[0]?.trim()).filter((s): s is string => !!s && s.length >= 7);
495
697
  },
698
+ lsRemoteError: (remote) => lsRemoteErrors.get(remote) ?? null,
496
699
  peelsToCommit: (sha) => {
497
700
  if (!commitPresent(sha, cwd) && run(['cat-file', '-e', sha]) === null) return null; // absent
498
701
  return run(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]) !== null;
@@ -550,8 +753,11 @@ export type ProveVerdict =
550
753
  | { status: 'fresh-target' }
551
754
  /** Not a git checkout — the rule cannot be verified here. */
552
755
  | { status: 'no-git' }
553
- /** Fast-forward proven — via a ledger witness or an ancestor recompile. */
554
- | { status: 'proven'; via: 'ledger' | 'recompile'; commit: string }
756
+ /** Fast-forward proven — via a ledger witness, its squashed successor, or an ancestor recompile.
757
+ * For `squash`, `commit` is the CARRIER (an ancestor of HEAD holding the deploying commit's
758
+ * models tree) and `witness` is the deploying commit the ledger names, which HEAD does not
759
+ * descend from — both are surfaced and the weaker proof is recorded in schema_log. */
760
+ | { status: 'proven'; via: 'ledger' | 'squash' | 'recompile'; commit: string; witness?: string }
555
761
  /** The newest ledger witness is NOT an ancestor of HEAD — applying would revert merged work. */
556
762
  | { status: 'revert'; commit: string; appliedAt: string; reason: string }
557
763
  /** The witness names a commit this clone does not have (shallow) — fetch, do not guess. */
@@ -625,7 +831,7 @@ export function proveCloneCurrent(git: GitLineageProbe): { current: true } | { c
625
831
  for (const remote of remotes) {
626
832
  const tips = git.lsRemote(remote);
627
833
  if (tips === null) {
628
- return { current: false, reason: `could not read ${remote} to confirm the clone is current — check connectivity and retry, or fetch the remote first.` };
834
+ return { current: false, reason: `cannot confirm the clone is current: ${unreadableRemote(remote, git)}` };
629
835
  }
630
836
  for (const tip of tips) {
631
837
  // refs/stash is excluded from the scan (enumerateModelTrees), so a tip contained ONLY by a
@@ -705,14 +911,17 @@ export async function proveDescent(
705
911
  if (isAncestor(r.gitRef, head, repoRoot)) {
706
912
  return { status: 'proven', via: 'ledger', commit: r.gitRef };
707
913
  }
914
+ const squash = squashedSuccessor(r.gitRef, head, modelsDir, repoRoot);
915
+ if (squash) return { status: 'proven', via: 'squash', commit: squash, witness: r.gitRef };
708
916
  return {
709
917
  status: 'revert',
710
918
  commit: r.gitRef,
711
919
  appliedAt: r.appliedAt,
712
920
  reason:
713
921
  `your checkout is behind the branch that last deployed this state — commit ${short(r.gitRef)} ` +
714
- `(applied ${shortDate(r.appliedAt)}) is NOT an ancestor of HEAD. Applying now would revert ` +
715
- 'that merged work. Pull the deploying branch, rebase onto it, and re-plan.',
922
+ `(applied ${shortDate(r.appliedAt)}) is NOT an ancestor of HEAD, and no ancestor of HEAD carries ` +
923
+ 'its models tree as a squashed successor. Applying now would revert that merged work. ' +
924
+ 'Pull the deploying branch, rebase onto it, and re-plan.',
716
925
  };
717
926
  }
718
927
 
@@ -751,37 +960,39 @@ export async function proveDescent(
751
960
 
752
961
  const compileFailures: string[] = [];
753
962
  const unevaluated: string[] = [];
963
+ const unreadable: UnreadableTree[] = [];
964
+ const probe = opts.probe ?? realLineageProbe(repoRoot);
965
+ const lastApplied = ordered.find((r) => isAppliedOutcome(r.outcome));
754
966
  const unverifiable = (scope: string, scanned: number): ProveVerdict => {
755
967
  const notes: string[] = [];
756
968
  if (compileFailures.length > 0) notes.push(`${compileFailures.length} would not compile under this CLI`);
757
969
  if (unevaluated.length > 0) notes.push(`${unevaluated.length} could not be extracted`);
758
- // Show the FIRST actual error: "Cannot find module '@everystack/model'" is a resolution
759
- // problem with a different fix than "an older tree that no longer builds", and a message that
760
- // hides it sends the operator to upgrade a CLI that is not the cause.
761
- const first = compileFailures[0] ?? unevaluated[0];
970
+ // Every unreadable tree is NAMED — with the refs that carry it, its place in history, and its
971
+ // own error ("Cannot find module '@everystack/model'" is a resolution problem with a different
972
+ // fix than "an older tree that no longer builds") — and the runbook leads with the fix the
973
+ // ledger supports. See unreadableRunbook for the incident that shaped this.
762
974
  return {
763
975
  status: 'unverifiable',
764
976
  reason:
765
977
  `cannot verify the fast-forward rule — of ${scanned} ${scope} model state(s), ` +
766
978
  `${notes.join(' and ')}, and none of the readable ones declares the live state. This is a ` +
767
979
  'limit of THIS run (an older tree that no longer builds, or a failed extraction), not ' +
768
- 'evidence about the database — it is NOT drift. Upgrade/pin the CLI that last deployed ' +
769
- `this state, or prune the branch that carries the unreadable tree, and re-run.${first ? ` First failure: ${first}` : ''}`,
980
+ `evidence about the database — it is NOT drift. Unreadable:\n${unreadableRunbook(unreadable, lastApplied, probe)}`,
770
981
  unevaluated,
771
982
  compileFailures,
772
983
  };
773
984
  };
985
+ const record = (candidate: ModelTreeCandidate, result: Extract<TreeEval, { error: string }>, ancestor: boolean): void => {
986
+ (result.kind === 'unevaluated' ? unevaluated : compileFailures).push(result.detail);
987
+ unreadable.push({ candidate, ancestor, error: result.error });
988
+ };
774
989
 
775
990
  let others: ModelTreeCandidate[] = [];
776
991
  try {
777
992
  for (const candidate of candidates) {
778
993
  const result = await evaluateTree(candidate, live, evalCtx);
779
- if (result.kind === 'unevaluated') {
780
- unevaluated.push(result.detail);
781
- continue;
782
- }
783
- if (result.kind === 'compile-failed') {
784
- compileFailures.push(result.detail);
994
+ if (result.kind === 'unevaluated' || result.kind === 'compile-failed') {
995
+ record(candidate, result, true);
785
996
  continue;
786
997
  }
787
998
  if (result.kind === 'no') continue;
@@ -824,7 +1035,7 @@ export async function proveDescent(
824
1035
  }
825
1036
  // Currency BEFORE the all-refs compile: a clone that is shallow, partial, or behind a remote
826
1037
  // is told to fetch now, not after paying for every historical tree on every ref.
827
- const currency = proveCloneCurrent(opts.probe ?? realLineageProbe(repoRoot));
1038
+ const currency = proveCloneCurrent(probe);
828
1039
  if (!currency.current) {
829
1040
  return {
830
1041
  status: 'unverifiable',
@@ -838,12 +1049,8 @@ export async function proveDescent(
838
1049
  others = all.filter((c) => !seen.has(c.tree));
839
1050
  for (const candidate of others) {
840
1051
  const result = await evaluateTree(candidate, live, evalCtx);
841
- if (result.kind === 'unevaluated') {
842
- unevaluated.push(result.detail);
843
- continue;
844
- }
845
- if (result.kind === 'compile-failed') {
846
- compileFailures.push(result.detail);
1052
+ if (result.kind === 'unevaluated' || result.kind === 'compile-failed') {
1053
+ record(candidate, result, false);
847
1054
  continue;
848
1055
  }
849
1056
  if (result.kind === 'no') continue;
@@ -873,7 +1080,6 @@ export async function proveDescent(
873
1080
  // this is the first adoption of a database everystack never wrote. A dirty-worktree apply is
874
1081
  // still an apply — its git_ref declares nothing (Phase A skips it as a witness), so the state
875
1082
  // it produced was never committed anywhere; the origin says so.
876
- const lastApplied = ordered.find((r) => isAppliedOutcome(r.outcome));
877
1083
  const origin: DriftOrigin = lastApplied
878
1084
  ? { kind: 'hand-edited', commit: lastApplied.gitRef, appliedAt: lastApplied.appliedAt, ...(lastApplied.dirty ? { dirty: true } : {}) }
879
1085
  : { kind: 'never-declared' };
@@ -929,9 +1135,44 @@ export function attestationNote(a: { commit: string; subcase: string }): string
929
1135
  return `descent-attested-lineage-lost (${short(a.commit)}, ${a.subcase})`;
930
1136
  }
931
1137
 
1138
+ /** The recorded note for a squash proof — goes in the apply row's schema_log outcome. */
1139
+ export function squashNote(carrier: string, witness: string): string {
1140
+ return `descent: squash-successor (${short(carrier)} carries the models tree of ${short(witness)}, which HEAD does not descend from)`;
1141
+ }
1142
+
1143
+ /**
1144
+ * Does the operator's attestation name this commit? Exact, or a hex prefix of at least 7
1145
+ * characters — compared against the ONE commit the refusal named, so there is nothing for a
1146
+ * prefix to be ambiguous with. The refusal prints the 12-character form, and an operator who
1147
+ * pastes it back is naming that commit. A strict string compare refused
1148
+ * "42a3b97af246 does not match (42a3b97af246)" — two identical strings — because the ledger holds
1149
+ * 40 characters and the refusal printed 12 (consumer cutover, cli 0.4.77, 2026-09-07).
1150
+ */
1151
+ export function attestsCommit(attestation: string, commit: string): boolean {
1152
+ const a = attestation.trim().toLowerCase();
1153
+ if (a === commit.toLowerCase()) return true;
1154
+ return a.length >= 7 && /^[0-9a-f]+$/.test(a) && commit.toLowerCase().startsWith(a);
1155
+ }
1156
+
1157
+ /**
1158
+ * Resolve an attestation through git before it is compared: a prefix, a tag, a branch, or a
1159
+ * `HEAD~n` becomes the full sha. A value git cannot resolve (the commit is absent from this clone —
1160
+ * the `unknown-commit-proven-absent` subcase, or a typo) passes through unchanged so the prefix
1161
+ * compare above still gets its chance and a mismatch names what was given.
1162
+ */
1163
+ export function resolveAttestation(attestation: string, cwd: string): string {
1164
+ const full = git(['rev-parse', '--verify', '--quiet', `${attestation.trim()}^{commit}`], cwd)?.trim();
1165
+ return full && /^[0-9a-f]{40,64}$/.test(full) ? full : attestation;
1166
+ }
1167
+
932
1168
  export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyContext): DescentApplyDecision {
933
1169
  switch (verdict.status) {
934
1170
  case 'proven':
1171
+ // A squash proof rests on models-tree identity, not ancestry of the deploying commit — the
1172
+ // record says so, exactly as an honoured attestation and a proven drift leave their trace.
1173
+ return verdict.via === 'squash' && verdict.witness
1174
+ ? { ok: true, note: squashNote(verdict.commit, verdict.witness) }
1175
+ : { ok: true };
935
1176
  case 'fresh-target':
936
1177
  return { ok: true };
937
1178
 
@@ -969,8 +1210,8 @@ export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyConte
969
1210
  // construction, so the lineage-lost attestation can never be honoured here — the proof refuses
970
1211
  // it as reachable. The escape is git-native: merge or rebase.
971
1212
  if (!ctx.attestation) return { ok: false, reason: verdict.reason };
972
- if (ctx.attestation !== verdict.commit) {
973
- return { ok: false, reason: `--attest-lineage-lost ${short(ctx.attestation)} does not match the commit that declares this state (${short(verdict.commit)}) — drop the flag; that commit is reachable, so merge or rebase instead.` };
1213
+ if (!attestsCommit(ctx.attestation, verdict.commit)) {
1214
+ return { ok: false, reason: `--attest-lineage-lost ${ctx.attestation} does not match the commit that declares this state (${verdict.commit}) — drop the flag; that commit is reachable, so merge or rebase instead.` };
974
1215
  }
975
1216
  const proof = ctx.proveLineage(verdict.commit);
976
1217
  return proof.proven
@@ -988,10 +1229,11 @@ export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyConte
988
1229
  reason: `${verdict.reason} If that commit is gone for good (its branch was rebased away or deleted), attest it: --attest-lineage-lost ${short(verdict.commit)}${ctx.destructive ? ' --confirm' : ''}.`,
989
1230
  };
990
1231
  }
991
- if (ctx.attestation !== verdict.commit) {
1232
+ if (!attestsCommit(ctx.attestation, verdict.commit)) {
1233
+ // Both in FULL: a short form on both sides once printed two identical strings as a mismatch.
992
1234
  return {
993
1235
  ok: false,
994
- reason: `--attest-lineage-lost ${short(ctx.attestation)} does not match the commit that declared this state (${short(verdict.commit)}) — attest the exact commit the refusal names, or drop the flag.`,
1236
+ reason: `--attest-lineage-lost ${ctx.attestation} does not match the commit that declared this state (${verdict.commit}) — attest the exact commit the refusal names, or drop the flag.`,
995
1237
  };
996
1238
  }
997
1239
  const proof = ctx.proveLineage(verdict.commit);
package/src/cli/index.ts CHANGED
@@ -11,7 +11,6 @@ import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand, dbDocto
11
11
  import { dbAuthzPullCommand, dbAuthzDiffCommand, dbAuthzTestCommand, dbAuthzOwnerCommand, dbAuthzReportCommand } from './commands/db-authz';
12
12
  import { dbGenerateCommand } from './commands/db-generate';
13
13
  import { dbPullCommand } from './commands/db-pull';
14
- import { dbAdoptCommand } from './commands/db-adopt';
15
14
  import { dbReconcileCommand } from './commands/db-reconcile';
16
15
  import { dbRefreshCommand } from './commands/db-refresh';
17
16
  import { dbFingerprintCommand } from './commands/db-fingerprint';
@@ -186,9 +185,6 @@ async function main() {
186
185
  case 'db:pull':
187
186
  await dbPullCommand(flags);
188
187
  break;
189
- case 'db:adopt':
190
- await dbAdoptCommand(flags);
191
- break;
192
188
  case 'db:reconcile':
193
189
  await dbReconcileCommand(flags);
194
190
  break;
@@ -368,9 +364,8 @@ Usage:
368
364
  everystack db:export --schema <name> [--stage <name> | --database-url <url> [--out <file.dump>]] [--models <barrel>] Schema-scoped pg_dump artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp). --stage dumps the stage's private DB via the ops Lambda → S3; --database-url (explicit flag, never the env) dumps a reachable DB to a local .dump + .meta.json — the build-locally → publish → swap on-ramp
369
365
  everystack db:swap --schema <name> --from <artifact.dump | artifact-id> [--stage <name> --direct | --database-url <url>] --confirm [--fingerprint <hash>] [--snapshot physical|logical|none] [--snapshot-ref <id>] [--rebuild-derived] [--dump-build <file.json>] Land a schema artifact atomically: fingerprint gate → pre-flight refusals → CONFIRMED snapshot → restore into <schema>_incoming (COPY-safe rewrite) → build the paired derived layer → one txn (drop+rename+recreate app→schema FKs, re-apply authz + schema USAGE) → assertions → drop retiring. Refresh-free; app.* untouched; the derived layer is never absent. DESTRUCTIVE. The venue is EXPLICIT — DATABASE_URL in the env is refused, and --stage requires --direct (a multi-GB restore exceeds the ops-Lambda 900s clock). The rollback point defaults to a PHYSICAL RDS snapshot on a stage that exposes databaseInstanceId (no locks, no pg_dump contending with the restore) and a WAITED logical db:backup otherwise; a bare --database-url refuses without --snapshot-ref <id> or --snapshot none. docs/schema-swap.md
370
366
  everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
371
- everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --govern-roles <r1,r2> (with --abilities live) transcribes the named FOREIGN roles' grants into privileges — the models then OWN them, so a fresh build recreates them; required for migration deletion when the migrations created grants to an ops/connection role. Explicit only (a re-pull must not silently govern), complete-or-refuse (a listed role holding a column-scoped grant fails the pull before anything is written). --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
367
+ everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models — THE on-ramp for an existing database: commit what it renders, then db:plan --stage <s>. EMPTY plan = adopted as it is (that commit declares the database; every later db:apply descends from it). NON-EMPTY plan = the hardening edge: review it, db:apply --plan <f> --stage <s> — no commit declares the database yet, so the reviewed plan is the ORIGIN. Render from the stage, never from a local restore of a dump. --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --govern-roles <r1,r2> (with --abilities live) transcribes the named FOREIGN roles' grants into privileges — the models then OWN them, so a fresh build recreates them; required for migration deletion when the migrations created grants to an ops/connection role. Explicit only (a re-pull must not silently govern), complete-or-refuse (a listed role holding a column-scoped grant fails the pull before anything is written). --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
372
368
  Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
373
- everystack db:adopt [--stage <name> | --database-url <url>] [--schema public] [--out db/models] [--govern-roles r1,r2] First-time adoption of a pre-Models database AS IT IS, BY PROOF. A production DB that never went through Models has no committed ancestor declaring it; db:adopt is the on-ramp that makes one. It renders field() Models from the live DB (the same renderers db:pull uses, --abilities live), compiles them back, and finalizes the baseline ONLY when the compiled Models declare the live state EXACTLY (predicted == governed live — the same declares-test descent and db:check use). On success it writes --out (default db/models) and prints the \`chore(db): adopt live baseline <fp>\` commit — that commit legitimately declares the live state, so a later db:apply descends for real. PROVE not attest: there is NO bypass flag. When the round-trip is not exact, adoption REFUSES, names the surfaces that diverged, prints the edge, and writes NOTHING — that is the HARDENING case (the Models vocabulary cannot declare what is live, e.g. a sequence grant with no INSERT behind it), and it needs no adoption commit: commit the Models you intend, db:plan --stage, db:apply --stage. No commit declares the database yet, so db:apply PROVES that (proven drift) and treats the reviewed plan as the ORIGIN. Commit AND push the baseline before db:apply — an unpushed baseline is an unknown commit to teammates and the ops Lambda.
374
369
  everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
375
370
  everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--only a,b] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
376
371
  everystack db:refresh [--stage <name> | --database-url <url> | --direct] [--only a,b] [--verify-nonempty] [--list] Refresh the declared materialized views in dependency order, credential-free. --stage runs the whole refresh in the ops Lambda on the operator connection (no URL on the operator's machine — the data-lane twin of the reconcile lane); --database-url refreshes over a local connection (dev); --stage <name> --direct resolves the stage's operator connection from its ops Lambda (credential never on argv) and refreshes CLI-side on an UNBOUNDED clock, for a matview set that exceeds the ops-Lambda 900s limit; --only refreshes a named subset (full identity or bare name); --verify-nonempty gates on populated-but-zero-rows matviews (the dark-panel check — a scoped EXISTS on just what was refreshed, so a promotion script gets the gate with no read authority) and FAILS naming any empty; --list previews the order without connecting. Plain REFRESH (ACCESS EXCLUSIVE); fail-fast, idempotent to re-run.