@everystack/cli 0.4.75 → 0.4.77

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 CHANGED
@@ -4,6 +4,31 @@ 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.77] - 2026-09-07
8
+
9
+ ### Added
10
+
11
+ - proven drift is the operator's decision — passes at apply with its origin recorded
12
+ - proven drift is announced as the origin on both lanes; refusals name a verb that works
13
+
14
+ ### Fixed
15
+
16
+ - currency is ref reachability, not object presence; a failed all-refs walk is unverifiable
17
+ - an empty barrel declares nothing; a non-commit tag is not 'behind'; tests bind the production decision
18
+ - one shipped descent step for both lanes, bound end to end by the real CLI; trees materialize inside the project
19
+ - the sandbox sits below the nearest node_modules above the barrel; a dirty-worktree origin reaches every surface
20
+ - the compile-failure detail keeps the cause; the monorepo test skips loudly; the stash filter sits on the refuse-closed side
21
+
22
+ ### Documentation
23
+
24
+ - proven drift, declared-elsewhere, the adoption lifecycle table, and a db:adopt section
25
+
26
+ ## [0.4.76] - 2026-09-07
27
+
28
+ ### Fixed
29
+
30
+ - ship @aws-sdk/client-rds as a real dependency (db:snapshot)
31
+
7
32
  ## [0.4.75] - 2026-09-06
8
33
 
9
34
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.75",
3
+ "version": "0.4.77",
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>",
@@ -117,6 +117,7 @@
117
117
  "@aws-sdk/client-cloudfront-keyvaluestore": "3.1053.0",
118
118
  "@aws-sdk/client-cloudwatch-logs": "3.1053.0",
119
119
  "@aws-sdk/client-lambda": "3.1053.0",
120
+ "@aws-sdk/client-rds": "3.1053.0",
120
121
  "@aws-sdk/client-s3": "3.1053.0",
121
122
  "@aws-sdk/client-ssm": "3.1053.0",
122
123
  "@aws-sdk/lib-storage": "3.1053.0",
@@ -132,7 +133,6 @@
132
133
  "peerDependencies": {
133
134
  "@everystack/server": ">=0.4.0",
134
135
  "@aws-sdk/client-cloudwatch": "3.1053.0",
135
- "@aws-sdk/client-rds": "3.1053.0",
136
136
  "@aws-sdk/s3-request-presigner": "3.1053.0",
137
137
  "drizzle-orm": "0.41.0",
138
138
  "expo-updates": "55.0.21",
@@ -150,9 +150,6 @@
150
150
  "@aws-sdk/client-cloudwatch": {
151
151
  "optional": true
152
152
  },
153
- "@aws-sdk/client-rds": {
154
- "optional": true
155
- },
156
153
  "expo-updates": {
157
154
  "optional": true
158
155
  },
@@ -168,7 +165,6 @@
168
165
  },
169
166
  "devDependencies": {
170
167
  "@aws-sdk/client-cloudwatch": "3.1053.0",
171
- "@aws-sdk/client-rds": "3.1053.0",
172
168
  "@aws-sdk/s3-request-presigner": "3.1053.0",
173
169
  "@types/jest": "29.5.14",
174
170
  "@types/node": "22.19.18",
@@ -131,9 +131,11 @@ export interface ApplyPlanOptions {
131
131
  /** Injectable clock for tests. */
132
132
  now?: () => number;
133
133
  /**
134
- * A note for the schema_log outcome when the fast-forward rule was waived by a
135
- * `--attest-lineage-lost` attestation. A getter, because the attestation is decided inside
136
- * `checkDescent` (which runs before the write) — the getter reads that decision at write time.
134
+ * A note for the schema_log outcome (`applied — <note>`) carrying what descent decided: a
135
+ * honoured `--attest-lineage-lost` waiver, or a PROVEN DRIFT origin (`descent: drift-origin
136
+ * proven (never-declared | hand-edited since … | applied from a DIRTY worktree at …)`) — the record that makes this row the origin
137
+ * every later apply descends from. A getter, because the decision is made inside `checkDescent`
138
+ * (which runs before the write) — the getter reads it at write time.
137
139
  */
138
140
  descentNote?: () => string | undefined;
139
141
  }
@@ -205,10 +207,16 @@ export interface LiveBaseAndLedger extends LiveBaseFingerprint {
205
207
  * The newest successful state applies — the descent witnesses. Read alongside the live state
206
208
  * (allowFailure, because a database that never applied through this CLI has no schema_log
207
209
  * yet). Newest-first, capped: the proof consults the newest usable row and stops.
210
+ *
211
+ * `applied%`, not `= 'applied'`: an apply that carried a descent note (an honoured lineage
212
+ * attestation, or a proven drift origin) is recorded as `applied — <note>`, and it is STILL a
213
+ * successful apply — the row that proves the next fast-forward. Matching the bare word only
214
+ * silently dropped every annotated apply from the ledger, which for a drift origin defeats the
215
+ * entire point of recording it.
208
216
  */
209
217
  export const SCHEMA_LOG_WITNESS_SQL = `SELECT id, to_fingerprint, git_ref, fp_version, dirty, applied_at, outcome
210
218
  FROM everystack.schema_log
211
- WHERE kind = 'state apply' AND outcome = 'applied' AND to_fingerprint IS NOT NULL
219
+ WHERE kind = 'state apply' AND outcome LIKE 'applied%' AND to_fingerprint IS NOT NULL
212
220
  ORDER BY id DESC
213
221
  LIMIT 200`;
214
222
 
@@ -34,7 +34,7 @@ export function snapshotMovedRefusal(ref: string, captured: string, planFrom: st
34
34
  // The USER-FACING refusal message (apply-legibility Phase 3, p3-action-refusals).
35
35
  //
36
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,
37
+ // reason string is already action-phrased at its source (checkPlanPrecondition, decideDescentApply,
38
38
  // checkDestructiveAuthority, the snapshot gate, the baseline gate). This layer only marks the OUTCOME
39
39
  // and forwards that reason — it does NOT re-label the check the way the old
40
40
  // `REFUSED (fast-forward rule):` / `(snapshot gate)` / `(destructive authority)` wording did.
@@ -127,7 +127,7 @@ export interface VestigialSequenceGrant {
127
127
  }
128
128
 
129
129
  /**
130
- * Sequence grants with no corresponding INSERT — an ADVISORY, never a revoke.
130
+ * Sequence grants with no corresponding INSERT — the db:pull ADVISORY.
131
131
  *
132
132
  * The shape a brownfield database arrives in: a blanket `GRANT USAGE ON ALL SEQUENCES IN
133
133
  * SCHEMA public` sitting beside a blanket `GRANT SELECT ON ALL TABLES`, years old, granted
@@ -135,11 +135,13 @@ export interface VestigialSequenceGrant {
135
135
  * holding USAGE on all 27 sequences with INSERT on zero tables — and it was the single
136
136
  * divergence between their database and the inheritance rule.
137
137
  *
138
- * Reported rather than revoked, and the distinction is the whole point: the inheritance rule
139
- * only ever ADDS what an INSERT needs, so a vestigial grant is untouched by reconciliation and
140
- * would otherwise sit there unnoticed forever. Naming it lets the owner decide. Revoking it
141
- * would be the tool making a security decision from an inference — the same move we refused for
142
- * the invented search_path.
138
+ * What the reconciler does with it depends on the grantee, and the advisory must say so
139
+ * exactly (it once said "nothing revokes it — revoke by hand", which sent an operator to a
140
+ * credential for an edge the tool itself mints): a GOVERNED grantee (anon / authenticated /
141
+ * admin / PUBLIC, or a declared governedRole) has its vestigial grant REVOKED by the next
142
+ * plan, as an authz removal — the Models cannot declare a sequence grant with no INSERT
143
+ * behind it, so the plan converges the database on the inheritance rule. An UNGOVERNED
144
+ * grantee is left alone (reconcileSequenceGrants skips it) and stays on this list every pull.
143
145
  */
144
146
  export function vestigialSequenceGrants(live: AuthzContract): VestigialSequenceGrant[] {
145
147
  if (!live.sequenceGrants) return []; // not measured
@@ -163,12 +165,24 @@ export function vestigialSequenceGrants(live: AuthzContract): VestigialSequenceG
163
165
  .sort((a, b) => a.grantee.localeCompare(b.grantee));
164
166
  }
165
167
 
166
- /** One line per grantee — the shared rendering, same idiom as renderGrantExemptions. */
167
- export function renderVestigialSequenceGrants(findings: readonly VestigialSequenceGrant[]): string[] {
168
- return findings.map(({ grantee, sequences }) =>
169
- `${grantee} holds privileges on ${sequences.length} sequence(s) but can INSERT into none of their tables — `
170
- + `probably a vestigial blanket GRANT ... ON ALL SEQUENCES. Nothing revokes it (the inheritance rule only adds); `
171
- + `review and revoke by hand if it is dead: ${sequences.slice(0, 6).join(', ')}${sequences.length > 6 ? `, +${sequences.length - 6} more` : ''}.`);
168
+ /**
169
+ * One line per grantee — the shared rendering, same idiom as renderGrantExemptions. The line
170
+ * names the verb that acts on it: a governed grantee's vestigial grant is an authz removal in
171
+ * the next db:plan; an ungoverned one is left alone.
172
+ */
173
+ export function renderVestigialSequenceGrants(
174
+ findings: readonly VestigialSequenceGrant[],
175
+ /** REQUIRED: the same governed set the next db:plan will use (the vocabulary plus any
176
+ * --govern-roles / declared governedRoles). A default here reproduced the bug this fixes. */
177
+ governed: ReadonlySet<string>,
178
+ ): string[] {
179
+ return findings.map(({ grantee, sequences }) => {
180
+ const list = `${sequences.slice(0, 6).join(', ')}${sequences.length > 6 ? `, +${sequences.length - 6} more` : ''}`;
181
+ const head = `${grantee} holds privileges on ${sequences.length} sequence(s) but can INSERT into none of their tables — probably a vestigial blanket GRANT ... ON ALL SEQUENCES.`;
182
+ return isGoverned(governed, grantee)
183
+ ? `${head} ${grantee} is governed, so the next db:plan REVOKES it (an authz removal — the Models cannot declare a sequence grant with no INSERT behind it): ${list}.`
184
+ : `${head} ${grantee} is not governed, so nothing revokes it; declare it in governedRoles to bring it under the plan, or leave it: ${list}.`;
185
+ });
172
186
  }
173
187
 
174
188
  /** A USING/WITH CHECK clause expression, parenthesized exactly once. */
@@ -5,8 +5,7 @@
5
5
  * [--govern-roles r1,r2]
6
6
  *
7
7
  * The problem it solves: a production database that never went through Models has no committed
8
- * ancestor declaring it, so db:apply refuses it as unverifiable drift — and no waiver fits (there
9
- * is nothing to attest a lineage FROM). db:adopt is the deliberate, auditable on-ramp. It renders
8
+ * ancestor declaring it. db:adopt is the deliberate, auditable on-ramp for taking it AS IT IS: it renders
10
9
  * Models from the live database (the same renderers db:pull uses), compiles them back, and PROVES
11
10
  * the compiled Models declare the live state EXACTLY — predicted == governed live, the same
12
11
  * declares-test descent and db:check use. Only then does it write the model files and print the
@@ -15,8 +14,11 @@
15
14
  *
16
15
  * The doctrine (Fable-ruled 2026-09-05): PROVE, not attest. There is NO bypass flag. When the
17
16
  * round-trip is not exact, adoption REFUSES, names the surfaces that diverged, and prints the
18
- * db:generate edge — because a baseline that papered over a difference is the bypass under another
19
- * name. It writes NOTHING on refusal; --out is left untouched.
17
+ * edge — because a baseline that papered over a difference is the bypass under another name. It
18
+ * writes NOTHING on refusal; --out is left untouched. That refusal is the HARDENING case (the
19
+ * vocabulary cannot declare something live), and it needs no adoption commit: commit the intended
20
+ * Models, db:plan, db:apply — descent proves no commit declares the database and treats the
21
+ * reviewed plan as the origin (git-descent.ts, proven drift, 2026-09-07).
20
22
  *
21
23
  * The pure proof core is adopt.ts (proveAdoption); the shared render is live-models.ts
22
24
  * (renderLiveModelFiles). This shell is the IO: connect, render into a sandbox to compile, prove,
@@ -123,7 +125,10 @@ export async function applyAdoptVerdict(result: AdoptResult, outDir: string): Pr
123
125
  for (const stmt of result.refusalDiff) console.log(stmt.endsWith(';') ? stmt : `${stmt};`);
124
126
  }
125
127
  console.log('');
126
- info('The renderer could not yet capture the surfaces above. Refine by hand until db:generate is empty, then commit the Models — a later db:apply will descend normally.');
128
+ info('The Models vocabulary cannot declare the surfaces above as they are live (a sequence grant with no INSERT behind it, for example), so no render can round-trip this database. That is the HARDENING case, and it needs no adoption commit: commit the Models you intend, then');
129
+ info(' everystack db:plan --stage <name> # mints the edge above as a reviewable plan');
130
+ info(' everystack db:apply --plan <file.plan.json> --stage <name> # credential-free; no commit declares this database yet, so the reviewed plan is the ORIGIN and is recorded as such');
131
+ info('Every later db:apply descends from that recorded origin.');
127
132
  return { exitCode: 1, wrote: false };
128
133
  }
129
134
 
@@ -36,11 +36,14 @@ import { planHash, PLAN_VERSION, APPLY_CONTRACT_VERSION, type EdgePlan } from '.
36
36
  import {
37
37
  proveDescent,
38
38
  decideDescentApply,
39
+ describeDriftOrigin,
39
40
  attestationNote,
40
41
  proveLineageLost,
41
42
  realLineageProbe,
42
43
  type DescentLedgerRow,
43
44
  type LiveState,
45
+ type ProveVerdict,
46
+ type ProveDescentOptions,
44
47
  } from '../git-descent';
45
48
  import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity, type AuthorityVerdict } from '../apply-authority';
46
49
  import { decideSnapshotMode, type SnapshotModeRequest } from '../swap-snapshot';
@@ -108,6 +111,82 @@ export function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void
108
111
  }
