fix(telegram): gate the spawn-approval prompt on the channels ceiling - #13503
Conversation
First Principles Review (Fable 5) — ✅ PASSPremise-level review of First-Principles-Verdict: PASS Verify the red-on-base claim (4 of 5 tests fail without the check) — this run has no shell, and that run is the fix's checkable provenance beyond #13491. Not justified as shipped
What this change shipsInventory (5 items) — 4 justifiedIntent: FIX — a spawn started from a governance-denied channel should stay answerable on Slack/dashboard instead of being auto-refused by the timeout of a prompt nobody could answer (#13491). The claimed mechanism verifies on base: the callback path drops a non-reject press on a denied channel (
[FIRST-PRINCIPLES-REVIEWED] efcf30d |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Verify the residual window is acceptable: a deny landing after the prompt posts still drops the press, times out, and returns Suggestions
[DESIGN-REVIEWED] efcf30d |
Opus 5 Review — ✅ no blocking findingsReviewed Review detailsNothing blocks: both survivors are advisory — an audit row named for the wrong caller, and an owning-spec enumeration the change made incomplete. FINDING — src/kiro_crew/messaging/spawn_approval_delivery.py:159 — FINDING — docs/system-specs/modules/subagent.md:406 — the diff adds a third [OPUS-REVIEWED] efcf30d Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
…ceiling A spawn parented on a channel conversation is offered its Approve/Deny prompt there by that channel's registered delivery hook. The delivery path consulted each hook's live rosters and egress gate, neither of which speaks for the operator's channels governance profile, so a profile that denies the channel after the transport connected still got the prompt posted with its agent-authored task preview. The press that would answer such a prompt is dropped: a channel's callback path refuses everything on a denied channel except an explicit reject. So an Approve resolves nothing, the deny-by-default wait runs to the approval timeout, and the seam hands the host spawn gate a False nobody pressed -- which the gate reads as the user's decision and refuses the spawn on, instead of re-offering it on the still-permitted Slack DM or dashboard surface. channel_inbound_permitted is consulted in spawn_approval_delivery, once, before any hook is invoked, and a deny answers None so the gate falls through. At the seam rather than inside each hook: every hook posts a prompt answered by an inbound press, the seam already resolves the channel token, and a copy per dispatcher would duplicate the authority that the next hook written without it would reopen. It runs after hook resolution, so a non-channel namespace never asks about a channel type that does not exist.
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
f41dfde to
efcf30d
Compare
Disposition of the advisory review items on
|
| Tree | Result |
|---|---|
| This head, unmodified | 5 passed |
| Ceiling check disabled | 4 failed, 1 passed |
| Check restored | 5 passed |
The arm that drives the real registered Telegram hook fails
assert False is None under the disabled check: the delivery returned a refusal
produced by the elapsed wait after the prompt was posted under a deny. That is
the symptom in #13491, reproduced. The one test that stays green is the permitted
arm, which is expected -- it asserts the unchanged path.
No code, description, or head change accompanies this comment.
bolichen97
left a comment
There was a problem hiding this comment.
Approving. All four lanes are green on this head and the author's disposition answers each advisory item: the inbound:<channel> audit label for an outbound decision, the incomplete None-cause enumeration in subagent.md, and the mid-wait deny race (deferred to #13559).
The fix itself is the right shape — checking the operator's channels ceiling at the single delivery entry point and returning None so approval falls back to Slack/dashboard, rather than letting a prompt nobody can answer time out and be read as a denial.
What is the problem
Channel-side spawn-approval delivery posts the prompt without consulting the
operator's
channelsgovernance ceiling.TelegramDispatcher.deliver_spawn_approvalis the registered hook on
main, so a host profile that denies thetelegramchannel after the transport connected does not stop a spawn-approval prompt,
carrying an agent-authored task preview, from being posted into that conversation.
The startup gate does not cover this: it only stops a transport from
connecting. A profile that denies the channel afterwards leaves the transport up
with its rosters intact, which is exactly the gap
channel_inbound_permittedexists to close on the inbound message path and the callback press path.
Why it matters to the user
The prompt cannot be answered once it is posted. A channel's callback path drops
an inbound press on a governance-denied channel, and only an explicit reject
(
a:<rid>:<nonce>:0) is exempt from that drop. So an Approve press on adenied channel resolves nothing, the deny-by-default wait runs to
APPROVAL_TIMEOUT_S, and the delivery answersFalse.That
Falseis not a fall-through. The delivery seam reads it as the user's realdecision, so the host spawn gate refuses the spawn on it instead of re-offering
the prompt on the still-permitted Slack DM or dashboard surface. The user sees a
spawn denied by a prompt they were never able to see, after a full timeout. A
deny is meant to withhold the channel, not to cast a vote in the operator's
name.
How the fix solves it, symptom to root cause
The symptom is "a denied channel denies the spawn". Walking it back: the refusal
came from an elapsed wait; the wait elapsed because the answering press was
dropped; the press was dropped because the channel is denied; and the prompt was
posted anyway because the delivery path never asked. The root cause is a missing
authority, not a wrong one -- the rosters and
transport.may_send_toanswer "maythis destination receive a send", which is a different question from "does the
operator permit this channel at all".
messaging/spawn_approval_delivery.deliver_spawn_approvalnow consultschannel_inbound_permitted(channel)and returnsNoneon a deny.At the seam, once, not inside each hook. Every hook this seam can invoke
posts an interactive prompt whose answering press arrives inbound on the same
channel, so the ceiling is a property of the delivery contract rather than of any
one channel. The seam already resolves the channel to find the hook, so the check
costs nothing extra there, and it gates every present and future hook -- including
a hook written by someone who never reads this PR. A copy inside each dispatcher
would be the same authority duplicated per implementation, which is the shape
this change exists to remove, not to repeat.
Two ordering properties follow structurally rather than by comment:
is no window in which a stale press could resolve a later request, and each
hook's own roster check keeps its suspension-free window before its send
untouched.
(
dashboard:,cron:) or aunifiedDM bucket names no governed channel, soit falls through without asking the profile store about a channel type that
does not exist, and without emitting a governance decision for a non-channel.
Nothing else moves. No channel gains permission it did not have, no deny is
skipped, no timeout is shortened, and an unreachable delivery is never reported
as an approval: a denied channel answers
None(fall through), neverTrueandnever
False. A permitted channel behaves exactly as before, and a channel thatregisters no hook is untouched.
What tests we did
test/test_spawn_approval_channel_governance_13491.py, 5 tests, run by path with-n0. Telegram client I/O is faked and the ceiling predicate is substituted, sonothing touches the network or a real profile store. Four of the five drive the
seam through the real registered Telegram hook rather than a stand-in, so the
pin covers the path the host gate actually takes.
Run alongside the two sibling spawn-approval suites and the Telegram dispatcher
suite to show the permitted path is unchanged: 392 passed for the four files
together.
Red on base first, with the real symptom: with the check absent, four of the five
tests fail, and the one driving the real hook fails
assert False is None-- thedelivery returned a refusal produced by the elapsed wait after the prompt was
posted under a deny.
Each property was then verified by an independent mutation:
['', 'unified', 'telegram'] == []-- three governance questions asked about keys that name no governed channelThe third mutation is why the permitted arm is asserted as its own test: a check
that denied unconditionally would pass every other test in the file while making
in-channel spawn approval impossible.
Other suggestions
Design Review (advisory CONCERNS) is what moved the check. The first revision
placed it inside
TelegramDispatcher.deliver_spawn_approval, as the issue's ownsuggested fix proposed. The review's objection was that a per-dispatcher copy
re-creates the defect class the change fixes, policed only by a lint rule this
PR's own harvest section had proposed. That is correct, and the hoist is strictly
better:
messaging/spawn_approval_delivery.pyalready holds the channel token theceiling needs,
messaging/identity.pyis in the same package so no layering orimport cycle is involved, and the ordering argument becomes structural. The
announced Discord port of this seam no longer needs a copy of its own.
The prose sweep covers two specs.
messaging.mddocuments the check in theseam's own bullet, next to the
Nonecontract it extends.governance.mdenumerated this predicate's call sites as each dispatcher's
handle_message; itnow also names this one outbound send that consults the inbound ceiling, why an
outbound prompt depends on an inbound permission, and why the check sits at the
seam.
Not addressed, and deliberately out of scope: the per-agent
auto_approve_spawnallowlist, which remains a separate design.
Pattern harvest
Rule candidate: review-prompt
Pattern: when a cross-channel seam needs an authority, enforce it at the seam,
not once per implementation behind it. The defect class this PR started from is
an authority consulted on the inbound paths of a module but skipped on the
sibling outbound path whose answer depends on that same inbound permission -- the
press that resolves the prompt is inbound, so a channel denied for inbound
traffic cannot answer a prompt sent outbound to it, and the resulting elapsed
wait is read as a decision. A semgrep rule for the narrow shape (a method that
arms an approval nonce must consult the ceiling first) was the first candidate
and is deliberately withdrawn: the check now lives at the single routing layer
every hook passes through, so the recurrence the rule would have policed is
retired by construction. What generalizes is the placement question, which a
reviewer can ask of any seam and a pattern matcher cannot.
Closes #13491