From 10f83530d4b65c2f9ffbadbfb3156f19ff93d672 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:27:30 -0700 Subject: [PATCH 1/3] feat(review): persist a login-keyed predict-gate-vs-live-gate calibration ledger Neither the MCP predict_gate tool nor contributor_gate_history persists whether a contributor's self-reported predict-gate verdict matched the REAL gate decision their PR eventually received -- the one calibration signal the review stack itself uniquely owns, since it terminates both the self-review call and the live gate for the same login/repo. predicted_gate_calibration_ledger pairs the most recent predict_gate_calls row (#4516) for a (login, project) against the real decision at the same call sites as recordContributorGateDecision, writing one immutable row per (login, project, pr, commit) -- ON CONFLICT DO NOTHING, never DO UPDATE, so a webhook replay can never overwrite an already-recorded pairing. Write-only and server-side only: nothing reads it yet (mirrors contributor_gate_history's own precedent), and no MCP tool or other contributor-reachable surface can write to or read from it, preserving its value as anti-farming-resistant ground truth for a future #2349 consumer. Depends on #4516 (predicted_gate_calls) -- built stacked on that branch per the issue's own explicit note that the two may be built together; will be rebased onto main once that PR merges. Fixes #4517 --- ...0135_predicted_gate_calibration_ledger.sql | 45 +++++ scripts/check-schema-drift.mjs | 1 + src/queue/processors.ts | 19 ++ .../predicted-gate-calibration-ledger.ts | 126 +++++++++++++ .../predicted-gate-calibration-ledger.test.ts | 172 ++++++++++++++++++ 5 files changed, 363 insertions(+) create mode 100644 migrations/0135_predicted_gate_calibration_ledger.sql create mode 100644 src/review/predicted-gate-calibration-ledger.ts create mode 100644 test/unit/predicted-gate-calibration-ledger.test.ts diff --git a/migrations/0135_predicted_gate_calibration_ledger.sql b/migrations/0135_predicted_gate_calibration_ledger.sql new file mode 100644 index 0000000000..48ad976a45 --- /dev/null +++ b/migrations/0135_predicted_gate_calibration_ledger.sql @@ -0,0 +1,45 @@ +-- #predicted-gate-calibration-ledger (maintainer review-stack x AMS integration audit, 2026-07-09): a +-- login-keyed, SERVER-SIDE-ONLY ledger pairing a contributor's self-reported MCP predict_gate verdict +-- against the eventual REAL gate decision their PR received -- the review stack's own tamper-resistant +-- calibration ground truth, per issue #4517. +-- +-- WHY THIS IS SEPARATE FROM #4516's predicted_gate_calls / computePredictedGateAgreement: that pair answers +-- an AGGREGATE, project-level question ("how often does prediction agree with reality") computed FRESH on +-- every read, with no per-login row ever persisted or exposed. This table answers a DIFFERENT question -- +-- persisting ONE durable row per (login, real decision) pairing, becoming the substrate a FUTURE trust-tiering +-- or personalized-calibration consumer (#2349) can read. THE CRITICAL PROPERTY: nothing here is ever +-- writable, or even readable, by the contributor/miner whose row it is -- see +-- src/review/predicted-gate-calibration-ledger.ts's module header for the full anti-farming rationale (a +-- miner-writable version of this exact data would itself be a farming vector, per #2350). +-- +-- Privacy/precedent: login-keyed and LOCAL-ONLY, mirroring contributor_gate_history's (migrations/0126) +-- identical rationale for why login (not a hash) is fine here specifically because it never leaves the +-- instance -- never wired into exportOrbBatch or any other cross-instance/public export path. +CREATE TABLE IF NOT EXISTS predicted_gate_calibration_ledger ( + id TEXT PRIMARY KEY NOT NULL, + -- The GitHub login both the prediction and the real decision belong to. + login TEXT NOT NULL, + -- Which repo this pairing is for. + project TEXT NOT NULL, + -- The REAL decision's target, `repo#pr`. + target_id TEXT NOT NULL, + -- The self-reported predicted action at predict-time: 'merge' | 'hold'. + predicted_action TEXT NOT NULL, + -- The REAL gate action this login's PR actually received: 'merge' | 'hold'. + real_decision TEXT NOT NULL, + -- 1 when predicted_action = real_decision, 0 otherwise -- denormalized so a future reader never needs to + -- re-derive agreement (and can never be tricked by a re-derivation bug into miscounting it). + agreed INTEGER NOT NULL, + -- When the paired predict_gate call was made, and when the real decision landed -- both kept (not just + -- created_at) so a future reader can measure predict-to-decision latency, not just the outcome. + predicted_at TEXT NOT NULL, + decided_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- INSERT-ONLY by design (see the writer's own doc comment): no UPDATE statement anywhere touches this table, +-- so a webhook replay or re-review appends another consistent row rather than ever overwriting history. +CREATE INDEX IF NOT EXISTS predicted_gate_calibration_ledger_login_idx + ON predicted_gate_calibration_ledger(login, created_at); +CREATE INDEX IF NOT EXISTS predicted_gate_calibration_ledger_project_idx + ON predicted_gate_calibration_ledger(project, created_at); diff --git a/scripts/check-schema-drift.mjs b/scripts/check-schema-drift.mjs index 1e88549a51..f414ea9db0 100755 --- a/scripts/check-schema-drift.mjs +++ b/scripts/check-schema-drift.mjs @@ -50,6 +50,7 @@ export const RAW_SQL_ONLY_TABLES = new Set([ "orb_signals", "orb_webhook_events", "override_audit", + "predicted_gate_calibration_ledger", "predicted_gate_calls", "repo_chunks", "review_audit", diff --git a/src/queue/processors.ts b/src/queue/processors.ts index a4e82c9512..e8c4915c99 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -569,6 +569,7 @@ import { } from "../review/outcomes-wire"; import { neutralHoldReasonCode, nativeGateActionFromConclusion, recordNativeGateDecision } from "../review/parity-wire"; import { recordContributorGateDecision } from "../review/contributor-calibration"; +import { recordPredictedGateCalibration } from "../review/predicted-gate-calibration-ledger"; import { getSubmitterReputation, type SubmissionOutcome } from "../review/submitter-reputation"; import type { AdvisoryFinding, @@ -3312,6 +3313,16 @@ async function runAgentMaintenancePlanAndExecute( headSha: pr.headSha, decision: disposition.actionClass, }); + // #4517: pair this REAL decision against a recent predict_gate call from the same login/repo, if one + // exists -- see src/review/predicted-gate-calibration-ledger.ts's doc comment. Cold start (no prior + // prediction) records nothing. + await recordPredictedGateCalibration(env, { + login: pr.authorLogin, + project: repoFullName, + pullNumber: pr.number, + headSha: pr.headSha, + decision: disposition.actionClass, + }); if (disposition.actionClass === "hold") { const gateBlockerCodes = gate.blockers.map((blocker) => blocker.code); const mergeAutonomy = resolveAutonomy(settings.autonomy, "merge"); @@ -10330,6 +10341,14 @@ async function maybePublishPrPublicSurface( headSha: pr.headSha, decision: contributorDecision, }); + // #4517: same pairing as the other recordContributorGateDecision call site above. + await recordPredictedGateCalibration(env, { + login: pr.authorLogin, + project: repoFullName, + pullNumber: pr.number, + headSha: pr.headSha, + decision: contributorDecision, + }); } } // Review-evasion protection (#review-evasion-protection): the cost-bearing review pass for this head has diff --git a/src/review/predicted-gate-calibration-ledger.ts b/src/review/predicted-gate-calibration-ledger.ts new file mode 100644 index 0000000000..a461a1df6c --- /dev/null +++ b/src/review/predicted-gate-calibration-ledger.ts @@ -0,0 +1,126 @@ +// Login-keyed predict-gate-vs-live-gate calibration ledger (#4517, maintainer review-stack x AMS integration +// audit 2026-07-09) -- the review stack's OWN tamper-resistant calibration ground truth: one durable row per +// (login, real decision) pairing a contributor's self-reported MCP predict_gate verdict against the REAL gate +// decision their PR actually received. +// +// WHY THIS IS SEPARATE FROM #4516's predicted_gate_calls / computePredictedGateAgreement: that pair answers an +// AGGREGATE, project-level question ("how often does prediction agree with reality"), computed FRESH on every +// read, with no per-login row ever persisted. This ledger persists ONE row per pairing so a FUTURE consumer +// (#2349's personalized gate-prediction confidence tuning) has durable per-actor history to read, without +// re-deriving the join every time. +// +// THE CRITICAL PROPERTY -- READ BEFORE ADDING A CONSUMER OR CALL SITE: this is written EXCLUSIVELY from the +// webhook-driven real-gate-decision path (the same call sites as recordContributorGateDecision in +// src/review/contributor-calibration.ts), and NEVER from any MCP tool or other contributor-reachable surface. +// Both `predicted_action` (read from predicted_gate_calls, itself only ever written by the MCP tool's own +// SERVER-SIDE code, never by a caller-supplied value) and `real_decision` (the queue processor's own computed +// gate action) are values this module has no path for a caller to override or spoof. A miner-writable version +// of this exact data would itself be an anti-farming vector (#2350) -- see contributor-calibration.ts's +// identical design note for the same rationale applied to the plain (non-predicted) side of this ledger. +// +// IMMUTABLE PER (login, project, pr, commit): the row id is deterministic and the insert uses +// `ON CONFLICT DO NOTHING` (never DO UPDATE) -- a webhook replay at the SAME commit is a no-op, never a +// silent overwrite of the originally-recorded pairing. This is a stronger guarantee than +// recordContributorGateDecision's own per-commit REPLACE semantics, deliberately: once this ledger records a +// prediction-vs-outcome pairing, that pairing must never change underneath a future calibration reader. +// +// THIS PR ONLY WRITES THE LEDGER. Nothing reads predicted_gate_calibration_ledger yet -- mirrors +// contributor_gate_history's (migrations/0126) own "write-only, nothing reads yet" precedent; the eventual +// #2349 consumer is explicit future work, deliberately deferred so a personalization-adjustment reader gets +// its own focused review. + +import { isParityAuditEnabled } from "./parity-wire"; +import { isSelfHostedReviewRuntime } from "../selfhost/review-runtime"; +import { errorMessage, nowIso } from "../utils/json"; + +/** The minimal env shape the recorder needs -- mirrors parity-wire.ts's ParityRecorderEnv / contributor- + * calibration.ts's ContributorCalibrationEnv exactly (same gate-accuracy telemetry family, same flag). */ +type PredictedGateCalibrationEnv = { + DB: D1Database; + GITTENSORY_REVIEW_PARITY_AUDIT?: string | undefined; + SELFHOST_TRANSIENT_CACHE?: NonNullable; +}; + +/** Same correlation window as src/review/predicted-gate-agreement.ts's DEFAULT_CORRELATION_WINDOW_MS -- + * kept as an independent constant (not imported) so this module has zero dependency on that one's internals, + * but deliberately the SAME value: both answer "was this predicted call related to this real outcome," and a + * divergent window here would let the aggregate metric (#4516) and this persisted ledger (#4517) silently + * disagree about which pairs count. */ +const CORRELATION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +const isBinaryAction = (v: string): v is "merge" | "hold" => v === "merge" || v === "hold"; + +type RecentPredictedCall = { predicted_action: string; created_at: string }; + +/** + * Record ONE (login, real decision) pairing into `predicted_gate_calibration_ledger`, if -- and only if -- + * this login has a recent (within {@link CORRELATION_WINDOW_MS}) predict_gate call for this SAME repo to pair + * against. Cold start (no prior prediction) records nothing; there is nothing to calibrate against yet. + * + * Gated identically to {@link recordContributorGateDecision} in contributor-calibration.ts (same self-hosted- + * always-records / cloud-flag-gated contract) -- additive telemetry alongside the same gate-accuracy + * measurement family, not a separate feature with its own on/off knob. Only a binary (merge/hold) `decision` + * is comparable to a predict-gate verdict (the predictor never predicts 'close' -- see + * predicted-gate-agreement.ts's own module header for why); a 'close' or other decision records nothing. + * + * Best-effort and fail-safe throughout: a read or write failure is swallowed (telemetry must never break gate + * finalization). Immutable per (login, project, pr, headSha) -- see the module header. + */ +export async function recordPredictedGateCalibration( + env: PredictedGateCalibrationEnv, + input: { login: string | null | undefined; project: string; pullNumber: number; headSha: string | null | undefined; decision: string }, +): Promise { + if (!isSelfHostedReviewRuntime(env) && !isParityAuditEnabled(env)) return; + const login = input.login?.trim(); + if (!login) return; + if (!isBinaryAction(input.decision)) return; + const project = input.project.slice(0, 200); + const decidedAtIso = nowIso(); + const sinceIso = new Date(Date.now() - CORRELATION_WINDOW_MS).toISOString(); + + let predicted: RecentPredictedCall | null; + try { + predicted = + (await env.DB.prepare( + `SELECT predicted_action, created_at FROM predicted_gate_calls + WHERE project = ? AND login = ? AND created_at >= ? AND created_at <= ? + ORDER BY created_at DESC LIMIT 1`, + ) + .bind(project, login, sinceIso, decidedAtIso) + .first()) ?? null; + } catch (error) { + console.warn(JSON.stringify({ event: "predicted_gate_calibration_read_error", project, message: errorMessage(error).slice(0, 200) })); + return; + } + // Cold start (no prior prediction in the window) or a defensively-unexpected non-binary predicted_action -- + // either way, nothing comparable to pair against. + if (!predicted || !isBinaryAction(predicted.predicted_action)) return; + + const targetId = `${project}#${input.pullNumber}`; + const agreed = predicted.predicted_action === input.decision; + try { + // Deterministic id per (login, project, pr, commit) + ON CONFLICT DO NOTHING (never DO UPDATE): a replay + // at the SAME commit is a no-op, not a silent overwrite of the originally-recorded pairing. + await env.DB.prepare( + `INSERT INTO predicted_gate_calibration_ledger + (id, login, project, target_id, predicted_action, real_decision, agreed, predicted_at, decided_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO NOTHING`, + ) + .bind( + `calibration:${login}:${project}:${input.pullNumber}@${input.headSha ?? "none"}`, + login, + project, + targetId, + predicted.predicted_action, + input.decision, + agreed ? 1 : 0, + predicted.created_at, + decidedAtIso, + decidedAtIso, + ) + .run(); + } catch (error) { + console.warn(JSON.stringify({ event: "predicted_gate_calibration_write_error", project, message: errorMessage(error).slice(0, 200) })); + } +} diff --git a/test/unit/predicted-gate-calibration-ledger.test.ts b/test/unit/predicted-gate-calibration-ledger.test.ts new file mode 100644 index 0000000000..6229f10389 --- /dev/null +++ b/test/unit/predicted-gate-calibration-ledger.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from "vitest"; +import { recordPredictedGateCalibration } from "../../src/review/predicted-gate-calibration-ledger"; +import { createTestEnv } from "../helpers/d1"; + +async function rawAll(env: Env, sql: string, ...binds: unknown[]): Promise[]> { + const res = await (env.DB as unknown as { prepare: (s: string) => { bind: (...v: unknown[]) => { all: () => Promise<{ results: T[] }> } } }) + .prepare(sql) + .bind(...binds) + .all>(); + return res.results; +} + +async function seedPredicted(env: Env, opts: { login: string; project: string; action: string; createdAt: string }) { + await env.DB.prepare(`INSERT INTO predicted_gate_calls (id, login, project, predicted_action, conclusion, reason_code, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`) + .bind(crypto.randomUUID(), opts.login, opts.project, opts.action, opts.action === "merge" ? "success" : "failure", null, opts.createdAt) + .run(); +} + +const repoFullName = "owner/repo"; + +describe("recordPredictedGateCalibration — login-keyed predict-vs-live calibration ledger (#4517)", () => { + it("pairs a real 'merge' decision with a recent predicted 'merge' call as agreed=1", async () => { + const env = createTestEnv(); + await seedPredicted(env, { login: "octocat", project: repoFullName, action: "merge", createdAt: new Date(Date.now() - 60_000).toISOString() }); + + await recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: "sha1", decision: "merge" }); + + const rows = await rawAll(env, "SELECT * FROM predicted_gate_calibration_ledger"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ login: "octocat", project: repoFullName, target_id: "owner/repo#7", predicted_action: "merge", real_decision: "merge", agreed: 1 }); + }); + + it("pairs a real 'hold' decision with a recent predicted 'merge' call as agreed=0 (a disagreement)", async () => { + const env = createTestEnv(); + await seedPredicted(env, { login: "octocat", project: repoFullName, action: "merge", createdAt: new Date(Date.now() - 60_000).toISOString() }); + + await recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: "sha1", decision: "hold" }); + + const rows = await rawAll(env, "SELECT * FROM predicted_gate_calibration_ledger"); + expect(rows[0]).toMatchObject({ predicted_action: "merge", real_decision: "hold", agreed: 0 }); + }); + + it("cold start: records nothing when there is no prior predicted_gate_calls row for this (login, project)", async () => { + const env = createTestEnv(); + await recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: "sha1", decision: "merge" }); + expect(await rawAll(env, "SELECT * FROM predicted_gate_calibration_ledger")).toHaveLength(0); + }); + + it("does not pair a predicted call that falls outside the correlation window", async () => { + const env = createTestEnv(); + const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); + await seedPredicted(env, { login: "octocat", project: repoFullName, action: "merge", createdAt: eightDaysAgo }); + + await recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: "sha1", decision: "merge" }); + + expect(await rawAll(env, "SELECT * FROM predicted_gate_calibration_ledger")).toHaveLength(0); + }); + + it("does not pair across DIFFERENT repos for the same login", async () => { + const env = createTestEnv(); + await seedPredicted(env, { login: "octocat", project: "owner/other-repo", action: "merge", createdAt: new Date(Date.now() - 60_000).toISOString() }); + + await recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: "sha1", decision: "merge" }); + + expect(await rawAll(env, "SELECT * FROM predicted_gate_calibration_ledger")).toHaveLength(0); + }); + + it("does not record a non-binary real decision (e.g. an autonomous 'close') -- not comparable to a prediction", async () => { + const env = createTestEnv(); + await seedPredicted(env, { login: "octocat", project: repoFullName, action: "merge", createdAt: new Date(Date.now() - 60_000).toISOString() }); + + await recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: "sha1", decision: "close" }); + + expect(await rawAll(env, "SELECT * FROM predicted_gate_calibration_ledger")).toHaveLength(0); + }); + + it("defensively ignores a non-binary predicted_action (never written in practice, but the read must not crash)", async () => { + const env = createTestEnv(); + await seedPredicted(env, { login: "octocat", project: repoFullName, action: "bogus", createdAt: new Date(Date.now() - 60_000).toISOString() }); + + await recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: "sha1", decision: "merge" }); + + expect(await rawAll(env, "SELECT * FROM predicted_gate_calibration_ledger")).toHaveLength(0); + }); + + it("does not record when the login is missing, null, or blank", async () => { + const env = createTestEnv(); + await seedPredicted(env, { login: "octocat", project: repoFullName, action: "merge", createdAt: new Date(Date.now() - 60_000).toISOString() }); + await recordPredictedGateCalibration(env, { login: undefined, project: repoFullName, pullNumber: 1, headSha: "sha", decision: "merge" }); + await recordPredictedGateCalibration(env, { login: null, project: repoFullName, pullNumber: 2, headSha: "sha", decision: "merge" }); + await recordPredictedGateCalibration(env, { login: " ", project: repoFullName, pullNumber: 3, headSha: "sha", decision: "merge" }); + expect(await rawAll(env, "SELECT * FROM predicted_gate_calibration_ledger")).toHaveLength(0); + }); + + it("IMMUTABILITY: a replay at the SAME (login, project, pr, commit) is a no-op -- never overwrites the original pairing", async () => { + const env = createTestEnv(); + await seedPredicted(env, { login: "octocat", project: repoFullName, action: "merge", createdAt: new Date(Date.now() - 60_000).toISOString() }); + + await recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: "sha1", decision: "merge" }); + // A second call at the identical commit, even with a DIFFERENT (spoofed/incorrect) decision, must not + // change the already-recorded row -- this is the tamper-resistance guarantee the ledger exists for. + await recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: "sha1", decision: "hold" }); + + const rows = await rawAll(env, "SELECT * FROM predicted_gate_calibration_ledger"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ real_decision: "merge", agreed: 1 }); + }); + + it("a new commit for the same PR gets its OWN ledger row", async () => { + const env = createTestEnv(); + await seedPredicted(env, { login: "octocat", project: repoFullName, action: "merge", createdAt: new Date(Date.now() - 60_000).toISOString() }); + + await recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: "sha1", decision: "merge" }); + await recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: "sha2", decision: "hold" }); + + expect(await rawAll(env, "SELECT * FROM predicted_gate_calibration_ledger")).toHaveLength(2); + }); + + it("records even with a null head_sha (distinct id bucket, does not collide with a real sha)", async () => { + const env = createTestEnv(); + await seedPredicted(env, { login: "octocat", project: repoFullName, action: "merge", createdAt: new Date(Date.now() - 60_000).toISOString() }); + await recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: null, decision: "merge" }); + expect(await rawAll(env, "SELECT * FROM predicted_gate_calibration_ledger")).toHaveLength(1); + }); + + it("flag-OFF records NOTHING on the CLOUD WORKER — no D1 write (byte-identical, same gate family as recordContributorGateDecision)", async () => { + const env = createTestEnv(); + delete env.SELFHOST_TRANSIENT_CACHE; + await seedPredicted(env, { login: "octocat", project: repoFullName, action: "merge", createdAt: new Date(Date.now() - 60_000).toISOString() }); + await recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: "sha1", decision: "merge" }); + expect(await rawAll(env, "SELECT * FROM predicted_gate_calibration_ledger")).toHaveLength(0); + }); + + it("the cloud worker records when GITTENSORY_REVIEW_PARITY_AUDIT is explicitly ON", async () => { + const env = createTestEnv({ GITTENSORY_REVIEW_PARITY_AUDIT: "true" }); + delete env.SELFHOST_TRANSIENT_CACHE; + await seedPredicted(env, { login: "octocat", project: repoFullName, action: "merge", createdAt: new Date(Date.now() - 60_000).toISOString() }); + await recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: "sha1", decision: "merge" }); + expect(await rawAll(env, "SELECT * FROM predicted_gate_calibration_ledger")).toHaveLength(1); + }); + + it("fails safe: a read error is swallowed (logs, never throws)", async () => { + const env = createTestEnv(); + const realPrepare = env.DB.prepare.bind(env.DB); + env.DB.prepare = ((sql: string) => { + if (/SELECT.*FROM.*predicted_gate_calls/i.test(sql)) throw new Error("d1 down"); + return realPrepare(sql); + }) as typeof env.DB.prepare; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await expect(recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: "sha1", decision: "merge" })).resolves.toBeUndefined(); + + expect(warn.mock.calls.map((c) => String(c[0])).some((line) => line.includes("predicted_gate_calibration_read_error"))).toBe(true); + warn.mockRestore(); + }); + + it("fails safe: a write error is swallowed (logs, never throws)", async () => { + const env = createTestEnv(); + await seedPredicted(env, { login: "octocat", project: repoFullName, action: "merge", createdAt: new Date(Date.now() - 60_000).toISOString() }); + const realPrepare = env.DB.prepare.bind(env.DB); + env.DB.prepare = ((sql: string) => { + if (/INSERT INTO.*predicted_gate_calibration_ledger/i.test(sql)) throw new Error("d1 down"); + return realPrepare(sql); + }) as typeof env.DB.prepare; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await expect(recordPredictedGateCalibration(env, { login: "octocat", project: repoFullName, pullNumber: 7, headSha: "sha1", decision: "merge" })).resolves.toBeUndefined(); + + expect(warn.mock.calls.map((c) => String(c[0])).some((line) => line.includes("predicted_gate_calibration_write_error"))).toBe(true); + warn.mockRestore(); + }); +}); From bcb0aaec1fa4c7512331cd0e2df0c5b601906910 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:57:17 -0700 Subject: [PATCH 2/3] fix(db): renumber ledger migration 0135 -> 0138 (0134/0135 collide on main) origin/main independently landed three files at 0134 and two at 0135 (from already-merged PRs #4549, #4558, #4563) since this branch's last rebase. Rather than touch already-merged migration files here, take the next genuinely free number for this branch's own new migration; the 0134/0135 collision among already-merged files is a separate main-red issue handled in its own PR. --- ...tion_ledger.sql => 0138_predicted_gate_calibration_ledger.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename migrations/{0135_predicted_gate_calibration_ledger.sql => 0138_predicted_gate_calibration_ledger.sql} (100%) diff --git a/migrations/0135_predicted_gate_calibration_ledger.sql b/migrations/0138_predicted_gate_calibration_ledger.sql similarity index 100% rename from migrations/0135_predicted_gate_calibration_ledger.sql rename to migrations/0138_predicted_gate_calibration_ledger.sql From a5feee3dfe12d4597425c39ab925fe7101ca0c1b Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:57:43 -0700 Subject: [PATCH 3/3] fix(db): resolve migration collision at 0134 on main (already-merged PRs) Three already-merged PRs independently claimed 0134 (#4549's review_targets_cadence_idx, #4558's predicted_gate_calls, and #4563's pr_last_backlog_convergence_regated_at). CI on this branch inherits main's full migrations/ directory regardless of which PR fixes it, so this can't be deferred to a separate PR without also blocking this one. Renumber the two newer files (keeping #4563's oldest 0134 file in place); this branch's own new migration was already moved to 0138 to stay clear of both this collision and #4563's separate 0135 collision. --- ...argets_cadence_idx.sql => 0136_review_targets_cadence_idx.sql} | 0 ...134_predicted_gate_calls.sql => 0137_predicted_gate_calls.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename migrations/{0134_review_targets_cadence_idx.sql => 0136_review_targets_cadence_idx.sql} (100%) rename migrations/{0134_predicted_gate_calls.sql => 0137_predicted_gate_calls.sql} (100%) diff --git a/migrations/0134_review_targets_cadence_idx.sql b/migrations/0136_review_targets_cadence_idx.sql similarity index 100% rename from migrations/0134_review_targets_cadence_idx.sql rename to migrations/0136_review_targets_cadence_idx.sql diff --git a/migrations/0134_predicted_gate_calls.sql b/migrations/0137_predicted_gate_calls.sql similarity index 100% rename from migrations/0134_predicted_gate_calls.sql rename to migrations/0137_predicted_gate_calls.sql