Memos 领域术语详解:基于 CONTEXT.md 的 Memo、Space 与集合作用域身份模型
CONTEXT.md 是 Memos(开源、自托管、以 Markdown 为核心的快速记事工具)的领域语言规范,它用一组精确词条定义了系统中各类实体"到底是什么、不是什么",并为每个词条标注了应避免的同义混用(_Avoid_)。理解这些术语能帮你在使用 Memos API、阅读其源码或二次开发时,准确区分内部 ID、公开 UID、资源名与显示标题这四类极易混淆的标识符。读完本文,你将掌握 Memos 中 Memo 与 Space 的双层身份设计、邀请/成员两种状态模型,以及 Space-aware 集合查询中 all 与归档集合的作用域规则,并能对照仓库源码逐一验证这些定义。
Memo 的三重身份:Memo ID、Memo UID 与资源名
CONTEXT.md 对 Memo 定义了三个身份层面的术语,这是整个领域模型中最容易混淆的一组概念:
| 术语 | 定义 | 应避免的混用 |
|---|---|---|
| Memo ID | 备忘录的稳定内部标识(stable internal identity),当备忘录的公开标识符发生变化时,Memo ID 不会改变 | Memo UID、slug |
| Memo UID | 用于备忘录资源名和 URL 的唯一公开标识符;创建备忘录时可由用户自定义,也可由系统自动生成 | Memo ID、database ID |
| Memo 资源名 | 备忘录在 API 中的身份,形式为 memos/{memo UID} |
Memo ID、content ID |
这个设计的关键点是:内部 ID 与公开标识解耦。Memo UID 可以变化(例如用户自定义了一个带语义的 UID),而数据库层面的 Memo ID 始终保持稳定,因此所有内部外键关系不会因公开标识变更而失效。
在仓库中可以直接验证这一分层。所有公开资源的 UID 都受同一个文法约束,定义在 resource_name.go 中:
var (
UIDMatcher = regexp.MustCompile(`^a-zA-Z0-9?$`)
)
即 UID 为 1 到 36 个字符,首尾必须是 ASCII 字母或数字,中间可含连字符。而 memos/{memo} 这一资源名形式则固化在 API 协议里,见 memo_service.proto 中 Memo 消息的资源声明与生成代码 memo_service.pb.go 中反复出现的注释:
// Format: memos/{memo}, where memo is the user-defined UID.
注意这里明确写着 memo 是 "user-defined UID",印证了 CONTEXT.md 中"Memo UID 可由用户自定义"的定义;API 请求中传递的永远是 UID,数据库内部 ID 不出现在协议表面。
Memo reaction:只绑定备忘录,不是通用反应机制
CONTEXT.md 对 Memo reaction 的定义是:"挂载在恰好一条备忘录上的响应。反应不是一种面向其他类型内容的通用机制。"(Avoid: Content reaction, generic reaction, reaction target)
这条"否定式"定义在实现中体现得非常干净:反应的 API 路径被硬编码在 Memo 资源名之下,见 memo_service.proto:
// Reaction is a reaction attached to a memo.
// pattern: "memos/{memo}/reactions/{reaction}"
对应的 HTTP 端点形如 GET /api/v1/{name=memos/*}/reactions 与 POST /api/v1/{name=memos/*}/reactions(Upsert),没有任何针对其他内容类型的抽象层。换言之,如果你想给其他实体加"表情回应",Memos 不提供现成机制——这个术语条目实际上是在划定架构边界,防止后续设计者把 reaction 泛化成通用组件。
Space 的身份三元组:Space ID、Space UID 与资源名
CONTEXT.md 为 Space(协作空间)定义了三个平行的身份术语:
- Space:实例作用域内的协作边界(instance-scoped collaboration boundary),面向已接受的成员与备忘录的放置(memo placement)。它不是租户(tenant)、文件夹(folder),也不是应用级的授权角色。
- Space ID:Space 的稳定内部标识,不对外暴露为公开标识。(Avoid: Space UID, Space title)
- Space UID:创建时分配的、不可变的、全实例唯一的公开标识符;可由用户定义或自动生成。
- Space 资源名:API 身份,形式为
spaces/{space UID}。 - Space title:可修改、不唯一的显示标签。
"Space ID 不对外暴露、UID 才是公开身份"与 Memo 的 ID/UID 分层完全同构。这个模型在 space.go 的存储层结构体中一目了然:
// Space groups memos and memberships.
type Space struct {
ID int32 // 内部稳定标识,即 Space ID
UID string // 不可变的公开标识,即 Space UID
Title string // 可修改的显示标签,即 Space title
Description string
CurrentUserRole SpaceMemberRole
MemberCount int32
}
创建时的校验逻辑同样印证了术语定义——UID 走 base.UIDMatcher 文法,Title 仅要求非空(即可修改、不唯一),见 space.go 的 CreateSpace:
if !base.UIDMatcher.MatchString(create.UID) {
return nil, errors.New("invalid uid")
}
if strings.TrimSpace(create.Title) == "" {
return nil, errors.New("space title is required")
}
Space UID 的分配规则(ADR 0003 补充)
关于 Space UID "可由用户定义或自动生成"这一条,仓库中的 ADR 文档 0003-space-uid-allocation-and-format.md 给出了完整的分配决策:第一方客户端会为每个新 Space 生成规范的小写 UUID v4 并放入 CreateSpaceRequest.space_id 字段;该字段为可选,留空时服务端生成 UUID v4;提供的自定义值则复用同一套公开资源 UID 文法(1–36 字符,内部允许连续连字符,纯数字亦合法,大小写保留)。UID 碰撞由既有的全实例唯一约束拒绝。这一决策还解释了为什么 CONTEXT.md 特别强调 Space UID 是 immutable(不可变)——它一旦写入资源名 spaces/{space UID} 就构成 API 身份,重写会造成所有客户端缓存的引用失效,因此文档明确"现有 Space UID 保持可读且不被重写"。
对应的请求消息定义在 space_service.proto:
message CreateSpaceRequest {
// Required. The space to create.
Space space = 1 [(google.api.field_behavior) = REQUIRED];
// Optional. The space UID to use for this space.
// If empty, a canonical UUID v4 will be generated.
// Format: ^a-zA-Z0-9?$
string space_id = 2 [(google.api.field_behavior) = OPTIONAL];
}
Space 邀请与成员:两种状态、一种关系
CONTEXT.md 对 Space 的关系模型定义了最后两个术语:
- Space invitation(邀请):向"已存在的活跃 Memos 用户"发出的待处理加入提议,并指定一个 Space 角色;在被接受之前,它不赋予任何成员资格或 Space 访问权限。(Avoid: Pending membership, direct add)
- Space membership(成员关系):一个活跃 Memos 用户与一个 Space 之间已被接受的关系,携带
ADMIN或USER角色。(Avoid: Invitation, application role)
这两个术语的区别在存储层被建模为明确的状态机,见 space.go:
// SpaceMemberStatus is the state of a user's relationship with a space.
type SpaceMemberStatus string
const (
// SpaceMemberStatusInvited represents an invitation that the user has not accepted.
SpaceMemberStatusInvited SpaceMemberStatus = "INVITED"
// SpaceMemberStatusActive represents a membership that the user has accepted.
SpaceMemberStatusActive SpaceMemberStatus = "ACTIVE"
)
角色定义与术语条目逐字对应:
const (
// SpaceMemberRoleAdmin can manage the space and its membership.
SpaceMemberRoleAdmin SpaceMemberRole = "ADMIN"
// SpaceMemberRoleUser is a regular space member.
SpaceMemberRoleUser SpaceMemberRole = "USER"
)
而"邀请在未被接受前不赋予任何访问"这一语义,则由 API 与存储层共同保证。API 侧,邀请是独立的子资源(spaces/{space}/invitations/{username}),接受后返回的是 SpaceMember 而非 SpaceInvitation,见 space_service.proto:
// AcceptSpaceInvitation accepts a pending invitation and creates a membership.
rpc AcceptSpaceInvitation(AceptSpaceInvitationRequest) returns (SpaceMember) { ... }
存储层进一步收紧了操作主体:AcceptSpaceInvitation 与 DeclineSpaceInvitation 都校验 actorUserID 必须等于被邀请用户本身,否则返回 ErrSpacePermissionDenied;CreateSpaceInvitation 的注释也明确"它从不创建活跃成员关系,只有被邀请人接受后才能创建",见 space.go:
func (s *Store) AcceptSpaceInvitation(ctx context.Context, accept *AcceptSpaceInvitation, actorUserID int32) (*SpaceMember, error) {
...
if accept.UserID != actorUserID {
return nil, ErrSpacePermissionDenied
}
return s.driver.AcceptSpaceInvitation(ctx, accept, actorUserID)
}
这也解释了术语条目为何要求"被邀请者必须是已存在的活跃用户"——store 包中还专门定义了 ErrSpaceMemberNotActive("space member user is not active")错误用于拦截此类目标。
Memo collection scope:all 与单一 Space 二选一
Memo collection scope 是 CONTEXT.md 中最精细的一个条目,它定义了 Space-aware 的备忘录(及其派生资源)集合查询中的一个维度,取值只有两种:
all——不附加任何 Space 谓词,即查询整个实例;- 某一个精确的 Space。
条目同时澄清了一个容易误解的点:"未分配的 Memo(unassigned)仍然属于 all;unassigned 是一种放置状态(placement),而不是一种集合作用域(collection scope)。"也就是说,客户端不允许、也不需要为"未放进任何 Space 的备忘录"单独发起一个查询——它们天然被 all 覆盖。
在 API 协议中,这个作用域通过 ListMemosRequest 的 space 字段表达,且协议注释给出的示例正是 space == "spaces/team" 或 space == null 的二值形式,见 memo_service.proto:
// Optional. The space in which this memo is placed. Format: spaces/{space}.
optional string space = 19
注释中列举的过滤条件形式(space (string resource name or null when the memo has no space)、space == "spaces/team" or space == null)与"一个精确 Space 或 null(无 Space)"的语义直接对应,验证了术语条目中"exact Space"的取值约束。
Archived memo collection:用户级、跨放置状态、独立于 Space 作用域
最后一个术语 Archived memo collection(归档备忘录集合) 定义为:"已登录用户处于归档生命周期状态的备忘录,跨越所有放置状态(across all placements)。它是用户级的(user-level),独立于当前的 Space collection scope。"(Avoid: Space archive, Space-scoped archive)
这句话拆成三个可验证的技术断言:
- 按用户过滤——只列当前登录者自己的备忘录;
- 按生命周期状态过滤——备忘录的
State字段取ARCHIVED; - 不带 Space 谓词——即使调用方当前正处于某个具体 Space 上下文中,归档集合也返回该用户全部放置位置的归档备忘录。
状态枚举定义在 common.proto 的 State(生成代码 common.pb.go 中可见 NORMAL 与 ARCHIVED 两个取值),而查询入口是 memo_service.go 中的归档分支:
if request.State == v1pb.State_ARCHIVED {
...
}
生成代码中该字段的注释恰好是术语定义的复述,见 memo_service.pb.go:
// Default to `NORMAL`. Set to `ARCHIVED` to list archived memos.
此外,归档状态的访问控制在 access/memo.go 中有专门语义——MemoReadDenialNotFound 用于"隐藏缺失的、归档的及无效状态的备忘录",而 access/memo_test.go 中的测试用例明确验证了一条关键行为:"an archived SPACE memo still requires active membership"(一条被归档的 Space 内备忘录在被访问方已失去活跃成员资格时同样不可见)。这说明"归档集合独立于 Space 作用域"是指查询维度上的独立,而权限校验仍按备忘录原属 Space 的成员关系执行。
小结:术语表作为架构契约
通读 CONTEXT.md 可以看到,它并非普通的产品词汇表,而是一份与代码强耦合的架构契约,每个条目都能在仓库中找到对应的实现证据:
| 术语 | 对应的实现证据 |
|---|---|
| Memo ID / Memo UID / 资源名 | resource_name.go 的 UIDMatcher、memo_service.proto 的 memos/{memo} 资源模式 |
| Memo reaction 仅限备忘录 | memo_service.proto 中 memos/{memo}/reactions/{reaction} 的硬编码路径 |
| Space ID / UID / title 三元组 | space.go 的 Space 结构体与 CreateSpace 校验 |
| Space UID 分配 | ADR 0003、space_service.proto 的 CreateSpaceRequest.space_id |
| 邀请 vs 成员 | space.go 的 SpaceMemberStatus、接受/拒绝操作对 actor 的校验 |
collection scope 的 all / 精确 Space |
memo_service.proto 的 space 过滤字段 |
| 用户级归档集合 | common.proto 的 State_ARCHIVED、memo_service.go 的归档查询分支 |
对于参与 Memos 开发或扩展其 API 的工程师,实践建议是:在新增功能前先对照 CONTEXT.md(以及 docs/glossary.md 中用户与用户名、标签部分的更大领域词汇表)确认自己使用的词汇落在已有词条定义内;当实现产生新的概念时,按同款的"定义 + _Avoid_"格式补充词条,可以显著减少 ID、UID、title、resource name 混用带来的接口歧义。
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