diff --git a/migrations/0179_decision_records.sql b/migrations/0179_decision_records.sql new file mode 100644 index 0000000000..6f2a99eaa3 --- /dev/null +++ b/migrations/0179_decision_records.sql @@ -0,0 +1,21 @@ +-- #8836 (epic #8828, Phase 4 — trust surface): content-addressed decision records. +-- +-- One row per (PR, head sha) holding the full canonical-JSON decision record and its SHA-256 content digest: +-- the resolved-config digest, the clause that fired, and (when an AI review contributed) the model/prompt +-- commitments. This is what lets a contributor argue "clause X of config abc123 closed me" — and what the +-- golden-corpus replay (#8832), the hash-chained ledger (#8837), and the replay harness (#8838) all consume. +-- Latest-finalize-wins per id, mirroring the gate_decision row the record explains. +-- +-- NOTE: numbered 0179 — 0178 (decision_audit_labels) ships in the sibling PR for #8830 and must merge first. +CREATE TABLE IF NOT EXISTS decision_records ( + id TEXT PRIMARY KEY, -- record:#@ + repo_full_name TEXT NOT NULL, + pull_number INTEGER NOT NULL, + head_sha TEXT NOT NULL, + action TEXT NOT NULL, -- merge | close | hold (the acted disposition, never the raw conclusion) + reason_code TEXT NOT NULL, -- blocker class | policy_close: | gate conclusion + record_digest TEXT NOT NULL, -- SHA-256 over the canonical record_json + record_json TEXT NOT NULL, -- the full canonical-JSON DecisionRecord + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS decision_records_target ON decision_records (repo_full_name, pull_number, created_at); diff --git a/scripts/check-schema-drift.ts b/scripts/check-schema-drift.ts index feec1f92bd..b4f08c5b8e 100644 --- a/scripts/check-schema-drift.ts +++ b/scripts/check-schema-drift.ts @@ -41,6 +41,7 @@ export const RAW_SQL_ONLY_TABLES: Set = new Set([ "ams_signals", "contributor_gate_history", "decision_audit_labels", + "decision_records", "global_agent_controls", "global_contributor_blacklist", "global_moderation_config", diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 27541ff22f..1a3ca2c191 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -645,6 +645,7 @@ import { recordReversalSignals, } from "../review/outcomes-wire"; import { maybeApplyCloseAuditHoldout } from "../review/close-audit-holdout"; +import { buildDecisionRecord, contentDigest, loadDecisionRecordCollapsible, persistDecisionRecord } from "../review/decision-record"; import { neutralHoldReasonCode, nativeGateActionFromConclusion, recordNativeGateDecision } from "../review/parity-wire"; import { recordContributorGateDecision } from "../review/contributor-calibration"; import { recordGateBlockersAndCheckRepeatAlarm } from "../review/rule-repeat-alarm-wire"; @@ -3321,6 +3322,32 @@ async function runAgentMaintenancePlanAndExecute( // erase a prior miner-authored prediction for the same head. minerAuthored: breakerMinerAuthored, }); + // #8836: the content-addressed decision record — the public commitment that lets a contributor argue + // "clause X of config abc123 decided this". Persist-only here (best-effort inside); the same reasonCode + // expression as recordNativeGateDecision above so the record and the calibration row can never disagree + // about WHY. Model/prompt commitments are wired when the AI-review contribution lands (tracked on #8836's + // follow-up note); rule-only decisions carry null there by design. + { + const { record, recordDigest } = await buildDecisionRecord({ + repoFullName, + pullNumber: pr.number, + headSha: pr.headSha ?? "unknown", + baseSha: null, + action: disposition.actionClass, + reasonCode: + disposition.blockerClass !== "none" + ? disposition.blockerClass + : policyCloseKind !== undefined + ? `policy_close:${policyCloseKind}` + : gate.conclusion, + configDigest: await contentDigest(settings), + gatePack: settings.gatePack, + ciState: null, + modelId: null, + promptDigest: null, + }); + await persistDecisionRecord(env, record, recordDigest); + } // #2349 (PR 1): additive per-contributor calibration data, gated identically to recordNativeGateDecision // above -- see src/review/contributor-calibration.ts's doc comment. Currently write-only; nothing reads // contributor_gate_history yet. @@ -11462,6 +11489,10 @@ async function maybePublishPrPublicSurface( // a few hundred lines up for the identical reason. const missingTestsFinding = advisory.findings.find((finding) => finding.code === "manifest_missing_tests"); const e2eTestGenAvailable = missingTestsFinding ? await convergedFeatureActive(env, repoFullName, "e2eTests") : false; + // #8836: append the persisted decision record (when one exists) so the public surface carries the + // content-addressed commitment — clause + config/model digests. Null on first publish (finalize runs + // after) and on any read hiccup; the section rides the NEXT republish, never blocks this one. + const decisionRecordCollapsible = await loadDecisionRecordCollapsible(env, repoFullName, pr.number); deterministicBody = buildUnifiedCommentBody({ gate: renderedGate, ...(aiReview !== undefined ? { aiReview } : {}), @@ -11498,7 +11529,7 @@ async function maybePublishPrPublicSurface( // A preflight HOLD (e.g. the review lane is unavailable → the review is incomplete) must never render as // "safe to merge"; the renderer downgrades an otherwise-ready status to a manual-review hold. (#2002) preflightHeld: preflight.status === "hold", - extraCollapsibles: buildPublicSafeCollapsibles({ + extraCollapsibles: [...buildPublicSafeCollapsibles({ repo, pr, profile, @@ -11514,7 +11545,7 @@ async function maybePublishPrPublicSurface( ...(missingTestsFinding !== undefined ? { missingTestsFinding } : {}), e2eTestGenAvailable, env, - }), + }), ...(decisionRecordCollapsible !== null ? [decisionRecordCollapsible] : [])], footerMarkdown: loopoverFooter(env, { earnUrl: repo?.isRegistered ? gittensorRepoEarnUrl(repoFullName) diff --git a/src/review/decision-record.ts b/src/review/decision-record.ts new file mode 100644 index 0000000000..84d8725b9d --- /dev/null +++ b/src/review/decision-record.ts @@ -0,0 +1,164 @@ +// Content-addressed decision records (#8836, epic #8828 Phase 4) — the legibility layer. +// +// WHY: a contributor closed by ORB today cannot see WHICH ruleset version or clause closed them — and the +// authoritative config being private (LOOPOVER_REPO_CONFIG_DIR on the operator's host) makes that worse, not +// better. The remedy is a per-decision record whose inputs are pinned by content address: the digest is +// published even where the contents stay private, so "the bot closed me" becomes "clause X of ruleset +// abc123 closed me" — inspectable, arguable, and stable under challenge. The shape follows SLSA's +// Verification Summary Attestation (verifier + policy digest + result), which exists for exactly this +// delegate-a-decision pattern. +// +// This record is also the input schema the golden-corpus replay (#8832) and the deterministic replay harness +// (#8838) consume — one schema, three consumers, so drift between "what we published" and "what we can +// replay" is structurally impossible. +import { errorMessage, nowIso } from "../utils/json"; + +/** Bump when the record's FIELD SET changes meaning — consumers compare records only within a version. */ +export const DECISION_RECORD_SCHEMA_VERSION = "1"; + +/** + * Canonical JSON: recursively key-sorted, no insignificant whitespace — the ONE serialization every digest + * in this system is computed over. Identical logical inputs must always hash identically, so object key + * order (an artifact of construction, not meaning) can never influence a digest. Arrays keep their order + * (order IS meaning there). undefined object members are dropped (JSON has no undefined); undefined inside + * arrays follows JSON.stringify's own null coercion. PURE. + */ +export function canonicalJson(value: unknown): string { + if (value === null || typeof value === "number" || typeof value === "boolean") return JSON.stringify(value); + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "undefined") return "null"; + if (Array.isArray(value)) return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`; + if (typeof value === "object") { + const record = value as Record; + const keys = Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort(); + return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; + } + // Functions/symbols/bigints have no JSON meaning; refusing loudly beats a silent wrong digest. + throw new Error(`canonicalJson: unsupported value type "${typeof value}"`); +} + +/** SHA-256 hex over UTF-8 text via Web Crypto (available in the Workers runtime AND Node ≥20). */ +export async function sha256Hex(text: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** Digest of any JSON-shaped value via the canonical serialization above. */ +export async function contentDigest(value: unknown): Promise { + return sha256Hex(canonicalJson(value)); +} + +/** The published, public-safe decision record. Counts/digests/enums only — no diffs, no private config + * contents (their DIGEST is the commitment), no author identity beyond what the PR page already shows. */ +export type DecisionRecord = { + schemaVersion: string; + repoFullName: string; + pullNumber: number; + headSha: string; + baseSha: string | null; + /** The disposition the bot actually acted on (merge/close/hold) — never the raw check conclusion (#8825). */ + action: string; + /** The clause that decided it: a blocker class, `policy_close:`, or the gate conclusion. */ + reasonCode: string; + /** Digest of the RESOLVED effective settings (canonical JSON) — commits the operator to the exact config + * that judged this PR, including private overlays, without publishing their contents. */ + configDigest: string; + /** The gate policy pack in force (public enum, safe to publish alongside the digest). */ + gatePack: string | null; + /** CI aggregate consumed by the decision, when one was read. */ + ciState: string | null; + /** Model + prompt commitments when an AI review contributed; null for rule-only decisions. */ + modelId: string | null; + promptDigest: string | null; + decidedAt: string; +}; + +/** Assemble the record and its own content digest. PURE given pre-computed digests. Normalizes the + * optional-shaped caller fields (undefined -> null) HERE so call sites carry no fallback arms of their own. */ +export async function buildDecisionRecord( + input: Omit & { + decidedAt?: string; + gatePack?: string | null | undefined; + ciState?: string | null | undefined; + baseSha?: string | null | undefined; + }, +): Promise<{ record: DecisionRecord; recordDigest: string }> { + const record: DecisionRecord = { + schemaVersion: DECISION_RECORD_SCHEMA_VERSION, + decidedAt: input.decidedAt ?? nowIso(), + ...input, + gatePack: input.gatePack ?? null, + ciState: input.ciState ?? null, + baseSha: input.baseSha ?? null, + }; + return { record, recordDigest: await contentDigest(record) }; +} + +/** + * Persist the record (decision_records, migration 0179), keyed per (target, head sha) with + * latest-finalize-wins — the same replacement contract as the gate_decision row it explains. Best-effort: + * recording legibility must never break finalization (mirrors recordNativeGateDecision's posture). + */ +export async function persistDecisionRecord(env: Env, record: DecisionRecord, recordDigest: string): Promise { + try { + await env.DB.prepare( + `INSERT INTO decision_records (id, repo_full_name, pull_number, head_sha, action, reason_code, record_digest, record_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET action = excluded.action, reason_code = excluded.reason_code, + record_digest = excluded.record_digest, record_json = excluded.record_json, created_at = excluded.created_at`, + ) + .bind( + `record:${record.repoFullName}#${record.pullNumber}@${record.headSha}`.slice(0, 250), + record.repoFullName.slice(0, 200), + record.pullNumber, + record.headSha, + record.action, + record.reasonCode.slice(0, 200), + recordDigest, + canonicalJson(record), + record.decidedAt, + ) + .run(); + } catch (error) { + console.warn(JSON.stringify({ event: "decision_record_persist_error", target: `${record.repoFullName}#${record.pullNumber}`, message: errorMessage(error).slice(0, 160) })); + } +} + +/** Bounded, human-readable markdown body for the public review surface: the claim ("clause X of config + * abc123…") plus the digests a challenger needs. Digests are truncated for display; the full values live in + * the persisted record. Returned WITHOUT a details wrapper — the unified-comment bridge renders the + * collapsible chrome itself (UnifiedCollapsible). */ +export function renderDecisionRecordSection(record: DecisionRecord, recordDigest: string): string { + const short = (digest: string): string => digest.slice(0, 12); + const lines = [ + `- **action**: ${record.action} · **clause**: \`${record.reasonCode}\``, + `- **config**: \`${short(record.configDigest)}\`${record.gatePack ? ` · **pack**: ${record.gatePack}` : ""}${record.ciState ? ` · **ci**: ${record.ciState}` : ""}`, + ...(record.modelId !== null || record.promptDigest !== null + ? [`- **model**: ${record.modelId ?? "n/a"}${record.promptDigest !== null ? ` · **prompt**: \`${short(record.promptDigest)}\`` : ""}`] + : []), + `- **record**: \`${short(recordDigest)}\` (schema v${record.schemaVersion}, head \`${record.headSha.slice(0, 7)}\`)`, + ]; + return lines.join("\n"); +} + +/** Load the latest persisted record for a PR as a ready-to-append UnifiedCollapsible body; null when none + * exists yet (first publish precedes the first finalize) or the stored JSON is unreadable (fail-safe: the + * comment simply omits the section rather than failing the publish). */ +export async function loadDecisionRecordCollapsible(env: Env, repoFullName: string, pullNumber: number): Promise<{ title: string; body: string } | null> { + try { + const row = await env.DB.prepare( + `SELECT record_digest AS recordDigest, record_json AS recordJson FROM decision_records + WHERE repo_full_name = ? AND pull_number = ? ORDER BY created_at DESC LIMIT 1`, + ) + .bind(repoFullName, pullNumber) + .first<{ recordDigest: string; recordJson: string }>(); + if (!row) return null; + const record = JSON.parse(row.recordJson) as DecisionRecord; + return { title: "Decision record", body: renderDecisionRecordSection(record, row.recordDigest) }; + } catch (error) { + console.warn(JSON.stringify({ event: "decision_record_load_error", target: `${repoFullName}#${pullNumber}`, message: errorMessage(error).slice(0, 160) })); + return null; + } +} diff --git a/test/unit/decision-record.test.ts b/test/unit/decision-record.test.ts new file mode 100644 index 0000000000..f90f0461a6 --- /dev/null +++ b/test/unit/decision-record.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildDecisionRecord, + canonicalJson, + contentDigest, + DECISION_RECORD_SCHEMA_VERSION, + persistDecisionRecord, + renderDecisionRecordSection, + sha256Hex, + type DecisionRecord, +} from "../../src/review/decision-record"; +import { loadDecisionRecordCollapsible } from "../../src/review/decision-record"; +import { createTestEnv } from "../helpers/d1"; + +// #8836: the digests are commitments a contributor can challenge — key-order invariance and unicode +// stability are not niceties, they are what makes "config abc123" a meaningful claim. +describe("canonicalJson", () => { + it("is KEY-ORDER INVARIANT at every nesting depth", () => { + const a = { b: 1, a: { d: [1, 2], c: "x" } }; + const b = { a: { c: "x", d: [1, 2] }, b: 1 }; + expect(canonicalJson(a)).toBe(canonicalJson(b)); + expect(canonicalJson(a)).toBe('{"a":{"c":"x","d":[1,2]},"b":1}'); + }); + + it("preserves array order (order IS meaning there), drops undefined members, and is unicode-stable", () => { + expect(canonicalJson([2, 1])).toBe("[2,1]"); + expect(canonicalJson({ a: undefined, b: 1 })).toBe('{"b":1}'); + expect(canonicalJson({ b: 1, a: undefined })).toBe('{"b":1}'); + expect(canonicalJson("hélloé")).toBe(JSON.stringify("hélloé")); + expect(canonicalJson(null)).toBe("null"); + expect(canonicalJson(true)).toBe("true"); + expect(canonicalJson(3.5)).toBe("3.5"); + expect(canonicalJson([undefined])).toBe("[null]"); // JSON.stringify's own array-slot coercion, delegated per-entry + }); + + it("REFUSES un-JSON-able values loudly — a silent wrong digest is worse than a throw", () => { + expect(() => canonicalJson({ f: () => 1 })).toThrow(/unsupported value type/); + expect(() => canonicalJson(Symbol("x") as never)).toThrow(/unsupported value type/); + }); +}); + +describe("sha256Hex / contentDigest", () => { + it("matches the known SHA-256 of 'abc' and digests canonical forms identically", async () => { + expect(await sha256Hex("abc")).toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + expect(await contentDigest({ b: 1, a: 2 })).toBe(await contentDigest({ a: 2, b: 1 })); + expect(await contentDigest({ a: 2 })).not.toBe(await contentDigest({ a: 3 })); + }); +}); + +function recordInput(over: Partial = {}): Omit { + return { + repoFullName: "o/r", + pullNumber: 7, + headSha: "abc1234def", + baseSha: "base999", + action: "close", + reasonCode: "ci_readiness", + configDigest: "c".repeat(64), + gatePack: "gittensor", + ciState: "failed", + modelId: null, + promptDigest: null, + ...over, + }; +} + +describe("buildDecisionRecord / persistDecisionRecord", () => { + it("stamps schema version + decidedAt, digests the whole record, and normalizes undefined optionals to null", async () => { + const { record, recordDigest } = await buildDecisionRecord(recordInput()); + expect(record.schemaVersion).toBe(DECISION_RECORD_SCHEMA_VERSION); + expect(typeof record.decidedAt).toBe("string"); + expect(recordDigest).toBe(await contentDigest(record)); + // Call sites pass optional-shaped settings fields raw; normalization happens HERE, once. + const { record: bare } = await buildDecisionRecord({ ...recordInput(), gatePack: undefined, ciState: undefined, baseSha: undefined }); + expect(bare.gatePack).toBeNull(); + expect(bare.ciState).toBeNull(); + expect(bare.baseSha).toBeNull(); + }); + + it("persists with latest-finalize-wins per (target, head sha)", async () => { + const env = createTestEnv(); + const first = await buildDecisionRecord(recordInput({ action: "merge", reasonCode: "success", decidedAt: "2026-07-26T00:00:00Z" } as never)); + await persistDecisionRecord(env, first.record, first.recordDigest); + const second = await buildDecisionRecord(recordInput({ action: "close", reasonCode: "policy_close:contributor_cap", decidedAt: "2026-07-26T01:00:00Z" } as never)); + await persistDecisionRecord(env, second.record, second.recordDigest); + const rows = await env.DB.prepare("SELECT action, reason_code, record_digest, record_json FROM decision_records").all<{ action: string; reason_code: string; record_digest: string; record_json: string }>(); + expect(rows.results).toHaveLength(1); + expect(rows.results![0]).toMatchObject({ action: "close", reason_code: "policy_close:contributor_cap", record_digest: second.recordDigest }); + // The stored JSON is the canonical form — re-digesting it reproduces the stored digest (the replay check). + expect(await sha256Hex(rows.results![0]!.record_json)).toBe(second.recordDigest); + }); + + it("a persist failure is swallowed (legibility must never break finalization)", async () => { + const env = createTestEnv(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + vi.spyOn(env.DB, "prepare").mockImplementation(() => { + throw new Error("db down"); + }); + const { record, recordDigest } = await buildDecisionRecord(recordInput()); + await expect(persistDecisionRecord(env, record, recordDigest)).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + vi.restoreAllMocks(); + }); +}); + +describe("renderDecisionRecordSection", () => { + it("renders the claim + truncated digests; model line only when an AI review contributed", async () => { + const { record, recordDigest } = await buildDecisionRecord(recordInput()); + const body = renderDecisionRecordSection(record, recordDigest); + // The section body carries no
chrome — the bridge's UnifiedCollapsible renders the title. + expect(body).not.toContain("
"); + expect(body).toContain("`ci_readiness`"); + expect(body).toContain(record.configDigest.slice(0, 12)); + expect(body).toContain(recordDigest.slice(0, 12)); + expect(body).not.toContain("**model**"); + + const ai = await buildDecisionRecord(recordInput({ modelId: "claude-sonnet-5", promptDigest: "p".repeat(64) })); + const aiBody = renderDecisionRecordSection(ai.record, ai.recordDigest); + expect(aiBody).toContain("**model**: claude-sonnet-5"); + expect(aiBody).toContain("`pppppppppppp`"); + // Bounded: a record section must stay a small fixed-size block. + expect(aiBody.length).toBeLessThan(700); + + // Null pack/ci render nothing for those segments; a prompt digest without a model id renders "n/a". + const bare = await buildDecisionRecord(recordInput({ gatePack: null, ciState: null, modelId: null, promptDigest: "q".repeat(64) })); + const bareBody = renderDecisionRecordSection(bare.record, bare.recordDigest); + expect(bareBody).not.toContain("**pack**"); + expect(bareBody).not.toContain("**ci**"); + expect(bareBody).toContain("**model**: n/a"); + expect(bareBody).toContain("`qqqqqqqqqqqq`"); + // Model id present with NO prompt digest: the model line renders without a prompt segment. + const modelOnly = await buildDecisionRecord(recordInput({ modelId: "claude-sonnet-5" })); + expect(renderDecisionRecordSection(modelOnly.record, modelOnly.recordDigest)).toContain("**model**: claude-sonnet-5"); + expect(renderDecisionRecordSection(modelOnly.record, modelOnly.recordDigest)).not.toContain("**prompt**"); + }); +}); + +describe("loadDecisionRecordCollapsible", () => { + it("returns the latest record as a collapsible; null when none exists; null (fail-safe) on unreadable JSON", async () => { + const env = createTestEnv(); + expect(await loadDecisionRecordCollapsible(env, "o/r", 7)).toBeNull(); + const { record, recordDigest } = await buildDecisionRecord(recordInput()); + await persistDecisionRecord(env, record, recordDigest); + const collapsible = await loadDecisionRecordCollapsible(env, "o/r", 7); + expect(collapsible!.title).toBe("Decision record"); + expect(collapsible!.body).toContain(record.configDigest.slice(0, 12)); + // Corrupt the stored JSON: the publish path must omit the section, never throw. + const { vi } = await import("vitest"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + await env.DB.prepare("UPDATE decision_records SET record_json = '{not json' WHERE pull_number = 7").run(); + expect(await loadDecisionRecordCollapsible(env, "o/r", 7)).toBeNull(); + expect(warn).toHaveBeenCalled(); + vi.restoreAllMocks(); + }); +}); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index fba4e25b58..002e502e70 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -4572,6 +4572,57 @@ describe("queue processors", () => { expect(commentBody).not.toContain("the review lane is unavailable"); }); + it("#8836: a republish AFTER a decision record exists appends the Decision record collapsible", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autoLabelEnabled: false, gatePack: "oss-anti-slop" }); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { settings: { commentMode: "all_prs", publicSurface: "comment_only", checkRunMode: "off", reviewCheckMode: "required", aiReviewMode: "off" } }); + // The finalize of a PRIOR pass persisted the record; this publish must surface it. + await env.DB.prepare( + `INSERT INTO decision_records (id, repo_full_name, pull_number, head_sha, action, reason_code, record_digest, record_json, created_at) + VALUES ('record:JSONbored/gittensory#95@a95', 'JSONbored/gittensory', 95, 'a95', 'hold', 'guardrail_hold', ?, ?, ?)`, + ) + .bind( + "d".repeat(64), + JSON.stringify({ schemaVersion: "1", repoFullName: "JSONbored/gittensory", pullNumber: 95, headSha: "a95", baseSha: null, action: "hold", reasonCode: "guardrail_hold", configDigest: "e".repeat(64), gatePack: "oss-anti-slop", ciState: null, modelId: null, promptDigest: null, decidedAt: "2026-07-26T00:00:00Z" }), + "2026-07-26T00:00:00Z", + ) + .run(); + let commentBody = ""; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/pulls/95/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+export const ok = true;" }]); + if (url.endsWith("/pulls/95")) return Response.json({ number: 95, title: "Clean PR", state: "open", user: { login: "contributor" }, head: { sha: "a95" }, labels: [], body: "Closes #1" }); + if (url.includes("/commits/a95/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/a95/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/1")) return Response.json({ number: 1, title: "Issue", state: "open", labels: [], user: { login: "reporter" } }); + if (url.includes("/issues/95/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/95/comments") && method === "POST") { + commentBody = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? ""); + return Response.json({ id: 1 }, { status: 201 }); + } + if (url.includes("/branches/")) return Response.json({ protected: false, protection: { required_status_checks: { contexts: [] } } }); + return Response.json({}); + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "record-republish", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 95, title: "Clean PR", state: "open", user: { login: "contributor" }, head: { sha: "a95" }, labels: [], body: "Closes #1" }, + }, + }); + expect(commentBody).toContain("Decision record"); + expect(commentBody).toContain("`guardrail_hold`"); + expect(commentBody).toContain("dddddddddddd"); // the truncated record digest + }); + it("REGRESSION (registry-never-synced lane fix): the SAME setup still shows the lane-unavailable hold once a registry snapshot has synced at least once", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); // A real snapshot exists, but it does not list THIS repo -- a genuine "unregistered" signal, unlike the