@everystack/cli 0.4.76 → 0.4.78
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 +29 -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-adoption-class.ts +112 -22
- package/src/cli/authz-reconcile.ts +26 -12
- package/src/cli/commands/db-adopt.ts +10 -5
- package/src/cli/commands/db-apply.ts +106 -42
- package/src/cli/commands/db-plan.ts +101 -24
- package/src/cli/commands/db-pull.ts +6 -4
- package/src/cli/edge-plan.ts +18 -3
- package/src/cli/git-descent.ts +624 -119
- package/src/cli/index.ts +2 -2
- package/src/cli/state-apply.ts +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ 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.78] - 2026-09-07
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- the adoption report describes the plan it ships with
|
|
12
|
+
- the unreadable-tree refusal names the refs that carry each tree and leads with the fix the ledger supports
|
|
13
|
+
- 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
|
|
14
|
+
- closing-gate round 1 — app-only squash, squash proof recorded and announced, bounded runbook, scrubbed errors, git timeouts
|
|
15
|
+
- 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
|
|
16
|
+
|
|
17
|
+
## [0.4.77] - 2026-09-07
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- proven drift is the operator's decision — passes at apply with its origin recorded
|
|
22
|
+
- proven drift is announced as the origin on both lanes; refusals name a verb that works
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- currency is ref reachability, not object presence; a failed all-refs walk is unverifiable
|
|
27
|
+
- an empty barrel declares nothing; a non-commit tag is not 'behind'; tests bind the production decision
|
|
28
|
+
- one shipped descent step for both lanes, bound end to end by the real CLI; trees materialize inside the project
|
|
29
|
+
- the sandbox sits below the nearest node_modules above the barrel; a dirty-worktree origin reaches every surface
|
|
30
|
+
- the compile-failure detail keeps the cause; the monorepo test skips loudly; the stash filter sits on the refuse-closed side
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
- proven drift, declared-elsewhere, the adoption lifecycle table, and a db:adopt section
|
|
35
|
+
|
|
7
36
|
## [0.4.76] - 2026-09-07
|
|
8
37
|
|
|
9
38
|
### 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.
|
|
@@ -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
|
}
|
|
@@ -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,15 @@ 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,
|
|
43
|
+
resolveAttestation,
|
|
42
44
|
type DescentLedgerRow,
|
|
43
45
|
type LiveState,
|
|
46
|
+
type ProveVerdict,
|
|
47
|
+
type ProveDescentOptions,
|
|
44
48
|
} from '../git-descent';
|
|
45
49
|
import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity, type AuthorityVerdict } from '../apply-authority';
|
|
46
50
|
import { decideSnapshotMode, type SnapshotModeRequest } from '../swap-snapshot';
|
|
@@ -108,6 +112,90 @@ export function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void
|
|
|
108
112
|
}
|
|
109
113
|
}
|
|
110
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Say what descent found, on BOTH lanes identically. Exported so a test can pin the one line
|
|
117
|
+
* that matters most: proven drift is announced as the ORIGIN, loudly, before anything writes.
|
|
118
|
+
*/
|
|
119
|
+
export function describeDescentVerdict(verdict: ProveVerdict, opts: { destructive?: boolean } = {}): { level: 'info' | 'warn'; text: string } | null {
|
|
120
|
+
switch (verdict.status) {
|
|
121
|
+
case 'proven':
|
|
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.` };
|
|
128
|
+
case 'fresh-target':
|
|
129
|
+
return { level: 'info', text: 'descent: fresh target (no tables) — nothing to protect, bootstrapping.' };
|
|
130
|
+
case 'no-git':
|
|
131
|
+
// On a destructive plan the decision refuses with its own message; do not double-report.
|
|
132
|
+
return opts.destructive ? null : { level: 'warn', text: 'descent: not a git checkout — the fast-forward rule cannot be verified here.' };
|
|
133
|
+
case 'drift':
|
|
134
|
+
return {
|
|
135
|
+
level: 'warn',
|
|
136
|
+
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.`,
|
|
137
|
+
};
|
|
138
|
+
case 'declared-elsewhere':
|
|
139
|
+
case 'revert':
|
|
140
|
+
case 'unknown-commit':
|
|
141
|
+
case 'unverifiable':
|
|
142
|
+
return null; // the decision refuses with the verdict's own reason
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function reportDescentVerdict(verdict: ProveVerdict, opts: { destructive?: boolean } = {}): void {
|
|
147
|
+
const line = describeDescentVerdict(verdict, opts);
|
|
148
|
+
if (!line) return;
|
|
149
|
+
(line.level === 'warn' ? warn : info)(line.text);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* THE descent step, shared by both lanes: prove the fast-forward rule over the live state and the
|
|
154
|
+
* ledger, report the verdict, decide it (attestation honoured only where proven), and hand back
|
|
155
|
+
* the wire/record shape — `descentVerdict` for the gate and `descentAttested` for the schema_log
|
|
156
|
+
* outcome (`applied — <note>`): a honoured lineage-lost waiver, or a PROVEN DRIFT origin.
|
|
157
|
+
*
|
|
158
|
+
* One function so the origin record cannot be dropped on one lane and kept on the other, and so
|
|
159
|
+
* a test can drive the SHIPPED wiring without AWS (the stage lane passes in the state it double-
|
|
160
|
+
* read and the ledger it fetched; the direct lane passes what executeApplyPlan read in-session).
|
|
161
|
+
* The closing-gate review found the note assignment unbound at both call sites — this is the bind.
|
|
162
|
+
*/
|
|
163
|
+
export async function proveDescentForApply(input: {
|
|
164
|
+
modelsPath: string;
|
|
165
|
+
live: LiveState;
|
|
166
|
+
ledger: DescentLedgerRow[];
|
|
167
|
+
destructive: boolean;
|
|
168
|
+
attestation?: string;
|
|
169
|
+
/** Where git runs. Default process.cwd(). */
|
|
170
|
+
cwd?: string;
|
|
171
|
+
/** Test seam: the barrel loader and materialize root (jest cannot dynamic-import a .ts barrel).
|
|
172
|
+
* Production passes nothing and rides the real loader. */
|
|
173
|
+
descent?: Pick<ProveDescentOptions, 'loader' | 'materializeRoot'>;
|
|
174
|
+
}): Promise<{ descentVerdict: { ok: true } | { ok: false; reason: string }; descentAttested?: string }> {
|
|
175
|
+
const cwd = input.cwd ?? process.cwd();
|
|
176
|
+
const verdict = await proveDescent(input.modelsPath, input.live, input.ledger, {
|
|
177
|
+
...(input.cwd ? { cwd: input.cwd } : {}),
|
|
178
|
+
...(input.descent ?? {}),
|
|
179
|
+
});
|
|
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.
|
|
183
|
+
const decision = decideDescentApply(verdict, {
|
|
184
|
+
...(input.attestation ? { attestation: resolveAttestation(input.attestation, cwd) } : {}),
|
|
185
|
+
destructive: input.destructive,
|
|
186
|
+
proveLineage: (sha) => proveLineageLost(sha, realLineageProbe(cwd)),
|
|
187
|
+
});
|
|
188
|
+
if (!decision.ok) return { descentVerdict: { ok: false, reason: decision.reason } };
|
|
189
|
+
if (decision.attested) {
|
|
190
|
+
const descentAttested = attestationNote(decision.attested);
|
|
191
|
+
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.`);
|
|
192
|
+
return { descentVerdict: { ok: true }, descentAttested };
|
|
193
|
+
}
|
|
194
|
+
// A proven-drift origin rides the same recorded field as a waiver — it is the same kind of
|
|
195
|
+
// record: what descent decided, and why this row is the one later applies descend from.
|
|
196
|
+
return { descentVerdict: { ok: true }, ...(decision.note ? { descentAttested: decision.note } : {}) };
|
|
197
|
+
}
|
|
198
|
+
|
|
111
199
|
/**
|
|
112
200
|
* db:apply --stage: the credential-free staged write. The operator never holds
|
|
113
201
|
* a database URL — the WRITE runs in the ops Lambda's db:apply action against
|
|
@@ -180,32 +268,17 @@ async function applyPlanViaStage(
|
|
|
180
268
|
// git-native (git revert / db:generate / fetch); the one attestation
|
|
181
269
|
// (--attest-lineage-lost) is honoured only when the CLI proves the declaring
|
|
182
270
|
// 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
|
-
}
|
|
271
|
+
const { snapshot, contract } = readPair.state;
|
|
272
|
+
step("Descent: reading the stage's schema_log ledger...");
|
|
273
|
+
const ledger = await readDescentLedger(lambdaSessionRunner(region, fn));
|
|
274
|
+
step("Descent: proving the fast-forward rule against the ledger...");
|
|
275
|
+
const { descentVerdict, descentAttested } = await proveDescentForApply({
|
|
276
|
+
modelsPath: resolveModelsPath(flags.models),
|
|
277
|
+
live: { snapshot, contract },
|
|
278
|
+
ledger,
|
|
279
|
+
destructive: plan.destructive > 0,
|
|
280
|
+
...(attestLineageLost ? { attestation: attestLineageLost } : {}),
|
|
281
|
+
});
|
|
209
282
|
|
|
210
283
|
// Destructive ceremony — the approver check is against the caller's REAL
|
|
211
284
|
// IAM identity (STS), which the Lambda cannot see, so the CLI resolves it
|
|
@@ -571,24 +644,15 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
571
644
|
descentNote: () => descentAttested,
|
|
572
645
|
checkDescent: async (live: LiveState & { fingerprint: string; ledger: DescentLedgerRow[] }) => {
|
|
573
646
|
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 } : {}),
|
|
647
|
+
const result = await proveDescentForApply({
|
|
648
|
+
modelsPath: resolveModelsPath(flags.models),
|
|
649
|
+
live,
|
|
650
|
+
ledger: live.ledger,
|
|
584
651
|
destructive: plan.destructive > 0,
|
|
585
|
-
|
|
652
|
+
...(attestLineageLost ? { attestation: attestLineageLost } : {}),
|
|
586
653
|
});
|
|
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 };
|
|
654
|
+
descentAttested = result.descentAttested;
|
|
655
|
+
return result.descentVerdict;
|
|
592
656
|
},
|
|
593
657
|
}),
|
|
594
658
|
);
|