# Communities: mapping to the Arbiter design post This is the map between contrail's community module and the Arbiter design sketched at [zicklag.leaflet.pub/3mjrvb5pul224](https://zicklag.leaflet.pub/3mjrvb5pul224) (zicklag / Roomy, April 2026). The post is an early design note and will likely evolve — so will this doc. The goal is to make it obvious, when the standard firms up, where contrail already lines up and where it needs to change. Contrail's community module layers community-owned spaces and tiered access-level management on top of the [spaces](./spaces-spec-mapping.md) module. Ownership alone — spaces owned by a community DID — is the signal that a space is community-managed. All the Arbiter's "group management" complexity lives here; the spaces module stays close to the rough permissioned-data spec with no knowledge of access levels or delegation. --- ## Concept-by-concept alignment | Arbiter concept | Contrail | Alignment | Notes | | ------------------------------------------- | ------------------------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------- | | Community as a DID | `communities.did` | ✅ | 1:1 | | Mint a fresh did:plc for a community | `community.mint` → P-256 keypairs + genesis op + plc.directory POST | ✅ | Post uses secp256k1; we use P-256 (both spec-valid, we avoid the dep) | | Adopt an existing account | `community.adopt` (app password) | ➕ | Not in the post; contrail addition — app passwords avoid OAuth's periodic re-auth, useful for machine-operated accounts | | Creator-held rotation key (recovery) | Returned once by `community.mint` as `recoveryKey` | ✅ | Never stored; caller must save it | | Groups-are-spaces | Community-owned rows in `spaces` | ✅ | No separate groups table; every group is a space | | `$admin` reserved space | Auto-created on community creation | ✅ | Keyed by the literal string `$admin`; cannot be deleted | | `$publishers` reserved space | Auto-created on community creation | ➕ | Not in the post; contrail extension for the "publish public records as the community" capability | | Delegated membership (space → space) | `community_access_levels.subject_kind = 'space'` | ✅ | Recursive resolution with cycle guard + depth cap | | Access-level ladder | `member` / `manager` / `admin` / `owner` | ⚠️ | 4 levels vs the post's 8 — post-levels 2–3 (add/remove members) collapse into `manager`; levels 4–5 collapse into `admin` | | Read-Member-List (pre-member tier) | _none_ | ❌ | Post's level 1; skipped in v1 | | Add-Members vs Remove-Members split | Bundled into `manager` | ⚠️ | Post treats them separately | | Configure-Space | `admin` in that space | ✅ | Post's level 5 | | Create-Spaces in `$admin` | `admin` in `$admin` | ✅ | Post's level 6 | | Remove-Space | `owner` in target space OR `admin` in `$admin` | ✅ | Post's level 7 | | Owner | `owner`; only meaningful in `$admin` for owner-management | ✅ | Post's level 8 | | Push model for membership lists | Reconciler writes `spaces_members` after each change | ➕ | Post doesn't specify a sync direction; push keeps spaces read-path zero-overhead | | Cross-community / cross-arbiter delegation | _same-contrail only_ | ❌ | v1 constraint. Private-membership federation is deferred until the cross-instance distribution shape firms up | | Invites as a separate service | `community.invite.*` (create/list/revoke/redeem) | ➕ | Built-in — tokens pre-sign a grant, redeemer gets the encoded access level. SHA-256 at rest, atomic redeem. | | Writing records under the arbiter's account | `community.space.putRecord` (in-space) + `community.putRecord` (public) | ✅ | In-space: `admin+`. Public: `member+` in `$publishers` — routes through adopted community's PDS | | Public membership-list flag | _none_ | ❌ | Post allows spaces to expose membership publicly. Ruled out for cross-instance federation (privacy) | | Space credential (from arbiter) | _none_ | ⚠️ | Covered by the spaces module's service-auth story, not the community module | Legend: ✅ aligned · ⚠️ partial / different granularity · ❌ missing · ➕ contrail extension (not in the post) --- ## XRPC surface All endpoints emitted under `.community.*` from templates in `lexicon-templates/community/`. Distinct from `.space.*` so the user-managed spaces surface stays clean. Community-managed spaces live under `.community.space.*` so the endpoint name reveals whether membership is user-controlled or community-controlled. ### Community lifecycle - `community.adopt` · `community.mint` · `community.reauth` · `community.delete` - `community.list` · `community.getHealth` ### Space (group / role / channel) lifecycle - `community.space.create` · `community.space.delete` ### Membership - `community.space.grant` · `community.space.revoke` · `community.space.setAccessLevel` - `community.space.listMembers` (`?flatten=true` for the resolved DID list) · `community.space.resync` - `community.space.whoami` — caller's effective access level in a space ### Invites - `community.invite.create` · `community.invite.list` · `community.invite.revoke` · `community.invite.redeem` ### Publishing - `community.putRecord` · `community.deleteRecord` (public records via the community's PDS; adopted only) - `community.space.putRecord` · `community.space.deleteRecord` (in-space records authored by the community DID) --- ## Contrail extensions over the post Labelled ➕ in the alignment table, worth calling out explicitly so future renames against a standard are easy: - **`adopt` mode** for communities backed by a regular ATProto account. App passwords are the default because they don't have the periodic re-auth ceremony OAuth does, which matters for machine-operated community accounts. - **`$publishers` reserved space.** The post doesn't model "publish public records as the community" — we gate that capability via a second reserved space so it's orthogonal to `$admin` governance without widening the access ladder. - **Push-based reconciliation into `spaces_members`.** The post doesn't specify sync direction; push keeps the spaces read path O(1) and lets the spaces module stay oblivious to delegation. --- ## Migration readiness Hasn't shipped as a standard yet. When it does, likely churn areas: 1. **Level renames / additions.** Our 4 are a coalesce of the post's 8. If a standard lands with finer tiers, add rows to the enum and migrate existing values — `community_access_levels.access_level` is a plain text column. 2. **Cross-instance delegation.** Our v1 is local-only. When private cross-contrail federation is figured out (encrypted membership records, authenticated pull, something else), the `subject_space_uri` field already supports remote URIs structurally — only the resolution path needs to change. 3. **Invites.** When the post's invite service firms up, reuse the spaces invite primitive or fork it. Adding an invite service as a member of `$admin` with `manager` access is the model; no schema change anticipated. 4. **Publishing for minted communities.** Currently returns `NotSupported`. When contrail-as-PDS or PDS-less minted publishing is defined, drop the check. --- ## Design decisions worth preserving - **Spaces doesn't know the community module exists.** Dependency is one-way: community writes to spaces tables; the spaces module never imports from `src/core/community/`. Preserve this — it's what keeps the spaces surface close to the permissioned-data spec. - **Ownership is the signal.** Community-owned = owner DID is in `communities`. No flag columns on `spaces`. Preserve this — adding a delegation flag re-introduces a schema hook we deliberately dropped. - **Access levels govern arbiter operations only.** Never leak them into record-level ACLs or app-level role systems. App roles belong as records, not as levels. - **Orthogonal capabilities go in reserved `$`-spaces, not new ladder rungs.** `$publishers` is the first; `$moderators`, `$billing`, etc. can follow. This keeps the ladder small and extensions additive. - **Push, not pull, for `spaces_members`.** Read-path overhead has to stay zero; complexity belongs in the writer. - **Single-instance is a feature, not a gap.** Federation is the hard problem; defer cleanly with a local-only assertion at grant time until private-distribution shape is clear.