@everystack/cli 0.4.93 → 0.4.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,51 +1,30 @@
1
1
  /**
2
- * git-descent — a protected database is a git ref (brick 7 of the
3
- * migration-authority design).
2
+ * git-descent — a protected database is a git ref (brick 7 of the migration-authority design).
4
3
  *
5
- * The rule git already taught everyone: `db:apply` is a fast-forward push.
6
- * The target's live fingerprint maps to the git commit whose committed
7
- * models DECLARE that state; the deploying checkout must be a descendant
8
- * of such a commit, or the apply refuses — "rebase first". This is what
9
- * stops the multi-dev disaster case: A merges and deploys, B (branched
10
- * before A merged) mints against the target — the concurrency lock passes,
11
- * because B's plan was minted against current reality, but B's edge would
12
- * REVERT A's merged work. The descent check catches exactly that: the
13
- * commit declaring the target's state is not in B's history.
4
+ * The rule git already taught everyone: `db:apply` is a fast-forward push. The commit that last
5
+ * deployed the target's state must be in the deploying checkout's history, or the apply refuses —
6
+ * "rebase first". This stops the multi-dev disaster case: A merges and deploys, B (branched before
7
+ * A merged) mints against the target — the concurrency lock passes, because B's plan was minted
8
+ * against current reality, but B's edge would REVERT A's work. The deploying commit is not in B's
9
+ * history, so B is refused.
14
10
  *
15
- * The search runs over GIT ALONE, never `schema_log` (the ledger is a
16
- * memoir, not a control surface — design decision 7): enumerate every
17
- * historical state of the models directory across all refs, compile each
18
- * unique tree, and ask whether its predicted live endpoint equals the
19
- * target's live fingerprint (brick 4's merged prediction, so unmodeled
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.
11
+ * The proof reads the `schema_log` ledger and asks git about ancestry — nothing else. A row is a
12
+ * witness only when its fingerprint matches live (so a stale or forged row proves nothing); the
13
+ * newest recorded apply's commit must be in HEAD's history either way. A database whose ledger
14
+ * shows no successful apply was never deployed by everystack, so there is nothing to revert. Any
15
+ * drift PASSES with its origin recorded; every removal still meets the destructive ceremony.
23
16
  *
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).
17
+ * Retired 2026-09-25 (Fable-ruled): recompiling every historical models tree to look for one that
18
+ * declares live. On a consumer with 257 trees it took 7.6 minutes and refused, because 33 no longer
19
+ * compiled; every path that records an apply already surfaces as the newest ledger row.
34
20
  */
35
21
 
36
22
  import { execFileSync } from 'node:child_process';
37
23
  import fs from 'node:fs';
38
24
  import path from 'node:path';
39
- import { pathToFileURL } from 'node:url';
40
- import type { ModelDescriptor } from '@everystack/model';
41
25
  import type { SchemaSnapshot } from './schema-introspect';
42
26
  import type { AuthzContract } from './authz-contract';
43
27
  import { fingerprintLive, fingerprintLiveAtVersion, type CanonHasher } from './schema-fingerprint';
44
- import { predictLiveFingerprint, governedLiveFingerprint } from './edge-plan';
45
- import { governedRolesForModels } from './schema-fingerprint';
46
-
47
- /** Compile a models barrel on disk. The default rides the CLI's runtime (tsx). */
48
- export type ModelsLoader = (barrel: string) => Promise<ModelDescriptor[]>;
49
28
 
