Skip to content

CHOO-2789: Extend session activity and approvals to all messaging platforms - #484

Draft
simonflk-sb wants to merge 96 commits into
work/rich-session-activity-slackfrom
work/refresh-messaging-platforms
Draft

simonflk-sb wants to merge 96 commits into
work/rich-session-activity-slackfrom
work/refresh-messaging-platforms

Conversation

@simonflk-sb

@simonflk-sb simonflk-sb commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Purpose

Bring live agent status and answerable permission requests to Mattermost, Discord, Telegram and Teams, building on the Slack implementation in #394. People can follow a turn and respond from the messaging app they already use.

Key Changes

  • Publish session status and request cards on the four additional platforms. Update status in place and retain the final duration when work ends; Telegram omits routine tool details to keep the conversation compact.
  • Add Telegram approval buttons alongside typed answers. Preserve approval scope, verify the answering user, and direct requests to Console when the complete decision cannot fit in a card.
  • Lead settled permission cards with the chosen outcome. On Telegram, omit duplicated option text where the buttons carry the complete choice; keep long labels and session-scope explanations in the body.
  • Preserve the original conversation when posting and updating cards. Persist Teams' confirmed message address so edits still reach the correct conversation after a restart.
  • Distinguish refused, throttled and uncertain sends; respect retry delays and platform payload limits. Label queued prompts explicitly and distinguish unconfirmed delivery from a confirmed failure.

Design Decisions

  • Reuse the shared session publisher and answer authority, with platform-specific rendering and delivery capabilities.
  • Replace the migrated adapters' legacy runtime-state rendering with the shared SDK publication path. Legacy-code removal is included in this PR and remains to be completed.
  • Retain uncertain reservations where delivery cannot be established. Telegram can issue a single Console fallback notice; Teams does not inherit that notification policy. Discord's uncertain-send recovery and duplicate-delivery limitations remain documented follow-up work.

Technical Notes

Stacked on #394: base work/rich-session-activity-slack, head work/refresh-messaging-platforms.

This comparison also includes upstream commits 901d3f35 (durable room follow-ups after reset/compaction) and cee01b0f (clearer automatic-session failure wording), merged via 1a676117. Neither commit is in the Slack base at 7906d058.

Release Notes

Important

Run just migrate before running the updated server. This adds the unconfirmed-card notice timestamp and the upstream room-control follow-up column; the migration chain is joined in this branch.

Testing

  • At 82ad1b92, the implementation author reports clean formatting, lint and mypy across 277 source files, with 1,980 session and collaboration tests passing.
  • Independent reviews exercised targeted adapter, renderer and lifecycle regressions at earlier pinned checkpoints. The latest reaction-save fix still awaits focused reviewer verification.
  • Earlier implementation checkpoints exercised the Postgres-backed session and collaboration suites. No full-suite pass is claimed for this head; previous full runs retained the existing raw-session tenant-allowlist failure.
  • Human testing has exercised Teams, Discord and Telegram, including Telegram permission buttons. Restart, permission-loss and delivery-failure edge cases remain incompletely verified live.

Remaining Before Ready for Review

  • Complete legacy-renderer cleanup and verify the retained publication paths.
  • Complete focused review of outstanding fixes and the remaining manual checks.

Recovery redesign and queued-message interruption controls are deferred.

simonflk-sb and others added 30 commits September 14, 2026 18:31
Mattermost now draws turns and requests through the rich-content seam
instead of the legacy runtime-state renderer, which is switched off here
(renders_legacy_runtime_state = False) so the two cannot both draw.

The platform's own presentation:

- One compact status post per turn, posted by the agent's own bot in the
  thread and edited in place. No separate tool log: Mattermost cannot
  collapse one, and the status carries the counts.
- A problem somebody has to act on still gets its own reply, so it
  notifies rather than arriving as a silent edit. Edited when it clears,
  never deleted — Mattermost leaves "(message deleted)" behind.
- Request cards as plain text with the typed-answer grammar spelled out
  against the particular form.
- Per-agent 👀 on the asking message, claimed and released per agent.

Shared code grew two capability flags for this. separate_attention_slot
splits the attention reply from separate_activity_log, which Slack had
conflated because it wants both. activity_reactions_per_agent scopes the
reaction claim, the in-memory holder key and the journal's reaction_held
query by agent, so one agent finishing does not strip another's mark.

SessionRequestCards now carries its surface, which the publisher reads
rather than being told separately. That replaces two hard-coded "slack"
literals: the queued-turn acknowledgement in refresh_activity and the
responder lookup in refresh, both of which were Slack-only by accident of
being written for Slack first.

The neutral renderer gains turn_status and request_summary, and the
pieces both platforms need (CLOSED, SURFACES, NO_OPTIONS, unanswerable,
example_value) are lifted out of slack.py unchanged.

The legacy Mattermost renderer and its tests stay: removing them is the
next task, and until then those tests drive the private methods directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six review findings on the Mattermost SDK parity work.

Uncertain sends. `RichContentFailed` is the publisher's licence to drop a
reservation and post again, so the adapter now raises it only for a refusal
Mattermost actually gave: the `mattermostdriver` exceptions that map to 4xx
statuses it understood. A 429 becomes `RichContentThrottled` carrying
`Retry-After`. Everything else — a timeout, a dropped connection, a 5xx —
propagates as itself, the reservation survives, and `find_request_card`
settles whether the post landed.

Reaction cleanup. `mark_activity` went through a handler that logged and
returned, so durable publication wrote a completion receipt for a turn that
still had eyes on it, and a missing bot read as success. The raising
`_react_or_raise` is now the SDK seam; the swallowing wrapper stays for the
legacy runtime path, which has nothing to retry with. A delete Mattermost
says is not there is still success — the channel is already as asked.

Approval and question forms. Option labels were clipped to 150 characters
while the form still said "Reply with `R42 1`", so two options differing
past the ceiling were the same choice under two numbers. Labels now get a
share of what the message can hold, an "accept for this session" option says
its scope on the form, and a form that could not be shown whole — a cut
label, a question left out, a body `_compose` had to trim — drops the
instruction and names Console instead.

Timer redraws. `redraws_for_elapsed_time` decides whether the clock alone is
reason to republish a running turn. Slack opts in, where the status is a
small line of its own; Mattermost does not, where it is the turn's one post.
The duplicate-draw gate in `_edit` now applies everywhere rather than only
where the activity log is separate, keyed on what the status actually shows.

Notification recipient. `notifies_only_by_mention` names the agent's owner
ahead of whoever typed the command, on platforms where an unnamed reader is
an unnotified one. The other is always the fallback. An attention post with
nobody to name says so, charged to the same character budget, restoring the
explanation the legacy ping gave.

Trigger-location typing. `notify_working` is a one-shot nudge sent as a turn
opens, at the place the work was asked for and never on a redraw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gaps left by the previous pass.

Clipping that the answer gate never saw. Option labels counted toward
"is this form showing itself whole", but the approval title and detail, the
question prompt and the option descriptions did not, so a permission detail
naming a second operation past 1000 characters still invited `R42 1`, and
two options distinguished only past 200 characters still read as the same
choice twice. Every piece of a form a reader decides on now goes through one
fitter that records whether it had to cut, so a budget cannot be added
without the check that goes with it. Descriptions get the label budget for
the same reason labels did. `_compose` drops a head line it cannot fit and
has no count to report for it, which was the same hole from the other end;
that now counts as cut too.

A card asked of nobody. `notify_unreachable` reached `TurnActivity` only, so
an open request that named no one rendered as if it had notified someone.
`RequestCard` carries it too, set on the first post of an open card and
never on a redraw — where the mention is dropped deliberately, having
already been made. Mattermost appends the same notice it appends to a
status, charged to the same budget as the form and the handle above it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The owner-first preference went in as a guess at who a stalled session
needs. The person waiting on the answer is the better guess: on a platform
where the mention is the whole notification, naming somebody else leaves
the asker watching a channel that never says their name. The owner stays
as the fallback, so a turn started by someone with no account here still
reaches somebody.

