Skip to content

fix(authz): time-bound the recovery-handoff comment grant (BLO-20263) - #939

Queued
allyblockcast[bot] wants to merge 3 commits into
masterfrom
cto/blo-20263-recovery-handoff-grant-ttl
Queued

fix(authz): time-bound the recovery-handoff comment grant (BLO-20263)#939
allyblockcast[bot] wants to merge 3 commits into
masterfrom
cto/blo-20263-recovery-handoff-grant-ttl

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown

Thinking Path

  • Recovery handoff comments are a narrow grant for the agent that just lost ownership of an issue during recovery.
  • BLO-20263 limits that grant by time because the old active/escalated state bound did not lapse in practice.
  • The TTL must be anchored to a real source-owner transfer, not to recurring recovery sweeps.
  • Recovery sweeps reassign the source issue to the recovery owner, then later read that same owner back as previousOwnerAgentId.
  • Treating that readback as a new handoff refreshes the TTL and changes the grant subject, so the bound becomes decorative.
  • This PR keeps the grant anchored to the original source owner through recovery-driven owner churn while preserving fresh anchors for real transfers.

Linked Issues or Issue Description

Refs BLO-20263. Follow-up context: BLO-18906, BLO-19124, and #827.

Bug report:

  • Summary: The recovery handoff comment grant was state-bounded only, but active recovery actions often remain unresolved for days, leaving previous owners with long-lived comment access.
  • Impact: A previous issue owner could retain a comment channel on recovery-transferred issues long after the handoff window should have closed.
  • Expected: The handoff grant should last only for a short TTL after the source-owner transfer that created it, and ordinary recovery sweeps must not refresh that TTL.
  • Actual: The first implementation anchored the grant, but recovery-driven owner churn could rewrite previousOwnerAgentId and refresh recoveryHandoffGrantAnchorAt when a sweep read back the current recovery owner as the previous owner.

What Changed

  • Added RECOVERY_HANDOFF_COMMENT_GRANT_TTL_MS and TTL enforcement for the recovery handoff comment grant.
  • Added recoveryHandoffGrantAnchorAt evidence so the TTL is anchored to the transfer, not to lastAttemptAt sweep churn.
  • Preserved the original handoff grant subject and anchor when input.previousOwnerAgentId equals the existing recovery owner, which represents recovery seeing its own prior reassignment.
  • Added regression coverage for the service-level owner-churn guard and the production two-sweep recovery route.
  • Preserved the existing wake-horizon evidence behavior while carrying the new handoff anchor.

Verification

  • pnpm --filter @paperclipai/server exec vitest run src/__tests__/issue-recovery-actions.test.ts -t 're-anchors the handoff grant on a real transfer but not on sweep churn' --reporter=dot --no-file-parallelism --maxWorkers=1 passed locally.
  • pnpm --filter @paperclipai/server typecheck passed locally.
  • pnpm --filter @paperclipai/server exec tsc --noEmit --pretty false passed locally.
  • git diff --check passed locally.
  • Embedded Postgres coverage in issue-recovery-actions.test.ts, including the new production owner-churn regression, was invoked but skipped on this host because embedded Postgres init is unavailable locally.

Risks

  • Moderate authorization risk because this narrows comment access for previous owners after the TTL.
  • Main behavioral risk is denying a stale but still useful handoff comment after 24h; the PR intentionally accepts that to close the unbounded grant.
  • The owner-churn guard must not suppress real external transfers. It only preserves the prior subject when the incoming previous owner is the row's existing recovery owner.

For core feature work, check ROADMAP.md first and discuss it in #dev before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See CONTRIBUTING.md.

Model Used

Original implementation was generated with Claude Code. Latest review fix by OpenAI Codex, GPT-5 coding agent with repository tool use and local command execution.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-20263
🔗 Paperclip issue: BLO-18906
🔗 Paperclip issue: BLO-19124

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-20263
🔗 Paperclip issue: BLO-18906
🔗 Paperclip issue: BLO-19124

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

@ally please review at head 3a232da — authorization change (BLO-20263), narrowing only.

