@everystack/cli 0.4.76 → 0.4.78

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.
@@ -17,15 +17,24 @@
17
17
  * historical state of the models directory across all refs, compile each
18
18
  * unique tree, and ask whether its predicted live endpoint equals the
19
19
  * target's live fingerprint (brick 4's merged prediction, so unmodeled
20
- * brownfield tables ride through the test). A fingerprint no commit
21
- * declares is a DRIFT report and an explicit operator decision — never a
22
- * guess. Identity is the models-directory TREE, not the commit: a
23
- * cherry-picked identical state is the same declared state.
20
+ * brownfield tables ride through the test). Identity is the models-directory
21
+ * TREE, not the commit: a cherry-picked identical state is the same declared
22
+ * state.
23
+ *
24
+ * A fingerprint NO commit on ANY ref declares is DRIFT, and proven drift is the
25
+ * operator's decision the design promised: it PASSES at apply. The rule exists to
26
+ * stop an apply from reverting MERGED work, and when no ref anywhere declares the
27
+ * live state there is no merged work to revert — by construction. The proof is
28
+ * refuse-closed: a ref you do not descend from that DOES declare live refuses by
29
+ * name (`declared-elsewhere`, a teammate got there first); a tree that could not be
30
+ * read, or a clone that is shallow, partial, or behind a remote, is UNVERIFIABLE,
31
+ * never drift. `--force-descent` used to be the operator's drift decision; its
32
+ * retirement (2026-08-26) left drift with no escape at all, and a consumer walked
33
+ * into that dead end on 2026-09-07. The proof replaced the flag (Fable-ruled).
24
34
  */
25
35
 
26
36
  import { execFileSync } from 'node:child_process';
27
37
  import fs from 'node:fs';
28
- import os from 'node:os';
29
38
  import path from 'node:path';
30
39
  import { pathToFileURL } from 'node:url';
31
40
  import type { ModelDescriptor } from '@everystack/model';