That is what the function did before this branch, so the platform-dependent
preference is gone rather than inverted, along with the flag that selected
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Discord now carries a turn the way Slack and Mattermost do: one status
message per turn, posted into the turn's thread under the agent's webhook
identity, edited in place as the turn moves, and a separate reply when
something needs a person. A question names the asker, because Discord
subscribes you to a thread you started, were mentioned in, or have spoken
in and to nothing else — a reply that names nobody reaches nobody.

Two behaviours are Discord's own. A finished turn's status is deleted when
it sits at a channel root and kept when it sits in a thread, so a busy
channel is not left with a row of completed statuses while a thread keeps
its record. And a webhook message carries no metadata, so recovery after an
uncertain send matches the request's visible handle rather than a hidden
marker; `find_request_card` takes that handle, and an activity publication,
which has none, cannot be recovered that way.

The failure contract is what decides whether a reservation survives. A 4xx
is Discord refusing and becomes `RichContentFailed`; a 5xx, a timeout or a
dropped connection is not an answer and propagates as itself, so the caller
keeps its reservation and settles it by searching instead of asking the
same question twice.

The legacy runtime-state renderer is still present but no longer reachable,
so its tests drive `_apply_runtime_state` directly. Removing it is its own
task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six defects found in review of 433a46c, all in how a session publication
is placed, found again, and failed.

Destination. A publication whose thread could not be resolved fell back to
the parent channel whatever the reason. A private thread the bot has lost
access to and a thread that does not exist yet are indistinguishable from
the error alone, and only one of them makes the parent an acceptable place
for a request card: the other hands a private conversation's question and
options to an audience that was never in it. The fallback now applies to
exactly one case — no thread exists and none could be made, so the channel
root the turn was addressed at is still its origin. Everything else refuses
before anything is sent, which leaves the caller's reservation intact.

Recovery. Publications now go through a second webhook of their own,
"Switch Sessions", which nothing else ever posts to. Sharing the agents'
webhook made authorship useless as evidence — an agent's reply saying "the
request `R7`" satisfied the same test as the card, and binding to it meant
every later settlement edit overwrote a sentence while the real card went
on saying the request was open. Among publications, a card is picked out by
its heading line: bold from the first character, ending in its own handle,
matched line by line rather than at the top, because the top of a card is
the mention that notifies whoever asked. In a DM, where there are no
webhooks, the author is the bot and the heading test carries the weight.
That is weaker, and is written down rather than left to be discovered.

Throttling. A 429 reaches this seam two ways. The library raises
RateLimited when it declines to sleep through one; the webhook transport
raises a plain HTTPException once it has exhausted its own retries, or when
the response lacks the header it classifies by. Only the first was read as
a throttle, so the second discarded the reservation and threw away the
delay Discord had just supplied. Both are throttles now, taking the delay
from the Retry-After header and falling back to five seconds — a floor,
because retrying a throttle immediately is how a throttle becomes a ban.

Also: a DM redraw reads the agent's name back off the message it is about
to edit when the in-memory note of who posted it is gone, since in a DM the
body is the only durable record of the sender; and a Forbidden on reaction
*removal* is logged as an error rather than as the same shrug as a reaction
that could not be added, because a mark left on a finished turn is a false
statement rather than an absence and no retry takes it back.

Two findings are deliberately not fixed. A status still cannot be recovered
after an uncertain send, and the library's webhook sender can still
duplicate a post by retrying a 5xx below us. They are one trade-off, not
two — both are the price of per-agent webhook identity on the status — and
choosing between that identity and a bot post with an idempotency nonce is
a product decision. Recorded as D17 for Simon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Telegram gains the SDK publication path the other adapters have: its own
send and edit seam that raises instead of returning nothing, HTML markup,
the expandable activity block inside the one status message, forum topic
ids kept apart from reply targets, per-chat pacing that always sends the
final state, and the capability flags that describe all of it.

Two shared seams moved to make that possible.

`update_rich` now takes the agent's name, and so does
`SessionRequestCards.refresh`. A platform that writes the name into the
message body — Telegram always, Discord in a DM — could only rebuild it
from an in-memory map, so a restart mid-turn redrew the message as
somebody else. The name now comes from the caller on every call, and both
per-adapter maps are deleted. The neutral renderer takes a markup seam for
the same reason: Telegram's body is HTML, and one renderer with a markup
argument beats a second copy of the budget and faithfulness logic.

The rest is delivery under uncertainty. A Telegram bot cannot search its
own history, so a send whose outcome is unknown can never be resolved: the
row stays reserved with `external_post_id` equal to its token, the answer
guard refuses every typed answer to it, and the publisher retries a lookup
that will never succeed. A question sits in the chat and typing the answer
does nothing.

`recovers_uncertain_posts` separates the two meanings of `None` from
`find_request_card` — "not there yet" on Slack, Mattermost and Discord,
"never" here. Where it is false the publisher sends one message naming the
request and linking Console, records it on the new
`unconfirmed_notice_at` column, and then leaves the row alone instead of
failing the publish on every cycle. The column is written before the send,
so a crash loses the notice rather than repeating it; a notice that cannot
be sent is logged and not retried. The reservation and the answer guard are
untouched, and nothing is reposted.

A turn's status goes the other way: an unrecoverable status reservation is
released and the slot posts again, with a warning that it may duplicate a
message already in the chat. Holding it would also swallow that turn's
later attention message, and a silent turn is the worse fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A status whose send was never acknowledged keeps its reservation for good
where the platform cannot search for it, instead of being reposted on every
cycle. Attention is published even when the status cannot be settled, so the
turn keeps its one way of saying something went wrong.

A finished turn's status is taken out of the chat in flat chats and forum
topics alike, the way the legacy indicator was: a Telegram chat is the
conversation, not a side channel, and a permanent "Worked for 12s" per turn
is litter. Request cards and anything still reporting a problem or an
unreached reader stay. A deletion Telegram refuses leaves the final state
showing and says so; an unknown outcome is raised so the publisher can come
back to it.

A publication with a thread root now refuses definitely rather than
detaching: a card that loses its reply target is the agent's question put to
the whole chat, and an answer typed at it there binds a request those readers
never saw. Relayed conversation keeps the old widening behaviour.

Removing a working mark this process added is raised when Telegram refuses,
so a turn is not recorded as cleaned up while the chat still shows it
running. A refused addition, and a reconciling removal of a mark this process
never added, stay reported-once as before.

Pacing is charged to the chat and covers sends as well as edits, per the
constraints note: Telegram's 429 is the chat's and several agents publish
into one group. Terminal states, cleanup, attention and cards are never
held back. The comments no longer assert an edit quota Telegram has not
published.

The status carries the expandable fold it was said to: the latest few tool
calls and a count of the rest, inside `<blockquote expandable>` assembled
after escaping, dropped first when the status is already near the limit.

Typing goes to the forum topic the command came from, rather than to the
chat at large.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Telegram card has had no controls: an approval could be read in the
chat but only answered by typing. This puts the options on the message
as buttons and takes the press back through the same authority a typed
answer goes through.

The obstacle is the payload. Telegram allows 64 bytes for everything a
press hands back, and an option id is an unbounded, possibly non-ASCII
string the host chose — so a button cannot carry one and a press that
did would be a press we could not fit. It sends where the control was on
the card instead, and the server resolves that against the form the card
rendered. That is the stronger of the two, not a concession: a press can
only ever name an option the card actually offered, whatever reached us.

