Skip to content

fix(buzz-acp): tell a refused author instead of dropping them silently - #1

Closed
QuicksilverSlick wants to merge 10 commits into
mainfrom
fix/author-gate-visible-refusal
Closed

fix(buzz-acp): tell a refused author instead of dropping them silently#1
QuicksilverSlick wants to merge 10 commits into
mainfrom
fix/author-gate-visible-refusal

Conversation

@QuicksilverSlick

Copy link
Copy Markdown
Owner

What this fixes

An inbound event from an author outside the gate was dropped with a tracing::debug! and a continue — no reply, no reaction, nothing above debug level:

if !allowed {
    tracing::debug!(..., "inbound author gate — dropping event");
    continue;
}

RespondTo::default() is OwnerOnly (asserted in config.rs), so this is the first thing a newly invited collaborator hits. Their opening message vanishes, they conclude the agent is broken, and the owner never learns anyone tried. It is silent at both ends.

What changes

  • warn! instead of debug! — a person who got no answer is not a debug-level event.
  • One in-channel notice per (channel, author), anchored to the sender's thread: what happened, that their message is not lost, and what has to change for it to be picked up.
  • The owner is p-tagged in channels, so they learn through the normal mention path — in the project the request arrived in.

The agent never opens a DM. Keeping the exchange with its project preserves the context, and an inbox of agent DMs is an inbox people stop reading.

Design notes

Once per person per channel, not per event. Silence strands the sender; replying every time lets a noisy author use the agent as a flooder. The memo is bounded — anyone can mint a pubkey and post, so the key space is attacker-controlled. At the cap it clears rather than evicting per-entry: the cost of forgetting is one repeat notice to someone already told, which is cheaper than the bookkeeping an LRU would need on a path that exists purely to suppress duplicates.

DMs carry no owner mention and the text does not name the owner — the sender is an unknown party and the owner's pubkey is not theirs to learn.

The gate itself is untouched. This changes only whether a refusal is visible, never who is allowed to steer. The existing DM hardening is unmodified, and all 12 pre-existing author_gate_tests still pass.

Message text

I saw your message but I'm not currently set up to take requests from you, so I can't start on it. Your message is safe in this channel and nothing needs re-typing. I've flagged this for the owner of this project — once they add you, send it again and I'll pick it up.

It names the state, not the mechanism, and never asks for a resend of a message that is already there — re-sending cannot change the outcome, and asking for it trains people to repeat themselves into silence.

Testing

5 new tests in author_gate_tests, covering: the notice says what happened and what happens next; it never asks for a resend; it does not leak the owner in a DM; it fires once per person per channel; and the memo stays bounded under attacker-supplied keys.

cargo fmt clean, cargo clippy --all-targets clean.

On the suite: cargo test -p buzz-acp reports 28 failures — these are pre-existing on main, verified by stashing this change and re-running:

passed failed
clean main 800 28
with this change 805 28

Same 28 failures, +5 passing — exactly the tests added here.

QuicksilverSlick and others added 10 commits August 30, 2026 13:37
An inbound event from an author outside the gate was dropped with a
tracing::debug! and a `continue`. Nothing was posted, no reaction was
added, and debug is off in practice — so the sender got silence and the
owner never learned anyone had tried to reach the agent.

RespondTo::default() is OwnerOnly, so this is the first thing a newly
invited collaborator hits: their opening message vanishes, and both
sides believe the other is unresponsive.

The refusal now:

- logs at warn! rather than debug! — a person who got no answer is not
  a debug-level event;
- posts one in-channel notice per (channel, author), anchored to the
  sender's thread, saying what happened, that their message is not lost,
  and what has to change for it to be picked up;
- p-tags the owner in channels, so they learn through the normal mention
  path, in the project the request arrived in. The agent never opens a
  DM: keeping the exchange with its project preserves the context, and
  an inbox of agent DMs is an inbox people stop reading.

One notice per person per channel, not per event: silence strands the
sender, but replying every time lets a noisy author use the agent as a
flooder. The memo is bounded, since anyone can mint a pubkey and post —
at the cap it clears rather than evicting, because the cost of
forgetting is one repeat notice to someone already told.

In a DM the notice carries no owner mention and does not name them:
the sender is an unknown party and the owner's pubkey is not theirs to
learn. The existing DM hardening is untouched — this changes only
whether a refusal is visible, never who is allowed to steer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four dead-letter notices named the mechanism, leaked internals, and
gave instructions the reader usually could not follow:

  "the turn exceeded the maximum duration (7200s)"
  "Please re-authenticate the CLI (e.g. run `claude /login`)"
  format!("{e}") interpolated straight into the channel

"Turn", "batch" and a raw seconds count are our vocabulary, not the
reader's. Telling a collaborator to run `claude /login` is worse than
useless: they have no access to the machine it would run on, so the
message describes a fix they cannot perform and names nobody who can.
And interpolating a Display impl into a channel can carry paths, hosts
or tokens to anyone with read access.

Each notice now says what happened, what it means for the reader, and
what happens next:

- the raw error stays in the log; the channel gets a plain description
  of the state via failure_reason_text();
- the auth notice says the work is blocked until someone with access
  restores it, rather than prescribing a command;
- notices stop claiming a re-send will help when the retry budget is
  already spent — that trains people to repeat themselves into silence;
- the owner is p-tagged, so a failure the reader cannot fix reaches
  somebody who can instead of sitting in a channel nobody is watching.

post_failure_notice and post_gate_notice were near-identical, so they
collapse into one post_notice() carrying mentions. Net -83 lines in
pool.rs.

Notice routing stays channel-based here. Role-based routing — the
requester in their own conversation, the person who can fix it in the
project — needs the orchestrator model that does not exist yet, and a
message promising delivery the code does not perform would be a
regression, not an improvement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two paths in EventQueue::push drop a request somebody sent, and both
were observable only as a log line nobody was necessarily reading:

- DedupMode::Drop discarded events for an in-flight channel at
  tracing::debug! — invisible in practice, so work could go missing
  with no way to establish afterwards that it had;
- the per-channel depth cap evicts the OLDEST queued event, meaning the
  request that has waited longest is the one lost.

Neither kept a running total, so "did we lose anything?" was not an
answerable question — only "is there a line in the log I happened to
catch".

Both now increment a DropCounts, logged with the running total the way
relay.rs already accounts for gated_observer_dropped. The dedup drop
moves from debug! to warn!, matching the depth-cap drop: discarding a
request is not a debug-level event.

A run that discarded anything says so on shutdown. Scattered mid-run
lines are only useful to someone watching at the time; a process that
loses work and then exits quietly leaves nothing to answer the question
later.

drop_counts() exposes the totals so a supervisor outside this queue can
observe the loss — the process doing the dropping is the least reliable
place to report it from, which is the same reason the watchdog belongs
outside the agent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This fork is Dreamforge. The rename covers what a person reads and
nothing else:

  RENAMED   window title, product name, 228 user-visible strings
  UNCHANGED crate names (buzz-*), so upstream `use` paths resolve
  UNCHANGED BUZZ_* environment variables
  UNCHANGED wire tags (buzz-channel, buzz-protect, buzz-visibility)
  UNCHANGED event kinds and the app identifier

The exclusions are the point. Upstream changed 367,825 lines in the last
fortnight, 5,412 of which touch the name: 1,943 crate/module references
and 870 environment variables, against ~350 mentions of the bare word.
Renaming the first two categories is where a fork breaks.

An environment variable is the worst case. Upstream adds BUZZ_NEW_THING,
its code reads BUZZ_NEW_THING, and a renamed prefix means it reads a
variable nobody sets — compiles, runs, silently takes a default. A wire
tag is worse still: no error, just two systems that no longer agree.
Display strings have no such coupling. Nothing reads them back.

The app identifier stays xyz.block.buzz.app deliberately. Changing it
moves the app data directory and keyring service, orphaning the identity
and settings of anyone who has already onboarded. It is invisible to
users, so the rebrand does not need it.

Code comments keep saying Buzz — 108 of them. They are invisible to
users and every one rewritten is a merge conflict against upstream for
no reader's benefit.

Identity surfaces (window title, terminal panel, shared compute) now
read from shared/constants/brand.ts. Prose keeps the literal word:
turning every sentence into a template literal would add conflict
surface without making a future rename meaningfully easier.

Verified: tsc clean, vite build clean, and biome flags exactly the same
7 pre-existing files as before the change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Buzz-native", "Buzz-hosted" and "Buzz-curated" survived the first sweep
because the pattern excluded a trailing hyphen — deliberately, to keep
wire tags like buzz-channel intact.

These are safe: wire tags are lowercase, and a capital B never appears
in one. All three are prose a person reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
@QuicksilverSlick

Copy link
Copy Markdown
Owner Author

Superseded by #2, #3, #4 and #5 — same commits, split by concern so each can be reviewed and merged on its own.

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