Summary
Under a per-peer DM session scope, the direct-message session-key builder force-lowercases the peer id before building the key. For Matrix (and Signal), where user ids are opaque and can be case-distinct, two different peers whose ids differ only in case collapse to the SAME session key. The second peer's DM turn then resolves to the first peer's session and loads the first peer's persisted DM transcript with the bot. This is the direct-message sibling of the case-preservation hardening shipped for group/room ids in #75670 / #87366, which was applied to only one of the two branches.
Environment
- Commit: d3ff48c (origin/main at time of filing)
- Surface: routing session-key builder, direct/DM branch; reached by the in-tree Matrix extension (Signal shares the same exposure)
Steps to reproduce
- Enable the Matrix extension and set the DM session scope to a per-peer variant (
per-peer, per-channel-peer, or per-account-channel-peer). The default is main, which shares one session and is unaffected.
- Have two distinct Matrix users whose MXIDs differ only in case DM the same bot, for example
@Alice:hs.example and @alice:hs.example.
- Observe that the second user's turn resolves to the same session key as the first user, so the bot loads and exposes the first user's prior DM history to the second user.
Real-runtime driver: call the production buildAgentPeerSessionKey (and the higher-level buildAgentSessionKey) with peerKind: "direct", a per-peer dmScope, and the two case-distinct MXIDs, exactly as the Matrix extension supplies them.
Expected
A per-peer DM scope must isolate distinct peer identities. Two case-distinct MXIDs are two distinct principals on a homeserver that permits them, so they must map to two distinct session keys and two separate transcripts.
Actual
Both MXIDs map to one session key agent:main:direct:@alice:hs.example (and the channel/account-scoped variants), so they share one session and one persisted transcript. The group/room path does not have this problem: it preserves case and keeps the two ids distinct.
Root cause
src/routing/session-key.ts direct branch (line 248 on the pinned commit):
if (peerKind === "direct") {
const dmScope = params.dmScope ?? "main";
let peerId = (params.peerId ?? "").trim();
...
peerId = normalizeLowercaseStringOrEmpty(peerId);
...
if (dmScope === "per-peer" && peerId) {
return `agent:${normalizeAgentId(params.agentId)}:direct:${peerId}`;
}
The blanket normalizeLowercaseStringOrEmpty(peerId) lowercases the opaque MXID before it is embedded in the DM key. The group branch of the same function instead routes peerId through normalizeSessionPeerId (src/sessions/session-key-utils.ts), which honors CASE_PRESERVING_PEERS so opaque channels keep case:
const CASE_PRESERVING_PEERS = [
{ channel: "signal", peerKinds: new Set(["group"]), span: "segment", unscoped: true },
{ channel: "matrix", peerKinds: new Set(["channel", "group"]), span: "tail", unscoped: true },
];
#75670 / #87366 added Matrix case preservation, but only for channel/group peer kinds and only on the group branch. The direct branch still uses the blanket lowercase, and direct is not in the preserving set, so DM ids for Matrix and Signal still fold.
Reachability: the in-tree Matrix extension builds a direct peer with the raw case-sensitive MXID both inbound (extensions/matrix/src/matrix/monitor/handler.ts) and outbound (extensions/matrix/src/session-route.ts, peer: { kind: "direct", id: target.id }). normalizeRouteBindingId (src/routing/binding-scope.ts) only trims, so the case survives all the way to line 248.
Suggested fix: route the direct branch's peerId through normalizeSessionPeerId (like the group branch) and add direct to the Matrix and Signal descriptors in CASE_PRESERVING_PEERS, so opaque-id channels keep case for DMs while case-insensitive channels (Telegram etc.) keep folding.
Sibling surfaces
Signal is the other CASE_PRESERVING_PEERS channel and shares the same DM-branch exposure. Case-insensitive channels (Telegram, Discord numeric ids) are correct to fold and are unaffected. The default main DM scope is unaffected because it does not reach the per-peer key path.
Real behavior proof
Behavior addressed: per-peer DM session keys for Matrix must isolate case-distinct MXIDs; on current main they collide and share one transcript.
Real environment tested: production buildAgentPeerSessionKey and buildAgentSessionKey driven directly with peerKind: "direct" and case-distinct MXIDs, at commit d3ff48c
Exact steps or command run after this patch: drive the real key builders with @Alice:hs.example and @alice:hs.example across the three per-peer dmScopes and the group path; compare keys on the pinned commit, then with the direct branch routed through the case-preserving normalizer on identical inputs
Evidence after fix:
# OBSERVED (buggy, origin/main d3ff48c)
DM per-peer @Alice: agent:main:direct:@alice:hs.example
DM per-peer @alice: agent:main:direct:@alice:hs.example
DM per-peer COLLIDES: true
DM per-channel-peer @Alice: agent:main:matrix:direct:@alice:hs.example
DM per-channel-peer @alice: agent:main:matrix:direct:@alice:hs.example
DM per-channel-peer COLLIDES: true
DM per-account-channel-peer @Alice: agent:main:matrix:acct1:direct:@alice:hs.example
DM per-account-channel-peer @alice: agent:main:matrix:acct1:direct:@alice:hs.example
DM per-account-channel-peer COLLIDES: true
GRP channel @Alice: agent:main:matrix:channel:@Alice:hs.example
GRP channel @alice: agent:main:matrix:channel:@alice:hs.example
GRP channel COLLIDES: false
# EXPECTED (direct branch case-aware, identical inputs)
DM per-peer @Alice: agent:main:direct:@Alice:hs.example
DM per-peer @alice: agent:main:direct:@alice:hs.example
DM per-peer COLLIDES: false
DM per-channel-peer @Alice: agent:main:matrix:direct:@Alice:hs.example
DM per-channel-peer @alice: agent:main:matrix:direct:@alice:hs.example
DM per-channel-peer COLLIDES: false
DM per-account-channel-peer @Alice: agent:main:matrix:acct1:direct:@Alice:hs.example
DM per-account-channel-peer @alice: agent:main:matrix:acct1:direct:@alice:hs.example
DM per-account-channel-peer COLLIDES: false
Observed result after fix: on the pinned commit two case-distinct Matrix MXIDs produce one identical per-peer DM key (COLLIDES true) so the second peer reads the first peer's transcript; with the direct branch made case-aware the keys are distinct (COLLIDES false) while the group path was already correct.
What was not tested: two preconditions limit real-world exposure and were not staged end to end against a live homeserver. First, the operator must have opted into a non-default per-peer DM scope; the default main scope does not hit this path. Second, the homeserver must permit two case-distinct MXIDs as distinct users. Standard Synapse canonicalizes and lowercases localparts at registration, so it does NOT fire there; this requires a permissive or non-standard homeserver (for example Dendrite/Conduit or custom setups), legacy pre-canonicalization accounts, or federated ids that preserve case. I did not verify a full inbound-to-transcript-load flow on a live server; the proof drives the real session-key builders that decide which transcript is loaded.
Summary
Under a per-peer DM session scope, the direct-message session-key builder force-lowercases the peer id before building the key. For Matrix (and Signal), where user ids are opaque and can be case-distinct, two different peers whose ids differ only in case collapse to the SAME session key. The second peer's DM turn then resolves to the first peer's session and loads the first peer's persisted DM transcript with the bot. This is the direct-message sibling of the case-preservation hardening shipped for group/room ids in #75670 / #87366, which was applied to only one of the two branches.
Environment
Steps to reproduce
per-peer,per-channel-peer, orper-account-channel-peer). The default ismain, which shares one session and is unaffected.@Alice:hs.exampleand@alice:hs.example.Real-runtime driver: call the production
buildAgentPeerSessionKey(and the higher-levelbuildAgentSessionKey) withpeerKind: "direct", a per-peerdmScope, and the two case-distinct MXIDs, exactly as the Matrix extension supplies them.Expected
A per-peer DM scope must isolate distinct peer identities. Two case-distinct MXIDs are two distinct principals on a homeserver that permits them, so they must map to two distinct session keys and two separate transcripts.
Actual
Both MXIDs map to one session key
agent:main:direct:@alice:hs.example(and the channel/account-scoped variants), so they share one session and one persisted transcript. The group/room path does not have this problem: it preserves case and keeps the two ids distinct.Root cause
src/routing/session-key.tsdirect branch (line 248 on the pinned commit):The blanket
normalizeLowercaseStringOrEmpty(peerId)lowercases the opaque MXID before it is embedded in the DM key. The group branch of the same function instead routes peerId throughnormalizeSessionPeerId(src/sessions/session-key-utils.ts), which honorsCASE_PRESERVING_PEERSso opaque channels keep case:#75670 / #87366 added Matrix case preservation, but only for
channel/grouppeer kinds and only on the group branch. The direct branch still uses the blanket lowercase, anddirectis not in the preserving set, so DM ids for Matrix and Signal still fold.Reachability: the in-tree Matrix extension builds a direct peer with the raw case-sensitive MXID both inbound (
extensions/matrix/src/matrix/monitor/handler.ts) and outbound (extensions/matrix/src/session-route.ts,peer: { kind: "direct", id: target.id }).normalizeRouteBindingId(src/routing/binding-scope.ts) only trims, so the case survives all the way to line 248.Suggested fix: route the direct branch's peerId through
normalizeSessionPeerId(like the group branch) and adddirectto the Matrix and Signal descriptors inCASE_PRESERVING_PEERS, so opaque-id channels keep case for DMs while case-insensitive channels (Telegram etc.) keep folding.Sibling surfaces
Signal is the other
CASE_PRESERVING_PEERSchannel and shares the same DM-branch exposure. Case-insensitive channels (Telegram, Discord numeric ids) are correct to fold and are unaffected. The defaultmainDM scope is unaffected because it does not reach the per-peer key path.Real behavior proof
Behavior addressed: per-peer DM session keys for Matrix must isolate case-distinct MXIDs; on current main they collide and share one transcript.
Real environment tested: production
buildAgentPeerSessionKeyandbuildAgentSessionKeydriven directly withpeerKind: "direct"and case-distinct MXIDs, at commit d3ff48cExact steps or command run after this patch: drive the real key builders with
@Alice:hs.exampleand@alice:hs.exampleacross the three per-peer dmScopes and the group path; compare keys on the pinned commit, then with the direct branch routed through the case-preserving normalizer on identical inputsEvidence after fix:
Observed result after fix: on the pinned commit two case-distinct Matrix MXIDs produce one identical per-peer DM key (COLLIDES true) so the second peer reads the first peer's transcript; with the direct branch made case-aware the keys are distinct (COLLIDES false) while the group path was already correct.
What was not tested: two preconditions limit real-world exposure and were not staged end to end against a live homeserver. First, the operator must have opted into a non-default per-peer DM scope; the default
mainscope does not hit this path. Second, the homeserver must permit two case-distinct MXIDs as distinct users. Standard Synapse canonicalizes and lowercases localparts at registration, so it does NOT fire there; this requires a permissive or non-standard homeserver (for example Dendrite/Conduit or custom setups), legacy pre-canonicalization accounts, or federated ids that preserve case. I did not verify a full inbound-to-transcript-load flow on a live server; the proof drives the real session-key builders that decide which transcript is loaded.