So the shape is shared rather than Telegram's. `offered_controls` states
once which requests a press can answer — one question, one choice out of
a list, still open — mirroring what the resolver will accept, so a card
does not draw a control whose press is going to be refused.
`resolve_pressed_position` turns a position into an option id against
the record and hands it to the existing resolver, which keeps what a
press means on each kind of card in one place.

On the adapter: a keyboard on a card, redrawn on every edit so a settled
card loses its buttons without anything having to remember it had them;
a press attributed to `from_user`, which Telegram signs and the payload
cannot claim; and an acknowledgement on every path out, including the
ones where nothing happened, since an unanswered press spins on the
presser's client until it gives up. A refusal reaches the presser as an
alert on the press itself rather than a message in the chat — a bot
cannot message someone who has not opened a chat with it, so this is
also the only private reply Telegram offers in a group. A press that
succeeds is acknowledged with no text: the card's redraw says an answer
was taken, and saying so here would say it before the redraw proves it.

A payload that would exceed the limit refuses the card rather than
posting it without buttons, so a card never looks answerable by typing
alone when it was meant to be pressable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…privately

Two defects found reviewing the Telegram interaction seam.

A card whose body had to be cut still drew live buttons. The footer under a
clipped form says the request cannot be answered from this message, but the
keyboard beside it would still resolve against the saved form and settle the
request on text the reader was never shown. The keyboard was deciding
pressability from `unavailable_reason` alone, which is a different question
from the one the renderer had already answered while composing.

The renderer now returns that answer with the drawing. `render_request` yields
a `Drawn(text, answerable)`; `request_summary` stays as it was for the three
platforms whose drawing is the whole of the card. Telegram carries the `Drawn`
from `_draw` through `_render_rich` to `_controls`, on send and on redraw, so
the buttons and the sentence under them cannot disagree. A truncated button
label is deliberately still pressable: the body above it carries the option in
full.

An answer turned down by authority went out through `admin_message`, so a
rejected approval was posted to the whole channel while the presser's client
showed nothing. It now goes through `tell_actor` like the refusals the card
itself gives — privately on a platform that has a private reply, and otherwise
into the card's thread, which is where it used to post anyway. The press path
passes no thread ref, because the reply to a press is addressed by the press.
Typed answers keep the card's thread and the answerer's name.

The comment claiming every refused press was already private is corrected: it
was true of the resolution branch, not of this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Telegram and Discord both swallowed a refused reaction and returned as
though it had worked. For a refused *addition* that is right: reactions
are switched off in the chat, or the bot lacks the permission, and the
turn should go on without the mark rather than retry it forever. For a
refused *removal* it is a false statement — the 👀 is still on the
message, the channel shows an agent working on something it finished,
and reporting the turn as drawn stops the publisher ever asking again.

Telegram tried to tell the two apart from `self._reacted`, a set a
restart empties. Empty then means "no idea", not "nothing was added", so
after a restart every stuck mark read as nothing to remove. Discord did
not try at all and logged both.

Both now raise `ActivityMarkRefused`, which says only that another
attempt will be refused the same way; anything a retry might fix still
raises as itself. The publisher decides what a refusal means, because
the question — was a mark ever put there — is answered by the durable
journal record and not by adapter memory. A refused addition is recorded
on the turn's own record, and only that recorded refusal lets a refused
removal count as done. Absence of evidence means the cleanup is
outstanding and the turn is not drawn.

Discord's pre-SDK path keeps deciding for itself, which is sound there:
it attempts a removal only for a mark this process added, so its own
memory is enough to know the mark is outstanding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of e48d1e7 found the evidence attached to the wrong thing. Turns
share one reaction: only the first to claim an asking message adds the
mark, only the last to finish takes it off, and those are rarely the same
turn. Recording a refusal on the turn that hit it therefore answered a
question about the mark with a fact about a turn, and got it wrong twice.

With reactions switched off, the first turn recorded its refusal and then
completed, which compacts its row to a receipt and discarded it; the last
holder found nothing, assumed a mark it had to remove, and never
finished. In the other direction a turn's historical refusal outlived the
moment it described: once another turn had successfully added the shared
mark, the refused turn still read its own old refusal as proof the
message was clean and reported a finished cleanup with the eyes still on
it. Neither needed a restart.

So the evidence is now keyed by the reaction — channel, asking message,
and the agent where each reacts as its own bot — and says only "this may
be on the message". It is written before the platform is called, because
an addition that fails without an answer may still have landed, and it is
retracted only when the platform states outright that nothing is there:
a refused addition, or a removal that worked. Completion carries it into
the receipt, because the holder that must remove the mark may be a later
turn in a later process. Any holder's standing expectation, ended or not,
makes a refused removal outstanding.

A publisher with no journal kept the same rule instead of assuming the
mark was absent: it has no durable state to be restarted into, so its own
memory is the whole truth, and it now owes cleanup for a mark it added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A request card whose thread could not be resolved was posted to the parent
channel. Discord answers "unknown channel" for a thread that was never
started and for one that existed and has been deleted, and the adapter read
both as "the turn was addressed at the channel root". For a private thread
that has since been deleted that is false: the card carries the agent's
question and its options, and the parent channel is an audience that was
never in the conversation. Nothing takes that back.

The distinction cannot be recovered from the platform, only from the
recorded origin of the command, so the decision moves to where the origin
is known. `_publication_thread` now raises `ThreadUnavailable` — a
statement that no thread exists and none could be made, with no view on
what to do about it — and never substitutes the channel. A thread that
exists and will not open still raises as itself, because that one may be
private and is nobody's licence to use the parent.

`SessionRequestCards.post` takes `asked_at_root`, derived from
`origin.thread_id is None`, and only a card that was asked at the channel
root is posted there when its thread is unavailable; the fallback is
logged as before. Asked in a thread, nothing is posted, the handle is
released and the caller is told, which is the same path as any other
refusal and is retried on the next cycle.

A card answering a card carries its destination as its origin thread, so
it reads as thread-asked and merely refuses the fallback it would not
have needed. Turn activity is unchanged: it never fell back, and
`ThreadUnavailable` is a `RichContentFailed` like the error it replaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A platform that cannot search for what it posted has no way to finish a
reservation whose send was never acknowledged. The publisher treated
that as a fresh failure on every pass: two tracebacks every five seconds
for the life of the process, and the session never entered the
"published" set, so its whole pass re-ran each cycle and the failures
that were new were buried under one that never changes.

Close the question instead of asking it forever. The decision is stamped
into the slot's existing journal entry as `abandoned_at`, so the record
distinguishes a reservation still being chased from one given up on, and
a restart can tell an old conclusion from one it has just reached. No
schema change: `ActivityRecord.data` is already a JSON dict we write.

`ActivityAbandoned` carries the decision to callers, and the callers
differ in what it costs them. A status is the whole of a turn's display,
so the publisher holds the turn back permanently and stops counting it.
An attention message is one message beside a status that is still being
drawn, so it is swallowed where it is raised — Telegram publishes a
separate attention slot and cannot search, and writing the turn off
there would have frozen a display that works.

Reported once per slot, where the decision is made, at warning level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Abandoning a turn's status held the whole turn back: the publisher checks
that before it works out state or an error summary, so a turn that later
failed never said so, its attention message never went out, and the journal
receipt and the `:eyes:` on the asking message were never cleared. One
message that cannot be confirmed is one message; the turn around it still
owes everything else.

The decision now stays where it is made. A status nothing can confirm
counts as drawn as far as it can go, so the next state is free to publish,
attention still goes out beside it, and an ended turn is still tidied up.

