From 4b7bcb9962943c49b6f8991973ec728c718e8ef6 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Thu, 30 Jul 2026 03:36:37 +0800 Subject: [PATCH] fix(orb): whitelist gate_verdict and gate_reasoncode_bucket on ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ingestOrbSignals whitelists outcome and reversal_flag against closed vocabularies but stored gate_verdict and gate_reasoncode_bucket with only a length check, though both are read downstream as closed enums by exact string equality. An off-vocabulary gate_verdict (a case variant like "Merge", or any foreign string) matches neither foldInstance branch, so a real decision is silently reclassified into `holds` and understates the published fleetAccuracy.coveragePct — the exact metric #8829 added so coverage could not be gamed by raising the hold rate. The open ingest endpoint means a registered instance on an older/buggier build can poison its own published coverage. Add VALID_VERDICTS (merge|close|hold, the writer's GateAction vocabulary) and VALID_REASONCODE_BUCKETS (bucketReasonCode's nine literals) next to VALID_OUTCOMES/ VALID_REVERSALS, and AND the membership check onto the existing length check for each column, coercing an off-vocabulary value to null (which foldInstance already treats as a normal quality verdict). No downstream change needed. Closes #9642 --- src/orb/ingest.ts | 22 ++++++++++++++++++++-- test/integration/orb-ingest.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/orb/ingest.ts b/src/orb/ingest.ts index 266b4a5bf1..ed89e3483a 100644 --- a/src/orb/ingest.ts +++ b/src/orb/ingest.ts @@ -12,6 +12,24 @@ const MAX_BUCKET_CHARS = 64; const MAX_VERDICT_CHARS = 32; const VALID_OUTCOMES = new Set(["merged", "closed"]); const VALID_REVERSALS = new Set(["none", "reopened", "reverted", "superseded"]); +// gate_verdict is read downstream as a CLOSED enum by exact equality (analytics.ts foldInstance branches on +// "merge"/"close"); an off-vocabulary value would silently fall into `holds` and understate published coverage. +// It is the honest writer's GateAction vocabulary (parity.ts): merge | close | hold. +const VALID_VERDICTS = new Set(["merge", "close", "hold"]); +// gate_reasoncode_bucket is compared against "policy_action" downstream; the writer (bucketReasonCode, +// orb-collector.ts) emits exactly these nine literals. Anything else is coerced to null (foldInstance already +// treats null as a normal quality verdict), so an older/buggier registered instance cannot poison its coverage. +const VALID_REASONCODE_BUCKETS = new Set([ + "none", + "policy_action", + "issue_policy", + "duplicate_risk", + "slop_advisory", + "ai_quality", + "author_policy", + "ci_readiness", + "other", +]); const MIN_CYCLE_MS = 1_000; // <1s is implausible const MAX_CYCLE_MS = 31_536_000_000; // >1y is implausible @@ -200,10 +218,10 @@ export async function handleOrbIngest(body: string, db: D1Database, presentedIns instance_id, event.repo_hash, event.pr_hash, - typeof event.gate_verdict === "string" && event.gate_verdict.length <= MAX_VERDICT_CHARS ? event.gate_verdict : null, + typeof event.gate_verdict === "string" && event.gate_verdict.length <= MAX_VERDICT_CHARS && VALID_VERDICTS.has(event.gate_verdict) ? event.gate_verdict : null, event.outcome, reversal, - typeof event.gate_reasoncode_bucket === "string" && event.gate_reasoncode_bucket.length <= MAX_BUCKET_CHARS ? event.gate_reasoncode_bucket : null, + typeof event.gate_reasoncode_bucket === "string" && event.gate_reasoncode_bucket.length <= MAX_BUCKET_CHARS && VALID_REASONCODE_BUCKETS.has(event.gate_reasoncode_bucket) ? event.gate_reasoncode_bucket : null, clampCycleMs(event.time_to_close_ms), typeof event.decision_timestamp === "string" ? event.decision_timestamp : null, typeof event.outcome_timestamp === "string" ? event.outcome_timestamp : null, diff --git a/test/integration/orb-ingest.test.ts b/test/integration/orb-ingest.test.ts index 601be08e48..001bfe7ce6 100644 --- a/test/integration/orb-ingest.test.ts +++ b/test/integration/orb-ingest.test.ts @@ -71,6 +71,34 @@ describe("handleOrbIngest()", () => { expect(await col(db, "b3", "gate_reasoncode_bucket")).toBeNull(); }); + it("regression (#9642): whitelists gate_verdict to merge/close/hold — an off-vocabulary value within the length cap is stored as null", async () => { + const db = makeDb(); + // "Merge"/"banana" clear the length check but are not GateAction literals; unvalidated, foldInstance would + // mis-bucket them into `holds` and understate the published fleet coverage. + await ingest(db, [ + ev({ pr_hash: "gv1", gate_verdict: "close" }), + ev({ pr_hash: "gv2", gate_verdict: "hold" }), + ev({ pr_hash: "gv3", gate_verdict: "Merge" }), + ev({ pr_hash: "gv4", gate_verdict: "banana" }), + ]); + expect(await col(db, "gv1", "gate_verdict")).toBe("close"); + expect(await col(db, "gv2", "gate_verdict")).toBe("hold"); + expect(await col(db, "gv3", "gate_verdict")).toBeNull(); + expect(await col(db, "gv4", "gate_verdict")).toBeNull(); + }); + + it("regression (#9642): whitelists gate_reasoncode_bucket to bucketReasonCode's vocabulary — an off-vocabulary value is stored as null", async () => { + const db = makeDb(); + await ingest(db, [ + ev({ pr_hash: "rc1", gate_reasoncode_bucket: "policy_action" }), + ev({ pr_hash: "rc2", gate_reasoncode_bucket: "other" }), + ev({ pr_hash: "rc3", gate_reasoncode_bucket: "made_up_bucket" }), + ]); + expect(await col(db, "rc1", "gate_reasoncode_bucket")).toBe("policy_action"); + expect(await col(db, "rc2", "gate_reasoncode_bucket")).toBe("other"); + expect(await col(db, "rc3", "gate_reasoncode_bucket")).toBeNull(); + }); + it("clamps time_to_close_ms: valid kept; absent / <1s / >1y → null", async () => { const db = makeDb(); await ingest(db, [