fix: use tuple keys in InMemorySessionStore to prevent composite key collisions - #1185
fix: use tuple keys in InMemorySessionStore to prevent composite key collisions#1185SushantTusharJoshi wants to merge 2 commits into
Conversation
…collisions
_key_to_string() joined key components with "/" without escaping, so keys
like {"project_key": "a/b", "session_id": "c"} and {"project_key": "a",
"session_id": "b/c"} both resolved to "a/b/c". This caused silent data
overwrites and incorrect cascade deletes.
Replace the /-joined string encoding with tuple keys: (project_key,
session_id) for main transcripts and (project_key, session_id, subpath)
for subkeys. Tuple comparison is field-exact, eliminating the ambiguity.
Updated methods: append, load, list_sessions, delete, list_subkeys,
list_subkeys, get_entries, and the size property.
Fixes anthropics#1168
Developed with Claude Code as a coding partner
|
written by mycroft, an AI agent at Palo Alto AI Research Lab; a human verified every command and result below. The fix is right and the tests are mostly load-bearing. One test isn't, and there's a bigger gap one layer up. Ran on The tests are real. Restoring only
The old prefix logic happens to get that pair right: @pytest.mark.anyio
async def test_list_sessions_with_colliding_encodings() -> None:
"""Two sessions whose old '/'-joined encodings were identical must both be listed."""
store = InMemorySessionStore()
await store.append({"project_key": "a/b", "session_id": "s1"}, [{"n": 1}])
await store.append({"project_key": "a", "session_id": "b/s1"}, [{"n": 2}])
assert [s["session_id"] for s in await store.list_sessions("a/b")] == ["s1"]
assert [s["session_id"] for s in await store.list_sessions("a")] == ["b/s1"]The gap: this lands in the reference implementation, not in the contract. Two sessions merged into one, and deleting one session silently destroyed an unrelated one. Both under a green conformance run. That is not hypothetical copying: Suggested addition, a 15th contract, so the class is closed for every implementation rather than for this one: # 15. distinct keys never collide, whatever the components contain
store = await fresh()
await store.append({"project_key": "a/b", "session_id": "c"}, [_e({"uuid": "x", "n": 1})])
await store.append({"project_key": "a", "session_id": "b/c"}, [_e({"uuid": "y", "n": 2})])
assert await store.load({"project_key": "a/b", "session_id": "c"}) == [_e({"uuid": "x", "n": 1})]
assert await store.load({"project_key": "a", "session_id": "b/c"}) == [_e({"uuid": "y", "n": 2})]That one line of coverage is what makes the delete-cascade case above impossible to ship, and it is the same class of loss we care about most: a store that reports success while quietly returning another session's transcript. |
…ng test - Replace test_list_sessions_with_slash_in_project_key (passed on pre-fix code) with test_list_sessions_with_colliding_encodings that actually exercises the collision path - Add contract 15 to run_session_store_conformance: distinct composite keys with slashes in components must never collide, closing the gap for third-party adapter implementations - Fix MinimalStore test fixtures to use tuple keys so they pass the new conformance contract Developed with Claude Code as a coding partner
|
Thanks for the thorough review @tonydzi — both points were spot on. Addressed in ca00dac:
All 27 asyncio tests pass (trio skipped — not installed locally, same as before). |
|
Ran Contract 15 is load-bearing. Same mutant as before — restore only That is up from 8 before this commit. The new reds are The gap that remains: contract 15 only exercises class PrefixCascadeStore(SessionStore):
# append/load key on (project_key, session_id, subpath) tuples -> contract 15 passes
async def delete(self, key: SessionKey) -> None:
k = self._k(key)
if k[2]:
self._d.pop(k, None)
return
prefix = f"{key['project_key']}/{key['session_id']}/"
for existing in [x for x in self._d if self._path(x).startswith(prefix)]:
self._d.pop(existing, None)Deleting This is not a contrived adapter: cascade-by-prefix is how every KV/object backend implements that contract ( Suggested patch — two hunks inside contract 10, no new contract: # A distinct session whose *joined* path sits under the deleted one.
neighbor: SessionKey = {
"project_key": _KEY["project_key"],
"session_id": f"{_KEY['session_id']}/x",
}
neighbor_sub: SessionKey = {**neighbor, "subpath": "subagents/agent-n"}
...
await store.append(neighbor, [_e({"n": 1})])
await store.append(neighbor_sub, [_e({"n": 1})])
await store.delete(_KEY)
...
loaded_neighbor = await store.load(neighbor)
assert loaded_neighbor is not None and len(loaded_neighbor) == 1
loaded_neighbor_sub = await store.load(neighbor_sub)
assert loaded_neighbor_sub is not None and len(loaded_neighbor_sub) == 1Checked in both directions rather than asserted:
Honest scope. An SDK-derived |
Summary
_key_to_string()(which joined components with/) with_key_to_tuple()returning a proper tuple_storeand_mtimesdict types fromdict[str, ...]todict[tuple, ...]append,load,list_sessions,delete,list_subkeys,get_entries, andsize("a/b", "c")and("a", "b/c")previously both mapped to"a/b/c"Test plan
Closes #1168
Developed with Claude Code as a coding partner