Commit Graph
321 Commits
Author SHA1 Message Date
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell ef9e174edf fix(workflow): seed interval cold-start anchor so new schedules fire
A brand-new interval workflow on a cold engine has no in-memory last_fired
entry and no prior durable claim, so the scheduler resolves last = None.
interval_should_fire then reads last = now and suppresses the tick (correct:
wait a full interval), but the in-memory anchor is only written AFTER a won
claim, and no claim is attempted until the prefilter passes. Every subsequent
tick repeats with last = None, so the workflow suppresses forever.

Extract interval_prefilter_should_fire (free fn over the last_fired map + a
thin &self wrapper): on the cold-start None suppress path it seeds the anchor
to now so the next tick counts from a real anchor and fires after one interval.
It seeds ONLY when last was None; an existing Some anchor is mid-interval and
must elapse on its own, so it is never advanced. A due/firing tick passes
through without seeding (the post-claim path owns that write).

Unit tests (no Db/Postgres; pure in-memory anchor state):
- cold start seeds then fires after one interval
- mid-interval suppress does not advance an existing anchor
- a due fire passes through without seeding

Caught by Max in cold review of the scheduled-workflow lane; predates this
branch's claim work but lives on the exact lane being cleared.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:30 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 00f75bcfc9 fix(workflow): add scheduled_workflow_fires.workflow_run_id for the attach audit link
The scheduler's post-run `attach_scheduled_workflow_run` does `SET workflow_run_id`,
but neither schema/schema.sql nor the initial migration declared that column, so
every won scheduled claim would create+run, then the best-effort attach would hit
`column "workflow_run_id" does not exist` (PG 42703) and warn forever — the audit
link could never populate. Found by Max in cold review of 778a5d28c.

- Add nullable `workflow_run_id UUID` to scheduled_workflow_fires (schema + migration)
  with a composite FK `(community_id, workflow_run_id) REFERENCES workflow_runs
  (community_id, id)`. The FK uses ON DELETE NO ACTION, not SET NULL: community_id is
  shared with the claim PK and is NOT NULL, so SET NULL is unimplementable (verified
  against live PG: it raises a NOT NULL violation mid-cascade). NO ACTION blocks a
  delete of a still-linked run cleanly; workflow_runs are not pruned today regardless.
- Rewrite the stale scheduled-fires schema comment that still claimed community is
  'resolved server-side from workflow_id, never a caller-supplied claim parameter' —
  contradicted by the S1 reconciliation: community is server provenance from
  list_all_enabled_workflows(), passed explicitly, since id is not globally unique.
- Surface the interval-anchor read failure with a warn! instead of unwrap_or(None)
  swallowing it (still fail-closed: a missing anchor suppresses the tick and retries).
- Add attach_links_run_to_claim_and_is_idempotent: proves the column populates on
  attach and the IS NULL guard makes a second attach a no-op. Proven RED against the
  pre-migration schema (the exact 42703 error), GREEN after — the regression that
  would have caught this gap.

Verified vs live Postgres, serial: buzz-db 110 / buzz-workflow 145 / buzz-relay 414,
0 failed; clippy -D warnings clean on the trio.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:30 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 4dc65b6ee5 fix(workflow): wire community-scoped durable claim into scheduler
The scheduled-workflow fire path never called the durable claim
primitives (`claim_scheduled_workflow_fire` had zero callers outside the
DB wrappers and tests), so with N relay pods every pod that saw a due
cron/interval row created a run and executed the side effect — a
multi-pod duplicate-run bug. And the claim primitive itself was still
keyed by bare `workflow_id` (`WHERE w.id = $1`) despite the schema's
`(community_id, id)` workflow identity: with duplicate workflow UUIDs
across communities (which the schema explicitly allows and the Issue-4
confinement tests pin), a single `INSERT ... SELECT` matched every
community's row and fanned one claim across all of them.

Scope the claim to its community and wire the claim->run boundary into
the scheduler:

- `claim_scheduled_workflow_fire` takes `CommunityId` and binds
  `WHERE w.community_id = $1 AND w.id = $2`. The community is server
  provenance — the `workflow.community_id` from the global scan, never
  client input. This reverses the earlier S1 "resolve-from-id-alone"
  lock, which was written against a globally-unique-`workflow_id`
  assumption the final `(community_id, id)` schema does not hold (and
  which is unimplementable on that key). The surviving invariant is
  "the claim community is server provenance, not client-controlled."

- `WorkflowEngine::run` now claims before `create_workflow_run`; the
  loser skips before any run creation or side effect. The claim anchor
  `scheduled_for` is deterministic across pods: the cron's own scheduled
  instant (`cron_fire_instant`, not `now`) or the interval bucket
  boundary (`interval_fire_instant`, floor to the interval). The
  interval anchor is seeded from `latest_scheduled_workflow_fire` on the
  first tick after restart so a process bounce can't double-fire within
  an interval. `attach_scheduled_workflow_run` links the won claim to
  its run for ops/audit forensics.

Tests:
- Rewrite the stale S1 comment block and `claim_for_workflow_in_other_
  community_no_ops` (which encoded the now-false globally-unique
  assumption) into `claim_confined_to_its_community`: a dup workflow
  UUID in A and B claims independently (claiming A/id leaves B/id
  claimable). Proven RED on the bare-`id` regression.
- `concurrent_same_window_claims_exactly_one_wins` and the prune-anchor
  test updated to the scoped signature.
- New `cron_fire_instant` / `interval_fire_instant` unit tests pin the
  deterministic, drift-stable claim anchors.

Full `cargo test -p buzz-db -p buzz-workflow -p buzz-relay` green vs
live Postgres (serial); clippy clean on the trio.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:30 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 5e62ba8cb8 fix(workflow): scope workflow execution and approvals to their community
`workflows`, `workflow_runs`, and `workflow_approvals` are all keyed
`(community_id, id|token)`, so the same UUID/token is structurally allowed
in two communities — exactly like channels and events. But the execution
and approval spine still fetched, listed, mutated, and posted by bare id,
so a webhook/manual trigger or NIP-09 deletion in community B could load,
drive, or erase community A's colliding workflow, and workflow side effects
were published under the deployment/default tenant instead of the run's own
community. This threads the owning community through every request-scoped
and run-scoped path so each lookup, write, and side effect is confined to
its tenant.

- `ActionSink::send_message` now takes the run's `community_id` as its first
  parameter. `RelayActionSink` drops `bind_deployment_community(relay_url)` —
  the Issue-4 root cause — and instead resolves the run community's host via
  `lookup_community_host` to form a complete `TenantContext::resolved`, fail
  closed if the community is unmapped. A workflow in B now posts into B.

- Executor (`dispatch_action`, `execute_run`, `execute_from_step`,
  `execute_steps`) and engine (`finalize_run`, `on_event`) carry the run's
  community; every `get_workflow_run` / `get_workflow` / `send_message` and
  the post-store `on_event` call (from `dispatch_persistent_event`, which has
  the bound `tenant`) are scoped. The interval `last_fired` DashMap is keyed
  `(CommunityId, Uuid)` so duplicate workflow UUIDs across communities cannot
  cross-suppress in memory.

- Webhook `/hooks/{id}` now binds its community from the request Host before
  any lookup (`bind_community`), then `get_workflow(community, id)`. The host
  — not the workflow row — determines the tenant, so a request to A's host
  can only reach A's workflows; unmapped host and not-found both fail closed
  with the same generic 404.

- WS manual trigger and `create_workflow` use `tenant.community()` as the
  authoritative owner. `create_workflow` no longer resolves the community via
  the ambiguous `community_of_channel(channel_id)`; it verifies the channel
  exists *inside* the bound community via scoped `get_channel` (the same
  guarantee the composite FK enforces, surfaced as a clean rejection).

- Approval grant/deny/resume handlers and the `buzz-db` approval methods
  (`get_approval`, `get_approval_by_stored_hash`, `get_run_approvals`,
  `update_approval`, `update_approval_by_stored_hash`, `create_approval`)
  are scoped by community; `create_approval`'s INSERT now includes the
  `community_id` NOT-NULL column it previously omitted. NIP-09 a-tag workflow
  deletion (`delete_workflow`, `find_workflow_by_owner_and_name`) is scoped
  to the request tenant.

Adds three `#[ignore]` Postgres regressions in `buzz-db::workflow`, each
verified green against live PG and red when the `community_id` predicate is
dropped: `workflow_lookup_is_confined_to_its_community` (dup workflow+channel
UUID in A/B; scoped get/list resolve only the bound community's row, cross
lookup is NotFound), `workflow_delete_is_confined_to_its_community` (deleting
A/id leaves B/id intact), and `approval_is_confined_to_its_community` (same
token in A/B; granting A leaves B pending). Full `cargo test -p buzz-db
-p buzz-workflow -p buzz-relay` green, clippy clean on the trio.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:30 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 66a0add86d fix(admin): derive admin tenant host via shared relay_url_authority
Issue 3 reopen (Max): buzz-admin's resolve_admin_tenant derived its
lookup host with Url::host_str(), which drops an explicit non-default
port and IPv6 brackets. For the default RELAY_URL ws://localhost:3000 the
admin would look up community host `localhost` while startup seeding (and
live request resolution) bind `localhost:3000` — so the admin CLI's
membership writes would miss, or hit, the wrong deployment community.

Lift relay_url_authority into buzz-core::tenant as the single canonical
helper so the relay's host-resolution seam (startup seeding,
bind_deployment_community) and the buzz-admin CLI derive a byte-identical
authority: host plus explicit non-default port, IPv6 brackets preserved,
default ports collapsed — exactly as normalize_host shapes an inbound
Host header. The relay tenant module now `pub use`-re-exports it (no
behavior change at the relay seam); buzz-admin calls it directly.