Tell "no marker to search for" apart from "a search came back empty".
Discord finds a card by the handle the card prints, and a turn's own
messages print none, so a lookup for one can never match — but a lookup
that misses on Slack or Mattermost means "not yet", and must be asked
again. A new `carries_publication_marker` capability says which an adapter
is, rather than inferring permanence from an empty result. It describes
this bridge's publications, not the platform: an adapter that starts
carrying a marker sets it True and recovers what it could not before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One reaction is shared by every turn working on the same message, and the
evidence that it may be on there is shared with it. Two ways of clearing that
evidence cleared more of it than they had grounds to, and both end the same
way: a turn reports itself tidied up with the eyes still in plain sight and
nothing left to make it try again.

A refused addition now retracts the attempt it refused and nothing else. It is
an answer about one request, not a report on the message, and an earlier turn's
reaction can be sitting there regardless.

A removal now clears the claims that existed when it was sent. Its
acknowledgement can arrive after another publisher has started a turn on the
same message and marked it afresh, and that mark really is there.

The three sequences are covered against a real database: an addition refused
after a restart, an acknowledgement overtaken by another publisher, and the
same confusion inside one process, where memory is the only evidence there is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A turn asks for the reaction again every time it is published, so an addition
refused now can be the second attempt against a mark the first one already put
there — after a restart, or after a request whose answer was lost. Retracting
the turn's own earlier grounds on the strength of that refusal ends where the
last one did: a turn reporting itself tidied up with the eyes in plain sight.

The expectation is now retracted only by the attempt that recorded it. Both
sequences are covered against a real database, and both carry on to check the
mark does come off once the chat will take it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Teams was not part-migrated, it was unstarted: no capability declared it
published SDK sessions, so the bridge built no publisher for it and every
turn went through the legacy runtime-state renderer. It now publishes the
same way the other four do, and the legacy renderer no longer runs.

The connector had to go first. One flattened BotConnectorError told a caller
nothing it could act on, raw httpx exceptions escaped it entirely, and an
accepted activity with no id came back as the empty string. Nothing above it
could be truthful about a publication while that was the vocabulary. The
hierarchy is now cut on certainty rather than severity: a refusal means
nothing was written and the caller may throw its reservation away; a timeout,
a 5xx, or an acceptance with no id mean the message may well be sitting in
the channel, so they are their own kinds and the reservation survives them.
An activity over Teams' size is refused here, named in bytes, rather than
sent to earn a 413 nobody can read.

A Teams edit is addressed to the conversation, and inside a channel post the
conversation is named by the thread rather than by the message — so a redraw
has to be told which thread the publication went into. update_rich gains
thread_root_id on every adapter, required rather than defaulted, fed from the
journal's anchor and the card row. Both were already durable, so this needed
no migration; what was missing was the pass-through. The adapter deliberately
does not fall back to its own _sent or _last_post maps: the first is empty
after a restart and the second is the relay's guess at where an untied reply
belongs.

A finished status is taken out of a chat and left in place in a posts
channel, because Teams replaces a deleted message with a tombstone that says
less than the status it removed. A refused removal is not treated as a
removal that happened: the final state is written into the message instead
and the refusal is logged. An uncertain one is raised, because a status
recorded as cleaned up when it was not is one that never goes.

Card text is drawn with a markup that has no code span, since an Adaptive
Card TextBlock renders backticks as backticks and a handle is the one literal
a card exists to be answered with.

Held back deliberately, and none of it designed out: expandable activity
(what an update does to toggled state is undocumented and needs a tenant),
Action.Submit buttons (a listener change, not an adapter one), per-
conversation write serialisation, and Graph-based recovery of an uncertain
publication. With no recovery, the shared publisher discloses an unconfirmed
card — that is the shared default, not a Teams decision, and it is Simon's
to rule on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of c09e965 found five things, all of which came back to the same
habit: treating something this process worked out as if the platform had
said it.

A publication reference now carries its own address. post_rich returns
`teams1|serviceUrl|conversationId|activityId` rather than a bare activity
id, because a Teams edit is addressed to a conversation, the conversation
Teams opens may not be the one that was asked for, and the service URL is
regional and learned from inbound traffic. An update given one of these
knows where it is editing; given a bare id it rebuilds, says so, and marks
the address untrusted. A 404 completes a cleanup only at a trusted address —
at a rebuilt one it may only mean the guess was wrong, so it is raised. This
also fixes a chat card redrawn after a restart being rebuilt as a channel
thread, because "is this a channel?" was answered from an emptied cache;
it is now read off the address.

412 was reaching callers as RichContentFailed — the caller's licence to
discard a reservation — for a conflict Microsoft documents as retryable. It
is a short backoff now, on post, edit and delete, and the adapter serialises
its own writes per conversation behind a bounded lock map.

The unconfirmed-card notice was active on Teams by inheritance. Whether a
platform can search for a lost publication and whether it may say in the
channel that it cannot are now two flags: the second is False on the base
and True on Telegram alone, which is where it was agreed. A platform that
can do neither holds the reservation and logs it once, naming the card, the
channel and the request.

The first post of a card is on the same widening backoff the recovery search
is. A refused post leaves no row, so the next cycle reserved, posted and
released all over again, forever, against a destination that is saying no.
No public disclosure was invented for it; that is a decision about the
channel and is not this commit's to take.

A failed card refresh addresses its notice to the conversation the card is
in rather than to the card, so it lands somewhere on a platform where a
message id is not a conversation.

Also: a card whose recipient cannot be mentioned says so, and says something
different from "nobody is linked", because they are; a Connector response
that carries no usable id raises instead of yielding one; the size guard
names both the UTF-16 metric it measures and the UTF-8 bytes it sends; and
the `_claimants` docstring no longer carries a proof review disproved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A thread root reaches the Telegram adapter in two spellings. Inbound records
a bare number — the topic in a forum, the message replied to everywhere else.
The publication seam resolves its root through the message map, and what that
stores is this platform's own reference to a message, `chat:message`. All
three anchor helpers read the whole string as a number, so the composite form
failed: `_publication_anchor` refused the send, which is why no Telegram
permission card has ever been posted and the status was spinning on the same
refusal every thirty seconds; `_anchor_kwargs` detached and lost the quote;
`_topic_kwargs` silently widened a chat action to the whole forum.

One resolver now answers what a root names in a given chat, and the three
callers keep their own policy for one it cannot address. Its substantive
judgement is that a composite reference is a reply target even in a forum: it
names one message, and reading `75` out of `-100123:75` as a topic id would
aim the post at whichever topic happens to hold that number. A composite
belonging to another chat resolves to nothing rather than being stripped to
its number — that is a confusion of destinations, not a missing quote.

Once a reply anchor is possible in a forum, `_send_text` carrying it only on
the first chunk would drop the tail of a long answer into General, away from
the people reading the topic. So the anchor repeats on every chunk in a forum
whichever kind it is: a repeated quote is noise, a split audience is not.

The existing tests all passed a root of `"88"`, a form the publication seam
never produces. Three of the six added here fail against the old adapter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Repeating an anchor on every chunk does not hold the destination if the
anchor is optional. In a forum the reply target is also the only thing naming
the topic, so `allow_sending_without_reply=True` is permission to deliver into
General, and that permission travels on every repeated copy of it. A target
deleted before or during a run therefore scattered the answer in front of the
whole group instead of the people in the conversation. The flag is now set
from the chat: required in a forum, where the send fails instead, and still
permissive outside one, where detaching costs the quote and not the audience.
The unformatted retry in `_send_chunk` re-sends with the same kwargs, so it
cannot detach either.

`_topic_kwargs` was described in the previous handoff as fixed and was not: a
same-chat message reference resolves to a message, so `notify_working` went on
raising a chat-wide typing indicator, seen by a whole forum that did not ask
for it while the people who did saw nothing. No topic id can be recovered from
a message reference, and the number in one would name whichever topic happens
to hold it, so the nudge is now dropped rather than misplaced. It is pure best
effort, it expires in about five seconds, and the status that follows carries
the real state. Outside a forum the chat is still the right destination.