109
112
  }
110
113
 
114
+ /**
115
+ * Say what descent found, on BOTH lanes identically. Exported so a test can pin the one line
116
+ * that matters most: proven drift is announced as the ORIGIN, loudly, before anything writes.
117
+ */
118
+ export function describeDescentVerdict(verdict: ProveVerdict, opts: { destructive?: boolean } = {}): { level: 'info' | 'warn'; text: string } | null {
119
+ switch (verdict.status) {
120
+ case 'proven':
121
+ return { level: 'info', text: `descent: proven via ${verdict.via} — ${verdict.commit.slice(0, 12)} is an ancestor of HEAD, fast-forward.` };
122
+ case 'fresh-target':
123
+ return { level: 'info', text: 'descent: fresh target (no tables) — nothing to protect, bootstrapping.' };
124
+ case 'no-git':
125
+ // On a destructive plan the decision refuses with its own message; do not double-report.
126
+ return opts.destructive ? null : { level: 'warn', text: 'descent: not a git checkout — the fast-forward rule cannot be verified here.' };
127
+ case 'drift':
128
+ return {
129
+ level: 'warn',
130
+ 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.`,
131
+ };
132
+ case 'declared-elsewhere':
133
+ case 'revert':
134
+ case 'unknown-commit':
135
+ case 'unverifiable':
136
+ return null; // the decision refuses with the verdict's own reason
137
+ }
138
+ }
139
+
140
+ function reportDescentVerdict(verdict: ProveVerdict, opts: { destructive?: boolean } = {}): void {
141
+ const line = describeDescentVerdict(verdict, opts);
142
+ if (!line) return;
143
+ (line.level === 'warn' ? warn : info)(line.text);
144
+ }
145
+
146
+ /**
147
+ * THE descent step, shared by both lanes: prove the fast-forward rule over the live state and the
148
+ * ledger, report the verdict, decide it (attestation honoured only where proven), and hand back
149
+ * the wire/record shape — `descentVerdict` for the gate and `descentAttested` for the schema_log
150
+ * outcome (`applied — <note>`): a honoured lineage-lost waiver, or a PROVEN DRIFT origin.
151
+ *
152
+ * One function so the origin record cannot be dropped on one lane and kept on the other, and so
153
+ * a test can drive the SHIPPED wiring without AWS (the stage lane passes in the state it double-
154
+ * read and the ledger it fetched; the direct lane passes what executeApplyPlan read in-session).
155
+ * The closing-gate review found the note assignment unbound at both call sites — this is the bind.
156
+ */
157
+ export async function proveDescentForApply(input: {
158
+ modelsPath: string;
159
+ live: LiveState;
160
+ ledger: DescentLedgerRow[];
161
+ destructive: boolean;
162
+ attestation?: string;
163
+ /** Where git runs. Default process.cwd(). */
164
+ cwd?: string;
165
+ /** Test seam: the barrel loader and materialize root (jest cannot dynamic-import a .ts barrel).
166
+ * Production passes nothing and rides the real loader. */
167
+ descent?: Pick<ProveDescentOptions, 'loader' | 'materializeRoot'>;
168
+ }): Promise<{ descentVerdict: { ok: true } | { ok: false; reason: string }; descentAttested?: string }> {
169
+ const verdict = await proveDescent(input.modelsPath, input.live, input.ledger, {
170
+ ...(input.cwd ? { cwd: input.cwd } : {}),
171
+ ...(input.descent ?? {}),
172
+ });
173
+ reportDescentVerdict(verdict, { destructive: input.destructive });
174
+ const decision = decideDescentApply(verdict, {
175
+ ...(input.attestation ? { attestation: input.attestation } : {}),
176
+ destructive: input.destructive,
177
+ proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(input.cwd ?? process.cwd())),
178
+ });
179
+ if (!decision.ok) return { descentVerdict: { ok: false, reason: decision.reason } };
180
+ if (decision.attested) {
181
+ const descentAttested = attestationNote(decision.attested);
182
+ warn(`DESCENT ATTESTED — ${descentAttested}. The declaring commit is proven gone from every ref; the fast-forward rule is waived for this apply and the waiver is recorded.`);
183
+ return { descentVerdict: { ok: true }, descentAttested };
184
+ }
185
+ // A proven-drift origin rides the same recorded field as a waiver — it is the same kind of
186
+ // record: what descent decided, and why this row is the one later applies descend from.
187
+ return { descentVerdict: { ok: true }, ...(decision.note ? { descentAttested: decision.note } : {}) };
188
+ }
189
+
111
190
  /**
112
191
  * db:apply --stage: the credential-free staged write. The operator never holds
113
192
  * a database URL — the WRITE runs in the ops Lambda's db:apply action against
@@ -180,32 +259,17 @@ async function applyPlanViaStage(
180
259
  // git-native (git revert / db:generate / fetch); the one attestation
181
260
  // (--attest-lineage-lost) is honoured only when the CLI proves the declaring
182
261
  // commit is gone from every ref.
183
- let descentVerdict: { ok: true } | { ok: false; reason: string };
184
- let descentAttested: string | undefined;
185
- {
186
- const { snapshot, contract } = readPair.state;
187
- step("Descent: reading the stage's schema_log ledger...");
188
- const ledger = await readDescentLedger(lambdaSessionRunner(region, fn));
189
- step("Descent: proving the fast-forward rule against the ledger...");
190
- const verdict = await proveDescent(resolveModelsPath(flags.models), { snapshot, contract }, ledger, {});
191
- if (verdict.status === 'proven') {
192
- info(`descent: proven via ${verdict.via} — ${verdict.commit.slice(0, 12)} is an ancestor of HEAD, fast-forward.`);
193
- } else if (verdict.status === 'fresh-target') {
194
- info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
195
- } else if (verdict.status === 'no-git') {
196
- warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
197
- }
198
- const decision = decideDescentApply(verdict, {
199
- ...(attestLineageLost ? { attestation: attestLineageLost } : {}),
200
- destructive: plan.destructive > 0,
201
- proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(process.cwd())),
202
- });
203
- if (decision.ok && decision.attested) {
204
- descentAttested = attestationNote(decision.attested);
205
- warn(`DESCENT ATTESTED — ${descentAttested}. The declaring commit is proven gone from every ref; the fast-forward rule is waived for this apply and the waiver is recorded.`);
206
- }
207
- descentVerdict = decision.ok ? { ok: true } : { ok: false, reason: decision.reason };
208
- }
262
+ const { snapshot, contract } = readPair.state;
263
+ step("Descent: reading the stage's schema_log ledger...");
264
+ const ledger = await readDescentLedger(lambdaSessionRunner(region, fn));
265
+ step("Descent: proving the fast-forward rule against the ledger...");
266
+ const { descentVerdict, descentAttested } = await proveDescentForApply({
267
+ modelsPath: resolveModelsPath(flags.models),
268
+ live: { snapshot, contract },
269
+ ledger,
270
+ destructive: plan.destructive > 0,
271
+ ...(attestLineageLost ? { attestation: attestLineageLost } : {}),
272
+ });
209
273
 
210
274
  // Destructive ceremony — the approver check is against the caller's REAL
211
275
  // IAM identity (STS), which the Lambda cannot see, so the CLI resolves it
@@ -571,24 +635,15 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
571
635
  descentNote: () => descentAttested,
572
636
  checkDescent: async (live: LiveState & { fingerprint: string; ledger: DescentLedgerRow[] }) => {
573
637
  step('Descent: proving the fast-forward rule against the schema_log ledger...');
574
- const verdict = await proveDescent(resolveModelsPath(flags.models), live, live.ledger, {});
575
- if (verdict.status === 'proven') {
576
- info(`descent: proven via ${verdict.via} — ${verdict.commit.slice(0, 12)} is an ancestor of HEAD, fast-forward.`);
577
- } else if (verdict.status === 'fresh-target') {
578
- info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
579
- } else if (verdict.status === 'no-git' && plan.destructive === 0) {
580
- warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
581
- }
582
- const decision = decideDescentApply(verdict, {
583
- ...(attestLineageLost ? { attestation: attestLineageLost } : {}),
638
+ const result = await proveDescentForApply({
639
+ modelsPath: resolveModelsPath(flags.models),
640
+ live,
641
+ ledger: live.ledger,
584
642
  destructive: plan.destructive > 0,
585
- proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(process.cwd())),
643
+ ...(attestLineageLost ? { attestation: attestLineageLost } : {}),
586
644
  });
587
- if (decision.ok && decision.attested) {
588
- descentAttested = attestationNote(decision.attested);
589
- warn(`DESCENT ATTESTED — ${descentAttested}. The declaring commit is proven gone from every ref; the fast-forward rule is waived for this apply and the waiver is recorded.`);
590
- }
591
- return decision.ok ? { ok: true } : { ok: false, reason: decision.reason };
645
+ descentAttested = result.descentAttested;
646
+ return result.descentVerdict;
592
647
  },
593
648
  }),
594
649
  );
@@ -50,7 +50,7 @@ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
50
50
  import { readStageStateTwice } from '../stage-read-consistency';
51
51
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
52
52
  import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan';
53
- import { proveDescent } from '../git-descent';
53
+ import { proveDescent, describeDriftOrigin, type ProveVerdict } from '../git-descent';
54
54
  import { readDescentLedger } from '../apply-execute';
55
55
  import { planBackfills, readBackfillLog } from '../backfill';
56
56
  import { readSqlDirIfPresent } from './db-sync';
@@ -70,6 +70,48 @@ import { compileTableContract } from '../authz-compile';
70
70
  import { reportPipelineLastRun } from './pipeline-run';
71
71
  import { step, success, fail, info, warn, reserveStdoutForData } from '../output';
72
72
 
73
+ /**
74
+ * What db:plan says about descent at mint — SYMMETRIC with what db:apply will do, because a mint
75
+ * that warns "will refuse" for a plan apply then runs (or the reverse) is the lie this used to
76
+ * tell. Exported so a test pins every verdict's text. The `never` arm makes the switch REALLY
77
+ * exhaustive: a new verdict fails to compile here instead of minting a plan with no warning and
78
+ * refusing at apply with no hint of why.
79
+ */
80
+ export function describePlanDescent(verdict: ProveVerdict): string[] {
81
+ switch (verdict.status) {
82
+ case 'proven':
83
+ case 'fresh-target':
84
+ case 'no-git':
85
+ return [];
86
+ case 'drift':
87
+ return [
88
+ `descent: NO COMMIT ON ANY REF DECLARES THIS DATABASE (${describeDriftOrigin(verdict.origin, { human: true })}) — proven over a full, current clone.`,
89
+ '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.',
90
+ ];
91
+ case 'declared-elsewhere':
92
+ return [
93
+ `descent: ${verdict.reason}`,
94
+ 'db:apply from this checkout will refuse this plan — fetch and merge or rebase onto that commit, then re-mint.',
95
+ ];
96
+ case 'revert':
97
+ return [`descent: ${verdict.reason}`, 'db:apply from this checkout will refuse this plan — rebase first, then re-mint.'];
98
+ case 'unknown-commit':
99
+ // NOT "rebase first" — the checkout is fine, it is just missing the deploying commit.
100
+ return [`descent: ${verdict.reason}`, 'db:apply from this checkout will refuse this plan until you fetch that commit — fetch, then re-mint.'];
101
+ case 'unverifiable':
102
+ // NOT "rebase first" — nothing is wrong with the checkout or the database. The search could
103
+ // not finish (an unreadable tree, or a clone that is not full and current).
104
+ 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.'];
105
+ default: {
106
+ // Compile-time exhaustiveness; at runtime (a verdict shape from outside this compile unit)
107
+ // an empty warning list is the safe value — the caller iterates it.
108
+ const exhaustive: never = verdict;
109
+ void exhaustive;
110
+ return [];
111
+ }
112
+ }
113
+ }
114
+
73
115
  const DEFAULT_OUT = 'db.plan.json';
74
116
 
75
117
  /**
@@ -289,28 +331,7 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
289
331
  // A switch, not an `if (revert || drift)`. That form silently ignored every verdict it
290
332
  // did not name, so a new one would mint a plan with no warning and then be refused at
291
333
  // apply time with no hint of why. Exhaustive here means a new verdict cannot be forgotten.
292
- switch (verdict.status) {
293
- case 'proven':
294
- case 'fresh-target':
295
- case 'no-git':
296
- break;
297
- case 'revert':
298
- case 'drift':
299
- warn(`descent: ${verdict.reason}`);
300
- warn('db:apply from this checkout will refuse this plan — rebase first, then re-mint.');
301
- break;
302
- case 'unknown-commit':
303
- // NOT "rebase first" — the checkout is fine, it is just missing the deploying commit.
304
- warn(`descent: ${verdict.reason}`);
305
- warn('db:apply from this checkout will refuse this plan until you fetch that commit — fetch, then re-mint.');
306
- break;
307
- case 'unverifiable':
308
- // NOT "rebase first" — nothing is wrong with the checkout or the database. The
309
- // search could not read an ancestor state, so the rule is unproven either way.
310
- warn(`descent: ${verdict.reason}`);
311
- warn('db:apply from this checkout will refuse this plan until that search completes — re-mint and try again.');
312
- break;
313
- }
334
+ for (const line of describePlanDescent(verdict)) warn(line);
314
335
  }
315
336
 
316
337
  // Visibility, never automation (brick 8): the review surface names the
@@ -236,10 +236,12 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
236
236
  // as exemptions too would double-book them — declared and exempted at once.
237
237
  pulledExemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
238
238
  // A6's advisory. A blanket `GRANT USAGE ON ALL SEQUENCES` beside a blanket SELECT is a
239
- // common brownfield shape, and the inheritance rule never touches it — the rule only ADDS
240
- // what an INSERT needs — so it would sit unnoticed forever. Named, never revoked: revoking
241
- // it would be the tool deciding a security property from an inference.
242
- for (const line of renderVestigialSequenceGrants(vestigialSequenceGrants(contract))) caution(line);
239
+ // common brownfield shape. The advisory says exactly what the next db:plan will do with it,
240
+ // so it must see the SAME governed set the plan will use: the vocabulary plus --govern-roles.
241
+ // (With the default set, a --govern-roles grantee was told "not governed, nothing revokes
242
+ // it" while the plan minted the REVOKE — closing-gate review, 2026-09-07.)
243
+ const governedAtPull = new Set<string>([...ALWAYS_GOVERNED, ...governRoles]);
244
+ for (const line of renderVestigialSequenceGrants(vestigialSequenceGrants(contract), governedAtPull)) caution(line);
243
245
  pulledFingerprint = fingerprintLive(current, contract).hash;
244
246
  }
245
247
  // --matviews-as-tables: the flip needs real fields — one extra catalog read for the
@@ -17,15 +17,24 @@
17
17
  * historical state of the models directory across all refs, compile each
18
18
  * unique tree, and ask whether its predicted live endpoint equals the
19
19
  * target's live fingerprint (brick 4's merged prediction, so unmodeled
20
- * brownfield tables ride through the test). A fingerprint no commit
21
- * declares is a DRIFT report and an explicit operator decision — never a
22
- * guess. Identity is the models-directory TREE, not the commit: a
23
- * cherry-picked identical state is the same declared state.
20
+ * brownfield tables ride through the test). Identity is the models-directory
21
+ * TREE, not the commit: a cherry-picked identical state is the same declared
22
+ * state.
23
+ *
24
+ * A fingerprint NO commit on ANY ref declares is DRIFT, and proven drift is the
25
+ * operator's decision the design promised: it PASSES at apply. The rule exists to
26
+ * stop an apply from reverting MERGED work, and when no ref anywhere declares the
27
+ * live state there is no merged work to revert — by construction. The proof is
28
+ * refuse-closed: a ref you do not descend from that DOES declare live refuses by
29
+ * name (`declared-elsewhere`, a teammate got there first); a tree that could not be
30
+ * read, or a clone that is shallow, partial, or behind a remote, is UNVERIFIABLE,
31
+ * never drift. `--force-descent` used to be the operator's drift decision; its
32
+ * retirement (2026-08-26) left drift with no escape at all, and a consumer walked
33
+ * into that dead end on 2026-09-07. The proof replaced the flag (Fable-ruled).
24
34
  */
25
35
 
26
36
  import { execFileSync } from 'node:child_process';
27
37
  import fs from 'node:fs';
28
- import os from 'node:os';
29
38
  import path from 'node:path';
30
39
  import { pathToFileURL } from 'node:url';
31
40
  import type { ModelDescriptor } from '@everystack/model';
@@ -107,7 +116,10 @@ export function enumerateModelTrees(
107
116
  // `ancestorsOf`, restrict to the ancestors of one commit — the fallback proof only ever
108
117
  // considers trees the checkout descends from, so a diverged side branch is not even a
109
118
  // candidate (a non-ancestor declaring commit is the ledger's REVERT case, not recompile's).
110
- const range = opts.ancestorsOf ? [opts.ancestorsOf] : ['--all'];
119
+ // `--all` walks refs/stash too. A stash is a WIP snapshot, never a deployed state: one that will
120
+ // not compile would poison the drift proof to unverifiable, and one that happens to match live
121
+ // would be named as a teammate's deployment. Excluded here and in refsContaining, consistently.
122
+ const range = opts.ancestorsOf ? [opts.ancestorsOf] : ['--exclude=refs/stash', '--all'];
111
123
  const out = git(['rev-list', ...range, '--full-history', '--date-order', '--', modelsDir], cwd);
112
124
  if (out === null) return [];
113
125
  const shas = out.trim().split('\n').filter(Boolean);
@@ -213,7 +225,7 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
213
225
  fs.rmSync(dest, { recursive: true, force: true });
214
226
  ctx.materialize(candidate.tree, dest, ctx.repoRoot);
215
227
  } catch (err: any) {
216
- return { kind: 'unevaluated', detail: `${where}: could not extract — ${err.message}` };
228
+ return { kind: 'unevaluated', detail: `${where}: could not extract — ${firstLine(err.message)}` };
217
229
  }
218
230
  }
219
231
 
@@ -228,6 +240,12 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
228
240
 
229
241
  const evaluate = async (): Promise<boolean> => {
230
242
  const models = await ctx.loader(barrelPath);
243
+ // A barrel that compiles to ZERO models declares NOTHING. The prediction rides every
244
+ // undeclared live table through unchanged, so an empty models array "predicts" any live
245
+ // state exactly — it would declare every database there is. Absent barrel and empty barrel
246
+ // are the same clean 'no'. (Closing-gate review round 2, 2026-09-07: on a side branch this
247
+ // minted a false declared-elsewhere; as a HEAD ancestor it silently proved a fast-forward.)
248
+ if (models.length === 0) return false;
231
249
  const governedRoles = governedRolesForModels(models);
232
250
  const predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: ctx.schema, governedRoles });
233
251
  return predicted === governedLiveFingerprint(live.snapshot, live.contract, governedRoles);
@@ -241,15 +259,61 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
241
259
  }
242
260
  return declares ? { kind: 'declares' } : { kind: 'no' };
243
261
  } catch (err: any) {
244
- return { kind: 'compile-failed', detail: `${where}: ${err.message}` };
262
+ return { kind: 'compile-failed', detail: `${where}: ${firstLine(err.message)}` };
263
+ }
264
+ }
265
+
266
+ /**
267
+ * A compile/extraction error, summarized for a refusal that is RECORDED in a shared stage's
268
+ * schema_log: the cause, never the operator's filesystem.
269
+ *
270
+ * - MODULE_NOT_FOUND: line 1 is the cause ("Cannot find module '@everystack/model'"); the
271
+ * `Require stack:` that follows is absolute local paths — dropped.
272
+ * - An esbuild TransformError (the "older tree that no longer builds" case): line 1 is only the
273
+ * header "Transform failed with 1 error:" and the cause is on line 2 — kept, with the sandbox
274
+ * path prefix stripped so what remains is `db/models/index.ts:2:0: ERROR: …`.
275
+ * - An empty message says so instead of rendering nothing.
276
+ *
277
+ * (Closing-gate review round 5: a bare first-line take blanked the cause on exactly the class the
278
+ * `unverifiable` message exists to explain.)
279
+ */
280
+ export function summarizeCompileError(message: unknown): string {
281
+ const lines = String(message ?? '').split('\n').map((l) => l.trimEnd());
282
+ const stop = lines.findIndex((l) => /^\s*Require stack:/.test(l));
283
+ const useful = (stop === -1 ? lines : lines.slice(0, stop)).filter((l) => l.trim().length > 0);
284
+ if (useful.length === 0) return '(error with no message)';
285
+ const take = /^Transform failed with \d+ errors?:?$/.test(useful[0].trim()) && useful.length > 1 ? useful.slice(0, 2) : useful.slice(0, 1);
286
+ return take
287
+ .map((l) => l.replace(/\/[^\s:'"]*\.everystack-descent-[^/\s]+\/[0-9a-f]+\//g, '').trim())
288
+ .join(' — ');
289
+ }
290
+ const firstLine = summarizeCompileError;
291
+
292
+ /**
293
+ * Where historical trees materialize so that the materialized barrel can `import '@everystack/model'`:
294
+ * node walks UP from the importing file, so the sandbox must sit below a `node_modules` that holds
295
+ * the package. That is the nearest `node_modules` ABOVE THE MODELS BARREL, not the repo root's —
296
+ * in a monorepo the app (`apps/expo/db/models`) has `apps/expo/node_modules`, and the root
297
+ * `node_modules` may not carry the package at all (closing-gate review round 4, reproduced). No
298
+ * `node_modules` anywhere up to the repo root → the repo root itself (a nested checkout still walks
299
+ * up to its host's). Never the OS temp dir — that resolves nothing (the db:adopt 0.4.74 bug).
300
+ */
301
+ export function materializeBase(repoRoot: string, modelsAbs: string): string {
302
+ let dir = path.dirname(modelsAbs);
303
+ while (dir.startsWith(repoRoot)) {
304
+ const nm = path.join(dir, 'node_modules');
305
+ if (fs.existsSync(nm)) return nm;
306
+ if (dir === repoRoot) break;
307
+ dir = path.dirname(dir);
245
308
  }
309
+ return repoRoot;
246
310
  }
247
311
 
248
312
  /** Resolve the repo root, HEAD, and the models directory/barrel split — shared setup. */
249
313
  function resolveRepoContext(
250
314
  modelsPath: string,
251
315
  opts: DescentOptions,
252
- ): { repoRoot: string; head: string; modelsDir: string; barrel: string } | null {
316
+ ): { repoRoot: string; head: string; modelsDir: string; barrel: string; modelsAbs: string } | null {
253
317
  const cwd = opts.cwd ?? process.cwd();
254
318
  const rootOut = git(['rev-parse', '--show-toplevel'], cwd);
255
319
  if (rootOut === null) return null;
@@ -261,7 +325,7 @@ function resolveRepoContext(
261
325
  // /private/var), and a mismatched prefix would walk the models path right out of the repo.
262
326
  const modelsAbs = path.resolve(fs.realpathSync(cwd), modelsPath);
263
327
  const rel = path.relative(repoRoot, modelsAbs);
264
- return { repoRoot, head, modelsDir: path.dirname(rel) || '.', barrel: path.basename(modelsAbs) };
328
+ return { repoRoot, head, modelsDir: path.dirname(rel) || '.', barrel: path.basename(modelsAbs), modelsAbs };
265
329
  }
266
330
 
267
331
  /** `git cat-file -e <ref>^{commit}` — is the commit present in THIS clone (not a shallow gap)? */
@@ -314,6 +378,13 @@ export interface GitLineageProbe {
314
378
  remotes(): string[] | null;
315
379
  /** `git ls-remote --heads --tags <remote>` advertised tip shas; null when the remote is unreachable. */
316
380
  lsRemote(remote: string): string[] | null;
381
+ /**
382
+ * Does this object peel to a commit (`<sha>^{commit}`)? `null` when the object is absent from
383
+ * the clone. An advertised tip that is present but is a tag on a blob or tree can carry no
384
+ * models and cannot be `--contains`-tested (git errors on a non-commit), so currency skips it.
385
+ * Optional: a probe without it treats every tip as a commit (refuse-closed).
386
+ */
387
+ peelsToCommit?(sha: string): boolean | null;
317
388
  }
318
389
 
319
390
  export type LineageProof =
@@ -422,6 +493,10 @@ export function realLineageProbe(cwd: string): GitLineageProbe {
422
493
  if (out === null) return null;
423
494
  return out.split('\n').map((l) => l.split('\t')[0]?.trim()).filter((s): s is string => !!s && s.length >= 7);
424
495
  },
496
+ peelsToCommit: (sha) => {
497
+ if (!commitPresent(sha, cwd) && run(['cat-file', '-e', sha]) === null) return null; // absent
498
+ return run(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]) !== null;
499
+ },
425
500
  };
426
501
  }
427
502
 
@@ -451,10 +526,25 @@ export interface DescentLedgerRow {
451
526
  dirty: boolean;
452
527
  /** When the apply landed — for the REVERT message (names the commit + date). */
453
528
  appliedAt: string;
454
- /** Only a successful state apply is a witness. */
529
+ /** Only a successful state apply is a witness: `applied`, or `applied — <descent note>`. */
455
530
  outcome: string;
456
531
  }
457
532
 
533
+ /** A successful state apply, with or without a recorded descent note (`applied — …`). */
534
+ export const isAppliedOutcome = (outcome: string): boolean => outcome === 'applied' || outcome.startsWith('applied — ');
535
+
536
+ /**
537
+ * Where a proven drift came from — informs the warning and the schema_log note, never the
538
+ * verdict (the ledger is a memoir, decision 7). `never-declared`: no successful state apply is
539
+ * recorded here — first adoption of a database everystack never wrote. `hand-edited`: everystack
540
+ * did apply here once (the newest such row is named), and the database has moved since, outside
541
+ * the tool.
542
+ */
543
+ export type DriftOrigin =
544
+ | { kind: 'never-declared' }
545
+ /** `dirty`: that last apply ran from a dirty worktree, so its commit never declared what it applied. */
546
+ | { kind: 'hand-edited'; commit: string | null; appliedAt: string; dirty?: boolean };
547
+
458
548
  export type ProveVerdict =
459
549
  /** The database has no tables — first apply bootstraps it. */
460
550
  | { status: 'fresh-target' }
@@ -466,19 +556,94 @@ export type ProveVerdict =
466
556
  | { status: 'revert'; commit: string; appliedAt: string; reason: string }
467
557
  /** The witness names a commit this clone does not have (shallow) — fetch, do not guess. */
468
558
  | { status: 'unknown-commit'; commit: string; reason: string }
469
- /** No ledger witness, and the ancestor recompile could not read every tree — inconclusive. */
559
+ /** A ref HEAD does not descend from declares the live state — a teammate's merged work. */
560
+ | { status: 'declared-elsewhere'; commit: string; reason: string }
561
+ /** The search could not read every tree, or the clone is not full and current — inconclusive. */
470
562
  | { status: 'unverifiable'; reason: string; unevaluated: string[]; compileFailures: string[] }
471
- /** No ledger witness, every ancestor tree read cleanly, none declares live — real drift. */
472
- | { status: 'drift'; reason: string; scannedTrees: number };
563
+ /** Every tree on every ref read cleanly in a full, current clone, and none declares live —
564
+ * PROVEN drift. Passes at apply: nothing merged exists to revert. */
565
+ | { status: 'drift'; reason: string; scannedTrees: number; origin: DriftOrigin };
473
566
 
474
567
  export interface ProveDescentOptions extends DescentOptions {
475
568
  /** Retained-canon registry override — the cross-bump recompute path (tests inject a
476
569
  * synthetic retired canon). Defaults to the module's RETAINED_CANONS. */
477
570
  canons?: Readonly<Record<number, CanonHasher>>;
571
+ /** The git probe the drift proof uses to establish the clone is full and current.
572
+ * Default: {@link realLineageProbe} over the repo root. Injected so tests can prove each
573
+ * currency refusal without a remote. */
574
+ probe?: GitLineageProbe;
478
575
  }
479
576
 
480
577
  const shortDate = (at: string): string => at.slice(0, 10);
481
578
 
579
+ /**
580
+ * The origin, in words — ONE rendering for the plan warning, the apply warning, and the recorded
581
+ * note, so the dirty-worktree sub-case can never be surfaced in one place and dropped in another
582
+ * (closing-gate review round 4: the flag was computed and reached no human and no record).
583
+ *
584
+ * never-declared — no everystack apply is recorded here
585
+ * hand-edited since <sha>, <date> — everystack applied from that commit; the database moved since
586
+ * applied from a DIRTY worktree at <sha>, <date> — that commit never declared what was applied,
587
+ * so nothing committed can declare this state
588
+ */
589
+ export function describeDriftOrigin(origin: DriftOrigin, opts: { human?: boolean } = {}): string {
590
+ if (origin.kind === 'never-declared') return opts.human ? 'never declared: no everystack apply is recorded here' : 'never-declared';
591
+ const at = origin.commit ? `${short(origin.commit)}, ${shortDate(origin.appliedAt)}` : `an apply on ${shortDate(origin.appliedAt)}`;
592
+ return origin.dirty ? `applied from a DIRTY worktree at ${at}` : `hand-edited since ${at}`;
593
+ }
594
+
595
+ /** The recorded note for a proven drift — goes in the apply row's schema_log outcome. */
596
+ export function driftOriginNote(origin: DriftOrigin): string {
597
+ return `descent: drift-origin proven (${describeDriftOrigin(origin)})`;
598
+ }
599
+
600
+ /**
601
+ * Is this clone full and current with every remote? The drift proof's precondition: "no ref
602
+ * anywhere declares live" is only a claim we may make when we hold every ref there is. Same
603
+ * probe as the lineage proof, but the question differs — currency, not the fate of one commit —
604
+ * so an empty remote set is simply current (a purely local repo has nothing to be behind).
605
+ * Refuse-closed: a failed `git remote` or an unreachable remote is NOT current.
606
+ *
607
+ * The test is REF reachability, never object presence. The scan this guards is `git rev-list
608
+ * --all`, which walks refs; an advertised tip that is present as an object but contained by no
609
+ * ref (a `git fetch origin <branch>` that wrote only FETCH_HEAD, or a locally deleted branch
610
+ * the remote still has) is invisible to the scan and MUST count as behind. Testing
611
+ * `objectExists` here let exactly that tip satisfy currency while the scan missed the models
612
+ * it declared — found by the closing-gate review 2026-09-07, before it shipped.
613
+ */
614
+ export function proveCloneCurrent(git: GitLineageProbe): { current: true } | { current: false; reason: string } {
615
+ if (git.isShallow()) {
616
+ return { current: false, reason: 'this is a shallow clone, so a ref that declares the live state may simply not be here — unshallow first (git fetch --unshallow), then re-run.' };
617
+ }
618
+ if (git.isPartial()) {
619
+ return { current: false, reason: 'this is a partial clone, so objects can be missing locally — fetch the full history, then re-run.' };
620
+ }
621
+ const remotes = git.remotes();
622
+ if (remotes === null) {
623
+ return { current: false, reason: "could not list this repo's git remotes to confirm the clone is current — retry, or fetch the remotes first." };
624
+ }
625
+ for (const remote of remotes) {
626
+ const tips = git.lsRemote(remote);
627
+ if (tips === null) {
628
+ return { current: false, reason: `could not read ${remote} to confirm the clone is current — check connectivity and retry, or fetch the remote first.` };
629
+ }
630
+ for (const tip of tips) {
631
+ // refs/stash is excluded from the scan (enumerateModelTrees), so a tip contained ONLY by a
632
+ // stash counts as behind here — the two sides of the currency proof see the same refs, and
633
+ // this direction is refuse-closed. The probe itself stays raw: for the lineage proof a stash
634
+ // that reaches a commit is a REACHABLE commit, and filtering it there would fall open.
635
+ if (git.refsContaining(tip).filter((ref) => ref !== 'refs/stash').length === 0) {
636
+ // A tip we HOLD that is not a commit and peels to none (a tag on a blob or tree — the
637
+ // `junio-gpg-pub` shape) carries no models and can never be contained by a ref; it is
638
+ // not evidence of being behind. An ABSENT tip, or a present commit no ref contains, is.
639
+ if (git.peelsToCommit && git.peelsToCommit(tip) === false) continue;
640
+ return { current: false, reason: `this clone is behind ${remote} (it advertises ${short(tip)}, which no ref here contains) — fetch it first (git fetch ${remote} --tags); a ref there may declare the live state.` };
641
+ }
642
+ }
643
+ }
644
+ return { current: true };
645
+ }
646
+
482
647
  /**
483
648
  * The fast-forward proof, ledger-first. Refuse-closed: no inconclusive branch passes.
484
649
  *
@@ -508,7 +673,7 @@ export async function proveDescent(
508
673
 
509
674
  const ctx = resolveRepoContext(modelsPath, opts);
510
675
  if (ctx === null) return { status: 'no-git' };
511
- const { repoRoot, head, modelsDir, barrel } = ctx;
676
+ const { repoRoot, head, modelsDir, barrel, modelsAbs } = ctx;
512
677
 
513
678
  // ---- Phase A: the ledger witness (newest usable row wins). ----
514
679
  // "Newest wins" (the S→T→S pre-episode refusal) is a security-relevant property, so do not
@@ -516,7 +681,7 @@ export async function proveDescent(
516
681
  // future caller passing an unsorted ledger must not silently let a pre-episode row win.
517
682
  const ordered = [...ledger].sort((a, b) => Number(b.id) - Number(a.id));
518
683
  for (const r of ordered) {
519
- if (r.outcome !== 'applied') continue; // only a successful state apply is a witness
684
+ if (!isAppliedOutcome(r.outcome)) continue; // only a successful state apply is a witness
520
685
  if (!r.toFingerprint) continue;
521
686
  if (!r.gitRef) continue; // a null git_ref cannot declare a commit
522
687
  if (r.dirty) continue; // a dirty mint's git_ref does not declare what was applied
@@ -554,9 +719,26 @@ export async function proveDescent(
554
719
  // ---- Phase B: no usable ledger witness → ancestor-restricted recompile. ----
555
720
  const candidates = enumerateModelTrees(modelsDir, repoRoot, { ancestorsOf: head });
556
721
 
557
- const nodeModules = path.join(repoRoot, 'node_modules');
722
+ // No ancestor carries a committed models directory to compare against — a shallow clone that
723
+ // truncated the deploying history, or the deploying branch is simply not in this checkout.
724
+ // That is a limit of THIS clone, not a hand-edit: UNVERIFIABLE, never drift.
725
+ if (candidates.length === 0) {
726
+ return {
727
+ status: 'unverifiable',
728
+ reason:
729
+ 'cannot verify the fast-forward rule — no ancestor of your checkout carries a committed ' +
730
+ 'models directory to compare against. This is a shallow clone (fetch the full history: ' +
731
+ 'git fetch --unshallow) or the deploying branch is not in this checkout, not evidence ' +
732
+ 'about the database — it is NOT drift. Fetch the deploying history and re-run.',
733
+ unevaluated: [],
734
+ compileFailures: [],
735
+ };
736
+ }
737
+
738
+ // Historical trees materialize INSIDE the project, below the nearest node_modules above the
739
+ // models barrel — see materializeBase. Never the OS temp dir.
558
740
  const materializeRoot = opts.materializeRoot
559
- ?? fs.mkdtempSync(path.join(fs.existsSync(nodeModules) ? nodeModules : os.tmpdir(), '.everystack-descent-'));
741
+ ?? fs.mkdtempSync(path.join(materializeBase(repoRoot, modelsAbs), '.everystack-descent-'));
560
742
  const ownsRoot = !opts.materializeRoot;
561
743
  const evalCtx: EvalContext = {
562
744
  repoRoot,
@@ -569,6 +751,28 @@ export async function proveDescent(
569
751
 
570
752
  const compileFailures: string[] = [];
571
753
  const unevaluated: string[] = [];
754
+ const unverifiable = (scope: string, scanned: number): ProveVerdict => {
755
+ const notes: string[] = [];
756
+ if (compileFailures.length > 0) notes.push(`${compileFailures.length} would not compile under this CLI`);
757
+ if (unevaluated.length > 0) notes.push(`${unevaluated.length} could not be extracted`);
758
+ // Show the FIRST actual error: "Cannot find module '@everystack/model'" is a resolution
759
+ // problem with a different fix than "an older tree that no longer builds", and a message that
760
+ // hides it sends the operator to upgrade a CLI that is not the cause.
761
+ const first = compileFailures[0] ?? unevaluated[0];
762
+ return {
763
+ status: 'unverifiable',
764
+ reason:
765
+ `cannot verify the fast-forward rule — of ${scanned} ${scope} model state(s), ` +
766
+ `${notes.join(' and ')}, and none of the readable ones declares the live state. This is a ` +
767
+ 'limit of THIS run (an older tree that no longer builds, or a failed extraction), not ' +
768
+ 'evidence about the database — it is NOT drift. Upgrade/pin the CLI that last deployed ' +
769
+ `this state, or prune the branch that carries the unreadable tree, and re-run.${first ? ` First failure: ${first}` : ''}`,
770
+ unevaluated,
771
+ compileFailures,
772
+ };
773
+ };
774
+
775
+ let others: ModelTreeCandidate[] = [];
572
776
  try {
573
777
  for (const candidate of candidates) {
574
778
  const result = await evaluateTree(candidate, live, evalCtx);
@@ -586,79 +790,113 @@ export async function proveDescent(
586
790
  const ancestor = candidate.commits.find((commit) => isAncestor(commit, head, repoRoot)) ?? candidate.commits[0];
587
791
  return { status: 'proven', via: 'recompile', commit: ancestor };
588
792
  }
793
+
794
+ // A tree we could not READ is not evidence about the database. If any ancestor tree could not
795
+ // be extracted or compiled, "nothing declares this state" is a claim we are not entitled to
796
+ // make — the answer is UNVERIFIABLE, and its next move is retry/upgrade, never db:pull.
797
+ if (unevaluated.length > 0 || compileFailures.length > 0) return unverifiable('ancestor', candidates.length);
798
+
799
+ // ---- Phase C: the drift proof — no ancestor declares live, so scan EVERY ref. ----
800
+ // The fast-forward rule protects MERGED work. Before calling the live state undeclared, look
801
+ // at every tree on every ref this clone holds: one that declares live belongs to a line of
802
+ // history HEAD does not descend from — a teammate deployed it — and applying our edge would
803
+ // revert it. That refuses BY NAME. Only when no tree anywhere declares live is there nothing
804
+ // to protect.
805
+ const seen = new Set(candidates.map((c) => c.tree));
806
+ const all = enumerateModelTrees(modelsDir, repoRoot);
807
+ // The all-refs walk must be a SUPERSET of the ancestor walk — every ancestor tree is reachable
808
+ // from `--all`. When it is not, `git rev-list --all` failed (a broken or dangling ref makes it
809
+ // exit 128 while the ancestor walk still succeeds) and returned nothing, which is NOT "no
810
+ // other refs". Reading an empty result as "nothing else declares live" fell open to drift;
811
+ // found by the closing-gate review 2026-09-07.
812
+ const allTrees = new Set(all.map((c) => c.tree));
813
+ if ([...seen].some((tree) => !allTrees.has(tree))) {
814
+ return {
815
+ status: 'unverifiable',
816
+ reason:
817
+ 'cannot verify the fast-forward rule — the walk over EVERY ref (git rev-list --all) did not ' +
818
+ 'return the trees the ancestor walk did, so it failed part-way: a broken or dangling ref ' +
819
+ '(run `git fsck`, prune the bad ref) or a git error. That is a limit of THIS clone, not ' +
820
+ 'evidence about the database — it is NOT drift. Repair the ref and re-run.',
821
+ unevaluated,
822
+ compileFailures,
823
+ };
824
+ }
825
+ // Currency BEFORE the all-refs compile: a clone that is shallow, partial, or behind a remote
826
+ // is told to fetch now, not after paying for every historical tree on every ref.
827
+ const currency = proveCloneCurrent(opts.probe ?? realLineageProbe(repoRoot));
828
+ if (!currency.current) {
829
+ return {
830
+ status: 'unverifiable',
831
+ reason:
832
+ `cannot verify the fast-forward rule — no ancestor of your checkout declares the live state, and ` +
833
+ `${currency.reason} That is a limit of THIS clone, not evidence about the database — it is NOT drift.`,
834
+ unevaluated,
835
+ compileFailures,
836
+ };
837
+ }
838
+ others = all.filter((c) => !seen.has(c.tree));
839
+ for (const candidate of others) {
840
+ const result = await evaluateTree(candidate, live, evalCtx);
841
+ if (result.kind === 'unevaluated') {
842
+ unevaluated.push(result.detail);
843
+ continue;
844
+ }
845
+ if (result.kind === 'compile-failed') {
846
+ compileFailures.push(result.detail);
847
+ continue;
848
+ }
849
+ if (result.kind === 'no') continue;
850
+ const commit = candidate.commits[0];
851
+ return {
852
+ status: 'declared-elsewhere',
853
+ commit,
854
+ reason:
855
+ `a ref your checkout does NOT descend from declares the target's live state — commit ` +
856
+ `${short(commit)} carries the models that match it. Someone deployed from that line of ` +
857
+ 'history; applying your edge would revert their work. Fetch and merge or rebase onto it, ' +
858
+ 'then re-plan.',
859
+ };
860
+ }
589
861
  } finally {
590
862
  if (ownsRoot) fs.rmSync(materializeRoot, { recursive: true, force: true });
591
863
  }
