feat(session): implement the monotonic session boundary the spec describes - #629
Merged
Conversation
Found while reviewing the Monotonic Session Sensitivity State draft against
the runtime. A reset is supposed to close one session and open a successor at
the minimum level. Four things let the boundary leak.
A response arriving during a reset raised the successor. The mutation lock
serialised the reset and the response elevation but did not order them, so
whichever coroutine acquired it second won: a response in flight when the reset
landed was applied to the successor and recorded its pre-reset call_id as the
raiser. update_from_inspection() now takes the reset_count observed at call
entry and drops a response whose generation no longer matches. The
discriminator is reset_count and not session_id because upgrade_attestation()
rotates the identifier while deliberately continuing the same session, so a
call in flight across an attestation upgrade must still apply. The previous
concurrency test asserted only that max_sensitivity stayed a member of
SENSITIVITY_ORDER, which every value satisfies.
The reset route accepted the tool-invocation token. POST
/sessions/{id}/reset is not reachable as an MCP tool, but it sat behind the
same CMCP_BEARER_TOKEN as POST /mcp, so an agent host holding its own
credential could clear accumulated sensitivity. The operator interface now
takes CMCP_OPERATOR_TOKEN, required outside dev mode and refused if it equals
the bearer token. Unset, those routes still fall back, so existing deployments
keep working until they set it.
The reset audit entry recorded the sensitivity transition and nothing that
identified the boundary. detail now carries the closed session id, the
successor id, the resulting reset counter and which credential was verified.
detail is inside the canonical body, so those fields are hash-covered.
The chain attributed every entry after a reset to the closed session, and the
successor's identifier appeared nowhere in it. rotate_session_id() moves
attribution after the boundary entry is written.
The closed session's final value is now preserved as a distinct
ClosedSessionRecord rather than overwritten, and the reset response returns it.
Nine tests added. Two suite failures are pre-existing on the base:
test_release_distribution_smoke (version 0.5.0 vs metadata 0.4.0) and
test_tpm_chained_verify (assertion text drift).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDzMWcn12dszchgrKfXzZY
…y value The accumulated session-sensitivity value lived in the gateway process, which left two gaps in what the ratchet actually guarantees. It did not survive a restart. A session that had reached hipaa_phi came back at public while the session identifier the agent host holds was still live, so the sensitivity accumulated over the whole session was gone and enforcement resumed from the minimum level. It was not shared. Where several instances serve one agent session, each held its own value, so the ratchet held per instance rather than per session. An agent that read sensitive data through one instance and egressed through another was evaluated by an instance that never saw the read. session/store.py holds both answers behind one protocol. InMemorySessionStateStore is the default and is the previous behaviour exactly. SqliteSessionStateStore serialises the read-modify-write with BEGIN IMMEDIATE, which takes SQLite's RESERVED lock: two processes cannot hold it at once, so the critical section spans instances on a shared volume. An asyncio.Lock cannot do this, being invisible to every other instance, and a test proves the difference against a real second process holding the lock. The audit chain already takes its durability from SQLite in WAL mode, so turning this on adds no infrastructure. apply_inspection and apply_reset are now the write path. Each holds the store's exclusive section, reads the shared value back, folds the response into it and writes it, so the greater-of comparison is made against what every instance shares rather than against one instance's copy. The gateway hydrates at call entry, before the pre-call policy evaluation reads the value and before the generation the call belongs to is captured, so an instance joining a session another instance opened does not permit what the session already forbids. A reset also advances the closed session's generation in the store while keeping the value it reached. The successor is written under its own identifier, so without that an instance still holding the old identifier would read a generation matching the one it captured and raise a session already closed. Only the value that must be shared is stored: injection events, drift lists and the kill switch stay per instance, because copying them would present one instance's observations as another's. session_state_path is unset by default. 10 tests added, 1681 passing. The two suite failures are pre-existing on the base: test_release_distribution_smoke (runtime 0.5.0 against metadata 0.4.0) and test_tpm_chained_verify (assertion text drift). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDzMWcn12dszchgrKfXzZY
imran-siddique
requested review from
a team,
carloshvp,
qubeena07 and
zohebk8s
as code owners
September 10, 2026 23:48
Qiang-Xu
approved these changes
Sep 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found while reviewing the Monotonic Session Sensitivity State patent draft against the runtime. Everything the specification describes about the monotonic ratchet and the session boundary a credentialed reset draws is now implemented, so the two can be read against each other.
Supersedes #628, which was cut from a branch that has since been squash-merged. Same first commit, cherry-picked onto current main.
1. A response arriving during a reset raised the successor
The per-session mutation lock serialised the reset and the response elevation but never ordered them, so whichever coroutine acquired it second won. A response in flight when the reset landed was applied to the successor, which had just been initialised to
public, and recorded its pre-resetcall_idas the call that raised it.update_from_inspection()now takes thereset_countobserved at call entry and drops a response whose generation no longer matches, loggingSESSION_RESET_RACE. The discriminator isreset_countand notsession_id, becauseupgrade_attestation()rotates the identifier while deliberately continuing the same session, so a call in flight across an attestation upgrade must still apply. Both cases are tested.The previous concurrency test asserted only that
max_sensitivityremained a member ofSENSITIVITY_ORDER, which every value satisfies.2. The reset route accepted the tool-invocation token
POST /sessions/{id}/resetis not reachable as an MCP tool, but it sat behind the same singleCMCP_BEARER_TOKENasPOST /mcp, so an agent host holding only its own tool-invocation credential could clear accumulated session sensitivity.The operator interface (session reset, catalog exception) now takes
CMCP_OPERATOR_TOKEN, which must differ fromCMCP_BEARER_TOKENand is required outsideCMCP_DEV_MODE=1(OPERATOR_TOKEN_REQUIRED). Unset, those routes still fall back to the bearer token, so an existing single-token deployment keeps working until it sets the new variable.Deployment note: set
CMCP_OPERATOR_TOKENin production before this ships, or startup aborts.3. The reset audit entry did not identify the boundary
It recorded the sensitivity transition and nothing else.
detailnow carries the closed session identifier, the successor identifier, the resulting reset counter, and which credential was verified.detailis inside_canonical_body(), so those fields are covered by the entry hash.4. The chain attributed post-reset entries to the closed session
AuditChainstamps its construction-timesession_id, so every entry after a reset carried the closed session's identifier and the successor's appeared nowhere.rotate_session_id()moves attribution after the boundary entry is written, so the reset entry belongs to the session that reached the recorded value.5. The accumulated value was neither persistent nor shared
Two gaps with one answer, in the new
session/store.py.It did not survive a restart. A session that had reached
hipaa_phicame back atpublicwhile the session identifier the agent host holds was still live, so enforcement resumed from the minimum level.It was not shared. Where several instances serve one agent session, each held its own value, so the ratchet held per instance rather than per session. An agent that read sensitive data through one instance and egressed through another was evaluated by an instance that never saw the read.
SqliteSessionStateStoreserialises the read-modify-write withBEGIN IMMEDIATE, which takes SQLite's RESERVED lock. Two processes cannot hold it at once, so the critical section spans instances on a shared volume. Anasyncio.Lockcannot do this, being invisible to every other instance, andtest_the_write_lock_serialises_across_processesproves the difference against a real second process holding the lock. The audit chain already takes its durability from SQLite in WAL mode, so this adds no infrastructure.apply_inspectionandapply_resetare now the write path: each holds the exclusive section, reads the shared value back, folds the response in and writes it, so the greater-of comparison is made against what every instance shares. The gateway hydrates at call entry, before the pre-call policy evaluation reads the value and before the call's generation is captured, so an instance joining a session another instance opened does not permit what the session already forbids.A reset also advances the closed session's generation in the store while keeping the value it reached. The successor is written under its own identifier, so without that an instance still holding the old identifier would read a generation matching the one it captured and raise a session already closed. That case had to be found by test, not by reading.
Only what must be shared is stored. Injection events, drift lists and the kill switch stay per instance, because copying them would present one instance's observations as another's.
session_state_pathis unset by default and the in-process path is byte-for-byte the previous behaviour.Testing
1681 passing, 19 new. Ruff and mypy clean on
src/.Two failures are pre-existing on main, confirmed by stashing:
test_release_distribution_smoke(runtime 0.5.0 against metadata 0.4.0) andtest_tpm_chained_verify(assertion text drift). Both passed in CI on #628, so they look local to this machine.🤖 Generated with Claude Code
https://claude.ai/code/session_01CDzMWcn12dszchgrKfXzZY