@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/src/cli/index.ts CHANGED
@@ -372,7 +372,7 @@ Usage:
372
372
  everystack db:sync [--database-url <url>] [--models <barrel>] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against the declared descriptors, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
373
373
  everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
374
374
  everystack db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only; plans are ephemeral, never committed. VENUES: --stage runs via the ops Lambda, which reads TWICE and refuses on disagreement — agreement there is a DETECTOR, not a verification. --stage --direct resolves the stage's operator connection from its IAM-gated ops Lambda, holds it in memory only, and reads ONCE over one session: the lane for a fingerprint you intend to trust, with the credential never on argv. --database-url is the local-dev venue (same read guarantee, but against a deployed stage it puts a privileged DSN on the command line)
375
- everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always + an attested --snapshot-ref + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there
375
+ everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--attest-lineage-lost <sha>] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — a descent refusal is escaped git-natively: git revert the declaring commit → db:generate → db:plan → db:apply, so the rollback is a recorded forward move; --attest-lineage-lost <sha> waives the rule ONLY for a declaring commit proven gone from every ref), and for DESTRUCTIVE plans require --confirm always + a confirmed rollback point + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there
376
376
  everystack db:build --database-url <url> [--models <barrel>] Build a database FROM the models and KEEP it — the fresh-database bootstrap a deleted migration folder is replaced BY. Runs the same compose db:check proves buildability with (extensions -> schemas -> contract roles -> declared functions -> state -> derived layer), against a real target, then reports MATCH. Venue is EXPLICIT: --database-url only, never the ambient environment, because this writes a whole schema. REFUSES a database that already holds objects — evolving an existing one is db:sync (dev) or db:plan -> db:apply (a stage). Contract roles are created NOLOGIN if absent, and roles are cluster-level
377
377
  everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, descriptors compile), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
378
378
  everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
@@ -0,0 +1,146 @@
1
+ /**
2
+ * confirmLocalDump — the bare `--database-url` rollback point for a destructive db:apply.
3
+ *
4
+ * Ratified 2026-08-26 (REVERSAL 1) and Fable-adjudicated 2026-08-26 (Option A): over a bare
5
+ * --database-url the tool takes its OWN `pg_dump -Fc` to a local file and VERIFIES it with
6
+ * `pg_restore --list` before the first destructive statement. The dump is the rollback point; the
7
+ * operator restores it by hand with standard tooling if the apply goes wrong
8
+ * (`pg_restore --clean --if-exists -d <url> <file>`) — there is no first-class restore verb here on
9
+ * purpose, because a bare --database-url is the local-dev venue where the operator owns the DB.
10
+ *
11
+ * FAIL CLOSED — a local .dump becomes theater two ways: a truncated file and a missing binary. The
12
+ * verdict is a total, pure function (`interpretLocalDumpVerdict`) so every failure mode refuses
13
+ * WITHOUT a database. `pg_restore --list` exiting 0 is not enough on its own: an empty or truncated
14
+ * archive can still exit 0 with no table of contents, so a non-empty TOC is required too. (The
15
+ * "is this the right database" worry that dogs an attested --snapshot-ref does not arise here: the
16
+ * dump is taken from the SAME url the apply will run against.)
17
+ *
18
+ * The dump carries no fingerprint stamp of its own — like a physical RDS snapshot it is taken fresh
19
+ * and the caller's concurrency lock (live == plan.from, re-read after this returns) is its coverage.
20
+ */
21
+
22
+ import { spawn } from 'node:child_process';
23
+ import { stat } from 'node:fs/promises';
24
+ import { pgEnvFromUrl, pgKeepaliveConninfo } from './commands/db';
25
+
26
+ /**
27
+ * pg_dump args for a rollback dump to `file`: a custom-format (-Fc) archive of the app schemas.
28
+ * Excludes the everystack ops schema — restoring it would rewind the target's own audit history, and
29
+ * pg_restore --clean would fail dropping it (its ledger tables depend on the schema). Ownership- and
30
+ * privilege-neutral so the restore lands on a dev DB the operator owns without role juggling.
31
+ */
32
+ export function pgDumpToFileArgs(file: string): string[] {
33
+ return ['-d', pgKeepaliveConninfo(), '-Fc', '--no-owner', '--no-privileges', '--exclude-schema=everystack', '-f', file];
34
+ }
35
+
36
+ export interface LocalDumpVerdictInput {
37
+ /** Did pg_dump exit 0? (A missing pg_dump binary is a spawn error ⇒ false.) */
38
+ dumpOk: boolean;
39
+ dumpError?: string;
40
+ /** Size of the written archive. Zero means pg_dump produced nothing usable. */
41
+ fileBytes: number;
42
+ /** Did `pg_restore --list` read the archive? (A missing pg_restore, or a truncated archive ⇒ false.) */
43
+ listOk: boolean;
44
+ listError?: string;
45
+ /** Non-comment entries in the archive's table of contents. Zero ⇒ an empty archive that lists clean. */
46
+ tocEntries: number;
47
+ }
48
+
49
+ /**
50
+ * The rollback point is durable ONLY when every stage of the take+verify succeeded. Any failure is a
51
+ * REFUSAL the caller must obey — no destructive statement runs. Ordered so the operator reads the
52
+ * FIRST thing that broke.
53
+ */
54
+ export function interpretLocalDumpVerdict(i: LocalDumpVerdictInput): { ok: true } | { ok: false; reason: string } {
55
+ const takeOne =
56
+ 'A destructive apply over a bare --database-url needs a rollback point it can prove is restorable. '
57
+ + 'Fix the tool (pg_dump/pg_restore must be on PATH and no older major than the server), or attest a '
58
+ + 'rollback point you took yourself with --snapshot-ref <id>, or accept the risk explicitly with --snapshot none.';
59
+ if (!i.dumpOk) {
60
+ return { ok: false, reason: `pg_dump could not take the local rollback point: ${i.dumpError ?? 'unknown error'}. ${takeOne}` };
61
+ }
62
+ if (!(i.fileBytes > 0)) {
63
+ return { ok: false, reason: `the local rollback dump is empty (0 bytes) — pg_dump produced nothing usable. ${takeOne}` };
64
+ }
65
+ if (!i.listOk) {
66
+ return { ok: false, reason: `pg_restore --list could not read the rollback dump (it may be truncated, or pg_restore is missing): ${i.listError ?? 'unknown error'}. ${takeOne}` };
67
+ }
68
+ if (!(i.tocEntries > 0)) {
69
+ return { ok: false, reason: `the rollback dump lists no objects — an empty archive is not a rollback point. ${takeOne}` };
70
+ }
71
+ return { ok: true };
72
+ }
73
+
74
+ /** Count table-of-contents ENTRIES in `pg_restore --list` output: non-empty lines that are not the ';'-prefixed header/comment lines. */
75
+ export function countTocEntries(listOutput: string): number {
76
+ return listOutput
77
+ .split('\n')
78
+ .map((l) => l.trim())
79
+ .filter((l) => l.length > 0 && !l.startsWith(';')).length;
80
+ }
81
+
82
+ async function runToExit(
83
+ bin: string,
84
+ args: string[],
85
+ env: NodeJS.ProcessEnv,
86
+ ): Promise<{ ok: boolean; error?: string; stdout: string }> {
87
+ return new Promise((resolve) => {
88
+ let stdout = '';
89
+ let stderr = '';
90
+ let child;
91
+ try {
92
+ child = spawn(bin, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
93
+ } catch (err: any) {
94
+ resolve({ ok: false, error: err?.message ?? String(err), stdout: '' });
95
+ return;
96
+ }
97
+ child.stdout?.on('data', (d) => { stdout += d.toString(); });
98
+ child.stderr?.on('data', (d) => { stderr += d.toString(); });
99
+ // ENOENT (binary not on PATH) arrives as an 'error' event, not a non-zero exit.
100
+ child.on('error', (err: any) => resolve({ ok: false, error: err?.message ?? String(err), stdout }));
101
+ child.on('close', (code) =>
102
+ resolve(code === 0 ? { ok: true, stdout } : { ok: false, error: `exited ${code}: ${stderr.trim()}`, stdout }));
103
+ });
104
+ }
105
+
106
+ export interface LocalDumpDeps {
107
+ /** The bare --database-url the apply targets — the same DB the rollback dump captures. */
108
+ url: string;
109
+ /** Absolute path to write the .dump to. */
110
+ file: string;
111
+ /** Binaries, injectable so the fail-closed paths (missing binary) are testable without unstubbing PATH. */
112
+ pgDumpBin?: string;
113
+ pgRestoreBin?: string;
114
+ /** Seam: report the written file's size. Defaults to fs.stat. */
115
+ fileBytes?: (file: string) => Promise<number>;
116
+ }
117
+
118
+ /**
119
+ * Take the local rollback dump and verify it, returning the file path as the rollback ref on success
120
+ * or a refusal on any failure. Never throws into the caller — every failure is a verdict.
121
+ */
122
+ export async function confirmLocalDump(
123
+ deps: LocalDumpDeps,
124
+ ): Promise<{ ok: true; ref: string } | { ok: false; reason: string }> {
125
+ const env = { ...process.env, ...pgEnvFromUrl(deps.url) };
126
+ const dumpBin = deps.pgDumpBin ?? 'pg_dump';
127
+ const restoreBin = deps.pgRestoreBin ?? 'pg_restore';
128
+
129
+ const dump = await runToExit(dumpBin, pgDumpToFileArgs(deps.file), env);
130
+ const fileBytes = dump.ok
131
+ ? await (deps.fileBytes ?? (async (f: string) => (await stat(f)).size.valueOf()))(deps.file).catch(() => 0)
132
+ : 0;
133
+ const list = dump.ok && fileBytes > 0
134
+ ? await runToExit(restoreBin, ['--list', deps.file], env)
135
+ : { ok: false as const, error: 'skipped (pg_dump did not produce a usable archive)', stdout: '' };
136
+
137
+ const verdict = interpretLocalDumpVerdict({
138
+ dumpOk: dump.ok,
139
+ ...(dump.error ? { dumpError: dump.error } : {}),
140
+ fileBytes,
141
+ listOk: list.ok,
142
+ ...(list.error ? { listError: list.error } : {}),
143
+ tocEntries: list.ok ? countTocEntries(list.stdout) : 0,
144
+ });
145
+ return verdict.ok ? { ok: true, ref: deps.file } : { ok: false, reason: verdict.reason };
146
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * promptYesNo — one line of interactive consent, default NO. Used where the tool is about to do
3
+ * something the operator did not explicitly ask for on the command line (db:apply's lane escalation).
4
+ * Callers MUST gate on `process.stdin.isTTY` first: a non-interactive session has no one to answer, so
5
+ * the safe path there is to refuse and name the explicit flag, not to call this.
6
+ */
7
+
8
+ import * as readline from 'node:readline';
9
+
10
+ export async function promptYesNo(question: string): Promise<boolean> {
11
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
12
+ try {
13
+ const answer: string = await new Promise((resolve) => rl.question(question, resolve));
14
+ return /^y(es)?$/i.test(answer.trim());
15
+ } finally {
16
+ rl.close();
17
+ }
18
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * confirmRollbackPoint — take (or account for) a durable rollback point before a destructive
3
+ * database operation, and do not return success until it EXISTS.
4
+ *
5
+ * This is the ceremony db:swap proved and db:apply now shares (apply-legibility Phase 2, Ty
6
+ * ratified 2026-08-26). The property both verbs need is identical: no destructive statement
7
+ * runs before a confirmed-durable rollback point. The old apply design took its OWN async
8
+ * snapshot and did not wait — destroyed data before the snapshot existed — and was removed
9
+ * (26ea947b) for exactly that. The fix is db:swap's pattern: decide the mode, take it, and
10
+ * WAIT for durability. This module is that pattern, verb-neutral, with its executors injected
11
+ * so the wait is provable without AWS.
12
+ *
13
+ * It confirms the point exists; it does NOT re-verify the live fingerprint after the wait —
14
+ * that is the CALLER's concurrency lock (the snapshot wait is a window, and the apply's own
15
+ * `live == plan.from` re-check after this returns is what closes it). Keeping the two separate
16
+ * is deliberate: this module answers "is there a rollback point", the lock answers "is the
17
+ * database still where the plan started".
18
+ */
19
+
20
+ import type { SnapshotDecision } from './swap-snapshot';
21
+
22
+ /** A confirmed, durable rollback point. */
23
+ export interface RollbackPoint {
24
+ mode: 'physical' | 'logical' | 'attested' | 'local' | 'none';
25
+ /** The snapshot/backup id, the attested ref, or the local dump's file path. Absent for `none`. */
26
+ ref?: string;
27
+ /** A logical backup stamps the live fingerprint at dump time — the caller checks it covers plan.from. */
28
+ stampedFingerprint?: string;
29
+ }
30
+
31
+ /**
32
+ * The executors, injected. Each wraps an already-tested primitive:
33
+ * - `confirmPhysical` wraps `confirmPhysicalSnapshot` (takes an RDS snapshot, waits for
34
+ * 'available', throws on failure/timeout naming the id).
35
+ * - `confirmLogical` wraps `runStageBackup` + `interpretBackupPoll` (dispatches db:backup and
36
+ * WAITS for the task to stop, verifying the artifact).
37
+ */
38
+ export interface ConfirmRollbackDeps {
39
+ confirmPhysical: (instanceId: string) => Promise<{ id: string }>;
40
+ confirmLogical: () => Promise<
41
+ | { ok: true; id: string; stampedFingerprint?: string; warning?: string }
42
+ | { ok: false; reason: string }
43
+ >;
44
+ /**
45
+ * Optional: fingerprint-verify an ATTESTED `--snapshot-ref` before it counts as the rollback point
46
+ * ("resolve-else-take" — reuse an existing backup only when it stamps plan.from). db:apply injects
47
+ * this to resolve the ref's backup manifest and run `verifySnapshotRef`; on success it returns the
48
+ * stamped fingerprint so the post-lock re-check ties the backup to live-at-lock too. When ABSENT
49
+ * (db:swap, whose attested ref may be a stampless physical RDS snapshot) an attested ref stays
50
+ * operator-trusted and the concurrency lock is its coverage.
51
+ */
52
+ verifyAttested?: (
53
+ ref: string,
54
+ ) => Promise<{ ok: true; stampedFingerprint?: string } | { ok: false; reason: string }>;
55
+ /**
56
+ * Optional: take our OWN local `pg_dump -Fc` and verify it (bare --database-url, db:apply only).
57
+ * Present only where `decideSnapshotMode` can return mode 'local' (db:apply passes localDump:true),
58
+ * so its absence when a 'local' decision arrives is a wiring bug and refuses rather than proceeding.
59
+ */
60
+ confirmLocal?: () => Promise<{ ok: true; ref: string } | { ok: false; reason: string }>;
61
+ log: (msg: string) => void;
62
+ warn: (msg: string) => void;
63
+ }
64
+
65
+ /**
66
+ * Produce a confirmed rollback point from a non-refusing SnapshotDecision, or return a refusal
67
+ * whose meaning is "there is no usable rollback point, so the caller must NOT proceed". Every
68
+ * branch either confirms durability or refuses; nothing returns a point that is merely in
69
+ * flight — that conflation is the defect this whole module exists to remove.
70
+ */
71
+ export async function confirmRollbackPoint(
72
+ decision: Exclude<SnapshotDecision, { mode: 'refuse' }>,
73
+ deps: ConfirmRollbackDeps,
74
+ ): Promise<{ ok: true; point: RollbackPoint } | { ok: false; reason: string }> {
75
+ switch (decision.mode) {
76
+ case 'attested': {
77
+ // An attested ref is a CLAIM until it is verified. Where a verifier is injected (db:apply), the
78
+ // claim must prove out — the backup exists, covers this stage, is fresh, and stamps plan.from —
79
+ // before it counts. A failure is a REFUSAL, not a fallback to trusting the operator's word: the
80
+ // escape is a fresh backup or a re-plan, never "proceed over an unverified snapshot".
81
+ if (deps.verifyAttested) {
82
+ const verified = await deps.verifyAttested(decision.ref);
83
+ if (!verified.ok) return { ok: false, reason: verified.reason };
84
+ deps.log(`rollback point: ${decision.ref} (attested via --snapshot-ref, fingerprint verified — no new snapshot taken).`);
85
+ return {
86
+ ok: true,
87
+ point: {
88
+ mode: 'attested',
89
+ ref: decision.ref,
90
+ ...(verified.stampedFingerprint ? { stampedFingerprint: verified.stampedFingerprint } : {}),
91
+ },
92
+ };
93
+ }
94
+ deps.log(`rollback point: ${decision.ref} (attested via --snapshot-ref — no new snapshot taken).`);
95
+ return { ok: true, point: { mode: 'attested', ref: decision.ref } };
96
+ }
97
+
98
+ case 'none':
99
+ deps.warn(
100
+ 'NO snapshot (--snapshot none) — there is no rollback point. If this operation lands bad ' +
101
+ 'data it cannot be undone from a snapshot.',
102
+ );
103
+ return { ok: true, point: { mode: 'none' } };
104
+
105
+ case 'physical': {
106
+ deps.log(`Taking a physical RDS snapshot of ${decision.instanceId} and waiting for it to become available...`);
107
+ try {
108
+ const { id } = await deps.confirmPhysical(decision.instanceId);
109
+ deps.log(`rollback point CONFIRMED: RDS snapshot ${id} is available.`);
110
+ return { ok: true, point: { mode: 'physical', ref: id } };
111
+ } catch (err: any) {
112
+ return { ok: false, reason: err?.message ?? String(err) };
113
+ }
114
+ }
115
+
116
+ case 'logical': {
117
+ deps.log('Taking a logical backup (db:backup) and waiting for the dump to finish...');
118
+ const backup = await deps.confirmLogical();
119
+ if (!backup.ok) return { ok: false, reason: backup.reason };
120
+ if (backup.warning) deps.warn(backup.warning);
121
+ deps.log(`rollback point CONFIRMED: backup ${backup.id} complete.`);
122
+ return {
123
+ ok: true,
124
+ point: { mode: 'logical', ref: backup.id, ...(backup.stampedFingerprint ? { stampedFingerprint: backup.stampedFingerprint } : {}) },
125
+ };
126
+ }
127
+
128
+ case 'local': {
129
+ if (!deps.confirmLocal) {
130
+ // A 'local' decision with no executor to run it is a wiring bug, not a rollback point. Refuse
131
+ // — the whole module exists so "no executor" never reads as "point on record".
132
+ return { ok: false, reason: 'a local dump rollback point was chosen but no executor was wired to take it (internal error).' };
133
+ }
134
+ deps.log('Taking a local pg_dump rollback point and verifying it (pg_restore --list)...');
135
+ const local = await deps.confirmLocal();
136
+ if (!local.ok) return { ok: false, reason: local.reason };
137
+ deps.log(`rollback point CONFIRMED: local dump ${local.ref} (restore with: pg_restore --clean --if-exists -d <url> ${local.ref}).`);
138
+ return { ok: true, point: { mode: 'local', ref: local.ref } };
139
+ }
140
+ }
141
+ }
@@ -324,6 +324,66 @@ export function fingerprintLive(
324
324
  return fingerprintState(snapshot, contract.tables, opts);
325
325
  }
326
326
 
327
+ // ---------------------------------------------------------------------------
328
+ // The retained-canon registry — recompute a live hash under a RECORDED version.
329
+ // ---------------------------------------------------------------------------
330
+
331
+ /**
332
+ * A frozen canonicalizer: the exact live-side hash produced by ONE historical
333
+ * FINGERPRINT_VERSION. `fingerprintState` above always emits the CURRENT form, so it is the
334
+ * retained entry for the current version only. When the canon bumps, the outgoing form does
335
+ * NOT disappear — it is frozen as its own function and registered under its number here.
336
+ */
337
+ export type CanonHasher = (
338
+ snapshot: SchemaSnapshot,
339
+ tables: TableContract[],
340
+ opts: CanonicalizeOptions,
341
+ ) => string;
342
+
343
+ /**
344
+ * Every canon this CLI can still recompute, keyed by its FINGERPRINT_VERSION.
345
+ *
346
+ * WHY THIS EXISTS — the descent strand. `db:apply`'s fast-forward proof reads the
347
+ * `schema_log` ledger and compares each row's recorded `to_fingerprint` against the live
348
+ * state. A row's hash was computed under whatever canon was current at that apply, recorded
349
+ * as its `fp_version`. When the CLI later bumps the canon, comparing that old-format hash
350
+ * against a current-format live hash reports a phantom mismatch — the very kind of false
351
+ * refusal the ledger exists to prevent. So descent recomputes the LIVE side under the ROW'S
352
+ * canon, through this registry.
353
+ *
354
+ * THE STANDING RULE, so the strand cannot recur one bump later: when you increment
355
+ * FINGERPRINT_VERSION in this file, you MUST freeze the outgoing canon as a pure function and
356
+ * register it here under its (now retired) number, alongside the new current one. The frozen
357
+ * function is a self-contained copy of the canonicalization logic as it stood — it can never
358
+ * call back into the live `canonicalizeState`, which always emits the new form. A version
359
+ * absent from this map is unrecomputable: `fingerprintLiveAtVersion` returns null and descent
360
+ * SKIPS that row (fails closed), never guesses a hash.
361
+ *
362
+ * Today the map holds exactly the current version — there is nothing retired yet. It is the
363
+ * mechanism and the rule that ship now; the first retired entry lands with the first bump.
364
+ */
365
+ export const RETAINED_CANONS: Readonly<Record<number, CanonHasher>> = {
366
+ [FINGERPRINT_VERSION]: (snapshot, tables, opts) => fingerprintState(snapshot, tables, opts).hash,
367
+ };
368
+
369
+ /**
370
+ * The raw live fingerprint recomputed under a RECORDED canon version, or null when that
371
+ * version is not retained (unknown, future, or pre-fp_version). Null is the fail-closed
372
+ * answer: a descent row whose canon we cannot reproduce is skipped, never matched on a hash
373
+ * computed under the wrong form.
374
+ */
375
+ export function fingerprintLiveAtVersion(
376
+ version: number | null | undefined,
377
+ snapshot: SchemaSnapshot,
378
+ contract: AuthzContract,
379
+ opts: CanonicalizeOptions = {},
380
+ canons: Readonly<Record<number, CanonHasher>> = RETAINED_CANONS,
381
+ ): string | null {
382
+ if (version == null) return null;
383
+ const canon = canons[version];
384
+ return canon ? canon(snapshot, contract.tables, opts) : null;
385
+ }
386
+
327
387
  // ---------------------------------------------------------------------------
328
388
  // The honesty report: what the base fingerprint does NOT see.
329
389
  // ---------------------------------------------------------------------------
@@ -0,0 +1,70 @@
1
+ /**
2
+ * stageConfirmDeps — the AWS-glue executors confirmRollbackPoint needs on a STAGE venue, built
3
+ * once and shared by db:swap and db:apply (apply-legibility Phase 2).
4
+ *
5
+ * rollback-point.ts stays pure — it takes `confirmPhysical` / `confirmLogical` injected so its
6
+ * wait logic is provable without AWS. This is where those two are wired to the real calls
7
+ * (RDS snapshot + the db:backup Task), so neither destructive verb hand-rolls its own copy.
8
+ * `verb` only tunes the log wording ('swap' vs 'apply').
9
+ */
10
+
11
+ import { createRdsSnapshot, describeRdsSnapshots } from './aws';
12
+ import { rdsSnapshotIdentifier } from './rds-snapshot';
13
+ import { utcStamp } from './backup';
14
+ import { pollTaskUntilStopped } from './task-poll';
15
+ import { confirmPhysicalSnapshot, interpretBackupPoll } from './swap-snapshot';
16
+ import { runStageBackup, stageBackupDeps } from './stage-backup';
17
+ import type { ConfirmRollbackDeps } from './rollback-point';
18
+
19
+ export interface StageConfirmContext {
20
+ region: string;
21
+ /** `config.databaseInstanceId` — 'placeholder'/undefined means no physical snapshot is possible. */
22
+ instanceId?: string;
23
+ opsFn: string;
24
+ stage?: string;
25
+ /** The verb, for log wording only. */
26
+ verb: 'swap' | 'apply';
27
+ log: (msg: string) => void;
28
+ step: (msg: string) => void;
29
+ }
30
+
31
+ /** The `confirmPhysical` / `confirmLogical` half of ConfirmRollbackDeps for a stage venue. */
32
+ export function stageConfirmDeps(ctx: StageConfirmContext): Pick<ConfirmRollbackDeps, 'confirmPhysical' | 'confirmLogical'> {
33
+ return {
34
+ confirmPhysical: async (instanceId) => {
35
+ ctx.step(`Snapshotting the instance before the ${ctx.verb} (RDS physical snapshot of ${instanceId})...`);
36
+ const snapshotId = rdsSnapshotIdentifier(`${ctx.stage ?? ctx.verb}-${ctx.verb}`, utcStamp(new Date()));
37
+ return confirmPhysicalSnapshot(
38
+ {
39
+ create: (sid) => createRdsSnapshot(ctx.region, instanceId, sid),
40
+ describe: () => describeRdsSnapshots(ctx.region, instanceId),
41
+ log: (m) => ctx.log(m),
42
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
43
+ now: () => Date.now(),
44
+ },
45
+ { instanceId, snapshotId },
46
+ );
47
+ },
48
+ // Logical: dispatch the db:backup Task and WAIT. The dispatch returning is not the backup
49
+ // existing — that conflation is what let a pg_dump run concurrently with the operation it
50
+ // was meant to precede.
51
+ confirmLogical: async () => {
52
+ ctx.step(`Snapshotting the stage before the ${ctx.verb} (db:backup — waiting for the dump to finish)...`);
53
+ const backup = await runStageBackup({
54
+ ...stageBackupDeps({ region: ctx.region }, ctx.opsFn, ctx.stage, {
55
+ onStep: ctx.step,
56
+ onDispatch: (d: { id: string; runId: string }) =>
57
+ ctx.log(`backup ${d.id} dispatched (run ${d.runId}) — waiting for the task to stop before the ${ctx.verb} starts.`),
58
+ }),
59
+ awaitTask: async (ids) => {
60
+ const poll = await pollTaskUntilStopped(ctx.region, ctx.opsFn, { runId: ids.runId, taskArn: ids.taskArn });
61
+ const verdict = interpretBackupPoll(poll, { runId: ids.runId, id: ids.id });
62
+ if (!verdict.ok) return verdict;
63
+ return { ok: true, stampedFingerprint: (poll as any).status?.result?.fingerprint as string | undefined };
64
+ },
65
+ });
66
+ if (!backup.ok) return backup;
67
+ return { ok: true, id: backup.id, stampedFingerprint: backup.fingerprint, warning: backup.warning };
68
+ },
69
+ };
70
+ }
@@ -17,7 +17,7 @@
17
17
  * named state at both ends" — but nothing here needs a folder.
18
18
  */
19
19
 
20
- import { execSync } from 'node:child_process';
20
+ import { execSync, execFileSync } from 'node:child_process';
21
21
  import type { ModelDescriptor } from '@everystack/model';
22
22
  import { introspectContract, type AuthzContract, type QueryRunner } from './authz-contract';
23
23
  import type { SessionRunner } from './session';
@@ -25,7 +25,7 @@ import { introspectSchema, type SchemaSnapshot } from './schema-introspect';
25
25
  import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog';
26
26
  import { generateMigrationSql, HELD_DROP_PREFIX } from './migration-generate';
27
27
  import type { SequenceDescriptor } from '@everystack/model';
28
- import { fingerprintLive } from './schema-fingerprint';
28
+ import { fingerprintLive, FINGERPRINT_VERSION } from './schema-fingerprint';
29
29
  import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
30
30
 
31
31
  const NOTICE_PREFIX = '-- NOTICE';
@@ -276,14 +276,49 @@ export function currentGitRef(): string | null {
276
276
  }
277
277
  }
278
278
 
279
+ /**
280
+ * True when the models the apply ran against differ from HEAD — a dirty mint.
281
+ *
282
+ * schema_log records HEAD (`currentGitRef`) as the commit that declares the applied state. That
283
+ * pointer is only honest if HEAD's committed models ARE what was applied. If the operator
284
+ * applied from a worktree with uncommitted model changes, HEAD does not declare plan.to — so
285
+ * the fast-forward proof must ignore the row (it treats a dirty row like a null git_ref).
286
+ * Scoped to the models path, so an unrelated dirty file (a README) does not disqualify the row.
287
+ * Not a git checkout → not dirty (git_ref is null there anyway).
288
+ */
289
+ export function worktreeDirty(modelsPath: string, cwd: string = process.cwd()): boolean {
290
+ try {
291
+ const out = execFileSync('git', ['status', '--porcelain', '--', modelsPath], {
292
+ cwd,
293
+ stdio: ['ignore', 'pipe', 'ignore'],
294
+ }).toString();
295
+ return out.trim().length > 0;
296
+ } catch {
297
+ return false;
298
+ }
299
+ }
300
+
279
301
  export interface StateApplyOptions {
280
302
  actor?: string | null;
281
303
  gitRef?: string | null;
282
304
  planRef?: string | null;
283
305
  /** The live base fingerprint before this apply (schema_log's from-side). */
284
306
  fromFingerprint?: string | null;
307
+ /** The canon the fingerprints were computed under. Defaults to the current FINGERPRINT_VERSION
308
+ * — the hashes this writer records are always current-canon — so the descent witness carries
309
+ * the version needed to recompute across a later bump. */
310
+ fpVersion?: number | null;
311
+ /** The apply ran from a dirty worktree (models differed from HEAD). The recorded git_ref then
312
+ * does not declare what was applied, so descent ignores the row. */
313
+ dirty?: boolean;
285
314
  /** Injectable clock for tests. */
286
315
  now?: () => number;
316
+ /**
317
+ * When the fast-forward rule was waived by a `--attest-lineage-lost` attestation, a short note read
318
+ * at WRITE time (a getter, because the attestation is decided in the descent step that runs before
319
+ * this) and folded into the `applied` schema_log outcome — the durable audit of a deliberate waiver.
320
+ */
321
+ descentNote?: () => string | undefined;
287
322
  }
288
323
 
289
324
  export interface StateApplyResult {
@@ -327,16 +362,19 @@ export async function applyGeneratedStatements(
327
362
  outcome: `failed: ${String(err?.message ?? err).slice(0, 500)}`,
328
363
  actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
329
364
  fromFingerprint: options.fromFingerprint,
365
+ fpVersion: options.fpVersion ?? FINGERPRINT_VERSION, dirty: options.dirty ?? false,
330
366
  durationMs: now() - started,
331
367
  }));
332
368
  } catch { /* the memoir is best-effort on failure */ }