592
864
 
593
- // No ancestor carries a committed models directory to compare against — a shallow clone that
594
- // truncated the deploying history, or the deploying branch is simply not in this checkout.
595
- // That is a limit of THIS clone, not a hand-edit: UNVERIFIABLE, never drift.
596
- if (candidates.length === 0) {
597
- return {
598
- status: 'unverifiable',
599
- reason:
600
- 'cannot verify the fast-forward rule — no ancestor of your checkout carries a committed ' +
601
- 'models directory to compare against. This is a shallow clone (fetch the full history: ' +
602
- 'git fetch --unshallow) or the deploying branch is not in this checkout, not evidence ' +
603
- 'about the database — it is NOT drift. Fetch the deploying history, or waive the rule deliberately.',
604
- unevaluated,
605
- compileFailures,
606
- };
607
- }
608
-
609
- // A tree we could not READ is not evidence about the database. If any ancestor tree could not
610
- // be extracted or compiled, "nothing declares this state" is a claim we are not entitled to
611
- // make — the answer is UNVERIFIABLE, and its next move is retry/upgrade, never db:pull.
612
- if (unevaluated.length > 0 || compileFailures.length > 0) {
613
- const notes: string[] = [];
614
- if (compileFailures.length > 0) notes.push(`${compileFailures.length} would not compile under this CLI`);
615
- if (unevaluated.length > 0) notes.push(`${unevaluated.length} could not be extracted`);
616
- return {
617
- status: 'unverifiable',
618
- reason:
619
- `cannot verify the fast-forward rule — of ${candidates.length} ancestor model state(s), ` +
620
- `${notes.join(' and ')}, and none of the readable ones declares the live state. This is a ` +
621
- 'limit of THIS run (an older tree that no longer builds, or a failed extraction), not ' +
622
- 'evidence about the database — it is NOT drift. Upgrade/pin the CLI that last deployed ' +
623
- 'this state, or waive the rule deliberately.',
624
- unevaluated,
625
- compileFailures,
626
- };
627
- }
628
-
629
- return {
630
- status: 'drift',
631
- scannedTrees: candidates.length,
632
- reason:
633
- `no ancestor of your checkout declares the target's live fingerprint ` +
634
- `${short(fingerprintLive(live.snapshot, live.contract).hash)} — read ${candidates.length} ` +
635
- 'ancestor model state(s), all cleanly, and none matches. Either this database has never been ' +
636
- 'adopted into Models (nothing committed can declare it — the first-adoption case), or it ' +
637
- 'drifted after adoption. THE RUNBOOK: run everystack db:adopt — it renders Models from the ' +
638
- 'live database and PROVES they declare it exactly (it REFUSES rather than paper over any ' +
639
- 'difference); commit AND push that baseline, then re-run db:plan / db:apply and it descends ' +
640
- 'normally. If instead your Models are the source of truth and the database drifted, reconcile ' +
641
- 'with db:generate, or waive the rule deliberately.',
642
- };
643
- }
644
-
645
- /**
646
- * Collapse a ProveVerdict into the ok/reason shape the apply core enforces. Refuse-closed:
647
- * only proven / fresh-target / no-git pass; every other verdict refuses, carrying its own
648
- * distinct cause so the operator's next move is unambiguous.
649
- */
650
- export function proveVerdictForApply(verdict: ProveVerdict): { ok: true } | { ok: false; reason: string } {
651
- switch (verdict.status) {
652
- case 'proven':
653
- case 'fresh-target':
654
- case 'no-git':
655
- return { ok: true };
656
- case 'revert':
657
- case 'unknown-commit':
658
- case 'unverifiable':
659
- case 'drift':
660
- return { ok: false, reason: verdict.reason };
661
- }
865
+ const scannedTrees = candidates.length + others.length;
866
+ // A side branch we could not read is not evidence either — refuse-closed, naming the count so
867
+ // the operator can prune the branch that carries it.
868
+ if (unevaluated.length > 0 || compileFailures.length > 0) return unverifiable('reachable', scannedTrees);
869
+
870
+ // PROVEN DRIFT (the clone was proven full and current before the all-refs scan). The origin is
871
+ // read from the ledger for the MESSAGE only (memoir, not control): any successful state apply
872
+ // recorded here means everystack once wrote this database and it has moved since; none means
873
+ // this is the first adoption of a database everystack never wrote. A dirty-worktree apply is
874
+ // still an apply — its git_ref declares nothing (Phase A skips it as a witness), so the state
875
+ // it produced was never committed anywhere; the origin says so.
876
+ const lastApplied = ordered.find((r) => isAppliedOutcome(r.outcome));
877
+ const origin: DriftOrigin = lastApplied
878
+ ? { kind: 'hand-edited', commit: lastApplied.gitRef, appliedAt: lastApplied.appliedAt, ...(lastApplied.dirty ? { dirty: true } : {}) }
879
+ : { kind: 'never-declared' };
880
+ const fp = short(fingerprintLive(live.snapshot, live.contract).hash);
881
+ // The scan is scoped to THIS checkout's models path: a ref that declares live from a different
882
+ // directory (a models move on another line of history) is outside it. Say so — the claim must
883
+ // be exactly as wide as the search.
884
+ const scanned = `read ${scannedTrees} model state(s) at ${modelsDir}/ across every ref of a full, current clone, all cleanly, and none matches`;
885
+ const runbook =
886
+ 'The reviewed plan you mint is the ORIGIN: `everystack db:plan --stage <name>` mints it, ' +
887
+ '`everystack db:apply --plan <file> --stage <name>` applies it, warns, and records the origin ' +
888
+ 'in schema_log; every later apply descends from that row.';
889
+ const reason = origin.kind === 'never-declared'
890
+ ? `no commit on any ref declares the target's live fingerprint ${fp} — ${scanned}. Nothing has ever ` +
891
+ 'declared this database (no everystack state apply is recorded here), so there is no merged work ' +
892
+ `the fast-forward rule could protect. ${runbook}`
893
+ : `no commit on any ref declares the target's live fingerprint ${fp} — ${scanned}. everystack last ` +
894
+ `applied here ${origin.commit ? `at commit ${short(origin.commit)} ` : ''}on ${shortDate(origin.appliedAt)}` +
895
+ (origin.dirty
896
+ ? ' from a DIRTY worktree — that commit never declared what was applied, so nothing committed can declare this state. '
897
+ : ', and the database has been changed outside the tool since (hand-edited). No ref declares what it is now, so ') +
898
+ `there is no merged work the fast-forward rule could protect; your committed Models are the source of truth. ${runbook}`;
899
+ return { status: 'drift', scannedTrees, origin, reason };
662
900
  }
