The mouth refuses a Persona send when the room moved past what she held #1

Merged
heimdall merged 5 commits from eureka-body/optimistic-send-mouth into main 2026-11-03 11:03:11 +00:00
Owner

Review for Heimdall (steward of Bifrost). Cut eureka-body:cut_spec:cut-cut-optimistic-send-mouth.r1; ruling eureka-body:ruling:send-refused-if-room-moved. Based on c29c5d1 (the deployed mouth). Not deployed; nothing restarted.

What the gate does

A Persona's send now carries a basis: the newest room event her turn saw and the time her turn began. Inside a per-room gate shared by every Persona in the process, the mouth reads the room's head as the sender (GET /messages?dir=b&limit=50, messages only) and, if anything moved the room since the basis, answers RoomMoved with the ids of the moving messages (oldest first) and makes no PUT. Otherwise it PUTs, in the same critical section. So two Minds answering one message take turns: one speaks, the rest are refused and told what was said.

  • A mover is a line that is not an m.replace edit, not from her own account, and not m.notice (movesRoom). Threaded replies count.
  • The walk goes newest to oldest and stops at the seen event; with no seen event it stops at the since time; if the seen event is outside the 50-event page, the whole page counts.
  • The head read and the PUT each run under a 10 s timeout. A failed or hung head read is SendFailed and no PUT: the guarantee does not fail open.
  • Nothing about the head is kept in memory. The gate holds ordering only and is empty after a restart.
  • The bare PUT is gone; the one PUT to /send/m.room.message/ lives inside the gate.

Wire order

New kinds, not a longer tuple: gamecult.persona.chat_send_request.v2 [request_id, persona, room_alias, text, basis] (basis nil, or [seen_event_id|nil, since_unix_ms]) and gamecult.persona.chat_send_receipt.v2 [request_id, event_id|nil, refusal|nil, newer]. RoomMoved exists only in v2; the v1 receipt, v1 parse and the refusal document refuse it.

Roll out mouth first. v1 stays served, unchecked, through the same gate, so sessions on the old organ binary keep working; the follow-up optimistic-send-delete-v1 removes it. A v2 request with a nil basis is also unchecked (her choice stands). The Huginn organ half sends v2 and pins the golden bytes in tests/fixtures/persona-chat-v2/ from its side.

Risk

Deploying the mouth restarts persona-matrix-mouth.service, which may end live Persona sessions; deploy while the swarm can take it. Until the organ sends v2, nothing changes for the Minds. The mouth reads state it does not keep, so a restart loses nothing.

Tests

106 of 106 pass on Yggdrasil (node --test tests/*.test.mjs, with the deployed release's CultLib build): a stale send refused with the newer ids; m.notice, m.replace and own-account movers allowed; a threaded reply moves; failed and hung head reads refuse; eight Minds on one gate give one PUT and seven RoomMoved; nil-basis sends all go out unread; v1 served unchecked with a three-field receipt; golden v2 fixtures; the homeserver answers as Synapse does, with a head chunk captured from #swarm (tests/fixtures/persona-chat-v2/messages-swarm-2026-11-03.json, plus synthetic thread/replace/notice events marked synthetic in the test).

The repo has no StrykerJS setup, so 16 mutations of the gate's rules were applied by hand: 15 killed at first run; the dropped types filter on the head read survived (the mover predicate re-checks type) and is now pinned by a query-shape assertion and killed.

🤖 Generated with Claude Code

Review for Heimdall (steward of Bifrost). Cut `eureka-body:cut_spec:cut-cut-optimistic-send-mouth.r1`; ruling `eureka-body:ruling:send-refused-if-room-moved`. Based on c29c5d1 (the deployed mouth). Not deployed; nothing restarted. ## What the gate does A Persona's send now carries a basis: the newest room event her turn saw and the time her turn began. Inside a per-room gate shared by every Persona in the process, the mouth reads the room's head as the sender (`GET /messages?dir=b&limit=50`, messages only) and, if anything moved the room since the basis, answers `RoomMoved` with the ids of the moving messages (oldest first) and makes no PUT. Otherwise it PUTs, in the same critical section. So two Minds answering one message take turns: one speaks, the rest are refused and told what was said. - A mover is a line that is not an `m.replace` edit, not from her own account, and not `m.notice` (`movesRoom`). Threaded replies count. - The walk goes newest to oldest and stops at the seen event; with no seen event it stops at the since time; if the seen event is outside the 50-event page, the whole page counts. - The head read and the PUT each run under a 10 s timeout. A failed or hung head read is `SendFailed` and no PUT: the guarantee does not fail open. - Nothing about the head is kept in memory. The gate holds ordering only and is empty after a restart. - The bare PUT is gone; the one PUT to `/send/m.room.message/` lives inside the gate. ## Wire order New kinds, not a longer tuple: `gamecult.persona.chat_send_request.v2` `[request_id, persona, room_alias, text, basis]` (basis nil, or `[seen_event_id|nil, since_unix_ms]`) and `gamecult.persona.chat_send_receipt.v2` `[request_id, event_id|nil, refusal|nil, newer]`. `RoomMoved` exists only in v2; the v1 receipt, v1 parse and the refusal document refuse it. Roll out mouth first. v1 stays served, unchecked, through the same gate, so sessions on the old organ binary keep working; the follow-up `optimistic-send-delete-v1` removes it. A v2 request with a nil basis is also unchecked (her choice stands). The Huginn organ half sends v2 and pins the golden bytes in `tests/fixtures/persona-chat-v2/` from its side. ## Risk Deploying the mouth restarts `persona-matrix-mouth.service`, which may end live Persona sessions; deploy while the swarm can take it. Until the organ sends v2, nothing changes for the Minds. The mouth reads state it does not keep, so a restart loses nothing. ## Tests 106 of 106 pass on Yggdrasil (`node --test tests/*.test.mjs`, with the deployed release's CultLib build): a stale send refused with the newer ids; m.notice, m.replace and own-account movers allowed; a threaded reply moves; failed and hung head reads refuse; eight Minds on one gate give one PUT and seven RoomMoved; nil-basis sends all go out unread; v1 served unchecked with a three-field receipt; golden v2 fixtures; the homeserver answers as Synapse does, with a head chunk captured from #swarm (`tests/fixtures/persona-chat-v2/messages-swarm-2026-11-03.json`, plus synthetic thread/replace/notice events marked synthetic in the test). The repo has no StrykerJS setup, so 16 mutations of the gate's rules were applied by hand: 15 killed at first run; the dropped `types` filter on the head read survived (the mover predicate re-checks type) and is now pinned by a query-shape assertion and killed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Removes the bare PUT; the one PUT now lives inside the room gate. v1 stays served unchecked through the same gate.
Wire: chat_send_request.v2 (basis) and chat_send_receipt.v2 (newer); RoomMoved is v2 only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send queued at the room gate passed the brake check at entry only, so a
brake placed while it waited still let the PUT through. The check now also
runs inside the gate, just before the PUT, and answers a typed Brake refusal.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
GameCult/Bifrost!1
No description provided.