0050. The ticket is its own chat surface¶
Numbered 0055, not 0054: this record and ADR-0049 (the lifecycle's two-axis split) were developed concurrently and both first claimed 0054. That record landed first, so
on_hold/the turn cap/resumebelow are written against itsblocked_onaxis (block()/unblock()), not the nine-state model this record's own extraction started from — the extraction moved the code, ADR-0049 changed what it says.
Context and Problem Statement¶
A ticket's assistant loop — the session that gates and drives kenny's tool calls against
the one PC a ticket is pinned to — has only ever had one door: a Discord thread.
DiscordService bundled the transport (gateway, threads, slash commands, identity mapping)
and the loop itself (session rebuild, the four-control gate, turn-driving) into one 2000+
line module. In the dashboard an operator could read a ticket, note it, reassign it, resolve
it — but not talk to kenny about it. A household with no Discord bot configured had no
ticket assistant at all, on any surface.
Two pre-existing gaps ride along with this and are worth fixing at the same time:
- Kenny's own replies leave no durable wording anywhere. A
messagetrail row for a reply has only ever carried a bare summary; the actual text lived solely in the resume-only transcript that ADR-0046 already prunes 30 days after close. Reload the ticket a month later and what kenny said is gone, even though the ticket itself is not. - A lifecycle move made from the dashboard is invisible to Discord. Resolve, cancel, close, and the auto-close sweep changed the ticket's state without telling its thread — the requester never found out their ticket was done unless they happened to check the dashboard.
Giving the ticket detail view its own chat means answering, again, three questions this codebase already settled once for Discord: what may a non-operator's message make kenny do autonomously (ADR-0045), whose identity and controls make that defensible (ADR-0044), and what does a ticket keep a record of (ADR-0046)? A second transport does not get to reopen those on its own terms — it either qualifies under what is already there, or the difference has to be recorded honestly.
Considered Options¶
- Build a second, independent tool loop for the dashboard — its own session shape, its
own gate, its own trail-writing. Rejected outright: two implementations of "what is
read-only, what is a standard change, what needs consent or approval" are guaranteed to
drift from each other, which is exactly the class of divergence
kenny-server/CLAUDE.mdrequires a joined test for. Building a second one is building the drift, not preventing it. - Keep the assistant Discord-only; let the dashboard show a read-only mirror of the Discord transcript. Rejected: it does nothing for a household with no Discord configured, and it does not touch either of the two gaps above — a mirror of an ungated conversation is not a record of one.
- Extract the transport-agnostic half of the existing loop into its own module, behind a narrow surface protocol, and make Discord its first implementation rather than its only caller. Chosen.
Decision Outcome¶
Chosen option: the ticket-bound assistant loop moves out of discord_service.py into a
new ticket_assistant.py, and DiscordService becomes the first of (now) two callers of
it. toolloop.drive_events — the actual agentic tool-use loop — is untouched by this
record; nothing new was built there. What moved is TicketSession, TicketPolicy, session
rebuild, and turn-driving: a transport split, not a new engine. A TicketSurface protocol
(deliver_reply, announce_gate, on_transition) is the seam a transport implements to
have a turn's output mirrored to it; the dashboard's chat route is not one of these — it is
a second, direct caller of TicketAssistant.run_turn that forwards the loop's own events as
Server-Sent Events, exactly the vocabulary the existing copilot chat already renders.
Amends ADR-0046: the trail now carries verbatim text — for curated work, not for a private conversation¶
Every dashboard-typed message, and every one of kenny's own replies regardless of which
surface(s) it went out on, now writes its wording into the trail
(TicketAssistant.append_message(..., verbatim=True)). A Discord-origin message from a
family member is unchanged: the trail row for it still carries only a summary, exactly as
before this record.
This is not a reversion to ADR-0046's rejected "thread-as-record" option. ADR-0046 already drew the distinction this amendment leans on: the trail and the paraphrase are deliberately neither a transcript — a machine-readable record of what happened, plus a generated prose explanation, and "the operator needs to know what kenny did and why, not everything that was said." That reasoning was never an objection to storing wording as such; it was an objection to storing somebody's private platform conversation verbatim for no operational need. A dashboard message is not that: it is curated work — the same rationale ADR-0025 already accepted when it let the copilot's chat history grow unbounded, because that history is the operator's own deliberate use of the tool, not a conversation they merely happened to be a party to. Kenny's own reply carries the same reasoning a step further: it is never a private family conversation regardless of which door it left through, so it is recorded in full every time, even when it answered a Discord-origin message. What stays exactly as opaque to kenny's storage as it always was is the other half of a Discord exchange — a family member's own words are still a summary line and nothing more.
Named honestly, the consequence this carries: the trail was already unpruned by design, and
it now holds more per ticket than it used to. There is still no prune knob for it — there
never was one — but the volume a long-lived, chat-heavy ticket accumulates is larger than it
would have been before this record. _MAX_TRAIL_TEXT_CHARS (20,000, in ticket_assistant.py)
bounds one row's contribution; it does not bound the trail as a whole.
Amends ADR-0045: a second surface now holds the ticket's tiered, autonomous gate¶
ADR-0045 separated a tool's tier (what kind of change it is) from a surface's policy (what
it does about each tier), and recorded that the dashboard holds both change tiers — every
state-changing tool there stops for a confirm, unchanged by that record. That statement was
true of the copilot; it is not what the ticket detail view now does. A message sent from a
ticket's own chat runs under TicketPolicy.gate, the same gate Discord has always used:
read_only runs immediately, standard_change runs autonomously with a trail row recording
that it did, normal_change holds for an operator, and a privacy-touching call holds for the
ticket's own requester's consent, consent always resolved before approval.
This is not the copilot's binary "both tiers always hold" gate reachable from a second URL — it is a distinct, second surface that qualifies for tiered autonomy the same way Discord did, because it carries the same controls: the ticket's target host is frozen at creation, its tool set is cut by a capability profile, and the tier gate plus consent sit in front of every call. The ticket detail view earns this not by being the dashboard, but by being a ticket — the object ADR-0044's four controls were written for, reachable now from a second transport rather than only from Discord.
Touches ADR-0044: same controls, no delegation on this path¶
ADR-0044's four controls were framed for a platform kenny does not own, where the danger is
a second, parallel authorization system creeping in beside webui/authz.py. Three of the
four apply to the dashboard surface completely unchanged: the target host is fixed at
ticket creation and nothing in the conversation can move it (TicketPolicy.resolve_target
discards a model-supplied agent_id/id exactly as it always has); the capability profile
narrows the reachable tool set the same way, checked at both the schema and the dispatch
side; and the tier gate plus consent are the identical TicketPolicy.gate a Discord-driven
turn goes through.
The fourth control — delegated identity, and the "no parallel authorization" concern it
exists to police — does not arise on this path at all. It is not weakened or exempted; it
is simply not a question a dashboard caller raises. The caller is already authenticated by
the ordinary dashboard auth machinery (OperatorAuthMiddleware, resolving a Principal
from a session cookie or a PAT) before the ticket route is ever reached — there is no
external platform's identity assertion being trusted here, and therefore nothing for a
second authorization system to sit beside. The dashboard's own auth is the one
authorization system, on this path exactly as on every other /api/* route. Recording this
plainly matters so that a later reader does not mistake the fourth control's absence here for
a loosening of ADR-0044 — it is the same gate, reachable from a transport that never needed
the control in the first place.
New in this record: the acting principal, not always the requester¶
Discord only ever had one possible speaker per actionable message: the ticket's own
requester. The dashboard breaks that assumption — an operator working someone else's ticket,
or the requester themselves, may be the one typing. TicketAssistant.session_for(ticket,
actor=principal) therefore builds a turn's authorization context from the acting
principal's own current role, capability profile, and host scope. The ticket's frozen
role_snapshot/profile_snapshot only narrow further when the actor is the requester:
that snapshot is the requester's own frozen context, and it must neither narrow nor widen a
third party who happens to be driving this turn instead. For Discord this changes nothing —
the acting principal there is always the requester on every actionable message — but it is
new, deliberate behavior for the dashboard, not a side effect of the extraction.
The turn cap (KENNY_DISCORD_MAX_TURNS_PER_TICKET) and Discord's per-account rate limit both
exist to bound autonomous work — the volume of turns a ticket runs with nobody watching.
An operator+-driven turn, from either surface, is exempt from both: the cap exists to block
a ticket on "operator" once it has run too long unattended
(ADR-0049's blocked-on axis), and an operator is by
definition the human that block was already waiting for. A scoped user chatting from the
dashboard is capped and rate-limited exactly like a Discord requester — the env vars keep
their historical, Discord-flavored names, but the cap they enforce is ticket-wide now,
across whichever surface drove the turn.
A gap closed in passing: lifecycle moves now reach the thread¶
TicketService.transition() and auto_close_resolved() call a best-effort
TransitionNotifier (tickets.set_transition_notifier) after a state change commits;
DiscordService.on_transition uses it to post a short message and archive the thread on
resolved/closed/cancelled. This closes gap #2 from the Context above: a dashboard
Resolve/Cancel/Close, and the auto-close sweeper, used to leave a ticket's Discord thread
silent. It is a bug fix riding along with this feature, not new scope — tickets.py itself
stays transport-blind, knowing only that something wants to be told.
Wire-contract impact¶
None. This surface sits entirely above the agent tunnel: docs/protocol.md and
docs/fixtures/ are untouched, and there is no PROTOCOL_VERSION bump — the same posture
ADR-0027 and
ADR-0044 record for their own additions.
Consequences¶
- Good, because a household with no Discord bot configured now has a working ticket assistant at all — the assistant is constructed as soon as an Anthropic client is available, no longer gated on a Discord token.
- Good, because the extraction removes duplication risk rather than adding it: there is one gate, one session shape, one trail writer, and Discord and the dashboard are both callers of it, not two independent implementations that could silently diverge.
- Good, because kenny's own words, and an operator's own typed work on a ticket, are finally part of the durable record instead of living only in a 30-day-pruned transcript.
- Good, because a resolved/closed/cancelled ticket's Discord thread is no longer silently out of date — the requester who opened it over Discord finds out it is done, from Discord.
- Bad, because the trail — already unpruned by design — now grows with conversation volume, and there is still no knob to bound that growth; a long-lived, chat-heavy ticket accumulates more storage than it would have before this record.
- Bad, because "why can't I do this from the ticket chat?" now has one more axis to check than before: whose turn it is (operator vs. requester) changes which host-scope/profile applies, on top of the tier gate and consent that already applied.
- Neutral, because none of ADR-0044's, ADR-0045's, or ADR-0046's underlying guarantees moved — this record extends where they apply, it does not relax what they require.
More Information¶
- Builds on ADR-0009 (the tool-use loop and its
confirm-gate) and ADR-0038 (the frozen-target
guarantee this record does not touch: a ticket's
agent_idremains unmovable by anything said in either surface's chat). - Amends ADR-0045 (a second surface now qualifies for
tiered autonomy) and ADR-0046 (the
trail's
messagekind may now carry verbatim text for curated work). - Touches ADR-0044: three of its four controls apply unchanged, the fourth (delegated identity) does not arise on an already-authenticated transport.
- Implementation:
kenny-server/kenny_server/ticket_assistant.py(the extracted loop —TicketSession,TicketPolicy,TicketAssistant.session_for/run_turn/resume/append_message, theTicketSurfaceprotocol);kenny-server/kenny_server/discord_service.py(DiscordService.deliver_reply/announce_gate/on_transition, the DiscordTicketSurfaceimplementation);kenny-server/kenny_server/tickets.py(TransitionNotifier,TicketService.set_transition_notifier, called fromtransition()andauto_close_resolved());kenny-server/kenny_server/webui/tickets.py(api_ticket_chat_stream,build_ticket_routes'sPOST /api/tickets/{tid}/chat/stream);kenny-server/kenny_server/webui/index.html(ticketComposerHtml,ticketOpenGate,handleTicketChatEvent,startTicketChatTurn,ticketEventHtml'smessagebranch). - Tests:
kenny-server/tests/test_ticket_assistant.py(session built from the acting principal, not always the requester; operator-turn cap exemption); the chat-route coverage and the Discord-surface regression tests intest_tickets_api.pyandtest_discord_service.py; the transition-notifier seam test intest_tickets.py.