663
901
 
664
902
  // ---------------------------------------------------------------------------
@@ -681,8 +919,9 @@ export interface DescentApplyContext {
681
919
  }
682
920
 
683
921
  export type DescentApplyDecision =
684
- /** Proceed. `attested` is set only when the attestation was honoured — record it in schema_log. */
685
- | { ok: true; attested?: { commit: string; subcase: 'revert-unreachable' | 'unknown-commit-proven-absent' } }
922
+ /** Proceed. `attested` is set only when the attestation was honoured — record it in schema_log.
923
+ * `note` is set for a proven drift — its origin, recorded in schema_log the same way. */
924
+ | { ok: true; attested?: { commit: string; subcase: 'revert-unreachable' | 'unknown-commit-proven-absent' }; note?: string }
686
925
  | { ok: false; reason: string };
687
926
 
688
927
  /** A short recorded note for a honoured attestation — goes in the apply row's schema_log outcome. */
@@ -703,17 +942,42 @@ export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyConte
703
942
  ? { ok: false, reason: 'this is not a git checkout and the plan is DESTRUCTIVE, so the fast-forward rule cannot be verified — run db:apply from the git checkout that declares your models.' }
704
943
  : { ok: true };
705
944
 
706
- case 'unverifiable':
707
945
  case 'drift':
