Symptom, witnessed twice in one day (2026-08-20)
Two agents in different workspaces, independently, same sequence over the MCP recall_channel tool:
join(channel="dev", name="Atlas") → Joined #dev as Atlas (a_ee2af0db) — acknowledged, membership written.
unread(channel="dev", name="Atlas") 10 milliseconds later, same session → No channel memberships -- join a channel first.
The exact-transcript witness (timestamps from the MCP end events): unread at 09:43:14.686Z failed, join at 09:43:20.455Z succeeded naming the id, unread at 09:43:20.465Z failed again. A subsequent read succeeded (channel reads are log-path-based and don't consult memberships) and later post calls succeeded and attributed correctly. The second witness reported the identical join-ack-then-no-memberships shape in a different workspace with a different display name.
Mechanism — every link read from source, and the id reproduced from the formula
Function names are the anchors here; line numbers are deliberately omitted because they drift (this issue's first draft cited them and they were already stale against dev by filing time).
_agent_id (channel.py) resolves in three branches: SYNAPT_AGENT_ID env → name-derived "a_" + sha256(f"named:{griptree}:{name}")[:8] → session-scoped "s_" + sha256(f"{griptree}:{data_dir}:{ppid}")[:8].
channel_join (channel.py) derives with the name: _agent_id(project_dir, name=display_name). The membership row is written under the name-derived id.
channel_unread and channel_unread_read (channel.py) derive without it: _agent_id(project_dir).
- The MCP layer accepts
name on every action and the unread handler drops it: _handle_unread (actions.py) calls channel_unread_read(limit=…, show_pins=…, detail=…) — no identity argument at all.
So for a session with no SYNAPT_AGENT_ID, join writes the membership under the a_* id and unread queries memberships WHERE agent_id = <s_* session hash>: zero rows, deterministically, forever. Not a race, not staleness — two different pure functions of the same inputs.
Derivation proof: the id the witnessed join returned is exactly what _agent_id's named branch produces from that session's workspace directory name and display name — computed independently from the formula and compared against the transcript above, byte-identical. The formula is reader-checkable on neutral inputs: "a_" + sha256("named:my-workspace:Alice").hexdigest()[:8] = a_300f4ee0. Formula, salt, and truncation are exactly _agent_id's named branch.
Why most sessions never see it: any session with SYNAPT_AGENT_ID set (e.g. spawned via gr) short-circuits both derivations to the same registered id at resolution step 1, and a session that never passes name= gets the same s_* hash on both sides. The defect bites exactly the named-but-unregistered sessions — which includes every external user who passes name= to feel like a person instead of a hash.
This is a class, not an unread bug — sweep derived by grep over the handlers
Of the 22 action handlers in actions.py, exactly 4 thread identity through (join, post, directive, broadcast). The other 18 derive the nameless id — which diverges from join's id precisely in the named-but-unregistered case above. The identity-DEPENDENT members of those 18, each a live defect for such a session:
| Action |
Consequence under the wrong id |
unread |
the witnessed failure: membership invisible in the same session that joined |
heartbeat |
refreshes a phantom s_* presence row while the real a_* row goes stale toward reaping — an agent heartbeating correctly still gets marked offline |
leave |
removes membership for the wrong id — you cannot leave a channel you joined with a name |
claim / unclaim / intent |
claims attributed to an id that matches neither your join nor your posts; unclaim breaks across sessions because the s_* hash includes the parent pid |
rename |
renames the wrong row |
read |
succeeds (no membership gate) but advances cursors under the s_* id, so read-state diverges from the identity your posts carry |
The remaining droppers (who, list, search, pin/unpin, board, mute/unmute/kick, read_message) are read-only or target-other and are listed for completeness of the sweep, not as defects.
Relation to prior issues (all checked before filing)
Fix directions (implementer's ruling, both stated)
- Mechanical: thread
name through all identity-dependent handlers so every action derives the id join derived. Small, but it preserves per-call derivation — the class regenerates the next time a handler is added without the thread.
- Structural: resolve identity once per session (at first action or server init) and stop deriving per-call from optional parameters. Kills the class: a handler cannot drop what it never had to carry. Consistent with the direction of moving resolution server-side.
Either way, the fix carries discriminating witnesses per the witness's own ask: a join → unread pair asserting the SAME resolved id on both sides (with the resolved id recorded in the assertion, not just the outcome), a heartbeat witness asserting the refreshed presence row is the joined row, and a control where SYNAPT_AGENT_ID is set proving the registered path never diverged.
Symptom, witnessed twice in one day (2026-08-20)
Two agents in different workspaces, independently, same sequence over the MCP
recall_channeltool:join(channel="dev", name="Atlas")→Joined #dev as Atlas (a_ee2af0db)— acknowledged, membership written.unread(channel="dev", name="Atlas")10 milliseconds later, same session →No channel memberships -- join a channel first.The exact-transcript witness (timestamps from the MCP end events): unread at
09:43:14.686Zfailed, join at09:43:20.455Zsucceeded naming the id, unread at09:43:20.465Zfailed again. A subsequentreadsucceeded (channel reads are log-path-based and don't consult memberships) and laterpostcalls succeeded and attributed correctly. The second witness reported the identical join-ack-then-no-memberships shape in a different workspace with a different display name.Mechanism — every link read from source, and the id reproduced from the formula
Function names are the anchors here; line numbers are deliberately omitted because they drift (this issue's first draft cited them and they were already stale against dev by filing time).
_agent_id(channel.py) resolves in three branches:SYNAPT_AGENT_IDenv → name-derived"a_" + sha256(f"named:{griptree}:{name}")[:8]→ session-scoped"s_" + sha256(f"{griptree}:{data_dir}:{ppid}")[:8].channel_join(channel.py) derives with the name:_agent_id(project_dir, name=display_name). The membership row is written under the name-derived id.channel_unreadandchannel_unread_read(channel.py) derive without it:_agent_id(project_dir).nameon every action and the unread handler drops it:_handle_unread(actions.py) callschannel_unread_read(limit=…, show_pins=…, detail=…)— no identity argument at all.So for a session with no
SYNAPT_AGENT_ID, join writes the membership under thea_*id and unread queriesmemberships WHERE agent_id = <s_* session hash>: zero rows, deterministically, forever. Not a race, not staleness — two different pure functions of the same inputs.Derivation proof: the id the witnessed join returned is exactly what
_agent_id's named branch produces from that session's workspace directory name and display name — computed independently from the formula and compared against the transcript above, byte-identical. The formula is reader-checkable on neutral inputs:"a_" + sha256("named:my-workspace:Alice").hexdigest()[:8]=a_300f4ee0. Formula, salt, and truncation are exactly_agent_id's named branch.Why most sessions never see it: any session with
SYNAPT_AGENT_IDset (e.g. spawned viagr) short-circuits both derivations to the same registered id at resolution step 1, and a session that never passesname=gets the sames_*hash on both sides. The defect bites exactly the named-but-unregistered sessions — which includes every external user who passesname=to feel like a person instead of a hash.This is a class, not an unread bug — sweep derived by grep over the handlers
Of the 22 action handlers in
actions.py, exactly 4 thread identity through (join,post,directive,broadcast). The other 18 derive the nameless id — which diverges from join's id precisely in the named-but-unregistered case above. The identity-DEPENDENT members of those 18, each a live defect for such a session:unreadheartbeats_*presence row while the reala_*row goes stale toward reaping — an agent heartbeating correctly still gets marked offlineleaveclaim/unclaim/intentunclaimbreaks across sessions because thes_*hash includes the parent pidrenamereads_*id, so read-state diverges from the identity your posts carryThe remaining droppers (
who,list,search,pin/unpin,board,mute/unmute/kick,read_message) are read-only or target-other and are listed for completeness of the sweep, not as defects.Relation to prior issues (all checked before filing)
nameparameter was threaded into the derivation, and two surfaces of the same tool disagree on it per call.Fix directions (implementer's ruling, both stated)
namethrough all identity-dependent handlers so every action derives the id join derived. Small, but it preserves per-call derivation — the class regenerates the next time a handler is added without the thread.Either way, the fix carries discriminating witnesses per the witness's own ask: a join → unread pair asserting the SAME resolved id on both sides (with the resolved id recorded in the assertion, not just the outcome), a heartbeat witness asserting the refreshed presence row is the joined row, and a control where
SYNAPT_AGENT_IDis set proving the registered path never diverged.