@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.
- package/CHANGELOG.md +34 -0
- package/package.json +2 -2
- package/src/cli/apply-execute.ts +133 -9
- package/src/cli/apply-lane.ts +102 -0
- package/src/cli/apply-refusals.ts +83 -0
- package/src/cli/backup-resolve.ts +31 -0
- package/src/cli/backup.ts +104 -0
- package/src/cli/commands/db-apply.ts +318 -180
- package/src/cli/commands/db-plan.ts +21 -10
- package/src/cli/commands/db-swap.ts +27 -54
- package/src/cli/derived-apply.ts +17 -2
- package/src/cli/edge-plan.ts +14 -0
- package/src/cli/git-descent.ts +526 -143
- package/src/cli/index.ts +1 -1
- package/src/cli/local-rollback.ts +146 -0
- package/src/cli/prompt.ts +18 -0
- package/src/cli/rollback-point.ts +141 -0
- package/src/cli/schema-fingerprint.ts +60 -0
- package/src/cli/stage-rollback-deps.ts +70 -0
- package/src/cli/state-apply.ts +41 -3
- package/src/cli/swap-snapshot.ts +18 -1
package/src/cli/git-descent.ts
CHANGED
|
@@ -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(
|
|
122
|
-
|
|
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:
|
|
163
|
-
* `drift`, telling the operator their database had been hand-edited. See the
|
|
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
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* committed models
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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
|
|
249
|
+
if (headOut === null) return null;
|
|
202
250
|
const head = headOut.trim();
|
|
203
|
-
|
|
204
|
-
//
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
219
|
-
|
|
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
|
|
227
|
-
|
|
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 (
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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: '
|
|
303
|
-
commits,
|
|
304
|
-
tree: declaringButDiverged[0].tree,
|
|
589
|
+
status: 'unverifiable',
|
|
305
590
|
reason:
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
'
|
|
309
|
-
'
|
|
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
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
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: '
|
|
319
|
-
scannedTrees: candidates.length,
|
|
320
|
-
unevaluated,
|
|
608
|
+
status: 'unverifiable',
|
|
321
609
|
reason:
|
|
322
|
-
|
|
323
|
-
'
|
|
324
|
-
'
|
|
325
|
-
|
|
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
|
|
338
|
-
|
|
339
|
-
'
|
|
340
|
-
'Align the models with the live
|
|
341
|
-
'
|
|
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
|
+
}
|