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
22 changes: 20 additions & 2 deletions src/orb/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions test/integration/orb-ingest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, [
Expand Down