draft
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.
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 NOTHINGsemantics 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
neventshare, 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.
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 thecommunity_idof 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 carryingcommunity_id, kept relational because authorization needs synchronous current state. - Disposable projections: mentions, thread metadata, reactions, full-text
search — each
community_id-keyed, rebuildable fromL, 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
idslookup,#e/#atag filters, metadata/member discovery, and projection reads). Returns rows and derived results matchingq, confined toctx.community_id. - Accept(ctx, e) — a write (EVENT / REST POST). Appends
etoL(or mutates control-plane state) underctx.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).
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:
- Event-id existence oracle.
INSERT … ON CONFLICT DO NOTHINGon 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-19neventshares, 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. - 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).
- 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.
- Unauthenticated global surface. The NIP-11 relay information document at
/is unauthenticated and tenant-unscoped by construction; no B-scoped connection, noc.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. TodayRelayInfo::build(crates/buzz-relay/src/nip11.rs:122) takes only static inputs andnip11_facts(:176) reads onlystate.config/state.relay_keypair, so the surface is clean — but by current code, not by the proof; adding atotal_eventscounter is one&PgPoolargument 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 overbuild'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 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. Itscontentis high-labeled at the row's community;e/p/qtag 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_idechoes the submission (benign);acceptedis a function of (validity, signature, resolved scope, dedup) over B-labeled state only;messageis drawn from the sanitized alphabetΣ_err(the C2.2 seam — the currentStringtype admits any value).O.WS.NOTICE/O.WS.CLOSED— out-of-band and sub-termination strings; sameΣ_errconstraint (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).nis a numeric channel: even under row confinement, a count touching non-B rows leaks A's cardinality. The rule:nis 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).
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 viaResolveTenant. For a channel-bearing event the label isresolve(channel_id); for a channel-less event (kind:0profiles,1059DMs,30023/30174/30315/30078, lists —channel_id = NULL) the label is the connection's host-bound communityresolve_host(connection.host), with the token stamp required to agree (never to supply it). Thehtag 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
Σ_errgoverns the authenticated, tenant-scoped WS error surface: everyO.WS.OK.message,O.WS.NOTICE, andO.WS.CLOSEDis 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-Σ_errstring is a structural code violation (the C2.2 code-fence — a lint, not a model property). TodayRelayError::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
Σ_errsymbol 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.
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).
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 unlessFORCE ROW LEVEL SECURITYis set (owners andBYPASSRLSroles bypass policies). - (A-RLS-3)
app.community_idis 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 DEFINERandleakproof/user-defined functions in the request path are audited as part of the trusted boundary: aleakprooffunction may be evaluated ahead of the RLS check, and aSECURITY DEFINERfunction 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).
- (P-APPEND)
INSERT … ON CONFLICT (community_id, created_at, id) DO NOTHINGcommits 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/payloadtags bind it to exactly one HTTP request and are non-transferable to a different request. - (P-RESOLVE)
resolve : channel_id → community_idis 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_idNOT 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_idis immutable after insert. Both mechanized models encode this — Tamarin as the persistent!ChannelCommunityfact (MultiTenantAuth.spthy:51, once-true-always-true), TLA+ as theChannelCommunityCONSTANT 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 isresolve_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 anhtag, requireresolve(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, storecommunity_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_hostis 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!HostCommunityfact (MultiTenantAuth.spthy): the channel-less use rule fires only when token stamp and host community coincide (witnessChannelLessResolved, lemmachannelless_use_confined_to_host_community), and the channel-bearing use rule fires only when the channel mapping and the host community coincide (witnessChannelBearingResolved(tok, used_comm, host, host_comm), lemmachannelbearing_use_agrees_with_hostassertingused_comm = host_comm). TLA+ encodes it as theHostCommunityresolver (with a⊥sentinel for unmapped hosts) and anInv_HostBindingFenceinvariant 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 (theDuplicateresult 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_atwithin ±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-86references 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.
- 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
Servereturns — including direct-id and#e/#alookups — isctx.community-labeled. - I2 (Resolution fence).
ctx.community = resolve(channel_id)for channel-bearing events andresolve_host(host)for channel-less ones, never thehtag, the claimed community, or the token stamp; an adversaryh = C' ≠ resolve = Ccannot 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_HostBindingFencequantifies 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
admittedMembersrelation keyed on(community, actor);AddMembershipand every channel-less read are gated onIsAdmitted(c, a), andInv_AdmissionFencequantifies over every membership and every recorded channel-less read, requiring same-community admission on both — the channel-less branch additionally bindingHostCommunity[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 (AuthenticateOpenCommunityrecording anauthRegistration), and the kinds-only feed read (ReadHostFeedRows) and#e-only aux read (ReadHostAuxRows) each record a witness only when the actor isIsAdmittedto the host community —Inv_AdmissionFencequantifies over those witness sets too, so an actor admitted only in B can neither open-register into A nor read A's no-#hfeed/aux. Channel creation (CreateChannel) stamps a fresh channel fromHostCommunity[host]andInv_ChannelCommunityImmutableproves 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.)
- 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-communityreq, retag a channel, replay the original mint bytes (samereqhash) — would mint a token for a request S2 declares unmintable. This is the structural analog of A-RLS-5'sUNIQUE (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
htag) 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 aschannelless_use_confined_to_host_community,channelless_token_agrees_with_host, andhost_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-witnessChannelBearingResolvedfact assertingused_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_MemberListmints the signed list andRelay_Accepts_NIP43_MemberListre-verifies the signature against!CommunitySigningKey(comm, sk), socommis 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_communityproves the confinement; the commentedMUTATION_Admit_Ignore_Community(the dual of S6'sMUTATION_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_Communitystamps 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 asopen_auth_registration_confined_to_host_community(a host-bound npub registers only into its host's community), with the exists-trace witnessexecutable_open_auth_registrationproving 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
(ChannelLessAuthorized ⋈ HostBoundFor) 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.
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
NOBYPASSRLSand non-owner-or-FORCE; assert noSECURITY DEFINERfunction in the request path reads tenant tables without re-establishing context; assert every unique/FK constraint includescommunity_id. A failing assertion rejects the deployment. -
P-RESOLVE is admitted by the
channels.community_id NOT NULLconstraint plus a test thatresolveis read under the operation's snapshot, plus a migration lint assertingchannels.community_idis never mutated after insert (noUPDATE/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:- 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 signedcreated_atis still inside the window, and a replay slips through. - 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 onevent_idor 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: 3indeploy/charts/buzz/examples/argocd-app.yaml:27anddeploy/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 inAuthorization: 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) an ingress annotation hashing upstream selection on a header stable
across replays —
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.
- Capacity vs. rate. The seen-set is bounded (capacity 10,000, TTL 120 s
= 2× the ±60 s window). It must satisfy
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, andprevious-tag validation, not just insert/select predicates. The reasonServemust 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.mdfor citations and local checkout line references.)
-
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 theadmittedMembersallowlist,channelLessReadscapability rows, and theAdmitMember/RevokeMemberactions → 16,226,016 with the open-community AUTH auto-registration, server-stamped channel creation, and the no-#hhost 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-#hread 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, theget_accessible_channel_idslandmine) →Safetyviolated at depth 4; widening the sanitized-error label to all communities (the raw-error leak) →Safetyviolated at depth 2; the global-id conflict key (M3:WriteDuplicatekeyed onidalone viaGlobalConflictRows, the missing-community_id-in-the-unique-index footgun) →Safetyviolated at depth 3, with a B-scopedWriteResultobservation carryinglabels |-> {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_HostBindingFenceviolated at depth 2, the counterexample exhibitinghostBadwriting intocommA; the M8 host/channel-agreement mutation (WriteInsertdropping the agreement fence so an A-host op on a B-channel is accepted) →Inv_HostBindingFenceviolated by a 2-state trace (Init → WriteInsert); and the M8-duplicate mutation (WriteDuplicatedropping the same fence so an A-host can probe a B-channel id-conflict) →Inv_HostBindingFenceviolated 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-communityIsAdmitted(c, a)to any-communityAdmittedInAnyCommunity(a)) →Inv_AdmissionFenceviolated 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 theAddMembershipgate 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-#hhost 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 ofHostCommunity[host]) →Inv_AdmissionFenceviolated by a 2-state trace (Init → AuthenticateOpenCommunity(hostB stamps commA)), catching anauthRegistrationwhose 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_ChannelCommunityImmutableviolated by a 2-state trace (Init → CreateChannel(hostB stamps commA)); the M12 feed global-admission mutation (re-keying theReadHostFeedRowsadmission guard from same-communityIsAdmitted(c, a)to relay-globalGloballyAdmitted(a)) →Inv_AdmissionFenceviolated by a 3-state trace (Init → AdmitMember(commB, alice) → ReadHostFeedRows(hostA)), so an actor admitted only in B cannot read A's no-#hfeed; and the M13 aux global-admission mutation (the same guard re-key onReadHostAuxRows) →Inv_AdmissionFenceviolated 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 tripsInv_AdmissionFenceis the action's admission guard (IsAdmitted(c, a)→GloballyAdmitted(a)inReadHostFeedRows/ReadHostAuxRows), not the row-set helper alone, since the invariant quantifies over the recordedfeedReads/auxReadswitnesses rather than the returned row set — the.tlahelper 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. Theh-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_Communityfor S1, the S3 bad-accept and S4 splice-as-append mutations,MUTATION_Use_Token_ChannelLess_Ignore_Hostfor S5's host fence,MUTATION_Use_Token_Ignore_Hostfor S6's channel-bearing host/channel-agreement fence, andMUTATION_Admit_Ignore_Communityfor 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 witnessexecutable_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 correctedother_community_key_compromise_does_not_authorizevacuity 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_Communityfalsifiestoken_confinementwhen authorization is rewritten to use a claimed community,MUTATION_Use_Token_ChannelLess_Ignore_Hostfalsifieschannelless_use_confined_to_host_communitywhen the relay reads the token stamp instead of the host binding, andMUTATION_Use_Token_Ignore_Hostfalsifieschannelbearing_use_agrees_with_hostwhen 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.spthybyte hash above.
The model's obligations map to concrete code seams:
- P-RESOLVE / I2 —
resolve(channel_id)must be the only source ofctx.community_id; thehtag is never written into tenancy. Today there is no community layer;channel_idis the only locality. - P-RESOLVE (immutability) / S2 —
channels.community_idmust be immutable after insert. No migration mayUPDATE 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 TLAChannelCommunityCONSTANT; 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.4RelayInfo::buildsignature lints. - I1 / I4 — every DB entry point takes
TenantContextandSET LOCAL app.community_id; the unscopedget_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), neverUNIQUE (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 includescommunity_id") — the same gate-on-the-migration class as the C2.4RelayInfo::buildsignature 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 chainsAuditEntry(community, seq, prev, hash). - P3 / S2 — the NIP-98 mint freshness obligation the Tamarin model abstracts
as a fresh
~timenonce is carried by two code seams: the ±60s window incrates/buzz-auth/src/nip98.rs:77-83and the event-id seen-setcheck_nip98_replayincrates/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
TenantContextbefore 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-98uURLs, 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 / P3 —
api_tokensis 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_tsvgenerated column, backed by a GIN index) is shared infrastructure, not a shared result space. Searchable rows carrycommunity_id, and every search query filters bycommunity_idso 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}, andbuzz:{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 theadmittedMembersrelation (MultiTenantRelay.tla:149), keyed on[community, actor];IsAdmitted(c, a)(:317) gatesAddMembershipand every channel-less read, andInv_AdmissionFenceproves no membership or channel-less read capability survives that is not same-community-admitted (Theorem I5). The deployment-global gate is exactly mutation M9: replacingIsAdmitted(c, a)with the any-communityAdmittedInAnyCommunity(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), andnip43_admission_confined_to_signing_communityproves B's signing key can never admit a pubkey into A (Theorem S7).
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).
A REQ from a connection authenticated under pubkey p and token t
registers a subscription only after:
accessible_channels ← get_accessible_channel_ids_cached(p)(:79) — the DB-derived UUID set the connection's pubkey is a member of.- If t carries a
channel_idsclaim, intersect with it (:88-90). This is the one-token-one-community enforcement at the WS surface. extract_channel_id_from_filters(filters)(:92, body at:795-822) returnsSome(uuid)only if every filter pins the same#h=<uuid>; any mixed-#hor missing-#hfilter yieldsNone, routing the subscription to the global indexes (tests at:1045-1083).- Channel-scoped path: if the returned
ch_id ∉ accessible_channels, re-confirm viais_memberagainst the DB (:112); onOk(false)orErr(_)emitCLOSED "restricted: …"(:127-132). - Global path (
channel_id = None): per-filter p/engram/author gates must hold against p (:144-167); otherwiseCLOSED. - Only then is
sub_registry.register_scoped(...)called. Directregistercalls are confined to test setup; production subscription registration goes through the community-scoped API inreq.rs.
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 whosepubkey_for_connequals 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 atstate.rs:560-568caches 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)orErr(_)drops that recipient.
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.
- EOSE cardinality. The count of events preceding
O.WS.EOSE(sub_id)must equal|{m ∈ messages : matches(m, F) ∧ m ∈ ResolvedScope(conn)}|, whereFis the sub's declared filter set. Delivery:req.rs:281(per-eventEVENTsend); EOSE emission:req.rs:292. Closure: G1 admits the subscription only with aResolvedScope(conn)-consistent filter set, and G2 drops any candidate not inResolvedScope(conn)at delivery; the EOSE count is therefore the sum of events that passed both gates. - 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 sameResolvedScope(conn)predicate used at EOSE time. The primary closure is the visibility short-circuit atevent.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 atevent.rs:107-110is the secondary backstop. sub_idreuse and collisions. Thesub_idnamespace is per-connection, not global. Cross-connection collisions are structurally impossible:SubRegistry.subsis keyedentry(conn_id).or_default().insert(sub_id, …)(subscription.rs:66-69) and every index entry stores(conn_id, sub_id). Same-connection reuse (REQwithsub_id="x"superseding a priorsub_id="x") is closed bysubscription.rs::registercallingremove_subscription(conn_id, &sub_id)at:64before re-insert, and by the new subscription re-running G1 against the connection's currentResolvedScope(conn).
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.