50
29
  export interface LiveState {
51
30
  snapshot: SchemaSnapshot;
@@ -55,23 +34,6 @@ export interface LiveState {
55
34
  export interface DescentOptions {
56
35
  /** Where git commands run. Default: process.cwd(). */
57
36
  cwd?: string;
58
- /** The Postgres schema the Models default to. Default: `public`. */
59
- schema?: string;
60
- loader?: ModelsLoader;
61
- /**
62
- * How a tree is extracted. Default: {@link materializeTree}.
63
- *
64
- * Injected for the same reason `loader` is: an in-module call goes through the local
65
- * binding, so it cannot be spied on, and extraction failure is a condition that MUST be
66
- * covered by a test — it is the one that used to be misreported as database drift.
67
- */
68
- materialize?: (tree: string, dest: string, cwd: string) => void;
69
- /**
70
- * Where historical trees materialize. Default: a fresh temp dir under the
71
- * repo's node_modules — bare imports (`@everystack/model`) must resolve
72
- * from the materialized files, and node walks parent directories.
73
- */
74
- materializeRoot?: string;
75
37
  /** The ref that must descend from the declaring commit. Default: HEAD. */
76
38
  headRef?: string;
77
39
  }
@@ -97,13 +59,6 @@ function git(args: string[], cwd: string): string | null {
97
59
  }
98
60
  }
99
61
 
100
- const defaultLoader: ModelsLoader = async (barrel) => {
101
- const mod: any = await import(pathToFileURL(path.resolve(barrel)).href);
102
- const models = mod.models ?? mod.default;
103
- if (!Array.isArray(models)) throw new Error(`${barrel} does not export a \`models\` array`);
104
- return models;
105
- };
106
-
107
62
  /**
108
63
  * The SQUASHED SUCCESSOR of a witness commit HEAD does not descend from: an ancestor of HEAD that
109
64
  * carries the witness's models tree UNCHANGED (same tree oid) and is not itself an ancestor of
@@ -154,13 +109,9 @@ export function enumerateModelTrees(
154
109
  cwd: string,
155
110
  opts: { ancestorsOf?: string } = {},
156
111
  ): ModelTreeCandidate[] {
157
- // Default: every historical state across ALL refs (the whole-history search). With
158
- // `ancestorsOf`, restrict to the ancestors of one commit — the fallback proof only ever
159
- // considers trees the checkout descends from, so a diverged side branch is not even a
160
- // candidate (a non-ancestor declaring commit is the ledger's REVERT case, not recompile's).
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.
112
+ // Default: every historical state across ALL refs. With `ancestorsOf`, only the ancestors of one
113
+ // commit — the squash-successor check's question. `--all` would walk refs/stash too; a stash is a
114
+ // WIP snapshot, never a deployed state, so it is excluded.
164
115
  const range = opts.ancestorsOf ? [opts.ancestorsOf] : ['--exclude=refs/stash', '--all'];
165
116
  const out = git(['rev-list', ...range, '--full-history', '--date-order', '--', modelsDir], cwd);
166
117
  if (out === null) return [];
@@ -194,259 +145,6 @@ export function enumerateModelTrees(
194
145
  return order.map((tree) => ({ tree, commits: byTree.get(tree)! }));
195
146
  }
196
147
 
197
- /**
198
- * Extract a tree into `dest`, via a temporary archive FILE — never a pipe.
199
- *
200
- * This used to buffer `git archive` into memory (512MB `maxBuffer`) and hand that buffer to
201
- * `tar` as `input`. `execFileSync` writes the whole buffer to the child's stdin, so if the
202
- * child is not draining it the write fails with **EPIPE** — and under a loaded machine it
203
- * does. Measured 2026-08-11 on a full-repo run: `spawnSync tar EPIPE`, recorded against a
204
- * historical tree, twice in four runs.
205
- *
206
- * It failed badly, not loudly: the descent recompile recorded it as a COMPILE failure and
207
- * returned `drift`, telling the operator their database had been hand-edited. See the
208
- * `unverifiable` verdict for that half of the fix; this is the half that stops the failure happening.
209
- *
210
- * `git archive -o` writes the archive itself and `tar -xf <file>` reads it — two independent
211
- * processes, no shared pipe, no backpressure to lose, and no multi-hundred-MB buffer through
212
- * the parent. Retrying the pipe harder would only have made it rarer.
213
- */
214
- export function materializeTree(tree: string, dest: string, cwd: string): void {
215
- fs.mkdirSync(dest, { recursive: true });
216
- // Sibling of `dest`, not inside it — a file under `dest` would be extracted over.
217
- const archivePath = `${dest}.tar`;
218
- try {
219
- execFileSync('git', ['archive', '--format=tar', '-o', archivePath, tree], { cwd });
220
- execFileSync('tar', ['-xf', archivePath, '-C', dest]);
221
- } finally {
222
- fs.rmSync(archivePath, { force: true });
223
- }
224
- }
225
-
226
- /**
227
- * TWO KINDS OF FAILURE, and conflating them is what made this path lie once.
228
- *
229
- * - `declares`: this tree's committed models DECLARE the live state (or not) — a real,
230
- * deterministic property of the tree.
231
- * - `compile-failed`: the tree no longer builds against today's @everystack/model. Also a
232
- * real property of the tree; skipping it is correct, but it means the search was not able
233
- * to read that state.
234
- * - `unevaluated`: extraction (git archive / tar) failed. This says NOTHING about the tree —
235
- * we never saw it. Reporting it as drift told operators their database was hand-edited; the
236
- * real cause was a lost pipe under load. It must never harden into a drift conclusion.
237
- */
238
- type TreeEval =
239
- | { kind: 'declares' }
240
- | { kind: 'no' }
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 };
244
-
245
- interface EvalContext {
246
- repoRoot: string;
247
- materializeRoot: string;
248
- barrel: string;
249
- loader: ModelsLoader;
250
- materialize: (tree: string, dest: string, cwd: string) => void;
251
- schema?: string;
252
- }
253
-
254
- /**
255
- * Materialize a candidate tree (one retry — extraction can fail transiently under load) and
256
- * ask whether its committed models declare the live state. The declares-test is GOVERNED on
257
- * both sides, per candidate: the commit's models define the governed set, and the live hash is
258
- * filtered through the SAME set — comparing a governed prediction against a raw live hash can
259
- * never match on a brownfield target with foreign roles.
260
- */
261
- async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx: EvalContext): Promise<TreeEval> {
262
- const dest = path.join(ctx.materializeRoot, candidate.tree);
263
- const where = `${short(candidate.tree)} (at ${short(candidate.commits[0])})`;
264
-
265
- try {
266
- if (!fs.existsSync(dest)) ctx.materialize(candidate.tree, dest, ctx.repoRoot);
267
- } catch {
268
- try {
269
- fs.rmSync(dest, { recursive: true, force: true });
270
- ctx.materialize(candidate.tree, dest, ctx.repoRoot);
271
- } catch (err: any) {
272
- const error = `could not extract — ${firstLine(err.message)}`;
273
- return { kind: 'unevaluated', detail: `${where}: ${error}`, error };
274
- }
275
- }
276
-
277
- // A materialized tree that carries NO barrel DECLARES NOTHING — a clean, deterministic 'no',
278
- // not a failure to read. Every pre-Models repo has such ancestors: the models directory
279
- // scaffolded (or present for another reason) before its barrel existed. Letting the loader
280
- // throw on the absent file would classify it `compile-failed`, poisoning the verdict to
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.
284
- const barrelPath = path.join(dest, ctx.barrel);
285
- if (!fs.existsSync(barrelPath)) return { kind: 'no' };
286
-
287
- const evaluate = async (): Promise<boolean> => {
288
- const models = await ctx.loader(barrelPath);
289
- // A barrel that compiles to ZERO models declares NOTHING. The prediction rides every
290
- // undeclared live table through unchanged, so an empty models array "predicts" any live
291
- // state exactly — it would declare every database there is. Absent barrel and empty barrel
292
- // are the same clean 'no'. (Closing-gate review round 2, 2026-09-07: on a side branch this
293
- // minted a false declared-elsewhere; as a HEAD ancestor it silently proved a fast-forward.)
294
- if (models.length === 0) return false;
295
- const governedRoles = governedRolesForModels(models);
296
- const predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: ctx.schema, governedRoles });
297
- return predicted === governedLiveFingerprint(live.snapshot, live.contract, governedRoles);
298
- };
299
- try {
300
- let declares: boolean;
301
- try {
302
- declares = await evaluate();
303
- } catch {
304
- declares = await evaluate();
305
- }
306
- return declares ? { kind: 'declares' } : { kind: 'no' };
307
- } catch (err: any) {
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}`;
400
- }
401
- return `${lines.join('\n')}\n${[lead, prune, rewrite].filter(Boolean).join(' ')}`;
402
- }
403
-
404
- /**
405
- * A compile/extraction error, summarized for a refusal that is RECORDED in a shared stage's
406
- * schema_log: the cause, never the operator's filesystem.
407
- *
408
- * - MODULE_NOT_FOUND: line 1 is the cause ("Cannot find module '@everystack/model'"); the
409
- * `Require stack:` that follows is absolute local paths — dropped.
410
- * - An esbuild TransformError (the "older tree that no longer builds" case): line 1 is only the
411
- * header "Transform failed with 1 error:" and the cause is on line 2 — kept, with the sandbox
412
- * path prefix stripped so what remains is `db/models/index.ts:2:0: ERROR: …`.
413
- * - An empty message says so instead of rendering nothing.
414
- *
415
- * (Closing-gate review round 5: a bare first-line take blanked the cause on exactly the class the
416
- * `unverifiable` message exists to explain.)
417
- */
418
- export function summarizeCompileError(message: unknown): string {
419
- const lines = String(message ?? '').split('\n').map((l) => l.trimEnd());
420
- const stop = lines.findIndex((l) => /^\s*Require stack:/.test(l));
421
- const useful = (stop === -1 ? lines : lines.slice(0, stop)).filter((l) => l.trim().length > 0);
422
- if (useful.length === 0) return '(error with no message)';
423
- const take = /^Transform failed with \d+ errors?:?$/.test(useful[0].trim()) && useful.length > 1 ? useful.slice(0, 2) : useful.slice(0, 1);
424
- return take
425
- .map((l) => l.replace(/\/[^\s:'"]*\.everystack-descent-[^/\s]+\/[0-9a-f]+\//g, '').trim())
426
- .join(' — ');
427
- }
428
- const firstLine = summarizeCompileError;
429
-
430
- /**
431
- * Where historical trees materialize so that the materialized barrel can `import '@everystack/model'`:
432
- * node walks UP from the importing file, so the sandbox must sit below a `node_modules` that holds
433
- * the package. That is the nearest `node_modules` ABOVE THE MODELS BARREL, not the repo root's —
434
- * in a monorepo the app (`apps/expo/db/models`) has `apps/expo/node_modules`, and the root
435
- * `node_modules` may not carry the package at all (closing-gate review round 4, reproduced). No
436
- * `node_modules` anywhere up to the repo root → the repo root itself (a nested checkout still walks
437
- * up to its host's). Never the OS temp dir — that resolves nothing (the 0.4.74 sandbox-resolution bug).
438
- */
439
- export function materializeBase(repoRoot: string, modelsAbs: string): string {
440
- let dir = path.dirname(modelsAbs);
441
- while (dir.startsWith(repoRoot)) {
442
- const nm = path.join(dir, 'node_modules');
443
- if (fs.existsSync(nm)) return nm;
444
- if (dir === repoRoot) break;
445
- dir = path.dirname(dir);
446
- }
447
- return repoRoot;
448
- }
449
-
450
148
  /** Resolve the repo root, HEAD, and the models directory/barrel split — shared setup. */
451
149
  function resolveRepoContext(
452
150
  modelsPath: string,
@@ -737,182 +435,204 @@ export interface DescentLedgerRow {
737
435
  export const isAppliedOutcome = (outcome: string): boolean => outcome === 'applied' || outcome.startsWith('applied — ');
738
436
 
739
437
  /**
740
- * Where a proven drift came from — informs the warning and the schema_log note, never the
741
- * verdict (the ledger is a memoir, decision 7). `never-declared`: no successful state apply is
742
- * recorded here — first adoption of a database everystack never wrote. `hand-edited`: everystack
743
- * did apply here once (the newest such row is named), and the database has moved since, outside
744
- * the tool.
438
+ * Where a drift came from — informs the warning and the schema_log note, never the verdict.
439
+ * `never-declared`: no successful apply here recorded a commit — none at all (first adoption), or
440
+ * only no-git ones. `hand-edited`: everystack applied here from `commit`, your checkout contains it,
441
+ * and the database has moved since, outside the tool.
745
442
  */
746
443
  export type DriftOrigin =
747
- | { kind: 'never-declared' }
444
+ | { kind: 'never-declared'; skipped?: NeverDeclaredSkip }
748
445
  /** `dirty`: that last apply ran from a dirty worktree, so its commit never declared what it applied. */
749
- | { kind: 'hand-edited'; commit: string | null; appliedAt: string; dirty?: boolean };
446
+ | { kind: 'hand-edited'; commit: string; appliedAt: string; dirty?: boolean };
750
447
 
751
448
  export type ProveVerdict =
752
449
  /** The database has no tables — first apply bootstraps it. */
753
450
  | { status: 'fresh-target' }
754
451
  /** Not a git checkout — the rule cannot be verified here. */
755
452
  | { status: 'no-git' }
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 }
761
- /** The newest ledger witness is NOT an ancestor of HEAD — applying would revert merged work. */
453
+ /** Fast-forward proven by a ledger witness, or by its squashed successor. For `squash`, `commit`
454
+ * is the CARRIER (an ancestor of HEAD holding the deploying commit's models tree) and `witness`
455
+ * is the deploying commit the ledger names — both surfaced, the weaker proof recorded. */
456
+ | { status: 'proven'; via: 'ledger' | 'squash'; commit: string; witness?: string; newerOffHead?: { commit: string; appliedAt: string } }
457
+ /** The deploying commit is NOT in HEAD's history — applying would revert deployed work. */
762
458
  | { status: 'revert'; commit: string; appliedAt: string; reason: string }
763
- /** The witness names a commit this clone does not have (shallow) — fetch, do not guess. */
459
+ /** The ledger names a commit this clone does not have — fetch, do not guess. */
764
460
  | { status: 'unknown-commit'; commit: string; reason: string }
765
- /** A ref HEAD does not descend from declares the live state — a teammate's merged work. */
766
- | { status: 'declared-elsewhere'; commit: string; reason: string }
767
- /** The search could not read every tree, or the clone is not full and current — inconclusive. */
768
- | { status: 'unverifiable'; reason: string; unevaluated: string[]; compileFailures: string[] }
769
- /** Every tree on every ref read cleanly in a full, current clone, and none declares live —
770
- * PROVEN drift. Passes at apply: nothing merged exists to revert. */
771
- | { status: 'drift'; reason: string; scannedTrees: number; origin: DriftOrigin };
461
+ /** The ledger could not be read, so it cannot vouch for anything — retry. */
462
+ | { status: 'unverifiable'; reason: string }
463
+ /** No recorded apply matches live, and nothing deployed is outside HEAD's history. Passes at
464
+ * apply: the reviewed plan becomes the origin every later apply descends from. */
465
+ | { status: 'drift'; reason: string; origin: DriftOrigin };
466
+
467
+ /**
468
+ * What the schema_log read found, told apart from the rows. Only a clean read may say the ledger is
469
+ * empty: a ledger that could not be read is not an empty one.
470
+ */
471
+ export type LedgerRead =
472
+ | { kind: 'absent' }
473
+ /** `applied` counts every successful state apply, not only the witness rows the proof can use. */
474
+ | { kind: 'present'; applied: number; refusals: number; anchored: boolean }
475
+ | { kind: 'unreadable'; reason: string };
476
+
477
+ /** The record of a never-declared verdict — the only forensic trace that separates a wiped ledger from a new one. */
478
+ export interface NeverDeclaredSkip {
479
+ ledger: 'absent' | 'present';
480
+ /** Successful applies on record — none of which recorded a commit. */
481
+ applied: number;
482
+ refusals: number;
483
+ head: string;
484
+ from: string;
485
+ }
486
+
487
+ /** Printed on every plan and apply of a never-declared database — never behind --verbose. */
488
+ export const NEVER_DECLARED_NOTICE =
489
+ "No prior everystack apply on this database. Descent proof skipped — everystack cannot tell if this schema came from another branch. " +
490
+ "Another branch's `db:pull` may describe this database; if one exists, merge it first. The plan's drops are the whole review.";
772
491
 
773
492
  export interface ProveDescentOptions extends DescentOptions {
493
+ /** What the ledger read proved (readDescentLedgerRead / liveBaseAndLedger). Required: "empty" is
494
+ * only ever believed from a read that says so. */
495
+ ledgerRead: LedgerRead;
774
496
  /** Retained-canon registry override — the cross-bump recompute path (tests inject a
775
497
  * synthetic retired canon). Defaults to the module's RETAINED_CANONS. */
776
498
  canons?: Readonly<Record<number, CanonHasher>>;
777
- /** The git probe the drift proof uses to establish the clone is full and current.
778
- * Default: {@link realLineageProbe} over the repo root. Injected so tests can prove each
779
- * currency refusal without a remote. */
780
- probe?: GitLineageProbe;
781
499
  }
782
500
 
783
501
  const shortDate = (at: string): string => at.slice(0, 10);
784
502
 
785
503
  /**
786
- * The origin, in words — ONE rendering for the plan warning, the apply warning, and the recorded
787
- * note, so the dirty-worktree sub-case can never be surfaced in one place and dropped in another
788
- * (closing-gate review round 4: the flag was computed and reached no human and no record).
504
+ * The origin, in words — ONE rendering for the recorded note, so no sub-case is surfaced in one
505
+ * place and dropped in another (closing-gate review round 4).
789
506
  *
790
- * never-declared — no everystack apply is recorded here
791
- * hand-edited since <sha>, <date> — everystack applied from that commit; the database moved since
792
- * applied from a DIRTY worktree at <sha>, <date> — that commit never declared what was applied,
793
- * so nothing committed can declare this state
507
+ * never-declared (ledger: …; ancestry walk retired; HEAD …; from …)
508
+ * hand-edited since <sha>, <date>
509
+ * applied from a DIRTY worktree at <sha>, <date>
794
510
  */
795
- export function describeDriftOrigin(origin: DriftOrigin, opts: { human?: boolean } = {}): string {
796
- if (origin.kind === 'never-declared') return opts.human ? 'never declared: no everystack apply is recorded here' : 'never-declared';
797
- const at = origin.commit ? `${short(origin.commit)}, ${shortDate(origin.appliedAt)}` : `an apply on ${shortDate(origin.appliedAt)}`;
511
+ export function describeDriftOrigin(origin: DriftOrigin): string {
512
+ if (origin.kind === 'never-declared') {
513
+ const s = origin.skipped;
514
+ if (!s) return 'never-declared';
515
+ const ledger = s.ledger === 'absent'
516
+ ? 'absent'
517
+ : s.applied === 0
518
+ ? `present, 0 successful, ${s.refusals} refusal(s)`
519
+ : `present, ${s.applied} successful, 0 anchored, ${s.refusals} refusal(s)`;
520
+ return `never-declared (ledger: ${ledger}; ancestry walk retired; HEAD ${short(s.head)}; from ${short(s.from)})`;
521
+ }
522
+ const at = `${short(origin.commit)}, ${shortDate(origin.appliedAt)}`;
798
523
  return origin.dirty ? `applied from a DIRTY worktree at ${at}` : `hand-edited since ${at}`;
799
524
  }
800
525
 
801
- /** The recorded note for a proven drift — goes in the apply row's schema_log outcome. */
526
+ /** The recorded note for a drift — goes in the apply row's schema_log outcome. */
802
527
  export function driftOriginNote(origin: DriftOrigin): string {
803
- return `descent: drift-origin proven (${describeDriftOrigin(origin)})`;
528
+ return `descent: drift-origin ${describeDriftOrigin(origin)}`;
529
+ }
530
+
531
+ /** The loud line plan and apply both print for a drift — never behind --verbose. */
532
+ export function driftNotice(origin: DriftOrigin): string {
533
+ if (origin.kind === 'never-declared') {
534
+ const applied = origin.skipped?.applied ?? 0;
535
+ if (applied === 0) return NEVER_DECLARED_NOTICE;
536
+ return `everystack has applied here ${applied} time(s), but none recorded a commit (no git checkout), so nothing ` +
537
+ 'deployed from any branch is on record. Descent proof skipped — everystack cannot tell if this schema came from ' +
538
+ "another branch. Another branch's `db:pull` may describe this database; if one exists, merge it first. The " +
539
+ "plan's drops are the whole review.";
540
+ }
541
+ const dirty = origin.dirty ? ' That apply ran from a DIRTY worktree, so its commit never declared what it applied.' : '';
542
+ return `This database changed since everystack last applied here from ${short(origin.commit)} (${shortDate(origin.appliedAt)}), ` +
543
+ `and no recorded apply matches it now.${dirty} Your checkout contains that commit, so nothing deployed is reverted. ` +
544
+ "everystack cannot tell whether another branch's Models describe this state. The plan's drops are the whole review.";
804
545
  }
805
546
 
806
- /**
807
- * Is this clone full and current with every remote? The drift proof's precondition: "no ref
808
- * anywhere declares live" is only a claim we may make when we hold every ref there is. Same
809
- * probe as the lineage proof, but the question differs — currency, not the fate of one commit —
810
- * so an empty remote set is simply current (a purely local repo has nothing to be behind).
811
- * Refuse-closed: a failed `git remote` or an unreachable remote is NOT current.
812
- *
813
- * The test is REF reachability, never object presence. The scan this guards is `git rev-list
814
- * --all`, which walks refs; an advertised tip that is present as an object but contained by no
815
- * ref (a `git fetch origin <branch>` that wrote only FETCH_HEAD, or a locally deleted branch
816
- * the remote still has) is invisible to the scan and MUST count as behind. Testing
817
- * `objectExists` here let exactly that tip satisfy currency while the scan missed the models
818
- * it declared — found by the closing-gate review 2026-09-07, before it shipped.
819
- */
820
- export function proveCloneCurrent(git: GitLineageProbe): { current: true } | { current: false; reason: string } {
821
- if (git.isShallow()) {
822
- 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.' };
823
- }
824
- if (git.isPartial()) {
825
- return { current: false, reason: 'this is a partial clone, so objects can be missing locally — fetch the full history, then re-run.' };
826
- }
827
- const remotes = git.remotes();
828
- if (remotes === null) {
829
- return { current: false, reason: "could not list this repo's git remotes to confirm the clone is current — retry, or fetch the remotes first." };
830
- }
831
- for (const remote of remotes) {
832
- const tips = git.lsRemote(remote);
833
- if (tips === null) {
834
- return { current: false, reason: `cannot confirm the clone is current: ${unreadableRemote(remote, git)}` };
835
- }
836
- for (const tip of tips) {
837
- // refs/stash is excluded from the scan (enumerateModelTrees), so a tip contained ONLY by a
838
- // stash counts as behind here — the two sides of the currency proof see the same refs, and
839
- // this direction is refuse-closed. The probe itself stays raw: for the lineage proof a stash
840
- // that reaches a commit is a REACHABLE commit, and filtering it there would fall open.
841
- if (git.refsContaining(tip).filter((ref) => ref !== 'refs/stash').length === 0) {
842
- // A tip we HOLD that is not a commit and peels to none (a tag on a blob or tree — the
843
- // `junio-gpg-pub` shape) carries no models and can never be contained by a ref; it is
844
- // not evidence of being behind. An ABSENT tip, or a present commit no ref contains, is.
845
- if (git.peelsToCommit && git.peelsToCommit(tip) === false) continue;
846
- 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.` };
847
- }
848
- }
849
- }
850
- return { current: true };
547
+ /** Said beside a PROVEN verdict when a newer apply came from a commit outside HEAD's history — disclosure, not a gate. */
548
+ export function newerOffHeadNotice(verdict: ProveVerdict): string | null {
549
+ if (verdict.status !== 'proven' || !verdict.newerOffHead) return null;
550
+ const n = verdict.newerOffHead;
551
+ return `descent: a newer apply (${short(n.commit)}, ${shortDate(n.appliedAt)}) came from a commit outside your history; ` +
552
+ 'the database has since returned to a state your checkout declares. Applying moves forward from here.';
851
553
  }
