## 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>
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 WebSocketEVENT/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.communityis rejected. - A client-supplied
htag is adversarial input. If present, it must resolve to a channel insidereq.community; if absent, the event is channel-less but still community-scoped ascommunity_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:
- 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. - 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.
- 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.
- Every externally reachable handler obtains
TenantContextfrom host binding before reading request body data that can cause tenant effects. - N=1 conformance tests prove existing clients do not need new tags, paths, event fields, CLI flags, or protocol messages to keep current behavior.