Files
buzz/crates
Junchao Yan 003770cb79 fix(buzz-acp): bound sibling-gate lookup failures instead of caching them (#7216)
## Summary

Fixes #5450. Supersedes #5459 (see "Related issue").

In `owner-only` / `allowlist` respond modes, the inbound author gate
admits a sibling agent (same owner, proven by the NIP-OA auth tag on its
kind:0) only after `check_sibling_via_profile` fetches that profile over
REST (`POST /query`, 2 s timeout). The boolean result of that lookup was
cached in `OwnerCache` **for the process lifetime, unconditionally** —
so a timeout, a network error, or a non-2xx response was cached as "not
a sibling". One relay blip at first contact and the agent silently
ignores that teammate bot until restart. The owner never hits this path
(`author == owner` short-circuits with no network call), which is
exactly the observed shape: agents keep answering their owner while
dropping every agent-to-agent mention.

Two things kept it invisible: the author-gate drop was logged at `debug`
(hidden by the default filter), and `RestClient::request_with_retry`
returned non-retriable HTTP statuses (401/403/500) with no log line at
all.

### What changed (`crates/buzz-acp`)

- **Tri-state verdict.** `check_sibling_via_profile` returns
`SiblingVerdict::{Sibling, NotSibling, Indeterminate}`. Only a *fetched*
profile yields a cacheable verdict. A failed query, a malformed body, or
a missing kind:0 (not published, or not yet replicated to this relay) is
`Indeterminate` — nothing was attested.
- **Bounded per-author retry instead of retry-per-event.** An
indeterminate lookup fails closed for the current event and arms a retry
deadline: 5 s, doubling per consecutive failure to a 60 s cap
(`SIBLING_RETRY_BASE` / `SIBLING_RETRY_MAX`). Events inside the deadline
are denied with no relay call; expiry re-arms exactly one lookup; a
proven verdict replaces the deadline. This matters because the lookup
awaits inline in the sole inbound-event loop — an unthrottled retry
under a persistent `/query` outage lets one chatty sibling stall ingress
for the owner too. With the backoff, a dead relay costs at most one
inline lookup per author per minute.
- **No wholesale cache clear at capacity.** Replacing an author already
present never evicts. A new author at the 256-entry cap evicts exactly
one entry — the longest-expired retry record if any, else an arbitrary
entry — so proven verdicts and the other authors' backoff tiers (their
`failures` counters) survive saturation.
- **Diagnosability.** The author-gate drop logs at INFO;
`request_with_retry` WARNs on non-retriable HTTP statuses.

### Reviewer notes

- Fail-closed security posture is unchanged: an unverifiable author is
still dropped. Only what is *remembered* about that author changes.
- A fetched profile with no verifying owner attestation is still a
cached `NotSibling`, so a stranger spamming an owner-only channel
doesn't cost a REST round-trip per message. A *missing* profile is
retried under the backoff, which removes the need to restart a harness
after a sibling's profile is published late.
- When no expired retry record exists, the single eviction victim is an
arbitrary map entry rather than LRU; per-entry access timestamps didn't
seem worth it for a cap the fleet sits far below. Happy to change if
you'd prefer it prefer throttled entries over proven ones.

### Related issue

- Fixes #5450.
- #5459 by @whyihaveyou introduced the tri-state verdict and the
no-cache-on-indeterminate rule for the same bug. It has been open
without review since 2026-08-10 and is now conflicting with `main`; it
also retries the inline lookup on every event and leaves the wholesale
`map.clear()` in place. This PR carries the same first half and adds the
bounded retry and eviction behaviour that came out of review on our fork
(yjc801/buzz#112). Credit for the diagnosis and the verdict design goes
to that PR.

### Testing

Nine regression tests in `author_gate_tests` bind the production seam
(`is_owner_or_sibling` → `check_sibling_via_profile`) against loopback
`/query` stubs:

- unreachable relay ⇒ denied, **not** cached as a verdict
- missing profile (`[]`) ⇒ denied, not cached, throttled under the
backoff
- fetched profile without a verifying tag ⇒ denied and cached `false`
- kind:0 carrying a real `compute_auth_tag` attestation ⇒ admitted and
cached `true`
- flaky relay (500 until healed): a burst inside the deadline makes
**no** relay calls, expiry re-queries once, recovery caches the proven
verdict and ends throttling
- backoff sequence 5 → 10 → cap, tracked per author
- re-arming one author's deadline in a full cache keeps the other 255
proven verdicts
- a new author at capacity displaces an expired record first, then
exactly one entry
- with two expired records at capacity, only the longest-expired is
evicted and the survivor keeps its backoff tier

`cargo test -p buzz-acp`: all unit and lifecycle tests pass on this
branch; `cargo clippy -p buzz-acp --all-targets` and `cargo fmt --check`
clean. Commit is DCO-signed.

Live context that led here: on a hosted relay whose REST endpoint
flapped for a day, every local agent that restarted with an empty cache
stopped answering its teammates within hours while still answering the
owner; two agents sat online with zero turns. Not live-verified post-fix
upstream — verified by the tests above and by the fork's review rounds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Junchao Yan <yjc801@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 15:14:04 -06:00
..