@everystack/cli 0.4.68 → 0.4.70

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.
@@ -31,10 +31,28 @@
31
31
 
32
32
  import fs from 'node:fs/promises';
33
33
  import { type QueryRunner } from '../authz-contract';
34
- import { currentGitRef } from '../state-apply';
35
- import { planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan';
36
- import { verifyDescent, type DescentVerdict, type LiveState } from '../git-descent';
34
+ import { currentGitRef, worktreeDirty } from '../state-apply';
35
+ import { planHash, PLAN_VERSION, APPLY_CONTRACT_VERSION, type EdgePlan } from '../edge-plan';
36
+ import {
37
+ proveDescent,
38
+ decideDescentApply,
39
+ attestationNote,
40
+ proveLineageLost,
41
+ realLineageProbe,
42
+ type DescentLedgerRow,
43
+ type LiveState,
44
+ } from '../git-descent';
37
45
  import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity, type AuthorityVerdict } from '../apply-authority';
46
+ import { decideSnapshotMode, type SnapshotModeRequest } from '../swap-snapshot';
47
+ import { confirmRollbackPoint, type RollbackPoint } from '../rollback-point';
48
+ import { stageConfirmDeps } from '../stage-rollback-deps';
49
+ import { verifySnapshotRef, SNAPSHOT_FRESHNESS_MS, utcStamp } from '../backup';
50
+ import { fetchBackupManifest } from '../backup-resolve';
51
+ import { confirmLocalDump } from '../local-rollback';
52
+ import { destructiveConfirmRefusal, snapshotMovedRefusal, formatApplyRefusal } from '../apply-refusals';
53
+ import { decideStageLane, checkStageApplyContract } from '../apply-lane';
54
+ import { promptYesNo } from '../prompt';
55
+ import path from 'node:path';
38
56
  import { resolveModelsPath } from '../models-path';
39
57
  import { loadModels } from './db-generate';
40
58
  import { loadDeclaredDerived } from '../declared-derived';
@@ -45,11 +63,11 @@ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '
45
63
  import { borrowedSessionRunner, type SessionRunner } from '../session';
46
64
  import { resolveConfig, opsFunction } from '../config';
47
65
  import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws';
48
- import { executeApplyPlan, type ApplyPlanResult } from '../apply-execute';
66
+ import { executeApplyPlan, readDescentLedger, type ApplyPlanResult } from '../apply-execute';
49
67
  import { estimateOpsRuntimeFit } from '../ops-fit';
50
68
  import { resolveOperatorUrlViaStage } from '../direct-venue';
51
69
  import { withMutationLease, MutationLeaseError } from '../mutation-lease';
52
- import { readStageStateTwice, stageDestructiveRefusal } from '../stage-read-consistency';
70
+ import { readStageStateTwice } from '../stage-read-consistency';
53
71
  import { step, success, fail, info, warn } from '../output';
54
72
 
55
73
  // The apply core lives in apply-execute.ts (shared with the ops Lambda's
@@ -62,60 +80,31 @@ export type { ApplyPlanOptions, ApplyPlanResult } from '../apply-execute';
62
80
  // The CLI shell.
63
81
  // ---------------------------------------------------------------------------
64
82
 
65
- /**
66
- * Collapse a DescentVerdict into the ok/reason shape the apply core (and the
67
- * ops Lambda's db:apply action) enforce. `no-git` / `fresh-target` pass — the
68
- * rule can't be verified there — matching the direct-URL path's callback.
69
- */
70
- export function descentVerdictForApply(verdict: DescentVerdict): { ok: true } | { ok: false; reason: string } {
71
- switch (verdict.status) {
72
- case 'ok':
73
- case 'fresh-target':
74
- case 'no-git':
75
- return { ok: true };
76
- case 'diverged':
77
- case 'drift':
78
- // `indeterminate` refuses like the others, and must: the search was not exhaustive, so
79
- // the fast-forward rule is unproven. Its REASON is the difference — it points at this
80
- // run, not at the database. Never fold it into `drift`.
81
- case 'indeterminate':
82
- return { ok: false, reason: verdict.reason };
83
- }
84
- }
85
-
86
83
  /**
87
84
  * Render an ApplyPlanResult — shared by the direct (`--database-url`) and the
88
85
  * credential-free staged (`--stage`) paths, so the two report identically.
89
86
  * Every refusal and the verify-fault exit non-zero.
87
+ *
88
+ * Exported so a test can drive the EXIT path directly — the layer the
89
+ * silent-exit-0 bug actually lived (a refusal status with no case printed
90
+ * nothing and exited 0). formatApplyRefusal owns the string; this owns the
91
+ * process.exit(1).
90
92
  */
