@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.
@@ -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 } 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';
@@ -70,6 +72,90 @@ import { compileTableContract } from '../authz-compile';
70
72
  import { reportPipelineLastRun } from './pipeline-run';
71
73
  import { step, success, fail, info, warn, reserveStdoutForData } from '../output';
72
74
 
75
+ /**
76
+ * What db:plan says about descent at mint — SYMMETRIC with what db:apply will do, because a mint
77
+ * that warns "will refuse" for a plan apply then runs (or the reverse) is the lie this used to
78
+ * tell. Exported so a test pins every verdict's text. The `never` arm makes the switch REALLY
79
+ * exhaustive: a new verdict fails to compile here instead of minting a plan with no warning and
80
+ * refusing at apply with no hint of why.
81
+ */
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
+ };
111
+ switch (verdict.status) {
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
+ : [];
118
+ case 'fresh-target':
119
+ case 'no-git':
120
+ return [];
121
+ case 'drift':
122
+ return [
123
+ `descent: NO COMMIT ON ANY REF DECLARES THIS DATABASE (${describeDriftOrigin(verdict.origin, { human: true })}) — proven over a full, current clone.`,
124
+ 'db:apply will treat this reviewed plan as the ORIGIN: it applies, warns, and records the origin in schema_log; every later apply descends from it.',
125
+ ];
126
+ case 'declared-elsewhere':
127
+ return [
128
+ `descent: ${verdict.reason}`,
129
+ 'db:apply from this checkout will refuse this plan — fetch and merge or rebase onto that commit, then re-mint.',
130
+ ];
131
+ case 'revert':
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
+ }
145
+ case 'unverifiable':
146
+ // NOT "rebase first" — nothing is wrong with the checkout or the database. The search could
147
+ // not finish (an unreadable tree, or a clone that is not full and current).
148
+ return [`descent: ${verdict.reason}`, 'db:apply from this checkout will refuse this plan until that search completes — fetch or prune as it says, then re-mint.'];
149
+ default: {
150
+ // Compile-time exhaustiveness; at runtime (a verdict shape from outside this compile unit)
151
+ // an empty warning list is the safe value — the caller iterates it.
152
+ const exhaustive: never = verdict;
153
+ void exhaustive;
154
+ return [];
155
+ }
156
+ }
157
+ }
158
+
73
159
  const DEFAULT_OUT = 'db.plan.json';
74
160
 
75
161
  /**
@@ -251,7 +337,17 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
251
337
  await fs.writeFile(out, body, 'utf8');
252
338
  }
253
339
 
254
- 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);
255
351
  info(`plan_ref: ${planHash(plan).slice(0, 12)}`);
256
352
 
257
353
  // WHY does each authorization statement exist? — the same report db:generate prints,
@@ -289,28 +385,9 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
289
385
  // A switch, not an `if (revert || drift)`. That form silently ignored every verdict it
290
386
  // did not name, so a new one would mint a plan with no warning and then be refused at
291
387
  // apply time with no hint of why. Exhaustive here means a new verdict cannot be forgotten.
292
- switch (verdict.status) {
293
- case 'proven':
294
- case 'fresh-target':
295
- case 'no-git':
296
- break;
297
- case 'revert':
298
- case 'drift':
299
- warn(`descent: ${verdict.reason}`);
300
- warn('db:apply from this checkout will refuse this plan — rebase first, then re-mint.');
301
- break;
302
- case 'unknown-commit':
303
- // NOT "rebase first" — the checkout is fine, it is just missing the deploying commit.
304
- warn(`descent: ${verdict.reason}`);
305
- warn('db:apply from this checkout will refuse this plan until you fetch that commit — fetch, then re-mint.');
306
- break;
307
- case 'unverifiable':
308
- // NOT "rebase first" — nothing is wrong with the checkout or the database. The
309
- // search could not read an ancestor state, so the rule is unproven either way.
310
- warn(`descent: ${verdict.reason}`);
311
- warn('db:apply from this checkout will refuse this plan until that search completes — re-mint and try again.');
312
- break;
313
- }
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);
314
391
  }
315
392
 
316
393
  // Visibility, never automation (brick 8): the review surface names the
@@ -236,10 +236,12 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
236
236
  // as exemptions too would double-book them — declared and exempted at once.
237
237
  pulledExemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
238
238
  // A6's advisory. A blanket `GRANT USAGE ON ALL SEQUENCES` beside a blanket SELECT is a
239
- // common brownfield shape, and the inheritance rule never touches it — the rule only ADDS
240
- // what an INSERT needs — so it would sit unnoticed forever. Named, never revoked: revoking
241
- // it would be the tool deciding a security property from an inference.
242
- for (const line of renderVestigialSequenceGrants(vestigialSequenceGrants(contract))) caution(line);
239
+ // common brownfield shape. The advisory says exactly what the next db:plan will do with it,
240
+ // so it must see the SAME governed set the plan will use: the vocabulary plus --govern-roles.
241
+ // (With the default set, a --govern-roles grantee was told "not governed, nothing revokes
242
+ // it" while the plan minted the REVOKE — closing-gate review, 2026-09-07.)
243
+ const governedAtPull = new Set<string>([...ALWAYS_GOVERNED, ...governRoles]);
244
+ for (const line of renderVestigialSequenceGrants(vestigialSequenceGrants(contract), governedAtPull)) caution(line);
243
245
  pulledFingerprint = fingerprintLive(current, contract).hash;
244
246
  }
245
247
  // --matviews-as-tables: the flip needs real fields — one extra catalog read for 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)`);