Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions migrations/0179_decision_records.sql
Original file line number Diff line number Diff line change
@@ -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:<owner/repo>#<pr>@<head sha>
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:<kind> | 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);
1 change: 1 addition & 0 deletions scripts/check-schema-drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const RAW_SQL_ONLY_TABLES: Set<string> = new Set([
"ams_signals",
"contributor_gate_history",
"decision_audit_labels",
"decision_records",
"global_agent_controls",
"global_contributor_blacklist",
"global_moderation_config",
Expand Down
35 changes: 33 additions & 2 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 } : {}),
Expand Down Expand Up @@ -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,
Expand All @@ -11514,7 +11545,7 @@ async function maybePublishPrPublicSurface(
...(missingTestsFinding !== undefined ? { missingTestsFinding } : {}),
e2eTestGenAvailable,
env,
}),
}), ...(decisionRecordCollapsible !== null ? [decisionRecordCollapsible] : [])],
footerMarkdown: loopoverFooter(env, {
earnUrl: repo?.isRegistered
? gittensorRepoEarnUrl(repoFullName)
Expand Down
164 changes: 164 additions & 0 deletions src/review/decision-record.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string> {
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<string> {
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:<kind>`, 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<DecisionRecord, "schemaVersion" | "decidedAt" | "gatePack" | "ciState" | "baseSha"> & {
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<void> {
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;
}
}
Loading
Loading