91
- function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void {
93
+ export function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void {
92
94
  switch (result.status) {
93
95
  case 'already-applied':
94
96
  success(`Already at ${plan.toFingerprint.slice(0, 12)} — nothing to do (idempotent re-apply).`);
95
- break;
96
- case 'refused':
97
- fail(`REFUSED: ${result.reason}`);
98
- process.exit(1);
99
- break;
100
- case 'descent-refused':
101
- fail(`REFUSED (fast-forward rule): ${result.reason}`);
102
- process.exit(1);
103
- break;
104
- case 'authority-refused':
105
- fail(`REFUSED (destructive authority): ${result.reason}`);
106
- process.exit(1);
107
- break;
108
- case 'snapshot-refused':
109
- fail(`REFUSED (snapshot gate): ${result.reason}`);
110
- process.exit(1);
111
- break;
112
- case 'verify-failed':
113
- fail(`Verify FAILED: ${result.reason}`);
114
- process.exit(1);
115
- break;
97
+ return;
116
98
  case 'applied':
117
99
  success(`Applied and verified — the target is at ${result.liveFingerprint.slice(0, 12)}, exactly as the plan predicted. Recorded in everystack.schema_log (plan_ref ${planHash(plan).slice(0, 12)}).`);
118
- break;
100
+ return;
101
+ default:
102
+ // Every remaining status is a refusal or a verify fault. TS narrows result.status here to
103
+ // exactly ApplyRefusalStatus, so a future status that is neither success nor a known refusal
104
+ // stops this compiling — the fallthrough that let `authz-refused` print nothing and exit 0
105
+ // cannot come back. The message is an action; the internal cause code stays in schema_log.
106
+ fail(formatApplyRefusal(result.status, result.reason ?? ''));
107
+ process.exit(1);
119
108
  }
120
109
  }
121
110
 
@@ -127,15 +116,20 @@ function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void {
127
116
  * destructive-approver check. The Lambda re-verifies the concurrency lock,
128
117
  * applies the DDL in one transaction, stamps schema_log, and verifies plan.to.
129
118
  */
119
+ /** applyPlanViaStage runs the credential-free ops-lane write, OR asks to escalate to the direct
120
+ * lane (a read-disagreement it cannot resolve on this lane). It never returns after a successful
121
+ * apply — reportApplyResult exits — so a returned value is always the escalate request. */
122
+ type StageOutcome = { escalate: true; reason: string } | void;
123
+
130
124
  async function applyPlanViaStage(
131
125
  plan: EdgePlan,
132
126
  flags: Record<string, string>,
133
- forceDescent: string | undefined,
134
- ): Promise<void> {
127
+ ): Promise<StageOutcome> {
135
128
  step('Resolving deployed config...');
136
129
  const config = await resolveConfig(flags.stage);
137
130
  const { region } = config;
138
131
  const fn = opsFunction(config);
132
+ const attestLineageLost = flags['attest-lineage-lost']; // validated (not bare 'true') in dbApplyCommand
139
133
 
140
134
  info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
141
135
 
@@ -151,45 +145,66 @@ async function applyPlanViaStage(
151
145
  process.exit(1);
152
146
  });
153
147
  if (!readPair.ok) {
148
+ // q2-escalate: a read-disagreement no longer dead-ends. INTERACTIVELY, ask to escalate to the
149
+ // direct lane — ONE session against one database, the lane that CAN read consistently (and the
150
+ // apply's concurrency lock still refuses if the state truly moved, so escalation bypasses no
151
+ // safety). NON-interactively, keep the actionable refusal: never auto-escalate an unattended run
152
+ // (it resolves a privileged credential), and the refusal already names --direct.
153
+ if (process.stdin.isTTY) {
154
+ return {
155
+ escalate: true,
156
+ reason: `The stage was read twice and the two reads disagree (${readPair.first.slice(0, 12)} then ${readPair.second.slice(0, 12)}) — the state may be changing, or inconsistent ops-Lambda containers answered. The direct lane reads over ONE session against one database, which resolves this.`,
157
+ };
158
+ }
154
159
  fail(readPair.reason);
155
160
  process.exit(1);
156
161
  }
157
162
  warn(readPair.warning);
158
163
 
164
+ // q1-contract-version direction B: the reads passed — now verify the deployed stage speaks THIS
165
+ // CLI's db:apply wire contract BEFORE dispatching the write. An older ops Lambda would silently
166
+ // under-enforce a gate the CLI relies on it to run; catch it with a redeploy message, not after a
167
+ // write. Pre-dispatch, so it opens no connection and writes no schema_log row.
168
+ step("Verifying the deployed stage speaks this CLI's apply contract...");
169
+ const contract = checkStageApplyContract(await invokeAction(region, fn, '_health', {}));
170
+ if (!contract.ok) {
171
+ fail(`REFUSED: ${contract.reason}`);
172
+ process.exit(1);
173
+ }
174
+
159
175
  try {
160
176
  // Descent — the fast-forward rule needs the git checkout, so the CLI
161
177
  // decides it (the Lambda has no checkout). It runs against the state the
162
178
  // double read already produced (no third trip), and the verdict goes to the
163
- // write action, which enforces + records it in order.
179
+ // write action, which enforces + records it in order. A refusal's escape is
180
+ // git-native (git revert / db:generate / fetch); the one attestation
181
+ // (--attest-lineage-lost) is honoured only when the CLI proves the declaring
182
+ // commit is gone from every ref.
164
183
  let descentVerdict: { ok: true } | { ok: false; reason: string };
165
- if (forceDescent !== undefined) {
166
- warn(`DESCENT FORCED — the fast-forward rule is bypassed for this apply. Snapshot on record: ${forceDescent}.`);
167
- descentVerdict = { ok: true };
168
- } else {
184
+ let descentAttested: string | undefined;
185
+ {
169
186
  const { snapshot, contract } = readPair.state;
170
- step("Descent: searching git for the commit that declares the stage's state...");
171
- const verdict = await verifyDescent(resolveModelsPath(flags.models), { snapshot, contract }, {});
172
- switch (verdict.status) {
173
- case 'ok':
174
- info(`descent: ${verdict.commit.slice(0, 12)} declares the stage's state and is an ancestor of HEAD — fast-forward.`);
175
- break;
176
- case 'fresh-target':
177
- info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
178
- break;
179
- case 'no-git':
180
- warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
181
- break;
182
- case 'diverged':
183
- case 'drift':
184
- break; // the reason travels to the action's refusal + the memoir
185
- case 'indeterminate':
186
- // Said out loud here because the operator's next move is "run it again", not
187
- // "reconcile your models" — and a refusal that reads like drift sends them the
188
- // wrong way. The reason still travels to the refusal.
189
- warn(`descent: ${verdict.unevaluated.length} historical tree(s) could not be extracted — the search was not exhaustive.`);
190
- break;
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.`);
191
206
  }
192
- descentVerdict = descentVerdictForApply(verdict);
207
+ descentVerdict = decision.ok ? { ok: true } : { ok: false, reason: decision.reason };
193
208
  }
194
209
 
195
210
  // Destructive ceremony — the approver check is against the caller's REAL
@@ -206,7 +221,7 @@ async function applyPlanViaStage(
206
221
  if (plan.destructive > 0) {
207
222
  const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
208
223
  if (flags.confirm !== 'true') {
209
- fail(`This plan is DESTRUCTIVE — ${plan.destructive} statement(s) lose data (${shape}). Explicit confirmation is required, always: re-run with --confirm.`);
224
+ fail(destructiveConfirmRefusal(plan.destructive, shape));
210
225
  process.exit(1);
211
226
  }
212
227
  step(`Destructive authority: checking the approver set for stage ${flags.stage}...`);
@@ -226,9 +241,14 @@ async function applyPlanViaStage(
226
241
  step('Applying in the ops Lambda (credential-free — the operator never holds a database URL)...');
227
242
  const result: any = await invokeAction(region, fn, 'db:apply', {
228
243
  plan,
244
+ // q1-contract-version direction A: the server refuses on exact mismatch, so a payload-key or
245
+ // gate-semantics skew is a hard refusal, never a silently dropped gate.
246
+ contractVersion: APPLY_CONTRACT_VERSION,
229
247
  actor: process.env.USER ?? null,
230
248
  gitRef: currentGitRef() ?? plan.gitRef,
249
+ dirty: worktreeDirty(resolveModelsPath(flags.models)),
231
250
  descentVerdict,
251
+ ...(descentAttested ? { descentAttested } : {}),
232
252
  ...(authorityVerdict ? { authorityVerdict } : {}),
233
253
  ...(flags['snapshot-ref'] ? { snapshotRef: flags['snapshot-ref'] } : {}),
234
254
  stage: flags.stage,
@@ -237,6 +257,19 @@ async function applyPlanViaStage(
237
257
  fail(`Apply failed: ${result.error}`);
238
258
  process.exit(1);
239
259
  }
260
+ // The probe/dispatch split-brain belt (q1-contract-version, Fable red team). Direction B's _health
261
+ // handshake and this write are SEPARATE invokes; mid-deploy they can land on two container
262
+ // versions, so the health check can pass on a new container while the WRITE runs on an old one
263
+ // that ignores the contract and silently skips the fast-forward rule — the two-invokes-two-
264
+ // containers blend, reproduced inside its own fix. It cannot be prevented (the write already
265
+ // committed), so surface it LOUDLY post-write: a current server stamps every applied result with
266
+ // the contract it ran under, so an applied result missing that stamp means an older container
267
+ // served the write. A refused result is not affected (nothing landed).
268
+ const landed = result?.status === 'applied' || result?.status === 'already-applied';
269
+ if (landed && result?.contractVersion !== APPLY_CONTRACT_VERSION) {
270
+ fail(`APPLIED, BUT UNVERIFIED: the write landed, but the ops Lambda did not stamp the apply contract (got v${result?.contractVersion ?? '(none)'}, expected v${APPLY_CONTRACT_VERSION}). A mid-deploy split-brain can route the health check and the write to different code versions — an older container silently skips the fast-forward rule, so this apply MAY have reverted merged work. Verify NOW: everystack db:fingerprint --stage ${flags.stage}, and inspect everystack.schema_log. Once the deploy has fully rolled out, re-plan and re-apply.`);
271
+ process.exit(1);
272
+ }
240
273
  if (result?.snapshotId) {
241
274
  info(`snapshot on record: ${result.snapshotId} — restore with db:restore --from ${result.snapshotId} --confirm.`);
242
275
  }
@@ -247,6 +280,36 @@ async function applyPlanViaStage(
247
280
  }
248
281
  }
249
282
 
283
+ /**
284
+ * Escalate a stage apply to the direct lane. Announce WHY, get one line of consent BEFORE any
285
+ * credential is resolved, then resolve the operator connection from the stage into memory (property
286
+ * 4: the operator never holds a URL) and return it as the db source. Shared by the two escalation
287
+ * triggers — a plan the ops lane cannot run (destructive/too-large), and a read-disagreement the ops
288
+ * lane cannot resolve (q2-escalate). On decline or resolve-failure it exits non-zero, never falls
289
+ * through. Reached only where a person can consent — the callers gate on interactivity first.
290
+ */
291
+ async function escalateStageToDirect(
292
+ reason: string,
293
+ plan: EdgePlan,
294
+ planPath: string,
295
+ stage: string,
296
+ ): Promise<Extract<DbSource, { kind: 'url' }>> {
297
+ info(reason);
298
+ info('It needs the direct lane, which resolves the operator connection FROM the stage itself (you never hold a URL) and runs CLI-side over ONE session with an unbounded clock.');
299
+ const consented = await promptYesNo(`Resolve the operator connection and run this apply on the direct lane for stage ${stage}? [y/N] `);
300
+ if (!consented) {
301
+ fail(`Aborted — no lane chosen. Re-run with --direct to name the lane (everystack db:apply --plan ${planPath} --stage ${stage} --direct${plan.destructive > 0 ? ' --confirm' : ''}), or make the plan additive.`);
302
+ process.exit(1);
303
+ }
304
+ step('Resolving the operator connection from the stage...');
305
+ const op = await resolveOperatorUrlViaStage(stage).catch((err: any) => {
306
+ fail(err.message);
307
+ process.exit(1);
308
+ });
309
+ info(`operator credential resolved (${op.source}) — executing CLI-side, unbounded clock.`);
310
+ return { kind: 'url', url: op.url, from: 'operator' };
311
+ }
312
+
250
313
  export async function dbApplyCommand(flags: Record<string, string>): Promise<void> {
251
314
  const planPath = flags.plan;
252
315
  if (!planPath) {
@@ -274,19 +337,15 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
274
337
  process.exit(1);
275
338
  }
276
339
 
277
- // The fast-forward rule's force ceremony (design decision 9): explicit
278
- // (--force-descent), snapshotted (its value NAMES the snapshot you took),
279
- // and confirmed (--confirm). All three or no bypass.
280
- const forceDescent = flags['force-descent'];
281
- if (forceDescent !== undefined) {
282
- if (forceDescent === 'true') {
283
- fail('--force-descent needs the snapshot you took as its value: --force-descent <snapshot-ref> (take one first: db:snapshot or db:backup).');
284
- process.exit(1);
285
- }
286
- if (flags.confirm !== 'true') {
287
- fail('--force-descent bypasses the fast-forward rule — confirm explicitly with --confirm.');
288
- process.exit(1);
289
- }
340
+ // The narrow descent attestation (replaces the retired --force-descent, Ty-ratified 2026-08-26).
341
+ // `--attest-lineage-lost <sha>` waives the fast-forward rule for the ONE case no re-plan can express:
342
+ // the declaring commit is proven unreachable from every ref. It NAMES the commit (must equal the one
343
+ // the refusal reports) and is honoured only when the CLI's own git proof concurs — every other
344
+ // descent refusal has a flagless git-native escape. `true` (a bare flag) is a usage error.
345
+ const attestLineageLost = flags['attest-lineage-lost'];
346
+ if (attestLineageLost === 'true') {
347
+ fail('--attest-lineage-lost needs the declaring commit as its value: --attest-lineage-lost <sha> (the commit the descent refusal names). It is honoured only when that commit is proven gone from every ref.');
348
+ process.exit(1);
290
349
  }
291
350
 
292
351
  // `--stage --direct` (lane 1): resolve the stage's OPERATOR connection from its ops
@@ -305,75 +364,149 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
305
364
  }
306
365
  }
307
366
 
308
- // Deployed stage, no direct URL: the write runs in the ops Lambda — the
309
- // operator never holds a database URL. --database-url stays local-only.
367
+ // Deployed stage, no explicit --direct: INFER the lane from the plan (p2-infer-lane). The ops-Lambda
368
+ // lane is credential-free but cannot run a destructive plan (its fingerprint can be a container
369
+ // collage) and cannot outlast the 900s clock. When the plan needs neither, it runs there. When it
370
+ // needs the direct lane, the tool reaches for the operator connection ITSELF — announcing why,
371
+ // getting one line of consent, and only then resolving the credential (into memory only, property 4).
310
372
  if (dbSource.kind === 'stage') {
311
- // A DESTRUCTIVE plan does not run on this lane at all — no ceremony lifts it.
312
- // The stage lane's fingerprint (the concurrency lock) can be assembled from
313
- // several ops-Lambda containers, and the double read below only DETECTS that;
314
- // it cannot rule it out. Dropping data on a read that may be a collage is the
315
- // one trade never worth making, so the destructive path is the direct lane's.
316
- if (plan.destructive > 0) {
317
- const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
318
- fail(stageDestructiveRefusal(shape, planPath, flags.stage));
319
- process.exit(1);
320
- }
321
- // Will this edge fit the ops-Lambda's 900-second clock? If not, refuse up front and
322
- // name --direct (credential-free, unbounded) instead of burning 15 minutes to learn it.
373
+ const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
323
374
  const fit = estimateOpsRuntimeFit(plan);
324
- if (!fit.fits) {
325
- fail(`REFUSED (ops-Lambda runtime): ${fit.reason}`);
375
+ const lane = decideStageLane({
376
+ destructive: plan.destructive > 0,
377
+ destructiveShape: shape,
378
+ destructiveCount: plan.destructive,
379
+ fits: fit.fits,
380
+ ...(fit.fits ? {} : { fitReason: fit.reason }),
381
+ interactive: !!process.stdin.isTTY,
382
+ confirmed: flags.confirm === 'true',
383
+ planPath,
384
+ stage: flags.stage,
385
+ });
386
+
387
+ if (lane.action === 'refuse') {
388
+ fail(`REFUSED: ${lane.reason}`);
326
389
  process.exit(1);
327
390
  }
328
- await applyPlanViaStage(plan, flags, forceDescent);
329
- return;
391
+ if (lane.action === 'ops') {
392
+ // Run credential-free in the ops Lambda. A void return = applied (or an internal refusal already
393
+ // exited). A returned escalate request = a read-disagreement this lane could not resolve; take
394
+ // it to the direct lane with the same announce→consent→resolve machinery (q2-escalate).
395
+ const outcome = await applyPlanViaStage(plan, flags);
396
+ if (!outcome) return;
397
+ dbSource = await escalateStageToDirect(outcome.reason, plan, planPath, flags.stage);
398
+ } else {
399
+ // escalate — the plan needs the direct lane (destructive / too-large). flags.stage stays set, so
400
+ // the direct path below runs the full stage-aware ceremony (approvers, the stage's snapshot lane).
401
+ dbSource = await escalateStageToDirect(lane.reason, plan, planPath, flags.stage);
402
+ }
330
403
  }
331
404
 
332
405
  // Losing data should be hard (decision 11): a destructive plan applies
333
- // only confirmed (--confirm, always), snapshotted (automatic with --stage,
334
- // named over a bare connection), and — when the stage declares an approver
335
- // set — identity-approved (decision 12).
406
+ // only confirmed (--confirm, always), with a durable rollback point, and —
407
+ // when the stage declares an approver set — identity-approved (decision 12).
336
408
  const isDestructive = plan.destructive > 0;
337
409
  let verifyAuthority: (() => Promise<{ ok: true } | { ok: false; reason: string }>) | undefined;
410
+ let rollbackPoint: RollbackPoint | undefined;
338
411
  if (isDestructive) {
339
412
  const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
340
413
  if (flags.confirm !== 'true') {
341
- fail(`This plan is DESTRUCTIVE — ${plan.destructive} statement(s) lose data (${shape}). Explicit confirmation is required, always: re-run with --confirm.`);
414
+ fail(destructiveConfirmRefusal(plan.destructive, shape));
342
415
  process.exit(1);
343
416
  }
344
- // A DIRECT apply is operator-attested: the safety net is a backup the operator took and
345
- // NAMES here, because this lane has no ops Lambda verifying one server-side.
346
- //
347
- // This used to end by offering "or drop --direct and let the credential-free --stage apply
348
- // auto-verify your latest backup". THAT ADVICE CANNOT BE FOLLOWED. Auto-verification is
349
- // real, but only for NON-destructive stage applies — and every plan reaching this block is
350
- // destructive, so dropping --direct lands on the outright refusal above (the `--stage`
351
- // destructive gate). It sent operators from a lane that works to one that refuses, and the
352
- // refusal's own text then pointed them at --database-url. Two wrong signposts in a row are
353
- // how a consumer ended up printing a privileged DSN to do something --direct already did.
354
- //
355
- // For a destructive plan there is exactly one remedy: take a safety point and name it.
356
- if (!flags['snapshot-ref']) {
357
- const stageArg = flags.stage ? ` --stage ${flags.stage}` : '';
358
- const takeIt = `everystack db:backup${stageArg}`;
359
- const lane = flags.stage
360
- ? `${stageArg.trim()} --direct`
361
- : '--database-url <url>';
362
- fail(
363
- `This plan is DESTRUCTIVE (${shape}) over a direct connection — the apply does not snapshot for you. `
364
- + `Take a safety point (${takeIt}) and name it: everystack db:apply --plan ${planPath} ${lane} --confirm --snapshot-ref <id>. `
365
- + 'A destructive plan always requires an attested --snapshot-ref; there is no lane that takes one for you.',
366
- );
417
+
418
+ // The rollback point, CONFIRMED durable BEFORE the first destructive statement (Ty-ratified
419
+ // 2026-08-26 — the apply takes it and waits, reusing db:swap's ceremony). On --stage --direct
420
+ // the tool takes it for you (RDS physical snapshot by default, logical backup fallback) and
421
+ // waits until it exists — no --snapshot-ref dance. Over a bare --database-url the tool cannot
422
+ // take one, so it refuses unless you attest one (--snapshot-ref) or opt out (--snapshot none).
423
+ // The check that the database did not MOVE under the snapshot wait is executeApplyPlan's
424
+ // concurrency lock below: it re-reads live and refuses unless live == plan.from, AFTER this.
425
+ const venue: 'stage' | 'url' = flags.stage ? 'stage' : 'url';
426
+ const stageConfig = flags.stage ? await resolveConfig(flags.stage) : undefined;
427
+ // Captured here where dbSource is narrowed to the url variant (the stage lane returned above), so
428
+ // the bare-URL confirmLocal closure below can read it without re-narrowing a reassigned `let`.
429
+ const targetUrl = dbSource.url;
430
+ const decision = decideSnapshotMode({
431
+ venue,
432
+ instanceId: stageConfig?.databaseInstanceId,
433
+ snapshotRef: flags['snapshot-ref'],
434
+ requested: flags.snapshot as SnapshotModeRequest | undefined,
435
+ // Bare --database-url takes its OWN pg_dump -Fc rollback point (REVERSAL 1). The stage lane
436
+ // never needs it — it has physical/logical — so scope it to the url venue.
437
+ localDump: venue === 'url',
438
+ });
439
+ if (decision.mode === 'refuse') {
440
+ fail(`REFUSED: ${decision.reason}`);
367
441
  process.exit(1);
368
442
  }
369
- if (flags.stage) {
370
- const config = await resolveConfig(flags.stage);
371
- const { parseAppName } = await import('../discover');
372
- const appName = await parseAppName();
443
+ const confirmed = await confirmRollbackPoint(decision, {
444
+ // Resolve-else-take: an attested --snapshot-ref is only a rollback point once it fingerprint-
445
+ // verifies against plan.from. On a stage the CLI resolves the ref's backup manifest from the
446
+ // stage's backups bucket and runs the same gate the server used (verifySnapshotRef). Over a
447
+ // bare --database-url there is no bucket to resolve against, so no verifier is injected and the
448
+ // attested ref stays operator-trusted + lock-guarded (its own fp gate is sub-step 3's work).
449
+ ...(stageConfig
450
+ ? {
451
+ verifyAttested: async (ref: string) => {
452
+ if (!stageConfig.backupsBucket) {
453
+ return {
454
+ ok: false as const,
455
+ reason: `stage ${flags.stage} has no backupsBucket in its deployed config, so --snapshot-ref ${ref} cannot be verified. Add \`backupsBucket: backups.name\` to the sst.config outputs and redeploy, or let db:apply take a fresh snapshot (drop --snapshot-ref).`,
456
+ };
457
+ }
458
+ const manifest = await fetchBackupManifest(stageConfig.region, stageConfig.backupsBucket, ref);
459
+ const verdict = verifySnapshotRef({
460
+ manifest,
461
+ planFrom: plan.fromFingerprint,
462
+ targetStage: flags.stage,
463
+ now: new Date(),
464
+ freshnessWindowMs: SNAPSHOT_FRESHNESS_MS,
465
+ });
466
+ return verdict.ok
467
+ ? { ok: true as const, stampedFingerprint: manifest!.fingerprint }
468
+ : { ok: false as const, reason: verdict.reason };
469
+ },
470
+ }
471
+ : {}),
472
+ ...(stageConfig
473
+ ? stageConfirmDeps({
474
+ region: stageConfig.region,
475
+ instanceId: stageConfig.databaseInstanceId,
476
+ opsFn: opsFunction(stageConfig),
477
+ stage: flags.stage,
478
+ verb: 'apply',
479
+ log: info,
480
+ step,
481
+ })
482
+ : {
483
+ // Bare --database-url. physical/logical are unreachable here (decideSnapshotMode refuses
484
+ // them on this venue) — present and loud so a future routing change cannot fail silently.
485
+ // What this venue DOES take is its own local dump (mode 'local'): pg_dump -Fc to a file in
486
+ // the working directory, verified with pg_restore --list before any destructive statement.
487
+ confirmPhysical: async () => { throw new Error('a physical snapshot needs a stage; a bare --database-url cannot take one'); },
488
+ confirmLogical: async () => ({ ok: false as const, reason: 'a logical snapshot needs a stage; a bare --database-url cannot take one' }),
489
+ confirmLocal: () => confirmLocalDump({
490
+ url: targetUrl,
491
+ file: path.resolve(process.cwd(), `everystack-rollback-${utcStamp(new Date())}.dump`),
492
+ }),
493
+ }),
494
+ log: info,
495
+ warn,
496
+ });
497
+ if (!confirmed.ok) {
498
+ fail(`REFUSED: ${confirmed.reason} — nothing was applied.`);
499
+ process.exit(1);
500
+ }
501
+ rollbackPoint = confirmed.point;
502
+
503
+ if (stageConfig) {
373
504
  verifyAuthority = async () => {
374
505
  step(`Destructive authority: checking the approver set for stage ${flags.stage}...`);
375
- const approvers = await readApproverParam(config.region, appName, flags.stage);
376
- const identity = approvers === null ? null : await resolveCallerIdentity(config.region);
506
+ const { parseAppName } = await import('../discover');
507
+ const appName = await parseAppName();
508
+ const approvers = await readApproverParam(stageConfig.region, appName, flags.stage);
509
+ const identity = approvers === null ? null : await resolveCallerIdentity(stageConfig.region);
377
510
  const verdict = checkDestructiveAuthority({ approvers, identity });
378
511
  if (!verdict.ok) return verdict;
379
512
  if (verdict.reason === 'approved') {
@@ -383,9 +516,6 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
383
516
  }
384
517
  return { ok: true };
385
518
  };
386
- warn(`DESTRUCTIVE direct apply — proceeding on ceremony: --confirm + attested snapshot ${flags['snapshot-ref']}.`);
387
- } else {
388
- warn(`DESTRUCTIVE apply over a bare connection — approver gating unavailable (no --stage), proceeding on ceremony: --confirm + attested snapshot ${flags['snapshot-ref']}.`);
389
519
  }
