@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.77",
3
+ "version": "0.4.78",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -30,7 +30,8 @@
30
30
 
31
31
  import { matchPolicies, roleSetEqual } from './authz-identity';
32
32
  import { governedRoleSet } from './authz-reconcile';
33
- import { effectivePolicyCheck, holdsPrivilege, isPolicyDead, policyCommands } from './authz-contract';
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
- /** Same authorization on every axis EXCEPT which roles it names — the role-split test. */
82
- function sameRule(a: PolicyContract, b: PolicyContract): boolean {
83
- return a.command === b.command
84
- && a.permissive === b.permissive
85
- && (a.using ?? '') === (b.using ?? '')
86
- && (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
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
- * A policy's predicate, collapsed to one line for the report.
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 using = (p.using ?? 'true').replace(/\s+/g, ' ').trim();
98
- return using === 'true' ? 'true (unfiltered)' : using;
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
- if (sameRoleReplacements.length && !sameRoleReplacements.some((r) => sameRule(r, live))) {
185
- const shared = [...new Set(sameRoleReplacements.flatMap((r) => r.roles.filter((role) => live.roles.includes(role))))];
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(', ')} USING ${pred(live)}; the declared replacement `
192
- + `${sameRoleReplacements.map((r) => `"${r.name}"`).join(', ')} keeps the same role(s) but states `
193
- + `${sameRoleReplacements.map((r) => pred(r)).join(' / ')} — a different rule, not a different spelling`,
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
- out.push({ table, subject: r.name, cls: 'narrowing', why: `the differently-predicated replacement for "${name}"` });
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
- ` convention ${counts.convention} — our spelling, not their schema (this must reach 0)`,
303
- ` capability ${counts.capability} — the model cannot express what is live`,
304
- ` narrowing ${counts.narrowing} — real access reduction, approve before applying`,
305
- ` dead ${counts.dead} — policed but never granted`,
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(` unclassified ${counts.unclassified} — NOT described; read these before applying:`);
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
- return { level: 'info', text: `descent: proven via ${verdict.via} — ${verdict.commit.slice(0, 12)} is an ancestor of HEAD, fast-forward.` };
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(input.cwd ?? process.cwd())),
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(verdict: ProveVerdict): string[] {
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}`, 'db:apply from this checkout will refuse this plan — rebase first, then re-mint.'];
98
- case 'unknown-commit':
99
- // NOT "rebase first" — the checkout is fine, it is just missing the deploying commit.
100
- return [`descent: ${verdict.reason}`, 'db:apply from this checkout will refuse this plan until you fetch that commit — fetch, then re-mint.'];
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
- for (const line of buildPlanSummary(plan)) info(line);
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
- for (const line of describePlanDescent(verdict)) warn(line);
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
@@ -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
- /** Human summary of a plan — the review surface. */
413
- export function buildPlanSummary(plan: EdgePlan): string[] {
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
- lines.push('empty — the target already declares this state');
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)`);
@@ -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
- | { kind: 'compile-failed'; detail: string }
199
- | { kind: 'unevaluated'; detail: string };
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
- return { kind: 'unevaluated', detail: `${where}: could not extract — ${firstLine(err.message)}` };
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
- return { kind: 'compile-failed', detail: `${where}: ${firstLine(err.message)}` };
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
- /** `git merge-base --is-ancestor <ancestor> <head>` — does HEAD descend from the commit? */
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
- | { proven: false; kind: 'shallow' | 'partial' | 'reachable' | 'behind-remote' | 'remote-unreachable' | 'remote-list-failed' | 'no-remotes'; reason: string };
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: `could not read ${remote} to confirm ${short(sha)} is not on it — check connectivity and retry, or fetch the remote first.` };
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
- const out = run(['ls-remote', '--heads', '--tags', remote]);
493
- if (out === null) return null;
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
- | { status: 'proven'; via: 'ledger' | 'recompile'; commit: string }
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: `could not read ${remote} to confirm the clone is current — check connectivity and retry, or fetch the remote first.` };
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. Applying now would revert ` +
715
- 'that merged work. Pull the deploying branch, rebase onto it, and re-plan.',
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
- // Show the FIRST actual error: "Cannot find module '@everystack/model'" is a resolution
759
- // problem with a different fix than "an older tree that no longer builds", and a message that
760
- // hides it sends the operator to upgrade a CLI that is not the cause.
761
- const first = compileFailures[0] ?? unevaluated[0];
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
- 'evidence about the database — it is NOT drift. Upgrade/pin the CLI that last deployed ' +
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
- unevaluated.push(result.detail);
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(opts.probe ?? realLineageProbe(repoRoot));
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
- unevaluated.push(result.detail);
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 !== verdict.commit) {
973
- return { ok: false, reason: `--attest-lineage-lost ${short(ctx.attestation)} does not match the commit that declares this state (${short(verdict.commit)}) — drop the flag; that commit is reachable, so merge or rebase instead.` };
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 !== verdict.commit) {
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 ${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.`,
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);