A contributor can change a PR base after CI is green, and ORB will merge into the new base using the diff, review, and CI computed against the old one.
Chain (all CONFIRMED in code)
- PR opened
head=S, base=A. Files synced; pull_request_detail_sync_state.head_sha = S, files_synced_at set. CI for S runs against A, green.
- Contributor retargets to
B → pull_request.edited with changes.base. Head SHA unchanged.
upsertPullRequestFromGitHub writes the new baseRef (src/db/repositories.ts ~458) — ORB knows, but nothing acts.
edited is in PR_PUBLIC_SURFACE_ACTIONS (processors.ts ~686-692), so the full readiness → gate → auto-maintain pipeline runs.
refreshPullRequestDetails is called without force (processors.ts ~6594), and filesUpToDate keys on head SHA only (backfill.ts ~2298) → GET /pulls/{n}/files is skipped. Stored paths and patches are still the diff vs A.
- The AI-review cache fingerprint hashes title + body + those stale patches — all unchanged → cache hit, pre-retarget review replayed (
baseSha was deliberately removed from the fingerprint, ai-review-cache-input.ts ~19-28).
- CI aggregate is keyed by head SHA, and GitHub does not re-run
pull_request workflows on a base change — so the checks from A remain the only checks.
classifyPullRequestFreshness (src/github/pr-freshness.ts ~47-93) checks state, head SHA, draft, labels — never base.ref → current. Live CI recheck → passed → merge into B.
Everything downstream is derived from the abandoned base: guardrail path matching, slop/manifest-policy evidence, screenshot gate, prMigrationFilenames (migration-collision detection!), and the published review. changes.base is not even modelled — src/types.ts ~363-369 types changes only for repository.name.from.
The divergence is permanent for that head: the sweep re-syncs only on head/label drift (processors.ts ~3653), so it persists until a new commit.
Related same-class hole: there is no push handler at all (grep finds only metric labels). A direct push to the base branch moves every open PR's merge-base with no sibling re-gate — maybeEnqueueSiblingRegateForMergedPr fires only on a PR merge. Base-branch deletion is likewise invisible.
Fix
- Cheapest, highest value: add
expectedBaseRef to PullRequestFreshness/classifyPullRequestFreshness and thread it from AgentActionContext, so a merge planned against base A is denied when the live base is B. The payload already carries base.ref.
- Add
base_ref to pull_request_detail_sync_state and include it in the filesUpToDate predicate.
- Detect
action === "edited" with changes.base (add it to the type) → refreshPullRequestDetails(..., {force: true}) + invalidatePrStateCache + invalidateCiStateCache.
- Treat a base change as review-invalidating (re-open the one-shot window).
Acceptance
- Retarget a green PR to a different base → ORB refuses to merge until files, CI, and review are recomputed against the new base.
A contributor can change a PR base after CI is green, and ORB will merge into the new base using the diff, review, and CI computed against the old one.
Chain (all CONFIRMED in code)
head=S,base=A. Files synced;pull_request_detail_sync_state.head_sha = S,files_synced_atset. CI forSruns againstA, green.B→pull_request.editedwithchanges.base. Head SHA unchanged.upsertPullRequestFromGitHubwrites the newbaseRef(src/db/repositories.ts~458) — ORB knows, but nothing acts.editedis inPR_PUBLIC_SURFACE_ACTIONS(processors.ts~686-692), so the full readiness → gate → auto-maintain pipeline runs.refreshPullRequestDetailsis called withoutforce(processors.ts~6594), andfilesUpToDatekeys on head SHA only (backfill.ts~2298) →GET /pulls/{n}/filesis skipped. Stored paths and patches are still the diff vsA.baseShawas deliberately removed from the fingerprint,ai-review-cache-input.ts~19-28).pull_requestworkflows on a base change — so the checks fromAremain the only checks.classifyPullRequestFreshness(src/github/pr-freshness.ts~47-93) checks state, head SHA, draft, labels — neverbase.ref→current. Live CI recheck →passed→ merge intoB.Everything downstream is derived from the abandoned base: guardrail path matching, slop/manifest-policy evidence, screenshot gate,
prMigrationFilenames(migration-collision detection!), and the published review.changes.baseis not even modelled —src/types.ts~363-369 typeschangesonly forrepository.name.from.The divergence is permanent for that head: the sweep re-syncs only on head/label drift (
processors.ts~3653), so it persists until a new commit.Related same-class hole: there is no
pushhandler at all (grep finds only metric labels). A direct push to the base branch moves every open PR's merge-base with no sibling re-gate —maybeEnqueueSiblingRegateForMergedPrfires only on a PR merge. Base-branch deletion is likewise invisible.Fix
expectedBaseReftoPullRequestFreshness/classifyPullRequestFreshnessand thread it fromAgentActionContext, so a merge planned against baseAis denied when the live base isB. The payload already carriesbase.ref.base_reftopull_request_detail_sync_stateand include it in thefilesUpToDatepredicate.action === "edited"withchanges.base(add it to the type) →refreshPullRequestDetails(..., {force: true})+invalidatePrStateCache+invalidateCiStateCache.Acceptance