390
520
  }
391
521
 
@@ -394,9 +524,9 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
394
524
 
395
525
  try {
396
526
  info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
397
- if (forceDescent !== undefined) {
398
- warn(`DESCENT FORCED — the fast-forward rule is bypassed for this apply. Snapshot on record: ${forceDescent}.`);
399
- }
527
+ // Set inside checkDescent when --attest-lineage-lost is honoured; read at write time so the
528
+ // waiver lands in the schema_log outcome (the descent decision runs before the apply writes).
529
+ let descentAttested: string | undefined;
400
530
  step('Verifying the target is where the plan started...');
401
531
  // The mutation lease: one operator mutates a database at a time. Acquired on this apply's
402
532
  // own session (the max:1 createUrlRunner) before any write; a second operator is refused
@@ -408,6 +538,22 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
408
538
  actor: process.env.USER ?? null,
409
539
  gitRef: currentGitRef() ?? plan.gitRef,
410
540
  ...(verifyAuthority ? { verifyAuthority } : {}),
541
+ // The rollback point must capture the state this plan STARTS from. A logical backup stamps
542
+ // the live fingerprint at dump time; if it does not equal plan.from, restoring it would not
543
+ // return the database to where the plan began — so refuse (the escape is re-plan, not a
544
+ // flag). A physical snapshot carries no stamp: it is fresh and the concurrency lock below
545
+ // proves live == plan.from, so temporal ordering is its coverage. `none` opted out.
546
+ ...(rollbackPoint?.stampedFingerprint
547
+ ? {
548
+ verifySnapshot: async ({ planFrom }: { planFrom: string }) =>
549
+ rollbackPoint!.stampedFingerprint === planFrom
550
+ ? { ok: true as const }
551
+ : {
552
+ ok: false as const,
553
+ reason: snapshotMovedRefusal(rollbackPoint!.ref!, rollbackPoint!.stampedFingerprint!, planFrom),
554
+ },
555
+ }
556
+ : {}),
411
557
  // Re-verified here, not just at mint: a plan is an artifact with a lifetime, and a
412
558
  // foreign role granted between minting and applying would otherwise ride through.
413
559
  verifyAuthzBaseline: async ({ contract }) => {
@@ -421,37 +567,29 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
421
567
  ? { ok: true as const }
422
568
  : { ok: false as const, reason: baselineRefusal(stageName, violations) };
423
569
  },
424
- ...(forceDescent === undefined ? {
425
- verifyDescent: async (live: LiveState & { fingerprint: string }) => {
426
- step('Descent: searching git for the commit that declares the target\'s state...');
427
- const verdict = await verifyDescent(resolveModelsPath(flags.models), live, {});
428
- switch (verdict.status) {
429
- case 'ok':
430
- info(`descent: ${verdict.commit.slice(0, 12)} declares the target's state and is an ancestor of HEAD — fast-forward.`);
431
- return { ok: true };
432
- case 'fresh-target':
433
- info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
434
- return { ok: true };
435
- case 'no-git':
436
- // A DESTRUCTIVE plan may not ride an accidental waiver: the fast-forward
437
- // rule's verdict must never depend on which directory the operator ran
438
- // from. Unverifiable + destructive ⇒ the waiver must be the deliberate
439
- // ceremony (--force-descent <snapshot-ref> --confirm), not a warning.
440
- if (plan.destructive > 0) {
441
- return {
442
- ok: false,
443
- reason: 'descent: not a git checkout, and this plan is DESTRUCTIVE — the fast-forward rule cannot be verified here, so waive it deliberately: re-run with --force-descent <snapshot-ref> --confirm.',
444
- };
445
- }
446
- warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
447
- return { ok: true };
448
- case 'diverged':
449
- case 'drift':
450
- case 'indeterminate':
451
- return { ok: false, reason: verdict.reason };
452
- }
453
- },
454
- } : {}),
570
+ dirty: worktreeDirty(resolveModelsPath(flags.models)),
571
+ descentNote: () => descentAttested,
572
+ checkDescent: async (live: LiveState & { fingerprint: string; ledger: DescentLedgerRow[] }) => {
573
+ 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 } : {}),
584
+ destructive: plan.destructive > 0,
585
+ proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(process.cwd())),
586
+ });
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 };
592
+ },
455
593
  }),
456
594
  );
457
595