@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
|
@@ -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 } 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';
|
|
@@ -70,6 +72,90 @@ import { compileTableContract } from '../authz-compile';
|
|
|
70
72
|
import { reportPipelineLastRun } from './pipeline-run';
|
|
71
73
|
import { step, success, fail, info, warn, reserveStdoutForData } from '../output';
|
|
72
74
|
|
|
75
|
+
/**
|
|
76
|
+
* What db:plan says about descent at mint — SYMMETRIC with what db:apply will do, because a mint
|
|
77
|
+
* that warns "will refuse" for a plan apply then runs (or the reverse) is the lie this used to
|
|
78
|
+
* tell. Exported so a test pins every verdict's text. The `never` arm makes the switch REALLY
|
|
79
|
+
* exhaustive: a new verdict fails to compile here instead of minting a plan with no warning and
|
|
80
|
+
* refusing at apply with no hint of why.
|
|
81
|
+
*/
|
|
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
|
+
};
|
|
111
|
+
switch (verdict.status) {
|
|
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
|
+
: [];
|
|
118
|
+
case 'fresh-target':
|
|
119
|
+
case 'no-git':
|
|
120
|
+
return [];
|
|
121
|
+
case 'drift':
|
|
122
|
+
return [
|
|
123
|
+
`descent: NO COMMIT ON ANY REF DECLARES THIS DATABASE (${describeDriftOrigin(verdict.origin, { human: true })}) — proven over a full, current clone.`,
|
|
124
|
+
'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.',
|
|
125
|
+
];
|
|
126
|
+
case 'declared-elsewhere':
|
|
127
|
+
return [
|
|
128
|
+
`descent: ${verdict.reason}`,
|
|
129
|
+
'db:apply from this checkout will refuse this plan — fetch and merge or rebase onto that commit, then re-mint.',
|
|
130
|
+
];
|
|
131
|
+
case 'revert':
|
|
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
|
+
}
|
|
145
|
+
case 'unverifiable':
|
|
146
|
+
// NOT "rebase first" — nothing is wrong with the checkout or the database. The search could
|
|
147
|
+
// not finish (an unreadable tree, or a clone that is not full and current).
|
|
148
|
+
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.'];
|
|
149
|
+
default: {
|
|
150
|
+
// Compile-time exhaustiveness; at runtime (a verdict shape from outside this compile unit)
|
|
151
|
+
// an empty warning list is the safe value — the caller iterates it.
|
|
152
|
+
const exhaustive: never = verdict;
|
|
153
|
+
void exhaustive;
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
73
159
|
const DEFAULT_OUT = 'db.plan.json';
|
|
74
160
|
|
|
75
161
|
/**
|
|
@@ -251,7 +337,17 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
251
337
|
await fs.writeFile(out, body, 'utf8');
|
|
252
338
|
}
|
|
253
339
|
|
|
254
|
-
|
|
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);
|
|
255
351
|
info(`plan_ref: ${planHash(plan).slice(0, 12)}`);
|
|
256
352
|
|
|
257
353
|
// WHY does each authorization statement exist? — the same report db:generate prints,
|
|
@@ -289,28 +385,9 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
289
385
|
// A switch, not an `if (revert || drift)`. That form silently ignored every verdict it
|
|
290
386
|
// did not name, so a new one would mint a plan with no warning and then be refused at
|
|
291
387
|
// apply time with no hint of why. Exhaustive here means a new verdict cannot be forgotten.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
}
|
|
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);
|
|
314
391
|
}
|
|
315
392
|
|
|
316
393
|
// 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/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)`);
|