diff --git a/src/orb/ingest.ts b/src/orb/ingest.ts index ed89e3483a..c1f5a9e2e4 100644 --- a/src/orb/ingest.ts +++ b/src/orb/ingest.ts @@ -10,6 +10,7 @@ const MAX_INSTANCE_ID_CHARS = 64; const MAX_HASH_CHARS = 128; const MAX_BUCKET_CHARS = 64; const MAX_VERDICT_CHARS = 32; +const MAX_TIMESTAMP_CHARS = 64; 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 @@ -120,6 +121,17 @@ function clampReuseCount(value: unknown): number | null { return rounded; } +/** Validate a sender-supplied timestamp (#10028). Returns the string unchanged only when it is a + * length-capped, `Date.parse`-able instant; otherwise null. decision_timestamp is the day bucket for the + * public fleet-accuracy trend and the retention rollup key, so a non-instant string would sort above every + * ISO date in the lexicographic window bound, be dropped in JS, and leave a permanent junk `day` in the + * rollup PK. Null lets `COALESCE(decision_timestamp, received_at)` fall back to the server clock, so the + * signal still counts. Mirrors the REUSE_DAY_PATTERN + clampReuseCount pair one field family over. */ +export function normalizeIngestTimestamp(value: unknown): string | null { + if (typeof value !== "string" || value.length > MAX_TIMESTAMP_CHARS) return null; + return Number.isFinite(Date.parse(value)) ? value : null; +} + export type OrbIngestResult = { accepted: number } | { error: string }; /** Clamp a sender-supplied cycle time to a plausible range; null for anything implausible/absent. */ @@ -223,9 +235,9 @@ export async function handleOrbIngest(body: string, db: D1Database, presentedIns reversal, 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, - typeof event.outcome_timestamp === "string" ? event.outcome_timestamp : null, + normalizeIngestTimestamp(event.decision_timestamp), + normalizeIngestTimestamp(event.outcome_timestamp), + normalizeIngestTimestamp(event.outcome_timestamp), ) .run(); if (result.meta.changes > 0) accepted++; diff --git a/test/integration/orb-ingest.test.ts b/test/integration/orb-ingest.test.ts index 001bfe7ce6..d99514f5a7 100644 --- a/test/integration/orb-ingest.test.ts +++ b/test/integration/orb-ingest.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { createApp } from "../../src/api/routes"; -import { handleOrbIngest, MAX_ORB_INGEST_BODY_BYTES, readOrbIngestBody } from "../../src/orb/ingest"; +import { handleOrbIngest, MAX_ORB_INGEST_BODY_BYTES, normalizeIngestTimestamp, readOrbIngestBody } from "../../src/orb/ingest"; import { createTestEnv, TestD1Database } from "../helpers/d1"; describe("handleOrbIngest()", () => { @@ -222,6 +222,46 @@ describe("handleOrbIngest()", () => { expect(await ingest(db, [ev()])).toBeTruthy(); expect(call).toBeGreaterThan(0); }); + + it("#10028: normalizeIngestTimestamp keeps a parseable capped instant and rejects everything else", () => { + expect(normalizeIngestTimestamp("2026-07-30T12:00:00.000Z")).toBe("2026-07-30T12:00:00.000Z"); + expect(normalizeIngestTimestamp("unknown")).toBeNull(); + expect(normalizeIngestTimestamp("x".repeat(65))).toBeNull(); + expect(normalizeIngestTimestamp(12345)).toBeNull(); + expect(normalizeIngestTimestamp(undefined)).toBeNull(); + }); + + it("#10028: a malformed decision_timestamp is stored NULL, the event still accepted", async () => { + const db = makeDb(); + expect(await ingest(db, [ev({ pr_hash: "ts1", decision_timestamp: "unknown" })])).toEqual({ accepted: 1 }); + expect(await col(db, "ts1", "decision_timestamp")).toBeNull(); + }); + + it("#10028: a well-formed decision_timestamp/outcome_timestamp is stored verbatim in all three columns", async () => { + const db = makeDb(); + const iso = "2026-07-30T12:00:00.000Z"; + await ingest(db, [ev({ pr_hash: "ts2", decision_timestamp: iso, outcome_timestamp: iso })]); + expect(await col(db, "ts2", "decision_timestamp")).toBe(iso); + expect(await col(db, "ts2", "outcome_timestamp")).toBe(iso); + expect(await col(db, "ts2", "sent_at")).toBe(iso); + }); + + it("#10028: an over-length (65+ char) timestamp is stored NULL while the event is still accepted", async () => { + const db = makeDb(); + // A parseable ISO prefix but past MAX_TIMESTAMP_CHARS (64): the length cap rejects it before Date.parse. + expect(await ingest(db, [ev({ pr_hash: "ts3", decision_timestamp: `2026-07-30T12:00:00.000Z${"0".repeat(50)}` })])).toEqual({ accepted: 1 }); + expect(await col(db, "ts3", "decision_timestamp")).toBeNull(); + }); + + it("#10028 REGRESSION: a malformed decision_timestamp must not silently drop the signal from the public trend", async () => { + const db = makeDb(); + await ingest(db, [ev({ pr_hash: "ts4", decision_timestamp: "unknown" })]); + // COALESCE(decision_timestamp, received_at) must fall back to the server clock and parse to a finite + // instant, so substr(...,1,10) is a real day bucket and the signal still counts toward the trend. + const coalesced = await col(db, "ts4", "COALESCE(decision_timestamp, received_at)"); + expect(typeof coalesced).toBe("string"); + expect(Number.isFinite(Date.parse(String(coalesced)))).toBe(true); + }); }); describe("handleOrbIngest() health ping (#4933)", () => {