Skip to content

fix(ledger): record WHY a pull request was held, not the gate's conclusion - #10072

Merged
JSONbored merged 1 commit into
mainfrom
fix/persist-hold-cause-9991
Jul 31, 2026
Merged

fix(ledger): record WHY a pull request was held, not the gate's conclusion#10072
JSONbored merged 1 commit into
mainfrom
fix/persist-hold-cause-9991

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

The ledger filed 518 holds across 146 pull requests under reason_code = "success". That is not a reason — deriveDecisionReasonCode ends with the gate conclusion when there is no blocker and no policy close, so a held PR whose gate passed records the conclusion as its cause. A reader of the public proof page's sample records sees the same thing.

It also blocks #9729, which must loosen only what clears a backtest per path. success is not a path — it is at least the seven inputs in MERGE_HOLD_INPUTS, and a clearance run against a bucket conflating them would mean nothing.

reason_code is deliberately untouched

This is the constraint that shaped everything else. replayDecision recomputes reason_code and reports a divergence on mismatch (decision-replay.ts:168). Changing the derivation would make all 518 existing records replay as unreproducible — a false "this decision cannot be re-derived" about records that are perfectly fine — and would change a value the proof page publishes.

So the cause lands in its own column, outside record_json, leaving every digest and the replay contract untouched. Historical rows stay NULL: we genuinely do not know which input held those 518, and backfilling a guess would be worse than the gap.

No signature change

The obvious route — widening planAgentMaintenanceActions to return the cause — churns 5 production call sites and ~30 test ones, and does not fit the data anyway: a hold's defining shape is that nothing was planned, so there is no PlannedAgentAction to hang the reason on.

Instead the disposition-input construction is extracted into one builder that both the planner and the new resolveHoldCause call. The cause recorded in the ledger is therefore the same expression that suppressed the merge, not a second opinion about it. Every value that builder needs (reviewGood, guardrailHit, guardrailEscalationCleared, closeActing) is a function of the plan input — I checked each, including that conclusion is exactly input.conclusion — which is what makes recomputing it outside the planner sound rather than approximate.

The extraction is behaviour-neutral: agent-actions' 347 existing tests pass unchanged.

heldBy

derivePrDisposition now returns which inputs held, in the table's declaration order so the recorded cause is deterministic for identical inputs. It was already a .some(...) over exactly that set, throwing the answer away; heldForManualReview is now derived from heldBy, so the two cannot disagree — pinned by a test that walks every key in MERGE_HOLD_INPUT_KEYS rather than sampling one.

Two exclusions are deliberate and tested: a released hold (#9808's guardrail cleared by a clean escalated review) is not a cause, and the unstable mergeable state stays out because it is GitHub's computation rather than one of our declared inputs — it is reported by heldForUnstableMergeState.

Mutation testing

Mutation Result
Ignore released holds 1 failed
Force heldBy empty 3 failed

Verification

  • Full test/unit + test/integration: 25,972 passed, 0 failed
  • typecheck, db:migrations:check, db:migrations:immutable:check, db:schema-drift:check, dead-exports:check, dead-source-files:check, import-specifiers:check, coverage-boltons:check, checkers-wired:check, ui:lint green
  • 9 new tests, including one asserting the cause does not appear in record_json and that reason_code still reads success

Unblocks #9729. Once this ships, the 518-hold bucket becomes splittable by cause for new verdicts, and per-path candidates can be chosen.

Closes #9991

…usion

The ledger filed 518 holds across 146 pull requests under
`reason_code = "success"`. That is not a reason: deriveDecisionReasonCode ends
with the gate conclusion when there is no blocker and no policy close, so a held
PR whose gate passed records the conclusion as its cause. A reader of the public
proof page's sample records sees the same thing.

It also blocks #9729, which must loosen only what clears a backtest, PER PATH.
`success` is not a path -- it is at least the seven inputs in MERGE_HOLD_INPUTS,
and a clearance run against a bucket conflating them would mean nothing.

reason_code is deliberately UNTOUCHED. replayDecision recomputes it and reports
a divergence on mismatch, so changing the derivation would make all 518 existing
records replay as unreproducible -- a false "this decision cannot be re-derived"
about records that are perfectly fine -- and would change a value the proof page
publishes. The cause goes in its own column, outside record_json, so digests and
the replay contract are untouched. Historical rows stay NULL: we genuinely do not
know which input held those 518, which is the whole finding.

NO SIGNATURE CHANGE. The obvious route -- widening planAgentMaintenanceActions
to return the cause -- churns five production call sites and roughly thirty test
ones, and does not fit the data: a hold's defining shape is that NOTHING was
planned, so there is no action to carry the reason. Instead the disposition-input
construction is extracted into one builder that the planner and the new
`resolveHoldCause` both call, so the cause recorded in the ledger is the same
expression that suppressed the merge rather than a second opinion about it. Every
value that builder needs is a function of the plan input, which is what makes
recomputing it outside the planner sound rather than approximate.

`derivePrDisposition` now returns `heldBy` -- which inputs held, in the table's
declaration order so the recorded cause is deterministic. It was already a
`.some(...)` over exactly that set, throwing the answer away;
`heldForManualReview` is now derived from it, so the two cannot disagree.
Released holds (#9808's cleared guardrail) are excluded, and the unstable
mergeable state stays out because it is GitHub's computation rather than one of
our declared inputs.

Mutation-tested: ignoring released holds fails 1, forcing heldBy empty fails 3.
The extraction is behaviour-neutral -- agent-actions' 347 tests pass unchanged.

Closes #9991
@loopover-orb

loopover-orb Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Important

🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨

⏳ LoopOver is waiting…

LoopOver has seen this pull request and is waiting on CI checks to finish before reviewing it. This comment will update once the review runs.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed · 🟨 Waiting

@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@JSONbored JSONbored self-assigned this Jul 31, 2026
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.47368% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.88%. Comparing base (a7673e2) to head (cf93ef6).
⚠️ Report is 7 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/settings/agent-actions.ts 81.81% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #10072      +/-   ##
==========================================
- Coverage   91.88%   91.88%   -0.01%     
==========================================
  Files         930      930              
  Lines      113827   113837      +10     
  Branches    27466    27471       +5     
==========================================
+ Hits       104588   104597       +9     
  Misses       7940     7940              
- Partials     1299     1300       +1     
Flag Coverage Δ
backend 95.67% <89.47%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/queue/processors.ts 94.60% <100.00%> (+<0.01%) ⬆️
src/review/decision-record.ts 100.00% <100.00%> (ø)
src/settings/pr-disposition.ts 100.00% <100.00%> (ø)
src/settings/agent-actions.ts 98.01% <81.81%> (-0.30%) ⬇️

@JSONbored
JSONbored merged commit 45c8723 into main Jul 31, 2026
9 of 10 checks passed
@JSONbored
JSONbored deleted the fix/persist-hold-cause-9991 branch July 31, 2026 06:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

orb(ledger): a hold records reason_code 'success', so the manual-review surface cannot be split by cause

1 participant