@everystack/cli 0.4.69 → 0.4.70

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.
@@ -31,7 +31,7 @@ import { pathToFileURL } from 'node:url';
31
31
  import type { ModelDescriptor } from '@everystack/model';
32
32
  import type { SchemaSnapshot } from './schema-introspect';
33
33
  import type { AuthzContract } from './authz-contract';
34
- import { fingerprintLive } from './schema-fingerprint';
34
+ import { fingerprintLive, fingerprintLiveAtVersion, type CanonHasher } from './schema-fingerprint';
35
35
  import { predictLiveFingerprint, governedLiveFingerprint } from './edge-plan';
36
36
  import { governedRolesForModels } from './schema-fingerprint';
37
37
 
@@ -67,26 +67,6 @@ export interface DescentOptions {
67
67
  headRef?: string;
68
68
  }
69
69
 
70
- export type DescentVerdict =
71
- /** Not a git checkout (or no commits yet) — the rule cannot be verified here. */
72
- | { status: 'no-git' }
73
- /** The target has no tables — nothing to protect, first apply bootstraps it. */
74
- | { status: 'fresh-target' }
75
- /** Fast-forward: `commit` declares the target's live state and is an ancestor of HEAD. */
76
- | { status: 'ok'; commit: string; tree: string; scannedTrees: number }
77
- /** The declaring commits exist but none is an ancestor of HEAD — rebase first. */
78
- | { status: 'diverged'; commits: string[]; tree: string; reason: string }
79
- /** No committed models state declares the live fingerprint — operator decision. */
80
- | { status: 'drift'; scannedTrees: number; compileFailures: string[]; reason: string }
81
- /**
82
- * One or more trees could not be EVALUATED at all — extraction or IO failed, so "no tree
83
- * declares this state" is not a conclusion we are entitled to draw. Distinct from `drift`
84
- * because the operator's next move is different: retry, not `db:pull`.
85
- *
86
- * `unevaluated` names the trees and why each failed.
87
- */
88
- | { status: 'indeterminate'; scannedTrees: number; unevaluated: string[]; reason: string };
89
-
90
70
  export interface ModelTreeCandidate {
91
71
  /** The models directory's tree oid — the identity of a declared state. */
92
72
  tree: string;
@@ -118,8 +98,17 @@ const defaultLoader: ModelsLoader = async (barrel) => {
118
98
  * `cat-file --batch-check` resolves all `<sha>:<dir>` specs in a single
119
99
  * child process, in input order.
120
100
  */
121
- export function enumerateModelTrees(modelsDir: string, cwd: string): ModelTreeCandidate[] {
122
- const out = git(['rev-list', '--all', '--full-history', '--date-order', '--', modelsDir], cwd);
101
+ export function enumerateModelTrees(
102
+ modelsDir: string,
103
+ cwd: string,
104
+ opts: { ancestorsOf?: string } = {},
105
+ ): ModelTreeCandidate[] {
106
+ // Default: every historical state across ALL refs (the whole-history search). With
107
+ // `ancestorsOf`, restrict to the ancestors of one commit — the fallback proof only ever
108
+ // considers trees the checkout descends from, so a diverged side branch is not even a
109
+ // candidate (a non-ancestor declaring commit is the ledger's REVERT case, not recompile's).
110
+ const range = opts.ancestorsOf ? [opts.ancestorsOf] : ['--all'];
111
+ const out = git(['rev-list', ...range, '--full-history', '--date-order', '--', modelsDir], cwd);
123
112
  if (out === null) return [];
124
113
  const shas = out.trim().split('\n').filter(Boolean);
125
114
  if (shas.length === 0) return [];
@@ -159,9 +148,9 @@ export function enumerateModelTrees(modelsDir: string, cwd: string): ModelTreeCa
159
148
  * does. Measured 2026-08-11 on a full-repo run: `spawnSync tar EPIPE`, recorded against a
160
149
  * historical tree, twice in four runs.
161
150
  *
162
- * It failed badly, not loudly: `verifyDescent` recorded it as a COMPILE failure and returned
163
- * `drift`, telling the operator their database had been hand-edited. See the `indeterminate`
164
- * verdict for that half of the fix; this is the half that stops the failure happening.
151
+ * It failed badly, not loudly: the descent recompile recorded it as a COMPILE failure and
152
+ * returned `drift`, telling the operator their database had been hand-edited. See the
153
+ * `unverifiable` verdict for that half of the fix; this is the half that stops the failure happening.
165
154
  *
166
155
  * `git archive -o` writes the archive itself and `tar -xf <file>` reads it — two independent
167
156
  * processes, no shared pipe, no backpressure to lose, and no multi-hundred-MB buffer through
@@ -180,164 +169,558 @@ export function materializeTree(tree: string, dest: string, cwd: string): void {
180
169
  }
181
170
 
182
171
  /**
183
- * The fast-forward rule. Given the target's live state (already
184
- * introspected — the apply path has it in hand), find the commit whose
185
- * committed models declare that state and verify the checkout descends
186
- * from it. Any declaring ancestor satisfies the rule; `diverged` is
187
- * reported only when NO tree declaring the state has one.
172
+ * TWO KINDS OF FAILURE, and conflating them is what made this path lie once.
173
+ *
174
+ * - `declares`: this tree's committed models DECLARE the live state (or not) — a real,
175
+ * deterministic property of the tree.
176
+ * - `compile-failed`: the tree no longer builds against today's @everystack/model. Also a
177
+ * real property of the tree; skipping it is correct, but it means the search was not able
178
+ * to read that state.
179
+ * - `unevaluated`: extraction (git archive / tar) failed. This says NOTHING about the tree —
180
+ * we never saw it. Reporting it as drift told operators their database was hand-edited; the
181
+ * real cause was a lost pipe under load. It must never harden into a drift conclusion.
188
182
  */
189
- export async function verifyDescent(
190
- modelsPath: string,
191
- live: LiveState,
192
- opts: DescentOptions = {},
193
- ): Promise<DescentVerdict> {
194
- if (live.snapshot.tables.length === 0) return { status: 'fresh-target' };
183
+ type TreeEval =
184
+ | { kind: 'declares' }
185
+ | { kind: 'no' }
186
+ | { kind: 'compile-failed'; detail: string }
187
+ | { kind: 'unevaluated'; detail: string };
188
+
189
+ interface EvalContext {
190
+ repoRoot: string;
191
+ materializeRoot: string;
192
+ barrel: string;
193
+ loader: ModelsLoader;
194
+ materialize: (tree: string, dest: string, cwd: string) => void;
195
+ schema?: string;
196
+ }
195
197
 
198
+ /**
199
+ * Materialize a candidate tree (one retry — extraction can fail transiently under load) and
200
+ * ask whether its committed models declare the live state. The declares-test is GOVERNED on
201
+ * both sides, per candidate: the commit's models define the governed set, and the live hash is
202
+ * filtered through the SAME set — comparing a governed prediction against a raw live hash can
203
+ * never match on a brownfield target with foreign roles.
204
+ */
205
+ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx: EvalContext): Promise<TreeEval> {
206
+ const dest = path.join(ctx.materializeRoot, candidate.tree);
207
+ const where = `${short(candidate.tree)} (at ${short(candidate.commits[0])})`;
208
+
209
+ try {
210
+ if (!fs.existsSync(dest)) ctx.materialize(candidate.tree, dest, ctx.repoRoot);
211
+ } catch {
212
+ try {
213
+ fs.rmSync(dest, { recursive: true, force: true });
214
+ ctx.materialize(candidate.tree, dest, ctx.repoRoot);
215
+ } catch (err: any) {
216
+ return { kind: 'unevaluated', detail: `${where}: could not extract — ${err.message}` };
217
+ }
218
+ }
219
+
220
+ const evaluate = async (): Promise<boolean> => {
221
+ const models = await ctx.loader(path.join(dest, ctx.barrel));
222
+ const governedRoles = governedRolesForModels(models);
223
+ const predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: ctx.schema, governedRoles });
224
+ return predicted === governedLiveFingerprint(live.snapshot, live.contract, governedRoles);
225
+ };
226
+ try {
227
+ let declares: boolean;
228
+ try {
229
+ declares = await evaluate();
230
+ } catch {
231
+ declares = await evaluate();
232
+ }
233
+ return declares ? { kind: 'declares' } : { kind: 'no' };
234
+ } catch (err: any) {
235
+ return { kind: 'compile-failed', detail: `${where}: ${err.message}` };
236
+ }
237
+ }
238
+
239
+ /** Resolve the repo root, HEAD, and the models directory/barrel split — shared setup. */
240
+ function resolveRepoContext(
241
+ modelsPath: string,
242
+ opts: DescentOptions,
243
+ ): { repoRoot: string; head: string; modelsDir: string; barrel: string } | null {
196
244
  const cwd = opts.cwd ?? process.cwd();
197
245
  const rootOut = git(['rev-parse', '--show-toplevel'], cwd);
198
- if (rootOut === null) return { status: 'no-git' };
246
+ if (rootOut === null) return null;
199
247
  const repoRoot = rootOut.trim();
200
248
  const headOut = git(['rev-parse', opts.headRef ?? 'HEAD'], cwd);
201
- if (headOut === null) return { status: 'no-git' };
249
+ if (headOut === null) return null;
202
250
  const head = headOut.trim();
203
-
204
- // realpath the cwd side: `--show-toplevel` resolves symlinks (macOS tmpdir
205
- // is /var → /private/var), and a mismatched prefix would walk the models
206
- // path right out of the repo.
251
+ // realpath the cwd side: `--show-toplevel` resolves symlinks (macOS tmpdir is /var →
252
+ // /private/var), and a mismatched prefix would walk the models path right out of the repo.
207
253
  const modelsAbs = path.resolve(fs.realpathSync(cwd), modelsPath);
208
254
  const rel = path.relative(repoRoot, modelsAbs);
209
- const modelsDir = path.dirname(rel) || '.';
210
- const barrel = path.basename(modelsAbs);
255
+ return { repoRoot, head, modelsDir: path.dirname(rel) || '.', barrel: path.basename(modelsAbs) };
256
+ }
257
+
258
+ /** `git cat-file -e <ref>^{commit}` — is the commit present in THIS clone (not a shallow gap)? */
259
+ function commitPresent(ref: string, cwd: string): boolean {
260
+ try {
261
+ execFileSync('git', ['cat-file', '-e', `${ref}^{commit}`], { cwd, stdio: 'ignore' });
262
+ return true;
263
+ } catch {
264
+ return false;
265
+ }
266
+ }
267
+
268
+ /** `git merge-base --is-ancestor <ancestor> <head>` — does HEAD descend from the commit? */
269
+ function isAncestor(ancestor: string, head: string, cwd: string): boolean {
270
+ try {
271
+ execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore' });
272
+ return true;
273
+ } catch {
274
+ return false;
275
+ }
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Lineage proof — is a declaring commit UNREACHABLE from every ref?
280
+ //
281
+ // This is the ONE case the `--attest-lineage-lost` attestation covers (Fable-adjudicated
282
+ // 2026-08-26, replacing the retired `--force-descent`): the commit that declared the target's live
283
+ // state is gone from every ref (a since-rebased or deleted branch), so no `git revert` can reach it
284
+ // and no re-plan can express it. Everything else has a flagless git-native escape, so the attestation
285
+ // must be structurally INERT there — this proof is what makes it refusable when the operator is merely
286
+ // stale. "Unreachable" is PROVEN, never assumed: a shallow/partial clone, or a clone behind any
287
+ // remote, cannot tell "gone" from "not fetched", so those refuse the attestation rather than honour it.
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /** The git operations the lineage proof needs, injected so the proof logic is testable without a repo. */
291
+ export interface GitLineageProbe {
292
+ /** `git rev-parse --is-shallow-repository` === true. */
293
+ isShallow(): boolean;
294
+ /** A partial clone (a promisor remote / `extensions.partialClone`), where absence is not proof. */
295
+ isPartial(): boolean;
296
+ /** `git cat-file -e <sha>^{commit}` — is the commit object in this repo at all? */
297
+ objectExists(sha: string): boolean;
298
+ /** `git for-each-ref --contains=<sha>` over ALL of refs/ — the full ref names that contain it. */
299
+ refsContaining(sha: string): string[];
300
+ /** `git merge-base --is-ancestor <sha> <committish>` — is sha an ancestor of committish? */
301
+ isAncestorOf(sha: string, committish: string): boolean;
302
+ /** `git remote` — configured remote names; null when the command itself failed (not an empty
303
+ * list). Null must refuse: a git failure that read as "no remotes" would skip the remote-currency
304
+ * loop and let a locally-absent commit prove "gone", falling OPEN on the attestation. */
305
+ remotes(): string[] | null;
306
+ /** `git ls-remote --heads --tags <remote>` advertised tip shas; null when the remote is unreachable. */
307
+ lsRemote(remote: string): string[] | null;
308
+ }
309
+
310
+ export type LineageProof =
311
+ | { proven: true; subcase: 'revert-unreachable' | 'unknown-commit-proven-absent' }
312
+ | { proven: false; kind: 'shallow' | 'partial' | 'reachable' | 'behind-remote' | 'remote-unreachable' | 'remote-list-failed' | 'no-remotes'; reason: string };
313
+
314
+ /**
315
+ * Prove the declaring commit `sha` is reachable from NO ref, local or remote. Refuse-closed: every
316
+ * path that cannot PROVE "gone" returns `proven: false` with the flagless escape the operator should
317
+ * take instead. Only two paths prove it: the commit is present but in no local ref and no advertised
318
+ * remote tip's ancestry (a rebased-away branch), or it is absent from a full, remote-current clone
319
+ * (which could not be true if any reachable ref still held it).
320
+ */
321
+ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProof {
322
+ if (git.isShallow()) {
323
+ return { proven: false, kind: 'shallow', reason: `this is a shallow clone, so ${short(sha)} being absent is not proof it is gone — unshallow first (git fetch --unshallow), then re-run.` };
324
+ }
325
+ if (git.isPartial()) {
326
+ return { proven: false, kind: 'partial', reason: `this is a partial clone, so objects can be fetched lazily and absence is not proof — fetch the full history, then re-run.` };
327
+ }
328
+
329
+ const present = git.objectExists(sha);
330
+ if (present) {
331
+ const containing = git.refsContaining(sha);
332
+ if (containing.length > 0) {
333
+ 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.` };
334
+ }
335
+ if (git.isAncestorOf(sha, 'HEAD')) {
336
+ 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.` };
337
+ }
338
+ }
339
+
340
+ // Remote currency — the guard against mistaking "not fetched" for "gone". Every advertised tip must
341
+ // already be present locally; only then is a full clone's object graph complete enough to prove
342
+ // absence, and only then can we test sha against each remote branch's ancestry.
343
+ const remotes = git.remotes();
344
+ if (remotes === null) {
345
+ // The command that lists remotes failed. We cannot tell a remote-free repo from an unreadable one,
346
+ // so we cannot prove the remote-currency precondition — refuse rather than skip the loop (skipping
347
+ // it would let a locally-absent commit prove "gone", falling open on the attestation).
348
+ return { proven: false, kind: 'remote-list-failed', reason: `could not list this repo's git remotes to confirm ${short(sha)} is not on any of them — retry, or fetch the remotes first.` };
349
+ }
350
+ for (const remote of remotes) {
351
+ const tips = git.lsRemote(remote);
352
+ if (tips === null) {
353
+ 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.` };
354
+ }
355
+ for (const tip of tips) {
356
+ if (!git.objectExists(tip)) {
357
+ 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.` };
358
+ }
359
+ if (present && git.isAncestorOf(sha, tip)) {
360
+ 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.` };
361
+ }
362
+ }
363
+ }
364
+
365
+ // A commit PRESENT in the clone but reachable from no ref, and on no remote's ancestry, is
366
+ // genuinely lost here — no remote can make it reachable if none advertises it, so an empty remote
367
+ // set is fine for this subcase.
368
+ if (present) return { proven: true, subcase: 'revert-unreachable' };
369
+
370
+ // An ABSENT commit is a different claim. Absence is only PROOF of "gone" when the clone is
371
+ // complete (not shallow/partial — checked above) AND current with every remote the commit could
372
+ // still live on (the loop above confirmed each advertised tip is present locally). With NO remotes
373
+ // there was nothing to confirm against — an absent sha could simply be one this clone never had.
374
+ // Refuse rather than attest a commit lost we cannot see anywhere.
375
+ if (remotes.length === 0) {
376
+ return {
377
+ proven: false,
378
+ kind: 'no-remotes',
379
+ reason: `${short(sha)} is not in this clone, and this repo has no remotes to confirm it is gone — an absent commit here is not proof it is lost. Add the remote it was on and fetch, or revert in git and re-plan instead of attesting it lost.`,
380
+ };
381
+ }
382
+ return { proven: true, subcase: 'unknown-commit-proven-absent' };
383
+ }
211
384
 
212
- const candidates = enumerateModelTrees(modelsDir, repoRoot);
385
+ /** The real probe: each method is one git command run in `cwd`. */
386
+ export function realLineageProbe(cwd: string): GitLineageProbe {
387
+ const run = (args: string[]): string | null => {
388
+ try {
389
+ return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
390
+ } catch {
391
+ return null;
392
+ }
393
+ };
394
+ return {
395
+ isShallow: () => (run(['rev-parse', '--is-shallow-repository']) ?? '').trim() === 'true',
396
+ isPartial: () => {
397
+ // A promisor remote is the mark of a partial clone; `extensions.partialClone` names it.
398
+ const ext = run(['config', '--get', 'extensions.partialClone']);
399
+ return ext !== null && ext.trim().length > 0;
400
+ },
401
+ objectExists: (sha) => commitPresent(sha, cwd),
402
+ refsContaining: (sha) =>
403
+ (run(['for-each-ref', '--format=%(refname)', `--contains=${sha}`]) ?? '')
404
+ .split('\n').map((l) => l.trim()).filter(Boolean),
405
+ isAncestorOf: (sha, committish) => isAncestor(sha, committish, cwd),
406
+ remotes: () => {
407
+ const out = run(['remote']);
408
+ // null (the command threw) is NOT an empty remote set — it must refuse, not skip the loop.
409
+ return out === null ? null : out.split('\n').map((l) => l.trim()).filter(Boolean);
410
+ },
411
+ lsRemote: (remote) => {
412
+ const out = run(['ls-remote', '--heads', '--tags', remote]);
413
+ if (out === null) return null;
414
+ return out.split('\n').map((l) => l.split('\t')[0]?.trim()).filter((s): s is string => !!s && s.length >= 7);
415
+ },
416
+ };
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // proveDescent — the ledger-first fast-forward proof (apply-legibility Phase 1a).
421
+ // ---------------------------------------------------------------------------
422
+
423
+ /**
424
+ * One `everystack.schema_log` row, as descent reads it. The apply path writes these on every
425
+ * successful state apply; the fast-forward proof reads them newest-first.
426
+ *
427
+ * Trust note: the ledger is a memoir, and a forger with write access to it already has write
428
+ * access to the database. The proof does not rest on the ledger's honesty — the fingerprint
429
+ * join (`fingerprintLiveAtVersion(fp_version) == to_fingerprint`) forces a row to be correctly
430
+ * content-addressed before its git_ref is ever consulted. A row with a stale or wrong
431
+ * to_fingerprint simply does not match, and descent falls through as if it were not there.
432
+ */
433
+ export interface DescentLedgerRow {
434
+ id: string;
435
+ /** The raw live fingerprint recorded when this state was applied. */
436
+ toFingerprint: string | null;
437
+ /** HEAD at apply time — the commit whose ancestry proves the fast-forward. */
438
+ gitRef: string | null;
439
+ /** The FINGERPRINT_VERSION `to_fingerprint` was computed under (null = pre-fp_version row). */
440
+ fpVersion: number | null;
441
+ /** The apply ran from a dirty worktree — git_ref does not declare what was applied. */
442
+ dirty: boolean;
443
+ /** When the apply landed — for the REVERT message (names the commit + date). */
444
+ appliedAt: string;
445
+ /** Only a successful state apply is a witness. */
446
+ outcome: string;
447
+ }
448
+
449
+ export type ProveVerdict =
450
+ /** The database has no tables — first apply bootstraps it. */
451
+ | { status: 'fresh-target' }
452
+ /** Not a git checkout — the rule cannot be verified here. */
453
+ | { status: 'no-git' }
454
+ /** Fast-forward proven — via a ledger witness or an ancestor recompile. */
455
+ | { status: 'proven'; via: 'ledger' | 'recompile'; commit: string }
456
+ /** The newest ledger witness is NOT an ancestor of HEAD — applying would revert merged work. */
457
+ | { status: 'revert'; commit: string; appliedAt: string; reason: string }
458
+ /** The witness names a commit this clone does not have (shallow) — fetch, do not guess. */
459
+ | { status: 'unknown-commit'; commit: string; reason: string }
460
+ /** No ledger witness, and the ancestor recompile could not read every tree — inconclusive. */
461
+ | { status: 'unverifiable'; reason: string; unevaluated: string[]; compileFailures: string[] }
462
+ /** No ledger witness, every ancestor tree read cleanly, none declares live — real drift. */
463
+ | { status: 'drift'; reason: string; scannedTrees: number };
464
+
465
+ export interface ProveDescentOptions extends DescentOptions {
466
+ /** Retained-canon registry override — the cross-bump recompute path (tests inject a
467
+ * synthetic retired canon). Defaults to the module's RETAINED_CANONS. */
468
+ canons?: Readonly<Record<number, CanonHasher>>;
469
+ }
470
+
471
+ const shortDate = (at: string): string => at.slice(0, 10);
472
+
473
+ /**
474
+ * The fast-forward proof, ledger-first. Refuse-closed: no inconclusive branch passes.
475
+ *
476
+ * Phase A — the newest successful ledger row whose `to_fingerprint` matches the live state
477
+ * (recomputed under the ROW'S canon) and whose git_ref is usable (non-null, not a dirty
478
+ * mint) is THE witness — newest wins. Ancestor of HEAD → PROVEN; not an ancestor → REVERT;
479
+ * absent from the clone → UNKNOWN-COMMIT. A null git_ref, a dirty mint, an unrecomputable
480
+ * canon (pre-fp_version or a retired canon we no longer retain), or a non-matching
481
+ * fingerprint SKIPS the row — the search continues to older rows.
482
+ *
483
+ * Phase B — no usable ledger witness: recompile RESTRICTED TO HEAD ANCESTORS. A declaring
484
+ * ancestor → PROVEN. Otherwise the classification is honest about what it could read: any
485
+ * tree we could not extract or compile makes "nothing declares this" a claim we cannot make
486
+ * → UNVERIFIABLE (never "drift"). Only a clean, exhaustive no-match is DRIFT.
487
+ *
488
+ * The old descent recompiled ALL history under the CURRENT CLI, so a stage whose declaring
489
+ * commit no longer compiled was reported as drift and stranded. Phase A never recompiles; the
490
+ * ledger carries the proof across a canon or API bump.
491
+ */
492
+ export async function proveDescent(
493
+ modelsPath: string,
494
+ live: LiveState,
495
+ ledger: DescentLedgerRow[],
496
+ opts: ProveDescentOptions = {},
497
+ ): Promise<ProveVerdict> {
498
+ if (live.snapshot.tables.length === 0) return { status: 'fresh-target' };
499
+
500
+ const ctx = resolveRepoContext(modelsPath, opts);
501
+ if (ctx === null) return { status: 'no-git' };
502
+ const { repoRoot, head, modelsDir, barrel } = ctx;
503
+
504
+ // ---- Phase A: the ledger witness (newest usable row wins). ----
505
+ // "Newest wins" (the S→T→S pre-episode refusal) is a security-relevant property, so do not
506
+ // TRUST the caller's ordering — enforce it. Production callers read ORDER BY id DESC, but a
507
+ // future caller passing an unsorted ledger must not silently let a pre-episode row win.
508
+ const ordered = [...ledger].sort((a, b) => Number(b.id) - Number(a.id));
509
+ for (const r of ordered) {
510
+ if (r.outcome !== 'applied') continue; // only a successful state apply is a witness
511
+ if (!r.toFingerprint) continue;
512
+ if (!r.gitRef) continue; // a null git_ref cannot declare a commit
513
+ if (r.dirty) continue; // a dirty mint's git_ref does not declare what was applied
514
+ const liveFp = fingerprintLiveAtVersion(r.fpVersion, live.snapshot, live.contract, {}, opts.canons);
515
+ if (liveFp === null) continue; // canon we cannot reproduce — skip, never guess
516
+ if (liveFp !== r.toFingerprint) continue; // not this witness (a stale/forged hash fails here)
517
+
518
+ // r is THE witness — the newest matching row with a usable git_ref. Decide on it; do not
519
+ // fall back to an older row (the pre-episode branch of an S→T→S history is refused).
520
+ if (!commitPresent(r.gitRef, repoRoot)) {
521
+ return {
522
+ status: 'unknown-commit',
523
+ commit: r.gitRef,
524
+ reason:
525
+ `the commit that last deployed this state (${short(r.gitRef)}) is not in this clone — ` +
526
+ 'it is a shallow checkout, or the branch was not fetched. Fetch the full history ' +
527
+ '(git fetch --unshallow, or fetch the deploying branch) and re-run; the fast-forward ' +
528
+ 'rule cannot be proven against a commit you do not have.',
529
+ };
530
+ }
531
+ if (isAncestor(r.gitRef, head, repoRoot)) {
532
+ return { status: 'proven', via: 'ledger', commit: r.gitRef };
533
+ }
534
+ return {
535
+ status: 'revert',
536
+ commit: r.gitRef,
537
+ appliedAt: r.appliedAt,
538
+ reason:
539
+ `your checkout is behind the branch that last deployed this state — commit ${short(r.gitRef)} ` +
540
+ `(applied ${shortDate(r.appliedAt)}) is NOT an ancestor of HEAD. Applying now would revert ` +
541
+ 'that merged work. Pull the deploying branch, rebase onto it, and re-plan.',
542
+ };
543
+ }
544
+
545
+ // ---- Phase B: no usable ledger witness → ancestor-restricted recompile. ----
546
+ const candidates = enumerateModelTrees(modelsDir, repoRoot, { ancestorsOf: head });
213
547
 
214
548
  const nodeModules = path.join(repoRoot, 'node_modules');
215
549
  const materializeRoot = opts.materializeRoot
216
550
  ?? fs.mkdtempSync(path.join(fs.existsSync(nodeModules) ? nodeModules : os.tmpdir(), '.everystack-descent-'));
217
551
  const ownsRoot = !opts.materializeRoot;
218
- const loader = opts.loader ?? defaultLoader;
219
- const materialize = opts.materialize ?? materializeTree;
552
+ const evalCtx: EvalContext = {
553
+ repoRoot,
554
+ materializeRoot,
555
+ barrel,
556
+ loader: opts.loader ?? defaultLoader,
557
+ materialize: opts.materialize ?? materializeTree,
558
+ schema: opts.schema,
559
+ };
220
560
 
221
561
  const compileFailures: string[] = [];
222
562
  const unevaluated: string[] = [];
223
- const declaringButDiverged: ModelTreeCandidate[] = [];
224
563
  try {
225
564
  for (const candidate of candidates) {
226
- const dest = path.join(materializeRoot, candidate.tree);
227
- const where = `${short(candidate.tree)} (at ${short(candidate.commits[0])})`;
228
-
229
- // TWO KINDS OF FAILURE, and conflating them is what made this path lie.
230
- //
231
- // EXTRACTION failing says nothing about the tree — we never saw it. It was recorded as a
232
- // "compile failure" and the verdict fell through to `drift`, which tells an operator
233
- // their database was hand-edited. Observed for real: `spawnSync tar EPIPE` under a loaded
234
- // machine. Those trees now go to `unevaluated` and produce `indeterminate`.
235
- //
236
- // COMPILING failing IS a property of the tree — a models file that no longer builds
237
- // against today's @everystack/model is genuinely unusable, deterministically, and
238
- // skipping it is correct. Those keep `compileFailures` and still allow `drift`.
239
- //
240
- // Each gets ONE retry from a clean materialization first, because either can also be
241
- // transient under load.
242
- try {
243
- if (!fs.existsSync(dest)) materialize(candidate.tree, dest, repoRoot);
244
- } catch {
245
- try {
246
- fs.rmSync(dest, { recursive: true, force: true });
247
- materialize(candidate.tree, dest, repoRoot);
248
- } catch (err: any) {
249
- unevaluated.push(`${where}: could not extract — ${err.message}`);
250
- continue;
251
- }
252
- }
253
-
254
- // The declares-test is GOVERNED on both sides, per candidate: the commit's
255
- // models define the governed set, and the live hash is filtered through the
256
- // SAME set as the prediction — comparing a governed prediction against the
257
- // raw live hash can never match on a brownfield target with foreign roles.
258
- const evaluate = async (): Promise<boolean> => {
259
- const models = await loader(path.join(dest, barrel));
260
- const governedRoles = governedRolesForModels(models);
261
- const predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: opts.schema, governedRoles });
262
- return predicted === governedLiveFingerprint(live.snapshot, live.contract, governedRoles);
263
- };
264
- let declares: boolean;
265
- try {
266
- try {
267
- declares = await evaluate();
268
- } catch {
269
- declares = await evaluate();
270
- }
271
- } catch (err: any) {
272
- compileFailures.push(`${where}: ${err.message}`);
565
+ const result = await evaluateTree(candidate, live, evalCtx);
566
+ if (result.kind === 'unevaluated') {
567
+ unevaluated.push(result.detail);
273
568
  continue;
274
569
  }
275
- if (!declares) continue;
276
-
277
- for (const commit of candidate.commits) {
278
- try {
279
- execFileSync('git', ['merge-base', '--is-ancestor', commit, head], {
280
- cwd: repoRoot,
281
- stdio: 'ignore',
282
- });
283
- return {
284
- status: 'ok',
285
- commit,
286
- tree: candidate.tree,
287
- scannedTrees: candidates.indexOf(candidate) + 1,
288
- };
289
- } catch {
290
- // not an ancestor — keep looking; an identical tree elsewhere may be
291
- }
570
+ if (result.kind === 'compile-failed') {
571
+ compileFailures.push(result.detail);
572
+ continue;
292
573
  }
293
- declaringButDiverged.push(candidate);
574
+ if (result.kind === 'no') continue;
575
+ // Enumerated over HEAD's ancestors, so every commit here descends into HEAD — but confirm,
576
+ // since a tree can be carried by commits on more than one line of history.
577
+ const ancestor = candidate.commits.find((commit) => isAncestor(commit, head, repoRoot)) ?? candidate.commits[0];
578
+ return { status: 'proven', via: 'recompile', commit: ancestor };
294
579
  }
295
580
  } finally {
296
581
  if (ownsRoot) fs.rmSync(materializeRoot, { recursive: true, force: true });
297
582
  }
298
583
 
299
- if (declaringButDiverged.length > 0) {
300
- const commits = declaringButDiverged.flatMap((c) => c.commits);
584
+ // No ancestor carries a committed models directory to compare against — a shallow clone that
585
+ // truncated the deploying history, or the deploying branch is simply not in this checkout.
586
+ // That is a limit of THIS clone, not a hand-edit: UNVERIFIABLE, never drift.
587
+ if (candidates.length === 0) {
301
588
  return {
302
- status: 'diverged',
303
- commits,
304
- tree: declaringButDiverged[0].tree,
589
+ status: 'unverifiable',
305
590
  reason:
306
- `the target's state is declared by ${short(commits[0])}` +
307
- (commits.length > 1 ? ` (+${commits.length - 1} more)` : '') +
308
- ', which is NOT an ancestor of your checkout — your branch diverged before it. ' +
309
- 'Rebase onto it and re-mint, so your edge moves the target forward instead of reverting merged work.',
591
+ 'cannot verify the fast-forward rule — no ancestor of your checkout carries a committed ' +
592
+ 'models directory to compare against. This is a shallow clone (fetch the full history: ' +
593
+ 'git fetch --unshallow) or the deploying branch is not in this checkout, not evidence ' +
594
+ 'about the database — it is NOT drift. Fetch the deploying history, or waive the rule deliberately.',
595
+ unevaluated,
596
+ compileFailures,
310
597
  };
311
598
  }
312
599
 
313
- // Nothing matched — but if a tree was never evaluated, "nothing matched" is a claim we
314
- // cannot make. The unevaluated one could be the declaring tree. Reported BEFORE drift
315
- // because drift accuses the database, and this condition is about our own run.
316
- if (unevaluated.length > 0) {
600
+ // A tree we could not READ is not evidence about the database. If any ancestor tree could not
601
+ // be extracted or compiled, "nothing declares this state" is a claim we are not entitled to
602
+ // make — the answer is UNVERIFIABLE, and its next move is retry/upgrade, never db:pull.
603
+ if (unevaluated.length > 0 || compileFailures.length > 0) {
604
+ const notes: string[] = [];
605
+ if (compileFailures.length > 0) notes.push(`${compileFailures.length} would not compile under this CLI`);
606
+ if (unevaluated.length > 0) notes.push(`${unevaluated.length} could not be extracted`);
317
607
  return {
318
- status: 'indeterminate',
319
- scannedTrees: candidates.length,
320
- unevaluated,
608
+ status: 'unverifiable',
321
609
  reason:
322
- `${unevaluated.length} of ${candidates.length} historical tree(s) could not be extracted, ` +
323
- 'so the search was not exhaustive and one of them may be the commit that declares this state. ' +
324
- 'This is a fault in THIS run, not evidence about the database — retry, and if it persists ' +
325
- `check disk space and open-file limits. Details: ${unevaluated.join('; ')}`,
610
+ `cannot verify the fast-forward rule — of ${candidates.length} ancestor model state(s), ` +
611
+ `${notes.join(' and ')}, and none of the readable ones declares the live state. This is a ` +
612
+ 'limit of THIS run (an older tree that no longer builds, or a failed extraction), not ' +
613
+ 'evidence about the database — it is NOT drift. Upgrade/pin the CLI that last deployed ' +
614
+ 'this state, or waive the rule deliberately.',
615
+ unevaluated,
616
+ compileFailures,
326
617
  };
327
618
  }
328
619
 
329
- const failureNote = compileFailures.length > 0
330
- ? ` (${compileFailures.length} historical tree(s) no longer compile and were skipped)`
331
- : '';
332
620
  return {
333
621
  status: 'drift',
334
622
  scannedTrees: candidates.length,
335
- compileFailures,
336
623
  reason:
337
- `no committed models state declares the target's live fingerprint ${short(fingerprintLive(live.snapshot, live.contract).hash)} — ` +
338
- `scanned ${candidates.length} historical tree(s) across all refs${failureNote}. ` +
339
- 'Either the database was hand-edited (drift) or the declaring history was rewritten. ' +
340
- 'Align the models with the live database first (db:pull, commit), ' +
341
- 'or force with ceremony: --force-descent <snapshot-ref> --confirm.',
624
+ `no ancestor of your checkout declares the target's live fingerprint ` +
625
+ `${short(fingerprintLive(live.snapshot, live.contract).hash)} — read ${candidates.length} ` +
626
+ 'ancestor model state(s), all cleanly, and none matches. The database was hand-edited ' +
627
+ '(drift), or the deploying branch is not in this checkout. Align the models with the live ' +
628
+ 'database (db:pull, commit), or waive the rule deliberately.',
342
629
  };
343
630
  }
631
+
632
+ /**
633
+ * Collapse a ProveVerdict into the ok/reason shape the apply core enforces. Refuse-closed:
634
+ * only proven / fresh-target / no-git pass; every other verdict refuses, carrying its own
635
+ * distinct cause so the operator's next move is unambiguous.
636
+ */
637
+ export function proveVerdictForApply(verdict: ProveVerdict): { ok: true } | { ok: false; reason: string } {
638
+ switch (verdict.status) {
639
+ case 'proven':
640
+ case 'fresh-target':
641
+ case 'no-git':
642
+ return { ok: true };
643
+ case 'revert':
644
+ case 'unknown-commit':
645
+ case 'unverifiable':
646
+ case 'drift':
647
+ return { ok: false, reason: verdict.reason };
648
+ }
649
+ }
650
+
651
+ // ---------------------------------------------------------------------------
652
+ // The descent decision AT APPLY, WITH the lineage-lost attestation.
653
+ //
654
+ // This replaces the retired `--force-descent` (which skipped the descent check wholesale). A descent
655
+ // refusal now has a GIT-NATIVE flagless escape, and the ONE attestation `--attest-lineage-lost <sha>`
656
+ // is honoured only where no re-plan can express the fix: the declaring commit is PROVEN unreachable
657
+ // from every ref. The proof (`proveLineageLost`) is injected so this stays a pure decision.
658
+ // Fable-adjudicated 2026-08-26.
659
+ // ---------------------------------------------------------------------------
660
+
661
+ export interface DescentApplyContext {
662
+ /** `--attest-lineage-lost <sha>`, if the operator passed it. */
663
+ attestation?: string;
664
+ /** Destructive plans have no additive no-git waiver — the no-git escape differs. */
665
+ destructive: boolean;
666
+ /** Proves a declaring commit is unreachable from every ref. `realLineageProbe`-backed in production. */
667
+ proveLineage: (sha: string) => LineageProof;
668
+ }
669
+
670
+ export type DescentApplyDecision =
671
+ /** Proceed. `attested` is set only when the attestation was honoured — record it in schema_log. */
672
+ | { ok: true; attested?: { commit: string; subcase: 'revert-unreachable' | 'unknown-commit-proven-absent' } }
673
+ | { ok: false; reason: string };
674
+
675
+ /** A short recorded note for a honoured attestation — goes in the apply row's schema_log outcome. */
676
+ export function attestationNote(a: { commit: string; subcase: string }): string {
677
+ return `descent-attested-lineage-lost (${short(a.commit)}, ${a.subcase})`;
678
+ }
679
+
680
+ export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyContext): DescentApplyDecision {
681
+ switch (verdict.status) {
682
+ case 'proven':
683
+ case 'fresh-target':
684
+ return { ok: true };
685
+
686
+ case 'no-git':
687
+ // no-git is "not standing in the lineage", NOT "lineage gone" — the attestation never covers it.
688
+ // Additive rides the (caller-warned) waiver as before; destructive refuses outright.
689
+ return ctx.destructive
690
+ ? { 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.' }
691
+ : { ok: true };
692
+
693
+ case 'unverifiable':
694
+ case 'drift':
695
+ // No single declaring commit — nothing to attest ABOUT. The escape is to make HEAD declare the
696
+ // live state, then re-plan. An attestation here is a category error, refused as one.
697
+ return {
698
+ ok: false,
699
+ reason: ctx.attestation
700
+ ? `--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.`
701
+ : `${verdict.reason} Commit your models so HEAD declares this state (db:generate), then db:plan and db:apply again.`,
702
+ };
703
+
704
+ case 'revert':
705
+ case 'unknown-commit': {
706
+ // These name a declaring commit. Without an attestation the flagless escape (git revert / fetch)
707
+ // is the verdict's own message; append the last-resort attestation for a branch that is truly gone.
708
+ if (!ctx.attestation) {
709
+ return {
710
+ ok: false,
711
+ 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' : ''}.`,
712
+ };
713
+ }
714
+ if (ctx.attestation !== verdict.commit) {
715
+ return {
716
+ ok: false,
717
+ 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.`,
718
+ };
719
+ }
720
+ const proof = ctx.proveLineage(verdict.commit);
721
+ return proof.proven
722
+ ? { ok: true, attested: { commit: verdict.commit, subcase: proof.subcase } }
723
+ : { ok: false, reason: proof.reason };
724
+ }
725
+ }
726
+ }