Adds 4 buzz-core unit tests pinning the authority shape:
non-default-port retention (localhost:3000, relay.example:8443),
default-port collapse (:443/:80), IPv6 brackets ([::1]:3000), and
unparseable/empty -> empty (callers fail closed on empty).

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:30 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell ae7b2e9b30 fix(db): scope reminder claim/release to their community
`claim_due_reminder_with_stamp` and `release_due_reminder` predicated only
on `(created_at, id)`, but `events` is keyed `(community_id, created_at, id)`
and the same Nostr event id — hence the same `id`/`created_at` pair — is
allowed across communities. So a claim for reminder `A/X` would also mark
`B/X` delivered (suppressing B's reminder), and a matching-stamp release for
`A/X` would clear `B/X`. That is cross-community interference in exactly the
reminder lane the claim-before-publish gate fixes; the scheduler's
exactly-once-publish proof rests on this primitive being community-scoped.

Thread `CommunityId` through both `event::` fns, their `Db` wrappers, and the
bare `claim_due_reminder` convenience fn; every predicate is now
`WHERE community_id = $1 AND created_at = $2 AND id = $3 ...`. The reminder
scheduler already carries `reminder.community_id` on the `DueReminder` row
(joined from `communities`), so both call sites pass it with no new tenant
minting.

Adds `reminder_claim_and_release_are_confined_to_their_community`: inserts one
signed reminder event into communities A and B (identical id/created_at),
claims A/X and asserts B/X stays claimable, then releases A/X and asserts B/X
stays claimed while A/X becomes reclaimable. Green against live Postgres
alongside the existing claim-race and stamp-rollback tests.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:30 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell f801807653 fix(relay): scope relay membership to its community
NIP-43 admission confinement (Max review #3 on PR #1321). The
`relay_members` table is keyed `(community_id, pubkey)`, but every DB
access keyed on `pubkey` alone. In closed mode a pubkey admitted to
community A was therefore admitted to community B — the exact M9
mutation #1285 targets.

Request-path scoping:
- thread `CommunityId` through all 9 `buzz-db::relay_members` functions
  and their `Db` wrappers; every query/insert/list/bootstrap now binds
  `community_id` (PK `ON CONFLICT (community_id, pubkey)`, WHERE clauses
  carry community).
- `check_/enforce_relay_membership` take the server-resolved community;
  pass `tenant.community()` at every entrypoint that already binds a
  tenant: bridge (events/query/count), media upload, git transport,
  audio handle, WS auth (`conn.tenant`), mesh `require_mesh_member`
  (connect + status), relay-admin events, leave-request ingest,
  identity-archive consent, NIP-43 list publish, and the buzz-admin CLI
  (via `resolve_admin_tenant`).

Startup seeding (the bootstrap half of the same fix): membership
backfill and owner bootstrap previously ran with no community. They now
run against the deployment's own community, seeded via
`ensure_configured_community` under the *same* normalized host that live
request resolution derives (`relay_url_authority` → `normalize_host`, now
`pub`), so the bootstrapped owner lands in exactly the community requests
for this host resolve to. An unparseable `relay_url` fails fast when
membership is enforced rather than seeding an unreachable empty-host
community.

Regression (Postgres-backed, `#[ignore]`):
- `membership_is_confined_to_its_community`: A admits a pubkey, B does
  not — `is_/get_/list_relay_members` confine it to A.
- `owner_bootstrap_is_confined_to_its_community`: owner bootstrapped in A
  is not a member of B.

cargo test -p buzz-db -p buzz-relay -p buzz-admin green; both new tests
pass against live Postgres; clippy clean on all three crates.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:30 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 7efb4af021 fix(relay): claim reminders before publishing
The NIP-ER reminder scheduler published the reminder event to Redis first
and only then claimed it (`claim_due_reminder`), treating a duplicate publish
as harmless because subscribers dedup by event id. That is the old unsafe
ordering: across N pods a due reminder could be published more than once,
violating the claim-before-side-effect rule for periodic producers (every
side effect must be claimed exactly once, not deduped after the fact).

Rewire to claim-before-publish using the stamp-guarded primitives that were
already built and wired into buzz-db but had zero callers:
`claim_due_reminder_with_stamp` (event.rs:1186) and `release_due_reminder`
(event.rs:1213). Each attempt mints a unique per-pod stamp; the scheduler
claims first, publishes only on a winning claim (`Ok(true)`) and `continue`s
on the loser (`Ok(false)`) so the loser never produces the side effect, and
releases its own claim on publish failure via compare-and-clear so the
reminder is redeliverable next tick. `events.delivered_at` is only ever read
as a NULL/non-NULL sentinel (due-reminder query guard + partial index), never
as a wall-clock value, so an opaque stamp is safe to store there.

The unused convenience wrapper `claim_due_reminder` (seconds stamp) is left in
place as public API; the scheduler no longer uses it.

Tests (buzz-db, Postgres-backed, verified locally against the dev DB):
- claim_due_reminder_is_won_by_exactly_one_of_two_racing_pods: two pods, two
  stamps, one reminder -> exactly one wins; the single winning claim is the
  proof of exactly one publish side effect.
- release_due_reminder_rolls_back_only_the_matching_stamp: a wrong-stamp
  release is a no-op (cannot clear another pod's claim); a matching-stamp
  release makes the reminder reclaimable for retry.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:30 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 60519a6f33 fix(relay): scope local-echo dedup by community
The local-echo dedup cache (`AppState.local_event_ids`) was keyed on the
bare Nostr event id. The same event id can legitimately exist in two
communities (channel-less events; same-channel-UUID/same-`h` events across
tenants), so a local publish of event X in community A would suppress
delivery of a *distinct* same-id event arriving via Redis for community B
for the 60s TTL — a cross-community non-interference violation.

Key the cache on `(CommunityId, [u8; 32])` instead. `mark_local_event` now
takes the community; all 11 callers pass the tenant already in scope
(`tenant.community()` / `conn.tenant.community()`), and the Redis-subscriber
skip/invalidate checks compare the pair. The community was already extracted
at the skip site (`handlers/event.rs`) and used by the scoped fan-out right
below it — it was simply absent from the dedup key.

Regression: `local_echo_suppression_is_scoped_to_its_community` marks A/X
locally, feeds B/X through `fan_out_pubsub_event`, and asserts the B-bound
subscriber still receives it. Fails on the bare-id key (delivery dropped),
passes once the key carries the community.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:30 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 2c6b1b925c docs: rename Typesense references to Postgres FTS
Migrate prose and doc-comments to describe the Postgres FTS backend that
replaced Typesense: README architecture diagram (3 boxes, Postgres now
"events + FTS search"), ARCHITECTURE.md buzz-search section rewritten to the
real API (SearchService::new(pool), search(&SearchQuery), ChannelScope) and
the search_tsv generated-column mechanism (CASE WHEN kind IN (1059,30300,30622)
THEN NULL, idx_events_search_tsv GIN), CONTRIBUTING step-6, VISION, AGENTS,
TESTING (both), and the chart README. Comment-only edits in desktop and
test-client files; drop the dead reindex-kind0 Justfile recipe (its binary no
longer exists).

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:30 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell f1fda2e15e refactor(relay): drop dead Typesense config + correct stale search comments
The Typesense search backend was replaced by Postgres FTS in this rewrite,
but two vestiges remained in live code:

- `Config.typesense_url` / `Config.typesense_key` were still parsed from
  `TYPESENSE_URL` / `TYPESENSE_API_KEY` and stored on the struct, yet read
  nowhere outside config.rs. Removed the fields, env parsing, and struct init.
- Several doc/inline comments still described the search path as hitting
  Typesense (req.rs NIP-50 handler, bridge.rs post-filter rationale). The
  behavior is unchanged but the engine is Postgres FTS; corrected the naming
  so the comments match the code.

Kept genuinely historical references intact (event.rs note that the old
index_event worker is gone; query.rs/schema provenance of the legacy
__global__ sentinel and the FTS migration).

cargo check -p buzz-relay green.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
a7a6dae2bf test(conformance): document status-code-as-layer-discriminator on nip98_replay assertion
Follow-up to da6051fdb per Quinn's cold-read (event 4529860195007964...). The
within-community replay assertion `assert_eq!(second_a.status(), UNAUTHORIZED)`
pins the 401 status code rather than checking the body, because the system has
defense-in-depth across two layers with distinct rejection signatures:

  * auth-layer replay check (`check_nip98_replay`) — rejects with
    401 + body "NIP-98: replay detected".
  * storage-layer dedup (`events` PK `ON CONFLICT DO NOTHING` in
    `ingest_event`) — accepts with 200 + body `accepted: false,
    message: "duplicate"`.

Both reject a duplicate, but only the 401 path proves the seen-set is in the
request path. A body-only check like `!accepted` would pass under a noop'd
`check_nip98_replay` because storage-dedup still 200-accepted-false's the
second post — the bite would go vacuous against the layer the obligation
actually names ("seen-set in the request path").

Adds:
  * Inline `//` comment block immediately above the `assert_eq!` naming the
    two layers, their distinct status signatures, and why the 401 expectation
    is the load-bearing-layer discriminator. Explicitly tells a future
    reader not to weaken to `!accepted` for "simpler reading."
  * Extended assertion message: when the test fails, the panic message now
    names both layers and which one the 401 proves, so a future debugger
    sees the architectural property without reading the doc-comment.

Generalized principle (per Quinn): when a system has defense-in-depth
across layers with different status-code signatures on rejection, the
assertion should pin the status code from the load-bearing layer, not
any rejection. Held in the row's doc-comment (not the shared discipline
slug) per Quinn's stopping rule — this is a deeper instance of slug
rule #2's defense-in-depth class, not a new spine entry.

Bar:
  * Comment/string-only diff: 21 lines (+19 / −2), zero runtime behavior
    change — verified by inspection (`git diff` shows only comments and
    string-literal extensions).
  * `cargo check -p buzz-test-client --tests`: clean.
  * `cargo clippy -p buzz-test-client --tests -- -D warnings`: clean.
  * `cargo fmt -p buzz-test-client -- --check`: clean.
  * Default `cargo test ... api_tokens` (no `--ignored`): doc-only `#[test]`
    still passes; wire-driven still `#[ignore]`-skipped.
  * No live mutate-bite re-run needed: the runtime path of the wire-driven
    test is byte-identical (only strings/comments touched), and the
    mutate-bite at da6051fdb was already RED-on-right-assertion by Sami's
    hands at :3300 and Eva's hands at her :3300 (event 9e9050cd44d6...).
    The follow-up makes the *reason* the bite bites discoverable to a
    future reader; it does not change *whether* the bite bites.

Base: PR #1321 head `da6051fdb`. Test-only diff.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
f49a7dc18e fix(relay): scope media and git substrate by tenant
Community-scope media metadata sidecars so shared CAS bytes are only readable when the request tenant owns the sidecar.

Community-scope git repo pointer cells while keeping immutable pack and manifest CAS shared, and bind git NIP-98 URL verification to the server-resolved request host.

Also isolate git repo path config validation so concurrent tests do not leak BUZZ_GIT_REPO_PATH mutations into unrelated Config::from_env() callers.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
1f08892501 test: cover pubsub presence typing isolation
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
354ad213c5 test(conformance): fill api_tokens_nip98_replay row — doc-only api_token half + wire-driven NIP-98 replay half
Fills both `pending_lane` stubs in `mod api_tokens_nip98_replay`:

# `token_minted_in_a_does_not_authorize_in_b` — doc-only

The api_token mint surface does not exist on the wire in `buzz-relay`: no
`/tokens` route in `router.rs:52-79` (verified by hand on PR head), no
`tokens` module in `crates/buzz-relay/src/api/`. The 792-line self-service
minting endpoint that existed pre-rewrite (sprout-relay PR #37, commit
`f84da74d3`) was deliberately not ported. Api_tokens are *consumed* (not
minted) by the Blossom upload path at `media.rs:638`.

This means "mint in A, present to B" has no wire precondition — a
wire-driven row would test a contract with no entry point. The honest
shape is doc-only, mirroring `audit_log`: where audit proves the
*output* surface does not exist on the wire, api_tokens proves the
*input* surface does not. Both are strictly stronger isolation claims
than a wire-denied assertion.

The `(community_id, token_hash)` fence itself is directly proven at the
storage layer (where direct Postgres access is in-convention):

  * `crates/buzz-db/src/api_token.rs:425
    lookup_by_hash_is_scoped_to_community` — same hash in A and B,
    A-scoped lookup returns A only.
  * `crates/buzz-db/src/api_token.rs:488
    active_lookup_by_hash_is_scoped_to_community` — mirror for the
    revoked-filter variant.

Plus the consumer fence: `media.rs:638` calls the scoped DB lookup with
`tenant.community()` derived from request host *before* token
resolution (`media.rs:97` comment names the row-44 fence explicitly).

# `nip98_replay_seenset_is_shared_and_community_scoped` — wire-driven

Load-bearing wire claim: within-community replay rejection. Sign a
NIP-98 event E for A's `u=`, POST to A → 200. POST again → 401 with a
body that names replay detection. The proof that the shared
(cross-pod) seen-set is in the request path at all — without it, any
pod would re-honor a spent NIP-98 event.

Mutate-bite: `check_nip98_replay → noop` in `bridge.rs:79` (return
`Ok(())` without consulting the guard). Under mutation, second POST
goes 200 instead of 401. Test asserts the failure with named
assertion message pointing at the mutate-bite handle, so a future
reader sees what would have been caught.

Cross-community independence is a *tripwire*, not a bite: sign an
independent NIP-98 event E' for B's `u=` (different event_id by
u-tag canonicalization divergence), POST to B → 200 even though E
was spent in A. Catches future namespace-globalization regressions
(key truncation, u-normalization collapse) that would break the
spend-spread, on top of the unit-layer proof at
`crates/buzz-auth/src/nip98_replay.rs:163
key_isolates_communities_for_same_event_id` (which the substrate's
own doc-comment names as "belt-and-suspenders").

The prefix-drop mutation considered earlier turned out to be vacuous
against natural wire traffic: u-tag divergence across communities
makes event_ids already community-distinct, so dropping the
community prefix from `nip98_replay_key` does not collapse natural
traffic into a shared slot. A same-event_id-different-community
wire collision can't be constructed because u-host
(`verify_bridge_auth`) rejects with 401 before the replay check
runs. That artificial property is proven at the unit layer; the
wire layer asserts the load-bearing per-call replay rejection.

# Bar

  * `cargo check -p buzz-test-client --tests`: clean.
  * `cargo clippy -p buzz-test-client --tests -- -D warnings`: clean.
  * `cargo fmt -p buzz-test-client -- --check`: clean.
  * Default test run (no `--ignored`): 1 passed (doc-only `#[test]`),
    16 ignored (live rows).
  * `--ignored api_tokens` against fresh `:3300` harness
    (`BUZZ_GIT_CONFORMANCE_PROBE=false`): GREEN.
  * Mutate-bite `check_nip98_replay → noop` on `bridge.rs:79`,
    rebuild, restart: RED on the within-A second-POST assertion
    ("second POST to A with the same NIP-98 event MUST be rejected
    as replay (got 200 OK)"), `left: 200, right: 401`. Restored
    byte-identical, GREEN again.

Base: PR #1321 head `ae703c5c8`. Test-only diff: zero lines in
`buzz-db`, `buzz-relay`, or `buzz-auth` production code. Matrix
8/14.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
2ff2af6e4b test(conformance): fill channels_membership row — same channel UUID coexists in two communities
Conformance matrix row `channels_membership` (re-routed from Mari to
Quinn at Eva's call — Mari's #1328 scroll-fix is still landing on main,
and the row's substrate is the same same-UUID-in-two-communities shape I
already used as the setup for `search_fts`). Fills the
`pending_lane("buzz-db", ...)` stub at
`crates/buzz-test-client/tests/conformance_multitenant.rs::mod
channels_membership`.

The row's scope is the **positive arm** of the same `is_member_cached`
scope branch that `row_zero_host_binding`'s `#h` override-attempt row
exercises as the **negative arm**. Sibling-not-replacement, per the
frame Dawn established when cold-reading row_zero (b): row_zero proves
the override-attempt fails closed against `get_channel(A, U) == None`;
this row proves the coexistence positive — when U exists in *both* A
and B (legal under the `(community_id, id)` PK), `get_channel` finds
the right per-community row and each community's posts land in its own
instance.

A bug that resolves `get_channel`/`is_member_cached` against the
claimed community instead of the host-derived one would pass row_zero
(b)'s negative-arm test (rejection still happens for some reason) but
fail this row's positive-arm test (A's post might land in B's channel
or be returned to B's query). So this row catches a class of bugs
row_zero (b) structurally cannot, even though both share the
`is_member_cached` scope branch.

Shape:

  1. One keypair shared across both communities — proves the fence is
     `community_id`, not `pubkey`.
  2. Same channel UUID `U` created in both A and B via REST kind:9007.
  3. Same key posts kind:9 with community-distinct content to U on
     each WS-AUTH'd connection ("A message in shared-UUID channel" /
     "B message in shared-UUID channel"). Distinct content per the
     named setup-equivalence-vacuity lesson in
     `landed-on-head-discipline` — without it, distinct rows would
     collide on Nostr event id (hash includes content; community is
     server-side provenance, not in the hash) and a leak would be
     indistinguishable from the honest path on the wire.
  4. REST `POST /query` with `{kinds:[9], #h:[U]}` against each host.
  5. Each side: count == 1, content == own community's. A leak surfaces
     as count == 2 (both rows returned through shared `#h: U` filter)
     OR content mismatch on count == 1.

Bar (by my own hands against the live `:3100` harness, PR head
`6aa0cec4a`):

  Clean → GREEN.
  Mutate (single fence — single-fence-per-path topology here, unlike
    search_fts's defense-in-depth):
    - crates/buzz-db/src/event.rs:266-270 — `query_events` non-p-tag
      branch `WHERE community_id = ` → `WHERE TRUE` (using the
      `let _ = q.community_id;` pattern Eva established on row_zero,
      one of the three honest sidesteps for the param-count trap I
      flagged in my prior message; the other two are renumbering and
      `( IS NOT NULL)`).
  → RED on `hits_a.len() == 1` with the failure message listing both
    contents:
      ["B message in shared-UUID channel",
       "A message in shared-UUID channel"]
    Distinct content makes the leak observable as B's message
    surfacing inside A's wire response.
  Restore → diff empty → GREEN.

Bar checklist:
- `cargo fmt -p buzz-test-client -- --check`: exit 0.
- `cargo clippy -p buzz-test-client --tests -- -D warnings`: clean.
- `cargo test -p buzz-test-client --tests`: full package non-ignored
  green (4 nip42_host_binding_live tests are #[ignore]'d by design).
- Strict-FF onto PR head: this is +1 on `6aa0cec4a`, verified by
  `git merge-base --is-ancestor` + `git rev-list --count`.
- Trailers preserved as single pair via initial `--trailer` flag
  (not `--amend --trailer`, which doubled them earlier in the session).
- Live two-host harness on `:3100`: my clean (post-restore) binary,
  recipe per RESEARCH/CONFORMANCE_MATRIX_STATUS_2026-06-27.md v3.

Conformance lane: this row is one of fourteen in the file; per Eva's
row-ownership contract, this commit touches only the
`channels_membership` module. No other rows modified.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
e8f42796ce test(conformance): fill users_profiles_nip05 row — kind:0 + NIP-05 lookup per-community
Conformance matrix row `users_profiles_nip05` (Quinn — buzz-search/auth
joint, both halves driven by Quinn per one-author-per-mod-block
discipline; Sami's active queue is `api_tokens_nip98_replay` per Eva's
batching). Fills both `pending_lane` stubs in the row.

Half 1: `same_pubkey_distinct_profiles_in_two_communities`

Same keypair publishes kind:0 (Metadata) on each host's WS-AUTH'd
connection with community-distinct content
(`{"display_name":"A profile"}` vs `{"display_name":"B profile"}`).
NIP-01 replaceable semantics: latest kind:0 per
`(community_id, pubkey)` is what subsequent queries return. REST
`POST /query` (using dev-mode `X-Pubkey` auth, which the
`BUZZ_REQUIRE_AUTH_TOKEN=false` harness allows) returns each host's
own kind:0 — never the other's. Distinct content per community is
load-bearing for the bite: identical content would collapse the leak
into setup-equivalence vacuity (Dawn's catch on `audit_log` —
identical Nostr event ids when (pubkey, created_at, kind, tags,
content) match — making the assertion blind to the wrong-row
substitution).

Half 2: `same_nip05_local_part_on_two_hosts_is_independent`

Same local-part registered in BOTH communities with **distinct**
pubkeys (one per community). `GET /.well-known/nostr.json?name=alice`
against host A returns A's pubkey; against host B returns B's pubkey.
Distinct pubkeys per community make the leak observable as
wrong-pubkey-returned on the wire — the same setup-equivalence-vacuity
defense Dawn established (different keys = different rows = the wrong
answer is observable in the response, not just absent from it). Handle
canonicalization uses `extract_relay_domain` (mirrors
`crates/buzz-relay/src/api/nip05.rs::extract_domain`) against
`RELAY_URL` env so the test still works if the harness's relay URL
changes; defaults to `localhost` for the standard recipe.

Bar (by my own hands against the live `:3100` harness, PR head
`b02d767f2`):

  Clean → BOTH GREEN.
  Mutate (community fences dropped, both paths simultaneously, mirroring
    the search_fts dual-fence approach):
    - crates/buzz-db/src/event.rs:267-270 — `query_events` non-p-tag
      branch `WHERE community_id = $1` → `WHERE TRUE`
    - crates/buzz-db/src/user.rs:185 — `get_user_by_nip05`
      `WHERE community_id = $1 AND LOWER(handle) = LOWER($2)` →
      `WHERE LOWER(handle) = LOWER($1)` (rebind to keep param count
      aligned)
  → BOTH halves RED on their own assertions:
    - kind:0 half: "B's kind:0 content is not B's profile — A's
      profile leaked through. got: '{"display_name":"A profile"}'"
    - NIP-05 half: "NIP-05 lookup on B for local-part 'alice_…' must
      resolve to B's pubkey ($B_PK); got $A_PK. If this is A's
      pubkey, the community fence on `get_user_by_nip05` has been
      dropped and A's user leaked through B's lookup."
  Restore both fences (worktree diff empty after restore) → BOTH GREEN.

Each half bit on a SINGLE-fence mutation this time, unlike search_fts's
defense-in-depth shape. That's because the kind:0 read path
(`query_events`) and the NIP-05 lookup path (`get_user_by_nip05`)
each have one community fence at their layer, not redundant fences
across two layers like the FTS+batch-fetch shape. Different rows have
different defense topologies; this row's mutate-bite is the simpler
single-fence form, exactly as the named failure-mode analysis in
`landed-on-head-discipline` rule #2 sub-bullet predicts (the union of
fences IS what makes the property load-bearing; here the union has
exactly one element per path).

Bar checklist:
- `cargo fmt -p buzz-test-client -- --check`: exit 0.
- `cargo clippy -p buzz-test-client --tests -- -D warnings`: clean.
- `cargo test -p buzz-test-client --tests`: non-ignored 0/0 (the 4
  nip42_host_binding_live tests are #[ignore]'d by design — they need
  the live harness).
- Strict-FF onto PR head: this is +1 on `b02d767f2`, verified by
  `git merge-base --is-ancestor` + `git rev-list --count`.
- Trailers preserved as single pair via the explicit `--trailer`
  flags on the initial commit (not `--amend --trailer`, which doubled
  them earlier in the session).
- Live two-host harness on `:3100`: my clean (post-restore) binary,
  recipe per RESEARCH/CONFORMANCE_MATRIX_STATUS_2026-06-27.md, two
  community rows seeded `a.localhost:3100`/`b.localhost:3100`.

Conformance lane: this row is one of fourteen in the file; per Eva's
row-ownership contract, this commit touches only the
`users_profiles_nip05` module. No other rows modified.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
b5d637d23b test(conformance): fill search_fts row — two-host NIP-50 isolation + delete
Conformance matrix row `search_fts` (Quinn, buzz-search). Fills the
`pending_lane("buzz-search", ...)` stub at
`crates/buzz-test-client/tests/conformance_multitenant.rs::mod search_fts`
with a two-host A/B-isolation shape: one keypair shared across A and B,
same channel UUID reused in both communities (legal under the
`(community_id, id)` PK), the *same* unique FTS token posted to each
community as kind:9 events but with **community-distinct content**.
NIP-50 search on each host must return exactly one hit carrying that
host's community's content; NIP-09 kind:5 delete in A leaves B's row
intact.

Bar (by my own hands against a live two-host relay on the conformance
recipe — Eva's `:3100` `relay-mt` harness, a/b.localhost, shared
PG/Redis, base PR head `bf8a1a4fa`):

  Clean → GREEN.
  Mutate (both community fences on the search read path, simultaneously):
    crates/buzz-search/src/query.rs:160-161           (FTS WHERE community_id = $ctx)
    crates/buzz-db/src/event.rs:870-872               (get_events_by_ids WHERE community_id = $1)
  → RED on `hits_a.len() == 1` with the failure message listing both
    contents:
      ["A community probe ftsconf_…", "B community probe ftsconf_…"]
  Restore both → GREEN.

Two contract surprises discovered by running the row against the live
relay (the lesson Eva established with nip11_relay_info — obligation
text under-determines the layer):

1. The community fence is doubly defended on the search read path: FTS
   filters at the query layer, then `get_events_by_ids` re-filters at
   the read layer. Mutating either fence alone keeps the
   wire-observable property intact (the other defends). The honest
   mutate-bite is to drop both simultaneously; that's what makes the
   union load-bearing for the wire return. The test's doc comment
   names both layers and explains why the single-layer mutation would
   give a false-green.

2. With identical content in both communities, the Nostr event id is
   the SAME byte string in both rows (id = hash(pubkey, created_at,
   kind, tags, content); community is server-side provenance, not
   serialized into id). Under a leak, the wire returns "the row
   matching id" — which can be either community's row — and a count==1
   assertion can't tell A's row from B's. Earlier iterations of this
   test used identical content and discovered the hard way that
   single-hit-with-other-community's-row is indistinguishable from
   correct behavior at that assertion. Per-community-distinct content
   (`"A community probe {token}"` vs `"B community probe {token}"`)
   makes the leak observable: distinct content hashes to distinct ids
   (different rows), and the assertion `hits[0].content == content_a`
   pins which community's row came back.

Other discipline notes:

- Test is `#[ignore]` by default; selected with `-- --ignored`. Reads
  two env vars: `RELAY_URL_A` / `RELAY_URL_B`, both addressing the same
  relay process on different `Host` headers.
- Requires the two-host harness recipe (`BUZZ_HEALTH_PORT=8180
  BUZZ_METRICS_PORT=9202 BUZZ_RECONCILE_CHANNELS=false
  BUZZ_GIT_CONFORMANCE_PROBE=false`, two `communities` rows mapping
  `a.localhost:3100` and `b.localhost:3100` to distinct community
  UUIDs, one binary). Full recipe in the v2 dependency report at
  `RESEARCH/CONFORMANCE_MATRIX_STATUS_2026-06-27.md`.
- Requires Sami's NIP-42 per-tenant relay-tag fix on PR head
  (`bf8a1a4fa`) — without it, `BuzzTestClient::connect(&ws_a, &keys)`
  fails AUTH on the non-configured host. The row was pre-positioned on
  `809ff9faf` and rebased forward to PR head `bf8a1a4fa` exactly +1
  commit; clean rebase (different file regions from
  auth.rs/bridge.rs/nip42_host_binding_live.rs).
- Conformance lane: this row (`search_fts`) is one of fourteen in the
  file; per Eva's row-ownership contract, this commit touches only the
  `search_fts` module. No other rows modified.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 0e9e8583aa test(conformance): fill row_zero_host_binding (unmapped fail-closed + #h override-reject)
Replace both pending_lane stubs in row_zero_host_binding with live wire tests:

(a) unmapped_host_fails_closed_generically — drives an unmapped host over the
    HTTP door (404) vs a mapped host (non-404): the status *difference* proves
    no default-tenant fallthrough. Asserts the rejection body echoes neither the
    host authority nor the bare label (generic, no enumeration oracle), and that
    a raw WS upgrade to the unknown host is rejected at the handshake. Doc-
    comment notes the mapped-200/unmapped-404 status difference is an
    intentional, door-scoped distinguisher (non-nostr+json SPA/WS door only) so
    a future reader does not "fix" it into a 404-everywhere that breaks the SPA
    fallback; the nostr+json door deliberately does not expose it.

(b) client_supplied_community_cannot_override_host — creates an OPEN channel in
    community B only, confirms it is postable in B (positive control), then posts
    a kind:9 #h-tagging that B-only channel UUID over an A connection. A must
    reject: the host-derived community wins over the client #h claim. Open
    visibility isolates the override property from the ordinary membership gate
    (the A-side post can fail for exactly one reason: the channel doesn't exist
    in A's community). Asserts the rejection does not echo the B channel UUID
    (no cross-community existence oracle).

    Bite-specificity: the override assertion also pins the reason string
    "restricted: not a channel member" (the exact IngestError::Rejected the
    override path emits at ingest.rs:446), so the red means "A rejected because
    the host-derived community refused the #h claim", not merely "A rejected
    for some earlier-gate reason" (bind_community 404 / bridge-auth 403 /
    NIP-98 replay / relay-membership 403 / JSON parse 400 all precede the
    channel-scope branch). Two cold reviewers (Dawn, Mari) converged on this
    independently from the sanitization and channels-membership sides.

Sibling-not-replacement: #h override-attempt is row_zero; the relay-tag /
token-u-host override signals are Sami's NIP-42 rows; same-UUID coexistence is
Mari's channels_membership row. Cross-refs documented in the doc-comments.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
c726103f73 test(conformance): audit_log isolation — doc-only row + integrated relay test + error sanitization
The audit log has no client-reachable wire surface: there is no /audit
route in the relay, and AuditError is never relayed to a client. A pure
black-box A≡B conformance row (the shape every other row in
conformance_multitenant.rs uses) is therefore impossible, and reaching
behind the wire into Postgres from that file would break its black-box
contract. So the obligation is proven across three honest homes:

1. Doc-only conformance row (conformance_multitenant.rs): cites the
   no-wire-surface fact — a strictly stronger isolation claim than "the
   oracle is denied" — plus the per-community-chain substrate and the two
   executable proofs below.

2. Integrated relay test (buzz-relay handlers::event): drives
   dispatch_persistent_event under two tenants against a shared Postgres
   and asserts each community's audit chain contains only its own
   object_id and verifies independently. Proves the
   host→TenantContext→chain wiring keeps tenants isolated end-to-end.
   No WS-AUTH in the loop, so it is not blocked on NIP-42.

3. Error-sanitization unit test (buzz-audit error): asserts no AuditError
   variant's rendered text embeds a community_id, constraint name, or
   cross-community object id, with a non-vacuous check that per-community
   seq still appears.

Both runnable pieces mutate-bitten: a stale-tenant scoping bug reds the
isolation assertion ("B's event id appeared in A's chain"); leaking a
constraint name into an #[error] string reds the sanitization assertion.

Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-27 14:42:29 -04:00
0eddf10345 buzz-relay: bind NIP-42 AUTH relay tag to per-tenant host (row 44 sibling)
NIP-42 sibling of the NIP-98 host-binding fix in be9d26e55. `handle_auth` was verifying the AUTH event's `relay` tag against `state.config.relay_url` (one static string per deployment), so under multi-tenant:

  (a) An AUTH event signed against community A's host could be accepted on a connection whose tenant resolved to community B (cross-host token reuse — the same hole `nip98_expected_url` closed on the HTTP side).

  (b) Every legitimate connection whose tenant host wasn't the single configured one would be rejected (the wall Quinn hit bringing up `search_fts`'s two-host harness).

Add `nip42_expected_relay_url(config_relay_url, &tenant)` next to `nip98_expected_url` in `bridge.rs` — scheme from config (preserves `ws://`/`wss://` TLS posture), host from `tenant.host()` (request-resolved, never client-supplied). Thread it at `handlers/auth.rs:73` so `verify_auth_event` receives the per-tenant URL.

Tests (mirror `nip98_expected_url_*` shape, `bridge.rs:1303-1432`):

  * `verify_nip42_rejects_event_signed_for_wrong_communitys_host` — attacker on B-bound connection signs AUTH matching `config.relay_url` (=A's host); fix rejects with `RelayUrlMismatch`. Bites the exact "reverted to config host" regression.

  * `verify_nip42_accepts_event_signed_for_matching_host` — positive control: matching-host AUTH verifies.

  * `nip42_expected_relay_url_uses_tenant_host_not_config_host` — pins host-from-tenant in both directions.

  * `nip42_expected_relay_url_derives_scheme_from_config` — pins `ws://` ↔ `wss://` scheme passthrough.

Plus a live two-host integration test (`crates/buzz-test-client/tests/nip42_host_binding_live.rs`, `#[ignore]`): two seeded communities at `a.localhost:3100`/`b.localhost:3100`, raw WS AUTH with forged `relay` tag, four cases. Under the pre-fix mutation (`config.relay_url` verbatim) with `RELAY_URL=ws://a.localhost:3100`: `nip42_matching_host_accepted_b` failed (`auth-required: verification failed` — legit B traffic blocked) and `nip42_cross_host_rejected_a_relay_tag_on_b_connection` failed (relay accepted A-tag on B connection — the hole). Both green after restore.

Bar:

  * `cargo test -p buzz-relay -p buzz-auth -- --test-threads=1`: buzz-auth 45/45, buzz-relay 403/403 + 1 integration.

  * `cargo test -p buzz-test-client --test nip42_host_binding_live -- --ignored`: 4/4 against live two-host relay.

  * `cargo clippy -p buzz-relay -p buzz-auth -p buzz-test-client --tests -- -D warnings`: clean.

  * Mutate-bite (helper body → `config_relay_url.to_string()`): unit tests 3 RED, live tests 2 RED — exact pre-fix bug shape — both restored byte-identical to green.

Base: 4b6e1e43d (PR #1321 tip). Row-zero priority — gates every WS-authed conformance matrix row.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 7469194042 test(conformance): nip11_relay_info — NIP-11 is host-agnostic, not a community-enumeration oracle
First real (non-pending_lane) conformance row in conformance_multitenant.rs;
the reference pattern remaining rows copy.

Asserts the wire-observable complement to the compile-time static-input fence
(_RELAY_INFO_BUILD_STATIC_INPUT_FENCE): the NIP-11 relay-info document served
for host A, host B, and an *unmapped* host are all byte-identical. Identical
docs are the proof that the unauthenticated relay-info endpoint carries no
host-derived field and therefore cannot be used to probe which communities are
configured on a deployment.

Corrects an initial design error caught by running the row against a live
two-host relay: the unmapped-host case must return 200 with the same static
doc, NOT 404 — a 200-vs-404 status difference between mapped and unmapped hosts
would itself be the enumeration oracle. Fail-closed host binding lives on the
WS-upgrade / non-nostr+json path (router.rs::nip11_or_ws_handler) and is the
obligation of row_zero_host_binding, not this row.

Adds url_unknown() helper (RELAY_URL_UNKNOWN) alongside url_a/url_b.

Verified by hand against a live two-host relay (a/b.localhost:3100, shared
PG/Redis): green -> mutate (leak request Host into the served description) ->
red on the A==B assertion with a real wire diff, not a compile break ->
restore -> green.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
efaad7e662 buzz-db: scope api_tokens lookups to community_id (Gap 2 / row 44)
Conformance row 44 obligates that API token lookups key on
(community_id, token_hash), not on token_hash alone. The storage UNIQUE
index `idx_api_tokens_hash` already enforces this as a *storage*
guarantee — but the query side was filtering on `token_hash = $1` only,
relying on uniqueness as load-bearing for tenancy. That's a structural
gap: any future relaxation of the index (or an adversarial mint that
landed two rows via a tx race) would let a token minted in community A
authorize against a request bound to community B.

This change closes the query-side gap. All eight Db API surface methods
now take a `CommunityId` first parameter, and the underlying SQL adds
`AND community_id = $N` (or includes the column on INSERT). The
`create_api_token*` family additionally INSERTs into the `community_id`
column, which it previously omitted — schema declares it NOT NULL, so
those functions would have failed at runtime if invoked. They have no
callers today (token mint is staged but not wired), but fixing them in
the same diff un-rots the public API and prevents the next caller from
hitting a runtime FK error.

The only live caller is `crates/buzz-relay/src/api/media.rs::resolve_
upload_scopes`, called from the `AuthenticatedUpload` extractor. The
extractor previously resolved scopes BEFORE binding the request's
tenant via `bind_community`, so threading the community through would
have been impossible — the tenant didn't yet exist. Reordered: row-zero
tenant bind moves to step 4 (immediately after header validation),
scope resolution to step 5 with `&TenantContext` in hand. The lookup
in `resolve_upload_scopes` now calls `get_api_token_by_hash_including_
revoked(tenant.community(), &hash)`.

Sharp regression test added at `api_token::tests::lookup_by_hash_is_
scoped_to_community` (#[ignore = "requires Postgres"]): inserts two
same-hash tokens in two communities (legal under UNIQUE(community_id,
token_hash)) and asserts each lookup returns only its own community's
row, and that a third unrelated community returns None. Mirror test
`active_lookup_by_hash_is_scoped_to_community` covers the
`revoked_at IS NULL` variant on `Db::get_api_token_by_hash`.

Mutate-bite proof (verified manually before commit): stripping
`AND community_id = $1` from the WHERE clause fails the test with
`community-B lookup must return B's row` — Postgres returns the
first-inserted (A's) row when filtering on hash alone, as expected.
Restored the clause and re-ran clean.

Verification:
- cargo build --workspace --tests: clean (1.95.0)
- cargo test -p buzz-db -- --include-ignored --test-threads=1: 101/101
- cargo test -p buzz-relay -- --test-threads=1: 399 + 1/1
- cargo clippy --workspace --tests -- -D warnings: clean
- Line-read final diff for tenant provenance: every binding correctly
  threads the request-resolved CommunityId; no client-supplied path.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
a1cd66c812 relay/auth: NIP-98 u-URL host is per-tenant, not config-global
Row 44 obligation closed: NIP-98 u-tag URL host must match req.community.
Previously expected_url was built from state.config.relay_url (one static
string per deployment), which under multi-tenant both (a) admitted events
signed for community A's host at community B's connection, and (b) rejected
every legitimate request whose community host wasn't the single configured
one.

Adds nip98_expected_url(config_relay_url, tenant, path): scheme from config
(preserves ws/wss dev-vs-prod), host from tenant.host() (the same host
row-zero bound from the request Host header). Swaps the three bridge call
sites (submit_event, query_events, count_events). Removes the orphaned
canonical_url helper.

Tests: 4 new in api::bridge::tests covering helper unit (both directions
of host substitution + scheme mapping) and verify_bridge_auth integration
(cross-host rejection + matching-host acceptance). Mutate-bite verified:
reverting the helper internals to config-global behavior turns all 4 new
tests RED with the exact diagnostics they were designed to surface.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
ce406c7160 fix(search): restore privacy kind exclusions at the FTS storage layer
The Typesense→Postgres FTS rewrite replaced out-of-band indexing with
`search_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('simple', content))
STORED` over every row. The old relay (handlers/event.rs:287 on main)
deliberately skipped search-indexing for three kind classes, and the new
search query layer has no kind exclusion — so gift wraps, DM-visibility
snapshots, and event reminders were all in the FTS index.

Fix at the storage layer (option A — single source of truth, zero
app-layer drift across multiple search call sites): make the generated
column yield `NULL::tsvector` for excluded kinds via a CASE expression.
A NULL tsvector never matches `@@`, so excluded rows are structurally
unsearchable.

Excluded set, parity with main's `handlers/event.rs:287-290`:
- 1059  KIND_GIFT_WRAP        (NIP-17 ciphertext)
- 30300 KIND_EVENT_REMINDER   (AUTHOR_ONLY_KINDS — defense in depth)
- 30622 KIND_DM_VISIBILITY    (per-viewer private hide state)

Constants are inlined in the migration with a comment naming the
`buzz_core::kind` names: sqlx migrations are frozen SQL and can't
`use buzz_core::kind`; importing core into a migration would be worse
drift than the inline-with-comment shape.

Three coupled layers:

1. Schema CASE in migrations/0001_initial_schema.sql. The 0001 schema
   was consolidated by Max in 4b7654a1c (Lane-0 contract) and is
   pre-deploy; editing in place rather than adding a new migration
   matches the agreed shape and is covered by
   `run_migrations_applies_consolidated_initial_schema_on_fresh_database`.

2. buzz-search/tests/fts_integration.rs — new test
   `excluded_kinds_are_storage_level_unsearchable` inserts kind:1059 +
   kind:30300 + kind:30622 + kind:9 with the same unique token; asserts
   only the kind:9 control surfaces. Each excluded kind has its own
   load-bearing negative assertion with a diagnostic message naming the
   regression, plus a tight exactly-one-hit bound.

   Mutate-bite verified: dropping the CASE's NULL branch (revert to
   `to_tsvector('simple', content)`) makes excluded kinds searchable
   and the test fails RED with the designed message
   "kind:1059 MUST NOT be searchable — privacy regression in search_tsv
   generated column". Restored → 10/10 green.

3. crates/buzz-test-client/tests/e2e_nostr_interop.rs — rewrote
   `test_nip17_gift_wrap_not_searchable` to use the relay's actual
   NIP-50 search seam (`Filter::new().search(token)`,
   `collect_until_eose`) instead of the now-removed Typesense
   `/multi_search`. Pattern cribbed from
   `test_nip50_search_returns_results_and_eose`. Pointer comment to
   the underlying mutate-bite in fts_integration.rs.

Verification on `quinn/search-kind-exclusions` off `34ffb8ab3`:
- `cargo test -p buzz-search --tests -- --include-ignored`: 10/10
- `cargo test -p buzz-db -- --include-ignored`: 99/99 (incl. migration
  lints + consolidated-schema fresh-DB)
- `cargo check -p buzz-test-client --tests`: clean
- `cargo clippy -p buzz-search -p buzz-db --all-targets -- -D warnings`:
  clean
- `cargo fmt --all -- --check`: clean

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57ccandnpub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d 4aa69f1d1b test(relay): red-team Attack 3 — same-pod regression + fail-closed proof
Adds two regression tests against Max's NIP-98 replay-guard wiring
(b30869d44), proving distinct properties beyond his existing
cross-pod test:

1. nip98_replay_guard_rejects_same_pod_same_community_replay
   (#[ignore], requires Redis) — single guard instance, A1 then A2
   with the same TenantContext rejects the second call. Guards
   against a fix that accidentally weakens same-pod replay
   detection when moka is replaced with the shared Redis seen-set.

2. nip98_replay_check_fails_closed_when_guard_errors (CI-runnable,
   no Redis) — injects a stub Nip98ReplayGuard that always returns
   Err(AuthError::Internal(_)); asserts check_nip98_replay_with_guard
   maps it to (401, error="NIP-98: replay check unavailable").
   Exercises the Err => arm of the match (bridge.rs:107-117) which
   is otherwise untested. This is the load-bearing fail-closed
   property: a stateless worker that loses Redis MUST reject rather
   than admit (Nip98ReplayGuard trait contract,
   buzz-auth/src/nip98_replay.rs:70-73).

Mutate→red→restore (two orthogonal bites, both verified):

- Mutate Ok(false) => Err(...) → Ok(()) at bridge.rs:103. Reds
  Max's cross_pod test AND same_pod_replay; fail_closed stays
  green (doesn't exercise this arm). Restored.
- Mutate Err(e) => Err(api_error(...)) → Ok(()) at bridge.rs:113.
  Reds ONLY fail_closed; cross_pod and same_pod stay green
  (don't trigger Err). Restored.

Distinct mutations bite distinct tests — proves the new properties
are load-bearing, not vacuous, and independent of Max's coverage.

Verification on this commit:
- cargo fmt --all -- --check 
- cargo clippy -p buzz-relay --all-targets -- -D warnings 
- REDIS_URL=… cargo test -p buzz-relay --lib \
    api::bridge::tests::nip98_replay -- --include-ignored \
    --test-threads=1 → 3/0  (Max's + 2 new)
- cargo test -p buzz-relay -- --test-threads=1 → 390/0 + 2 ignored 
  (baseline 389/1 ignored; +1 passing fail-closed, +1 ignored same-pod)

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
(cherry picked from commit 64a94bec901733ee87a84cbb4938e1180a5ca289)
2026-06-27 14:42:29 -04:00
09b56818d7 test(buzz-db): pin communities_of_channels missing-channel-absent contract
The relay-side read-row emitter relies on a load-bearing contract from
`Db::communities_of_channels`: a channel id with no row in the DB MUST
be absent from the returned map, never mapped to a default. The relay's
`MissingLookup → ImplBug{row_community_lookup_missing} → CoverageBreach`
fail-closed guard-rail goes blind if this helper ever started returning
a default/zero entry for unknown channels — and the relay-side
mutate-bite for that guard-rail wouldn't catch it (different layer).

This adds a PG-ignored test that pins both directions:
- (1) Existing channel → present with its true community.
- (2) Missing channel → ABSENT from the result map (load-bearing).
- (3) Map size equals the number of existing channels.

Mutate → red → restore verified against live Postgres:
  Mutant: post-loop `for ch in channel_ids { entry().or_insert(nil) }`
  Result: assertion (2) bites with explicit message
          "missing channel must be absent from the result map,
           got Some(CommunityId(00000000-…))"
  Restored: green.

Closes the read-seam fail-closed chain end-to-end (DB layer through
checker), making it non-vacuous across both layers.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
f190bd41b9 fix(relay): share NIP-98 replay guard via Redis
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
215a82c7f8 feat(relay): emit read-row trace steps with (B) projection
Land the read-seam emitter for the runtime conformance gate. Two
emit sites, one buzz-db helper, one negative fixture.

## buzz-db: `communities_of_channels` helper

`Buzz::communities_of_channels(&[Uuid]) -> HashMap<Uuid, CommunityId>`
— batched per-channel community lookup. Used by the relay emitters to
project each row's true community label independently of the fetch
query's WHERE clause. That independence is what makes the
`Inv_NonInterference` / `Inv_ReadConfinement` bite non-vacuous: a
mutation dropping `community_id = $X` from `query_events` would
still let this helper return the row's true label and the checker
would catch the mismatch.

Channels missing from the result map are intentionally NOT mapped to
a default — callers MUST treat "channel-id not in map" as a coverage
breach, never as "use the resolved community."

## buzz-relay: projection + record helpers

`crate::conformance` gains four new items:

- `project_row_community` — single-row helper encoding the (B)
  strategy: channel-less → resolved (honest, not tautological);
  channel-scoped → lookup or `None` (caller fails closed).
- `RowCommunityProjection` enum — Ok(Vec<CommunityLabel>) OR
  MissingLookup discriminated outcome.
- `record_read_message_rows` — non-search lane: emits
  `ReadMessageRows` on Ok projection, `ImplBug { kind:
  "row_community_lookup_missing" }` on MissingLookup.
- `record_read_by_id_rows` — search lane companion, same shape but
  emits `ReadByIdRows`. `filter_channel` is `None` for the search
  lane (search at the abstract level isn't bound to a single channel;
  per-row `channel_id` carries channel identity honestly).

## req.rs wire-up: two emit sites

- Site 1 (`req.rs` non-search loop, after `query_events`): collect
  distinct channel ids from the result set → `communities_of_channels`
  → `record_read_message_rows`. Production cost: one extra DB query
  per request (NoopTracer short-circuits in non-conformance builds).
- Site 2 (`req.rs` search loop, after `get_events_by_ids`): same
  pattern → `record_read_by_id_rows`. `handle_search_req` gains a
  threaded-through `trace_state: Option<&AbstractState>` parameter.

DB-helper errors on either site fall back to an empty lookup map.
This intentionally triggers `MissingLookup` → `ImplBug` for any
channel-scoped row in the result set, surfacing the helper failure
as a coverage breach (fail-closed) rather than a silent resolved-
label substitution.

## Negative fixture: foreign-row leak

New `bad_foreign_row_leak.jsonl` + matching test. The fixture is a
`ReadMessageRows` whose row_communities contains community B while
the state is bound to community A. This is the proof artifact Eva
requested for the (B)-projection guard-rail: if the row had been
mis-projected as channel-less (defaulting to resolved A), the subset
check would have passed vacuously. By recording the row's TRUE
community independently, `Inv_NonInterference` surfaces it
immediately as `NonInterference`.

## Unit tests (conformance::tests)

Five new tests pinning every behavior:
- `project_row_communities_channelless_uses_resolved` (positive)
- `project_row_communities_channel_scoped_uses_lookup_label` (the
  non-tautological correctness — lookup label, NOT resolved)
- `project_row_communities_channel_scoped_missing_is_breach` (the
  guard-rail bite)
- `record_read_message_rows_missing_lookup_emits_impl_bug`
- `record_read_by_id_rows_ok_emits_read_by_id_rows`

## Mutate → red → restore (three independent bites)

1. Make `project_row_community` fall back to resolved on missing-
   lookup (the tempting wrong-fix): `project_row_communities_channel_
   scoped_missing_is_breach` + `record_read_message_rows_missing_
   lookup_emits_impl_bug` go red with explicit messages
   ("missing lookup must be a breach, got Ok([...])", "expected
   ImplBug coverage breach, got ReadMessageRows {...}"). Restored.

2. Make every channel-scoped row project to resolved (the
   tautological projection): 4 of 9 unit tests go red — the lookup-
   label, missing-breach, and record-helper tests all bite. Restored.

3. Edit the negative fixture to use community_a instead of
   community_b: `foreign_row_leak_is_non_interference` reds ("foreign
   row community label must be rejected by Inv_NonInterference"). The
   fixture is load-bearing, not decorative. Restored.

## Test surfaces

- `cargo test -p buzz-relay --lib` → **394/0** (was 387 baseline).
- `cargo test -p buzz-conformance --lib` → 9/0.
- `cargo test -p buzz-conformance --test replay_fixtures` → **6/0**
  (was 5; +foreign_row_leak).
- `cargo test -p buzz-db --lib` → 75/0 (helper compiles; DB-driven
  integration coverage lives in `--include-ignored` lane on PG).
- `cargo clippy -p buzz-relay -p buzz-db -p buzz-conformance
  --all-targets -- -D warnings` clean.
- `cargo fmt --all -- --check` clean.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
036e3eee30 feat(relay): emit AuthCheck on REQ membership decision
Wire the conformance read seam at `req.rs` channel-membership
confirmation. When the relay falls through to the DB-uncached
membership check, record one `AuthCheck` step on the tracer
mapping `is_member` → `Allow`/`Deny`.

Design notes:
- `trace_state` is built once at request entry, after `pubkey_bytes`
  is available. Reused by every downstream emit (matches ingest's
  `state_for_request` discipline). The `Option` only goes `None`
  on malformed pubkey bytes — a separate failure path.
- `claimed_community: None` is the load-bearing choice on the read
  path: the REQ wire has NO client-asserted community (the `h` filter
  is a channel-id, not a community-id). Encoding `None` here rather
  than copying the resolved community means a future regression that
  ever starts reading a wire-community on REQ would need to put a
  real value in the field — that surfaces at code-review time
  instead of silently projecting away the M2 (claim ≠ resolved) bite.
- No `EmitGuard` at REQ entry: read paths legitimately skip the DB
  on cache hit (no `is_member` call), so a coverage-breach guard
  would false-positive on the common case. Coverage for the read
  seam comes from the upcoming row-emit fixtures, not from a guard
  at the entry point.

Implementation:
- New `crate::conformance::record_req_authcheck` helper centralises
  the emit so the call site stays one line and the helper carries
  the design rationale in its doc comment.
- Two unit tests pin the verdict mapping table:
  `record_req_authcheck_emits_allow_with_none_claim_when_member` and
  `record_req_authcheck_emits_deny_when_not_member`. Mutate→red→
  restore verified: inverting the `if member` branches reds both
  tests with explicit panic messages ("member=true must map to
  Allow", "member=false must map to Deny"); restored both green.

Test surfaces:
- `cargo test -p buzz-relay --lib` → 389/0 (was 387 baseline).
- `cargo test -p buzz-conformance` → 14/0 unchanged.
- `cargo clippy -p buzz-relay --all-targets -- -D warnings` clean.
- `cargo fmt --all -- --check` clean.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
473075561e test(relay): EmitGuard coverage-breach self-test
Two unit tests in `crates/buzz-relay/src/conformance/mod.rs` that prove
the structural fail-closed property of the [`EmitGuard`]:

- `emit_guard_drop_records_exactly_one_impl_bug_when_no_emit` — drop
  the guard with zero recorded steps on the returned counting tracer →
  exactly one `ImplBug` step lands on the inner tracer, carrying the
  seam-name string passed to `EmitGuard::arm`.
- `emit_guard_drop_is_silent_when_an_emit_reached_the_tracer` — record
  at least one step through the counting wrapper → Drop emits no
  `ImplBug`, only the original step.

These pin the counting-tracer design against a future refactor to a
"disarm" flag, which would only fail-close by author discipline. The
counting wrapper makes coverage breach fire *structurally* regardless
of what the request path did or didn't do — that's the whole point of
the coverage-breach mode.

Verify: `cargo test -p buzz-relay --lib conformance::` → 2/2.
Full `cargo test -p buzz-relay --lib -- --test-threads=1` → 387/0.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
92f65ae0f2 feat(relay): wire conformance emitter into ingest seam
Add `crates/buzz-relay/src/conformance/` module:
- `Tracer` re-export, `NoopTracer` (production), `JsonlTracer` (test/CI).
- `EmitGuard::arm(tracer, state, kind) → (guard, counting_tracer)`:
  RAII coverage breach. The guard wraps the original tracer in a
  counting layer; production callers transparently use that wrapper.
  If no emit reaches the wrapper before the guard drops, the guard
  emits a synthetic `ImplBug` step on the underlying tracer — the
  checker treats that as CoverageBreach. The wrapper design means
  production paths never need to "disarm" or pass anything around;
  a future new exit that forgets to emit will be caught
  automatically.
- `state_for_request(tenant, actor)`: builds AbstractState. Pulls
  community + host directly from server-resolved TenantContext.
- `claimed_community_from_event`: reads the event's h tag for the
  trace's `claimed_community` field — recorded SEPARATELY from
  `resolved_community` so M2 (claim≠resolved) and M8 (host/channel
  disagreement) bite at the checker.
- `sanitized_reason_for(&IngestError) → SanitizedReason`: 1:1 map
  of IngestError variants (Rejected/AuthFailed/Internal) onto the
  closed SanitizedReason alphabet (Invalid/Restricted/ServerError).
  Adding a fourth IngestError variant breaks this match — CI catches
  it before it ships.

Emitter wiring in `crates/buzz-relay/src/handlers/ingest.rs`:
- `ingest_event` is now a thin wrapper: arms EmitGuard, calls
  `ingest_event_inner`, and on Err maps to SanitizedError. All 36
  early-Err returns and 6 Ok returns in the inner fn are covered
  by this single outer mapping.
- At `check_channel_membership` call site (line 1401): emits
  `AuthCheck { channel, claimed_community, verdict }` with
  Allow on Ok, Deny on Err. The verdict basis is `tenant.community()`
  server-resolved — confirmed at ingest.rs:424's `is_member_cached`
  call signature (not event-derived).
- At each `dispatch_persistent_event` call site (lines 1908, 2014):
  emits `WriteInsert` (channel + was_inserted=true), `WriteDuplicate`
  (channel + was_inserted=false), or `WriteInsertGlobal` (no channel).
  This is the entire write side of the ingest seam.

`crates/buzz-relay/src/handlers/event.rs` `dispatch_persistent_event`:
- No emit added. Documented why inline: the spec has no separate
  fan-out action — acceptance is recorded at ingest's WriteInsert;
  fan-out surfaces as ReadMessageRows on the subscriber side (the
  read seam in req.rs, lands in the held-back additive diff).

AppState carries `tracer: Arc<dyn buzz_conformance::Tracer>`,
defaulting to NoopTracer (zero cost). Test contexts overwrite this
field with a JsonlTracer after construction.

Verify:
- cargo check -p buzz-relay green
- cargo test -p buzz-relay --lib: 378/378 (no regressions)
- cargo test -p buzz-conformance: 9/9 (checker still bites all four
  failure modes)

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
caae4cdbb8 feat(conformance): runtime trace schema + independent replay checker
Adds `crates/buzz-conformance/` — the substrate for runtime formal-spec
conformance. It is the **independent oracle** for the multi-tenant relay:
given a trace of seam events recorded by the relay at runtime, the
checker asserts they obey `docs/spec/MultiTenantRelay.tla`. Production
binaries pay zero cost (the relay defaults to `NoopTracer`); test/staging
runs against `JsonlTracer` and the checker re-runs every captured trace.

Crate contents:

- `src/lib.rs` — schema: `TraceStep`, `TraceAction` (8 spec actions +
  `ImplBug` for coverage-breach), `AbstractState` (resolved_community,
  bound_host, actor), the `Tracer` trait, `NoopTracer` for prod.
- `src/transitions.rs` — re-implementation of the spec's `Next` relation
  in Rust, used by the checker. Owned by this crate, not pulled from the
  relay — that's what makes the oracle independent.
- `src/checker.rs` — replay engine: `check_trace` returns
  `Err(IllegalTransition | StateMismatch | NonInterference | CoverageBreach)`
  on any departure from the spec. 9 unit tests covering each failure mode
  plus the M2/M8 (`claimed != resolved`) and NI/ReadConfinement bites.
- `tests/replay_fixtures.rs` + `tests/fixtures/*.jsonl` — five tests that
  reconstruct three on-disk JSONL fixtures from typed Rust, assert the
  committed file matches byte-for-byte (any schema change requires
  `BUZZ_CONFORMANCE_UPDATE=1` to refresh), then replay each through
  `check_trace`:
    - `good.jsonl`                       → `Ok(())`
    - `bad_host_channel_mismatch.jsonl`  → `IllegalTransition`
    - `bad_coverage_breach.jsonl`        → `CoverageBreach`
- `TRACE_SCHEMA.md` — grounds every action in its `MultiTenantRelay.tla`
  line and calls out the three load-bearing projection rules.
- `LIMITS.md` — honestly describes what a green run does/doesn't prove,
  and the CI command listing the test surfaces.

Production-fence discipline: deps are exactly `serde / serde_json /
thiserror / uuid`. Zero `buzz-*` production crates. `CommunityLabel(Uuid)`
is a newtype in this crate, NOT `buzz_core::CommunityId` — the checker
physically cannot inherit a production bug because it shares no code
with the relay.

Verify discipline:
- `cargo test -p buzz-conformance --lib`             → 9/9
- `cargo test -p buzz-conformance --test replay_fixtures` → 5/5
- Mutate→red→restore proven three times (in earlier session): row-label
  corruption → `NonInterference` fires; trace `claimed = resolved` →
  `IllegalTransition` vanishes; counter threshold loosened →
  `ImplBug` doesn't fire.

This commit lands the substrate only. The relay-side glue
(`crates/buzz-relay/src/conformance/{mod,tracers}.rs`, `AppState.tracer`,
`EmitGuard`) and the ingest-seam emitter follow on the next branch
(`quinn/conformance-relay-glue`). The req.rs read-seam emitters land
after.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 7f191ccb1e fix(relay): clear clippy on integrated multi-tenant stack
The H1 fanout fix threaded a server-resolved CommunityId through the
connection registry and two membership caches, tipping three clippy lints:
register() to 8 args (too_many_arguments) and the moka cache keys to
type_complexity. Allow both locally with rationale, matching existing
repo convention (observer_owner_cache already carries the same allow in
this file; buzz-db/buzz-cli use too_many_arguments allows). Also relocate
topic_for_subscription above the req.rs test module to clear
items_after_test_module surfaced by --all-targets.

No behavior change; buzz-relay 385/0, clippy --all-targets -D warnings clean.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyfandnpub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d 5f03ea42c3 fix(relay): scope DM command writes by community
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
2026-06-27 14:42:29 -04:00
a7a2270216 fix(relay): bind fanout to receiver community
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:42:29 -04:00
9b0bd814ce fix(relay): fail closed on empty/whitespace host in bind_community
The host-binding seam (Inv_RowZero) must derive a community only from a
request's actual Host. An empty raw_host carries no community evidence,
yet bind_community passed it straight to the resolver. The schema does
not forbid an `host = ''` row in communities (0001_initial_schema.sql:
`host VARCHAR(255) NOT NULL`, unique index on `lower(host)`), so a
misconfigured/empty-host row plus a request with a missing, unreadable,
or whitespace-only Host header would silently bind to that community.

Guard before the resolver lookup: if normalize_host(raw_host) is empty,
return BindError::UnmappedHost. Reuses the existing variant so the
rejection is byte-identical to any other unmapped host — an
unauthenticated caller cannot probe whether an empty-host row exists.

Red-team finding (Attack 2, MEDIUM defense-in-depth) by Sami; her three
proof tests (empty, whitespace-only, plus a non-empty negative control)
are included un-ignored. Verified mutate->red->restore: removing the
guard reds both empty-host gates with the literal fence collapse
(TenantContext{community: X, host: ""}). Full cargo test -p buzz-relay
--lib: 381 passed, 0 failed.

Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-26 23:26:30 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf fb0d6a4ea0 fix(relay): use deployment tenant binding for startup membership
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
2026-06-26 22:29:59 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf 139d6dbda5 fix(relay): preserve relay URL port for deployment binding
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
2026-06-26 22:25:07 -04:00
tlongwell-blockandEva 3e57144b48 fix(relay-mt): clear clippy -D warnings introduced by tenant threading
CI Rust Lint + Windows Rust run `cargo clippy --workspace --all-targets -- -D warnings`; the community_id/tenant args pushed six fns to 8/7 and the new NIP-98 replay code tripped clamp/const-assert lints. Resolve at the bar, matching existing repo conventions:

- 6x #[allow(clippy::too_many_arguments)] on the fns that gained a tenant/community arg (same convention already used across buzz-db/relay).
- buzz-pubsub replay TTL: .max().min() -> .clamp() (floor 120 < ceiling 3600, cannot panic; behavior identical, incl. the u64::MAX clamp test).
- buzz-auth replay const-drift tripwires: scoped #[allow(clippy::assertions_on_constants)] — the assert-on-constant IS the design (fails if someone drifts the TTL constants).

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:48:11 -04:00
tlongwell-blockandEva a8a9dd9e1a feat(relay): wire relay + admin call sites to community-scoped v3 API
Threads server-resolved `community_id`/`TenantContext` through the whole
relay call graph and the operator CLI against the v3 DB/pubsub API, so
every scoped row read and every Redis publish names a community the relay
derived from data, never from caller input.

Relay (`crates/buzz-relay`):
- Read-path caches take `CommunityId`; write/invalidate publishers take
  `&TenantContext` (the Redis topic key needs the host). The cross-node
  fan-out path only has the community, so caches stay constructible there.
- Doors fail closed: WS/bridge/media/NIP-05 bind community from the request
  host via `bind_community`, falling through to an empty/404 response on an
  unmapped host — no default tenant, no host echo.
- Background loops get tenant from the DB row they act on: the reaper builds
  `TenantContext::resolved(row.community_id, row.host)` per archived channel
  from the reaper RETURNING; the dev/CI reconciler and reminder scheduler
  resolve the one configured community from `relay_url`, fail-closed.
- Deployment-community cases with no connection tenant (git hook/finalize,
  workflow sink) resolve via the same host-resolution seam.
- Drop the Typesense-only `reindex_kind0` backfill binary, obsolete under
  the Postgres FTS migration and referenced nowhere.

Admin CLI (`crates/buzz-admin`):
- New `resolve_admin_tenant` reads `RELAY_URL` host (the CLI runs
  `compose exec relay buzz-admin`, sharing the relay's env) and resolves it
  via `lookup_community_by_host`, fail-closed on an unmapped host.
- Scope the NIP-43 membership-list publish (`EventTopic::Global`), channel
  reconcile, `get_members`, and the kind:39000 existence `EventQuery`
  (`..EventQuery::for_community`). Drop the now-dead `uuid` dep.

Workspace gate: `cargo check --workspace` green; buzz-db 97/97, buzz-audit
13/13, buzz-relay 375 + main 1 (`--include-ignored --test-threads=1`),
buzz-admin compiles, fmt + buzz-admin clippy clean.

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:37:00 -04:00
tlongwell-blockandMari 031b84e255 fix(db): scope archived identities by community
Archived identity state is tenant-local; a pubkey archived in one community must not read as archived in another. Thread CommunityId through the archived identity queries and DB wrappers, and bind the composite key used by the migration.

Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:37:00 -04:00
tlongwell-blockandEva ec22cdb117 feat(relay): RelayInfo::build static-input fence (conformance NIP-11 row)
The conformance obligation for the NIP-11 surface: RelayInfo::build must
not grow unscoped DB/search/audit inputs, so an unauthenticated NIP-11
read can never become a cross-community enumeration oracle.

Binds RelayInfo::build to its exact allowed signature via a const fn
pointer. Adding a &Db / &AppState / search / audit input makes the
function-pointer type stop matching and breaks the build at the fence —
a silent cross-tenant leak becomes a hard compile error, deny-lint style.

Adversarially proven: injecting a &AppState param into build() produces
error[E0308] mismatched types at the fence const (plus E0061 at the call
sites); reverted to confirm the fence, not the call sites alone, is the
guard. buzz-relay package 374 green at --test-threads=1.

(cherry picked from commit 76a4044c7cfb1c96a6817be1e81c7ae42d1ea3da)

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:36:59 -04:00
tlongwell-blockandEva 787774f3b4 test(conformance): multi-tenant A/B isolation harness skeleton
Executable form of docs/multi-tenant-conformance.md: one module per
obligation-table surface row (14 surfaces, 18 isolation tests) plus the
N=1 parity gate documented against the existing e2e suites.

Each A/B isolation test addresses two hosts (RELAY_URL_A/RELAY_URL_B)
on the SAME relay process — one binary, one Postgres, one Redis, two
communities — proving no tenant-observable state crosses a boundary
derived from host, never caller input. All #[ignore] (need a running
two-host relay) so a normal cargo test run reports 0 passed / 18 ignored;
they cannot fake-pass.

Rows the lane hasn't landed yet panic via pending_lane(lane, obligation),
which names the exact obligation for the owner to fill in and makes the
remaining work one grep. Lane ownership tagged per module.

(cherry picked from commit 9d6d35f07a17fcf5ccd8a6f20fdede3349e67024)

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:36:59 -04:00
tlongwell-blockandEva be56652d63 feat(relay): huddle-audio-unavailable guardrail under horizontal scaling (§5b)
Plan §5b, decided by Tyler: rather than sticky-route huddles or ship a
silent split-room, a horizontally-scaled deployment surfaces a clear,
client-handleable unavailable signal on huddle join.

- config: huddle_audio_available bool, env BUZZ_HUDDLE_AUDIO_AVAILABLE.
  Defaults true so single-pod (N=1) deployments keep today's huddle
  behavior unchanged. Operators running multiple relay pods set it false.
- audio handler: after auth + membership pass and BEFORE get_or_create joins
  a room, if huddle_audio_available is false we send
  {type:error, code:huddle_audio_unavailable, message:...} and return — no
  silent room join whose frames never cross pods.

Why a config flag and not pod-count self-detection: the relay can't reliably
count its own pods; an explicit operator flag is the honest model and keeps
the §4 fork-B (any-pod-any-connection) generic routing free of huddle
stickiness. The real fix is the out-of-relay media/SFU service (Tyler's
long-term target), out of scope for this rewrite.

Tests: default-true (N=1 compat) and env-false-disables, both green. Full
buzz-relay --lib green at --test-threads=1 (374). Note for this lane: there
is a pre-existing parallel-run env-var race (global_presence_pubsub test
calls Config::from_env without the config tests' ENV_MUTEX guard) — not a
regression from this change; flagged to fix in the wiring lane.

(cherry picked from commit cc2bc29d4429da9e1a3e80217936340a4c1ca721)

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:36:59 -04:00
tlongwell-blockandEva c6ec9a33fe feat(relay): row-zero host-binding seam (HostResolver + fail-closed bind)
Conformance row zero: req.community = resolve_host(connection.host), bound
before any handler observes tenant data. This lands the relay-side seam:

- HostResolver trait (native async fn, no async-trait dep) — buzz-db's
  Db::resolve_host satisfies it; the relay depends on the trait, not the
  query, so the binding is testable without a database. Callers are generic
  over R, no dyn dispatch (the relay holds a concrete Db).
- bind_community(): normalizes the host with the one shared rule, resolves
  it, and fails closed on BOTH unmapped host AND lookup error — there is no
  path that yields a default/fallback community. UnmappedHost is a distinct
  variant the call site turns into a GENERIC reject (no host echo, no
  unmapped-vs-error distinction) so an unauthenticated caller can't probe
  which hosts exist.
- TenantContext carries the normalized host, so downstream NIP-05/audit
  labelling and the NIP-98 u-host check all see the canonical form the
  community was resolved from.

Tests (4, green) cover known-host bind, variant normalization (case/dot/
default-port can't split a tenant), unmapped fail-closed, and lookup-error
fail-closed-not-default. Adversarially verified: mutating the None arm to
fall through to a nil default community turns unmapped_host_fails_closed RED.

Seam contract for the buzz-db lane (Mari): Db::resolve_host(&self,
normalized_host: &str) -> Result<Option<CommunityId>, DbError>, a SELECT id
FROM communities WHERE host = $1 on the normalized key. Router call site
(nip11_or_ws_handler) + threading TenantContext through handle_connection
land next in this lane.

(cherry picked from commit 0be8532e0e94e5ecd6529f2f3f52255dd36f6009)

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:36:59 -04:00
tlongwell-blockandDawn fec1834caf feat(audit): widen NewAuditEntry.community_id to CommunityId
Make the provenance fence visible in the type signature, not a
per-call-site convention. `NewAuditEntry.community_id` becomes
`CommunityId` (the server-resolved newtype) instead of a raw `Uuid`,
so a wiring call site can no longer pass an arbitrary UUID off the
event/channel being acted on — the only doors to a `CommunityId` are
host resolution or a server-scoped DB row, never client input.

The DB-row type `AuditEntry` stays `Uuid`: sqlx reads/writes it directly
and `compute_hash` does `.as_bytes()` on it, so the stored hash bytes are
byte-for-byte identical and the already-integrated chain stays valid — no
migration, no re-hash. The `as_uuid()` dereference moves inside
`AuditService::log` at the DB boundary, where the column is written; the
advisory-lock key is unchanged (CommunityId's Display delegates to Uuid).

Drop the now-orphaned `Serialize`/`Deserialize` derive (and the
`#[serde(default)]` on `detail`) from `NewAuditEntry`: it has no
serde consumer — it travels only through the in-process audit sink
(mpsc), never a wire/DB boundary. Keeping it non-deserializable
reinforces the fence: no client blob can mint a NewAuditEntry.

Full package green (13/13, incl. the 6 PG isolation tests and the
community_id_is_part_of_identity fence); clippy -D warnings + fmt clean.
Adversarially verified the fence is non-vacuous: dropping community_id
from compute_hash turns community_id_is_part_of_identity RED, restored.

(cherry picked from commit 284cc699b2b04c9078456ffda849315fb4562763)

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:36:59 -04:00
tlongwell-blockandDawn 81b064fb19 feat(audit): per-community hash chain on the frozen audit_log DDL
Convert the audit log from one global hash chain to an independent
per-community chain, conforming to the frozen Lane-0 0001 schema.

- Collapse to one DDL: delete schema.rs / AUDIT_SCHEMA_SQL and their
  lib.rs exports. The 0001 migration is the sole owner of audit_log.
- Chain shape: PK (community_id, seq), seq monotonic per-community,
  UNIQUE (community_id, hash); hash/prev_hash/actor_pubkey as BYTEA;
  object_id TEXT generalizes the old event_id/channel_id; detail JSONB.
- community_id is folded into the SHA-256 (leads the hash) so a row
  cannot be lifted out of one community's chain and re-verified in
  another. Per-community advisory lock — communities never serialize
  each other's audit writes (no throughput bottleneck, no timing oracle).
- verify_chain / get_entries scoped to a CommunityId.
- Error variants carry only per-community seq (meaningless without its
  chain) — never community_id, hash values, or raw action strings.
- AUTH-body protection becomes caller discipline + the AuditAction enum
  (AuthSuccess/AuthFailure carry outcome metadata, never the token);
  the dropped event_kind column is not persisted.

13/13 green (7 unit + 6 Postgres isolation). Adversarial: disabling the
community_id line in compute_hash turns community_id_is_part_of_identity
RED (two communities hash identically); restored to green.

(cherry picked from commit ba11d66636)

Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:36:59 -04:00
tlongwell-blockandQuinn 38708b84ad rewrite(search): ChannelScope enum closes ChannelLessOnly fence hole
The legacy 2x2 `(channel_ids: Option<Vec<Uuid>>, include_channel_less: bool)` shape could not unambiguously express "channel-less events only" — both `Some(vec![]) + true` and `None + true` fell into the no-constraint branch, silently broadening to all community channels rather than restricting to `channel_id IS NULL`. That matched the legacy Typesense `channel_id:=__global__` sentinel one way (per-channel + global) but not the other (global only).

Replace with a single `ChannelScope` enum whose four variants are 1-to-1 with the legacy `(accessible_channels, include_global)` matrix:

  - non-empty + true  -> ChannelsOrChannelLess(accessible)
  - non-empty + false -> Channels(accessible)
  - empty     + true  -> ChannelLessOnly  (the variant the old shape could not express)
  - empty     + false -> caller short-circuits to EOSE, doesn't call search

Emitted SQL fragments are byte-identical to the legacy match for the three carry-over cases; `ChannelLessOnly` adds `AND channel_id IS NULL` — the fence the old type could not express.

Verification:
- Full package `cargo test -p buzz-search -- --include-ignored --test-threads=1`: 9/9 green (8 existing + 1 new `channel_less_only_excludes_per_channel_events`).
- Adversarial mutation: replaced the `ChannelLessOnly` SQL emission with a no-op (the buggy semantic the old shape produced); new test went RED with 3 hits instead of 1, restored, green again. The fix is the emitted predicate, not the variant name.
- clippy -D warnings clean; fmt clean.
- Empty-vec edge cases are intentionally not special-cased: `Channels(vec![])` emits `channel_id = ANY('{}')` (false-for-all, zero hits, preserves the old early-return semantic via SQL); `ChannelsOrChannelLess(vec![])` is equivalent to `ChannelLessOnly`.

Coordinated with Eva ahead of relay-wiring sweep at req.rs and bridge.rs so call sites land against the final type, not the buggy one.

(cherry picked from commit c8cd333e33)

Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:36:59 -04:00
tlongwell-blockandQuinn c2477ba34f rewrite(search): Postgres FTS, community-scoped, drop Typesense
The Lane-0 freeze landed `events.search_tsv TSVECTOR GENERATED ALWAYS AS
(to_tsvector('simple', content)) STORED` + `GIN (search_tsv)` directly in
the schema. With that in place the entire Typesense apparatus is dead
weight: there is nothing to index out-of-band, no consistency window to
reason about, no client-forgeable index/content drift. Indexing is the
SQL write.

This rewrites `crates/buzz-search/` from scratch around that:

  - `query.rs`: one SQL builder. `community_id = $ctx` is the first
    predicate of every executed statement and is unconditional —
    `SearchQuery` requires a `CommunityId` at the type level (no
    construction path omits it). `search_tsv @@ websearch_to_tsquery(...)`
    is the FTS predicate; `ts_rank_cd DESC, created_at DESC, id` is the
    order. Channel scope replaces today's `__global__` sentinel with
    `channel_id IS NULL`. Empty query short-circuits without a roundtrip.

  - `lib.rs`: thin `SearchService { pool }`. Takes `&PgPool` directly so
    the crate stays a leaf — no buzz-db dependency. Re-exports
    `CommunityId` for callers that need to mint the fence.

  - `error.rs`: collapsed to one variant (`Db(sqlx::Error)`); empty
    queries are not errors.

  - Deleted `collection.rs` and `index.rs` (Typesense HTTP client and
    indexer). Dropped `reqwest`/`serde`/`serde_json`/`chrono`/`nostr`
    from `Cargo.toml`.

  - Added `tests/fts_integration.rs` — 8 integration tests against real
    Postgres, each on its own throwaway schema applying the frozen
    `migrations/0001_initial_schema.sql` via `include_str!`. The
    load-bearing one is `search_does_not_return_other_community_events`:
    mutating the `community_id = $ctx` predicate to `1=1` makes that
    test go red (verified, then reverted) — the fence bites where it
    has to.

Conformance row 50 — search re-auth and one-shot NIP-50 — is unchanged
in shape: the relay refetches canonical events per hit through buzz-db's
scoped fetcher and runs the access predicate. Search is never the
access boundary; this crate just returns candidate ids. The row's
Typesense prose rewrite is owned by Eva's integration lane (one writer
per path).

EXPLAIN ANALYZE evidence on a 200k-row community confirms the planner
picks `Bitmap Index Scan on events_p<...>_search_tsv_idx` for the
populated partition (full plan in RESEARCH/SEARCH_LANE_FTS_EXPLAIN.md
in the workspace). Single-column `GIN (search_tsv)` is sufficient at
this scale — no `btree_gin` needed (Max's caveat holds).

Cross-lane removals owed to Eva (relay-wiring lane, not this commit):
  - relay state.rs: remove `search_index_tx` mpsc + worker
  - relay main.rs: remove `search.ensure_collection()` call
  - relay handlers/event.rs: remove `search_index_tx.send()`
  - relay api/bridge.rs::handle_bridge_search: rewrite to new API
  - relay handlers/req.rs::handle_search_req: rewrite to new API
  - relay handlers/req.rs::build_search_channel_scope_filter: delete
  - relay bin/reindex_kind0.rs: delete
  - docker-compose.yml: drop typesense service + volume
  - docs/multi-tenant-conformance.md row 50: rewrite Typesense prose

Tests: `cargo test -p buzz-search --test fts_integration --
--include-ignored --test-threads=1` — 8 passed, 0 failed.
Clippy: `cargo clippy -p buzz-search --all-targets -- -D warnings` — clean.

(cherry picked from commit e31c098ce5)

Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:36:58 -04:00