@mmnto/totem 1.91.0 → 1.93.0

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.
@@ -0,0 +1,863 @@
1
+ /**
2
+ * Verdict-artifact contract — the single convergence point both review lanes
3
+ * emit (mmnto-ai/totem#2106, Proposal 302 / 304 R2 local review runner).
4
+ *
5
+ * A verdict artifact is the immutable, content-addressed record of ONE review
6
+ * round over ONE masked diff: the fan of lanes that attempted it (each a
7
+ * terminal {@link RunArtifact} reference, one hop from provenance), the
8
+ * deterministic #2103 post-checks, the normalized findings, the optional #2104
9
+ * panel it assembled, and the derived round/lineage bookkeeping. Everything
10
+ * downstream (the CLI round loop, the pilot ledger's covariate PR-line, the
11
+ * Phase-2 disposition ledger) consumes this shape, so it stays minimal but
12
+ * versioned.
13
+ *
14
+ * ── LANE-BLINDNESS INVARIANT (Proposal 302, DELIBERATE EXCLUSION) ────────────
15
+ * There is NO warm/cold runner-lane discriminator field ANYWHERE in this schema
16
+ * — not at the top level, not on a lane. This exclusion is deliberate: a
17
+ * contract consumer reads the verdict and CANNOT discriminate WHICH runner lane
18
+ * (a warm resident agent vs a cold SDK invocation) produced it. The wording
19
+ * matters (strategy 1a): "consumers cannot discriminate lanes FROM the
20
+ * artifact", NOT "lane identity is unknowable" — `lanes[].runArtifactHash`
21
+ * reaches provenance one hop away and `resolvedBackend` is panel-DIVERSITY data,
22
+ * neither of which is a warm/cold runner discriminator. The absence is enforced
23
+ * by a structural test (snapshots the key set) IN ADDITION to this note.
24
+ *
25
+ * The KEY-set structural test is not enough on its own: a runner class could be
26
+ * smuggled through a laneId VALUE. So `laneId` is additionally constrained to a
27
+ * backend-derived vocabulary — `lane-<index>:<resolvedBackendOrConfiguredLane>`
28
+ * (see {@link LaneIdSchema}) — with a refinement rejecting warm/cold/headless/
29
+ * sdk-runner substrings (strategy-codex G1). Net invariant: a consumer can
30
+ * identify WHICH backend participated (diversity), NEVER whether the producer was
31
+ * warm / cold / headless.
32
+ *
33
+ * Schema-evolution policy mirrors {@link RunArtifactSchema} / the panel artifact
34
+ * (F1): the reader is version-tolerant WITHIN the major — `schemaVersion`
35
+ * validates as `1.x`, every post-1.0.0 field is additive-optional, and a MAJOR
36
+ * bump requires a migration entry in `loadVerdictArtifact` before the writer
37
+ * ships. Hard-reject only unknown majors. Zod is the persisted-JSON boundary
38
+ * (read back from disk), per the repo's Zod-at-boundaries-only rule.
39
+ */
40
+ import * as fs from 'node:fs';
41
+ import * as path from 'node:path';
42
+ import { z } from 'zod';
43
+ import { rethrowAsParseError, TotemError, TotemParseError } from '../errors.js';
44
+ import { readJsonSafe } from '../sys/fs.js';
45
+ import { calculateDeterministicHash } from './hash.js';
46
+ import { classifyDiversity, PanelDiversitySchema, PersistedPostCheckFindingSchema, } from './panel.js';
47
+ // ─── Schema version (mirrors RunArtifact / Panel F1) ────────────────────────
48
+ /** The verdict schemaVersion WRITTEN by this code. Readers accept any 1.x (F1). */
49
+ export const VERDICT_ARTIFACT_SCHEMA_VERSION = '1.0.0';
50
+ /** The major this reader understands; other majors need a migration entry. */
51
+ export const VERDICT_ARTIFACT_KNOWN_MAJOR = 1;
52
+ /** Major-1 semver literal — keep in sync with {@link VERDICT_ARTIFACT_KNOWN_MAJOR} (a literal beats runtime RegExp construction; the major only changes alongside a migration entry). */
53
+ const VERDICT_SCHEMA_VERSION_RE = /^1\.\d+\.\d+$/;
54
+ /** Accept any 1.x version; reject other majors with the version NAMED (F1) —
55
+ * mirrors run-artifact's `schemaVersionField` refine so the rejection error
56
+ * carries the offending value, not just a static string. */
57
+ const verdictSchemaVersionField = z.string().refine((v) => VERDICT_SCHEMA_VERSION_RE.test(v), (v) => ({
58
+ message: `unsupported verdict-artifact schemaVersion "${v}" — this reader understands major ${VERDICT_ARTIFACT_KNOWN_MAJOR}.x; a new major requires a migration entry in loadVerdictArtifact`,
59
+ }));
60
+ /** sha256 hex content hash (full digest — identity, not display). */
61
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
62
+ /** Zod guard for a sha256 hex content address (mirrors schema.ts; no bare RegExp.test at the boundary). */
63
+ const Sha256HexSchema = z.string().regex(SHA256_HEX, 'must be a sha256 hex digest');
64
+ // ─── Diff scope (source-discriminated) ──────────────────────────────────────
65
+ /**
66
+ * The four `getDiffForReview` sources. Canonical order matches the design doc.
67
+ */
68
+ export const VERDICT_DIFF_SOURCES = [
69
+ 'explicit-range',
70
+ 'staged',
71
+ 'uncommitted',
72
+ 'branch-vs-base',
73
+ ];
74
+ /**
75
+ * The reviewed diff's scope, DISCRIMINATED by `source`. `diffHash` is ALWAYS
76
+ * required (sha256 over the MASKED review-payload bytes the lanes actually
77
+ * reviewed — hash symmetry with the artifact chain, never binds secret-bearing
78
+ * bytes; agy fold 5). The git ref fields are required only where the source
79
+ * makes them meaningful:
80
+ * - `explicit-range` — `base` AND `head` (the two endpoints).
81
+ * - `branch-vs-base` — `base` only (head is the working ref, implicit).
82
+ * - `staged` / `uncommitted` — NO refs (the index / worktree has none).
83
+ */
84
+ export const VerdictDiffScopeSchema = z.discriminatedUnion('source', [
85
+ z.object({
86
+ source: z.literal('explicit-range'),
87
+ diffHash: Sha256HexSchema,
88
+ base: z.string().min(1),
89
+ head: z.string().min(1),
90
+ }),
91
+ z.object({
92
+ source: z.literal('branch-vs-base'),
93
+ diffHash: Sha256HexSchema,
94
+ base: z.string().min(1),
95
+ }),
96
+ z.object({
97
+ source: z.literal('staged'),
98
+ diffHash: Sha256HexSchema,
99
+ }),
100
+ z.object({
101
+ source: z.literal('uncommitted'),
102
+ diffHash: Sha256HexSchema,
103
+ }),
104
+ ]);
105
+ // ─── Lanes (status-discriminated union) ─────────────────────────────────────
106
+ /**
107
+ * Typed terminal-failure reasons for a `failed` lane. A failed lane is never
108
+ * handed to `assemblePanelArtifact` and never stamps the cache. NOTE (Prop 302
109
+ * lane-blindness): these classify the FAILURE, never the runner lane — none of
110
+ * them names warm/cold.
111
+ */
112
+ export const VERDICT_LANE_FAILURE_REASONS = [
113
+ 'invoke-error',
114
+ 'quota-exhausted',
115
+ 'missing-artifact-emission',
116
+ 'config-error',
117
+ ];
118
+ /** A `completed` lane's own severity tally (from its extracted structured verdict). */
119
+ export const VerdictLaneSummarySchema = z.object({
120
+ critical: z.number().int().nonnegative(),
121
+ warn: z.number().int().nonnegative(),
122
+ info: z.number().int().nonnegative(),
123
+ });
124
+ // ─── laneId value-channel vocabulary (Prop 302 G1) ──────────────────────────
125
+ /**
126
+ * The EXACT laneId shape — `lane-<index>:<resolvedBackendOrConfiguredLane>`:
127
+ * - `lane-` literal prefix,
128
+ * - `<index>` — the lane's zero-based position in the fan (`\d+`),
129
+ * - `:` separator,
130
+ * - `<resolvedBackendOrConfiguredLane>` — the resolved backend (`provider:model`,
131
+ * which itself carries a colon) for a lane that reached a backend, or the
132
+ * CONFIGURED lane string for a lane that failed before one resolved. Non-empty,
133
+ * opaque backend/lane text (`.+` — newlines excluded).
134
+ *
135
+ * This is backend-DERIVED vocabulary: a consumer can read the id and identify
136
+ * WHICH backend participated (panel-diversity data), and NOTHING about whether the
137
+ * producer was warm / cold / headless.
138
+ */
139
+ export const LANE_ID_SHAPE_RE = /^lane-\d+:.+$/;
140
+ /**
141
+ * laneId: shape-validated here; the VALUE channel of lane-blindness (Prop 302
142
+ * G1) is closed STRUCTURALLY by the schema's superRefine — every lane's suffix
143
+ * must equal its binding field (`resolvedBackend` for completed/abstained,
144
+ * `configuredLane` for failed), so a laneId cannot carry free text at all and a
145
+ * runner class (`warm`/`cold`/`headless`) has no channel to ride. An earlier
146
+ * substring blacklist here was removed: with structural binding as the primary
147
+ * guard it added only false-positive risk against legitimate future model names
148
+ * (e.g. a model literally named `*-cold-*`; PR #2337 greptile P2).
149
+ */
150
+ export const LaneIdSchema = z
151
+ .string()
152
+ .regex(LANE_ID_SHAPE_RE, 'laneId must have the shape `lane-<index>:<resolvedBackendOrConfiguredLane>` (e.g. `lane-0:anthropic:claude-4`) — backend-derived vocabulary only (Prop 302 G1)');
153
+ /**
154
+ * One lane's terminal outcome, DISCRIMINATED by `status`. The union makes
155
+ * impossible records unrepresentable (codex fold 2): a `completed` lane STRUCTURALLY
156
+ * requires its `runArtifactHash` (a response-cache hit emits no run artifact and so
157
+ * can never be `completed`); a `failed` lane carries a typed reason and never a
158
+ * `runArtifactHash`. `resolvedBackend` records what actually ran (post quota
159
+ * fallback) and is panel-diversity data — NOT a runner discriminator.
160
+ */
161
+ export const VerdictLaneSchema = z.discriminatedUnion('status', [
162
+ z.object({
163
+ status: z.literal('completed'),
164
+ laneId: LaneIdSchema,
165
+ resolvedBackend: z.string().min(1),
166
+ runArtifactHash: Sha256HexSchema,
167
+ verdictSummary: VerdictLaneSummarySchema,
168
+ }),
169
+ z.object({
170
+ status: z.literal('abstained'),
171
+ laneId: LaneIdSchema,
172
+ resolvedBackend: z.string().min(1),
173
+ runArtifactHash: Sha256HexSchema,
174
+ /** Why no usable structured verdict was extractable (invoke happened, output unparseable). */
175
+ reason: z.string().min(1),
176
+ }),
177
+ z.object({
178
+ status: z.literal('failed'),
179
+ laneId: LaneIdSchema,
180
+ typedReason: z.enum(VERDICT_LANE_FAILURE_REASONS),
181
+ /**
182
+ * REQUIRED (rev-6 item 3): the configured `provider:model` string the lane was
183
+ * created from. A failed lane can have NO `resolvedBackend` (it failed before a
184
+ * backend resolved — e.g. `config-error` / `missing-artifact-emission`), so the
185
+ * laneId suffix has nothing to bind to unless the configured lane is persisted.
186
+ * The `superRefine` binds `laneId` suffix === `configuredLane` (closing the
187
+ * `lane-0:gemini:completely-invented` tautology): the suffix is no longer free —
188
+ * it must equal this declared field.
189
+ */
190
+ configuredLane: z.string().min(1),
191
+ /**
192
+ * OPTIONAL supplementary provenance: the backend that ACTUALLY ran before the
193
+ * lane failed (present only when a backend resolved — e.g. a quota fallback that
194
+ * then failed). NOT the id binding: after a quota fallback it can legitimately
195
+ * DIFFER from `configuredLane`, so the laneId suffix binds to `configuredLane`
196
+ * (stable at lane creation), never to this field.
197
+ */
198
+ resolvedBackend: z.string().min(1).optional(),
199
+ }),
200
+ ]);
201
+ // ─── Findings (aligned with ShieldFinding — core must not import from cli) ────
202
+ /** Severity vocabulary — aligned VERBATIM with cli `ShieldFindingSeveritySchema` (defined here so core stays cli-independent). */
203
+ export const VerdictFindingSeveritySchema = z.enum(['CRITICAL', 'WARN', 'INFO']);
204
+ /**
205
+ * A normalized finding from the shared review-output extractor. Field names
206
+ * align with cli `ShieldFinding` (`severity` / `confidence` / `message` /
207
+ * `file` / `line`); `confidence` is optional here because not every extracted
208
+ * lane output carries one, but when present it is a 0..1 probability (same
209
+ * bound as ShieldFinding). The diagnostic `message` is NEVER dropped or
210
+ * renamed.
211
+ */
212
+ export const VerdictFindingSchema = z.object({
213
+ severity: VerdictFindingSeveritySchema,
214
+ confidence: z.number().min(0).max(1).optional(),
215
+ file: z.string().optional(),
216
+ line: z.number().optional(),
217
+ message: z.string(),
218
+ });
219
+ // ─── Round / lineage ─────────────────────────────────────────────────────────
220
+ /**
221
+ * Round bookkeeping (all DERIVED — see the CLI lifecycle). `lineageKey` is the
222
+ * composite hash over the RESOLVED scope selector (see {@link computeLineageKey}
223
+ * — worktree identity + branch + source + the meaningful range selectors), NOT
224
+ * the diff bytes, so legitimate fix rounds still chain; `priorVerdictHash` links
225
+ * the implicit prior round (latest verdict sharing the lineage key) or an
226
+ * explicit `--continues` override; absent at round 0.
227
+ */
228
+ export const VerdictRoundSchema = z.object({
229
+ index: z.number().int().nonnegative(),
230
+ priorVerdictHash: Sha256HexSchema.optional(),
231
+ lineageKey: z.string().min(1),
232
+ });
233
+ /** Shared first conjunct: a nonempty fan where every attempted lane completed. */
234
+ function everyLaneCompleted(lanes) {
235
+ return lanes.length > 0 && lanes.every((l) => l.status === 'completed');
236
+ }
237
+ /** A decidable-tier post-check row failed (sensor-tier rows never gate — ADR-109 / #2106). */
238
+ function hasDecidablePostCheckFail(postChecks) {
239
+ return postChecks.some((r) => r.tier === 'decidable' && r.verdict === 'fail');
240
+ }
241
+ /**
242
+ * `settled` — the current-round dryness predicate, PURE over artifact content (no
243
+ * cross-round input, no model output):
244
+ *
245
+ * settled = (every attempted lane completed)
246
+ * AND (zero actionable — WARN|CRITICAL — findings)
247
+ * AND (no decidable-tier post-check row with verdict 'fail')
248
+ * AND (reviewedState === 'matched')
249
+ *
250
+ * A failed/abstained lane ⇒ fan incomplete ⇒ never settled (a persistent CRITICAL
251
+ * can never settle by lane dropout — agy fold 1, satisfied structurally); drift ⇒
252
+ * the verdict is bound to the pre-fan diff and does NOT cover the current tree ⇒
253
+ * not settled (codex rev-2 fold 1). This export is the SINGLE SOURCE OF TRUTH: the
254
+ * CLI derives its loop-termination signal from it and {@link VerdictArtifactSchema}
255
+ * re-derives + checks the stored `settled` (finding 5) — a crafted lane output
256
+ * cannot flip it (pure function; the exemption filter is the only removal
257
+ * mechanism, upstream of this boundary).
258
+ */
259
+ export function deriveSettled(v) {
260
+ return (everyLaneCompleted(v.lanes) &&
261
+ !v.findings.some((f) => f.severity === 'WARN' || f.severity === 'CRITICAL') &&
262
+ !hasDecidablePostCheckFail(v.postChecks) &&
263
+ v.reviewedState === 'matched');
264
+ }
265
+ /**
266
+ * Cache eligibility — the DISTINCT, weaker predicate (codex fold 4). Identical to
267
+ * {@link deriveSettled} except it tolerates WARNs (matching today's PASS
268
+ * semantics — the drip class the runner absorbs is WARN-shaped):
269
+ *
270
+ * cacheEligible = (every attempted lane completed)
271
+ * AND (zero CRITICAL findings)
272
+ * AND (no decidable-tier post-check row with verdict 'fail')
273
+ * AND (reviewedState === 'matched')
274
+ *
275
+ * A degraded fan (any failed/abstained lane) fails the first conjunct and is
276
+ * therefore never cache-eligible; drift blocks the stamp. `settled` (no WARNs) is
277
+ * deliberately STRICTER than cache-eligible (no CRITICALs).
278
+ */
279
+ export function deriveCacheEligible(v) {
280
+ return (everyLaneCompleted(v.lanes) &&
281
+ !v.findings.some((f) => f.severity === 'CRITICAL') &&
282
+ !hasDecidablePostCheckFail(v.postChecks) &&
283
+ v.reviewedState === 'matched');
284
+ }
285
+ // ─── Verdict artifact ──────────────────────────────────────────────────────
286
+ /**
287
+ * The verdict artifact. See the module docstring for the LANE-BLINDNESS
288
+ * invariant (Prop 302): NO warm/cold runner-lane discriminator field exists,
289
+ * deliberately.
290
+ *
291
+ * `superRefine` enforces the cross-field invariants that a hand-edited or
292
+ * builder-buggy record could otherwise violate silently — mirrored counts are
293
+ * NEVER accepted on trust (codex):
294
+ * - `attemptedLaneCount === lanes.length`; `completedLaneCount === #completed`.
295
+ * - lanes nonempty, laneIds unique (finding 9c).
296
+ * - panel ⟺ diversity AND panel ⟺ ≥2 completed lanes, BOTH directions
297
+ * (finding 9a): a panel is assembled from — and only from — ≥2 usable lanes,
298
+ * and always emits its diversity summary.
299
+ * - round-chain shape (finding 9b): `round.index === 0` ⟺ `priorVerdictHash`
300
+ * absent (round 0 starts a chain; round N>0 links its prior).
301
+ * - stored `settled === deriveSettled(value)` (finding 5): the persisted
302
+ * boundary re-derives the dryness predicate, never trusting a fabricated flag.
303
+ */
304
+ export const VerdictArtifactSchema = z
305
+ .object({
306
+ schemaVersion: verdictSchemaVersionField,
307
+ /** The reviewed diff's scope + masked-payload hash (source-discriminated). */
308
+ diffScope: VerdictDiffScopeSchema,
309
+ /** Every attempted lane's terminal outcome (status-discriminated union). */
310
+ lanes: z.array(VerdictLaneSchema),
311
+ /** MUST equal `lanes.length` (validated below — never trusted). */
312
+ attemptedLaneCount: z.number().int().nonnegative(),
313
+ /** MUST equal the count of `completed` lanes (validated below — never trusted). */
314
+ completedLaneCount: z.number().int().nonnegative(),
315
+ /** Present IFF a #2104 panel was actually assembled (≥2 completed lanes; guarded below). */
316
+ panelArtifactHash: Sha256HexSchema.optional(),
317
+ /** Deterministic #2103 post-checks — the persisted vocabulary VERBATIM (`ruleName`/`tier`/`verdict`/`message`). */
318
+ postChecks: z.array(PersistedPostCheckFindingSchema),
319
+ /** Normalized findings from the shared extractor (exemption-filtered by the CLI before it lands here). */
320
+ findings: z.array(VerdictFindingSchema),
321
+ /** A SINGLE top-level panel-diversity summary (classifyDiversity output) — present only with a panel; NEVER mirrored per finding. */
322
+ diversity: PanelDiversitySchema.optional(),
323
+ round: VerdictRoundSchema,
324
+ /**
325
+ * Post-fan tree compare against the PRE-fan content hash (codex rev-2 fold 1):
326
+ * `'matched'` when the tracked-source tree is byte-identical before and after
327
+ * the fan, `'drifted'` when it changed mid-fan. A DERIVED, non-sentinel field
328
+ * (the two hash domains stay separate — this records the OUTCOME of the compare,
329
+ * not the content hash itself). Drift forces `settled=false` and blocks the
330
+ * cache stamp: the verdict is bound to the pre-fan diff, so a dry fan over a
331
+ * mutated tree does NOT cover the current tree and must not settle the loop.
332
+ */
333
+ reviewedState: z.enum(['matched', 'drifted']),
334
+ /** Current-round dryness predicate (see the CLI lifecycle) — pure over artifact content. */
335
+ settled: z.boolean(),
336
+ /**
337
+ * ISO-8601 emission time — a VALIDATED datetime (rev-5 item 8): a malformed
338
+ * `createdAt` is rejected at the persisted boundary rather than trusted.
339
+ * EXCLUDED from the content address (identical rounds dedup to one artifact
340
+ * regardless of when they ran) — observability only, and it is never a
341
+ * lineage tie-breaker (see {@link findLatestVerdictForLineage}). See
342
+ * {@link computeVerdictArtifactContentHash}.
343
+ */
344
+ createdAt: z.string().datetime(),
345
+ })
346
+ .superRefine((a, ctx) => {
347
+ if (a.attemptedLaneCount !== a.lanes.length) {
348
+ ctx.addIssue({
349
+ code: z.ZodIssueCode.custom,
350
+ path: ['attemptedLaneCount'],
351
+ message: `attemptedLaneCount (${a.attemptedLaneCount}) must equal lanes.length (${a.lanes.length}) — counts are never mirrored on trust`,
352
+ });
353
+ }
354
+ const completed = a.lanes.filter((l) => l.status === 'completed').length;
355
+ if (a.completedLaneCount !== completed) {
356
+ ctx.addIssue({
357
+ code: z.ZodIssueCode.custom,
358
+ path: ['completedLaneCount'],
359
+ message: `completedLaneCount (${a.completedLaneCount}) must equal the number of completed lanes (${completed}) — counts are never mirrored on trust`,
360
+ });
361
+ }
362
+ // ── Finding 9c: lanes nonempty, laneIds unique ──
363
+ if (a.lanes.length === 0) {
364
+ ctx.addIssue({
365
+ code: z.ZodIssueCode.custom,
366
+ path: ['lanes'],
367
+ message: 'lanes must be nonempty — a verdict records at least the lane(s) attempted (even a total-failure fan lists its failed lanes)',
368
+ });
369
+ }
370
+ const laneIds = a.lanes.map((l) => l.laneId);
371
+ const duplicateLaneIds = [...new Set(laneIds.filter((id, i) => laneIds.indexOf(id) !== i))];
372
+ if (duplicateLaneIds.length > 0) {
373
+ ctx.addIssue({
374
+ code: z.ZodIssueCode.custom,
375
+ path: ['lanes'],
376
+ message: `duplicate laneId(s) [${duplicateLaneIds.join(', ')}] — each lane needs a unique id`,
377
+ });
378
+ }
379
+ // ── Finding 9a: panel ⟺ diversity AND panel ⟺ ≥2 completed lanes (both directions) ──
380
+ const hasPanel = a.panelArtifactHash !== undefined;
381
+ const hasDiversity = a.diversity !== undefined;
382
+ if (hasPanel !== hasDiversity) {
383
+ ctx.addIssue({
384
+ code: z.ZodIssueCode.custom,
385
+ path: [hasPanel ? 'diversity' : 'panelArtifactHash'],
386
+ message: 'panelArtifactHash and diversity must be present together — a panel always emits its diversity summary, and a diversity summary is meaningful only alongside a panel',
387
+ });
388
+ }
389
+ if (hasPanel && completed < 2) {
390
+ ctx.addIssue({
391
+ code: z.ZodIssueCode.custom,
392
+ path: ['panelArtifactHash'],
393
+ message: `panelArtifactHash present requires at least 2 completed lanes (found ${completed}) — a panel is assembled only from usable lanes`,
394
+ });
395
+ }
396
+ if (!hasPanel && completed >= 2) {
397
+ ctx.addIssue({
398
+ code: z.ZodIssueCode.custom,
399
+ path: ['panelArtifactHash'],
400
+ message: `${completed} completed lanes require a panel (panelArtifactHash + diversity) — a panel is assembled from ALL usable lanes (≥2)`,
401
+ });
402
+ }
403
+ // ── Finding 9b: round.index === 0 ⟺ priorVerdictHash absent ──
404
+ const isRoundZero = a.round.index === 0;
405
+ const hasPrior = a.round.priorVerdictHash !== undefined;
406
+ if (isRoundZero && hasPrior) {
407
+ ctx.addIssue({
408
+ code: z.ZodIssueCode.custom,
409
+ path: ['round', 'priorVerdictHash'],
410
+ message: 'round 0 must NOT carry priorVerdictHash — round 0 starts a lineage chain (a divergence forks back to round 0)',
411
+ });
412
+ }
413
+ if (!isRoundZero && !hasPrior) {
414
+ ctx.addIssue({
415
+ code: z.ZodIssueCode.custom,
416
+ path: ['round', 'priorVerdictHash'],
417
+ message: `round ${a.round.index} (>0) requires priorVerdictHash linking the prior round in the chain`,
418
+ });
419
+ }
420
+ // ── Finding 5: the persisted boundary re-derives `settled`, never trusts it ──
421
+ const derivedSettled = deriveSettled(a);
422
+ if (a.settled !== derivedSettled) {
423
+ ctx.addIssue({
424
+ code: z.ZodIssueCode.custom,
425
+ path: ['settled'],
426
+ message: `settled (${a.settled}) must equal the re-derived current-round dryness predicate (${derivedSettled}) — settled is derived at the persisted boundary, never trusted (finding 5)`,
427
+ });
428
+ }
429
+ // ── rev-5 item 6 + rev-6 item 3: structural laneId validation (array-index + binding) ──
430
+ // {@link LaneIdSchema} enforces the SHAPE + the runner-vocab blacklist per-value;
431
+ // the CROSS-FIELD invariants need the lane's array position and sibling fields, so
432
+ // they live here. For every lane the id must be exactly `lane-<i>:<suffix>` where
433
+ // `<i>` matches the array index; the suffix binds to the lane's OWN identity field —
434
+ // `resolvedBackend` for a completed/abstained lane (the backend that ran), and
435
+ // `configuredLane` for a failed lane (the configured provider:model, present even
436
+ // pre-resolution — rev-6 item 3, closing the free-suffix tautology). A failed lane's
437
+ // optional `resolvedBackend` is supplementary provenance, NOT the binding (a quota
438
+ // fallback can make it differ from configuredLane). The blacklist stays defense-in-depth.
439
+ a.lanes.forEach((lane, i) => {
440
+ const expectedPrefix = `lane-${i}:`;
441
+ if (!lane.laneId.startsWith(expectedPrefix)) {
442
+ ctx.addIssue({
443
+ code: z.ZodIssueCode.custom,
444
+ path: ['lanes', i, 'laneId'],
445
+ message: `laneId "${lane.laneId}" must begin with "${expectedPrefix}" — the index must match the lane's array position (${i})`,
446
+ });
447
+ return; // suffix checks are meaningless once the prefix is wrong
448
+ }
449
+ const suffix = lane.laneId.slice(expectedPrefix.length);
450
+ if (lane.status === 'completed' || lane.status === 'abstained') {
451
+ if (suffix !== lane.resolvedBackend) {
452
+ ctx.addIssue({
453
+ code: z.ZodIssueCode.custom,
454
+ path: ['lanes', i, 'laneId'],
455
+ message: `laneId suffix "${suffix}" must equal resolvedBackend "${lane.resolvedBackend}" for a ${lane.status} lane`,
456
+ });
457
+ }
458
+ }
459
+ else if (suffix !== lane.configuredLane) {
460
+ // A failed lane's suffix binds to the CONFIGURED lane (rev-6 item 3): the lane
461
+ // may have failed before ANY backend resolved, so the configured provider:model
462
+ // is the only stable identity, and the id must not be a free-floating value.
463
+ ctx.addIssue({
464
+ code: z.ZodIssueCode.custom,
465
+ path: ['lanes', i, 'laneId'],
466
+ message: `failed laneId suffix "${suffix}" must equal configuredLane "${lane.configuredLane}" — a failed lane's id binds to the configured provider:model (rev-6 item 3)`,
467
+ });
468
+ }
469
+ });
470
+ // ── rev-5 item 7 + rev-6 item 2: FULLY re-derive the diversity summary ──
471
+ // A present diversity summary must be RE-DERIVABLE from the completed lanes' resolved
472
+ // backends via the SAME {@link classifyDiversity} logic the panel uses (single source
473
+ // of truth). rev-6 item 2 compares the WHOLE classifyDiversity result — not just the
474
+ // provider SET + class — so a forged `distinctProviders` / `unrecognizedProviders` /
475
+ // `diversityConfidence` over same-vendor lanes can no longer slip through:
476
+ // - `providers` as a sorted MULTISET (order ignored — the verdict's completed lanes
477
+ // are in configured order while the panel's providers[] is laneId-sorted — but
478
+ // DUPLICATES preserved: two same-vendor lanes are two entries, not a collapsed set);
479
+ // - `distinctProviders`, `class`, `unrecognizedProviders`, `diversityConfidence`
480
+ // each re-derived and required to match (all pure functions of the multiset).
481
+ if (a.diversity !== undefined) {
482
+ const completedProviders = a.lanes
483
+ .filter((l) => l.status === 'completed')
484
+ .map((l) => providerFamilyOf(l.resolvedBackend));
485
+ const derived = classifyDiversity(completedProviders);
486
+ const storedProviders = [...a.diversity.providers].sort();
487
+ const derivedProviders = [...derived.providers].sort();
488
+ const providersEqual = storedProviders.length === derivedProviders.length &&
489
+ storedProviders.every((p, i) => p === derivedProviders[i]);
490
+ if (!providersEqual) {
491
+ ctx.addIssue({
492
+ code: z.ZodIssueCode.custom,
493
+ path: ['diversity', 'providers'],
494
+ message: `diversity providers multiset [${storedProviders.join(', ')}] must equal the multiset derived from the completed lanes' backends [${derivedProviders.join(', ')}] — the summary is re-derived at the persisted boundary, never trusted`,
495
+ });
496
+ }
497
+ if (a.diversity.distinctProviders !== derived.distinctProviders) {
498
+ ctx.addIssue({
499
+ code: z.ZodIssueCode.custom,
500
+ path: ['diversity', 'distinctProviders'],
501
+ message: `diversity.distinctProviders (${a.diversity.distinctProviders}) must equal the value derived from the completed lanes' backends (${derived.distinctProviders})`,
502
+ });
503
+ }
504
+ if (a.diversity.class !== derived.class) {
505
+ ctx.addIssue({
506
+ code: z.ZodIssueCode.custom,
507
+ path: ['diversity', 'class'],
508
+ message: `diversity.class "${a.diversity.class}" must equal the class derived from the completed lanes' backends ("${derived.class}")`,
509
+ });
510
+ }
511
+ const storedUnrecognized = a.diversity.unrecognizedProviders;
512
+ const unrecognizedEqual = storedUnrecognized.length === derived.unrecognizedProviders.length &&
513
+ storedUnrecognized.every((p, i) => p === derived.unrecognizedProviders[i]);
514
+ if (!unrecognizedEqual) {
515
+ ctx.addIssue({
516
+ code: z.ZodIssueCode.custom,
517
+ path: ['diversity', 'unrecognizedProviders'],
518
+ message: `diversity.unrecognizedProviders [${storedUnrecognized.join(', ')}] must equal the set derived from the completed lanes' backends [${derived.unrecognizedProviders.join(', ')}]`,
519
+ });
520
+ }
521
+ if (a.diversity.diversityConfidence !== derived.diversityConfidence) {
522
+ ctx.addIssue({
523
+ code: z.ZodIssueCode.custom,
524
+ path: ['diversity', 'diversityConfidence'],
525
+ message: `diversity.diversityConfidence "${a.diversity.diversityConfidence}" must equal the value derived from the completed lanes' backends ("${derived.diversityConfidence}")`,
526
+ });
527
+ }
528
+ }
529
+ });
530
+ /**
531
+ * The provider FAMILY of a resolved backend (`provider:model` → `provider`) — the
532
+ * unit {@link classifyDiversity} clusters on (rev-5 item 7). The panel derives its
533
+ * diversity from each lane's `backend.provider`; a completed verdict lane records
534
+ * `resolvedBackend` as the `qualifiedModel` (`provider:model`), so the family is the
535
+ * segment before the FIRST colon (`provider` never contains a colon). A bare string
536
+ * with no colon is returned whole (defensive — fan lanes are always `provider:model`).
537
+ */
538
+ function providerFamilyOf(resolvedBackend) {
539
+ const idx = resolvedBackend.indexOf(':');
540
+ return idx === -1 ? resolvedBackend : resolvedBackend.slice(0, idx);
541
+ }
542
+ /**
543
+ * The composite round-chain lineage key: a domain-tagged sha256 over the resolved
544
+ * scope selector (agy fold 3; codex rev-2 fold 2). Two branches sharing `base=main`
545
+ * can NEVER cross-link because `branch` participates, and two DIFFERENT explicit
546
+ * ranges on one branch + merge-base cannot cross-link because `base`/`head`
547
+ * participate.
548
+ *
549
+ * The domain tag is `verdict-lineage/3` — bumped from `/2` because the selector
550
+ * shape changed (source-discriminated input + `selectorForm`), so keys under the
551
+ * two tags are deliberately incompatible.
552
+ *
553
+ * Only the fields VALID for the discriminated `source` participate (the switch
554
+ * reads them per-variant), pinning the others to `null`. The selector is hashed as
555
+ * a canonicalized (recursively key-sorted) JSON object with the fixed domain tag,
556
+ * so there is NO delimiter-injection ambiguity — `branch='a', mergeBase='b|c'` and
557
+ * `branch='a|b', mergeBase='c'` serialize to distinct JSON and therefore distinct
558
+ * keys, which a naive `join('|')` would collide. A `null` hole (a source that omits
559
+ * a field) can never collide with an empty string a source supplies for it.
560
+ */
561
+ export function computeLineageKey(input) {
562
+ let base = null;
563
+ let head = null;
564
+ let mergeBase = null;
565
+ switch (input.source) {
566
+ case 'explicit-range':
567
+ base = input.base;
568
+ head = input.head;
569
+ break;
570
+ case 'branch-vs-base':
571
+ base = input.base;
572
+ mergeBase = input.mergeBase;
573
+ break;
574
+ case 'staged':
575
+ case 'uncommitted':
576
+ break;
577
+ }
578
+ return calculateDeterministicHash({
579
+ domain: 'verdict-lineage/3',
580
+ repoIdentity: input.repoIdentity,
581
+ branch: input.branch,
582
+ source: input.source,
583
+ selectorForm: input.selectorForm ?? null,
584
+ base,
585
+ head,
586
+ mergeBase,
587
+ });
588
+ }
589
+ // ─── Content-addressed storage (mirrors storage.ts / panel.ts) ──────────────
590
+ /** Storage layout segments under the totem dir (exact layout = impl call). */
591
+ const VERDICTS_DIR_SEGMENTS = ['artifacts', 'verdicts'];
592
+ /** Matches a stored verdict file name and captures its content-address stem. */
593
+ const VERDICT_FILE_RE = /^([0-9a-f]{64})\.json$/;
594
+ /**
595
+ * Migration-on-read registry (F1). Keyed by MAJOR; each entry lifts a parsed
596
+ * raw object of that major to the current shape. EMPTY at 1.0.0 by design — the
597
+ * policy requires a major bump to land its migration entry here BEFORE the
598
+ * writer ships, so empty is the honest statement that no other major exists.
599
+ * Each entry MUST return current-schema output; the loader re-validates via
600
+ * parse() before returning.
601
+ */
602
+ const MIGRATIONS = new Map();
603
+ /** Absolute verdicts directory for a given absolute totem dir. */
604
+ export function verdictsDir(totemDirAbs) {
605
+ return path.join(totemDirAbs, ...VERDICTS_DIR_SEGMENTS);
606
+ }
607
+ /**
608
+ * Content address of a verdict: deterministic hash over everything EXCEPT
609
+ * `createdAt` (observability, not identity). Identical rounds dedup to one
610
+ * artifact regardless of when they ran.
611
+ */
612
+ export function computeVerdictArtifactContentHash(artifact) {
613
+ const { createdAt: _excluded, ...identity } = artifact;
614
+ return calculateDeterministicHash(identity);
615
+ }
616
+ /**
617
+ * Content address over the RAW parsed JSON payload with ONLY `createdAt` excluded
618
+ * (rev-5 item 5) — the canonical identity used for load verification. Unlike
619
+ * {@link computeVerdictArtifactContentHash} (which hashes the Zod-normalized shape),
620
+ * this hashes exactly the bytes on disk minus `createdAt`, so an unknown-key tamper
621
+ * is caught and a forward-minor additive field verifies. `calculateDeterministicHash`
622
+ * canonicalizes (recursive key sort), so for a same-version artifact with no unknown
623
+ * keys the two functions agree.
624
+ */
625
+ function computeRawVerdictContentHash(raw) {
626
+ if (typeof raw !== 'object' || raw === null) {
627
+ // Unreachable after a successful parse (the schema requires an object), but stay
628
+ // defensive rather than destructure a non-object.
629
+ return calculateDeterministicHash(raw);
630
+ }
631
+ const { createdAt: _excluded, ...identity } = raw;
632
+ return calculateDeterministicHash(identity);
633
+ }
634
+ /**
635
+ * Render the machine-readable covariate line — the CORE-OWNED signal every caller
636
+ * (CLI print, headless, `/review-reply`) emits identically so the skill stays pure
637
+ * transport (strategy-codex G4; resolves finding 14). Format, EXACTLY:
638
+ *
639
+ * `local-lane: <hash8> round=<n> settled=<true|false> lanes=<completed>/<attempted>`
640
+ *
641
+ * where `<hash8>` is the first 8 hex of the artifact's STORED content address. The
642
+ * signature takes a {@link VerdictWithAddress} (rev-6 item 1) so the rendered `<hash8>`
643
+ * is the VERIFIED on-disk address that survived the tolerant parse — NOT a recompute
644
+ * over the Zod-stripped shape, which would diverge for a forward-minor artifact and
645
+ * advertise a hash with no backing file. A caller with a freshly-assembled verdict
646
+ * pairs it with the address `saveVerdictArtifact` returned.
647
+ *
648
+ * @remarks Covariate line format v1 — do NOT alter without a spec amendment (the
649
+ * pilot ledger joins on this grep-able line; the format is contract, versioned with
650
+ * the `review-loop` skill).
651
+ */
652
+ export function renderCovariateLine(verdict) {
653
+ const hash8 = verdict.contentHash.slice(0, 8);
654
+ const a = verdict.artifact;
655
+ return `local-lane: ${hash8} round=${a.round.index} settled=${a.settled} lanes=${a.completedLaneCount}/${a.attemptedLaneCount}`;
656
+ }
657
+ /**
658
+ * Persist a verdict at its content address, write-if-absent (`wx` create-
659
+ * exclusive). Validates on the way OUT so a writer bug never poisons the ledger
660
+ * with a record the reader would reject.
661
+ *
662
+ * EEXIST is LOGICAL-IDENTITY DEDUP (`createdAt` excluded from the address; codex
663
+ * fold 8 / agy fold 4): the existing record is loaded and its content hash
664
+ * recomputed. If it matches this address (equal MODULO `createdAt`), the stored
665
+ * record IS this save's outcome — first-write-wins, dedup return. If the record
666
+ * at this address recomputes to a DIFFERENT hash, its bytes disagree with the
667
+ * content address — a hard identity violation (a corrupted/tampered record or a
668
+ * sha256 collision), never silently accepted.
669
+ */
670
+ export function saveVerdictArtifact(totemDirAbs, artifact) {
671
+ const validated = VerdictArtifactSchema.parse(artifact);
672
+ // Save/load address SYMMETRY (rev-6 item 1): the hash is computed over the SAME
673
+ // object that gets serialized (`validated`, no unknown keys), and load re-hashes the
674
+ // raw on-disk bytes minus `createdAt` — so a record written here always verifies back
675
+ // to THIS `hash`. The returned `hash` IS the raw address on disk; callers pairing a
676
+ // freshly-saved verdict with its address use it directly (never a re-derivation).
677
+ const hash = computeVerdictArtifactContentHash(validated);
678
+ const dir = verdictsDir(totemDirAbs);
679
+ const filePath = path.join(dir, `${hash}.json`);
680
+ fs.mkdirSync(dir, { recursive: true });
681
+ try {
682
+ // `wx` = atomic create-exclusive: the write fails EEXIST if a record already
683
+ // occupies this address, so the identity-verification path below always sees
684
+ // the durable record (no TOCTOU between a check and the write).
685
+ fs.writeFileSync(filePath, JSON.stringify(validated, null, 2), {
686
+ encoding: 'utf-8',
687
+ mode: 0o600, // matches run/panel storage — verdicts reach masked prompt content one hop away
688
+ flag: 'wx',
689
+ });
690
+ }
691
+ catch (err) {
692
+ if (err !== null && typeof err === 'object' && 'code' in err && err.code === 'EEXIST') {
693
+ // Logical-identity dedup (codex fold 8 / agy fold 4). loadVerdictArtifact now
694
+ // VERIFIES the incumbent's content address (finding 4): a successful load
695
+ // proves the stored record hashes back to THIS address — which equals our
696
+ // artifact's content address — i.e. the SAME logical verdict modulo createdAt
697
+ // (first-write-wins). A DIFFERING or corrupt record cannot occupy this address
698
+ // without failing that verification, so the verified load itself surfaces the
699
+ // identity violation loud (its own hard error) — nothing is swallowed here.
700
+ loadVerdictArtifact(totemDirAbs, hash);
701
+ return { hash, path: filePath, existed: true };
702
+ }
703
+ throw err;
704
+ }
705
+ return { hash, path: filePath, existed: false };
706
+ }
707
+ /**
708
+ * Load + validate a verdict by content address, returning the artifact WITH its
709
+ * verified content address (rev-6 item 1 — {@link VerdictWithAddress}). Throws
710
+ * {@link TotemParseError} on a missing file, corrupt JSON, schema violation, or an
711
+ * unknown major with no migration entry, and {@link TotemError} (`DATABASE_MISMATCH`)
712
+ * when the stored bytes do not hash back to their filename address (finding 4) — loud,
713
+ * never a silent partial (Tenet 4).
714
+ *
715
+ * Order (rev-6 item 5): the RAW stored address is verified FIRST — the content-address
716
+ * guarantee is over the on-disk bytes (minus `createdAt`), MAJOR-agnostic and
717
+ * migration-independent, so a mis-addressed / tampered file fails before it is
718
+ * transformed. Only THEN is any migration applied and its output validated against the
719
+ * current schema (a separate concern — migration correctness, not address integrity).
720
+ * The returned `contentHash` is always this verified filename address.
721
+ */
722
+ export function loadVerdictArtifact(totemDirAbs, hash) {
723
+ if (!Sha256HexSchema.safeParse(hash).success) {
724
+ throw new TotemParseError(`Invalid verdict-artifact id "${hash}" — expected a 64-char sha256 hex content address.`, 'Pass the hash exactly as reported at emission (or from the artifacts/verdicts/ filename).');
725
+ }
726
+ const filePath = path.join(verdictsDir(totemDirAbs), `${hash}.json`);
727
+ const raw = readJsonSafe(filePath);
728
+ // ── rev-6 item 5 + rev-5 item 5: verify the RAW stored address BEFORE any migration ──
729
+ // Content-address verification hashes the RAW logical payload (`createdAt` excluded),
730
+ // NOT a Zod-normalized shape. Hashing the normalized output would be unsound TWO ways:
731
+ // (a) an unknown-key TAMPER survives — Zod strips the injected key before the recompute,
732
+ // so a normalized hash still matches the address; (b) a forward-minor artifact is WRONGLY
733
+ // rejected — its writer addressed a raw payload including an additive field this reader
734
+ // strips, so a normalized recompute diverges. The raw payload IS the canonical identity,
735
+ // it is major-AGNOSTIC (whatever major wrote the file addressed its own raw bytes), so
736
+ // this check precedes migration: a mis-addressed / hand-edited / collided record is
737
+ // rejected LOUD before we transform it (finding 4).
738
+ const verificationHash = computeRawVerdictContentHash(raw);
739
+ if (verificationHash !== hash) {
740
+ throw new TotemError('DATABASE_MISMATCH', `Verdict artifact at ${filePath} fails content-address verification: its recomputed content hash ${verificationHash} does not match the filename address ${hash} (modulo createdAt).`, 'This should be unreachable in a content-addressed store. Investigate a mis-addressed copy, a hand-edited/corrupted verdict file, or a hash collision, then re-emit the round.');
741
+ }
742
+ // ── THEN migrate (if a known older major) and validate the migrated/parsed output
743
+ // SEPARATELY against the current schema (migration correctness ≠ address integrity) ──
744
+ const major = readMajor(raw);
745
+ const migrate = major !== undefined ? MIGRATIONS.get(major) : undefined;
746
+ if (migrate !== undefined) {
747
+ // Re-validate migrated output against the CURRENT schema before returning: a
748
+ // migration's contract is to PRODUCE the current shape, so a migration bug must
749
+ // fail loud here — never return it unvalidated. The stored file remains addressed
750
+ // over its (verified) raw bytes, so `contentHash` stays the filename address.
751
+ return { artifact: VerdictArtifactSchema.parse(migrate(raw)), contentHash: hash };
752
+ }
753
+ // `.safeParse` (not try/catch) so the fail-loud rethrow is an explicit statement,
754
+ // never swallowed control flow: rethrowAsParseError returns `never` (always throws),
755
+ // normalizing ZodError to the module's TotemParseError load contract and preserving
756
+ // cause. No catch clause ⇒ no bare-swallow surface. The tolerant Zod parse governs
757
+ // SHAPE only (additive fields stripped); the RAW address verified above is identity.
758
+ const result = VerdictArtifactSchema.safeParse(raw);
759
+ if (!result.success) {
760
+ rethrowAsParseError(`Verdict artifact ${hash} failed schema validation`, result.error, 'The artifact may be corrupted or written by an incompatible totem version; re-emit it (or add the migration entry for its major).');
761
+ }
762
+ return { artifact: result.data, contentHash: hash };
763
+ }
764
+ /**
765
+ * Verified per-entry load for a SCAN (list / lineage). A KNOWN corruption class —
766
+ * bad JSON, schema violation, or a content-address mismatch (finding 4), all
767
+ * surfaced by {@link loadVerdictArtifact} as a {@link TotemError} — is routed to
768
+ * `onWarn` and the entry is SKIPPED (returns `undefined`). An UNEXPECTED failure
769
+ * (e.g. a filesystem permission error) is rethrown, never swallowed.
770
+ *
771
+ * This is the honest degradation for a scan (vs the hard error of a direct
772
+ * load-by-hash): one corrupt / mis-addressed artifact must neither crash an
773
+ * UNRELATED lineage query nor silently WIN or LOSE a lineage scan by being counted
774
+ * as valid — it is announced LOUDLY per entry and dropped, so a broken prior round
775
+ * makes the chain honestly restart at round 0 (the failure-table "prior verdict
776
+ * missing/corrupt" row) rather than aborting the whole command.
777
+ */
778
+ function loadVerifiedVerdictForScan(totemDirAbs, hash, onWarn) {
779
+ try {
780
+ return loadVerdictArtifact(totemDirAbs, hash);
781
+ }
782
+ catch (err) {
783
+ if (err instanceof TotemError) {
784
+ onWarn(`Skipping corrupt or mis-addressed verdict artifact ${hash} during scan: ${err.message}`);
785
+ return undefined;
786
+ }
787
+ throw err;
788
+ }
789
+ }
790
+ /**
791
+ * Load every stored verdict under `artifacts/verdicts/`, verifying each through
792
+ * the SAME content-address check as {@link loadVerdictArtifact}. A missing
793
+ * directory yields `[]` (nothing written yet). Non-verdict file names are skipped
794
+ * silently; a corrupt / mis-addressed verdict is skipped LOUDLY via `onWarn`.
795
+ * `onWarn` is REQUIRED — core stays console-free (no presentation-layer default),
796
+ * and the caller must decide where scan warnings land rather than inheriting a
797
+ * silent noop (Tenet 4); see {@link loadVerifiedVerdictForScan}. (PR #2337 CR.)
798
+ */
799
+ export function listVerdictArtifacts(totemDirAbs, onWarn) {
800
+ const dir = verdictsDir(totemDirAbs);
801
+ let names;
802
+ try {
803
+ names = fs.readdirSync(dir);
804
+ }
805
+ catch (err) {
806
+ if (err.code === 'ENOENT')
807
+ return [];
808
+ throw err;
809
+ }
810
+ const out = [];
811
+ for (const name of names) {
812
+ const match = VERDICT_FILE_RE.exec(name);
813
+ if (match === null)
814
+ continue;
815
+ const loaded = loadVerifiedVerdictForScan(totemDirAbs, match[1], onWarn);
816
+ if (loaded !== undefined)
817
+ out.push(loaded);
818
+ }
819
+ return out;
820
+ }
821
+ /**
822
+ * The latest verdict sharing `lineageKey` — highest `round.index`, ties broken by
823
+ * the lexical STORED content address (rev-5 item 8 / rev-6 item 1), NOT `createdAt`.
824
+ * Returns the winning {@link VerdictWithAddress} (artifact + verified address) or
825
+ * `undefined` when no verdict carries the key. Used for implicit round linkage (the
826
+ * next round's `priorVerdictHash` = the returned `contentHash`, so the link always
827
+ * points at the on-disk file even for a forward-minor artifact). Goes through the same
828
+ * verified scan load as {@link listVerdictArtifacts}: a corrupt / mis-addressed
829
+ * artifact is warned + skipped (never silently winning or losing the lineage). `onWarn`
830
+ * is REQUIRED — core is console-free and the caller owns where warnings land (Tenet 4).
831
+ *
832
+ * The tie-break is IDENTITY-BOUND and deterministic: two same-round verdicts break on
833
+ * their STORED content address (the on-disk identity, `createdAt` excluded), so
834
+ * selection never depends on wall-clock emission time (observability-only) — the same
835
+ * corpus always resolves the same latest verdict regardless of when each round ran.
836
+ */
837
+ export function findLatestVerdictForLineage(totemDirAbs, lineageKey, onWarn) {
838
+ const matching = listVerdictArtifacts(totemDirAbs, onWarn).filter((v) => v.artifact.round.lineageKey === lineageKey);
839
+ if (matching.length === 0)
840
+ return undefined;
841
+ // Tie-break on the STORED, verified content address (rev-6 item 1) — NOT a recompute
842
+ // over the Zod-normalized shape, which would diverge for a forward-minor artifact and
843
+ // could reorder same-round ties. The stored address is the on-disk identity, so the
844
+ // same corpus always resolves the same latest verdict, timestamp-independently.
845
+ const ordered = [...matching].sort((a, b) => {
846
+ if (b.artifact.round.index !== a.artifact.round.index) {
847
+ return b.artifact.round.index - a.artifact.round.index;
848
+ }
849
+ return b.contentHash.localeCompare(a.contentHash);
850
+ });
851
+ return ordered[0];
852
+ }
853
+ /** Best-effort major extraction from a raw parsed payload; undefined when absent/garbled. */
854
+ function readMajor(raw) {
855
+ if (typeof raw !== 'object' || raw === null || !('schemaVersion' in raw))
856
+ return undefined;
857
+ const version = raw.schemaVersion;
858
+ if (typeof version !== 'string')
859
+ return undefined;
860
+ const major = Number.parseInt(version.split('.')[0] ?? '', 10);
861
+ return Number.isNaN(major) ? undefined : major;
862
+ }
863
+ //# sourceMappingURL=verdict.js.map