852
554
 
853
555
  /**
854
- * The fast-forward proof, ledger-first. Refuse-closed: no inconclusive branch passes.
556
+ * The fast-forward proof: the ledger and git ancestry, nothing else. Refuse-closed.
855
557
  *
856
- * Phase A — the newest successful ledger row whose `to_fingerprint` matches the live state
857
- * (recomputed under the ROW'S canon) and whose git_ref is usable (non-null, not a dirty
858
- * mint) is THE witness — newest wins. Ancestor of HEAD → PROVEN; not an ancestor → REVERT;
859
- * absent from the clone → UNKNOWN-COMMIT. A null git_ref, a dirty mint, an unrecomputable
860
- * canon (pre-fp_version or a retired canon we no longer retain), or a non-matching
861
- * fingerprint SKIPS the row — the search continues to older rows.
558
+ * Ledger witness — the newest successful row whose `to_fingerprint` matches live (recomputed
559
+ * under the ROW'S canon) with a usable git_ref (non-null, not a dirty mint). Ancestor of HEAD →
560
+ * PROVEN; a squashed successor → PROVEN via squash; absent → UNKNOWN-COMMIT; else REVERT.
862
561
  *
863
- * Phase B — no usable ledger witness: recompile RESTRICTED TO HEAD ANCESTORS. A declaring
864
- * ancestor → PROVEN. Otherwise the classification is honest about what it could read: any
865
- * tree we could not extract or compile makes "nothing declares this" a claim we cannot make
866
- * → UNVERIFIABLE (never "drift"). Only a clean, exhaustive no-match is DRIFT.
562
+ * No witness — W is the newest successful row with a git_ref (a legacy row with no fp_version,
563
+ * or a dirty one, still anchors ancestry). W absent → UNKNOWN-COMMIT; W outside HEAD's history
564
+ * (no squashed successor) → REVERT; else DRIFT, hand-edited since W. No W on a clean read (no
565
+ * apply, or none that recorded a commit) → DRIFT, never-declared. A drift never rests on an
566
+ * unreadable read → UNVERIFIABLE.
867
567
  *
868
- * The old descent recompiled ALL history under the CURRENT CLI, so a stage whose declaring
869
- * commit no longer compiled was reported as drift and stranded. Phase A never recompiles; the
870
- * ledger carries the proof across a canon or API bump.
568
+ * Retired 2026-09-25 (Fable-ruled): recompiling every historical models tree to look for one that
569
+ * declares live. Every path that records an apply surfaces as the newest row and is checked above;
570
+ * what the walk added was a label, and on a consumer with 257 trees it took 7.6 minutes and then
571
+ * refused because 33 no longer compiled. Every removal still meets the destructive ceremony.
871
572
  */
