@junghanacs/entwurf 0.25.0 → 0.25.1
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 +109 -0
- package/CONTRIBUTING.md +1 -1
- package/VERIFY.md +1 -1
- package/package.json +1 -1
- package/run.sh +5 -4
- package/scripts/check-gate-qualification.ts +2347 -7
- package/scripts/lib/qualification-acceptance.ts +146 -0
- package/scripts/lib/qualification-artifact.ts +359 -0
- package/scripts/lib/qualification-collection.ts +384 -0
- package/scripts/lib/qualification-gh.ts +231 -0
- package/scripts/lib/qualification-ledger.ts +669 -0
- package/scripts/lib/qualification-oracle-core.ts +279 -0
- package/scripts/lib/qualification-receipt.ts +561 -0
- package/scripts/lib/qualification-release.ts +271 -0
- package/scripts/mutants/qualification-ledger.json +489 -0
- package/scripts/qualification-oracle.ts +124 -0
- package/scripts/smoke-herdr-raw-install-live.sh +7 -2
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qualification-ledger — compose PARTIAL group receipts into one candidate ledger (#124 P2).
|
|
3
|
+
*
|
|
4
|
+
* The composer is PURE: `composeLedger(receipts, current)` reads no file and runs no
|
|
5
|
+
* gate. It answers one question per exact-argv group of the CURRENT committed manifest
|
|
6
|
+
* set: what does the evidence on hand say about this group on THIS candidate?
|
|
7
|
+
*
|
|
8
|
+
* (a) MEASURED the newest receipt matching the current candidate exactly
|
|
9
|
+
* (HEAD, work surface, C0, group spec set, claims, manifest
|
|
10
|
+
* claim set, declared-input fingerprints) passed.
|
|
11
|
+
* (e) FAIL that newest exact-match receipt is red. Unresolved.
|
|
12
|
+
* (c) INHERITED-UNVERIFIED no exact match, but a passing receipt for the same spec
|
|
13
|
+
* set and claims exists on other bytes. Its mismatches are
|
|
14
|
+
* listed; nothing is concluded from them.
|
|
15
|
+
* (d) UNRUN / INVALIDATED no receipt, or only receipts whose spec set/claims moved,
|
|
16
|
+
* or only red receipts on other bytes.
|
|
17
|
+
* (b) CARRIED defined, never populated: a carry needs an audited
|
|
18
|
+
* dependency projection and none exists. There is no code
|
|
19
|
+
* path that promotes (c) to (b).
|
|
20
|
+
*
|
|
21
|
+
* A red on the current candidate is superseded only by a NEWER exact-match
|
|
22
|
+
* re-measurement; the red stays in the group's history either way. A green that follows
|
|
23
|
+
* a red on identical bytes is a nondeterminism signal and is flagged.
|
|
24
|
+
*
|
|
25
|
+
* Verdict (local — `FULL-GREEN` and `RISK-ACCEPTED` are not producible here):
|
|
26
|
+
* RED a current FAIL (incl. tie), an invalid receipt, or an input list mismatch.
|
|
27
|
+
* NOT-QUALIFIED no RISK can be proposed: dirty candidate, the runner's own group
|
|
28
|
+
* (`check-gate-manifests`) not exact-MEASURED, a c-input group (declared
|
|
29
|
+
* input moved), or a group whose newest receipt is red and not re-measured.
|
|
30
|
+
* RISK-PENDING only named risks remain (c-core, UNRUN, flags incl. content-identical);
|
|
31
|
+
* it proposes, never accepts, and authorizes no release.
|
|
32
|
+
* COMPOSITE-GREEN every group exact-MEASURED, no flag: "each group measured in its own
|
|
33
|
+
* snapshot on these bytes" — NOT the serial full body.
|
|
34
|
+
*
|
|
35
|
+
* `receiptSha256` is re-derived to catch corruption; it cannot tell a runner-written
|
|
36
|
+
* receipt from a hand-written one, and the ledger says so.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { spawnSync } from "node:child_process";
|
|
40
|
+
import * as fs from "node:fs";
|
|
41
|
+
import type { MutantSpec } from "./mutation-qualify.ts";
|
|
42
|
+
import { originHead, originWorkSurfaceSha } from "./mutation-qualify.ts";
|
|
43
|
+
import {
|
|
44
|
+
CORE_FILES,
|
|
45
|
+
canonicalJson,
|
|
46
|
+
coreSha256,
|
|
47
|
+
declaredInputPaths,
|
|
48
|
+
fileFingerprints,
|
|
49
|
+
nodeModulesFingerprint,
|
|
50
|
+
nodeRuntime,
|
|
51
|
+
RECEIPT_LABEL,
|
|
52
|
+
RECEIPT_SCHEMA,
|
|
53
|
+
sha256,
|
|
54
|
+
} from "./qualification-receipt.ts";
|
|
55
|
+
|
|
56
|
+
export const LEDGER_SCHEMA = "entwurf.qualification-ledger/v0";
|
|
57
|
+
|
|
58
|
+
export type GroupState = "MEASURED" | "CARRIED" | "INHERITED-UNVERIFIED" | "UNRUN" | "FAIL";
|
|
59
|
+
/**
|
|
60
|
+
* What a LOCAL ledger may say. `FULL-GREEN` (the serial body at an exact SHA) and
|
|
61
|
+
* `RISK-ACCEPTED` (a procedural human decision, not machine-authenticated) are deliberately NOT in this type:
|
|
62
|
+
* no local composition can produce them.
|
|
63
|
+
*/
|
|
64
|
+
export type LedgerVerdict = "RED" | "NOT-QUALIFIED" | "RISK-PENDING" | "COMPOSITE-GREEN";
|
|
65
|
+
export type Provenance = "exact" | "content-identical";
|
|
66
|
+
export type InvalidCode =
|
|
67
|
+
| "R-PARSE"
|
|
68
|
+
| "R-SCHEMA"
|
|
69
|
+
| "R-SHAPE"
|
|
70
|
+
| "R-DIGEST"
|
|
71
|
+
| "R-RESULT-INCONSISTENT"
|
|
72
|
+
| "R-UNKNOWN-GROUP"
|
|
73
|
+
| "R-DUPLICATE";
|
|
74
|
+
|
|
75
|
+
export interface CurrentGroup {
|
|
76
|
+
gate: string[];
|
|
77
|
+
claims: string[];
|
|
78
|
+
specSetSha256: string;
|
|
79
|
+
declaredInputs: Record<string, string>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface CurrentCandidate {
|
|
83
|
+
head: string;
|
|
84
|
+
workSurfaceSha256: string;
|
|
85
|
+
coreSha256: string;
|
|
86
|
+
/** `git status --porcelain` empty: the work surface IS the committed tree. */
|
|
87
|
+
porcelainClean: boolean;
|
|
88
|
+
/** The group that proves the runner itself; it must be exact-MEASURED for any RISK proposal. */
|
|
89
|
+
coreGroupGate: string[];
|
|
90
|
+
/** CORE_FILES content fingerprints — the part of C0 anyone can recompute from the tree. */
|
|
91
|
+
coreFiles?: Record<string, string>;
|
|
92
|
+
/** Sorted claim ids of the whole committed set. */
|
|
93
|
+
manifestClaims: string[];
|
|
94
|
+
/** In manifest (= qualification run) order. */
|
|
95
|
+
groups: CurrentGroup[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A receipt as read from disk: its source name and raw text. Parsing happens in the composer. */
|
|
99
|
+
export interface LoadedReceipt {
|
|
100
|
+
source: string;
|
|
101
|
+
text: string;
|
|
102
|
+
/**
|
|
103
|
+
* Explicit order key (higher = newer). When every receipt of a group carries one, it —
|
|
104
|
+
* not the receipt's own `startedAt` clock — decides order and ties. A release chain
|
|
105
|
+
* sets it from first-parent position, because clocks of different runs are not trusted.
|
|
106
|
+
*/
|
|
107
|
+
seq?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One line of an explicit expected-input list (`sha256sum` format: `<sha256> <path>`). */
|
|
111
|
+
export interface ExpectedInput {
|
|
112
|
+
source: string;
|
|
113
|
+
sha256: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface ReceiptMutant {
|
|
117
|
+
claim: string;
|
|
118
|
+
verdict: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface Receipt {
|
|
122
|
+
schema: string;
|
|
123
|
+
label: string;
|
|
124
|
+
startedAt: string;
|
|
125
|
+
candidate: { head: string; workSurfaceSha256: string };
|
|
126
|
+
core: { sha256: string };
|
|
127
|
+
group: { gate: string[]; specSetSha256: string; claims: string[]; declaredInputs: Record<string, string> };
|
|
128
|
+
manifestSet: { claimCount: number; claims: string[] };
|
|
129
|
+
result: { passed: boolean; control: string; mutants: ReceiptMutant[]; treeClean: boolean; porcelainClean: boolean };
|
|
130
|
+
seal: { stable: boolean; nodeModulesExcludedDrift: string[] };
|
|
131
|
+
receiptSha256: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface HistoryEntry {
|
|
135
|
+
source: string;
|
|
136
|
+
receiptSha256: string;
|
|
137
|
+
startedAt: string;
|
|
138
|
+
passed: boolean;
|
|
139
|
+
/** Empty = exact match with the current candidate. */
|
|
140
|
+
mismatch: string[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface GroupLedger {
|
|
144
|
+
gate: string[];
|
|
145
|
+
claims: string[];
|
|
146
|
+
state: GroupState;
|
|
147
|
+
/** For MEASURED/FAIL: how the deciding receipt matched this candidate. */
|
|
148
|
+
provenance: Provenance | null;
|
|
149
|
+
/** For INHERITED-UNVERIFIED: c-core (only environment/surface moved) or c-input (a declared input moved). */
|
|
150
|
+
inheritKind: "c-core" | "c-input" | null;
|
|
151
|
+
flags: string[];
|
|
152
|
+
reasons: string[];
|
|
153
|
+
/** The receipt that decided the state, if any. */
|
|
154
|
+
decidedBy: string | null;
|
|
155
|
+
history: HistoryEntry[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface Ledger {
|
|
159
|
+
schema: string;
|
|
160
|
+
candidate: { head: string; workSurfaceSha256: string; coreSha256: string; porcelainClean: boolean };
|
|
161
|
+
verdict: LedgerVerdict;
|
|
162
|
+
/** Why no RISK can even be proposed (verdict NOT-QUALIFIED). */
|
|
163
|
+
blockers: string[];
|
|
164
|
+
/** Named risks a RISK-PENDING ledger asks a human to accept or reject. Listing is not accepting. */
|
|
165
|
+
risks: string[];
|
|
166
|
+
/** Every input file with its digest. `local-file` is the only provenance this stage can state. */
|
|
167
|
+
inputs: { source: string; sha256: string; provenance: "local-file" }[];
|
|
168
|
+
inputErrors: string[];
|
|
169
|
+
counts: {
|
|
170
|
+
groups: Record<GroupState, number> & { total: number };
|
|
171
|
+
claims: Record<GroupState, number> & { total: number };
|
|
172
|
+
invalid: number;
|
|
173
|
+
};
|
|
174
|
+
groups: GroupLedger[];
|
|
175
|
+
invalid: { source: string; code: InvalidCode; detail: string }[];
|
|
176
|
+
notCovered: string[];
|
|
177
|
+
summary: string[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const LEDGER_NOT_COVERED = [
|
|
181
|
+
"COMPOSITE-GREEN is per-group snapshots on identical bytes, not the serial full body: inter-group order and state leaks are not exercised",
|
|
182
|
+
"(b) CARRIED is never populated: no audited dependency projection exists",
|
|
183
|
+
"undeclared inputs a gate reads by path or spawns; host tools; kernel/tmp state",
|
|
184
|
+
"node_modules/.vite is excluded from identity by name; a group whose run drifted it is flagged cache-drift",
|
|
185
|
+
"receiptSha256 detects corruption only; a hand-written receipt with a recomputed digest is indistinguishable",
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
// ── receipt validation (pure) ───────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
const isStr = (v: unknown): v is string => typeof v === "string";
|
|
191
|
+
const isStrArr = (v: unknown): v is string[] => Array.isArray(v) && v.every(isStr);
|
|
192
|
+
const isObj = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
193
|
+
const isStrRecord = (v: unknown): v is Record<string, string> => isObj(v) && Object.values(v).every(isStr);
|
|
194
|
+
|
|
195
|
+
function shapeError(r: Record<string, unknown>): string | null {
|
|
196
|
+
const g = r.group;
|
|
197
|
+
const res = r.result;
|
|
198
|
+
const c = r.candidate;
|
|
199
|
+
const seal = r.seal;
|
|
200
|
+
const ms = r.manifestSet;
|
|
201
|
+
if (!isStr(r.startedAt) || Number.isNaN(Date.parse(r.startedAt))) return "startedAt";
|
|
202
|
+
if (!isObj(c) || !isStr(c.head) || !isStr(c.workSurfaceSha256)) return "candidate";
|
|
203
|
+
if (!isObj(r.core) || !isStr(r.core.sha256)) return "core.sha256";
|
|
204
|
+
if (
|
|
205
|
+
!isObj(g) ||
|
|
206
|
+
!isStrArr(g.gate) ||
|
|
207
|
+
!isStr(g.specSetSha256) ||
|
|
208
|
+
!isStrArr(g.claims) ||
|
|
209
|
+
!isStrRecord(g.declaredInputs)
|
|
210
|
+
) {
|
|
211
|
+
return "group";
|
|
212
|
+
}
|
|
213
|
+
if (!isObj(ms) || !isStrArr(ms.claims)) return "manifestSet";
|
|
214
|
+
if (
|
|
215
|
+
!isObj(res) ||
|
|
216
|
+
typeof res.passed !== "boolean" ||
|
|
217
|
+
!isStr(res.control) ||
|
|
218
|
+
typeof res.treeClean !== "boolean" ||
|
|
219
|
+
typeof res.porcelainClean !== "boolean" ||
|
|
220
|
+
!Array.isArray(res.mutants) ||
|
|
221
|
+
!res.mutants.every((m) => isObj(m) && isStr(m.claim) && isStr(m.verdict))
|
|
222
|
+
) {
|
|
223
|
+
return "result";
|
|
224
|
+
}
|
|
225
|
+
if (!isObj(seal) || typeof seal.stable !== "boolean" || !isStrArr(seal.nodeModulesExcludedDrift)) return "seal";
|
|
226
|
+
if (!isStr(r.receiptSha256)) return "receiptSha256";
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
type Checked = { ok: true; receipt: Receipt } | { ok: false; code: InvalidCode; detail: string };
|
|
231
|
+
|
|
232
|
+
/** Validate one receipt on its own terms — no knowledge of the current candidate. */
|
|
233
|
+
export function checkReceipt(text: string): Checked {
|
|
234
|
+
let raw: unknown;
|
|
235
|
+
try {
|
|
236
|
+
raw = JSON.parse(text);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
return { ok: false, code: "R-PARSE", detail: err instanceof Error ? err.message : String(err) };
|
|
239
|
+
}
|
|
240
|
+
if (!isObj(raw)) return { ok: false, code: "R-PARSE", detail: "not a JSON object" };
|
|
241
|
+
if (raw.schema !== RECEIPT_SCHEMA || raw.label !== RECEIPT_LABEL) {
|
|
242
|
+
return { ok: false, code: "R-SCHEMA", detail: `schema=${String(raw.schema)} label=${String(raw.label)}` };
|
|
243
|
+
}
|
|
244
|
+
const bad = shapeError(raw);
|
|
245
|
+
if (bad) return { ok: false, code: "R-SHAPE", detail: `missing or mistyped: ${bad}` };
|
|
246
|
+
const { receiptSha256, ...body } = raw;
|
|
247
|
+
if (sha256(canonicalJson(body)) !== receiptSha256) {
|
|
248
|
+
return { ok: false, code: "R-DIGEST", detail: "receiptSha256 does not match the canonical body" };
|
|
249
|
+
}
|
|
250
|
+
const r = raw as unknown as Receipt;
|
|
251
|
+
const mutantClaims = r.result.mutants.map((m) => m.claim);
|
|
252
|
+
if (JSON.stringify(mutantClaims) !== JSON.stringify(r.group.claims)) {
|
|
253
|
+
return { ok: false, code: "R-RESULT-INCONSISTENT", detail: "result.mutants claims differ from group.claims" };
|
|
254
|
+
}
|
|
255
|
+
const recomputed =
|
|
256
|
+
r.result.mutants.length > 0 &&
|
|
257
|
+
r.result.mutants.every((m) => m.verdict === "KILLED") &&
|
|
258
|
+
r.result.control === "ok" &&
|
|
259
|
+
r.result.treeClean &&
|
|
260
|
+
r.result.porcelainClean &&
|
|
261
|
+
r.seal.stable;
|
|
262
|
+
if (recomputed !== r.result.passed) {
|
|
263
|
+
return {
|
|
264
|
+
ok: false,
|
|
265
|
+
code: "R-RESULT-INCONSISTENT",
|
|
266
|
+
detail: `result.passed=${r.result.passed} but verdicts/control/purity/seal say ${recomputed}`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return { ok: true, receipt: r };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── composition (pure) ──────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
const UNRESOLVED_INPUT = (v: string): boolean =>
|
|
275
|
+
v === "missing" || v === "outside-repo" || v === "special" || v.startsWith("link:");
|
|
276
|
+
|
|
277
|
+
function mismatchAgainst(r: Receipt, cur: CurrentCandidate, g: CurrentGroup): string[] {
|
|
278
|
+
const out: string[] = [];
|
|
279
|
+
if (r.candidate.head !== cur.head) out.push("head");
|
|
280
|
+
if (r.candidate.workSurfaceSha256 !== cur.workSurfaceSha256) out.push("workSurface");
|
|
281
|
+
if (r.core.sha256 !== cur.coreSha256) out.push("core");
|
|
282
|
+
if (r.group.specSetSha256 !== g.specSetSha256) out.push("specSet");
|
|
283
|
+
if (JSON.stringify(r.group.claims) !== JSON.stringify(g.claims)) out.push("claims");
|
|
284
|
+
if (JSON.stringify([...r.manifestSet.claims].sort()) !== JSON.stringify(cur.manifestClaims)) out.push("manifestSet");
|
|
285
|
+
const keys = new Set([...Object.keys(r.group.declaredInputs), ...Object.keys(g.declaredInputs)]);
|
|
286
|
+
for (const k of [...keys].sort()) {
|
|
287
|
+
if (r.group.declaredInputs[k] !== g.declaredInputs[k]) out.push(`declaredInput:${k}`);
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const STATES: GroupState[] = ["MEASURED", "CARRIED", "INHERITED-UNVERIFIED", "UNRUN", "FAIL"];
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* `ledgerPath`: where the caller writes the full ledger JSON. Only then may the console
|
|
296
|
+
* summary compress the UNRUN list (count + first 5 + that path); without it, a
|
|
297
|
+
* RISK-PENDING summary lists every unrun group, because a risk someone is asked to weigh
|
|
298
|
+
* must be readable in full. FAIL, BLOCKER, INVALID, INPUT and the other RISK lines are
|
|
299
|
+
* never compressed. `ledger.risks` and `ledger.groups` always hold everything.
|
|
300
|
+
*
|
|
301
|
+
* `expected` is optional for a LOCAL composition. The offline composite oracle
|
|
302
|
+
* (qualification-oracle-core → composeRelease) passes none: its declared input list is each
|
|
303
|
+
* collection's own `attempted[].sha256` (checkCollectionData) under the GitHub artifact
|
|
304
|
+
* digest (admitArtifact), and an ancestor run it was not given leaves its groups UNRUN, a
|
|
305
|
+
* release-mode blocker. It is not the release oracle: verify-exact-ci.sh stays the strict
|
|
306
|
+
* exact-SHA full-body gate until #124 P3.
|
|
307
|
+
*/
|
|
308
|
+
export function composeLedger(
|
|
309
|
+
loaded: LoadedReceipt[],
|
|
310
|
+
cur: CurrentCandidate,
|
|
311
|
+
expected?: ExpectedInput[],
|
|
312
|
+
opts: {
|
|
313
|
+
ledgerPath?: string;
|
|
314
|
+
/** `release`: an UNRUN group is a blocker, and every `required` group must be exact-MEASURED. */
|
|
315
|
+
mode?: "local" | "release";
|
|
316
|
+
required?: string[][];
|
|
317
|
+
} = {},
|
|
318
|
+
): Ledger {
|
|
319
|
+
const invalid: Ledger["invalid"] = [];
|
|
320
|
+
const byGroup = new Map<string, { source: string; r: Receipt; seq?: number }[]>();
|
|
321
|
+
for (const g of cur.groups) byGroup.set(JSON.stringify(g.gate), []);
|
|
322
|
+
const seen = new Set<string>();
|
|
323
|
+
for (const l of loaded) {
|
|
324
|
+
const c = checkReceipt(l.text);
|
|
325
|
+
if (!c.ok) {
|
|
326
|
+
invalid.push({ source: l.source, code: c.code, detail: c.detail });
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (seen.has(c.receipt.receiptSha256)) {
|
|
330
|
+
invalid.push({
|
|
331
|
+
source: l.source,
|
|
332
|
+
code: "R-DUPLICATE",
|
|
333
|
+
detail: `receiptSha256 ${c.receipt.receiptSha256} already counted`,
|
|
334
|
+
});
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
seen.add(c.receipt.receiptSha256);
|
|
338
|
+
const bucket = byGroup.get(JSON.stringify(c.receipt.group.gate));
|
|
339
|
+
if (!bucket) {
|
|
340
|
+
invalid.push({
|
|
341
|
+
source: l.source,
|
|
342
|
+
code: "R-UNKNOWN-GROUP",
|
|
343
|
+
detail: `${JSON.stringify(c.receipt.group.gate)} is not an exact gate argv of the current manifest set`,
|
|
344
|
+
});
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
bucket.push({ source: l.source, r: c.receipt, seq: l.seq });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const groups: GroupLedger[] = cur.groups.map((g) => {
|
|
351
|
+
const entries = (byGroup.get(JSON.stringify(g.gate)) ?? [])
|
|
352
|
+
.slice()
|
|
353
|
+
.sort((x, y) =>
|
|
354
|
+
x.seq !== undefined && y.seq !== undefined
|
|
355
|
+
? x.seq - y.seq
|
|
356
|
+
: Date.parse(x.r.startedAt) - Date.parse(y.r.startedAt),
|
|
357
|
+
);
|
|
358
|
+
const orderOf = (e: { r: Receipt; seq?: number }): number => e.seq ?? Date.parse(e.r.startedAt);
|
|
359
|
+
const history: HistoryEntry[] = entries.map(({ source, r }) => ({
|
|
360
|
+
source,
|
|
361
|
+
receiptSha256: r.receiptSha256,
|
|
362
|
+
startedAt: r.startedAt,
|
|
363
|
+
passed: r.result.passed,
|
|
364
|
+
mismatch: mismatchAgainst(r, cur, g),
|
|
365
|
+
}));
|
|
366
|
+
const flags: string[] = [];
|
|
367
|
+
const unresolved = Object.entries(g.declaredInputs).filter(([, v]) => UNRESOLVED_INPUT(v));
|
|
368
|
+
if (unresolved.length > 0) flags.push(`input-unresolved:${unresolved.map(([k, v]) => `${k}=${v}`).join(",")}`);
|
|
369
|
+
|
|
370
|
+
// Exact match first. Only when there is none, and only on a CLEAN candidate, may a
|
|
371
|
+
// receipt whose sole difference is `head` stand in: the snapshot re-inits git
|
|
372
|
+
// (mutation-qualify.ts createRepoSnapshot), so no gate can see origin history, and a
|
|
373
|
+
// clean porcelain means the surface IS the committed tree. That is a LOCAL
|
|
374
|
+
// content-identity statement, flagged, and it can never make a ledger green.
|
|
375
|
+
const exactIdx = entries.map((_, i) => i).filter((i) => history[i].mismatch.length === 0);
|
|
376
|
+
const contentIdx = cur.porcelainClean
|
|
377
|
+
? entries.map((_, i) => i).filter((i) => history[i].mismatch.length === 1 && history[i].mismatch[0] === "head")
|
|
378
|
+
: [];
|
|
379
|
+
const provenance: Provenance | null =
|
|
380
|
+
exactIdx.length > 0 ? "exact" : contentIdx.length > 0 ? "content-identical" : null;
|
|
381
|
+
if (provenance) {
|
|
382
|
+
const matching = (provenance === "exact" ? exactIdx : contentIdx).map((i) => entries[i]);
|
|
383
|
+
const newest = matching[matching.length - 1];
|
|
384
|
+
if (provenance === "content-identical") {
|
|
385
|
+
flags.push(`content-identical(head ${newest.r.candidate.head.slice(0, 12)}→${cur.head.slice(0, 12)})`);
|
|
386
|
+
}
|
|
387
|
+
// Two matches at the SAME newest instant that disagree cannot be ordered;
|
|
388
|
+
// picking one would be arbitrary, so the group stays an unresolved FAIL.
|
|
389
|
+
const tied = matching.filter((e) => orderOf(e) === orderOf(newest));
|
|
390
|
+
const base = { gate: g.gate, claims: g.claims, provenance, inheritKind: null, flags, history };
|
|
391
|
+
if (tied.some((e) => e.r.result.passed !== newest.r.result.passed)) {
|
|
392
|
+
return {
|
|
393
|
+
...base,
|
|
394
|
+
state: "FAIL",
|
|
395
|
+
reasons: [`tie: ${tied.length} matching receipts at ${newest.r.startedAt} disagree`],
|
|
396
|
+
decidedBy: null,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
const redBefore = matching.slice(0, -1).some((e) => !e.r.result.passed);
|
|
400
|
+
// Tied receipts that agree on `passed` may still differ elsewhere; the drift flag is
|
|
401
|
+
// taken over ALL of them so no tie-break decides it. Other per-receipt differences
|
|
402
|
+
// among agreeing ties (decidedBy, reasons) remain a future-risk: decidedBy names
|
|
403
|
+
// the last in input order.
|
|
404
|
+
if (tied.some((e) => e.r.seal.nodeModulesExcludedDrift.length > 0)) flags.push("cache-drift");
|
|
405
|
+
if (newest.r.result.passed) {
|
|
406
|
+
if (redBefore) flags.push("red-then-green-same-candidate");
|
|
407
|
+
return {
|
|
408
|
+
...base,
|
|
409
|
+
state: "MEASURED",
|
|
410
|
+
reasons: redBefore ? ["an earlier red on these bytes is superseded but kept in history"] : [],
|
|
411
|
+
decidedBy: newest.r.receiptSha256,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
const notKilled = newest.r.result.mutants
|
|
415
|
+
.filter((m) => m.verdict !== "KILLED")
|
|
416
|
+
.map((m) => `${m.claim}=${m.verdict}`);
|
|
417
|
+
const why = [
|
|
418
|
+
...notKilled,
|
|
419
|
+
...(newest.r.result.control !== "ok" ? [`control=${newest.r.result.control}`] : []),
|
|
420
|
+
...(!newest.r.result.treeClean || !newest.r.result.porcelainClean ? ["snapshot impure"] : []),
|
|
421
|
+
...(!newest.r.seal.stable ? ["seal moved during the run"] : []),
|
|
422
|
+
];
|
|
423
|
+
return { ...base, state: "FAIL", reasons: why, decidedBy: newest.r.receiptSha256 };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const inheritable = entries
|
|
427
|
+
.map((e, i) => ({ e, h: history[i] }))
|
|
428
|
+
.filter(({ e, h }) => e.r.result.passed && !h.mismatch.includes("specSet") && !h.mismatch.includes("claims"));
|
|
429
|
+
if (inheritable.length > 0) {
|
|
430
|
+
const { e, h } = inheritable[inheritable.length - 1];
|
|
431
|
+
const inheritKind = h.mismatch.some((m) => m.startsWith("declaredInput:")) ? "c-input" : "c-core";
|
|
432
|
+
return {
|
|
433
|
+
gate: g.gate,
|
|
434
|
+
claims: g.claims,
|
|
435
|
+
state: "INHERITED-UNVERIFIED",
|
|
436
|
+
provenance: null,
|
|
437
|
+
inheritKind,
|
|
438
|
+
flags,
|
|
439
|
+
reasons: [`passed on other bytes; differs in: ${h.mismatch.join(", ")}`],
|
|
440
|
+
decidedBy: e.r.receiptSha256,
|
|
441
|
+
history,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
const reasons =
|
|
445
|
+
entries.length === 0
|
|
446
|
+
? ["no receipt"]
|
|
447
|
+
: [
|
|
448
|
+
`no usable receipt: ${history.map((h) => `${h.passed ? "green" : "red"}[${h.mismatch.join(",")}]`).join("; ")}`,
|
|
449
|
+
];
|
|
450
|
+
return {
|
|
451
|
+
gate: g.gate,
|
|
452
|
+
claims: g.claims,
|
|
453
|
+
state: "UNRUN",
|
|
454
|
+
provenance: null,
|
|
455
|
+
inheritKind: null,
|
|
456
|
+
flags,
|
|
457
|
+
reasons,
|
|
458
|
+
decidedBy: null,
|
|
459
|
+
history,
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const zero = (): Record<GroupState, number> & { total: number } =>
|
|
464
|
+
Object.assign(Object.fromEntries(STATES.map((s) => [s, 0])) as Record<GroupState, number>, { total: 0 });
|
|
465
|
+
const gCounts = zero();
|
|
466
|
+
const cCounts = zero();
|
|
467
|
+
for (const g of groups) {
|
|
468
|
+
gCounts[g.state]++;
|
|
469
|
+
gCounts.total++;
|
|
470
|
+
cCounts[g.state] += g.claims.length;
|
|
471
|
+
cCounts.total += g.claims.length;
|
|
472
|
+
}
|
|
473
|
+
const flagged = groups.filter((g) => g.flags.length > 0);
|
|
474
|
+
const label = (g: GroupLedger): string => g.gate.join(" ");
|
|
475
|
+
|
|
476
|
+
// Inputs: every file is listed with its digest. An explicit expected list turns a
|
|
477
|
+
// missing, extra or altered input into RED — evidence that vanished is not UNRUN.
|
|
478
|
+
const inputs = loaded.map((l) => ({ source: l.source, sha256: sha256(l.text), provenance: "local-file" as const }));
|
|
479
|
+
const inputErrors: string[] = [];
|
|
480
|
+
if (expected) {
|
|
481
|
+
const have = new Map(inputs.map((i) => [i.source, i.sha256]));
|
|
482
|
+
const want = new Map(expected.map((e) => [e.source, e.sha256]));
|
|
483
|
+
for (const [src, sha] of want) {
|
|
484
|
+
if (!have.has(src)) inputErrors.push(`input-missing: ${src}`);
|
|
485
|
+
else if (have.get(src) !== sha) inputErrors.push(`input-digest: ${src} is ${have.get(src)}, expected ${sha}`);
|
|
486
|
+
}
|
|
487
|
+
for (const src of have.keys()) if (!want.has(src)) inputErrors.push(`input-unexpected: ${src}`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Blockers: states under which no RISK may even be proposed. The minimum a proposal
|
|
491
|
+
// needs is the runner's own group exact-MEASURED on these bytes, every red re-measured,
|
|
492
|
+
// every group whose declared inputs moved re-measured, and a committed (clean) candidate.
|
|
493
|
+
const blockers: string[] = [];
|
|
494
|
+
if (!cur.porcelainClean) blockers.push("candidate-dirty: uncommitted changes, not a release candidate");
|
|
495
|
+
const coreGroup = groups.find((g) => JSON.stringify(g.gate) === JSON.stringify(cur.coreGroupGate));
|
|
496
|
+
if (!coreGroup || coreGroup.state !== "MEASURED" || coreGroup.provenance !== "exact") {
|
|
497
|
+
blockers.push(`core-group-not-measured: ${cur.coreGroupGate.join(" ")} must be exact-MEASURED on these bytes`);
|
|
498
|
+
}
|
|
499
|
+
for (const g of groups) {
|
|
500
|
+
if (g.state === "INHERITED-UNVERIFIED" && g.inheritKind === "c-input") {
|
|
501
|
+
blockers.push(`c-input: ${label(g)} — a declared input changed since its last pass; re-measure it`);
|
|
502
|
+
}
|
|
503
|
+
const newest = g.history[g.history.length - 1];
|
|
504
|
+
if ((g.state === "UNRUN" || g.state === "INHERITED-UNVERIFIED") && newest && !newest.passed) {
|
|
505
|
+
blockers.push(`red-unremeasured: ${label(g)} — its newest receipt is red; re-measure it`);
|
|
506
|
+
}
|
|
507
|
+
if (opts.mode === "release" && g.state === "UNRUN") {
|
|
508
|
+
blockers.push(`unrun (release): ${label(g)} — no usable pass receipt in the chain; measure it`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
for (const argv of opts.required ?? []) {
|
|
512
|
+
const g = groups.find((x) => JSON.stringify(x.gate) === JSON.stringify(argv));
|
|
513
|
+
if (!g || g.state !== "MEASURED" || g.provenance !== "exact") {
|
|
514
|
+
blockers.push(`required-not-measured: ${argv.join(" ")} must be exact-MEASURED on these bytes`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const risks = [
|
|
518
|
+
...groups
|
|
519
|
+
.filter((g) => g.state === "INHERITED-UNVERIFIED" && g.inheritKind === "c-core")
|
|
520
|
+
.map((g) => `c-core: ${label(g)} (${g.reasons[0]})`),
|
|
521
|
+
...groups.filter((g) => g.state === "UNRUN").map((g) => `unrun: ${label(g)} (${g.claims.length} claims)`),
|
|
522
|
+
...flagged.map((g) => `flag: ${label(g)}: ${g.flags.join("; ")}`),
|
|
523
|
+
];
|
|
524
|
+
|
|
525
|
+
// RED: an unresolved failure on these bytes, or evidence that cannot be read or is
|
|
526
|
+
// not the evidence declared. Fail-closed; nothing here is a risk to accept.
|
|
527
|
+
const verdict: LedgerVerdict =
|
|
528
|
+
gCounts.FAIL > 0 || invalid.length > 0 || inputErrors.length > 0
|
|
529
|
+
? "RED"
|
|
530
|
+
: blockers.length > 0
|
|
531
|
+
? "NOT-QUALIFIED"
|
|
532
|
+
: gCounts.MEASURED === gCounts.total && gCounts.total > 0 && flagged.length === 0
|
|
533
|
+
? "COMPOSITE-GREEN"
|
|
534
|
+
: "RISK-PENDING";
|
|
535
|
+
|
|
536
|
+
const summary = [
|
|
537
|
+
`[qualification-ledger] ${verdict} on ${cur.head.slice(0, 12)}${cur.porcelainClean ? "" : " (dirty)"} surface ${cur.workSurfaceSha256.slice(0, 12)} core ${cur.coreSha256.slice(0, 12)}`,
|
|
538
|
+
` groups ${gCounts.total}: MEASURED ${gCounts.MEASURED}, CARRIED ${gCounts.CARRIED}, INHERITED-UNVERIFIED ${gCounts["INHERITED-UNVERIFIED"]}, UNRUN ${gCounts.UNRUN}, FAIL ${gCounts.FAIL}`,
|
|
539
|
+
` claims ${cCounts.total}: MEASURED ${cCounts.MEASURED}, INHERITED-UNVERIFIED ${cCounts["INHERITED-UNVERIFIED"]}, UNRUN ${cCounts.UNRUN}, FAIL ${cCounts.FAIL}; invalid receipts ${invalid.length}`,
|
|
540
|
+
...groups.filter((g) => g.state === "FAIL").map((g) => ` FAIL ${label(g)}: ${g.reasons.join("; ")}`),
|
|
541
|
+
...invalid.map((i) => ` INVALID ${i.source}: ${i.code} ${i.detail}`),
|
|
542
|
+
...inputErrors.map((e) => ` INPUT ${e}`),
|
|
543
|
+
...blockers.map((b) => ` BLOCKER ${b}`),
|
|
544
|
+
...summarizeRisks(risks, verdict, opts.ledgerPath),
|
|
545
|
+
verdict === "COMPOSITE-GREEN"
|
|
546
|
+
? ` ${cCounts.MEASURED}/${cCounts.total} claims measured on these bytes (per-group snapshots, not the serial full body)`
|
|
547
|
+
: verdict === "RISK-PENDING"
|
|
548
|
+
? " RISK-PENDING: named risks above await a human decision; this ledger accepts nothing and authorizes no release"
|
|
549
|
+
: verdict === "NOT-QUALIFIED"
|
|
550
|
+
? " NOT-QUALIFIED: no risk can be proposed until the blockers above are measured away"
|
|
551
|
+
: " RED: an unresolved failure, or evidence that is unreadable or not the evidence declared",
|
|
552
|
+
];
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
schema: LEDGER_SCHEMA,
|
|
556
|
+
candidate: {
|
|
557
|
+
head: cur.head,
|
|
558
|
+
workSurfaceSha256: cur.workSurfaceSha256,
|
|
559
|
+
coreSha256: cur.coreSha256,
|
|
560
|
+
porcelainClean: cur.porcelainClean,
|
|
561
|
+
},
|
|
562
|
+
verdict,
|
|
563
|
+
blockers,
|
|
564
|
+
risks,
|
|
565
|
+
inputs,
|
|
566
|
+
inputErrors,
|
|
567
|
+
counts: { groups: gCounts, claims: cCounts, invalid: invalid.length },
|
|
568
|
+
groups,
|
|
569
|
+
invalid,
|
|
570
|
+
notCovered: LEDGER_NOT_COVERED,
|
|
571
|
+
summary,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const UNRUN_SHOWN = 5;
|
|
576
|
+
|
|
577
|
+
function summarizeRisks(risks: string[], verdict: LedgerVerdict, ledgerPath: string | undefined): string[] {
|
|
578
|
+
const unrun = risks.filter((r) => r.startsWith("unrun: "));
|
|
579
|
+
const other = risks.filter((r) => !r.startsWith("unrun: ")).map((r) => ` RISK ${r}`);
|
|
580
|
+
const mustBeFull = verdict === "RISK-PENDING" && ledgerPath === undefined;
|
|
581
|
+
if (mustBeFull || unrun.length <= UNRUN_SHOWN) return [...other, ...unrun.map((r) => ` RISK ${r}`)];
|
|
582
|
+
const where = ledgerPath ? `full list: ${ledgerPath}` : "full list: pass --ledger-out";
|
|
583
|
+
return [
|
|
584
|
+
...other,
|
|
585
|
+
` RISK unrun: ${unrun.length} groups — first ${UNRUN_SHOWN}: ${unrun
|
|
586
|
+
.slice(0, UNRUN_SHOWN)
|
|
587
|
+
.map((r) => r.slice("unrun: ".length))
|
|
588
|
+
.join("; ")}; ${where}`,
|
|
589
|
+
];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ── impure edges ────────────────────────────────────────────────────────────
|
|
593
|
+
|
|
594
|
+
export function loadReceipt(file: string): LoadedReceipt {
|
|
595
|
+
return { source: file, text: fs.readFileSync(file, "utf8") };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** The current candidate, computed with the SAME functions the receipt producer uses. */
|
|
599
|
+
export const CORE_GROUP_GATE = ["bash", "run.sh", "check-gate-manifests"];
|
|
600
|
+
|
|
601
|
+
/** Parse a `sha256sum`-format list (`<64 hex> <path>` per line); anything else throws. */
|
|
602
|
+
export function parseExpectedList(text: string): ExpectedInput[] {
|
|
603
|
+
return text
|
|
604
|
+
.split("\n")
|
|
605
|
+
.filter((l) => l.trim() !== "")
|
|
606
|
+
.map((l, i) => {
|
|
607
|
+
const m = /^([0-9a-f]{64}) [ *](.+)$/.exec(l);
|
|
608
|
+
if (!m) throw new Error(`expected-input list line ${i + 1} is not \`<sha256> <path>\`: ${l}`);
|
|
609
|
+
return { sha256: m[1], source: m[2] };
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* A candidate's CONTENT projection: everything recomputable from the tree itself (head,
|
|
615
|
+
* surface, porcelain, CORE_FILES, manifest, groups). It deliberately has no `coreSha256`:
|
|
616
|
+
* the node/node_modules part of C0 belongs to the environment that ran the gates, and
|
|
617
|
+
* whoever holds only the tree must not fill it in.
|
|
618
|
+
*/
|
|
619
|
+
export type ContentCandidate = Omit<CurrentCandidate, "coreSha256">;
|
|
620
|
+
|
|
621
|
+
export function contentCandidate(
|
|
622
|
+
repoDir: string,
|
|
623
|
+
selected: MutantSpec[],
|
|
624
|
+
coreGroupGate: string[] = CORE_GROUP_GATE,
|
|
625
|
+
): ContentCandidate {
|
|
626
|
+
const order: string[] = [];
|
|
627
|
+
const specs = new Map<string, MutantSpec[]>();
|
|
628
|
+
for (const m of selected) {
|
|
629
|
+
const k = JSON.stringify(m.gate);
|
|
630
|
+
if (!specs.has(k)) {
|
|
631
|
+
specs.set(k, []);
|
|
632
|
+
order.push(k);
|
|
633
|
+
}
|
|
634
|
+
specs.get(k)?.push(m);
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
head: originHead(repoDir),
|
|
638
|
+
workSurfaceSha256: originWorkSurfaceSha(repoDir),
|
|
639
|
+
coreFiles: fileFingerprints(repoDir, CORE_FILES),
|
|
640
|
+
porcelainClean: spawnSync("git", ["-C", repoDir, "status", "--porcelain"], { encoding: "utf8" }).stdout === "",
|
|
641
|
+
coreGroupGate,
|
|
642
|
+
manifestClaims: selected.map((m) => m.claim).sort(),
|
|
643
|
+
groups: order.map((k) => {
|
|
644
|
+
const gs = specs.get(k) ?? [];
|
|
645
|
+
const gate = JSON.parse(k) as string[];
|
|
646
|
+
return {
|
|
647
|
+
gate,
|
|
648
|
+
claims: gs.map((m) => m.claim),
|
|
649
|
+
specSetSha256: sha256(canonicalJson(gs)),
|
|
650
|
+
declaredInputs: fileFingerprints(repoDir, declaredInputPaths(repoDir, gs, gate)),
|
|
651
|
+
};
|
|
652
|
+
}),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** The full local candidate: the content projection plus THIS host's node/node_modules C0. */
|
|
657
|
+
export function currentCandidate(
|
|
658
|
+
repoDir: string,
|
|
659
|
+
selected: MutantSpec[],
|
|
660
|
+
coreGroupGate: string[] = CORE_GROUP_GATE,
|
|
661
|
+
): CurrentCandidate {
|
|
662
|
+
const content = contentCandidate(repoDir, selected, coreGroupGate);
|
|
663
|
+
const core = {
|
|
664
|
+
files: content.coreFiles ?? {},
|
|
665
|
+
node: nodeRuntime(),
|
|
666
|
+
nodeModules: nodeModulesFingerprint(repoDir).summary,
|
|
667
|
+
};
|
|
668
|
+
return { ...content, coreSha256: coreSha256(core) };
|
|
669
|
+
}
|