708
- // No single declaring commit — nothing to attest ABOUT. The escape is to make HEAD declare the
709
- // live state, then re-plan. An attestation here is a category error, refused as one.
946
+ // PROVEN drift is the operator's decision: no ref anywhere declares live, so there is no merged
947
+ // work to revert. It passes, and its origin is recorded. An attestation here is a category
948
+ // error — there is no single declaring commit to attest about — refused as one.
949
+ if (ctx.attestation) {
950
+ return {
951
+ ok: false,
952
+ reason: `--attest-lineage-lost does not apply here: there is no single declaring commit to attest about. ${verdict.reason} Proven drift needs no attestation — drop the flag and re-run.`,
953
+ };
954
+ }
955
+ return { ok: true, note: driftOriginNote(verdict.origin) };
956
+
957
+ case 'unverifiable':
958
+ // The search could not finish (an unreadable tree, or a clone that is not full and current).
959
+ // The verdict carries its own fetch/retry runbook; nothing to attest ABOUT.
710
960
  return {
711
961
  ok: false,
712
962
  reason: ctx.attestation
713
- ? `--attest-lineage-lost does not apply here: there is no single declaring commit to attest about. ${verdict.reason} Commit your models so HEAD declares this state (db:generate), then db:plan and db:apply again.`
714
- : `${verdict.reason} Commit your models so HEAD declares this state (db:generate), then db:plan and db:apply again.`,
963
+ ? `--attest-lineage-lost does not apply here: there is no single declaring commit to attest about. ${verdict.reason}`
964
+ : verdict.reason,
715
965
  };
