@xdxer/dingtalk-agent 0.1.5-beta.4 → 0.1.5-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ # 存储端口契约
2
+
3
+ 本篇定义 Fact 怎么落地。**模型层(`model.md`)与事件层(`event-ingest.md`)只对本端口编程,不认识钉钉。** 介质特有的坑全部收在 `adapters.md`。
4
+
5
+ **解耦检验**:把某适配器的能力位调成最宽松档后,通用算法里的完整性校验、写后回读、判重读、翻页判存在四条分支应**自动全灭,Skill 正文一字不改**。
6
+
7
+ ## 零、先用端口形状消解,剩下的才用能力位描述
8
+
9
+ 三条病理不进能力位,靠端口形状让它**说不出来**:
10
+
11
+ - **没有 delete / update 操作** → `raw` 只增不删由形状强制,遗忘只能 append tombstone。
12
+ - **水位是 meta 键空间的一等对象,不是任何页里的一行** → 「水位必须放短页」这件事无从发生。
13
+ - **模型层词汇里没有页、分区、seg、月份** → 切的是 Fact,不是页;物理分区如何滚动是适配器私事。
14
+
15
+ **适配器私有地址空间**:适配器可在自己的地址空间保存分区表、哨兵、物理 ID。模型层不可读不可写;端口签名里**不得出现任何介质地址类型**(nodeId / URL / 路径),它们只出现在 `resolve` 返回的不透明句柄与绑定档案里。
16
+
17
+ 🔴 **用户可见链接的锚点必须是 `(subjectKey, section, evidenceId)` 三元**,永不含物理分区名——否则换介质即死链。
18
+
19
+ ## 一、操作集(10 个)
20
+
21
+ | 操作 | 语义 / 前置 | 返回与失败模式 |
22
+ |---|---|---|
23
+ | `resolve(agentIdentity)` | Boot 期一次解析两个 root 与能力位;同 Session 缓存 | `{roots[], capabilities}`;`NOT_CONFIGURED`(确证未建,可引导)/`UNAVAILABLE`(读失败,**禁止折成 NOT_CONFIGURED**)/`DRIFT(level, observed, expected)`(只报双边真实值,不自愈)。两 root 各自独立 |
24
+ | `probeWrite(root)` | **显式、昂贵、绑定期一次** | `writable` / `read-only` / `write-unverified`。结果写进绑定档案,运行期不重复探测 |
25
+ | `ensureEntity(root, subjectKey, displayHint)` | 唯一的建档动作。**禁止在 ingest/消息触发路径调用** | `exists` / `created` / `ambiguous`(同键多实例=残片)→ `ambiguous` 一律 HALT,不自愈、不猜正本 |
26
+ | `listEntities(root, cursor)` | 判「不存在」的唯一权威来源 | `{keys[], cursor, complete}`;`complete=false` 的结果**只能用于发现,不能用于否定** |
27
+ | `appendFacts(subject, section, facts[])` | 只增追加;facts 自带 `evidenceId` | `receipt{accepted[], skipped[]}`;`PARTIAL` / `UNVERIFIED` / `TOO_LARGE` / `READ_ONLY` |
28
+ | `readFacts(subject, section, range)` | 跨物理分区连续读 | `{facts[], cursor, complete}`;`complete=false` **等同读失败**,不是空 |
29
+ | `putView(subject, section, content, {expectVersion?})` | 整体重写;端口按 section 的 `layer` **拒绝对 `raw` 调用** | `receipt{version}`;`STALE` / `TOO_LARGE`(降级为摘要视图,溢出内容降为 Fact) |
30
+ | `readView(subject, section)` | 召回主路径 | `{content, version, complete}`;`NOT_FOUND` 是**合法初态**,必须与 `UNAVAILABLE` 严格可区分 |
31
+ | `metaGet / metaPut(scope, key, value, {expectVersion?})` | A0 键空间:水位、writeState、绑定档案、停用账本、tombstone 索引、欠账指针 | `{value, version}` / `NOT_FOUND` / `CONFLICT`。🔴 **水位键固定为 `watermark:(subjectKey, channel)`,与 section 正交** |
32
+ | `confirm(receipt)` | 通用算法**无条件调用**;`write.confirm=implicit` 时 O(1) 返回、零往返 | `confirmed` / `unconfirmed` / `failed`。未 confirmed 不得声称已记 |
33
+
34
+ 🔴 **水位键必须是 `(subject, channel)`,绝不能是 `(subject, section)`。** 一个 section 可能同时吃多个渠道的事件(`activity` 同时吃待办与文档),两渠道追平进度天然不同;用 section 当键会让追平待办后推进的水位把文档的未消化区间一起判成已消化,**那批事件永远不会被再消化**——正好踩中「水位虚高不可逆」。
35
+
36
+ ## 二、能力位(8 位 + 容量参数)
37
+
38
+ **准入规则(硬性)**:新增一位必须能**同时改变 ≥2 个适配器的行为**,否则它是适配器内部细节,不许上浮。**位数上限 8。**
39
+
40
+ | 位 | 取值 | 通用算法据此改变什么 |
41
+ |---|---|---|
42
+ | `read.completeness` | `exact` / `verified-partial` | `verified-partial` 时 `complete=false` 按读失败处理:记欠账、跳过本轮。**完整性如何自证全在适配器内部,模型层不知道「截断」这个词**。🔴 **静默截断介质(doc read 不报错)上,唯一可靠的自证是 count 对账**:写侧在 meta 记 `count:(subject,section,partition)`,读回条数 ≠ 记录条数即 `complete=false`。哨兵尾行会被静默截断吃掉,不可靠。详见 `consolidation.md` §0 |
43
+ | `limits.maxFactBytes` / `maxViewBytes` | 整数 / ∞ | 超限时**切 Fact**(同 `evidenceId` 拆多条)或把 View 降级为摘要 |
44
+ | `write.confirm` | `implicit` / `required` | `required` 时每次写后真回读 |
45
+ | `write.cas` | `none` / `revision` | `none`:append → confirm → metaPut 三步,任一失败整批作废、水位不动。`revision`:两步原子提交,`CONFLICT` 靠 `evidenceId` 幂等重放 |
46
+ | `dedupe.enforcement` | `server` / `client` | `client` 时重放前必须 `readFacts` 尾窗判重,且**要求 `complete=true` 才敢重放**;否则不写、记欠账、水位不动 |
47
+ | `identity.collision` | `reject` / `silent-rename` | `silent-rename` 时 `ensureEntity` 内部回读核对,检出残片返回 `ambiguous` |
48
+ | `lookup.negative` | `immediate` / `list-only` | `list-only` 时任何搜索的 0 结果**不构成否定证据**;否定结论只能来自 `listEntities(complete=true)` |
49
+ | `access.scope` | `container` / `leaf` | `leaf` 时只接受叶子锚点 seed;容器枚举缺席一律映射为 `UNAVAILABLE`,**禁止**推出 `NOT_CONFIGURED` |
50
+ | `access.probe` | `declarative` / `empirical` | `empirical` 时 `resolve` 不承诺写权,须显式 `probeWrite` 一次并缓存 |
51
+
52
+ ## 三、病理逐条映射
53
+
54
+ | 病理 | 归宿 | 通用算法的行为 |
55
+ |---|---|---|
56
+ | 原始层按月分页 | `read.completeness` + `limits.*` | 只调 `appendFacts/readFacts(range)`。「按月」降为适配器把一个 section 映射到多个物理对象的**内部分区策略**;本地文件上就是一个文件 |
57
+ | 水位必须放短页 | **端口形状消解** | `metaGet/Put` 独立寻址,落在哪是适配器的事 |
58
+ | 判存在只认列父目录翻完页 | `lookup.negative=list-only` | 先读模型层自持的索引 View 快判(有界成本);索引未命中且真要建时才付 `listEntities` 全量代价;`complete=false` → 记欠账、本拍跳过,**绝不新建** |
59
+ | 建后必回读核对残片 | `identity.collision=silent-rename` + `write.confirm` | 回读关进 `ensureEntity`,模型层只看三态;`ambiguous` → HALT 报人 |
60
+ | 先追加后推水位、任一失败整批作废 | `write.cas` + `write.confirm` | 这是 `cas=none` 分支的标准应对,不是某介质专属规则 |
61
+ | 写权用经验判定 | `access.probe=empirical` | 不查权限表、不解析显示名,只消费三态;`read-only` → 只召回不沉淀且禁说「我记住了」 |
62
+ | 容器级探测漏落点 | `access.scope=leaf` | `resolve` 只走 seed 一跳;容器级失败 = `UNAVAILABLE` 而非未配置 |
63
+
64
+ ## 四、适配器取值表
65
+
66
+ 🔴 **一个部署会同时用多个适配器**:narrative 类 section 走 `dingtalk-doc`,registry 类 section 走 `aitable`(见 `model.md` §三 kind、`adapters.md`)。端口的解耦价值正在这里——同一套模型层操作,registry 换介质就把一半病理关掉。
67
+
68
+ | 位 | `dingtalk-doc`(narrative·生产) | `aitable`(registry·生产,🔬实测) | `local-md`(开发/评测) |
69
+ |---|---|---|---|
70
+ | `read.completeness` | `verified-partial` | **`exact`** | `exact` |
71
+ | `maxFactBytes / maxViewBytes` | 4KB / 24KB | 单元格上限(故不放长正文) | ∞ / ∞ |
72
+ | `write.confirm` | `required` | `required` | `implicit` |
73
+ | `write.cas` | **`none`(raw 与 view 都是)** | `revision`(按 recordId 改行) | `revision`(临时文件 rename) |
74
+ | `dedupe.enforcement` | `client` | `server`(按 key 查重) | `client`(读 exact,故可靠) |
75
+ | `identity.collision` | `silent-rename` | `reject`(recordId 唯一) | `reject` |
76
+ | `lookup.negative` | `list-only` | **`immediate`**(`--filters` 查不到就是没有) | `immediate` |
77
+ | `access.scope` | `leaf` | `leaf`(baseId 直达) | `container` |
78
+ | `access.probe` | `empirical` | `declarative` | `declarative` |
79
+
80
+ > 🔬 **`aitable=exact/immediate` 是实测结论**(2026-07-23 核对 `dws aitable record query --help`):`--all`+`--cursor` 真分页无 341 行墙、`--filters` 按字段查、`record update --records` 按 recordId 精准改。**这就是"registry 类不该塞进文档"的凭据**:把 `00-群索引`/水位台账/tombstone 从 doc 挪到 aitable,`read.completeness=exact` 让 count 对账分支自动全灭、`lookup.negative=immediate` 让翻页判存在分支自动全灭。
81
+
82
+ > 🔬 **`dingtalk-doc` 的 `write.cas=none` 是实测结论**:`dws doc update` 只有 `--mode overwrite|append`,**没有任何 revision / expectVersion / CAS 参数**(2026-07-23 核对 `--help`)。所以编译层的整体重写同样没有并发保护,只能靠「写后回读 + 冲突时以最后一次为准」,并接受两个并发写者会互相覆盖。不要以为编译层比原始层安全。
83
+
84
+ `local-md` 落地形态:`<root>/people/<personKey>/` 为目录(**主键即目录名**,显示名进 front-matter),raw section = 一个只增 `.md`,view = 临时文件 rename,meta = `_meta/*.json`。
85
+
86
+ 🔴 **`local-md --chaos` 是契约的组成部分,不是建议**:强制 `complete=false`、随机静默改名、随机 `UNVERIFIED`、注入索引延迟与判重读截断。CI 必须让 chaos 档与真实档跑同一组用例——否则这套抽象只是把 bug 挪到了适配器边界之外,第一次执行仍然发生在生产。
87
+
88
+ ## 五、不能下放给适配器的模型层不变式
89
+
90
+ 1. `raw` 只增不删、纠错用 `supersedes`;`derived` 可重建。**View 里不得存在唯一信息。**
91
+ 2. `evidenceId` 全局唯一即去重键,必须是**源系统主键**(不得用内容哈希或时间戳)。端口只做机械去重,选错它无从发现。
92
+ 3. `audience` 写入时定、读取时只收窄。**收窄必须 fail-closed**:读该实体事实时 `complete=false`,则本轮不引用它的任何事实,宁可答「不清楚」。
93
+ 4. 交叉归属三铁律;唯一上浮方向 = 会话公开 → 人的 `public-facts`。
94
+ 5. 水位键 `(subject, channel)` 单调不回退;**先写事实、后推水位**的因果顺序**即使有 CAS 也不得反转**(CAS 只允许合并两步,不允许先推)。
95
+ 6. 「读失败 ≠ 不存在」的翻译权在模型层。**任何适配器不得把 `UNAVAILABLE` / `complete=false` 补成空集。**
96
+ 7. section 的 `layer` 归属由模型层定义并交给端口;会话隐私档位白名单默认拒绝。
97
+ 8. 知情同意:未启用态零写调用、未 `confirmed` 不说「我记住了」、首次落盘回读念一遍并给链接。
98
+ 9. **有界降级**:同一 root 连续 N 次 `unconfirmed` ⇒ 作废 `probeWrite` 缓存、降级 `read-only`、显式报人。否则表现为「一直在记但什么都没记」。
99
+
100
+ ## 六、这个抽象做不到什么(诚实清单)
101
+
102
+ - **并发建重复无法根除。** 无 CAS 介质上,端口只能把撞车压到绑定期一次并在事后 `ambiguous` → HALT。「建档只在绑定期由单写者触发」是**流程约束,不是端口能力**——这是抽象的公开缺口。
103
+ - **`client` 去重 + `verified-partial` 读存在真实双写窗口。** 判重读本身可能不全;缓解是「`complete=false` 即不重放」,代价是欠账增长而非双写。这两位的组合应在 `resolve` 阶段被显式标记为高风险档。
104
+ - **能力位大多是为 `dingtalk-doc` 的病理而设。** `aitable` 与 `local-md` 上这些位几乎全取宽松档、对应的通用算法分支全灭。这本是"抽象没做干净"的嫌疑——但反过来看,registry 走 aitable 后一半病理消失,恰恰证明**问题不在模型、在"把结构化数据塞进叙事介质"**。准入规则(≥2 适配器行为差异、上限 8 位)仍须执行,否则第 N 个介质接入时会退化成「每个适配器一个 if」。
105
+ - **判存在的读放大只被压小、没被消除。** 索引 View 可能陈旧,真要新建时仍需付全量翻页代价,且它落在「新人首次建档」这条常见路径上。
106
+ - **授权门不在本端口。** 它是消息**源侧**的门,归 ingest 源端口的 `AUTH_REQUIRED` 一态;放进存储端口即是端口变宽的开始。
107
+ - **端口窄度需要持续维护。** 任何一次「我只要再加一个 listFiles / getUrl」,都会把它推回通用文件系统。