首页
/ Memos 多空间(Multi-Spaces)设计解析:Space 数据模型、权限矩阵与 0.31 迁移全解

Memos 多空间(Multi-Spaces)设计解析:Space 数据模型、权限矩阵与 0.31 迁移全解

2026-09-05 16:48:41作者:冯爽妲Honey

本文围绕 Memos 仓库中的设计文档 multi-spaces.md 展开,系统讲解 Memos 实例内“多空间协作”的设计决策:Space / SpaceMember / SpaceInvitation 的资源模型、SPACE 可见性域与读权限推导规则、治理动作权限矩阵、迁移版本 0.31 的三库 Schema 变更,以及 space_service.proto 中完整的 v1 API 形态。读完后可理解 Memos 如何在保持“备忘录属于作者”这一既有模型的前提下,引入一套不转移所有权、不级联删除的共享协作上下文,并能对照源码验证其关键实现。

设计定位:Space 是协作上下文,不是租户边界

设计文档 docs/design/multi-spaces.md 开篇即给出边界定义:Multi-Spaces 在一个 Memos 实例内部引入共享协作上下文。Space 把成员和备忘录(memo)分组,但它不是租户,也不是工作区隔离边界

核心不变式是:每条备忘录始终是作者所有、独立存在的资源。放置(placement)、受众(audience)、作者身份(authorship)、分发(distribution)与关系上下文(relation context)是五个相互独立的维度——备忘录作者通过既有的备忘录更新 API 控制其放置位置,而 Space 治理层只管理 Space 本身、其成员关系,以及“对直接分配到该 Space 的备忘录做聚合删除”;治理层不转移作者身份,也不授予协作编辑权。

文档还明确了目标与非目标,这是理解整个设计取舍的钥匙:

目标(Goals)

  • 允许一个活跃用户创建或加入多个 Space;
  • 每个 Space 接受 ADMINUSER 两级成员身份;
  • Space ADMIN 可邀请实例内已存在的活跃用户并指定角色,但该用户必须接受邀请后才成为成员;
  • 成员可在共享 Space 上下文中贡献和浏览备忘录;
  • 每条备忘录(含评论)要么 Unassigned(未分配),要么恰好属于一个 Space,同时保留自己的作者、受众与生命周期;
  • 新增“仅限 Space 成员可见”的备忘录受众;
  • 保留既有备忘录身份、关系与数据,兼容非 Space 工作流;
  • 既有备忘录保持 Unassigned,不生成默认 Space
  • 提供 Space 创建、邀请、成员管理、备忘录放置与 Space 浏览的完整后端工作流。

非目标(Non-goals)

  • 不做租户隔离、按 Space 独立的认证或实例设置;
  • 不支持多条或嵌套放置、文件夹、替代标签与保存视图;
  • 不做 Space 拥有备忘录、共享作者身份或协作编辑;
  • Space 管理员不能修改、审查单条备忘录(包括移动其放置位置);
  • 不做线程所有权、放置/受众继承、基于关系级联删除;
  • COMMENT 关系不可变、不可改挂;
  • 不做 Space 归档与恢复;
  • 不做访客、邮件/外部用户邀请、公开注册、联邦、公开 Space 发现;
  • 不重定义 Inbox、用户资料等用户全局面。

竞品调研支撑的四项选择

文档记录了 2026-08-22 对 Discourse、Notion、Mastodon 三类产品的对比调研(原文附有各产品官方文档来源,此处从略)。对比结论如下表,其中“Memos 应避免”一列直接塑造了设计:

产品 可借鉴的模型 Memos 应避免的做法
Discourse 帖子保留原作者,而主题只有一个类别,组权限控制访问 把 Group、Category、版主三种概念压在同一协作区域;嵌套类别;默认开放访问
Notion Teamspaces 是一等的成员与导航上下文,页面可在个人区与共享区间移动 强制默认 Teamspaces、共享页面所有权、页面树、多层权限继承
Mastodon 帖子的作者、可见性、Feed 分发相互独立 把个人列表或关注关系当作共享成员关系、把受众与 Feed 放置耦合、引入联邦问题