716
966
 
967
+ case 'declared-elsewhere': {
968
+ // A commit on a ref we hold declares live and HEAD does not descend from it. Reachable by
969
+ // construction, so the lineage-lost attestation can never be honoured here — the proof refuses
970
+ // it as reachable. The escape is git-native: merge or rebase.
971
+ if (!ctx.attestation) return { ok: false, reason: verdict.reason };
972
+ if (ctx.attestation !== verdict.commit) {
973
+ return { ok: false, reason: `--attest-lineage-lost ${short(ctx.attestation)} does not match the commit that declares this state (${short(verdict.commit)}) — drop the flag; that commit is reachable, so merge or rebase instead.` };
974
+ }
975
+ const proof = ctx.proveLineage(verdict.commit);
976
+ return proof.proven
977
+ ? { ok: true, attested: { commit: verdict.commit, subcase: proof.subcase } }
978
+ : { ok: false, reason: proof.reason };
979
+ }
980
+
717
981
  case 'revert':
718
982
  case 'unknown-commit': {
719
983
  // These name a declaring commit. Without an attestation the flagless escape (git revert / fetch)
package/src/cli/index.ts CHANGED
@@ -370,14 +370,14 @@ Usage:
370
370
  everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
371
371
  everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --govern-roles <r1,r2> (with --abilities live) transcribes the named FOREIGN roles' grants into privileges — the models then OWN them, so a fresh build recreates them; required for migration deletion when the migrations created grants to an ops/connection role. Explicit only (a re-pull must not silently govern), complete-or-refuse (a listed role holding a column-scoped grant fails the pull before anything is written). --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
372
372
  Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
373
- everystack db:adopt [--stage <name> | --database-url <url>] [--schema public] [--out db/models] [--govern-roles r1,r2] First-time adoption of a pre-Models database, BY PROOF. A production DB that never went through Models has no committed ancestor declaring it, so db:apply refuses it as unverifiable drift and no waiver fits. db:adopt renders field() Models from the live DB (the same renderers db:pull uses, --abilities live), compiles them back, and finalizes the baseline ONLY when the compiled Models declare the live state EXACTLY (predicted == governed live — the same declares-test descent and db:check use). On success it writes --out (default db/models) and prints the \`chore(db): adopt live baseline <fp>\` commit — that commit legitimately declares the live state, so a later db:apply descends for real. PROVE not attest: there is NO bypass flag. When the round-trip is not exact, adoption REFUSES, names the surfaces that diverged, prints the db:generate edge, and writes NOTHING. Commit AND push the baseline before db:apply — an unpushed baseline is an unknown commit to teammates and the ops Lambda.
373
+ everystack db:adopt [--stage <name> | --database-url <url>] [--schema public] [--out db/models] [--govern-roles r1,r2] First-time adoption of a pre-Models database AS IT IS, BY PROOF. A production DB that never went through Models has no committed ancestor declaring it; db:adopt is the on-ramp that makes one. It renders field() Models from the live DB (the same renderers db:pull uses, --abilities live), compiles them back, and finalizes the baseline ONLY when the compiled Models declare the live state EXACTLY (predicted == governed live — the same declares-test descent and db:check use). On success it writes --out (default db/models) and prints the \`chore(db): adopt live baseline <fp>\` commit — that commit legitimately declares the live state, so a later db:apply descends for real. PROVE not attest: there is NO bypass flag. When the round-trip is not exact, adoption REFUSES, names the surfaces that diverged, prints the edge, and writes NOTHING — that is the HARDENING case (the Models vocabulary cannot declare what is live, e.g. a sequence grant with no INSERT behind it), and it needs no adoption commit: commit the Models you intend, db:plan --stage, db:apply --stage. No commit declares the database yet, so db:apply PROVES that (proven drift) and treats the reviewed plan as the ORIGIN. Commit AND push the baseline before db:apply — an unpushed baseline is an unknown commit to teammates and the ops Lambda.
374
374
  everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
375
375
  everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--only a,b] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
376
376
  everystack db:refresh [--stage <name> | --database-url <url> | --direct] [--only a,b] [--verify-nonempty] [--list] Refresh the declared materialized views in dependency order, credential-free. --stage runs the whole refresh in the ops Lambda on the operator connection (no URL on the operator's machine — the data-lane twin of the reconcile lane); --database-url refreshes over a local connection (dev); --stage <name> --direct resolves the stage's operator connection from its ops Lambda (credential never on argv) and refreshes CLI-side on an UNBOUNDED clock, for a matview set that exceeds the ops-Lambda 900s limit; --only refreshes a named subset (full identity or bare name); --verify-nonempty gates on populated-but-zero-rows matviews (the dark-panel check — a scoped EXISTS on just what was refreshed, so a promotion script gets the gate with no read authority) and FAILS naming any empty; --list previews the order without connecting. Plain REFRESH (ACCESS EXCLUSIVE); fail-fast, idempotent to re-run.
377
377
  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
378
378
  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)
379
379
  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)
380
- 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
380
+ 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 — it protects MERGED work: a commit on any ref that declares the live state and is not an ancestor of HEAD refuses by name, escaped git-natively by fetch + merge/rebase + re-plan; --attest-lineage-lost <sha> waives it ONLY for a declaring commit proven gone from every ref. When NO commit on ANY ref declares the live state — a database everystack never wrote, or one hand-edited since its last apply — the rule has nothing to protect: over a full, current clone that is PROVEN DRIFT, the reviewed plan is the ORIGIN, it applies with a loud warning, and the origin is recorded in schema_log so every later apply descends from it. A shallow, partial, or behind-remote clone, or a models tree that will not compile, is UNVERIFIABLE and refuses with a fetch/prune runbook), 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
381
381
  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
382
382
  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
383
383
  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
@@ -314,9 +314,12 @@ export interface StateApplyOptions {
314
314
  /** Injectable clock for tests. */
315
315
  now?: () => number;
316
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.
317
+ * What descent decided, folded into the schema_log outcome as `applied — <note>`: a honoured
318
+ * `--attest-lineage-lost` waiver, or a PROVEN DRIFT origin (`descent: drift-origin proven
319
+ * (never-declared | hand-edited since … | applied from a DIRTY worktree at …)`) — the record that makes this row the origin every
320
+ * later apply descends from. Read at WRITE time (a getter, because the decision is made in the
321
+ * descent step that runs before this). The witness query matches `applied%`, so an annotated
322
+ * row is still a witness.
320
323
  */
321
324
  descentNote?: () => string | undefined;
322
325
  }