Skip to content

Latest commit

 

History

History
1110 lines (1024 loc) · 72.5 KB

File metadata and controls

1110 lines (1024 loc) · 72.5 KB

Multi-Tenant Buzz Relay: A Formal Specification

draft

Abstract

This document specifies the data and authorization model that lets one shared Postgres instance, served by N stateless relay processes, host M independent communities without one community observing or acting on another, and gives a formal proof of its safety properties. It proves two families of property: isolation — a community is non-interfering with every other community across the relay's logical interface (query results, authorization decisions, emitted errors, and audit-chain contents) — and authorization soundness — no credential, signature, or forged event lets an actor cross a community boundary.

Today a Buzz relay process is the security boundary: one DATABASE_URL, one relay keypair, one relay-global relay_members table, with channel_id (the h tag) as the only sub-relay locality. The model proven here demotes the relay process to stateless compute and elevates a new community entity to the tenant/security boundary, carried as a community_id on every scoped row. That move collapses a process-level boundary into a row-level one. The contribution of this document is the formal characterization that the collapse loses nothing — proven relative to explicitly stated axioms about Postgres row-level security, Schnorr/NIP-98, a collision-resistant hash, and the relay's own channel_id → community_id resolution.

The architecture is not novel as a pattern: row-level multi-tenancy with a discriminator column and row-level security (RLS) is established practice (see §Prior Art). The contribution is the formal treatment — stating tenant isolation as non-interference encoded as a label-flow invariant (not a WHERE community_id = $1 predicate), mechanizing it (TLA+ for the concurrency/serving model, Tamarin for the authorization protocol under a Dolev-Yao adversary), and gating every invariant on a mutation test so the proof is non-vacuous.

Scope and Non-Goals

This specification proves safety ("nothing bad happens"). It deliberately does not prove:

  • Liveness or performance. That a query meets a latency budget, or that a hot partition does not throttle, is empirical — characterized by the perf rig, not by theorem.
  • Postgres's internal correctness. RLS enforcement, MVCC snapshot isolation, and ON CONFLICT DO NOTHING semantics are trusted and stated as axioms (§Axioms). We prove our composition on top of them; we do not reprove them.
  • Cryptographic primitives. Schnorr signature unforgeability (BIP-340), the NIP-98 request binding, and second-preimage resistance of the event-id hash are the Tamarin model's equational theory, not reproven.
  • Physical-resource isolation. Communities share an id space, time partitions, a connection pool, and a CPU. The proof covers the logical interface; bandwidth-limited physical channels are a named, explicit carve-out (§Isolation Boundary, class C1).
  • Above-the-interface client leakage. The proof boundary is the relay's observational interface. If a client (a multi-tenant UI, an NIP-19 nevent share, a screenshot, a leaked log) surfaces a user's own event ids from community A while that user is also a member of B, the user then holds an A-id out-of-band and can probe the existence oracle from a B connection. The composite-index closure (A-RLS-5) means the probe still reveals nothing — B's write at that id is a fresh (community_id, id) key — but we name this surface explicitly: closing it for any weaker index shape is above the interface and is the client's obligation, not the relay's.

Stating this boundary is part of the claim. "Provably isolated" without naming the trust boundary does not survive scrutiny; "isolation is machine-checkable relative to these stated axioms, with every shared logical channel either closed in-model or closed by a named axiom" does.

System Model

A community C is the tenant/security boundary. It owns: a set of channels, a membership relation, a signing keypair, a token namespace, workflows, an audit hash chain, and the messages scoped to it. A community is a durable row in a communities table; creating one is an INSERT, never DDL.

The shared store holds three tiers:

  • One canonical message log L: an append-only table keyed by (community_id, created_at, id). Every message carries the community_id of the community it belongs to. Append is idempotent (ON CONFLICT (community_id, created_at, id) DO NOTHING).
  • A tenant-scoped control plane: relational, ACID tables — channels, channel_members, api_tokens, workflows, audit entries — each carrying community_id, kept relational because authorization needs synchronous current state.
  • Disposable projections: mentions, thread metadata, reactions, full-text search — each community_id-keyed, rebuildable from L, never authoritative.

A relay process is stateless compute. It owns no community data; any process can serve any community, and N processes share the store.

