@everystack/cli 0.4.76 → 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,35 @@ 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
+
17
+ ## [0.4.77] - 2026-09-07
18
+
19
+ ### Added
20
+
21
+ - proven drift is the operator's decision — passes at apply with its origin recorded
22
+ - proven drift is announced as the origin on both lanes; refusals name a verb that works
23
+
24
+ ### Fixed
25
+
26
+ - currency is ref reachability, not object presence; a failed all-refs walk is unverifiable
27
+ - an empty barrel declares nothing; a non-commit tag is not 'behind'; tests bind the production decision
28
+ - one shipped descent step for both lanes, bound end to end by the real CLI; trees materialize inside the project
29
+ - the sandbox sits below the nearest node_modules above the barrel; a dirty-worktree origin reaches every surface
30
+ - the compile-failure detail keeps the cause; the monorepo test skips loudly; the stash filter sits on the refuse-closed side
31
+
32
+ ### Documentation
33
+
34
+ - proven drift, declared-elsewhere, the adoption lifecycle table, and a db:adopt section
35
+
7
36
  ## [0.4.76] - 2026-09-07
8
37
 
9
38
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.76",
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>",
@@ -131,9 +131,11 @@ export interface ApplyPlanOptions {
131
131
  /** Injectable clock for tests. */
132
132
  now?: () => number;
133
133
  /**
134
- * A note for the schema_log outcome when the fast-forward rule was waived by a
135
- * `--attest-lineage-lost` attestation. A getter, because the attestation is decided inside
136
- * `checkDescent` (which runs before the write) — the getter reads that decision at write time.
134
+ * A note for the schema_log outcome (`applied — <note>`) carrying what descent decided: a
135
+ * honoured `--attest-lineage-lost` waiver, or a PROVEN DRIFT origin (`descent: drift-origin
136
+ * proven (never-declared | hand-edited since … | applied from a DIRTY worktree at …)`) — the record that makes this row the origin
137
+ * every later apply descends from. A getter, because the decision is made inside `checkDescent`
138
+ * (which runs before the write) — the getter reads it at write time.
137
139
  */
138
140
  descentNote?: () => string | undefined;
139
141
  }
@@ -205,10 +207,16 @@ export interface LiveBaseAndLedger extends LiveBaseFingerprint {
205
207
  * The newest successful state applies — the descent witnesses. Read alongside the live state
206
208
  * (allowFailure, because a database that never applied through this CLI has no schema_log
207
209
  * yet). Newest-first, capped: the proof consults the newest usable row and stops.
210
+ *
211
+ * `applied%`, not `= 'applied'`: an apply that carried a descent note (an honoured lineage
212
+ * attestation, or a proven drift origin) is recorded as `applied — <note>`, and it is STILL a
213
+ * successful apply — the row that proves the next fast-forward. Matching the bare word only
214
+ * silently dropped every annotated apply from the ledger, which for a drift origin defeats the
215
+ * entire point of recording it.
208
216
  */
209
217
  export const SCHEMA_LOG_WITNESS_SQL = `SELECT id, to_fingerprint, git_ref, fp_version, dirty, applied_at, outcome
210
218
  FROM everystack.schema_log
211
- WHERE kind = 'state apply' AND outcome = 'applied' AND to_fingerprint IS NOT NULL
219
+ WHERE kind = 'state apply' AND outcome LIKE 'applied%' AND to_fingerprint IS NOT NULL
212
220
  ORDER BY id DESC
213
221
  LIMIT 200`;
214
222
 
@@ -34,7 +34,7 @@ export function snapshotMovedRefusal(ref: string, captured: string, planFrom: st
34
34
  // The USER-FACING refusal message (apply-legibility Phase 3, p3-action-refusals).
35
35
  //
36
36
  // Every refusal db:apply reaches from executeApplyPlan prints an ACTION, never a check name. The
37
- // reason string is already action-phrased at its source (checkPlanPrecondition, proveVerdictForApply,
37
+ // reason string is already action-phrased at its source (checkPlanPrecondition, decideDescentApply,
38
38
  // checkDestructiveAuthority, the snapshot gate, the baseline gate). This layer only marks the OUTCOME
39
39
  // and forwards that reason — it does NOT re-label the check the way the old
40
40
  // `REFUSED (fast-forward rule):` / `(snapshot gate)` / `(destructive authority)` wording did.
@@ -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
  }
@@ -127,7 +127,7 @@ export interface VestigialSequenceGrant {
127
127
  }
128
128
 
129
129
  /**
130
- * Sequence grants with no corresponding INSERT — an ADVISORY, never a revoke.
130
+ * Sequence grants with no corresponding INSERT — the db:pull ADVISORY.
131
131
  *
132
132
  * The shape a brownfield database arrives in: a blanket `GRANT USAGE ON ALL SEQUENCES IN
133
133
  * SCHEMA public` sitting beside a blanket `GRANT SELECT ON ALL TABLES`, years old, granted
@@ -135,11 +135,13 @@ export interface VestigialSequenceGrant {
135
135
  * holding USAGE on all 27 sequences with INSERT on zero tables — and it was the single
136
136
  * divergence between their database and the inheritance rule.
137
137
  *
138
- * Reported rather than revoked, and the distinction is the whole point: the inheritance rule
139
- * only ever ADDS what an INSERT needs, so a vestigial grant is untouched by reconciliation and
140
- * would otherwise sit there unnoticed forever. Naming it lets the owner decide. Revoking it
141
- * would be the tool making a security decision from an inference — the same move we refused for
142
- * the invented search_path.
138
+ * What the reconciler does with it depends on the grantee, and the advisory must say so
139
+ * exactly (it once said "nothing revokes it — revoke by hand", which sent an operator to a
140
+ * credential for an edge the tool itself mints): a GOVERNED grantee (anon / authenticated /
141
+ * admin / PUBLIC, or a declared governedRole) has its vestigial grant REVOKED by the next
142
+ * plan, as an authz removal — the Models cannot declare a sequence grant with no INSERT
143
+ * behind it, so the plan converges the database on the inheritance rule. An UNGOVERNED
144
+ * grantee is left alone (reconcileSequenceGrants skips it) and stays on this list every pull.
143
145
  */
144
146
  export function vestigialSequenceGrants(live: AuthzContract): VestigialSequenceGrant[] {
145
147
  if (!live.sequenceGrants) return []; // not measured
@@ -163,12 +165,24 @@ export function vestigialSequenceGrants(live: AuthzContract): VestigialSequenceG
163
165
  .sort((a, b) => a.grantee.localeCompare(b.grantee));
164
166
  }
165
167
 
166
- /** One line per grantee — the shared rendering, same idiom as renderGrantExemptions. */
167
- export function renderVestigialSequenceGrants(findings: readonly VestigialSequenceGrant[]): string[] {
168
- return findings.map(({ grantee, sequences }) =>
169
- `${grantee} holds privileges on ${sequences.length} sequence(s) but can INSERT into none of their tables — `
170
- + `probably a vestigial blanket GRANT ... ON ALL SEQUENCES. Nothing revokes it (the inheritance rule only adds); `
171
- + `review and revoke by hand if it is dead: ${sequences.slice(0, 6).join(', ')}${sequences.length > 6 ? `, +${sequences.length - 6} more` : ''}.`);
168
+ /**
169
+ * One line per grantee — the shared rendering, same idiom as renderGrantExemptions. The line
170
+ * names the verb that acts on it: a governed grantee's vestigial grant is an authz removal in
171
+ * the next db:plan; an ungoverned one is left alone.
172
+ */
173
+ export function renderVestigialSequenceGrants(
174
+ findings: readonly VestigialSequenceGrant[],
175
+ /** REQUIRED: the same governed set the next db:plan will use (the vocabulary plus any
176
+ * --govern-roles / declared governedRoles). A default here reproduced the bug this fixes. */
177
+ governed: ReadonlySet<string>,
178
+ ): string[] {
179
+ return findings.map(({ grantee, sequences }) => {
180
+ const list = `${sequences.slice(0, 6).join(', ')}${sequences.length > 6 ? `, +${sequences.length - 6} more` : ''}`;
181
+ const head = `${grantee} holds privileges on ${sequences.length} sequence(s) but can INSERT into none of their tables — probably a vestigial blanket GRANT ... ON ALL SEQUENCES.`;
182
+ return isGoverned(governed, grantee)
183
+ ? `${head} ${grantee} is governed, so the next db:plan REVOKES it (an authz removal — the Models cannot declare a sequence grant with no INSERT behind it): ${list}.`
184
+ : `${head} ${grantee} is not governed, so nothing revokes it; declare it in governedRoles to bring it under the plan, or leave it: ${list}.`;
185
+ });
172
186
  }
173
187
 
174
188
  /** A USING/WITH CHECK clause expression, parenthesized exactly once. */
@@ -5,8 +5,7 @@
5
5
  * [--govern-roles r1,r2]
6
6
  *
7
7
  * The problem it solves: a production database that never went through Models has no committed
8
- * ancestor declaring it, so db:apply refuses it as unverifiable drift — and no waiver fits (there
9
- * is nothing to attest a lineage FROM). db:adopt is the deliberate, auditable on-ramp. It renders
8
+ * ancestor declaring it. db:adopt is the deliberate, auditable on-ramp for taking it AS IT IS: it renders
10
9
  * Models from the live database (the same renderers db:pull uses), compiles them back, and PROVES
11
10
  * the compiled Models declare the live state EXACTLY — predicted == governed live, the same
12
11
  * declares-test descent and db:check use. Only then does it write the model files and print the
@@ -15,8 +14,11 @@
15
14
  *
16
15
  * The doctrine (Fable-ruled 2026-09-05): PROVE, not attest. There is NO bypass flag. When the
17
16
  * round-trip is not exact, adoption REFUSES, names the surfaces that diverged, and prints the
18
- * db:generate edge — because a baseline that papered over a difference is the bypass under another
19
- * name. It writes NOTHING on refusal; --out is left untouched.
17
+ * edge — because a baseline that papered over a difference is the bypass under another name. It
18
+ * writes NOTHING on refusal; --out is left untouched. That refusal is the HARDENING case (the
19
+ * vocabulary cannot declare something live), and it needs no adoption commit: commit the intended
20
+ * Models, db:plan, db:apply — descent proves no commit declares the database and treats the
21
+ * reviewed plan as the origin (git-descent.ts, proven drift, 2026-09-07).
20
22
  *
21
23
  * The pure proof core is adopt.ts (proveAdoption); the shared render is live-models.ts
22
24
  * (renderLiveModelFiles). This shell is the IO: connect, render into a sandbox to compile, prove,
@@ -123,7 +125,10 @@ export async function applyAdoptVerdict(result: AdoptResult, outDir: string): Pr
123
125
  for (const stmt of result.refusalDiff) console.log(stmt.endsWith(';') ? stmt : `${stmt};`);
124
126
  }
125
127
  console.log('');
126
- info('The renderer could not yet capture the surfaces above. Refine by hand until db:generate is empty, then commit the Models — a later db:apply will descend normally.');
128
+ info('The Models vocabulary cannot declare the surfaces above as they are live (a sequence grant with no INSERT behind it, for example), so no render can round-trip this database. That is the HARDENING case, and it needs no adoption commit: commit the Models you intend, then');
129
+ info(' everystack db:plan --stage <name> # mints the edge above as a reviewable plan');
130
+ info(' everystack db:apply --plan <file.plan.json> --stage <name> # credential-free; no commit declares this database yet, so the reviewed plan is the ORIGIN and is recorded as such');
131
+ info('Every later db:apply descends from that recorded origin.');
127
132
  return { exitCode: 1, wrote: false };
128
133
  }
129
134
 
@@ -36,11 +36,15 @@ import { planHash, PLAN_VERSION, APPLY_CONTRACT_VERSION, type EdgePlan } from '.
36
36
  import {
37
37
  proveDescent,
38
38
  decideDescentApply,
39
+ describeDriftOrigin,
39
40
  attestationNote,
40
41
  proveLineageLost,
41
42
  realLineageProbe,
43
+ resolveAttestation,
42
44
  type DescentLedgerRow,
43
45
  type LiveState,
46
+ type ProveVerdict,
47
+ type ProveDescentOptions,
44
48
  } from '../git-descent';
45
49
  import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity, type AuthorityVerdict } from '../apply-authority';
46
50
  import { decideSnapshotMode, type SnapshotModeRequest } from '../swap-snapshot';
@@ -108,6 +112,90 @@ export function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void
108
112
  }
109
113
  }
110
114
 
115
+ /**
116
+ * Say what descent found, on BOTH lanes identically. Exported so a test can pin the one line
117
+ * that matters most: proven drift is announced as the ORIGIN, loudly, before anything writes.
118
+ */
119
+ export function describeDescentVerdict(verdict: ProveVerdict, opts: { destructive?: boolean } = {}): { level: 'info' | 'warn'; text: string } | null {
120
+ switch (verdict.status) {
121
+ case 'proven':
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.` };
128
+ case 'fresh-target':
129
+ return { level: 'info', text: 'descent: fresh target (no tables) — nothing to protect, bootstrapping.' };
130
+ case 'no-git':
131
+ // On a destructive plan the decision refuses with its own message; do not double-report.
132
+ return opts.destructive ? null : { level: 'warn', text: 'descent: not a git checkout — the fast-forward rule cannot be verified here.' };
133
+ case 'drift':
134
+ return {
135
+ level: 'warn',
136
+ text: `NO COMMIT ON ANY REF DECLARES THIS DATABASE (${describeDriftOrigin(verdict.origin, { human: true })}) — this reviewed plan is the ORIGIN. The fast-forward rule has nothing merged to protect (proven over a full, current clone); the origin is recorded in schema_log, and every later apply descends from it.`,
137
+ };
138
+ case 'declared-elsewhere':
139
+ case 'revert':
140
+ case 'unknown-commit':
141
+ case 'unverifiable':
142
+ return null; // the decision refuses with the verdict's own reason
143
+ }
144
+ }
145
+
146
+ function reportDescentVerdict(verdict: ProveVerdict, opts: { destructive?: boolean } = {}): void {
147
+ const line = describeDescentVerdict(verdict, opts);
148
+ if (!line) return;
149
+ (line.level === 'warn' ? warn : info)(line.text);
150
+ }
151
+
152
+ /**
153
+ * THE descent step, shared by both lanes: prove the fast-forward rule over the live state and the
154
+ * ledger, report the verdict, decide it (attestation honoured only where proven), and hand back
155
+ * the wire/record shape — `descentVerdict` for the gate and `descentAttested` for the schema_log
156
+ * outcome (`applied — <note>`): a honoured lineage-lost waiver, or a PROVEN DRIFT origin.
157
+ *
158
+ * One function so the origin record cannot be dropped on one lane and kept on the other, and so
159
+ * a test can drive the SHIPPED wiring without AWS (the stage lane passes in the state it double-
160
+ * read and the ledger it fetched; the direct lane passes what executeApplyPlan read in-session).
161
+ * The closing-gate review found the note assignment unbound at both call sites — this is the bind.
162
+ */
163
+ export async function proveDescentForApply(input: {
164
+ modelsPath: string;
165
+ live: LiveState;
166
+ ledger: DescentLedgerRow[];
167
+ destructive: boolean;
168
+ attestation?: string;
169
+ /** Where git runs. Default process.cwd(). */
170
+ cwd?: string;
171
+ /** Test seam: the barrel loader and materialize root (jest cannot dynamic-import a .ts barrel).
172
+ * Production passes nothing and rides the real loader. */
173
+ descent?: Pick<ProveDescentOptions, 'loader' | 'materializeRoot'>;
174
+ }): Promise<{ descentVerdict: { ok: true } | { ok: false; reason: string }; descentAttested?: string }> {
175
+ const cwd = input.cwd ?? process.cwd();
176
+ const verdict = await proveDescent(input.modelsPath, input.live, input.ledger, {
177
+ ...(input.cwd ? { cwd: input.cwd } : {}),
178
+ ...(input.descent ?? {}),
179
+ });
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.
183
+ const decision = decideDescentApply(verdict, {
184
+ ...(input.attestation ? { attestation: resolveAttestation(input.attestation, cwd) } : {}),
185
+ destructive: input.destructive,
186
+ proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(cwd)),
187
+ });
188
+ if (!decision.ok) return { descentVerdict: { ok: false, reason: decision.reason } };
189
+ if (decision.attested) {
190
+ const descentAttested = attestationNote(decision.attested);
191
+ warn(`DESCENT ATTESTED — ${descentAttested}. The declaring commit is proven gone from every ref; the fast-forward rule is waived for this apply and the waiver is recorded.`);
192
+ return { descentVerdict: { ok: true }, descentAttested };
193
+ }
194
+ // A proven-drift origin rides the same recorded field as a waiver — it is the same kind of
195
+ // record: what descent decided, and why this row is the one later applies descend from.
196
+ return { descentVerdict: { ok: true }, ...(decision.note ? { descentAttested: decision.note } : {}) };
197
+ }
198
+
111
199
  /**
112
200
  * db:apply --stage: the credential-free staged write. The operator never holds
113
201
  * a database URL — the WRITE runs in the ops Lambda's db:apply action against
@@ -180,32 +268,17 @@ async function applyPlanViaStage(
180
268
  // git-native (git revert / db:generate / fetch); the one attestation
181
269
  // (--attest-lineage-lost) is honoured only when the CLI proves the declaring
182
270
  // commit is gone from every ref.
183
- let descentVerdict: { ok: true } | { ok: false; reason: string };
184
- let descentAttested: string | undefined;
185
- {
186
- const { snapshot, contract } = readPair.state;
187
- step("Descent: reading the stage's schema_log ledger...");
188
- const ledger = await readDescentLedger(lambdaSessionRunner(region, fn));
189
- step("Descent: proving the fast-forward rule against the ledger...");
190
- const verdict = await proveDescent(resolveModelsPath(flags.models), { snapshot, contract }, ledger, {});
191
- if (verdict.status === 'proven') {
192
- info(`descent: proven via ${verdict.via} — ${verdict.commit.slice(0, 12)} is an ancestor of HEAD, fast-forward.`);
193
- } else if (verdict.status === 'fresh-target') {
194
- info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
195
- } else if (verdict.status === 'no-git') {
196
- warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
197
- }
198
- const decision = decideDescentApply(verdict, {
199
- ...(attestLineageLost ? { attestation: attestLineageLost } : {}),
200
- destructive: plan.destructive > 0,
201
- proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(process.cwd())),
202
- });
203
- if (decision.ok && decision.attested) {
204
- descentAttested = attestationNote(decision.attested);
205
- warn(`DESCENT ATTESTED — ${descentAttested}. The declaring commit is proven gone from every ref; the fast-forward rule is waived for this apply and the waiver is recorded.`);
206
- }
207
- descentVerdict = decision.ok ? { ok: true } : { ok: false, reason: decision.reason };
208
- }
271
+ const { snapshot, contract } = readPair.state;
272
+ step("Descent: reading the stage's schema_log ledger...");
273
+ const ledger = await readDescentLedger(lambdaSessionRunner(region, fn));
274
+ step("Descent: proving the fast-forward rule against the ledger...");
275
+ const { descentVerdict, descentAttested } = await proveDescentForApply({
276
+ modelsPath: resolveModelsPath(flags.models),
277
+ live: { snapshot, contract },
278
+ ledger,
279
+ destructive: plan.destructive > 0,
280
+ ...(attestLineageLost ? { attestation: attestLineageLost } : {}),
281
+ });
209
282
 
210
283
  // Destructive ceremony — the approver check is against the caller's REAL
211
284
  // IAM identity (STS), which the Lambda cannot see, so the CLI resolves it
@@ -571,24 +644,15 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
571
644
  descentNote: () => descentAttested,
572
645
  checkDescent: async (live: LiveState & { fingerprint: string; ledger: DescentLedgerRow[] }) => {
573
646
  step('Descent: proving the fast-forward rule against the schema_log ledger...');
574
- const verdict = await proveDescent(resolveModelsPath(flags.models), live, live.ledger, {});
575
- if (verdict.status === 'proven') {
576
- info(`descent: proven via ${verdict.via} — ${verdict.commit.slice(0, 12)} is an ancestor of HEAD, fast-forward.`);
577
- } else if (verdict.status === 'fresh-target') {
578
- info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
579
- } else if (verdict.status === 'no-git' && plan.destructive === 0) {
580
- warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
581
- }
582
- const decision = decideDescentApply(verdict, {
583
- ...(attestLineageLost ? { attestation: attestLineageLost } : {}),
647
+ const result = await proveDescentForApply({
648
+ modelsPath: resolveModelsPath(flags.models),
649
+ live,
650
+ ledger: live.ledger,
584
651
  destructive: plan.destructive > 0,
585
- proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(process.cwd())),
652
+ ...(attestLineageLost ? { attestation: attestLineageLost } : {}),
586
653
  });
587
- if (decision.ok && decision.attested) {
588
- descentAttested = attestationNote(decision.attested);
589
- warn(`DESCENT ATTESTED — ${descentAttested}. The declaring commit is proven gone from every ref; the fast-forward rule is waived for this apply and the waiver is recorded.`);
590
- }
591
- return decision.ok ? { ok: true } : { ok: false, reason: decision.reason };
654
+ descentAttested = result.descentAttested;
655
+ return result.descentVerdict;
592
656
  },
593
657
  }),
594
658
  );