872
573
  export async function proveDescent(
873
574
  modelsPath: string,
874
575
  live: LiveState,
875
576
  ledger: DescentLedgerRow[],
876
- opts: ProveDescentOptions = {},
577
+ opts: ProveDescentOptions,
877
578
  ): Promise<ProveVerdict> {
878
579
  if (live.snapshot.tables.length === 0) return { status: 'fresh-target' };
879
580
 
880
581
  const ctx = resolveRepoContext(modelsPath, opts);
881
582
  if (ctx === null) return { status: 'no-git' };
882
- const { repoRoot, head, modelsDir, barrel, modelsAbs } = ctx;
583
+ const { repoRoot, head, modelsDir } = ctx;
883
584
 
884
- // ---- Phase A: the ledger witness (newest usable row wins). ----
885
- // "Newest wins" (the S→T→S pre-episode refusal) is a security-relevant property, so do not
886
- // TRUST the caller's ordering — enforce it. Production callers read ORDER BY id DESC, but a
887
- // future caller passing an unsorted ledger must not silently let a pre-episode row win.
585
+ // "Newest wins" (the S→T→S pre-episode refusal) is security-relevant, so enforce the order here.
888
586
  const ordered = [...ledger].sort((a, b) => Number(b.id) - Number(a.id));
889
- for (const r of ordered) {
890
- if (!isAppliedOutcome(r.outcome)) continue; // only a successful state apply is a witness
891
- if (!r.toFingerprint) continue;
892
- if (!r.gitRef) continue; // a null git_ref cannot declare a commit
893
- if (r.dirty) continue; // a dirty mint's git_ref does not declare what was applied
894
- const liveFp = fingerprintLiveAtVersion(r.fpVersion, live.snapshot, live.contract, {}, opts.canons);
895
- if (liveFp === null) continue; // canon we cannot reproduce — skip, never guess
896
- if (liveFp !== r.toFingerprint) continue; // not this witness (a stale/forged hash fails here)
587
+ const applied = ordered.filter((r) => isAppliedOutcome(r.outcome));
588
+ const read = opts.ledgerRead;
589
+ // The anchor: the newest SUCCESSFUL apply that recorded a commit. Refusal rows never anchor, and a
590
+ // newer no-git row never hides an older commit — the read fetches this row uncapped.
591
+ const w = applied.find((r) => r.gitRef);
592
+ const outsideHead = (commit: string): boolean =>
593
+ !isAncestor(commit, head, repoRoot) && !squashedSuccessor(commit, head, modelsDir, repoRoot);
594
+ const unverifiable = (reason: string): ProveVerdict => ({
595
+ status: 'unverifiable',
596
+ reason:
597
+ `cannot verify the fast-forward rule — everystack.schema_log could not be read (${reason}). ` +
598
+ 'An unreadable ledger is not an empty one, so this is NOT drift. Retry; if it persists, the ' +
599
+ 'connection needs SELECT on everystack.schema_log.',
600
+ });
897
601
 
898
- // r is THE witness — the newest matching row with a usable git_ref. Decide on it; do not
899
- // fall back to an older row (the pre-episode branch of an S→T→S history is refused).
900
- if (!commitPresent(r.gitRef, repoRoot)) {
602
+ const behind = (commit: string, appliedAt: string, what: string): ProveVerdict | null => {
603
+ if (!commitPresent(commit, repoRoot)) {
901
604
  return {
902
605
  status: 'unknown-commit',
903
- commit: r.gitRef,
606
+ commit,
904
607
  reason:
905
- `the commit that last deployed this state (${short(r.gitRef)}) is not in this clone — ` +
906
- 'it is a shallow checkout, or the branch was not fetched. Fetch the full history ' +
907
- '(git fetch --unshallow, or fetch the deploying branch) and re-run; the fast-forward ' +
908
- 'rule cannot be proven against a commit you do not have.',
608
+ `${what} (${short(commit)}, ${shortDate(appliedAt)}) is not in this clone — it is a shallow checkout, ` +
609
+ 'or the branch was not fetched. Fetch the full history (git fetch --unshallow, or fetch the deploying ' +
610
+ 'branch) and re-run; the fast-forward rule cannot be proven against a commit you do not have.',
909
611
  };
910
612
  }
911
- if (isAncestor(r.gitRef, head, repoRoot)) {
912
- return { status: 'proven', via: 'ledger', commit: r.gitRef };
613
+ return null;
614
+ };
615
+
616
+ // ---- The ledger witness (newest usable matching row wins). ----
617
+ for (const r of applied) {
618
+ if (!r.toFingerprint || !r.gitRef || r.dirty) continue;
619
+ const liveFp = fingerprintLiveAtVersion(r.fpVersion, live.snapshot, live.contract, {}, opts.canons);
620
+ if (liveFp === null || liveFp !== r.toFingerprint) continue; // unrecomputable canon, or not this state
621
+ const missing = behind(r.gitRef, r.appliedAt, 'the commit that last deployed this state');
622
+ if (missing) return missing;
623
+ const ancestor = isAncestor(r.gitRef, head, repoRoot);
624
+ const squash = ancestor ? null : squashedSuccessor(r.gitRef, head, modelsDir, repoRoot);
625
+ if (ancestor || squash) {
626
+ const proven: ProveVerdict = squash
627
+ ? { status: 'proven', via: 'squash', commit: squash, witness: r.gitRef }
628
+ : { status: 'proven', via: 'ledger', commit: r.gitRef };
629
+ // A NEWER apply from outside HEAD's history, since reverted to this state by hand: the proof
630
+ // stands (live is what HEAD declares), but the operator is told.
631
+ if (w && Number(w.id) > Number(r.id) && w.gitRef !== r.gitRef && commitPresent(w.gitRef!, repoRoot) && outsideHead(w.gitRef!)) {
632
+ return { ...proven, newerOffHead: { commit: w.gitRef!, appliedAt: w.appliedAt } };
633
+ }
634
+ return proven;
913
635
  }
914
- const squash = squashedSuccessor(r.gitRef, head, modelsDir, repoRoot);
915
- if (squash) return { status: 'proven', via: 'squash', commit: squash, witness: r.gitRef };
916
636
  return {
917
637
  status: 'revert',
918
638
  commit: r.gitRef,
@@ -925,184 +645,55 @@ export async function proveDescent(
925
645
  };
926
646
  }
927
647
 
928
- // ---- Phase B: no usable ledger witness → ancestor-restricted recompile. ----
929
- const candidates = enumerateModelTrees(modelsDir, repoRoot, { ancestorsOf: head });
930
-
931
- // No ancestor carries a committed models directory to compare against — a shallow clone that
932
- // truncated the deploying history, or the deploying branch is simply not in this checkout.
933
- // That is a limit of THIS clone, not a hand-edit: UNVERIFIABLE, never drift.
934
- if (candidates.length === 0) {
935
- return {
936
- status: 'unverifiable',
937
- reason:
938
- 'cannot verify the fast-forward rule — no ancestor of your checkout carries a committed ' +
939
- 'models directory to compare against. This is a shallow clone (fetch the full history: ' +
940
- 'git fetch --unshallow) or the deploying branch is not in this checkout, not evidence ' +
941
- 'about the database — it is NOT drift. Fetch the deploying history and re-run.',
942
- unevaluated: [],
943
- compileFailures: [],
944
- };
945
- }
946
-
947
- // Historical trees materialize INSIDE the project, below the nearest node_modules above the
948
- // models barrel — see materializeBase. Never the OS temp dir.
949
- const materializeRoot = opts.materializeRoot
950
- ?? fs.mkdtempSync(path.join(materializeBase(repoRoot, modelsAbs), '.everystack-descent-'));
951
- const ownsRoot = !opts.materializeRoot;
952
- const evalCtx: EvalContext = {
953
- repoRoot,
954
- materializeRoot,
955
- barrel,
956
- loader: opts.loader ?? defaultLoader,
957
- materialize: opts.materialize ?? materializeTree,
958
- schema: opts.schema,
959
- };
960
-
961
- const compileFailures: string[] = [];
962
- const unevaluated: string[] = [];
963
- const unreadable: UnreadableTree[] = [];
964
- const probe = opts.probe ?? realLineageProbe(repoRoot);
965
- const lastApplied = ordered.find((r) => isAppliedOutcome(r.outcome));
966
- const unverifiable = (scope: string, scanned: number): ProveVerdict => {
967
- const notes: string[] = [];
968
- if (compileFailures.length > 0) notes.push(`${compileFailures.length} would not compile under this CLI`);
969
- if (unevaluated.length > 0) notes.push(`${unevaluated.length} could not be extracted`);
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.
974
- return {
975
- status: 'unverifiable',
976
- reason:
977
- `cannot verify the fast-forward rule — of ${scanned} ${scope} model state(s), ` +
978
- `${notes.join(' and ')}, and none of the readable ones declares the live state. This is a ` +
979
- 'limit of THIS run (an older tree that no longer builds, or a failed extraction), not ' +
980
- `evidence about the database — it is NOT drift. Unreadable:\n${unreadableRunbook(unreadable, lastApplied, probe)}`,
981
- unevaluated,
982
- compileFailures,
983
- };
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
- };
989
-
990
- let others: ModelTreeCandidate[] = [];
991
- try {
992
- for (const candidate of candidates) {
993
- const result = await evaluateTree(candidate, live, evalCtx);
994
- if (result.kind === 'unevaluated' || result.kind === 'compile-failed') {
995
- record(candidate, result, true);
996
- continue;
997
- }
998
- if (result.kind === 'no') continue;
999
- // Enumerated over HEAD's ancestors, so every commit here descends into HEAD — but confirm,
1000
- // since a tree can be carried by commits on more than one line of history.
1001
- const ancestor = candidate.commits.find((commit) => isAncestor(commit, head, repoRoot)) ?? candidate.commits[0];
1002
- return { status: 'proven', via: 'recompile', commit: ancestor };
1003
- }
1004
-
1005
- // A tree we could not READ is not evidence about the database. If any ancestor tree could not
1006
- // be extracted or compiled, "nothing declares this state" is a claim we are not entitled to
1007
- // make — the answer is UNVERIFIABLE, and its next move is retry/upgrade, never db:pull.
1008
- if (unevaluated.length > 0 || compileFailures.length > 0) return unverifiable('ancestor', candidates.length);
1009
-
1010
- // ---- Phase C: the drift proof — no ancestor declares live, so scan EVERY ref. ----
1011
- // The fast-forward rule protects MERGED work. Before calling the live state undeclared, look
1012
- // at every tree on every ref this clone holds: one that declares live belongs to a line of
1013
- // history HEAD does not descend from — a teammate deployed it — and applying our edge would
1014
- // revert it. That refuses BY NAME. Only when no tree anywhere declares live is there nothing
1015
- // to protect.
1016
- const seen = new Set(candidates.map((c) => c.tree));
1017
- const all = enumerateModelTrees(modelsDir, repoRoot);
1018
- // The all-refs walk must be a SUPERSET of the ancestor walk — every ancestor tree is reachable
1019
- // from `--all`. When it is not, `git rev-list --all` failed (a broken or dangling ref makes it
1020
- // exit 128 while the ancestor walk still succeeds) and returned nothing, which is NOT "no
1021
- // other refs". Reading an empty result as "nothing else declares live" fell open to drift;
1022
- // found by the closing-gate review 2026-09-07.
1023
- const allTrees = new Set(all.map((c) => c.tree));
1024
- if ([...seen].some((tree) => !allTrees.has(tree))) {
1025
- return {
1026
- status: 'unverifiable',
1027
- reason:
1028
- 'cannot verify the fast-forward rule — the walk over EVERY ref (git rev-list --all) did not ' +
1029
- 'return the trees the ancestor walk did, so it failed part-way: a broken or dangling ref ' +
1030
- '(run `git fsck`, prune the bad ref) or a git error. That is a limit of THIS clone, not ' +
1031
- 'evidence about the database — it is NOT drift. Repair the ref and re-run.',
1032
- unevaluated,
1033
- compileFailures,
1034
- };
1035
- }
1036
- // Currency BEFORE the all-refs compile: a clone that is shallow, partial, or behind a remote
1037
- // is told to fetch now, not after paying for every historical tree on every ref.
1038
- const currency = proveCloneCurrent(probe);
1039
- if (!currency.current) {
1040
- return {
1041
- status: 'unverifiable',
1042
- reason:
1043
- `cannot verify the fast-forward rule — no ancestor of your checkout declares the live state, and ` +
1044
- `${currency.reason} That is a limit of THIS clone, not evidence about the database — it is NOT drift.`,
1045
- unevaluated,
1046
- compileFailures,
1047
- };
1048
- }
1049
- others = all.filter((c) => !seen.has(c.tree));
1050
- for (const candidate of others) {
1051
- const result = await evaluateTree(candidate, live, evalCtx);
1052
- if (result.kind === 'unevaluated' || result.kind === 'compile-failed') {
1053
- record(candidate, result, false);
1054
- continue;
1055
- }
1056
- if (result.kind === 'no') continue;
1057
- const commit = candidate.commits[0];
648
+ // ---- No witness: the last deployed commit must be in your history. ----
649
+ // On the stage lane this ledger read and the fingerprint read are separate sessions and can
650
+ // straddle a concurrent apply. That window is closed at apply by the concurrency lock
651
+ // (live == plan.from, re-read server-side in one session) — not here.
652
+ if (w) {
653
+ const commit = w.gitRef!;
654
+ const missing = behind(commit, w.appliedAt, 'everystack last applied here from a commit that');
655
+ if (missing) return missing;
656
+ if (outsideHead(commit)) {
1058
657
  return {
1059
- status: 'declared-elsewhere',
658
+ status: 'revert',
1060
659
  commit,
660
+ appliedAt: w.appliedAt,
1061
661
  reason:
1062
- `a ref your checkout does NOT descend from declares the target's live state — commit ` +
1063
- `${short(commit)} carries the models that match it. Someone deployed from that line of ` +
1064
- 'history; applying your edge would revert their work. Fetch and merge or rebase onto it, ' +
1065
- 'then re-plan.',
662
+ `everystack last applied here from commit ${short(commit)} (${shortDate(w.appliedAt)}), which is NOT an ` +
663
+ 'ancestor of HEAD, and the database has changed since. Applying now would revert that deployed work. ' +
664
+ 'Pull the branch that deployed it, rebase onto it, and re-plan.',
1066
665
  };
1067
666
  }
1068
- } finally {
1069
- if (ownsRoot) fs.rmSync(materializeRoot, { recursive: true, force: true });
667
+ // A drift may only rest on a clean read: a partial one cannot vouch that W is the newest anchor.
668
+ if (read.kind === 'unreadable') return unverifiable(read.reason);
669
+ const origin: DriftOrigin = { kind: 'hand-edited', commit, appliedAt: w.appliedAt, ...(w.dirty ? { dirty: true } : {}) };
670
+ return { status: 'drift', origin, reason: driftNotice(origin) };
1070
671
  }
1071
672
 
1072
- const scannedTrees = candidates.length + others.length;
1073
- // A side branch we could not read is not evidence either — refuse-closed, naming the count so
1074
- // the operator can prune the branch that carries it.
1075
- if (unevaluated.length > 0 || compileFailures.length > 0) return unverifiable('reachable', scannedTrees);
673
+ if (read.kind === 'unreadable') return unverifiable(read.reason);
674
+ if (read.kind === 'present' && read.anchored) {
675
+ return {
676
+ status: 'unverifiable',
677
+ reason:
678
+ 'cannot verify the fast-forward rule — everystack.schema_log counts an apply with a commit, but no such row ' +
679
+ 'read back as one (an empty git_ref, or an outcome not written by everystack). That is a hand-edited ledger ' +
680
+ 'row, not a read failure: inspect everystack.schema_log and correct or remove it, then re-run.',
681
+ };
682
+ }
1076
683
 
1077
- // PROVEN DRIFT (the clone was proven full and current before the all-refs scan). The origin is
1078
- // read from the ledger for the MESSAGE only (memoir, not control): any successful state apply
1079
- // recorded here means everystack once wrote this database and it has moved since; none means
1080
- // this is the first adoption of a database everystack never wrote. A dirty-worktree apply is
1081
- // still an apply — its git_ref declares nothing (Phase A skips it as a witness), so the state
1082
- // it produced was never committed anywhere; the origin says so.
1083
- const origin: DriftOrigin = lastApplied
1084
- ? { kind: 'hand-edited', commit: lastApplied.gitRef, appliedAt: lastApplied.appliedAt, ...(lastApplied.dirty ? { dirty: true } : {}) }
1085
- : { kind: 'never-declared' };
1086
- const fp = short(fingerprintLive(live.snapshot, live.contract).hash);
1087
- // The scan is scoped to THIS checkout's models path: a ref that declares live from a different
1088
- // directory (a models move on another line of history) is outside it. Say so — the claim must
1089
- // be exactly as wide as the search.
1090
- const scanned = `read ${scannedTrees} model state(s) at ${modelsDir}/ across every ref of a full, current clone, all cleanly, and none matches`;
1091
- const runbook =
1092
- 'The reviewed plan you mint is the ORIGIN: `everystack db:plan --stage <name>` mints it, ' +
1093
- '`everystack db:apply --plan <file> --stage <name>` applies it, warns, and records the origin ' +
1094
- 'in schema_log; every later apply descends from that row.';
1095
- const reason = origin.kind === 'never-declared'
1096
- ? `no commit on any ref declares the target's live fingerprint ${fp} — ${scanned}. Nothing has ever ` +
1097
- 'declared this database (no everystack state apply is recorded here), so there is no merged work ' +
1098
- `the fast-forward rule could protect. ${runbook}`
1099
- : `no commit on any ref declares the target's live fingerprint ${fp} — ${scanned}. everystack last ` +
1100
- `applied here ${origin.commit ? `at commit ${short(origin.commit)} ` : ''}on ${shortDate(origin.appliedAt)}` +
1101
- (origin.dirty
1102
- ? ' from a DIRTY worktree — that commit never declared what was applied, so nothing committed can declare this state. '
1103
- : ', and the database has been changed outside the tool since (hand-edited). No ref declares what it is now, so ') +
1104
- `there is no merged work the fast-forward rule could protect; your committed Models are the source of truth. ${runbook}`;
1105
- return { status: 'drift', scannedTrees, origin, reason };
684
+ // No successful apply ever recorded a commit — none at all, or only no-git ones. Nothing deployed
685
+ // from any line of history is on record, so there is nothing to revert.
686
+ const origin: DriftOrigin = {
687
+ kind: 'never-declared',
688
+ skipped: {
689
+ ledger: read.kind === 'absent' ? 'absent' : 'present',
690
+ applied: read.kind === 'present' ? read.applied : 0,
691
+ refusals: read.kind === 'present' ? read.refusals : 0,
692
+ head,
693
+ from: fingerprintLive(live.snapshot, live.contract).hash,
694
+ },
695
+ };
696
+ return { status: 'drift', origin, reason: driftNotice(origin) };
1106
697
  }
1107
698
 
1108
699
  // ---------------------------------------------------------------------------
@@ -1196,8 +787,7 @@ export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyConte
1196
787
  return { ok: true, note: driftOriginNote(verdict.origin) };
1197
788
 
1198
789
  case 'unverifiable':
1199
- // The search could not finish (an unreadable tree, or a clone that is not full and current).
1200
- // The verdict carries its own fetch/retry runbook; nothing to attest ABOUT.
790
+ // The ledger could not be read. The verdict carries its own retry runbook; nothing to attest ABOUT.
1201
791
  return {
1202
792
  ok: false,
1203
793
  reason: ctx.attestation
@@ -1205,20 +795,6 @@ export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyConte
1205
795
  : verdict.reason,
1206
796
  };
1207
797
 
1208
- case 'declared-elsewhere': {
1209
- // A commit on a ref we hold declares live and HEAD does not descend from it. Reachable by
1210
- // construction, so the lineage-lost attestation can never be honoured here — the proof refuses
1211
- // it as reachable. The escape is git-native: merge or rebase.
1212
- if (!ctx.attestation) return { ok: false, reason: verdict.reason };
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.` };
1215
- }
1216
- const proof = ctx.proveLineage(verdict.commit);
1217
- return proof.proven
1218
- ? { ok: true, attested: { commit: verdict.commit, subcase: proof.subcase } }
1219
- : { ok: false, reason: proof.reason };
1220
- }
1221
-
1222
798
  case 'revert':
1223
799
  case 'unknown-commit': {
1224
800
  // These name a declaring commit. Without an attestation the flagless escape (git revert / fetch)