A connection is bound to an actor (a pubkey, authenticated via NIP-42 on WebSocket or via a NIP-98-minted bearer token on REST). Every connection operation is evaluated under a TenantContext ⟨community_id, actor⟩. The community_id is resolved by the relay, never read from the client-supplied h tag or claimed community. For a channel-bearing operation it is the community of the channel the operation names (resolve : channel_id → community_id, an indexed lookup the relay owns under the same transaction snapshot as the operation), and the connection's host must agree with it — an A-host presenting a B-channel event is rejected fail-closed, never acted on as B. For a channel-less operation (profiles, DMs, long-form, status, read-state, lists — no h tag) it is the community bound to the connection's host at establishment (resolve_host : host → community_id, lifting today's per-relay URL identity up to the community); an unmapped host binds to no community and the connection is rejected fail-closed. The composed resolver is ResolveTenant(req, event) (see P-RESOLVE-HOST).

Two operation classes act on the store:

  • Serve(ctx, q) — a read (REQ / REST GET, including direct ids lookup, #e/#a tag filters, metadata/member discovery, and projection reads). Returns rows and derived results matching q, confined to ctx.community_id.
  • Accept(ctx, e) — a write (EVENT / REST POST). Appends e to L (or mutates control-plane state) under ctx.community_id, after an authorization decision over current control-plane state.

A community is either allowlisted or open. An allowlisted community admits actors only via a signed NIP-43 member list (§Authorization, S7); an open community (one with no member-pubkey allowlist) auto-registers any authenticated npub on AUTH — but the registration is stamped to the host-resolved community, never a client-claimed one, so "open" widens who may join, never which community they join. Two further control-plane writes are first-class: channel creation stamps a fresh channel atomically from HostCommunity[host] (the client supplies no community id, and the stamp is immutable thereafter), and no-#h reads — the kinds-only feed read and the #e-only aux read (reactions, edits, deletes, thread metadata) — resolve their community from the connection's host and are gated on host-community admission like every other channel-less operation. These surfaces are modeled, not asserted: see §Isolation (I5) and §Authorization (S5/S8).

The resolved community_id is the sole tenant authority. The h tag on a wire event is a routing hint a client asserts; it is never the commit point of tenancy. This is the confused-deputy hazard (Hardy 1988): the relay holds broad authority over a shared DB, and a client supplies an ambient name; if the relay acts on its broad authority under the client's name, the client escapes its community. The defense is capability discipline — authority is bound to the resolved object (community_id, channel_id, capabilities), never to a caller-supplied tag. The model treats the h tag as adversary-controlled and proves it is not load-bearing (Theorem I2 / S1).

Isolation Boundary

Tenant isolation is stated as non-interference: for any two executions equal on community B's inputs and initial B-visible state, B's observable outputs are equal regardless of community-A-only actions (Goguen–Meseguer 1982; the concurrent variant is observational determinism). A WHERE community_id = $1 row-return invariant is only one projection of this theorem — it implies nothing about timing, errors, uniqueness collisions, projection rebuild, or the auth gate. Two execution traces cannot be expressed directly in TLA+; the standard tractable encoding is a label-flow invariant: every state element (message row, membership, projection cell, in-flight query, emitted error, audit entry) carries the community label it originated from, and the single-run safety invariant is "no high-labeled value ever flows into a low-labeled observation." This encoding forces enumeration of every state element's label, which is what catches the projection-rebuild and error-surface channels that a predicate hides.

Shared channels split into two classes:

(C1) Bandwidth-limited physical channels — declared, out of scope. Buffer cache, autovacuum, planner statistics, partition right-edge throughput, and connection-pool tail latency are shared. A co-tenant can measure these as timing; the channel is bandwidth-bounded and orthogonal to the threat model (cross-tenant data leak, privilege escalation, audit forgery). We declare this class as git-on-s3 declares physical pack pruning: named, with a deferred future bandwidth bound. We do not claim timing non-interference.

(C2) Logical channels — in scope, enumerated, each closed. These are not carve-outs; a B-scoped connection can observe them at the interface, so each must be closed in-model or by a named axiom:

  1. Event-id existence oracle. INSERT … ON CONFLICT DO NOTHING on the content-hash id: a B-writer observing zero rows affected learns some tenant wrote that id. Closed by A-RLS-5 (§Axioms): the uniqueness constraint is composite over (community_id, …, id), so a B-scoped write at an id A already holds gets a fresh key, not a conflict — B's rows-affected count is a function of B's own state alone, never A's. A_HASH is the supporting axiom: it additionally rules out the adversarial-search variant (B cannot find a fresh event hashing to a chosen id). Note the residual: A_HASH says nothing about ids B already knows out-of-band (NIP-19 nevent shares, multi-tenant client UIs that surface a user's own ids across communities) — that exposure is closed by the composite index, not the hash, and any above-the-interface client surface that leaks a user's A-ids while they are also in B is a named residual in §Scope and Non-Goals, not a relay-closed channel.
  2. Constraint-violation error surface. Postgres errors can leak constraint names, conflicting tuples, and columns. Closed by a fixed sanitized error alphabet and the structural obligation that the relay emits only errors from that alphabet (an implementation code-fence, proven relative to it).
  3. Projection rebuild path. A rebuild touches every community's events by construction. Closed by the invariant that rebuild writes server-side projection tables only and never serves rows to a tenant-scoped connection; a tenant query concurrent with a rebuild sees its own rows or none.
  4. Unauthenticated global surface. The NIP-11 relay information document at / is unauthenticated and tenant-unscoped by construction; no B-scoped connection, no c.scope, no label exists, so the labeling invariant does not reach it. Closed by a typed-input code-fence: the doc-build function consumes only relay-static configuration types — no database handle, no tenant context, no audit service. Today RelayInfo::build (crates/buzz-relay/src/nip11.rs:122) takes only static inputs and nip11_facts (:176) reads only state.config/state.relay_keypair, so the surface is clean — but by current code, not by the proof; adding a total_events counter is one &PgPool argument away and the labeling invariant catches none of it. This is the same enforcement class as the Σ_err alphabet (C2.2) — a typed constraint at a seam, lintable over build's signature — but disjoint: Σ_err governs what symbols leave on authenticated paths, C2.4 governs what state populates unauthenticated paths. Any future unauthenticated relay-level endpoint (NIP-66 monitoring, health probes that expose counters) lives under C2.4 by default.

The numeric COUNT (NIP-45) and EOSE cardinality channels are deliberately not on this list: they are closed by the same label propagation as event rows (a count is |{B-labeled rows matching the filter}|), so they belong in the typed interface, not as distinct C2 mechanisms. The C2 list is the index of distinct closure mechanisms — A_HASH, the Σ_err alphabet, the rebuild behavioral invariant, and the C2.4 typed-input fence — not the index of channels.

(C3) Historical writes after revocation — declared, out of scope. The admission fence (I5, Inv_AdmissionFence) governs current capability: it proves that no membership or channel-less read capability survives for an actor not currently admitted to that community. Revocation (RevokeMember) removes the current admittedMembers row and therefore the capability, but it does not relabel or delete rows the actor wrote while admitted — those historical writes retain their original community label and remain present. This is sound and intended: the property we mechanize is "current membership and read capability track current admission," not "writes are retroactively un-admitted." We declare this as C1 declares physical timing: named, with retroactive-write redaction left to an operator data-lifecycle surface outside the isolation model. We do not claim historical writes are revoked when a member is revoked.

The typed observational interface

The non-interference theorem is stated over an interface: the exclusive set of observations a B-scoped connection (one whose resolved community is B) can make. Enumerating this set is load-bearing — a WHERE community_id = $1 invariant silently omits cardinality, error, status-code, and global-document channels. Any observation not in this set is either C1 (declared) or a model violation. There is no third category. Each entry below names its code seam so the TLA+ model, the Tamarin model, and the red-team audit reference the same surface.

O.WS — WebSocket transport (crates/buzz-relay/src/protocol.rs:180-215). The relay emits exactly these client-bound messages:

  • O.WS.EVENT(sub_id, event) — a delivered Nostr event. Its content is high-labeled at the row's community; e/p/q tag references inherit the row's label (they may name globally-existing ids, but the row reaches B only if B-labeled).
  • O.WS.EOSE(sub_id) — end-of-stored-events. The count of preceding events is the cardinality of B-visible rows matching the filter; it must be a function only of B-labeled state.
  • O.WS.OK(event_id, accepted, message) — write ack. event_id echoes the submission (benign); accepted is a function of (validity, signature, resolved scope, dedup) over B-labeled state only; message is drawn from the sanitized alphabet Σ_err (the C2.2 seam — the current String type admits any value).
  • O.WS.NOTICE / O.WS.CLOSED — out-of-band and sub-termination strings; same Σ_err constraint (connection.rs:307,326).
  • O.WS.AUTH(challenge) — NIP-42 challenge; a fresh nonce, function of relay randomness only, never of any tenant's writes.
  • O.WS.COUNT(sub_id, n) — NIP-45 count (protocol.rs:213). n is a numeric channel: even under row confinement, a count touching non-B rows leaks A's cardinality. The rule: n is the count of B-labeled rows matching the filter, full stop.

O.REST — HTTP API surface.

  • O.REST.BODY — JSON response: row content, projection results, and audit entries (crates/buzz-audit/src/service.rs:get_entries) must all be B-labeled.
  • O.REST.META — status code, headers, structured error envelope. The status code is itself observable: IngestError::{Rejected,AuthFailed,Internal}400/401|403/500 (handlers/ingest.rs:138-146) must be a function of {request, B-labeled state}, never of A's state.

O.AUTH — auth verdict. The Boolean "did this pass the gate," observable via O.WS.OK.accepted and O.REST.META.status. It is a function of (submitted credentials, server-side resolution channel_id → community, B-labeled membership/token/policy state). The claimed community never appears in this function — only the resolved one. (Theorem S1.)

O.AUDIT — audit chain. get_entries(scope=B) returns only B-chain entries; verify_chain(scope=B) is decidable from B-labeled entries alone; compromise of A's chain key does not affect B's. (Theorem S4.)

O.NIP11 — relay info document (/). Global and unauthenticated, so by construction it cannot be tenant-labeled — therefore its content must be a function of relay-static configuration only. supported_nips is fine; total_events would be a cross-tenant leak.

Everything outside this set is C1 (wall-clock latency, buffer-cache hit rate, planner choice, autovacuum, partition right-edge throughput, pool saturation, memory/fd/scheduler effects — declared, bandwidth-bounded) or closed by axiom (the INSERT … ON CONFLICT DO NOTHING id-existence oracle at event.rs:151, closed by A_HASH).

Label-propagation rules

The labeling discipline that makes non-interference a single-run safety invariant (every state element carries a community label; the invariant is "no high-labeled value flows into a low observation"):

  • L1 — Source label. Every event row carries community_id, set by the server-side resolver at insert time via ResolveTenant. For a channel-bearing event the label is resolve(channel_id); for a channel-less event (kind:0 profiles, 1059 DMs, 30023/30174/30315/30078, lists — channel_id = NULL) the label is the connection's host-bound community resolve_host(connection.host), with the token stamp required to agree (never to supply it). The h tag is not the label source, and neither is the client-claimed community. (Resolution is a fence — see P-RESOLVE and P-RESOLVE-HOST.)
  • L2 — Projection inheritance. Each projection row (event_mentions, thread_metadata, reactions, FTS) inherits its source event's label; rebuild = replay of labeled source rows, so rebuilds preserve labels by construction.
  • L3 — Audit partitioning. N independent chains, one per community label; community-scoped writers only; no cross-chain reference, no global "latest" head.
  • L4 — Auth-verdict label. The allow/deny verdict carries the resolved community label, never the claimed one.
  • L5 — Token stamp. A NIP-98 token has exactly one community stamp, assigned at mint from the resolved channel set; a mint resolving to >1 community is rejected fail-closed (S2). The token's label is its stamp.
  • L6 — Connection scope. A connection has exactly one resolved community at a time, bound from its host (resolve_host(connection.host)) at establishment before any handler runs; re-scoping requires a new connection to a different host; all its observations inherit that scope. An unmapped host binds to no community and is rejected fail-closed (P-RESOLVE-HOST), never defaulted.
  • L7 — Error label. A finite, statically-declared alphabet Σ_err governs the authenticated, tenant-scoped WS error surface: every O.WS.OK.message, O.WS.NOTICE, and O.WS.CLOSED is drawn from it (the 9 NIP-01-reachable prefixes — auth-required, restricted, invalid, duplicate, pow, rate-limited, blocked, error, frame-too-large). Emitting a non-Σ_err string is a structural code violation (the C2.2 code-fence — a lint, not a model property). Today RelayError::Database(#[from] buzz_db::DbError) (error.rs:11) is the seam. The unauthenticated/REST error surface (not-found, bad-request) is a distinct fence — C2.4's typed-input constraint, not Σ_err — because it has no tenant scope and no label, so it sits outside the labeling invariant entirely. One Rust enum may back both for ergonomics, but the model treats them as two alphabets closed by two mechanisms.
  • L8 — No injection. Per L7, A-labeled state cannot influence which Σ_err symbol B observes.

In one line: for every reachable state s, every B-scoped connection c, and every observation o ∈ O.* ∪ Σ_err emitted to c, o is a deterministic function of (B-labeled state in s, c's request history, relay-static config); no A-labeled element is an input to o. This is what the TLA+ model encodes — strictly stronger than row-equality, because it forces enumeration of every observation channel above.

Axioms

The proof holds relative to the following. Each is a documented property of Postgres / the crypto primitives, and a testable assumption admitted per deployment (§Conformance).

Row-level security (the fail-closed backstop)

Postgres RLS is fail-closed only under specific configuration (PostgreSQL manual, "Row Security Policies"). We state the configuration as obligations:

  • (A-RLS-1) Every queryable tenant-bearing table has RLS enabled with a restrictive policy community_id = current_setting('app.community_id')::uuid, and no permissive policy that admits cross-tenant rows.
  • (A-RLS-2) The relay's request role is non-superuser, NOBYPASSRLS, and not the table owner unless FORCE ROW LEVEL SECURITY is set (owners and BYPASSRLS roles bypass policies).
  • (A-RLS-3) app.community_id is set transaction-locally (SET LOCAL) before any query and cleared at transaction end. Pooled connections must not retain or combine tenant context across requests.
  • (A-RLS-4) SECURITY DEFINER and leakproof/user-defined functions in the request path are audited as part of the trusted boundary: a leakproof function may be evaluated ahead of the RLS check, and a SECURITY DEFINER function can read data unavailable to the caller.
  • (A-RLS-5) Uniqueness and foreign-key constraints include community_id, so a conflict outcome or a dangling reference cannot reveal or reach another community.

A query that fails to set app.community_id matches the policy predicate over NULL → no rows, never all rows. This is what makes a missed application predicate fail closed rather than leak (Theorem I4).

Concurrency, crypto, and resolution

  • (P-APPEND) INSERT … ON CONFLICT (community_id, created_at, id) DO NOTHING commits a row iff no row with that key exists; concurrent appends are serializable under MVCC; a committed row is never silently overwritten; a read sees a consistent snapshot.
  • (P-SIG) An actor cannot produce a valid Schnorr signature (BIP-340) for a pubkey whose secret key it does not hold. A NIP-98 event's u/method/ payload tags bind it to exactly one HTTP request and are non-transferable to a different request.
  • (P-RESOLVE) resolve : channel_id → community_id is a total function over existing channels, computed from control-plane state under the operation's transaction snapshot. A channel belongs to exactly one community (channels.community_id NOT NULL); resolution never returns a community a channel does not belong to. A channel's community is set at creation and never reassigned: channels.community_id is immutable after insert. Both mechanized models encode this — Tamarin as the persistent !ChannelCommunity fact (MultiTenantAuth.spthy:51, once-true-always-true), TLA+ as the ChannelCommunity CONSTANT function (MultiTenantRelay.tla:107). Any future re-tenanting would be a separate axiomatic admission with its own audit discipline and re-verification of S1/S2 (and I1–I5).
  • (P-RESOLVE-HOST) resolve_host : host → community_id ∪ {⊥} is the upstream binding for every connection, lifting today's per-relay URL identity one level up to the community. A connection's community is resolve_host(host), fixed at establishment; the URL the client connects to is the selector, exactly as a relay URL is today. ResolveTenant(req, event) composes the two: if the event has an h tag, require resolve(h) = resolve_host(host) (the host/channel agreement fence — an A-host presenting a B-channel event is a confused deputy on the host axis and is rejected fail-closed, never acted on as B) and store that community; if it has none, store community_id = resolve_host(host), channel_id = NULL. Two fences hold for both paths. Fail-closed: a host/channel disagreement (incl. an unmapped host resolving to , which can never equal a real channel community) is rejected generically (auth-required/restricted), never bound to a default tenant — resolve_host is partial and the absence/disagreement of a binding is a reject, not a fallback. Host wins: a NIP-98 token's community stamp (L5) must agree with the host-derived community; a token that disagrees is rejected, so the confused-deputy fence (I2) is intact with authority binding to the host-resolved object. Tamarin encodes this as the persistent !HostCommunity fact (MultiTenantAuth.spthy): the channel-less use rule fires only when token stamp and host community coincide (witness ChannelLessResolved, lemma channelless_use_confined_to_host_community), and the channel-bearing use rule fires only when the channel mapping and the host community coincide (witness ChannelBearingResolved(tok, used_comm, host, host_comm), lemma channelbearing_use_agrees_with_host asserting used_comm = host_comm). TLA+ encodes it as the HostCommunity resolver (with a sentinel for unmapped hosts) and an Inv_HostBindingFence invariant quantifying over every accepted write — channel-bearing and channel-less — and every observable duplicate/no-op outcome, that its stored community equals its originating host's mapping. The duplicate/no-op path carries the same obligation because it is client-observable write surface (the Duplicate result exposes the scoped existence/conflict rows): an A-host presenting a B-channel id is fenced before any conflict lookup, so it cannot learn whether that id exists in B. At N = 1 this is byte-identical to today: one host → the one community, every connection lands there, nothing client-observable changes.
  • (A_HASH) The event id sha256(canonical event) is second-preimage resistant: an actor cannot find a distinct event hashing to a chosen id. (NIP-01 already relies on this; we cite it the way git-on-s3 cites its CAS axiom.)
  • (P3) NIP-98 mint freshness. A NIP-98 mint event (kind:27235) is accepted at most once. The implementation enforces this with two checks: a created_at within ±60s of server time (buzz-auth/src/nip98.rs:77-83, TIMESTAMP_TOLERANCE_SECS = 60) and a seen-set keyed on event id (buzz-relay/src/api/bridge.rs::check_nip98_replay), whose cache TTL (120s, state.rs:407) is 2× the window so a mint valid at either edge stays tracked for the full window. The Tamarin model abstracts the window as a fresh nonce on ~time (MultiTenantAuth.spthy:91), which over-approximates the implementation by treating every mint as structurally unique; the spthy comment at :84-86 references this obligation as "P3."

P-RESOLVE is the load-bearing application assumption for channel-bearing events and P-RESOLVE-HOST is its channel-less counterpart — together the fence the h-tag and claimed-community adversary cannot circumvent. A-RLS-1..5 are the load-bearing backstop.

Safety Theorems

Isolation (mechanized in TLA+)

  • NI (Non-interference, master). For every reachable state and every B-scoped observation, the observed value is a function only of B-labeled state — no high-labeled value flows into a low-labeled observation. I1–I5 are the specific flows it rules out, each independently mutation-tested non-vacuous.
  • I1 (Read confinement). Every row a Serve returns — including direct-id and #e/#a lookups — is ctx.community-labeled.
  • I2 (Resolution fence). ctx.community = resolve(channel_id) for channel-bearing events and resolve_host(host) for channel-less ones, never the h tag, the claimed community, or the token stamp; an adversary h = C' ≠ resolve = C cannot widen what is served or accepted. The host axis is fenced on both paths: a channel-less write over host A cannot land in community B, and a channel-bearing op over host A on a B-channel is rejected rather than acted on as B — including the duplicate/no-op outcome, so an A-host cannot use a B-channel id-conflict result as a cross-tenant existence oracle (Inv_HostBindingFence quantifies over accepted writes and recorded duplicates, making "default to C", "A-host drives a B-channel insert", and "A-host probes a B-channel duplicate" caught mutations, not invisible ones).
  • I3 (Write non-loss & no cross-contamination). Every accepted append commits under the resolved label and no other; no committed message is lost or overwritten; two communities appending the same event id land as two rows under distinct labels (cross-community id collision is not a write conflict).
  • I4 (Fail-closed backstop). A dropped application predicate yields ∅ under A-RLS, and NI still holds; removing the RLS guard makes the dropped predicate produce a cross-label row — proving RLS load-bearing, not decorative.
  • I5 (Admission fence). Channel membership and channel-less read capability exist only for actors admitted to that community. The NIP-43 allowlist is the admittedMembers relation keyed on (community, actor); AddMembership and every channel-less read are gated on IsAdmitted(c, a), and Inv_AdmissionFence quantifies over every membership and every recorded channel-less read, requiring same-community admission on both — the channel-less branch additionally binding HostCommunity[host] = community, so the host axis is fenced here too. The same gate covers the open-community and no-#h-read surfaces: an open community auto-registers an authenticated npub into the host-resolved community (AuthenticateOpenCommunity recording an authRegistration), and the kinds-only feed read (ReadHostFeedRows) and #e-only aux read (ReadHostAuxRows) each record a witness only when the actor is IsAdmitted to the host community — Inv_AdmissionFence quantifies over those witness sets too, so an actor admitted only in B can neither open-register into A nor read A's no-#h feed/aux. Channel creation (CreateChannel) stamps a fresh channel from HostCommunity[host] and Inv_ChannelCommunityImmutable proves that stamp is never re-labeled — creation is an in-relay analog of S2's resolve-then-immutable discipline. The fence is about current capability: it is mutation-tested non-vacuous by M9 (re-keying the membership/read gate to any-community admission), which goes red on both a membership trace and a channel-less-read trace, and by M10–M13 (the open-AUTH, channel-create, feed-read, and aux-read stamp/gate mutations), each confirmed red — proving an admit-into-A then act-in-B escape is caught rather than invisible on every one of these surfaces. (See C3 for the explicit historical-write carve-out.)

Authorization soundness (mechanized in Tamarin, Dolev-Yao adversary)

  • S1 (Token confinement). A token accepted for a B-resolved operation was minted with stamped community B; a token stamped A never authorizes in B. A leaked token authorizes within its own community (blast radius is not zero and we do not pretend otherwise) but never another — containment, proven.
  • S2 (Mint integrity). A token exists only as the output of a NIP-98 mint by the holder of owner_pubkey's key (P-SIG); it carries exactly one stamped community; a mint whose channel set spans two communities yields no token. S2's trace-level mint-rejection closure relies on P-RESOLVE's totality, single-valuedness, and immutability: the Tamarin model encodes immutability via persistent-fact semantics (!ChannelCommunity), without which a retag-then-replay — reject a cross-community req, retag a channel, replay the original mint bytes (same req hash) — would mint a token for a request S2 declares unmintable. This is the structural analog of A-RLS-5's UNIQUE (community_id, id) clause for I1: both turn stable scope into the disjointness witness.
  • S3 (Signing-key non-confusion + containment). A community-B-signed system event (NIP-29 39000/39001/39002) is never accepted as an authentic community-A event, even when group ids collide; compromise of B's signing key does not let the adversary forge A's events.
  • S4 (Audit-chain unforgeability + containment). No splice, reorder, or forge in community A's hash chain; compromise of B's chain does not break A's — N independent chains, N independent guarantees.
  • S5 (Channel-less host confinement). A channel-less authorization (profiles, DMs, long-form, lists — no h tag) is confined to the community bound to the connection's host, not the token's stamp: host wins. The token must agree with the host community or the request is rejected; a B-stamped token presented over an A-host never authorizes for B. This is I2's host counterpart, mechanized as channelless_use_confined_to_host_community, channelless_token_agrees_with_host, and host_token_mismatch_not_authorized.
  • S6 (Channel-bearing host/channel agreement). A channel-bearing authorization is confined to the community bound to the connection's host: the host and the channel mapping must agree. An A-host presenting a B-channel event never authorizes as B — the host axis of the confused-deputy fence, which the prior model proved only on the channel axis (claimed-community ignored). This closes the cross-tenant escape over a wildcard host route where the channel mapping alone would have been authoritative. Mechanized as channelbearing_use_agrees_with_host (the single-witness ChannelBearingResolved fact asserting used_comm = host_comm).
  • S7 (NIP-43 admission confinement). A community's member-list (NIP-43) admission is confined to the community whose signing key signed it: B's signing key can never admit a pubkey into A. Modeled as a parallel rule pair — Community_Signs_NIP43_MemberList mints the signed list and Relay_Accepts_NIP43_MemberList re-verifies the signature against !CommunitySigningKey(comm, sk), so comm is bound by unification to the resolved community (the same confused-deputy discipline as the S5/S6 host fence), emitting persistent !Admitted(pk, comm). nip43_admission_confined_to_signing_community proves the confinement; the commented MUTATION_Admit_Ignore_Community (the dual of S6's MUTATION_Use_Token_Ignore_Host) falsifies it, confirming the green is non-vacuous. This is the authorization-world half of the same admission property TLA+'s I5 proves in the in-relay world: !Admitted(pk, comm) / MemberAdmitted(pk, comm)admittedMembers/IsAdmitted(c, a) — one property, two worlds (Tamarin proves the admission event per-community unforgeable, TLA+ proves the resulting capability in-relay scoped).
  • S8 (Open-community AUTH confinement). When a community carries no NIP-43 member-pubkey allowlist it is open: any authenticated npub auto-registers on AUTH. The registration is still confined to the host-resolved community — Authenticate_To_Open_Community stamps the registration from the connection's host binding, never a client-supplied selector, so "open" relaxes the gate on membership without relaxing the boundary it lands in. Mechanized as open_auth_registration_confined_to_host_community (a host-bound npub registers only into its host's community), with the exists-trace witness executable_open_auth_registration proving a legitimate open registration is producible so the confinement lemma is non-vacuous. This is S5/S6's host-binding discipline applied to the admission event: the same confused-deputy fence that stops a B-stamped token authorizing over an A-host stops a B-host AUTH registering into A. Its in-relay counterpart is I5's open-community branch (AuthenticateOpenCommunity, mutation M10).

Each Tamarin lemma is paired with an exists-trace sanity lemma (the honest protocol can run), the Tamarin analog of the mutation test.

Verification status. S1–S8 are machine-verified green on Tamarin 1.12.0 / Maude 3.5.1 — the full selected run verifies all 32 lemmas in ~12s with zero analyzed failures. S1/S2: token_confinement, cross_community_use_attempts_are_not_authorized, the two minted_*_channels_match_stamp lemmas, token_stamp_matches_mint, cross_community_mint_yields_no_token_for_that_request, and the leaked_token_blast_radius_contained / leaked_token_can_authorize_within_its_community containment pair, with MUTATION_Use_Token_Claimed_Community confirmed red (falsified — found trace). S3: system_event_acceptance_requires_same_community_key_or_compromise (21 steps) and other_community_key_compromise_does_not_authorize (147 steps). S4: audit_append_advances_same_community_head (2 steps) and cross_community_audit_splice_attempt_is_not_append (1 step). S5 (channel-less host confinement): channelless_use_confined_to_host_community (2 steps), channelless_token_agrees_with_host (3 steps), and host_token_mismatch_not_authorized (6 steps), each paired with an exists-trace probe (executable_host_bound, executable_channelless_use, executable_host_token_mismatch_attempt). The S5 mutation MUTATION_Use_Token_ChannelLess_Ignore_Host (the relay reading the token's stamp and ignoring the host binding — the B-token-on-A-host confused deputy) is confirmed red: it falsifies channelless_use_confined_to_host_community in 3.3s with a 13-step trace. Each safety lemma is paired with a verified exists-trace sanity lemma, and the S3/S4 mutations are confirmed red: the bad-accept-with-other-community-key mutation falsifies both S3 lemmas (5 / 16 steps) and the splice-as-append mutation falsifies the S4 splice lemma (8 steps). S6 (channel-bearing host/channel agreement): channelbearing_use_agrees_with_host (2 steps), with the MUTATION_Use_Token_Ignore_Host mutation (the relay resolving a channel-bearing op from the channel mapping while ignoring the host binding — the A-host-on-a- B-channel confused deputy) confirmed red: it falsifies channelbearing_use_agrees_with_host in 2.6s with a 14-step trace. S7 (NIP-43 admission confinement): nip43_admission_confined_to_signing_community (19 steps) and other_community_key_compromise_does_not_admit (79 steps), with the exists-trace probe executable_member_admitted (7 steps) proving a legitimate admission is producible — so the confinement lemma is non-vacuous, not trivially true over an unreachable premise. The S7 mutation MUTATION_Admit_Ignore_Community (the relay minting !Admitted for a community other than the one whose key signed — the admission-side confused deputy, the dual of S6's MUTATION_Use_Token_Ignore_Host) is confirmed red: it falsifies nip43_admission_confined_to_signing_community in 1.57s with a 7-step trace. S8 (open-community AUTH confinement): open_auth_registration_confined_to_host_community (2 steps), paired with the exists-trace witness executable_open_auth_registration (5 steps) proving a legitimate open-community registration is producible, so the confinement lemma is non-vacuous; its in-relay counterpart is the M10 open-AUTH stamp mutation, confirmed red in TLA+ (a 2-state Inv_AdmissionFence violation).

The S5 confinement lemma was deliberately framed to keep its mutation cheaply refutable. An earlier framing joined two action facts (ChannelLessAuthorizedHostBoundFor) on a shared host; the proof verified, but the mutation refutation did not terminate — Tamarin chased which HostBoundFor instance applied for a given host across both the real and mutated rules. The fix emits a single combined witness ChannelLessResolved(tok, used_comm, host, host_comm) from the authorizing rule (in the real rule both communities are the same variable), so the confinement lemma is a single-fact assertion used_comm = host_comm and the mutation that breaks it is a one-rule-instance counterexample. The proof dropped to 2 steps and the mutation falsifies in 3.3s — the same "make the bad case structurally cheap to exhibit" discipline as the S1 claimed-community mutation.

The S3/S4 round corrected one vacuity bug in the committed 1e7fb042…aceaacf24 artifact: other_community_key_compromise_does_not_authorize bound Neq(commA, commB) to the same timepoint as CommunityKeyCompromised(commB), but no rule emits Neq at the compromise point, so that premise was unsatisfiable — the lemma verified vacuously and asserted nothing. (Independently confirmed: an exists-trace probe of the old premise returns no trace found.) The fix decouples the inequality onto a separate witness timepoint #k; a new exists-trace lemma executable_other_key_compromise_plus_system_accept (16 steps, verified) proves the corrected premise is satisfiable, so the 147-step proof is non-vacuous. This is the same hygiene class as F1/F3/F4 — an artifact relying on a fact the model never makes reachable — but caught inside a safety lemma's premise rather than a comment. That fix predates this milestone's host-binding additions and is carried forward unchanged in the current .spthy.

Conformance

Each axiom is admitted per deployment, not assumed universally:

  • A-RLS-1..5 are admitted by a startup/CI assertion suite: enumerate every tenant-bearing table and assert RLS enabled + restrictive policy present; assert the request role is NOBYPASSRLS and non-owner-or-FORCE; assert no SECURITY DEFINER function in the request path reads tenant tables without re-establishing context; assert every unique/FK constraint includes community_id. A failing assertion rejects the deployment.

  • P-RESOLVE is admitted by the channels.community_id NOT NULL constraint plus a test that resolve is read under the operation's snapshot, plus a migration lint asserting channels.community_id is never mutated after insert (no UPDATE/ALTER/drop-recreate). A failing lint rejects the deployment.

  • P-SIG / A_HASH are the standard Nostr crypto assumptions; admitted by using the audited libraries the rest of Buzz uses.

  • P3 is admitted by the NIP-98 handler enforcing both timestamp-range validation and the seen-event-id check (check_nip98_replay) before any mint. Two structural gates make the seen-set sound, and both are conformance checks because the implementation is silent if either is violated:

    1. Capacity vs. rate. The seen-set is bounded (capacity 10,000, TTL 120 s = 2× the ±60 s window). It must satisfy capacity ≥ peak NIP-98 RPS × 120 s (≈ 83 RPS sustained at the current capacity); above that, LRU eviction can release an entry while its signed created_at is still inside the window, and a replay slips through.
    2. Per-pod scope. The seen-set is Arc<AppState>-scoped, not cross-pod, so the same replayed event reaching two pods succeeds once on each. P3 therefore requires either NIP-98 mints be pod-sticky on event_id or the seen-set be shared across pods (e.g. Redis with the same atomic insert-if-absent semantics and TTL ≥ 120 s). The chart default (replicaCount: 1) satisfies this gate today; the shipped HA examples (replicaCount: 3 in deploy/charts/buzz/examples/argocd-app.yaml:27 and deploy/charts/buzz/examples/flux-helmrelease.yaml:35) are P3-non-conforming as shipped unless the operator adds one of:
      • (a) an ingress annotation hashing upstream selection on a header stable across replays — nginx.ingress.kubernetes.io/upstream-hash-by: "$http_authorization" works for today's NIP-98 HTTP path, since the signed event rides in Authorization: Nostr <base64> (bridge.rs:34-46) and is bit-identical across replays. Two caveats keep this from being the recommended fix: it couples replay-stickiness to literal-byte-identity of the auth header (any future header normalization — whitespace, casing, base64 padding — silently breaks it), and it does not extend to any mint path that moves off HTTP (a WS mint has no Authorization header to hash on).
      • (b) a shared seen-set backed by a store with atomic insert-if-absent and TTL ≥ 120 s (e.g. Redis, already present in the HA chart for git-pubsub). This is the recommended path — no new infra surface and none of (a)'s fragility.

    A regression test asserts a replayed mint within the window yields a single token under the deployment's routing/storage shape (and that the seen-set TTL covers the full ±60 s window). A failing test or an unmet gate rejects the deployment.

Prior Art

The pattern (discriminator column + RLS) is established; the formal treatment as label-flow non-interference is, to our knowledge, new for a Nostr relay.

  • Goguen & Meseguer, "Security Policies and Security Models" (IEEE S&P 1982) — the origin of non-interference; the theorem shape ("A's actions do not affect B's observations"), with "community" for "security domain."
  • Sabelfeld & Myers, "Language-Based Information-Flow Security" (IEEE JSAC 2003) — the canonical label-based IFC survey; its declassification discipline is the model for our named C1 carve-out.
  • Jean Yang et al., "Precise, Dynamic Information Flow for Database-Backed Applications" (arXiv:1507.03513, Jacqueline) and Parker, Vazou, Hicks, "LWeb" (arXiv:1901.07665) — the closest formal analogs: label-based per-row policy over a real relational store with a mechanized non-interference proof. They justify "RLS is a backstop axiom; the theorem is the composition."
  • Hardy, "The Confused Deputy" (ACM SIGOPS OSR 1988) and Miller et al., "Capability Myths Demolished" (HPL-2003-222) — the resolution-as-capability framing: bind authority to the resolved object, not the caller-supplied name.
  • NIP-29 (relay-based groups) — confirms the relay is authoritative and group ids are not globally unique security domains; supports per-community signing keys and per-community audit chains, and motivates S3's "non-confusable even when group ids collide."
  • fiatjaf/relay29 — empirical prior art: isolation logic lives across read filters, direct-id lookups, metadata generation, in-memory state rebuilds, and previous-tag validation, not just insert/select predicates. The reason Serve must model the full observable surface, not just channel reads.
  • PostgREST / PostGraphile — converge on the transaction-local-context fence (A-RLS-3); real systems install request-local identity into the DB transaction and let policies authorize. (See RESEARCH/MULTITENANT_ISOLATION_PRIOR_ART.md for citations and local checkout line references.)

Mechanized Verification

  • docs/spec/MultiTenantRelay.tla + .cfg — the TLA+ isolation model. Run: java -cp tla2tools.jar tlc2.TLC -config MultiTenantRelay.cfg MultiTenantRelay.tla. On the core finite harness (2 communities × 4 channels, 2 message ids, 1 actor, 1 worker, 2 audit values, bounded observation set, symmetry over the permutable model-value sets) TLC completes exhaustively: Model checking completed. No error has been found. — 472,530,528 states generated, 16,226,016 distinct, 0 left on queue, depth 13 (8 workers, ~5m). The distinct-state count grew from the pre-host-binding baseline (4,350,464 → 5,091,328 with channel-less host binding → 5,621,760 with channel-bearing host/channel agreement → 9,232,992 with the admittedMembers allowlist, channelLessReads capability rows, and the AdmitMember/RevokeMember actions → 16,226,016 with the open-community AUTH auto-registration, server-stamped channel creation, and the no-#h host feed/aux read paths) precisely because the channel-less write path, the fail-closed unmapped-host path, the channel-bearing host/channel-agreement (and its fail-closed disagreement) path, the admit/revoke/gated-membership/gated-read paths, and now the open-AUTH/channel-create/feed-read/aux-read paths are genuinely reachable — new behavior, not dead code. Threading the host through the duplicate/no-op path adds reachable fail-closed transitions without new distinct states: only the agreeing host can produce a recorded duplicate, so the host on that path is fully determined; layering the admission gate, then the open-AUTH/channel-create/feed/aux surfaces on top, is the growth to the figures above (admit-then-act, revoke-then-act-fails, gated reads/joins, open-community auto-registration, server-stamped creation, and the two host-fenced no-#h read shapes multiply the reachable space). That each new surface is reachable rather than dead is pinned by four intentionally-false reachability probes (Probe_OpenAuthRegistration_Unreachable, Probe_CreatedChannel_Unreachable, Probe_HostFeedRead_Unreachable, Probe_HostAuxRead_Unreachable): each asserts the corresponding witness set stays empty, so each must go red if its action fires — and all four do (open AUTH and channel-create at 2 states; host feed and host aux at 3 states, via open AUTH then read). A vacuously-passing new conjunct over an unfireable action is therefore ruled out, not assumed. Non-vacuity of the invariants themselves is shown by thirteen mutations (M1–M13), each confirmed to produce a counterexample: substituting the unscoped direct-by-id lookup (UnscopedDirectIdRows, the get_accessible_channel_ids landmine) → Safety violated at depth 4; widening the sanitized-error label to all communities (the raw-error leak) → Safety violated at depth 2; the global-id conflict key (M3: WriteDuplicate keyed on id alone via GlobalConflictRows, the missing-community_id-in-the-unique-index footgun) → Safety violated at depth 3, with a B-scoped WriteResult observation carrying labels |-> {commA} (the existence-oracle leak C2.1 closes); the host-default-tenant mutation (a channel-less write from an unmapped host landing in a default community instead of failing closed) → Inv_HostBindingFence violated at depth 2, the counterexample exhibiting hostBad writing into commA; the M8 host/channel-agreement mutation (WriteInsert dropping the agreement fence so an A-host op on a B-channel is accepted) → Inv_HostBindingFence violated by a 2-state trace (Init → WriteInsert); and the M8-duplicate mutation (WriteDuplicate dropping the same fence so an A-host can probe a B-channel id-conflict) → Inv_HostBindingFence violated by a 3-state trace (Init → WriteInsert → WriteDuplicate), the counterexample exhibiting a foreign-host duplicate record whose stored community ≠ its host's mapping (the existence oracle the duplicate path would otherwise reopen); and the M9 global-allowlist mutation (re-keying the admission gate from same-community IsAdmitted(c, a) to any-community AdmittedInAnyCommunity(a)) → Inv_AdmissionFence violated in two surfaces: a 5-state membership trace (Init → WriteInsert → WriteInsert → AdmitMember(commA, alice) → AddMembership(commB/chanB1, alice)) where alice, admitted to A, joins B's channel through the global hole; and a 4-state channel-less-read trace (Init → WriteInsert → AdmitMember(commB, alice) → ReadMessageRows(commA, NoChannel, hostA)). The two M9 variants prove both the AddMembership gate and the channel-less-read gate are independently load-bearing, not just one. The four newest surfaces — open-community AUTH auto-registration, server-stamped channel creation, and the two no-#h host read shapes (kinds-only feed, #e-only aux) — are each held by their own confirmed-red mutation: the M10 open-AUTH stamp mutation (the relay stamping an open-community auto-registration into a default/claimed community instead of HostCommunity[host]) → Inv_AdmissionFence violated by a 2-state trace (Init → AuthenticateOpenCommunity(hostB stamps commA)), catching an authRegistration whose host maps elsewhere; the M11 channel-create stamp mutation (a fresh channel stamped into a default/claimed community rather than the host's) → Inv_HostBindingFence/Inv_ChannelCommunityImmutable violated by a 2-state trace (Init → CreateChannel(hostB stamps commA)); the M12 feed global-admission mutation (re-keying the ReadHostFeedRows admission guard from same-community IsAdmitted(c, a) to relay-global GloballyAdmitted(a)) → Inv_AdmissionFence violated by a 3-state trace (Init → AdmitMember(commB, alice) → ReadHostFeedRows(hostA)), so an actor admitted only in B cannot read A's no-#h feed; and the M13 aux global-admission mutation (the same guard re-key on ReadHostAuxRows) → Inv_AdmissionFence violated by a 3-state trace (Init → AdmitMember(commB, alice) → ReadHostAuxRows(hostA)). M10–M13 confirm the open-AUTH/create/feed/aux fences are load-bearing, not decorative — the same "every new conjunct earns a confirmed red" contract as M1–M9. (To reproduce M12/M13, the substitution that trips Inv_AdmissionFence is the action's admission guard (IsAdmitted(c, a)GloballyAdmitted(a) in ReadHostFeedRows/ReadHostAuxRows), not the row-set helper alone, since the invariant quantifies over the recorded feedReads/auxReads witnesses rather than the returned row set — the .tla helper comments call this out.) The host-fence and new-surface figures above are counterexample trace lengths (the error-trace state count), which unlike TLC's run-dependent "depth of complete graph search" total are reproducible from the printed error trace. The h-tag mutation is the same shape (I2). The config is deliberately a fast non-vacuity harness, not the full deployment scale — widening workers, actors, and ids explodes the space; symmetry + bounded observations keep the core isolation surface exhaustively checkable.

  • docs/spec/MultiTenantAuth.spthy — the Tamarin authorization model. Run: tamarin-prover --prove docs/spec/MultiTenantAuth.spthy. All 32 lemmas (S1–S8) verify green (Tamarin 1.12.0 / Maude 3.5.1, ~12 s) — each safety lemma paired with a verified exists-trace sanity lemma, and the documented mutations (MUTATION_Use_Token_Claimed_Community for S1, the S3 bad-accept and S4 splice-as-append mutations, MUTATION_Use_Token_ChannelLess_Ignore_Host for S5's host fence, MUTATION_Use_Token_Ignore_Host for S6's channel-bearing host/channel-agreement fence, and MUTATION_Admit_Ignore_Community for S7's NIP-43 admission confinement) confirmed red. The 32 lemmas include the open-community AUTH pair added with the host-scoped-open-auth surfaces: open_auth_registration_confined_to_host_community (2 steps) proves an open-community auto-registration commits to the host-resolved community and never a client-claimed one, and its exists-trace witness executable_open_auth_registration (5 steps) proves a legitimate open-community registration is producible, so the confinement lemma is non-vacuous. See §Authorization soundness for the full lemma list, the S5/S6 single-witness framing, and the corrected other_community_key_compromise_does_not_authorize vacuity fix.

    Machine-check hygiene. S1–S8 lemmas close by two distinct shapes. Rule-shape closure means the lemma's conclusion follows by unification on a single rule's action multiset: token_confinement, audit_append_advances_same_community_head, channelless_use_confined_to_host_community (the S5 single-witness fact), channelbearing_use_agrees_with_host (the S6 single-witness fact), and the S2 supporting set (minted_token_channels_match_stamp, minted_request_channels_match_stamp, token_stamp_matches_mint). These are well-formedness guards on the model's action labels; the substantive security claim is carried by the corresponding rule design and mutation (for example, MUTATION_Use_Token_Claimed_Community falsifies token_confinement when authorization is rewritten to use a claimed community, MUTATION_Use_Token_ChannelLess_Ignore_Host falsifies channelless_use_confined_to_host_community when the relay reads the token stamp instead of the host binding, and MUTATION_Use_Token_Ignore_Host falsifies channelbearing_use_agrees_with_host when the relay resolves a channel-bearing op from the channel mapping while ignoring the host). Substantive closure requires cross-rule reasoning over persistent-fact invariance (cross_community_mint_yields_no_token_for_that_request, leaked_token_blast_radius_contained, cross_community_use_attempts_are_not_authorized), linear-fact lifecycle (cross_community_audit_splice_attempt_is_not_append), or signed-preimage unification (system_event_acceptance_requires_same_community_key_or_compromise). Tamarin proves both kinds identically; the distinction is for reviewer hygiene, not a weakened theorem claim. This paragraph is prose-only to preserve the .spthy byte hash above.

Implementation Correspondence

The model's obligations map to concrete code seams:

  • P-RESOLVE / I2resolve(channel_id) must be the only source of ctx.community_id; the h tag is never written into tenancy. Today there is no community layer; channel_id is the only locality.
  • P-RESOLVE (immutability) / S2channels.community_id must be immutable after insert. No migration may UPDATE channels SET community_id = …, ALTER TABLE channels … community_id …, or drop-and-recreate the column without an explicit re-admission of P-RESOLVE and re-verification of S1/S2. This is the load-bearing assumption behind S2's trace-level mint-rejection (a retag-then- replay breaks it) and behind the TLA ChannelCommunity CONSTANT; it is invisible to both the labeling invariant and the Tamarin lemmas (the proofs would silently weaken, not fail), so it is enforced by a migration lint — the same gate-on-the-migration class as the C2.1 composite-index and C2.4 RelayInfo::build signature lints.
  • I1 / I4 — every DB entry point takes TenantContext and SET LOCAL app.community_id; the unscoped get_accessible_channel_ids() (crates/buzz-db/src/channel.rs:545-560, which unions every open channel in the DB) must not exist in any tenant-scoped path. RLS is the backstop.
  • C2.1 / A-RLS-5 — the message-uniqueness constraint must be composite over (community_id, …, id), never UNIQUE (id) alone. This is the closure for the existence-oracle (M3 goes red at depth 3 under a global key). It is one bad migration away from breaking and is invisible to the labeling invariant, so it is enforced by the conformance schema assertion (§Conformance: "every unique/FK constraint includes community_id") — the same gate-on-the-migration class as the C2.4 RelayInfo::build signature lint.
  • S3 / S4 — the relay keypair becomes a per-community signing key (communities.signing_key), distinct from relay-instance identity; the single global audit chain (crates/buzz-audit/src/service.rs) becomes N per-community chains AuditEntry(community, seq, prev, hash).
  • P3 / S2 — the NIP-98 mint freshness obligation the Tamarin model abstracts as a fresh ~time nonce is carried by two code seams: the ±60s window in crates/buzz-auth/src/nip98.rs:77-83 and the event-id seen-set check_nip98_replay in crates/buzz-relay/src/api/bridge.rs:76-94, called before every mint (bridge.rs:181, :254, :514). The seen-set (state.nip98_seen, state.rs:249/:407) is the structural analog of the model's nonce: it makes a replayed mint within the window non-fresh, so the implementation matches the "every mint is structurally unique" world the model proves S2 in. This correspondence is deployment-conditional: today's in-process moka cache carries P3 for the chart default (replicaCount: 1) and for any deployment that routes all mints for the same event id to the same pod, but the shipped HA examples (replicaCount: 3) do not carry P3 as shipped because there is no sticky routing and no shared seen-set. HA conformance requires a Redis/shared-store seen-set with atomic insert-if-absent and TTL ≥ 120 s (recommended), or a header-stable sticky-routing layer — see §Conformance (P3) for the two operator options and the caveats on the routing workaround.
  • C2.2 — the client-facing error path must map all DB errors to a fixed sanitized alphabet; no sqlx::Error::to_string() reaches a tenant connection.
  • C2.4 — the NIP-11 builder RelayInfo::build (crates/buzz-relay/src/nip11.rs:122) must keep its relay-static-only signature (no &PgPool, no tenant context, no audit service); a signature lint enforces the typed-input fence on the unauthenticated / surface.
  • P-RESOLVE-HOST / row-zero conformance — every externally reachable relay-global surface consumes the host-derived TenantContext before reading or mutating tenant data. This is the implementation seam for NIP-11/community relay identity, NIP-98/API-token REST calls, media upload/serve, git Smart HTTP, workflow webhooks/schedules/manual triggers, search, presence, and Redis fan-out. Tokens, signed NIP-98 u URLs, webhook ids, workflow ids, repo names, media hashes, and event ids are subordinate names; none may select a community that disagrees with the request host.
  • NIP-11 / S3 — tenant-observable relay identity is per-community. Static software/version fields may be operator-global, but self/relay-signed group, membership, audit, and system events use the community signing key. The unauthenticated info path may reveal facts about the addressed host/community only; unknown hosts fail closed generically rather than returning another community's info document.
  • API tokens / P3api_tokens is a community-scoped namespace. Token hash lookup, channel claims, scopes, revocation, and NIP-98 replay checks are evaluated under (community_id, token_hash/event_id). HA deployments require a shared atomic seen-set keyed by community and NIP-98 event id, or an explicitly admitted sticky/single-replica deployment; otherwise S2's freshness premise is not carried in production.
  • Search / C2.1 — the Postgres FTS index (the events.search_tsv generated column, backed by a GIN index) is shared infrastructure, not a shared result space. Searchable rows carry community_id, and every search query filters by community_id so the FTS predicate is BitmapAnd-ed with the community-leading btree filters; a hit never crosses tenants and refetch by hit id is (community_id, event_id). The channel-less scope (ChannelScope::ChannelLessOnly, formerly the __global__ sentinel) means channel-less within one community, never operator-global.
  • Redis / subscription refinement — Redis pub/sub keys, presence keys, typing keys, cache invalidation channels, and local-echo dedup labels include community context in any shared multi-tenant deployment. The safe shape is buzz:{community}:channel:{channel_id}, buzz:{community}:presence:{pubkey}, and buzz:{community}:typing:{channel_id}. The current unprefixed keys are admissible only for the degenerate single-community deployment or physically isolated Redis.
  • Media / Blossom — raw blob bytes may remain content-addressed and operator-deduplicated, but descriptors, upload authorization, quotas, audit rows, and any future read policy are community-scoped. A media hash collision or pre-existing blob in another community must not become an existence oracle via metadata, status code, quota accounting, or audit output.
  • Git / NIP-34 — git Smart HTTP resolves the repository namespace from the host-derived community before consulting owner/repo names, branch protection, NIP-34 repo announcements, manifests, or object-store pointers. Pointer keys include community (for example repos/{community}/{owner}/{repo}/pointer); pack/object CAS may be shared only below community-scoped refs/manifests and authorization metadata.
  • Workflows / system events — workflow definitions, runs, approval hashes, webhook/manual trigger routes, cron scheduling, and relay-signed workflow events inherit community_id. A workflow id or approval token hash alone is never a lookup key. Trigger evaluation sees events in the same community only, and schedule coordination must preserve that label across pods.
  • Relay membership / pubkey admission — relay membership, pubkey allowlist, and archived identities are community-global admission facts. The portable value is the pubkey; the stored membership/archive fact is (community_id, pubkey, ...). No deployment-global user gate is tenant-observable unless it is modeled as a separate operator surface. This is no longer asserted-only: the (community_id, pubkey) admission key and the absence of a deployment-global gate are both mechanized. TLA+ carries the allowlist as the admittedMembers relation (MultiTenantRelay.tla:149), keyed on [community, actor]; IsAdmitted(c, a) (:317) gates AddMembership and every channel-less read, and Inv_AdmissionFence proves no membership or channel-less read capability survives that is not same-community-admitted (Theorem I5). The deployment-global gate is exactly mutation M9: replacing IsAdmitted(c, a) with the any-community AdmittedInAnyCommunity(a) (:324) makes the model go red — so admit-into-A-then-act-in-B is a caught escape, not an invisible one. On the authorization side, NIP-43 member-list events are signed and accepted per-community in Tamarin (Community_Signs_NIP43_MemberList / Relay_Accepts_NIP43_MemberList, MultiTenantAuth.spthy:403/:413), and nip43_admission_confined_to_signing_community proves B's signing key can never admit a pubkey into A (Theorem S7).

Subscription-pipeline abstraction

The mechanized models abstract one structural seam: the subscription pipeline (REQ → register → match → fan-out → access-filter → EVENT/EOSE). The TLA+ isolation model represents this pipeline as the synchronous Read* actions, indexed by (worker, actor, community, channel); it has no sub_id, no Register, no Match, no FanOut, no EOSE, no filter state. This is sound — the model proves Inv_LabelPropagation over the aggregate row-set delivered to a B-scoped worker, and the prose observational interface (§The typed observational interface) presents the same property over per-sub streams. The refinement from aggregate to per-stream is coarser than the interface, not wrong — but it is not mechanized, and it is closed here, by code-fence and obligation, against the implementation.

Governing rule. Every observation kind enumerated in §The typed observational interface must either (i) be discharged by a TLA+ invariant or Tamarin lemma, or (ii) appear by name in this subsection with a code-fence and a closure obligation. New observation kinds added to §The typed observational interface require a new entry here in the same commit. This rule is what surfaced F1 (A_HASH closure mis-attribution) and F2 (the subscription-pipeline abstraction itself).

G1 — establishment (crates/buzz-relay/src/handlers/req.rs:79-204)

A REQ from a connection authenticated under pubkey p and token t registers a subscription only after:

  1. accessible_channels ← get_accessible_channel_ids_cached(p) (:79) — the DB-derived UUID set the connection's pubkey is a member of.
  2. If t carries a channel_ids claim, intersect with it (:88-90). This is the one-token-one-community enforcement at the WS surface.
  3. extract_channel_id_from_filters(filters) (:92, body at :795-822) returns Some(uuid) only if every filter pins the same #h=<uuid>; any mixed-#h or missing-#h filter yields None, routing the subscription to the global indexes (tests at :1045-1083).
  4. Channel-scoped path: if the returned ch_id ∉ accessible_channels, re-confirm via is_member against the DB (:112); on Ok(false) or Err(_) emit CLOSED "restricted: …" (:127-132).
  5. Global path (channel_id = None): per-filter p/engram/author gates must hold against p (:144-167); otherwise CLOSED.
  6. Only then is sub_registry.register_scoped(...) called. Direct register calls are confined to test setup; production subscription registration goes through the community-scoped API in req.rs.

G2 — delivery (crates/buzz-relay/src/handlers/event.rs:59-113)

Every candidate from sub_registry.fan_out passes through filter_fanout_by_access before any send_to. The function (:59) and its doc comment (:117-124) state the invariant: a registered subscription is never sufficient for delivery — delivery always revalidates access on the sending pod. Three checks, in order:

  • Author-only kinds (:70-83) — filter to recipients whose pubkey_for_conn equals the event author.
  • Channel visibility (:85-97) — channel_visibility_cached(channel_id). Non-private → pass through; "private" → continue. Lookup error → return Vec::new() (:91-96): visibility short-circuit, fail-closed for the whole fan-out. The cache discipline at state.rs:560-568 caches only "private", so a stale entry can only over-restrict (≤10s), never leak.
  • Membership (:99-111) — is_member_cached(channel_id, pubkey) per recipient; Ok(false) or Err(_) drops that recipient.

Non-mechanized obligations

The following obligations close the per-sub stream properties the TLA+ Inv_LabelPropagation does not reach. Each names its code-fence and the gates (G1, G2) that carry the closure.

  1. EOSE cardinality. The count of events preceding O.WS.EOSE(sub_id) must equal |{m ∈ messages : matches(m, F) ∧ m ∈ ResolvedScope(conn)}|, where F is the sub's declared filter set. Delivery: req.rs:281 (per-event EVENT send); EOSE emission: req.rs:292. Closure: G1 admits the subscription only with a ResolvedScope(conn)-consistent filter set, and G2 drops any candidate not in ResolvedScope(conn) at delivery; the EOSE count is therefore the sum of events that passed both gates.
  2. EOSE → late-EVENT temporal pairing. No O.WS.EVENT(sub_id, …) delivered after the sub's EOSE may reveal state withheld by G2 during the historical dump. Closure: G2 re-validates visibility and membership on every live fan-out, against the same ResolvedScope(conn) predicate used at EOSE time. The primary closure is the visibility short-circuit at event.rs:91-96 — a transient DB error during the late-EVENT window returns an empty fan-out for the whole event, not a relaxed predicate; the per-recipient membership branch at event.rs:107-110 is the secondary backstop.
  3. sub_id reuse and collisions. The sub_id namespace is per-connection, not global. Cross-connection collisions are structurally impossible: SubRegistry.subs is keyed entry(conn_id).or_default().insert(sub_id, …) (subscription.rs:66-69) and every index entry stores (conn_id, sub_id). Same-connection reuse (REQ with sub_id="x" superseding a prior sub_id="x") is closed by subscription.rs::register calling remove_subscription(conn_id, &sub_id) at :64 before re-insert, and by the new subscription re-running G1 against the connection's current ResolvedScope(conn).

Summary

One shared Postgres, one canonical community_id-keyed message log, stateless relay workers, a relational tenant-scoped control plane, and disposable tenant-scoped projections — with isolation stated as label-flow non-interference (TLA+), authorization soundness stated as trace lemmas under a Dolev-Yao adversary (Tamarin), every shared logical channel enumerated and closed, and every invariant mutation-tested. Safety is machine-checkable relative to the RLS, crypto, and resolution axioms, each admitted per deployment by a conformance gate.