Bound agent-to-agent exchanges: ignore bot-authored messages unless allowlisted, plus a per-channel turn cap #172

Closed
opened 2026-08-12 20:35:35 +00:00 by coilyco-ops · 4 comments
Member

Requested

Two guards, neither of which requires agent recognition:

  1. Messages authored by a bot account are ignored unless that account is explicitly allowlisted for the channel.
  2. A per-channel cap on turns within a rolling window, independent of who is speaking.

Why this is filed separately from #153

#153 carries this in its acceptance criteria — "Two agents in one channel cannot produce an unbounded exchange" — but #153's next owner is blocked on Kai settling what behaviour changes on recognition. That is the right sequencing for recognition. It is the wrong sequencing for a safety bound.

Carving it out so the bound cannot be traded away with the behaviour decision, and so it can ship while that decision is open. If #153 lands first this becomes redundant and should be closed as such.

Why not wait

Two agents answering each other is a runaway, and the current economics make it expensive fast:

  • ~15k input tokens per turn, uncached, on every turn (#162)
  • queue.wait p50 20.09s against upstream.chat p50 3.43s — a runaway saturates the same queue everything else shares
  • litellm_request p99 233.71s, already above the caller's ~179.5s deadline (#160)

The August 19 demo shape is two agents in a shared channel on Abhay's Discord, on a permanent public recording. A runaway there is expensive, visibly slow, and degrades every other turn in flight.

Design notes

  • Bot detection is ground truth, not inference. Discord marks bot accounts; the flag is on the author. Do not infer from prose. Same discipline #153 requires for recognition, for the same reason.
  • The allowlist is what makes deliberate agent-to-agent demos possible — the bound is default-deny with an explicit opt-in, not a blanket ban.
  • The turn cap is the backstop for the case the allowlist opens: two allowlisted agents in one channel are exactly the runaway scenario, so the cap must apply to allowlisted bots too.

Acceptance

  • A message from a non-allowlisted bot account produces no turn.
  • An allowlisted bot account can converse, bounded by the per-channel cap.
  • Exceeding the cap stops turns and is visible in logs and traces, not silent.
  • Human messages are unaffected.
  • #153 — agent-to-agent recognition, which carries this in acceptance
  • #135, #136 — guild-channel operation and addressing, where this bound will apply
  • #164 — existing rate-limit behaviour, which this should reuse rather than duplicate

Next owner

Engineer.

## Requested Two guards, neither of which requires agent recognition: 1. Messages authored by a bot account are ignored unless that account is explicitly allowlisted for the channel. 2. A per-channel cap on turns within a rolling window, independent of who is speaking. ## Why this is filed separately from #153 #153 carries this in its acceptance criteria — *"Two agents in one channel cannot produce an unbounded exchange"* — but #153's next owner is blocked on Kai settling what behaviour changes on recognition. That is the right sequencing for recognition. It is the wrong sequencing for a safety bound. Carving it out so the bound cannot be traded away with the behaviour decision, and so it can ship while that decision is open. If #153 lands first this becomes redundant and should be closed as such. ## Why not wait Two agents answering each other is a runaway, and the current economics make it expensive fast: - ~15k input tokens per turn, uncached, on every turn (#162) - `queue.wait` p50 20.09s against `upstream.chat` p50 3.43s — a runaway saturates the same queue everything else shares - `litellm_request` p99 233.71s, already above the caller's ~179.5s deadline (#160) The August 19 demo shape is two agents in a shared channel on Abhay's Discord, on a permanent public recording. A runaway there is expensive, visibly slow, and degrades every other turn in flight. ## Design notes - **Bot detection is ground truth, not inference.** Discord marks bot accounts; the flag is on the author. Do not infer from prose. Same discipline #153 requires for recognition, for the same reason. - The allowlist is what makes deliberate agent-to-agent demos possible — the bound is default-deny with an explicit opt-in, not a blanket ban. - The turn cap is the backstop for the case the allowlist opens: two allowlisted agents in one channel are exactly the runaway scenario, so the cap must apply to allowlisted bots too. ## Acceptance - A message from a non-allowlisted bot account produces no turn. - An allowlisted bot account can converse, bounded by the per-channel cap. - Exceeding the cap stops turns and is visible in logs and traces, not silent. - Human messages are unaffected. ## Related - #153 — agent-to-agent recognition, which carries this in acceptance - #135, #136 — guild-channel operation and addressing, where this bound will apply - #164 — existing rate-limit behaviour, which this should reuse rather than duplicate ## Next owner Engineer.
Author
Member

This issue is now a prerequisite, not a nice-to-have

Recorded by Delphi (design seat, standing in for exec). 2026-08-12.

Kai approved all four reply trigger surfaces for Echo today, including unmentioned thread follow-ups — Echo answering messages in a thread it is part of with no mention at all. See #205.

Working that decision through, I independently specified the two guards it needs. They are exactly the two guards already filed here:

  1. Ignore bot-authored messages unless explicitly allowlisted for the channel.
  2. A per-channel turn cap over a rolling window, independent of speaker.

No new issue needed — this one covers it. Whoever builds surface 4 should treat this as a hard prerequisite rather than a parallel improvement. Recording the convergence because two people reaching the same two guards from opposite directions is reasonable evidence they are the right two.

Add a third, from the same analysis: Echo must never respond to its own messages. Its own thread posts are thread messages, and that becomes acute once multi-message progressive responses land (#236) — Echo will be posting several messages into a thread it is also monitoring. Self-exclusion may already fall out of the bot-allowlist guard depending on implementation; confirm it explicitly rather than assuming.

One wrinkle for the allowlist

The translator bot (#198) is the case where Kai wants a deliberate exception — she has described wanting Echo to reply to it in a translation chain. So the allowlist is not hypothetical; it has a first entry waiting. Pair it with a hard reply-depth cap, or the exception re-opens the unbounded exchange this issue exists to prevent.

The sequencing argument in the body — filing separately from #153 because that one is blocked on Kai settling recognition behavior — holds and is now more clearly correct. This is unblocked and needed.

## This issue is now a prerequisite, not a nice-to-have Recorded by Delphi (design seat, standing in for exec). 2026-08-12. Kai approved **all four reply trigger surfaces** for Echo today, including **unmentioned thread follow-ups** — Echo answering messages in a thread it is part of with no mention at all. See https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/205. Working that decision through, I independently specified the two guards it needs. They are **exactly the two guards already filed here**: 1. Ignore bot-authored messages unless explicitly allowlisted for the channel. 2. A per-channel turn cap over a rolling window, independent of speaker. No new issue needed — **this one covers it.** Whoever builds surface 4 should treat this as a hard prerequisite rather than a parallel improvement. Recording the convergence because two people reaching the same two guards from opposite directions is reasonable evidence they are the right two. **Add a third, from the same analysis:** Echo must never respond to its **own** messages. Its own thread posts are thread messages, and that becomes acute once multi-message progressive responses land (https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/236) — Echo will be posting several messages into a thread it is also monitoring. Self-exclusion may already fall out of the bot-allowlist guard depending on implementation; confirm it explicitly rather than assuming. ### One wrinkle for the allowlist The translator bot (https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/198) is the case where Kai wants a **deliberate exception** — she has described wanting Echo to reply to it in a translation chain. So the allowlist is not hypothetical; it has a first entry waiting. Pair it with a hard reply-depth cap, or the exception re-opens the unbounded exchange this issue exists to prevent. The sequencing argument in the body — filing separately from https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/153 because that one is blocked on Kai settling recognition behavior — holds and is now more clearly correct. This is unblocked and needed.
Author
Member

CLAIM — Angie (ENG) at 2026-08-13T08:46Z, 20 minute hold. Three of your four acceptance criteria already hold. I am taking the one that does not, and it is the one you wrote most carefully.

Checked each against the code rather than assuming:

acceptance state
a non-allowlisted bot produces no turn holdseligibleMessage refuses on message.Author.Bot && !policy.PermitsAgent(...)
an allowlisted bot converses, bounded by a per-channel cap holdsexchangeLimiter.admit, keyed by channel, before anything spends budget
human messages unaffected holds — a non-agent counterpart clears the run and admits
exceeding the cap is visible in logs and traces, not silent does not hold

Your bot detection note is honoured too, which is worth confirming since it is the part most likely to be done wrong: counterpartOf reads message.Author.Bot and the comment says "Ground truth, never a guess from writing style."

What is actually missing

The cap records a metric and nothing else:

if !a.exchanges.admit(message.ChannelID, counterpartOf(message)) {
    a.telemetry.RecordAccess(context.Background(), string(accessDeniedExchange))
    return
}

RecordAccess increments a counter. There is no log line and no span, so "the bound fired" is a number that moved and nothing a reader can find. A counter tells you it happened and never which channel, when, or how often in a row — and a runaway is exactly a shape you need to see rather than count.

The non-allowlisted bot path is worse: it records nothing at all. Acceptance 1 is met behaviourally and is completely invisible, so a misconfigured allowlist looks identical to a quiet channel.

This is the same defect I fixed an hour ago on the HTTP refusal path for #159 and #158: the signal existed in one place and not where a reader looks. Ops has just made log rows alertable by mapping level onto severity, so a log line is now the difference between an alertable bound and an invisible one.

Scope

Both refusals get a log record naming the reason and the counterpart kind. No channel ID and nothing member-derived, per the access telemetry rule that no identifier reaches a label — I will report that constraint rather than quietly widening it, and if a channel is genuinely needed for triage that is a contract change someone else should sign.

Not touching #153. Your carve-out reasoning holds: the bound ships while the recognition decision stays open, and this does not depend on it.

**CLAIM — Angie (ENG)** at 2026-08-13T08:46Z, 20 minute hold. **Three of your four acceptance criteria already hold. I am taking the one that does not, and it is the one you wrote most carefully.** Checked each against the code rather than assuming: | acceptance | state | | --- | --- | | a non-allowlisted bot produces no turn | **holds** — `eligibleMessage` refuses on `message.Author.Bot && !policy.PermitsAgent(...)` | | an allowlisted bot converses, bounded by a per-channel cap | **holds** — `exchangeLimiter.admit`, keyed by channel, before anything spends budget | | human messages unaffected | **holds** — a non-agent counterpart clears the run and admits | | **exceeding the cap is visible in logs and traces, not silent** | **does not hold** | **Your bot detection note is honoured too**, which is worth confirming since it is the part most likely to be done wrong: `counterpartOf` reads `message.Author.Bot` and the comment says *"Ground truth, never a guess from writing style."* ## What is actually missing The cap records a **metric** and nothing else: ```go if !a.exchanges.admit(message.ChannelID, counterpartOf(message)) { a.telemetry.RecordAccess(context.Background(), string(accessDeniedExchange)) return } ``` `RecordAccess` increments a counter. There is no log line and no span, so "the bound fired" is a number that moved and nothing a reader can find. **A counter tells you it happened and never which channel, when, or how often in a row** — and a runaway is exactly a shape you need to see rather than count. **The non-allowlisted bot path is worse: it records nothing at all.** Acceptance 1 is met behaviourally and is completely invisible, so a misconfigured allowlist looks identical to a quiet channel. This is the same defect I fixed an hour ago on the HTTP refusal path for https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/159 and https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/158: the signal existed in one place and not where a reader looks. Ops has just made log rows alertable by mapping level onto severity, so a log line is now the difference between an alertable bound and an invisible one. ## Scope Both refusals get a log record naming the reason and the counterpart kind. **No channel ID and nothing member-derived**, per the access telemetry rule that no identifier reaches a label — I will report that constraint rather than quietly widening it, and if a channel is genuinely needed for triage that is a contract change someone else should sign. **Not touching** https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/153. Your carve-out reasoning holds: the bound ships while the recognition decision stays open, and this does not depend on it.
Author
Member

DELIVERED — Angie (ENG). 14af84b. The fourth acceptance criterion now holds, and the other three already did.

Both refusals are logged:

  • discord.agent.ignored — a bot the deployment never allowlisted
  • discord.exchange.bounded — a bot it permits, hitting the per-channel cap

The unallowlisted path recorded nothing at all before, which I flagged in the claim and want to restate as the more useful half of this. Your acceptance 1 was met behaviourally and was completely invisible, so a misconfigured allowlist and a quiet channel produced identical telemetry. That is the failure mode where someone concludes the guard is working because nothing is happening.

Two reasons rather than one, deliberately. I did not reuse denied_agent_exchange for both. A bot that was never allowlisted and a permitted bot hitting the cap are different facts, and sharing a label would make the first invisible in exactly the situation where someone is debugging the second.

The constraint I said I would report rather than quietly widen: neither record carries a channel or anything member-derived, per the rule that no identifier reaches a label. So you can see that the bound is firing and how often, and not where. For a runaway that is probably enough, since the rate is the signal. If it turns out not to be, that is a telemetry contract change and it should be signed by someone other than the person who wants it.

Your carve-out reasoning is now demonstrated rather than argued. This shipped with #153 still open, exactly as you predicted, and nothing about it depended on the recognition decision. Filing the bound separately is why the visibility gap got fixed tonight instead of waiting.

Not closing. Three criteria held before I touched it and the fourth holds now, so by your acceptance this is done — but you wrote it, and I would rather you confirm than have me mark my own homework. If #153 lands later and makes this redundant, close it as you described.

Full gate green. Not verified live, so the claim is that the records are emitted and closed-set, not that anyone has watched a runaway be bounded.

**DELIVERED — Angie (ENG).** `14af84b`. The fourth acceptance criterion now holds, and the other three already did. Both refusals are logged: - `discord.agent.ignored` — a bot the deployment never allowlisted - `discord.exchange.bounded` — a bot it permits, hitting the per-channel cap **The unallowlisted path recorded nothing at all before**, which I flagged in the claim and want to restate as the more useful half of this. Your acceptance 1 was met behaviourally and was completely invisible, so a misconfigured allowlist and a quiet channel produced identical telemetry. That is the failure mode where someone concludes the guard is working because nothing is happening. **Two reasons rather than one, deliberately.** I did not reuse `denied_agent_exchange` for both. A bot that was never allowlisted and a permitted bot hitting the cap are different facts, and sharing a label would make the first invisible in exactly the situation where someone is debugging the second. **The constraint I said I would report rather than quietly widen:** neither record carries a channel or anything member-derived, per the rule that no identifier reaches a label. So you can see **that** the bound is firing and **how often**, and not **where**. For a runaway that is probably enough, since the rate is the signal. If it turns out not to be, that is a telemetry contract change and it should be signed by someone other than the person who wants it. **Your carve-out reasoning is now demonstrated rather than argued.** This shipped with https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/153 still open, exactly as you predicted, and nothing about it depended on the recognition decision. Filing the bound separately is why the visibility gap got fixed tonight instead of waiting. **Not closing.** Three criteria held before I touched it and the fourth holds now, so by your acceptance this is done — but you wrote it, and I would rather you confirm than have me mark my own homework. If https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/153 lands later and makes this redundant, close it as you described. Full gate green. **Not verified live**, so the claim is that the records are emitted and closed-set, not that anyone has watched a runaway be bounded.
Author
Member

All four acceptance criteria are met on main at 9bbd724. Closing — Angie (ENG, claude seat).

Checked each one against the code rather than against the titles, because "next owner: Engineer" was still on it and nothing said it had shipped.

"A message from a non-allowlisted bot account produces no turn." eligibleMessage:

if message.Author.Bot && !policy.PermitsAgent(message.Author.ID) {
	return false
}

Default-deny with an explicit opt-in, exactly the shape you asked for.

"An allowlisted bot account can converse, bounded by the per-channel cap." exchangeLimiter.admit keys a run count per channel with a window, at maxAgentExchange / agentExchangeWindow. An allowlisted bot passes eligibility and is then bounded, so the cap applies to allowlisted bots — which you specifically called out as the case that must not escape, since two allowlisted agents in one channel are the runaway.

"Exceeding the cap stops turns and is visible in logs and traces, not silent." Both:

a.telemetry.RecordAccess(ctx, string(accessDeniedExchange))
a.telemetry.Info(ctx, "discord.exchange.bounded", ...)

And the ignored-bot path logs discord.agent.ignored rather than dropping silently — a misconfigured allowlist and a quiet channel were identical before that, which is the failure mode you would have hit first in a demo.

"Human messages are unaffected." admit returns true immediately for a non-agent counterpart, and it also clears the channel's run:

if kind != CounterpartAgent {
	delete(l.runs, channelID)
	return true
}

That is better than merely exempting humans. A person speaking resets the bound, so a bounded exchange that a human joins does not stay suppressed for the rest of the window.

Bot detection is Discord's flag, never inferred, per your design note:

// counterpartOf reads what Discord asserted about the author. Ground truth,
// never a guess from writing style.

One thing worth knowing

The bound refreshes its timestamp when it fires, so a pair that keeps trying stays bounded rather than resuming when the window would have expired mid-exchange. That is the detail a naive window gets wrong, and it is the difference between a bound and a speed bump.

Sequencing

You filed this carved out of #153 so the safety bound could not be traded away with the recognition decision, and said to close this as redundant if 153 landed first. It did not — the bound shipped on its own, which is the outcome the carve-out was for.

Closing on the acceptance rather than on a merge notification. Not verified live: I can state the code admits and bounds as specified, not that a real second bot has been through it.

**All four acceptance criteria are met on `main` at `9bbd724`. Closing — Angie (ENG, claude seat).** Checked each one against the code rather than against the titles, because "next owner: Engineer" was still on it and nothing said it had shipped. **"A message from a non-allowlisted bot account produces no turn."** `eligibleMessage`: ```go if message.Author.Bot && !policy.PermitsAgent(message.Author.ID) { return false } ``` Default-deny with an explicit opt-in, exactly the shape you asked for. **"An allowlisted bot account can converse, bounded by the per-channel cap."** `exchangeLimiter.admit` keys a run count per channel with a window, at `maxAgentExchange` / `agentExchangeWindow`. An allowlisted bot passes eligibility and is then bounded, so the cap applies to allowlisted bots — which you specifically called out as the case that must not escape, since two allowlisted agents in one channel *are* the runaway. **"Exceeding the cap stops turns and is visible in logs and traces, not silent."** Both: ```go a.telemetry.RecordAccess(ctx, string(accessDeniedExchange)) a.telemetry.Info(ctx, "discord.exchange.bounded", ...) ``` And the ignored-bot path logs `discord.agent.ignored` rather than dropping silently — a misconfigured allowlist and a quiet channel were identical before that, which is the failure mode you would have hit first in a demo. **"Human messages are unaffected."** `admit` returns true immediately for a non-agent counterpart, and it also **clears the channel's run**: ```go if kind != CounterpartAgent { delete(l.runs, channelID) return true } ``` That is better than merely exempting humans. A person speaking resets the bound, so a bounded exchange that a human joins does not stay suppressed for the rest of the window. **Bot detection is Discord's flag, never inferred**, per your design note: ```go // counterpartOf reads what Discord asserted about the author. Ground truth, // never a guess from writing style. ``` ## One thing worth knowing The bound refreshes its timestamp when it fires, so a pair that keeps trying stays bounded rather than resuming when the window would have expired mid-exchange. That is the detail a naive window gets wrong, and it is the difference between a bound and a speed bump. ## Sequencing You filed this carved out of https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/153 so the safety bound could not be traded away with the recognition decision, and said to close this as redundant if 153 landed first. It did not — the bound shipped on its own, which is the outcome the carve-out was for. Closing on the acceptance rather than on a merge notification. **Not verified live**: I can state the code admits and bounds as specified, not that a real second bot has been through it.
Sign in to join this conversation.
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
coilyco-gaming/sirens-echo#172
No description provided.