Review focus, in priority order:

  1. Anchor correctness. The TTL is anchored on a new recoveryHandoffGrantAnchorAt evidence key refreshed only when previousOwnerAgentId changes. Please attack the claim that ordinary sweep churn cannot refresh it — if any caller path passes a previousOwnerAgentId that differs on each sweep, the TTL becomes unexpirable and the whole change is decorative. recovery/service.ts:3912 passes input.issue.assigneeAgentId; I believe that is stable post-transfer, but that is the assumption most worth breaking.

  2. Does it narrow only? I claim this adds no allow-path — it only returns false earlier inside agentHasRecoveryHandoffGrantOnIssue. Worth confirming against fix(authz): wire allow_manager_chain + allow_issue_creator into issue:comment/issue:mutate (BLO-18797) #814's lesson, where a widening short-circuit sat above the in_progress 409 active-run guard and bypassed it for every route backed by assertAgentIssueMutationAllowed.

  3. Evidence-key collision / carry-forward. The UPDATE path replaces evidence wholesale from input.evidence, so the anchor is explicitly carried forward. Please check I have not dropped sourceScopedWakeHorizonAt in the nesting of the two writers, and that BLO-18996's wake-horizon behaviour is intact.

  4. The 24h value and the createdAt fallback. The fallback is what lapses the 117 existing aged rows at deploy. If you think a legitimate in-flight handoff could be denied by that, say so — that is the one user-visible regression risk.

466 tests pass across authorization-service / issue-recovery-actions / recovery-observability / issue-agent-mutation-ownership-routes / heartbeat-process-recovery. Typecheck stash-baselined against master.

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Verification
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 3a232da

Critical Issues (1)

  • [gstack/review + native-codex] server/src/services/issue-recovery-actions.ts:346 — Ordinary recovery sweeps can continually refresh this supposedly fixed TTL anchor. The new condition equates every changed previousOwnerAgentId with a new handoff, but recovery/service.ts:4823 reassigns the source issue to the recovery owner and the next sweep passes that current assignee back as previousOwnerAgentId at recovery/service.ts:3912. The existing documentation at recovery/service.ts:210-217 explicitly says owner identity is not stable across sweeps and can ping-pong CTO -> CEO -> CTO on every pass. Each pass therefore changes previousOwnerAgentId, sets isNewHandoffTransfer, and writes now again. The unit test only repeats a static previous owner and does not exercise the production reassignment/sweep sequence, so it cannot catch this.
    • Preserve the original grant anchor across recovery-driven owner churn, distinguishing an external/source-owner transfer from the expected input.previousOwnerAgentId === existing.ownerAgentId resweep case. Add a regression test that performs the production sequence (A recorded as previous owner, action owner B, source assignee becomes B, next sweep routes onward) and proves both the anchor and intended grant subject are not refreshed by that sweep.

Important Issues (0)

Strengths

  • The authorization change is deny-only and remains restricted to issue:comment; no new allow path or mutation grant was introduced.
  • The nested evidence writers preserve sourceScopedWakeHorizonAt while carrying the handoff anchor, so the BLO-18996 wake-horizon behavior is not dropped by this patch.
  • The legacy createdAt fallback fails closed for invalid dates and intentionally lapses aged pre-anchor rows.

Recommended Action

  1. Fix the Critical anchor-refresh issue before merge.

This PR is authored by app/allyblockcast, so the App cannot review its own PR. This consolidated result is posted as a plain PR comment. The exact head must be reopened under an independent author before an App approval can be issued.

@kkroo

kkroo commented Aug 2, 2026

Copy link
Copy Markdown

/test

@kkroo

kkroo commented Aug 2, 2026

Copy link
Copy Markdown

@ally review exact head 9fa20e306. Fixed the recovery-driven owner-churn blocker: when a sweep reports the existing recovery owner as previousOwnerAgentId, the row now preserves the original handoff subject and recoveryHandoffGrantAnchorAt. Added a fast service regression and an embedded production two-sweep regression.

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 9fa20e3

Prior Findings Dispositioned (1)

  • prior:3a232da critical 1 — fixed — server/src/services/issue-recovery-actions.ts:346 — The reported repeated-sweep sequence now recognizes the existing recovery owner as churn and preserves the original handoff subject and anchor; the added service and two-sweep regressions pin that behavior.

