⚠️ Definition of Done: this issue must be completed in full, in a single PR. Do not split this
work across multiple PRs, and do not defer any Deliverable below to a follow-up issue. A PR that
satisfies only some of the Deliverables, stubs a required test, or leaves a checkbox
partially-done does NOT resolve this issue and will be closed.
Context
handleOrbIngest is the open, unauthenticated fleet-calibration collector (src/orb/ingest.ts:132). Its own
header comments state the posture: every field of an incoming event is untrusted and is whitelist- or
range-validated before storage — repo_hash/pr_hash are length-capped at MAX_HASH_CHARS
(src/orb/ingest.ts:197-198), gate_verdict is capped and checked against VALID_VERDICTS
(:221), gate_reasoncode_bucket against VALID_REASONCODE_BUCKETS (:224), time_to_close_ms through
clampCycleMs (:225), reversal_flag against VALID_REVERSALS (:205), and the optional reuse counters
against a strict REUSE_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/ plus clampReuseCount (:113-121, :296-299).
Two fields are the exception (src/orb/ingest.ts:226-228):
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,
decision_timestamp and outcome_timestamp get a bare typeof === "string" check: no length cap, no format
check. outcome_timestamp is additionally written verbatim into a third column, sent_at
(src/orb/ingest.ts:214). Any string of any length — up to MAX_ORB_INGEST_BODY_BYTES (1 MiB), times
MAX_BATCH (500) events per request — is persisted three times per row.
decision_timestamp is not inert data. It is the day bucket for the public fleet-accuracy trend
(src/services/public-fleet-accuracy-trend.ts:121-125):
SELECT substr(COALESCE(s.decision_timestamp, s.received_at), 1, 10) AS day, s.instance_id, …
FROM orb_signals s
JOIN orb_instances i ON i.instance_id = s.instance_id AND i.registered = 1
WHERE COALESCE(s.decision_timestamp, s.received_at) >= ?1
and the same expression is the day key the retention prune folds rows under, permanently, into
orb_signal_rollups (src/db/retention.ts:311). Consequences, all reachable today from a self-host build
that emits a malformed timestamp:
- The
WHERE COALESCE(...) >= ?1 bound is a lexicographic string comparison. A value that is not an ISO
instant (e.g. "unknown") sorts above every ISO date, so it always passes the window filter regardless of
how old the row is.
loadPublicFleetAccuracyTrend then drops the row in JS (substr(...,1,10) is unparseable →
!Number.isFinite(dayMs) → continue, src/services/public-fleet-accuracy-trend.ts:141-143). The signal
is silently excluded from the published accuracy series — an undercount with no warning.
- The retention fold writes that garbage prefix as a
day value in orb_signal_rollups' composite primary
key. Each distinct malformed prefix becomes its own permanent rollup row that no window query will ever
read again and no prune will ever remove, since the rollup table is the fold destination, not a pruned
source.
The fix is small and has an exact in-file precedent: REUSE_DAY_PATTERN + clampReuseCount already reject a
malformed day/count row one field family over, in this same function.
Requirements
- Add a single shared normalizer in
src/orb/ingest.ts for the two timestamp fields. It MUST return null
for anything that is not a string, is longer than a new MAX_TIMESTAMP_CHARS constant, or does not parse as
a finite instant via Date.parse. It MUST return the value unchanged when it parses.
decision_timestamp, outcome_timestamp and the sent_at bind (all three binds at
src/orb/ingest.ts:226-228) MUST route through that normalizer.
MAX_TIMESTAMP_CHARS MUST be a named module constant sitting alongside the existing MAX_HASH_CHARS /
MAX_BUCKET_CHARS / MAX_VERDICT_CHARS block (src/orb/ingest.ts:9-12), set to 64.
- A malformed timestamp MUST NOT drop the whole event: the row MUST still be inserted with the offending
column(s) as NULL, so COALESCE(decision_timestamp, received_at) falls back to the server-side
received_at and the signal still counts toward the public trend. This is the behaviour difference that
matters — today the signal is silently lost.
- What must NOT change: the per-event skip rules (
repo_hash/pr_hash/outcome validation at
src/orb/ingest.ts:196-202), the accepted counter semantics, the whitelist/clamp behaviour of every other
field, the MAX_BATCH slice, the health / risk_control / reuse_counters blocks, and the
orb_signals schema (this is a validation change only — no migration).
⚠️ Required pattern: mirror the existing REUSE_DAY_PATTERN + clampReuseCount validation pair already in
this same file (src/orb/ingest.ts:113-121, applied at :296-299) — a small named constant plus a pure
normalizer applied at the bind site. What does NOT satisfy this issue: (a) a new migrations/NNNN_*.sql
adding a CHECK constraint or altering orb_signals — this is an input-validation fix, and a shipped
migration is immutable; (b) rejecting the whole event on a malformed timestamp, which turns a recoverable
row into a lost signal and changes the accepted count; (c) sanitizing at READ time in
public-fleet-accuracy-trend.ts or retention.ts instead of at ingest, which leaves the junk in the
rollup primary key forever; (d) a test-only PR.
Deliverables
All Deliverables above are required in a single PR. A PR that satisfies only some of them — for example one
that adds the length cap but not the Date.parse check, so "unknown" still reaches orb_signals — does not
resolve this issue.
Test Coverage Requirements
This repo enforces 99%+ Codecov patch coverage, branch-counted. vitest.config.ts's coverage.include
covers src/**/*.ts and packages/loopover-engine/src/**/*.ts; src/orb/ingest.ts is measured and gated.
Every branch the normalizer introduces needs both arms tested: typeof value === "string" (true/false),
value.length > MAX_TIMESTAMP_CHARS (true/false), and Number.isFinite(Date.parse(value)) (true/false).
Each of the three bind sites must be exercised with at least one accepted and one rejected value, since
outcome_timestamp feeds two columns and a partial change could normalize one and not the other.
This change is NOT in packages/loopover-engine/src/**, so the dual-upload engine-coverage rule does not apply.
Expected Outcome
orb_signals can no longer accept an arbitrary-length or non-instant string into decision_timestamp,
outcome_timestamp, or sent_at. A self-host build that emits a malformed timestamp still has its calibration
signal counted — bucketed by the server-side received_at — instead of being silently dropped from the
published fleet-accuracy trend, and orb_signal_rollups can no longer accumulate permanent junk day keys.
The two remaining unvalidated string fields now match the whitelist/clamp discipline every other field in this
ingest already follows.
Links & Resources
src/orb/ingest.ts:226-228 — the three unvalidated binds
src/orb/ingest.ts:197-225 — the sibling fields that ARE length-capped and whitelist-validated
src/orb/ingest.ts:113-121, :296-299 — REUSE_DAY_PATTERN / clampReuseCount, the pattern to mirror
src/services/public-fleet-accuracy-trend.ts:121-125, :141-143 — the public trend that buckets on, and
then silently drops, an unparseable decision_timestamp
src/db/retention.ts:311 — the permanent rollup day key derived from the same expression
Context
handleOrbIngestis the open, unauthenticated fleet-calibration collector (src/orb/ingest.ts:132). Its ownheader comments state the posture: every field of an incoming event is untrusted and is whitelist- or
range-validated before storage —
repo_hash/pr_hashare length-capped atMAX_HASH_CHARS(
src/orb/ingest.ts:197-198),gate_verdictis capped and checked againstVALID_VERDICTS(
:221),gate_reasoncode_bucketagainstVALID_REASONCODE_BUCKETS(:224),time_to_close_msthroughclampCycleMs(:225),reversal_flagagainstVALID_REVERSALS(:205), and the optional reuse countersagainst a strict
REUSE_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/plusclampReuseCount(:113-121,:296-299).Two fields are the exception (
src/orb/ingest.ts:226-228):decision_timestampandoutcome_timestampget a baretypeof === "string"check: no length cap, no formatcheck.
outcome_timestampis additionally written verbatim into a third column,sent_at(
src/orb/ingest.ts:214). Any string of any length — up toMAX_ORB_INGEST_BODY_BYTES(1 MiB), timesMAX_BATCH(500) events per request — is persisted three times per row.decision_timestampis not inert data. It is the day bucket for the public fleet-accuracy trend(
src/services/public-fleet-accuracy-trend.ts:121-125):and the same expression is the day key the retention prune folds rows under, permanently, into
orb_signal_rollups(src/db/retention.ts:311). Consequences, all reachable today from a self-host buildthat emits a malformed timestamp:
WHERE COALESCE(...) >= ?1bound is a lexicographic string comparison. A value that is not an ISOinstant (e.g.
"unknown") sorts above every ISO date, so it always passes the window filter regardless ofhow old the row is.
loadPublicFleetAccuracyTrendthen drops the row in JS (substr(...,1,10)is unparseable →!Number.isFinite(dayMs)→continue,src/services/public-fleet-accuracy-trend.ts:141-143). The signalis silently excluded from the published accuracy series — an undercount with no warning.
dayvalue inorb_signal_rollups' composite primarykey. Each distinct malformed prefix becomes its own permanent rollup row that no window query will ever
read again and no prune will ever remove, since the rollup table is the fold destination, not a pruned
source.
The fix is small and has an exact in-file precedent:
REUSE_DAY_PATTERN+clampReuseCountalready reject amalformed day/count row one field family over, in this same function.
Requirements
src/orb/ingest.tsfor the two timestamp fields. It MUST returnnullfor anything that is not a string, is longer than a new
MAX_TIMESTAMP_CHARSconstant, or does not parse asa finite instant via
Date.parse. It MUST return the value unchanged when it parses.decision_timestamp,outcome_timestampand thesent_atbind (all three binds atsrc/orb/ingest.ts:226-228) MUST route through that normalizer.MAX_TIMESTAMP_CHARSMUST be a named module constant sitting alongside the existingMAX_HASH_CHARS/MAX_BUCKET_CHARS/MAX_VERDICT_CHARSblock (src/orb/ingest.ts:9-12), set to64.column(s) as
NULL, soCOALESCE(decision_timestamp, received_at)falls back to the server-sidereceived_atand the signal still counts toward the public trend. This is the behaviour difference thatmatters — today the signal is silently lost.
repo_hash/pr_hash/outcomevalidation atsrc/orb/ingest.ts:196-202), theacceptedcounter semantics, the whitelist/clamp behaviour of every otherfield, the
MAX_BATCHslice, thehealth/risk_control/reuse_countersblocks, and theorb_signalsschema (this is a validation change only — no migration).Deliverables
MAX_TIMESTAMP_CHARS = 64constant and a pure normalizer (e.g.normalizeIngestTimestamp(value: unknown): string | null)in
src/orb/ingest.ts, applied to all three binds atsrc/orb/ingest.ts:226-228. Exact expectations:normalizeIngestTimestamp("2026-07-30T12:00:00.000Z")→"2026-07-30T12:00:00.000Z";normalizeIngestTimestamp("unknown")→null;normalizeIngestTimestamp("x".repeat(65))→null;normalizeIngestTimestamp(12345)→null;normalizeIngestTimestamp(undefined)→null.test/integration/orb-ingest.test.tsasserting that an event carryingdecision_timestamp: "unknown"is still accepted ({ accepted: 1 }) and that the persisted row'sdecision_timestampisNULL.test/integration/orb-ingest.test.tsasserting that a well-formeddecision_timestamp/outcome_timestamppair is stored verbatim in all three columns(
decision_timestamp,outcome_timestamp,sent_at) — the currently-correct behaviour must be pinned.test/integration/orb-ingest.test.tsasserting an over-length (65+ char) timestamp is storedas
NULLwhile the event is still accepted.test/integration/orb-ingest.test.tsnamed for this bug (e.g."REGRESSION: a malformed decision_timestamp must not silently drop the signal from the public trend")that ingests one malformed-timestamp event and asserts
COALESCE(decision_timestamp, received_at)onthe stored row parses to a finite instant.
All Deliverables above are required in a single PR. A PR that satisfies only some of them — for example one
that adds the length cap but not the
Date.parsecheck, so"unknown"still reachesorb_signals— does notresolve this issue.
Test Coverage Requirements
This repo enforces 99%+ Codecov patch coverage, branch-counted.
vitest.config.ts'scoverage.includecovers
src/**/*.tsandpackages/loopover-engine/src/**/*.ts;src/orb/ingest.tsis measured and gated.Every branch the normalizer introduces needs both arms tested:
typeof value === "string"(true/false),value.length > MAX_TIMESTAMP_CHARS(true/false), andNumber.isFinite(Date.parse(value))(true/false).Each of the three bind sites must be exercised with at least one accepted and one rejected value, since
outcome_timestampfeeds two columns and a partial change could normalize one and not the other.This change is NOT in
packages/loopover-engine/src/**, so the dual-upload engine-coverage rule does not apply.Expected Outcome
orb_signalscan no longer accept an arbitrary-length or non-instant string intodecision_timestamp,outcome_timestamp, orsent_at. A self-host build that emits a malformed timestamp still has its calibrationsignal counted — bucketed by the server-side
received_at— instead of being silently dropped from thepublished fleet-accuracy trend, and
orb_signal_rollupscan no longer accumulate permanent junkdaykeys.The two remaining unvalidated string fields now match the whitelist/clamp discipline every other field in this
ingest already follows.
Links & Resources
src/orb/ingest.ts:226-228— the three unvalidated bindssrc/orb/ingest.ts:197-225— the sibling fields that ARE length-capped and whitelist-validatedsrc/orb/ingest.ts:113-121,:296-299—REUSE_DAY_PATTERN/clampReuseCount, the pattern to mirrorsrc/services/public-fleet-accuracy-trend.ts:121-125,:141-143— the public trend that buckets on, andthen silently drops, an unparseable
decision_timestampsrc/db/retention.ts:311— the permanent rollupdaykey derived from the same expression