Kind 30175 persona sync events carry plaintext `system_prompt` and
`respond_to_allowlist`. This PR adds **author-only-unless-shared read
semantics**: events without `["shared","true"]` are visible only to the
author; events with that tag are community-readable.
## What changed
### New read class (kind 30175)
Kind 30175 gets per-event gating at every relay read surface. The
`shared` marker is a **tag**, not a content field, so content bytes
(which double as the `source_version` drift basis) are not affected when
toggling share state.
### `event_visible_to_reader` helper (`handlers/req.rs`)
Centralizes the three per-event access predicates —
`is_author_only_event`, `is_unshared_persona_event`,
`reader_authorized_for_event` — into one `pub(crate)` fn callable from
both WS and HTTP adapters. All result-visibility sites now call this
single helper.
### NIP-98 HTTP bridge (`api/bridge.rs`)
- `POST /query` catchall: replaced the two-step author-only +
result-gated checks with `event_visible_to_reader` (now also covers the
persona shared-gate).
- `POST /count`: added `needs_persona_filtering` to the fast-path guard
(forces per-event fallback when filter can match `kind:30175`) and
replaced both fallback loops' individual checks with
`event_visible_to_reader`.
- FTS `/search` bridge helper: replaced `is_author_only_event` with
`event_visible_to_reader` as defense-in-depth (30175 is not in the FTS
allowlist today; comment at site explains the future-proofing intent).
### Ingest validation (`handlers/ingest.rs`)
`validate_persona_envelope` rejects malformed `shared` tags: wrong
value, missing value, duplicates. Accepts exactly `["shared","true"]`
and tag-absent.
### Kind helpers (`buzz-core/src/kind.rs`)
`is_persona_shared_kind`, `is_unshared_persona_event`,
`filter_can_match_persona_shared_kinds`.
### Tests (`e2e_persona.rs`)
8 unit tests in `kind.rs`, 6 in `ingest.rs`, 8 e2e tests total:
- AC-1–6 covering the gated surfaces
- `test_persona_live_fanout_shared_gate`: reworked with explicit
monotonic `created_at` timestamps (t0 < t1 < t2) and per-step head
assertions, eliminating the NIP-33 event-id tie-break race. Also asserts
foreign live subscription receives nothing on shared→unshared
transition.
- `test_persona_ingest_shared_tag_validation`: added `shared=x` and
missing-value wire-level rejection cases.
- `test_persona_mixed_kind_filter_does_not_leak`: publishes a kind-9
event and asserts it IS returned; absence-only assertion no longer
sufficient.
- `test_persona_http_query_cross_author_gate`: NIP-98 `/query`
cross-author gate (authors filter, kindless `ids` — both blocked; shared
`ids` — passes).
- `test_persona_http_count_cross_author_gate`: NIP-98 `/count`
cross-author gate (foreign sees 1/shared, author sees all, wildcard
checked).
### NIP-AP.md
Replaced aspirational "every relay read chokepoint" wording with an
enumerated list of gated surfaces including NIP-98 `/query`, `/count`,
and FTS/search with their enforcement mechanism named. Added
**Non-goal** note for side-band existence oracles
(reaction/report/deletion target resolution).
## Existing tests
All pre-existing `e2e_persona` tests use `{ids:[event_id]}` or
`{authors:[self]}` filters — author self-reads bypass the gate and are
unaffected.
## Gates
`just check` ✅ | `just test-unit` ✅ | `cargo test -p buzz-relay` ✅ (749
passed, 1 pre-existing failure in
`demo_join_forwarded_arm_round_trips_echo` — flaky on `main`, unrelated
to this PR, verified red at `origin/main` before this branch)
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
## Summary
- classify IPv4-compatible, IPv4-mapped, and SIIT IPv4-translated IPv6
addresses using the existing IPv4 SSRF policy
- decode IPv4 destinations under the RFC 6052 well-known NAT64 prefix
- conservatively block local-use NAT64, Teredo, and 6to4 ranges
- add boundary coverage for every newly handled transition prefix
## Why
The workflow webhook SSRF guard previously recognized IPv4-mapped IPv6
addresses but not other standardized IPv6 forms that can embed or route
to IPv4 destinations. Private, loopback, or link-local IPv4 targets
represented through those forms could therefore pass address
classification.
This also covers the legacy SIIT IPv4-translated prefix
(`::ffff:0:0:0/96`), which Rust's `Ipv6Addr::to_ipv4()` does not
recognize but an SIIT-enabled network may route to the IPv4 value in the
final 32 bits.
Network-specific NAT64 prefixes remain a deployment concern and should
be restricted through egress policy; they cannot be inferred generically
from an IPv6 address.
## Test plan
- `cargo fmt --all -- --check`
- `cargo test -p buzz-core network` (35 passed)
- `cargo clippy -p buzz-core --all-targets -- -D warnings`
- `git diff --check`
---------
Co-authored-by: Amp <amp@ampcode.com>
## Summary
Disable automatic system-proxy discovery for workflow webhook requests.
## Why
Webhook destinations are resolved, validated, and pinned before the
request to prevent DNS-rebinding SSRF. If reqwest uses a system proxy,
the proxy can resolve the original hostname itself instead of connecting
to the validated address, bypassing that pinning guarantee.
Calling `no_proxy()` keeps these security-sensitive requests on the
directly validated connection path. Redirects remain disabled.
## Test plan
- `cargo fmt --all -- --check`
- `cargo test -p buzz-workflow --features reqwest` (149 passed)
- `cargo clippy -p buzz-workflow --all-targets --features reqwest -- -D
warnings`
- `git diff --check`
Co-authored-by: Amp <amp@ampcode.com>
Fixes#2637 — full analysis and reproduction there.
## Problem
Audit entries are stamped and hashed with `Utc::now()` (nanoseconds),
then stored in a `TIMESTAMPTZ` column (microseconds). `compute_hash`
covers `created_at.to_rfc3339()`, and chrono emits 0/3/6/**9**
fractional digits depending on the value — so the digest written at
`service.rs:103` is computed over `…T12:00:00.123456789+00:00` while
`verify_chain` recomputes over the `…T12:00:00.123456+00:00` that
Postgres hands back.
Every hash chain backed by a real database therefore fails verification
at its first entry, on untampered data. That is not just a broken
feature — it means a genuinely forged row is indistinguishable from the
permanent baseline failure, so `HashMismatch` carries no signal.
It is invisible in CI because all six chain tests are `#[ignore =
"requires Postgres"]`, and the in-process `hash.rs` tests use a fixture
timestamp of `2026-01-01T00:00:00Z` — zero sub-seconds, the one value
where the bug cannot appear.
## Solution
Reduce `created_at` to the stored precision *before* hashing, so the
in-memory entry and the row are byte-identical:
```rust
pub fn to_storage_precision(created_at: DateTime<Utc>) -> DateTime<Utc> {
created_at.trunc_subsecs(6)
}
```
`log_inner` is the only place that assigns `created_at` — every caller
goes through `NewAuditEntry`, which carries no timestamp — so this is a
single choke point. It is wrapped in a `log_timestamp()` helper purely
so the invariant is assertable without a database.
I chose truncation at the write path over the alternative (hashing a
precision-independent encoding such as
`timestamp_micros().to_be_bytes()`). Both fix the mismatch, but
truncating keeps the existing hash preimage format and gives the
stronger invariant: the `AuditEntry` returned from `log()` is now
exactly what a later read returns.
Truncation matches what actually happens on the wire — sqlx encodes
`DateTime<Utc>` as microseconds since the Postgres epoch, truncating —
so the value hashed is the value stored.
## Validation
Toolchain note: built on Windows with the `x86_64-pc-windows-gnu`
toolchain (no MSVC linker locally).
**Before**, against Postgres 17 with `migrations/*` applied:
```
$ cargo test -p buzz-audit --lib -- --ignored --test-threads=1
test service::tests::chain_links_within_one_community ... FAILED
test service::tests::chains_are_independent_per_community ... FAILED
test service::tests::community_chain_starts_at_seq_1_with_null_prev ... ok
test service::tests::cross_community_row_does_not_verify ... ok
test service::tests::verify_detects_tampering_within_a_community ... FAILED
test service::tests::verify_empty_range_is_false ... ok
test result: FAILED. 3 passed; 3 failed
```
with `HashMismatch { seq: 2 }` / `HashMismatch { seq: 1 }` on untampered
chains.
**After**, same database:
```
test result: ok. 6 passed; 0 failed
```
`verify_detects_tampering_within_a_community` is the one to look at: it
asserts `HashMismatch` lands on the *tampered* entry's `seq`. It was
failing because verification already blew up on an earlier untampered
row — so the assertion proving tamper detection works had never actually
been exercised. It passes now.
Also:
- `cargo test -p buzz-audit --lib` (no Postgres) — 12 passed, 0 failed.
- `cargo clippy -p buzz-audit --all-targets -- -D warnings` — clean.
- `cargo fmt -p buzz-audit -- --check` — clean.
## New tests
Three in `hash.rs`, none needing Postgres:
- `storage_precision_drops_sub_microsecond_digits` — the helper's
contract, and that it is idempotent so a re-read value is unchanged.
- `nanosecond_timestamps_cannot_survive_a_database_round_trip` — asserts
the digests **differ**. This is the trap itself, written down so the
next person changing the hash preimage sees why the precision reduction
is load-bearing.
- `storage_precision_timestamps_survive_a_database_round_trip` — the
invariant the write path must hold.
Plus `log_timestamp_carries_no_sub_microsecond_digits` in `service.rs`,
deliberately **not** `#[ignore]`d, so a regression on the write path is
caught by `just test-unit` instead of only by Postgres-gated tests that
normally never run.
## Compatibility
Rows written before this stay unverifiable — they always were — so there
is no migration. An operator relying on an existing chain has to
re-anchor.
## Relationship to #2620#2620 proposes a shared `verify_entries` walk (anchoring, seq
contiguity, tail-truncation detection) plus a `buzz-admin audit verify`
command. Its Postgres-free unit tests build entries in memory and would
pass regardless, but its `#[ignore]` Postgres tests and the operator
command itself would fail on every real chain until this lands. Worth
taking this first so that work has a verifiable baseline — the two
changes don't overlap in code.
---------
Signed-off-by: Shani Singh <teamdeveloperworld@gmail.com>
## Problem
Auth-class errors (expired OAuth token, HTTP 401) are non-retryable: the
token won't self-repair between attempts. Today, `PromptOutcome::Error`
for an application-class error falls into the generic `queue.requeue()`
path, burning up to 10 retry slots over a long backoff window before
dead-lettering. Will's canary run observed the 401 message being retried
repeatedly.
## Solution
Add `is_auth_error()` that classifies `AcpError::AgentError` messages
matching two narrow patterns observed in the field:
- `"Re-authenticate"` — emitted by the Claude CLI for expired OAuth
tokens
- `"API Error: 401"` — present in Claude/Codex HTTP-401 responses
Conservative matching is intentional: a false positive (misclassifying a
transient error as non-retryable) silently drops a user message, which
is worse than a false negative (extra retries on an auth error).
In `handle_prompt_result`, a new branch intercepts the failing batch
before `queue.requeue()` for auth-class errors and dead-letters
immediately, posting a user-visible notice to re-authenticate the CLI
(e.g. `claude /login` or `codex login`).
The transport/application split in `PromptOutcome::Error` is untouched —
this only changes batch fate after an application-class auth error.
## Tests
6 new tests in `error_outcome_emission_tests`:
1. `is_auth_error` matches `Re-authenticate` message
2. `is_auth_error` matches `API Error: 401` message
3. `is_auth_error` rejects other `AgentError` messages (usage credits,
etc.)
4. `is_auth_error` rejects transport errors (I/O, WriteTimeout)
5. Auth error dead-letters immediately — 0 pending channels after
`handle_prompt_result`
6. Non-auth application error still requeued — 1 pending channel after
`handle_prompt_result`
Full `cargo test -p buzz-acp`: 598/598 passing.
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>