Seven tests: the mandatory flag on every chunk, the ordinary-chat policy left
alone, a target already gone before the first chunk where the retry must keep
its anchor, a target deleted mid-run which must truncate rather than scatter,
the attachment caller of the same helper, and the nudge dropped in a forum but
still sent in an ordinary chat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four residuals from the Teams publication-address work, plus a Telegram
regression from the commit before last.

The conversation write registry asked `asyncio.Lock.locked()` whether a
lock was in use. That is False for the whole window between a release and
the woken waiter resuming, so a lock with a writer queued behind it looks
idle and can be evicted; the next writer then gets a brand-new lock and the
two run side by side in one conversation. The registry now counts its own
users, holder and queue alike, and `_writes_to` is the context manager that
keeps the count.

A notice about a card that could not be updated went to the thread root,
which Teams rebuilds into an address in whatever region the process last
heard from. Where the publication reference is there it names the
conversation Teams confirmed, service URL included, and it now wins. The
choice is the adapter's: the base port keeps today's behaviour, which is
right for the other four platforms.

A card posted into a carried publication reference took the conversation
from the reference and the region from the channel, sending a card in one
region to another and writing the wrong region into the reference that
comes back. Both halves of the reference are kept, as `_relay` already did.

A 404 at a rebuilt address said the status was gone. It says nothing of the
kind — the address may simply be wrong — so it leaves as `RichContentFailed`
with the cleanup outstanding, and the port's contract no longer claims more
than a refusal arriving as the port's own error.

Telegram: working out where a nudge belongs needs a getChat on a cold cache,
and hoisting that lookup out of the try turned a best-effort signal into a
precondition of the status that follows. A chat that cannot be read now
costs the nudge and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A removal is issued on behalf of the turns expecting the mark, and clears
their expectations when the platform answers. A turn in that set can ask for
the mark again while the answer is in flight — the publisher allows a queued
receipt to be replaced by the real turn under the same command key, and a
publisher that takes the message up with no claim of its own asks afresh —
and the reaction the second ask puts there is as new as any other turn's.
Clearing by turn loses it, and the turn then finishes clean with the 👀 still
on the message. Nothing serialises the two: a removal holds its own turn's
record lock and no other's.

So an expectation is now stamped with the attempt that recorded it, in memory
and in the journal, and a removal clears a holder only while it still carries
the ask the removal went out against. A refusal rolls its own attempt back to
what stood before it rather than dropping the expectation outright, which is
the same rule read from the other end: an answer speaks for the ask that
provoked it and for no other.

An expectation written before the asks were stamped carries no stamp and is
named by the empty one, until the turn asks again.

The regression drives the window through the real publisher and a real
database: a queued receipt claims the mark, another turn ends last and its
removal is answered late, and the queued command becomes its real turn and
marks afresh in the gap. It fails without the fix with the mark visible and
the turn reporting a clean finish.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A channel that was deleted, or that the bot has been put out of, refuses
the post every time. The widening wait bounds how often that costs a
reservation and a released handle, but it never ends it: the post is
tried again every ten minutes for as long as the request is open, and
the session it belongs to is an unfinished publication throughout, so
every cycle reports the same failure over whatever is new.

Once the wait has stretched as far as it goes the card is given up on,
with one error-level record naming the request and saying it can still
be answered in Console. Nothing is said in the channel — there is no
reachable channel to say it in, and this does not choose somewhere else
to say it. The giving up is this process's, not the row's: the
reservation went back with the refusal, so there is nothing to stamp,
and a restart is entitled to try again.

`CardRefused` separates the platform's own refusal from the other ways
a card comes to be unposted, so that bound applies to the destination
answering and not, say, to a card that turns out to be posted already.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Telegram status used to be deleted when the turn ended, on the reasoning
that a chat is the conversation itself and a permanent "Worked for 12s" is
clutter. That left a reader scrolling back with nothing: no sign the turn
ran, no duration, and no way into the Console.

The status now stays, edited to its final state, and it is compact for the
reason it used to be deleted. The tool log Telegram folded into an expandable
quotation is gone, and so is the "Now:/Last:" line naming the tool of the
moment — `turn_status` takes `current_tool` and Telegram declines it. What is
left is the state, the duration, one Console link, the agent's name (a bot has
no other identity) and, when a call failed or was declined, the tally saying
so.

With nothing retired there is no retirement to remember, so the delete path
and its bookkeeping come out with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A turn ending at the channel root or in a DM took its status down; only a
thread kept one. The reasoning was that outside a thread the status was just
the thing saying work was happening — but the channel root is where most
turns are published, so in practice the outcome, the duration and the link to
the session were exactly where a reader scrolling back could not find them.

Now nothing is taken down, which is what a request card already did. With no
retirement there is no retired set to consult, and no half-deleted case to
degrade into, so `_retire_rich`, `_is_flat`, `_turn_has_ended` and the ref
bookkeeping come out with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A posts channel already kept it, because Teams leaves "This message has been
deleted." behind and that is worse than the line it replaces. A chat, a group
chat and a chat-layout channel deleted it, on the reasoning that a bot's own
message goes from those without trace and a finished status is clutter. What
went with it was the only account of the turn a reader scrolling back could
find: that it ran, how long it took, and the link to open it.

Both layouts now end the same way, edited in place. The layout question only
ever decided how to retire, so `_retires` and `_retire_rich` go, and with them
the degraded paths for a deletion refused, a deletion timed out, and a message
already gone — none of which can arise when nothing is deleted. A Gone on the
edit is still reported: it is a `BotConnectorRefused`, which `_edit_rich`
turns into `RichContentFailed`.

The shared descriptions of what a status becomes at the end of a turn are
updated to match, on every platform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An Adaptive Card TextBlock renders Markdown, where a lone newline is
whitespace, so a multi-line body arrived as a run-on sentence. Doubling
every lone newline fixed that by turning each line into its own
paragraph: a six-line permission card read as six paragraphs, and the
blank line between "1." and "2." also made Markdown render the options
as a loose list, gapping them again.

The body is now split into consecutive TextBlocks, a line to a block,
carrying spacing "None" where a line simply follows the one above and
"Small" where the body itself left a blank line. Spacing is a schema
primitive with defined values in the host config rather than a guess at
a Markdown renderer's newline handling. Consecutive list items stay in
one block so the list keeps its numbering.

Every agent message on Teams shares the builder, so ordinary prose gets
the same treatment. The admin message is the one plain-text seam with no
card to put blocks in, and keeps the doubling.

Nothing was removed from a card: heading, handle, title, detail,
numbered options, reply fallback and settled outcome are all present, in
order. fallbackText and the activity summary now carry the body as
written, which is what toasts and mobile read.

Needs a Teams client check in both layouts, open and settled — the tests
can only pin the JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
simonflk-sb and others added 30 commits September 16, 2026 11:22
The removal of a granted card ran after the redraw, which is only safe while
the card is certainly there. It is not: a deletion whose acknowledgement was
lost, or a process that died before the timestamp committed, leaves a row
still owing a removal and no message at the address. A restart has an empty
redraw guard, so the first thing it does is edit that message. The edit fails,
the "card could not be updated" fallback posts a notice about a card nobody
can see, and it re-raises before the second delete — the one that would have
found the address empty and settled it — is ever reached. Every restart says
it again, and the row never catches up.

Ask the platform first. A removal that succeeds leaves nothing to draw, so the
cycle moves on; a removal refused or throttled falls through to the settled
draw exactly as before, which is the card a failure is meant to leave behind.

Recovery keeps its unconditional draw through a flag rather than the redraw
gate: the gate was told about the post that went unconfirmed, so at the same
revision and state it reads a message found by its handle as one already
drawn.

