@everystack/cli 0.4.69 → 0.4.71
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 +73 -1
- package/README.md +2 -1
- package/package.json +2 -2
- package/src/cli/apply-execute.ts +133 -9
- package/src/cli/apply-lane.ts +102 -0
- package/src/cli/apply-refusals.ts +83 -0
- package/src/cli/backup-resolve.ts +31 -0
- package/src/cli/backup.ts +104 -0
- package/src/cli/commands/db-apply.ts +318 -180
- package/src/cli/commands/db-plan.ts +21 -10
- package/src/cli/commands/db-reconcile.ts +2 -0
- package/src/cli/commands/db-swap.ts +27 -54
- package/src/cli/commands/db.ts +142 -2
- package/src/cli/derived-apply.ts +21 -3
- package/src/cli/edge-plan.ts +14 -0
- package/src/cli/git-descent.ts +526 -143
- package/src/cli/index.ts +1 -1
- package/src/cli/local-rollback.ts +146 -0
- package/src/cli/prompt.ts +18 -0
- package/src/cli/rollback-point.ts +141 -0
- package/src/cli/schema-fingerprint.ts +60 -0
- package/src/cli/stage-rollback-deps.ts +70 -0
- package/src/cli/state-apply.ts +41 -3
- package/src/cli/swap-snapshot.ts +18 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,12 +4,84 @@ 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
|
-
## [
|
|
7
|
+
## [0.4.71] - 2026-09-04
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- wire declared ownership into db:provision (B6, CLI half)
|
|
12
|
+
- reconcile refusals point at db:provision, not hand-SQL (B8)
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- BLOCKER A — reorder ownership convergence AFTER the secret write
|
|
17
|
+
- round-2 BLOCKER-1 — run ownership convergence on the credentials no-op path too
|
|
18
|
+
- align diag header assertion with the current source
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
- brownfield ownership convergence — db:provision ENACT + ADOPT (B10)
|
|
23
|
+
|
|
24
|
+
## [0.4.70] - 2026-09-02
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- proveDescent — ledger-first fast-forward proof + retained-canon registry
|
|
29
|
+
- wire proveDescent into db:apply — ledger read shares the lock session
|
|
30
|
+
- db:apply takes its own durable rollback point (shared db:swap ceremony)
|
|
31
|
+
- db:apply fp-verifies an attested --snapshot-ref before it counts (resolve-else-take)
|
|
32
|
+
- bare --database-url db:apply takes+verifies its own local pg_dump rollback point
|
|
33
|
+
- db:apply infers the lane from the plan — no --direct for a destructive or too-large plan
|
|
34
|
+
- retire --force-descent for the git-native escape + a proven-lineage-lost attestation
|
|
35
|
+
- db:apply refusals print an action, not a check name (p3-action-refusals)
|
|
36
|
+
- the stage lane's fast-forward rule is structurally non-droppable (q1-fail-closed)
|
|
37
|
+
- version the db:apply wire contract, both directions (q1-contract-version)
|
|
38
|
+
- a stage read-disagreement escalates to the direct lane, not a dead-end (q2-escalate)
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- address closing-gate review (F1-F4) — db:plan agrees with apply, defensive ordering
|
|
43
|
+
- proveLineageLost refuses when `git remote` fails (close the fail-open)
|
|
44
|
+
- proveLineageLost refuses an absent commit when there are no remotes (R2)
|
|
45
|
+
- split-brain belt + correct the version-skew remedy (closing gate)
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- db:apply destructive refusals as two named classes (intent vs staleness)
|
|
50
|
+
- delete the orphaned recompile-descent helpers, rewire the illusion of coverage (F1)
|
|
51
|
+
|
|
52
|
+
### Documentation
|
|
53
|
+
|
|
54
|
+
- rollback-point identity rests on stage-scoped manifest resolution, not the fingerprint (R5)
|
|
55
|
+
- the two-class refusal doctrine for db:apply (p3-doctrine)
|
|
56
|
+
- the db:apply version-skew refusal and read-disagreement escalation
|
|
57
|
+
|
|
58
|
+
## [0.4.69] - 2026-08-24
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- explain paren-only predicate churn instead of leaving it a mystery
|
|
63
|
+
- hedge the paren-only note — it cannot prove "not a change"
|
|
64
|
+
|
|
65
|
+
## [0.4.68] - 2026-08-24
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- round-trip a text_col = 'literal' predicate — strip the implicit ::text cast
|
|
70
|
+
- harden the ::text strip — whitespace-tolerant array guard, stacked-cast fixpoint
|
|
71
|
+
|
|
72
|
+
## [0.4.67] - 2026-08-22
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
|
|
76
|
+
- auto-generated changelogs from conventional commits
|
|
8
77
|
|
|
9
78
|
### Fixed
|
|
10
79
|
|
|
11
80
|
- secrets test expected .js extension stripped by Metro compat
|
|
12
81
|
- async RSA key generation — unblocks the event loop in parallel test runs
|
|
82
|
+
- db:provision is additive-only — never reset an existing role's password
|
|
83
|
+
- address closing-gate review — pin ordering, guard half-done recovery
|
|
84
|
+
- address Fable red-team — rotated-stage guard, safe polarity
|
|
13
85
|
|
|
14
86
|
## [0.4.66] - 2026-08-15
|
|
15
87
|
|
package/README.md
CHANGED
|
@@ -230,7 +230,8 @@ everystack db:fork # fork a deployed stage's DB into a feat
|
|
|
230
230
|
|
|
231
231
|
# Security
|
|
232
232
|
everystack db:doctor # is the DB least-privilege + RLS-subject?
|
|
233
|
-
everystack db:provision #
|
|
233
|
+
everystack db:provision # role chain + converge declared ownership (ENACT grants, ADOPT plain objects)
|
|
234
|
+
everystack db:provision --confirm # + ADOPT SECURITY DEFINER functions / views / matviews (runtime change)
|
|
234
235
|
everystack db:authz:pull|diff|test|owner|report # the authorization contract + red-team
|
|
235
236
|
|
|
236
237
|
# Backups (see docs/backups.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.71",
|
|
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>",
|
|
@@ -178,7 +178,7 @@
|
|
|
178
178
|
"jest": "29.7.0",
|
|
179
179
|
"react": "19.2.0",
|
|
180
180
|
"ts-jest": "29.4.9",
|
|
181
|
-
"@everystack/server": "0.4.
|
|
181
|
+
"@everystack/server": "0.4.29"
|
|
182
182
|
},
|
|
183
183
|
"scripts": {
|
|
184
184
|
"test": "jest",
|
package/src/cli/apply-execute.ts
CHANGED
|
@@ -29,16 +29,53 @@
|
|
|
29
29
|
* and the memoir stay here, atomic with the connection.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
import {
|
|
33
|
+
introspectContract,
|
|
34
|
+
assembleContract,
|
|
35
|
+
RLS_SQL,
|
|
36
|
+
POLICIES_SQL,
|
|
37
|
+
GRANTS_SQL,
|
|
38
|
+
COLUMN_GRANTS_SQL,
|
|
39
|
+
SCHEMA_ACL_SQL,
|
|
40
|
+
SEQUENCE_ACLS_SQL,
|
|
41
|
+
type QueryRunner,
|
|
42
|
+
type AuthzContract,
|
|
43
|
+
} from './authz-contract';
|
|
44
|
+
import { INTROSPECTION_SESSION, rowsOrEmpty, type SessionRunner } from './session';
|
|
45
|
+
import {
|
|
46
|
+
introspectSchema,
|
|
47
|
+
assembleSchema,
|
|
48
|
+
COLUMNS_SQL,
|
|
49
|
+
CONSTRAINTS_SQL,
|
|
50
|
+
ENUMS_SQL,
|
|
51
|
+
INDEXES_SQL,
|
|
52
|
+
SEQUENCES_SQL,
|
|
53
|
+
EXTENSIONS_SQL,
|
|
54
|
+
type SchemaSnapshot,
|
|
55
|
+
} from './schema-introspect';
|
|
35
56
|
import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog';
|
|
36
|
-
import { fingerprintLive } from './schema-fingerprint';
|
|
57
|
+
import { fingerprintLive, FINGERPRINT_VERSION } from './schema-fingerprint';
|
|
37
58
|
import { governedLiveFingerprint } from './edge-plan';
|
|
38
59
|
import { applyGeneratedStatements } from './state-apply';
|
|
39
60
|
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
|
|
40
61
|
import { checkPlanPrecondition, planHash, type EdgePlan } from './edge-plan';
|
|
62
|
+
import type { DescentLedgerRow } from './git-descent';
|
|
41
63
|
|
|
64
|
+
/**
|
|
65
|
+
* CALLER CONTRACT (fail-closed protection lives here, not inside executeApplyPlan):
|
|
66
|
+
*
|
|
67
|
+
* - The fast-forward rule (`checkDescent`) is optional in THIS type — the direct lane and tests
|
|
68
|
+
* construct it in-process — but on the STAGE lane it is REQUIRED by a narrower `StageApplyOptions`
|
|
69
|
+
* type in the server plugin, so a wire payload can never drop it. Absence here is a legitimate
|
|
70
|
+
* in-process state, never a dropped gate.
|
|
71
|
+
* - A DESTRUCTIVE caller MUST confirm a durable rollback point BEFORE calling (the CLI shell runs
|
|
72
|
+
* `confirmRollbackPoint` and hard-refuses if it cannot). executeApplyPlan does not police that —
|
|
73
|
+
* the protection is structural in the caller. `verifySnapshot` here is the STAMPED-LOGICAL variant
|
|
74
|
+
* only (a physical snapshot carries no stamp; its coverage is the concurrency lock below), so its
|
|
75
|
+
* absence on a destructive plan is by design, not a skipped gate.
|
|
76
|
+
* - `verifyAuthority` is present whenever a STAGE is in play (approvers are a stage-side SSM concept);
|
|
77
|
+
* a bare `--database-url` has no stage and legitimately omits it.
|
|
78
|
+
*/
|
|
42
79
|
export interface ApplyPlanOptions {
|
|
43
80
|
actor?: string | null;
|
|
44
81
|
/** Defaults to the plan's own gitRef (the intent that minted it). */
|
|
@@ -50,10 +87,17 @@ export interface ApplyPlanOptions {
|
|
|
50
87
|
* declaring the target's state. Omitted = no descent enforcement (tests,
|
|
51
88
|
* or an explicit, confirmed, snapshotted force).
|
|
52
89
|
*/
|
|
53
|
-
|
|
90
|
+
checkDescent?: (live: {
|
|
54
91
|
snapshot: SchemaSnapshot;
|
|
55
92
|
contract: AuthzContract;
|
|
56
93
|
fingerprint: string;
|
|
94
|
+
/** The schema_log witness rows, newest-first. On the direct/server path these are read in
|
|
95
|
+
* the SAME session as `fingerprint` (liveBaseAndLedger), so the fast-forward proof and the
|
|
96
|
+
* concurrency lock cannot describe two instants. The stage lane's CLI-side descent reads
|
|
97
|
+
* the ledger in a separate ops-Lambda invoke, ordered AFTER the state read so a race
|
|
98
|
+
* degrades toward REVERT/refusal, never a false pass — and the server re-verifies the lock
|
|
99
|
+
* in its own one-session read. */
|
|
100
|
+
ledger: DescentLedgerRow[];
|
|
57
101
|
}) => Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
58
102
|
/**
|
|
59
103
|
* Destructive authority (brick 9, decisions 11+12): called only when the
|
|
@@ -80,8 +124,17 @@ export interface ApplyPlanOptions {
|
|
|
80
124
|
* schema_log. Omitted = no gate (callers with no baseline to check against).
|
|
81
125
|
*/
|
|
82
126
|
verifyAuthzBaseline?: (ctx: { contract: AuthzContract }) => Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
127
|
+
/** The apply ran from a dirty worktree (models differed from HEAD) — recorded on the
|
|
128
|
+
* schema_log row so a later descent ignores this witness. */
|
|
129
|
+
dirty?: boolean;
|
|
83
130
|
/** Injectable clock for tests. */
|
|
84
131
|
now?: () => number;
|
|
132
|
+
/**
|
|
133
|
+
* A note for the schema_log outcome when the fast-forward rule was waived by a
|
|
134
|
+
* `--attest-lineage-lost` attestation. A getter, because the attestation is decided inside
|
|
135
|
+
* `checkDescent` (which runs before the write) — the getter reads that decision at write time.
|
|
136
|
+
*/
|
|
137
|
+
descentNote?: () => string | undefined;
|
|
85
138
|
}
|
|
86
139
|
|
|
87
140
|
export interface ApplyPlanResult {
|
|
@@ -142,6 +195,74 @@ export async function liveBaseFingerprint(session: SessionRunner): Promise<LiveB
|
|
|
142
195
|
return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract };
|
|
143
196
|
}
|
|
144
197
|
|
|
198
|
+
/** The live base fingerprint AND the descent ledger, read in ONE session. */
|
|
199
|
+
export interface LiveBaseAndLedger extends LiveBaseFingerprint {
|
|
200
|
+
ledger: DescentLedgerRow[];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The newest successful state applies — the descent witnesses. Read alongside the live state
|
|
205
|
+
* (allowFailure, because a database that never applied through this CLI has no schema_log
|
|
206
|
+
* yet). Newest-first, capped: the proof consults the newest usable row and stops.
|
|
207
|
+
*/
|
|
208
|
+
export const SCHEMA_LOG_WITNESS_SQL = `SELECT id, to_fingerprint, git_ref, fp_version, dirty, applied_at, outcome
|
|
209
|
+
FROM everystack.schema_log
|
|
210
|
+
WHERE kind = 'state apply' AND outcome = 'applied' AND to_fingerprint IS NOT NULL
|
|
211
|
+
ORDER BY id DESC
|
|
212
|
+
LIMIT 200`;
|
|
213
|
+
|
|
214
|
+
function mapLedgerRows(rows: any[]): DescentLedgerRow[] {
|
|
215
|
+
return rows.map((r) => ({
|
|
216
|
+
id: String(r.id),
|
|
217
|
+
toFingerprint: r.to_fingerprint == null ? null : String(r.to_fingerprint),
|
|
218
|
+
gitRef: r.git_ref == null ? null : String(r.git_ref),
|
|
219
|
+
fpVersion: r.fp_version == null ? null : Number(r.fp_version),
|
|
220
|
+
dirty: r.dirty === true,
|
|
221
|
+
appliedAt: r.applied_at instanceof Date ? r.applied_at.toISOString() : String(r.applied_at ?? ''),
|
|
222
|
+
outcome: String(r.outcome ?? ''),
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Just the descent ledger, over one session — the stage lane already has the live state from
|
|
228
|
+
* its double read, and needs only the witness rows to prove the fast-forward CLI-side.
|
|
229
|
+
*/
|
|
230
|
+
export async function readDescentLedger(session: SessionRunner): Promise<DescentLedgerRow[]> {
|
|
231
|
+
const [rows] = await session([{ sql: SCHEMA_LOG_WITNESS_SQL, allowFailure: true }], INTROSPECTION_SESSION);
|
|
232
|
+
return mapLedgerRows(rowsOrEmpty(rows));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The live base fingerprint and the descent ledger in ONE repeatable-read session.
|
|
237
|
+
*
|
|
238
|
+
* This is the "reads share one session" guarantee the fast-forward proof needs. The
|
|
239
|
+
* concurrency lock compares this `hash` against plan.from; the descent proof recomputes the
|
|
240
|
+
* live fingerprint from this `snapshot`/`contract` and joins it against this `ledger`. Reading
|
|
241
|
+
* them in separate sessions could land on two instants (on the ops Lambda, two containers) —
|
|
242
|
+
* a fingerprint whose halves describe different databases. One session, one snapshot.
|
|
243
|
+
*/
|
|
244
|
+
export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBaseAndLedger> {
|
|
245
|
+
const results = await session(
|
|
246
|
+
[
|
|
247
|
+
COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL, EXTENSIONS_SQL,
|
|
248
|
+
RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, FUNCTIONS_SQL,
|
|
249
|
+
{ sql: SCHEMA_LOG_WITNESS_SQL, allowFailure: true },
|
|
250
|
+
],
|
|
251
|
+
INTROSPECTION_SESSION,
|
|
252
|
+
);
|
|
253
|
+
const snapshot = assembleSchema({
|
|
254
|
+
columns: results[0] as any[], constraints: results[1] as any[], enums: results[2] as any[],
|
|
255
|
+
indexes: results[3] as any[], sequences: results[4] as any[], extensions: results[5] as any[],
|
|
256
|
+
});
|
|
257
|
+
const contract = assembleContract({
|
|
258
|
+
rls: results[6] as any[], policies: results[7] as any[], grants: results[8] as any[],
|
|
259
|
+
columnGrants: results[9] as any[], schemaAcls: results[10] as any[], sequenceAcls: results[11] as any[],
|
|
260
|
+
functions: (results[12] as any[]).map(contractFunctionRow),
|
|
261
|
+
});
|
|
262
|
+
const ledger = mapLedgerRows(rowsOrEmpty(results[13]));
|
|
263
|
+
return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract, ledger };
|
|
264
|
+
}
|
|
265
|
+
|
|
145
266
|
/** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
|
|
146
267
|
export async function executeApplyPlan(
|
|
147
268
|
runner: QueryRunner,
|
|
@@ -149,7 +270,7 @@ export async function executeApplyPlan(
|
|
|
149
270
|
plan: EdgePlan,
|
|
150
271
|
options: ApplyPlanOptions = {},
|
|
151
272
|
): Promise<ApplyPlanResult> {
|
|
152
|
-
const { hash: live, snapshot: before, contract: beforeAuthz } = await
|
|
273
|
+
const { hash: live, snapshot: before, contract: beforeAuthz, ledger } = await liveBaseAndLedger(session);
|
|
153
274
|
|
|
154
275
|
// Two flavors, matched to what each check compares (see EdgePlan): the RAW
|
|
155
276
|
// hash for live-vs-live (the lock, the backup chain), the GOVERNED hash for
|
|
@@ -168,8 +289,8 @@ export async function executeApplyPlan(
|
|
|
168
289
|
return { status: 'refused', liveFingerprint: live, reason: pre.reason };
|
|
169
290
|
}
|
|
170
291
|
|
|
171
|
-
if (options.
|
|
172
|
-
const descent = await options.
|
|
292
|
+
if (options.checkDescent) {
|
|
293
|
+
const descent = await options.checkDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live, ledger });
|
|
173
294
|
if (!descent.ok) {
|
|
174
295
|
await recordRefusal(runner, plan, live, 'fast-forward rule', descent.reason, options);
|
|
175
296
|
return { status: 'descent-refused', liveFingerprint: live, reason: descent.reason };
|
|
@@ -208,7 +329,10 @@ export async function executeApplyPlan(
|
|
|
208
329
|
gitRef: options.gitRef ?? plan.gitRef,
|
|
209
330
|
planRef: planHash(plan),
|
|
210
331
|
fromFingerprint: live,
|
|
332
|
+
fpVersion: FINGERPRINT_VERSION,
|
|
333
|
+
dirty: options.dirty ?? false,
|
|
211
334
|
now: options.now,
|
|
335
|
+
...(options.descentNote ? { descentNote: options.descentNote } : {}),
|
|
212
336
|
});
|
|
213
337
|
|
|
214
338
|
const { hash: landed, snapshot: after, contract: afterAuthz } = await liveBaseFingerprint(session);
|
|
@@ -237,4 +361,4 @@ export async function executeApplyPlan(
|
|
|
237
361
|
// Re-exported so a single import (`@everystack/cli/apply`) gives a consumer —
|
|
238
362
|
// notably the ops Lambda's db:apply action — everything it needs to validate
|
|
239
363
|
// and run a plan without reaching into the CLI's internal module layout.
|
|
240
|
-
export { PLAN_VERSION, planHash, checkPlanPrecondition, type EdgePlan } from './edge-plan';
|
|
364
|
+
export { PLAN_VERSION, APPLY_CONTRACT_VERSION, planHash, checkPlanPrecondition, type EdgePlan } from './edge-plan';
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* decideStageLane — when the target is a deployed stage and the operator did NOT name a lane, choose
|
|
3
|
+
* one from the PLAN (apply-legibility Phase 2, p2-infer-lane). The user should type `db:apply --stage
|
|
4
|
+
* <s>` and have the right lane happen; naming `--direct` is an advanced override, not a prerequisite.
|
|
5
|
+
*
|
|
6
|
+
* The ops-Lambda lane is credential-free but bounded: it cannot run a DESTRUCTIVE plan (its fingerprint
|
|
7
|
+
* can be assembled from several containers, so the concurrency lock cannot be trusted for data loss),
|
|
8
|
+
* and it cannot outlast the 900-second Lambda clock. When the plan needs neither, the ops lane runs it.
|
|
9
|
+
* When it needs either, the DIRECT lane is required — it resolves the operator connection FROM the stage
|
|
10
|
+
* into memory only (property 4 holds: the operator never holds a URL) and runs CLI-side unbounded.
|
|
11
|
+
*
|
|
12
|
+
* Reaching for that connection is not something to do behind the operator's back. So:
|
|
13
|
+
* - a DESTRUCTIVE plan still needs --confirm, always and up front (intent stays explicit — a lane
|
|
14
|
+
* choice never substitutes for it);
|
|
15
|
+
* - a NON-INTERACTIVE session refuses and names --direct, because auto-escalation must never widen
|
|
16
|
+
* what an unattended run does (Fable B4);
|
|
17
|
+
* - an INTERACTIVE session escalates after the shell announces why and gets one line of consent,
|
|
18
|
+
* BEFORE any credential is resolved.
|
|
19
|
+
*
|
|
20
|
+
* This function is pure — it decides and phrases; the shell announces, prompts, and resolves.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { destructiveConfirmRefusal } from './apply-refusals';
|
|
24
|
+
import { APPLY_CONTRACT_VERSION } from './edge-plan';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* q1-contract-version direction B: verify the deployed stage speaks THIS CLI's db:apply wire contract
|
|
28
|
+
* BEFORE dispatching a write. The `_health` action of a current ops Lambda advertises its
|
|
29
|
+
* `applyContract`; an older one omits it. Exact equality (never `>=`): a bump means the payload keys or
|
|
30
|
+
* a gate's meaning changed, so an off-by-any peer must refuse, not guess. Prevention over a post-write
|
|
31
|
+
* alarm — a mismatched server would silently under-enforce a gate the CLI is relying on it to run.
|
|
32
|
+
*/
|
|
33
|
+
export function checkStageApplyContract(health: unknown): { ok: true } | { ok: false; reason: string } {
|
|
34
|
+
const advertised = (health as { applyContract?: unknown } | null | undefined)?.applyContract;
|
|
35
|
+
if (advertised === APPLY_CONTRACT_VERSION) return { ok: true };
|
|
36
|
+
if (advertised === undefined) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
reason: `the deployed stage does not advertise a db:apply contract version — its ops Lambda predates the apply-contract handshake and could silently under-enforce a safety gate. Redeploy the stage so its server matches this CLI (apply-contract v${APPLY_CONTRACT_VERSION}); nothing was applied.`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
reason: `the deployed stage speaks apply-contract v${String(advertised)} but this CLI speaks v${APPLY_CONTRACT_VERSION} — the CLI and the stage are on mismatched versions. Redeploy the stage (or align the CLI) so they match; nothing was applied.`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type StageLaneDecision =
|
|
49
|
+
/** Additive and within the clock — run credential-free in the ops Lambda. */
|
|
50
|
+
| { action: 'ops' }
|
|
51
|
+
/** Needs the direct lane and the session can consent — the shell announces, prompts, then resolves. */
|
|
52
|
+
| { action: 'escalate'; reason: string }
|
|
53
|
+
/** Needs the direct lane but cannot proceed here — the shell prints this and exits non-zero. */
|
|
54
|
+
| { action: 'refuse'; reason: string };
|
|
55
|
+
|
|
56
|
+
export interface StageLaneInput {
|
|
57
|
+
destructive: boolean;
|
|
58
|
+
/** "N drop(s), M narrowing type change(s)" — for the announcement/refusal. */
|
|
59
|
+
destructiveShape: string;
|
|
60
|
+
destructiveCount: number;
|
|
61
|
+
/** estimateOpsRuntimeFit: does the edge fit the 900s ops-Lambda clock? */
|
|
62
|
+
fits: boolean;
|
|
63
|
+
fitReason?: string;
|
|
64
|
+
/** process.stdin.isTTY — is there an operator present to consent? */
|
|
65
|
+
interactive: boolean;
|
|
66
|
+
/** --confirm present? (Destructive intent, always explicit.) */
|
|
67
|
+
confirmed: boolean;
|
|
68
|
+
planPath: string;
|
|
69
|
+
stage: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function decideStageLane(input: StageLaneInput): StageLaneDecision {
|
|
73
|
+
const needsDirect = input.destructive || !input.fits;
|
|
74
|
+
if (!needsDirect) return { action: 'ops' };
|
|
75
|
+
|
|
76
|
+
const directHint = `everystack db:apply --plan ${input.planPath} --stage ${input.stage} --direct${input.destructive ? ' --confirm' : ''}`;
|
|
77
|
+
|
|
78
|
+
// INTENT first, and up front: a destructive plan needs --confirm before any lane work. Refusing here
|
|
79
|
+
// (rather than after resolving a credential) keeps the intent attestation the first thing the
|
|
80
|
+
// operator must supply, and names the lane it will run on once they do.
|
|
81
|
+
if (input.destructive && !input.confirmed) {
|
|
82
|
+
return {
|
|
83
|
+
action: 'refuse',
|
|
84
|
+
reason: `${destructiveConfirmRefusal(input.destructiveCount, input.destructiveShape)} It runs on the direct lane (the credential-free lane cannot run destructive plans): ${directHint}.`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const why = input.destructive
|
|
89
|
+
? `This plan is DESTRUCTIVE (${input.destructiveShape}); the credential-free lane cannot run destructive plans.`
|
|
90
|
+
: `This plan is too large to finish inside the credential-free lane's 900-second limit${input.fitReason ? ` (${input.fitReason})` : ''}.`;
|
|
91
|
+
|
|
92
|
+
// Never widen an unattended run: auto-escalation resolves a privileged operator connection, so it
|
|
93
|
+
// happens only where a person can consent. A non-interactive run names the lane explicitly instead.
|
|
94
|
+
if (!input.interactive) {
|
|
95
|
+
return {
|
|
96
|
+
action: 'refuse',
|
|
97
|
+
reason: `${why} It needs the direct lane, but this is a non-interactive session — name the lane explicitly: ${directHint}.`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { action: 'escalate', reason: why };
|
|
102
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db:apply's DESTRUCTIVE-path refusals, as the TWO classes the DX north star names
|
|
3
|
+
* (apply-legibility Phase 2, Ty-ratified 2026-08-26). Kept together, and pure, so the two classes
|
|
4
|
+
* cannot drift into each other — a regression test asserts each names the right escape and only that.
|
|
5
|
+
*
|
|
6
|
+
* INTENT — the operator is asserting something a re-plan cannot express ("yes, destroy data").
|
|
7
|
+
* The escape is a NAMED ATTESTATION (--confirm here), never re-plan.
|
|
8
|
+
* STALENESS — the database moved out from under the plan (or the plan was minted against old live).
|
|
9
|
+
* The escape is ALWAYS re-plan + apply, and NEVER a flag.
|
|
10
|
+
*
|
|
11
|
+
* A new safety check adds a REFUSAL, never a FLAG; and a refusal is one class or the other. This is
|
|
12
|
+
* the seam Phase 3's p3-action-refusals rewrites the surrounding wording over — the classes stay.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* INTENT: a destructive plan requires explicit confirmation, every time. Re-planning cannot assert
|
|
17
|
+
* "yes, destroy" for the operator, so the attestation (--confirm) is irreducible — do NOT reword this
|
|
18
|
+
* toward "re-plan".
|
|
19
|
+
*/
|
|
20
|
+
export function destructiveConfirmRefusal(destructiveCount: number, shape: string): string {
|
|
21
|
+
return `This plan is DESTRUCTIVE — ${destructiveCount} statement(s) lose data (${shape}). Explicit confirmation is required, always: re-run with --confirm.`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* STALENESS: the rollback point captured a different schema than the plan starts from, which means the
|
|
26
|
+
* database moved since the plan was minted. Restoring it would not return the DB to where the plan
|
|
27
|
+
* began, so refuse — and the escape is re-plan, NOT a flag. Do NOT add a flag to this path.
|
|
28
|
+
*/
|
|
29
|
+
export function snapshotMovedRefusal(ref: string, captured: string, planFrom: string): string {
|
|
30
|
+
return `the rollback point ${ref} captures ${captured.slice(0, 12)}, not the plan's start ${planFrom.slice(0, 12)} — the database moved since the plan was minted. Re-plan (db:plan) and apply again.`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// The USER-FACING refusal message (apply-legibility Phase 3, p3-action-refusals).
|
|
35
|
+
//
|
|
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,
|
|
38
|
+
// checkDestructiveAuthority, the snapshot gate, the baseline gate). This layer only marks the OUTCOME
|
|
39
|
+
// and forwards that reason — it does NOT re-label the check the way the old
|
|
40
|
+
// `REFUSED (fast-forward rule):` / `(snapshot gate)` / `(destructive authority)` wording did.
|
|
41
|
+
//
|
|
42
|
+
// The internal cause code (`descent-refused`, `snapshot-refused`, `ungoverned grantee`, …) stays where
|
|
43
|
+
// it belongs: recorded in everystack.schema_log by recordRefusal, the audit trail. It never reaches
|
|
44
|
+
// user text.
|
|
45
|
+
//
|
|
46
|
+
// The switch is EXHAUSTIVE by construction: the `assertNever` default is the gate that stops a new
|
|
47
|
+
// refusal status from ever again reaching the display with no case — the bug that let `authz-refused`
|
|
48
|
+
// print nothing and exit 0. Add a status to ApplyPlanResult and this stops compiling until it has a line.
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** The failure statuses executeApplyPlan can return — everything that is not `applied`/`already-applied`. */
|
|
52
|
+
export type ApplyRefusalStatus =
|
|
53
|
+
| 'refused'
|
|
54
|
+
| 'descent-refused'
|
|
55
|
+
| 'authority-refused'
|
|
56
|
+
| 'snapshot-refused'
|
|
57
|
+
| 'authz-refused'
|
|
58
|
+
| 'verify-failed';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Render the user-facing line for a refused or faulted apply. Pure, so the message-snapshot test can
|
|
62
|
+
* assert every path prints an action and no path prints check-name jargon. Returns the full line
|
|
63
|
+
* (the caller adds the ✗ marker and exits non-zero).
|
|
64
|
+
*
|
|
65
|
+
* `verify-failed` is NOT a refusal — the apply RAN and the result did not match the plan, so it keeps
|
|
66
|
+
* its own outcome word. Its reason already says what to do (investigate).
|
|
67
|
+
*/
|
|
68
|
+
export function formatApplyRefusal(status: ApplyRefusalStatus, reason: string): string {
|
|
69
|
+
switch (status) {
|
|
70
|
+
case 'refused': // the concurrency lock — the database moved since minting
|
|
71
|
+
case 'descent-refused': // the fast-forward rule — the checkout is behind the deploying branch
|
|
72
|
+
case 'authority-refused': // the destructive-approver set — hand the plan to an approver
|
|
73
|
+
case 'snapshot-refused': // no confirmed rollback point before a destructive statement
|
|
74
|
+
case 'authz-refused': // a foreign grantee arrived after the plan was minted
|
|
75
|
+
return `REFUSED: ${reason}`;
|
|
76
|
+
case 'verify-failed':
|
|
77
|
+
return `Verify FAILED: ${reason}`;
|
|
78
|
+
default: {
|
|
79
|
+
const never: never = status;
|
|
80
|
+
throw new Error(`unhandled apply refusal status: ${String(never)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetchBackupManifest — resolve a backup's `.meta.json` manifest from the stage's backups bucket,
|
|
3
|
+
* CLI-side. This is the IO half of the destructive-apply rollback-point gate; the pure verifier is
|
|
4
|
+
* `verifySnapshotRef` in backup.ts. MIRRORS @everystack/server/src/backup-run.ts fetchBackupManifest
|
|
5
|
+
* (the CLI does not import the server — see backup.ts's mirror note), reading over the CLI's own S3
|
|
6
|
+
* helper instead of the server's.
|
|
7
|
+
*
|
|
8
|
+
* Returns null when the id is malformed OR the manifest object is absent. A manifest exists ONLY on a
|
|
9
|
+
* successful dump, so absence == "no completed backup with that id" — and `verifySnapshotRef` treats
|
|
10
|
+
* null as a refusal. Every failure (NoSuchKey, a truncated read, unparseable JSON) collapses to null:
|
|
11
|
+
* an unreadable manifest is not a rollback point, and refusing is the safe answer.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getFromS3 } from './aws';
|
|
15
|
+
import { keyForId, metaKey, type BackupManifest } from './backup';
|
|
16
|
+
|
|
17
|
+
export async function fetchBackupManifest(
|
|
18
|
+
region: string,
|
|
19
|
+
bucket: string,
|
|
20
|
+
id: string,
|
|
21
|
+
): Promise<BackupManifest | null> {
|
|
22
|
+
const key = keyForId(id);
|
|
23
|
+
if (!key) return null;
|
|
24
|
+
try {
|
|
25
|
+
const body = await getFromS3(region, bucket, metaKey(key));
|
|
26
|
+
if (!body) return null;
|
|
27
|
+
return JSON.parse(body.toString('utf8')) as BackupManifest;
|
|
28
|
+
} catch {
|
|
29
|
+
return null; // NoSuchKey / parse failure → no valid manifest
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/cli/backup.ts
CHANGED
|
@@ -49,6 +49,110 @@ export function keyForId(id: string): string | null {
|
|
|
49
49
|
return ref ? `${backupPrefix(ref.stage)}/${ref.name}.dump.gz` : null;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// --- The destructive-apply rollback-point gate — MIRRORS @everystack/server/src/backup.ts --------
|
|
53
|
+
// db:apply's destructive lane runs CLI-side (--stage --direct), so the safety gate that server-side
|
|
54
|
+
// db:apply used (verifySnapshotRef against a backup's `.meta.json`) has to exist CLI-side too. The
|
|
55
|
+
// server is a peer dependency the CLI deliberately does NOT import from — server/plugin.ts imports
|
|
56
|
+
// `@everystack/cli/reconcile`, so a runtime import back would close a cycle. The house rule (see the
|
|
57
|
+
// plugin-type mirrors in src/plugin.ts) is to MIRROR the pure core and guard the two copies with a
|
|
58
|
+
// parity test. BackupManifest, SnapshotVerifyInput, SnapshotVerdict, SNAPSHOT_FRESHNESS_MS and
|
|
59
|
+
// verifySnapshotRef below are byte-for-byte the server's — a matrix parity test (verify-snapshot-ref
|
|
60
|
+
// parity) runs BOTH over the same inputs so a divergence fails CI, not a consumer's rollback.
|
|
61
|
+
|
|
62
|
+
/** A backup's `.meta.json` sidecar — written on a SUCCESSFUL dump, so its presence means "succeeded". */
|
|
63
|
+
export interface BackupManifest {
|
|
64
|
+
id: string;
|
|
65
|
+
stage: string;
|
|
66
|
+
/** ISO 8601. */
|
|
67
|
+
createdAt: string;
|
|
68
|
+
/** The base-schema fingerprint captured at dump time (absent on pre-B3 backups). */
|
|
69
|
+
fingerprint?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Who computed `fingerprint`.
|
|
72
|
+
*
|
|
73
|
+
* Absent or 1: the TASK IMAGE computed it, from a copy of the CLI frozen into a digest-pinned
|
|
74
|
+
* image at publish time. That copy could disagree with the CLI now doing the comparing, so such a
|
|
75
|
+
* fingerprint cannot be trusted as the safety gate's tie.
|
|
76
|
+
*
|
|
77
|
+
* 2: the OPERATOR computed it, with the same code `db:apply` uses, and the CLI verified the task
|
|
78
|
+
* stamped exactly what it sent.
|
|
79
|
+
*/
|
|
80
|
+
metaVersion?: number;
|
|
81
|
+
fingerprintSource?: string;
|
|
82
|
+
bytes?: number;
|
|
83
|
+
key: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SnapshotVerifyInput {
|
|
87
|
+
/** The resolved backup manifest, or null when none could be resolved. */
|
|
88
|
+
manifest: BackupManifest | null;
|
|
89
|
+
/** plan.fromFingerprint — the schema state the apply expects to change. */
|
|
90
|
+
planFrom: string;
|
|
91
|
+
/** The stage being changed — the backup must cover THIS database. */
|
|
92
|
+
targetStage: string;
|
|
93
|
+
now: Date;
|
|
94
|
+
/** How old a backup may be and still qualify (data-rollback bound; fp already proves schema). */
|
|
95
|
+
freshnessWindowMs: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type SnapshotVerdict = { ok: true; id: string } | { ok: false; reason: string };
|
|
99
|
+
|
|
100
|
+
/** How old a `--snapshot-ref` backup may be and still count as a rollback point (mirrors server). */
|
|
101
|
+
export const SNAPSHOT_FRESHNESS_MS = 60 * 60_000;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Verify a resolved backup is a valid safety net for a destructive apply.
|
|
105
|
+
*
|
|
106
|
+
* IDENTITY vs STATE — the rollback point's IDENTITY (which backup, for which database) rests on the
|
|
107
|
+
* STAGE-SCOPED MANIFEST RESOLUTION, not on the fingerprint. The ref was resolved against THIS stage's
|
|
108
|
+
* backupsBucket (backup-resolve.ts), and the `manifest.stage === targetStage` tie below confirms the
|
|
109
|
+
* backup was taken FOR this database. The fingerprint is a STATE check on top of that identity: it
|
|
110
|
+
* proves the backup captured the same SCHEMA the plan starts from (plan.from) — a value a DIFFERENT
|
|
111
|
+
* database could match by coincidence, so it can never stand in for the stage-scoped resolution. Do
|
|
112
|
+
* not read `fingerprint == plan.from` as "this is the right backup"; the bucket + stage tie is that.
|
|
113
|
+
*
|
|
114
|
+
* So a valid rollback point: exists (⇒ succeeded), covers this stage (identity), carries a fingerprint
|
|
115
|
+
* equal to plan.from (⇒ restores the exact schema state being changed), and is fresh. Every refusal
|
|
116
|
+
* names the fix (db:backup). Pure; the resolution IO lives in backup-resolve.ts and the enforcement in
|
|
117
|
+
* db:apply's destructive lane.
|
|
118
|
+
*/
|
|
119
|
+
export function verifySnapshotRef(input: SnapshotVerifyInput): SnapshotVerdict {
|
|
120
|
+
const { manifest, planFrom, targetStage, now, freshnessWindowMs } = input;
|
|
121
|
+
const takeFresh = `take a fresh backup: everystack db:backup --stage ${targetStage}, then re-run.`;
|
|
122
|
+
if (!manifest) {
|
|
123
|
+
return { ok: false, reason: `no backup found for stage ${targetStage} — ${takeFresh}` };
|
|
124
|
+
}
|
|
125
|
+
if (manifest.stage !== targetStage) {
|
|
126
|
+
return { ok: false, reason: `snapshot ${manifest.id} is for stage ${manifest.stage}, not ${targetStage} — a snapshot must cover the database being changed. ${takeFresh}` };
|
|
127
|
+
}
|
|
128
|
+
if (!manifest.fingerprint) {
|
|
129
|
+
return { ok: false, reason: `snapshot ${manifest.id} predates fingerprint stamping — ${takeFresh}` };
|
|
130
|
+
}
|
|
131
|
+
// Freshness first among the remaining checks: it is independent of everything below and is by far
|
|
132
|
+
// the most common reason a snapshot is rejected, so it should be what the operator reads.
|
|
133
|
+
const ageMs = now.getTime() - Date.parse(manifest.createdAt);
|
|
134
|
+
if (!(ageMs >= 0) || ageMs > freshnessWindowMs) {
|
|
135
|
+
const mins = Math.round(ageMs / 60_000);
|
|
136
|
+
return { ok: false, reason: `snapshot ${manifest.id} is ${mins} min old (freshness window ${Math.round(freshnessWindowMs / 60_000)} min) — ${takeFresh}` };
|
|
137
|
+
}
|
|
138
|
+
// Provenance BEFORE the tie: a pre-v2 manifest's fingerprint was computed INSIDE the task image,
|
|
139
|
+
// by a copy of the CLI frozen at publish time, so comparing it against this plan's fingerprint
|
|
140
|
+
// compares two things that were not necessarily produced the same way. Checking the tie first
|
|
141
|
+
// would report a mismatch as if the schema differed, sending the operator after the wrong thing.
|
|
142
|
+
// With an hour-long freshness window this only ever refuses a backup taken in the last hour by a
|
|
143
|
+
// pre-upgrade CLI — and the remedy is the backup they were going to take anyway.
|
|
144
|
+
if (!(manifest.metaVersion && manifest.metaVersion >= 2)) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
reason: `snapshot ${manifest.id} carries a fingerprint computed inside the task image, which can disagree with this CLI — ${takeFresh}`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (manifest.fingerprint !== planFrom) {
|
|
151
|
+
return { ok: false, reason: `snapshot ${manifest.id} captured schema ${manifest.fingerprint.slice(0, 12)}, but this plan starts from ${planFrom.slice(0, 12)} — it would not restore the state being changed. ${takeFresh}` };
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, id: manifest.id };
|
|
154
|
+
}
|
|
155
|
+
|
|
52
156
|
// --- Artifact key scheme (schema-scoped exports for the hot-swap) — mirrors server/backup.ts ----
|
|
53
157
|
// A backup is the whole database; an ARTIFACT is one schema, content-addressed, carrying the schema
|
|
54
158
|
// fingerprint it was built against. Separate prefix so the two never collide. db:swap --stage --direct
|