18 KiB
Multi-Spaces Design
Status: Accepted
Date: 2026-08-23
Existing domain language: Memos context
Summary
Multi-Spaces adds shared collaboration contexts inside one Memos instance. A Space groups members and memos, but it is not a tenant or workspace boundary.
Every memo remains an author-owned, independent resource. Placement, audience, authorship, distribution, and relation context are separate. Memo authors control placement through the existing memo update API. Space governance controls the Space, its membership, and the aggregate deletion of directly assigned memos; it does not transfer authorship or grant collaborative editing rights.
Goals
- Let an active user create or join multiple Spaces.
- Give each Space direct
ADMINandUSERmemberships. - Let members contribute and browse memos in a shared Space context.
- Let every memo, including a comment, be Unassigned or assigned to exactly one Space while retaining its own author, audience, and lifecycle.
- Add a Space-members-only memo audience.
- Preserve existing memo identities, relations, data, and non-Space workflows.
- Keep existing memos Unassigned; do not create a default Space.
- Provide a complete backend workflow for Space creation, membership, memo placement, and Space browsing.
Non-goals
- Tenant isolation, per-Space authentication, or per-Space instance settings.
- Multiple or nested Space placement, folders, or replacement of tags and saved views.
- Space ownership of memos, shared authorship, or collaborative editing.
- Space-admin mutation or moderation of an individual memo, including changing its placement.
- Thread ownership, inherited placement or audience, or relation-based cascading deletion.
- Mutable or re-parentable
COMMENTrelations. - Space archival or restoration.
- Invitations, guests, open enrollment, federation, or public Space discovery.
- A generated default Space.
- UI/UX design or a redesign of global surfaces.
Research
Research completed on 2026-08-22 compared the agreed boundary with Discourse Categories and Groups, Notion Teamspaces, and Mastodon.
| Product | Useful model | What Memos should avoid |
|---|---|---|
| Discourse | Posts retain their authors while a topic has one category and group permissions control access. | Separate Group, Category, and moderator concepts for one collaboration area; nested categories; default-open access. |
| Notion | Teamspaces are first-class membership and navigation contexts, and pages can move between personal and shared areas. | Mandatory default Teamspaces, shared page ownership, page trees, and layered permission inheritance. |
| Mastodon | A post's author, visibility, and feed distribution are independent. | Treating personal Lists or follows as shared membership, coupling audience to feed placement, or importing federation concerns. |
The research supports four choices:
- Make Space one first-class resource with direct membership and roles.
- Keep placement, audience, authorship, and distribution independent.
- Represent Unassigned as a real absence of placement.
- Eventually make Spaces visible in normal browsing and creation flows, while deferring UI/UX here.
Sources: Discourse category permissions, Discourse post ownership, Notion Teamspaces, Notion sharing and permissions, Mastodon post visibility, and Mastodon Lists.
These sources establish product mechanics, not demand or prevalence. This research did not include Memos analytics, user interviews, or usability testing.
Proposed design
Core model
- A Space is an instance-scoped collaboration resource, not a tenant, group, folder, or memo author. It exists or is hard-deleted; there is no archived state.
- A memo keeps its
memos/{uid}identity and author. Its placement is either Unassigned or one Space. - Placement, audience, authorship, distribution, and relation context do not imply one another.
- Audience is a single choice: Author, Instance, Space, or Public. These are named domains, not ordered access levels.
- A comment is an independent memo connected to a context memo by one immutable
COMMENTrelation created atomically with it. The relation grants no ownership, inheritance, or lifecycle authority. - A reaction belongs to one memo and has no independent audience.
- A Space membership connects one active registered user to one Space with role
ADMINorUSER. The creator becomes the firstADMIN; there is no permanent owner. - Application
ADMINis a control-plane role and is not an implicit Space member or memo reader.
Read access and distribution
The v1 API calls a memo's audience visibility for compatibility. Ordinary identity-based read access is defined only by this table:
| Visibility | Readers |
|---|---|
PRIVATE |
The active authenticated author. |
PROTECTED |
Active authenticated users. |
PUBLIC |
Active authenticated users, plus anonymous callers when instance policy permits. |
SPACE |
Active members of the assigned Space. It is invalid for an Unassigned memo. |
Space placement adds no additional read gate. In particular, an active non-member may directly read an assigned PROTECTED or PUBLIC memo, and anonymous access to an assigned PUBLIC memo follows instance policy. Such access does not reveal Space metadata, membership, or the Space feed. A memo's Space reference is returned only to its author or an active member.
An unexpired bearer share is an explicit capability exception for one exact memo. It does not grant list, feed, Space, or related-memo access.
Distribution is derived rather than separately configured:
- Global feeds return readable memos that do not have a
COMMENTrelation. - A Space feed first requires active membership, then returns readable memos assigned to that Space that do not have a
COMMENTrelation. Membership does not make another author's assignedPRIVATEmemo readable. - Direct memo reads evaluate each memo independently, including comments.
- A comment or conversation query requires its context memo to be readable, then filters every replying memo by that memo's own audience.
- A
COMMENTorREFERENCErelation and its snippet are returned only when both endpoints are readable. - Reactions are readable whenever their memo is readable.
- RSS, public profiles, and other public surfaces continue to use
PUBLIC, not Space placement.
Participation and governance
Reading and participation are separate. A caller may read an assigned PUBLIC or PROTECTED memo without membership, but participation in assigned content requires active membership.
| Action | Required authority |
|---|---|
| Create a Space | Any active registered user; creation atomically adds the first ADMIN. |
| View Space metadata, members, or feed | Active Space membership. |
| Update Space metadata | Space ADMIN. |
| Add or remove another member, or change a role | Space ADMIN; the Space must always retain an active ADMIN. |
| Leave a Space | The member; leaving must preserve an active ADMIN. |
| Create or assign a memo in a Space | The author is an active member of the target Space. |
| Comment on or react to an assigned memo | The caller can read the memo and is an active member of its Space. The new comment is authorized independently as a new memo. |
| Edit content or audience; manage attachments, references, or shares | Memo author; if assigned, the author is also an active member of its Space. |
| Delete one memo | Memo author. This does not delete another memo. |
| Withdraw or move a memo | Memo author. Source membership is unnecessary; target membership is required. |
| Hard-delete a Space | Space ADMIN. |
Any membership change or user archival that would leave a Space without an active ADMIN is rejected.
Only the memo author changes placement, using the existing memo update API to assign, move, or withdraw it. Assigning a memo does not change its audience. Changing placement and audience may be one atomic update. Moving a SPACE memo requires both space and visibility in the update mask, with visibility set to SPACE, to confirm the new member audience. Withdrawing one requires a non-Space audience in the same update.
Creating a share for a SPACE memo is rejected. Changing a memo with active shares to SPACE is rejected until those shares are revoked.
Lifecycle
Leaving or removing a member does not move or delete their memos. A removed author reads them only when their audience permits, so they cannot read a SPACE memo in the former Space. They retain narrow author lifecycle authority to delete it, withdraw it, or move it to a Space where they are active, but cannot otherwise mutate it while it remains assigned.
Deleting one memo deletes that memo, its owned resources, and relations having it as an endpoint. It does not traverse relations or delete other memos. Inbox records are independent and are not deleted because their payload references a deleted memo; notification surfaces omit a complete notification when its required memo is missing or unreadable.
Hard-deleting a Space atomically deletes the Space, its memberships, every directly assigned memo, those memos' owned database resources, and relations having a deleted endpoint. It does not follow relations into other Spaces or Unassigned memos, and it does not delete inbox records. The deleting ADMIN receives no content inventory for memos they cannot read. External attachment objects use the existing post-commit cleanup path; this feature adds no cleanup queue or dispatcher.
General account erasure remains deferred. As a fail-closed guard, user hard deletion fails while the user has any Space membership, and force does not bypass this rule. Account deletion removes only inbox rows whose sender or receiver is that user; deleting the user's memos does not remove other inbox rows that reference them.
Persistence and migration
space(id, uid, title, description)
space_member(space_id, user_id, role ADMIN | USER)
memo(..., space_id NULL means Unassigned, visibility encodes audience)
memo_relation(memo_id, related_memo_id, type COMMENT | REFERENCE)
The Space-user pair is unique. The initial version does not record Space or membership timestamps; they can be added when a concrete audit or ordering requirement exists. A nullable memo.space_id directly enforces zero-or-one placement; an association table is unnecessary. Existing memo_relation rows remain the source of truth for comments.
The change ships in migration 0.31 for SQLite, MySQL, and PostgreSQL, with equivalent fresh-install schemas. Existing memos keep their UID, author, visibility, relations, permalink, and become Unassigned. Comment rows and comment visibility are not rewritten. SQLite rebuilds memo only to add space_id and extend its visibility constraint.
Space creation, membership changes, placement and audience changes, comment creation, memo deletion, and Space deletion use ordinary transactions where partial application would corrupt directly affected data. Space deletion directly removes assigned memos and their owned rows in the same style as existing user deletion, then uses the existing best-effort attachment storage cleanup after commit. The initial version does not introduce explicit row-lock protocols, transaction retries, a cleanup queue, or a global concurrency framework.
API shape
A dedicated Space service provides create, list, get, update, and hard-delete operations plus member CRUD. Listing Spaces returns only Spaces in which the caller has active membership; no application-wide Space listing or archive API is added. An authenticated non-member receives NotFound for Space metadata, members, and feed requests.
Memo responses gain an optional Space resource name, and Visibility adds SPACE = 4 without renumbering existing values. The domain-to-v1 mapping is Author to PRIVATE, Instance to PROTECTED, Public to PUBLIC, and Space to SPACE. VISIBILITY_UNSPECIFIED remains an input sentinel: create treats it as PRIVATE, an explicit visibility update rejects it, and responses never return it. Visibility values are named domains and must not be compared numerically. The global default memo visibility setting continues to accept only PRIVATE, PROTECTED, and PUBLIC, because it cannot identify a Space.
Memo listing gains explicit all-readable, Unassigned, and Space scopes. Space scope requires active membership. Existing global and Space feeds exclude comments by default, and Space identity is not added to the CEL filter schema.
Placement and audience use the existing memo update mechanism so the memo author can change them atomically. The Space API does not add an operation for an ADMIN to move, withdraw, or otherwise mutate an individual memo.
MCP memo operations reuse the same memo policy; Space management is not exposed through MCP in the initial version.
Security invariants
Before SPACE can be stored, one shared, memo-local, fail-closed policy must cover point reads, lists and counts, files, reactions, relations, notifications, email, webhooks, shares, search, statistics, public feeds, and MCP. Child resources resolve the memo they directly belong to. Application ADMIN receives no implicit bypass.
Unknown visibility denies access. A missing or invalid placement denies SPACE reads and placement-dependent operations. Other audiences continue to govern ordinary reads, but an invalid Space identity is omitted. Inactive users and invalid memberships deny any access that depends on them. Database list and count authorization is applied before pagination:
PRIVATE + active authenticated author
OR PROTECTED + active authenticated caller
OR PUBLIC permitted for caller
OR SPACE + active membership in memo.space_id
CEL filters may only narrow this predicate. Space membership is checked per request or through immediately invalidated cache state. Write paths validate membership and placement as part of their ordinary operation. Missing or unreadable notification subjects and relation endpoints fail closed without leaking partial metadata. Strong serialization between concurrent membership, placement, and Space-deletion operations is deferred.
Live refresh is an authenticated, subject-free cache-invalidation channel. Successful memo, reaction, Space, and membership mutations broadcast only {"type":"memo.changed"} to connected clients. The event carries no resource name, audience, actor, or membership data; clients invalidate memo-backed caches and refetch through ordinary authorization. SSE does not materialize recipient sets or perform memo-level authorization.
Notifications are authorized when presented. Email and user webhook payloads are authorized before entering the existing asynchronous queue; the initial version does not cancel a prepared delivery when access changes while it is queued. A comment webhook requires both the comment and its context memo to be readable by the webhook owner when the event is prepared. A deleted-memo webhook is built only from an author-readable pre-delete snapshot.
Files for PRIVATE, PROTECTED, and SPACE memos use private, no-store; public files may use revalidation.
Alternatives considered
| Alternative | Decision |
|---|---|
| Make Space a tenant, general Group, nested folder, or multi-placement label | Rejected; Space is one flat collaboration context inside an instance. |
| Derive audience from placement or add membership as a second read gate | Rejected; the memo audience alone defines ordinary reads, while membership separately controls Space browsing and participation. |
| Use a placement association table, nested memo names, or a default Space | Rejected; nullable memo.space_id preserves stable memo identity and a real Unassigned state. |
| Replace relations with parent/root columns or inherit thread placement, audience, or lifecycle | Rejected; every comment is an independent memo and no canonical thread participates in authorization. |
Allow COMMENT mutation, require comments to share placement, or cascade individual deletion |
Rejected; comment context is immutable and non-owning, and both endpoints remain independent. |
| Archive Spaces, reject non-empty deletion, or automatically unassign their memos | Rejected for the initial version; hard deletion has an explicit aggregate lifecycle for directly assigned memos. |
| Follow relations during Space deletion | Rejected; relations cannot extend deletion into other Spaces or Unassigned content. |
| Move or delete a member's memos when membership ends | Rejected; historical contributions remain until an author lifecycle action or Space deletion. |
Give a Space ADMIN a separate operation to evict or otherwise mutate one memo |
Rejected; placement belongs to the memo author's lifecycle, while Space governance is limited to Space metadata, membership, and aggregate hard deletion. |
Give application ADMIN implicit Space or memo access |
Rejected; moderation and recovery require separate control-plane design. |
Rename v1 visibility to audience, or expose both fields |
Rejected; the domain term is Audience, while one legacy v1 field remains the compatibility representation. |
Deferred design
UI/UX is explicitly out of scope, including navigation, editor controls, membership screens, warnings, and confirmation flows.
Invitations, open enrollment, account erasure beyond the membership guard, notification retention policy, application-admin moderation and recovery, audit history, soft deletion, restoration, retryable external-object cleanup, delivery-time cancellation of queued email/webhooks, concurrent mutation hardening, and asynchronous deletion of very large Spaces remain deferred. Later work must preserve independent memo authorization, non-propagating relations, and explicit Space aggregate deletion unless this design is revisited.