fix(cache): source-qualify the peer identity and gate the declared bind

Both blockers from @andrexibiza's review of 28a2d7f0ee.

1. The generation lookup was not in the same identity domain as recovery.
   latest_conversation_boundary() selected on session_key alone, while
   _declared_conversation_session() is qualified by (source, session_key).
   X-Hermes-Session-Key accepts any authenticated caller-supplied string, so an
   API conversation may legally carry the same key as a Telegram row in one
   database -- a /new over there rotated this conversation's gwk_ generation
   while recovery correctly refused to cross the same line, moving the affinity
   identity out from under a physical identity that had not moved.

   The boundary read now takes (session_key, source), and the carrier is
   'source|key|generation' rather than 'key|generation' -- keying on the string
   alone would also collapse two same-key conversations from different sources
   onto one routing key, since this value leaves the process verbatim as
   OpenRouter's sticky session_id and xAI's x-grok-conv-id. The source comes
   from the agent's own session row, falling back to the platform the row will
   be created with before it lands.

2. The declared key's stated lower precedence did not survive settlement. Both
   handlers let stored_session_id / an explicit body session_id win, then called
   _bind_declared_conversation() unconditionally. record_gateway_session_peer()
   does SET session_key = ? across compression ancestors, so a request carrying
   conversation A's chain plus header key B silently rebound A to B: A could no
   longer be recovered by its own key, and B recovered A's session.

   Recording is now gated on the declared key having actually selected or
   minted the session, on both paths. Behind that gate the bind itself refuses
   to overwrite a row already bound to a different key, so a future caller
   cannot reintroduce the same defect by opting in wrongly.

test_declaration_outranks_the_lineage_root asserted the pre-qualification
contract by comparing a DB-backed agent against a DB-less one; it now makes the
stronger statement it was written for -- one declared conversation reached
through two different physical ids on the same peer.

Refs #96811

Found in review by @andrexibiza, whose analysis located each of these
defects and specified what a correct fix had to prove.

Co-Authored-By: Andrex Ibiza, MBA <andrexibiza@gmail.com>
This commit is contained in:
joaomarcos
2026-08-30 21:17:37 -03:00
committed by Teknium
parent e5bce4df4b
commit d63e5d8a10
6 changed files with 212 additions and 21 deletions
+28 -6
View File
@@ -2356,6 +2356,21 @@ class APIServerAdapter(BasePlatformAdapter):
if db is None:
return
try:
# Defence in depth behind the callers' precedence gate: never
# rewrite a row that already belongs to a different conversation.
# record_gateway_session_peer does SET session_key = ?, so a
# mistaken bind would strand the original conversation and hand its
# session to this request's key.
existing = db.get_session(sid) or {}
current = str(existing.get("session_key") or "").strip()
if current and current != key:
logger.debug(
"[%s] refusing to rebind session %s from a different "
"declared conversation",
self.name,
sid,
)
return
db.record_gateway_session_peer(
sid,
source=self._SESSION_SOURCE,
@@ -6423,6 +6438,12 @@ class APIServerAdapter(BasePlatformAdapter):
# the conversation it declared via ``X-Hermes-Session-Key`` before
# minting a throwaway id — otherwise every reply is a new conversation
# to every affinity surface (#96811).
# The response chain still outranks the declared key. Recording is
# gated on that same precedence: binding a session the chain selected
# would rewrite ITS routing key to this request's header
# (record_gateway_session_peer does SET session_key = ?), stranding the
# original conversation and letting the header key recover it instead.
_declared_selected = not stored_session_id and bool(gateway_session_key)
session_id = (
stored_session_id
or self._declared_conversation_session(gateway_session_key)
@@ -6498,7 +6519,7 @@ class APIServerAdapter(BasePlatformAdapter):
tool_complete_callback=_on_tool_complete,
agent_ref=agent_ref,
gateway_session_key=gateway_session_key,
bind_declared_conversation=True,
bind_declared_conversation=_declared_selected,
**agent_overrides,
route=route,
))
@@ -6534,7 +6555,7 @@ class APIServerAdapter(BasePlatformAdapter):
ephemeral_system_prompt=instructions,
session_id=session_id,
gateway_session_key=gateway_session_key,
bind_declared_conversation=True,
bind_declared_conversation=_declared_selected,
**agent_overrides,
route=route,
)
@@ -7599,10 +7620,11 @@ class APIServerAdapter(BasePlatformAdapter):
# (/v1/responses, /v1/runs) record one, so no other
# caller's rows change shape.
if bind_declared_conversation:
self._bind_declared_conversation(
getattr(agent, "session_id", None) or session_id,
gateway_session_key,
)
if _declared_selected:
self._bind_declared_conversation(
getattr(agent, "session_id", None) or session_id,
gateway_session_key,
)
clear_session_vars(tokens)
self._activate_admitted_request()