由此支撑四项选择:

  1. Space 是一等资源,具备显式成员关系与角色,并以“被邀请者接受”作为门槛;
  2. 放置、受众、作者、分发彼此独立;
  3. Unassigned 是一种真实存在的放置缺失状态,而非默认 Space;
  4. Space 出现在常规浏览、创建与管理流程中,但用户全局面(Inbox、用户资料等)不随当前 Space 变化。

文档同时声明:这些调研材料只说明产品机制,不构成需求或普及率证据;本次调研未包含 Memos 数据分析、用户访谈或可用性测试。

核心模型与读取规则

核心对象

  • Space:实例范围内的协作资源,不是租户、组、文件夹或备忘录作者。它要么存在,要么被硬删除,没有归档状态
  • Memo:保留 memos/{uid} 身份与作者;其放置要么是 Unassigned,要么是一个 Space;
  • Audience:单选,取值 Author / Instance / Space / Public。文档强调这是“命名的域”(named domains),不是有序访问等级,不能做数值比较;
  • Comment:一条独立的备忘录,通过一条随其原子创建的、不可变的 COMMENT 关系连接到上下文备忘录;该关系不授予任何所有权、继承或生命周期权限;
  • Reaction:隶属于一条备忘录,没有独立受众;
  • SpaceInvitation:面向一个已存在的活跃注册用户、带有选定 ADMIN/USER 角色的待定要约。在接受之前不授予任何 Space 访问权;
  • SpaceMember:一个活跃注册用户与一个 Space 之间已接受的关系,角色为 ADMINUSER。创建者自动成为第一个 ADMIN,系统没有永久 owner 概念;
  • 应用级 ADMIN:属于控制平面角色,不是隐式 Space 成员,也不是隐式备忘录读取者。

读访问表:v1 中 visibility 即受众

v1 API 出于兼容仍称备忘录受众为 visibility。普通身份型读访问只由下表定义(见 memo_service.proto 中的 Visibility 枚举,SPACE = 4):

Visibility 可读者
PRIVATE 活跃已认证作者本人
PROTECTED 活跃已认证用户
PUBLIC 活跃已认证用户;实例策略允许时还包括匿名调用者
SPACE 所放置 Space 的活跃成员;对 Unassigned 备忘录无效

由此推出几条重要规则:

  • Space 放置不额外增加读取门槛:一个活跃非成员可以直接读取已放置的 PROTECTEDPUBLIC 备忘录;已放置 PUBLIC 备忘录的匿名访问同样遵循实例策略。这类访问不会泄露 Space 元数据、成员列表或 Space Feed。备忘录的 Space 引用只返回给作者或活跃成员;
  • 未过期的 bearer 分享链接是对“某一条精确备忘录”的显式能力例外,不授予列表、Feed、Space 或关联备忘录访问;
  • 备忘录的 Space 引用仅在调用者有资格时才在响应中出现。

分发是推导出来的,而不是单独配置的

  • 全局 Feed 返回“可读且不带 COMMENT 关系”的备忘录;
  • Space Feed 先要求活跃成员身份,然后返回“可读、已分配到该 Space 且不带 COMMENT 关系”的备忘录。成员身份不会让他人放置的 PRIVATE 备忘录变得可读;
  • 直接读取备忘录时逐条独立判定,评论同理;
  • 评论/会话查询要求上下文备忘录可读,然后按每条回复备忘录自己的受众过滤;
  • COMMENT/REFERENCE 关系及其摘要只在两端都可读时返回;
  • 反应(reaction)在其备忘录可读时即可读;
  • 公开资料页等公开面继续使用 PUBLIC,与 Space 放置无关。

参与与治理:动作权限矩阵

设计明确区分“读”与“参与”:调用者可以在无成员身份时读取已放置的 PUBLIC/PROTECTED 备忘录,但参与已放置内容(评论、反应等)需要活跃成员身份。完整的动作—权限矩阵如下:

动作 所需权限
创建 Space 任何活跃注册用户;创建时原子地加入第一个 ADMIN
查看 Space 元数据、成员、Feed 活跃 Space 成员身份
更新 Space 元数据 Space ADMIN
邀请既有活跃用户、选定其角色、列出或撤销邀请 Space ADMIN
接受或拒绝邀请 仅被邀请用户本人
移除其他成员或修改已接受成员角色 Space ADMIN;且 Space 必须始终保留一个活跃 ADMIN
退出 Space 该成员本人;退出后必须仍保留活跃 ADMIN
在 Space 中创建或放置备忘录 作者是目标 Space 的活跃成员
评论/反应一条已放置备忘录 调用者能读取该备忘录,且是其 Space 的活跃成员;新评论作为新备忘录独立鉴权
编辑内容或受众;管理附件、引用、分享 备忘录作者;若已放置,作者同时须是该 Space 活跃成员
删除一条备忘录 备忘录作者;不会连带删除其他备忘录
撤回(withdraw)或移动备忘录 备忘录作者;不需要源 Space 成员身份,但需要目标 Space 成员身份
硬删除 Space Space ADMIN

配套规则:

  • 任何会导致 Space 没有活跃 ADMIN 的成员变更或用户归档操作都会被拒绝;
  • 邀请永远不等于成员身份。除邀请自身携带的只读 Space 摘要外,待接受的 ADMIN 邀请不授予任何元数据、Feed、备忘录、参与或治理权限,也不计入“最后活跃 ADMIN”不变式。Accept 保持邀请创建时选定的角色;拒绝或管理员撤销都只移除待定要约,之后同一 Space-用户组合可以被再次邀请。

Store 层对“接受者必须是被邀请者本人”做了硬校验,见 store/space.go 中的 AcceptSpaceInvitation / DeclineSpaceInvitation

if accept.UserID != actorUserID {
    return nil, ErrSpacePermissionDenied
}

放置变更的原子性约束

只有备忘录作者可以变更放置位置,且复用既有备忘录更新 API。文档给出三条实操约束:

  1. 分配备忘录不改变其受众;
  2. 同时变更放置与受众可以是同一个原子更新;
  3. 把一条 SPACE 备忘录移动到新 Space,要求更新掩码中同时包含 spacevisibility(visibility 置为 SPACE),以确认新的成员受众;撤回放置则要求同一次更新中给出一个非 Space 受众。

另外,为 SPACE 备忘录创建分享链接会被拒绝;把一条存在活跃分享链接的备忘录改为 SPACE 受众,也会被拒绝直到这些分享被撤销。

生命周期规则

成员退出/被移除:不移除、不删除其备忘录。被移除的作者仅在其受众允许时才能读取自己留在原 Space 的备忘录——因此他读不到该 Space 中自己的 SPACE 备忘录。他保留窄范围的作者生命周期权限:可以删除、撤回、或把它移动到自己仍活跃的成员 Space,但备忘录保持已放置期间不能做其他变更。

删除单条备忘录:只删除该备忘录、其自有资源、以及以它为端点的关系;不遍历关系、不删除其他备忘录。Inbox 记录相互独立——即使其载荷引用了被删备忘录也不会被删;通知面在所需备忘录缺失或不可读时省略整条通知。

硬删除 Space:原子删除 Space、其成员关系、所有直接分配的备忘录、这些备忘录自有的数据库资源,以及端点已被删的关系。沿关系追踪到其他 Space 或 Unassigned 备忘录,也删 Inbox 记录。执行删除的 ADMIN 不会收到其无权读取的备忘录清单。外部附件对象走既有的提交后(post-commit)清理路径,该特性不新增清理队列或调度器。

账号删除的 fail-closed 护栏:通用账号擦除仍被推迟,但作为护栏,当用户仍有活跃 Space 成员身份时,用户硬删除会失败,且 force 不能绕过此规则;待定邀请不阻止删除,并会在账号删除事务中被移除。账号删除只删发送者或接收者是该用户的 Inbox 行;删除其备忘录不会删掉其他引用它们的 Inbox 行。

持久化模型与 0.31 迁移

设计文档给出的目标持久化形状:

space(id, uid, title, description)
space_member(space_id, user_id, status INVITED | ACTIVE, role ADMIN | USER)
memo(..., space_id NULL means Unassigned, visibility encodes audience)
memo_relation(memo_id, related_memo_id, type COMMENT | REFERENCE)