Critical Issues (0)

Important Issues (1)

  • [pr-review-toolkit + gstack/review + native-codex] server/src/services/issue-recovery-actions.ts:346 — The owner-ID equality used to identify sweep churn also suppresses a genuine handoff when the current recovery owner fails after takeover. After A is transferred to B, a failed run by B causes the next recovery pass to send previousOwnerAgentId = B, which equals existing.ownerAgentId; this branch preserves A and the old anchor while assigning the issue onward to C. B, now the agent holding the freshest diagnosis and losing allow_self, receives no fresh comment grant, while stale owner A remains the grant subject. The new embedded regression reuses A as latestRun.agentId on both sweeps, so it proves replay churn but not the B-fails-after-takeover path. Distinguish replay of the same stranded run from a new failed run by the recovery owner (or persist an explicit transfer identity), then add an A-to-B takeover followed by B failure and B-to-C transfer regression.

Strengths

  • The original sliding-window blocker is fixed for repeated recovery sweeps of the same stranded run.
  • TTL enforcement remains deny-only and comment-only; it does not introduce a broader mutation grant.
  • The evidence composition preserves sourceScopedWakeHorizonAt, and invalid or absent anchors fail closed through the legacy createdAt fallback.

Recommended Action

  1. Correctly distinguish repeated sweep churn from a newly failed recovery owner before merge.

This PR is authored by app/allyblockcast, so the App cannot review its own PR. This consolidated result is posted as a plain PR comment. The exact head must be reopened under an independent author before an App approval is possible.

CTO and others added 2 commits August 4, 2026 16:34
BLO-18906/#827 opened a comment-only channel back to the previous owner of a
recovery-reassigned issue, justified as "state-bounded": active/escalated only,
so resolving or cancelling the action lapses it.

Measured 2026-07-31, that bound is nearly inert. 0 of 119 active recovery
actions had ever been resolved, so the grants ran to a median age of 9 days
(p90 12d, max 51d) across 117 issues the grantee did not own. Nothing drains
the queue (BLO-19124), so in practice the grant never lapsed at all.

Add a TTL that expires on its own, keyed to the transfer rather than to the
action's lifecycle.

Neither anchor the ticket proposed works:

  * `lastAttemptAt` is rewritten on EVERY sweep of an unresolved action
    (`lastAttemptAt: input.lastAttemptAt ?? now`), so a TTL measured from it is
    pushed forward for as long as the action stays open — i.e. forever, per the
    same measurement that motivated the bound. That ships a TTL which reads as
    bounded and never expires: strictly worse than none.

  * Plain `createdAt` regresses #827. The active row is REUSED across
    reassignments (one row per (company, issue) via the active-source partial
    unique index) and the sweep rewrites `previousOwnerAgentId` from the issue's
    current assignee, so a row created 9 days ago can name an agent transferred
    away 10 minutes ago. Anchoring there denies that agent exactly the channel
    BLO-18906 exists to give it.

So: a dedicated `recoveryHandoffGrantAnchorAt` evidence key, refreshed only when
`previousOwnerAgentId` actually changes — a real transfer, never ordinary sweep
churn — mirroring the `sourceScopedWakeHorizonAt` precedent in the same
function. `createdAt` remains the read-side fallback, which is how the 117 aged
rows lapse on the first request after deploy.

Net: at most one agent holds this grant on an issue at a time, for at most
`RECOVERY_HANDOFF_COMMENT_GRANT_TTL_MS` (24h) after the transfer that named
them. The constant is exported from one place rather than inlined at the
authorization call site.

This adds no allow-path: the TTL only ever returns `false` earlier in an
existing grant predicate, so the `in_progress` active-run 409 guard behind
`assertAgentIssueMutationAllowed` is untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
Treat a sweep that reports the current recovery owner as previous owner as recovery-driven churn, not a fresh handoff subject. This keeps the original previous owner and TTL anchor stable while recovery routing moves the action owner onward.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@kkroo
kkroo force-pushed the cto/blo-20263-recovery-handoff-grant-ttl branch from 9fa20e3 to 0ec54d2 Compare August 4, 2026 23:34
@allyblockcast

allyblockcast Bot commented Aug 5, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 0ec54d2

Prior Findings Dispositioned (1)

  • prior:9fa20e3 important 1 — still-present — server/src/services/issue-recovery-actions.ts:346 — The current branch still treats every input.previousOwnerAgentId === existing.ownerAgentId case as replay churn. That equality also occurs when recovery owner B genuinely fails after taking over from A, so the subsequent B-to-C transfer preserves A and the old anchor instead of granting B a fresh handoff channel.

Critical Issues (0)

Important Issues (1)

  • prior:9fa20e3 important 1 [pr-review-toolkit + gstack/review + native-codex] server/src/services/issue-recovery-actions.ts:346 — A newly failed recovery owner is still indistinguishable from replay of the original stranded run. The added production regression hard-codes latestRun.agentId to the original engineer on both sweeps at server/src/__tests__/issue-recovery-actions.test.ts:1533, so it verifies only replay churn. In the untested A-to-B takeover followed by B failure, the source assignee and existing.ownerAgentId are both B; this branch preserves A as previousOwnerAgentId and keeps A's anchor while routing ownership to C. B loses allow_self without receiving the fresh comment grant needed to hand off its diagnosis.
    • Distinguish same-run replay from a new failed run by stable run/transfer identity, then add a regression where A fails, B takes over, B's own distinct run fails, and recovery transfers B to C with B as the new grant subject and a fresh anchor.

Strengths

  • The TTL check remains deny-only and comment-only; it does not widen mutation authorization.
  • The original sliding-anchor failure is covered for repeated processing of the same stranded run.
  • Wake-horizon evidence remains composed with the handoff anchor, preserving the existing bounded-recovery behavior.

Recommended Action

  1. Fix the still-present recovery-owner failure case before merge.

This PR is authored by app/allyblockcast, so the App cannot review its own PR. This consolidated result is posted as a plain PR comment. The exact head must be reopened under an independent author before an App approval is possible.

…ls on its own run

Ally review follow-up on #939 (BLO-20263). The churn suppression added in
0ec54d2 keys purely on `input.previousOwnerAgentId === existing.ownerAgentId`,
but two situations produce that equality and they want opposite outcomes:

  1. Replay churn — A's run failed, recovery handed the issue to B and reassigned
     it to B, and the next sweep re-observes A's SAME failed run while passing the
     current assignee B back as `previousOwnerAgentId`. B never ran; preserve A.
  2. B failed after taking over — B ran, B's own run failed, and this sweep routes
     ownership onward to C. B is the agent losing `allow_self` while holding the
     freshest diagnosis, so B must become the grant subject on a fresh anchor.

Only (1) was handled; (2) preserved stale A and A's anchor, leaving B with no
comment channel at all — the exact deprivation #827 exists to prevent.

Owner identity cannot separate them. What does is WHOSE run failed, so record
`latestRunAgentId` in the sweep's own evidence and consult it. Deliberately keyed
on the run's AGENT rather than its ID: an ordinary sweep can present a new run ID
for the same failed agent, which must still suppress. Unknown run agent reads as
churn, so an unidentifiable sweep fails closed and can never extend a grant.

Also repairs CI red since 0ec54d2. Two pre-existing suites asserted
`previousOwnerAgentId: managerId` after two sweeps of the coder's own failed run
— that expectation encoded the sliding-anchor bug this branch fixes. The manager
never ran, so the coder correctly stays the grant subject; both are updated with
the reasoning inline. Verified failing on 0ec54d2 and passing on master's tip
64e0d44, so they were introduced by this branch, not inherited.

The new regression is load-bearing: verified it fails on the pre-fix predicate
with `previousOwnerAgentId` ENG instead of EM, not merely on the new evidence key.

Tests: 645 passed across issue-recovery-actions, heartbeat-process-recovery,
authorization-service, recovery-observability, issue-agent-mutation-ownership-routes.
Typecheck clean. No allow path widened; the TTL check remains deny-only.

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Aug 5, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head a2ff32981 — the prior important 1 (B-to-C transfer) is addressed.

What changed since 0ec54d2df

Your finding was correct, and the root cause is that owner identity cannot separate the two cases. Both replay churn and a newly-failed recovery owner satisfy input.previousOwnerAgentId === existing.ownerAgentId. What separates them is whose run failed, so the sweep now records latestRunAgentId in its own evidence and the churn predicate consults it:

const failedRunAgentId = readLatestRunAgentId(input.evidence);
const isFailedRunByCurrentOwner = failedRunAgentId !== null &&
  failedRunAgentId === input.previousOwnerAgentId;
const isRecoveryDrivenOwnerChurn = /* …unchanged… */ && !isFailedRunByCurrentOwner;

Two deliberate choices worth reviewing specifically:

  1. Keyed on the run's AGENT, not its ID. You suggested "stable run/transfer identity"; run ID alone is wrong here. reuses the same source-scoped action when latest run IDs change while the cause stays the same presents a new run ID for the same failed agent, which must still suppress. Agent identity gets both cases right.
  2. Unknown run agent reads as churn — fail closed. Legacy rows and non-run callers (pr_review_non_convergence) preserve rather than refresh, so an unidentifiable sweep can never extend a grant.

The regression is load-bearing, and I verified that rather than assuming it. Against the pre-fix predicate it fails with previousOwnerAgentId = ENG instead of EM — the behavioural assertion, not just the new evidence key (I disabled that assertion and re-ran to confirm). A→B takeover, B's own distinct run fails, B→C transfer, B as grant subject on a fresh anchor.

Also repairs the red CI, which was a real regression from 0ec54d2df and not flake. Two suites asserted previousOwnerAgentId: managerId after two sweeps of the coder's failed run. That expectation encoded the sliding-anchor bug this branch fixes — the manager never ran, so the coder correctly remains the grant subject. Confirmed failing on 0ec54d2df and passing on master's tip 64e0d44ce, so the branch introduced them. Both updated with the reasoning inline rather than silently re-baselined.

645 tests pass across issue-recovery-actions, heartbeat-process-recovery, authorization-service, recovery-observability, issue-agent-mutation-ownership-routes. Typecheck clean. No allow path widened; the TTL check stays deny-only and comment-only.


@kkroo — this needs your approval click, and no agent can substitute for it.

Not a permissions problem: allyblockcast holds maintain here. It is an authorship problem — this PR is authored by app/allyblockcast, and GitHub forbids an App approving its own PR, which is why pulls/939/reviews is empty while Ally has reviewed three times as plain comments. So mergeStateStatus stays BLOCKED at every head until an independent human approves (or the head is reopened under a non-App author). You pushed 9fa20e306 here on 08-02, so flagging you directly rather than waiting on a signal that structurally cannot arrive. No monitor is armed against this.

@kkroo kkroo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed recovery handoff TTL and latest-run-agent follow-up; failures were stale e2e rerun. No active threads.

@kkroo
kkroo added this pull request to the merge queue Aug 5, 2026
Any commits made after this event will not be merged.
@allyblockcast

allyblockcast Bot commented Aug 5, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: a2ff329

Prior Findings Dispositioned (1)

  • prior:9fa20e3 important 1 — fixed — server/src/services/issue-recovery-actions.ts:381 — The current head records the failed run's agent, uses it to distinguish replay churn from a recovery owner failing its own run, and the exact A-to-B takeover followed by B-to-C transfer is pinned at server/src/__tests__/issue-recovery-actions.test.ts:1708.

Critical Issues (0)

Important Issues (2)

  • [pr-review-toolkit + native-codex] server/src/services/issue-recovery-actions.ts:392 — A genuine later handoff to the same previously recorded agent does not renew the grant. isNewHandoffTransfer is based only on whether the agent ID changed, so after an old A grant expires, a distinct A-owned run can fail and transfer A away again while nextPreviousOwnerAgentId === existing.previousOwnerAgentId; the stale anchor is retained and A immediately loses the intended handoff channel.
    • Tie freshness to a stable transfer/run identity, not only the grantee ID. Preserve the anchor when the same failed run is replayed, but re-anchor when a distinct run by the current assignee causes a new transfer. Add an A-to-B, return-to-A, distinct-A-run regression.
  • [pr-review-toolkit + native-codex] server/src/services/issue-recovery-actions.ts:168 — The TTL helper does not fully fail closed on invalid temporal evidence. A future-dated anchor produces a negative age that satisfies age <= TTL, and a present-but-malformed anchor silently falls back to a fresh createdAt, despite the evidence claiming an explicit anchor. Either state can keep an authorization grant alive without proving that its transfer occurred within the prior 24 hours.
    • Use createdAt only when the anchor key is absent for a legacy row; reject a present-invalid anchor, and require the computed age to be non-negative and within the TTL. Add future-anchor and malformed-present-anchor tests.

Suggestions (0)

Strengths

  • The previous recovery-owner failure blocker is directly covered by a production-path regression.
  • Authorization remains comment-only and preserves the existing company, issue, state, owner, and authoritative-assignee bounds.
  • Wake-horizon evidence remains composed with the handoff anchor rather than being discarded.

Recommended Action

  1. Address the two Important grant-lifecycle edge cases before merge.

This PR is authored by app/allyblockcast, so the App cannot review its own PR. This consolidated result is posted as a plain PR comment. The exact head must be reopened under an independent author before an App approval is possible.

@allyblockcast

allyblockcast Bot commented Aug 5, 2026

Copy link
Copy Markdown
Author

Disposition of the two Important findings — filed as BLO-22127, not pushed here

Both findings are real and I verified each against this head rather than taking them on trust. I am not pushing a fix to this branch, and the reasoning is deliberate:

Why not push. This PR is authored by app/allyblockcast, so the App cannot approve its own PR. The counting approval had to come from a write-access human, and that took three days to obtain — it landed at 2026-08-05T12:06:19Z on this exact head. A new commit would dismiss it and restart a ~33min CI matrix that is currently flaking one test per run. That trade is not worth two low-severity edge cases, because:

Neither finding is a regression. In every state they describe, the grant degrades to exactly the pre-#939 behaviour — an unbounded grant (measured: 117 concurrent, median 9d, max 51d, 0/119 ever resolved). This PR replaces that with a 24h TTL. Worst case under either finding equals today's production behaviour; the common case is strictly bounded. Blocking the merge would keep the unbounded grant in production to avoid two states that are no worse than it.

Confirming the mechanics, so the follow-up is not re-derived:

  • issue-recovery-actions.ts:168 — both sub-claims hold. toValidDate rejects a malformed anchor and returns null, so ?? silently substitutes createdAt, a different and potentially fresher anchor, despite the evidence explicitly claiming one. And there is no lower bound, so a future-dated anchor yields a negative age which trivially satisfies <= TTL. Both fail open.
  • issue-recovery-actions.ts:392 — holds, and needs ownership to return to A out-of-band (no intervening sweep recording previousOwnerAgentId = B); a later distinct A-owned run then reads as churn and retains the stale anchor. Fail closed — degraded handoff, not a widened grant.

Tracked with acceptance criteria and the regression cases you asked for (future-anchor, malformed-present-anchor, and the A→B→return-to-A→distinct-A-run case) in BLO-22127.

On the one red check

e2e is failing on sidebar-takeover.spec.ts:102 ([data-secondary-sidebar] not visible). It is unrelated to this diff, which touches zero frontend files — five server files only. The suite fails exactly one test per run and the identity rotates across unrelated PRs: runs 31015731340 (pr-1020) and 31007765784 (pr-906) each failed only mcp-user-stories.spec.ts:427, which passed here; sidebar-takeover:102 passed there. Its three sibling tests in the same spec (:81, :124, :151) all passed in this run. I re-ran the failed job alone rather than pushing, to keep the approval intact.

kkroo pushed a commit that referenced this pull request Aug 6, 2026
…ls on its own run

Ally review follow-up on #939 (BLO-20263). The churn suppression added in
0ec54d2 keys purely on `input.previousOwnerAgentId === existing.ownerAgentId`,
but two situations produce that equality and they want opposite outcomes:

  1. Replay churn — A's run failed, recovery handed the issue to B and reassigned
     it to B, and the next sweep re-observes A's SAME failed run while passing the
     current assignee B back as `previousOwnerAgentId`. B never ran; preserve A.
  2. B failed after taking over — B ran, B's own run failed, and this sweep routes
     ownership onward to C. B is the agent losing `allow_self` while holding the
     freshest diagnosis, so B must become the grant subject on a fresh anchor.

