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.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
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.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
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.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Closes the last Lane-0 schema items before the frozen base:
- events.search_tsv TSVECTOR GENERATED ALWAYS AS to_tsvector('simple',
content) STORED + GIN idx_events_search_tsv. The Typesense->Postgres FTS
data shape, landed in Lane 0 because it touches the just-locked events
table (Quinn option A). GENERATED ALWAYS = single source of truth: proven
against PG that a client cannot forge search_tsv out of sync with content
(generated_always rejection). Index left minimal single-column GIN; the
search lane picks the final spelling after EXPLAIN (Max's caveat).
- Delete stale 0002_backfill_d_tag.sql / 0003_event_reminders.sql. In the
consolidated-from-scratch model 0001 already carries d_tag, not_before,
delivered_at, and idx_events_not_before; re-running the old additive
migrations would error (duplicate column / duplicate index name).
audit_log DDL shape confirmed for the audit-crate collapse (Dawn's lane):
PRIMARY KEY (community_id, seq), UNIQUE (community_id, hash), community_id
NOT NULL on every row. 0001 is the single source; buzz-audit drops its own
schema.rs / AUDIT_SCHEMA_SQL / ensure_schema() in the audit lane.
Re-proven against real Postgres — full fence suite green: T1 re-tenant
rejected, T6 cross-community member FK rejected, T6b same-community ok, T7
same channel UUID in two communities allowed, T8 host case-collision
rejected, T9 same event id in two communities allowed, plus the FTS
generated+GIN match and the forge-rejection. buzz-core: 189 + 2 doctests.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The frozen base for the multi-tenant rewrite. Consolidated 0001 schema
makes community_id a first-class, server-resolved key on every scoped
row, mapped table-by-table to docs/multi-tenant-conformance.md.
Schema highlights:
- channels PK is (community_id, id): the same channel UUID may legitimately
co-exist in two communities; child FKs (channel_members, workflows,
thread_metadata) are composite (community_id, channel_id) so a child can
never reference a cross-community channel — DB-enforced, not by handler
discipline. channels.community_id is immutable (BEFORE UPDATE trigger).
- communities.host uniqueness is UNIQUE(lower(host)); normalize_host applies
the same rule on the resolution side, so case/dot/default-port variants
can never split one tenant into two.
- every scoped unique/PK leads with community_id; cross-community dedup of
the same signed event is allowed, within-community dup rejected.
- new tables: communities (host map), scheduled_workflow_fires (the cron
at-most-once claim), audit_log (per-community chain), and an explicit
_operator_global_tables registry the migration lint reads.
buzz-core:
- normalize_host(host): the one shared host-canonicalization rule.
- TenantContext fence doc corrected to say plainly it is a lint-and-review
fence, not a compiler fence (resolved()/from_uuid are pub) — honest about
the guarantee the API actually gives.
Schema proven against Postgres with an adversarial fence suite (re-tenant
rejected, cross-community FKs rejected, same-UUID/same-event cross-community
allowed, host-case collision rejected). buzz-core: 189 tests + 2 doctests
green.
Folds in review round 1 from Mari (channel global-uniqueness leak, host
normalization, fence-claim honesty) and Sami (NIP-98 localhost normalization
to be dropped in the auth lane).
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
buzz-core gets the zero-I/O tenant identity types every scoped layer
shares. TenantContext encodes conformance row-zero in the type system:
no Default, no Deserialize, no public constructor except resolved(),
which is meant to be called only from host resolution. Downstream code
holds &TenantContext and can read but not mint a community, so
client-chosen-community cannot type-check outside resolution.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>