Context
Found during the 2026-07-06 incident audit (parent: #1667). This is the most direct fix for the user-facing complaint: contributors should be credited properly when someone links/closes the same issue after someone else already did the same or similar work.
Verified against src/signals/duplicate-winner.ts and src/db/repositories.ts (upsertPullRequestFromGitHub, resolveLinkedIssueClaimedAt) as they exist today.
isDuplicateClusterWinnerByClaim elects the earliest linkedIssueClaimedAt as the surviving PR in a duplicate cluster (all other open siblings linking the same issue get gate-blocked + closed). But linkedIssueClaimedAt is not GitHub's true PR-creation time — it is set to observedLinkedIssueClaimedAt = syncedAt (repositories.ts:321,343), i.e. the wall-clock instant gittensory itself first synced/processed that PR's linked-issue body. Two real PRs opened in the true order A (10:00) then B (10:05), where gittensory's webhook/backfill processing happens to observe B before A (queue reordering, backfill catch-up, webhook delivery delay, or exactly today's incident where a sweep stalled for hours and then processed a backlog in non-creation order), get linkedIssueClaimedAt values in the REVERSE of their true creation order. B — the later contributor — wins the cluster and A gets closed as the 'duplicate,' even though A opened their PR first and did the original work.
This is exactly the failure mode the user flagged: silent mis-credit when processing order and true creation order diverge, which today's incident (hours-long sweep stalls, backlog processed out of order) makes far more likely than the module's own docstring assumed.
GitHubPullRequestPayload.created_at (src/types.ts:301) already carries GitHub's authoritative PR creation timestamp and is already stored in each PR's payloadJson — it is simply never surfaced as its own comparable field or used in the winner election.
Requirements
- Add a durable
createdAt (or reuse/expose the existing GitHub created_at) field alongside linkedIssueClaimedAt on the PR record, sourced from pr.created_at in upsertPullRequestFromGitHub — this value is immutable per-PR (GitHub never changes a PR's creation time), so no resolveXxx-style existing-vs-observed merge logic is needed; it can be written once at first-seen and never touched again.
- Change
isDuplicateClusterWinnerByClaim (or add a new claim-comparison strategy alongside it) to elect the winner by true PR creation time when available, falling back to the existing linkedIssueClaimedAt-based logic only for legacy rows that predate this field.
- Preserve the existing fail-closed behavior for sparse/missing data: a PR or sibling with no resolvable creation time should not be able to win by default over ones that do.
- Update
openSiblingsForPr (the processors.ts:7275 call site) to fetch/pass creation time alongside linkedIssueClaimedAt.
- Add regression tests: (a) two PRs where gittensory observes/syncs the later-created PR first — assert the earlier-created PR still wins; (b) confirm existing claim-time-based tests for legacy rows without creation time still pass unchanged; (c) tie-break on identical creation-time falls back to PR number, matching current behavior.
Deliverables
src/db/schema.ts: new column (e.g. createdAt sourced from GitHub, distinct from linkedIssueClaimedAt) + migration.
src/db/repositories.ts: populate the new column at PR upsert time from pr.created_at.
src/signals/duplicate-winner.ts: creation-time-first election logic, with a documented, tested fallback to claim-time for legacy data.
src/queue/processors.ts: thread the new field through openSiblingsForPr/the duplicate-winner call site.
test/unit/duplicate-winner.test.ts (or equivalent): new cases per above.
Expected outcome
The surviving PR in a duplicate cluster is the one whose author actually opened their PR first on GitHub, regardless of the order gittensory's own webhook/sweep/backfill pipeline happened to observe or process them in — removing a silent mis-credit failure mode that today's incident made concretely more likely to occur.
Context
Found during the 2026-07-06 incident audit (parent: #1667). This is the most direct fix for the user-facing complaint: contributors should be credited properly when someone links/closes the same issue after someone else already did the same or similar work.
Verified against
src/signals/duplicate-winner.tsandsrc/db/repositories.ts(upsertPullRequestFromGitHub,resolveLinkedIssueClaimedAt) as they exist today.isDuplicateClusterWinnerByClaimelects the earliestlinkedIssueClaimedAtas the surviving PR in a duplicate cluster (all other open siblings linking the same issue get gate-blocked + closed). ButlinkedIssueClaimedAtis not GitHub's true PR-creation time — it is set toobservedLinkedIssueClaimedAt = syncedAt(repositories.ts:321,343), i.e. the wall-clock instant gittensory itself first synced/processed that PR's linked-issue body. Two real PRs opened in the true order A (10:00) then B (10:05), where gittensory's webhook/backfill processing happens to observe B before A (queue reordering, backfill catch-up, webhook delivery delay, or exactly today's incident where a sweep stalled for hours and then processed a backlog in non-creation order), getlinkedIssueClaimedAtvalues in the REVERSE of their true creation order. B — the later contributor — wins the cluster and A gets closed as the 'duplicate,' even though A opened their PR first and did the original work.This is exactly the failure mode the user flagged: silent mis-credit when processing order and true creation order diverge, which today's incident (hours-long sweep stalls, backlog processed out of order) makes far more likely than the module's own docstring assumed.
GitHubPullRequestPayload.created_at(src/types.ts:301) already carries GitHub's authoritative PR creation timestamp and is already stored in each PR'spayloadJson— it is simply never surfaced as its own comparable field or used in the winner election.Requirements
createdAt(or reuse/expose the existing GitHubcreated_at) field alongsidelinkedIssueClaimedAton the PR record, sourced frompr.created_atinupsertPullRequestFromGitHub— this value is immutable per-PR (GitHub never changes a PR's creation time), so noresolveXxx-style existing-vs-observed merge logic is needed; it can be written once at first-seen and never touched again.isDuplicateClusterWinnerByClaim(or add a new claim-comparison strategy alongside it) to elect the winner by true PR creation time when available, falling back to the existinglinkedIssueClaimedAt-based logic only for legacy rows that predate this field.openSiblingsForPr(theprocessors.ts:7275call site) to fetch/pass creation time alongsidelinkedIssueClaimedAt.Deliverables
src/db/schema.ts: new column (e.g.createdAtsourced from GitHub, distinct fromlinkedIssueClaimedAt) + migration.src/db/repositories.ts: populate the new column at PR upsert time frompr.created_at.src/signals/duplicate-winner.ts: creation-time-first election logic, with a documented, tested fallback to claim-time for legacy data.src/queue/processors.ts: thread the new field throughopenSiblingsForPr/the duplicate-winner call site.test/unit/duplicate-winner.test.ts(or equivalent): new cases per above.Expected outcome
The surviving PR in a duplicate cluster is the one whose author actually opened their PR first on GitHub, regardless of the order gittensory's own webhook/sweep/backfill pipeline happened to observe or process them in — removing a silent mis-credit failure mode that today's incident made concretely more likely to occur.