关键设计点:

  • Space-用户组合唯一,表示一个“当前关系槽位”。INVITED 对外暴露为 Space Invitation,ACTIVE 暴露为 Space Membership。接受操作把当前行从 INVITED 原子改为 ACTIVE;拒绝与撤销只删除 INVITED 行。刻意没有邀请代号 UID——同一 Space-用户组合的同一请求永远指向其当前待定邀请;
  • status 必填、无默认值、数据库层无 CHECK 约束:Store 逻辑写入并识别 INVITED/ACTIVE,未知取值在鉴权查询中 fail closed。角色约束保持不变;
  • 初版不记录邀请人、邀请/Space/成员时间戳,待出现具体归因、过期、审计或排序需求时再补;
  • memo.space_id 可空,直接表达“零或一”放置,无需关联表;既有 memo_relation 行继续是评论的真理来源。

三库迁移脚本(0.31)

变更随迁移版本 0.31 发布到 SQLite、MySQL、PostgreSQL,并提供等价的新装 Schema。以 store/migration/sqlite/0.31/03__multi_spaces.sql 为例:

CREATE TABLE space (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  uid TEXT NOT NULL UNIQUE,
  title TEXT NOT NULL,
  description TEXT NOT NULL DEFAULT ''
);

CREATE TABLE space_member (
  space_id INTEGER NOT NULL,
  user_id INTEGER NOT NULL,
  role TEXT NOT NULL CHECK (role IN ('ADMIN', 'USER')),
  PRIMARY KEY (space_id, user_id)
);
-- ... memo 表重建(因需扩展 visibility CHECK 约束):
  visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC','PROTECTED','PRIVATE','SPACE')) DEFAULT 'PRIVATE',
  space_id INTEGER DEFAULT NULL

SQLite 因 ALTER TABLE 能力受限,需要重建 memo 表:原样拷贝全部既有值(放置初始为 Unassigned,即 space_id 置 NULL),迁移后重建索引,并特别注意保留 sqlite_sequence 中已发出的最大自增 ID(包括已删备忘录占用的 ID),避免新备忘录 UID/ID 与历史冲突。

MySQL 侧的 03__multi_spaces.sql 则直接 ALTER TABLE memo ADD COLUMN space_id INT DEFAULT NULL,PostgreSQL 同目录提供等价 DDL。

紧随其后的 04__space_member_status.sqlspace_member 增加 status 列并回灌为 ACTIVE,且如设计所述不建数据库 CHECK:

CREATE TABLE space_member_new (
  space_id INTEGER NOT NULL,
  user_id INTEGER NOT NULL,
  status TEXT NOT NULL,
  role TEXT NOT NULL CHECK (role IN ('ADMIN', 'USER')),
  PRIMARY KEY (space_id, user_id)
);
INSERT INTO space_member_new (space_id, user_id, status, role)
SELECT space_id, user_id, 'ACTIVE', role FROM space_member;

与既有数据兼容的行为:既有备忘录保留 UID、作者、可见性、关系与永久链接,成为 Unassigned;评论行与评论可见性不做改写

查询性能方面,SQLite/MySQL 迁移均建立了 idx_memo_space_idspace_id, row_status, created_ts DESC, id DESC),与 Space Feed 的典型查询形态(按放置 + 状态 + 时间倒序分页)对齐。

事务与并发

  • Space 创建、邀请状态迁移、成员变更、放置与受众变更、评论创建、备忘录删除、Space 删除,在“部分应用会直接破坏数据”的场景下使用普通事务;
  • MySQL 与 PostgreSQL 上,创建/激活 Space 关系与“目标用户行上的用户删除”串行化,邀请创建与“Space 行上的 Space 删除”串行化,防止并发删除留下孤儿活跃成员或邀请;
  • Space 删除以与既有用户删除相同的风格直接删除已放置备忘录及其自有行,然后在提交后做既有的尽力型附件存储清理;
  • 初版引入通用事务重试、清理队列或全局并发框架。

API 形态:专用 SpaceService 与备忘录字段扩展

SpaceService 完整 RPC 一览

proto/api/v1/space_service.proto 定义了独立 Space 服务,覆盖创建、列表、获取、更新、硬删除,活跃成员读/改/删,以及独立的 Space Invitation 资源

