@everystack/cli 0.4.77 → 0.4.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/src/cli/authz-adoption-class.ts +112 -22
- package/src/cli/commands/db-apply.ts +12 -3
- package/src/cli/commands/db-plan.ts +64 -8
- package/src/cli/edge-plan.ts +18 -3
- package/src/cli/git-descent.ts +286 -45
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ All notable changes to `@everystack/cli` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.78] - 2026-09-07
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- the adoption report describes the plan it ships with
|
|
12
|
+
- the unreadable-tree refusal names the refs that carry each tree and leads with the fix the ledger supports
|
|
13
|
+
- a squashed successor proves the fast-forward; the attest resolves through git; the plan predicts the apply; an empty edge names what carries the hash difference
|
|
14
|
+
- closing-gate round 1 — app-only squash, squash proof recorded and announced, bounded runbook, scrubbed errors, git timeouts
|
|
15
|
+
- closing-gate round 2 — doc matches the shipped squash rule, the round-1 fixes are pinned, every git call is bounded, the squash guard refuses when git cannot answer
|
|
16
|
+
|
|
7
17
|
## [0.4.77] - 2026-09-07
|
|
8
18
|
|
|
9
19
|
### Added
|
package/package.json
CHANGED
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
import { matchPolicies, roleSetEqual } from './authz-identity';
|
|
32
32
|
import { governedRoleSet } from './authz-reconcile';
|
|
33
|
-
import {
|
|
33
|
+
import { tablePoliciesEquivalent } from './authz-canonical';
|
|
34
|
+
import { effectivePolicyCheck, holdsPrivilegeEffective, isPolicyDead, policyCommands } from './authz-contract';
|
|
34
35
|
import type { AuthzContract, PolicyContract, TableContract } from './authz-contract';
|
|
35
36
|
|
|
36
37
|
export type AdoptionClass = 'convention' | 'capability' | 'narrowing' | 'dead' | 'unclassified';
|
|
@@ -78,24 +79,66 @@ function covers(outer: readonly string[], inner: readonly string[]): boolean {
|
|
|
78
79
|
return inner.every((r) => o.has(r));
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
/**
|
|
83
|
+
* The predicate slots ONE command enforces — the same fan the canonical form uses. SELECT and
|
|
84
|
+
* DELETE have no WITH CHECK; INSERT has no USING and its check falls back to USING when the
|
|
85
|
+
* source is a `FOR ALL` with no explicit check (the server's own defaulting rule); UPDATE and
|
|
86
|
+
* ALL carry both. Comparing policies through this is what lets a declared INSERT be measured
|
|
87
|
+
* against the INSERT arm of a live ALL instead of against a USING the INSERT never had.
|
|
88
|
+
*/
|
|
89
|
+
function armOf(p: PolicyContract, command: string): { using: string; check: string } {
|
|
90
|
+
const using = command === 'INSERT' ? '' : (p.using ?? '');
|
|
91
|
+
// ONE definition of the server's WITH CHECK defaulting rule — effectivePolicyCheck, shared with
|
|
92
|
+
// the matcher, the reconciler and the retained v10 canon.
|
|
93
|
+
const check = command === 'SELECT' || command === 'DELETE' ? '' : (effectivePolicyCheck(p) ?? '');
|
|
94
|
+
return { using, check };
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
/**
|
|
90
|
-
*
|
|
98
|
+
* Same authorization on every command the replacement governs, roles aside — the role-split and
|
|
99
|
+
* per-command tests. Arm for arm, so a live `FOR ALL USING (x)` is the same rule as a declared
|
|
100
|
+
* `INSERT WITH CHECK (x)` on INSERT. Whether the replacement covers EVERY live command is a
|
|
101
|
+
* separate question (`aliveCommands`), answered by the caller.
|
|
102
|
+
*/
|
|
103
|
+
function sameRule(r: PolicyContract, live: PolicyContract): boolean {
|
|
104
|
+
if (r.permissive !== live.permissive) return false;
|
|
105
|
+
const liveCommands = commandsOf(live);
|
|
106
|
+
return commandsOf(r).every((cmd) => {
|
|
107
|
+
if (!liveCommands.includes(cmd)) return false;
|
|
108
|
+
const a = armOf(r, cmd);
|
|
109
|
+
const b = armOf(live, cmd);
|
|
110
|
+
return a.using === b.using && a.check === b.check;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The commands a live policy actually governs — the arms some role it names holds the grant for. */
|
|
115
|
+
function aliveCommands(t: TableContract, p: PolicyContract): string[] {
|
|
116
|
+
const roles = p.roles.includes('public') || p.roles.includes('PUBLIC')
|
|
117
|
+
? Object.keys(t.effectivePrivileges ?? t.grants)
|
|
118
|
+
: p.roles;
|
|
119
|
+
return commandsOf(p).filter((cmd) => roles.some((r) => holdsPrivilegeEffective(t, r, cmd)));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A policy's predicate, collapsed to one line for the report, in the slot its command enforces:
|
|
124
|
+
* an INSERT is shown by its WITH CHECK (it has no USING — rendering one printed
|
|
125
|
+
* "true (unfiltered)" for every INSERT policy, which is what the consumer read as an unfiltered
|
|
126
|
+
* rule); UPDATE and ALL show both halves when they differ; SELECT and DELETE show USING.
|
|
91
127
|
*
|
|
92
128
|
* Live predicates arrive from `pg_get_expr` with real newlines and indentation (an EXISTS
|
|
93
129
|
* subquery spans several lines), and the adoption report is one line per statement. Collapsing
|
|
94
130
|
* keeps the rule READABLE in place rather than referring the operator elsewhere to find it.
|
|
95
131
|
*/
|
|
96
132
|
function pred(p: PolicyContract): string {
|
|
97
|
-
const
|
|
98
|
-
|
|
133
|
+
const one = (s: string | null | undefined): string => {
|
|
134
|
+
const t = (s ?? 'true').replace(/\s+/g, ' ').trim();
|
|
135
|
+
return t === 'true' ? 'true (unfiltered)' : t;
|
|
136
|
+
};
|
|
137
|
+
if (p.command === 'INSERT') return `WITH CHECK ${one(p.check)}`;
|
|
138
|
+
if (p.command === 'SELECT' || p.command === 'DELETE') return `USING ${one(p.using)}`;
|
|
139
|
+
const using = one(p.using);
|
|
140
|
+
const check = one(p.check ?? p.using);
|
|
141
|
+
return check === using ? `USING ${using}` : `USING ${using} WITH CHECK ${check}`;
|
|
99
142
|
}
|
|
100
143
|
|
|
101
144
|
// `commandsOf` and the dead-policy test live in authz-contract — ONE definition, shared with
|
|
@@ -106,6 +149,16 @@ const isDead = isPolicyDead;
|
|
|
106
149
|
/** Classify the policy statements one table would emit. */
|
|
107
150
|
function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedStatement[], governed: ReadonlySet<string>): void {
|
|
108
151
|
const table = d.table;
|
|
152
|
+
|
|
153
|
+
// The reconciler's own gate, mirrored (authz-reconcile reconcilePolicies, Condition B): two
|
|
154
|
+
// tables that are the SAME authorization under the canonical fan emit nothing, so there is
|
|
155
|
+
// nothing to classify. A live `FOR ALL` whose alive arms the declared per-command split covers
|
|
156
|
+
// is the shape that escaped — the per-policy matcher below cannot see it (a live ALL is never
|
|
157
|
+
// the same policy as a declared INSERT), so this classifier counted four narrowings for a plan
|
|
158
|
+
// that carried none. Gating on the same predicate the emitter gates on is the only way the
|
|
159
|
+
// two can never disagree.
|
|
160
|
+
if (tablePoliciesEquivalent(d, l, governed)) return;
|
|
161
|
+
|
|
109
162
|
const m = matchPolicies(d.policies, l.policies);
|
|
110
163
|
const liveByName = new Map(l.policies.map((p) => [p.name, p]));
|
|
111
164
|
|
|
@@ -144,12 +197,18 @@ function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedSta
|
|
|
144
197
|
|
|
145
198
|
const replacementRoles = [...new Set(replacements.flatMap((r) => r.roles))];
|
|
146
199
|
|
|
200
|
+
// The COMMAND axis: which of the live policy's alive arms no replacement governs at all. A
|
|
201
|
+
// live ALL replaced by an INSERT-only declaration loses SELECT/UPDATE/DELETE — a narrowing
|
|
202
|
+
// no role or predicate comparison can see. Dead arms are not access, so they are not lost.
|
|
203
|
+
const liveAlive = aliveCommands(l, live);
|
|
204
|
+
const uncovered = liveAlive.filter((cmd) => !replacements.some((r) => commandsOf(r).includes(cmd)));
|
|
205
|
+
|
|
147
206
|
// The role split: one live policy covering several roles, against the per-role policies the
|
|
148
207
|
// compiler emits. Same predicate, and the declared roles union to EXACTLY the live role set,
|
|
149
208
|
// so the two authorize identically for every session. Pure spelling — ours to fix (A5).
|
|
150
209
|
// Note this is recognized here for LABELING before the matcher can act on it; the two must
|
|
151
210
|
// agree once A5 lands, which is what the emitter-parity test pins.
|
|
152
|
-
if (roleSetEqual(live.roles, replacementRoles) && replacements.every((r) => sameRule(r, live))) {
|
|
211
|
+
if (roleSetEqual(live.roles, replacementRoles) && !uncovered.length && replacements.every((r) => sameRule(r, live))) {
|
|
153
212
|
out.push({ table, subject: name, cls: 'convention', why: `live covers ${live.roles.join(' + ')} in one policy; we split it per role with the same predicate` });
|
|
154
213
|
for (const r of replacements) {
|
|
155
214
|
if (consumed.has(r.name)) continue;
|
|
@@ -181,21 +240,48 @@ function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedSta
|
|
|
181
240
|
// we cannot prove is identical is reported as a reduction, and BOTH predicates are carried
|
|
182
241
|
// so the operator reads the rule rather than the label.
|
|
183
242
|
const sameRoleReplacements = replacements.filter((r) => r.roles.some((role) => live.roles.includes(role)));
|
|
184
|
-
|
|
185
|
-
|
|
243
|
+
const shared = [...new Set(sameRoleReplacements.flatMap((r) => r.roles.filter((role) => live.roles.includes(role))))];
|
|
244
|
+
|
|
245
|
+
// Same roles, same rule on every command the replacements govern, but some live command is
|
|
246
|
+
// left with no policy at all — the command-axis narrowing.
|
|
247
|
+
if (sameRoleReplacements.length && uncovered.length && sameRoleReplacements.every((r) => sameRule(r, live))) {
|
|
248
|
+
const covered = liveAlive.filter((cmd) => !uncovered.includes(cmd));
|
|
249
|
+
out.push({
|
|
250
|
+
table,
|
|
251
|
+
subject: name,
|
|
252
|
+
cls: 'narrowing',
|
|
253
|
+
why:
|
|
254
|
+
`live governs ${liveAlive.join('/')} for ${shared.join(', ')} ${pred(live)}; the declared replacement `
|
|
255
|
+
+ `${sameRoleReplacements.map((r) => `"${r.name}"`).join(', ')} keeps the same rule but governs only `
|
|
256
|
+
+ `${covered.join('/')} — ${uncovered.join('/')} loses its policy`,
|
|
257
|
+
});
|
|
258
|
+
for (const r of sameRoleReplacements) {
|
|
259
|
+
if (consumed.has(r.name)) continue;
|
|
260
|
+
consumed.add(r.name);
|
|
261
|
+
out.push({ table, subject: r.name, cls: 'narrowing', why: `the narrower replacement for "${name}"` });
|
|
262
|
+
}
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (sameRoleReplacements.length && !sameRoleReplacements.every((r) => sameRule(r, live))) {
|
|
267
|
+
const differing = sameRoleReplacements.filter((r) => !sameRule(r, live));
|
|
186
268
|
out.push({
|
|
187
269
|
table,
|
|
188
270
|
subject: name,
|
|
189
271
|
cls: 'narrowing',
|
|
190
272
|
why:
|
|
191
|
-
`live applies to ${shared.join(', ')}
|
|
192
|
-
+ `${
|
|
193
|
-
+ `${
|
|
273
|
+
`live applies to ${shared.join(', ')} ${pred(live)}; the declared replacement `
|
|
274
|
+
+ `${differing.map((r) => `"${r.name}"`).join(', ')} keeps the same role(s) but states `
|
|
275
|
+
+ `${differing.map((r) => pred(r)).join(' / ')} — a different rule, not a different spelling`,
|
|
194
276
|
});
|
|
195
277
|
for (const r of sameRoleReplacements) {
|
|
196
278
|
if (consumed.has(r.name)) continue;
|
|
197
279
|
consumed.add(r.name);
|
|
198
|
-
|
|
280
|
+
// A replacement that DOES match its live arm exists only because the differing one forced
|
|
281
|
+
// the live policy to be replaced — our spelling, emitted for a real difference next to it.
|
|
282
|
+
out.push(sameRule(r, live)
|
|
283
|
+
? { table, subject: r.name, cls: 'convention', why: `the per-command half of "${name}", same rule; emitted because "${differing[0].name}" differs` }
|
|
284
|
+
: { table, subject: r.name, cls: 'narrowing', why: `the differently-predicated replacement for "${name}"` });
|
|
199
285
|
}
|
|
200
286
|
continue;
|
|
201
287
|
}
|
|
@@ -298,17 +384,21 @@ export function classifyAdoption(
|
|
|
298
384
|
* inline is a wall nobody reads, and they are each described by their class.
|
|
299
385
|
*/
|
|
300
386
|
export function renderAdoptionReport(counts: AdoptionCounts, statements: readonly ClassifiedStatement[] = []): string[] {
|
|
387
|
+
const row = (label: string, n: number, why: string): string => ` ${label.padEnd(18)} ${n} — ${why}`;
|
|
301
388
|
const lines = [
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
389
|
+
row('convention', counts.convention, 'our spelling, not their schema (this must reach 0)'),
|
|
390
|
+
row('capability', counts.capability, 'the model cannot express what is live'),
|
|
391
|
+
row('narrowing (access)', counts.narrowing, 'real access reduction, approve before applying'),
|
|
392
|
+
row('dead', counts.dead, 'policed but never granted'),
|
|
306
393
|
];
|
|
307
394
|
if (counts.unclassified) {
|
|
308
|
-
lines.push(
|
|
395
|
+
lines.push(row('unclassified', counts.unclassified, 'NOT described; read these before applying:'));
|
|
309
396
|
for (const s of statements.filter((s) => s.cls === 'unclassified')) {
|
|
310
397
|
lines.push(` ${s.table} "${s.subject}" — ${s.why}`);
|
|
311
398
|
}
|
|
312
399
|
}
|
|
400
|
+
// The name collision from the module header, said where the operator can see it: the plan
|
|
401
|
+
// file's `narrowings` is the DESTRUCTIVE axis (lossy type changes) and is counted elsewhere.
|
|
402
|
+
lines.push(' (access counts above are policies and grants; the plan file\'s `narrowings` counts lossy type changes — the destructive axis, reported separately)');
|
|
313
403
|
return lines;
|
|
314
404
|
}
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
attestationNote,
|
|
41
41
|
proveLineageLost,
|
|
42
42
|
realLineageProbe,
|
|
43
|
+
resolveAttestation,
|
|
43
44
|
type DescentLedgerRow,
|
|
44
45
|
type LiveState,
|
|
45
46
|
type ProveVerdict,
|
|
@@ -118,7 +119,12 @@ export function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void
|
|
|
118
119
|
export function describeDescentVerdict(verdict: ProveVerdict, opts: { destructive?: boolean } = {}): { level: 'info' | 'warn'; text: string } | null {
|
|
119
120
|
switch (verdict.status) {
|
|
120
121
|
case 'proven':
|
|
121
|
-
|
|
122
|
+
// A squash proof names BOTH commits: the carrier that is an ancestor of HEAD, and the
|
|
123
|
+
// deploying commit that is not — announcing the carrier alone as "an ancestor" hid that
|
|
124
|
+
// the proof rests on tree identity (closing-gate review, 2026-09-07).
|
|
125
|
+
return verdict.via === 'squash' && verdict.witness
|
|
126
|
+
? { level: 'info', text: `descent: proven via squash — ${verdict.commit.slice(0, 12)} (an ancestor of HEAD) carries the models tree of the deploying commit ${verdict.witness.slice(0, 12)}, which HEAD does not descend from; recorded in schema_log.` }
|
|
127
|
+
: { level: 'info', text: `descent: proven via ${verdict.via} — ${verdict.commit.slice(0, 12)} is an ancestor of HEAD, fast-forward.` };
|
|
122
128
|
case 'fresh-target':
|
|
123
129
|
return { level: 'info', text: 'descent: fresh target (no tables) — nothing to protect, bootstrapping.' };
|
|
124
130
|
case 'no-git':
|
|
@@ -166,15 +172,18 @@ export async function proveDescentForApply(input: {
|
|
|
166
172
|
* Production passes nothing and rides the real loader. */
|
|
167
173
|
descent?: Pick<ProveDescentOptions, 'loader' | 'materializeRoot'>;
|
|
168
174
|
}): Promise<{ descentVerdict: { ok: true } | { ok: false; reason: string }; descentAttested?: string }> {
|
|
175
|
+
const cwd = input.cwd ?? process.cwd();
|
|
169
176
|
const verdict = await proveDescent(input.modelsPath, input.live, input.ledger, {
|
|
170
177
|
...(input.cwd ? { cwd: input.cwd } : {}),
|
|
171
178
|
...(input.descent ?? {}),
|
|
172
179
|
});
|
|
173
180
|
reportDescentVerdict(verdict, { destructive: input.destructive });
|
|
181
|
+
// The attestation is resolved THROUGH git (a prefix, tag or HEAD~n → the full sha) before it is
|
|
182
|
+
// compared; a value git cannot resolve passes through and the prefix compare decides.
|
|
174
183
|
const decision = decideDescentApply(verdict, {
|
|
175
|
-
...(input.attestation ? { attestation: input.attestation } : {}),
|
|
184
|
+
...(input.attestation ? { attestation: resolveAttestation(input.attestation, cwd) } : {}),
|
|
176
185
|
destructive: input.destructive,
|
|
177
|
-
proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(
|
|
186
|
+
proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(cwd)),
|
|
178
187
|
});
|
|
179
188
|
if (!decision.ok) return { descentVerdict: { ok: false, reason: decision.reason } };
|
|
180
189
|
if (decision.attested) {
|
|
@@ -50,7 +50,9 @@ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
|
|
|
50
50
|
import { readStageStateTwice } from '../stage-read-consistency';
|
|
51
51
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
52
52
|
import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan';
|
|
53
|
-
import { proveDescent, describeDriftOrigin, type ProveVerdict } from '../git-descent';
|
|
53
|
+
import { proveDescent, describeDriftOrigin, proveLineageLost, realLineageProbe, type ProveVerdict, type LineageProof } from '../git-descent';
|
|
54
|
+
import { fingerprintLive, divergentSurfaces } from '../schema-fingerprint';
|
|
55
|
+
import { predictLiveState } from '../edge-plan';
|
|
54
56
|
import { readDescentLedger } from '../apply-execute';
|
|
55
57
|
import { planBackfills, readBackfillLog } from '../backfill';
|
|
56
58
|
import { readSqlDirIfPresent } from './db-sync';
|
|
@@ -77,9 +79,42 @@ import { step, success, fail, info, warn, reserveStdoutForData } from '../output
|
|
|
77
79
|
* exhaustive: a new verdict fails to compile here instead of minting a plan with no warning and
|
|
78
80
|
* refusing at apply with no hint of why.
|
|
79
81
|
*/
|
|
80
|
-
export function describePlanDescent(
|
|
82
|
+
export function describePlanDescent(
|
|
83
|
+
verdict: ProveVerdict,
|
|
84
|
+
opts: { proveLineage?: (sha: string) => LineageProof } = {},
|
|
85
|
+
): string[] {
|
|
86
|
+
// The plan predicts the apply. For a verdict that names a declaring commit, run the SAME lineage
|
|
87
|
+
// proof db:apply runs and say what it will do with it: a commit reachable from a ref → rebase
|
|
88
|
+
// onto that ref; a commit reachable from NO ref → the exact attestation apply will honour. The
|
|
89
|
+
// two surfaces disagreed once (consumer cutover, cli 0.4.77): plan said "will refuse — rebase"
|
|
90
|
+
// on a commit no ref held, and apply honoured the attest. Phase A tests OBJECT presence, so a
|
|
91
|
+
// ref-less commit still reads as a revert until the lineage proof is asked.
|
|
92
|
+
const applyWillDo = (commit: string): string => {
|
|
93
|
+
const proof = opts.proveLineage?.(commit);
|
|
94
|
+
if (!proof) return 'db:apply from this checkout will refuse this plan — rebase first, then re-mint.';
|
|
95
|
+
if (proof.proven) {
|
|
96
|
+
return `db:apply from this checkout will refuse this plan without an attestation; commit ${commit} is reachable from no ref ` +
|
|
97
|
+
`(${proof.subcase}), so it will honour --attest-lineage-lost ${commit} — or fetch the branch that had it, rebase, and re-mint.`;
|
|
98
|
+
}
|
|
99
|
+
if (proof.kind === 'reachable' && proof.ref && proof.ref !== 'HEAD') {
|
|
100
|
+
// `ref` is a full ref name, or a REMOTE name when the commit was found in a remote branch's
|
|
101
|
+
// ancestry (the probe sees advertised tips, not their branch names) — you cannot rebase onto
|
|
102
|
+
// a remote, so that case says fetch first. `HEAD` cannot reach here from a revert (the commit
|
|
103
|
+
// is not an ancestor of HEAD by construction); if it ever did, the generic reason is safer
|
|
104
|
+
// than "git fetch HEAD".
|
|
105
|
+
return proof.ref.startsWith('refs/')
|
|
106
|
+
? `db:apply from this checkout will refuse this plan — that commit is still on ${proof.ref}; rebase onto it, then re-mint.`
|
|
107
|
+
: `db:apply from this checkout will refuse this plan — that commit is still on remote '${proof.ref}'; fetch it (git fetch ${proof.ref}), rebase onto the branch that has it, then re-mint.`;
|
|
108
|
+
}
|
|
109
|
+
return `db:apply from this checkout will refuse this plan — ${proof.reason}`;
|
|
110
|
+
};
|
|
81
111
|
switch (verdict.status) {
|
|
82
112
|
case 'proven':
|
|
113
|
+
// A squash proof is the one PROVEN shape whose deploying commit is not in the operator's
|
|
114
|
+
// history — say so where a human reads the plan, as the apply record will.
|
|
115
|
+
return verdict.via === 'squash' && verdict.witness
|
|
116
|
+
? [`descent: fast-forward proven via a squashed successor — ${verdict.commit.slice(0, 12)} (an ancestor of HEAD) carries the models tree of the deploying commit ${verdict.witness.slice(0, 12)}, which HEAD does not descend from. db:apply will record that.`]
|
|
117
|
+
: [];
|
|
83
118
|
case 'fresh-target':
|
|
84
119
|
case 'no-git':
|
|
85
120
|
return [];
|
|
@@ -94,10 +129,19 @@ export function describePlanDescent(verdict: ProveVerdict): string[] {
|
|
|
94
129
|
'db:apply from this checkout will refuse this plan — fetch and merge or rebase onto that commit, then re-mint.',
|
|
95
130
|
];
|
|
96
131
|
case 'revert':
|
|
97
|
-
return [`descent: ${verdict.reason}`,
|
|
98
|
-
case 'unknown-commit':
|
|
99
|
-
// NOT "rebase first" — the checkout is fine, it is just missing the deploying commit.
|
|
100
|
-
|
|
132
|
+
return [`descent: ${verdict.reason}`, applyWillDo(verdict.commit)];
|
|
133
|
+
case 'unknown-commit': {
|
|
134
|
+
// NOT "rebase first" — the checkout is fine, it is just missing the deploying commit. Unless
|
|
135
|
+
// the lineage proof says the commit is gone from every remote too: then apply will honour the
|
|
136
|
+
// attestation, and fetching cannot help.
|
|
137
|
+
const proof = opts.proveLineage?.(verdict.commit);
|
|
138
|
+
return [
|
|
139
|
+
`descent: ${verdict.reason}`,
|
|
140
|
+
proof?.proven
|
|
141
|
+
? applyWillDo(verdict.commit)
|
|
142
|
+
: 'db:apply from this checkout will refuse this plan until you fetch that commit — fetch, then re-mint.',
|
|
143
|
+
];
|
|
144
|
+
}
|
|
101
145
|
case 'unverifiable':
|
|
102
146
|
// NOT "rebase first" — nothing is wrong with the checkout or the database. The search could
|
|
103
147
|
// not finish (an unreadable tree, or a clone that is not full and current).
|
|
@@ -293,7 +337,17 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
293
337
|
await fs.writeFile(out, body, 'utf8');
|
|
294
338
|
}
|
|
295
339
|
|
|
296
|
-
|
|
340
|
+
// An EMPTY edge between two different fingerprints reads as a contradiction. `from` is the
|
|
341
|
+
// target's full fingerprint (the concurrency lock at apply); `to` is the declared endpoint
|
|
342
|
+
// through the models' governed set. They differ by exactly what the plan leaves alone — name
|
|
343
|
+
// it, computed at mint from the two canonical forms, never stored on the plan.
|
|
344
|
+
const carriedBy = plan.statements.length === 0 && plan.fromFingerprint !== plan.toFingerprint
|
|
345
|
+
? divergentSurfaces(
|
|
346
|
+
predictLiveState(models, snapshot, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles) }).canonical,
|
|
347
|
+
fingerprintLive(snapshot, contract).canonical,
|
|
348
|
+
)
|
|
349
|
+
: undefined;
|
|
350
|
+
for (const line of buildPlanSummary(plan, carriedBy ? { carriedBy } : {})) info(line);
|
|
297
351
|
info(`plan_ref: ${planHash(plan).slice(0, 12)}`);
|
|
298
352
|
|
|
299
353
|
// WHY does each authorization statement exist? — the same report db:generate prints,
|
|
@@ -331,7 +385,9 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
331
385
|
// A switch, not an `if (revert || drift)`. That form silently ignored every verdict it
|
|
332
386
|
// did not name, so a new one would mint a plan with no warning and then be refused at
|
|
333
387
|
// apply time with no hint of why. Exhaustive here means a new verdict cannot be forgotten.
|
|
334
|
-
|
|
388
|
+
// A PROVEN verdict is information, as it is on the apply surface; everything else is a warning.
|
|
389
|
+
const say = verdict.status === 'proven' ? info : warn;
|
|
390
|
+
for (const line of describePlanDescent(verdict, { proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(process.cwd())) })) say(line);
|
|
335
391
|
}
|
|
336
392
|
|
|
337
393
|
// Visibility, never automation (brick 8): the review surface names the
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -409,12 +409,27 @@ export function checkPlanPrecondition(plan: EdgePlan, liveFingerprint: string):
|
|
|
409
409
|
|
|
410
410
|
const short = (hash: string): string => hash.slice(0, 12);
|
|
411
411
|
|
|
412
|
-
/**
|
|
413
|
-
|
|
412
|
+
/**
|
|
413
|
+
* Human summary of a plan — the review surface.
|
|
414
|
+
*
|
|
415
|
+
* `carriedBy` names what accounts for an EMPTY edge between two DIFFERENT fingerprints: `from`
|
|
416
|
+
* is the target's full fingerprint (the concurrency lock), `to` the declared endpoint through the
|
|
417
|
+
* governed set, and they differ by exactly the live objects the plan leaves alone — an ungoverned
|
|
418
|
+
* role's grants, a policy the vocabulary cannot declare. Without the name the line "the target
|
|
419
|
+
* already declares this state" under two different hashes reads as a contradiction (consumer
|
|
420
|
+
* cutover, cli 0.4.77). The caller computes it at mint from the canonical forms; it is never
|
|
421
|
+
* stored on the plan.
|
|
422
|
+
*/
|
|
423
|
+
export function buildPlanSummary(plan: EdgePlan, opts: { carriedBy?: string[] } = {}): string[] {
|
|
414
424
|
const lines: string[] = [];
|
|
415
425
|
lines.push(`edge: ${short(plan.fromFingerprint)} → ${short(plan.toFingerprint)}`);
|
|
416
426
|
if (plan.statements.length === 0) {
|
|
417
|
-
|
|
427
|
+
if (plan.fromFingerprint === plan.toFingerprint) {
|
|
428
|
+
lines.push('empty — the target already declares this state');
|
|
429
|
+
} else {
|
|
430
|
+
lines.push('empty — no statement to emit. The fingerprints differ by what the plan leaves alone: `from` is the target\'s full fingerprint (the concurrency lock), `to` the declared endpoint through the governed set'
|
|
431
|
+
+ (opts.carriedBy?.length ? `; carried by: ${opts.carriedBy.join('; ')}` : '') + '.');
|
|
432
|
+
}
|
|
418
433
|
return lines;
|
|
419
434
|
}
|
|
420
435
|
lines.push(`${plan.executable} executable statement(s), ${plan.notices} notice(s)`);
|
package/src/cli/git-descent.ts
CHANGED
|
@@ -85,9 +85,13 @@ export interface ModelTreeCandidate {
|
|
|
85
85
|
|
|
86
86
|
const short = (ref: string): string => ref.slice(0, 12);
|
|
87
87
|
|
|
88
|
+
/** Every git command this module runs is bounded: in a partial clone even `cat-file` and
|
|
89
|
+
* `merge-base` can fetch lazily over the network, and a hang must become a null (refuse-closed). */
|
|
90
|
+
const GIT_TIMEOUT_MS = 60_000;
|
|
91
|
+
|
|
88
92
|
function git(args: string[], cwd: string): string | null {
|
|
89
93
|
try {
|
|
90
|
-
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
94
|
+
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: GIT_TIMEOUT_MS }).toString();
|
|
91
95
|
} catch {
|
|
92
96
|
return null;
|
|
93
97
|
}
|
|
@@ -100,6 +104,44 @@ const defaultLoader: ModelsLoader = async (barrel) => {
|
|
|
100
104
|
return models;
|
|
101
105
|
};
|
|
102
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The SQUASHED SUCCESSOR of a witness commit HEAD does not descend from: an ancestor of HEAD that
|
|
109
|
+
* carries the witness's models tree UNCHANGED (same tree oid) and is not itself an ancestor of
|
|
110
|
+
* the witness. Deploy from a PR branch, squash-merge, deploy the next stage — the ledger names the
|
|
111
|
+
* PR commit, main carries its tree under a new sha, and every line of the declaring commit is
|
|
112
|
+
* inside HEAD. Refusing that as a revert is false (consumer cutover, cli 0.4.77, 2026-09-07).
|
|
113
|
+
*
|
|
114
|
+
* The second clause is the whole safety. In an S→T→S episode the pre-episode ancestor carries S's
|
|
115
|
+
* tree too — byte-identical — and a checkout branched there must still be refused: the witness
|
|
116
|
+
* (the re-declaration of S) DESCENDS from that carrier, so the carrier is the state the episode
|
|
117
|
+
* started from, not a successor. A squash commit is never an ancestor of the branch it squashed.
|
|
118
|
+
* Tree identity is exact and cheap (`rev-parse <sha>:<dir>`, no materialization); a state-level
|
|
119
|
+
* recompile is deliberately NOT used here, because it cannot tell those two shapes apart.
|
|
120
|
+
*
|
|
121
|
+
* One carrier that IS an ancestor of the witness still counts: the one with NO models change
|
|
122
|
+
* between it and the witness. That is the app-only PR — the deploy changed no models, the
|
|
123
|
+
* witness's tree is the base tree, and `rev-list --full-history -- <dir>` lists only the base as a
|
|
124
|
+
* carrier (closing-gate review, 2026-09-07). The pre-episode carrier never qualifies: the T
|
|
125
|
+
* episode sits between it and the witness, so `rev-list carrier..witness -- <dir>` is non-empty.
|
|
126
|
+
*/
|
|
127
|
+
function squashedSuccessor(witness: string, head: string, modelsDir: string, repoRoot: string): string | null {
|
|
128
|
+
const spec = modelsDir === '.' ? `${witness}^{tree}` : `${witness}:${modelsDir}`;
|
|
129
|
+
const tree = git(['rev-parse', '--verify', '--quiet', spec], repoRoot)?.trim();
|
|
130
|
+
if (!tree) return null;
|
|
131
|
+
const carriers = enumerateModelTrees(modelsDir, repoRoot, { ancestorsOf: head }).find((c) => c.tree === tree)?.commits ?? [];
|
|
132
|
+
const modelsUnchangedBetween = (carrier: string): boolean => {
|
|
133
|
+
const changes = git(['rev-list', `${carrier}..${witness}`, '--', modelsDir], repoRoot);
|
|
134
|
+
return changes !== null && changes.trim() === '';
|
|
135
|
+
};
|
|
136
|
+
// Refuse-closed on BOTH git questions: an ancestry git could not answer is not "not an
|
|
137
|
+
// ancestor" (that would accept the carrier), and a rev-list that failed is not "no changes".
|
|
138
|
+
return carriers.find((c) => {
|
|
139
|
+
const anc = ancestry(c, witness, repoRoot);
|
|
140
|
+
if (anc === null) return false;
|
|
141
|
+
return !anc || modelsUnchangedBetween(c);
|
|
142
|
+
}) ?? null;
|
|
143
|
+
}
|
|
144
|
+
|
|
103
145
|
/**
|
|
104
146
|
* Every historical state of the models directory across ALL refs, newest
|
|
105
147
|
* first, grouped by tree oid. `--full-history` keeps every !TREESAME commit
|
|
@@ -132,6 +174,7 @@ export function enumerateModelTrees(
|
|
|
132
174
|
cwd,
|
|
133
175
|
input: specs.join('\n') + '\n',
|
|
134
176
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
177
|
+
timeout: GIT_TIMEOUT_MS,
|
|
135
178
|
}).toString();
|
|
136
179
|
} catch {
|
|
137
180
|
return [];
|
|
@@ -195,8 +238,9 @@ export function materializeTree(tree: string, dest: string, cwd: string): void {
|
|
|
195
238
|
type TreeEval =
|
|
196
239
|
| { kind: 'declares' }
|
|
197
240
|
| { kind: 'no' }
|
|
198
|
-
|
|
199
|
-
| { kind: '
|
|
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 };
|
|
200
244
|
|
|
201
245
|
interface EvalContext {
|
|
202
246
|
repoRoot: string;
|
|
@@ -225,7 +269,8 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
|
|
|
225
269
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
226
270
|
ctx.materialize(candidate.tree, dest, ctx.repoRoot);
|
|
227
271
|
} catch (err: any) {
|
|
228
|
-
|
|
272
|
+
const error = `could not extract — ${firstLine(err.message)}`;
|
|
273
|
+
return { kind: 'unevaluated', detail: `${where}: ${error}`, error };
|
|
229
274
|
}
|
|
230
275
|
}
|
|
231
276
|
|
|
@@ -259,8 +304,100 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
|
|
|
259
304
|
}
|
|
260
305
|
return declares ? { kind: 'declares' } : { kind: 'no' };
|
|
261
306
|
} catch (err: any) {
|
|
262
|
-
|
|
307
|
+
const error = firstLine(err.message);
|
|
308
|
+
return { kind: 'compile-failed', detail: `${where}: ${error}`, error };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** One tree the walk could not read, with where it sits in history — the refusal names it. */
|
|
313
|
+
interface UnreadableTree {
|
|
314
|
+
candidate: ModelTreeCandidate;
|
|
315
|
+
/** Carried by a commit HEAD descends from — no ref prune removes it. */
|
|
316
|
+
ancestor: boolean;
|
|
317
|
+
error: string;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The git verb that removes one ref, or null for a ref shape we do not prescribe deleting. */
|
|
321
|
+
export function pruneCommand(ref: string): string | null {
|
|
322
|
+
const head = ref.match(/^refs\/heads\/(.+)$/);
|
|
323
|
+
if (head) return `git branch -D ${head[1]}`;
|
|
324
|
+
const remote = ref.match(/^refs\/remotes\/([^/]+)\/(.+)$/);
|
|
325
|
+
if (remote) return `git push ${remote[1]} --delete ${remote[2]}`;
|
|
326
|
+
const tag = ref.match(/^refs\/tags\/(.+)$/);
|
|
327
|
+
if (tag) return `git tag -d ${tag[1]}`;
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The unreadable-tree refusal, built from what the walk KNOWS: each tree, the refs that carry it,
|
|
333
|
+
* whether HEAD descends from it, and what the target's ledger says about who deployed what.
|
|
334
|
+
*
|
|
335
|
+
* Consumer cutover, cli 0.4.77 (2026-09-07): four August trees carried only by one backup branch
|
|
336
|
+
* on origin refused a production apply, and the message led with "upgrade/pin the CLI that last
|
|
337
|
+
* deployed this state" — on a database no CLI had ever deployed (schema_log was empty). The
|
|
338
|
+
* operator reconstructed the carrying ref with `git rev-list --all -- db/models` and a per-tree
|
|
339
|
+
* grep. Every fact in that reconstruction was already in this function's hands.
|
|
340
|
+
*
|
|
341
|
+
* The verdict does not change here; only the account of it. A tree we cannot read may still
|
|
342
|
+
* describe live, so the walk stays refuse-closed; the ledger is a memoir and never decides
|
|
343
|
+
* (decision 7). What the ledger DOES decide is which fix comes first: with no recorded apply,
|
|
344
|
+
* no CLI deployed these trees and "pin the CLI" is false, so bundle + prune leads; with one, pin
|
|
345
|
+
* the CLI only when the recorded commit is itself among the unreadable trees.
|
|
346
|
+
*/
|
|
347
|
+
function unreadableRunbook(
|
|
348
|
+
unreadable: readonly UnreadableTree[],
|
|
349
|
+
lastApplied: DescentLedgerRow | undefined,
|
|
350
|
+
probe: GitLineageProbe,
|
|
351
|
+
): string {
|
|
352
|
+
// Bounded on both axes, because this reason is RECORDED in schema_log and each ref lookup is a
|
|
353
|
+
// full ref walk: at most MAX_TREES trees are named, and a tree's refs are the union over its
|
|
354
|
+
// first MAX_COMMITS carrying commits (a tree is often carried by several commits on several
|
|
355
|
+
// branches — naming one commit's refs under-named the prune; closing-gate review, 2026-09-07).
|
|
356
|
+
const MAX_TREES = 10;
|
|
357
|
+
const MAX_COMMITS = 5;
|
|
358
|
+
const listed = unreadable.slice(0, MAX_TREES);
|
|
359
|
+
const refsOf = new Map<string, string[]>();
|
|
360
|
+
for (const u of listed) {
|
|
361
|
+
const refs = new Set<string>();
|
|
362
|
+
for (const commit of u.candidate.commits.slice(0, MAX_COMMITS)) {
|
|
363
|
+
for (const ref of probe.refsContaining(commit)) if (ref !== 'refs/stash') refs.add(ref);
|
|
364
|
+
}
|
|
365
|
+
refsOf.set(u.candidate.tree, [...refs].sort());
|
|
366
|
+
}
|
|
367
|
+
const lines = listed.map((u) => {
|
|
368
|
+
const refs = refsOf.get(u.candidate.tree)!;
|
|
369
|
+
const carried = refs.length === 0
|
|
370
|
+
? 'carried by no ref'
|
|
371
|
+
: `carried by ${refs.slice(0, 3).join(', ')}${refs.length > 3 ? ` and ${refs.length - 3} more` : ''}`;
|
|
372
|
+
const where = u.ancestor ? "on your checkout's own history" : 'not an ancestor of your checkout';
|
|
373
|
+
return ` ${short(u.candidate.tree)} at ${short(u.candidate.commits[0])} — ${carried} (${where}) — ${scrubPaths(u.error)}`;
|
|
374
|
+
});
|
|
375
|
+
if (unreadable.length > listed.length) {
|
|
376
|
+
lines.push(` … and ${unreadable.length - listed.length} more unreadable tree(s) not listed — their refs are not in the prune list below; re-run after pruning to see them`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const prunable = listed.filter((u) => !u.ancestor);
|
|
380
|
+
const ancestors = unreadable.filter((u) => u.ancestor);
|
|
381
|
+
const pruneRefs = [...new Set(prunable.flatMap((u) => refsOf.get(u.candidate.tree)!))];
|
|
382
|
+
const pruneCmds = pruneRefs.map(pruneCommand).filter((c): c is string => c !== null);
|
|
383
|
+
const prune = pruneRefs.length === 0
|
|
384
|
+
? ''
|
|
385
|
+
: `Keep them if you want (git bundle create unreadable-trees.bundle ${pruneRefs.join(' ')}), then prune: ${pruneCmds.join('; ')}; then re-run.`;
|
|
386
|
+
const rewrite = ancestors.length === 0
|
|
387
|
+
? ''
|
|
388
|
+
: `${ancestors.length === unreadable.length ? 'These trees are' : `${ancestors.length} of them ${ancestors.length === 1 ? 'is' : 'are'}`} on your checkout's own history, which no ref prune removes; the walk needs every ancestor tree readable. Rewriting that history is the git-native remedy — keep it first (git bundle create everything.bundle --all).`;
|
|
389
|
+
|
|
390
|
+
let lead: string;
|
|
391
|
+
if (!lastApplied) {
|
|
392
|
+
lead = 'No everystack apply is recorded on this target, so no CLI deployed these trees.';
|
|
393
|
+
} else {
|
|
394
|
+
const at = lastApplied.gitRef ? `at ${short(lastApplied.gitRef)} ` : '';
|
|
395
|
+
const among = lastApplied.gitRef !== null && unreadable.some((u) => u.candidate.commits.includes(lastApplied.gitRef!));
|
|
396
|
+
lead = `everystack last applied here ${at}on ${shortDate(lastApplied.appliedAt)}.`
|
|
397
|
+
+ (among ? ' That commit is among the unreadable trees — pin the CLI that applied it (the version in that commit\'s lockfile) and re-run.' : '');
|
|
398
|
+
if (among) return `${lines.join('\n')}\n${lead}`;
|
|
263
399
|
}
|
|
400
|
+
return `${lines.join('\n')}\n${[lead, prune, rewrite].filter(Boolean).join(' ')}`;
|
|
264
401
|
}
|
|
265
402
|
|
|
266
403
|
/**
|
|
@@ -331,17 +468,32 @@ function resolveRepoContext(
|
|
|
331
468
|
/** `git cat-file -e <ref>^{commit}` — is the commit present in THIS clone (not a shallow gap)? */
|
|
332
469
|
function commitPresent(ref: string, cwd: string): boolean {
|
|
333
470
|
try {
|
|
334
|
-
execFileSync('git', ['cat-file', '-e', `${ref}^{commit}`], { cwd, stdio: 'ignore' });
|
|
471
|
+
execFileSync('git', ['cat-file', '-e', `${ref}^{commit}`], { cwd, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
|
|
335
472
|
return true;
|
|
336
473
|
} catch {
|
|
337
474
|
return false;
|
|
338
475
|
}
|
|
339
476
|
}
|
|
340
477
|
|
|
341
|
-
/**
|
|
478
|
+
/**
|
|
479
|
+
* `git merge-base --is-ancestor`, three-valued: true / false / null when git could not answer
|
|
480
|
+
* (a timeout, a missing object, a broken repo). Every caller whose SAFE answer is "not an
|
|
481
|
+
* ancestor" may use {@link isAncestor}; a caller whose safe answer is the OTHER way — the squash
|
|
482
|
+
* guard accepts a carrier that is NOT an ancestor of the witness — must read null as "refuse".
|
|
483
|
+
*/
|
|
484
|
+
function ancestry(ancestor: string, head: string, cwd: string): boolean | null {
|
|
485
|
+
try {
|
|
486
|
+
execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
|
|
487
|
+
return true;
|
|
488
|
+
} catch (err: any) {
|
|
489
|
+
return err?.status === 1 ? false : null; // exit 1 = "not an ancestor"; anything else = could not tell
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** `git merge-base --is-ancestor <ancestor> <head>` — does HEAD descend from the commit? False when git cannot tell. */
|
|
342
494
|
function isAncestor(ancestor: string, head: string, cwd: string): boolean {
|
|
343
495
|
try {
|
|
344
|
-
execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore' });
|
|
496
|
+
execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
|
|
345
497
|
return true;
|
|
346
498
|
} catch {
|
|
347
499
|
return false;
|
|
@@ -378,6 +530,11 @@ export interface GitLineageProbe {
|
|
|
378
530
|
remotes(): string[] | null;
|
|
379
531
|
/** `git ls-remote --heads --tags <remote>` advertised tip shas; null when the remote is unreachable. */
|
|
380
532
|
lsRemote(remote: string): string[] | null;
|
|
533
|
+
/**
|
|
534
|
+
* Why the last `lsRemote(remote)` returned null — git's own first stderr line, paths scrubbed —
|
|
535
|
+
* or null when nothing was captured. Optional: a probe without it still names the remote.
|
|
536
|
+
*/
|
|
537
|
+
lsRemoteError?(remote: string): string | null;
|
|
381
538
|
/**
|
|
382
539
|
* Does this object peel to a commit (`<sha>^{commit}`)? `null` when the object is absent from
|
|
383
540
|
* the clone. An advertised tip that is present but is a tag on a blob or tree can carry no
|
|
@@ -387,9 +544,41 @@ export interface GitLineageProbe {
|
|
|
387
544
|
peelsToCommit?(sha: string): boolean | null;
|
|
388
545
|
}
|
|
389
546
|
|
|
547
|
+
/**
|
|
548
|
+
* The runbook for a remote `git ls-remote` could not read. Three things it must do, each learned
|
|
549
|
+
* from a consumer's production cutover (cli 0.4.77, 2026-09-07) where the remote was named `db`
|
|
550
|
+
* and "could not read db" was read as the database being unreachable: name the remote AS a git
|
|
551
|
+
* remote, carry git's own error so the cause is on the surface, and name the verb that clears a
|
|
552
|
+
* dead one — `git remote remove` — next to the one that fixes a live one.
|
|
553
|
+
*/
|
|
554
|
+
function unreadableRemote(remote: string, git: GitLineageProbe): string {
|
|
555
|
+
const err = git.lsRemoteError?.(remote);
|
|
556
|
+
return `git remote '${remote}' could not be read: \`git ls-remote --heads --tags ${remote}\` failed${err ? ` — ${err}` : ''}. `
|
|
557
|
+
+ `Fix the remote or remove it (git remote remove ${remote}), then re-run.`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* git's stderr, reduced to what a refusal may carry: the first non-empty line, absolute paths
|
|
562
|
+
* and file:// URLs replaced by `<path>` (the reason is recorded, and a home directory is not
|
|
563
|
+
* part of the diagnosis), capped so a chatty transport cannot flood the surface.
|
|
564
|
+
*/
|
|
565
|
+
export function firstErrorLine(stderr: string): string | null {
|
|
566
|
+
const line = stderr.split('\n').map((l) => l.trim()).find(Boolean);
|
|
567
|
+
if (!line) return null;
|
|
568
|
+
return scrubPaths(line).slice(0, 200);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Absolute paths and file:// URLs replaced by `<path>` — a recorded reason never carries a home directory. */
|
|
572
|
+
export function scrubPaths(s: string): string {
|
|
573
|
+
return s
|
|
574
|
+
.replace(/file:\/\/\S+/g, 'file://<path>')
|
|
575
|
+
.replace(/(^|[\s'"(])\/[^\s'"()]+/g, '$1<path>');
|
|
576
|
+
}
|
|
577
|
+
|
|
390
578
|
export type LineageProof =
|
|
391
579
|
| { proven: true; subcase: 'revert-unreachable' | 'unknown-commit-proven-absent' }
|
|
392
|
-
|
|
580
|
+
/** `ref` is set for `reachable`: the ref (or `HEAD`, or a remote name) that still holds the commit. */
|
|
581
|
+
| { proven: false; kind: 'shallow' | 'partial' | 'reachable' | 'behind-remote' | 'remote-unreachable' | 'remote-list-failed' | 'no-remotes'; reason: string; ref?: string };
|
|
393
582
|
|
|
394
583
|
/**
|
|
395
584
|
* Prove the declaring commit `sha` is reachable from NO ref, local or remote. Refuse-closed: every
|
|
@@ -410,10 +599,10 @@ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProo
|
|
|
410
599
|
if (present) {
|
|
411
600
|
const containing = git.refsContaining(sha);
|
|
412
601
|
if (containing.length > 0) {
|
|
413
|
-
return { proven: false, kind: 'reachable', reason: `commit ${short(sha)} is still reachable (${containing[0]}) — revert it in git and re-plan instead of attesting it lost.` };
|
|
602
|
+
return { proven: false, kind: 'reachable', ref: containing[0], reason: `commit ${short(sha)} is still reachable (${containing[0]}) — revert it in git and re-plan instead of attesting it lost.` };
|
|
414
603
|
}
|
|
415
604
|
if (git.isAncestorOf(sha, 'HEAD')) {
|
|
416
|
-
return { proven: false, kind: 'reachable', reason: `commit ${short(sha)} is still reachable (an ancestor of HEAD) — revert it in git and re-plan instead of attesting it lost.` };
|
|
605
|
+
return { proven: false, kind: 'reachable', ref: 'HEAD', reason: `commit ${short(sha)} is still reachable (an ancestor of HEAD) — revert it in git and re-plan instead of attesting it lost.` };
|
|
417
606
|
}
|
|
418
607
|
}
|
|
419
608
|
|
|
@@ -430,14 +619,14 @@ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProo
|
|
|
430
619
|
for (const remote of remotes) {
|
|
431
620
|
const tips = git.lsRemote(remote);
|
|
432
621
|
if (tips === null) {
|
|
433
|
-
return { proven: false, kind: 'remote-unreachable', reason: `
|
|
622
|
+
return { proven: false, kind: 'remote-unreachable', reason: `cannot confirm ${short(sha)} is not on ${remote}: ${unreadableRemote(remote, git)}` };
|
|
434
623
|
}
|
|
435
624
|
for (const tip of tips) {
|
|
436
625
|
if (!git.objectExists(tip)) {
|
|
437
626
|
return { proven: false, kind: 'behind-remote', reason: `this clone is behind ${remote} (it advertises ${short(tip)}, which you do not have) — fetch it first; the declaring commit may still be on the remote.` };
|
|
438
627
|
}
|
|
439
628
|
if (present && git.isAncestorOf(sha, tip)) {
|
|
440
|
-
return { proven: false, kind: 'reachable', reason: `commit ${short(sha)} is still reachable (on ${remote}) — revert it in git and re-plan instead of attesting it lost.` };
|
|
629
|
+
return { proven: false, kind: 'reachable', ref: remote, reason: `commit ${short(sha)} is still reachable (on ${remote}) — revert it in git and re-plan instead of attesting it lost.` };
|
|
441
630
|
}
|
|
442
631
|
}
|
|
443
632
|
}
|
|
@@ -464,9 +653,12 @@ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProo
|
|
|
464
653
|
|
|
465
654
|
/** The real probe: each method is one git command run in `cwd`. */
|
|
466
655
|
export function realLineageProbe(cwd: string): GitLineageProbe {
|
|
656
|
+
const lsRemoteErrors = new Map<string, string>();
|
|
657
|
+
// A hung remote must become a refusal, not a hang: every probe command is bounded. A timeout
|
|
658
|
+
// throws, so it reads as null — unreachable — and the proof refuses closed as it would offline.
|
|
467
659
|
const run = (args: string[]): string | null => {
|
|
468
660
|
try {
|
|
469
|
-
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
661
|
+
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: GIT_TIMEOUT_MS }).toString();
|
|
470
662
|
} catch {
|
|
471
663
|
return null;
|
|
472
664
|
}
|
|
@@ -489,10 +681,20 @@ export function realLineageProbe(cwd: string): GitLineageProbe {
|
|
|
489
681
|
return out === null ? null : out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
490
682
|
},
|
|
491
683
|
lsRemote: (remote) => {
|
|
492
|
-
|
|
493
|
-
|
|
684
|
+
// stderr is KEPT here, unlike `run`: the refusal this feeds must say why git could not read
|
|
685
|
+
// the remote, or "could not read db" is the database to whoever reads it.
|
|
686
|
+
let out: string;
|
|
687
|
+
try {
|
|
688
|
+
out = execFileSync('git', ['ls-remote', '--heads', '--tags', remote], { cwd, stdio: ['ignore', 'pipe', 'pipe'], timeout: GIT_TIMEOUT_MS }).toString();
|
|
689
|
+
} catch (e) {
|
|
690
|
+
const stderr = (e as { stderr?: Buffer | string }).stderr?.toString() ?? '';
|
|
691
|
+
lsRemoteErrors.set(remote, firstErrorLine(stderr) ?? firstErrorLine((e as Error).message ?? '') ?? 'no error output');
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
lsRemoteErrors.delete(remote);
|
|
494
695
|
return out.split('\n').map((l) => l.split('\t')[0]?.trim()).filter((s): s is string => !!s && s.length >= 7);
|
|
495
696
|
},
|
|
697
|
+
lsRemoteError: (remote) => lsRemoteErrors.get(remote) ?? null,
|
|
496
698
|
peelsToCommit: (sha) => {
|
|
497
699
|
if (!commitPresent(sha, cwd) && run(['cat-file', '-e', sha]) === null) return null; // absent
|
|
498
700
|
return run(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]) !== null;
|
|
@@ -550,8 +752,11 @@ export type ProveVerdict =
|
|
|
550
752
|
| { status: 'fresh-target' }
|
|
551
753
|
/** Not a git checkout — the rule cannot be verified here. */
|
|
552
754
|
| { status: 'no-git' }
|
|
553
|
-
/** Fast-forward proven — via a ledger witness or an ancestor recompile.
|
|
554
|
-
|
|
755
|
+
/** Fast-forward proven — via a ledger witness, its squashed successor, or an ancestor recompile.
|
|
756
|
+
* For `squash`, `commit` is the CARRIER (an ancestor of HEAD holding the deploying commit's
|
|
757
|
+
* models tree) and `witness` is the deploying commit the ledger names, which HEAD does not
|
|
758
|
+
* descend from — both are surfaced and the weaker proof is recorded in schema_log. */
|
|
759
|
+
| { status: 'proven'; via: 'ledger' | 'squash' | 'recompile'; commit: string; witness?: string }
|
|
555
760
|
/** The newest ledger witness is NOT an ancestor of HEAD — applying would revert merged work. */
|
|
556
761
|
| { status: 'revert'; commit: string; appliedAt: string; reason: string }
|
|
557
762
|
/** The witness names a commit this clone does not have (shallow) — fetch, do not guess. */
|
|
@@ -625,7 +830,7 @@ export function proveCloneCurrent(git: GitLineageProbe): { current: true } | { c
|
|
|
625
830
|
for (const remote of remotes) {
|
|
626
831
|
const tips = git.lsRemote(remote);
|
|
627
832
|
if (tips === null) {
|
|
628
|
-
return { current: false, reason: `
|
|
833
|
+
return { current: false, reason: `cannot confirm the clone is current: ${unreadableRemote(remote, git)}` };
|
|
629
834
|
}
|
|
630
835
|
for (const tip of tips) {
|
|
631
836
|
// refs/stash is excluded from the scan (enumerateModelTrees), so a tip contained ONLY by a
|
|
@@ -705,14 +910,17 @@ export async function proveDescent(
|
|
|
705
910
|
if (isAncestor(r.gitRef, head, repoRoot)) {
|
|
706
911
|
return { status: 'proven', via: 'ledger', commit: r.gitRef };
|
|
707
912
|
}
|
|
913
|
+
const squash = squashedSuccessor(r.gitRef, head, modelsDir, repoRoot);
|
|
914
|
+
if (squash) return { status: 'proven', via: 'squash', commit: squash, witness: r.gitRef };
|
|
708
915
|
return {
|
|
709
916
|
status: 'revert',
|
|
710
917
|
commit: r.gitRef,
|
|
711
918
|
appliedAt: r.appliedAt,
|
|
712
919
|
reason:
|
|
713
920
|
`your checkout is behind the branch that last deployed this state — commit ${short(r.gitRef)} ` +
|
|
714
|
-
`(applied ${shortDate(r.appliedAt)}) is NOT an ancestor of HEAD
|
|
715
|
-
'
|
|
921
|
+
`(applied ${shortDate(r.appliedAt)}) is NOT an ancestor of HEAD, and no ancestor of HEAD carries ` +
|
|
922
|
+
'its models tree as a squashed successor. Applying now would revert that merged work. ' +
|
|
923
|
+
'Pull the deploying branch, rebase onto it, and re-plan.',
|
|
716
924
|
};
|
|
717
925
|
}
|
|
718
926
|
|
|
@@ -751,37 +959,39 @@ export async function proveDescent(
|
|
|
751
959
|
|
|
752
960
|
const compileFailures: string[] = [];
|
|
753
961
|
const unevaluated: string[] = [];
|
|
962
|
+
const unreadable: UnreadableTree[] = [];
|
|
963
|
+
const probe = opts.probe ?? realLineageProbe(repoRoot);
|
|
964
|
+
const lastApplied = ordered.find((r) => isAppliedOutcome(r.outcome));
|
|
754
965
|
const unverifiable = (scope: string, scanned: number): ProveVerdict => {
|
|
755
966
|
const notes: string[] = [];
|
|
756
967
|
if (compileFailures.length > 0) notes.push(`${compileFailures.length} would not compile under this CLI`);
|
|
757
968
|
if (unevaluated.length > 0) notes.push(`${unevaluated.length} could not be extracted`);
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
|
|
969
|
+
// Every unreadable tree is NAMED — with the refs that carry it, its place in history, and its
|
|
970
|
+
// own error ("Cannot find module '@everystack/model'" is a resolution problem with a different
|
|
971
|
+
// fix than "an older tree that no longer builds") — and the runbook leads with the fix the
|
|
972
|
+
// ledger supports. See unreadableRunbook for the incident that shaped this.
|
|
762
973
|
return {
|
|
763
974
|
status: 'unverifiable',
|
|
764
975
|
reason:
|
|
765
976
|
`cannot verify the fast-forward rule — of ${scanned} ${scope} model state(s), ` +
|
|
766
977
|
`${notes.join(' and ')}, and none of the readable ones declares the live state. This is a ` +
|
|
767
978
|
'limit of THIS run (an older tree that no longer builds, or a failed extraction), not ' +
|
|
768
|
-
|
|
769
|
-
`this state, or prune the branch that carries the unreadable tree, and re-run.${first ? ` First failure: ${first}` : ''}`,
|
|
979
|
+
`evidence about the database — it is NOT drift. Unreadable:\n${unreadableRunbook(unreadable, lastApplied, probe)}`,
|
|
770
980
|
unevaluated,
|
|
771
981
|
compileFailures,
|
|
772
982
|
};
|
|
773
983
|
};
|
|
984
|
+
const record = (candidate: ModelTreeCandidate, result: Extract<TreeEval, { error: string }>, ancestor: boolean): void => {
|
|
985
|
+
(result.kind === 'unevaluated' ? unevaluated : compileFailures).push(result.detail);
|
|
986
|
+
unreadable.push({ candidate, ancestor, error: result.error });
|
|
987
|
+
};
|
|
774
988
|
|
|
775
989
|
let others: ModelTreeCandidate[] = [];
|
|
776
990
|
try {
|
|
777
991
|
for (const candidate of candidates) {
|
|
778
992
|
const result = await evaluateTree(candidate, live, evalCtx);
|
|
779
|
-
if (result.kind === 'unevaluated') {
|
|
780
|
-
|
|
781
|
-
continue;
|
|
782
|
-
}
|
|
783
|
-
if (result.kind === 'compile-failed') {
|
|
784
|
-
compileFailures.push(result.detail);
|
|
993
|
+
if (result.kind === 'unevaluated' || result.kind === 'compile-failed') {
|
|
994
|
+
record(candidate, result, true);
|
|
785
995
|
continue;
|
|
786
996
|
}
|
|
787
997
|
if (result.kind === 'no') continue;
|
|
@@ -824,7 +1034,7 @@ export async function proveDescent(
|
|
|
824
1034
|
}
|
|
825
1035
|
// Currency BEFORE the all-refs compile: a clone that is shallow, partial, or behind a remote
|
|
826
1036
|
// is told to fetch now, not after paying for every historical tree on every ref.
|
|
827
|
-
const currency = proveCloneCurrent(
|
|
1037
|
+
const currency = proveCloneCurrent(probe);
|
|
828
1038
|
if (!currency.current) {
|
|
829
1039
|
return {
|
|
830
1040
|
status: 'unverifiable',
|
|
@@ -838,12 +1048,8 @@ export async function proveDescent(
|
|
|
838
1048
|
others = all.filter((c) => !seen.has(c.tree));
|
|
839
1049
|
for (const candidate of others) {
|
|
840
1050
|
const result = await evaluateTree(candidate, live, evalCtx);
|
|
841
|
-
if (result.kind === 'unevaluated') {
|
|
842
|
-
|
|
843
|
-
continue;
|
|
844
|
-
}
|
|
845
|
-
if (result.kind === 'compile-failed') {
|
|
846
|
-
compileFailures.push(result.detail);
|
|
1051
|
+
if (result.kind === 'unevaluated' || result.kind === 'compile-failed') {
|
|
1052
|
+
record(candidate, result, false);
|
|
847
1053
|
continue;
|
|
848
1054
|
}
|
|
849
1055
|
if (result.kind === 'no') continue;
|
|
@@ -873,7 +1079,6 @@ export async function proveDescent(
|
|
|
873
1079
|
// this is the first adoption of a database everystack never wrote. A dirty-worktree apply is
|
|
874
1080
|
// still an apply — its git_ref declares nothing (Phase A skips it as a witness), so the state
|
|
875
1081
|
// it produced was never committed anywhere; the origin says so.
|
|
876
|
-
const lastApplied = ordered.find((r) => isAppliedOutcome(r.outcome));
|
|
877
1082
|
const origin: DriftOrigin = lastApplied
|
|
878
1083
|
? { kind: 'hand-edited', commit: lastApplied.gitRef, appliedAt: lastApplied.appliedAt, ...(lastApplied.dirty ? { dirty: true } : {}) }
|
|
879
1084
|
: { kind: 'never-declared' };
|
|
@@ -929,9 +1134,44 @@ export function attestationNote(a: { commit: string; subcase: string }): string
|
|
|
929
1134
|
return `descent-attested-lineage-lost (${short(a.commit)}, ${a.subcase})`;
|
|
930
1135
|
}
|
|
931
1136
|
|
|
1137
|
+
/** The recorded note for a squash proof — goes in the apply row's schema_log outcome. */
|
|
1138
|
+
export function squashNote(carrier: string, witness: string): string {
|
|
1139
|
+
return `descent: squash-successor (${short(carrier)} carries the models tree of ${short(witness)}, which HEAD does not descend from)`;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Does the operator's attestation name this commit? Exact, or a hex prefix of at least 7
|
|
1144
|
+
* characters — compared against the ONE commit the refusal named, so there is nothing for a
|
|
1145
|
+
* prefix to be ambiguous with. The refusal prints the 12-character form, and an operator who
|
|
1146
|
+
* pastes it back is naming that commit. A strict string compare refused
|
|
1147
|
+
* "42a3b97af246 does not match (42a3b97af246)" — two identical strings — because the ledger holds
|
|
1148
|
+
* 40 characters and the refusal printed 12 (consumer cutover, cli 0.4.77, 2026-09-07).
|
|
1149
|
+
*/
|
|
1150
|
+
export function attestsCommit(attestation: string, commit: string): boolean {
|
|
1151
|
+
const a = attestation.trim().toLowerCase();
|
|
1152
|
+
if (a === commit.toLowerCase()) return true;
|
|
1153
|
+
return a.length >= 7 && /^[0-9a-f]+$/.test(a) && commit.toLowerCase().startsWith(a);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Resolve an attestation through git before it is compared: a prefix, a tag, a branch, or a
|
|
1158
|
+
* `HEAD~n` becomes the full sha. A value git cannot resolve (the commit is absent from this clone —
|
|
1159
|
+
* the `unknown-commit-proven-absent` subcase, or a typo) passes through unchanged so the prefix
|
|
1160
|
+
* compare above still gets its chance and a mismatch names what was given.
|
|
1161
|
+
*/
|
|
1162
|
+
export function resolveAttestation(attestation: string, cwd: string): string {
|
|
1163
|
+
const full = git(['rev-parse', '--verify', '--quiet', `${attestation.trim()}^{commit}`], cwd)?.trim();
|
|
1164
|
+
return full && /^[0-9a-f]{40,64}$/.test(full) ? full : attestation;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
932
1167
|
export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyContext): DescentApplyDecision {
|
|
933
1168
|
switch (verdict.status) {
|
|
934
1169
|
case 'proven':
|
|
1170
|
+
// A squash proof rests on models-tree identity, not ancestry of the deploying commit — the
|
|
1171
|
+
// record says so, exactly as an honoured attestation and a proven drift leave their trace.
|
|
1172
|
+
return verdict.via === 'squash' && verdict.witness
|
|
1173
|
+
? { ok: true, note: squashNote(verdict.commit, verdict.witness) }
|
|
1174
|
+
: { ok: true };
|
|
935
1175
|
case 'fresh-target':
|
|
936
1176
|
return { ok: true };
|
|
937
1177
|
|
|
@@ -969,8 +1209,8 @@ export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyConte
|
|
|
969
1209
|
// construction, so the lineage-lost attestation can never be honoured here — the proof refuses
|
|
970
1210
|
// it as reachable. The escape is git-native: merge or rebase.
|
|
971
1211
|
if (!ctx.attestation) return { ok: false, reason: verdict.reason };
|
|
972
|
-
if (ctx.attestation
|
|
973
|
-
return { ok: false, reason: `--attest-lineage-lost ${
|
|
1212
|
+
if (!attestsCommit(ctx.attestation, verdict.commit)) {
|
|
1213
|
+
return { ok: false, reason: `--attest-lineage-lost ${ctx.attestation} does not match the commit that declares this state (${verdict.commit}) — drop the flag; that commit is reachable, so merge or rebase instead.` };
|
|
974
1214
|
}
|
|
975
1215
|
const proof = ctx.proveLineage(verdict.commit);
|
|
976
1216
|
return proof.proven
|
|
@@ -988,10 +1228,11 @@ export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyConte
|
|
|
988
1228
|
reason: `${verdict.reason} If that commit is gone for good (its branch was rebased away or deleted), attest it: --attest-lineage-lost ${short(verdict.commit)}${ctx.destructive ? ' --confirm' : ''}.`,
|
|
989
1229
|
};
|
|
990
1230
|
}
|
|
991
|
-
if (ctx.attestation
|
|
1231
|
+
if (!attestsCommit(ctx.attestation, verdict.commit)) {
|
|
1232
|
+
// Both in FULL: a short form on both sides once printed two identical strings as a mismatch.
|
|
992
1233
|
return {
|
|
993
1234
|
ok: false,
|
|
994
|
-
reason: `--attest-lineage-lost ${
|
|
1235
|
+
reason: `--attest-lineage-lost ${ctx.attestation} does not match the commit that declared this state (${verdict.commit}) — attest the exact commit the refusal names, or drop the flag.`,
|
|
995
1236
|
};
|
|
996
1237
|
}
|
|
997
1238
|
const proof = ctx.proveLineage(verdict.commit);
|