Subjectivity 记录笔记:一个 ACL 粒度问题
(原名为 "Subjectivity owner-visibility (gate-one tightening)"——现改名,因为"owner"从一开始就是错误的框架。owner 的可见性没有任何变化:整个语料库本来就是属主的。真正会变化的是访客能否读到某条记录笔记,而这就是普通的 ACL 问题。)
结论(2026-07-16): subjectivity 与 wiki/output 是同一类东西——都走普通的语料库 ACL(角色 glob)。它多出来的唯一一根轴是
show_as_source:agent 读过之后,是否可以引用它。是两个旋钮,不是三个:
knob lives on governs role glob 角色 agent 能否读取它 show_as_source笔记 读过之后,能否引用它——subjectivity 自 017c37d23(2026-09-05)起默认为 false:此前corpus.create/corpus.update沿用了 wiki/output 那个 nil→true 的默认值(showAsSourceForSubjectivity,backend/internal/corpus/ops/corpus_write_args.go:89)下文的"owner 层级"是在 glob 之外又加了一个能不能读的旋钮——是同一根轴,所以它没有增加任何能力。已经实现过(
eacc06c7、36fced08),又被回退(e9241618)。它指出的缺口是真实的;答案是 glob 的粒度问题,不是要一个新概念——而这个答案里"按 code"的那一半同一天就上线了,见"该怎么做"。
触发事件: subjectivity/ 里出现了第一篇记录类笔记——简历:真实姓名、教育经历、雇主、城市。PII 进入了一个 StandMeet 会提供给访客的语料库。
已实现的两道门 (这部分成立——是准确的描述)
- 门一——进入上下文。 语料库的准入是角色级别的路径 glob 正向白名单(
role_corpus_uris、path_acl.go里的MatchesAnyCorpusGlob):命中即通过,raw://**硬编码拒绝,空集即全拒,签发时冻结进 RoleSnapshot。access code 只能收窄它所承袭的角色(纯 AND 的层级关系,见capability-acl-hierarchy.md);code 级别的语料库收窄——写这篇时作为决定 A.2 被推迟——已于 2026-07-16 以code_corpus_denials上线(6395374b0后端,9a08ab6a2role + code 上的 GUI;backend/db/schema.sql:1043)。 - 门二——呈现给访客。 subjectivity 是一个私有层级:读取只用来给 agent 的语气打底,除非笔记自己用
show_as_source: true开了口子,否则永不引用给访客(由服务端按笔记逐条判定,权威实现在subjectivity_cite.go)。
缺口(真实,尚未关闭): 门二藏起来的是出处,不是信息本身——读过简历的 agent,仍然可以在回答里直接说出雇主名字。而且一次 subjectivity://** 授权,会把记录类笔记和立场类笔记一起放进来。
由此得出的错误结论:"门一需要一个笔记级别的排除机制"。门一手上早就有这个手段——不授予这个 glob 就是了。简历暴露的是一个粒度问题(subjectivity://** 太粗,而扁平命名空间又提供不出更细的东西可授予),不是缺了一道门。
方案:笔记级别的 owner 层级 (已否决——留档)
在笔记 frontmatter 里加 visibility: owner;同步时带进数据库;服务端把这类笔记从每一个访客会话的可达集合里剔除,不管角色 glob 怎么写:
readable(note) = MatchesAnyCorpusGlob(role_globs, uri) <- existing gate 1 (role, frozen)
∧ NOT note.owner_only <- new AND term (note, live, owner-unilateral)
为什么它是错的,按反对意见出现的顺序:
visibility本来就有两层含义,而且它们不是一回事。visibility是writings上的public | private,说的是前端怎么渲染(private → 渲染出locked_body的预览)。它从来不是访问控制——visibility.go里白纸黑字写着。给它加上owner,就让同一个键既要管渲染模式,又要管准入规则:F-L-8又犯了一次——一个 flag,两个正交的概念。D.1 当时就点出了这个冲突,却还是倾向"复用"。- "owner"没有所指对象。 它标记一篇笔记对 owner 可见——可整个语料库按定义就对 owner 可见。它标注的是一个常量。真正会变化的只有访客的可达性,所以错的是框架本身,不只是措辞:一个诚实的名字,主语应该是
visitor。 - 所以它是多余的。 一旦这根轴是"访客可达性",那就正是角色 glob 已经在管的那根轴。同一根轴上装两个旋钮,不是一个功能。
- 简历要的不是隐藏。 它要的是定向开放:默认不公开,再对该看到它的角色/code 逐一授权——招聘者的 loop 能看到,路人看不到。这正是普通 ACL 存在的意义。
该怎么做
简历是一个普通的 ACL 问题:不公开,按角色/code 逐一开放。
- 现状: 该看到它的角色会授予一个覆盖它的 glob;不该看到的角色就不授予。但 subjectivity 扁平的命名空间,把真实的选择压缩成了
subjectivity://**(全部)或者逐条枚举每一篇笔记(也就是下文被否决的"纯靠自觉")。今天诚实的状态是:subjectivity 只能要么全给,要么全不给。 - 这篇笔记本该问的问题: 要授予部分 subjectivity,就得有个东西可以拿来授予——一个子路径、一套命名约定,或者逐笔记枚举。这与"subjectivity 保持扁平"的 vault 规则相冲突。被否决方案 #2 拒绝"让文件系统决定语义",转头又发明了一套机制来绕开这条约束。如果 ACL 的最小单位是路径 glob,那么扁平命名空间本身就是一个"不要粒度"的决定——所以要么扁平规则让步,要么 subjectivity 就该是有意识地选择全有全无。
- 按 code 定向是另外那一半——2026-07-16 已上线,形状正是下面"重新打开"所论证的那个。
code_corpus_denials(6395374b0;e2e/test/code-corpus-narrowing.spec.ts)是一个只做减法的层:readable = role 的 glob 命中 AND 没被本码的 deny 命中,集合求交、无序,所以 A.2 担心的顺序敏感性根本不会出现。单位是 glob,跟 role 的正列表是同一种语言:subjectivity://cv从一张邀约上减掉一篇笔记,subjectivity://**把整个体裁从这张码上收回(schema.sql:1034-1042)。仍然全有全无的是扁平命名空间在 role 这一侧。
被否决的方案 (原文如此;#2 的推理现在反而是最有意思的部分)
- 在 glob 列表里加拒绝行(先匹配者胜) ——正是 A.2 推迟掉的那种顺序敏感式收窄;会改变门一的代数和角色编辑器的 UX;而且真正要保护的单位是"这篇笔记是一条记录",不是一种路径形状。
- 给记录类笔记单开一个子路径或独立体裁 ——按 vault 规则 subjectivity 必须保持扁平(分组只存在于索引里);为了满足 ACL 而重组内容,等于让文件系统来决定语义。(← 重新打开这一条:ACL 的单位本来就是路径。)
- 纯靠自觉(不用通配符,逐条枚举允许项) ——依赖 owner 永远不偷懒;只要有任何通配符存在,新的记录类笔记默认就会泄露。作为习惯可以接受,作为机制是失败的。
悬而未决的决定点 (已作废——留下来展示当初错在哪)
- D.1 字段命名:复用
visibility:还是新开一个owner_only: true。倾向复用,靠体裁区分歧义。两个都错——这套机制本就不该存在。留下的教训:status: seed笔记里的"倾向 X"不是一个决定。 但它当时被当成了决定,并被直接上线了。 - D.2 范围 / D.3 Meili / D.4 默认方向:作废。
否决之后还站得住的部分
- 缺口是真实的,关了一半。
subjectivity://**加一篇记录类笔记 = 该角色就能读到这份记录;一张码现在可以把它减掉(code_corpus_denials),但在 role 这一侧,今天的安全性仍然来自手工收窄授权,不是结构性的保证。 - 一个索引过期的隐患,值得记下来,供未来任何笔记级 ACL 事实参考。 语料库搜索里走 Meili 的那条腿,信任的是该标志位的索引副本。索引是尽力而为、异步写入的,所以它手上的副本可能是过期的、缺失的,或者是孤儿数据——而这三种情况读出来都是放行。任何笔记级的 ACL 事实都必须回表核对(数据库才是权威,索引只是候选来源),否则就会失效开放。线上实测发现:
corpus_read正确地扣下了这篇笔记,而corpus_search却把它整篇返回——路径、标题、雇主都出现在摘要里。 - 原始笔记的结论句在写下的那一刻就已经是错的:
"目前没有任何角色 glob 会放行——审计用的subjectivity/cv.md"subj-verify角色授予了subjectivity://**,而它恰好放行了这篇笔记。之所以没有泄露,只是因为cv.md晚于已同步的 vault 副本而已。安全性来自简历还没被同步,而不是来自 glob 本身——这正是这篇笔记自己那句"只要有任何通配符存在,新的记录类笔记默认就会泄露"的现场应验。