mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
## 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>
75 lines
17 KiB
Markdown
75 lines
17 KiB
Markdown
# 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.
|