mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
pr-1321
337 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1b53c2ffcb |
test(search): track p-gated FTS skip drift
Companion to author_only_kinds_are_storage_level_unsearchable ( |
||
|
|
0bd34eac8f |
fix(search): use clamp for page bounds
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> |
||
|
|
a3f407ce96 |
fix(search): exclude p-gated membership notices from FTS
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> |
||
|
|
46ba39e45a |
test(search): track author-only FTS skip drift
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> |
||
|
|
4c51a8119f |
fix(search): harden FTS query inputs
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> |
||
|
|
d5fe805860 |
test(relay): track feed conformance lane
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> |
||
|
|
bdf48ac1d5 |
fix(relay): scope feed queries by community
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> |
||
|
|
4720dc54c2 |
test(buzz-conformance): property/fuzz traces for the replay checker
Add proptest-generated action sequences exercising the conformance checker beyond the hand-built fixtures, closing the skill's "property/fuzz-generated action sequences where feasible" gap (skill-runtime-formal-compliance). Test-only: no production or checker behavior change. The tests assert spec-derived invariants about check_trace's verdict — NOT a parallel oracle re-deriving the verdict (which would just clone check_step and test the code against itself). Six properties, each honoring check_trace's fail-fast contract by constructing traces where the targeted violation is the first/only one: - non-interference soundness: any read (ReadMessageRows / ReadByIdRows / ReadHostFeedRows) carrying a foreign row label is rejected - non-interference completeness: a fully clean trace is accepted - AuthCheck Allow + foreign claim bites IllegalTransition; Deny is in-spec - ImplBug bites CoverageBreach - a mid-trace state flip bites StateMismatch - check_trace is deterministic and never panics proptest is added as a dev-dependency only; the property tests touch only the crate's public check_trace API and depend on no production crate, preserving the checker's independence rule. 128 cases, trace length 1..=12. The new tests run in the existing just test-unit gate (now 22 buzz-conformance tests, was 15) at negligible cost. Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
e23f39175d |
fix(buzz-db): index-match lookup_community_by_host on lower(host)
lookup_community_by_host runs on every relay handshake (WS/bridge/media/git/nip05). The query used `WHERE host = $1`, but the only host index is `idx_communities_host ON communities (lower(host))` — so Postgres seq-scanned communities on every connection: ~5.4ms scanning all rows at 100k tenants. Match the indexed expression with `WHERE lower(host) = lower($1)` so the lookup uses idx_communities_host (~0.037ms index scan at 100k rows). Adds an ignored Postgres regression test asserting case-insensitive host lookup resolves against the lower(host) index. Found by Max while load-testing #1321 at 100k communities. Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
16f955760d |
Merge origin/main into relay rewrite branch
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> |
||
|
|
9edfd33fac |
Remove unused NIP-28 proxy
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> |
||
|
|
982fa1f483 |
relay: complete multi-tenant scoping for reactions, allowlist, git names, and seed/partition fixes
Closes the remaining half-migrations surfaced red-teaming the #1321 multi-tenant relay against the #1285 isolation model: - BUG-1 (ship-blocker): community auto-seed ON CONFLICT(host) now targets the lower(host) index so closed-mode (NIP-43) boot no longer FATALs on a fresh DB. - BUG-2: align runtime partition naming with the seeded partition names. - BUG-3: scope the legacy pubkey_allowlist gate (is_pubkey_allowed, has_allowlist_entries, add/remove/list) and its relay_members backfill by community_id across buzz-db, auth.rs, media.rs, relay_members.rs; scope the git .names disk registry under .names/<community>/ in side_effects.rs. - BUG-5 (ship-blocker): reaction.rs was never migrated to multi-tenant — every reaction 500'd on an ON CONFLICT that omitted community_id. Thread CommunityId through all reaction.rs queries, fix the ON CONFLICT to the full scoped PK (community_id, event_created_at, event_id, pubkey, emoji), and scope every read/list/remove path; update the Db shims and ingest.rs/side_effects.rs call sites to pass tenant.community(). Test harness: normalize the workflow-confinement REST 400 {error} envelope and add a scoped pointer helper in e2e_git.rs; add ignored Postgres regression reactions_are_scoped_to_community. Verified: fmt/build/diff-check green; buzz-db 75/0 + 37 ignored (serial) green; test_nip29_standard_client_flow RED at #1321 -> GREEN. Clean-context reviewed. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
744c77bc2d |
fix(buzz-agent): charge images a token-equivalent for the handoff gate (#1332)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
16c55a1d46 |
test(conformance): wire-live workflow trigger community-confinement row
Replace the stubbed workflows::identical_workflow_and_approval_token row with
two honest rows matching readiness:
- workflow_trigger_is_community_confined: wire-live A/B isolation. Define a
workflow under host A (kind:30620, h=channel + name tag), take A's
server-generated workflow_id, fire it under host B (kind:46020) as a caller
who is an owner-member of the *same channel UUID* in B. B must fail closed
with the generic 'workflow not found' because get_workflow(host_community,
id) is community-scoped (command_executor.rs:703, commit
|
||
|
|
08a0399923 |
style(workflow): rustfmt the multi-tenant fix set
Apply cargo fmt to the 11 files touched by the 9 reviewed multi-tenant
fix commits. CI Rust Lint (just fmt-check) flagged formatting drift my local
clippy --lib --tests run did not exercise; base
|
||
|
|
ea687ccb33 |
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> |
||
|
|
9e0534eecc |
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> |
||
|
|
f64974705c |
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> |
||
|
|
c81b893558 |
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>
|
||
|
|
ce747ec759 |
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> |
||
|
|
2adae32bc7 |
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> |
||
|
|
1151eca83d |
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> |
||
|
|
6846a8f10c |
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> |
||
|
|
c77f2c27d7 |
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> |
||
|
|
c258393ebb |
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> |
||
|
|
f1f6bbf3c2 |
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> |
||
|
|
bfbfe57513 |
test(conformance): document status-code-as-layer-discriminator on nip98_replay assertion
Follow-up to |
||
|
|
d7600deb18 |
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> |
||
|
|
b9fa51ecaf |
test: cover pubsub presence typing isolation
Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
da6051fdbc |
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> |
||
|
|
ae703c5c89 |
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> |
||
|
|
6aa0cec4a9 |
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>
|
||
|
|
b02d767f24 |
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>
|
||
|
|
f7ec53b06d |
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>
|
||
|
|
1d7db983ab |
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>
|
||
|
|
bf8a1a4fa7 |
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 |
||
|
|
809ff9faf7 |
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> |
||
|
|
4b6e1e43d9 |
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> |
||
|
|
be9d26e55a |
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> |
||
|
|
3fd16cc1f7 |
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
|
||
|
|
34ffb8ab37 |
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) |
||
|
|
9e2959e32d |
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>
|
||
|
|
726c83185a |
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> |
||
|
|
36485ade2b |
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>
|
||
|
|
1d3fb3c59a |
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>
|
||
|
|
e6ed8e9356 |
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> |
||
|
|
acb01e4da0 |
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>
|
||
|
|
e1b090e48e |
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>
|
||
|
|
bc453f3d91 |
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> |
||
|
|
2738faf9b2 |
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> |