feat(buzz-core): durable ticket kinds and the state fold - #5
Merged
Merged
Conversation
Foundation for durable tickets: a request is written down when it arrives so it cannot be silently dropped, and something outside the agent sweeps for ones that stopped moving. This commit is the part with no I/O — kinds and the fold — so the model can be reviewed before anything depends on it. TWO KINDS, NOT ONE KIND_TICKET 30623 the request KIND_TICKET_STATUS 30624 one live row per (ticket, actor) The NIP-33 replacement key is (community, kind, pubkey, d_tag), so the author pubkey is part of an addressable event's identity. A single addressable ticket could therefore only ever be advanced by its original signer — an assignee, a reviewer, or the watchdog could never move it. Splitting root from status turns that binding from an obstacle into the authorization mechanism: each actor owns their own row, and no actor can forge another's. WHY 3062x AND NOT THE EMPTY 47000 BLOCK Storage class is decided purely by numeric range, so a 4xxxx kind can never be replaced — every transition would be a new immutable row — and extract_d_tag returns None outside 30000–39999, leaving d_tag NULL and no indexed way to find all events for a ticket. 30623/30624 are the next free slots after 30620/30621/30622; verified free across .rs/.ts/.sql. Adding a kind to the registry does not make the relay accept it: required_scope_for_kind still ends in "restricted: unknown event kind", so ingest wiring is a separate, deliberate step. THE FOLD State is derived from status events rather than stored in a column, so the events stay canonical and a replayed log reproduces the same answer. Four rules, each with a test: - terminal wins, so one actor's stale Progress cannot keep a finished ticket alive; - otherwise the furthest-advanced live state wins; - ties break on recency; - the deadline is the EARLIEST live one, not the latest — a watchdog must fire on the first thing that should have happened, and taking the latest would let one long-running actor mask another's stall. Escalated is deliberately NOT terminal. Raising something to a human is not resolving it, and an escalation that is then ignored is exactly the case this exists to catch. Unknown `s` tag values are rejected rather than guessed: a ticket in an unrecognised state must not silently look healthy. 9 tests. fmt and clippy clean; buzz-core suite green at 271 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fold's rules are judgement calls, not implementation details — one person finishing a ticket while another is mid-work SHOULD arguably end it, but that is a decision someone has to agree with, and it is cheaper to disagree now than after the relay and watchdog depend on the semantics. Eight scenarios, printed with what the fold decides and whether the watchdog would act: cargo run -p buzz-core --example ticket_scenarios Covers the cases that actually happen: nobody picked it up, healthy progress, went quiet mid-work, one worker stalled behind a slow one, finished while another was still working, gave up with a reason, escalated then ignored, and out-of-order delivery. Written so the behaviour can be reviewed without reading Rust. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An adversarial review compiled this module standalone and executed the cases, rather than reasoning about them. Three holes were real, all in the same direction: the fold could stop watching a ticket that nobody had finished. 1. A LIVE ROW WITH NO DEADLINE READ AS "NEVER DUE". One Acknowledged row with not_before: None gave deadline: None, and is_overdue returned false at every representable timestamp. Any actor could silence the sweeper permanently by acknowledging without a deadline — and it was strictly worse than posting nothing, because open_deadline is consulted only when there are no rows at all. So acknowledging a ticket made it LESS watched than ignoring it. The module's premise — every non-terminal state carries the instant it must be acted on by — was documented and enforced nowhere. is_overdue now treats a live ticket with no deadline as due. "Nobody said when to check this" is not "never check this". Sweeping early is noisy; sweeping never is the failure this exists to prevent. The fold also falls back to open_deadline for a live row that named none. 2. A FAILURE COULD CARRY NO REASON. fold returned state: Failed, reason: None. The requirement is failed-with-reason and the struct's own comment said required, but nothing checked. A failure a person cannot read is a silent failure wearing a loud label. Missing reasons now render as MISSING_REASON rather than an empty field the reader has to interpret. 3. SAME-SECOND ROWS FROM ONE ACTOR RESOLVED BY STATE RANK. Nostr timestamps are whole seconds, so two rows from one actor in the same second is ordinary. Rank ordering let a same-second Done swallow the actor's own newer live row and terminalise the ticket forever. Ties now resolve fail-safe: prefer the non-terminal row, then the tighter deadline. An extra escalation on a finished ticket is noise; a dropped request is not recoverable. Deliberately NOT ordered by state rank, which was the reviewer's suggested fix. The confirmation pass ran the counterexample: with Acknowledged@100/nb=1000 against Progress@100/nb=130, rank ordering picks the loose deadline and goes silent at now=200 — producing exactly the failure it claimed to prevent. Rank and deadline tightness are independent, so tightness decides. 7 regression tests, one per hole plus order-independence and a guard that finished tickets do not sweep forever. 16 ticket tests, buzz-core green at 278. fmt and clippy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The watchdog's clock and a worker's completion can land in the same second. The terminal row was chosen with max_by_key((created_at, state)), and Failed ranks above Done, so the watchdog's timeout won — the requester was told their request failed, with a reason that had not actually happened, while the work had in fact completed. A false failure is worse than a late success. Owner's call, and the right one: completion wins. Ties now select the lower-ranked terminal state via Reverse, and the test asserts it in both arrival orders, since the fold must not depend on which row the relay hands over first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Research into how mature systems handle multi-actor work items turned up
one near-universal rule that the previous model broke: no single
participant's exit may write the outcome of shared work. Temporal states
it flatly — "an Activity Failure will never directly cause a Workflow
Failure". PagerDuty records a responder as joined or declined on the
RESPONDER. ITIL has the requester confirm closure, never the implementer,
because the party who did the work is the least able to judge whether the
need was met and the most motivated to call it finished.
The old fold had one enum and rule 1 was "terminal wins — if ANY actor
says done or failed, the ticket is finished". So a Claude Code session
that could not push a branch closed Craig's bug report for everyone,
permanently, while a reviewer was still working it. The example program
flagged that as a judgement call; it was a bug.
Split into two axes that cannot be confused:
Participation acknowledged / working / blocked / delivered / abandoned
what ONE actor says about ITSELF; never terminal
Outcome done(confirmation) / archived
how the TICKET ended; only an Authority may write it
Consequences, each with a test:
- A worker giving up records Abandoned with its reason against itself.
The ticket stays live, the reason is kept, and the actor drops out of
the live set. This is the owner's instinct — archived with the reason
so it stays tracked — honored at the right altitude.
- Everyone leaving yields Unowned: not terminal, still swept, needing
reassignment. That state previously had no name and folded to a
deadline-less limbo.
- Delivered is LIVE, not terminal. Finishing is not the same as being
confirmed, and an unconfirmed delivery must not sit unnoticed.
- An Outcome from an actor without authority is DEMOTED to the matching
participation value and its author named in unauthorized_close.
Obeying it is the bug; dropping it silently is the other bug.
- Done carries a Confirmation, so a close because a person agreed and a
close because nobody objected are different facts. The second names
who delivered and how long the window was, and can never render as the
first.
- A close is deliberately not blocked by live work, so anyone
interrupted is named rather than silently orphaned.
Blocked (was Escalated) stays available to every actor: refusing an
agent the ability to say it is stuck would itself be a silent failure.
Which rung of the escalation ladder to use remains the watchdog's
decision, so no actor can page a human harder by asking.
Deliberately NOT adopted from the research: Kubernetes finalizers and
Jira sub-task blocking conditions, both write-time enforcement with no
implementation on this substrate, and the finalizer pattern imports a
deadlock whose only documented remedy is an admin override that does not
exist here.
Recorded as a known gap rather than papered over: kind:30624 is
addressable, so an actor can replace its own row and erase the
Abandoned reason that triggered an escalation. Closing that needs status
rows to become head pointers over a chain of regular transition events —
a change to the event model, not to this fold.
19 tests. Nothing outside buzz-core consumes this yet, so the break is
free to take now and would not be later.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Foundation for durable tickets — a request is written down when it arrives so it cannot be silently dropped, and something outside the agent sweeps for ones that stopped moving.
Pure: no I/O, no callers outside this crate. Adding kinds to the registry does not make the relay accept them (
required_scope_for_kindstill ends in"restricted: unknown event kind"), so ingest wiring is a separate deliberate step. This is the cheap moment to disagree with the model.Two kinds
KIND_TICKETKIND_TICKET_STATUSWhy two: the NIP-33 replacement key is
(community, kind, pubkey, d_tag)— the author pubkey is part of an addressable event's identity. A single addressable ticket could only ever be advanced by its original signer; no assignee, reviewer, or watchdog could move it. Splitting root from status turns that binding from an obstacle into the authorization mechanism.Why 3062x and not the empty 47000 block: storage class is decided purely by numeric range. A 4xxxx kind can never be replaced, and
extract_d_tagreturnsNoneoutside 30000–39999 —d_tagstays NULL and there is no indexed way to find a ticket's events.Two axes, deliberately not one
An actor's statement about itself and the ticket's outcome are different facts. Collapsing them meant a Claude Code session that could not push a branch closed the requester's bug for everyone, permanently, while a reviewer was still working it.
This is near-universal. Temporal: "an Activity Failure will never directly cause a Workflow Failure." PagerDuty records a responder as joined or declined on the responder. ITIL has the requester confirm closure, never the implementer.
Consequences, each tested:
Abandonedwith its reason against itself; the ticket stays live.Unowned— not terminal, still swept, needs reassignment.Deliveredis live: finishing is not being confirmed, and unconfirmed work must not sit unnoticed.Outcomefrom an actor without authority is demoted and named. Obeying it is the bug; dropping it silently is the other bug.Donecarries aConfirmation, so "a person agreed" and "nobody objected in 2h" can never render as the same fact.Sweeping guarantees
An adversarial review compiled this module standalone and executed the cases. Three holes, all in the same direction — the fold could stop watching a ticket nobody had finished:
is_overduereturned false at every representable timestamp. Strictly worse than posting nothing, sinceopen_deadlineis only consulted when there are zero rows — so acknowledging made a ticket less watched than silence.Ties now resolve fail-safe: prefer the live row, then the tighter deadline. Not by state rank — the verifier ran the counterexample where rank ordering picks a loose deadline over a tight one and goes silent, producing exactly the failure it claimed to prevent.
Known gap, recorded not papered over
kind:30624is addressable, so an actor can replace its own row and erase theAbandonedreason that triggered an escalation. The fold cannot prevent that — it is a property of the storage class. Closing it needs status rows to become head pointers over a chain of regular, non-replaceable transition events: a change to the event model, documented in the module header.Reviewing it without reading Rust
Nine scenarios printed with what the fold decides and whether the watchdog would act.
19 tests, buzz-core green at 281,
fmtand clippy clean.