Skip to content

fix(proxy): never inject a cross-account previous_response_id anchor (takeover of #1274) - #1638

Open
Komzpa wants to merge 6 commits into
mainfrom
pr1274-rebased-20260806
Open

fix(proxy): never inject a cross-account previous_response_id anchor (takeover of #1274)#1638
Komzpa wants to merge 6 commits into
mainfrom
pr1274-rebased-20260806

Conversation

@Komzpa

@Komzpa Komzpa commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Takeover of #1274 by @aididhaiqal — the original three commits are carried with authorship intact; two additions on top. The original diagnosis was exactly right and turned out to be the root cause of the bridge-wedge family we have been mitigating from the other end all day (#1580 detection, #1630 quarantine): an injected previous_response_id belonging to a different account is accepted by upstream, which then never emits response.created, and the client waits forever.

What this adds over #1274 as-is:

  • Rebased onto current main (one mechanical conflict in mixin.py; main's newer try/abort structure kept verbatim).
  • A second unguarded post-binding injection site closed: the owner-forward recovery rebind (allow_previous_response_recovery_rebind / allow_bootstrap_owner_rebind) could legally land on a non-owner account and then injected the durable anchor — same wedge, different door. Now gated the same way.
  • Real-bridge integration coverage answering the review's P1: the new test drives real session creation, framing, response_create gate and created-wait against a fake upstream that models the account scope of previous_response_id (accepts the alien anchor, answers with silence). Removing the guard reproduces the production wedge (5s POST timeout, gate held); keeping it settles via full-history resend, and a follow-up turn proves same-account continuity still anchors.
  • Both declines log cross_account_anchor_declined with site + owning account — a concrete key for the Keep HTTP bridge missing-created watchdog armed after prelude #1580/fix(proxy): quarantine silent HTTP bridge sessions (takeover of #1405) #1630 detection path.
  • The pre-binding fresh_reattach_anchor_injected site is documented as safe-by-construction (injection there makes the durable owner a required account) rather than double-guarded.

Verification: bridge suites 604 passed; full unit suite 5681 passed / 3 skipped; ruff + format clean; openspec validate fix-cross-account-previous-response-anchor --strict green.

Happy to fold this back into #1274's branch instead if @aididhaiqal prefers to land it under the original PR.

Komzpa pushed a commit that referenced this pull request Aug 6, 2026
Every new bug class extends the leg that should have caught it, so the three
classes proven on the live stack today each get model state, an invariant or
liveness property, and a negative control that reproduces the failure.

Anchor account ownership (PR #1638). A continuity anchor now carries the
account that owns it. Upstream accepts a request carrying a
previous_response_id owned by a different account and then never sends
response.created, so UpstreamRespondsTo gates StartStream, CompleteTurn and
ClaimCompletedDelivery: a foreign-anchored turn can only leave the
pre-response phase through a timer or a cancel. Inv10AnchorAccountOwnership
forbids dispatching with a foreign-owned anchor;
weak-cross-account-anchor.cfg reproduces the wedge.

Pre-response eventless phase (PR #1633). The "active" phase - dispatched
upstream, response.created not seen yet - now has its own bound instead of
sharing the request/stream-idle budget, and ExpireDeadline picks both its
bound and its budget label per phase. Inv11PreResponseBudget requires the
pre-response bound to be the minimum of the named gate-retire and stream-idle
budgets, at or above the keepalive cadence floor, strictly below the
post-start budget, and forbids reporting a kill under the post-start
stream-idle budget while the response had not started.
weak-conflated-timers.cfg collapses the two timer names and TLC produces a
healthy pre-start wait killed under the wrong budget.

Bounded client retry backoff (PR #1634). A turn killed in the pre-response
phase tears the client session; ClientRetryAttempt repairs it and is fair,
but only while retryBackoff stays inside MaxRetryBackoff. The new liveness
property TearEventuallyRecovers states that a recoverable tear is eventually
recovered; weak-unbounded-backoff.cfg lets the backoff grow past every
deadline in the model, and TLC produces a behaviour where the client stays
torn forever - the 29-hour client sleep observed today.

check.sh gains the three mappings and a PROPERTY:<Name> expectation form: a
liveness control must violate exactly the temporal property its config
declares and must not violate any invariant.

bash spec/check.sh exits 0. Full model: 16696096 states generated, 3606740
distinct, depth 23, zero violations, deadlock checking enabled. All 13
weakenings produce their mapped counterexample.
Komzpa added a commit that referenced this pull request Aug 6, 2026
Every new bug class extends the leg that should have caught it, so the three
classes proven on the live stack today each get model state, an invariant or
liveness property, and a negative control that reproduces the failure.

Anchor account ownership (PR #1638). A continuity anchor now carries the
account that owns it. Upstream accepts a request carrying a
previous_response_id owned by a different account and then never sends
response.created, so UpstreamRespondsTo gates StartStream, CompleteTurn and
ClaimCompletedDelivery: a foreign-anchored turn can only leave the
pre-response phase through a timer or a cancel. Inv10AnchorAccountOwnership
forbids dispatching with a foreign-owned anchor;
weak-cross-account-anchor.cfg reproduces the wedge.

Pre-response eventless phase (PR #1633). The "active" phase - dispatched
upstream, response.created not seen yet - now has its own bound instead of
sharing the request/stream-idle budget, and ExpireDeadline picks both its
bound and its budget label per phase. Inv11PreResponseBudget requires the
pre-response bound to be the minimum of the named gate-retire and stream-idle
budgets, at or above the keepalive cadence floor, strictly below the
post-start budget, and forbids reporting a kill under the post-start
stream-idle budget while the response had not started.
weak-conflated-timers.cfg collapses the two timer names and TLC produces a
healthy pre-start wait killed under the wrong budget.

Bounded client retry backoff (PR #1634). A turn killed in the pre-response
phase tears the client session; ClientRetryAttempt repairs it and is fair,
but only while retryBackoff stays inside MaxRetryBackoff. The new liveness
property TearEventuallyRecovers states that a recoverable tear is eventually
recovered; weak-unbounded-backoff.cfg lets the backoff grow past every
deadline in the model, and TLC produces a behaviour where the client stays
torn forever - the 29-hour client sleep observed today.

check.sh gains the three mappings and a PROPERTY:<Name> expectation form: a
liveness control must violate exactly the temporal property its config
declares and must not violate any invariant.

bash spec/check.sh exits 0. Full model: 16696096 states generated, 3606740
distinct, depth 23, zero violations, deadlock checking enabled. All 13
weakenings produce their mapped counterexample.
aididhaiqal and others added 5 commits August 7, 2026 02:41
The HTTP-bridge compact-anchor optimization injects
`previous_response_id = session.last_completed_response_id` and trims the stored
history prefix. A `previous_response_id` is account-scoped upstream: only the
account that created a response can resume it. The injection had no ownership
check, so when a Codex session failed over to a different account (durable owner
unavailable, or a durable record restored onto a session bound to another
account) the anchor pointed at a response the serving account never created.
Upstream accepted the `response.create` but never emitted `response.created`, and
with the history trimmed away there was no fallback — the per-bridge
`response_create_gate` (`Semaphore(1)`) wedged. The holder's client saw "idle
timeout waiting for SSE"; queued requests saw "temporarily overloaded".

Track the owning account of `last_completed_response_id`
(`last_completed_response_account_id`), set in lockstep at both setters — the real
`response.completed` path records the session's current account, the durable
restore records the durable owner account — and gate the session-level anchor
injection on owner == serving account. On mismatch, fall through to a
full-history resend (correct output, more tokens), never a cross-account anchor.

Regression tests: anchor injected when same-account; anchor skipped + full
history resent after cross-account failover. OpenSpec:
fix-cross-account-previous-response-anchor (sticky-session-operations).
The session-level compact anchor was not the only injection site that runs
after the serving account is bound. When an owner forward fails, the local
recovery path rebinds with allow_previous_response_recovery_rebind /
allow_bootstrap_owner_rebind, which are explicitly allowed to bind the session
to an account other than the durable record's owner, and then injects
durable_lookup.latest_response_id into the recovery payload with the stored
prefix trimmed away. That is the same wedge: upstream accepts the
response.create, never emits response.created, and the per-bridge
response_create_gate stays held.

Gate that injection on durable_lookup.account_id == session.account.id and keep
the full input on a mismatch. Both declines now emit a
cross_account_anchor_declined bridge event naming the injection site and the
anchor's owning account, so the wedge family stays visible in bridge logs.

The remaining proxy-side site, the pre-binding fresh-reattach durable anchor, is
already covered: setting previous_response_id there makes the durable owner a
required account, so both session creation and session reuse refuse to serve it
elsewhere and an unavailable owner degrades along the account-neutral
full-resend path instead.

Tests: same-account recovery still anchors, cross-account recovery keeps the
full input. The owner-unavailable fake now honours preferred_account_id when it
builds the recovery session, matching what the real creation path pins.
…l bridge

The unit coverage stubs _get_or_create_http_bridge_session and
_submit_http_bridge_request, so it never sends a frame upstream, waits for
response.created, or touches the response-create gate: it would still pass if
the real bridge path stayed wedged.

Add bridge-level coverage that runs the real path. A durable record owned by a
paused account is restored onto a session created on another account, and the
fake upstream models the account scope of previous_response_id: a
response.create carrying an anchor it never issued is accepted and then
answered with silence, exactly like upstream. With the guard the turn goes
upstream as a full-history resend and settles; without it the request never
completes and the run fails on the 5s POST timeout with the gate still held.

The same test then proves same-account continuity is untouched: once the
durable record names the account that created the response, the next turn
reuses the session and anchors on it instead of resending the history.
@Komzpa
Komzpa force-pushed the pr1274-rebased-20260806 branch from 70f686f to 02b3c12 Compare August 6, 2026 23:01
Komzpa added a commit that referenced this pull request Aug 6, 2026
Every new bug class extends the leg that should have caught it, so the three
classes proven on the live stack today each get model state, an invariant or
liveness property, and a negative control that reproduces the failure.

Anchor account ownership (PR #1638). A continuity anchor now carries the
account that owns it. Upstream accepts a request carrying a
previous_response_id owned by a different account and then never sends
response.created, so UpstreamRespondsTo gates StartStream, CompleteTurn and
ClaimCompletedDelivery: a foreign-anchored turn can only leave the
pre-response phase through a timer or a cancel. Inv10AnchorAccountOwnership
forbids dispatching with a foreign-owned anchor;
weak-cross-account-anchor.cfg reproduces the wedge.

Pre-response eventless phase (PR #1633). The "active" phase - dispatched
upstream, response.created not seen yet - now has its own bound instead of
sharing the request/stream-idle budget, and ExpireDeadline picks both its
bound and its budget label per phase. Inv11PreResponseBudget requires the
pre-response bound to be the minimum of the named gate-retire and stream-idle
budgets, at or above the keepalive cadence floor, strictly below the
post-start budget, and forbids reporting a kill under the post-start
stream-idle budget while the response had not started.
weak-conflated-timers.cfg collapses the two timer names and TLC produces a
healthy pre-start wait killed under the wrong budget.

Bounded client retry backoff (PR #1634). A turn killed in the pre-response
phase tears the client session; ClientRetryAttempt repairs it and is fair,
but only while retryBackoff stays inside MaxRetryBackoff. The new liveness
property TearEventuallyRecovers states that a recoverable tear is eventually
recovered; weak-unbounded-backoff.cfg lets the backoff grow past every
deadline in the model, and TLC produces a behaviour where the client stays
torn forever - the 29-hour client sleep observed today.

check.sh gains the three mappings and a PROPERTY:<Name> expectation form: a
liveness control must violate exactly the temporal property its config
declares and must not violate any invariant.

bash spec/check.sh exits 0. Full model: 16696096 states generated, 3606740
distinct, depth 23, zero violations, deadlock checking enabled. All 13
weakenings produce their mapped counterexample.
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.

2 participants