Skip to content

Keep conversations with encrypted history on the upstream that encrypted it - #368

Merged
fylorn merged 1 commit into
mainfrom
fix/encrypted-history-pin
Oct 22, 2026
Merged

fylorn merged 1 commit into
mainfrom
fix/encrypted-history-pin

Conversation

@fylorn

@fylorn fylorn commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

OpenAI Responses with store: false (how Codex always sends) hands the client encrypted state to send back every turn: reasoning, remote compaction, and Codex multi-agent messages (agent_message content parts of type encrypted_content). Only the account that encrypted it can read it. When a conversation moved to another upstream — the route changed, a load-balance group rotated after the cache went cold, or a failover — every following request failed with invalid_encrypted_content ("Encrypted function output content could not be decrypted or decoded"), often as an error event inside a 200 stream. Dropping encrypted reasoning (seal.rs) does not help when the encrypted part is the sub-agent's message itself.

What changes

  • tw_dialect::sealed (shared with the enterprise gateway): sealed(body) tells whether a Responses request carries upstream-encrypted content, and whether it is only reasoning (Sealed::Reasoning) or something that cannot be dropped (Sealed::Content). Values the gateway wrote itself (tw1. prefix) do not count. refused(wire, said) recognises an upstream refusing such content; seal::refusal now uses it and also matches the "could not be decrypted or decoded" wording.
  • Affinity remembers, per conversation, the upstream that last answered it (holder). Unlike the cache-driven stay, it survives the upstream going quiet, group changes, cache expiry and config reloads, and conversations that carried encrypted content are kept for 30 days instead of one.
  • Routing: a request carrying encrypted content goes back to its holder whatever the rule picks now (Stay::Sealed in the routing view).
    • Content: the holder is the only candidate, no failover. If it was removed from the config, the request is refused up front with gw.route.sealed_upstream_gone.
    • Reasoning only: the holder goes first; other candidates stay as failover, where seal.rs drops the reasoning they cannot read.
    • Unknown holder (after a restart): routed as before.
  • Failure message: when an upstream refuses encrypted content, in a 4xx body or an error event mid-stream, the request fails with gw.upstream.sealed_elsewhere, which says another upstream encrypted the history and what to do, followed by the upstream's own words.
  • Docs: config.md / config.zh-CN.md describe the behaviour next to conversation affinity.

New message codes: gw.route.sealed_upstream_gone, gw.upstream.sealed_elsewhere. New Stay value: sealed. No protocol bump: older UIs show the English text and no stay label.

Tests

  • tw-gateway/tests/sealed.rs drives the real pipeline against two fake accounts that each refuse the other's encrypted content: a sealed conversation returns to its account after a config change and does not fail over; a removed holder is refused plainly; reasoning-only prefers its account and still fails over; both refusal shapes (400 and in-stream) produce the new message. Three of the four fail with the pin disabled.
  • Unit tests for detection, refusal recognition and holder retention.
  • cargo fmt --check, cargo clippy --workspace --all-targets -D warnings, cargo test --workspace pass locally.

🤖 Generated with Claude Code

…ted it

OpenAI Responses hands clients encrypted state (reasoning, compaction,
Codex sub-agent messages) that only the encrypting account can read. A
conversation moved to another upstream failed every turn with
invalid_encrypted_content.

- tw_dialect::sealed: detect upstream-encrypted content in a Responses
  request (reasoning only vs content that cannot be dropped) and
  recognise refusals of it.
- Affinity remembers the upstream that last answered each conversation,
  across config reloads, cache expiry and quiet upstreams; 30 days for
  conversations that carried encrypted content.
- Routing sends such a conversation back to that upstream (Stay::Sealed):
  the only candidate when more than reasoning is encrypted, refused with
  gw.route.sealed_upstream_gone if it was removed; first among the
  candidates when only reasoning is.
- A refusal of encrypted content fails with gw.upstream.sealed_elsewhere,
  which explains what happened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fylorn
fylorn merged commit 3668339 into main Oct 22, 2026
6 checks passed
@fylorn
fylorn deleted the fix/encrypted-history-pin branch October 22, 2026 14:55
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 22, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant