Skip to content

fix(session_store): key InMemorySessionStore structurally instead of by joined string - #1180

Closed
PranavMishra28 wants to merge 1 commit into
anthropics:mainfrom
PranavMishra28:fix/session-store-structural-keys
Closed

fix(session_store): key InMemorySessionStore structurally instead of by joined string#1180
PranavMishra28 wants to merge 1 commit into
anthropics:mainfrom
PranavMishra28:fix/session-store-structural-keys

Conversation

@PranavMishra28

Copy link
Copy Markdown

Closes #1168.

Problem

_key_to_string() joined the three key components with /, and every read path parsed them back out by prefix, so any component containing the separator could alias a different logical key.

The issue describes this as a listing/ambiguity problem. Reproducing it first turned up two sharper consequences:

# {"project_key": "a/b", "session_id": "c"}  vs
# {"project_key": "a", "session_id": "b", "subpath": "c"}
load(a) -> [{'x': 1}, {'x': 2}]      # both sessions' entries, merged
load(b) -> [{'x': 1}, {'x': 2}]
# delete({"project_key": "tenant", "session_id": "x"}) while a
# {"project_key": "tenant", "session_id": "x/y"} session exists
victim survives cascade: None        # unrelated session destroyed

So it's cross-session bleed on read, and data loss on cascade delete.

Approach

Key on a (project_key, session_id, subpath | None) tuple. list_sessions, delete, list_subkeys and size now compare fields instead of slicing strings. No public API change, and no behavior change for keys containing no separator.

Worth noting the shipped Postgres example already does this correctly — PRIMARY KEY (project_key, session_id, subpath, seq) with WHERE subpath = '' — so structural keying looks like the intended contract rather than something I'm inventing here. This brings the in-memory reference in line with it.

Beyond this PR, and deliberately not in it

Two related findings I verified but left alone, since they widen the diff well past the issue:

  • The conformance harness has no delimiter coverage. run_session_store_conformance is what third-party adapters validate against, and it never puts a separator in any component — so an adapter with this exact bug passes it today.

  • Two of the three shipped example adapters have the same bug. Calling their key builders directly:

    redis  _entry_key({"project_key": "a:b", "session_id": "c"})            -> "a:b:c"
    redis  _entry_key({"project_key": "a", "session_id": "b", "subpath": "c"}) -> "a:b:c"   # collide
    s3     _key_prefix(...) likewise collides on "/"
    

    Redis joins on ":", S3 on "/"; Postgres is immune for the reason above.

Adding a delimiter case to the conformance suite would therefore fail the Redis and S3 examples until they're keyed structurally too. I'd rather land the reference fix first and then do conformance plus both adapters as a focused follow-up, but happy to fold it all into this PR if you'd prefer one change — the invariant is the same in all three places.

How tested

  • Three new cases in tests/test_session_store_conformance.py: the aliasing pair on project_key, the cascade-delete case on session_id, and listings/size/list_subkeys reporting components verbatim. All fail on the parent commit under both asyncio and trio (6 failures), pass after.
  • They assert on the aliasing pairs rather than on the encoding, so they remain valid for any representation that keeps the components distinct.
  • Full suite: 1414 passed, 3 skipped. mypy clean. ruff unchanged from base (32 pre-existing findings, identical with this change stashed).

Developed with Claude Code; reviewed and tested by Pranav before marking ready for review.

…by joined string

Closes anthropics#1168.

`_key_to_string()` joined the three key components with "/" and every read path
parsed them back out by prefix, so any component containing the separator could
alias a different logical key. Two concrete consequences, both reproduced against
main before changing anything:

    # {"project_key": "a/b", "session_id": "c"} vs
    # {"project_key": "a", "session_id": "b", "subpath": "c"}
    load(a) -> [{'x': 1}, {'x': 2}]      # both sessions' entries, merged
    load(b) -> [{'x': 1}, {'x': 2}]

    # delete({"project_key": "tenant", "session_id": "x"}) with a
    # {"session_id": "x/y"} session present
    victim survives cascade: None        # unrelated session destroyed

So this is cross-session data bleed on read and data loss on cascade delete, not
only the listing ambiguity the issue describes.

The store now keys on a `(project_key, session_id, subpath | None)` tuple, and
`list_sessions`, `delete`, `list_subkeys` and `size` compare fields rather than
slicing strings. No public API or behavior change for keys that contain no
separator.

Tests: three cases covering the aliasing pairs and the cascade, all failing on
the parent commit under both asyncio and trio. They assert on the aliasing pairs
rather than the encoding, so they stay valid for any representation that keeps the
components distinct.
@PranavMishra28

Copy link
Copy Markdown
Author

Closing this as a duplicate of #1178, which does the same thing and predates it by two days. I checked whether the issue had been claimed in the comments but did not search for an open PR against it, which is the actual check that would have caught this.

Moved the parts that were additive onto #1178: the Redis and S3 example adapters collide identically (":" and "/" respectively) while Postgres is immune because it uses real columns, and the conformance harness has no delimiter coverage, so a third-party adapter with this bug passes it today. Also noted there that the failure is a cross-session read bleed and a cascade delete destroying an unrelated session, not only listing ambiguity.

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.

InMemorySessionStore composite string keys can collide across logical sessions

1 participant