@@ -76,9 +85,13 @@ export interface ModelTreeCandidate {
76
85
 
77
86
  const short = (ref: string): string => ref.slice(0, 12);
78
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
+
79
92
  function git(args: string[], cwd: string): string | null {
80
93
  try {
81
- 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();
82
95
  } catch {
83
96
  return null;
84
97
  }
@@ -91,6 +104,44 @@ const defaultLoader: ModelsLoader = async (barrel) => {
91
104
  return models;
92
105
  };
93
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
+
94
145
  /**
95
146
  * Every historical state of the models directory across ALL refs, newest
96
147
  * first, grouped by tree oid. `--full-history` keeps every !TREESAME commit
@@ -107,7 +158,10 @@ export function enumerateModelTrees(
107
158
  // `ancestorsOf`, restrict to the ancestors of one commit — the fallback proof only ever
108
159
  // considers trees the checkout descends from, so a diverged side branch is not even a
109
160
  // candidate (a non-ancestor declaring commit is the ledger's REVERT case, not recompile's).
110
- const range = opts.ancestorsOf ? [opts.ancestorsOf] : ['--all'];
161
+ // `--all` walks refs/stash too. A stash is a WIP snapshot, never a deployed state: one that will
162
+ // not compile would poison the drift proof to unverifiable, and one that happens to match live
163
+ // would be named as a teammate's deployment. Excluded here and in refsContaining, consistently.
164
+ const range = opts.ancestorsOf ? [opts.ancestorsOf] : ['--exclude=refs/stash', '--all'];
111
165
  const out = git(['rev-list', ...range, '--full-history', '--date-order', '--', modelsDir], cwd);
112
166
  if (out === null) return [];
113
167
  const shas = out.trim().split('\n').filter(Boolean);
@@ -120,6 +174,7 @@ export function enumerateModelTrees(
120
174
  cwd,
121
175
  input: specs.join('\n') + '\n',
122
176
  stdio: ['pipe', 'pipe', 'ignore'],
177
+ timeout: GIT_TIMEOUT_MS,
123
178
  }).toString();
124
179
  } catch {
125
180
  return [];
@@ -183,8 +238,9 @@ export function materializeTree(tree: string, dest: string, cwd: string): void {
183
238
  type TreeEval =
184
239
  | { kind: 'declares' }
185
240
  | { kind: 'no' }
186
- | { kind: 'compile-failed'; detail: string }
187
- | { 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 };
188
244
 
189
245
  interface EvalContext {
190
246
  repoRoot: string;
@@ -213,7 +269,8 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
213
269
  fs.rmSync(dest, { recursive: true, force: true });
214
270
  ctx.materialize(candidate.tree, dest, ctx.repoRoot);
215
271
  } catch (err: any) {
216
- return { kind: 'unevaluated', detail: `${where}: could not extract — ${err.message}` };
272
+ const error = `could not extract — ${firstLine(err.message)}`;
273
+ return { kind: 'unevaluated', detail: `${where}: ${error}`, error };
217
274
  }
218
275
  }
219
276
 
@@ -228,6 +285,12 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
228
285
 
229
286
  const evaluate = async (): Promise<boolean> => {
230
287
  const models = await ctx.loader(barrelPath);
288
+ // A barrel that compiles to ZERO models declares NOTHING. The prediction rides every
289
+ // undeclared live table through unchanged, so an empty models array "predicts" any live
290
+ // state exactly — it would declare every database there is. Absent barrel and empty barrel
291
+ // are the same clean 'no'. (Closing-gate review round 2, 2026-09-07: on a side branch this
292
+ // minted a false declared-elsewhere; as a HEAD ancestor it silently proved a fast-forward.)
293
+ if (models.length === 0) return false;
231
294
  const governedRoles = governedRolesForModels(models);
232
295
  const predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: ctx.schema, governedRoles });
233
296
  return predicted === governedLiveFingerprint(live.snapshot, live.contract, governedRoles);
@@ -241,15 +304,153 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
241
304
  }
242
305
  return declares ? { kind: 'declares' } : { kind: 'no' };
243
306
  } catch (err: any) {
244
- return { kind: 'compile-failed', detail: `${where}: ${err.message}` };
307
+ const error = firstLine(err.message);
308
+ return { kind: 'compile-failed', detail: `${where}: ${error}`, error };
309
+ }
310
+ }
311
+
312
+ /** One tree the walk could not read, with where it sits in history — the refusal names it. */
313
+ interface UnreadableTree {
314
+ candidate: ModelTreeCandidate;
315
+ /** Carried by a commit HEAD descends from — no ref prune removes it. */
316
+ ancestor: boolean;
317
+ error: string;
318
+ }
319
+
320
+ /** The git verb that removes one ref, or null for a ref shape we do not prescribe deleting. */
321
+ export function pruneCommand(ref: string): string | null {
322
+ const head = ref.match(/^refs\/heads\/(.+)$/);
323
+ if (head) return `git branch -D ${head[1]}`;
324
+ const remote = ref.match(/^refs\/remotes\/([^/]+)\/(.+)$/);
325
+ if (remote) return `git push ${remote[1]} --delete ${remote[2]}`;
326
+ const tag = ref.match(/^refs\/tags\/(.+)$/);
327
+ if (tag) return `git tag -d ${tag[1]}`;
328
+ return null;
329
+ }
330
+
331
+ /**
332
+ * The unreadable-tree refusal, built from what the walk KNOWS: each tree, the refs that carry it,
333
+ * whether HEAD descends from it, and what the target's ledger says about who deployed what.
334
+ *
335
+ * Consumer cutover, cli 0.4.77 (2026-09-07): four August trees carried only by one backup branch
336
+ * on origin refused a production apply, and the message led with "upgrade/pin the CLI that last
337
+ * deployed this state" — on a database no CLI had ever deployed (schema_log was empty). The
338
+ * operator reconstructed the carrying ref with `git rev-list --all -- db/models` and a per-tree
339
+ * grep. Every fact in that reconstruction was already in this function's hands.
340
+ *
341
+ * The verdict does not change here; only the account of it. A tree we cannot read may still
342
+ * describe live, so the walk stays refuse-closed; the ledger is a memoir and never decides
343
+ * (decision 7). What the ledger DOES decide is which fix comes first: with no recorded apply,
344
+ * no CLI deployed these trees and "pin the CLI" is false, so bundle + prune leads; with one, pin
345
+ * the CLI only when the recorded commit is itself among the unreadable trees.
346
+ */
347
+ function unreadableRunbook(
348
+ unreadable: readonly UnreadableTree[],
349
+ lastApplied: DescentLedgerRow | undefined,
350
+ probe: GitLineageProbe,
351
+ ): string {
352
+ // Bounded on both axes, because this reason is RECORDED in schema_log and each ref lookup is a
353
+ // full ref walk: at most MAX_TREES trees are named, and a tree's refs are the union over its
354
+ // first MAX_COMMITS carrying commits (a tree is often carried by several commits on several
355
+ // branches — naming one commit's refs under-named the prune; closing-gate review, 2026-09-07).
356
+ const MAX_TREES = 10;
357
+ const MAX_COMMITS = 5;
358
+ const listed = unreadable.slice(0, MAX_TREES);
359
+ const refsOf = new Map<string, string[]>();
360
+ for (const u of listed) {
361
+ const refs = new Set<string>();
362
+ for (const commit of u.candidate.commits.slice(0, MAX_COMMITS)) {
363
+ for (const ref of probe.refsContaining(commit)) if (ref !== 'refs/stash') refs.add(ref);
364
+ }
365
+ refsOf.set(u.candidate.tree, [...refs].sort());
366
+ }
367
+ const lines = listed.map((u) => {
368
+ const refs = refsOf.get(u.candidate.tree)!;
369
+ const carried = refs.length === 0
370
+ ? 'carried by no ref'
371
+ : `carried by ${refs.slice(0, 3).join(', ')}${refs.length > 3 ? ` and ${refs.length - 3} more` : ''}`;
372
+ const where = u.ancestor ? "on your checkout's own history" : 'not an ancestor of your checkout';
373
+ return ` ${short(u.candidate.tree)} at ${short(u.candidate.commits[0])} — ${carried} (${where}) — ${scrubPaths(u.error)}`;
374
+ });
375
+ if (unreadable.length > listed.length) {
376
+ 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`);
377
+ }
378
+
379
+ const prunable = listed.filter((u) => !u.ancestor);
380
+ const ancestors = unreadable.filter((u) => u.ancestor);
381
+ const pruneRefs = [...new Set(prunable.flatMap((u) => refsOf.get(u.candidate.tree)!))];
382
+ const pruneCmds = pruneRefs.map(pruneCommand).filter((c): c is string => c !== null);
383
+ const prune = pruneRefs.length === 0
384
+ ? ''
385
+ : `Keep them if you want (git bundle create unreadable-trees.bundle ${pruneRefs.join(' ')}), then prune: ${pruneCmds.join('; ')}; then re-run.`;
386
+ const rewrite = ancestors.length === 0
387
+ ? ''
388
+ : `${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).`;
389
+
390
+ let lead: string;
391
+ if (!lastApplied) {
392
+ lead = 'No everystack apply is recorded on this target, so no CLI deployed these trees.';
393
+ } else {
394
+ const at = lastApplied.gitRef ? `at ${short(lastApplied.gitRef)} ` : '';
395
+ const among = lastApplied.gitRef !== null && unreadable.some((u) => u.candidate.commits.includes(lastApplied.gitRef!));
396
+ lead = `everystack last applied here ${at}on ${shortDate(lastApplied.appliedAt)}.`
397
+ + (among ? ' That commit is among the unreadable trees — pin the CLI that applied it (the version in that commit\'s lockfile) and re-run.' : '');
398
+ if (among) return `${lines.join('\n')}\n${lead}`;
245
399
  }
400
+ return `${lines.join('\n')}\n${[lead, prune, rewrite].filter(Boolean).join(' ')}`;
401
+ }
402
+
403
+ /**
404
+ * A compile/extraction error, summarized for a refusal that is RECORDED in a shared stage's
405
+ * schema_log: the cause, never the operator's filesystem.
406
+ *
407
+ * - MODULE_NOT_FOUND: line 1 is the cause ("Cannot find module '@everystack/model'"); the
408
+ * `Require stack:` that follows is absolute local paths — dropped.
409
+ * - An esbuild TransformError (the "older tree that no longer builds" case): line 1 is only the
410
+ * header "Transform failed with 1 error:" and the cause is on line 2 — kept, with the sandbox
411
+ * path prefix stripped so what remains is `db/models/index.ts:2:0: ERROR: …`.
412
+ * - An empty message says so instead of rendering nothing.
413
+ *
414
+ * (Closing-gate review round 5: a bare first-line take blanked the cause on exactly the class the
415
+ * `unverifiable` message exists to explain.)
416
+ */
417
+ export function summarizeCompileError(message: unknown): string {
418
+ const lines = String(message ?? '').split('\n').map((l) => l.trimEnd());
419
+ const stop = lines.findIndex((l) => /^\s*Require stack:/.test(l));
420
+ const useful = (stop === -1 ? lines : lines.slice(0, stop)).filter((l) => l.trim().length > 0);
421
+ if (useful.length === 0) return '(error with no message)';
422
+ const take = /^Transform failed with \d+ errors?:?$/.test(useful[0].trim()) && useful.length > 1 ? useful.slice(0, 2) : useful.slice(0, 1);
423
+ return take
424
+ .map((l) => l.replace(/\/[^\s:'"]*\.everystack-descent-[^/\s]+\/[0-9a-f]+\//g, '').trim())
425
+ .join(' — ');
426
+ }
427
+ const firstLine = summarizeCompileError;
428
+
429
+ /**
430
+ * Where historical trees materialize so that the materialized barrel can `import '@everystack/model'`:
431
+ * node walks UP from the importing file, so the sandbox must sit below a `node_modules` that holds
432
+ * the package. That is the nearest `node_modules` ABOVE THE MODELS BARREL, not the repo root's —
433
+ * in a monorepo the app (`apps/expo/db/models`) has `apps/expo/node_modules`, and the root
434
+ * `node_modules` may not carry the package at all (closing-gate review round 4, reproduced). No
435
+ * `node_modules` anywhere up to the repo root → the repo root itself (a nested checkout still walks
436
+ * up to its host's). Never the OS temp dir — that resolves nothing (the db:adopt 0.4.74 bug).
437
+ */
438
+ export function materializeBase(repoRoot: string, modelsAbs: string): string {
439
+ let dir = path.dirname(modelsAbs);
440
+ while (dir.startsWith(repoRoot)) {
441
+ const nm = path.join(dir, 'node_modules');
442
+ if (fs.existsSync(nm)) return nm;
443
+ if (dir === repoRoot) break;
444
+ dir = path.dirname(dir);
445
+ }
446
+ return repoRoot;
246
447
  }
247
448
 
248
449
  /** Resolve the repo root, HEAD, and the models directory/barrel split — shared setup. */
249
450
  function resolveRepoContext(
250
451
  modelsPath: string,
251
452
  opts: DescentOptions,
252
- ): { repoRoot: string; head: string; modelsDir: string; barrel: string } | null {
453
+ ): { repoRoot: string; head: string; modelsDir: string; barrel: string; modelsAbs: string } | null {
253
454
  const cwd = opts.cwd ?? process.cwd();
254
455
  const rootOut = git(['rev-parse', '--show-toplevel'], cwd);
255
456
  if (rootOut === null) return null;
@@ -261,23 +462,38 @@ function resolveRepoContext(
261
462
  // /private/var), and a mismatched prefix would walk the models path right out of the repo.
262
463
  const modelsAbs = path.resolve(fs.realpathSync(cwd), modelsPath);
263
464
  const rel = path.relative(repoRoot, modelsAbs);
264
- return { repoRoot, head, modelsDir: path.dirname(rel) || '.', barrel: path.basename(modelsAbs) };
465
+ return { repoRoot, head, modelsDir: path.dirname(rel) || '.', barrel: path.basename(modelsAbs), modelsAbs };
265
466
  }
266
467
 
267
468
  /** `git cat-file -e <ref>^{commit}` — is the commit present in THIS clone (not a shallow gap)? */
268
469
  function commitPresent(ref: string, cwd: string): boolean {
269
470
  try {
270
- execFileSync('git', ['cat-file', '-e', `${ref}^{commit}`], { cwd, stdio: 'ignore' });
471
+ execFileSync('git', ['cat-file', '-e', `${ref}^{commit}`], { cwd, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
271
472
  return true;
272
473
  } catch {
273
474
  return false;
274
475
  }
275
476
  }
276
477
 
277
- /** `git merge-base --is-ancestor <ancestor> <head>` — does HEAD descend from the commit? */
478
+ /**
479
+ * `git merge-base --is-ancestor`, three-valued: true / false / null when git could not answer
480
+ * (a timeout, a missing object, a broken repo). Every caller whose SAFE answer is "not an
481
+ * ancestor" may use {@link isAncestor}; a caller whose safe answer is the OTHER way — the squash
482
+ * guard accepts a carrier that is NOT an ancestor of the witness — must read null as "refuse".
483
+ */
484
+ function ancestry(ancestor: string, head: string, cwd: string): boolean | null {
485
+ try {
486
+ execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
487
+ return true;
488
+ } catch (err: any) {
489
+ return err?.status === 1 ? false : null; // exit 1 = "not an ancestor"; anything else = could not tell
490
+ }
491
+ }
492
+
493
+ /** `git merge-base --is-ancestor <ancestor> <head>` — does HEAD descend from the commit? False when git cannot tell. */
278
494
  function isAncestor(ancestor: string, head: string, cwd: string): boolean {
279
495
  try {
280
- execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore' });
496
+ execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
281
497
  return true;
282
498
  } catch {
283
499
  return false;
@@ -314,11 +530,55 @@ export interface GitLineageProbe {
314
530
  remotes(): string[] | null;
315
531
  /** `git ls-remote --heads --tags <remote>` advertised tip shas; null when the remote is unreachable. */
316
532
  lsRemote(remote: string): string[] | null;
533
+ /**
534
+ * Why the last `lsRemote(remote)` returned null — git's own first stderr line, paths scrubbed —
535
+ * or null when nothing was captured. Optional: a probe without it still names the remote.
536
+ */
537
+ lsRemoteError?(remote: string): string | null;
538
+ /**
539
+ * Does this object peel to a commit (`<sha>^{commit}`)? `null` when the object is absent from
540
+ * the clone. An advertised tip that is present but is a tag on a blob or tree can carry no
541
+ * models and cannot be `--contains`-tested (git errors on a non-commit), so currency skips it.
542
+ * Optional: a probe without it treats every tip as a commit (refuse-closed).
543
+ */
544
+ peelsToCommit?(sha: string): boolean | null;
545
+ }
546
+
547
+ /**
548
+ * The runbook for a remote `git ls-remote` could not read. Three things it must do, each learned
549
+ * from a consumer's production cutover (cli 0.4.77, 2026-09-07) where the remote was named `db`
550
+ * and "could not read db" was read as the database being unreachable: name the remote AS a git
551
+ * remote, carry git's own error so the cause is on the surface, and name the verb that clears a
552
+ * dead one — `git remote remove` — next to the one that fixes a live one.
553
+ */
554
+ function unreadableRemote(remote: string, git: GitLineageProbe): string {
555
+ const err = git.lsRemoteError?.(remote);
556
+ return `git remote '${remote}' could not be read: \`git ls-remote --heads --tags ${remote}\` failed${err ? ` — ${err}` : ''}. `
557
+ + `Fix the remote or remove it (git remote remove ${remote}), then re-run.`;
558
+ }
559
+
560
+ /**
561
+ * git's stderr, reduced to what a refusal may carry: the first non-empty line, absolute paths
562
+ * and file:// URLs replaced by `<path>` (the reason is recorded, and a home directory is not
563
+ * part of the diagnosis), capped so a chatty transport cannot flood the surface.
564
+ */
565
+ export function firstErrorLine(stderr: string): string | null {
566
+ const line = stderr.split('\n').map((l) => l.trim()).find(Boolean);
567
+ if (!line) return null;
568
+ return scrubPaths(line).slice(0, 200);
569
+ }
570
+
571
+ /** Absolute paths and file:// URLs replaced by `<path>` — a recorded reason never carries a home directory. */
572
+ export function scrubPaths(s: string): string {
573
+ return s
574
+ .replace(/file:\/\/\S+/g, 'file://<path>')
575
+ .replace(/(^|[\s'"(])\/[^\s'"()]+/g, '$1<path>');
317
576
  }
318
577
 
319
578
  export type LineageProof =
320
579
  | { proven: true; subcase: 'revert-unreachable' | 'unknown-commit-proven-absent' }
321
- | { proven: false; kind: 'shallow' | 'partial' | 'reachable' | 'behind-remote' | 'remote-unreachable' | 'remote-list-failed' | 'no-remotes'; reason: string };
580
+ /** `ref` is set for `reachable`: the ref (or `HEAD`, or a remote name) that still holds the commit. */
581
+ | { proven: false; kind: 'shallow' | 'partial' | 'reachable' | 'behind-remote' | 'remote-unreachable' | 'remote-list-failed' | 'no-remotes'; reason: string; ref?: string };
322
582
 
323
583
  /**
324
584
  * Prove the declaring commit `sha` is reachable from NO ref, local or remote. Refuse-closed: every
@@ -339,10 +599,10 @@ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProo
339
599
  if (present) {
340
600
  const containing = git.refsContaining(sha);
341
601
  if (containing.length > 0) {
342
- 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.` };
602
+ 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.` };
343
603
  }
344
604
  if (git.isAncestorOf(sha, 'HEAD')) {
345
- 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.` };
605
+ 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.` };
346
606
  }
347
607
  }
348
608
 
@@ -359,14 +619,14 @@ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProo
359
619
  for (const remote of remotes) {
360
620
  const tips = git.lsRemote(remote);
361
621
  if (tips === null) {
362
- 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.` };
622
+ return { proven: false, kind: 'remote-unreachable', reason: `cannot confirm ${short(sha)} is not on ${remote}: ${unreadableRemote(remote, git)}` };
363
623
  }
364
624
  for (const tip of tips) {
365
625
  if (!git.objectExists(tip)) {
366
626
  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.` };
367
627
  }
368
628
  if (present && git.isAncestorOf(sha, tip)) {
369
- 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.` };
629
+ 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.` };
370
630
  }
371
631
  }
372
632
  }
@@ -393,9 +653,12 @@ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProo
393
653
 
394
654
  /** The real probe: each method is one git command run in `cwd`. */
395
655
  export function realLineageProbe(cwd: string): GitLineageProbe {
656
+ const lsRemoteErrors = new Map<string, string>();
657
+ // A hung remote must become a refusal, not a hang: every probe command is bounded. A timeout
658
+ // throws, so it reads as null — unreachable — and the proof refuses closed as it would offline.
396
659
  const run = (args: string[]): string | null => {
397
660
  try {
398
- return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
661
+ return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: GIT_TIMEOUT_MS }).toString();
399
662
  } catch {
400
663
  return null;
401
664
  }
@@ -418,10 +681,24 @@ export function realLineageProbe(cwd: string): GitLineageProbe {
418
681
  return out === null ? null : out.split('\n').map((l) => l.trim()).filter(Boolean);
419
682
  },
420
683
  lsRemote: (remote) => {
421
- const out = run(['ls-remote', '--heads', '--tags', remote]);
422
- if (out === null) return null;
684
+ // stderr is KEPT here, unlike `run`: the refusal this feeds must say why git could not read
685
+ // the remote, or "could not read db" is the database to whoever reads it.
686
+ let out: string;
687
+ try {
688
+ out = execFileSync('git', ['ls-remote', '--heads', '--tags', remote], { cwd, stdio: ['ignore', 'pipe', 'pipe'], timeout: GIT_TIMEOUT_MS }).toString();
689
+ } catch (e) {
690
+ const stderr = (e as { stderr?: Buffer | string }).stderr?.toString() ?? '';
691
+ lsRemoteErrors.set(remote, firstErrorLine(stderr) ?? firstErrorLine((e as Error).message ?? '') ?? 'no error output');
692
+ return null;
693
+ }
694
+ lsRemoteErrors.delete(remote);
423
695
  return out.split('\n').map((l) => l.split('\t')[0]?.trim()).filter((s): s is string => !!s && s.length >= 7);
424
696
  },
697
+ lsRemoteError: (remote) => lsRemoteErrors.get(remote) ?? null,
698
+ peelsToCommit: (sha) => {
699
+ if (!commitPresent(sha, cwd) && run(['cat-file', '-e', sha]) === null) return null; // absent
700
+ return run(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]) !== null;
701
+ },
425
702
  };
426
703
  }
427
704
 
@@ -451,34 +728,127 @@ export interface DescentLedgerRow {
451
728
  dirty: boolean;
452
729
  /** When the apply landed — for the REVERT message (names the commit + date). */
453
730
  appliedAt: string;
454
- /** Only a successful state apply is a witness. */
731
+ /** Only a successful state apply is a witness: `applied`, or `applied — <descent note>`. */
455
732
  outcome: string;
456
733
  }
457
734
 
735
+ /** A successful state apply, with or without a recorded descent note (`applied — …`). */
736
+ export const isAppliedOutcome = (outcome: string): boolean => outcome === 'applied' || outcome.startsWith('applied — ');
737
+
738
+ /**
739
+ * Where a proven drift came from — informs the warning and the schema_log note, never the
740
+ * verdict (the ledger is a memoir, decision 7). `never-declared`: no successful state apply is
741
+ * recorded here — first adoption of a database everystack never wrote. `hand-edited`: everystack
742
+ * did apply here once (the newest such row is named), and the database has moved since, outside
743
+ * the tool.
744
+ */
745
+ export type DriftOrigin =
746
+ | { kind: 'never-declared' }
747
+ /** `dirty`: that last apply ran from a dirty worktree, so its commit never declared what it applied. */
748
+ | { kind: 'hand-edited'; commit: string | null; appliedAt: string; dirty?: boolean };
749
+
458
750
  export type ProveVerdict =
459
751
  /** The database has no tables — first apply bootstraps it. */
460
752
  | { status: 'fresh-target' }
461
753
  /** Not a git checkout — the rule cannot be verified here. */
462
754
  | { status: 'no-git' }
463
- /** Fast-forward proven — via a ledger witness or an ancestor recompile. */
464
- | { status: 'proven'; via: 'ledger' | 'recompile'; commit: string }
755
+ /** Fast-forward proven — via a ledger witness, its squashed successor, or an ancestor recompile.
756
+ * For `squash`, `commit` is the CARRIER (an ancestor of HEAD holding the deploying commit's
757
+ * models tree) and `witness` is the deploying commit the ledger names, which HEAD does not
758
+ * descend from — both are surfaced and the weaker proof is recorded in schema_log. */
759
+ | { status: 'proven'; via: 'ledger' | 'squash' | 'recompile'; commit: string; witness?: string }
465
760
  /** The newest ledger witness is NOT an ancestor of HEAD — applying would revert merged work. */
466
761
  | { status: 'revert'; commit: string; appliedAt: string; reason: string }
467
762
  /** The witness names a commit this clone does not have (shallow) — fetch, do not guess. */
468
763
  | { status: 'unknown-commit'; commit: string; reason: string }
469
- /** No ledger witness, and the ancestor recompile could not read every tree — inconclusive. */
764
+ /** A ref HEAD does not descend from declares the live state — a teammate's merged work. */
765
+ | { status: 'declared-elsewhere'; commit: string; reason: string }
766
+ /** The search could not read every tree, or the clone is not full and current — inconclusive. */
470
767
  | { status: 'unverifiable'; reason: string; unevaluated: string[]; compileFailures: string[] }
471
- /** No ledger witness, every ancestor tree read cleanly, none declares live — real drift. */
472
- | { status: 'drift'; reason: string; scannedTrees: number };
768
+ /** Every tree on every ref read cleanly in a full, current clone, and none declares live —
769
+ * PROVEN drift. Passes at apply: nothing merged exists to revert. */
770
+ | { status: 'drift'; reason: string; scannedTrees: number; origin: DriftOrigin };
473
771
 
474
772
  export interface ProveDescentOptions extends DescentOptions {
475
773
  /** Retained-canon registry override — the cross-bump recompute path (tests inject a
476
774
  * synthetic retired canon). Defaults to the module's RETAINED_CANONS. */
477
775
  canons?: Readonly<Record<number, CanonHasher>>;
776
+ /** The git probe the drift proof uses to establish the clone is full and current.
777
+ * Default: {@link realLineageProbe} over the repo root. Injected so tests can prove each
778
+ * currency refusal without a remote. */
779
+ probe?: GitLineageProbe;
478
780
  }
479
781
 
480
782
  const shortDate = (at: string): string => at.slice(0, 10);
481
783
 
784
+ /**
785
+ * The origin, in words — ONE rendering for the plan warning, the apply warning, and the recorded
786
+ * note, so the dirty-worktree sub-case can never be surfaced in one place and dropped in another
787
+ * (closing-gate review round 4: the flag was computed and reached no human and no record).
788
+ *
789
+ * never-declared — no everystack apply is recorded here
790
+ * hand-edited since <sha>, <date> — everystack applied from that commit; the database moved since
791
+ * applied from a DIRTY worktree at <sha>, <date> — that commit never declared what was applied,
792
+ * so nothing committed can declare this state
793
+ */
794
+ export function describeDriftOrigin(origin: DriftOrigin, opts: { human?: boolean } = {}): string {
795
+ if (origin.kind === 'never-declared') return opts.human ? 'never declared: no everystack apply is recorded here' : 'never-declared';
796
+ const at = origin.commit ? `${short(origin.commit)}, ${shortDate(origin.appliedAt)}` : `an apply on ${shortDate(origin.appliedAt)}`;
797
+ return origin.dirty ? `applied from a DIRTY worktree at ${at}` : `hand-edited since ${at}`;
798
+ }
799
+
800
+ /** The recorded note for a proven drift — goes in the apply row's schema_log outcome. */
801
+ export function driftOriginNote(origin: DriftOrigin): string {
802
+ return `descent: drift-origin proven (${describeDriftOrigin(origin)})`;
803
+ }
804
+
805
+ /**
806
+ * Is this clone full and current with every remote? The drift proof's precondition: "no ref
807
+ * anywhere declares live" is only a claim we may make when we hold every ref there is. Same
808
+ * probe as the lineage proof, but the question differs — currency, not the fate of one commit —
809
+ * so an empty remote set is simply current (a purely local repo has nothing to be behind).
810
+ * Refuse-closed: a failed `git remote` or an unreachable remote is NOT current.
811
+ *
812
+ * The test is REF reachability, never object presence. The scan this guards is `git rev-list
813
+ * --all`, which walks refs; an advertised tip that is present as an object but contained by no
814
+ * ref (a `git fetch origin <branch>` that wrote only FETCH_HEAD, or a locally deleted branch
815
+ * the remote still has) is invisible to the scan and MUST count as behind. Testing
816
+ * `objectExists` here let exactly that tip satisfy currency while the scan missed the models
817
+ * it declared — found by the closing-gate review 2026-09-07, before it shipped.
818
+ */
819
+ export function proveCloneCurrent(git: GitLineageProbe): { current: true } | { current: false; reason: string } {
820
+ if (git.isShallow()) {
821
+ return { current: false, reason: 'this is a shallow clone, so a ref that declares the live state may simply not be here — unshallow first (git fetch --unshallow), then re-run.' };
822
+ }
823
+ if (git.isPartial()) {
824
+ return { current: false, reason: 'this is a partial clone, so objects can be missing locally — fetch the full history, then re-run.' };
825
+ }
826
+ const remotes = git.remotes();
827
+ if (remotes === null) {
828
+ return { current: false, reason: "could not list this repo's git remotes to confirm the clone is current — retry, or fetch the remotes first." };
829
+ }
830
+ for (const remote of remotes) {
831
+ const tips = git.lsRemote(remote);
832
+ if (tips === null) {
833
+ return { current: false, reason: `cannot confirm the clone is current: ${unreadableRemote(remote, git)}` };
834
+ }
835
+ for (const tip of tips) {
836
+ // refs/stash is excluded from the scan (enumerateModelTrees), so a tip contained ONLY by a
837
+ // stash counts as behind here — the two sides of the currency proof see the same refs, and
838
+ // this direction is refuse-closed. The probe itself stays raw: for the lineage proof a stash
839
+ // that reaches a commit is a REACHABLE commit, and filtering it there would fall open.
840
+ if (git.refsContaining(tip).filter((ref) => ref !== 'refs/stash').length === 0) {
841
+ // A tip we HOLD that is not a commit and peels to none (a tag on a blob or tree — the
842
+ // `junio-gpg-pub` shape) carries no models and can never be contained by a ref; it is
843
+ // not evidence of being behind. An ABSENT tip, or a present commit no ref contains, is.
844
+ if (git.peelsToCommit && git.peelsToCommit(tip) === false) continue;
845
+ return { current: false, reason: `this clone is behind ${remote} (it advertises ${short(tip)}, which no ref here contains) — fetch it first (git fetch ${remote} --tags); a ref there may declare the live state.` };
846
+ }
847
+ }
848
+ }
849
+ return { current: true };
850
+ }
851
+
482
852
  /**
483
853
  * The fast-forward proof, ledger-first. Refuse-closed: no inconclusive branch passes.
484
854
  *
@@ -508,7 +878,7 @@ export async function proveDescent(
508
878
 
509
879
  const ctx = resolveRepoContext(modelsPath, opts);
510
880
  if (ctx === null) return { status: 'no-git' };
511
- const { repoRoot, head, modelsDir, barrel } = ctx;
881
+ const { repoRoot, head, modelsDir, barrel, modelsAbs } = ctx;
512
882
 
513
883
  // ---- Phase A: the ledger witness (newest usable row wins). ----
514
884
  // "Newest wins" (the S→T→S pre-episode refusal) is a security-relevant property, so do not
@@ -516,7 +886,7 @@ export async function proveDescent(
516
886
  // future caller passing an unsorted ledger must not silently let a pre-episode row win.
517
887
  const ordered = [...ledger].sort((a, b) => Number(b.id) - Number(a.id));
518
888
  for (const r of ordered) {
519
- if (r.outcome !== 'applied') continue; // only a successful state apply is a witness
889
+ if (!isAppliedOutcome(r.outcome)) continue; // only a successful state apply is a witness
520
890
  if (!r.toFingerprint) continue;
521
891
  if (!r.gitRef) continue; // a null git_ref cannot declare a commit
522
892
  if (r.dirty) continue; // a dirty mint's git_ref does not declare what was applied
@@ -540,23 +910,43 @@ export async function proveDescent(
540
910
  if (isAncestor(r.gitRef, head, repoRoot)) {
541
911
  return { status: 'proven', via: 'ledger', commit: r.gitRef };
542
912
  }
913
+ const squash = squashedSuccessor(r.gitRef, head, modelsDir, repoRoot);
914
+ if (squash) return { status: 'proven', via: 'squash', commit: squash, witness: r.gitRef };
543
915
  return {
544
916
  status: 'revert',
545
917
  commit: r.gitRef,
546
918
  appliedAt: r.appliedAt,
547
919
  reason:
548
920
  `your checkout is behind the branch that last deployed this state — commit ${short(r.gitRef)} ` +
549
- `(applied ${shortDate(r.appliedAt)}) is NOT an ancestor of HEAD. Applying now would revert ` +
550
- 'that merged work. Pull the deploying branch, rebase onto it, and re-plan.',
921
+ `(applied ${shortDate(r.appliedAt)}) is NOT an ancestor of HEAD, and no ancestor of HEAD carries ` +
922
+ 'its models tree as a squashed successor. Applying now would revert that merged work. ' +
923
+ 'Pull the deploying branch, rebase onto it, and re-plan.',
551
924
  };
552
925
  }
553
926
 
554
927
  // ---- Phase B: no usable ledger witness → ancestor-restricted recompile. ----
555
928
  const candidates = enumerateModelTrees(modelsDir, repoRoot, { ancestorsOf: head });
556
929
 
557
- const nodeModules = path.join(repoRoot, 'node_modules');
930
+ // No ancestor carries a committed models directory to compare against — a shallow clone that
931
+ // truncated the deploying history, or the deploying branch is simply not in this checkout.
932
+ // That is a limit of THIS clone, not a hand-edit: UNVERIFIABLE, never drift.
933
+ if (candidates.length === 0) {
934
+ return {
935
+ status: 'unverifiable',
936
+ reason:
937
+ 'cannot verify the fast-forward rule — no ancestor of your checkout carries a committed ' +
938
+ 'models directory to compare against. This is a shallow clone (fetch the full history: ' +
939
+ 'git fetch --unshallow) or the deploying branch is not in this checkout, not evidence ' +
940
+ 'about the database — it is NOT drift. Fetch the deploying history and re-run.',
941
+ unevaluated: [],
942
+ compileFailures: [],
943
+ };
944
+ }
945
+
946
+ // Historical trees materialize INSIDE the project, below the nearest node_modules above the
947
+ // models barrel — see materializeBase. Never the OS temp dir.
558
948
  const materializeRoot = opts.materializeRoot
559
- ?? fs.mkdtempSync(path.join(fs.existsSync(nodeModules) ? nodeModules : os.tmpdir(), '.everystack-descent-'));
949
+ ?? fs.mkdtempSync(path.join(materializeBase(repoRoot, modelsAbs), '.everystack-descent-'));
560
950
  const ownsRoot = !opts.materializeRoot;
561
951
  const evalCtx: EvalContext = {
562
952
  repoRoot,
@@ -569,15 +959,39 @@ export async function proveDescent(
569
959
 
570
960
  const compileFailures: string[] = [];
571
961
  const unevaluated: string[] = [];
962
+ const unreadable: UnreadableTree[] = [];
963
+ const probe = opts.probe ?? realLineageProbe(repoRoot);
964
+ const lastApplied = ordered.find((r) => isAppliedOutcome(r.outcome));
965
+ const unverifiable = (scope: string, scanned: number): ProveVerdict => {
966
+ const notes: string[] = [];
967
+ if (compileFailures.length > 0) notes.push(`${compileFailures.length} would not compile under this CLI`);
968
+ if (unevaluated.length > 0) notes.push(`${unevaluated.length} could not be extracted`);
969
+ // Every unreadable tree is NAMED — with the refs that carry it, its place in history, and its
970
+ // own error ("Cannot find module '@everystack/model'" is a resolution problem with a different
971
+ // fix than "an older tree that no longer builds") — and the runbook leads with the fix the
972
+ // ledger supports. See unreadableRunbook for the incident that shaped this.
973
+ return {
974
+ status: 'unverifiable',
975
+ reason:
976
+ `cannot verify the fast-forward rule — of ${scanned} ${scope} model state(s), ` +
977
+ `${notes.join(' and ')}, and none of the readable ones declares the live state. This is a ` +
978
+ 'limit of THIS run (an older tree that no longer builds, or a failed extraction), not ' +
979
+ `evidence about the database — it is NOT drift. Unreadable:\n${unreadableRunbook(unreadable, lastApplied, probe)}`,
980
+ unevaluated,
981
+ compileFailures,
982
+ };
983
+ };
984
+ const record = (candidate: ModelTreeCandidate, result: Extract<TreeEval, { error: string }>, ancestor: boolean): void => {
985
+ (result.kind === 'unevaluated' ? unevaluated : compileFailures).push(result.detail);
986
+ unreadable.push({ candidate, ancestor, error: result.error });
987
+ };
988
+
989
+ let others: ModelTreeCandidate[] = [];
572
990
  try {
573
991
  for (const candidate of candidates) {
574
992
  const result = await evaluateTree(candidate, live, evalCtx);
575
- if (result.kind === 'unevaluated') {
576
- unevaluated.push(result.detail);
577
- continue;
578
- }
579
- if (result.kind === 'compile-failed') {
580
- compileFailures.push(result.detail);
993
+ if (result.kind === 'unevaluated' || result.kind === 'compile-failed') {
994
+ record(candidate, result, true);
581
995
  continue;
582
996
  }
583
997
  if (result.kind === 'no') continue;
@@ -586,79 +1000,108 @@ export async function proveDescent(
586
1000
  const ancestor = candidate.commits.find((commit) => isAncestor(commit, head, repoRoot)) ?? candidate.commits[0];
587
1001
  return { status: 'proven', via: 'recompile', commit: ancestor };
588
1002
  }
589
- } finally {
590
- if (ownsRoot) fs.rmSync(materializeRoot, { recursive: true, force: true });
591
- }
592
1003
 
593
- // No ancestor carries a committed models directory to compare against — a shallow clone that
594
- // truncated the deploying history, or the deploying branch is simply not in this checkout.
595
- // That is a limit of THIS clone, not a hand-edit: UNVERIFIABLE, never drift.
596
- if (candidates.length === 0) {
597
- return {
598
- status: 'unverifiable',
599
- reason:
600
- 'cannot verify the fast-forward rule — no ancestor of your checkout carries a committed ' +
601
- 'models directory to compare against. This is a shallow clone (fetch the full history: ' +
602
- 'git fetch --unshallow) or the deploying branch is not in this checkout, not evidence ' +
603
- 'about the database — it is NOT drift. Fetch the deploying history, or waive the rule deliberately.',
604
- unevaluated,
605
- compileFailures,
606
- };
607
- }
1004
+ // A tree we could not READ is not evidence about the database. If any ancestor tree could not
1005
+ // be extracted or compiled, "nothing declares this state" is a claim we are not entitled to
1006
+ // make — the answer is UNVERIFIABLE, and its next move is retry/upgrade, never db:pull.
1007
+ if (unevaluated.length > 0 || compileFailures.length > 0) return unverifiable('ancestor', candidates.length);
608
1008
 
609
- // A tree we could not READ is not evidence about the database. If any ancestor tree could not
610
- // be extracted or compiled, "nothing declares this state" is a claim we are not entitled to
611
- // make — the answer is UNVERIFIABLE, and its next move is retry/upgrade, never db:pull.
612
- if (unevaluated.length > 0 || compileFailures.length > 0) {
613
- const notes: string[] = [];
614
- if (compileFailures.length > 0) notes.push(`${compileFailures.length} would not compile under this CLI`);
615
- if (unevaluated.length > 0) notes.push(`${unevaluated.length} could not be extracted`);
616
- return {
617
- status: 'unverifiable',
618
- reason:
619
- `cannot verify the fast-forward rule — of ${candidates.length} ancestor model state(s), ` +
620
- `${notes.join(' and ')}, and none of the readable ones declares the live state. This is a ` +
621
- 'limit of THIS run (an older tree that no longer builds, or a failed extraction), not ' +
622
- 'evidence about the database — it is NOT drift. Upgrade/pin the CLI that last deployed ' +
623
- 'this state, or waive the rule deliberately.',
624
- unevaluated,
625
- compileFailures,
626
- };
1009
+ // ---- Phase C: the drift proof — no ancestor declares live, so scan EVERY ref. ----
1010
+ // The fast-forward rule protects MERGED work. Before calling the live state undeclared, look
1011
+ // at every tree on every ref this clone holds: one that declares live belongs to a line of
1012
+ // history HEAD does not descend from — a teammate deployed it — and applying our edge would
1013
+ // revert it. That refuses BY NAME. Only when no tree anywhere declares live is there nothing
1014
+ // to protect.
1015
+ const seen = new Set(candidates.map((c) => c.tree));
1016
+ const all = enumerateModelTrees(modelsDir, repoRoot);
1017
+ // The all-refs walk must be a SUPERSET of the ancestor walk — every ancestor tree is reachable
1018
+ // from `--all`. When it is not, `git rev-list --all` failed (a broken or dangling ref makes it
1019
+ // exit 128 while the ancestor walk still succeeds) and returned nothing, which is NOT "no
1020
+ // other refs". Reading an empty result as "nothing else declares live" fell open to drift;
1021
+ // found by the closing-gate review 2026-09-07.
1022
+ const allTrees = new Set(all.map((c) => c.tree));
1023
+ if ([...seen].some((tree) => !allTrees.has(tree))) {
1024
+ return {
1025
+ status: 'unverifiable',
1026
+ reason:
1027
+ 'cannot verify the fast-forward rule — the walk over EVERY ref (git rev-list --all) did not ' +
1028
+ 'return the trees the ancestor walk did, so it failed part-way: a broken or dangling ref ' +
1029
+ '(run `git fsck`, prune the bad ref) or a git error. That is a limit of THIS clone, not ' +
1030
+ 'evidence about the database — it is NOT drift. Repair the ref and re-run.',
1031
+ unevaluated,
1032
+ compileFailures,
1033
+ };
1034
+ }
1035
+ // Currency BEFORE the all-refs compile: a clone that is shallow, partial, or behind a remote
1036
+ // is told to fetch now, not after paying for every historical tree on every ref.
1037
+ const currency = proveCloneCurrent(probe);
1038
+ if (!currency.current) {
1039
+ return {
1040
+ status: 'unverifiable',
1041
+ reason:
1042
+ `cannot verify the fast-forward rule — no ancestor of your checkout declares the live state, and ` +
1043
+ `${currency.reason} That is a limit of THIS clone, not evidence about the database — it is NOT drift.`,
1044
+ unevaluated,
1045
+ compileFailures,
1046
+ };
1047
+ }
1048
+ others = all.filter((c) => !seen.has(c.tree));
1049
+ for (const candidate of others) {
1050
+ const result = await evaluateTree(candidate, live, evalCtx);
1051
+ if (result.kind === 'unevaluated' || result.kind === 'compile-failed') {
1052
+ record(candidate, result, false);
1053
+ continue;
1054
+ }
1055
+ if (result.kind === 'no') continue;
1056
+ const commit = candidate.commits[0];
1057
+ return {
1058
+ status: 'declared-elsewhere',
1059
+ commit,
1060
+ reason:
1061
+ `a ref your checkout does NOT descend from declares the target's live state — commit ` +
1062
+ `${short(commit)} carries the models that match it. Someone deployed from that line of ` +
1063
+ 'history; applying your edge would revert their work. Fetch and merge or rebase onto it, ' +
1064
+ 'then re-plan.',
1065
+ };
1066
+ }
1067
+ } finally {
1068
+ if (ownsRoot) fs.rmSync(materializeRoot, { recursive: true, force: true });
627
1069
  }
628
1070
 
629
- return {
630
- status: 'drift',
631
- scannedTrees: candidates.length,
632
- reason:
633
- `no ancestor of your checkout declares the target's live fingerprint ` +
634
- `${short(fingerprintLive(live.snapshot, live.contract).hash)} — read ${candidates.length} ` +
635
- 'ancestor model state(s), all cleanly, and none matches. Either this database has never been ' +
636
- 'adopted into Models (nothing committed can declare it — the first-adoption case), or it ' +
637
- 'drifted after adoption. THE RUNBOOK: run everystack db:adopt — it renders Models from the ' +
638
- 'live database and PROVES they declare it exactly (it REFUSES rather than paper over any ' +
639
- 'difference); commit AND push that baseline, then re-run db:plan / db:apply and it descends ' +
640
- 'normally. If instead your Models are the source of truth and the database drifted, reconcile ' +
641
- 'with db:generate, or waive the rule deliberately.',
642
- };
643
- }
1071
+ const scannedTrees = candidates.length + others.length;
1072
+ // A side branch we could not read is not evidence either — refuse-closed, naming the count so
1073
+ // the operator can prune the branch that carries it.
1074
+ if (unevaluated.length > 0 || compileFailures.length > 0) return unverifiable('reachable', scannedTrees);
644
1075
 
645
- /**
646
- * Collapse a ProveVerdict into the ok/reason shape the apply core enforces. Refuse-closed:
647
- * only proven / fresh-target / no-git pass; every other verdict refuses, carrying its own
648
- * distinct cause so the operator's next move is unambiguous.
649
- */
650
- export function proveVerdictForApply(verdict: ProveVerdict): { ok: true } | { ok: false; reason: string } {
651
- switch (verdict.status) {
652
- case 'proven':
653
- case 'fresh-target':
654
- case 'no-git':
655
- return { ok: true };
656
- case 'revert':
657
- case 'unknown-commit':
658
- case 'unverifiable':
659
- case 'drift':
660
- return { ok: false, reason: verdict.reason };
661
- }
1076
+ // PROVEN DRIFT (the clone was proven full and current before the all-refs scan). The origin is
1077
+ // read from the ledger for the MESSAGE only (memoir, not control): any successful state apply
1078
+ // recorded here means everystack once wrote this database and it has moved since; none means
1079
+ // this is the first adoption of a database everystack never wrote. A dirty-worktree apply is
1080
+ // still an apply — its git_ref declares nothing (Phase A skips it as a witness), so the state
1081
+ // it produced was never committed anywhere; the origin says so.
1082
+ const origin: DriftOrigin = lastApplied
1083
+ ? { kind: 'hand-edited', commit: lastApplied.gitRef, appliedAt: lastApplied.appliedAt, ...(lastApplied.dirty ? { dirty: true } : {}) }
1084
+ : { kind: 'never-declared' };
1085
+ const fp = short(fingerprintLive(live.snapshot, live.contract).hash);
1086
+ // The scan is scoped to THIS checkout's models path: a ref that declares live from a different
1087
+ // directory (a models move on another line of history) is outside it. Say so — the claim must
1088
+ // be exactly as wide as the search.
1089
+ const scanned = `read ${scannedTrees} model state(s) at ${modelsDir}/ across every ref of a full, current clone, all cleanly, and none matches`;
1090
+ const runbook =
1091
+ 'The reviewed plan you mint is the ORIGIN: `everystack db:plan --stage <name>` mints it, ' +
1092
+ '`everystack db:apply --plan <file> --stage <name>` applies it, warns, and records the origin ' +
1093
+ 'in schema_log; every later apply descends from that row.';
1094
+ const reason = origin.kind === 'never-declared'
1095
+ ? `no commit on any ref declares the target's live fingerprint ${fp} — ${scanned}. Nothing has ever ` +
1096
+ 'declared this database (no everystack state apply is recorded here), so there is no merged work ' +
1097
+ `the fast-forward rule could protect. ${runbook}`
1098
+ : `no commit on any ref declares the target's live fingerprint ${fp} — ${scanned}. everystack last ` +
1099
+ `applied here ${origin.commit ? `at commit ${short(origin.commit)} ` : ''}on ${shortDate(origin.appliedAt)}` +
1100
+ (origin.dirty
1101
+ ? ' from a DIRTY worktree — that commit never declared what was applied, so nothing committed can declare this state. '
1102
+ : ', and the database has been changed outside the tool since (hand-edited). No ref declares what it is now, so ') +
1103
+ `there is no merged work the fast-forward rule could protect; your committed Models are the source of truth. ${runbook}`;
1104
+ return { status: 'drift', scannedTrees, origin, reason };
662
1105
  }
663
1106
 
664
1107
  // ---------------------------------------------------------------------------
@@ -681,8 +1124,9 @@ export interface DescentApplyContext {
681
1124
  }
682
1125
 
683
1126
  export type DescentApplyDecision =
684
- /** Proceed. `attested` is set only when the attestation was honoured — record it in schema_log. */
685
- | { ok: true; attested?: { commit: string; subcase: 'revert-unreachable' | 'unknown-commit-proven-absent' } }
1127
+ /** Proceed. `attested` is set only when the attestation was honoured — record it in schema_log.
1128
+ * `note` is set for a proven drift — its origin, recorded in schema_log the same way. */
1129
+ | { ok: true; attested?: { commit: string; subcase: 'revert-unreachable' | 'unknown-commit-proven-absent' }; note?: string }
686
1130
  | { ok: false; reason: string };
687
1131
 
688
1132
  /** A short recorded note for a honoured attestation — goes in the apply row's schema_log outcome. */
@@ -690,9 +1134,44 @@ export function attestationNote(a: { commit: string; subcase: string }): string
690
1134
  return `descent-attested-lineage-lost (${short(a.commit)}, ${a.subcase})`;
691
1135
  }
692
1136
 
1137
+ /** The recorded note for a squash proof — goes in the apply row's schema_log outcome. */
1138
+ export function squashNote(carrier: string, witness: string): string {
1139
+ return `descent: squash-successor (${short(carrier)} carries the models tree of ${short(witness)}, which HEAD does not descend from)`;
1140
+ }
1141
+
1142
+ /**
1143
+ * Does the operator's attestation name this commit? Exact, or a hex prefix of at least 7
1144
+ * characters — compared against the ONE commit the refusal named, so there is nothing for a
1145
+ * prefix to be ambiguous with. The refusal prints the 12-character form, and an operator who
1146
+ * pastes it back is naming that commit. A strict string compare refused
1147
+ * "42a3b97af246 does not match (42a3b97af246)" — two identical strings — because the ledger holds
1148
+ * 40 characters and the refusal printed 12 (consumer cutover, cli 0.4.77, 2026-09-07).
1149
+ */
1150
+ export function attestsCommit(attestation: string, commit: string): boolean {
1151
+ const a = attestation.trim().toLowerCase();
1152
+ if (a === commit.toLowerCase()) return true;
1153
+ return a.length >= 7 && /^[0-9a-f]+$/.test(a) && commit.toLowerCase().startsWith(a);
1154
+ }
1155
+
1156
+ /**
1157
+ * Resolve an attestation through git before it is compared: a prefix, a tag, a branch, or a
1158
+ * `HEAD~n` becomes the full sha. A value git cannot resolve (the commit is absent from this clone —
1159
+ * the `unknown-commit-proven-absent` subcase, or a typo) passes through unchanged so the prefix
1160
+ * compare above still gets its chance and a mismatch names what was given.
1161
+ */
1162
+ export function resolveAttestation(attestation: string, cwd: string): string {
1163
+ const full = git(['rev-parse', '--verify', '--quiet', `${attestation.trim()}^{commit}`], cwd)?.trim();
1164
+ return full && /^[0-9a-f]{40,64}$/.test(full) ? full : attestation;
1165
+ }
1166
+
693
1167
  export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyContext): DescentApplyDecision {
694
1168
  switch (verdict.status) {
695
1169
  case 'proven':
1170
+ // A squash proof rests on models-tree identity, not ancestry of the deploying commit — the
1171
+ // record says so, exactly as an honoured attestation and a proven drift leave their trace.
1172
+ return verdict.via === 'squash' && verdict.witness
1173
+ ? { ok: true, note: squashNote(verdict.commit, verdict.witness) }
1174
+ : { ok: true };
696
1175
  case 'fresh-target':
697
1176
  return { ok: true };
698
1177
 
@@ -703,17 +1182,42 @@ export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyConte
703
1182
  ? { ok: false, reason: 'this is not a git checkout and the plan is DESTRUCTIVE, so the fast-forward rule cannot be verified — run db:apply from the git checkout that declares your models.' }
704
1183
  : { ok: true };
705
1184
 
706
- case 'unverifiable':
707
1185
  case 'drift':
708
- // No single declaring commit — nothing to attest ABOUT. The escape is to make HEAD declare the
709
- // live state, then re-plan. An attestation here is a category error, refused as one.
1186
+ // PROVEN drift is the operator's decision: no ref anywhere declares live, so there is no merged
1187
+ // work to revert. It passes, and its origin is recorded. An attestation here is a category
1188
+ // error — there is no single declaring commit to attest about — refused as one.
1189
+ if (ctx.attestation) {
1190
+ return {
1191
+ ok: false,
1192
+ reason: `--attest-lineage-lost does not apply here: there is no single declaring commit to attest about. ${verdict.reason} Proven drift needs no attestation — drop the flag and re-run.`,
1193
+ };
1194
+ }
1195
+ return { ok: true, note: driftOriginNote(verdict.origin) };
1196
+
1197
+ case 'unverifiable':
1198
+ // The search could not finish (an unreadable tree, or a clone that is not full and current).
1199
+ // The verdict carries its own fetch/retry runbook; nothing to attest ABOUT.
710
1200
  return {
711
1201
  ok: false,
712
1202
  reason: ctx.attestation
713
- ? `--attest-lineage-lost does not apply here: there is no single declaring commit to attest about. ${verdict.reason} Commit your models so HEAD declares this state (db:generate), then db:plan and db:apply again.`
714
- : `${verdict.reason} Commit your models so HEAD declares this state (db:generate), then db:plan and db:apply again.`,
1203
+ ? `--attest-lineage-lost does not apply here: there is no single declaring commit to attest about. ${verdict.reason}`
1204
+ : verdict.reason,
715
1205
  };
716
1206
 
1207
+ case 'declared-elsewhere': {
1208
+ // A commit on a ref we hold declares live and HEAD does not descend from it. Reachable by
1209
+ // construction, so the lineage-lost attestation can never be honoured here — the proof refuses
1210
+ // it as reachable. The escape is git-native: merge or rebase.
1211
+ if (!ctx.attestation) return { ok: false, reason: verdict.reason };
1212
+ if (!attestsCommit(ctx.attestation, verdict.commit)) {
1213
+ 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.` };
1214
+ }
1215
+ const proof = ctx.proveLineage(verdict.commit);
1216
+ return proof.proven
1217
+ ? { ok: true, attested: { commit: verdict.commit, subcase: proof.subcase } }
1218
+ : { ok: false, reason: proof.reason };
1219
+ }
1220
+
717
1221
  case 'revert':
718
1222
  case 'unknown-commit': {
719
1223
  // These name a declaring commit. Without an attestation the flagless escape (git revert / fetch)
@@ -724,10 +1228,11 @@ export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyConte
724
1228
  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' : ''}.`,
725
1229
  };
726
1230
  }
727
- if (ctx.attestation !== verdict.commit) {
1231
+ if (!attestsCommit(ctx.attestation, verdict.commit)) {
1232
+ // Both in FULL: a short form on both sides once printed two identical strings as a mismatch.
728
1233
  return {
729
1234
  ok: false,
730
- 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.`,
1235
+ 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.`,
731
1236
  };
732
1237
  }
733
1238
  const proof = ctx.proveLineage(verdict.commit);