diff --git a/docs/decisions/0001-ontology-import-and-governance.md b/docs/decisions/0001-ontology-import-and-governance.md
index c63cf84d..1d5f5dd4 100644
--- a/docs/decisions/0001-ontology-import-and-governance.md
+++ b/docs/decisions/0001-ontology-import-and-governance.md
@@ -1,581 +1,265 @@
-# 0001 · 本体导入与治理路线
-
-- **状态**:进行中 · P0 / P1 / P2 / P2c 全部建成;P3 的「按预算切换」已落地(部署级 `ontology_prompt_budget`,缺省 24,000 字符,超了按块检索),P3a 建成但**只能手动触发**、没有类数阈值这根轴;P3b 两条腿都建成但形态与本文不同(原词落 `fact_evidence.proposed_predicate`,映射由 `predicate_match` + [0003](0003-ontology-growth-loop.md) 的采纳回路承担);**P4a 已建成,P4b / P4c 仍待做**;**P5 已由 [0002](0002-reasoning-engine.md) 落地,`utopia-reason` 不再是空壳**;判据 2 关于「参数顺序」的那一半已被 [0012](0012-the-ontology-is-a-contract-not-a-suggestion.md) 推翻。2026-09-02 对照代码复核,修订就地
-- **成文**:2026-08-27 / 28,随调研持续修订(约定见 [README](README.md))
-
-> 理由比清单重要——清单会过期,理由决定以后遇到新情况怎么判。所以本文把「贯穿性判据」放在分阶段之前,且推翻过的判断就地留痕(见 P1、P3 的修订记录)。
-
-## 为什么是这条线
-
-产品的差异化是"时间"与"可信",而这两样的上游都是**本体**:抽取器按本体决定抽什么、归成什么类;时态引擎按本体的 `functional` 决定什么算矛盾;消解按类型决定谁能和谁是同一个。本体不对,下游全歪,而且歪得沉默。
-
-企业已经有自己的本体(FIBO、行业标准、Protégé 自建),让他们在界面上一条条重建不现实。所以这条线的终点是:**把企业本体导进来,让抽取按他们的词汇工作,并且治理过程本身能沉淀。**
-
----
-
-## 已验证的事实(2026-08-28,真实语料测得)
-
-用公开企业新闻稿建了 `Industry Corpus` 库(NVIDIA 博客 18 篇 + 微软新闻室 10 篇,RSS 摄入):
-
-| 指标 | 数值 |
-|---|---|
-| 文档 / 分块 | 28 / 181 |
-| 实体 / 事实 | 917 / 922 |
-| **实体密度** | **5.07 个 / 分块** |
-| 消解:自动合并 / 待人工 | 10 对 / 5 对 |
-| 攒批裁决任务数 | 22(处理 917 实体的消解对) |
-| 图谱总览查询 | 103 ms |
-| as-of 查询 | 107 ms |
-| 实体变更历史(NVIDIA 122 事件) | 16 ms |
-| 当前 9 类本体在提示词中的体积 | 466 字符 |
-
-**跨文档消解成立**:NVIDIA 聚合了 16 篇文档的 118 条事实,没有裂成同名副本。
-
-**已修的三个缺陷**(分支见文末):
-
-1. HTTP 客户端不发 User-Agent → 维基百科等站点 403,URL/RSS 对一大片真实网站直接失效
-2. HTML 提取全文档遍历 → 一篇维基条目 60 个分块,首块整块是侧栏菜单、末块是版权声明
-3. `part_of` 被标成 `functional` → 28 篇新闻稿积压 59 条假冲突("属于 Microsoft Learn"与"属于 Microsoft"并存不是矛盾)
-
-**未解决的质量问题**:Product 类占 40%(368 个),抽样约四分之一是泛化短语("row power center"、"financial databases")。补救手段是给类写描述(description 进抽取提示词)。
-
-> **原文这里写「目前只能整库重抽,因为实体不可单独编辑」——P0 建成后不再成立。**
-> 今天有三条更细的路:实体面板直改(受 P4a 的 `type_source` 保护)、类型消解批量精化、
-> 本体长出新类后的 `adopt_proposed_types` 认领。整库重抽不再是唯一手段。
-
----
-
-## 贯穿性判据
-
-以后遇到新构造、新特性,用这几条判,不要重新辩论:
-
-1. **不是"OWL 有没有",是"我们有没有机器消费它"**——但要加时间维度:**丢弃不可逆,保存近乎免费**。所以原文一律保真,投影只覆盖当下用得上的。推论:**保留了原文,投影就不必语义完整**——简化是 UI/提示词层的取舍,不是数据损失。
-2. **本体是引导,不是执法**。声明可能是错的(`part_of` 就是),所以本体应当影响提示词与候选排序,而不是驱动丢弃、覆盖、自动改写。错误的引导模型能靠原文覆盖;错误的执法会系统性地毁数据。
- > **修订(2026-09-02,据 [0012](0012-the-ontology-is-a-contract-not-a-suggestion.md))**:这条要拆成两半。**哪些类型能参与**仍是引导——本体可能写错,原文说西雅图就写西雅图。**参数顺序**改成执法:它不是关于世界的断言,是这个 key 的编码约定,原文从来没有「说了别的方向」。主语违反 domain 而宾语符合时按签名对调,留 `direction_corrected` 痕迹;对调也不合法则丢掉谓词、保留主宾与证据。留痕的自动动作不属于本条反对的那一类。实测五轮:违反率 57% → 4%,真·反向 39 → 0。
-3. **提示词大小必须与本体规模脱钩**。把 N 个类塞进每个分块的提示词,成本是 O(分块 × 本体),而且模型从 800 个选项里挑反而更差。规模问题用检索解,不用"少列几个"解。
-4. **同名不能传递任何结论**。判定为同一实体 → 本来就一个类型,无所谓传递;判定为不同 → 名字正是我们刻意不信任的信号(两个张伟)。能携带信息的是上下文。
-5. **不确定性浮到人面前**,不要悄悄猜。分层阈值 + 灰区进 Review,与消解的"宁分勿合"同源。
-6. **治理经验固化进 schema,不要固化成隐形规则**。改一次类描述是可见、可评审、进审计台账的;从点击里长出来的自动规则半年后没人知道为什么。
-7. **唯一 ≠ 身份**。`key` 有 `UNIQUE (kb_id, key)`,但它派生自会变的东西(label / 局部名),而身份要求跨时间稳定。只要两个不同的全局事物可能想要同一个 key,key 就必须附带"来自何处"才能当标识符——那就是 IRI。另:**IRI 是名字不是地址**,绝大多数不可访问,不要去抓取,也不要把 http/https、末尾斜杠"规范化"成同一个。
-
----
-
-## 分阶段
-
-### P0 · 实体可编辑 —— 地基,且现在就在流血
-
-**问题**:实体是只读的。`/kbs/{id}/entities/{entity_id}` 只有 GET,store 层唯一的 `UPDATE entities SET type_id` 在 `resolution.rs:388`(合并时自动升格),前端面板没有任何编辑入口。类型判错、名字抽歪,只能整库重抽这把大锤。
-
-**做**
-- `PATCH /kbs/{id}/entities/{entity_id}`,Editor 权限,可改 `type_id` / `canonical_name`
-- 落审计台账:`entity.retyped` / `entity.renamed`,detail 带前后值快照(沿用决策台账的自包含快照约定)
-- 前端:实体面板头部加编辑入口
-
-> **修订记录**:初稿写「`entities` 上有唯一索引,改名会撞 409」。**两处都错**——`entities_kb_type_name_idx` 是**普通索引不是唯一索引**,而且同类同名的重复**现在就存在**(`General` 库「张伟」×2、「rust compiler」×2)。更要紧的是:不该加那个约束。
-
-**碰撞是提示,不是拒绝**:「张伟」×2 正是"宁分勿合"的设计产物——两个不同的人可以同名同类,消解的整个灰区逻辑依赖于能把他们分开存放。所以改名/改类型撞上同名时:**查出来、提示"已有同名实体,是否合并?"、但允许继续**。加唯一约束会砸掉消解的地基,报 409 会让用户无法录入第二个张伟。
-
-**动机数据**:`General` 库里跨类型同名的——"rust" **5 个**、"java" 3 个、"c++" 3 个。同一个东西被分进五个类,今天无法收拾。
-
-**验收**:能把 "row power center" 从 Product 改成 Concept,改动出现在审计台账,图谱与本体页的计数同步。
-
----
-
-### P1 · 停止静默丢弃 —— 小,但现在就在流血
-
-> **修订记录**:这一格原本是"关系的 range 校验",理由是"对现有语料立刻生效"。**那句是错的**——查证后:23 个关系里只有 2 个有 domain(就是那两个属性),**range 一条都没有**,因为 UI 里没有这一项、没有任何东西会写它。range 数据只可能来自 OWL 导入,所以它不可能独立于 P2 生效。range 的正确位置见 P2(导入并保存)与 P3(作为信号之一消费)。
-
-**真正现在就在流血的**:抽取器把事实**静默丢弃**——抽出来了、被挡掉、不留任何痕迹,用户看不见,只是图里少了东西。与"账本 append-only、每条事实都有证据、不确定性浮到人面前"三条原则直接冲突。
-
-**范围比最初判断的大**(2026-08-28 逐行核过 `extraction.rs`)。属性路径不是一个 `continue`,是四个真丢弃:
-
-| 行 | 条件 | 性质 |
-|---|---|---|
-| 255 | 主语未在 `entities` 中声明 | 真丢弃 |
-| 257 | `domain_type_id` 为 NULL | **不可达**——`ontology.rs:197` 强制 attribute 必有 domain,`update_relation_type` 又不许改 kind/domain(`ontology.rs:290`)。且无 domain 的属性连提示词都进不去(`extraction.rs:122` 的 `r.domain_type_id?`)。防御性代码,不需要信号 |
-| 261 | domain 不匹配 | 真丢弃(最初只点了这一条) |
-| 269 | 既无 `value` 也无 `object` | 真丢弃 |
-| 274 | datatype 归一化失败 | 有 `debug!` 日志,但用户不可见 |
-
-关系路径另有两条值得记:`confidence < 0.6`(第 234 行,设计阈值,但同样不可见——用户无从知道"抽到了但不够自信")与 `related_to` 不在本体时**整条事实消失**(第 380 行,删掉默认关系就会踩到)。〔**后者已不可能发生**:`related_to` 整个删掉,落不上就是 `predicate_id = NULL`,见 [0010](0010-no-relation-is-no-relation.md)。〕
-
-**顺带发现一个不一致**:关系路径上主宾未声明时会**兜底按 `concept` 消解**(332-362 行),属性路径上同样的缺失却直接丢(255 行)。同一个原因,一边宽容一边静默丢弃。要么两边都兜底,要么两边都记信号——不能一边一个样。〔**已消除**:`concept` 兜底类随 [0009](0009-no-type-is-a-type.md) 删掉,两条路径现在都记 `subject_not_declared`。丢弃原因码今天有 12 个(`extraction_drops::reason`),含 `truncated_reply`、`malformed_item`、`not_an_entity_name`、`direction_corrected`,比本节设想的多一倍。〕
-
-**做**
-- 新表 `extraction_drops (kb_id, document_id, reason, detail, count, example, updated_at)`,PK `(kb_id, document_id, reason, detail)`,抽取开始时按 document 清空
-- 不复用 `ontology_misses`:那张表语义是"你的本体缺这些"(面向本体演进),丢弃是"这些事实没落地"(面向数据完整性),两者的读者和动作都不同
-- 按 document 归集顺带修好生命周期:`ontology_misses` 只在整库 rebuild 时清(`graph.rs:689`),来源级重抽不清,会攒陈旧条目。新表按 document 清则天然正确
-
-**验收**:故意给 Person 专属属性喂一个 Organization 主语,Library 的文档行能看到"3 条事实未落地",点开有原因与样例,事实不再无声消失。
-
----
-
-### P2 · OWL 导入
-
-**架构分三层,关键在第一层**
-
-1. **原文保真**:导入的本体文件原样进 blob store(内容寻址那套摄入管道在用),`ontology_imports` 记录 (kb_id, blob sha, 文件名, 时间, 投影版本)。这一步不理解语义,只负责不丢。
-2. **投影**:把今天能消费的子集映射进 `entity_types` / `relation_types`。**是可重跑的推导,不是一次性转换。**
-3. **重投影**:推理机上线或我们补上新校验时重跑,用户什么都不用做。
-
-这样"我们表达不了"从**能力缺口**降级成**投影暂未覆盖**。
-
-**映射表**
-
-| OWL / RDFS | Utopia | 备注 |
-|---|---|---|
-| `owl:Class` | entity_type | |
-| `rdfs:subClassOf` | **多父**(见下) | |
-| `rdfs:label` | `label` | 优先 `@en`/`@zh` |
-| **`rdfs:comment`** | **`description`** | 承重:进抽取提示词,也是 P3 检索的匹配依据 |
-| `owl:ObjectProperty` | relation_type (`kind=relation`) | |
-| `owl:DatatypeProperty` | relation_type (`kind=attribute`) | |
-| `rdfs:domain` | `relation_type_domains`(多值关联表) | 见下"多值域" |
-| `rdfs:range` | `relation_type_ranges`(多值关联表)/ `datatype` | 同上;**只存不强制**,消费者是 P3/P5 |
-| `owl:FunctionalProperty` | `functional` | max-cardinality-1,时态引擎的命根子 |
-| `owl:InverseFunctionalProperty` | `inverse_functional` | |
-| IRI | 新增 `iri` 列 | 见下"IRI 与 key 的分工" |
-| 其余公理 | **保留在原文,报告为"暂未投影"** | 不是"已跳过" |
-
-**IRI 与 key 的分工**:`key` 有硬约束——只允许 `[a-z0-9_]`、最长 40 字符(`ontology.rs:77`),而且它是**给模型读写的令牌**:提示词列的是 key,模型返回的 `type` 是 key,`type_ids` 按 key 查。IRI 塞不进去(冒号斜杠井号全非法),放宽约束的话提示词变成 800 行 URL,还要模型逐字复现——截断或幻觉一个字符就匹配失败。
-
-所以:**IRI 是全局身份(权威),key 是模型标签**。key 从 IRI 派生(命名空间前缀 + 局部名,snake_case,冲突加后缀),短而稳定。存储:一个可空 TEXT 列 + `UNIQUE (kb_id, iri) WHERE iri IS NOT NULL`;手工建的类为 NULL,无负担。
-
-**为什么 key 唯一了还要它**——重导入是本体功能上线就会撞到的第一件事:
-
-```
-v1: http://acme.com/hr#Employee rdfs:label "Employee" → key = employee
-v2: http://acme.com/hr#Employee rdfs:label "Staff Member" → key = staff_member ← 同一个类
-```
-
-没有 IRI 只能二选一,都错:建新类 `staff_member` 把 `employee` 变孤儿(实体全挂在上面),或按 label 匹配(label 正是刚变的那个)。有 IRI 就是一次 `WHERE iri = $1`,改名字,实体不动。
-
-想「那 key 就从局部名派生」也撑不住:`foaf:Person` 与 `acme:Person` 都要 `person`,谁拿 `_2` 后缀取决于导入顺序,下次重导入分不出 `person_2` 是谁的——除非记下它来自哪个 IRI,那就是 IRI 列绕一圈重新发明。另两个用途:**溯源**(上游类被手改后重导入要调和,得先知道它是上游的)与 **P5 导出**(key 反推不出命名空间,有损)。
-
-**多值域(domain / range)**:OWL 里并集是常态(`works_at` 的值域可能是 `Organization ∪ Project`),单列表达不了,用关联表存,判定走子类 DAG。
-
-导入的语义陷阱:RDFS 中同一属性的**多条 `rdfs:range` 是交集**("必须同时是两者"),不是并集——极常见的建模错误,但规范如此。`owl:unionOf` → 直接进表;多条独立 `rdfs:range` → 一者是另一者的子类则取更具体的,互不包含则**报告"暂未投影",不猜**。
-
-**domain/range 的正确用法是提示词里的类型签名,不是闸门**
-
-关系在提示词里从 `- works_at (works at): 描述` 变成 `- works_at (works at): Person → Organization。描述`。这是**给模型的类型签名**,在源头减少 "Alice works_at 西雅图" 这类三元组,而不是等错了再修——事后校验面对的是既成事实(丢掉可惜、留着是脏数据),签名是在生成那一刻掰正。
-
-**是引导不是强制**:本体写错时模型看到原文说了别的仍可覆盖;强制闸门则会系统性丢数据(`part_of` 烧我们的正是这种方式)。
-
-大本体下(P3 规模)它换位置继续有用:预测谓词时,**domain/range 与实际主宾类型匹配的候选关系排名更高**,作为检索排序信号。
-
-三件事的必要性不同:**存**(是,P5 的正经输入,成本在解析器里本来就要写)、**进提示词当签名**(是,最高性价比,约十行)、**校验/自动升格/违规队列**(否,P3 用更丰富的证据做同一件事,且用可能错的声明驱动自动动作风险高)。
-
-**交集不实现,理由是架构性的**:**因为保留了原文,投影就不必语义完整**。投影只服务提示词与界面展示,两者都接受简化;需要完整语义的是推理机,而它读的是 blob 里的原文。实现交集意味着要表达"既是 A 又是 B"的匿名类——往描述逻辑走的第一步,会把 schema 拖进类表达式的泥潭,而收益只是提示词多一行字。
-
-**多继承要真支持**:`type_matches_domain`(`extraction.rs:137`)沿单父链上溯。丢掉一个父分支,那分支上的属性会**静默失配**——抽出来了、写入时被挡、不报错。补法:`entity_type_parents` 关联表存全部父类,domain/range 判定走 DAG(访问集防环),左栏仍按主父展示成树,避免同一个类出现多次。
-
-**两个必须在预览里说清的**
-- **哪些关系会因 `functional` 而开启冲突检测**——这正是 `part_of` 那个坑,企业本体声明为函数性但数据不遵守,导完就是一队假冲突
-- **有多少类没有 `rdfs:comment`**——它们在 P3 的自动分类里质量会明显偏低
-
-**流程**:上传 → **dry-run 预览**(新建/更新/暂未投影各多少、上述两项警告)→ 确认才落库。绝不让上传一个文件就不可逆地改掉本体。
-
-**v1 砍掉**:OWL/XML 与 Manchester 语法(Turtle + RDF/XML 覆盖 Protégé 导出的绝大多数)、任何推理、个体导入、反向导出。
-
-**个体(ABox)永不作为事实导入**——不是能力问题,是原则:每条事实必须有证据链和出处,凭空塞进来的实例破坏的正是这个地基。它们随原文件保存,将来推理机可当背景知识读。
-
-**依赖**:`oxttl` + `oxrdfxml`(Oxigraph 那套小而专的 crate)。不引 `horned-owl`,我们不需要推理。工作区目前无任何 RDF 依赖。〔已引入,住在 `utopia-ingest`。〕
-
-> **修订记录 · P2a + P2b 已落地**(`feat/owl-import`)
->
-> 落地的是三层里的前两层与整条预览流程:解析(`utopia-ingest/src/ontology_rdf.rs`)、
-> 计划与执行(`utopia-server/src/owl_import.rs`,预览与落库**共用同一个 `plan()`**)、
-> 界面(本体页左栏底部 Import 入口 → 选文件 → 计划 → 确认)。
-> `ontology_imports` 表与 `entity_types.iri` / `relation_types.iri` 见 `ontology_imports` 的建表注释。
->
-> **对着真实词汇表验证,不是自己写的样例**。FOAF(RDF/XML,635 三元组):
-> 15 类 + 89 属性,`functional` / `inverse_functional` / domain / range 全部读出;
-> DCTerms(Turtle,700 三元组):22 类 + 55 属性。**自己手写的样例文件全部通过,
-> 而真实的 FOAF 在第一行就死了**——格式判定写反了,而且文件开头的 ` Microsoft
-```
-
-而 schema.org 声明 `employee (organization → person)`。**130 条可校验的事实里 102 条这样反着落库。**
-
-这恰恰是选 schema.org 的理由失效——`ontology_packs.rs` 写着「1488 个属性带
-domain + range,方向是**声明的**不是描述的」。声明了,然后没人执行。
-
-而且它比空谓词严重得多:**空谓词是诚实的沉默,反向边是自信的错误**。图上写着
-Musk 雇佣了 Microsoft,而 0002 说推理机是缺陷放大器——这正是它会放大的那种输入。
-
-## 三、一个根因,两个症状
-
-查下去,类型判错与方向反向是同一件事的两副面孔:
-
-```
-#128 撤掉种子 → seed_classes 空(它取的是 builtin 的类)
- ↓
-检索偏爱字面出现在正文里的叶子类
-(讲 Sutskever 的分块,976 个类里 researcher 第 4、person 第 359、organization 第 795)
- ↓
- ┌──────────────────────┬──────────────────────────┐
-实体判成 researcher employee 的签名退化成 (* → *)
-(schema.org 里它是 (sig_of 只认铺出去的类,
- Audience 的子类,不是人) 一侧没铺就写 *)
-```
-
-`build_lists` 的注释早写过这道地板:「**内置类恒在**。检索漏掉的分块仍然要有地方
-落脚,否则模型无类可选」。种子退场后判据悬空了——**它当初碰巧等价,只因为种子类
-正好是那几个通用类**。
-
-## 四、五轮实验
-
-同语料同本体,每次只动一个变量,全部 60/60 块:
-
-| 变体 | 事实 | 空谓词 | 可校验 | 违反 | **真·反向** | 违反率 |
-|---|---|---|---|---|---|---|
-| 只改提示词 | 429 | 138 | 172 | 98 | 39 | 57.0% |
-| + 祖先地板 | 451 | 138 | 181 | 63 | 31 | 34.8% |
-| + 签名类恒在 | — | — | — | — | 26 | 37.2% |
-| + 按库内类型掰正 | 447 | 139 | 179 | **0** | **0** | 29.6% |
-| + 前缀归并 + 不适用则沉默 | 447 | 179 | 149 | **6** | **0** | **4.0%** |
-
-(第三行停在 41/60 块——服务器被杀,只能看比率。)
-
-### 提示词能做的,和不能做的
-
-第一版把两件事混成一句 "hint, not a rule — when the text says otherwise, write what
-the text says",于是模型连参数顺序也按原文说法写。两件事的可覆盖性根本不同:
-
-- **哪些类型能参与**:提示不是闸门。本体可能写错,原文说西雅图就写西雅图。
- [0001](0001-ontology-import-and-governance.md) 的判断在这里不变——硬闸门会
- 系统性丢数据,`part_of` 烧我们的正是那样。
-- **参数顺序**:由签名定。**顺序不是关于世界的断言,是这个 key 的编码约定**;
- 原文从来没有「说了别的方向」,它只说两个实体之间存在某种关系。
-
-改了之后有效,但**不是主力**:三轮提示词把违反率从 57% 压到 35%,压下去的
-**全是类型判错那一半**,真·反向纹丝不动(22.7% → 17.1% → 17.6%,后两个在噪声里)。
-
-**模型看得见签名,就是不照做**——英语的 "X is an employee of Y" 太强。
-继续加措辞不会赢。
-
-### 于是在写入时掰正
-
-主语违反 domain **而宾语符合**时,按签名交换主宾。
-
-这不是新原则:`produced_by` 命中 `produces` 时(#109)早就在自动翻转主宾了,
-区别只在触发条件是**措辞**还是**签名**。
-
-当初反对自动掰正的理由是实体类型不可靠——实测 Elon Musk 被判成 `researcher`。
-**祖先地板修好之后那个前提不成立了**,同一批人判成了 `person`。
-
-类型**从库里的实体读**,不用抽取器手上那份 `entity_type_of`——那份只覆盖模型在
-这一块里声明过的实体,而宾语常是别处已存在的实体。**这一处差别就是反向从 10 降到 0
-的原因。**
-
-**绝不静默**:每次掰正落一条 `direction_corrected`。0001 反对的是「用可能错的声明
-驱动**自动动作**」,而留痕的动作不属于那一类。29 次里有 1 次掰错(`spatial`,
-schema.org 对它的 domain 定义本就模糊)——这是这个机制的固有成本:
-**它信任本体的声明,而声明可能不适用于这对实体。**
-
-### 不适用的关系该沉默
-
-对调也不合法时,从前照原样落库,等于**用本体的名义说一件本体不同意的事**:
-
-```
-OpenAI --affectedBy--> … schema.org 的 affectedBy 是医学检验用的
-Mistral --amount--> €105 million amount 属于融资工具,不属于公司
-Anthropic --competitor--> OpenAI schema.org 的 competitor 是 SportsEvent 的属性
-```
-
-现在丢掉谓词,保留主宾、时间、证据,原词留在 `fact_evidence.proposed_predicate`,
-显示时由 `fact_surface_predicate()` 取回([0010](0010-no-relation-is-no-relation.md))。
-验过没有变哑:**退回的 179 条空谓词事实,179 条全部拿得出原文说法。**
-
-## 五、剩下的不是抽取的错
-
-- **具化建模缺口**(`amount` 13 / `target` 8 / `participant` 5):schema.org 用中间
- 节点建模(Action / LoanOrCredit / Offer),我们是扁平二元关系。
-- **同名词**(`affected_by` / `competitor` / `uses_device`):包里有很多名字通用、
- 含义狭窄的词,按名字或向量相似度挑必然撞上。跟 `Researcher` 被拿来标人同源。
-
-**这两类是本体包的本质成本**,0008 该记进去:**包的代价不在提示词长度,在选择难度。**
-
-### 为什么不引入更强的建模语言
-
-数据模型**已经支持**中间节点——`entities` + `facts` 就是属性图,
-`[某轮融资] --amount--> €105M` 今天就能存,库里也确实有 10 个 `event`、
-9 个 `monetary_amount` 实体。卡住的不是表达力。
-
-卡住的是**提示词的规则 1 与 2**:「用文中写出的规范全名」+「每条事实的主宾都必须
-出现在 entities 里」。而一轮融资在原文里**没有名字**——要满足签名就得凭空造一个实体,
-而提示词从头到尾在教它不要造。**模型做的是对的。**
-
-真要支持具化,动的是这两条规则的地基,还得给无名节点一套命名与去重办法
-(同一轮融资在两篇文章里怎么认成一个?它没有名字可比)——那是实体消解的新问题。
-
-判据应该是**限定词本身需不需要被查询**:「2024 年 A 轮里由 General Catalyst
-领投的公司有哪些」——那个「领投」挂在融资轮上。**今天问不了这种问题,那才是该做的信号**,
-而不是「schema.org 这么建模所以我们也要」。
-
-## 六、顺带修掉的两处
-
-**领头的轻动词不算区别**:`has_funding` 与 `funding` 是同一个关系。实测空谓词里
-`has_funding` ×4 落空而本体有 `funding`,`product` ×2 落空而本体有 `has_product`。
-过度归并不会造成错配——撞车即作废,最坏退回「匹配不上」。
-
-**「实体名」是一句话时不该造成实体**。实测 421 个实体里 76 个无类型,最长的 111 字符
-是一整个从句。守卫本来就有(`> 100 字符` 就 `continue`),但**只装在声明实体那条路上**,
-而未声明的主宾会绕过去直接 `resolve()` 造实体——**前门有锁,后门开着**。而且那个
-`continue` 是静默的,正是 `drop_signal` 当初为之而建的七处之一。
-
-判据改成**词数 + 限定动词**而不是字符数:`US District Court for the Northern District
-of California`(57 字符)是真实体,`removal was driven by growing discontent and
-distrust with Altman`(65 字符)是从句——字符数分不开,**谓语分得开**。
-
-效果:最长实体名 111 → 57 字符(57 那个正是上面那个法院),无类型实体 76 → 60。
-被挡下的全是真句子,没有误伤。
-
-**孤点(一条事实都没有的实体)14.0% → 17.5%,但这不能归因于守卫**:守卫总共只挡了
-个位数,而孤点多了 14 个,更可能是跑次方差(两次跑的模型输出本来就不同,
-后一次还中途重启过、被回收的文档整篇重抽)。孤点的真实成因是另一回事——
-**模型在 `entities` 里声明了实体却没产出用到它的事实**,孤点列表里是法院、SEC、
-特斯拉总部这些正当实体。这个比例值得单独记,但它跟这次的守卫无关。
-
-## 待做
-
-- **写入时的守卫挡不住写入之后的改动**:实体合并会把主语换成另一个类型的实体,
- 于是事实「变成」违反。实测 6 条残留里 4 条如此。要治得把同样的检查放进合并路径。〔**已做**(#190 / #196,`fix/one-domain-check-for-three-paths`):写入时的判断抽成 `ontology::judge_direction`,抽取与**采纳**共用——采纳是第二条写谓词的路,实测把违反率从 0 抬到 12.3%,全在包关系上;两边都不合的采纳**不挂谓词**,条数随 `facts_left_off` 上报。**合并**不掰不改只报:搬动过的事实查一遍 domain / range,违反的进 `axiom_violations`(新 kind `signature`)。一致性检查(0002 R0)也多了这一类,全量量、事实撤了就清,所以本体事后改 domain 也逃不过。未分类实体不算违反——「不知道」不是「不符合」。〕
-- 另外 2 条残留(`Stability AI Ltd`、`Colossus 2 data center`)没有合并也没有改类型,
- **原因未查明**。
-- 导入本体包时过滤掉需要中间节点才能用的关系(domain 全是 `Action`/`Offer`/
- `LoanOrCredit` 这类具化壳的),它们铺给模型只会产出违反。〔仍未做。〕
-- **(2026-09-02 补)`bootstrap_ontology.rs` 开头的模块注释仍在说「新建的库只有 10 个默认关系……降级成 related_to」**,那是三次退场之前的世界。不属于本篇,但与本条线同源,该改。
+# 0012 · The ontology is a contract
+
+- **Status**: implemented · five controlled runs on `ai-timeline-ends` × schema.org + W3C Org took the violation rate from 57% to 4% and true reversals from 39 to 0 · the write-time judgment (`ontology::judge_direction`) now also covers adoption and merge (#190 / #196; merge reports `signature` rows into `axiom_violations`, R0 re-checks the same class) · filtering reified-shell relations out of pack import is still not done · since this record the ontology can also declare `inverseOf` / `subPropertyOf` (#177 / #179) and the pack list grew to five; the runs cover only the first two
+- **Written**: 2026-08-31 (inferred: the source is undated and sits between 0011 and 0013, both dated 2026-08-31) · condensed into English 2026-09-03
+- **Related**: [0008](0008-ontology-packs-as-cold-start.md) built the packs but never asked whether a large ontology holds up on real text; criterion 2 of [0001](0001-ontology-import-and-governance.md) ("the ontology guides, it does not enforce") is overturned by half here; [0010](0010-no-relation-is-no-relation.md) supplies the empty-predicate behavior; [0013](0013-a-source-should-hand-over-its-history.md) is the other end of the same line — how things come in, versus the rules they land by
+
+## The problem
+
+The packs earn their keep. Six Wikipedia articles with schema.org + W3C Org (1655 relations,
+973 classes) and zero seeds: all 215 predicated facts and all 41 distinct relations came from
+the pack, and the empty-predicate share fell to 25.6% (55.5% `related_to` in the ten-seed days).
+
+But not one declared constraint was enforced. schema.org declares
+`employee (organization → person)`; the graph said `Elon Musk --employee--> Microsoft`, and
+102 of 130 checkable facts were written backwards — the reason for choosing schema.org, 1488
+properties with declared domain and range, went unused. An empty predicate is honest silence;
+a reversed edge is a confident error, exactly the input the engine of
+[0002](0002-reasoning-engine.md) amplifies.
+
+One root cause, two symptoms. After #128 removed the seeds, `seed_classes` was empty and
+retrieval favored leaf classes that appear literally in the text (in a chunk about Sutskever,
+`researcher` ranked 4th of 976 classes, `person` 359th, `organization` 795th). Entities got
+typed `researcher` (a subclass of `Audience` in schema.org), and `sig_of`, which only knows
+classes that were laid out, degraded the signature of `employee` to `(* → *)`.
+
+## Decisions
+
+1. **Which types may participate stays guidance; argument order is enforced by the
+ signature.** The first prompt merged the two into "hint, not a rule — when the text says
+ otherwise, write what the text says", and the model applied that to argument order too.
+ Order is not a claim about the world, it is the encoding convention of the key; the text
+ never "says another direction", it only says a relation exists. For types, 0001 still
+ holds: a hard gate loses data systematically, as `part_of` showed.
+
+2. **The ancestor floor.** The ancestors of every retrieved class join the list, so `person`
+ and `organization` are always available. This removed the type half of the violations and
+ made entity types trustworthy: the people typed `researcher` became `person`.
+
+3. **Correct direction at write time.** When the subject violates the domain and the object
+ satisfies it, swap them by signature — the move `produced_by` → `produces` already made
+ (#109), triggered by the signature instead of the wording. Types are read from the entity
+ in the store, not from the extractor's `entity_type_of`, which only covers entities
+ declared in the current chunk while the object usually already exists. That single
+ difference took reversals from 10 to 0.
+
+4. **Never silently.** Every swap leaves a `direction_corrected` trace. 0001 objected to
+ automatic action driven by possibly wrong declarations; a traced action is a different
+ thing. One of 29 swaps was wrong (`spatial`, whose schema.org domain is vague) — the
+ built-in cost of trusting a declaration that may not apply to this pair.
+
+5. **A relation that does not apply falls silent.** When swapping is also illegal, keeping
+ the predicate would assert in the ontology's name something the ontology disagrees with
+ (`OpenAI --affectedBy--> …` is a medical-test property; `competitor` belongs to
+ `SportsEvent`). The predicate is dropped, subject, object, time and evidence stay, the
+ model's word goes to `fact_evidence.proposed_predicate` and `fact_surface_predicate()`
+ shows it (0010). All 179 facts pushed back to an empty predicate still surface their wording.
+
+6. **Two side repairs.** A leading light verb is not a distinction: `has_funding` and
+ `funding` merge, and over-merging is harmless because a collision voids the match. A clause
+ is not an entity name: the 100-character guard sat only on the declared-entities path while
+ undeclared subjects and objects went straight to `resolve()`; the criterion is now word
+ count plus a finite verb, since a 57-character court name and a 65-character clause cannot
+ be told apart by length. Longest name 111 → 57 characters, untyped entities 76 → 60.
+
+## Dead ends
+
+- **More prompt wording.** Three rounds moved the violation rate from 57% to 35%, all of it
+ type errors; true reversals stayed flat (22.7% → 17.1% → 17.6%, within noise). The model
+ sees the signature and does not follow it — English "X is an employee of Y" is too strong.
+- **Automatic swapping before the floor.** Rejected earlier because entity types were
+ unreliable (Musk typed `researcher`); once the floor landed the premise fell away.
+- **A richer modeling language for reification.** `amount` (13), `target` (8) and
+ `participant` (5) fail because schema.org models them through intermediate nodes (Action /
+ LoanOrCredit / Offer) and we write flat binary relations. Expressiveness is not the blocker:
+ `entities` + `facts` already form a property graph. The blocker is prompt rules 1 and 2 —
+ canonical names from the text, every subject and object an entity — and a funding round has
+ no name in the text, so the model would have to invent one. The model is right. Revisit when
+ a query needs the qualifier itself ("companies whose 2024 Series A was led by General
+ Catalyst"); unnamed nodes would also need their own naming and dedup.
+- **Blaming the guard for orphans** (entities with no fact, 14.0% → 17.5%). The guard blocked
+ single digits; the run was restarted mid-way. The real cause is the model declaring
+ entities it never uses (courts, the SEC, Tesla HQ) — worth measuring separately.
+
+The remaining cost of a pack is selection difficulty, not prompt length: many names are
+generic with narrow meanings (`affected_by`, `competitor`, `uses_device`, `Researcher`) and
+get picked by name or vector similarity. 0008 should carry this.
+
+## Revisions
+
+- 2026-09-02: the write-time guard could not stop later edits — a merge swaps the subject for
+ an entity of another type and the fact "becomes" a violation (4 of the 6 residuals). Done in
+ #190 / #196: `ontology::judge_direction` is shared by extraction and adoption (adoption was
+ a second path writing predicates and had pushed the rate back to 12.3%); adoption that fits
+ neither way leaves the predicate off, counted under `facts_left_off`; merge does not swap,
+ it reports `signature` violations on moved facts, and R0 re-checks them so a later change of
+ domain cannot hide. An unclassified entity is not a violation — "unknown" is not "does not fit".
+
+## Open questions
+
+- Two residual violations (`Stability AI Ltd`, `Colossus 2 data center`) were neither merged
+ nor retyped; the cause is unknown.
+- Pack import still lays out relations whose domain is a reified shell (`Action`, `Offer`,
+ `LoanOrCredit`); shown to the model they can only produce violations.
diff --git a/docs/decisions/0013-a-source-should-hand-over-its-history.md b/docs/decisions/0013-a-source-should-hand-over-its-history.md
index 3d6dc251..1e6ccc1f 100644
--- a/docs/decisions/0013-a-source-should-hand-over-its-history.md
+++ b/docs/decisions/0013-a-source-should-hand-over-its-history.md
@@ -1,202 +1,109 @@
-# 0013 · 一个来源该交出它的历史,不是它的现状
-
-- **状态**:已实施两个(`github_issues` #134、`jira_issues` #135)· 文档协作类(飞书 / Confluence / Notion)仍未开工,`instant` 精度未触发(2026-09-02 核,其余陈述逐条复核为真)
-- **成文**:2026-08-31(约定见 [README](README.md))
-- **相关**:[0001](0001-ontology-import-and-governance.md) 的双时态地基;
- 语料侧的同一个判断见 `scripts/bench/fetch-wiki-history.mjs`(#122);
- [0012](0012-the-ontology-is-a-contract-not-a-suggestion.md) 是同一件事的另一头——
- 这一篇管**东西怎么进来**,那一篇管**进来之后按什么规矩落**
-
-## 判据:一个来源值不值得接
-
-这产品是双时态账本,所以看四条,缺一条就退化成"又一个抓网页的":
-
-1. **有没有真实时间戳** —— 认知时间那根轴靠它
-2. **会不会自我推翻** —— `supersedes` 才有事可做
-3. **有没有稳定身份** —— 同一份东西的新版能认出来(`external_key`)
-4. **企业知识是不是真的住在那儿**
-
-工单系统四条全中:每张工单的状态天生随时间变,而且信噪比远高于聊天记录。
-
-## 核心判断:别取现在,取变化
-
-**工单最有价值的部分不是"它现在是 closed"**,而是"8-18 开出、8-20 关掉、
-中间被指派给谁、优先级怎么变的"。
-
-只抓当前状态,这条时间线要靠一次次同步慢慢攒——第一次同步只能看见此刻,
-之前发生的全丢了。而多数系统**已经把变更史准备好了**,只是要多问一句。
-
-这与 #122 给维基百科语料做的是同一个判断。区别是维基要从修订列表里采样,
-工单系统直接把变更事件给你。
-
-落进正文的形状(两个来源一致,刻意的):
-
-```
-# KAFKA-9 Consumer logs ERROR during close
-
-Type Bug.
-Currently Closed.
-Resolved on 2011-07-19.
-
-## History
-- 2011-08-05 — Alan Cabrera changed Workflow: jira → no-reopen-closed
-- 2015-09-01 — Tony Stevenson changed Workflow: no-reopen-closed → Apache Kafka Workflow
-
-## Comments
-### Luke Chen on 2026-08-24
-…
-```
-
-**抬头写成带日期的陈述句,不是键值对。** `Opened by X on 2026-08-18` 能抽出带
-`valid_from` 的事实;`created_at: 2026-08-18` 则要模型自己猜这是什么意思。
-
-## 两个供应商教的不同的事
-
-| | GitHub | Jira |
-|---|---|---|
-| 取法 | 三次拉取 + **逐工单**取事件 | **一次调用**取全(`expand=changelog`)|
-| 增量 | `since` 参数 | JQL `updated >= "…"` |
-| 变更史 | 事件级("发生了 labeled")| **字段级**(`Workflow: A → B`)|
-| 时间戳 | RFC3339 | `+0000` **无冒号**,不是 RFC3339 |
-
-### GitHub:聪明的设计悄悄丢掉了全部价值
-
-第一版想让三样都走仓库级端点、一次分页取全,避免 200 张工单 401 次请求。
-**拿真实数据一跑就发现事件那一路是错的**:`issues/events` 不支持 `since`,
-只能从最新往回翻,而 GitHub 的模型里 PR 也产生 issue 事件——实测本仓库的工单事件
-埋在**第 5 页**,换一个 PR 活跃的仓库就会被推到翻页上限之外。
-
-于是"状态变更史"悄悄变成空的,**而它正是这个来源存在的理由**。改成逐工单取,
-N 只是本轮要写入的工单数。用一次准确换一次省事,这里该换。
-
-### Jira:三个坑都藏在字段形状里
-
-- **时间戳不是 RFC3339**(`2026-08-24T11:11:52.944+0000`,时区偏移没有冒号)。
- chrono 默认解不了,解不出来就是整页工单丢掉。
-- **增量没有 `since` 参数**,只能靠 JQL,时间格式是 Jira 自己的且**要带引号**——
- 两点写错的症状都是 400,不是"查不到"。
-- **`fields` 必须显式列**:不列 `comment` 就不返回评论;默认返回全部字段会把
- 一条响应撑到几百 KB。
-
-## 拿真实响应钉住字段形状
-
-手写的 JSON 只能证明"我以为的形状"解得出来。GitHub 一条 issue 有上百个字段,
-我们只声明十个;哪个其实叫别的名、哪个在某些情况下是 null,只有真数据说得清。
-
-两个连接器的夹具都取自**公开可匿名读的实例**(`deeplethe/utopia`、
-`issues.apache.org`),字段裁到我们声明的那些——裁剪本身顺带证明了未声明的
-字段不会让 serde 失败。
-
-它当场抓到一个手写测试全绿而真实数据报错的 bug:**逐工单事件端点不返回 `issue`
-字段**(上下文已经在 URL 里)。第一版按仓库级响应写成必填,换端点后整片解不出来。
-
-**这条对后来者是硬要求**:接一个没有公开实例、拿不到真实响应的来源,
-交付时必须说明"未经真实实例验证",别跟这两个混为一谈。
-
-## 截断要说出来
-
-Kafka 项目有 **14506** 张工单,一轮翻页上限取 500。不报的话界面上"同步完成"
-是一句误导——所以取回数小于 `total` 时落一条 warn,说明本轮只覆盖了一段。
-
-与 #108「部分抽取报告成完成」、#127「一条坏记录不该毁掉一整块」同一条原则:
-**限了覆盖面就要说出来**。
-
-## 已知取舍:时刻被截成了日期
-
-连接器把时间戳写进正文时是 `created_at.format("%Y-%m-%d")`——**按 UTC 截到天**。
-抽取器从 "Opened by X on 2026-08-18." 这句话里读出的 `valid_from` 因此是
-day 精度,时刻在入库前就没了。
-
-**代价说清楚**:跨 UTC 午夜的事件会差一天。一个 UTC+8 的人在 8 月 19 日
-早上七点开的 issue,GitHub 界面上写着 8-19,我们的文档里写的是 8-18。
-
-**为什么仍然接受**:
-
-- 它没有真的丢。源头原样还在,而两个连接器都是**幂等可重同步**的——
- 真要精确到时刻,重同步一次就能拿回来。这与"不可逆丢失"差一个量级。
-- 精度模型本来就在说实话。`precision = day` 的含义正是"知道哪天、不知哪刻",
- day 精度的日期存成 UTC 午夜、按 UTC 渲染,是自洽的。**真正的错是假装知道**。
-- 这产品目前不问"几点"。它回答"某个时刻世界是什么样",粒度到天。
-
-**什么时候该回头做**:有人要按"当地营业日"统计,或者接了一个事件天然聚在
-午夜附近的源(交接班、开盘收盘)。那时 day 精度不够,才值得加一档 `instant`
-精度——连带 CHECK 约束、抽取提示词、渲染分支一起改。
-
-顺带记一条同源的规矩,它已经落在代码里:**世界时间与记录时间的时区待遇相反**。
-`valid_from`/`valid_to` 来自文档里的陈述,是日历日期不是时刻,一律按 UTC 渲染
-(转本地会让 UTC-5 的读者看到前一天);`recorded_at` 这类"我们何时这么认为"
-是真实时刻,按看的人的时区渲染。这两处**故意不一样,别统一**。
-
-## 为什么没有抽象成 `issues` + provider
-
-接第二个供应商正是为了看清该不该抽象。答案是**暂时不该**:取法根本不同
-(三次拉取 vs 一次调用、`since` vs JQL、事件 vs 字段变更)。共同的只有
-"一张工单一篇文档、正文带变更史"这条判断——而它已经体现在两边一致的**文档形状**上,
-不需要一个共同的 trait 来强制。
-
-硬抽一个 provider 接口,会把这些真实差异挤进一堆 `match`。等第三个来了再看:
-如果它的取法落在已有两种之一,那时抽象才有形状可依。
-
-## 这条线后来延伸到了哪
-
-「说清楚一样东西是哪来的」不止管来源。同一句话在本体侧也成立,
-而且是这篇写完之后才补上的:
-
-- **#141**:类的形状开始承载来历——**方 = 词表声明的(有 IRI),
- 圆 = 语料里长出来的**。从前所有自动建的类都写死 `circle`,
- 一张图上看不出哪些类是本体里定义过的、哪些是从文档里长出来的。
-- **#145**:一个类被词汇表**认领**(`adopt_iri_onto_key`)之后形状要跟着改。
- 漏了这一处的症状很刺眼:类拿到了 IRI 却仍画成圆——**有 IRI 却是圆的,
- 画面就在说谎**。这个缝隙只有真导入一次才撞得到,实测撞上过。
-
-两件都不是这篇的主题,但判据是同一条:**来历要看得见**。
-接新来源时值得一并想:这个来源带进来的东西,读者能不能一眼看出它是从哪来的。
-
-## 相邻的一层:一个源能被谁挂载
-
-本文谈「东西怎么进来」。写完之后加了一层本文没有的东西——**授权**(#142,`data_source_grants`):注册数据源是部署级动作,而它携带的连接串会到达每一个被授权的工作区,所以挂载只能在授权过的集合里挑,守卫在两侧都有(列表按工作区过滤 + 挂载端点复核)。
-判据与本文一致:来历要看得见,去向也要管得住。
-
-## 待做:飞书 / Confluence / Notion
-
-文档协作类是下一个该接的——它是 #122 那份维基历史快照语料的**企业版**:
-同一份文档在不同时刻的版本,天然带版本号,不用采样也不用跟埋在 PR 里的事件较劲。
-四条判据全中。
-
-**飞书优先**(`feishu_docs`):中文用户的实际落点,而且它的 API 直接给文档版本。
-
-三个已知的未知,接的人要先答:
-
-1. **拿不到公开实例。** 飞书/Confluence/Notion 都要租户与凭据,所以做不到
- GitHub/Jira 那样的真实端到端。要么用测试租户,要么明说未经真实验证。
-2. **富文本不是字符串。** 飞书文档是块结构(`docx` 的 block 树),
- Notion 是 block、Confluence Cloud 是 ADF——都需要一个"块树 → 纯文本"的渲染器。
- Jira Cloud 的 v3 也是同一个问题,本轮绕开了(走 v2)。
-3. **"变更史"在这里是什么。** 工单的变更史是事件流;文档的变更史是**版本序列**,
- 更接近 #122 那份语料的做法——可能要按"变了多少"采样,而不是每个版本都取。
-
-### 照着走的骨架
-
-两个已有实现是同一个形状,抄它就行:`github_issues.rs`(三次拉取 + 逐工单事件)、
-`jira_issues.rs`(一次调用取全,`expand=changelog`)。
-
-1. **纯函数 `render()`** 把一条记录排成 Markdown——不联网,于是测得动
-2. **`fetch_all()`** 负责分页与鉴权
-3. `ingest_sources.rs` 里加一个 `sync_*` 分支,调 `ingest_item()`——身份、
- sha256 去重、版本记录都由它负责
-4. `sources::KINDS` 白名单 **加前端三处**(`api.ts` 的 `SourceView["kind"]`、
- `Library.tsx` 的建来源对话框、`SourcesRail.tsx` 的图标与 `SYNCING_KINDS`)
-
-第 4 步最容易漏,症状是**界面上选得到、建的时候报 `kind must be one of…`**——
-单元测试与 tsc 都看不见,只有端到端会撞上(#134 就是这么撞的)。
-
-另外注意凭据只进不出:编辑来源时留空 = 保留库里原值,写法见 `sync_custom`。
-
-### 验收
-
-- 单元测试覆盖 `render()`(含"空评论不留空节"这类边界)
-- 拿得到真实响应就加夹具测试;**拿不到就在 PR 里明说未经真实实例验证**
-- 端到端:建来源 → 同步 → 文档带版本/变更信息 → 二次同步幂等(新增 0)
-- `cargo clippy --workspace --all-targets` 与 `npm run typecheck` 干净
-
-Confluence / Notion 是同一类,上面第 2 条未知三家共通。
+# 0013 · A source hands over its history
+
+- **Status**: implemented for `github_issues` (#134), `jira_issues` (#135) and `notion` (#213, pages keep their own clock); WebDAV shares and object storage (S3 / Azure / GCS) arrived as plain file sources (#200, #207, #209) · Feishu and Confluence not started · the `instant` precision has not been triggered
+- **Written**: 2026-08-31 · condensed into English 2026-09-03
+- **Related**: the bitemporal ground of [0001](0001-ontology-import-and-governance.md); the same judgment on the corpus side in `scripts/bench/fetch-wiki-history.mjs` (#122); [0012](0012-the-ontology-is-a-contract-not-a-suggestion.md) is the other end of the line — this record is about how things come in, that one about the rules they land by; the grant layer added afterwards (#142, `data_source_grants`) decides who may mount a source: provenance visible, destination governed
+
+## The problem
+
+The product is a bitemporal ledger, so a source is worth connecting only if it passes four
+tests: real timestamps (the recorded-time axis depends on them); the ability to contradict
+itself (otherwise `supersedes` has nothing to do); a stable identity so a new version of the
+same thing is recognized (`external_key`); and enterprise knowledge actually living there.
+Miss one and it degrades into another web scraper. Issue trackers pass all four.
+
+The valuable part of a ticket is not "it is closed now" but "opened 08-18, closed 08-20,
+assigned to whom in between, priority changed how". Syncing only the current state builds
+that timeline sync by sync, and everything before the first sync is lost — while most systems
+already keep the change history and only need to be asked. #122 made the same call for
+Wikipedia; there revisions have to be sampled, an issue tracker hands the events over.
+
+## Decisions
+
+1. **One ticket is one document, and the body carries the history.** Both connectors render
+ the same shape on purpose: a heading, dated declarative sentences (`Currently Closed.`,
+ `Resolved on 2011-07-19.`), a `## History` list of `date — who changed Field: A → B`, then
+ `## Comments`. Sentences with dates, never key-value pairs: `Opened by X on 2026-08-18`
+ extracts to a fact with `valid_from`; `created_at: 2026-08-18` leaves the model to guess.
+
+2. **GitHub fetches events per ticket.** The first version routed everything through
+ repository-level endpoints to spare 200 tickets 401 requests. Real data showed the events
+ leg was wrong: `issues/events` has no `since`, pages from newest backwards, and pull
+ requests produce issue events too — this repository's issue events sat on page 5, and a
+ PR-heavy repository pushes them past the page cap. The state history came back silently
+ empty, and it is the reason the source exists. Per-ticket fetching costs N requests, N
+ being the tickets written this round; accuracy over economy here.
+
+3. **Jira's traps are all in field shape.** Timestamps are `2026-08-24T11:11:52.944+0000`,
+ no colon in the offset, not RFC3339; an unparsed one drops a whole page. Incremental sync
+ has no `since` and goes through JQL `updated >= "…"` in Jira's own format with quotes, and
+ both mistakes surface as 400, not as an empty result. `fields` must be listed explicitly:
+ omit `comment` and no comments come back; list nothing and one response runs to hundreds
+ of KB. `expand=changelog` returns the field-level history in one call.
+
+4. **Fixtures come from real responses.** Hand-written JSON only proves that the shape I
+ imagined parses. Both fixtures come from publicly readable instances (`deeplethe/utopia`,
+ `issues.apache.org`), trimmed to the declared fields — the trimming itself shows that
+ undeclared fields do not break serde. It caught a bug the hand-written tests passed: the
+ per-ticket events endpoint does not return the `issue` field (the context is in the URL),
+ and the first version had it as required. A source without a public instance must be
+ delivered with "not verified against a real instance" stated.
+
+5. **Truncation is reported.** Kafka has 14506 tickets; a round caps at 500 pages. When the
+ fetched count is below `total`, a warning says this round covered a slice, otherwise "sync
+ complete" misleads. Same principle as #108 (partial extraction reported as complete) and
+ #127 (one bad record must not sink a chunk).
+
+6. **Timestamps are cut to the day, deliberately.** The connectors write
+ `created_at.format("%Y-%m-%d")` in UTC, so `valid_from` extracted from
+ "Opened by X on 2026-08-18." has day precision, and an event across UTC midnight is off by
+ one day for a UTC+8 reader. Accepted because nothing is truly lost (both connectors are
+ idempotent and resyncable); because `precision = day` says exactly "the day is known, the
+ instant is not" — the real error would be pretending to know; and because the product does
+ not yet ask "at what hour". Add an `instant` precision (CHECK constraint, extraction prompt
+ and rendering branch together) only when someone needs local business days or a source
+ whose events cluster around midnight.
+
+ A related rule is already in code: world time and record time get opposite timezone
+ treatment. `valid_from` / `valid_to` are calendar dates from statements in documents and
+ render in UTC (local conversion shows a UTC-5 reader the previous day); `recorded_at` is a
+ real instant and renders in the viewer's timezone. The difference is intentional.
+
+## Dead ends
+
+- **An `issues` + provider abstraction.** The second vendor was connected to find out whether
+ to abstract. Not yet: the fetch strategies differ at the root (three pulls vs one call,
+ `since` vs JQL, event-level vs field-level history). What they share — one ticket, one
+ document, history in the body — already lives in the identical document shape and needs no
+ trait; a forced interface would push the real differences into a pile of `match`.
+ Reconsider when a third source lands in one of the two existing shapes.
+
+## The same judgment elsewhere
+
+Provenance has to be visible, and that reached the ontology after this record: a class's
+shape carries its origin (#141: square = declared by a vocabulary with an IRI, circle = grown
+from the corpus), and a class adopted by a vocabulary (`adopt_iri_onto_key`, #145) changes
+shape — a class with an IRI drawn as a circle is a picture that lies.
+
+## Open questions
+
+- **Feishu / Confluence / Notion** are next: the enterprise version of the #122 Wikipedia
+ history corpus — versions of one document at different times, numbered, no sampling and no
+ events buried in PRs. Feishu first (`feishu_docs`): it is where Chinese users are, and its
+ API hands out document versions. Three unknowns to answer first: no public instance (a test
+ tenant, or "not verified" stated); rich text is a block tree (Feishu `docx` blocks, Notion
+ blocks, Confluence ADF; Jira v3 has the same problem, avoided this round via v2) and needs a
+ block-tree-to-text renderer; and "history" here is a version sequence, closer to #122 —
+ probably sampled by how much changed rather than every version.
+
+ The skeleton is the two existing connectors: a pure `render()` turning one record into
+ Markdown (no network, so testable); `fetch_all()` for paging and auth; a `sync_*` branch in
+ `ingest_sources.rs` calling `ingest_item()`, which owns identity, sha256 dedup and version
+ records; and the `sources::KINDS` whitelist plus three frontend places (`SourceView["kind"]`
+ in `api.ts`, the create dialog in `Library.tsx`, icons and `SYNCING_KINDS` in
+ `SourcesRail.tsx`). The fourth step is the one that gets missed: the kind is selectable and
+ creation fails with `kind must be one of…`, invisible to unit tests and tsc (#134 hit it).
+ Credentials go in and never out — an empty field on edit keeps the stored value, as in
+ `sync_custom`. Acceptance: unit tests on `render()`, fixture tests where a real response is
+ obtainable, create → sync → versions present → second sync adds 0, and clean
+ `cargo clippy --workspace --all-targets` and `npm run typecheck`.
diff --git a/docs/decisions/0014-identity-from-the-person-scope-from-the-token.md b/docs/decisions/0014-identity-from-the-person-scope-from-the-token.md
index 14dd5db7..486655dd 100644
--- a/docs/decisions/0014-identity-from-the-person-scope-from-the-token.md
+++ b/docs/decisions/0014-identity-from-the-person-scope-from-the-token.md
@@ -1,139 +1,82 @@
-# 0014 · 身份跟着人,范围跟着令牌
-
-- **状态**:已实施(#161 记录、#180 落地)· `personal_tokens` + Streamable HTTP 的 MCP 服务端已上线,暴露五个只读工具;界面在 `feat/tokens-have-a-page`(0016 A2):账户层「Agents & tokens」页,发放时明文与 MCP 客户端配置片段一起只显示一次,列表只剩前缀,撤销留痕(2026-09-02 核,修订见文末)
-- **成文**:2026-09-01(约定见 [README](README.md))
-- **相关**:迁移 `0014_data_source_grants` 刚给数据源补上授权层——本篇是同一个问题
- 换到「机器来敲门」这一侧;[0004](0004-language-and-localization.md) 定下服务端只说英文,
- 错误码这一条对 MCP 同样适用
-
-> 起因是要把 Utopia 的七个工具暴露成 MCP 服务端。**第一个要回答的不是传输层选哪个,
-> 是客户端以什么身份接进来。** 这一篇只答这个。
-
-## 现状:两种凭据,都不合身
-
-| | 跟着谁 | 存法 | 过期 | 能撤吗 |
-|---|---|---|---|---|
-| JWT | 人 | 无状态 | 7 天 | **不能**——签出去就管不了 |
-| `sources.ingest_token` | 一个来源 | 明文 | 无 | 换一个 |
-
-JWT 是给浏览器会话设计的:短命、每次登录重签、无状态所以不需要一张表。MCP 客户端是
-长命的、机器的、配在别人机器上的一个文件里——七天过期意味着每周手动重配一次,而
-「撤不回来」意味着笔记本丢了只能等它自己过期。
-
-`ingest_token` 更不合适:它跟来源走,不跟人走,而且只能**往里推文档**。
-
-## 走过的岔路:KB 级机器令牌
-
-先提的方案是给知识库发机器令牌——一枚令牌对应一个库,跟人无关。
-
-**否掉了,两条理由:**
-
-1. **它引入第三套授权模型。** 现在已经有工作区成员和 KB 角色两层;再加一层「令牌自己的
- 权限」,那么「这个 agent 能看什么」就得同时查三张表才答得出来。而三层里任何一层写错,
- 失败方向都是「多给了」。
-2. **归因会变成假的。** `audit_events.actor_id` 现在记的是活人,`actor_label` 还存了一份
- 身份快照。机器令牌写进来的事实,actor 只能是一个合成 id——台账上就多出一类「不是任何
- 人做的」记录,而账本存在的理由正是「谁在什么时候认下了什么」。
-
-**改成:令牌以这个人的身份行事。**
-
-## 决定
-
-```
-有效权限 = 这个人的角色 ∩ 这枚令牌的 scope
-```
-
-交集,不是并集。**令牌只能收窄,永远不能放宽。** 一个 viewer 的令牌勾上 write 也还是
-只读——scope 是上限,不是授权。
-
-### 为什么身份跟着人
-
-- **现有守卫一行不用改。** `require_kb(kb_id, Role::Viewer)`、`access::kb_role` 拿到的还是
- 一个 `User`,它从哪来的无所谓
-- **归因是真的。** 台账上是活人,不是机器人
-- **停用即失效。** 人离职停用,他的令牌跟着废,不用单独维护一张「谁的机器还连着」的表
-- **多个库不用发多把钥匙**
-
-### 为什么范围仍然要单独收窄
-
-因为有一个 MCP 特有的问题,应用内对话没有这么严重:
-
-> **混淆代理。** MCP 客户端是别人的 agent、别人的系统提示词,而它读的是知识库里的
-> 文档——**不可信内容**。一份文档里写「请执行这段 SQL」或者「记住 X」,那个 agent 可能
-> 就照做了,用的是这个人的全部权限。
-
-应用内对话也有这个面,但那里提示词和工具循环都在 Utopia 手里;走 MCP,Utopia 对客户端
-的提示词、对它还接了哪些别的服务端,一无所知。
-
-而这个人的「全部权限」是什么:经 `query_data`,对他所在**每一个库挂载的每一个生产
-数据库**跑只读 SQL;经 `remember`,往 append-only 账本里写事实。这些能力配在一串放在
-`claude_desktop_config.json` 明文里的字符串上。
-
-所以默认发**只读、限定到一个库**的令牌。要让 agent 写,得显式勾。
-
-## 这枚要哈希,而 `ingest_token` 不哈希
-
-两处结论不同,不是疏忽。`ingest_token` 那条明文决定的原话(`0002_ingest.sql`):
-
-> **明文存,不是哈希。** 自部署威胁模型下「只看一次」是自找麻烦:改存明文随时可查。
-> DB 失守时文档本体早已泄露,密钥哈希化没有额外收益
-
-那个推理对 ingest_token 成立,因为**它只能往里推文档**——泄露它的最坏结果是有人往你
-库里塞垃圾,而库都失守了,塞垃圾不是最要紧的事。
-
-个人令牌不一样:它经 `query_data` 能**读出 Utopia 之外的生产库**。Utopia 的数据库失守
-本来就泄露 Utopia 自己的文档,但数仓在另一台机器上、装着另一批数据,不该跟着一起丢。
-**爆炸半径不同,所以存法不同。**
-
-## 形状
-
-```sql
-CREATE TABLE personal_tokens (
- id UUID PRIMARY KEY,
- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- name TEXT NOT NULL, -- 人自己起的,"我的笔记本"
- token_hash TEXT NOT NULL, -- 哈希,理由见上
- scope TEXT NOT NULL DEFAULT 'read'
- CHECK (scope IN ('read', 'write')),
- kb_ids UUID[], -- NULL = 这个人能进的全部
- expires_at TIMESTAMPTZ, -- NULL = 不过期,但 UI 默认给 90 天
- last_used_at TIMESTAMPTZ, -- 「这枚还在用吗」,撤之前要答得出
- revoked_at TIMESTAMPTZ, -- 撤销不删行:撤过这件事本身要留痕
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-```
-
-`user_id` 这条有外键且级联,与 `audit_events.actor_id` 的裸外键相反——**台账要活得比
-用户久,令牌不该**。人没了,他的钥匙就该一起没。
-
-## 一条实施纪律
-
-**每个工具入口都要校验 scope,不能只在连接握手时校验一次。**
-
-这是 `0014_data_source_grants` 那轮的教训,原话写在测试里:
-
-> 列表过滤只挡「看得见」,而挂载端点是照着 id 调的——守卫必须在两侧都有
-
-MCP 的对应形态是:握手时校验一次,然后整条连接生命周期里都信任它。工具调用是一个个
-独立请求,`revoked_at` 在中途被写上时,正在跑的连接必须立刻失效。
-
-## 修订记录(2026-09-02):落地之后对照本文
-
-**形状**多一列 `token_prefix`(`utp_pat_…`,与 ingest 的 `utp_` 区分——日志与配置文件里一眼要认得出是哪一种),`token_hash` 加了 `UNIQUE`。哈希选 SHA-256 不选 argon2:高熵串不怕爆破,而每行盐不同就查不了唯一索引。
-
-**「每个工具入口都校验 scope」那条纪律被满足了,但形态与预想不同**:做成完全无状态——每个 POST 重跑一次认证(撤销 / 过期在 SQL 的 `WHERE` 里判)+ `covers()` + `require_kb`,没有「连接」这个东西可以被信任。`scope` 在这一版没有分支:`can_write` 硬编码 `false`,就算令牌勾了 write 也不放开。每次工具调用写一条审计(`mcp.tool_called`,target 是令牌),归因是真的。
-
-**前置条件本文没记**:#175 把七个工具的执行从 `chat.rs` 的 `match` 里抽成 `tools.rs`,对话与 MCP 共用同一份实现——否则「对话里的 `entity_facts` 和 MCP 里的不是同一个东西」。工具的 JSON schema 仍留在 `chat.rs`,MCP 复用它做转换,已知带一处瑕疵:`search_chunks` 的描述里还写着「可以引用成 [n]」,而 MCP 客户端拿不到引用编号。
-
-**一处会误导人的陈述**:`crates/utopia-mcp` 曾是三行占位,宣称的三个工具名(`add_memory` / `search_memory` / `get_entity_timeline`)从未实现,MCP 服务端住在 `utopia-server/src/api/mcp.rs`。〔已删(2026-09-02,连同 `utopia-graph`、`utopia-connectors`),理由见 [0016](0016-close-the-open-seams-before-cutting-new-ones.md) A2。〕
-
-## 未决
-
-- **`query_data` 与 `remember` 要不要进第一版。** 倾向不进——先发四个只读工具
- (`search_chunks` / `find_entities` / `entity_facts` / `changes`),把身份这条路走通再说。
- 这两个各自还有没答的问题:外部 agent 写进来的事实挂什么证据?跑 SQL 的审计怎么记?〔**已答:不进**。实际发的是五个而非四个,多一个 `search_docs`。`remember` 的前提是 [0015](0015-recording-a-sentence-is-not-asserting-a-fact.md) 那道闸,而闸还没接上。〕
-- **传输层**:stdio(本地,配 Claude Desktop 最省事)还是 streamable HTTP(远程,
- 和 Utopia 已经是个服务端相称)。这个选择不影响本篇的结论,两种都要认令牌。〔**已答:Streamable HTTP**,一条路由 `POST /api/v1/kbs/{kb_id}/mcp`,响应用 `application/json` 不用 SSE——工具一问一答,没有服务端主动推的东西。〕
-- **令牌能不能跨工作区。** 现在的 `kb_ids` 是库级白名单;如果将来要按工作区发,
- 和数据源授权那张表会长得很像,届时看要不要合并概念。〔仍未做。〕
-- **(2026-09-02 补)没有界面。** 「UI 默认给 90 天」的 90 天在服务端,而前端根本没有令牌那一页。这是 MCP 今天对用户不可用的直接原因。〔**已做**,同日:`/account/tokens`。三个决定落在界面上——scope 缺省只读、库多选、过期缺省 90 天且「不过期」要显式选;配置片段按库出端点(令牌限定到库,端点也按库分),写法取 Claude Code / Claude Desktop 一族的 Streamable HTTP 键名,别的客户端要自己改键。〕
+# 0014 · Identity from the person, scope from the token
+
+- **Status**: implemented (#161 record, #180 code) · `personal_tokens` and a Streamable HTTP MCP server at `POST /api/v1/kbs/{kb_id}/mcp` with five read-only tools, `application/json` responses rather than SSE · the account-level "Agents & tokens" page at `/account/tokens` (0016 A2): plaintext shown once beside a per-base client config snippet, the list keeps the prefix, revocation leaves a trace · `can_write` is still hard-coded `false`, so a `write` scope changes nothing yet
+- **Written**: 2026-09-01 · condensed into English 2026-09-03
+- **Related**: migration `0014_data_source_grants` gave data sources a grant layer; this is the same question where a machine knocks. [0004](0004-language-and-localization.md) has the server speak English only, MCP error codes included. [0015](0015-recording-a-sentence-is-not-asserting-a-fact.md) removes the main objection to opening `remember` over MCP.
+
+## The problem
+
+Exposing Utopia's tools over MCP raises one question before transport: as whom does the
+client connect? Neither existing credential fits. The JWT follows a person but is stateless,
+expires in seven days and cannot be revoked — a client configured in a file on someone else's
+machine would be reconfigured weekly, and a lost laptop waits for expiry.
+`sources.ingest_token` follows a source, is stored in plaintext, never expires and can only
+push documents in.
+
+## Decisions
+
+1. **The token acts as the person.** Effective permission = the person's role ∩ the token's
+ scope. A token can only narrow, never widen: a viewer's token ticked `write` stays
+ read-only. Existing guards (`require_kb(kb_id, Role::Viewer)`, `access::kb_role`) still
+ receive a `User`; the audit trail shows a person; deactivating the person kills their
+ tokens; several bases need no extra keys.
+
+2. **Scope still narrows separately, because of the confused deputy.** An MCP client is
+ someone else's agent under someone else's system prompt, reading untrusted content from the
+ base — a document saying "run this SQL" or "remember X" may be obeyed with the person's
+ full permissions, and Utopia knows nothing about that client's prompt or its other servers.
+ Full permission means read-only SQL against every production database mounted in every base
+ the person can reach (`query_data`) and writes to the ledger (`remember`), all hanging off
+ a string in `claude_desktop_config.json`. So the default token is read-only and limited to
+ one base; writing must be ticked.
+
+3. **This token is hashed while `ingest_token` is not.** `0002_ingest.sql` reasoned that once
+ the database is lost the documents are lost anyway, so hashing buys nothing — true for a
+ token that can only push documents in. A personal token reads production databases outside
+ Utopia through `query_data`, and that warehouse should not fall with Utopia's database.
+ Different blast radius, different storage. SHA-256 rather than argon2: a high-entropy
+ string does not fear brute force, and a per-row salt would defeat the `UNIQUE` index on
+ `token_hash`.
+
+4. **Shape.** `personal_tokens(id, user_id, name, token_prefix, token_hash, scope, kb_ids,
+ expires_at, last_used_at, revoked_at, created_at)`: `scope` defaults to `read`; `kb_ids`
+ NULL means every base the person can reach; `expires_at` NULL means never, but the UI
+ defaults to 90 days and "never" is an explicit choice; `last_used_at` answers "is this
+ still in use" before revoking. The prefix `utp_pat_…` against ingest's `utp_` tells the
+ kinds apart in logs and config files. `user_id` cascades on delete, unlike the bare foreign
+ key on `audit_events.actor_id`: the ledger must outlive the user, the keys must not.
+ Revocation writes `revoked_at` rather than deleting the row: the revocation is a trace.
+
+5. **Every tool call checks scope.** The lesson of `0014_data_source_grants`: list filtering
+ only guards what is visible, the mount endpoint is called by id, so the guard sits on both
+ sides. Rather than a handshake check followed by trust for the connection's lifetime, every
+ POST re-runs authentication (revocation and expiry in the SQL `WHERE`), `covers()` and
+ `require_kb`; a token revoked mid-session fails on its next call. Each call writes an audit
+ row (`mcp.tool_called`, target = the token), so attribution is real.
+
+## Dead ends
+
+- **Base-level machine tokens**, one per base and unrelated to a person. It would be a third
+ authorization model beside workspace membership and base roles — "what can this agent see"
+ needs three tables, and a mistake in any fails towards "gave too much". And attribution
+ becomes fake: `audit_events.actor_id` records living people with an `actor_label` snapshot,
+ while a machine token's facts would belong to a synthetic id.
+
+## Revisions
+
+- 2026-09-02: an unstated precondition — #175 moved the seven tools out of the `match` in
+ `chat.rs` into `tools.rs`, so chat and MCP share one implementation. The JSON schemas stay
+ in `chat.rs`; `search_chunks` still says results can be cited as `[n]`, which MCP clients
+ cannot see.
+- 2026-09-02: the placeholder crate `utopia-mcp` advertised three tools that never existed;
+ the server lives in `utopia-server/src/api/mcp.rs`. Deleted with `utopia-graph` and
+ `utopia-connectors` ([0016](0016-close-the-open-seams-before-cutting-new-ones.md) A2).
+
+## Open questions
+
+- **`query_data` and `remember` over MCP.** The first version ships `search_chunks`,
+ `search_docs`, `find_entities`, `entity_facts` and `changes`. `remember` waits on the gate
+ of 0015; what evidence an external agent's fact carries and how SQL runs are audited are
+ still unanswered.
+- **Tokens across workspaces.** `kb_ids` is a base-level whitelist; a workspace-level grant
+ would look much like `data_source_grants`, and the two concepts may merge then.
diff --git a/docs/decisions/0015-recording-a-sentence-is-not-asserting-a-fact.md b/docs/decisions/0015-recording-a-sentence-is-not-asserting-a-fact.md
index 3c9a3863..1d33c584 100644
--- a/docs/decisions/0015-recording-a-sentence-is-not-asserting-a-fact.md
+++ b/docs/decisions/0015-recording-a-sentence-is-not-asserting-a-fact.md
@@ -1,168 +1,90 @@
-# 0015 · 记下一句话,不等于断言一个事实
-
-- **状态**:已实施 · schema 在迁移 `0018`(#180),运行时随 [0016](0016-close-the-open-seams-before-cutting-new-ones.md) A1 接上:抽取分流进 `pending_facts`、Review 新一档、对话里跟在 remember 后面的确认卡、`remember` 重新打开。**决定 3 的措辞与本文写的不同**,见文末最后一条修订
-- **成文**:2026-09-01(约定见 [README](README.md))
-- **相关**:[0010](0010-no-relation-is-no-relation.md) 删掉兜底关系(本篇那条空谓词正是它的
- 正确行为)、[0011](0011-a-mapping-is-not-a-fact.md) 批过「拿浮点数编码二值状态」——
- 本篇的实现红线;[0014](0014-identity-from-the-person-scope-from-the-token.md) 里
- 「MCP 不开写」的主要顾虑,被本篇的闸消掉
-
-> 起因是一次实测,不是推演。跑通 `remember` 之后回头查库,看到的东西和助手说的
-> 对不上。
-
-## 那一次实测
-
-对话里说:
-
-> Please remember this: Acme moved its headquarters to Shenzhen on 2026-03-15.
-
-助手答:
-
-> I've recorded that **Acme moved its headquarters to Shenzhen** on March 15, 2026.
-
-而图里落下的是:
-
-```
-Acme --(空谓词)-> Shenzhen confidence 0.9 invalidated_at 空
-```
-
-谓词是空的——本体里没有「搬迁到 / 总部位于」这类关系,抽取落不上就留空。**按 0010
-这是正确行为**(不编一个 `related_to` 出来)。但结果是图上多了一条 0.9 置信、没有
-语义的边,而且**说的和进去的不是一回事,人没有任何办法发现**。
-
-## 现状:根本没有确认这道闸
-
-查之前以为 `unconfirmed` 队列就是它。不是,它的定义是:
-
-```sql
--- 待确认 = 有证据、但证据所在的分块全被新版本取代了
-```
-
-那是**证据过期**队列。`lowconf` 是 `confidence < 0.75`。两个都是事后清理:低置信事实
-在队列里躺着的时候,它已经是图上一条活边(`invalidated_at IS NULL`)。
-
-所以:**任何抽取出来的事实,一落库就生效,没有任何一步需要人点头。**
-
-## 这对批量摄入是对的,对 `remember` 不是
-
-灌 500 篇文档抽出一万条事实,让人逐条确认是不可能的。乐观写入 + 事后审阅是那条路上
-唯一可行的设计,本篇不动它。
-
-`remember` 是另一回事,三点都不一样:
-
-| | 文档摄入 | remember |
-|---|---|---|
-| 量 | 一次上万条 | 一次一句 |
-| 人在哪 | 上传完就走了 | **就在对话里** |
-| 素材 | 外部证据 | 人自己特意说的一句话 |
-
-确认成本最低的那一刻,恰好就是他说完那句话的那一刻。
-
-## 真正的病不是「少一道闸」
-
-是**助手宣称的和图里得到的不一致**。
-
-「已记录 Acme 把总部搬到深圳」听起来像是那件事进去了;实际进去的是一条无谓词的边。
-所以二次确认的价值主要不在多点一下,在于**让人看见即将断言的是什么**——看到
-`Acme --?-> Shenzhen`,人会立刻说这不对。
-
-这也决定了确认界面该长什么样:**原句在上,抽出的三元组在下**,两者并排。只列三元组
-等于要人凭空判断它对不对。
-
-## 决定
-
-1. **`remember` 写文档这一步不变。** 那只是记录「你说过这句话」,本身无害,也确实
- 该立刻可检索。
-2. **从记忆抽出的事实,进图前要人确认。** 未确认的不参与检索、不上图、不进推理。
-3. **助手的话照实说**:「已记录,抽出 N 条待你确认」,不宣称完成。
-
-## 修订记录:第一版把状态加在 `facts` 上,错了
-
-**原本的方案**是给 `facts` 加三列(`nod` / `nodded_by` / `nodded_at`),
-`nod = 'pending'` 表示等人点头。迁移写完、`insert_fact` 的参数也穿好了,
-才发现这是**这个仓库半个月前刚踩过的坑**。
-
-`0013_reasoning.sql` 里那段话,把这个形状的问题说得比我清楚:
-
-> 试过塞进 `facts` 加一位 `derived_by_rule` 标记,那一版的问题是**失败方向反了**:
-> 仓库里有四十多处读 `facts` 的查询,其中只有一处认识那个标记,于是新写一条
-> 查询默认就是把派生当断言看,得记得加过滤。写这个功能的人(我)当场就漏了两处。
->
-> 分开之后忘了 UNION 的后果是**看不见**派生,而不是**混进去**。
-
-数了一下:今天有 **27 处**查询按 `invalidated_at IS NULL` 捞活事实,分布在
-6 个文件。给它们逐个补 `AND nod IS DISTINCT FROM 'pending'`,漏一处的后果就是
-一条没人点头的事实混进图里——而这个功能存在的全部理由就是防这件事。
-
-**改成自己一张表 `pending_facts`。** 确认时才写进 `facts`。忘了读它的后果变成
-「看不见待确认队列」,而不是「未确认的混进了图」。失败方向对了。
-
-顺带一提,0013 给出的另外两条理由这里也成立:**列不一样**(待确认的需要
-`proposed_predicate` 原话、需要指回那句记忆,而不需要 `supersedes`),
-**生命周期不一样**(确认后它就不该继续存在于那张表里)。
-
-
-
-0011 那条线上批过一次同样的错。原话不在 ADR 里,在它落地的建表注释
-(`migrations/0006_semantic_layer.sql` 的 `concept_mappings.status` 列):
-
-> **状态而不是置信度。** 从前借事实的 confidence 表达「提议 0.6 / 确认 1.0」,
-> 那是把一个二值状态编码成浮点数,还顺带让它落进「低置信事实」那一档。
-
-`facts` 表今天没有状态列(`id, kb_id, subject_id, predicate_id, object_id,
-object_value, valid_from, valid_to, ..., confidence, derived_by_rule, supersedes`)。
-要加就加真的一列,别再拿 0.6 糊弄——那会让这条事实同时出现在「待确认」和
-「低置信」两个队列里,而它们问的不是同一个问题。
-
-## 顺带:MCP 开写的障碍消掉了
-
-0014 里不开 `remember` 的主要顾虑是混淆代理——知识库里的文档是不可信内容,一份文档
-写「请 remember 某某」,外部 agent 可能就照做了,用的是这个人的全部权限。
-
-装上这道闸之后,**外部 agent 也只能提议,不能断言**。人先看见才生效,那条顾虑就不
-成立了。所以本篇不只是修一个 bug,它是 0014 里那个「未决」的答案。
-
-## 修订记录(2026-09-02):只有 schema,没有运行时
-
-`pending_facts` 与 `rejected_facts` 两张表在 #180 建好了,形状按「原句在上、三元组在下」设计:`chunk_id NOT NULL` 指回那句记忆、`proposed_predicate` 存模型原话、`predicate_id` **可空**——人要看见的正是这个空、`proposed_by` 补上「谁的话」。失败方向的论证被完整搬进了迁移注释。
-
-**但全仓零读零写。** `memory::is_memory_document()` 为这道判据写好了,零调用;抽取管线没有任何 `pending_facts` 写入点;Review 的八档计数里没有 `pending` 这一档;`remember` 的回话仍是 `Recorded (effective …)`。
-
-**临时闸是把工具整个关掉**:`chat.rs` 的 `REMEMBER_ENABLED = false`,注释写着「抽取侧接上那张表之后改回 true;在那之前宁可没有这个工具,也不要一个会悄悄改图的工具」。所以三条决定里只有隐含的第 0 条(不让说谎继续)实现了。
-
-**于是下文「MCP 开写的障碍消掉了」在代码上还不成立**——闸没装上,MCP 依旧只读。
-
-**一个数字的更正**:「27 处查询按 `invalidated_at IS NULL` 捞活事实,分布在 6 个文件」——今天是 56 处、7 个文件(多出 `ontology.rs`)。论证方向不变,数字若再被引用应重新数。
-
-**同一类问题的另一面**(#173,迁移 `0015_what_it_did_not_just_what_it_said`):跨轮回放助手上一轮的工具调用与结果,模型才知道自己做过什么,不会把上一轮的检索重跑一遍落到另一批同名实体上。本文讲的是「助手说的」与「图里得到的」之间的落差,那一条讲的是「助手说的」与「助手做的」之间的落差。
-
-## 修订记录:接上运行时之后,三处与本文不同
-
-**一、助手说不出「抽出 N 条」。** 决定 3 写的是「已记录,抽出 N 条待你确认」。抽取是异步走队列的,
-`remember` 回话的那一刻抽取还没跑,N 不存在。两条路:把记忆抽取改成同步(一句话十来秒,卡住整个对话循环),
-或者如实说「这句话记下了,从中抽出的事实会先给你确认,确认前不进图」,等抽取完成时再把确认卡**长到对话里**。
-选了后者。卡片靠 SSE 的 `pending` 事件触发,按那句记忆的 chunk 取待确认项;回放旧会话时同样按 chunk 取,
-还有没点头的就照样显示,都处理完了就不占地方。
-
-**二、确认界面在两处,同一个组件。** Review 页新一档「等你点头」排在最前——它是人自己说的话,而且这一档里的
-东西还没进图,别处每一档审的都是已经在图上的。对话里那张卡是同一行组件。载荷是声明式的(chunk + 三元组列表),
-将来若要让别人的客户端渲染它(MCP 那一侧),换的是渲染器不是服务端。
-
-**三、确认走的正是抽取那条路。** 事实 + 证据(指回那句记忆,引句就是整句)+ 时态对账。所以「Mira 交给
-Devin 了」点头之后,Mira 那条会像从文档里抽出来时一样被闭合。置信度不动,人的态度在台账里
-(`fact.nod_confirmed` / `fact.nod_rejected`,快照自包含)。
-
-**顺带定下的**:拒绝记录按(主语, 谓词, 宾语实体)查,**字面值事实不查**——`rejected_facts` 没有 `object_value`
-列,按(主语, 谓词)挡会把「薪水 28000 被拒」扩大成「薪水这个属性永远不再提」,宁可多问一次。
-实体照常消解并创建,`pending_facts.subject_id` 是外键,这是 0018 定下的取舍;代价是几个暂时孤立的节点。
-「谁说的」从 `remember` 经 `memory_ingest` 与 `extract_document` 的任务载荷一路传到 `proposed_by`。
-
-## 未决
-
-- **闸只拦记忆,还是拦所有「单条、交互式」的写入?** 今天只有 `remember` 这一条路,
- 但将来若有「在图上手工加一条边」的界面,它该走哪边?
-- **确认之后置信度取什么。** 人点了头还留 0.9,还是升到 1.0?0011 的教训说别拿
- confidence 表达人的态度,那大概是「不动它」——但要想清楚。〔**已定:不动**。人的态度在台账里。〕
-- **拒绝之后那条记忆怎么办。** 文档还在(人确实说过那句话),只是没抽出可用的事实。
- 下一轮重抽会不会又提议一遍?`concept_mappings` 那边靠 `status='rejected'` 挡住了
- 重复提议,这里需要对应的东西。〔答了一半:`rejected_facts` 表与查重索引建好了,语义就是「这个三元组在这个库里被拒过」,但没有任何代码写它或查它。〕
+# 0015 · A recorded sentence waits for a nod
+
+- **Status**: implemented · schema in migration `0018` (#180: `pending_facts`, `rejected_facts`) · runtime wired in [0016](0016-close-the-open-seams-before-cutting-new-ones.md) A1: extraction from a memory document goes to `pending_facts`, Review has a "waiting for your nod" queue placed first, a confirmation card grows into the chat after `remember`, `REMEMBER_ENABLED` is `true` again · decision 3 landed with different wording, see Revisions · MCP is still read-only; opening `remember` there is the next cut
+- **Written**: 2026-09-01 · condensed into English 2026-09-03
+- **Related**: [0010](0010-no-relation-is-no-relation.md) removed the fallback relation (the empty predicate below is its correct behavior); [0011](0011-a-mapping-is-not-a-fact.md) rejected encoding a binary state as a float, the red line for this implementation; [0014](0014-identity-from-the-person-scope-from-the-token.md) kept MCP read-only mainly because of the confused deputy, which this gate removes
+
+## The problem
+
+A real run, not a thought experiment. The user said "Please remember this: Acme moved its
+headquarters to Shenzhen on 2026-03-15." The assistant answered "I've recorded that Acme
+moved its headquarters to Shenzhen on March 15, 2026." The graph got
+`Acme --(empty predicate)--> Shenzhen`, confidence 0.9, live. The ontology has no "moved to /
+headquartered in", so the predicate stayed empty — correct by 0010 — but the graph gained a
+meaningless 0.9 edge, and what was said and what went in differed with no way to notice.
+
+There was no confirmation gate at all. `unconfirmed` is the queue for facts whose evidence
+chunks were all superseded; `lowconf` is `confidence < 0.75`; both are after-the-fact cleanup
+on facts that are already live (`invalidated_at IS NULL`). Every extracted fact takes effect
+on insert. That is right for bulk ingest — nobody confirms ten thousand facts from five
+hundred documents one by one. `remember` differs on all three axes: one sentence at a time,
+the person is still in the conversation, and the material is something they said on purpose.
+The cheapest moment to confirm is right after they said it.
+
+The disease is not the missing gate but the gap between what the assistant claims and what
+the graph gets. The value of confirming is mostly that the person sees what is about to be
+asserted — shown `Acme --?-> Shenzhen`, they say "that's wrong" at once. So the card shows the
+original sentence above the extracted triples; triples alone ask for a judgment from nothing.
+
+## Decisions
+
+1. **`remember` still writes the document.** That only records "you said this", is harmless
+ and should be searchable at once.
+2. **Facts extracted from a memory wait for a nod before entering the graph.** Unconfirmed
+ facts take no part in retrieval, the graph or reasoning.
+3. **The assistant says what happened** and does not claim completion.
+4. **Pending facts get their own table, `pending_facts`,** written to `facts` only on
+ confirmation. The columns differ (`chunk_id NOT NULL` pointing back at the memory,
+ `proposed_predicate` with the model's wording, `predicate_id` nullable — the emptiness is
+ exactly what the person must see — and `proposed_by`), and so does the lifecycle: after
+ confirmation the row should not exist there. The failure direction is right: forgetting to
+ read the table hides the queue instead of leaking an unconfirmed fact into the graph.
+5. **Confirmation takes the extraction path**: fact plus evidence (the memory chunk, the
+ whole sentence as the quote) plus temporal reconciliation, so a nod on "Mira handed over to
+ Devin" closes Mira's fact as a document extraction would. Confidence is untouched; the
+ person's stance lives in the audit ledger (`fact.nod_confirmed` / `fact.nod_rejected`).
+ Rejections go to `rejected_facts` and are checked on the next extraction by (subject,
+ predicate, object entity); literal-value facts are not checked, since `rejected_facts` has
+ no `object_value` and blocking on (subject, predicate) would turn "salary 28000 rejected"
+ into "never propose salary again" — better to ask once more. Entities are resolved and
+ created as usual (`pending_facts.subject_id` is a foreign key), at the cost of a few
+ temporarily orphaned nodes. `proposed_by` travels from `remember` through the
+ `memory_ingest` and `extract_document` job payloads.
+6. **The MCP objection is gone.** With the gate an external agent can only propose, never
+ assert; a document saying "please remember X" reaches the person before the graph. This
+ answers the open item in 0014.
+
+## Dead ends
+
+- **Status columns on `facts`** (`nod` / `nodded_by` / `nodded_at`, `pending` = awaiting a
+ nod). The migration was written and `insert_fact` threaded before it turned out to be the
+ trap `0013_reasoning.sql` describes for `derived_by_rule`: the failure direction is
+ reversed. Dozens of queries fetch live facts by `invalidated_at IS NULL` (27 in 6 files when
+ first counted, 56 in 7 files by 2026-09-02); each would need
+ `AND nod IS DISTINCT FROM 'pending'`, and missing one leaks an unconfirmed fact — the one
+ thing the feature exists to prevent.
+- **Confidence 0.6 for "proposed".** Rejected once already for `concept_mappings.status`
+ (0011): a binary state encoded as a float, which also lands the fact in the low-confidence
+ queue, and the two queues ask different questions.
+
+## Revisions
+
+- 2026-09-02: only the schema existed — zero reads and writes, `memory::is_memory_document()`
+ written but uncalled, and the interim gate was `REMEMBER_ENABLED = false` in `chat.rs`
+ ("better no tool than one that silently changes the graph").
+- On wiring the runtime, decision 3 changed. The assistant cannot say "N facts extracted":
+ extraction runs asynchronously and N does not exist when `remember` replies. Making it
+ synchronous would block the chat loop for ten seconds a sentence; instead the reply says the
+ sentence is recorded and its facts will be shown for confirmation first, and the card grows
+ into the conversation on the SSE `pending` event, fetched by the memory's chunk (replayed
+ sessions fetch the same way). The Review queue and the chat card are one component with a
+ declarative payload (chunk + triple list), so an external client would swap the renderer.
+- The same disease from the other side (#173, migration
+ `0015_what_it_did_not_just_what_it_said`): replaying the previous turn's tool calls so the
+ model knows what it did and does not rerun a search onto another set of same-name entities.
+ This record is "said" versus "got"; that one is "said" versus "did".
+
+## Open questions
+
+- Does the gate hold only memories, or every single-item interactive write? Today `remember`
+ is the only such path; a future "add an edge by hand" interface should take the same table.
diff --git a/docs/decisions/0016-close-the-open-seams-before-cutting-new-ones.md b/docs/decisions/0016-close-the-open-seams-before-cutting-new-ones.md
index e695fd25..5463554c 100644
--- a/docs/decisions/0016-close-the-open-seams-before-cutting-new-ones.md
+++ b/docs/decisions/0016-close-the-open-seams-before-cutting-new-ones.md
@@ -1,123 +1,102 @@
-# 0016 · 先把开着的口子收上,再开新的
+# 0016 · Close the open seams before cutting new ones
-- **状态**:规划中 · v0.1.0 之后的第一份排期
-- **成文**:2026-09-02(约定见 [README](README.md))
-- **相关**:本文是对 [0001](0001-ontology-import-and-governance.md) 到 [0015](0015-recording-a-sentence-is-not-asserting-a-fact.md) 逐篇对照代码复核之后写的;每一条「待做」都能在那十五篇里找到出处。复核本身的产物是各篇里 2026-09-02 的修订记录,本文只排顺序、只讲为什么是这个顺序
+- **Status**: in progress · the first schedule after v0.1.0 · A1, A2, A3 done · B1 done (#227) · B2a done (#238, [0017](0017-a-contradiction-points-upstream.md)), B2b planned · B3: the signature half done (#190 / #196), cross-pack signatures and range-aware direction done (#233), `disjointWith` into resolution still open · D2's blank-base problem worked around with builtin `metric` / `dimension` classes (#231), the pack itself still planned · the lakehouse engines landed ahead of D4 (#239, [0018](0018-the-lakehouse-is-one-protocol-away.md))
+- **Written**: 2026-09-02 · condensed into English 2026-09-03
+- **Related**: written after checking [0001](0001-ontology-import-and-governance.md) through [0015](0015-recording-a-sentence-is-not-asserting-a-fact.md) against the code; every item below has its source in those fifteen records, whose 2026-09-02 revision notes are the check's product. This record only orders them and says why this order.
-> 这一篇没有实验数字。它是一次盘点:v0.1.0 发出去了,README 的能力表写了十二行,
-> 十五篇决策记录里有三篇的状态行落后于同一个 PR 里的代码。**在开下一条大线之前,
-> 先把「说的」和「做的」对齐一遍。**
+## The problem
-## 现状,一段话
+v0.1.0 shipped with twelve capability rows in the README while three of fifteen records had
+status lines behind the code in the same PR. Before the next big line, align "said" with
+"done". The half-built pieces still open:
-一个 Rust 二进制加一个 Postgres:摄入(十种格式、URL / RSS / GitHub / Jira 同步、按源令牌推送)→
-混合检索 → 抽取(本体驱动、预算内全铺、超预算按块检索加祖先补齐、写入时按签名掰正方向)→
-三段消解(等值召回、画像相似、包含关系)加攒批裁决 → 双时态账本(证据、supersedes、合并可回滚)→
-本体从语料长出来(计数采纳、可撤销、默认开)→ 推理机 R0 全建、R1 带开关 → 语义层(概念映射另存、口径有历史)→
-问数(Ontology2SQL,源授权到工作区)→ 对话是 agentic 的,七个工具,跨轮回放自己做过什么 → MCP 只读五工具、个人令牌。
-告警五种,审计台账,每库角色矩阵。约 4.7 万行 Rust、2.2 万行前端、18 个连库测试、8 份可重跑语料。
-
-**这些都是真的。** 下面这些也是真的:
-
-| 半成品 | 出处 | 症状 |
+| Half-built | Source | Symptom |
|---|---|---|
-| `pending_facts` 只有表没有运行时;`remember` 整个停用 | 0015 | README 里有 Chat 记忆,用户用不到 |
-| MCP 只有 API,没有令牌界面 | 0014 | README 里有 MCP,用户配不了 |
-| `utopia-mcp` / `utopia-connectors` / `utopia-graph` 三个 crate 是三行占位,其中一个宣称的工具名从未实现 | 0014 | 读源码的人被误导 |
-| 证明树只展开一层;增量维护没做;派生 vs 断言矛盾不记信号 | 0002 | README 说 derivation path expands back to the sentence,只对了一层 |
-| `disjointWith` 落库了,消解侧仍用硬编码 `CONFUSABLE_TYPE_KEYS` | 0009 / 0001 P5 | 本体声明了不相交,合并候选照样跨过去 |
-| 合并路径没有签名复核〔已做,#196;采纳那条路也没有,#190,一并做了〕 | 0012 待做 | 写入时掰正的方向,合并一次就能再反回去 |
-| 类型消解只能手动跑 | 0001 P3a | 大本体下新实体的细化靠人记得点一下 |
-| 中文库拿到纯英文本体;`zh.ts` 没追平所以界面默认英文 | 0008 / 0004 | 中文用户两头都是英文 |
-| `merge_key` 不折 `_by`;叙述动词进本体 | 0007 | 同一关系两个方向并存 |
-| 预算 24,000 与每块 40 / 30 / 30 标着「待测」;答案键仍是人填的;混装准确率没测 | 0006 / 0008 | 所有「更好」都说不清是改动还是方差 |
-| 语义层映射不留证据;多源同概念全给模型挑 | 0011 | 口径是黑箱 |
-| 映射探查硬依赖 `metric` / `dimension` 两个 key,空库静默零条 | 0009 未决 | 不装包就没有问数语义层 |
-| 关掉自动扩本体后没有「新说法」提醒 | 0003 | 索引铺好了,提醒没做 |
-| `document.no_text_layer` 未接,OCR 端点不存在 | 0005 | 扫描件全绿 |
-| 死代码与陈旧注释:`graph.rs` 的 `confirmed_mappings()`、`confirm_fact` 里的 `mapped_to` 连接、`bootstrap_ontology.rs` 开头的注释、`reasoning.rs` 里写错的迁移号、测试抬头写错的迁移号 | 0010 / 0012 / 0002 / 0014 | 下一个读的人会信 |
-
-## 判据
-
-以后再排期,用这几条判,不重新辩论:
-
-1. **说的和做的要一致。** README 的一行承诺、决策记录的一个状态行、代码里的一句注释——三者不一致时,先改到一致,再谈新功能。[0015](0015-recording-a-sentence-is-not-asserting-a-fact.md) 讲的是助手对用户,这条是我们对读者。
-2. **写图的东西先于读图的东西。** 一个会悄悄改图的半成品比没有更坏;一个只读的半成品只是少一个功能。所以 `pending_facts` 排在证明树前面。
-3. **能量的先做。** `scripts/bench/` 在,语料在,缺的是外部答案键。没有它,本体与抽取侧的任何调参都是把过拟合调得更紧([0006](0006-ontology-scale-and-the-prompt.md) 未决)。
-4. **模拟与执行门等地基。** README 路线图的头两条——决策推演、执行校验——本质是「**不落账的提议叠加在账本之上**」。0015 的 `pending_facts` 就是这个形状的第一块砖:一条提议、原句在上三元组在下、人点头才进账。先把这块砖砌稳,模拟引擎才有可以叠的东西。
-
-## 四条线,按这个顺序
-
-### A · 收口——两到三周,不加新概念
-
-**A1 · 把 0015 接上。**〔已做,形态与下文有两处不同,见 0015 末尾的修订:助手说不出 N,确认卡在对话里随抽取完成长出来。〕 抽取遇到记忆文档(`memory::is_memory_document`,已写好零调用)时写 `pending_facts` 而不是 `facts`;
-Review 加 `pending` 一档,界面**原句在上、三元组在下**;确认 → `insert_fact`,**置信度不动**(0011 的教训:别拿浮点数表达人的态度,人的态度在审计台账里);
-拒绝 → 写 `rejected_facts`,下一轮重抽先查它;`remember` 的回话改成「已记录,抽出 N 条待你确认」;然后把 `REMEMBER_ENABLED` 改回 true。
-**验收**:那条实测(「Acme 把总部搬到深圳」)跑一遍,图上不出现空谓词活边,Review 里出现一条带原句的待确认项。
-做完这一条,MCP 可以放 `remember`(scope = write,令牌以人的身份提议,人自己确认)——0014 未决的答案由此成立。
-
-**A2 · 令牌有界面。**〔已做,`feat/tokens-have-a-page`〕Account 页:列表(前缀、名字、scope、库、最后使用、过期)、发放(明文只显示一次)、撤销;旁边给一段可复制的 MCP 客户端配置(URL + Authorization 头)。
-三个占位 crate **删掉**(已做,与 A3 同一刀):`utopia-mcp` 宣称的三个工具从未存在,服务端住在 `utopia-server/src/api/mcp.rs`;`utopia-graph` 没有对应物;`utopia-connectors` 的意图是对的,但两个连接器看不出边界该划在哪,[0013](0013-a-source-should-hand-over-its-history.md) 说等第三个来了再看抽象,crate 是同一个判断。占位是对读者的承诺,兑现不了就别留,需要时 `cargo new` 是几分钟的事。
-
-**A3 · 清死代码与陈旧注释。**〔已做,分两刀:#188 那批清了五处死代码与三个占位 crate;`chore/comments-catch-up` 补了还在描述「兜底谓词」的五处注释和一处指向仓库外 DESIGN.md 的引用〕上表最后一行那五处,加上 `resolution.rs` 里 `CONFUSABLE_TYPE_KEYS` 上方停留在旧世界的注释。一个 PR。
-
-**A4 · README 与代码对表。** Reasoning 那一行的 "derivation path expands all the way back to the original sentence" 在 B1 做完之前改成只承诺一层;
-Chat memory 与 MCP 两处在 A1 / A2 落地前标 in development。中英两份一起改。
-
-### B · 推理机补完——A 之后,与 C 并行
-
-**B1 · R2 证明树。**〔已做,`feat/proof-tree`;实际是链不是树,理由见 0002 R2 的修订〕递归展开到叶子 chunk 的 API + 实体面板里可展开的树。数据结构(`fact_derivations`)已够。
-**B2 · 派生 vs 断言矛盾要有信号。** `axiom_violations` 加一种 kind(`derived_contradiction`),进 Review 同一档——0002 写了没做的那一行。(完整方案见 [0017](0017-a-contradiction-points-upstream.md);B2a 引擎与队列已做,B2b 图与面板上的可见性待做。)
-**B3 · `disjointWith` 进消解,合并路径复核签名。** `classify_type_drift` 改从 `entity_type_disjoint` 读(没声明就退回今天的行为,不硬编码);
-`merge_entities` 搬事实前跑一遍与写入时相同的 domain / range 检查,违反的进 `axiom_violations` 而不是静默搬过去。这是 0009 与 0012 各自待做的同一件事。
-〔**签名那一半已提前做了**(#190 / #196 逼出来的,A 线里插队):`ontology::judge_direction` 一处判断,抽取与采纳共用;合并后对搬动过的事实报 `signature` 违规;R0 多一类。剩下 `disjointWith` 进消解那一半仍在这里。〕
-**B4 · R3 增量维护——后置。** 只在测量台上全量重推超过一个明确阈值(建议:`ai-timeline-ends` 语料超过 10 秒)时才做。今天每次全量重推活得下去。
-
-### C · 本体与抽取质量——先造尺子,再调参
-
-**C1 · 外部答案键。** 0006 未决的那条:维基条目配 Wikidata `P31` 作类型答案,生成脚本入库。有了它,才能跑 0008 的混装对照(`run.mjs --packs` 已支持)与 0006 的预算曲线。
-**C2 · 类型消解自动触发。** 抽取结束入队一次类型消解(与 `bootstrap_ontology` 并列),只自动落「在原类子树里」的那一档,跨轴仍进人工。**排在 C1 后面**:没有尺子,自动跑等于把噪声规模化(0001 P3 的原话)。
-**C3 · 中文本体。** 三选一,倾向第一个:装包时若 `ontology_lang = zh`,由 LLM 批量生成中文 **description**(模型读的那一行,0004 说它该跟语料语言),存进库、原文 IRI 不动、`label` 不动(label 是 key 的展示,key 永不翻译);
-另两个是「等社区出中文包」与「什么都不做只提示」。同时把 `zh.ts` 追平,把 `navigator.language` 加回 `detect()`。
-**C4 · 0007 的两条尾巴。** `merge_key` 折 `_by` 并把整组标成需对调;叙述动词交给那次同义归并的 LLM 调用一并审(「这些里哪些是文章的口吻」),可撤销。
-**C5 · 0003 的提醒。** 关掉开关后 Ontology 页显示「自上次以来有 N 个新说法」,数据从 `ontology_misses` 按 `dismissed_at IS NULL` 算,索引已在。
-
-### D · 语义层与问数——C1 之后
-
-**D1 · 映射留证据。** `concept_mappings` 加 `evidence`(探查时读了哪几张表的 schema 文档,chunk id 数组),数据映射页展示。
-**D2 · `metric` / `dimension` 出包。** 0009 未决的答案:做一个「Utopia 语义层」包,带 IRI、可选、可替换,与其它包同一条装法;`mappings.rs` 改按 IRI 找类型,不按 key。
-**D3 · 多源同概念的选法。** 先做最简单的规则:问数时若挂载的源只有一个,只给那个源的映射;多个源全给。规则写在 `mappings::confirmed` 一处。
-**D4 · MySQL 线协议。** 白捡 TiDB / OceanBase / Doris / StarRocks,`sqlparser` 换方言。README 路线图里的那条。
-
-### E · 企业交付——v0.2 的门槛,可穿插
-
-- **凭据静态加密**(SECURITY.md 承诺「1.0 之前」;LLM key 与连接串同待遇)——小,可提前。
-- **OIDC SSO**;**备份与恢复命令**;**十万文档基准**(先跑再说,数字比设计重要)。
-- **`document.no_text_layer` + OCR**:告警那条接上要先有 OCR 端点(docling-serve sidecar),两件一起做,提示语才完整(0005 留到第二批的那条)。
-- **`instant` 精度**:0013 说了触发条件(按当地营业日统计、或事件聚在午夜附近的源),没触发前不做。
-
-### 后置:模拟引擎与执行门
-
-README 路线图的头两条**不在这份排期里**,理由见判据 4。它们需要三样地基:`pending_facts` 的提议语义(A1)、派生的可解释性(B1)、派生与断言的矛盾信号(B2)。
-A 与 B 做完之后单独写一篇决策记录,那时再排。
-
-## 顺序与理由,一句话
-
-**A → B ∥ C → D → E。** A 是对齐「说的和做的」,不做新东西;B 与 C 互不依赖,B 动写图的路径、C 动尺子;D 等 C1 的尺子;E 可穿插,加密先。
-
-## 一条新约定
-
-**状态行由实现它的 PR 负责更新。** 0011、0014、0015 三篇的状态行都落后于同一个 PR 里的代码——写决策记录的人和写代码的人是同一个人,在同一个提交里,仍然漏了。
-所以不靠记性:PR 模板里加一行「这个改动实现或推翻了哪篇决策记录,状态行改了没有」。已写进 [README](README.md) 的约定。
-
-## 开放问题
-
-- **A1 的闸只拦记忆,还是拦所有单条交互式写入?** 0015 未决。今天只有 `remember` 一条路,先拦它;将来「在图上手工加一条边」的界面出现时,按同一张表走。
-- **C3 生成的中文描述算不算「原文保真」的例外?** 它不是导入的原文,是我们生成的投影。倾向记成投影(可重跑、可丢弃),原文 IRI 与英文描述保留在包里。
-- **B4 的阈值**:10 秒是拍的。
-- **对话内 agent 生成的界面走哪个协议。** A1 的确认卡载荷是声明式的(chunk + 三元组列表),渲染器是我们的 React。
- 讨论过 A2UI(2026-09-02):它解决的是「你不控制的客户端渲染你 agent 的 UI」,而 Chat 页两头都是我们的;
- 卖点成立的地方在 MCP 那一侧,那里对应的是 MCP 自己的 UI 扩展,不是 A2UI。**判据是谁的客户端在看**,
- 等 MCP 放开写工具、有外部客户端要渲染这张卡时再定。
-- **要不要把抽取层拆出 `utopia-server`。** 今天约 6,500 行领域代码(extraction、type_resolution、bootstrap_ontology、predicate_match、adjudication、owl_import、连接器)住在 HTTP crate 里,依赖方向仍是干净的一条线。讨论过(2026-09-02)决定**不拆**:唯一实在的收益是脱离 server 测纯函数,而它们的单测今天已经不起 server;搬动 1,500 行的 `extraction.rs` 会让紧接着的 A1 diff 和 blame 变难看。判据留下:**等它造成实际损失再拆,损失的定义是「有东西没法脱离 server 测试」**。连接器那一块的边界等第三个连接器(飞书)来了再定。
-- **十万文档基准跑出来之后**,单进程 worker 与内嵌 Tantivy 的天花板在哪,决定了要不要在 v0.2 谈横向扩展。现在不谈。
+| `disjointWith` is stored; resolution still uses the hard-coded `CONFUSABLE_TYPE_KEYS` | 0009 / 0001 P5 | merge candidates cross declared disjointness |
+| type resolution runs only by hand | 0001 P3a | refining new entities depends on someone clicking |
+| a Chinese base gets an English ontology; `zh.ts` lags, so the interface defaults to English | 0008 / 0004 | Chinese users get English at both ends |
+| `merge_key` does not fold `_by`; narrative verbs enter the ontology | 0007 | one relation in two directions |
+| the 24,000 budget and 40 / 30 / 30 per-chunk counts are untested; the answer key is hand-filled; mixed packs unmeasured | 0006 / 0008 | no "better" can be told from variance |
+| mappings carry no evidence; with several sources every same-name concept goes to the model | 0011 | the calculation is a black box |
+| the mapping probe depends on the `metric` / `dimension` keys | 0009 | a blank base found nothing until the builtin classes of #231; the pack is D2 |
+| no "new phrasings" reminder once auto-extension is off | 0003 | the index is there, the reminder is not |
+| `document.no_text_layer` unwired, no OCR endpoint | 0005 | scanned documents show all green |
+
+## Criteria
+
+1. **Said and done agree.** README line, status line, code comment: when they disagree, fix
+ that before adding features. 0015 is the assistant to the user; this is us to the reader.
+2. **What writes the graph comes before what reads it.** A half-built thing that silently
+ changes the graph is worse than nothing; a half-built read is a missing feature. So
+ `pending_facts` came before the proof tree.
+3. **Measurable first.** `scripts/bench/` and the corpora exist; the external answer key does
+ not, and without it tuning tightens overfitting ([0006](0006-ontology-scale-and-the-prompt.md)).
+4. **Simulation and execution gates wait for the ground.** The README roadmap's first two
+ items are proposals overlaid on the ledger; `pending_facts` (A1), the proof chain (B1) and
+ the contradiction signal (B2) are what they stack on.
+
+## The lines: A → B ∥ C → D → E
+
+**A · Close the seams**, no new concepts. A1 wire 0015 (done). A2 a tokens page and delete
+the three placeholder crates (done; a placeholder is a promise to the reader, and `cargo new`
+takes minutes). A3 dead code and stale comments (done, #188 and `chore/comments-catch-up`).
+A4 README against code, both languages: promise only what has landed.
+
+**B · Finish the reasoning engine**, after A, parallel with C. B1 the R2 proof chain (done,
+#227; a chain, not a tree — see [0002](0002-reasoning-engine.md)). B2 the derived-vs-asserted
+contradiction signal, `axiom_violations` kind `derived_contradiction` (B2a done; B2b, visibility
+on graph and panel, planned — 0017). B3 `classify_type_drift` reads `entity_type_disjoint`
+instead of the hard-coded list, today's behavior when nothing is declared (class kinship from the
+hierarchy landed first, #226). B4 R3 incremental
+maintenance, deferred until a full re-derivation of `ai-timeline-ends` exceeds a threshold
+(proposal: 10 seconds).
+
+**C · Ontology and extraction quality**: build the ruler, then tune. C1 an external answer
+key, Wikidata `P31` as the type answer for Wikipedia articles, generated by script; only then
+can the 0008 mixed-pack comparison (`run.mjs --packs`) and the 0006 budget curve run. C2 type
+resolution queued after extraction, auto-applying only the "within the original subtree"
+tier; after C1, or it scales noise. C3 with `ontology_lang = zh`, generate Chinese
+`description` rows by LLM (the line the model reads follows the corpus,
+[0004](0004-language-and-localization.md)), IRIs and `label` untouched; catch `zh.ts` up and
+put `navigator.language` back into `detect()`. C4 fold `_by` in `merge_key` and let the
+synonym-merging LLM call review narrative verbs, reversibly. C5 "N new phrasings since last
+time" from `ontology_misses` where `dismissed_at IS NULL`.
+
+**D · Semantic layer and data questions**, after C1. D1 `concept_mappings` gains `evidence`
+(the schema-doc chunk ids read during probing). D2 a "Utopia semantic layer" pack with IRIs,
+optional and replaceable; `mappings.rs` finds types by IRI. D3 one mounted source → only its
+mappings, several → all, in `mappings::confirmed`. D4 the MySQL wire protocol (TiDB /
+OceanBase / Doris / StarRocks, `sqlparser` dialect switch); the lakehouse engines came first.
+
+**E · Enterprise delivery**, the v0.2 bar, can interleave. Credentials encrypted at rest
+(SECURITY.md promises it before 1.0) — small, do it early. OIDC SSO; backup and restore; a
+hundred-thousand-document benchmark (numbers beat design). `document.no_text_layer` together
+with an OCR endpoint (docling-serve sidecar). `instant` precision only when the trigger in
+[0013](0013-a-source-should-hand-over-its-history.md) fires.
+
+**Deferred: simulation engine and execution gate** (criterion 4); a separate record once A
+and B are done.
+
+## A new convention
+
+The PR that implements a record updates its status line. 0011, 0014 and 0015 fell behind the
+code in the same PR by the same author, so the PR template asks which record the change
+implements or overturns and whether the status line moved. Written into the [README](README.md).
+
+## Open questions
+
+- Does the A1 gate hold only memories or every single-item interactive write? Only `remember`
+ today; a hand-added edge should take the same table.
+- Are C3's generated descriptions an exception to "keep the original text"? Leaning towards a
+ projection — rerunnable, disposable — with the IRI and English description kept.
+- B4's 10-second threshold is a guess.
+- Which protocol for agent-generated interfaces in chat. A2UI (discussed 2026-09-02) solves
+ "a client you do not control renders your agent's UI"; on the Chat page both ends are ours,
+ on the MCP side the counterpart is MCP's own UI extension. Decide when an external client
+ needs the A1 card.
+- Whether to split extraction (about 6,500 lines) out of `utopia-server`. Decided not to
+ (2026-09-02): the only real gain is testing pure functions without the server, which their
+ unit tests already do. Split when something cannot be tested without the server; the
+ connector boundary waits for Feishu.
+- After the benchmark: where the single-process worker and embedded Tantivy hit their ceiling
+ decides whether v0.2 discusses horizontal scaling.
diff --git a/docs/decisions/0017-a-contradiction-points-upstream.md b/docs/decisions/0017-a-contradiction-points-upstream.md
index faecada8..f321908d 100644
--- a/docs/decisions/0017-a-contradiction-points-upstream.md
+++ b/docs/decisions/0017-a-contradiction-points-upstream.md
@@ -123,8 +123,8 @@ chain. Here a person sees "the engine could have drawn this edge, and what stopp
**Edges on the graph**:
-- A contested assertion switches to the **alert colour**, edge and all, with no reliance on
- a node ring. The request was explicit: a ring sits on the node while the edge stays grey,
+- A contested assertion switches to the **alert color**, edge and all, with no reliance on
+ a node ring. The request was explicit: a ring sits on the node while the edge stays gray,
and peripheral vision cannot tell them apart.
- A blocked derivation is drawn as a **ghost edge**: the same hue mixed toward `EDGE_DIM`
(under premultiplied blending alpha cannot darken an edge; only the RGB can be mixed — see
@@ -133,14 +133,14 @@ chain. Here a person sees "the engine could have drawn this edge, and what stopp
program is introduced for this.
- The hover label chip gets a "⚠" prefix, and the tooltip states the dispute.
-**Colour**: one new token, `--u-contest`, set to **#ff6a3d** (hot coral orange). It has to
-stand apart from three colours already in use: derived edges are gold
+**Color**: one new token, `--u-contest`, set to **#ff6a3d** (hot coral orange). It has to
+stand apart from three colors already in use: derived edges are gold
(`rgb(231,197,124)`), warning chips are amber (`--u-warn` #f2b66d, too close to gold to
-double as an edge colour), and danger is pink (`--u-danger` #ff9daf, reserved for
+double as an edge color), and danger is pink (`--u-danger` #ff9daf, reserved for
destructive actions). Coral orange is bright enough on the dark ground, its hue is far from
-all three, and it is no common type colour (the type palette leans blue, green and violet).
-Edge colour `rgba(255,106,61,0.55)`; ghost edge `lerp(#ff6a3d, EDGE_DIM, 0.55)`. This is
-the only new colour; chips on the Ontology and Review pages use the same token.
+all three, and it is no common type color (the type palette leans blue, green and violet).
+Edge color `rgba(255,106,61,0.55)`; ghost edge `lerp(#ff6a3d, EDGE_DIM, 0.55)`. This is
+the only new color; chips on the Ontology and Review pages use the same token.
### 4. Data
@@ -196,7 +196,7 @@ B1 (#227) merges first after a rebase; B2a branches from it.
thousand.
- **Granularity of `accepted`**: per pair (one derivation against one assertion). An
assertion hit by several derivations needs several clicks; aggregating the exemption per
- assertion would generalise "this axiom does not apply to this assertion" too far. Per
+ assertion would generalize "this axiom does not apply to this assertion" too far. Per
pair first, measure later.
- **Could ghost edges be too many?** Their number is bounded by the (capped) violation
count, so it should stay manageable; failing that, draw them only when a related node is
diff --git a/docs/decisions/README.md b/docs/decisions/README.md
index d14c9f94..90b28d6e 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -1,59 +1,52 @@
-# 决策记录
+# Decision records
-代码记录了**做成什么样**,git 记录了**什么时候改的**。两者都不记录**为什么这样而不是那样**,以及**哪些路走过发现是死路**。这个目录记那个。
+Code records what was built and git records when it changed. Neither records why it was built this way and which roads turned out to be dead ends. This directory does.
-判断标准:如果半年后有人(包括我们自己)看着某段代码问"当初为什么不直接……",而答案不在代码里,那就该有一篇。
+The test for writing one: if someone (including us) looks at a piece of code in six months and asks "why not simply…", and the answer is not in the code, there should be a record.
-## 约定
+## Conventions
-**文件名** `NNNN-短横线英文标题.md`,四位序号,按创建顺序递增,不留空号。序号只是稳定的引用锚点,不表示优先级。
+**File names** are `NNNN-short-english-title.md`, four digits, increasing in creation order, no gaps. The number is a stable anchor for references and says nothing about priority.
-**目录扁平**。在出现第二种性质不同的文档之前,不建子目录。
+**The directory is flat.** No subdirectories until a second kind of document appears.
-**修订就地留痕,不静默改写**。结论变了——尤其是因为查证推翻了原判断——**在原处保留「修订记录」块**写清:原本是什么、为什么错、依据什么改的。不要把错误的那版删干净。
+**Revisions stay in place.** When a conclusion changes, especially because checking the code overturned it, keep a dated revision note where the original claim stood: what it said, why it was wrong, what changed it. The ledger this product keeps never updates a fact in place; a correction inserts a new row that supersedes the old one, because the change of mind is information. Records follow the same rule: knowing "we assumed a range would take effect immediately, checked, and found it never did" tells the next reader what to check first.
-> 这条不是文档洁癖,是同一条产品原则。我们的账本从不原地 UPDATE 事实,纠正是插入新行 + `supersedes` 旧行,因为**认知变更本身是信息**。决策记录同理:知道"我们曾经以为 range 能立刻生效,查完发现一条都没有",比只看到最终结论有用得多——它告诉你下次该先查什么。
+**When too much has changed**, write a new record and mark the old one `superseded by NNNN` at the top instead of rewriting it.
-**结论变得太多时**,另起一篇并在旧篇顶部标 `已被 NNNN 取代`,而不是把旧篇改成新的。
+**The PR that implements a record updates its status line.** Three status lines once lagged behind the code in the same PR, written by the same person. So the PR description answers one question: which record does this change implement or overturn, and is its status line updated. (Added 2026-09-02, from [0016](0016-close-the-open-seams-before-cutting-new-ones.md).)
-**状态行由实现它的 PR 负责更新。** 0011、0014、0015 三篇的状态行曾落后于同一个 PR 里的代码——写记录的人和写代码的人是同一个人、同一个提交,仍然漏了。所以 PR 描述里要答一句:这个改动实现或推翻了哪篇决策记录,状态行改了没有。(2026-09-02 加,出处见 [0016](0016-close-the-open-seams-before-cutting-new-ones.md)。)
+**Line numbers drift and file names change.** Prefer function, table and constant names over `file.rs:123`. Migrations were consolidated from 53 files into one per domain (#130, #131); older references to migration files are by domain.
-**行号会漂,文件名会换。** 正文里的 `file.rs:123` 是成文时的坐标,不保证仍然准确;迁移在 #130 / #131 从 53 份折成 10 份、一个域一份,所以 2026-08-31 之前写的迁移文件名都要按域重找。引用时优先写函数名、表名、常量名。
+**Language: English.** The first sixteen records were written in Chinese and condensed into English on 2026-09-03; the Chinese originals remain in git history. Code comments are still Chinese; UI, README and records are English.
-**语言**:2026-09-03 起新记录用英文(0017 起),读者已经包括外部贡献者;此前的十六篇仍是中文,与代码注释一致,是否回译等有人需要时再定。
+## Index
-## 索引
-
-| | 文档 | 状态 |
+| | Record | Status |
|---|---|---|
-| 0001 | [本体导入与治理路线](0001-ontology-import-and-governance.md) | 进行中 · P0–P2c 全建成;P3 按预算落地、P3a 只能手动跑;P3b 建成但形态不同;**P4b/P4c 待做**;P5 已由 0002 落地;判据 2 被 0012 推翻一半 |
-| 0002 | [推理机](0002-reasoning-engine.md) | R0 建成(事实层五类含签名、本体自检八类)· R1 建成带开关默认关 · R2 证明链到原句(B1)· **R3 未做** · 派生 vs 断言矛盾无信号 |
-| 0003 | [本体从语料里长出来,人站在哪一环](0003-ontology-growth-loop.md) | 已建成且仍在跑 · 起点已被 0010 与种子退场改写 · 「拒绝有记忆」被 0007 推翻重做 · 新说法提醒待做 |
-| 0004 | [语言:哪些字跟着看的人走,哪些跟着语料走](0004-language-and-localization.md) | 已建成 · L0–L3 全落地 · 界面刻意不猜浏览器语言 · 「中文内置本体」随播种退场作废 |
-| 0005 | [告警中心](0005-alert-center.md) | 已建成 · 五种告警 · 三个决定推翻两个(就地留痕)· `no_text_layer` 待接 |
-| 0006 | [本体规模与抽取提示词](0006-ontology-scale-and-the-prompt.md) | 已建成 · 「内置类恒在」换成祖先补齐 · 预算与候选数仍待测 · 外部答案键未做 |
-| 0007 | [谁来决定一个说法值不值得成为关系](0007-who-decides-what-becomes-a-relation.md) | 已建成 · 六条缺陷全修 · 起点(种子、`related_to`)已不存在 · `_by` 折叠与叙述动词待解 |
-| 0008 | [预制本体包作为冷启动](0008-ontology-packs-as-cold-start.md) | 已建成 · 五包内嵌、多选、对齐表 22 条 · 三个开放问题全开,中文标签变严重 |
-| 0009 | [「还没判出来」不该是一个类](0009-no-type-is-a-type.md) | 已实施 · `disjointWith` 落库但消解侧未消费 · `metric`/`dimension` 去处未答且有可见代价 |
-| 0010 | [「说不出是什么关系」不该是一个关系](0010-no-relation-is-no-relation.md) | 已实施 · 待做两条随 0011 完成 · 两处死代码待清 |
-| 0011 | [「怎么算」不是「有什么」](0011-a-mapping-is-not-a-fact.md) | 已实施(#126 / #140 / #148)· 证据链未做 · 多源选法未做 |
-| 0012 | [本体是一份契约,不只是一份建议](0012-the-ontology-is-a-contract-not-a-suggestion.md) | 已实施 · 违反率 57%→4%、反向 39→0 · 守卫已扩到采纳与合并(#190 / #196),另两条待做仍在 |
-| 0013 | [一个来源该交出它的历史,不是它的现状](0013-a-source-should-hand-over-its-history.md) | 已实施两个(GitHub / Jira)· 文档协作类未开工 |
-| 0014 | [身份跟着人,范围跟着令牌](0014-identity-from-the-person-scope-from-the-token.md) | 已实施(#180)· MCP 只读五工具 · 令牌页在账户层(A2)· 误导性的占位 crate 已删 |
-| 0015 | [记下一句话,不等于断言一个事实](0015-recording-a-sentence-is-not-asserting-a-fact.md) | 已实施 · 记忆抽出的事实进 `pending_facts`,Review 新档 + 跟在 remember 步骤后的确认卡 · `remember` 重新打开 · MCP 放开写是下一刀 |
-| 0016 | [先把开着的口子收上,再开新的](0016-close-the-open-seams-before-cutting-new-ones.md) | 规划中 · v0.1.0 之后的排期:A 收口 → B 推理机 ∥ C 尺子与本体 → D 语义层 → E 企业交付;模拟引擎后置 |
-| 0017 | [A contradiction points at an error upstream](0017-a-contradiction-points-upstream.md) | B2a 已实现(引擎与队列:逐条封顶、按规则对聚合、卡片给线索与修法)· B2b 待做:争议在图和面板上原地可见(新警戒色)|
-| 0018 | [The lakehouse is one protocol away](0018-the-lakehouse-is-one-protocol-away.md) | 已实施 · 问数引擎扩到 HTTP 族:Trino(Iceberg / Delta / Hive)、Databricks、Snowflake,scheme 决定引擎,只有回放测试 · MaxCompute 未做 |
-
-## 不是决策记录的那些
-
-**[../pipeline.md](../pipeline.md) —— 一份文档如何变成图谱。** 决策记录讲「为什么这样
-而不是那样」;那一篇讲「东西怎么流的、在哪一步会被丢掉」,带五张 mermaid 图(2026-09-02 加了公理那一张)。
-新来的人先看它,再回来看这里的理由。
-
-这是本目录约定里说的「第二种性质不同的文档」。它没进子目录——放在 `docs/` 根上,
-`.gitignore` 里单独开口,跟 `decisions/` 并列。
-
-## 不放这里的东西
-
-`docs/` 根目录是**本地草稿区**(`.gitignore` 里 `/docs/*` 忽略,只对 `/docs/decisions/` 开口)。随手的调研笔记、临时清单、跑测试的中间产物放那儿,不入库。等某份草稿沉淀出了值得留的判断,再作为一篇决策记录搬进来。
+| 0001 | [Ontology import and governance](0001-ontology-import-and-governance.md) | In progress · P0–P2c built; the P3 budget built, P3a by hand only; P3b built in a different shape; P4b / P4c pending; P5 delivered by 0002; criterion 2 half overturned by 0012 |
+| 0002 | [Reasoning engine](0002-reasoning-engine.md) | R0 checker and R1 materialization (KB switch, default off) built; R2 proof chain (#227); contradiction signals per 0017; R3 incremental maintenance not built |
+| 0003 | [The ontology grows out of the corpus](0003-ontology-growth-loop.md) | Built and running, default on · starting point rewritten by 0010 and the retired seeds · dismissal redone per 0007 · the "new phrasings" reminder pending |
+| 0004 | [Language follows the reader of each text](0004-language-and-localization.md) | Built · UI strings, coded server errors, ontology description language and the locale of generated text · the browser language is not guessed yet |
+| 0005 | [The alert center](0005-alert-center.md) | Built · five alert kinds live, search and paging in the panel · `document.no_text_layer` still unwired |
+| 0006 | [Ontology scale and the extraction prompt](0006-ontology-scale-and-the-prompt.md) | Built · the character budget (24,000) and per-chunk retrieval live, values untested · answer keys still hand-filled |
+| 0007 | [Counting decides what becomes a relation](0007-who-decides-what-becomes-a-relation.md) | Built · adoption decided by counting (`MIN_DOCS = 2`, `MIN_SIGNALS = 3`), proposals persist (#112) · narrative verbs and `_by` folding still open |
+| 0008 | [Ontology packs as the cold start](0008-ontology-packs-as-cold-start.md) | Built · five packs embedded, multi-select at creation, schema.org by default · three open questions stay open; Chinese labels got worse |
+| 0009 | [An undecided type stays empty](0009-no-type-is-a-type.md) | Implemented · `type_id` nullable, builtin classes gone · kin classes go to Review (#226), `disjointWith` still unread by resolution · `metric` / `dimension` builtin on demand (#231) |
+| 0010 | [An unnamed relation stays empty](0010-no-relation-is-no-relation.md) | Implemented · `predicate_id` nullable, `related_to` gone, wording recovered by `fact_surface_predicate` · follow-ups done with 0011 |
+| 0011 | [A mapping is configuration](0011-a-mapping-is-not-a-fact.md) | Implemented (#126 / #140 / #148) · Review flow and revision history rebuilt · the evidence chain not built |
+| 0012 | [The ontology is a contract](0012-the-ontology-is-a-contract-not-a-suggestion.md) | Implemented · violation rate 57% → 4%, reversals 39 → 0 · guard extended to adoption and merge (#190 / #196) · reified-shell filter at pack import open |
+| 0013 | [A source hands over its history](0013-a-source-should-hand-over-its-history.md) | Implemented for GitHub, Jira and Notion (#134 / #135 / #213) · Feishu and Confluence not started · `instant` precision not triggered |
+| 0014 | [Identity from the person, scope from the token](0014-identity-from-the-person-scope-from-the-token.md) | Implemented (#180) · five read-only MCP tools over Streamable HTTP · tokens page at `/account/tokens` · `can_write` still hard-coded false |
+| 0015 | [A recorded sentence waits for a nod](0015-recording-a-sentence-is-not-asserting-a-fact.md) | Implemented · memory facts wait in `pending_facts`, nod queue and chat card, `remember` reopened · MCP write is the next cut |
+| 0016 | [Close the open seams before cutting new ones](0016-close-the-open-seams-before-cutting-new-ones.md) | In progress · A done · B1 and B2a done, B2b and `disjointWith` open · C untouched · D2 worked around (#231); the lakehouse landed ahead of D4 (#239) |
+| 0017 | [A contradiction points at an error upstream](0017-a-contradiction-points-upstream.md) | B2a implemented: engine and queue, per-item cap, aggregation by rule pair, cards with clues and repairs (#238) · B2b planned: disputes visible on the graph and in the panel |
+| 0018 | [The lakehouse is one protocol away](0018-the-lakehouse-is-one-protocol-away.md) | Implemented: Trino (Iceberg / Delta / Hive), Databricks and Snowflake behind the same trait, scheme picks the engine (#239) · replay tests only, real clusters wanted (#240–#242) · MaxCompute waits |
+
+## Not a decision record
+
+**[../pipeline.md](../pipeline.md), how a document becomes a graph.** Records explain why; that page explains how things flow and where they get dropped, with five mermaid diagrams. Newcomers read it first, then come back here for the reasons. It is the "second kind of document" the conventions mention, kept at the `docs/` root beside `decisions/`.
+
+## What does not belong here
+
+The `docs/` root is a local scratch area (`/docs/*` is git-ignored except `/docs/decisions/`). Research notes, temporary checklists and test output live there and stay out of the repository. When a draft settles into a judgment worth keeping, it moves here as a record.
diff --git a/docs/pipeline.md b/docs/pipeline.md
index 355e7878..34cb2b9e 100644
--- a/docs/pipeline.md
+++ b/docs/pipeline.md
@@ -1,34 +1,31 @@
-# 一份文档如何变成图谱
+# How a Document Becomes a Graph
-**这一篇不讲为什么,讲东西怎么流的。** 「为什么这样而不是那样」在 [decisions/](decisions/README.md);
-这里回答另一个问题:**我改的这一行,处在整条链的哪个位置,它上游给我什么、我不给下游什么会断在哪。**
+**This page describes how a document flows through the pipeline.** The reasons behind each choice live in [decisions/](decisions/README.md). This page answers a different question: **where the line you are editing sits in the chain, what upstream hands it, and what breaks downstream if you hand nothing on.**
-> 图上最值钱的不是箭头,是**箭头断掉的地方**。每一段末尾都有一节「这里会丢东西吗」,
-> 列的是代码里真实存在的丢弃点与它们在库里的落点——不是"理论上可能失败",
-> 是**已经在 `extraction_drops` 里数得出来的那几种**。
+> The most valuable thing on the map is **where the arrows break**. Every stage ends with a "Where this stage drops things" section listing the drop points that actually exist in the code and where they land in the database: the kinds you can already count in `extraction_drops`, rather than theoretical failures.
-## 全景
+## Overview
```mermaid
flowchart TB
- U[上传 / 来源同步] --> P[解析
parsers.rs]
- P --> C[分块
1200 字符 · 重叠 150]
- C --> E1[嵌入
chunks.embedding]
- E1 --> RDY[(文档 ready
可搜可问)]
- E1 --> X[抽取
每块一次 LLM]
- X --> ENT[实体消解
这一条是谁]
- X --> FCT[事实落库
双时态账本]
- ENT --> ADJ[裁决
攒批一次 LLM]
- ADJ --> MRG[合并 / 保持分开]
- FCT --> TR[类型消解
这一条是什么]
- FCT --> GROW[本体增长
词表外的说法回流成提案]
- MRG --> G[(图谱)]
+ U[Upload / source sync] --> P[Parse
parsers.rs]
+ P --> C[Chunk
1200 chars · overlap 150]
+ C --> E1[Embed
chunks.embedding]
+ E1 --> RDY[(Document ready
searchable and askable)]
+ E1 --> X[Extract
one LLM call per chunk]
+ X --> ENT[Entity resolution
who this mention is]
+ X --> FCT[Facts to store
bitemporal ledger]
+ ENT --> ADJ[Adjudication
one batched LLM call]
+ ADJ --> MRG[Merge / keep apart]
+ FCT --> TR[Type resolution
what this entity is]
+ FCT --> GROW[Ontology growth
out-of-vocabulary terms become proposals]
+ MRG --> G[(Graph)]
TR --> G
- GROW --> ONT[(本体)]
- ONT -.喂回.-> X
- G --> R0[一致性检查
公理 vs 事实 · 不写库]
+ GROW --> ONT[(Ontology)]
+ ONT -.feeds back.-> X
+ G --> R0[Consistency check
axioms vs facts · writes nothing]
ONT --> R0
- G --> R1[物化推导
开关 · 派生另存]
+ G --> R1[Materialized inference
opt-in · derived facts stored separately]
ONT --> R1
R1 --> G
@@ -37,274 +34,229 @@ flowchart TB
style ONT fill:#2d4a5a,color:#fff
```
-**两段式是有意的**:嵌入完成即 `ready`,搜索与问答立刻可用,抽取在后台排队。
-一篇长文档的图谱要几分钟才长出来,但它在几十秒内就能被搜到。
+**The two-phase split is deliberate.** A document is `ready` as soon as embedding finishes; search and Q&A work immediately while extraction queues in the background. A long document takes minutes to grow its graph but is searchable within seconds.
-**本体那条回流虚线是这套东西的循环**:抽取用本体,抽取遇到本体没有的说法就把原词记下来,
-提案回流补进本体,下一批文档的抽取就用上了。见 [0003](decisions/0003-ontology-growth-loop.md)。
+**The dotted line back into extraction is the loop.** Extraction uses the ontology; when it meets a term the ontology lacks, it records the original wording; proposals flow back into the ontology; the next batch of documents is extracted with it. See [0003](decisions/0003-ontology-growth-loop.md).
-**本体从哪来**:建库时什么都不种。起点是可选的预制包(schema.org 默认勾选,另有 W3C Org、PROV-O、FOAF、IOF Core),
-或者用户导入自己的 OWL,或者空着——空库照样能抽,实体没有类型就是没有类型。见 [0008](decisions/0008-ontology-packs-as-cold-start.md)、[0009](decisions/0009-no-type-is-a-type.md)。
+**Where the ontology comes from.** A new database seeds nothing. The starting point is an optional prebuilt pack (schema.org checked by default; also W3C Org, PROV-O, FOAF, IOF Core), a user-imported OWL file, or nothing at all. An empty ontology still extracts; an entity without a type simply has no type. See [0008](decisions/0008-ontology-packs-as-cold-start.md) and [0009](decisions/0009-no-type-is-a-type.md).
-**最下面那两个框是本体的公理在干活**:一致性检查不写 `facts`,只把矛盾摆出来(Review 的 violations / defects 两档);
-物化推导默认关,打开后派生事实另存一张表、图上金色,永远不闭合任何断言事实。见第四节。
+**The bottom two boxes are the ontology's axioms at work.** The consistency check never writes `facts`; it only surfaces contradictions (the Review page's two tiers, violations and defects). Materialized inference is off by default; when on, derived facts go to a separate table, show as gold on the graph, and never close any asserted fact. See section 4.
---
-## 一、抽取一个分块
+## 1. Extracting a Chunk
```mermaid
flowchart TB
- subgraph 提示词
- B{本体装得下预算吗?}
- B -->|装得下| FULL[全量铺
小本体的老路]
- B -->|装不下| RET[按这一块的向量检索
约 40 类 / 30 关系 / 30 属性
+ 命中类的祖先一起铺]
+ subgraph prompt [Prompt]
+ B{Does the ontology fit the budget?}
+ B -->|yes| FULL[Lay out the whole ontology
the small-ontology path]
+ B -->|no| RET[Retrieve by the chunk vector
about 40 classes / 30 relations / 30 attributes
+ ancestors of the hit classes]
end
FULL --> LLM[LLM]
RET --> LLM
- CHK[分块正文 + 本文档已认下的实体] --> LLM
- LLM --> J{输出的每一条}
- J -->|entities| EN[实体
type 从清单挑
specific_type 自由文本]
- J -->|predicate 命中属性| AT[属性事实
值按 datatype 归一]
- J -->|predicate 命中关系| RL[关系事实]
- RL --> DIR{主宾类型
对得上签名?}
- DIR -->|对| OK[落库]
- DIR -->|主语违反且宾语符合| SWAP[按签名对调主宾
留 direction_corrected]
- DIR -->|对调也不合法| NOP[谓词留空
主宾时间证据都留]
- J -->|词表外 + 字面值| LIT[值落 object_value
原词落 proposed_predicate]
- J -->|词表外 + 实体宾语| FB[谓词留空
原词落 proposed_predicate]
+ CHK[Chunk text + entities already accepted in this document] --> LLM
+ LLM --> J{Each item in the output}
+ J -->|entities| EN[Entity
type picked from the list
specific_type free text]
+ J -->|predicate matches an attribute| AT[Attribute fact
value normalized by datatype]
+ J -->|predicate matches a relation| RL[Relation fact]
+ RL --> DIR{Subject and object types
match the signature?}
+ DIR -->|yes| OK[Stored]
+ DIR -->|subject violates, object fits| SWAP[Swap subject and object per the signature
record direction_corrected]
+ DIR -->|swap is also invalid| NOP[Predicate left empty
subject, object, time and evidence kept]
+ J -->|out of vocabulary + literal value| LIT[Value into object_value
original term into proposed_predicate]
+ J -->|out of vocabulary + entity object| FB[Predicate left empty
original term into proposed_predicate]
style LLM fill:#3a3a5a,color:#fff
```
-**`specific_type` 是这一步最容易被忽略的输出**:自由文本、不校验、不入本体,就是模型自己
-对这个实体的说法("vector database software")。类型消解靠它把任务从「读懂这是什么」
-换回「本体里哪个类叫这个名字」。没有它,实测 17 个实体的 `proposed_type` 全是空的——
-因为清单里总有个"差不多"的,模型选了它,心里那个更准的说法就此丢失。
+**`specific_type` is the easiest output to overlook.** Free text, unvalidated, never entered into the ontology: it is the model's own description of the entity ("vector database software"). Type resolution uses it to turn the task from "understand what this is" into "which class in the ontology has this name". Without it, a test run left `proposed_type` empty on all 17 entities: the list always has something close enough, the model picks it, and the more precise description is lost.
-**词表外的两条路都不丢东西**:带字面值的落 `object_value`(而不是凭空造一个叫「2015」的实体),
-带实体宾语的**谓词留空**——不是降级成一个叫「有关联」的关系,那是断言不是含糊([0010](decisions/0010-no-relation-is-no-relation.md))。
-两者的原词都进 `fact_evidence.proposed_predicate`,那是这条事实身上唯一还留着原意的地方,显示时由 `fact_surface_predicate()` 取回。
+**Neither out-of-vocabulary path loses anything.** A literal value goes to `object_value` instead of becoming an entity named "2015". An entity object leaves the **predicate empty** instead of degrading into a relation called "related to", which would be an assertion rather than vagueness ([0010](decisions/0010-no-relation-is-no-relation.md)). In both cases the original term goes to `fact_evidence.proposed_predicate`, the one place on the fact that still carries the original meaning; `fact_surface_predicate()` reads it back for display.
-**命中的关系还要过一道签名**:本体声明了 `employee (organization → person)`,模型照样会写 `Musk employee Microsoft`——
-提示词三轮都压不下去,英语的 "X is an employee of Y" 太强。所以写入时掰正:主语违反 domain 而宾语符合就对调,**绝不静默**,留一条 `direction_corrected`;
-对调也不合法(`OpenAI affectedBy …`,schema.org 里那是医学检验用的)就丢掉谓词、留下主宾与证据。参数顺序是 key 的编码约定,不是关于世界的断言,所以这一处本体是执法的。
-见 [0012](decisions/0012-the-ontology-is-a-contract-not-a-suggestion.md)。
+**A matched relation still passes a signature check.** The ontology declares `employee (organization → person)`, and the model still writes `Musk employee Microsoft`; three rounds of prompt tuning could not suppress it, because English "X is an employee of Y" is too strong. So the write path corrects it: if the subject violates the domain and the object fits, swap them and record `direction_corrected`, never silently. If the swap is also invalid (`OpenAI affectedBy …`, a medical-test predicate in schema.org), drop the predicate and keep subject, object and evidence. Argument order is an encoding convention of the key, not a claim about the world, so here the ontology enforces. See [0012](decisions/0012-the-ontology-is-a-contract-not-a-suggestion.md).
-### 这里会丢东西吗
+### Where this stage drops things
-会。十二种原因码,全部记进 `extraction_drops`,界面上可见(其中一种不是丢弃,是留痕):
+Eleven reason codes, all recorded in `extraction_drops` and visible in the UI (one is a trace, not a drop):
-| 原因 | 什么时候 |
+| Reason | When |
|---|---|
-| `truncated_reply` | 模型的输出被截断,这一块整块作废 |
-| `malformed_item` | 一条事实格式不对——**只丢这一条**,不丢整块(#127) |
-| `not_an_entity_name` | 「实体名」是一整句话(按词数 + 限定动词判,#143) |
-| `low_confidence` | 模型自报置信度低于阈值 |
-| `subject_not_declared` | 主语没在 `entities` 里声明——关系与属性两条路径**都**记 |
-| `attr_domain_mismatch` | 属性挂到了 domain 之外的类上(沿父类 DAG 上溯仍不匹配) |
-| `attr_no_value` / `attr_datatype` | 属性事实没给值,或值换算不出声明的 datatype |
-| `object_missing` | 关系事实没有宾语 |
-| `direction_corrected` | **不是丢弃**:主宾按签名对调了,记下来是为了不静默 |
-| `domain_mismatch` | 对调也不合法,谓词被丢掉(主宾与证据留下) |
-
-**`attr_domain_mismatch` 是最贵的一种**:它在落地当场丢弃,而事后改类救不回来——
-那条事实从没写入过,只能重抽。
-
-从前还有一种 `fallback_relation_missing`(兜底关系被删了就整条消失)——随 `related_to` 一起没了。
+| `truncated_reply` | The model's output was cut off; the whole chunk is discarded |
+| `malformed_item` | One fact is malformed; **only that item** is dropped, not the chunk (#127) |
+| `not_an_entity_name` | The "entity name" is a whole sentence (judged by word count and finite verbs, #143) |
+| `low_confidence` | The model's self-reported confidence is below the threshold |
+| `subject_not_declared` | The subject is not declared in `entities`; recorded on **both** the relation and attribute paths |
+| `attr_domain_mismatch` | The attribute is attached to a class outside its domain, even after walking up the parent DAG |
+| `attr_no_value` / `attr_datatype` | The attribute fact has no value, or the value cannot be converted to the declared datatype |
+| `object_missing` | The relation fact has no object |
+| `direction_corrected` | **Not a drop**: subject and object were swapped per the signature; recorded so it is never silent |
+| `domain_mismatch` | The swap is also invalid; the predicate is dropped (subject, object and evidence stay) |
+
+**`attr_domain_mismatch` is the most expensive.** It drops the fact at write time, and retyping the entity later does not recover it: the fact was never written and can only be re-extracted.
---
-## 二、实体消解:这一条是谁
+## 2. Entity Resolution: Who This Mention Is
```mermaid
flowchart TB
- M[一次 mention
类型 + 名字 + 分块向量] --> EQ[等值召回
canonical_name 或 aliases 相等]
- EQ --> S{画像相似度}
- S -->|0.55 及以上| ATT[并进已有实体
更新画像]
- S -->|0.35 到 0.55| NEW1[新建 + 入审阅队列]
- S -->|低于 0.35| NEW2[新建,不打扰队列]
- EQ -->|一个都没有| NEW3[新建]
+ M[One mention
type + name + chunk vector] --> EQ[Equality recall
canonical_name or aliases equal]
+ EQ --> S{Profile similarity}
+ S -->|0.55 and above| ATT[Attach to the existing entity
update its profile]
+ S -->|0.35 to 0.55| NEW1[Create + enqueue for review]
+ S -->|below 0.35| NEW2[Create, queue untouched]
+ EQ -->|no match| NEW3[Create]
NEW1 --> CT
NEW2 --> CT
- NEW3 --> CT[包含关系召回
只在新建时跑一次]
- CT --> Q[(审阅队列
pending)]
- Q --> AD[裁决
攒批一次 LLM]
- AD -->|同一个| ME[合并
名字进 aliases
事实搬过去]
- AD -->|不是| KP[保持分开]
- ME --> RD[把该 source 上其余 pending
改指到合并目标]
+ NEW3 --> CT[Containment recall
runs once, on creation only]
+ CT --> Q[(Review queue
pending)]
+ Q --> AD[Adjudication
one batched LLM call]
+ AD -->|same| ME[Merge
name into aliases
facts moved over]
+ AD -->|different| KP[Keep apart]
+ ME --> RD[Redirect the other pending pairs on that source
to the merge target]
RD --> Q
style AD fill:#3a3a5a,color:#fff
style Q fill:#2d4a5a,color:#fff
```
-**三个阈值分三档**(`SIM_ATTACH = 0.55`、`SIM_NEW = 0.35`):像得没话说就并,
-像得可疑就新建但入队,不像就新建且不打扰队列。**宁分勿合**——错合的代价是两个实体的
-事实混在一起,比多一个实体贵得多。
+**Two thresholds, three tiers** (`SIM_ATTACH = 0.55`, `SIM_NEW = 0.35`): clearly the same attaches; suspiciously similar creates a new entity and queues it; dissimilar creates one without touching the queue. **Prefer splitting over merging.** A wrong merge mixes two entities' facts together, which costs far more than one extra entity.
-**包含关系召回**(`Holmes` ⊂ `Sherlock Holmes`)补等值召回的盲区:前缀枚举不完,
-简称会静默变成第二个实体。它有三条约束:较短那个名字至少 4 字符(低于此多是通名)、
-单次最多产出 4 对、SQL 侧多扫 16 行(硬互斥类型在 Rust 侧才筛得掉)。
+**Containment recall** (`Holmes` ⊂ `Sherlock Holmes`) covers the blind spot of equality recall: prefixes cannot be enumerated, so a short name would silently become a second entity. Three constraints: the shorter name is at least 4 characters (below that it is usually a generic word), at most 4 pairs per run, and SQL scans 16 extra rows because hard-disjoint types are only filtered on the Rust side.
-**改指那一步**是整张图里最不直觉的一环,也是最容易被误删的:合并之后,涉及被合实体的
-其余待审阅对**不能关掉**,要改指到合并目标。理由见下。
+**The redirect step** is the least intuitive part of the graph and the easiest to delete by mistake. After a merge, the other pending pairs that involve the merged-away entity **must not be closed**; they are redirected to the merge target. The reason follows.
-### 这一段修过三个洞,三个都是同一份语料照出来的
+### Three Holes, One Corpus
-用《福尔摩斯冒险史》前六篇(`scripts/bench/corpora/holmes.json`)连跑四次,每次修一层:
+Four runs over the first six stories of *The Adventures of Sherlock Holmes* (`scripts/bench/corpora/holmes.json`), each fixing one layer:
-| | 原始 | 修同类型 | 加别名召回 | 加改指 |
+| | Baseline | Same-type fix | Alias recall | Redirect |
|---|---|---|---|---|
-| 已合并实体 | 14 | 37 | 47 | **57** |
-| `Holmes` 并入 | ✗ | ✓ | ✓ | ✓ |
-| `Mr. Holmes` 并入 | ✗ | ✗ | ✗ | **✓** |
+| Entities merged | 14 | 37 | 47 | **57** |
+| `Holmes` merged | ✗ | ✓ | ✓ | ✓ |
+| `Mr. Holmes` merged | ✗ | ✗ | ✗ | **✓** |
-**第一层**:`classify_type_drift` 没有「两个类型相同」这一档,`person × person` 落进
-`Disjoint`——"永不可能是同一个"。那个函数生来服务「类型漂移」(同名被抽成两种类型),
-那里两边相同不会发生;后来被包含关系召回借去当相容性判据,**而那里两边相同才是常态**。
-全文最明显的同指关系一对都没进过队列,十二个既有单元测试全在测跨类型。
+**Layer one.** `classify_type_drift` had no tier for "both types equal", so `person × person` fell into `Disjoint`, "can never be the same". The function was written for type drift (one name extracted as two types), where equal sides never occur; containment recall later borrowed it as a compatibility test, where equal sides are the norm. The most obvious coreferences in the text never reached the queue, and all twelve existing unit tests covered cross-type cases.
-**第二层**:召回只看 `canonical_name`。合并把名字搬进 `aliases`,于是**每成功合并一次
-就拆掉一条桥**——`Holmes` 并入之后,后来的 `Mr. Holmes` 跟 `Sherlock Holmes` 谁也不含谁,
-本来正是靠 `Holmes` 桥接。修好第一层反而让第二层的漏显形了。
+**Layer two.** Recall looked only at `canonical_name`. A merge moves the name into `aliases`, so every successful merge removed a bridge: once `Holmes` was merged, the later `Mr. Holmes` and `Sherlock Holmes` contained neither the other, and `Holmes` had been the bridge. Fixing layer one exposed layer two.
-**第三层**:合并会把涉及被合实体的其余 pending 审阅关成 `superseded by merge`,
-代码注释里的理由是"疑点若仍在会由后续 mention 重新提起"。**那句是错的**:包含关系召回
-只在新建实体时跑,而这些实体早就存在、不会再被新建。关掉即永久关闭。现在改指到合并目标,
-只有两类真正过时的才关——重定向后成自环的,和目标对已在队列里的。
+**Layer three.** A merge closed the other pending reviews involving the merged-away entity as `superseded by merge`; the code comment claimed a later mention would raise the doubt again. That was wrong: containment recall runs only when an entity is created, and these entities already exist, so closing was permanent. Now they are redirected to the merge target, and only two genuinely stale kinds are closed: pairs that become self-loops after the redirect, and pairs whose target pair is already queued.
-**这三层是一层套一层的**:不修第一层看不见第二层,不修第二层看不见第三层。
-基准语料的价值不在第一次跑出的数字,在**每修一次就再照出下一层**。
+**Each layer hid the next.** The value of the benchmark corpus is the next layer it exposes after every fix, more than the first number it produces.
-### 这里会丢东西吗
+### Where this stage drops things
-不会丢事实,但会**留下不该分开的实体**。两个已知缺口:
+No facts are lost, but **entities that belong together can stay apart**. Two known gaps:
-- 两个名字既不互相包含、又没有共同别名做桥(`启明 X7 加速卡` vs `启明 X7 推理加速卡`)。
- 要三元组相似度,而 `CREATE EXTENSION pg_trgm` 需要超级权限,本仓库是受限角色连库。
-- 单次包含关系召回上限 4 对:一个通名可能被几十个实体包含,全放进去会淹掉队列。
+- Two names that neither contain each other nor share an alias as a bridge (`启明 X7 加速卡` vs `启明 X7 推理加速卡`). Trigram similarity would cover this, but `CREATE EXTENSION pg_trgm` needs superuser and this repository connects with a restricted role.
+- Containment recall is capped at 4 pairs per run. A generic name may be contained by dozens of entities, and admitting them all would flood the queue.
-合并本身**可撤销**(`entity_merges` 记着改之前的一切,`revert_merge` 放回去)。
+A merge itself is **reversible**: `entity_merges` records everything as it was, and `revert_merge` restores it.
---
-## 三、本体消解:这一条是什么
+## 3. Type Resolution: What This Entity Is
```mermaid
flowchart TB
- subgraph 抽取留下的线索
- PT[proposed_type
词表外的类型名]
- ST[specific_type
模型自己的说法]
- PP[proposed_predicate
词表外的谓词原词]
+ subgraph clues [Clues left by extraction]
+ PT[proposed_type
out-of-vocabulary type name]
+ ST[specific_type
free-text description from the model]
+ PP[proposed_predicate
out-of-vocabulary predicate, original term]
end
PT --> TR
ST --> TR
- PP --> GP[本体提案
检索候选 + 裁决]
- TR[类型消解] --> C1[候选一
画像 → 类的描述]
- TR --> C2[候选二
语境近邻的类当票投]
- C1 --> AD2{裁决}
+ PP --> GP[Ontology proposal
retrieve candidates + adjudicate]
+ TR[Type resolution] --> C1[Candidate one
profile → class descriptions]
+ TR --> C2[Candidate two
classes of context neighbors vote]
+ C1 --> AD2{Adjudication}
C2 --> AD2
- AD2 -->|在原类子树里| AUTO[自动改类
entity_retypes]
- AD2 -->|跨了分类轴| REV[待人工
类对认可一次即免问]
- AD2 -->|都不是| NONE[不动
并记下理由]
- GP -->|已有的| MAP[映射到已有类型
改写等待的事实]
- GP -->|没有的| NEWT[新建类型 + 改写]
+ AD2 -->|within the original class subtree| AUTO[Retype automatically
entity_retypes]
+ AD2 -->|crosses a classification axis| REV[Human review
a class pair approved once is never asked again]
+ AD2 -->|neither| NONE[Leave alone
reason recorded]
+ GP -->|type exists| MAP[Map to the existing type
rewrite the waiting facts]
+ GP -->|type is new| NEWT[Create the type + rewrite]
style AD2 fill:#3a3a5a,color:#fff
```
-**两路候选取并集,不合分数。** 距离在三处都不可比:跨实体不可比
-(`清华大学计算机系→computer_store` 0.46 比 `星云科技→corporation` 0.59 还近,而前者荒谬)、
-两路之间不可比(一个在类空间一个在实体空间)、同一路的两个查询之间也不可比
-(短查询"医药集团"产生的距离系统性小于一整段画像)。**一律交替取。**
+**The two candidate routes are unioned, never scored together.** Distances are incomparable in three places: across entities (`清华大学计算机系→computer_store` at 0.46 is closer than `星云科技→corporation` at 0.59, and the former is absurd), between the two routes (one lives in class space, the other in entity space), and between two queries on the same route (a short query like "医药集团" yields systematically smaller distances than a full profile). Candidates are interleaved instead.
-**分档不看模型自报的 confidence**——实测是双峰的(15 条全 ≥0.85、4 条 null,中间没有),
-自报置信度是语气不是概率。改用「选中的类在不在原类的子树里」:在 = 往下走一格,自动;
-不在 = 换了分类轴,进人工。
+**Tiering ignores the model's self-reported confidence.** In practice it is bimodal (15 items all ≥ 0.85, 4 null, nothing in between): it is tone, not probability. The tier comes from whether the chosen class sits in the original class's subtree. Inside means one step down, applied automatically; outside means a different classification axis, sent to a human.
-**纠正也走人工,而且天然如此**:抽取按块检索候选之后自己就会挑细类,也会挑错
-(`绍兴 → address`);正确答案是错类的**兄弟**不是后代,所以必然判为跨轴。
-推翻抽取的判断比细化它风险大,不该自动发生。
+**Corrections go to a human as well, by construction.** Extraction already picks fine-grained classes after per-chunk retrieval, and sometimes picks wrong ones (`绍兴 → address`). The right answer is a **sibling** of the wrong class, so it always reads as crossing an axis. Overturning extraction's judgment is riskier than refining it and should not happen automatically.
-### 这里会丢东西吗
+### Where this stage drops things
-不丢事实,但**改类不进时间轴**——它是 `entities` 上一次 UPDATE 加一行 `entity_retypes`〔实体历史现在会显示 `retyped` / `retype_reverted` 两种事件,这一条已经补上〕。**可撤销不等于会被撤销**,
-这是先做 preview 再做 apply 的理由。
+No facts are lost. A retype is one UPDATE on `entities` plus a row in `entity_retypes`, and entity history shows it as `retyped` / `retype_reverted` events. **Reversible does not mean it will be reversed**, which is why preview comes before apply.
-**类型消解今天只能手动跑**——本体页 preview → apply,没有任何自动触发。抽取结束只入队本体扩展与实体裁决。
-所以大本体下新实体的细化依赖人记得去点一下,这是个真缺口(0001 P3a)。
+**Type resolution runs only by hand today**: preview → apply on the ontology page, with no automatic trigger. Finishing extraction only enqueues ontology growth and entity adjudication, so refining new entities under a large ontology depends on someone remembering to click. This is a real gap (0001 P3a).
-**人拍过板的不再被引擎重判**:`entities.type_source` 是 `human` 的实体不进取材,包括「人判了,就是没有类型」(0001 P4a)。
+**Human decisions are never re-judged by the engine.** Entities whose `entities.type_source` is `human` are excluded from the candidate pool, including "a human decided it has no type" (0001 P4a).
-**拒绝要给理由。** `left_alone` 曾经只是个数,而这一步的设计押在"选择都不是是个体面答案"上——
-最大的一档不透明。记上理由之后第一次跑就回答了此前答不出的问题:失败**全在检索一侧**
-(`administrative_area`、`periodical` 从没被端上来过),不在裁决。
+**Rejections carry a reason.** The design bets on "none of these" being an honest answer, which made `left_alone` the largest and most opaque tier while it was only a count. With reasons recorded, the first run answered a question that had been unanswerable: the failures were **all on the retrieval side** (`administrative_area` and `periodical` were never offered), none in adjudication.
---
-## 四、公理:检查与推导
+## 4. Axioms: Checking and Inference
```mermaid
flowchart TB
- ONT[(本体的公理
functional · symmetric · asymmetric
transitive · inverseOf · subPropertyOf · disjoint)] --> SELF[本体自检
八类缺陷]
+ ONT[(Ontology axioms
functional · symmetric · asymmetric
transitive · inverseOf · subPropertyOf · disjoint)] --> SELF[Ontology self-check
eight defect kinds]
SELF --> DEF[(ontology_defects)]
- ONT --> R0[事实层检查
自环 · 反对称 · 传递环 · 基数 · 签名]
- G[(图谱)] --> R0
- R0 --> VIO[(axiom_violations
带完整路径)]
- VIO --> DEC{人裁}
- DEC -->|撤回事实| RET[事实作废]
- DEC -->|放宽公理| RLX[改本体]
- DEC -->|接受| ACC[两条都留]
- ONT --> R1{materialize_inferences
开关,默认关}
+ ONT --> R0[Fact-level check
self-loop · asymmetry · transitive cycle · cardinality · signature]
+ G[(Graph)] --> R0
+ R0 --> VIO[(axiom_violations
with the full path)]
+ VIO --> DEC{Human decision}
+ DEC -->|retract the fact| RET[Fact retracted]
+ DEC -->|relax the axiom| RLX[Edit the ontology]
+ DEC -->|accept| ACC[Both stay]
+ ONT --> R1{materialize_inferences
switch, off by default}
G --> R1
- R1 -->|开| DER[(derived_facts
另一张表 · rule_id · 前提链)]
- DER --> GV[图上金色边
实体面板「推出来的」]
+ R1 -->|on| DER[(derived_facts
separate table · rule_id · premise chain)]
+ DER --> GV[Gold edges on the graph
entity panel marks them inferred]
style DEC fill:#3a3a5a,color:#fff
```
-**签名在三条写路径上都算数**(#190 / #196):第一节那道「主宾对得上签名吗」的判断住在 store(`ontology::judge_direction`),抽取落新事实、采纳把谓词挂回旧事实都调它——两边都不合的采纳**不挂**,条数随 `facts_left_off` 上报;合并换了主宾之后不掰不改,只对搬动过的事实查一遍,违反的进 `axiom_violations`(`signature`)。检查里的第五类就是它的全量版,本体事后改了 domain 也逃不过。
+**Signatures count on all three write paths** (#190 / #196). The "do subject and object match the signature" check from section 1 lives in the store (`ontology::judge_direction`). Extraction calls it when writing new facts. Adoption calls it when reattaching a predicate to old facts; where both directions fail, the predicate is **not attached** and the count is reported as `facts_left_off`. A merge that changes subject or object neither swaps nor edits; it re-checks only the moved facts, and violations go to `axiom_violations` as `signature`. The `signature` check kind is the full-scan version of the same rule, so a domain changed in the ontology after the fact is caught too.
-**检查不写库,推导写另一张表。** 一致性检查(R0)只指出问题,风险面为零;物化推导(R1)会往图里加东西,所以它的每条约束都是必要的:
-规则**只从本体公理编译**,没有用户 DSL;**断言硬性优先于派生**——已经断言过的三元组不再派生,「这条是谁说的」有唯一答案;
-深度上限加环检测,每谓词封顶两万条且被截掉的**要说出来**;有效时间取前提的交集,空交集不推。
-派生不进 `facts`:四十多处读 `facts` 的查询只有一处认得标记,分开之后忘了 UNION 的后果是**看不见**派生,而不是**混进去**。
+**The check writes nothing; inference writes to a separate table.** The consistency check (R0) only points at problems, with zero risk. Materialized inference (R1) adds to the graph, so each of its constraints is necessary: rules **compile only from ontology axioms**, with no user DSL; **asserted facts strictly override derived ones**, so a triple already asserted is never derived and "who said this" has one answer; a depth limit plus cycle detection, a cap of 20,000 per predicate, and truncation **is reported**; valid time is the intersection of the premises, and an empty intersection derives nothing. Derived facts never enter `facts`: of the forty-odd queries that read `facts`, only one recognizes a marker, so with separate tables a forgotten UNION makes derived facts **invisible** rather than **mixed in**.
-**本体自检排在前面**:自相矛盾的本体(一个关系既 symmetric 又 asymmetric、子类成环、逆没指回来)会让事实层的结论全部可疑。
+**The ontology self-check runs first.** A self-contradictory ontology (a relation both symmetric and asymmetric, a subclass cycle, an inverse that does not point back) makes every fact-level conclusion suspect.
-**没装本体包的库跑出来是零**,那是实情不是故障——没有公理就没有判据,不报矛盾比猜一个公理出来安全。
+**A database without an ontology pack reports zero.** That is the truth, not a fault: without axioms there is no criterion, and reporting no contradiction is safer than guessing an axiom.
-**什么时候跑**:导入本体后自动跑一次检查(公理刚变,最该重算的时刻);Review 页可手动跑;推导按 `inference_interval_minutes`(缺省 60)定时全量重推——增量维护还没做。
+**When it runs.** The check runs automatically after an ontology import (the axioms just changed, the best moment to recompute) and on demand from the Review page. Inference reruns in full on a timer, `inference_interval_minutes` (default 60); incremental maintenance is not built yet.
-### 这里会丢东西吗
+### Where this stage drops things
-不丢,但**有两处沉默**:派生 vs 断言矛盾时派生不落地,今天**不记信号**;同一三元组有多条推导路径只留第一条证明。两者都是 [0002](decisions/0002-reasoning-engine.md) 里写了而没做的。
+Nothing is dropped, but the engine keeps quiet in two places. A derivation that contradicts an assertion (or another derivation) is not written; since #238 it leaves an `axiom_violations` row of kind `derived_contradiction`, capped per predicate, with the overflow counted in the run report and rule-versus-rule disagreements recorded as `ontology_defects` (`rules_disagree`) — see [0017](decisions/0017-a-contradiction-points-upstream.md). When one triple has several derivation paths, only the first proof is kept; that one is still silent ([0002](decisions/0002-reasoning-engine.md)).
-## 想自己跑一遍
+## Running the Benchmarks
-`scripts/bench/` 是可重跑的测量台,**每一组一个新库**——复用一个库省几分钟,
-换来的是一整段无效结论(那是踩过的坑,不是假设)。
+`scripts/bench/` is a rerunnable measurement bench, **one fresh database per run**. Reusing a database saves minutes and costs a whole run of invalid conclusions; that is a lesson learned, not a hypothesis.
```bash
node scripts/bench/run.mjs --corpus pharma --label seeds-only
node scripts/bench/run.mjs --corpus holmes --label holmes
```
-三份语料各测一件事,用途不同、要求也不同:
+Three corpora measure three things, with different requirements:
-| 语料 | 测什么 | 有答案键 |
+| Corpus | Measures | Answer key |
|---|---|---|
-| `tech` / `pharma` | 类型准确性 | 有(弱:自己写的) |
-| `holmes` | 实体消解 · demo 空镜 | **无,故意的** |
+| `tech` / `pharma` | Type accuracy | Yes (weak: hand-written) |
+| `holmes` | Entity resolution · demo footage | **None, by design** |
-福尔摩斯那份**不该有**准确性答案键:模型早就读过它,量类型准确率量到的是记忆,
-不是这条流水线。**编一份假答案比不打分更糟。**
+The Holmes corpus **must not** have an accuracy key: the model has read the book, so measuring type accuracy would measure memory, not the pipeline. A fabricated answer key is worse than no score.
-## 相关决策
+## Related Decisions
-- [0001](decisions/0001-ontology-import-and-governance.md) 本体导入与治理,含 P3 的实测修订
-- [0003](decisions/0003-ontology-growth-loop.md) 本体从语料里长出来,人站在哪一环
-- [0006](decisions/0006-ontology-scale-and-the-prompt.md) 本体规模与抽取提示词,含曲线与一次撤回
-- [0002](decisions/0002-reasoning-engine.md) 推理机的顺序与安全边界;[0012](decisions/0012-the-ontology-is-a-contract-not-a-suggestion.md) 写入时的方向掰正
-- [0009](decisions/0009-no-type-is-a-type.md) / [0010](decisions/0010-no-relation-is-no-relation.md) 为什么「还没判出来」不是类、「说不出」不是关系
+- [0001](decisions/0001-ontology-import-and-governance.md) Ontology import and governance, with the measured revision of P3
+- [0003](decisions/0003-ontology-growth-loop.md) Growing the ontology from the corpus, and where the human stands in the loop
+- [0006](decisions/0006-ontology-scale-and-the-prompt.md) Ontology scale and the extraction prompt, with the curve and one reversal
+- [0002](decisions/0002-reasoning-engine.md) Reasoning engine order and safety boundary; [0012](decisions/0012-the-ontology-is-a-contract-not-a-suggestion.md) direction correction at write time
+- [0017](decisions/0017-a-contradiction-points-upstream.md) A contradiction points at an error upstream: what happens when a derivation disagrees with the ledger
+- [0009](decisions/0009-no-type-is-a-type.md) / [0010](decisions/0010-no-relation-is-no-relation.md) An undecided type and an unnamed relation both stay empty