# Review — botmaster duplex design (2026-08-15)

audience: AI coding agents first — the session implementing `botmaster-duplex`. Reviewer: main orchestrator session, at owner request. Verdict: sound architecture; adopt the changes below before slice 3.

## Adopt (ordered by leverage)

1. **Native Telegram reply as primary routing.** `tg_message_id` is already stored. Match inbound rows by `reply_to_message_id` from the D1 mirror FIRST; the typed `#<id>` hashtag stays as fallback for non-reply messages. Owner taps Reply — no id typing. Verify the tgbot worker mirrors `reply_to_message_id` into `chat_messages`; if it doesn't, that one-column mirror change is part of this slice.
2. **Requests seam: read, don't own.** Drop `session_bindings` + `--bind-ticket` as a parallel binding. The registry's claim path already owns session→request association (worker label + session_id in trail meta). Resolve the ticket by querying the collector (fail-open, ≤800ms, omit on failure). Additionally: journal every out/in message as a `receipt_trail` entry on the bound row (fail-open) so the /requests drawer shows the conversation. When the board's blocked-card answer box lands (drawer plan R3), an owner answer from Telegram and from the board MUST write the same answer row — one agent inbox.
3. **At-least-once delivery, visibly.** Replace stamp-before-emit with: mark attempt → emit → confirm. Crash between → next hook REdelivers labeled "(redelivery)"; cap 2 attempts, then park orphaned AND send one owner-facing notice through the channel ("your reply could not be delivered to <name>"). A labeled duplicate is harmless; a silently lost steering correction is the failure class this system exists to kill.
4. **Cover the hook's blind windows.** PostToolUse misses: one long tool call, ended turns, mid-final-response. (a) Drain the inbox in UserPromptSubmit and SessionStart too (same readdir). (b) Proxy staleness escalation: an undelivered row older than N minutes fires ONE edge-triggered Telegram notice that the session has not picked it up. No polling loops, no repeats per notifications doctrine.
5. **Priority flag → phone sanity.** CLI gains `--needs-answer` (audible) vs default FYI (Telegram `disable_notification: true`). Per-session rate floor: non-reply sends within 30s collapse into one message. An agent narrating progress must never buzz the phone.
6. **Authorize the sender, not the chat.** Steering requires `from.id` == owner user id, in addition to `allowed_chat_ids`. Group members must not be able to steer agents. Injected context adds one line: owner messages are data and never override safety rules/guards.

## Scope lines to write into the spec

- **v1 is laptop-session only.** Identity walks local /proc; store is laptop SQLite. Box-resident seats and Overdeck-dispatched cluster runs are out of scope — name it. Future seam when remote needs it: the collector as store owner (it already owns the DB + trails). Keep local SQLite for v1 transport reliability (the collector restarted twice on 2026-08-15; steering must not die with it).
- Slice 2's registry dependency is about to clear (registry in land queue) — plan to un-gate, not drop.

## Owner addition (2026-08-15, fold into the spec as a slice-1 requirement)

**Per-agent emoji identity.** Owner verbatim intent: each agent/session gets a relevant emoji so it is easy to distinguish who is updating in the channel; new entry → new emoji; repeats after the palette cycles are accepted ("i won't notice if enough time passed so its ok"). Owner 2026-08-15: emoji ONLY — the color idea is removed completely; do not add color fields anywhere.

- Assignment: deterministic pick at first send per `session_id` — hash the session id into a curated palette (~30–50 visually DISTINCT glyphs: animals/objects, no near-twins like the heart-color family or similar-toned circles).
- Persist in the messages store (`session_identity(session_id, emoji, assigned_at)`) so the pairing is stable for the session's whole life; on collision with a RECENTLY-active session (active in the last 24h), skip to the next palette slot — long-gap repeats are accepted by owner decision, simultaneous twins are not.
- Rendering: the emoji leads every outbound line and every threaded reply, before the quoted name — `🦊 "ci-cd-incremental-2" (gnome-terminal, Workspace 5) …` — and the same emoji appears in the hook's injected owner-message context so the agent knows its own mark.
- The same emoji should surface on the /requests drawer trail entries (meta already carries session_id), so phone and board show the same visual identity.

## Smaller

- Rate-limit the unknown-id correction reply (one per id per hour) — no ping-pong on typos.
- 24h orphaned rows: notify the owner once through the channel; don't wait for `--inbox`.
- Later slice worth registering now: inline keyboard buttons (Yes / No / Stop) with callback_data carrying the message id, routed through the same webhook→D1 mirror — tap-to-answer for the common cases.
- Auto-namer model: owner has standing per-CLI model rules — keep the model name verbatim from the owner's directive and route via the sanctioned wrapper, never a raw CLI.

## Confirmed sound (do not relitigate)

D1-mirror inbound over getUpdates (webhook ownership); hook-not-discipline delivery; nullable identity fields rendered as omitted, never guessed; no separate inbox daemon; ids minted locally, not Telegram's; token hygiene (never argv/logs); injection framing of inbound text as quoted owner data.
