0062. A block is machine-written, and a ticket nobody works dies of neglect¶
- Status: accepted
- Boundary moved: the ticket lifecycle model — who may put a ticket into a waiting state, and whether a ticket can reach a terminal state with no actor at all.
- Date: 2026-09-12
- Amends: ADR-0049
Context and Problem Statement¶
ADR-0049 split the lifecycle into state and blocked_on and gave every remaining edge a
dashboard button computed from can_transition()/can_block(). Making each value
reachable was the right fix for an enum most of which was decorative. Making each value
reachable by hand was not, and three defects follow from it.
A hand-set block has no referent. POST /tickets/{tid}/block never passed a ref, and
_check_block never wanted one. Setting blocked_on="approval" from the console therefore
produced a ticket waiting on an approval that does not exist. Three surfaces then asserted
it — the header chip ("AWAITING APPROVAL"), the inbox row ("waiting for approval") and the
timeline ("parked this ticket on an approval") — while the banner that would carry the
decision rendered nothing, because there was no pending row to render. Nor could it ever
end: expire_due iterates approval rows and finds none, and nudge_stalled excludes
approval by design, so the ticket sat in needs you until a human clicked Unblock. The
real gate path (ticket_assistant.open_approval) creates the row first and blocks with
ref=approval.id; it was the only producer of a coherent approval block all along.
Claiming and retargeting model a workplace kenny does not have. assignee_user_id was
introduced by ADR-0049 to close a "consequence gap" left by retiring awaiting_agent — the
reasoning was that a ticket needing an operator ought to have somewhere for which
operator to live. That is an argument from model symmetry, not from need. In a household
installation there is no handover of responsibility between operators: two of them may work
the same ticket at once, and ticket_events already records who did what and when. The
column was read only by the model briefing; the console never displayed it, so the only way
to learn a ticket's assignee was the wording of its Claim button. Retargeting has the same
shape: a ticket is about the machine the problem is on, and webui/tickets.py already
called that target "an authorization control" while offering a button that moved it.
A ticket nobody works never ends. The stall ladder ADR-0049 built runs nudge → escalate
to operator → nothing. An escalated ticket sits in needs you indefinitely, and a new
alert-origin ticket in a deployment without triage never leaves new at all.
Considered Options¶
- Hide the affordances in the console only. Rejected: the routes stay open, and the console starts suppressing options the server advertises — the second copy of the vocabulary ADR-0049 set out to abolish.
- Require a
refon a hand-set block. Fixes the phantom without answering who would legitimately type one. A person asserting a wait they did not create has norefto give. - Narrow the actor tables so blocks are machine-written, delete claim and retarget, and add a fourth rung to the stall ladder. Chosen.
- A new
abandonedstate rather thancancelled+resolved_by. Rejected: exactly the enum inflation ADR-0049 was written against.
Decision Outcome¶
Blocks are written where the wait begins. _BLOCK_SETTERS is {"system"} for all three
reasons, so a block always carries the ref of the thing being waited for.
_UNBLOCK_CLEARERS["approval"] becomes {"system"} to match: a gate is settled by a
decision or by its TTL, both of which resolve the held call, and a person declaring the wait
over would strand a pending approval behind an unblocked ticket — the phantom inverted. The
requester may still clear their own user block and an operator may clear user or a
operator escalation, because the answer often arrives out of band.
TicketService.assign/reassign and TicketStore.set_assignee/set_agent_id are
deleted, along with their routes. tickets.assignee_user_id stays as a column with no
writer and no reader: rows written before this record carry a value, and ticket_events
keeps the assign/handoff rows that explain them. Retiring a kind does not rewrite a
trail (ADR-0046), so both still render.
TicketService.abandon_stale cancels a new/in_progress ticket no person has touched
for KENNY_TICKET_ABANDON_SECS (default 14 days), with resolved_by="inactivity". It
runs as the fourth pass of sweep(), after nudge_stalled, so a ticket the escalation pass
has just handed to a human is not dropped in the same tick. blocked_on="approval" is
excluded: transition() denies an open gate on the way into any terminal state, and ending
a ticket here would make that denial a side effect of ageing — the thing the hand-authored
guard in _check_transition refuses for system. Cancelled rather than resolved because
nothing was solved, and because closed is reachable only through resolved, so resolving
first would both claim a resolution that never happened and route around that same guard,
which covers cancelled alone.
The clock is a new column, tickets.last_human_at, not updated_at. This is the part
that is not optional. updated_at is wrong in both directions: a note, a tool call and a
message go through _insert_event, which never touches the tickets row, so a ticket an
operator is actively annotating looks untouched — while set_blocked does bump it,
including from this module's own escalation pass and from the assistant's gate handling, so
a ticket only machines have touched looks freshly handled. last_human_at is written in
exactly one place, _insert_event, when the actor matches operator:/user: — every human
action already leaves a trail row, so the clock needs no second write site and no new route
can bypass it. It is seeded to created_at at creation (empty would sort before every
stamp, making a new ticket read as infinitely idle) and backfilled from the trail at
migration, falling back to created_at.
PATCH /api/tickets/{tid} now writes a kind="patch" trail row naming the actor and the
fields changed. It wrote nothing before: a title or priority could be rewritten with no
record of by whom. Closing that audit gap is what makes _insert_event a complete account
of human activity, so one change settles both.
Consequences¶
- Good, because the state three surfaces described as a pending decision that did not exist
can no longer be created — by the console, by
curl, or by a future route. - Good, because a ticket that has been forgotten now ends, and says in
resolved_bythat nobody decided it, so dropped stays distinguishable from withdrawn. - Good, because the ticket page falls to one primary action plus, at most, one contextual
button and one quiet exit, without any option being hidden from a server that still offers
it —
allowed_blocksis genuinely empty now. - Good, because field edits are auditable for the first time.
- Bad, because this narrows the actor tables ADR-0049's "Neutral" consequence promised to
preserve. That promise was kept for a model in which a human describing a wait was
coherent; the
approvalphantom is the proof that it was not. - Bad, because a requester whose ticket has no bound Discord thread is not told it was dropped — alert-origin tickets have nobody to tell, but a dashboard-origin one only learns from the inbox.
- Bad, because
last_human_atis a second clock on a row that already hasupdated_at, and a reader has to know which question each answers. - Neutral:
blocked_refstill has no reader. With hand-set blocks gone it is now always the real gate's id, which makes linking the ticket to its approval possible; that is a separate change.
More Information¶
- Amends ADR-0049: the two axes and the five states stand exactly as recorded. What changes is who may move the second axis, and that the ladder it started now has a last rung.
- Related: ADR-0046 (the trail is the
authority and is never rewritten — why the retired columns and event kinds stay),
ADR-0059 (the buckets
blocked_onfeeds), ADR-0032 (KENNY_TICKET_ABANDON_SECSis a live setting by virtue of being in the catalog). - Code:
kenny-server/kenny_server/tickets.py(_BLOCK_SETTERS,_UNBLOCK_CLEARERS,abandon_stale,sweep,update),kenny-server/kenny_server/ticketstore.py(last_human_at,_is_human_actor,_migrate,list(human_before=…)),kenny-server/kenny_server/config.py,kenny-web/src/views/ticket/TicketActions.tsx.