Skip to content

Commit dcbfe2a

Browse files
authored
fix(stats): derive the published totals from the ledger instead of publishing a false zero (#9967)
An Orb reported `totals.handled: 0` beside a `reviewParity` block describing 2,123 verdicts from the same deployment's ledger. The public verifier caught it within minutes of the flag going on. The cause is NOT the D1/Postgres store split the issue hypothesised -- I traced it end to end on edge-nl-01 and the store is fine. `totals.*` is gated on LOOPOVER_PUBLIC_STATS_REPOS, which is unset there, so `projects` is empty and getPublicStats takes its own early-return branch and sums nothing. The ledger-derived blocks (reviewParity, automation-rate) honour no such allowlist. Two halves of one payload, two different gates. Ruled out both portability traps that produce the same symptom: `instr()` does fail on Postgres, but pg-dialect.ts rewrites it (verified by running the real translator over PUBLISHED_PR_KEYS), and PG16 accepts the unaliased subquery. The disposition query returns 9,657 against the live store. Publishing a number known to be false is the one option that is definitely wrong, so the aggregate now comes from the SAME ledger the parity block reads when no repo is allowlisted. The privacy intent is untouched, because it is about NAMING repos: `byProject` still requires the allowlist and stays empty, so no repo that was not opted in is identified. `totals` carries no repo identity. DISTINCT (repo, number) deliberately -- decision_records holds one row per VERDICT, 2,354 rows over 987 pull requests on the production Orb, so counting rows would overstate `handled` by more than 2x and reintroduce the same class of cross-surface disagreement this removes. The pre-existing "must not query an unscoped own-ledger" test encoded the old contract and passed only because safeAll swallowed its throw. Narrowed honestly: the per-PROJECT queries still must not run, and the aggregate read is asserted to carry no GROUP BY. Closes #9963
1 parent 43c7b0f commit dcbfe2a

2 files changed

Lines changed: 162 additions & 6 deletions

File tree

src/review/public-stats.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,39 @@ export const PUBLISHED_PR_KEYS = `
323323
/** Assemble the public-safe payload from the LIVE review ledger: distinct PRs the bot published a review for
324324
* (audit_events) joined to their terminal disposition (pull_requests state). Realtime behind the 60s HTTP cache
325325
* — a new review shows up within ~a minute; no rollup/cron. */
326+
/**
327+
* #9963: aggregate-only dispositions keyed off the anchored ledger (`decision_records`) rather than the
328+
* allowlist-gated audit trail, for a deployment that has published no repo allowlist.
329+
*
330+
* DISTINCT (repo, number) deliberately: `decision_records` holds one row per VERDICT, and a repeat evaluation
331+
* of the same head is its own row -- 2,354 rows over 987 pull requests on the production Orb. `handled` counts
332+
* pull requests, so counting rows here would overstate it by more than 2x and reintroduce exactly the kind of
333+
* cross-surface disagreement this fix exists to remove.
334+
*
335+
* The merged/closed/in-review split mirrors the allowlisted query's own LEFT JOIN against `pull_requests`
336+
* verbatim, so the two paths cannot drift into different definitions of the same word. No GROUP BY: this
337+
* returns one aggregate row and never a per-repo breakdown, which is what keeps repo identity unpublished.
338+
*/
339+
async function loadLedgerDerivedTotals(env: Env): Promise<{ handled: number; merged: number; closed: number; inReview: number } | null> {
340+
// `handled` is COUNT(*), which is never NULL -- only the SUM()s can be, when the join matches no rows. Typing
341+
// it non-nullable keeps a `?? 0` off it that no input could ever reach.
342+
const rows = await safeAll<{ handled: number; merged: number | null; closed: number | null; inReview: number | null }>(
343+
env,
344+
`SELECT COUNT(*) AS handled,
345+
SUM(CASE WHEN pr.merged_at IS NOT NULL THEN 1 ELSE 0 END) AS merged,
346+
SUM(CASE WHEN pr.state = 'closed' AND pr.merged_at IS NULL THEN 1 ELSE 0 END) AS closed,
347+
SUM(CASE WHEN pr.id IS NULL OR pr.state = 'open' THEN 1 ELSE 0 END) AS inReview
348+
FROM (SELECT DISTINCT repo_full_name AS repo, pull_number AS number FROM decision_records) ev
349+
LEFT JOIN pull_requests pr ON pr.repo_full_name = ev.repo AND pr.number = ev.number`,
350+
);
351+
const row = rows[0];
352+
// No ledger (a fresh deployment, or the Worker where decision records live elsewhere) is not a failure --
353+
// it means there is nothing to correct, so the caller keeps its existing zeros rather than inventing a
354+
// figure. safeAll already swallows a missing table into an empty result.
355+
if (!row) return null;
356+
return { handled: row.handled, merged: row.merged ?? 0, closed: row.closed ?? 0, inReview: row.inReview ?? 0 };
357+
}
358+
326359
export async function getPublicStats(
327360
env: Env,
328361
nowMs: number = Date.now(),
@@ -331,7 +364,18 @@ export async function getPublicStats(
331364
const projects = publicStatsProjects(env);
332365
const generatedAt = new Date(nowMs).toISOString();
333366
// The own-ledger side needs at least one allowlisted project to query; an empty allowlist skips these three
334-
// queries entirely (own-ledger totals stay zero) but still lets the Orb aggregate below run.
367+
// queries entirely but still lets the Orb aggregate below run.
368+
//
369+
// #9963: it used to leave `totals.*` at ZERO in that case, which on a self-hosted Orb published a flat
370+
// falsehood -- `totals.handled: 0` beside a `reviewParity` block reporting 2,123 verdicts from the same
371+
// deployment's ledger, caught by the public verifier within minutes of the flag being turned on. The two
372+
// halves answered to different gates: totals honours this allowlist, the ledger-derived blocks do not.
373+
//
374+
// Publishing a number known to be false is the one option that is definitely wrong, so the aggregate is now
375+
// derived from the SAME ledger the parity block reads when no repo is allowlisted. The privacy intent is
376+
// untouched, because it is about NAMING repos: `byProject` still requires the allowlist and stays empty, so
377+
// nothing identifies a repo that was not opted in. `totals` carries no repo identity at all.
378+
const ledgerTotals = projects.length === 0 ? await loadLedgerDerivedTotals(env) : null;
335379
const inList = projects.map(() => "?").join(", ");
336380
const [dispositions, windowedDispositions, reversalRows, weeklyRows, effortRows] = projects.length === 0
337381
? await Promise.all([
@@ -532,6 +576,16 @@ export async function getPublicStats(
532576
// Fleet accuracy (bugfix, #fairness-analytics): independent of the own-ledger allowlist above, so it's fetched
533577
// unconditionally alongside the Orb global fold, matching that fold's own unscoped-regardless-of-allowlist
534578
// behavior (see the "skips the own-ledger queries but still queries the Orb aggregate" test).
579+
// #9963: apply the ledger-derived aggregate BEFORE the Orb fold, in exactly the place the allowlisted
580+
// per-project loop would have contributed. Only reached when no repo is allowlisted, so it can never
581+
// double-count: `byProject` is empty in that case and contributed nothing above.
582+
if (ledgerTotals !== null) {
583+
totals.handled += ledgerTotals.handled;
584+
totals.merged += ledgerTotals.merged;
585+
totals.closed += ledgerTotals.closed;
586+
totals.commented += ledgerTotals.inReview;
587+
}
588+
535589
const [orb, fleet] = await Promise.all([getOrbGlobalStats(env), computeFleetAnalytics(env)]);
536590
totals.merged += orb.merged;
537591
totals.closed += orb.closed;

test/unit/public-stats.test.ts

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -916,17 +916,24 @@ describe("getPublicStats — live aggregate over the review ledger", () => {
916916
expect(out.totals.minutesSaved).toBe(101);
917917
});
918918

919-
it("skips the own-ledger queries but still queries the Orb aggregate when the allowlist is empty", async () => {
919+
it("skips the per-PROJECT own-ledger queries when the allowlist is empty, and never names a repo", async () => {
920+
// #9963 narrowed this contract rather than dropping it. The allowlist exists to keep repo IDENTITY
921+
// unpublished, so the per-project queries (the ones that GROUP BY repo) still must not run -- but the
922+
// aggregate ledger read below carries no repo identity and is now allowed, because publishing
923+
// `handled: 0` on a deployment holding thousands of verdicts was a flat falsehood.
920924
const env = {
921925
DB: {
922926
prepare: (sql: string) => {
923927
// #9474: getOrbGlobalStats now ALSO reads the durable orb_outcome_rollups fold (empty here).
924928
if (sql.includes("orb_pr_outcomes") || sql.includes("orb_outcome_rollups")) {
925-
return {
926-
bind: () => ({ first: async () => ({ merged: 0, closed: 0, total: 0 }) }),
927-
};
929+
return { bind: () => ({ first: async () => ({ merged: 0, closed: 0, total: 0 }) }) };
928930
}
929-
throw new Error("public stats must not query an unscoped own-ledger");
931+
// The aggregate ledger read: no GROUP BY, no repo column in the output.
932+
if (sql.includes("decision_records")) {
933+
expect(sql).not.toContain("GROUP BY");
934+
return { all: async () => ({ results: [{ handled: 0, merged: 0, closed: 0, inReview: 0 }] }) };
935+
}
936+
throw new Error("public stats must not run a per-project own-ledger query without an allowlist");
930937
},
931938
},
932939
LOOPOVER_PUBLIC_STATS_REPOS: "",
@@ -938,6 +945,101 @@ describe("getPublicStats — live aggregate over the review ledger", () => {
938945
expect(out.byProject).toEqual([]);
939946
});
940947

948+
it("REGRESSION (#9963): an unallowlisted Orb reports its LEDGER's handled count, not a false zero", async () => {
949+
// The published contradiction, caught live by the verifier within minutes of the flag going on:
950+
// reviewParity.verdicts = 2123 (read from decision_records, ungated)
951+
// totals.handled = 0 (read from the allowlist-gated audit trail, which was empty)
952+
// An Orb that has decided thousands of PRs reporting zero handled is not a rounding difference. Whichever
953+
// way it is fixed, publishing a number known to be false is the one option that is definitely wrong.
954+
const env = {
955+
DB: {
956+
prepare: (sql: string) => {
957+
if (sql.includes("orb_pr_outcomes") || sql.includes("orb_outcome_rollups")) {
958+
return { bind: () => ({ first: async () => ({ merged: 0, closed: 0, total: 0 }) }) };
959+
}
960+
if (sql.includes("decision_records")) {
961+
return { all: async () => ({ results: [{ handled: 987, merged: 488, closed: 497, inReview: 2 }] }) };
962+
}
963+
throw new Error("unexpected query");
964+
},
965+
},
966+
LOOPOVER_PUBLIC_STATS_REPOS: "",
967+
} as unknown as Env;
968+
const out = await getPublicStats(env, NOW);
969+
expect(out.totals.handled).toBe(987);
970+
expect(out.totals.merged).toBe(488);
971+
expect(out.totals.closed).toBe(497);
972+
// Still no repo named: the allowlist governs identity, and it is empty.
973+
expect(out.byProject).toEqual([]);
974+
});
975+
976+
it("INVARIANT (#9963): totals.handled cannot be zero while the ledger holds verdicts", async () => {
977+
// This is the cross-surface claim the public verifier checks ("parity rollups report N verdicts,
978+
// exceeding the all-time handled count of 0"). Pinned here so the two halves of one payload cannot drift
979+
// back into answering to different gates.
980+
for (const ledgerHandled of [1, 42, 987]) {
981+
const env = {
982+
DB: {
983+
prepare: (sql: string) => {
984+
if (sql.includes("orb_pr_outcomes") || sql.includes("orb_outcome_rollups")) {
985+
return { bind: () => ({ first: async () => ({ merged: 0, closed: 0, total: 0 }) }) };
986+
}
987+
if (sql.includes("decision_records")) {
988+
return { all: async () => ({ results: [{ handled: ledgerHandled, merged: 0, closed: 0, inReview: 0 }] }) };
989+
}
990+
throw new Error("unexpected query");
991+
},
992+
},
993+
LOOPOVER_PUBLIC_STATS_REPOS: "",
994+
} as unknown as Env;
995+
const out = await getPublicStats(env, NOW);
996+
expect(out.totals.handled, `ledger held ${ledgerHandled} PRs`).toBeGreaterThan(0);
997+
}
998+
});
999+
1000+
it("INVARIANT (#9963): NULL sums from an empty join read as zero, not NaN", async () => {
1001+
// A SUM() over zero matching rows is NULL, not 0 -- the real shape when the ledger holds PRs the
1002+
// pull_requests cache has never seen (a fresh Orb, or rows pruned by retention). Adding NULL would
1003+
// poison every downstream figure with NaN, so the nullish arms are exercised deliberately.
1004+
const env = {
1005+
DB: {
1006+
prepare: (sql: string) => {
1007+
if (sql.includes("orb_pr_outcomes") || sql.includes("orb_outcome_rollups")) {
1008+
return { bind: () => ({ first: async () => ({ merged: 0, closed: 0, total: 0 }) }) };
1009+
}
1010+
if (sql.includes("decision_records")) {
1011+
return { all: async () => ({ results: [{ handled: 5, merged: null, closed: null, inReview: null }] }) };
1012+
}
1013+
throw new Error("unexpected query");
1014+
},
1015+
},
1016+
LOOPOVER_PUBLIC_STATS_REPOS: "",
1017+
} as unknown as Env;
1018+
const out = await getPublicStats(env, NOW);
1019+
expect(out.totals.handled).toBe(5);
1020+
expect(out.totals.merged).toBe(0);
1021+
expect(out.totals.closed).toBe(0);
1022+
expect(Number.isNaN(out.totals.merged)).toBe(false);
1023+
});
1024+
1025+
it("INVARIANT (#9963): a deployment with NO ledger keeps its zeros instead of inventing a figure", async () => {
1026+
// safeAll swallows a missing table into an empty result; absence of evidence must not become a number.
1027+
const env = {
1028+
DB: {
1029+
prepare: (sql: string) => {
1030+
if (sql.includes("orb_pr_outcomes") || sql.includes("orb_outcome_rollups")) {
1031+
return { bind: () => ({ first: async () => ({ merged: 0, closed: 0, total: 0 }) }) };
1032+
}
1033+
if (sql.includes("decision_records")) return { all: async () => ({ results: [] }) };
1034+
throw new Error("unexpected query");
1035+
},
1036+
},
1037+
LOOPOVER_PUBLIC_STATS_REPOS: "",
1038+
} as unknown as Env;
1039+
const out = await getPublicStats(env, NOW);
1040+
expect(out.totals.handled).toBe(0);
1041+
});
1042+
9411043
it("reports Orb-only totals when the own-ledger allowlist is empty but Orb has data", async () => {
9421044
const env = {
9431045
DB: {

0 commit comments

Comments
 (0)