333
369
  throw err;
334
370
  }
335
371
 
372
+ const note = options.descentNote?.();
336
373
  const logRows = await runner(renderSchemaLogInsert({
337
- kind: 'state apply', sql: batchSql, outcome: 'applied',
374
+ kind: 'state apply', sql: batchSql, outcome: note ? `applied — ${note}` : 'applied',
338
375
  actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
339
376
  fromFingerprint: options.fromFingerprint,
377
+ fpVersion: options.fpVersion ?? FINGERPRINT_VERSION, dirty: options.dirty ?? false,
340
378
  durationMs: now() - started,
341
379
  }));
342
380
  const logId = logRows?.[0]?.id !== undefined ? Number(logRows[0].id) : undefined;
@@ -33,6 +33,8 @@ export type SnapshotDecision =
33
33
  | { mode: 'logical' }
34
34
  /** The operator already took one and named it — nothing to do but record it. */
35
35
  | { mode: 'attested'; ref: string }
36
+ /** Take our OWN local `pg_dump -Fc` and verify it (bare --database-url, db:apply only). */
37
+ | { mode: 'local' }
36
38
  /** The operator explicitly accepted no rollback point. */
37
39
  | { mode: 'none' }
38
40
  /** Nothing safe is available and no consent was given — refuse before anything moves. */
