@everystack/cli 0.4.92 → 0.4.95
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 +76 -0
- package/package.json +2 -2
- package/src/cli/apply-execute.ts +100 -19
- package/src/cli/commands/db-apply.ts +19 -18
- package/src/cli/commands/db-plan.ts +12 -18
- package/src/cli/commands/db-pull.ts +2 -2
- package/src/cli/derived-apply.ts +56 -20
- package/src/cli/derived-plan.ts +7 -1
- package/src/cli/exec-digest.ts +61 -16
- package/src/cli/exec-run.ts +81 -2
- package/src/cli/git-descent.ts +204 -628
- package/src/cli/index.ts +1 -1
- package/src/cli/plain-output.ts +12 -6
- package/src/cli/state-apply.ts +2 -2
- package/src/exec.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,82 @@ 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.95] - 2026-09-26
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- descent reads schema_log and git ancestry, never historical models trees — no OOM, no minutes; when live has moved, the last recorded apply's commit must be in your history, and the schema_log note reads 'descent: drift-origin ...'
|
|
12
|
+
- a destructive db:apply that records no git commit is refused
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- read a schema_log written before fp_version and dirty existed
|
|
17
|
+
|
|
18
|
+
## [0.4.94] - 2026-09-24
|
|
19
|
+
|
|
20
|
+
### BREAKING CHANGES
|
|
21
|
+
|
|
22
|
+
- db:exec refuses to write the everystack schema
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- --baseline grant-checks what it adopts
|
|
27
|
+
- plain output names every grantee of a multi-grantee grant
|
|
28
|
+
- db:exec's DML-only guard sees rules, triggers, defaults, event triggers, extensions
|
|
29
|
+
- db:exec guard round 2 — deferred triggers, inherited ledgers, track_counts, search_path
|
|
30
|
+
- db:exec guard round 3 — shadowed catalogs, held cursors, re-deferred triggers
|
|
31
|
+
|
|
32
|
+
## [0.4.93] - 2026-09-24
|
|
33
|
+
|
|
34
|
+
### BREAKING CHANGES
|
|
35
|
+
|
|
36
|
+
- a declared owner needs USAGE on its schema; db:apply refuses without it, db:provision (server 0.4.33) grants it
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- name USAGE with CREATE for a missing schema; review follow-ups
|
|
41
|
+
|
|
42
|
+
## [0.4.92] - 2026-09-24
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- grant drift on an owned object runs as its owner
|
|
47
|
+
- an apply fails unless the grants it set really converged
|
|
48
|
+
- settle a built object's grants before checking convergence
|
|
49
|
+
- settle statements are recorded and named; pin settle-as-owner
|
|
50
|
+
|
|
51
|
+
### Documentation
|
|
52
|
+
|
|
53
|
+
- a declared owner needs USAGE on its schema to grant there
|
|
54
|
+
|
|
55
|
+
## [0.4.91] - 2026-09-24
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- one apply revokes a grantee dropped from a replaced function
|
|
60
|
+
- a lowercase public grant is PUBLIC in the function grant diff
|
|
61
|
+
|
|
62
|
+
## [0.4.90] - 2026-09-23
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
|
|
66
|
+
- db:plan warns when the derived layer is out of sync
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- db:apply reports skipped brownfield derived objects by default
|
|
71
|
+
|
|
72
|
+
## [0.4.89] - 2026-09-22
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- outputsDescribeStage matches SST's truncated app-name prefix
|
|
77
|
+
- discoverFunction/discoverCfFunction/discoverBuckets match SST's truncated app-name prefix
|
|
78
|
+
- warn once when cloudfront:ListTagsForResource is denied during distribution discovery
|
|
79
|
+
- db:provision --rotate probes siteUrl on a no-CDN_URL stage
|
|
80
|
+
- update refuses a missing HOST_URL with or without --stage
|
|
81
|
+
- db:reconcile/db:refresh --only respects parens in function identities
|
|
82
|
+
|
|
7
83
|
## [0.4.88] - 2026-09-21
|
|
8
84
|
|
|
9
85
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.95",
|
|
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>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"react": "19.2.0",
|
|
183
183
|
"ts-jest": "29.4.9",
|
|
184
184
|
"zod": "3.25.67",
|
|
185
|
-
"@everystack/server": "0.4.
|
|
185
|
+
"@everystack/server": "0.4.33"
|
|
186
186
|
},
|
|
187
187
|
"scripts": {
|
|
188
188
|
"test": "jest",
|
package/src/cli/apply-execute.ts
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
type QueryRunner,
|
|
43
43
|
type AuthzContract,
|
|
44
44
|
} from './authz-contract';
|
|
45
|
-
import { INTROSPECTION_SESSION, rowsOrEmpty, type SessionRunner } from './session';
|
|
45
|
+
import { INTROSPECTION_SESSION, isSessionError, rowsOrEmpty, type SessionResult, type SessionRunner } from './session';
|
|
46
46
|
import {
|
|
47
47
|
introspectSchema,
|
|
48
48
|
assembleSchema,
|
|
@@ -69,7 +69,7 @@ import {
|
|
|
69
69
|
import { validationPendingReason, pendingValidationsAreSafe } from './apply-refusals';
|
|
70
70
|
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
|
|
71
71
|
import { checkPlanPrecondition, planHash, defaultAclHash, type EdgePlan } from './edge-plan';
|
|
72
|
-
import type { DescentLedgerRow } from './git-descent';
|
|
72
|
+
import type { DescentLedgerRow, LedgerRead } from './git-descent';
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
75
|
* CALLER CONTRACT (fail-closed protection lives here, not inside executeApplyPlan):
|
|
@@ -108,6 +108,8 @@ export interface ApplyPlanOptions {
|
|
|
108
108
|
* degrades toward REVERT/refusal, never a false pass — and the server re-verifies the lock
|
|
109
109
|
* in its own one-session read. */
|
|
110
110
|
ledger: DescentLedgerRow[];
|
|
111
|
+
/** What the same read proved about the ledger — absent, present (with counts), or unreadable. */
|
|
112
|
+
ledgerRead: LedgerRead;
|
|
111
113
|
}) => Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
112
114
|
/**
|
|
113
115
|
* Destructive authority (brick 9, decisions 11+12): called only when the
|
|
@@ -141,8 +143,8 @@ export interface ApplyPlanOptions {
|
|
|
141
143
|
now?: () => number;
|
|
142
144
|
/**
|
|
143
145
|
* A note for the schema_log outcome (`applied — <note>`) carrying what descent decided: a
|
|
144
|
-
* honoured `--attest-lineage-lost` waiver, or a
|
|
145
|
-
*
|
|
146
|
+
* honoured `--attest-lineage-lost` waiver, or a DRIFT origin (`descent: drift-origin
|
|
147
|
+
* never-declared (…) | hand-edited since … | applied from a DIRTY worktree at …`) — the record that makes this row the origin
|
|
146
148
|
* every later apply descends from. A getter, because the decision is made inside `checkDescent`
|
|
147
149
|
* (which runs before the write) — the getter reads it at write time.
|
|
148
150
|
*/
|
|
@@ -257,6 +259,7 @@ export async function liveBaseFingerprint(session: SessionRunner): Promise<LiveB
|
|
|
257
259
|
/** The live base fingerprint AND the descent ledger, read in ONE session. */
|
|
258
260
|
export interface LiveBaseAndLedger extends LiveBaseFingerprint {
|
|
259
261
|
ledger: DescentLedgerRow[];
|
|
262
|
+
ledgerRead: LedgerRead;
|
|
260
263
|
}
|
|
261
264
|
|
|
262
265
|
/**
|
|
@@ -270,12 +273,85 @@ export interface LiveBaseAndLedger extends LiveBaseFingerprint {
|
|
|
270
273
|
* silently dropped every annotated apply from the ledger, which for a drift origin defeats the
|
|
271
274
|
* entire point of recording it.
|
|
272
275
|
*/
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
+
//
|
|
277
|
+
// `fp_version` and `dirty` are read through `to_jsonb` because a ledger written before they existed
|
|
278
|
+
// lacks the columns until the next apply adds them (ENSURE_RECONCILER_SQL). Such rows read NULL /
|
|
279
|
+
// false, which descent already skips as witnesses; naming the columns made the whole read fail.
|
|
280
|
+
const LEDGER_COLUMNS = `id, to_fingerprint, git_ref,
|
|
281
|
+
(to_jsonb(s) ->> 'fp_version')::int AS fp_version,
|
|
282
|
+
COALESCE((to_jsonb(s) ->> 'dirty')::boolean, false) AS dirty,
|
|
283
|
+
applied_at, outcome`;
|
|
284
|
+
|
|
285
|
+
export const SCHEMA_LOG_WITNESS_SQL = `SELECT ${LEDGER_COLUMNS}
|
|
286
|
+
FROM everystack.schema_log s
|
|
287
|
+
WHERE kind = 'state apply' AND outcome LIKE 'applied%'
|
|
276
288
|
ORDER BY id DESC
|
|
277
289
|
LIMIT 200`;
|
|
278
290
|
|
|
291
|
+
/**
|
|
292
|
+
* The newest successful apply that recorded a commit — the row descent's ancestry check anchors on.
|
|
293
|
+
* Read on its own, uncapped, because 200 newer no-git applies would push it out of the witness set
|
|
294
|
+
* and turn a REVERT into a drift. Ordered by id, never applied_at: ids survive a dump restore, and
|
|
295
|
+
* clocks on different containers do not agree.
|
|
296
|
+
*/
|
|
297
|
+
export const SCHEMA_LOG_ANCHOR_SQL = `SELECT ${LEDGER_COLUMNS}
|
|
298
|
+
FROM everystack.schema_log s
|
|
299
|
+
WHERE kind = 'state apply' AND outcome LIKE 'applied%' AND git_ref IS NOT NULL
|
|
300
|
+
ORDER BY id DESC
|
|
301
|
+
LIMIT 1`;
|
|
302
|
+
|
|
303
|
+
/** A query that cannot fail on a missing ledger — so "absent" is never a read error in disguise. */
|
|
304
|
+
export const SCHEMA_LOG_PRESENT_SQL = `SELECT to_regclass('everystack.schema_log') IS NOT NULL AS present`;
|
|
305
|
+
|
|
306
|
+
/** Every successful state apply and every recorded refusal — ALL rows, not the capped witness set. */
|
|
307
|
+
export const SCHEMA_LOG_COUNTS_SQL = `SELECT
|
|
308
|
+
count(*) FILTER (WHERE kind = 'state apply' AND outcome LIKE 'applied%')::int AS applied,
|
|
309
|
+
count(*) FILTER (WHERE kind = 'state refusal')::int AS refusals
|
|
310
|
+
FROM everystack.schema_log`;
|
|
311
|
+
|
|
312
|
+
const LEDGER_STATEMENTS = [
|
|
313
|
+
{ sql: SCHEMA_LOG_WITNESS_SQL, allowFailure: true },
|
|
314
|
+
{ sql: SCHEMA_LOG_PRESENT_SQL, allowFailure: true },
|
|
315
|
+
{ sql: SCHEMA_LOG_COUNTS_SQL, allowFailure: true },
|
|
316
|
+
{ sql: SCHEMA_LOG_ANCHOR_SQL, allowFailure: true },
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The witness rows and what the read proved about the ledger. A failed read is UNREADABLE, never
|
|
321
|
+
* empty: only a clean read may say no apply landed, or that none recorded a commit.
|
|
322
|
+
*/
|
|
323
|
+
export function ledgerFromSlots(
|
|
324
|
+
witness: SessionResult | undefined,
|
|
325
|
+
present: SessionResult | undefined,
|
|
326
|
+
counts: SessionResult | undefined,
|
|
327
|
+
anchor: SessionResult | undefined,
|
|
328
|
+
): { ledger: DescentLedgerRow[]; ledgerRead: LedgerRead } {
|
|
329
|
+
// Rows that were read are always kept: a witness does not become less true because a sibling
|
|
330
|
+
// read failed. Only the claims "no apply landed" and "none anchors" need every read to succeed.
|
|
331
|
+
const ledger = mapLedgerRows(rowsOrEmpty(witness));
|
|
332
|
+
for (const row of mapLedgerRows(rowsOrEmpty(anchor))) {
|
|
333
|
+
if (!ledger.some((r) => r.id === row.id)) ledger.push(row);
|
|
334
|
+
}
|
|
335
|
+
const unreadable = (slot: SessionResult | undefined, what: string) => ({
|
|
336
|
+
ledger,
|
|
337
|
+
ledgerRead: { kind: 'unreadable' as const, reason: isSessionError(slot) ? slot.message : `no ${what} answer` },
|
|
338
|
+
});
|
|
339
|
+
if (!Array.isArray(present) || present.length !== 1) return unreadable(present, 'presence');
|
|
340
|
+
if (present[0].present !== true) return { ledger, ledgerRead: { kind: 'absent' } };
|
|
341
|
+
if (!Array.isArray(witness)) return unreadable(witness, 'witness');
|
|
342
|
+
if (!Array.isArray(counts) || counts.length !== 1) return unreadable(counts, 'count');
|
|
343
|
+
if (!Array.isArray(anchor)) return unreadable(anchor, 'anchor');
|
|
344
|
+
return {
|
|
345
|
+
ledger,
|
|
346
|
+
ledgerRead: {
|
|
347
|
+
kind: 'present',
|
|
348
|
+
applied: Number(counts[0].applied),
|
|
349
|
+
refusals: Number(counts[0].refusals),
|
|
350
|
+
anchored: anchor.length > 0,
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
279
355
|
function mapLedgerRows(rows: any[]): DescentLedgerRow[] {
|
|
280
356
|
return rows.map((r) => ({
|
|
281
357
|
id: String(r.id),
|
|
@@ -288,13 +364,10 @@ function mapLedgerRows(rows: any[]): DescentLedgerRow[] {
|
|
|
288
364
|
}));
|
|
289
365
|
}
|
|
290
366
|
|
|
291
|
-
/**
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
export async function readDescentLedger(session: SessionRunner): Promise<DescentLedgerRow[]> {
|
|
296
|
-
const [rows] = await session([{ sql: SCHEMA_LOG_WITNESS_SQL, allowFailure: true }], INTROSPECTION_SESSION);
|
|
297
|
-
return mapLedgerRows(rowsOrEmpty(rows));
|
|
367
|
+
/** The witness rows AND what the read proved about the ledger, in one session — what descent is handed. */
|
|
368
|
+
export async function readDescentLedgerRead(session: SessionRunner): Promise<{ ledger: DescentLedgerRow[]; ledgerRead: LedgerRead }> {
|
|
369
|
+
const [witness, present, counts, anchor] = await session(LEDGER_STATEMENTS, INTROSPECTION_SESSION);
|
|
370
|
+
return ledgerFromSlots(witness, present, counts, anchor);
|
|
298
371
|
}
|
|
299
372
|
|
|
300
373
|
/**
|
|
@@ -314,7 +387,7 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
|
|
|
314
387
|
// The default-privilege posture the plan's `defaultAclHash` is checked against — read in
|
|
315
388
|
// the SAME session as the lock's state, so the two gates describe one instant.
|
|
316
389
|
DEFAULT_ACL_SQL,
|
|
317
|
-
|
|
390
|
+
...LEDGER_STATEMENTS,
|
|
318
391
|
],
|
|
319
392
|
INTROSPECTION_SESSION,
|
|
320
393
|
);
|
|
@@ -331,8 +404,8 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
|
|
|
331
404
|
functions: (results[13] as any[]).map(contractFunctionRow),
|
|
332
405
|
defaultAcls: results[14] as any[],
|
|
333
406
|
});
|
|
334
|
-
const ledger =
|
|
335
|
-
return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract, ledger };
|
|
407
|
+
const { ledger, ledgerRead } = ledgerFromSlots(results[15], results[16], results[17], results[18]);
|
|
408
|
+
return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract, ledger, ledgerRead };
|
|
336
409
|
}
|
|
337
410
|
|
|
338
411
|
/** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
|
|
@@ -342,7 +415,7 @@ export async function executeApplyPlan(
|
|
|
342
415
|
plan: EdgePlan,
|
|
343
416
|
options: ApplyPlanOptions = {},
|
|
344
417
|
): Promise<ApplyPlanResult> {
|
|
345
|
-
const { hash: live, snapshot: before, contract: beforeAuthz, ledger } = await liveBaseAndLedger(session);
|
|
418
|
+
const { hash: live, snapshot: before, contract: beforeAuthz, ledger, ledgerRead } = await liveBaseAndLedger(session);
|
|
346
419
|
|
|
347
420
|
// The endpoint is FULL (it carries the ungoverned live authz through), so it is compared against
|
|
348
421
|
// the RAW live hash directly — the same flavor as `from`. A plan without that flavor predates
|
|
@@ -381,8 +454,16 @@ export async function executeApplyPlan(
|
|
|
381
454
|
return { status: 'refused', liveFingerprint: live, reason };
|
|
382
455
|
}
|
|
383
456
|
|
|
457
|
+
// A destructive apply must record the commit it came from. Descent anchors on the newest apply
|
|
458
|
+
// WITH a commit, so a destructive row without one would be invisible to the next ancestry check.
|
|
459
|
+
if (plan.destructive > 0 && (options.gitRef ?? plan.gitRef) == null) {
|
|
460
|
+
const reason = 'a destructive apply must record the commit it came from, and this one has none — run db:plan and db:apply from the git checkout that declares your models.';
|
|
461
|
+
await recordRefusal(runner, plan, live, 'fast-forward rule', reason, options);
|
|
462
|
+
return { status: 'descent-refused', liveFingerprint: live, reason };
|
|
463
|
+
}
|
|
464
|
+
|
|
384
465
|
if (options.checkDescent) {
|
|
385
|
-
const descent = await options.checkDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live, ledger });
|
|
466
|
+
const descent = await options.checkDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live, ledger, ledgerRead });
|
|
386
467
|
if (!descent.ok) {
|
|
387
468
|
await recordRefusal(runner, plan, live, 'fast-forward rule', descent.reason, options);
|
|
388
469
|
return { status: 'descent-refused', liveFingerprint: live, reason: descent.reason };
|
|
@@ -37,15 +37,16 @@ import { planHash, PLAN_VERSION, APPLY_CONTRACT_VERSION, declaredGranteesOf, typ
|
|
|
37
37
|
import {
|
|
38
38
|
proveDescent,
|
|
39
39
|
decideDescentApply,
|
|
40
|
-
|
|
40
|
+
driftNotice,
|
|
41
|
+
newerOffHeadNotice,
|
|
41
42
|
attestationNote,
|
|
42
43
|
proveLineageLost,
|
|
43
44
|
realLineageProbe,
|
|
44
45
|
resolveAttestation,
|
|
45
46
|
type DescentLedgerRow,
|
|
47
|
+
type LedgerRead,
|
|
46
48
|
type LiveState,
|
|
47
49
|
type ProveVerdict,
|
|
48
|
-
type ProveDescentOptions,
|
|
49
50
|
} from '../git-descent';
|
|
50
51
|
import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity, type AuthorityVerdict } from '../apply-authority';
|
|
51
52
|
import { decideSnapshotMode, type SnapshotModeRequest } from '../swap-snapshot';
|
|
@@ -68,7 +69,7 @@ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '
|
|
|
68
69
|
import { borrowedSessionRunner, type SessionRunner } from '../session';
|
|
69
70
|
import { resolveConfig, opsFunction } from '../config';
|
|
70
71
|
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws';
|
|
71
|
-
import { executeApplyPlan,
|
|
72
|
+
import { executeApplyPlan, readDescentLedgerRead, type ApplyPlanResult } from '../apply-execute';
|
|
72
73
|
import { estimateOpsRuntimeFit } from '../ops-fit';
|
|
73
74
|
import { resolveOperatorUrlViaStage } from '../direct-venue';
|
|
74
75
|
import { withMutationLease, MutationLeaseError } from '../mutation-lease';
|
|
@@ -412,24 +413,23 @@ function exitOnCatchUpFailure(report: CatchUpReport): asserts report is { ok: tr
|
|
|
412
413
|
*/
|
|
413
414
|
export function describeDescentVerdict(verdict: ProveVerdict, opts: { destructive?: boolean } = {}): { level: 'info' | 'warn'; text: string } | null {
|
|
414
415
|
switch (verdict.status) {
|
|
415
|
-
case 'proven':
|
|
416
|
+
case 'proven': {
|
|
416
417
|
// A squash proof names BOTH commits: the carrier that is an ancestor of HEAD, and the
|
|
417
418
|
// deploying commit that is not — announcing the carrier alone as "an ancestor" hid that
|
|
418
419
|
// the proof rests on tree identity (closing-gate review, 2026-09-07).
|
|
419
|
-
|
|
420
|
-
?
|
|
421
|
-
:
|
|
420
|
+
const text = verdict.via === 'squash' && verdict.witness
|
|
421
|
+
? `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.`
|
|
422
|
+
: `descent: proven via ${verdict.via} — ${verdict.commit.slice(0, 12)} is an ancestor of HEAD, fast-forward.`;
|
|
423
|
+
const newer = newerOffHeadNotice(verdict);
|
|
424
|
+
return newer ? { level: 'warn', text: `${text} ${newer}` } : { level: 'info', text };
|
|
425
|
+
}
|
|
422
426
|
case 'fresh-target':
|
|
423
427
|
return { level: 'info', text: 'descent: fresh target (no tables) — nothing to protect, bootstrapping.' };
|
|
424
428
|
case 'no-git':
|
|
425
429
|
// On a destructive plan the decision refuses with its own message; do not double-report.
|
|
426
430
|
return opts.destructive ? null : { level: 'warn', text: 'descent: not a git checkout — the fast-forward rule cannot be verified here.' };
|
|
427
431
|
case 'drift':
|
|
428
|
-
return {
|
|
429
|
-
level: 'warn',
|
|
430
|
-
text: `NO COMMIT ON ANY REF DECLARES THIS DATABASE (${describeDriftOrigin(verdict.origin, { human: true })}) — this reviewed plan is the ORIGIN. The fast-forward rule has nothing merged to protect (proven over a full, current clone); the origin is recorded in schema_log, and every later apply descends from it.`,
|
|
431
|
-
};
|
|
432
|
-
case 'declared-elsewhere':
|
|
432
|
+
return { level: 'warn', text: `${driftNotice(verdict.origin)} This reviewed plan is the ORIGIN; it is recorded in schema_log, and every later apply descends from it.` };
|
|
433
433
|
case 'revert':
|
|
434
434
|
case 'unknown-commit':
|
|
435
435
|
case 'unverifiable':
|
|
@@ -458,18 +458,17 @@ export async function proveDescentForApply(input: {
|
|
|
458
458
|
modelsPath: string;
|
|
459
459
|
live: LiveState;
|
|
460
460
|
ledger: DescentLedgerRow[];
|
|
461
|
+
/** What the ledger read proved. */
|
|
462
|
+
ledgerRead: LedgerRead;
|
|
461
463
|
destructive: boolean;
|
|
462
464
|
attestation?: string;
|
|
463
465
|
/** Where git runs. Default process.cwd(). */
|
|
464
466
|
cwd?: string;
|
|
465
|
-
/** Test seam: the barrel loader and materialize root (jest cannot dynamic-import a .ts barrel).
|
|
466
|
-
* Production passes nothing and rides the real loader. */
|
|
467
|
-
descent?: Pick<ProveDescentOptions, 'loader' | 'materializeRoot'>;
|
|
468
467
|
}): Promise<{ descentVerdict: { ok: true } | { ok: false; reason: string }; descentAttested?: string }> {
|
|
469
468
|
const cwd = input.cwd ?? process.cwd();
|
|
470
469
|
const verdict = await proveDescent(input.modelsPath, input.live, input.ledger, {
|
|
471
470
|
...(input.cwd ? { cwd: input.cwd } : {}),
|
|
472
|
-
|
|
471
|
+
ledgerRead: input.ledgerRead,
|
|
473
472
|
});
|
|
474
473
|
reportDescentVerdict(verdict, { destructive: input.destructive });
|
|
475
474
|
// The attestation is resolved THROUGH git (a prefix, tag or HEAD~n → the full sha) before it is
|
|
@@ -569,12 +568,13 @@ async function applyPlanViaStage(
|
|
|
569
568
|
// commit is gone from every ref.
|
|
570
569
|
const { snapshot, contract } = readPair.state;
|
|
571
570
|
step("Descent: reading the stage's schema_log ledger...");
|
|
572
|
-
const ledger = await
|
|
571
|
+
const { ledger, ledgerRead } = await readDescentLedgerRead(lambdaSessionRunner(region, fn));
|
|
573
572
|
step("Descent: proving the fast-forward rule against the ledger...");
|
|
574
573
|
const { descentVerdict, descentAttested } = await proveDescentForApply({
|
|
575
574
|
modelsPath: resolveModelsPath(flags.models),
|
|
576
575
|
live: { snapshot, contract },
|
|
577
576
|
ledger,
|
|
577
|
+
ledgerRead,
|
|
578
578
|
destructive: plan.destructive > 0,
|
|
579
579
|
...(attestLineageLost ? { attestation: attestLineageLost } : {}),
|
|
580
580
|
});
|
|
@@ -1119,12 +1119,13 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
1119
1119
|
},
|
|
1120
1120
|
dirty: worktreeDirty(resolveModelsPath(flags.models)),
|
|
1121
1121
|
descentNote: () => descentAttested,
|
|
1122
|
-
checkDescent: async (live: LiveState & { fingerprint: string; ledger: DescentLedgerRow[] }) => {
|
|
1122
|
+
checkDescent: async (live: LiveState & { fingerprint: string; ledger: DescentLedgerRow[]; ledgerRead: LedgerRead }) => {
|
|
1123
1123
|
step('Descent: proving the fast-forward rule against the schema_log ledger...');
|
|
1124
1124
|
const result = await proveDescentForApply({
|
|
1125
1125
|
modelsPath: resolveModelsPath(flags.models),
|
|
1126
1126
|
live,
|
|
1127
1127
|
ledger: live.ledger,
|
|
1128
|
+
ledgerRead: live.ledgerRead,
|
|
1128
1129
|
destructive: plan.destructive > 0,
|
|
1129
1130
|
...(attestLineageLost ? { attestation: attestLineageLost } : {}),
|
|
1130
1131
|
});
|
|
@@ -50,10 +50,10 @@ 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, explainCarriedBy, declaredGranteesOf } from '../edge-plan';
|
|
53
|
-
import { proveDescent,
|
|
53
|
+
import { proveDescent, driftNotice, newerOffHeadNotice, proveLineageLost, realLineageProbe, type ProveVerdict, type LineageProof } from '../git-descent';
|
|
54
54
|
import { fingerprintLive, divergentSurfaces } from '../schema-fingerprint';
|
|
55
55
|
import { predictLiveState } from '../edge-plan';
|
|
56
|
-
import {
|
|
56
|
+
import { readDescentLedgerRead } from '../apply-execute';
|
|
57
57
|
import { planBackfills, readBackfillLog } from '../backfill';
|
|
58
58
|
import { readSqlDirIfPresent } from './db-sync';
|
|
59
59
|
import { currentGitRef, classifyGeneratedStatements, classifyDestructive } from '../state-apply';
|
|
@@ -115,25 +115,20 @@ export function describePlanDescent(
|
|
|
115
115
|
return `db:apply from this checkout will refuse this plan — ${proof.reason}`;
|
|
116
116
|
};
|
|
117
117
|
switch (verdict.status) {
|
|
118
|
-
case 'proven':
|
|
118
|
+
case 'proven': {
|
|
119
119
|
// A squash proof is the one PROVEN shape whose deploying commit is not in the operator's
|
|
120
120
|
// history — say so where a human reads the plan, as the apply record will.
|
|
121
|
-
|
|
121
|
+
const lines = verdict.via === 'squash' && verdict.witness
|
|
122
122
|
? [`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.`]
|
|
123
123
|
: [];
|
|
124
|
+
const newer = newerOffHeadNotice(verdict);
|
|
125
|
+
return newer ? [...lines, newer] : lines;
|
|
126
|
+
}
|
|
124
127
|
case 'fresh-target':
|
|
125
128
|
case 'no-git':
|
|
126
129
|
return [];
|
|
127
130
|
case 'drift':
|
|
128
|
-
return [
|
|
129
|
-
`descent: NO COMMIT ON ANY REF DECLARES THIS DATABASE (${describeDriftOrigin(verdict.origin, { human: true })}) — proven over a full, current clone.`,
|
|
130
|
-
'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.',
|
|
131
|
-
];
|
|
132
|
-
case 'declared-elsewhere':
|
|
133
|
-
return [
|
|
134
|
-
`descent: ${verdict.reason}`,
|
|
135
|
-
'db:apply from this checkout will refuse this plan — fetch and merge or rebase onto that commit, then re-mint.',
|
|
136
|
-
];
|
|
131
|
+
return [driftNotice(verdict.origin), '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.'];
|
|
137
132
|
case 'revert':
|
|
138
133
|
return [`descent: ${verdict.reason}`, applyWillDo(verdict.commit)];
|
|
139
134
|
case 'unknown-commit': {
|
|
@@ -149,9 +144,8 @@ export function describePlanDescent(
|
|
|
149
144
|
];
|
|
150
145
|
}
|
|
151
146
|
case 'unverifiable':
|
|
152
|
-
// NOT "rebase first" — nothing is wrong with the checkout or the database. The
|
|
153
|
-
|
|
154
|
-
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.'];
|
|
147
|
+
// NOT "rebase first" — nothing is wrong with the checkout or the database. The ledger could not be read.
|
|
148
|
+
return [`descent: ${verdict.reason}`, 'db:apply will refuse this plan until the ledger can be read — retry, then re-mint.'];
|
|
155
149
|
default: {
|
|
156
150
|
// Compile-time exhaustiveness; at runtime (a verdict shape from outside this compile unit)
|
|
157
151
|
// an empty warning list is the safe value — the caller iterates it.
|
|
@@ -433,8 +427,8 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
433
427
|
// the very strand apply now PROVES via the ledger and applies, telling the operator the
|
|
434
428
|
// opposite of what happens. proveDescent over the same ledger apply reads keeps them agreed.
|
|
435
429
|
if (plan.statements.length > 0) {
|
|
436
|
-
const ledger = await
|
|
437
|
-
const verdict = await proveDescent(modelsPath, { snapshot, contract }, ledger, {});
|
|
430
|
+
const { ledger, ledgerRead } = await readDescentLedgerRead(session);
|
|
431
|
+
const verdict = await proveDescent(modelsPath, { snapshot, contract }, ledger, { ledgerRead });
|
|
438
432
|
// A switch, not an `if (revert || drift)`. That form silently ignored every verdict it
|
|
439
433
|
// did not name, so a new one would mint a plan with no warning and then be refused at
|
|
440
434
|
// apply time with no hint of why. Exhaustive here means a new verdict cannot be forgotten.
|
|
@@ -54,8 +54,8 @@ import { introspectDerived, type DerivedCatalog } from '../derived-introspect';
|
|
|
54
54
|
*
|
|
55
55
|
* db:pull is the only way an existing database comes in. What happens next is decided by
|
|
56
56
|
* db:plan, not by a verb: an EMPTY plan means the committed Models declare the database as it
|
|
57
|
-
* is (adopted —
|
|
58
|
-
* it); a NON-EMPTY plan is the hardening edge, reviewed and applied as the origin. db:adopt used
|
|
57
|
+
* is (adopted — nothing to apply; the first real change applies as the never-declared origin, and
|
|
58
|
+
* every later apply descends from it); a NON-EMPTY plan is the hardening edge, reviewed and applied as the origin. db:adopt used
|
|
59
59
|
* to bolt a proof onto this render; the proof was "would the plan be empty", so the verb is gone
|
|
60
60
|
* (cli 0.4.79) and this is what replaced it. Exported so a test pins the lines and the
|
|
61
61
|
* refusal-runbook lint reads them.
|