Peering: send into a Space's shared inbox (Space as a peering recipient) #244

Closed
opened 2026-08-15 15:31:59 +00:00 by Cordy · 3 comments
Owner

Feature request from the #234/#243 discussion. Distinct from #243 — recording why, plus the design questions to answer before building. Not started; parked for scheduling.

Motivation

A Cairn Space is already a group with members (owner-defined) and a shared folder. That makes it the natural Cairn-native equivalent of Nextcloud's group@domain federated target: instead of the sender enumerating members and delivering N copies to N personal inboxes, the sender addresses the Space, and one copy lands in the Space's shared inbox where members read it through their existing space membership.

This is the clean answer to the #234 privacy problem: no member usernames are ever disclosed to the sender — the group is an opaque destination the receiver resolves internally.

Why this is separate from #243

#243 (group receivers → personal folders) This (Space inbox)
Delivery per-member, into each /home/<member>/Inbox/ one copy into the Space's shared folder
Addressing member usernames (picker enumerates them) the Space name, as an opaque handle
Privacy discloses member names (opt-in, #234) discloses nothing
Availability IdP groups only (needs Keycloak/LDAP) app-owned spaces work in every custody mode

Grounding: two Space backends already exist

  • App-owned (internal/spacestore, ADR 0001): {name, owner, members[]} age-encrypted in the state-backend, no IdP, works under every auth mode + key custody. This is what makes openbao/app-owned instances able to group users at all.
  • IdP-group (internal/spaces, Manager): a pair of Keycloak groups (<name> / <name>-ro) + owner attribute; membership in Keycloak; Members() via the group-members API.

Both expose owner + members + roles (write/read). A space-as-recipient feature sits on top of whichever backend the instance uses.

Design questions to resolve first

  1. Addressing / allow-list. New recipient kind (space vs user) on the wire and a new receiver allow-list dimension (Receivers.Spaces, or a marker on the existing list). The peer's admin decides which Spaces may receive from a given peer, same shape as the user/group allow-lists.
  2. Delivery target + path. Deliver into the Space's shared folder (e.g. /Spaces/<name>/Inbox/<peer>/) re-encrypted through the normal stack, so quota/audit/trash/retention/holds apply with no new code (same principle as #104/#237 personal delivery). Confirm the space folder path convention (#64 scope).
  3. Encryption to a shared space — the crux. Personal delivery (#237) writes as the single recipient so the encrypt decorator resolves one user key. A shared space folder must be readable by all members → the write must encrypt to every member's recipient (multi-recipient age) + deployment recovery. Does the encrypt/KeyProvider path already support writing into a shared space under per-user custody, or is that machinery that has to exist first? This is the load-bearing question; a space inbox is only as sound as the shared-folder encryption model. If shared-space writes aren't yet a solved path for per-user custody, that is its own prerequisite.
  4. Membership churn vs. encrypted history. When a member is added later, can they read files delivered before they joined? (Re-encryption on join vs. accept that history predates membership.) Mirror whatever the existing shared-space model already decides — do not invent a second rule here.
  5. Authorization. CanReceive-equivalent for spaces: the Space must be allow-listed for this peer AND exist on this instance. No member-level check needed at delivery (delivery is to the folder, not a person), which is what makes it privacy-clean.
  6. Discovery. The sender's picker can offer allow-listed Space names (not members) — safe to surface because a Space name the admin allow-listed for this peer is not member PII. This is the discovery story #234 wanted, achieved without member expansion.

Relationship to existing issues

  • Supersedes the "send-to-group fan-out (Nextcloud model)" direction I parked in the #234 memo — this is the concrete, better-scoped version of it.
  • Complementary to #243, not a replacement: #243 remains the answer for "a group of individuals each receiving personally"; this is "a team receiving into a shared space."

Milestone: v0.7.

Refs: #234, #243, #104, #237, #64, #211, #226; internal/spacestore, internal/spaces.

Feature request from the #234/#243 discussion. Distinct from #243 — recording why, plus the design questions to answer before building. **Not started; parked for scheduling.** ## Motivation A Cairn **Space** is already a group with members (owner-defined) *and* a shared folder. That makes it the natural Cairn-native equivalent of Nextcloud's `group@domain` federated target: instead of the sender enumerating members and delivering N copies to N personal inboxes, the sender addresses the **Space**, and one copy lands in the Space's shared inbox where members read it through their existing space membership. This is the clean answer to the #234 privacy problem: **no member usernames are ever disclosed to the sender** — the group is an opaque destination the receiver resolves internally. ## Why this is separate from #243 | | #243 (group receivers → personal folders) | This (Space inbox) | |---|---|---| | Delivery | per-member, into each `/home/<member>/Inbox/` | one copy into the Space's shared folder | | Addressing | member usernames (picker enumerates them) | the Space name, as an opaque handle | | Privacy | discloses member names (opt-in, #234) | discloses nothing | | Availability | IdP groups only (needs Keycloak/LDAP) | **app-owned spaces work in every custody mode** | ## Grounding: two Space backends already exist - **App-owned** (`internal/spacestore`, ADR 0001): `{name, owner, members[]}` age-encrypted in the state-backend, no IdP, works under every auth mode + key custody. This is what makes openbao/app-owned instances able to group users at all. - **IdP-group** (`internal/spaces`, `Manager`): a pair of Keycloak groups (`<name>` / `<name>-ro`) + owner attribute; membership in Keycloak; `Members()` via the group-members API. Both expose owner + members + roles (write/read). A space-as-recipient feature sits on top of whichever backend the instance uses. ## Design questions to resolve first 1. **Addressing / allow-list.** New recipient kind (space vs user) on the wire and a new receiver allow-list dimension (`Receivers.Spaces`, or a marker on the existing list). The peer's admin decides which Spaces may receive from a given peer, same shape as the user/group allow-lists. 2. **Delivery target + path.** Deliver into the Space's shared folder (e.g. `/Spaces/<name>/Inbox/<peer>/`) re-encrypted through the normal stack, so quota/audit/trash/retention/holds apply with no new code (same principle as #104/#237 personal delivery). Confirm the space folder path convention (#64 scope). 3. **Encryption to a shared space — the crux.** Personal delivery (#237) writes *as the single recipient* so the encrypt decorator resolves one user key. A shared space folder must be readable by **all** members → the write must encrypt to every member's recipient (multi-recipient age) + deployment recovery. **Does the encrypt/KeyProvider path already support writing into a shared space under per-user custody, or is that machinery that has to exist first?** This is the load-bearing question; a space inbox is only as sound as the shared-folder encryption model. If shared-space writes aren't yet a solved path for per-user custody, that is its own prerequisite. 4. **Membership churn vs. encrypted history.** When a member is added later, can they read files delivered before they joined? (Re-encryption on join vs. accept that history predates membership.) Mirror whatever the existing shared-space model already decides — do not invent a second rule here. 5. **Authorization.** `CanReceive`-equivalent for spaces: the Space must be allow-listed for this peer AND exist on this instance. No member-level check needed at delivery (delivery is to the folder, not a person), which is what makes it privacy-clean. 6. **Discovery.** The sender's picker can offer **allow-listed Space names** (not members) — safe to surface because a Space name the admin allow-listed for this peer is not member PII. This is the discovery story #234 wanted, achieved without member expansion. ## Relationship to existing issues - Supersedes the "send-to-group fan-out (Nextcloud model)" direction I parked in the #234 memo — this is the concrete, better-scoped version of it. - Complementary to #243, not a replacement: #243 remains the answer for "a group of individuals each receiving personally"; this is "a team receiving into a shared space." Milestone: v0.7. Refs: #234, #243, #104, #237, #64, #211, #226; `internal/spacestore`, `internal/spaces`.
Author
Owner

Design Q3 (shared-space encryption) — RESOLVED, no prerequisite. Verified against internal/storage/encrypt.

Shared spaces already encrypt to a per-space identity, not to each member's key and not by fan-out. The encrypt driver derives the group scope from the path (GroupFromPath("/spaces/<name>/…")WithGroup), and the KeyProvider resolves the space's age identity:

  • keycloak-profile — per-space identity in the Keycloak group's attributes (keysForgroupKeysFor), mirror of per-user.
  • openbao — per-space identity in KV at .../groups/<space> (keysFor: rel = "groups/"+g), mirror of per-user.
  • static/deployment — one identity encrypts everything; spaces covered for free, no per-space key needed.

So the crypto exists in every custody mode. Two consequences that make delivery trivial:

  1. The group scope comes from the path and (openbao/keycloak) resolves by group name with no user in context — so a Space-inbox delivery needs no #237-style recipient injection; writing to /spaces/<name>/Inbox/<peer>/ auto-encrypts to the space key.
  2. Peering delivery to a space would reuse the identical encrypt path a space member's own write already uses (a shipped feature) → zero new crypto. Membership churn is already free (design note in group.go: join = full history, leave = revoked, no re-encryption).

Revised Q3 verdict: not a prerequisite and not load-bearing after all — the shared-space encrypt path carries it. The feature reduces to: address a Space as a recipient, allow-list it per peer, and write into its inbox path.

Minor corner to confirm at build time (not a blocker): app-owned spaces (spacestore) pair with openbao/static custody (group key in KV / deployment key). On a keycloak-custody instance the group-key path expects a real Keycloak group, so an app-owned space there would need that path checked — unusual combination, orthogonal to the feature.

**Design Q3 (shared-space encryption) — RESOLVED, no prerequisite.** Verified against `internal/storage/encrypt`. Shared spaces already encrypt to a **per-space identity**, not to each member's key and not by fan-out. The encrypt driver derives the group scope from the path (`GroupFromPath("/spaces/<name>/…")` → `WithGroup`), and the KeyProvider resolves the *space's* age identity: - **keycloak-profile** — per-space identity in the Keycloak group's attributes (`keysFor` → `groupKeysFor`), mirror of per-user. - **openbao** — per-space identity in KV at `.../groups/<space>` (`keysFor`: `rel = "groups/"+g`), mirror of per-user. - **static/deployment** — one identity encrypts everything; spaces covered for free, no per-space key needed. So the crypto exists in **every custody mode**. Two consequences that make delivery trivial: 1. The group scope comes from the **path** and (openbao/keycloak) resolves by group **name with no user in context** — so a Space-inbox delivery needs no #237-style recipient injection; writing to `/spaces/<name>/Inbox/<peer>/` auto-encrypts to the space key. 2. Peering delivery to a space would reuse the **identical encrypt path a space member's own write already uses** (a shipped feature) → zero new crypto. Membership churn is already free (design note in `group.go`: join = full history, leave = revoked, no re-encryption). **Revised Q3 verdict:** not a prerequisite and not load-bearing after all — the shared-space encrypt path carries it. The feature reduces to: address a Space as a recipient, allow-list it per peer, and write into its inbox path. Minor corner to confirm at build time (not a blocker): **app-owned spaces (`spacestore`) pair with openbao/static custody** (group key in KV / deployment key). On a *keycloak-custody* instance the group-key path expects a real Keycloak group, so an app-owned space there would need that path checked — unusual combination, orthogonal to the feature.
Author
Owner

Design confirmed — build-ready spec

All six questions resolved (Q3/Q4 by the encryption finding above; Q1/Q2/Q5/Q6 ratified). Recording the final shape.

Confirmed decisions

  • Q1 addressing/allow-list — a recipient kind (user vs space) on the wire; a new Receivers.Spaces allow-list on the peer entry, same shape/UX as the user/group rows.
  • Q2 delivery — one copy into the Space's shared inbox /spaces/<name>/Inbox/<peer>/, re-encrypted through the normal stack (quota/audit/trash/retention/holds apply free).
  • Q3 encryption — reuses the existing per-space encrypt path; the group scope is derived from the path, resolves by space name with no user in context, identical to a member's own write. Zero new crypto, every custody mode.
  • Q4 churn — free: join = full inbox history, leave = revoked, no re-encryption (existing group.go behavior).
  • Q5 authorization — a CanReceive-equivalent for spaces: the Space is allow-listed for this peer AND exists on this instance. No member-level check — a space existing implies its membership already exists and is managed locally.
  • Q6 discovery — the sender gets a dropdown of allow-listed Space names they may send to (the exact analogue of the #125 recipient picker for users), via a ListReceiverSpaces-style RPC. Space names the admin allow-listed for this peer are not member PII, so this is safe to surface.

Work breakdown

  1. Registry / allow-listReceivers.Spaces []string on peering.Peer; ReceiverSpaces(peer) accessor; CanReceiveSpace(peer, space) = allow-listed ∩ exists. Peering-card UI to edit the space allow-list (mirrors the send/recv rows).
  2. Space existence capability — a Spaces.Exists(name) bool resolver (spacestore for app-owned, spaces.Manager for IdP-group), wired like the Recipients interface.
  3. Wire / proto — recipient kind on PrepareTransfer (user vs space); a ListReceiverSpaces RPC returning allow-listed spaces ∩ exist (one audited event per call, like ListRecipients).
  4. PrepareTransfer gate — dispatch on kind: CanReceiveSpace for spaces, existing Recipients.Exists/CanReceive for users.
  5. Deliverydeliver.go writes into /spaces/<name>/Inbox/<peer>/<file>; the encrypt driver's path-derived group scope handles encryption to the space key automatically (no recipient-user injection needed). Confirm the inbox path against the #64 scope convention.
  6. Frontend — send-dialog space dropdown (reuse the #125 picker pattern); peering-card Receivers.Spaces editor.
  7. Docs + dogfood — per-custody-mode note; the app-owned-space × keycloak-custody corner; an A↔B space-inbox transfer as the acceptance test.

Design is complete; ready to implement when scheduled.

## Design confirmed — build-ready spec All six questions resolved (Q3/Q4 by the encryption finding above; Q1/Q2/Q5/Q6 ratified). Recording the final shape. ### Confirmed decisions - **Q1 addressing/allow-list** — a recipient *kind* (user vs space) on the wire; a new `Receivers.Spaces` allow-list on the peer entry, same shape/UX as the user/group rows. - **Q2 delivery** — one copy into the Space's shared inbox `/spaces/<name>/Inbox/<peer>/`, re-encrypted through the normal stack (quota/audit/trash/retention/holds apply free). - **Q3 encryption** — reuses the existing per-space encrypt path; the group scope is derived from the path, resolves by space name with no user in context, identical to a member's own write. Zero new crypto, every custody mode. - **Q4 churn** — free: join = full inbox history, leave = revoked, no re-encryption (existing `group.go` behavior). - **Q5 authorization** — a `CanReceive`-equivalent for spaces: the Space is allow-listed for this peer **AND** exists on this instance. **No member-level check** — a space existing implies its membership already exists and is managed locally. - **Q6 discovery** — the sender gets a dropdown of **allow-listed Space names** they may send to (the exact analogue of the #125 recipient picker for users), via a `ListReceiverSpaces`-style RPC. Space names the admin allow-listed for this peer are not member PII, so this is safe to surface. ### Work breakdown 1. **Registry / allow-list** — `Receivers.Spaces []string` on `peering.Peer`; `ReceiverSpaces(peer)` accessor; `CanReceiveSpace(peer, space)` = allow-listed ∩ exists. Peering-card UI to edit the space allow-list (mirrors the send/recv rows). 2. **Space existence capability** — a `Spaces.Exists(name) bool` resolver (spacestore for app-owned, `spaces.Manager` for IdP-group), wired like the `Recipients` interface. 3. **Wire / proto** — recipient kind on `PrepareTransfer` (user vs space); a `ListReceiverSpaces` RPC returning allow-listed spaces ∩ exist (one audited event per call, like `ListRecipients`). 4. **PrepareTransfer gate** — dispatch on kind: `CanReceiveSpace` for spaces, existing `Recipients.Exists`/`CanReceive` for users. 5. **Delivery** — `deliver.go` writes into `/spaces/<name>/Inbox/<peer>/<file>`; the encrypt driver's path-derived group scope handles encryption to the space key automatically (no recipient-user injection needed). Confirm the inbox path against the #64 scope convention. 6. **Frontend** — send-dialog space dropdown (reuse the #125 picker pattern); peering-card `Receivers.Spaces` editor. 7. **Docs + dogfood** — per-custody-mode note; the app-owned-space × keycloak-custody corner; an A↔B space-inbox transfer as the acceptance test. Design is complete; ready to implement when scheduled.
Cordy referenced this issue from a commit 2026-08-15 17:32:56 +00:00
Cordy referenced this issue from a commit 2026-08-15 17:59:30 +00:00
Cordy referenced this issue from a commit 2026-08-16 11:31:22 +00:00
Author
Owner

Done end to end and dogfood-verified (v0.6.26–v0.6.29):

  • Backend: inbound space allow-list on peers (Receivers.Spaces, CanReceiveSpace, ReceiverSpaces) + admin API (v0.6.26); proto recipient_kind + ListReceiverSpaces RPC, PrepareTransfer space/user dispatch, delivery into /spaces/<name>/Inbox/<peer>/ reusing the space's own path-derived group key, peerSpaces existence resolver over the app-owned store + IdP-group manager (v0.6.27); Kind threaded through the send path + GET /peering/peers/{peer}/spaces (v0.6.28).
  • Frontend: send dialog destination selector + space picker, peering-card receiver recvSpaces editor (v0.6.28), add-peer form inbound Spaces field (v0.6.29).
  • Verified: A→B space-inbox transfer confirmed working end to end on the dogfoods; every space member decrypts via the group key. No member key fan-out; membership churn needs no re-encryption.

Closing as complete.

Done end to end and dogfood-verified (v0.6.26–v0.6.29): - **Backend:** inbound space allow-list on peers (`Receivers.Spaces`, `CanReceiveSpace`, `ReceiverSpaces`) + admin API (v0.6.26); proto `recipient_kind` + `ListReceiverSpaces` RPC, PrepareTransfer space/user dispatch, delivery into `/spaces/<name>/Inbox/<peer>/` reusing the space's own path-derived group key, `peerSpaces` existence resolver over the app-owned store + IdP-group manager (v0.6.27); Kind threaded through the send path + `GET /peering/peers/{peer}/spaces` (v0.6.28). - **Frontend:** send dialog destination selector + space picker, peering-card receiver `recvSpaces` editor (v0.6.28), add-peer form inbound Spaces field (v0.6.29). - **Verified:** A→B space-inbox transfer confirmed working end to end on the dogfoods; every space member decrypts via the group key. No member key fan-out; membership churn needs no re-encryption. Closing as complete.
Cordy closed this issue 2026-08-16 11:48:19 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#244
No description provided.