The fake that hid this now refuses edits to a message it has deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Requested behaviour change: a card answered "Deny" is removed from the channel
too. The reasoning that kept it — the channel holding the only readable record
of a refusal — does not survive contact with where that record actually lives:
the request, its decision and the person who made it are in the session, in
Console and in the row, which outlives the card either way. What is left
behind on a refusal is a settled message still occupying a channel.

So the predicate stops asking which way the answer went. `granted` becomes
`decided`, and `GRANTS` goes with it: what matters is that the host confirmed
a person chose an option the card actually offered. An answer naming an option
the request never had, an approval settled with no result, a questions result
against an approval — each is still unreadable, and an unreadable answer still
leaves a card someone can read by hand. So does a request that ended with no
answer at all: expired, withdrawn, or reported as a provider error.

`removes_approved_cards` is renamed `removes_answered_cards` to match, and the
two test modules with "granted"/"approved" in their names are renamed rather
than left describing a rule that no longer exists.

Also corrects the `removed_at` comment in models.py, which still described the
mark being written before the platform was asked and cleared on refusal — the
ordering that was fixed two commits ago.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing the card on any option the request offered went wider than the change
that was asked for. The requested amendment was that a refusal removes its
card as a grant does; `cancel` is a fourth decision and the contract has
always held it apart from `decline`, so extending the rule to it was an
unrequested behaviour change hidden inside a requested one.

`DECISIONS` names the three that answer the question a permission card asks.
Cancelling stops the operation instead of permitting or refusing it, and the
card it leaves is the channel's only record of where a run was halted.
Widening the set later is one line if that is what is wanted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The publication webhook deletes what it sent, so no Manage Messages
permission is involved and none is in the documented install. A DM card
has no webhook behind it and is deleted as the bot's own message.

Only a rate limit survives as itself. A refusal, a server error and a
request that never came back all become RemovalFailed, because the
caller does the same thing with all three: keep the settled card, record
nothing, and ask again later. The uncertainty an uncertain send has to
preserve does not arise, since asking again about a deletion that did
land is answered with "already gone" — which is logged rather than
raised, and is how a lost acknowledgement settles itself.

A reference that is not two snowflakes is refused before the channel is
resolved, rather than partway through by an int() that happens to raise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A bot deletes its own messages in a group as an ordinary member, and in a
broadcast channel under the Delete Messages right the install already
asks for. The limit is time: after 48 hours Telegram refuses, and says
so. That refusal is raised, so the card stays settled and readable
rather than being recorded as gone; a card answered inside two days,
which is every card anybody is waiting on, comes back.

Two BadRequests that only their text tells apart: "message to delete not
found" is the absence the caller asked for and is logged, and "message
can't be deleted" is a real failure. The house pattern for reading a
BadRequest by its text is already used for "not modified" on the edit
path.

A rate limit is charged to the bot rather than to the message, so a
throttled deletion records the chat-wide quiet period the redraws share,
and a deletion is not sent into a wait that is already running.

Unlike the edit path, a reference with no chat is refused rather than
completed from the channel argument: an edit that lands wrong rewrites
one of our own messages or is refused, while a deletion is neither
reversible nor confined to messages we posted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deleted as the admin: the card was posted by an agent's bot and the
reference does not say which, and the admin is the account that may
delete a post it did not write. A 404 means nothing remains at the
address, which is what the caller asked for, so it is logged and
returned rather than raised; a 429 keeps the wait Mattermost named;
everything else is owed and asked about again.

Clients with the channel open see Mattermost's own "(message deleted)"
placeholder until the next load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addressed through the publication reference — service URL, conversation
and activity, all three as Teams confirmed them — rather than through the
map a restart empties. That is also what makes a 404 readable: gone at an
address Teams issued means gone, while gone at an address rebuilt from
the channel is as likely to be the address, and is owed rather than
recorded. A 412 is a wait for the same reason it is on a redraw.

Gives `_Publication.trusted` its first reader.

A posts-layout channel keeps "This message has been deleted." where the
card was; a chat and a threads-layout channel take it away entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four docstrings and two test names still claimed nothing a bridge
publishes is ever taken down. That is now true only of a status: an
answered request card is removed, on every platform, through
remove_publication and never through a redraw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Discord answers "Unknown Webhook" with the same 404 as "Unknown Message",
and the publication webhook is looked up by name and created when none is
found — so a webhook deleted in the channel's settings is replaced by one
that sent none of the cards already posted, and answers 404 about every one
of them while they sit on the screen. Reading either as absence stamps
removed_at on a card that is still there and nothing looks at the row again.

Resolve the channel and the webhook outside the deletion's error handling,
restrict the absence reading to code 10008, and confirm it through the
channel route, which answers about the message.

Teams named no wait on a 412 or on a 429 without Retry-After, and the
synthetic interval substituted for them came back shorter than the interval
the cleanup had grown to. The caller takes a named wait as authoritative and
drops its own, so a channel refusing persistently was retried hardest. Both
are now owed like any other failure and the growing backoff applies.

Drop the claim that every awaited card is answered inside Telegram's
48 hours; nothing here bounds how long a card stays open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The card's options become Action.Execute buttons in an ActionSet, which is
the one card action that reaches the bot and can answer with something only
the presser sees. A press posts nothing into the conversation; a refusal
comes back as the invoke's own response and stays with the person who made
it. An Action.Submit would have posted the refusal where everybody reading
the post could see whose answer failed.

Only a card that carries buttons asks for schema 1.5. A client too old for
the universal action model renders the whole card as its fallback text, so
the version is raised where there is something to gain by it and nowhere
else, and the buttons themselves fall back to `drop`.

The body still lists every option in full, unlike Telegram's, because those
buttons are exactly what an old client drops — and a card whose options only
ever lived on them would drop the question with them. Typing still answers
it, by the same numbers the buttons carry.

What travels in a button is the card's opaque token and the option's
position, and nothing else: no option id, no actor, no label. Who pressed
comes from the activity, and which card comes from the conversation, region
and replyToId the activity names, so the reference is the one the request
was stored under rather than one rebuilt from what this process last
learned. A press on a card that outlived a restart resolves the same way.

Controls are absent wherever a press cannot land: a status, a settled card,
one that says it cannot be answered here, one whose decision text was cut,
and a bridge with no interaction handler to route a press to.

A press that lands is answered with nothing at all — the card's own redraw
is what says the answer was taken, and saying so here would be saying it
before the redraw that proves it.

Owed live check: the private-response behaviour is read from Microsoft's
documented invoke contract, not observed. A transport prototype on a real
tenant still has to establish that a press submits invisibly and that a
refusal is visible to the presser alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A request card posted to Discord now carries one button per option, and a
press answers it the same way typing the number does — resolved against the
stored card rather than against anything held in memory, so a press works
after a restart.

Two ownership facts shape this. Discord only lets an application-owned
webhook carry interactive components, and the publication webhook is
adopted by name, so it may be someone else's. The creator on the resolved
webhook is the probe; a webhook the bridge minted is its own by
construction. Where it is not ours the card is posted without buttons and a
warning names the remedy, rather than switching to a different webhook: a
webhook may only edit and delete what it sent, so swapping would strand
every card already posted through it. A DM card is the bot's own message
and has no webhook to own.

Presses arrive as gateway interactions registered alongside the command
tree, since the tree is handed application commands only. The press is
acknowledged with a deferred message update before any Switch work —
invisible, because the card's own redraw is what says an answer was taken —
and a refusal reaches the presser alone through a follow-up, carried out of
the shared inbound path by a context variable that `tell_actor` writes to.
A typed answer has no press to follow up and still lands in the card's
thread.

