// The VERDICT LEDGER — how a judgment criterion stays decided without paying a model again. // // The engine can decide only a handful of criteria outright; the rest are judgment calls that // an agent adjudicates from harvested evidence (src/adjudicate.ts). That worked in a session // and nowhere else: every other run — a CI job, a nightly report, a colleague's checkout — // started from zero and published « to assess », because a verdict lived only in the // `audit-latest.json` that one run produced. // // The ledger is that verdict, written down where it can be reviewed: a small file committed to // the audited repository, holding one entry per adjudicated criterion with its justification, // its citations and — the part that makes it trustworthy — a FINGERPRINT of the evidence it was // ruled against. Replaying it is not a cache lookup: each entry is rebuilt into an ordinary // adjudication and folded through `applyAdjudication`, so the same coverage checks, the same // citation matching and the same content-level re-grounding decide whether it still stands. // A verdict nobody can prove is refused in CI exactly as it would be in a session. // // Staleness is the reason the fingerprint exists. When the code under a criterion changes, the // evidence the agent read changes with it, the fingerprint stops matching, and the verdict is // dropped as STALE — the criterion returns to « to assess » saying so. The alternative, a // verdict that silently outlives the code it described, is the one failure mode a conformance // deliverable cannot afford. // // The fingerprint deliberately ignores LINE NUMBERS. Grounding already tolerates ±10 lines of // drift (a citation that moved is still the same citation), so hashing line numbers would // invalidate every verdict in a file the moment someone added a comment at the top — punishing // a formatting change like a semantic one. It hashes what the agent actually read: the file, the // selector and the normalised snippet. import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { type AdjudicationFile, type AdjudicationItem, type AgentFinding, buildAdjudicationWorklist, type Evidence, readCitation } from "./adjudicate.js"; import { PAGES_DIR } from "./snapshot.js"; import { CORE, type StandardId } from "./standards/index.js"; import type { AuditResult } from "./types.js"; import { SCHEMA_VERSION } from "./types.js"; import type { VerifyItem } from "./verify.js"; /** Where a ledger lives by default, relative to the audited repository root. Committed on * purpose: a verdict is a claim about this codebase, so it belongs in review alongside it. */ export const LEDGER_DIR = ".ultra11y/verdicts"; /** The default path for a standard's ledger. */ export function ledgerPath(standard: StandardId, root = "."): string { return join(root, LEDGER_DIR, `${standard}.json`); } export interface LedgerEntry { criteriaId: string; /** The verdict as folded — same vocabulary as an adjudication item. */ verdict: "C" | "NC" | "NA" | "manual"; justification?: string; reason?: string | null; citations?: Evidence[]; findings?: AgentFinding[]; recommendations?: AgentFinding[]; /** Fingerprint of the evidence this verdict was ruled against (see module header). */ evidenceFingerprint: string; /** THE ANCHOR SET behind that fingerprint — one short hash per piece of evidence, sorted, * comma-joined. * * A fingerprint answers one question — « is this the same evidence? » — and on a living * application the answer is always no. It cannot answer the question that decides whether a * verdict still covers today's code: « is anything here NEW? ». That needs the set, not its * digest. * * HASHES, not the anchors themselves, because this file is committed and reviewed: one * criterion in egapro's grid carries 634 anchors, and spelling them out would bury its * justification under half a megabyte of snippets. Sixteen hex characters of SHA-256 each — * a collision here keeps a verdict that should have expired, which is why they are not * shortened further. * * ONE STRING, not an array, for the same reason the entries are id-sorted: `JSON.stringify` * puts each array item on its own line, so the real ledger would gain some nine thousand * lines and every re-adjudication would produce a diff nobody reads. A ledger nobody reads * is a ledger that stops being reviewed, which is the whole reason it is committed. * * Absent on an entry recorded before this field existed. Absent means « no set to compare », * which keeps the old strict rule — never « nothing was there ». */ evidenceAnchors?: string; /** How many evidence items were harvested — carried for the reader, never trusted. */ evidenceCount: number; /** THE FILES THAT EVIDENCE CAME FROM, canonical and sorted. * * The anchor set answers « is anything NEW? ». It cannot answer the other half — « is * anything MISSING because nobody read it? » — and those two failures point opposite ways. * A file deleted from the repository legitimately stops contributing; a file still sitting * on disk that this run did not read is a coverage hole, and reading its silence as a * shrunken codebase replays a verdict over code nobody looked at. * * Absent on an entry recorded before this field existed, which keeps the strict rule. */ evidenceFiles?: string[]; /** ISO date the verdict was recorded. */ date: string; /** Who ruled. Only ever "agent": the engine's own verdicts are recomputed every run and * have no business in a ledger. */ decidedBy: "agent"; } export interface VerdictLedger { tool: "ultra11y"; kind: "verdict-ledger"; schemaVersion: number; standard: StandardId; entries: LedgerEntry[]; } const norm = (s: string) => s.replace(/\s+/g, " ").trim(); /** A snapshot is a committed, repo-relative artefact even when the browser happened to write * it through an absolute `--cwd`. Hashing the runner's checkout prefix made the same page * evidence stale on another machine. Keep ordinary source paths strict, but key snapshots on * their published `.ultra11y/pages//dom.html` identity. */ const canonicalFile = (file: string): string => { const posix = file.replace(/\\/g, "/"); const marker = `${PAGES_DIR}/`; const at = posix.lastIndexOf(marker); return at >= 0 ? posix.slice(at) : posix; }; /** The line-independent identity of one piece of evidence: what the agent actually read. */ const anchorKey = (e: { file: string; selector?: string; snippet?: string }) => { const file = canonicalFile(e.file); let snippet = norm(e.snippet ?? ""); // The capture header records transport provenance, not page evidence. A local port or CI // hostname changing must not stale a verdict about the same DOM/doctype. if (file.startsWith(`${PAGES_DIR}/`) && snippet.startsWith("