Skip to content

Commit ddd7e51

Browse files
authored
fix(review): prevent backdated duplicate-winner claims (#3956)
1 parent 2589b8e commit ddd7e51

4 files changed

Lines changed: 52 additions & 94 deletions

File tree

packages/gittensory-engine/src/duplicate-winner.ts

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,11 @@
1111
* caller can compute the winner ONCE per review run and thread the result boolean consistently into every
1212
* surface (advisory finding, close reason, slop, panels), so they agree by construction.
1313
*
14-
* ELECTION ORDER (#dup-winner true-creation-time): prefer each PR's true GitHub `pull_request.created_at` —
15-
* the real order contributors opened their PRs in — over `linkedIssueClaimedAt` (gittensory's own sync-time,
16-
* i.e. whenever a webhook/sweep/backfill pass happened to OBSERVE the linked issue). Sync order and creation
17-
* order diverge whenever processing isn't strictly FIFO (a stalled sweep catching up on a backlog, backfill
18-
* reordering, webhook delivery delay), under the old claim-time-only rule, that divergence could crown a
19-
* LATER contributor the winner and close the PR of whoever actually opened first. `createdAt` is compared
20-
* only when BOTH sides of a given comparison have a valid one; otherwise this falls back to the legacy
21-
* claim-time comparison unchanged, so sparse/legacy rows keep their existing fail-closed behavior exactly.
14+
* ELECTION ORDER: compare `linkedIssueClaimedAt`, the time gittensory first observed the PR claiming
15+
* the issue. GitHub `pull_request.created_at` is intentionally not an ordering signal here: contributors can
16+
* edit an old placeholder PR to add a linked issue later, so creation time would let backdated claims steal
17+
* duplicate-winner credit from the PR that actually claimed the issue first. Sparse legacy rows that lack
18+
* claim timing keep failing closed so unknown ordering cannot suppress duplicate evidence.
2219
*
2320
* INVARIANT (the caller MUST honor it): {@link openSiblingNumbers} carries OPEN-only sibling PR numbers. The
2421
* existing sources already exclude closed/merged PRs. Once the winner closes (e.g. red CI), it leaves the open
@@ -34,7 +31,7 @@
3431
export type DuplicateClaimMember = {
3532
number: number;
3633
linkedIssueClaimedAt?: string | null | undefined;
37-
/** GitHub's true PR creation time. See the module doc's "ELECTION ORDER" note. */
34+
/** GitHub's true PR creation time. Retained for caller compatibility; not used for winner ordering. */
3835
createdAt?: string | null | undefined;
3936
};
4037

@@ -55,8 +52,7 @@ export function isDuplicateClusterWinner(prNumber: number, openSiblingNumbers: n
5552

5653
/**
5754
* True iff `pr` is the earliest-elected claimant in the open duplicate cluster (see the module doc's
58-
* "ELECTION ORDER" note for the createdAt-vs-claim-time precedence). Sparse legacy rows fail closed; ties
59-
* between equally-ordered members use PR number.
55+
* "ELECTION ORDER" note). Sparse legacy rows fail closed; ties between equally-ordered members use PR number.
6056
*/
6157
export function isDuplicateClusterWinnerByClaim(pr: DuplicateClaimMember, openSiblings: DuplicateClaimMember[]): boolean {
6258
if (openSiblings.length === 0) return true;
@@ -67,18 +63,11 @@ export function isDuplicateClusterWinnerByClaim(pr: DuplicateClaimMember, openSi
6763
}
6864

6965
/**
70-
* True iff `pr` is ordered at or ahead of `sibling` for cluster-winner purposes. Prefers `createdAt` when BOTH
71-
* sides have a valid one (the true creation-time order); otherwise falls back to the legacy `linkedIssueClaimedAt`
72-
* comparison unchanged (including its fail-closed-on-missing/invalid-timestamp behavior), so a mixed
73-
* legacy/modern cluster never silently guesses using two different clocks for the two sides of one comparison.
66+
* True iff `pr` is ordered at or ahead of `sibling` for cluster-winner purposes. Only the observed linked-issue
67+
* claim time participates in the election; `createdAt` is deliberately ignored because an older PR can claim a
68+
* linked issue later by editing its body.
7469
*/
7570
function prPrecedesSibling(pr: DuplicateClaimMember, sibling: DuplicateClaimMember): boolean {
76-
const prCreated = claimTimeMs(pr.createdAt);
77-
const siblingCreated = claimTimeMs(sibling.createdAt);
78-
if (prCreated !== null && siblingCreated !== null) {
79-
if (prCreated !== siblingCreated) return prCreated < siblingCreated;
80-
return pr.number <= sibling.number;
81-
}
8271
const prClaim = claimTimeMs(pr.linkedIssueClaimedAt);
8372
if (prClaim === null) return false;
8473
const siblingClaim = claimTimeMs(sibling.linkedIssueClaimedAt);

packages/gittensory-engine/src/signals/duplicate-winner.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,27 @@
1111
* caller can compute the winner ONCE per review run and thread the result boolean consistently into every
1212
* surface (advisory finding, close reason, slop, panels), so they agree by construction.
1313
*
14-
* ELECTION ORDER (#dup-winner true-creation-time): prefer each PR's true GitHub `pull_request.created_at` —
15-
* the real order contributors opened their PRs in — over `linkedIssueClaimedAt` (gittensory's own sync-time,
16-
* i.e. whenever a webhook/sweep/backfill pass happened to OBSERVE the linked issue). Sync order and creation
17-
* order diverge whenever processing isn't strictly FIFO (a stalled sweep catching up on a backlog, backfill
18-
* reordering, webhook delivery delay), under the old claim-time-only rule, that divergence could crown a
19-
* LATER contributor the winner and close the PR of whoever actually opened first. `createdAt` is compared
20-
* only when BOTH sides of a given comparison have a valid one; otherwise this falls back to the legacy
21-
* claim-time comparison unchanged, so sparse/legacy rows keep their existing fail-closed behavior exactly.
14+
* ELECTION ORDER: compare `linkedIssueClaimedAt`, the time gittensory first observed the PR claiming
15+
* the issue. GitHub `pull_request.created_at` is intentionally not an ordering signal here: contributors can
16+
* edit an old placeholder PR to add a linked issue later, so creation time would let backdated claims steal
17+
* duplicate-winner credit from the PR that actually claimed the issue first. Sparse legacy rows that lack
18+
* claim timing keep failing closed so unknown ordering cannot suppress duplicate evidence.
2219
*
2320
* INVARIANT (the caller MUST honor it): {@link openSiblingNumbers} carries OPEN-only sibling PR numbers. The
2421
* existing sources already exclude closed/merged PRs. Once the winner closes (e.g. red CI), it leaves the open
2522
* set and the next-earliest OPEN claimant becomes the winner on re-eval — no permanently-orphaned cluster.
23+
*
24+
* SECOND CONSUMER (#2278): this module is intentionally engine-hosted (not `src/`-only) because its election
25+
* logic is reusable for the miner's own soft-claim adjudication — deciding which of several miners claiming
26+
* the same issue proceeds. A future contributor wiring the miner's local claim ledger should import this
27+
* module rather than reimplementing the election rule, so both the maintainer gate and the miner agree on
28+
* exactly one winner by construction.
2629
*/
2730

2831
export type DuplicateClaimMember = {
2932
number: number;
3033
linkedIssueClaimedAt?: string | null | undefined;
31-
/** GitHub's true PR creation time. See the module doc's "ELECTION ORDER" note. */
34+
/** GitHub's true PR creation time. Retained for caller compatibility; not used for winner ordering. */
3235
createdAt?: string | null | undefined;
3336
};
3437

@@ -49,8 +52,7 @@ export function isDuplicateClusterWinner(prNumber: number, openSiblingNumbers: n
4952

5053
/**
5154
* True iff `pr` is the earliest-elected claimant in the open duplicate cluster (see the module doc's
52-
* "ELECTION ORDER" note for the createdAt-vs-claim-time precedence). Sparse legacy rows fail closed; ties
53-
* between equally-ordered members use PR number.
55+
* "ELECTION ORDER" note). Sparse legacy rows fail closed; ties between equally-ordered members use PR number.
5456
*/
5557
export function isDuplicateClusterWinnerByClaim(pr: DuplicateClaimMember, openSiblings: DuplicateClaimMember[]): boolean {
5658
if (openSiblings.length === 0) return true;
@@ -61,18 +63,11 @@ export function isDuplicateClusterWinnerByClaim(pr: DuplicateClaimMember, openSi
6163
}
6264

6365
/**
64-
* True iff `pr` is ordered at or ahead of `sibling` for cluster-winner purposes. Prefers `createdAt` when BOTH
65-
* sides have a valid one (the true creation-time order); otherwise falls back to the legacy `linkedIssueClaimedAt`
66-
* comparison unchanged (including its fail-closed-on-missing/invalid-timestamp behavior), so a mixed
67-
* legacy/modern cluster never silently guesses using two different clocks for the two sides of one comparison.
66+
* True iff `pr` is ordered at or ahead of `sibling` for cluster-winner purposes. Only the observed linked-issue
67+
* claim time participates in the election; `createdAt` is deliberately ignored because an older PR can claim a
68+
* linked issue later by editing its body.
6869
*/
6970
function prPrecedesSibling(pr: DuplicateClaimMember, sibling: DuplicateClaimMember): boolean {
70-
const prCreated = claimTimeMs(pr.createdAt);
71-
const siblingCreated = claimTimeMs(sibling.createdAt);
72-
if (prCreated !== null && siblingCreated !== null) {
73-
if (prCreated !== siblingCreated) return prCreated < siblingCreated;
74-
return pr.number <= sibling.number;
75-
}
7671
const prClaim = claimTimeMs(pr.linkedIssueClaimedAt);
7772
if (prClaim === null) return false;
7873
const siblingClaim = claimTimeMs(sibling.linkedIssueClaimedAt);

src/signals/duplicate-winner.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Duplicate-winner adjudication (#dup-winner), extracted to `@jsonbored/gittensory-engine` (#2278) so the
33
* maintainer gate and the miner's own soft-claim adjudication (a later Phase-0 issue) import the identical,
44
* versioned election logic instead of drifting apart. See the engine module's doc comment for the full
5-
* election-order rationale (createdAt-vs-claim-time precedence, fail-closed semantics).
5+
* election-order rationale (claim-time election, anti-backdating semantics).
66
*
77
* packages/gittensory-engine/src/duplicate-winner.ts (imported via relative source path, not the published
88
* module, matching the #2282 scoring-preview extraction) is the source of truth.

test/unit/duplicate-winner.test.ts

Lines changed: 25 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -92,76 +92,50 @@ describe("isDuplicateClusterWinnerByClaim (#dup-winner claim election)", () => {
9292
});
9393
});
9494

95-
describe("isDuplicateClusterWinnerByClaim createdAt precedence (#dup-winner true-creation-time)", () => {
95+
describe("isDuplicateClusterWinnerByClaim claim-time election with createdAt present (#dup-winner anti-backdating)", () => {
9696
const member = (number: number, createdAt: string | null, linkedIssueClaimedAt: string | null) => ({ number, createdAt, linkedIssueClaimedAt });
9797

98-
it("REGRESSION: elects the PR that GitHub says opened first, even when gittensory OBSERVED (claimed) the later-opened sibling first", () => {
99-
// PR 13 truly opened first (10:00) but gittensory's stalled sweep only got around to syncing/claiming it at
100-
// 11:00. PR 14 opened later (10:05) but was claimed immediately (10:06) because the sweep happened to reach
101-
// it first. Under the old claim-time-only rule, 14 would wrongly win and 13 (the real first mover) would be
102-
// closed as the "duplicate." createdAt must override that.
98+
it("REGRESSION: does not let an older placeholder PR steal winner credit by adding the issue later", () => {
99+
// PR 12 was opened first but only edited in the linked issue after PR 13 had already claimed it. The
100+
// anti-backdating signal is linkedIssueClaimedAt, so createdAt must not override the actual claim order.
103101
expect(
104102
isDuplicateClusterWinnerByClaim(
105-
member(13, "2026-06-29T10:00:00.000Z", "2026-06-29T11:00:00.000Z"),
106-
[member(14, "2026-06-29T10:05:00.000Z", "2026-06-29T10:06:00.000Z")],
107-
),
108-
).toBe(true);
109-
// And symmetrically, the later-created PR no longer wins just because it was claimed first.
110-
expect(
111-
isDuplicateClusterWinnerByClaim(
112-
member(14, "2026-06-29T10:05:00.000Z", "2026-06-29T10:06:00.000Z"),
113-
[member(13, "2026-06-29T10:00:00.000Z", "2026-06-29T11:00:00.000Z")],
103+
member(12, "2026-06-29T09:00:00.000Z", "2026-06-29T10:05:00.000Z"),
104+
[member(13, "2026-06-29T09:30:00.000Z", "2026-06-29T10:00:00.000Z")],
114105
),
115106
).toBe(false);
116-
});
117-
118-
it("falls back to claim-time comparison when only ONE side has a valid createdAt (mixed legacy/modern cluster)", () => {
119-
// pr has createdAt; sibling (a legacy row) does not — never mix clocks across the two sides of one
120-
// comparison. pr's claim (10:00) is earlier than sibling's claim (10:05) ⇒ pr still wins via the fallback.
121107
expect(
122108
isDuplicateClusterWinnerByClaim(
123-
{ number: 12, createdAt: "2026-06-29T09:00:00.000Z", linkedIssueClaimedAt: "2026-06-29T10:00:00.000Z" },
124-
[{ number: 13, createdAt: null, linkedIssueClaimedAt: "2026-06-29T10:05:00.000Z" }],
109+
member(13, "2026-06-29T09:30:00.000Z", "2026-06-29T10:00:00.000Z"),
110+
[member(12, "2026-06-29T09:00:00.000Z", "2026-06-29T10:05:00.000Z")],
125111
),
126112
).toBe(true);
127-
// Same mixed case, but pr's own claim is later than the sibling's ⇒ pr loses via the fallback.
128-
expect(
129-
isDuplicateClusterWinnerByClaim(
130-
{ number: 12, createdAt: "2026-06-29T09:00:00.000Z", linkedIssueClaimedAt: "2026-06-29T10:05:00.000Z" },
131-
[{ number: 13, createdAt: null, linkedIssueClaimedAt: "2026-06-29T10:00:00.000Z" }],
132-
),
133-
).toBe(false);
134113
});
135114

136-
it("falls back to claim-time comparison when a createdAt value is present but unparseable", () => {
115+
it("ignores createdAt even when both sides have valid values", () => {
137116
expect(
138117
isDuplicateClusterWinnerByClaim(
139-
member(12, "not-a-date", "2026-06-29T10:00:00.000Z"),
140-
[member(13, "2026-06-29T09:00:00.000Z", "2026-06-29T10:05:00.000Z")],
118+
member(14, "2026-06-29T10:05:00.000Z", "2026-06-29T10:00:00.000Z"),
119+
[member(13, "2026-06-29T10:00:00.000Z", "2026-06-29T11:00:00.000Z")],
141120
),
142121
).toBe(true);
143122
});
144123

145-
it("tie-breaks equal createdAt values by PR number, mirroring the claim-time tie-break", () => {
146-
expect(isDuplicateClusterWinnerByClaim(member(12, "2026-06-29T10:00:00.000Z", null), [member(13, "2026-06-29T10:00:00.000Z", null)])).toBe(true);
147-
expect(isDuplicateClusterWinnerByClaim(member(13, "2026-06-29T10:00:00.000Z", null), [member(12, "2026-06-29T10:00:00.000Z", null)])).toBe(false);
148-
});
149-
150-
it("createdAt-based cases are unaffected by (and do not require) a claim timestamp at all", () => {
151-
expect(isDuplicateClusterWinnerByClaim(member(12, "2026-06-29T10:00:00.000Z", null), [member(13, "2026-06-29T10:05:00.000Z", null)])).toBe(true);
124+
it("still fails closed when createdAt is present but claim timing is missing", () => {
125+
expect(isDuplicateClusterWinnerByClaim(member(12, "2026-06-29T10:00:00.000Z", null), [member(13, "2026-06-29T10:05:00.000Z", null)])).toBe(false);
152126
});
153127
});
154128

155129
describe("resolveDuplicateClusterWinnerNumber (#dup-winner-credit)", () => {
156130
it("returns this PR's own number when it is the winner", () => {
157-
expect(resolveDuplicateClusterWinnerNumber({ number: 12, createdAt: "2026-06-29T10:00:00.000Z" }, [{ number: 13, createdAt: "2026-06-29T10:05:00.000Z" }])).toBe(12);
131+
expect(resolveDuplicateClusterWinnerNumber({ number: 12, linkedIssueClaimedAt: "2026-06-29T10:00:00.000Z" }, [{ number: 13, linkedIssueClaimedAt: "2026-06-29T10:05:00.000Z" }])).toBe(12);
158132
});
159133

160134
it("returns the actual winning sibling's number when this PR is a loser, even with multiple siblings", () => {
161135
expect(
162-
resolveDuplicateClusterWinnerNumber({ number: 14, createdAt: "2026-06-29T10:10:00.000Z" }, [
163-
{ number: 13, createdAt: "2026-06-29T10:00:00.000Z" },
164-
{ number: 15, createdAt: "2026-06-29T10:05:00.000Z" },
136+
resolveDuplicateClusterWinnerNumber({ number: 14, linkedIssueClaimedAt: "2026-06-29T10:10:00.000Z" }, [
137+
{ number: 13, linkedIssueClaimedAt: "2026-06-29T10:00:00.000Z" },
138+
{ number: 15, linkedIssueClaimedAt: "2026-06-29T10:05:00.000Z" },
165139
]),
166140
).toBe(13);
167141
});
@@ -223,9 +197,9 @@ describe("dupWinnerLinkedDuplicateCount (#dup-winner close-reason seam)", () =>
223197
expect(dupWinnerLinkedDuplicateCount([], 12, "2026-06-29T10:00:00.000Z", false)).toBe(0);
224198
});
225199

226-
it("REGRESSION (#dup-winner true-creation-time): createdAt overrides a claim-time-only verdict when passed through", () => {
227-
// By claim time alone this PR (12) would lose to sibling 13 (claimed earlier, 10:00 vs 10:05). But 12's true
228-
// createdAt (09:00) precedes 13's (09:30), so passing createdAt flips the verdict to a win (count 0).
200+
it("REGRESSION (#dup-winner anti-backdating): createdAt does not override claim-time ordering when passed through", () => {
201+
// PR 12 is older, but sibling 13 claimed the linked issue first; passing createdAt must not suppress the
202+
// duplicate count for the later claimant.
229203
expect(
230204
dupWinnerLinkedDuplicateCount(
231205
[{ number: 13, linkedIssueClaimedAt: "2026-06-29T10:00:00.000Z", createdAt: "2026-06-29T09:30:00.000Z" }],
@@ -234,21 +208,21 @@ describe("dupWinnerLinkedDuplicateCount (#dup-winner close-reason seam)", () =>
234208
true,
235209
"2026-06-29T09:00:00.000Z",
236210
),
237-
).toBe(0);
211+
).toBe(1);
238212
});
239213
});
240214

241215
describe("dupWinnerLinkedDuplicateWinnerNumber (#dup-winner-credit close-reason naming seam)", () => {
242216
it("flag OFF ⇒ null regardless of who would win (generic wording, byte-identical to before this existed)", () => {
243-
expect(dupWinnerLinkedDuplicateWinnerNumber([{ number: 13, createdAt: "2026-06-29T10:05:00.000Z" }], 12, undefined, false, "2026-06-29T10:00:00.000Z")).toBeNull();
217+
expect(dupWinnerLinkedDuplicateWinnerNumber([{ number: 13, linkedIssueClaimedAt: "2026-06-29T10:05:00.000Z" }], 12, "2026-06-29T10:00:00.000Z", false, "2026-06-29T10:00:00.000Z")).toBeNull();
244218
});
245219

246220
it("winner + flag ON ⇒ null (nothing to name — its own close reason omits the duplicate cause entirely)", () => {
247-
expect(dupWinnerLinkedDuplicateWinnerNumber([{ number: 13, createdAt: "2026-06-29T10:05:00.000Z" }], 12, undefined, true, "2026-06-29T10:00:00.000Z")).toBeNull();
221+
expect(dupWinnerLinkedDuplicateWinnerNumber([{ number: 13, linkedIssueClaimedAt: "2026-06-29T10:05:00.000Z" }], 12, "2026-06-29T10:00:00.000Z", true, "2026-06-29T10:00:00.000Z")).toBeNull();
248222
});
249223

250224
it("loser + flag ON ⇒ the actual winning sibling's number", () => {
251-
expect(dupWinnerLinkedDuplicateWinnerNumber([{ number: 12, createdAt: "2026-06-29T10:00:00.000Z" }], 14, undefined, true, "2026-06-29T10:10:00.000Z")).toBe(12);
225+
expect(dupWinnerLinkedDuplicateWinnerNumber([{ number: 12, linkedIssueClaimedAt: "2026-06-29T10:00:00.000Z" }], 14, "2026-06-29T10:10:00.000Z", true, "2026-06-29T10:10:00.000Z")).toBe(12);
252226
});
253227

254228
it("loser + flag ON, but the election is too ambiguous ⇒ null (falls back to generic wording)", () => {
@@ -326,7 +300,7 @@ describe("listOtherOpenPullRequests ordering (#audit-3.9)", () => {
326300
});
327301
});
328302

329-
describe("upsertPullRequestFromGitHub createdAt threading (#dup-winner true-creation-time)", () => {
303+
describe("upsertPullRequestFromGitHub createdAt threading", () => {
330304
it("populates createdAt from GitHub's true pull_request.created_at on the IMMEDIATE upsert return, not just on a later DB round-trip", async () => {
331305
const env = createTestEnv();
332306
const record = await upsertPullRequestFromGitHub(env, "owner/repo", {

0 commit comments

Comments
 (0)