Memos 多空间(Multi-Spaces)设计解析:Space 数据模型、权限矩阵与 0.31 迁移全解
本文围绕 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 接受
ADMIN与USER两级成员身份; - 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 放置耦合、引入联邦问题 |
由此支撑四项选择:
- Space 是一等资源,具备显式成员关系与角色,并以“被邀请者接受”作为门槛;
- 放置、受众、作者、分发彼此独立;
- Unassigned 是一种真实存在的放置缺失状态,而非默认 Space;
- 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 之间已接受的关系,角色为
ADMIN或USER。创建者自动成为第一个ADMIN,系统没有永久 owner 概念; - 应用级
ADMIN:属于控制平面角色,不是隐式 Space 成员,也不是隐式备忘录读取者。
读访问表:v1 中 visibility 即受众
v1 API 出于兼容仍称备忘录受众为 visibility。普通身份型读访问只由下表定义(见 memo_service.proto 中的 Visibility 枚举,SPACE = 4):
| Visibility | 可读者 |
|---|---|
PRIVATE |
活跃已认证作者本人 |
PROTECTED |
活跃已认证用户 |
PUBLIC |
活跃已认证用户;实例策略允许时还包括匿名调用者 |
SPACE |
所放置 Space 的活跃成员;对 Unassigned 备忘录无效 |
由此推出几条重要规则:
- Space 放置不额外增加读取门槛:一个活跃非成员可以直接读取已放置的
PROTECTED或PUBLIC备忘录;已放置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。文档给出三条实操约束:
- 分配备忘录不改变其受众;
- 同时变更放置与受众可以是同一个原子更新;
- 把一条
SPACE备忘录移动到新 Space,要求更新掩码中同时包含space与visibility(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.sql 为 space_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_id(space_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/*} |
仅 title、description |
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/*}/members、GET .../members/* |
成员列表/详情 |
UpdateSpaceMember |
PATCH /api/v1/{space_member.name=spaces/*/members/*} |
仅支持修改 role |
DeleteSpaceMember |
DELETE /api/v1/{name=spaces/*/members/*} |
移除成员;成员删自己即退出 |
消息结构要点:
Space:name(spaces/{space})、title(必填)、description,以及两个仅输出字段current_user_role与member_count。通过成员授权操作返回的 Space 响应携带当前用户角色与已接受成员数;元数据型摘要(如邀请携带的摘要)使用默认角色与零计数;SpaceMember:name(spaces/{space}/members/{username})、user(users/{username})、role(ADMIN = 1/USER = 2);SpaceInvitation:name(spaces/{space}/invitations/{username})、invitee、role(接受后获得的角色),以及仅输出的space摘要——让被邀请者无需成员身份下的GetSpace权限即可理解要约内容;- 不存在任何直接创建活跃成员身份的操作,这是有意为之。
可见性与列表范围
- 备忘录响应新增可选 Space 资源名字段;
Visibility增加SPACE = 4且不重排既有值。领域到 v1 的映射:Author→PRIVATE、Instance→PROTECTED、Public→PUBLIC、Space→SPACE; VISIBILITY_UNSPECIFIED保持为输入哨兵:创建时视作PRIVATE,显式更新时拒绝它,响应永不返回它;- 全局默认备忘录可见性设置仍只接受
PRIVATE、PROTECTED、PUBLIC——因为它无法定位某个 Space; - 备忘录列表获得显式的“全部可读 / Unassigned / Space”范围;Space 范围要求活跃成员身份;既有全局与 Space Feed 默认排除评论;
- 放置与受众变更复用既有备忘录更新机制实现原子变更;Space API 不新增任何让
ADMIN移动、撤回或变更单条备忘录的操作。
一个耐人寻味的细节:memo_service.proto 中 ListMemosRequest 末尾声明了 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.proto 中 space_id 字段的注释 Format: ^a-zA-Z0-9?$ 完全对应,Store 层入口 store/space.go 的 CreateSpace 也会用 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 只基于作者可读的删前快照构建;
PRIVATE、PROTECTED、SPACE备忘录的文件使用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.md、proto/api/v1/space_service.proto、store/space.go 与 store/migration/*/0.31/ 下的迁移脚本共同构成了一套可从设计到落盘逐层对照的完整证据链。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00