Files
buzz/docs/multi-tenant-conformance.md
Brad Seiler 64d12e2975 BUZZ-511: Honor relay mode for community NIP-FI enforcement (#8988)
## Summary

Apply relay mode consistently across protected HTTP, root/audio
WebSocket, Git, media and V1 admission. Off requires no NIP-FI,
including for stored required communities. Shadow enforces only stored
community requirements and observes other communities. Enforce requires
NIP-FI for every community. Durable requirements survive all mode
changes.

Off retains ordinary tenant routing and authentication. It skips FI
policy/dependency reads for admission, uses cached WebSocket tenant
routing, and loads no FI binding snapshot at startup. Discovery reports
`requires_nip_fi=false` and no FI capability in Off. Evaluating modes
retain same-row WebSocket binding and immutable HTTP policy tenants,
including host remaps, pending deletion and unarchive races. Global
Enforce discovery retains its existing compatibility behavior.

Host-specific root NIP-11 and `/info` expose the additive
`requires_nip_fi` field with no-store. In evaluating modes, an
unavailable policy returns 503 and an unknown or non-serving host
returns 404. Off uses ordinary routing; a discovery lookup failure
returns 404. Stored requirements in Shadow require usable FI
dependencies.

Admission does not establish activation. BUZZ-512 owns existing-session
application, drain and mode-aware readback; its separate revision must
report stored requirements as suspended in Off. Production baseline
verification and human acceptance remain open.

Community status now shares the bounded host cache: 10,000 entries,
one-second leases measured before checkout, coalesced fills, and no
cached misses or errors. Git reuses one request policy. Root/audio
retain the final primary binding check: two reads on a cold successful
upgrade, one on a cache hit. Off creates no verifier or JWKS source and
reads no FI policy. Proof metrics count stored-required Shadow
enforcement.

Acceptance locks and validates the community row, takes the matching
host advisory lock, and drains optional leases for 1.1 seconds before
commit. Reads begun after commit see required across participating relay
instances, even without Redis invalidation. The row lock closes single
and double remap races. Admissions already in flight remain subject to
BUZZ-512 activation fencing.

### Related issue


[BUZZ-511](https://linear.app/squareup/issue/BUZZ-511/relay-enforce-per-community-nip-fi-policy-and-publish-compatible).
[BUZZ-510 / #8985](https://github.com/block/buzz/pull/8985) is merged;
this PR now targets main.

The restack retains main’s #8974 denial telemetry and artifact
capabilities. Related work includes #8526 tenant lookup errors and #8444
media authorization.

### Rollout and containment

Keep `BUZZ_NIP_FI_UPGRADES_ENABLED=false`. [The rollout
plan](docs/community-auth-upgrades.md#staging-and-containment) starts in
staging with zero required rows and a 30-minute before/after traffic
comparison. Initial abort limits include any policy timeout/503,
policy-read p99 above 50 ms, writer acquisition p99 above 20 ms or twice
baseline, and a five-minute 503 increase above 0.1 percentage point.
These are initial limits to validate against measured staging results,
not production capacity claims.

Verify signed/unsigned free controls, real required root/audio upgrades,
dependency failures, restart behavior and discovery before expansion.
Also watch binding timeouts/errors and their current generic 404
responses on known serving hosts alongside writer-pool pressure;
evaluating root/audio binding has a 500 ms budget; Off uses ordinary
cached routing. In Shadow, a pre-511 rollback loses enforcement of
accepted community requirements. Stop new upgrade calls, retain accepted
policy and receipts, and repair with an enforcing binary or
operator-approved containment. Never clear durable policy to restore
access. Off deliberately suspends FI enforcement and must be an explicit
operator mode choice. No production activation is claimed.

All acceptance paths must implement the lease protocol before upgrades
are enabled. Keep upgrades disabled through a mixed-version rollout. For
rollback, disable new acceptance before removing the protocol while
caching pods remain. A cold policy fill uses a six-statement transaction
and a 400 ms server timeout within the 500 ms lookup budget. A contended
policy fill returns 503. During the drain, archive, deletion, ownership,
issuer-binding and competing acceptance writers for that community may
wait; ordinary serving reads and writes retain their existing behavior.
Host-binding changes retain the existing one-second routing bound. Local
and Redis invalidation accelerate refresh; safety does not depend on
delivery. Deploy the verified Shadow revocation behavior from #8991
before accepting required communities in Shadow.

### Testing

Published review-fix head: `39dfe79b1fc04f10ee3eb94d6ea42cf686bafe1e`,
tree `1e2e8d3abd39ee1d6821defe8c8d34de73726e08`. McKeon cleared this
cache delta. Full affected auth (291), DB (192), pubsub (31) and relay
(1,682) package tests passed, including integration binaries. Two
previously documented hardcoded-Redis audit tests remained excluded.
All-target clippy, all 1,297 native PostgreSQL tests and the full pubsub
Redis suite passed. The full ACP package and `just ci` passed after
rebuilding cached test executables that embedded a removed checkout
path. No ACP source changed. Tauri's generated lockfile delta was
archived and restored; it is excluded from this candidate.

Both positive lease regressions passed. Four deliberately broken
variants failed at the intended assertions: removing the drain left an
optional lease alive after 202.935667 ms; skipping the fill lock failed
contention refusal; moving the timestamp after the read failed the
anchor check; taking the host lock before the row failed the
remap-blocking check. Exact source was restored after each mutation.
These are local validation results, not a deployed-fleet acceptance
claim. All applicable normal push hooks passed. GitHub reports the exact
tested head and tree; the published code has no delta from the reviewed
candidate.

Earlier mode-correction evidence follows for context.

Current mode-correction head:
`aa9abf07284b56a88eb78a2014e590f39d705288`, on main
`af54258f19ac32667c027f889652bdeb479c8e13`. Its code equals reviewed
`f1e7da5fc5`, plus the independently reviewed operator documentation
follow-up; all 19 earlier remote patches are preserved. McKeon verified
the correction and its mapping to the tested candidate.

The mode-correction candidate passed full auth (291) and relay (1,679)
package tests, clippy, and four explicit native PostgreSQL policy
workflows. There were 623 ignored relay tests and two excluded Redis
tests. Real PostgreSQL checks cover an ordinary unsigned caller on a
stored-required host in Off with no FI token or dependencies, discovery
false, durable policy retained, root/audio upgrades, host remaps and the
unarchive race in evaluating modes. The `STORED_OFF` mutation fails the
enum truth table only; `OFF_PRIMARY` fails cached upgrade routing.
Restored source hashes match. Earlier failed compile/fixture attempts
remain recorded.

The first normal push hook attempt passed Tauri checks but failed the
gateway startup verifier test (82/83 gateway tests passed). The remote
remained unchanged. The retry rebuilt gateway artifacts with separate
root and Tauri caches, then passed every normal push hook and published
this head. No gateway source changes or hook bypasses were needed.
Current-head hosted CI is tracked separately. Human acceptance and the
staging binding-timeout/404 workflow remain open. The following retained
validation and controls are historical and describe behavior before the
mode correction; their Off-enforcement statements are superseded by the
Summary above.


Published head: `9a717fa89725f9430ff4a8bb78cd600affb8836d`, on main
`1055f79d048ef22c878d02a7afb04f49c753564f`. CI, three schema checks,
2,136 ordinary tests, all 291 auth tests, 12 Redis tests, all 1,300
native PostgreSQL tests and discovery passed. The required wrapper
passed units, then exited 1 at unavailable Docker bootstrap. BUZZ-510 is
complete at `95c8d31c835d6f7ff6e432fb1ed248771d4f06dd`: CI, three schema
checks, 2,127 ordinary tests, 12 Redis tests, all 1,295 native Postgres
tests and discovery passed; its wrapper passed units and failed at
unavailable Docker bootstrap.

The first native run at historical BUZZ-510 `5380cb72` passed 1,291
tests and failed two event-follow-up queue assertions and the scale-sync
case. All three passed serially, then the full 1,294-test run passed
with normal concurrency. The original cause remains unresolved; failed
runs and resource samples are retained.

Standalone BUZZ-511 checks exposed access-slot callers, a Clippy warning
and early HTTP 503 attribution that had remained in the child. All are
now corrected in the parent. The unchanged real-router refusal
regression passed before the latest commit. BUZZ-512's final tree is
unchanged by moving these parent prerequisites. The remaining 46-file
child delta was audited; no additional parent prerequisite was found.
Earlier compile and native failures remain archived.

The following earlier-head results and controls are historical; the
current-head validation is reported above.

At `48388834f99b8d045e243b619904cb3e27b47146`, based on BUZZ-510
`130f14a675edd7cbe6a69d1c9d7818bd1a51f46c` and reconciled main
`152441ae0a56b204b42b6faa56a36ad2b98218b1`:

- `just ci`: passed.
- Four-package ordinary suite: 2,069 passed; full `buzz-auth` package:
291 passed.
- Native Redis: 12 passed. Native PostgreSQL: 1,261 passed. PostgreSQL
discovery checks passed.
- Required `just test`: units passed, then exited 1 because Docker
service bootstrap was unavailable. Native service results above are
separate.
- Real-router remap checks reject an A-only member under B policy, avoid
A data after B admission, and advertise B in discovery despite a stale A
cache. Removing each tenant pin separately fails its intended witness;
restored source passes. An unmapped host cannot reuse a cached tenant.
- Deadline witnesses advance their clock during real HTTP signature
verification and retain socket primary-lookup barriers. Removing either
final enforced or shadow HTTP deadline check separately fails its
intended witness; restored source passes both deadlines and the
single-resolution control.
- Telemetry regression: unsigned dev proofs now expect the earlier
NIP-98 `missing` denial; signed mismatched-key proofs retain pairing
denial and signer attribution. Removing unsigned-proof rejection fails
the exact two-counter assertion. Restored full relay package: 1,654
passed.
- Preserved failed runs: two prior bottom-layer desktop gates failed an
immediate inventory assertion, repaired with a bounded wait for the same
observable arrays and verified with delayed-notification and
frozen-provider controls. An earlier BUZZ-511 native run passed 1,242
tests and failed two deadline fixtures that relied on the tenant lookup
removed by the policy pin; the test-only adaptation above replaces that
seam. These failures remain archived. The first native run on
bottom-layer `130f14a6` passed 1,255 tests and failed partition
contention; its focused original test and fresh full 1,256-test run
passed without source changes. The cause remains unresolved. The first
CI run at `d8d88433` failed the old telemetry expectation; the test-only
correction above preserves exact denial counts and attribution.

Earlier evidence at `dddf05d8be8195d567db0f989f70825b1246a6c1`
(historical head):

- `cargo nextest run -p buzz-auth -p buzz-db -p buzz-relay --lib
--tests`: 2,139/2,139 passed.
- `scripts/postgres-test-run.sh -p buzz-db -p buzz-relay --lib --tests
--success-output final`: 1,236/1,236 passed with native PostgreSQL 16
and Redis. Docker's legacy `just test` wrapper was unavailable.
- `just ci`: passed, including repository checks, tests and client
builds.
- **Pre-correction behavior (superseded):** Real ES256 assertions
through TCP root/audio upgrades: valid evidence upgrades; missing
evidence denies in Off/Shadow, including submitted/inventoried/approved
deletion stages. Restoring the old query makes the regression fail on an
assertion-less Off/submitted/root HTTP 101.
- **Pre-correction behavior (superseded):** Deterministic unarchive
race: Notify barriers pause an actual archived-host policy miss; the
same required row becomes serving before binding. Both root/audio deny
missing assertions and admit valid ES256 assertions in Off/Shadow.
Ignoring bound-row policy makes this test fail on an assertion-less HTTP
101.
- The full rerun exposed an existing cancellation fixture whose
synthetic five-second replica fence expired between probes. Refreshing
that test fence per operation preserves its timeout assertions; the full
suite now passes.
- Local probe: 100 serial uncached primary policy reads took 12.33 ms.
Production latency is unmeasured.

No packaged-app, human or deployed-fleet acceptance run. Live activation
awaits BUZZ-512 and the staging verification tickets.

Published with the three-layer stack in one atomic push through normal
hooks. The published stack includes main
`1055f79d048ef22c878d02a7afb04f49c753564f`, including access logging,
storage reporting and mobile read-state changes. Access-slot integration
and early HTTP refusal attribution belong to BUZZ-511; activation
callback refusal close owners belong to BUZZ-512. Every changed layer
requires review at its published head and new-head CI. Fresh trunk,
remote membership, queue and immutable-lease guards passed before
publication. All nine normal pre-push hook lanes passed; no hooks were
bypassed. Remote heads, chained bases, membership, ready/draft states
and native tracking were verified after publication.

Trunk’s merged 0081 is unchanged. The parent upgrade expansion is 0082
and activation is 0083, with contiguous migrator versions, updated
documentation and a populated-upgrade fixture starting at 81. Trunk’s
storage-test repair supersedes our earlier local repair.

Before publication, main advanced three commits from
`1055f79d048ef22c878d02a7afb04f49c753564f` to `ba7d7825ea` (desktop
deep-link settling and mobile pairing/sign-in). The normal branch-skew
guard passed. Those later trunk commits are outside this validated
published head; current-trunk CI and delta review remain required before
merge.

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
2026-11-05 03:17:19 +00:00

17 KiB

Multi-tenant Conformance Checklist

This document is the source-vs-model checklist for adding first-class communities without changing the observed behavior of a single-community Buzz deployment.

The compatibility rule is: today's Buzz is one implicit community selected by its relay URL. Multi-tenant Buzz makes that selector explicit at the backend boundary while preserving the Nostr wire format, existing REST paths, channel UUIDs, event shapes, media URLs, git Smart HTTP behavior, workflow behavior, and CLI/Desktop/MCP expectations when N = 1.

Row zero: request community binding

Every external request starts with exactly one community:

req.community = resolve_host(connection.host), bound at connection establishment, before any WebSocket EVENT/REQ, REST handler, media handler, git transport handler, webhook handler, workflow side effect, search query, or pub/sub fan-out path observes tenant data.

Conformance obligations:

  • The URL host is the authoritative community selector. This preserves today's "the relay URL is the thing I connected to" semantic while lifting it one level up from relay process to community.
  • Unknown or unmapped hosts fail closed with a generic rejection; they never fall through to a default tenant.
  • NIP-98/API-token/community stamps may narrow or authenticate authority, but they never override the host-derived community. A token whose community stamp disagrees with req.community is rejected.
  • A client-supplied h tag is adversarial input. If present, it must resolve to a channel inside req.community; if absent, the event is channel-less but still community-scoped as community_id = req.community.
  • The single-community deployment is the degenerate case: one configured host resolves to the one default community, so existing clients observe the same behavior.

Conformance table

Surface Today's observable behavior Tenant source Community-global vs operator-global Required DB/index/RLS scope Auth/fan-out/search effects Single-community compatibility check Open decision/test
Row zero: host binding A user connects to one relay URL and all state they can observe belongs to that relay. resolve_host(connection.host) before handler entry. Community-global selector; operator only manages the host map. communities(host, id, signing_key, …); every scoped table references immutable community_id. All auth, event, REST, media, git, search, pub/sub, and workflow paths consume TenantContext; host/token mismatch rejects generically. One host maps to the default community; no client-visible protocol field changes. Add model/prose gate that ctx.community is derived from host, not supplied by the client.
NIP-11 relay info and relay self GET ///info returns one relay info document; RelayInfo::build advertises static NIPs, a stable relay signing pubkey when configured, the community's workspace icon (NIP-WP), host-specific requires_nip_fi and compatible FI capability fields, and the config-derived buzz-gif descriptor when GIF search is configured. Host-derived community for community-specific facts; host policy is read from the primary by normalized Host (unknown/non-serving host → generic 404, unavailable policy → 503); optional workspace icon is pre-fetched as a scalar via bind_community; GIF provider availability is passed as a credential-free config scalar; no other DB lookup from unauthenticated global state unless explicitly through TenantContext. NIP-11 is community-global. Operator-global software/version and GIF provider configuration may be shared; relay self for group/system/audit signing is per-community; icon is per-community intentionally-public presentation. communities.signing_key or equivalent per-community signing material; no platform-global self for tenant-observable system events. Unauthenticated reads must not become enumeration oracles for other communities: host-specific fields reveal only the requesting host's public icon, requirement and capabilities, never a neighboring community's facts. Discovery is no-store. The GIF descriptor reveals provider availability but never its credential; NIP-43 advertisement reflects membership enforcement for that community only. One community retains existing capability/descriptor semantics; the additive requirement boolean reflects stored policy OR global enforce. Signature/static-input lint remains: RelayInfo::build must not grow unscoped DB/search/audit inputs — host-scoped or config-derived scalars (like icon, pairing URL, and GIF provider) are passed in pre-derived.
API tokens and NIP-98 replay API/NIP-98 clients authenticate REST/media/git; API tokens may carry scopes and channel IDs; NIP-98 replay uses an in-process seen-set today. Host-derived community plus token's stamped community; stamps must agree. Community-global token namespace; operator-global only for deployment health/secrets. api_tokens gains community_id; token hash uniqueness and lookup are (community_id, token_hash) or the token cryptographically embeds community and lookup verifies both. Channel claims must reference channels in the same community. Replay seen-set key is (community_id, event_id) in shared HA storage or equivalent sticky routing; NIP-98 u URL host must match req.community. Existing single-community tokens continue to authorize the same scopes/channels after backfill to default community. HA gate: Redis/shared seen-set with atomic insert-if-absent and TTL ≥ replay window, or documented single-replica/sticky alternative.
Relay membership, pubkey allowlist, archived identities relay_members, pubkey_allowlist, and archived_identities are relay-global gates over pubkeys. Host-derived community for tenant access; operator context only for platform administration. Community-global membership/allowlist/archive by default. Operator-global only for explicit platform ops tables that are never tenant-observable. Add community_id to these tables; primary/unique keys become (community_id, pubkey) and indexes include community_id. Membership errors remain generic. NIP-OA owner checks test owner membership in the same community. Identity archive requests cannot hide/archive a key in another community. One default community preserves today's closed/open relay behavior and admin CLI semantics after commands target the default community. Decide any future operator-global super-admin surface separately; do not reuse tenant membership tables for it.
Users, profiles, NIP-05, and user search Kind:0 updates sync a users row; NIP-05 handles are unique; /api/users/search searches display name/NIP-05/pubkey. Channel-less events use req.community; NIP-05 domain is the connected community host. Community-global. Same pubkey can have one profile per community; users repost kind:0 in each community they join. users gains community_id; keys/uniques are (community_id, pubkey), (community_id, lower(nip05_handle)), and (community_id, okta_user_id) where applicable. Profile event replacement is scoped by (community_id, pubkey). Search and batch profile reads include community_id; NIP-05 lookup only resolves handles for the requested host/community. No cross-community profile inheritance. Existing users backfill into the default community; profile APIs and CLI output stay unchanged. Add tests for same pubkey with different profile/NIP-05 in two communities and for NIP-05 same local part on two hosts.
Channel-less global events and DMs Events with channel_id = NULL include profiles, DMs, lists, status, long-form, engrams, membership notifications, workflow commands, and repo announcements; global subscriptions use p/kind gates. req.community when no h tag is present. Community-global. "Global" means visible across channels inside one community, never across communities. DMs are per-community. events, event_mentions, replaceable/NIP-33 indexes, reactions, thread metadata, feed tables, and direct ID lookup helpers include community_id. NIP-33 uniqueness is (community_id, kind, pubkey, d_tag). REQ, /query, /count, feed, direct GET /api/events/{id}, deletes, reactions, and thread lookups filter by community before event id/pubkey/kind matching. Single-community global subscriptions and DMs still behave as today. Regression tests for same event id/d-tag/pubkey in two communities and for DM #p not cross-delivering.
Channels and channel membership channel_id (h tag) is the only locality boundary; channels, membership, canvas, topic, DMs, NIP-29 discovery are channel-scoped. resolve(h) must equal req.community; channel creation writes community_id = req.community. Community-global channel namespace; channel-local for channel content. channels, channel_members, canvas/topic/DM participant hashes, NIP-29 group ids, and channel indexes include community_id. channels.community_id is immutable. Mixed-community or unknown h tags reject generically. Open-channel discovery lists only channels in the community. Existing channel UUIDs and h tags remain valid after default-community backfill. Migration lint forbids channel re-tenanting except through an explicitly modeled admission path.
Workflows, runs, approvals, webhooks, schedules Workflows are channel-scoped or project/channel-global; triggers fire on matching stored events; schedule/webhook/manual triggers create runs; approval tokens are hashed. Workflow definition's community from req.community at create/update; webhook/schedule/manual routes resolve workflow id inside host-derived community. Community-global workflow namespace; runs/approvals inherit workflow community. workflows, workflow_runs, workflow_approvals include community_id; workflow id/token hash lookups are scoped; trigger event ids are scoped. Trigger evaluation only sees events in the same community. Webhook URLs include host-derived community; approval token grants cannot act on another community's same hash/id. Existing workflow APIs and YAML remain unchanged in default community. Add tests for identical workflow UUID/approval token hash in different communities and schedule execution isolation.
Search / FTS Postgres FTS over the events.search_tsv generated tsvector column (GIN-indexed); searchable rows expose id, content, kind, pubkey, optional channel_id, created_at, tag terms; channel-less scope is ChannelScope::ChannelLessOnly; the relay refetches canonical events from Postgres by hit id. Search query carries req.community; searchable rows carry community_id. Community-global search results; operator-global FTS index infrastructure may be shared. Every search query filters by community_id, BitmapAnd-ed with the GIN @@ probe; refetch by (community_id, event_id). Every query carries community_id plus channel scope. ChannelLessOnly means channel-less within the community, not platform global. One community produces the same search results as today. Tests for same event id/content in A and B, deletion in A not deleting B.
Redis pub/sub, presence, typing, and cache invalidation Event fan-out uses buzz:channel:{uuid}; presence uses buzz:presence:{pubkey}; typing uses buzz:typing:{channel_id}; cache invalidation uses buzz:cache-invalidate. Pub/sub calls receive TenantContext and derive keys from community_id plus channel/pubkey. Pub/sub and presence are community-global; Redis deployment is operator-global shared infrastructure. Redis keys include community: buzz:{community}:channel:{uuid}, buzz:{community}:presence:{pubkey}, buzz:{community}:typing:{channel_id}, and community-aware cache invalidation payloads/channels. Cross-node fan-out must not deliver events to subscriptions in another community. Same pubkey can be online/away differently in two communities. Cache drops only affect same-community membership/visibility caches unless explicitly all-community operator maintenance. Single-community can preserve existing key names only if deployment is isolated; shared multi-tenant Redis must use the prefixed form. Add tests for same pubkey presence in two communities and same channel UUID collision in two communities.
Media / Blossom / S3 Authenticated uploads return content-addressed descriptors; GET/HEAD /media/{sha256.ext} requires a Blossom t=get auth event scoped to the serving host or the blob hash and binds the read to the header-resolved tenant, so a bare read is rejected before any storage lookup; upload audit has channel_id = None. Upload and read request host provides req.community; Blossom/NIP-98 auth URL host must agree. Blob CAS bytes may be operator-global shared storage; metadata, authorization, quotas, audit, and visibility are community-global. Media metadata/audit rows include community_id; if object keys stay SHA-addressed, any per-community policy lives outside the raw blob key. Upload/read authorization uses community context. Shared hash bytes are allowed only as dedup/storage optimization; metadata/errors must not reveal another community's private upload. Existing media URLs keep working for default community, but clients must now present read auth; there is no config flag that restores unauthenticated reads. Resolved: blob reads are authenticated and host/tenant-scoped, not public. Remaining gap, deferred: a read is not gated on the channel ACL of the message the blob was attached to, so relay membership plus a known hash is sufficient.
Git hosting / NIP-34 / object storage Smart HTTP at /git/{owner}/{repo} hydrates from S3 object pointers; NIP-34 repo announcements use d=repo-id; pointer key is repos/{owner}/{repo}/pointer; git push emits kind:30618. Git HTTP host gives req.community; NIP-98 URL and repo announcement community must agree. Community-global repo namespace and NIP-34 state; pack/manifests CAS objects may be operator-global if pointers are scoped. Pointer/name keys include community, e.g. repos/{community}/{owner}/{repo}/pointer; NIP-34 replaceable coords include community_id; any repo-name registry is (community_id, owner, repo) or (community_id, repo) per product rule. Clone/push/read policy resolves repo and branch protections only inside the host community. Git hook policy callback carries community and rejects mismatches. Existing clone URLs and repo ids work under the default community; object-store migration can move pointers under default prefix without changing git clients. Add tests for same owner/repo in two communities and push in A not advancing B pointer.
Mesh, agents, ACP/MCP, and CLI Agents/CLI connect to a relay URL and use WS/REST; mesh/pairing/presence/status events are regular signed relay events. The relay URL/host configured in the agent/CLI session selects community. Agent membership, persona/profile, presence, jobs, memory events, and mesh status are community-global unless a future operator mesh plane is explicitly separate. Any persisted agent profile/job/mesh status rows/events use community_id; Redis/presence/search keys follow the same community scoping. A portable key may join multiple communities, but memberships, DMs, profiles, jobs, and presence do not bleed across them. Existing BUZZ_RELAY_URL continues to select the one default community. Add CLI/ACP smoke tests against two hosts using same key with different memberships/profile.
Audit log and observability One hash-chain audit log records event/channel/auth/media actions; errors are sanitized before reaching clients. Every tenant-observable audit entry is labeled with req.community or inherited community from the object being acted on. Community-global audit chains; operator metrics/log aggregation may be platform-global only if tenant labels are bounded and access-controlled. audit_log key/sequence/head includes community_id; error/audit projection tables include community_id; uniqueness is (community_id, seq) and (community_id, hash) as appropriate. Audit reads verify only one community chain. Error strings must not include cross-community IDs, constraint names, or existence facts. Single-community audit verification still traverses one chain. Eva owns model edits here; infra lane must ensure media/git/token/search rows emit community-labeled audit entries.

Migration gates

Before multi-tenant mode is admitted, the implementation must have automated gates for these classes of mistakes:

  1. Every tenant-scoped table has community_id, RLS policy, and no unique/FK constraint that can be observed across tenants unless explicitly admitted as operator-global.
  2. Every direct lookup by event id, token hash, workflow id, approval token, repo pointer/name, media hash metadata, pubkey profile, or channel id also carries community context or first resolves the object under community.
  3. Every cache/search/pubsub/object-store key that can affect tenant-visible observations includes community context, except for deliberately shared CAS byte storage whose authorization metadata is community-scoped.
  4. Every externally reachable handler obtains TenantContext from host binding before reading request body data that can cause tenant effects.
  5. N=1 conformance tests prove existing clients do not need new tags, paths, event fields, CLI flags, or protocol messages to keep current behavior.