@everystack/cli 0.4.77 → 0.4.79
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 +31 -4
- package/package.json +1 -1
- package/src/cli/authz-adoption-class.ts +112 -22
- package/src/cli/commands/db-apply.ts +12 -3
- package/src/cli/commands/db-plan.ts +64 -8
- package/src/cli/commands/db-pull.ts +31 -0
- package/src/cli/edge-plan.ts +18 -3
- package/src/cli/git-descent.ts +290 -48
- package/src/cli/index.ts +1 -6
- package/src/cli/adopt.ts +0 -58
- package/src/cli/commands/db-adopt.ts +0 -243
- package/src/cli/live-models.ts +0 -65
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ 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.79] - 2026-09-07
|
|
8
|
+
|
|
9
|
+
### Removed
|
|
10
|
+
|
|
11
|
+
- remove db:adopt — its proof was "would db:plan be empty", and db:plan answers that on the review surface
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- db:pull ends with the on-ramp runbook; docs and help say the on-ramp is pull, commit, plan
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- closing-gate round 1 — the as-is lane runs the shipped db:pull, the runbook is gated on --abilities live, the fixture proves live derivation, live-models.ts is gone
|
|
20
|
+
|
|
21
|
+
## [0.4.78] - 2026-09-07
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- the adoption report describes the plan it ships with
|
|
26
|
+
- the unreadable-tree refusal names the refs that carry each tree and leads with the fix the ledger supports
|
|
27
|
+
- a squashed successor proves the fast-forward; the attest resolves through git; the plan predicts the apply; an empty edge names what carries the hash difference
|
|
28
|
+
- closing-gate round 1 — app-only squash, squash proof recorded and announced, bounded runbook, scrubbed errors, git timeouts
|
|
29
|
+
- closing-gate round 2 — doc matches the shipped squash rule, the round-1 fixes are pinned, every git call is bounded, the squash guard refuses when git cannot answer
|
|
30
|
+
|
|
7
31
|
## [0.4.77] - 2026-09-07
|
|
8
32
|
|
|
9
33
|
### Added
|
|
@@ -931,6 +955,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
931
955
|
|
|
932
956
|
## [0.2.37] - 2026-06-17
|
|
933
957
|
|
|
958
|
+
### Removed
|
|
959
|
+
|
|
960
|
+
- remove onMutation callback — events replaces it
|
|
961
|
+
- remove unused @everystack/jobs devDependency
|
|
962
|
+
- remove secret auto-injection, skip SSR check for web-only updates
|
|
963
|
+
- remove media asset pruning from update command
|
|
964
|
+
|
|
934
965
|
### Added
|
|
935
966
|
|
|
936
967
|
- rename @everystack/updates → @everystack/cli, add db:migrate/db:seed, remove AdminSecret
|
|
@@ -982,7 +1013,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
982
1013
|
### Fixed
|
|
983
1014
|
|
|
984
1015
|
- normalize cache:purge --path to strip query params and ensure leading slash
|
|
985
|
-
- remove unused @everystack/jobs devDependency
|
|
986
1016
|
- include updateId in register-web payload
|
|
987
1017
|
- psql Lambda-only queries, composite key notify, logging defaults
|
|
988
1018
|
- add AWS KVS + SigV4a as direct dependencies
|
|
@@ -1001,14 +1031,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
1001
1031
|
- drop GetFunctionConfiguration from log group resolution
|
|
1002
1032
|
- detect export completion to prevent static pre-rendering hang
|
|
1003
1033
|
- rewrite secrets for SST Ion, filter EXPO_PUBLIC_* only
|
|
1004
|
-
- remove secret auto-injection, skip SSR check for web-only updates
|
|
1005
1034
|
- clear Metro transform cache on export
|
|
1006
1035
|
- worker function discovery and log group resolution for SQS subscribers
|
|
1007
1036
|
- replace infrastructure headers with single X-Server-Debug timestamp
|
|
1008
1037
|
- version bumps and publish fixes for cli, server, jobs
|
|
1009
1038
|
- invalidate stale config cache on schema change
|
|
1010
1039
|
- fall back to outputs.json for custom-named media buckets
|
|
1011
|
-
- remove media asset pruning from update command
|
|
1012
1040
|
- media-sync test uses same minimatch interop as source
|
|
1013
1041
|
- discover the Router distribution by domain and SST tags
|
|
1014
1042
|
- use minimatch's named export — the package ships types now
|
|
@@ -1017,7 +1045,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
1017
1045
|
|
|
1018
1046
|
### Changed
|
|
1019
1047
|
|
|
1020
|
-
- remove onMutation callback — events replaces it
|
|
1021
1048
|
- migrate all package schemas to everyschema canonical tables
|
|
1022
1049
|
- lazy-load AWS SDK imports for faster CLI startup
|
|
1023
1050
|
- drop type module from all packages, switch Jest to CJS mode
|
package/package.json
CHANGED
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
import { matchPolicies, roleSetEqual } from './authz-identity';
|
|
32
32
|
import { governedRoleSet } from './authz-reconcile';
|
|
33
|
-
import {
|
|
33
|
+
import { tablePoliciesEquivalent } from './authz-canonical';
|
|
34
|
+
import { effectivePolicyCheck, holdsPrivilegeEffective, isPolicyDead, policyCommands } from './authz-contract';
|
|
34
35
|
import type { AuthzContract, PolicyContract, TableContract } from './authz-contract';
|
|
35
36
|
|
|
36
37
|
export type AdoptionClass = 'convention' | 'capability' | 'narrowing' | 'dead' | 'unclassified';
|
|
@@ -78,24 +79,66 @@ function covers(outer: readonly string[], inner: readonly string[]): boolean {
|
|
|
78
79
|
return inner.every((r) => o.has(r));
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
/**
|
|
83
|
+
* The predicate slots ONE command enforces — the same fan the canonical form uses. SELECT and
|
|
84
|
+
* DELETE have no WITH CHECK; INSERT has no USING and its check falls back to USING when the
|
|
85
|
+
* source is a `FOR ALL` with no explicit check (the server's own defaulting rule); UPDATE and
|
|
86
|
+
* ALL carry both. Comparing policies through this is what lets a declared INSERT be measured
|
|
87
|
+
* against the INSERT arm of a live ALL instead of against a USING the INSERT never had.
|
|
88
|
+
*/
|
|
89
|
+
function armOf(p: PolicyContract, command: string): { using: string; check: string } {
|
|
90
|
+
const using = command === 'INSERT' ? '' : (p.using ?? '');
|
|
91
|
+
// ONE definition of the server's WITH CHECK defaulting rule — effectivePolicyCheck, shared with
|
|
92
|
+
// the matcher, the reconciler and the retained v10 canon.
|
|
93
|
+
const check = command === 'SELECT' || command === 'DELETE' ? '' : (effectivePolicyCheck(p) ?? '');
|
|
94
|
+
return { using, check };
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
/**
|
|
90
|
-
*
|
|
98
|
+
* Same authorization on every command the replacement governs, roles aside — the role-split and
|
|
99
|
+
* per-command tests. Arm for arm, so a live `FOR ALL USING (x)` is the same rule as a declared
|
|
100
|
+
* `INSERT WITH CHECK (x)` on INSERT. Whether the replacement covers EVERY live command is a
|
|
101
|
+
* separate question (`aliveCommands`), answered by the caller.
|
|
102
|
+
*/
|
|
103
|
+
function sameRule(r: PolicyContract, live: PolicyContract): boolean {
|
|
104
|
+
if (r.permissive !== live.permissive) return false;
|
|
105
|
+
const liveCommands = commandsOf(live);
|
|
106
|
+
return commandsOf(r).every((cmd) => {
|
|
107
|
+
if (!liveCommands.includes(cmd)) return false;
|
|
108
|
+
const a = armOf(r, cmd);
|
|
109
|
+
const b = armOf(live, cmd);
|
|
110
|
+
return a.using === b.using && a.check === b.check;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The commands a live policy actually governs — the arms some role it names holds the grant for. */
|
|
115
|
+
function aliveCommands(t: TableContract, p: PolicyContract): string[] {
|
|
116
|
+
const roles = p.roles.includes('public') || p.roles.includes('PUBLIC')
|
|
117
|
+
? Object.keys(t.effectivePrivileges ?? t.grants)
|
|
118
|
+
: p.roles;
|
|
119
|
+
return commandsOf(p).filter((cmd) => roles.some((r) => holdsPrivilegeEffective(t, r, cmd)));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A policy's predicate, collapsed to one line for the report, in the slot its command enforces:
|
|
124
|
+
* an INSERT is shown by its WITH CHECK (it has no USING — rendering one printed
|
|
125
|
+
* "true (unfiltered)" for every INSERT policy, which is what the consumer read as an unfiltered
|
|
126
|
+
* rule); UPDATE and ALL show both halves when they differ; SELECT and DELETE show USING.
|
|
91
127
|
*
|
|
92
128
|
* Live predicates arrive from `pg_get_expr` with real newlines and indentation (an EXISTS
|
|
93
129
|
* subquery spans several lines), and the adoption report is one line per statement. Collapsing
|
|
94
130
|
* keeps the rule READABLE in place rather than referring the operator elsewhere to find it.
|
|
95
131
|
*/
|
|
96
132
|
function pred(p: PolicyContract): string {
|
|
97
|
-
const
|
|
98
|
-
|
|
133
|
+
const one = (s: string | null | undefined): string => {
|
|
134
|
+
const t = (s ?? 'true').replace(/\s+/g, ' ').trim();
|
|
135
|
+
return t === 'true' ? 'true (unfiltered)' : t;
|
|
136
|
+
};
|
|
137
|
+
if (p.command === 'INSERT') return `WITH CHECK ${one(p.check)}`;
|
|
138
|
+
if (p.command === 'SELECT' || p.command === 'DELETE') return `USING ${one(p.using)}`;
|
|
139
|
+
const using = one(p.using);
|
|
140
|
+
const check = one(p.check ?? p.using);
|
|
141
|
+
return check === using ? `USING ${using}` : `USING ${using} WITH CHECK ${check}`;
|
|
99
142
|
}
|
|
100
143
|
|
|
101
144
|
// `commandsOf` and the dead-policy test live in authz-contract — ONE definition, shared with
|
|
@@ -106,6 +149,16 @@ const isDead = isPolicyDead;
|
|
|
106
149
|
/** Classify the policy statements one table would emit. */
|
|
107
150
|
function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedStatement[], governed: ReadonlySet<string>): void {
|
|
108
151
|
const table = d.table;
|
|
152
|
+
|
|
153
|
+
// The reconciler's own gate, mirrored (authz-reconcile reconcilePolicies, Condition B): two
|
|
154
|
+
// tables that are the SAME authorization under the canonical fan emit nothing, so there is
|
|
155
|
+
// nothing to classify. A live `FOR ALL` whose alive arms the declared per-command split covers
|
|
156
|
+
// is the shape that escaped — the per-policy matcher below cannot see it (a live ALL is never
|
|
157
|
+
// the same policy as a declared INSERT), so this classifier counted four narrowings for a plan
|
|
158
|
+
// that carried none. Gating on the same predicate the emitter gates on is the only way the
|
|
159
|
+
// two can never disagree.
|
|
160
|
+
if (tablePoliciesEquivalent(d, l, governed)) return;
|
|
161
|
+
|
|
109
162
|
const m = matchPolicies(d.policies, l.policies);
|
|
110
163
|
const liveByName = new Map(l.policies.map((p) => [p.name, p]));
|
|
111
164
|
|
|
@@ -144,12 +197,18 @@ function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedSta
|
|
|
144
197
|
|
|
145
198
|
const replacementRoles = [...new Set(replacements.flatMap((r) => r.roles))];
|
|
146
199
|
|
|
200
|
+
// The COMMAND axis: which of the live policy's alive arms no replacement governs at all. A
|
|
201
|
+
// live ALL replaced by an INSERT-only declaration loses SELECT/UPDATE/DELETE — a narrowing
|
|
202
|
+
// no role or predicate comparison can see. Dead arms are not access, so they are not lost.
|
|
203
|
+
const liveAlive = aliveCommands(l, live);
|
|
204
|
+
const uncovered = liveAlive.filter((cmd) => !replacements.some((r) => commandsOf(r).includes(cmd)));
|
|
205
|
+
|
|
147
206
|
// The role split: one live policy covering several roles, against the per-role policies the
|
|
148
207
|
// compiler emits. Same predicate, and the declared roles union to EXACTLY the live role set,
|
|
149
208
|
// so the two authorize identically for every session. Pure spelling — ours to fix (A5).
|
|
150
209
|
// Note this is recognized here for LABELING before the matcher can act on it; the two must
|
|
151
210
|
// agree once A5 lands, which is what the emitter-parity test pins.
|
|
152
|
-
if (roleSetEqual(live.roles, replacementRoles) && replacements.every((r) => sameRule(r, live))) {
|
|
211
|
+
if (roleSetEqual(live.roles, replacementRoles) && !uncovered.length && replacements.every((r) => sameRule(r, live))) {
|
|
153
212
|
out.push({ table, subject: name, cls: 'convention', why: `live covers ${live.roles.join(' + ')} in one policy; we split it per role with the same predicate` });
|
|
154
213
|
for (const r of replacements) {
|
|
155
214
|
if (consumed.has(r.name)) continue;
|
|
@@ -181,21 +240,48 @@ function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedSta
|
|
|
181
240
|
// we cannot prove is identical is reported as a reduction, and BOTH predicates are carried
|
|
182
241
|
// so the operator reads the rule rather than the label.
|
|
183
242
|
const sameRoleReplacements = replacements.filter((r) => r.roles.some((role) => live.roles.includes(role)));
|
|
184
|
-
|
|
185
|
-
|
|
243
|
+
const shared = [...new Set(sameRoleReplacements.flatMap((r) => r.roles.filter((role) => live.roles.includes(role))))];
|
|
244
|
+
|
|
245
|
+
// Same roles, same rule on every command the replacements govern, but some live command is
|
|
246
|
+
// left with no policy at all — the command-axis narrowing.
|
|
247
|
+
if (sameRoleReplacements.length && uncovered.length && sameRoleReplacements.every((r) => sameRule(r, live))) {
|
|
248
|
+
const covered = liveAlive.filter((cmd) => !uncovered.includes(cmd));
|
|
249
|
+
out.push({
|
|
250
|
+
table,
|
|
251
|
+
subject: name,
|
|
252
|
+
cls: 'narrowing',
|
|
253
|
+
why:
|
|
254
|
+
`live governs ${liveAlive.join('/')} for ${shared.join(', ')} ${pred(live)}; the declared replacement `
|
|
255
|
+
+ `${sameRoleReplacements.map((r) => `"${r.name}"`).join(', ')} keeps the same rule but governs only `
|
|
256
|
+
+ `${covered.join('/')} — ${uncovered.join('/')} loses its policy`,
|
|
257
|
+
});
|
|
258
|
+
for (const r of sameRoleReplacements) {
|
|
259
|
+
if (consumed.has(r.name)) continue;
|
|
260
|
+
consumed.add(r.name);
|
|
261
|
+
out.push({ table, subject: r.name, cls: 'narrowing', why: `the narrower replacement for "${name}"` });
|
|
262
|
+
}
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (sameRoleReplacements.length && !sameRoleReplacements.every((r) => sameRule(r, live))) {
|
|
267
|
+
const differing = sameRoleReplacements.filter((r) => !sameRule(r, live));
|
|
186
268
|
out.push({
|
|
187
269
|
table,
|
|
188
270
|
subject: name,
|
|
189
271
|
cls: 'narrowing',
|
|
190
272
|
why:
|
|
191
|
-
`live applies to ${shared.join(', ')}
|
|
192
|
-
+ `${
|
|
193
|
-
+ `${
|
|
273
|
+
`live applies to ${shared.join(', ')} ${pred(live)}; the declared replacement `
|
|
274
|
+
+ `${differing.map((r) => `"${r.name}"`).join(', ')} keeps the same role(s) but states `
|
|
275
|
+
+ `${differing.map((r) => pred(r)).join(' / ')} — a different rule, not a different spelling`,
|
|
194
276
|
});
|
|
195
277
|
for (const r of sameRoleReplacements) {
|
|
196
278
|
if (consumed.has(r.name)) continue;
|
|
197
279
|
consumed.add(r.name);
|
|
198
|
-
|
|
280
|
+
// A replacement that DOES match its live arm exists only because the differing one forced
|
|
281
|
+
// the live policy to be replaced — our spelling, emitted for a real difference next to it.
|
|
282
|
+
out.push(sameRule(r, live)
|
|
283
|
+
? { table, subject: r.name, cls: 'convention', why: `the per-command half of "${name}", same rule; emitted because "${differing[0].name}" differs` }
|
|
284
|
+
: { table, subject: r.name, cls: 'narrowing', why: `the differently-predicated replacement for "${name}"` });
|
|
199
285
|
}
|
|
200
286
|
continue;
|
|
201
287
|
}
|
|
@@ -298,17 +384,21 @@ export function classifyAdoption(
|
|
|
298
384
|
* inline is a wall nobody reads, and they are each described by their class.
|
|
299
385
|
*/
|
|
300
386
|
export function renderAdoptionReport(counts: AdoptionCounts, statements: readonly ClassifiedStatement[] = []): string[] {
|
|
387
|
+
const row = (label: string, n: number, why: string): string => ` ${label.padEnd(18)} ${n} — ${why}`;
|
|
301
388
|
const lines = [
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
389
|
+
row('convention', counts.convention, 'our spelling, not their schema (this must reach 0)'),
|
|
390
|
+
row('capability', counts.capability, 'the model cannot express what is live'),
|
|
391
|
+
row('narrowing (access)', counts.narrowing, 'real access reduction, approve before applying'),
|
|
392
|
+
row('dead', counts.dead, 'policed but never granted'),
|
|
306
393
|
];
|
|
307
394
|
if (counts.unclassified) {
|
|
308
|
-
lines.push(
|
|
395
|
+
lines.push(row('unclassified', counts.unclassified, 'NOT described; read these before applying:'));
|
|
309
396
|
for (const s of statements.filter((s) => s.cls === 'unclassified')) {
|
|
310
397
|
lines.push(` ${s.table} "${s.subject}" — ${s.why}`);
|
|
311
398
|
}
|
|
312
399
|
}
|
|
400
|
+
// The name collision from the module header, said where the operator can see it: the plan
|
|
401
|
+
// file's `narrowings` is the DESTRUCTIVE axis (lossy type changes) and is counted elsewhere.
|
|
402
|
+
lines.push(' (access counts above are policies and grants; the plan file\'s `narrowings` counts lossy type changes — the destructive axis, reported separately)');
|
|
313
403
|
return lines;
|
|
314
404
|
}
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
attestationNote,
|
|
41
41
|
proveLineageLost,
|
|
42
42
|
realLineageProbe,
|
|
43
|
+
resolveAttestation,
|
|
43
44
|
type DescentLedgerRow,
|
|
44
45
|
type LiveState,
|
|
45
46
|
type ProveVerdict,
|
|
@@ -118,7 +119,12 @@ export function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void
|
|
|
118
119
|
export function describeDescentVerdict(verdict: ProveVerdict, opts: { destructive?: boolean } = {}): { level: 'info' | 'warn'; text: string } | null {
|
|
119
120
|
switch (verdict.status) {
|
|
120
121
|
case 'proven':
|
|
121
|
-
|
|
122
|
+
// A squash proof names BOTH commits: the carrier that is an ancestor of HEAD, and the
|
|
123
|
+
// deploying commit that is not — announcing the carrier alone as "an ancestor" hid that
|
|
124
|
+
// the proof rests on tree identity (closing-gate review, 2026-09-07).
|
|
125
|
+
return verdict.via === 'squash' && verdict.witness
|
|
126
|
+
? { level: 'info', text: `descent: proven via squash — ${verdict.commit.slice(0, 12)} (an ancestor of HEAD) carries the models tree of the deploying commit ${verdict.witness.slice(0, 12)}, which HEAD does not descend from; recorded in schema_log.` }
|
|
127
|
+
: { level: 'info', text: `descent: proven via ${verdict.via} — ${verdict.commit.slice(0, 12)} is an ancestor of HEAD, fast-forward.` };
|
|
122
128
|
case 'fresh-target':
|
|
123
129
|
return { level: 'info', text: 'descent: fresh target (no tables) — nothing to protect, bootstrapping.' };
|
|
124
130
|
case 'no-git':
|
|
@@ -166,15 +172,18 @@ export async function proveDescentForApply(input: {
|
|
|
166
172
|
* Production passes nothing and rides the real loader. */
|
|
167
173
|
descent?: Pick<ProveDescentOptions, 'loader' | 'materializeRoot'>;
|
|
168
174
|
}): Promise<{ descentVerdict: { ok: true } | { ok: false; reason: string }; descentAttested?: string }> {
|
|
175
|
+
const cwd = input.cwd ?? process.cwd();
|
|
169
176
|
const verdict = await proveDescent(input.modelsPath, input.live, input.ledger, {
|
|
170
177
|
...(input.cwd ? { cwd: input.cwd } : {}),
|
|
171
178
|
...(input.descent ?? {}),
|
|
172
179
|
});
|
|
173
180
|
reportDescentVerdict(verdict, { destructive: input.destructive });
|
|
181
|
+
// The attestation is resolved THROUGH git (a prefix, tag or HEAD~n → the full sha) before it is
|
|
182
|
+
// compared; a value git cannot resolve passes through and the prefix compare decides.
|
|
174
183
|
const decision = decideDescentApply(verdict, {
|
|
175
|
-
...(input.attestation ? { attestation: input.attestation } : {}),
|
|
184
|
+
...(input.attestation ? { attestation: resolveAttestation(input.attestation, cwd) } : {}),
|
|
176
185
|
destructive: input.destructive,
|
|
177
|
-
proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(
|
|
186
|
+
proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(cwd)),
|
|
178
187
|
});
|
|
179
188
|
if (!decision.ok) return { descentVerdict: { ok: false, reason: decision.reason } };
|
|
180
189
|
if (decision.attested) {
|
|
@@ -50,7 +50,9 @@ 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, describeDriftOrigin, type ProveVerdict } from '../git-descent';
|
|
53
|
+
import { proveDescent, describeDriftOrigin, proveLineageLost, realLineageProbe, type ProveVerdict, type LineageProof } from '../git-descent';
|
|
54
|
+
import { fingerprintLive, divergentSurfaces } from '../schema-fingerprint';
|
|
55
|
+
import { predictLiveState } from '../edge-plan';
|
|
54
56
|
import { readDescentLedger } from '../apply-execute';
|
|
55
57
|
import { planBackfills, readBackfillLog } from '../backfill';
|
|
56
58
|
import { readSqlDirIfPresent } from './db-sync';
|
|
@@ -77,9 +79,42 @@ import { step, success, fail, info, warn, reserveStdoutForData } from '../output
|
|
|
77
79
|
* exhaustive: a new verdict fails to compile here instead of minting a plan with no warning and
|
|
78
80
|
* refusing at apply with no hint of why.
|
|
79
81
|
*/
|
|
80
|
-
export function describePlanDescent(
|
|
82
|
+
export function describePlanDescent(
|
|
83
|
+
verdict: ProveVerdict,
|
|
84
|
+
opts: { proveLineage?: (sha: string) => LineageProof } = {},
|
|
85
|
+
): string[] {
|
|
86
|
+
// The plan predicts the apply. For a verdict that names a declaring commit, run the SAME lineage
|
|
87
|
+
// proof db:apply runs and say what it will do with it: a commit reachable from a ref → rebase
|
|
88
|
+
// onto that ref; a commit reachable from NO ref → the exact attestation apply will honour. The
|
|
89
|
+
// two surfaces disagreed once (consumer cutover, cli 0.4.77): plan said "will refuse — rebase"
|
|
90
|
+
// on a commit no ref held, and apply honoured the attest. Phase A tests OBJECT presence, so a
|
|
91
|
+
// ref-less commit still reads as a revert until the lineage proof is asked.
|
|
92
|
+
const applyWillDo = (commit: string): string => {
|
|
93
|
+
const proof = opts.proveLineage?.(commit);
|
|
94
|
+
if (!proof) return 'db:apply from this checkout will refuse this plan — rebase first, then re-mint.';
|
|
95
|
+
if (proof.proven) {
|
|
96
|
+
return `db:apply from this checkout will refuse this plan without an attestation; commit ${commit} is reachable from no ref ` +
|
|
97
|
+
`(${proof.subcase}), so it will honour --attest-lineage-lost ${commit} — or fetch the branch that had it, rebase, and re-mint.`;
|
|
98
|
+
}
|
|
99
|
+
if (proof.kind === 'reachable' && proof.ref && proof.ref !== 'HEAD') {
|
|
100
|
+
// `ref` is a full ref name, or a REMOTE name when the commit was found in a remote branch's
|
|
101
|
+
// ancestry (the probe sees advertised tips, not their branch names) — you cannot rebase onto
|
|
102
|
+
// a remote, so that case says fetch first. `HEAD` cannot reach here from a revert (the commit
|
|
103
|
+
// is not an ancestor of HEAD by construction); if it ever did, the generic reason is safer
|
|
104
|
+
// than "git fetch HEAD".
|
|
105
|
+
return proof.ref.startsWith('refs/')
|
|
106
|
+
? `db:apply from this checkout will refuse this plan — that commit is still on ${proof.ref}; rebase onto it, then re-mint.`
|
|
107
|
+
: `db:apply from this checkout will refuse this plan — that commit is still on remote '${proof.ref}'; fetch it (git fetch ${proof.ref}), rebase onto the branch that has it, then re-mint.`;
|
|
108
|
+
}
|
|
109
|
+
return `db:apply from this checkout will refuse this plan — ${proof.reason}`;
|
|
110
|
+
};
|
|
81
111
|
switch (verdict.status) {
|
|
82
112
|
case 'proven':
|
|
113
|
+
// A squash proof is the one PROVEN shape whose deploying commit is not in the operator's
|
|
114
|
+
// history — say so where a human reads the plan, as the apply record will.
|
|
115
|
+
return verdict.via === 'squash' && verdict.witness
|
|
116
|
+
? [`descent: fast-forward proven via a squashed successor — ${verdict.commit.slice(0, 12)} (an ancestor of HEAD) carries the models tree of the deploying commit ${verdict.witness.slice(0, 12)}, which HEAD does not descend from. db:apply will record that.`]
|
|
117
|
+
: [];
|
|
83
118
|
case 'fresh-target':
|
|
84
119
|
case 'no-git':
|
|
85
120
|
return [];
|
|
@@ -94,10 +129,19 @@ export function describePlanDescent(verdict: ProveVerdict): string[] {
|
|
|
94
129
|
'db:apply from this checkout will refuse this plan — fetch and merge or rebase onto that commit, then re-mint.',
|
|
95
130
|
];
|
|
96
131
|
case 'revert':
|
|
97
|
-
return [`descent: ${verdict.reason}`,
|
|
98
|
-
case 'unknown-commit':
|
|
99
|
-
// NOT "rebase first" — the checkout is fine, it is just missing the deploying commit.
|
|
100
|
-
|
|
132
|
+
return [`descent: ${verdict.reason}`, applyWillDo(verdict.commit)];
|
|
133
|
+
case 'unknown-commit': {
|
|
134
|
+
// NOT "rebase first" — the checkout is fine, it is just missing the deploying commit. Unless
|
|
135
|
+
// the lineage proof says the commit is gone from every remote too: then apply will honour the
|
|
136
|
+
// attestation, and fetching cannot help.
|
|
137
|
+
const proof = opts.proveLineage?.(verdict.commit);
|
|
138
|
+
return [
|
|
139
|
+
`descent: ${verdict.reason}`,
|
|
140
|
+
proof?.proven
|
|
141
|
+
? applyWillDo(verdict.commit)
|
|
142
|
+
: 'db:apply from this checkout will refuse this plan until you fetch that commit — fetch, then re-mint.',
|
|
143
|
+
];
|
|
144
|
+
}
|
|
101
145
|
case 'unverifiable':
|
|
102
146
|
// NOT "rebase first" — nothing is wrong with the checkout or the database. The search could
|
|
103
147
|
// not finish (an unreadable tree, or a clone that is not full and current).
|
|
@@ -293,7 +337,17 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
293
337
|
await fs.writeFile(out, body, 'utf8');
|
|
294
338
|
}
|
|
295
339
|
|
|
296
|
-
|
|
340
|
+
// An EMPTY edge between two different fingerprints reads as a contradiction. `from` is the
|
|
341
|
+
// target's full fingerprint (the concurrency lock at apply); `to` is the declared endpoint
|
|
342
|
+
// through the models' governed set. They differ by exactly what the plan leaves alone — name
|
|
343
|
+
// it, computed at mint from the two canonical forms, never stored on the plan.
|
|
344
|
+
const carriedBy = plan.statements.length === 0 && plan.fromFingerprint !== plan.toFingerprint
|
|
345
|
+
? divergentSurfaces(
|
|
346
|
+
predictLiveState(models, snapshot, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles) }).canonical,
|
|
347
|
+
fingerprintLive(snapshot, contract).canonical,
|
|
348
|
+
)
|
|
349
|
+
: undefined;
|
|
350
|
+
for (const line of buildPlanSummary(plan, carriedBy ? { carriedBy } : {})) info(line);
|
|
297
351
|
info(`plan_ref: ${planHash(plan).slice(0, 12)}`);
|
|
298
352
|
|
|
299
353
|
// WHY does each authorization statement exist? — the same report db:generate prints,
|
|
@@ -331,7 +385,9 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
331
385
|
// A switch, not an `if (revert || drift)`. That form silently ignored every verdict it
|
|
332
386
|
// did not name, so a new one would mint a plan with no warning and then be refused at
|
|
333
387
|
// apply time with no hint of why. Exhaustive here means a new verdict cannot be forgotten.
|
|
334
|
-
|
|
388
|
+
// A PROVEN verdict is information, as it is on the apply surface; everything else is a warning.
|
|
389
|
+
const say = verdict.status === 'proven' ? info : warn;
|
|
390
|
+
for (const line of describePlanDescent(verdict, { proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(process.cwd())) })) say(line);
|
|
335
391
|
}
|
|
336
392
|
|
|
337
393
|
// Visibility, never automation (brick 8): the review surface names the
|
|
@@ -38,6 +38,36 @@ import { fingerprintLive } from '../schema-fingerprint';
|
|
|
38
38
|
import { ungovernedGrants, vestigialSequenceGrants, renderVestigialSequenceGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile';
|
|
39
39
|
import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline';
|
|
40
40
|
import { introspectDerived, type DerivedCatalog } from '../derived-introspect';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The on-ramp runbook, printed last so the operator is never left guessing what a pull is FOR.
|
|
44
|
+
*
|
|
45
|
+
* db:pull is the only way an existing database comes in. What happens next is decided by
|
|
46
|
+
* db:plan, not by a verb: an EMPTY plan means the committed Models declare the database as it
|
|
47
|
+
* is (adopted — descent proves the pull commit by recompile, and every later apply descends from
|
|
48
|
+
* it); a NON-EMPTY plan is the hardening edge, reviewed and applied as the origin. db:adopt used
|
|
49
|
+
* to bolt a proof onto this render; the proof was "would the plan be empty", so the verb is gone
|
|
50
|
+
* (cli 0.4.79) and this is what replaced it. Exported so a test pins the lines and the
|
|
51
|
+
* refusal-runbook lint reads them.
|
|
52
|
+
*/
|
|
53
|
+
export function pullOnRampRunbook(opts: { stage?: string; out?: string; abilities?: string }): string[] {
|
|
54
|
+
const target = opts.stage ? `--stage ${opts.stage}` : '--stage <name>';
|
|
55
|
+
const what = opts.out ? `the files in ${opts.out}` : 'these Models';
|
|
56
|
+
// Only a LIVE pull transcribes the database's own authz; only then can an empty plan mean
|
|
57
|
+
// "adopted as it is". A commented or public-read pull carries authored abilities — its plan is
|
|
58
|
+
// an edge to review (public-read on an existing database GRANTS public read of every table),
|
|
59
|
+
// never an adoption, and the runbook must not call it one (closing-gate review, 2026-09-07).
|
|
60
|
+
if (opts.abilities !== 'live') {
|
|
61
|
+
return [
|
|
62
|
+
`Next: author each model's abilities (db:check fails until every model declares) — for an EXISTING database, re-pull with --abilities live to derive them from the grants and policies already there. Then commit ${what} and run \`everystack db:plan ${target}\`: the plan is an edge to review, not an adoption of what is live.`,
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
return [
|
|
66
|
+
`Next: commit ${what}, then \`everystack db:plan ${target}\`.`,
|
|
67
|
+
'EMPTY plan = adopted as it is: that commit declares the database, and every later db:apply descends from it. Nothing to apply.',
|
|
68
|
+
`NON-EMPTY plan = the hardening edge: review it, then \`everystack db:apply --plan <file> ${target}\`. No commit declares this database yet, so the reviewed plan is the ORIGIN — db:apply proves that and records it.`,
|
|
69
|
+
];
|
|
70
|
+
}
|
|
41
71
|
import { introspectContract, type TableContract, type AuthzContract } from '../authz-contract';
|
|
42
72
|
import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership';
|
|
43
73
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
@@ -461,5 +491,6 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
461
491
|
note(`Committing this file ADOPTS those roles. Anything not in it — or anything that grows beyond it — refuses at db:plan.`);
|
|
462
492
|
}
|
|
463
493
|
}
|
|
494
|
+
for (const line of pullOnRampRunbook({ stage: flags.stage, out: flags.out, abilities })) note(line);
|
|
464
495
|
process.exit(0);
|
|
465
496
|
}
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -409,12 +409,27 @@ export function checkPlanPrecondition(plan: EdgePlan, liveFingerprint: string):
|
|
|
409
409
|
|
|
410
410
|
const short = (hash: string): string => hash.slice(0, 12);
|
|
411
411
|
|
|
412
|
-
/**
|
|
413
|
-
|
|
412
|
+
/**
|
|
413
|
+
* Human summary of a plan — the review surface.
|
|
414
|
+
*
|
|
415
|
+
* `carriedBy` names what accounts for an EMPTY edge between two DIFFERENT fingerprints: `from`
|
|
416
|
+
* is the target's full fingerprint (the concurrency lock), `to` the declared endpoint through the
|
|
417
|
+
* governed set, and they differ by exactly the live objects the plan leaves alone — an ungoverned
|
|
418
|
+
* role's grants, a policy the vocabulary cannot declare. Without the name the line "the target
|
|
419
|
+
* already declares this state" under two different hashes reads as a contradiction (consumer
|
|
420
|
+
* cutover, cli 0.4.77). The caller computes it at mint from the canonical forms; it is never
|
|
421
|
+
* stored on the plan.
|
|
422
|
+
*/
|
|
423
|
+
export function buildPlanSummary(plan: EdgePlan, opts: { carriedBy?: string[] } = {}): string[] {
|
|
414
424
|
const lines: string[] = [];
|
|
415
425
|
lines.push(`edge: ${short(plan.fromFingerprint)} → ${short(plan.toFingerprint)}`);
|
|
416
426
|
if (plan.statements.length === 0) {
|
|
417
|
-
|
|
427
|
+
if (plan.fromFingerprint === plan.toFingerprint) {
|
|
428
|
+
lines.push('empty — the target already declares this state');
|
|
429
|
+
} else {
|
|
430
|
+
lines.push('empty — no statement to emit. The fingerprints differ by what the plan leaves alone: `from` is the target\'s full fingerprint (the concurrency lock), `to` the declared endpoint through the governed set'
|
|
431
|
+
+ (opts.carriedBy?.length ? `; carried by: ${opts.carriedBy.join('; ')}` : '') + '.');
|
|
432
|
+
}
|
|
418
433
|
return lines;
|
|
419
434
|
}
|
|
420
435
|
lines.push(`${plan.executable} executable statement(s), ${plan.notices} notice(s)`);
|