@@ -51,6 +53,13 @@ export interface SnapshotDecisionInput {
51
53
  snapshotRef?: string;
52
54
  /** `--snapshot <physical|logical|none>`. */
53
55
  requested?: SnapshotModeRequest;
56
+ /**
57
+ * Bare-URL only: may the tool take its OWN local `pg_dump -Fc` as the rollback point? db:apply
58
+ * passes true (REVERSAL 1 — bare-URL apply takes+verifies a local dump); db:swap leaves it false, so
59
+ * a bare-URL swap still REFUSES without an attested ref (a multi-GB schema restore is not a local
60
+ * pg_dump's job). Default false keeps the shared decision unchanged for db:swap.
61
+ */
62
+ localDump?: boolean;
54
63
  }
55
64
 
56
65
  /** Is this instance id something we can actually snapshot, or an absent/placeholder output? */
@@ -73,7 +82,7 @@ function usableInstanceId(id?: string): id is string {
73
82
  * is optional in practice.
74
83
  */
75
84
  export function decideSnapshotMode(input: SnapshotDecisionInput): SnapshotDecision {
76
- const { venue, instanceId, snapshotRef, requested } = input;
85
+ const { venue, instanceId, snapshotRef, requested, localDump } = input;
77
86
 
78
87
  if (requested !== undefined && !SNAPSHOT_MODES.includes(requested)) {
79
88
  return {
@@ -113,6 +122,11 @@ export function decideSnapshotMode(input: SnapshotDecisionInput): SnapshotDecisi
113
122
 
114
123
  if (requested === 'logical') {
115
124
  if (venue === 'url') {
125
+ // On the venue that CAN take its own local dump (db:apply), an explicit `--snapshot logical`
126
+ // means exactly that — a pg_dump rollback point — so honour it as the local dump. Where the
127
+ // local dump is not offered (db:swap), the stage's Task lane is the only logical backup and a
128
+ // bare URL has no stage, so refuse.
129
+ if (localDump) return { mode: 'local' };
116
130
  return {
117
131
  mode: 'refuse',
118
132
  reason: 'A logical snapshot runs in the stage\'s Task lane via the ops Lambda, and a bare --database-url has no stage behind it. '
@@ -127,6 +141,9 @@ export function decideSnapshotMode(input: SnapshotDecisionInput): SnapshotDecisi
127
141
  if (venue === 'stage') {
128
142
  return usableInstanceId(instanceId) ? { mode: 'physical', instanceId } : { mode: 'logical' };
129
143
  }
144
+ // Bare --database-url. Where the tool takes its own local dump (db:apply), that is the default; where
145
+ // it does not (db:swap), a destructive run over a bare URL still refuses without an attested ref.
146
+ if (localDump) return { mode: 'local' };
130
147
  return {
131
148
  mode: 'refuse',
132
149
  reason: 'db:swap over a bare --database-url does not take a snapshot for you, and it will not run destructively without one. '