RPC HTTP 映射 说明
CreateSpace POST /api/v1/spaces(body: space 创建 Space,调用者成为首个管理员
ListSpaces GET /api/v1/spaces 只列出调用者是其成员的 Space
GetSpace GET /api/v1/{name=spaces/*} 成员才可读
UpdateSpace PATCH /api/v1/{space.name=spaces/*} titledescription
DeleteSpace DELETE /api/v1/{name=spaces/*} 永久删除 Space 与其全部已放置备忘录,不沿关系追踪
CreateSpaceInvitation POST /api/v1/{parent=spaces/*}/invitations 邀请既有活跃用户
ListSpaceInvitations GET /api/v1/{parent=spaces/*}/invitations 某 Space 的待定邀请
ListUserSpaceInvitations GET /api/v1/{parent=users/*}/spaceInvitations 用户收到的待定邀请
GetSpaceInvitation GET /api/v1/{name=spaces/*/invitations/*} 读取单条邀请
DeleteSpaceInvitation DELETE /api/v1/{name=spaces/*/invitations/*} 管理员撤销
AcceptSpaceInvitation POST /api/v1/{name=spaces/*/invitations/*}:accept 被邀请者接受,返回 SpaceMember
DeclineSpaceInvitation POST /api/v1/{name=spaces/*/invitations/*}:decline 被邀请者拒绝
ListSpaceMembers / GetSpaceMember GET /api/v1/{parent=spaces/*}/membersGET .../members/* 成员列表/详情
UpdateSpaceMember PATCH /api/v1/{space_member.name=spaces/*/members/*} 仅支持修改 role
DeleteSpaceMember DELETE /api/v1/{name=spaces/*/members/*} 移除成员;成员删自己即退出

消息结构要点:

  • Spacenamespaces/{space})、title(必填)、description,以及两个仅输出字段 current_user_rolemember_count。通过成员授权操作返回的 Space 响应携带当前用户角色与已接受成员数;元数据型摘要(如邀请携带的摘要)使用默认角色与零计数;
  • SpaceMembernamespaces/{space}/members/{username})、userusers/{username})、roleADMIN = 1 / USER = 2);
  • SpaceInvitationnamespaces/{space}/invitations/{username})、inviteerole(接受后获得的角色),以及仅输出的 space 摘要——让被邀请者无需成员身份下的 GetSpace 权限即可理解要约内容;
  • 不存在任何直接创建活跃成员身份的操作,这是有意为之。

可见性与列表范围

  • 备忘录响应新增可选 Space 资源名字段;Visibility 增加 SPACE = 4 且不重排既有值。领域到 v1 的映射:Author→PRIVATE、Instance→PROTECTED、Public→PUBLIC、Space→SPACE
  • VISIBILITY_UNSPECIFIED 保持为输入哨兵:创建时视作 PRIVATE,显式更新时拒绝它,响应永不返回它;
  • 全局默认备忘录可见性设置仍只接受 PRIVATEPROTECTEDPUBLIC——因为它无法定位某个 Space;
  • 备忘录列表获得显式的“全部可读 / Unassigned / Space”范围;Space 范围要求活跃成员身份;既有全局与 Space Feed 默认排除评论;
  • 放置与受众变更复用既有备忘录更新机制实现原子变更;Space API 不新增任何让 ADMIN 移动、撤回或变更单条备忘录的操作。

一个耐人寻味的细节:memo_service.protoListMemosRequest 末尾声明了 reserved 7, 8; reserved "space", "unassigned";——说明早期曾设想过用请求字段表达范围,最终改为在 CEL 过滤表达式中表达。当前 CEL 过滤支持 space 字段(字符串资源名,无 Space 时为 null)与 visibility(含 SPACE 字符串),例如:

space == "spaces/team" or space == null
pinned == true && visibility == "PUBLIC"

文档同时声明:Space 身份加入 CEL 过滤 schema 的独立鉴权维度——CEL 只能收窄鉴权谓词,不能放宽。MCP 侧备忘录操作复用同一备忘录策略,Space 管理不通过 MCP 暴露。

Space UID 的分配与格式(ADR 0003)

Space 标题可变且不唯一,因此稳定的公共身份是资源名 spaces/{space UID} 中实例范围的 UID。配套 ADR docs/adr/0003-space-uid-allocation-and-format.md 规定:

  • 第一方客户端为每个新 Space 生成规范的小写 UUID v4,放入 CreateSpaceRequest.space_id;同一创建交互的重试复用同一生成值,用户也可在创建前替换为自定义 UID;
  • 该 API 字段保持可选以兼容旧客户端;为空时服务端生成规范小写 UUID v4;
  • 共享的公共资源 UID 语法(与 ADR 中定义一致):
SpaceUID     := Alphanumeric
              | Alphanumeric UIDCharacter{0,34} Alphanumeric
UIDCharacter := Alphanumeric | "-"
Alphanumeric := ASCII letter | ASCII digit

即 1 到 36 字符、首尾必须是字母数字、内部允许连字符;大小写拼写原样保留。这与 space_service.protospace_id 字段的注释 Format: ^a-zA-Z0-9?$ 完全对应,Store 层入口 store/space.goCreateSpace 也会用 base.UIDMatcher 校验后再落库。

UI 展示规则:设置面与管理子流程始终显示完整 UID 并带 Space UID 标签;其他界面仅在两个已知 Space 的(区分大小写)标题完全相同、或标题不可用时才显示 UID;紧凑场景下规范 UUID 取 8 字符前缀,短自定义 UID 全显,长自定义 UID 显示两端。已有 Space UID 保持可读、不重写。UID 冲突由实例范围唯一性约束拒绝。

源码中的安全不变式

设计文档的“安全不变式”一节要求在 SPACE 可以落库之前,存在一个共享的、备忘录本地的、fail-closed 的策略,覆盖点读、列表与计数、文件、反应、关系、通知、邮件、Webhook、分享、搜索、统计、公开 Feed 与 MCP;子资源解析其直接隶属的备忘录;应用 ADMIN 没有隐式旁路。

数据库层的鉴权谓词(在分页之前应用)形如:

PRIVATE + active authenticated author
OR PROTECTED + active authenticated caller
OR PUBLIC permitted for caller
OR SPACE + active membership in memo.space_id

server/access/memo.go 中的实现印证了这一点——对已认证调用者,SPACE 可见性要求存在一条 status = 'ACTIVE' 且角色为 ADMIN/USER 的成员关系:

(`visibility` = 'SPACE' AND EXISTS (
  SELECT 1 FROM `space_member` AS m
  WHERE m.`space_id` = memo.`space_id`
    AND m.`user_id` = ?
    AND m.`status` = 'ACTIVE'
    AND m.`role` IN ('ADMIN', 'USER')
))

同时还有一条“放置有效性”子句:visibility <> 'SPACE'space_id IS NOT NULL 且对应的 space 行存在——即缺失或无效的放置拒绝 SPACE 读取,未知可见性一律拒绝,未知关系状态、非活跃用户、待定邀请与无效成员角色都拒绝一切依赖它们的访问。

其余不变式还包括:

  • 成员身份按请求校验,或走“立即失效”的缓存状态;
  • 数据库支持行锁时,关系创建/激活与用户删除串行化,邀请创建与 Space 删除串行化;
  • 缺失或不可读的通知主题与关系端点 fail closed,不泄露部分元数据;
  • 实时刷新是一条已认证、无主体的缓存失效通道:备忘录/反应变更成功后广播 {"type":"memo.changed"},Space/邀请/成员变更成功后广播 {"type":"space.changed"}。事件不携带资源名、受众、行为人、邀请或成员数据,客户端失效相应缓存后经普通鉴权重取;SSE 不物化接收者集合、不做备忘录级鉴权(对应前端 web/src/contexts/SpaceContext.tsx 维护的当前 Space 上下文与设置面中的 Spaces 管理区);
  • 通知在呈现时鉴权;邮件与用户 Webhook 载荷在进入既有异步队列前完成鉴权,初版不取消“排队期间权限已变化”的待发投递;评论 Webhook 要求评论与上下文备忘录都可被 Webhook 属主读取;已删备忘录 Webhook 只基于作者可读的删前快照构建;
  • PRIVATEPROTECTEDSPACE 备忘录的文件使用 private, no-store 响应头,公开文件可走再验证。

备选方案与否决记录

设计文档以决策表形式留下了完整的取舍记录,这是评估该设计稳健性的最好材料:

备选方案 决策
Space 作为租户、通用 Group、嵌套文件夹或多放置标签 否决;Space 是实例内一个扁平协作上下文
由放置推导受众,或把成员身份加为第二道读取门槛 否决;备忘录受众单独定义普通读取,成员身份另行控制 Space 浏览与参与
放置关联表、嵌套备忘录名或默认 Space 否决;可空 memo.space_id 保留稳定备忘录身份与真实 Unassigned 状态
用父/根列替代关系,或继承线程放置/受众/生命周期 否决;每条评论是独立备忘录,没有“规范线程”参与鉴权
允许修改 COMMENT、要求评论共享放置、或级联单条删除 否决;评论上下文不可变且非所有权,两端各自独立
归档 Space、拒绝删非空 Space、或自动取消其备忘录 初版否决;硬删除对直接放置的备忘录有显式聚合生命周期
Space 删除时沿关系追踪 否决;关系不能把删除延伸到其他 Space 或 Unassigned 内容
成员关系结束时移动/删除其备忘录 否决;历史贡献保留至作者生命周期动作或 Space 删除
给 Space ADMIN 驱逐/变更单条备忘录的独立操作 否决;放置属于备忘录作者生命周期,Space 治理限于元数据、成员与聚合硬删
给应用 ADMIN 隐式 Space/备忘录访问 否决;审核与恢复需要独立控制平面设计
允许 Space ADMIN 直接创建活跃成员 否决;只有被邀请者能把待定要约变成成员身份
现在就加独立邀请表 本迭代否决;关系状态可放进唯一的 Space-用户槽位,刻意省略历史、过期与投递凭据,需求出现时再改
把 v1 visibility 改名为 audience 或双字段暴露 否决;领域术语是 Audience,保留一个遗留 v1 字段作为兼容表示

延后的设计与适用前提

以下事项在设计中被显式延后:邮件/外部用户邀请、邀请过期与邀请人归因、邀请历史、公开注册、超出成员护栏的账号擦除、通知投递与保留策略、应用管理员的审核与恢复、审计历史、软删除与恢复、可重试的外部对象清理、排队邮件/Webhook 的投递时取消、更宽的并发变更加固、超大 Space 的异步删除。后续工作必须保留:被邀请者同意、备忘录独立鉴权、不传播的关系、显式的 Space 聚合删除——除非本设计被重开。

对使用者与二次开发者的适用前提提示:

  • 该能力随迁移版本 0.31 生效,适用于 SQLite/MySQL/PostgreSQL 三库;升级后既有备忘录全部处于 Unassigned,评论与既有可见性不受影响;
  • API 层面 Visibility.SPACE = 4 是命名域,不可做数值大小比较;列表鉴权在数据库分页之前执行,客户端 CEL 过滤只能收窄;
  • 若你基于该 API 构建客户端,注意:ListSpaces 只返回自己是活跃成员的 Space,非成员(含待定邀请者)对 Space 元数据、成员、Feed 请求一律收到 NotFound——这是刻意的隐藏语义,而非查询条件不满足。

小结

Memos 的 Multi-Spaces 设计可以概括为一句话:在“备忘录永远属于作者”的前提下,用一个可空外键加一个唯一关系槽位,获得一个扁平、非租户化、以邀请接受为门槛的协作上下文。其工程价值体现在三个层面:模型上把放置、受众、作者、分发解耦,避免权限继承的复杂度;存储上用可空 space_id 与唯一 Space-用户槽位同时表达 Unassigned 与“邀请即成员的前态”;安全上以一份备忘录本地的 fail-closed 谓词统一所有读取面,并用“最后活跃 ADMIN”“接受者必须是受邀者本人”等少量强不变式守住治理边界。仓库内 docs/design/multi-spaces.mdproto/api/v1/space_service.protostore/space.gostore/migration/*/0.31/ 下的迁移脚本共同构成了一套可从设计到落盘逐层对照的完整证据链。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.79 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
988
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384