Capture a merged PR's changed file paths and its merge instant — the substrate a subsumption check needs, and the one `github_pull_request` does not carry
Repo: motir-core. Surfaced by motir run MOTIR-2903 under run.md guard #4 (the "X doesn't exist" claim gate). MOTIR-2903's recommended mechanism reads "a merged PR's diff touches every path its acceptance criteria name" and adds "the channel already computes description↔graph references; this is the same read against the PR set." It is not the same read: the PR set carries no diff. This card supplies the substrate; MOTIR-2903 then builds the advisory on it.
The absence, verified against shipped reality (rung 2, on origin/main @ 64fb9e6e)
prisma/schema.prisma—GithubPullRequestcarriesprovider · repoId · number · state · merged · headRef · title · workItemId · linkedManually · createdAt · updatedAt. No path column, and no merge INSTANT (mergedis a boolean;updatedAtmoves on any later delivery, so it cannot answer "did this land after that card was filed?"). A scan of all 82 models finds no table holding a pull request's files. The one model in the schema that stores repository paths isDesignAsset.sourcePath, and those paths are pushed by CI throughPOST /api/work-items/[id]/design-evidence— never read back from a pull request.lib/repositories/githubPullRequestRepository.ts— nine methods (findByRepoAndNumber,lockByRepoAndNumber,findByRepoAndHeadRef,countOtherOpenByWorkItem,listByWorkItemWithContext,findByIdWithInstallation,searchCandidates,setWorkItemLink,upsert). None returns a path.lib/github/— twenty read leaves, none of which callsGET /repos/{owner}/{repo}/pulls/{n}/files.historicalPullRequests.tsis the closest shape and the model to copy: an installation-authenticated, paginated, rate-limit-aware REST read that normalizes PR metadata only.
Why the plan-side substitute is ruled out, and by which measurement
The obvious cheaper answer is to compare the subsumed card's prose against the COVERING card's prose and never call GitHub at all. That answer is dead on the canonical fixture, and the measurement is short enough to repeat:
- The card that actually swept up MOTIR-2757 is MOTIR-2846 ("bind the production call sites the scanner reports in-scope"). Its description — title, body, acceptance criteria, context refs — contains zero occurrences of
workflowsService,getWorkflow,listStatusesByProject,getStatusByKey, orlib/services/workflowsService.ts. Its parent story MOTIR-2796 contains none either, and none of 2796's twenty-one children names a workflow read. - The merged DIFF does:
c99efdc7(PR #2059, 2026-08-15) toucheslib/services/workflowsService.ts, three days after MOTIR-2757 was filed and two days before a run claimed it. - So the fact that connects the two cards exists only in the diff. Any check reading solely the plan is blind to it by construction, which makes MOTIR-2903's criterion 4 ("MOTIR-2757's state on 2026-08-16 fires the check") unsatisfiable without this card.
What to build
GithubPullRequest.mergedAt DateTime?— stamped from the webhook payload'spull_request.merged_aton the delivery that reports the merge, left null otherwise. This is the ordering fact a subsumption check needs;updatedAtcannot serve, because a latercheck_suite-driven upsert moves it.GithubPullRequest.changedPaths String[]— the repo-relative paths the merge touched, capped (aMAX_CAPTURED_PR_PATHSconstant; a PR past the cap stores the first N and sets achangedPathsTruncated Boolean, so a consumer can never read a partial set as a complete one — the no-silent-caps rule). A column on the existing row rather than a child table, deliberately: the shippedgithub_pull_requestRLS policy then covers it unchanged, and no consumer wants a path without its PR.lib/github/pullRequestFiles.ts— a new read leaf onhistoricalPullRequests.ts's exact shape: installation-authenticated,per_page=100, bounded page walk, rate-limit backoff, a typed error class, and a page cap that REPORTS truncation rather than stopping quietly.- The capture point —
githubWebhookService'spull_requesthandler, on the transition into merged. Best-effort and post-commit, onenqueueCodeGraphIndex's precedent (lib/github/indexEnqueue.ts): the status sync is the load-bearing effect of that delivery and a GitHub blip must never fail or roll it back. A failed capture is swallowed, logged, and leaveschangedPathsempty. - A repository accessor —
findMergedTouchingPaths(workspaceId, paths, since, excludeWorkItemId, tx)(name it as you like), one query, returning the merged rows whosechangedPathsintersectpathsand whosemergedAtis aftersince. This is the single read MOTIR-2903 consumes, and it belongs here so that card adds no data access of its own.
Acceptance criteria
- A
pull_requestdelivery reporting a merge stores that PR's changed paths and its merge instant on thegithub_pull_requestrow; a delivery for an OPEN pull request stores neither. Both directions asserted against a real Postgres row, not against the fetcher's return value. - The GitHub fetch is stubbed at the transport in tests (the
undiciMockAgent convention the sibling GitHub suites use) and asserted to request/repos/{owner}/{repo}/pulls/{number}/fileswith the installation token — never the app JWT. - A fetch that throws, times out, or returns a non-2xx leaves the row's status-sync outcome byte-identical to a delivery where the fetch succeeded, with
changedPathsempty. Assert the transition the sync performed, not merely that no exception escaped — a swallowed error that also swallowed the sync is the failure mode. - A pull request whose file list exceeds
MAX_CAPTURED_PR_PATHSstores exactly that many paths and sets the truncation flag; a pull request under the cap stores every path and leaves the flag false. - The repository accessor returns a merged row whose
changedPathscontains a queried path and whosemergedAtis later than thesinceargument, and omits: a row merged beforesince, a row whose paths do not intersect, an OPEN row, and the row linked toexcludeWorkItemId. One case per omission, so a single over-broadWHEREcannot pass them all. prisma migrate diff --from-schema … --to-config-datasource --exit-codereports no drift after the migration — thebuildjob's own gate, run locally before the pull request opens.- Existing rows are unaffected:
changedPathsdefaults to empty andmergedAtto null, and no shipped consumer ofGithubPullRequestchanges behaviour. Assert the Development-surface DTO's shape is unchanged.
Out of scope
- The subsumption advisory itself — that is MOTIR-2903, which this card unblocks. Add no advisory family, no DTO variant, and no renderer here.
- Backfilling already-merged rows. MOTIR-2903's fixtures seed rows directly, so it needs no backfill to be built or tested. If a live retro-check is wanted later it is an operator script on
scripts/'s existing pattern, and its own card. - GitLab.
historicalPullRequests.tsrecords why a GitHub-specific read leaf sits beside theGitProviderseam rather than inside it; the same reasoning holds here.
Context refs
prisma/schema.prisma— theGithubPullRequestmodel (~L4262) and its@@unique([repoId, number]).lib/github/historicalPullRequests.ts— the read leaf to mirror: pagination,retryDelayMs,MAX_PULL_REQUEST_PAGES, the typed error, and the header explaining why it is a leaf and not a seam method.lib/github/indexEnqueue.ts— the best-effort, post-commit side-effect precedent, and itsPROD-443reasoning for why a transport call must not be able to fail a committed mutation.lib/services/githubWebhookService.ts— thepull_requesthandler andchangeRequestStatusSync, the effect that must stay unaffected.lib/repositories/githubPullRequestRepository.ts— where the accessor lands, and thecountOtherOpenByWorkItemshape to follow.- MOTIR-2903 — the consumer, and the incident that made the gap visible.