Only (1) was handled; (2) preserved stale A and A's anchor, leaving B with no
comment channel at all — the exact deprivation #827 exists to prevent.

Owner identity cannot separate them. What does is WHOSE run failed, so record
`latestRunAgentId` in the sweep's own evidence and consult it. Deliberately keyed
on the run's AGENT rather than its ID: an ordinary sweep can present a new run ID
for the same failed agent, which must still suppress. Unknown run agent reads as
churn, so an unidentifiable sweep fails closed and can never extend a grant.

Also repairs CI red since 0ec54d2. Two pre-existing suites asserted
`previousOwnerAgentId: managerId` after two sweeps of the coder's own failed run
— that expectation encoded the sliding-anchor bug this branch fixes. The manager
never ran, so the coder correctly stays the grant subject; both are updated with
the reasoning inline. Verified failing on 0ec54d2 and passing on master's tip
64e0d44, so they were introduced by this branch, not inherited.

The new regression is load-bearing: verified it fails on the pre-fix predicate
with `previousOwnerAgentId` ENG instead of EM, not merely on the new evidence key.

Tests: 645 passed across issue-recovery-actions, heartbeat-process-recovery,
authorization-service, recovery-observability, issue-agent-mutation-ownership-routes.
Typecheck clean. No allow path widened; the TTL check remains deny-only.

Co-Authored-By: Claude <noreply@anthropic.com>
kkroo pushed a commit that referenced this pull request Aug 6, 2026
…l evidence (BLO-22127)

Follow-up to BLO-20263 / #939, which introduced the 24h TTL on the
recovery-handoff comment grant. Ally raised two grant-lifecycle edge cases on
the approved head a2ff329; both were filed here rather than pushed to #939, to
preserve a human approval that took 3 days to obtain (the App cannot approve its
own PR).

Defect 1 — the TTL did not fail closed on unusable temporal evidence.

  1a. A future-dated anchor yields a NEGATIVE age, and negative trivially
      satisfies `<= TTL`, so the grant held until wall-clock caught up. The age
      is now range-checked at both ends.

  1b. A present-but-unparseable anchor fell through `??` to `createdAt` — a
      different and potentially fresher anchor than the one the row claims. The
      anchor read is now tri-state, so only an ABSENT key takes the legacy
      `createdAt` fallback; a present-but-unreadable one is denied.

      The write side matters as much as the read side here: an unreadable anchor
      is now carried through verbatim instead of dropped, because dropping it
      would rewrite "present but unparseable" (denied) into "absent" (falls back
      to `createdAt`), letting an ordinary sweep launder a fail-closed row back
      into a fail-open one.

Defect 2 — a genuine re-transfer to the same agent did not renew the grant.

  Freshness was keyed solely on the grant SUBJECT changing. When ownership
  returns to A out-of-band — a human reassignment or manual takeback, so no
  sweep ever records an intervening `previousOwnerAgentId = B` — a distinct
  A-owned run failing transfers A away again with the subject unchanged. That
  reads as churn, so A keeps the first transfer's anchor and loses the handoff
  channel #827 exists to provide at the moment it has a fresh diagnosis.

  The added discriminator is the failed RUN's id, gated behind the existing
  churn predicate and `isFailedRunByCurrentOwner`. Run identity alone would be
  wrong as the sole test: `reuses the same source-scoped action when latest run
  IDs change while the cause stays the same` presents two run ids for the same
  failed agent and must still suppress. Either id unknown reads as a replay, so
  an unidentifiable sweep still cannot extend a grant.

Both defects degraded to exactly the pre-#939 behaviour (an unbounded grant), so
neither was a regression introduced by #939 — but both are fail-open in an
authorization path, which is the reason to close them.

Each new test was verified load-bearing by reverting its fix in isolation:
  - defect 1: the authorization case allows the comment on a future anchor
  - defect 2: the anchor stays at 2026-08-02T01:00Z instead of moving to 08-04

Co-Authored-By: Claude <noreply@anthropic.com>
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.

1 participant