The buttons are rebuilt on every redraw and passed on every edit, `None`
included, so a settled or unanswerable card stops offering a press without
anything having to remember it once had one. Unlike Teams, an option a
button says in full is dropped from the body: every Discord client renders
components, so there is no older client to keep the list for. An option the
button had to cut stays whole in the text. More options than Discord's
five-by-five grid holds means no buttons at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mattermost keeps an action's context confidential and hands it back to the
integration untouched, and documents it as the place to put something that
proves a callback came from the server rather than from whoever found the
URL. This is that proof, and the reading of it.

What travels is a signature, not the key. A context that leaks — through a
bug, a backup, a database dump — then hands over one card's button rather
than the means to forge every card's. The card's token rides along as the
subject of the press, never as its credential: it says which request is
being answered and nothing about who may answer it, and the actor is read
from the body Mattermost fills in.

The key is derived from the server secret and the bridge's id rather than
stored on the bridge. A stored secret would have to be minted when a bridge
is registered, leaving every Mattermost bridge that predates this unable to
carry a button until somebody edited its configuration by hand, and would
add a second secret to keep and rotate. Deriving separates one bridge's
signature from another's, and rotating the server secret rotates it —
which invalidates buttons already on the channel, so that case is refused
by name in the log rather than silently.

The context is read as strictly as it is written: exactly the three signed
fields and nothing beside them, since the signature cannot vouch for a
field it does not cover.

No route serves this yet. Where the callback is hosted is an open
deployment question; nothing here depends on the answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Almost every platform Switch bridges to is dialled out to, and nothing
has to reach Switch for it to work. A Mattermost button press is the
exception: the Mattermost server delivers it by HTTP.

The listener is owned by the lifecycle service rather than by an
adapter, because the port belongs to the process and not to a bridge.
Two Mattermost bridges — two tenants, or a test server beside a real
one — are ordinary, and a listener each would be a port and an ingress
rule each. Each bridge is routed by type and id, so a second platform
needing callbacks is a sibling rather than a collision.

It gets a socket of its own rather than a route on the agent API, which
also carries the MCP server and the operator dashboard: what an
operator has to expose for a button should be callbacks and nothing
else. Nothing binds until a bridge asks to be served, so a deployment
with no callback address anywhere opens no port at all, and the bind
outlives any one bridge so a restart cannot unbind the port under its
neighbours. A bridge that is not running answers 404 rather than
refusing the connection, which Mattermost would retry into.

Each bridge is handed its place already bound to it, along with the key
it authenticates its own presses with. The key moves here from the
Mattermost module so that no adapter ever holds the server secret it is
derived from, and gains the bridge type so two bridges sharing an id
across platforms do not share a key.

A verified press goes the whole way: the presser comes from the body,
which the server fills in and the button cannot, and the card and
option come from the signed context. A refusal raised while the answer
is judged is carried back out as `ephemeral_text`, the one reply
Mattermost shows to the presser alone, instead of being posted where
the channel would read it. A presser the server cannot name is refused
rather than attributed to their raw id — the id is what authority is
judged on, but the handle is what a participant is created under, and a
lookup failure must not become somebody's permanent name in the room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things have to be true for a button press to reach Switch, and
none of them is code.

The Mattermost server has to have an address for Switch. That is
`callback_base_url` on the bridge, and it is a third address unrelated
to the two already there: those are routes to Mattermost, this is the
route back, and between two servers it is frequently nothing like the
route a browser takes. Both local stacks now set it — the service name
under the standalone compose, where switch-core is a container, and the
host under the dev compose, where it is not.

Mattermost has to be allowed to use it. It refuses outbound
integration requests to private addresses unless the host is
allowlisted, and the symptom otherwise is a press that does nothing
with an error only in the Mattermost server log. Both bundled
Mattermosts now allow the address the seeder gives them.

And an operator has to know the rest: that the listener is a port of
its own and not public ingress, that TLS is a proxy's job as it is for
Teams, and that rotating the server secret invalidates the buttons on
cards already posted while leaving those cards answerable by typing.

The seeder skips a bridge that is already registered, so an existing
stack keeps working without buttons until `callback_base_url` is added
by hand. The Helm chart does not publish the port yet; the setup page
says so rather than implying it works there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An edit replaces a message's whole blocks array, so the client redraws the
plan block from scratch and closes any step the reader had expanded. That is
why the clock lived in a message of its own: at a redraw every five seconds,
nothing stayed open long enough to read.

chat.appendStream replaces nothing. A task_update carrying an id Slack already
holds merges into that card, and a plan_update moves the header without
touching the cards, so the two messages become one, the clock ticks in its
header, and an expanded step stays expanded.

Verified against the live API before it was built: in-place card mutation, and
the undocumented plain-string `details` a chunk requires where the task_card
block wants rich_text. Threads with no asker, no workspace id or no thread at
all fall back to an ordinary post, with a warning naming the missing piece.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bridges configured for callbacks start in tasks of their own, so they
reach the first bind together: both found nothing bound, both tried, and
the loser failed startup on a port its own neighbour had just taken. The
bind is now serialised, and a bind that fails cleans up the runner it set
up rather than leaking it.

A bridge also registered itself before binding, so a failed bind left the
door claiming to serve a bridge that never came up. Bind first, register
after: a press in the gap is answered as not running, which is what it is.

The other half is a bridge that dies in its own task. Nothing calls stop
for it, so its place on the listener stayed registered and went on handing
presses to an adapter that was no longer running. The crash cleanup now
withdraws the endpoint with the rest.

Both were reachable only once a card carries buttons, which is why they
are fixed before that lands rather than after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A card's options become message-attachment actions in the post's props,
one per option, numbered the way the body numbers them and the way a
typed answer names them. Each carries its own signed context addressed
to this bridge's place on the shared callback listener, so a press
cannot be retargeted at another option by anything a client can see.

The body still prints every option. Mattermost documents no budget for
a button's label, so there is no width at which an option could be
called fully shown by the control.

Buttons come off a card the moment it stops being pressable, because
every redraw builds the actions again. That makes props part of a
card's edit, and a patch replaces props wholesale — on the server
version deployed locally nothing re-applies the identity props it set
when the post was made. So a redraw reads the post back and merges,
rather than writing the props Switch knows about and silently dropping
`from_bot` off the card. A bridge that draws no buttons at all does
neither, and behaves exactly as it did before.

Action ids are written rather than left to the server, which mints them
only on the create path. A card is redrawn many times, and an id that
changed under a reader would remount the control they were mid-press
on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The seeder registered a fresh bridge with one and left an existing
bridge alone, which meant every deployment that predates callbacks —
including every developer's — kept drawing cards with no buttons and no
way to say why. The dashboard is no help: its registration form is
generated from the connection schema and does offer the field, but
there is no form for editing a connection afterwards, only the two
toggles. So the only cure was a hand-written API call.

The seeder now sends the address on every run, the way it already
adopts the default-bridge invariant. Sent rather than compared because
a bridge's config is not readable back — it carries the admin password,
so no endpoint returns it — and the patch is merged over what is
stored, so nothing else has to be restated.

The setup page picks up the rest: the API call for an operator who is
not running the seeder, that a Mattermost container created before the
allowlist landed has to be recreated rather than restarted, and what a
reader actually sees on a card now that one carries buttons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A streamed activity message grew without bound and repeated itself. Two
defects, one code path.

The Console link went out on a `task_update` that was re-sent whenever the
card moved. `details` on a `task_update` *appends* to what the card already
holds rather than replacing it, so the link accumulated: a reader watching a
long turn saw "Open in Console app" nine times. Measured against the live API,
not inferred. The link now lives on one card of its own, sent once.

The steps were `task_update` chunks too, and a stream cannot take a card back.
Past fifty Slack silently stored no more, so the header disclosed a count of
steps nobody could ever see. They move into ordinary `plan` blocks carried
inside the stream, addressed by `block_id` and replaced whole. Two of those
blocks rotate through fifty-step pages, so the newest hundred steps are always
on screen and the message is the same three blocks at step 5 and at step 230.
What fell off the front is said in the older page's title, which is the line a
reader sees with the block collapsed.

