@everystack/cli 0.4.77 → 0.4.79

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,30 @@ 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.79] - 2026-09-07
8
+
9
+ ### Removed
10
+
11
+ - remove db:adopt — its proof was "would db:plan be empty", and db:plan answers that on the review surface
12
+
13
+ ### Added
14
+
15
+ - db:pull ends with the on-ramp runbook; docs and help say the on-ramp is pull, commit, plan
16
+
17
+ ### Fixed
18
+
19
+ - closing-gate round 1 — the as-is lane runs the shipped db:pull, the runbook is gated on --abilities live, the fixture proves live derivation, live-models.ts is gone
20
+
21
+ ## [0.4.78] - 2026-09-07
22
+
23
+ ### Fixed
24
+
25
+ - the adoption report describes the plan it ships with
26
+ - the unreadable-tree refusal names the refs that carry each tree and leads with the fix the ledger supports
27
+ - 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
28
+ - closing-gate round 1 — app-only squash, squash proof recorded and announced, bounded runbook, scrubbed errors, git timeouts
29
+ - 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
30
+
7
31
  ## [0.4.77] - 2026-09-07
8
32
 
9
33
  ### Added
@@ -931,6 +955,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
931
955
 
932
956
  ## [0.2.37] - 2026-06-17
933
957
 
958
+ ### Removed
959
+
960
+ - remove onMutation callback — events replaces it
961
+ - remove unused @everystack/jobs devDependency
962
+ - remove secret auto-injection, skip SSR check for web-only updates
963
+ - remove media asset pruning from update command
964
+
934
965
  ### Added
935
966
 
936
967
  - rename @everystack/updates → @everystack/cli, add db:migrate/db:seed, remove AdminSecret
@@ -982,7 +1013,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
982
1013
  ### Fixed
983
1014
 
984
1015
  - normalize cache:purge --path to strip query params and ensure leading slash
985
- - remove unused @everystack/jobs devDependency
986
1016
  - include updateId in register-web payload
987
1017
  - psql Lambda-only queries, composite key notify, logging defaults
988
1018
  - add AWS KVS + SigV4a as direct dependencies
@@ -1001,14 +1031,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
1001
1031
  - drop GetFunctionConfiguration from log group resolution
1002
1032
  - detect export completion to prevent static pre-rendering hang
1003
1033
  - rewrite secrets for SST Ion, filter EXPO_PUBLIC_* only
1004
- - remove secret auto-injection, skip SSR check for web-only updates
1005
1034
  - clear Metro transform cache on export
1006
1035
  - worker function discovery and log group resolution for SQS subscribers
1007
1036
  - replace infrastructure headers with single X-Server-Debug timestamp
1008
1037
  - version bumps and publish fixes for cli, server, jobs
1009
1038
  - invalidate stale config cache on schema change
1010
1039
  - fall back to outputs.json for custom-named media buckets
1011
- - remove media asset pruning from update command
1012
1040
  - media-sync test uses same minimatch interop as source
1013
1041
  - discover the Router distribution by domain and SST tags
1014
1042
  - use minimatch's named export — the package ships types now
@@ -1017,7 +1045,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
1017
1045
 
1018
1046
  ### Changed
1019
1047
 
1020
- - remove onMutation callback — events replaces it
1021
1048
  - migrate all package schemas to everyschema canonical tables
1022
1049
  - lazy-load AWS SDK imports for faster CLI startup
1023
1050
  - drop type module from all packages, switch Jest to CJS mode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.77",
3
+ "version": "0.4.79",
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
@@ -38,6 +38,36 @@ import { fingerprintLive } from '../schema-fingerprint';
38
38
  import { ungovernedGrants, vestigialSequenceGrants, renderVestigialSequenceGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile';
39
39
  import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline';
40
40
  import { introspectDerived, type DerivedCatalog } from '../derived-introspect';
41
+
42
+ /**
43
+ * The on-ramp runbook, printed last so the operator is never left guessing what a pull is FOR.
44
+ *
45
+ * db:pull is the only way an existing database comes in. What happens next is decided by
46
+ * db:plan, not by a verb: an EMPTY plan means the committed Models declare the database as it
47
+ * is (adopted — descent proves the pull commit by recompile, and every later apply descends from
48
+ * it); a NON-EMPTY plan is the hardening edge, reviewed and applied as the origin. db:adopt used
49
+ * to bolt a proof onto this render; the proof was "would the plan be empty", so the verb is gone
50
+ * (cli 0.4.79) and this is what replaced it. Exported so a test pins the lines and the
51
+ * refusal-runbook lint reads them.
52
+ */
53
+ export function pullOnRampRunbook(opts: { stage?: string; out?: string; abilities?: string }): string[] {
54
+ const target = opts.stage ? `--stage ${opts.stage}` : '--stage <name>';
55
+ const what = opts.out ? `the files in ${opts.out}` : 'these Models';
56
+ // Only a LIVE pull transcribes the database's own authz; only then can an empty plan mean
57
+ // "adopted as it is". A commented or public-read pull carries authored abilities — its plan is
58
+ // an edge to review (public-read on an existing database GRANTS public read of every table),
59
+ // never an adoption, and the runbook must not call it one (closing-gate review, 2026-09-07).
60
+ if (opts.abilities !== 'live') {
61
+ return [
62
+ `Next: author each model's abilities (db:check fails until every model declares) — for an EXISTING database, re-pull with --abilities live to derive them from the grants and policies already there. Then commit ${what} and run \`everystack db:plan ${target}\`: the plan is an edge to review, not an adoption of what is live.`,
63
+ ];
64
+ }
65
+ return [
66
+ `Next: commit ${what}, then \`everystack db:plan ${target}\`.`,
67
+ 'EMPTY plan = adopted as it is: that commit declares the database, and every later db:apply descends from it. Nothing to apply.',
68
+ `NON-EMPTY plan = the hardening edge: review it, then \`everystack db:apply --plan <file> ${target}\`. No commit declares this database yet, so the reviewed plan is the ORIGIN — db:apply proves that and records it.`,
69
+ ];
70
+ }
41
71
  import { introspectContract, type TableContract, type AuthzContract } from '../authz-contract';
42
72
  import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership';
43
73
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
@@ -461,5 +491,6 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
461
491
  note(`Committing this file ADOPTS those roles. Anything not in it — or anything that grows beyond it — refuses at db:plan.`);
462
492
  }
463
493
  }
494
+ for (const line of pullOnRampRunbook({ stage: flags.stage, out: flags.out, abilities })) note(line);
464
495
  process.exit(0);
465
496
  }
@@ -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)`);