@everystack/cli 0.4.76 → 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 +19 -0
- package/package.json +1 -1
- package/src/cli/apply-execute.ts +12 -4
- package/src/cli/apply-refusals.ts +1 -1
- package/src/cli/authz-reconcile.ts +26 -12
- package/src/cli/commands/db-adopt.ts +10 -5
- package/src/cli/commands/db-apply.ts +97 -42
- package/src/cli/commands/db-plan.ts +44 -23
- package/src/cli/commands/db-pull.ts +6 -4
- package/src/cli/git-descent.ts +358 -94
- package/src/cli/index.ts +2 -2
- package/src/cli/state-apply.ts +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ 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
|
+
|
|
7
26
|
## [0.4.76] - 2026-09-07
|
|
8
27
|
|
|
9
28
|
### Fixed
|
package/package.json
CHANGED
package/src/cli/apply-execute.ts
CHANGED
|
@@ -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
|
|
135
|
-
* `--attest-lineage-lost`
|
|
136
|
-
*
|
|
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
|
|
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,
|
|
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 —
|
|
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
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* the
|
|
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
|
-
/**
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
643
|
+
...(attestLineageLost ? { attestation: attestLineageLost } : {}),
|
|
586
644
|
});
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
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
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
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
|
package/src/cli/git-descent.ts
CHANGED
|
@@ -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).
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
472
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
if (
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
709
|
-
//
|
|
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}
|
|
714
|
-
:
|
|
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
|
|
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
|
|
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
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -314,9 +314,12 @@ export interface StateApplyOptions {
|
|
|
314
314
|
/** Injectable clock for tests. */
|
|
315
315
|
now?: () => number;
|
|
316
316
|
/**
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
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
|
}
|