@everystack/cli 0.4.93 → 0.4.95
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/package.json +1 -1
- package/src/cli/apply-execute.ts +100 -19
- package/src/cli/commands/db-apply.ts +19 -18
- package/src/cli/commands/db-plan.ts +12 -18
- package/src/cli/commands/db-pull.ts +2 -2
- package/src/cli/derived-plan.ts +7 -1
- package/src/cli/exec-digest.ts +61 -16
- package/src/cli/exec-run.ts +81 -2
- package/src/cli/git-descent.ts +204 -628
- package/src/cli/index.ts +1 -1
- package/src/cli/plain-output.ts +12 -6
- package/src/cli/state-apply.ts +2 -2
- package/src/exec.ts +1 -1
package/src/cli/git-descent.ts
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
158
|
-
//
|
|
159
|
-
//
|
|
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
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
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
|
|
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
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
|
|
760
|
-
|
|
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
|
|
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
|
-
/**
|
|
766
|
-
| { status: '
|
|
767
|
-
/**
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
|
787
|
-
*
|
|
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
|
|
791
|
-
* hand-edited since <sha>, <date>
|
|
792
|
-
* applied from a DIRTY worktree at <sha>, <date>
|
|
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
|
|
796
|
-
if (origin.kind === 'never-declared')
|
|
797
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
|
556
|
+
* The fast-forward proof: the ledger and git ancestry, nothing else. Refuse-closed.
|
|
855
557
|
*
|
|
856
|
-
*
|
|
857
|
-
*
|
|
858
|
-
*
|
|
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
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
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
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
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
|
|
583
|
+
const { repoRoot, head, modelsDir } = ctx;
|
|
883
584
|
|
|
884
|
-
//
|
|
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
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
899
|
-
|
|
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
|
|
606
|
+
commit,
|
|
904
607
|
reason:
|
|
905
|
-
|
|
906
|
-
'
|
|
907
|
-
'
|
|
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
|
-
|
|
912
|
-
|
|
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
|
-
// ----
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
//
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
return
|
|
936
|
-
|
|
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: '
|
|
658
|
+
status: 'revert',
|
|
1060
659
|
commit,
|
|
660
|
+
appliedAt: w.appliedAt,
|
|
1061
661
|
reason:
|
|
1062
|
-
`
|
|
1063
|
-
|
|
1064
|
-
'
|
|
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
|
-
|
|
1069
|
-
if (
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
//
|
|
1078
|
-
//
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
|
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)
|