Boundaries are fixed rather than a sliding window: a step never moves between
pages once it has landed, so a settled page is not rewritten and a card the
reader opened is still the card they opened.

Checked against the live API over 230 steps: 231 appends accepted, the
finished message holds three blocks, and the link appears once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three fixes to the streamed turn, all in the same path.

The Console link was being dropped from the session card whenever the card
serialised past 256 characters, which every real session url does: the raw
switchdash form runs to 186 and the gateway redirect Slack actually gets to
205. The 256 came from a refusal seen once and never searched for. Bisecting
the live API found no boundary below 8000 characters for a task_update's
details, its title, its id, a plan_update title, or two cards in one append,
and a 213-character production-shaped url is accepted and stored as a link.
So the budget and the trimming built on it are gone, and the whole url goes
in the card.

What the message had to leave out was written into the older section's title.
It is now one cumulative line of its own above both sections, naming the whole
missing range — Steps 1-150 no longer shown — rather than a count a reader has
to add up. That line has to be created before the sections it describes,
because Slack fixes a block where it was first written, so the top of the three
block ids starts as the first page of steps and is replaced in place by the
line once there is something to disclose.

The adapter was remembering the session card it last drew rather than the one
it sent, so a publish that arrived without the url erased the record of the
link and the next one appended the same link to a card already carrying it.

Verified against the live API over 230 steps in the adapter's own chunk order:
four blocks in the finished message, the disclosure line above the two pages,
the 213-character url stored whole, and the link present exactly once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d card

Two things a reader sees, both raised from a live channel.

The body was listing every option underneath buttons that already showed
them. The renderer has carried the seam for this since Discord and Telegram
used it: an option a control shows in full loses its line. Mattermost passed
no width because the server documents no cap on an action's name, which
mistook the constraint — what bounds a label is what still reads as a button,
not what the API will accept.

The suppression is a promise that a control is carrying the option, so it
holds only where one will be. The plain text of a `RichContentFailed` is
drawn without controls, and a card that earns none — a form too big to show
faithfully — is drawn a second time, because the pass that discovered it had
already dropped the lines.

An answered card is now edited down to its outcome instead of being taken
back. The settled rendering already existed; Mattermost never reached it.
Mattermost is alone in leaving a "(message deleted)" line behind a post
removed while somebody has the channel open, which is reason enough for it
alone to keep its cards — the other four platforms are untouched. The seam
below the capability is kept, and the test file says why: the flag is the
whole of the decision, and reversing it should not also need an
implementation written again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Slack never parses

The header carried the tool call — "Working… 40s · Running: Read" — which said
what was happening but not where to look for it. Past fifty steps there is more
than one section to open, and the header pointed at none of them. It moves onto
the heading of the section holding the step instead: "Steps 51–75 · Running:
Read". Said once, where it is useful, and it leaves the header to say only where
the turn got to.

The stream's own plan was sending its one card complete from the moment the
stream opened, so the top of the message showed a check beside "Working…". Slack
draws a block's glyph from the cards in it and that plan holds exactly one, so
the card now carries the turn's status and turns over at the end. That is the
only update it ever takes, and its shape is measured rather than guessed: an
update of id and status alone stores an empty title, and one that repeats
`details` appends the Console link a second time. So the adapter now works out
which fields it still owes rather than deciding whether to resend the card
whole.

Card titles were escaped on the grounds that Slack asks for `&`, `<` and `>`
escaped and that a tool call named `Ran <!here>` would notify the channel.
Measured against the live API, neither holds for this field: a card title parses
no mrkdwn, so a title sent escaped is stored and shown escaped, and `<!here>` is
stored as the text it is rather than as a broadcast. Every `&&` in a shell
command was reaching the reader as `&amp;&amp;`. A field that parses nothing
needs nothing escaped for it, so the plan and card titles are left as written.
The mrkdwn surfaces — sections, contexts, the message fallback — still escape.

Checked against the live API over 230 steps: the header ends free of the tool,
each section names the step it is holding, the card ends complete with its title
intact and the link in it once, and a title holding `echo "x" && ls 2>/dev/null`
comes back with no entities in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A card whose options are carried by buttons stops listing them in its
body. The text of a `RichContentFailed` has no buttons — the caller
forwards it as an ordinary message — so reporting a refusal with that
same drawing asks for a choice it has stopped printing.

Wherever buttons were possible the card is now drawn twice, and both
drawings travel together: the post gets the one with the options
suppressed, every failure gets the one without buttons. That is also the
drawing the post itself gets when the card turns out to earn no
controls, which folds in the second pass that was already there for the
too-big form.

All four failure sites take it: a refused post, a refused edit, no bot
for the agent, and a connection that has gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A turn's activity became a single message when `separate_activity_log`
went to False, but `_save_anchor` after `_edit` stayed inside the branch
that draws a second one. With no log message the anchor was never
persisted, so the state the message was already showing was unknown to
the next process to pick the turn up, and every publisher sweep or
restart re-edited a message that had not changed — collapsing an
expanded plan each time.

The tests that counted a status message and a tool log separately are
updated to the one message they now get, including the durability suites
that assert on `channel-demo:N` by position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Slack posts the per-call log into the channel beside the status. Discord
showed only the status: state, what it is doing now, and a tally. The
calls were rendered nowhere, so the detail existed in the code and not on
the platform.

The status now carries a View activity button. Pressing it opens a
message only the presser can see, with the calls oldest-first, when the
read was taken, a Refresh and a link into Console. Nothing is added to
the channel's history, and a second reader pressing the same button gets
their own copy.

Three pieces make that possible.

`activity_log` in the neutral renderer draws the list for any platform
with somewhere to put one, against a single budget for the whole message.
When the budget runs out it cuts the oldest end, and it pays for the line
saying how many it cut out of the calls rather than out of the leftover
space: a log that quietly showed its tail would read as a turn that only
made those calls.

The address of a status message now outlives the turn it is showing. The
anchor a publisher keeps is a delivery reservation and is discarded the
moment an ordinary turn ends, while the message stays in the channel with
its button on it, so `_save_anchor` writes the address separately and the
compaction keeps it.

`activity_shown_at` reads a turn back from that address, re-running every
check the publisher makes before it may draw one: tenant, bridge, the
room the command came from, the channel that room points at now, and the
agent's membership of it. Not the surface the command arrived on — a turn
is published to the room whatever asked for it.

Who may read it is the adapter's to decide, because only it knows who
pressed. A press is authorised against the conversation the reference
names, never against the one it arrived from: the initial view has those
two the same, but Refresh sits on a private message with no journal row
and so carries an address, and an address a presser supplied is treated
as though it had been typed. A private thread is checked by membership
rather than by the parent's permissions, and a refresh arriving on a
public message is refused outright, since an update would rewrite that
message into somebody's tool log.

Whether an ephemeral message survives a client reload, and whether a
component on one yields a fresh interaction token past fifteen minutes,
are not documented and need a live check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_still_reads` refused a reader who had lost a channel, and checked a
private thread by membership rather than by the parent's permissions, but
allowed a channel outside any guild unconditionally — there were no
permissions to consult, so it consulted nothing.

That branch was the one place the destination check could be steered
into. Every other path here treats the address as something that arrived
on a press and is not evidence of anything; this one rested on the
address having been generated by us, which is the inference the rest of
the design refuses to make.

Who may read a channel with no guild is exactly who is in it, so that is
what is now asked, via `recipients` or the single `recipient`. A channel
that can say neither is refused and logged, like any other destination
whose audience cannot be established.

This bridge does not in practice publish into real DMs — `create_dm_channel`
makes a private guild channel, which `permissions_for` already answers
for — so the change closes a reachable-only-by-forgery gap rather than a
live one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants