mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
## 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>