Skip to content

feat(buzz-core): durable ticket kinds and the state fold - #5

Merged
QuicksilverSlick merged 5 commits into
mainfrom
feat/ticket-foundation
Aug 31, 2026
Merged

QuicksilverSlick merged 5 commits into
mainfrom
feat/ticket-foundation

Conversation

@QuicksilverSlick

Copy link
Copy Markdown
Owner

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_kind still 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_TICKET 30623 the request
KIND_TICKET_STATUS 30624 one live row per (ticket, actor)

Why 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_tag returns None outside 30000–39999 — d_tag stays 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.

Axis Values Who writes it
Participation acknowledged / working / blocked / delivered / abandoned any actor, about itself — never terminal
Outcome done(confirmation) / archived only the requester or owner

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:

  • A worker giving up records Abandoned with its reason against itself; the ticket stays live.
  • Everyone leaving yields Unowned — not terminal, still swept, needs reassignment.
  • Delivered is live: finishing is not being confirmed, and unconfirmed work must not sit unnoticed.
  • An Outcome from an actor without authority is demoted and named. Obeying it is the bug; dropping it silently is the other bug.
  • Done carries a Confirmation, so "a person agreed" and "nobody objected in 2h" can never render as the same fact.
  • A close is not blocked by live work, so whoever was interrupted is named.

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:

  • A live row with no deadline read as "never due." is_overdue returned false at every representable timestamp. Strictly worse than posting nothing, since open_deadline is only consulted when there are zero rows — so acknowledging made a ticket less watched than silence.
  • A failure could carry no reason, despite the requirement being failed-with-reason.
  • Same-second rows resolved by state rank, letting a same-second terminal swallow an actor's own newer live row. Nostr timestamps are whole seconds, so this is ordinary.

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:30624 is addressable, so an actor can replace its own row and erase the Abandoned reason 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

cargo run -p buzz-core --example ticket_scenarios

Nine scenarios printed with what the fold decides and whether the watchdog would act.

19 tests, buzz-core green at 281, fmt and clippy clean.

QuicksilverSlick and others added 5 commits August 31, 2026 10:41
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant