@everystack/cli 0.4.79 → 0.4.81
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 +38 -0
- package/package.json +1 -1
- package/src/cli/apply-execute.ts +40 -17
- package/src/cli/authz-compile.ts +184 -51
- package/src/cli/authz-contract.ts +68 -2
- package/src/cli/authz-derive.ts +87 -18
- package/src/cli/authz-reconcile.ts +68 -2
- package/src/cli/commands/db-check.ts +23 -2
- package/src/cli/commands/db-plan.ts +17 -11
- package/src/cli/edge-plan.ts +219 -28
- package/src/cli/migration-generate.ts +37 -3
- package/src/cli/schema-diff.ts +14 -4
- package/src/cli/schema-fingerprint.ts +12 -9
package/src/cli/authz-derive.ts
CHANGED
|
@@ -92,14 +92,20 @@ function deriveExtraPrivileges(contract: TableContract): Record<string, string[]
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
* The deparsed owner predicate
|
|
96
|
-
* (
|
|
95
|
+
* The deparsed owner predicate, in BOTH wire spellings — the `::text` casts are optional:
|
|
96
|
+
* normalized (what both producers emit since a616a1f3, 2026-08-24, and what a live stage
|
|
97
|
+
* returns through `predicate()`'s cross-version normalizer):
|
|
98
|
+
* (col = (((current_setting('request.jwt.claims', true))::jsonb ->> 'sub'))::uuid)
|
|
99
|
+
* raw old-major deparse (a dump captured before the normalizer, or an older stage):
|
|
100
|
+
* (col = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text)))
|
|
97
101
|
* Recognising it turns a predicate back into `{ owner: 'col' }` — the structured shape —
|
|
98
|
-
* instead of an opaque `sql:` fragment.
|
|
99
|
-
*
|
|
102
|
+
* instead of an opaque `sql:` fragment. Before the casts were made optional the matcher
|
|
103
|
+
* expected only the raw spelling, so a normalized live predicate — every one, today — fell
|
|
104
|
+
* through to `sql:` and lost the `owner:` on round-trip. Anything unrecognised still stays
|
|
105
|
+
* verbatim rather than being guessed at.
|
|
100
106
|
*/
|
|
101
107
|
const OWNER_RE =
|
|
102
|
-
/^\(?([a-z_][a-z0-9_]*)\s*=\s*\(*\(current_setting\('request\.jwt\.claims'
|
|
108
|
+
/^\(?([a-z_][a-z0-9_]*)\s*=\s*\(*\(current_setting\('request\.jwt\.claims'(?:::text)?,\s*true\)\)::jsonb\s*->>\s*'([a-z_]+)'(?:::text)?\)*(?:::[a-z]+)?\)?$/i;
|
|
103
109
|
|
|
104
110
|
/**
|
|
105
111
|
* The OTHER owner shape: the database's own accessor, `(<col> = auth.user_id())`.
|
|
@@ -132,6 +138,33 @@ export function parseOwnerPredicate(
|
|
|
132
138
|
return f ? { column: f[1], claim: 'sub', expr: f[2] } : null;
|
|
133
139
|
}
|
|
134
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Decompose a WRITE predicate into its owner conjunct and the remaining guard.
|
|
143
|
+
*
|
|
144
|
+
* The compiler builds an owner write as `rowScope AND where` (USING) and `rowScope AND (check ??
|
|
145
|
+
* where)` (WITH CHECK). To round-trip, the deriver reverses that: the owner term may be the whole
|
|
146
|
+
* predicate (`owner = claim`) or one top-level AND conjunct (`owner = claim AND state = 'draft'`),
|
|
147
|
+
* and everything else is the guard the compiler will AND back in beside the owner. Returns the
|
|
148
|
+
* owner parse (null when there is none) and the guard text (null when the owner is the whole
|
|
149
|
+
* predicate). Nothing is guessed: a predicate with no recognisable owner conjunct returns
|
|
150
|
+
* `{ owner: null, guard: <the whole predicate> }`, which the caller carries verbatim as `sql:`.
|
|
151
|
+
*/
|
|
152
|
+
function splitOwnerPredicate(
|
|
153
|
+
pred: string | null,
|
|
154
|
+
): { owner: ReturnType<typeof parseOwnerPredicate>; guard: string | null } {
|
|
155
|
+
if (!pred) return { owner: null, guard: null };
|
|
156
|
+
const direct = parseOwnerPredicate(pred);
|
|
157
|
+
if (direct) return { owner: direct, guard: null };
|
|
158
|
+
const conjuncts = splitTopLevel(pred, 'AND');
|
|
159
|
+
if (conjuncts.length < 2) return { owner: null, guard: pred };
|
|
160
|
+
const idx = conjuncts.findIndex((c) => parseOwnerPredicate(c));
|
|
161
|
+
if (idx === -1) return { owner: null, guard: pred };
|
|
162
|
+
const owner = parseOwnerPredicate(conjuncts[idx]);
|
|
163
|
+
const rest = conjuncts.filter((_, i) => i !== idx);
|
|
164
|
+
const guard = rest.length === 0 ? null : rest.length === 1 ? rest[0] : `(${rest.join(' AND ')})`;
|
|
165
|
+
return { owner, guard };
|
|
166
|
+
}
|
|
167
|
+
|
|
135
168
|
/**
|
|
136
169
|
* Split a predicate on a top-level operator, respecting parens and string literals.
|
|
137
170
|
*
|
|
@@ -562,7 +595,7 @@ export function deriveAbilities(
|
|
|
562
595
|
}
|
|
563
596
|
|
|
564
597
|
if (diverges && own) {
|
|
565
|
-
const parts = [`owner: '${own.owner.column}'`];
|
|
598
|
+
const parts = [`owner: '${modelFieldKey(own.owner.column)}'`];
|
|
566
599
|
if (own.owner.claim !== 'sub') parts.push(`userField: '${own.owner.claim}'`);
|
|
567
600
|
if (own.owner.expr) parts.push(`ownerExpr: sql\`${own.owner.expr}\``);
|
|
568
601
|
const guard = sqlFragment(own.ownerSql);
|
|
@@ -597,7 +630,7 @@ export function deriveAbilities(
|
|
|
597
630
|
const pol = policiesFor(contract, 'authenticated', 'SELECT')[0];
|
|
598
631
|
const owner = parseOwnerPredicate(pol?.using ?? null);
|
|
599
632
|
if (owner) {
|
|
600
|
-
const parts = [`owner: '${owner.column}'`];
|
|
633
|
+
const parts = [`owner: '${modelFieldKey(owner.column)}'`];
|
|
601
634
|
if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
|
|
602
635
|
if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
|
|
603
636
|
abilities.push(`can('read', { ${parts.join(', ')} })`);
|
|
@@ -616,22 +649,58 @@ export function deriveAbilities(
|
|
|
616
649
|
?? policiesFor(contract, 'authenticated', command)[0];
|
|
617
650
|
const using = pol?.using ?? null;
|
|
618
651
|
const check = pol?.check ?? null;
|
|
619
|
-
|
|
652
|
+
// Decompose both halves into (owner, guard). INSERT carries the owner in WITH CHECK (its
|
|
653
|
+
// USING is null); UPDATE/DELETE carry it in USING. The owner is recovered from whichever
|
|
654
|
+
// half has it.
|
|
655
|
+
const u = splitOwnerPredicate(using);
|
|
656
|
+
const c = splitOwnerPredicate(check);
|
|
657
|
+
const owner = u.owner ?? c.owner;
|
|
620
658
|
|
|
621
659
|
const parts: string[] = [];
|
|
622
660
|
if (owner) {
|
|
623
|
-
parts.push(`owner: '${owner.column}'`);
|
|
661
|
+
parts.push(`owner: '${modelFieldKey(owner.column)}'`);
|
|
624
662
|
if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
|
|
625
663
|
if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
664
|
+
// The USING guard beyond the owner gate (a state guard, a tenant filter) rides as `sql`.
|
|
665
|
+
const usingGuard = sqlFragment(u.guard);
|
|
666
|
+
if (usingGuard) parts.push(`sql: ${usingGuard}`);
|
|
667
|
+
// The WRITE half rides as `check` ONLY when its guard differs from the USING guard. The
|
|
668
|
+
// compiler defaults WITH CHECK to (rowScope AND where), so emitting an equal guard would
|
|
669
|
+
// double the owner term on recompile — the round-trip regression this closes.
|
|
670
|
+
// WITH CHECK handling. `check == null` means the live policy has NO explicit WITH CHECK
|
|
671
|
+
// (Postgres defaults it to USING at runtime) — emit no `check:` and let the compiler
|
|
672
|
+
// default it the same way. Only an EXPLICIT check that differs from the USING guard needs a
|
|
673
|
+
// `check:`, and only in the shapes below.
|
|
674
|
+
if (command !== 'DELETE' && check != null && c.owner == null) {
|
|
675
|
+
// The live WITH CHECK does not even restate the owner gate that USING carries — it is
|
|
676
|
+
// WIDER on the owner axis (the extreme is `WITH CHECK (true)`: write a row you do not
|
|
677
|
+
// own). The model has no spelling for that, and every `check:` the compiler emits ANDs
|
|
678
|
+
// the owner back in, so the derived ability necessarily TIGHTENS live. Disclose it rather
|
|
679
|
+
// than round-trip the loss silently; the residual db:plan statement is the real diff.
|
|
680
|
+
notes.push(`policy "${pol?.name}" (${command}) has a WITH CHECK that does not restate the owner gate in its USING —`);
|
|
681
|
+
notes.push(` it is wider than any owner ability can express, so the rendered model TIGHTENS it to the owner. Live WITH CHECK:`);
|
|
682
|
+
notes.push(` ${check}`);
|
|
683
|
+
} else if (command !== 'DELETE' && check != null && c.owner != null && c.guard == null && u.guard != null) {
|
|
684
|
+
// Explicit owner-only WITH CHECK while USING carries a guard — the write half is
|
|
685
|
+
// deliberately WIDER than the read half (the moderation shape this file's header names:
|
|
686
|
+
// "your own draft rows" to target, "your own row" to write into). Left implicit, the
|
|
687
|
+
// compiler would default WITH CHECK to (owner AND where) and TIGHTEN it, so the pull would
|
|
688
|
+
// misreport live. `check: sql`true`` forces WITH CHECK back to the owner gate alone.
|
|
689
|
+
parts.push('check: sql`true`');
|
|
690
|
+
} else if (c.guard != null && (c.guard ?? '') !== (u.guard ?? '')) {
|
|
691
|
+
// An explicit WITH CHECK carrying a DIFFERENT guard than USING (owner ∧ a distinct write
|
|
692
|
+
// guard). Rides as `check:`; the compiler ANDs the owner back in.
|
|
693
|
+
const checkFrag = sqlFragment(c.guard);
|
|
694
|
+
if (checkFrag) parts.push(`check: ${checkFrag}`);
|
|
695
|
+
}
|
|
696
|
+
} else {
|
|
697
|
+
// No owner recovered — carry the predicates verbatim, USING and (differing) WITH CHECK.
|
|
698
|
+
const usingFrag = sqlFragment(using);
|
|
699
|
+
if (usingFrag) parts.push(`sql: ${usingFrag}`);
|
|
700
|
+
if (check && check !== using) {
|
|
701
|
+
const checkFrag = sqlFragment(check);
|
|
702
|
+
if (checkFrag) parts.push(`check: ${checkFrag}`);
|
|
703
|
+
}
|
|
635
704
|
}
|
|
636
705
|
|
|
637
706
|
if (!parts.length && !pol) {
|
|
@@ -89,6 +89,61 @@ export function ungovernedGrants(live: AuthzContract, governed: ReadonlySet<stri
|
|
|
89
89
|
return out.sort((a, b) => a.grantee.localeCompare(b.grantee) || a.table.localeCompare(b.table));
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* A declared table's authz AFTER the edge lands — the FULL prediction's per-table building block.
|
|
94
|
+
*
|
|
95
|
+
* The reconciler converges the GOVERNED roles onto the declared contract and leaves every other
|
|
96
|
+
* (foreign) grantee alone. So the post-apply truth for a declared table is: the declared contract
|
|
97
|
+
* PLUS the live authz of the ungoverned roles. The governed prediction drops that second half; on
|
|
98
|
+
* a brownfield stage the foreign grants live on, so a `to` endpoint without them can never equal
|
|
99
|
+
* the post-apply full live hash (milestone plan-endpoint-full).
|
|
100
|
+
*
|
|
101
|
+
* Carried ONLY onto objects the declared target still has (condition (a) of the N2 ruling):
|
|
102
|
+
* - table-level grants ride whole — the table is present by construction;
|
|
103
|
+
* - a column grant rides only for the columns in `presentColumns`; a grant on a column the edge
|
|
104
|
+
* DROPS is gone after apply, so carrying it would predict an endpoint the apply cannot reach —
|
|
105
|
+
* a false verify-failed after the DDL already landed;
|
|
106
|
+
* - a policy rides only when EVERY role it names is ungoverned. A policy touching a governed role
|
|
107
|
+
* is the reconciler's to manage, so it is not carried here (if the reconciler leaves it, the
|
|
108
|
+
* verify-after fails LOUD rather than being hidden).
|
|
109
|
+
*
|
|
110
|
+
* The declared (governed) grants always win over any live grant to the same role: a governed role
|
|
111
|
+
* whose live grant the models dropped must be REVOKED by the plan, never carried through.
|
|
112
|
+
*/
|
|
113
|
+
export function carryUngovernedAuthz(
|
|
114
|
+
declared: TableContract,
|
|
115
|
+
live: TableContract,
|
|
116
|
+
governed: ReadonlySet<string>,
|
|
117
|
+
presentColumns: ReadonlySet<string>,
|
|
118
|
+
): TableContract {
|
|
119
|
+
const foreign = (role: string): boolean => !isGoverned(governed, role);
|
|
120
|
+
|
|
121
|
+
const grants: Record<string, string[]> = { ...declared.grants };
|
|
122
|
+
for (const [role, privileges] of Object.entries(live.grants)) {
|
|
123
|
+
if (foreign(role)) grants[role] = [...privileges];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const columnGrants: Record<string, Record<string, string[]>> = { ...(declared.columnGrants ?? {}) };
|
|
127
|
+
for (const [role, byPrivilege] of Object.entries(live.columnGrants ?? {})) {
|
|
128
|
+
if (!foreign(role)) continue;
|
|
129
|
+
const kept: Record<string, string[]> = {};
|
|
130
|
+
for (const [privilege, columns] of Object.entries(byPrivilege)) {
|
|
131
|
+
const present = columns.filter((c) => presentColumns.has(c));
|
|
132
|
+
if (present.length > 0) kept[privilege] = present;
|
|
133
|
+
}
|
|
134
|
+
if (Object.keys(kept).length > 0) columnGrants[role] = kept;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const carriedPolicies = live.policies.filter((p) => p.roles.length > 0 && p.roles.every(foreign));
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
...declared,
|
|
141
|
+
grants,
|
|
142
|
+
...(Object.keys(columnGrants).length > 0 ? { columnGrants } : {}),
|
|
143
|
+
policies: [...declared.policies, ...carriedPolicies],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
92
147
|
/**
|
|
93
148
|
* The exemption report, one line per grantee — the shared rendering every surface prints.
|
|
94
149
|
*
|
|
@@ -307,7 +362,18 @@ function reconcileSequenceGrants(
|
|
|
307
362
|
export function emitReconcileSql(
|
|
308
363
|
declared: AuthzContract,
|
|
309
364
|
live: AuthzContract,
|
|
310
|
-
opts: {
|
|
365
|
+
opts: {
|
|
366
|
+
governedRoles?: ReadonlySet<string>;
|
|
367
|
+
/**
|
|
368
|
+
* The serial sequences the SAME plan's data phase CREATES (a new serial table, a serial
|
|
369
|
+
* column added to an existing one) — reconciled as if live with no grants. Without them a
|
|
370
|
+
* created sequence got no governed grant until the NEXT plan: db:apply verified green and
|
|
371
|
+
* every INSERT by the governed holder failed `permission denied for sequence` for one plan
|
|
372
|
+
* cycle (milestone finish-0-4-x F1). They are emitted even when the live side never read
|
|
373
|
+
* sequences: the plan creates these, so there is no unread live state to misjudge.
|
|
374
|
+
*/
|
|
375
|
+
createdSequences?: readonly SequenceGrantContract[];
|
|
376
|
+
} = {},
|
|
311
377
|
): string[] {
|
|
312
378
|
// Condition C — two phases PER TABLE in one transaction: every access-REMOVING statement
|
|
313
379
|
// (policy DROP, REVOKE, RLS ENABLE/FORCE) before every access-ADDING one (policy CREATE, GRANT,
|
|
@@ -325,7 +391,7 @@ export function emitReconcileSql(
|
|
|
325
391
|
// Absent = the recorder never read sequences; skip the pass entirely rather than diff
|
|
326
392
|
// against a set nobody measured. Grouped once, not re-scanned per table.
|
|
327
393
|
const sequencesByTable = new Map<string, SequenceGrantContract[]>();
|
|
328
|
-
for (const seq of live.sequenceGrants ?? []) {
|
|
394
|
+
for (const seq of [...(live.sequenceGrants ?? []), ...(opts.createdSequences ?? [])]) {
|
|
329
395
|
(sequencesByTable.get(seq.table) ?? sequencesByTable.set(seq.table, []).get(seq.table)!).push(seq);
|
|
330
396
|
}
|
|
331
397
|
|
|
@@ -80,6 +80,15 @@ export interface StaticCheckInput {
|
|
|
80
80
|
artifactPath: string;
|
|
81
81
|
/** null = no generated schema artifact on disk. */
|
|
82
82
|
artifactSource: string | null;
|
|
83
|
+
/**
|
|
84
|
+
* Where `artifactPath` came from — the same resolution db:generate records. An ABSENT artifact
|
|
85
|
+
* is only "nothing to match" at the untouched default (an app that never generated one). A path
|
|
86
|
+
* the operator named with `--schema-out`, or one the journal recorded, is a claim that an
|
|
87
|
+
* artifact lives there; its absence is the artifact gate vanishing, and must FAIL. Measured:
|
|
88
|
+
* `db:check --schema-out db/does-not-exist.ts` printed the note and passed with exit 0.
|
|
89
|
+
* Absent = default (older callers).
|
|
90
|
+
*/
|
|
91
|
+
artifactOrigin?: 'flag' | 'recorded' | 'default';
|
|
83
92
|
/** Set when a retired db/sql directory still carries .sql files — the tombstone
|
|
84
93
|
* message (with the migration path); a hard fail, never a shrug. */
|
|
85
94
|
sqlDirRetired?: string | null;
|
|
@@ -250,7 +259,19 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
250
259
|
}
|
|
251
260
|
}
|
|
252
261
|
|
|
253
|
-
if (input.artifactSource === null) {
|
|
262
|
+
if (input.artifactSource === null && input.artifactOrigin === 'flag') {
|
|
263
|
+
findings.push({
|
|
264
|
+
level: 'fail',
|
|
265
|
+
area: 'artifact',
|
|
266
|
+
message: `no generated schema at ${input.artifactPath} — the path came from --schema-out, so an artifact is expected there. Fix the path, or run db:generate to write it. A named path with nothing at it is a gate that guards nothing.`,
|
|
267
|
+
});
|
|
268
|
+
} else if (input.artifactSource === null && input.artifactOrigin === 'recorded') {
|
|
269
|
+
findings.push({
|
|
270
|
+
level: 'fail',
|
|
271
|
+
area: 'artifact',
|
|
272
|
+
message: `no generated schema at ${input.artifactPath} — the migration journal records that path as the artifact (db:generate wrote it there). Restore it, run db:generate, or re-record with --schema-out.`,
|
|
273
|
+
});
|
|
274
|
+
} else if (input.artifactSource === null) {
|
|
254
275
|
findings.push({
|
|
255
276
|
level: 'note',
|
|
256
277
|
area: 'artifact',
|
|
@@ -393,7 +414,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
393
414
|
} catch { /* no baseline — see above */ }
|
|
394
415
|
|
|
395
416
|
const findings = runStaticChecks({
|
|
396
|
-
modelsPath, models, modelsError, modelFileScans, extensions, artifactPath, artifactSource, sqlDirRetired,
|
|
417
|
+
modelsPath, models, modelsError, modelFileScans, extensions, artifactPath, artifactSource, artifactOrigin: artifactRes.source, sqlDirRetired,
|
|
397
418
|
sequences: declaredDb?.sequences, derived: declaredDb?.derived, derivedError,
|
|
398
419
|
moduleModels: declaredDb?.models ?? null,
|
|
399
420
|
baselineSource,
|
|
@@ -49,7 +49,7 @@ import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport } fr
|
|
|
49
49
|
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
|
-
import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan';
|
|
52
|
+
import { mintEdgePlan, planHash, buildPlanSummary, explainCarriedBy } from '../edge-plan';
|
|
53
53
|
import { proveDescent, describeDriftOrigin, proveLineageLost, realLineageProbe, type ProveVerdict, type LineageProof } from '../git-descent';
|
|
54
54
|
import { fingerprintLive, divergentSurfaces } from '../schema-fingerprint';
|
|
55
55
|
import { predictLiveState } from '../edge-plan';
|
|
@@ -337,16 +337,22 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
337
337
|
await fs.writeFile(out, body, 'utf8');
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
-
// An EMPTY edge
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
340
|
+
// An EMPTY edge still has a story to tell, and the two cases tell DIFFERENT ones:
|
|
341
|
+
// - `from == to`: the full endpoint carried the foreign grants through, so the plan is empty
|
|
342
|
+
// AND equal. Name the ungoverned roles (condition (c)) — the operator must still see what
|
|
343
|
+
// rides untouched and under which baseline.
|
|
344
|
+
// - `from != to`: the FULL prediction failed to reproduce raw live — a real, unexplained
|
|
345
|
+
// divergence. The foreign grants are ABSORBED into `to`, so they are NOT the cause; naming
|
|
346
|
+
// them would misdirect. Name the divergent surface of the FULL prediction vs live instead.
|
|
347
|
+
const emptyEdge = plan.statements.length === 0;
|
|
348
|
+
const carriedBy = !emptyEdge
|
|
349
|
+
? undefined
|
|
350
|
+
: plan.fromFingerprint === plan.toFingerprint
|
|
351
|
+
? explainCarriedBy(exemptions, [])
|
|
352
|
+
: divergentSurfaces(
|
|
353
|
+
predictLiveState(models, snapshot, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles), carryUngoverned: true }).canonical,
|
|
354
|
+
fingerprintLive(snapshot, contract).canonical,
|
|
355
|
+
);
|
|
350
356
|
for (const line of buildPlanSummary(plan, carriedBy ? { carriedBy } : {})) info(line);
|
|
351
357
|
info(`plan_ref: ${planHash(plan).slice(0, 12)}`);
|
|
352
358
|
|