A vibe coded tangled fork which supports pijul.
1# Pijul × AT Protocol:DID 作为一等公民的身份设计
2
3## 动机
4
5Git 在 Tangled 中的身份是事后关联的:commit 里的 `author.email` 是自声明字符串,平台通过维护 `committerDid` 字段来补救,但这个关联只存在于平台数据库,没有密码学保证。
6
7Pijul 提供了重新设计的机会。Pijul change 的 `[[authors]]` 字段天然是结构化 KV map,可以直接承载 DID。更重要的是,AT Protocol 本身就有完整的签名基础设施——用户的 PDS 会对其 AT repo 的每次写入做 MST 签名,使用的正是 DID 的原生签名密钥。
8
9因此,**不需要为 pijul 另造一套密钥体系**。DID 即身份,PDS 上的 AT record 即签名证明。
10
11---
12
13## 设计原则
14
151. **DID 是唯一身份标识**:change 文件中只记录 `did`,不引入独立的 pijul 签名密钥
162. **签名由 PDS 完成**:推送时在用户 PDS 上创建 `sh.tangled.pijul.refUpdate` record,PDS 用 DID 的原生密钥对 AT repo 做 MST 签名,这就是归属证明
173. **change hash 不变**:DID 写入 change 的 `unhashed` 部分,不影响 hash 计算,客户端和服务器的 change 保持一致
184. **验证依赖 AT Protocol 网络**:这与"DID 本身需要联网解析"一致,不是额外代价
19
20---
21
22## 签名链
23
24**pijul push(CLI 侧发布):**
25
26```
27用户运行 pijul identity link-at → 本地 keyring 存入 AT access token
28 ↓
29pijul push → knot server 接收 changes
30 ↓
31CLI 从 keyring 取 token,以用户身份向其 PDS 发 createRecord 请求
32 ↓
33PDS 将 sh.tangled.pijul.refUpdate 写入用户的 AT repo
34 ↓
35PDS 用 DID 原生签名密钥对 MST 做签名
36 ↓
37结果:该 DID 密码学地声明了这些 change hash 归属于自己
38```
39
40**apply/merge(appview 侧发布):**
41
42```
43用户通过 OAuth 登录 appview → appview 持有用户 AT 会话
44 ↓
45用户 merge discussion → appview 以用户身份向其 PDS 发 createRecord 请求
46 ↓
47(同上)PDS MST 签名完成
48```
49
50与独立 pijul 签名密钥方案的对比:
51
52| | 独立 pijul 密钥方案 | DID-only 方案 |
53|---|---|---|
54| 签名密钥 | 单独生成的 Ed25519 | DID 原生签名密钥 |
55| 信任链 | pijul key → prove → DID(两跳) | DID(直接) |
56| 签名位置 | change 文件内 | PDS 的 AT repo MST |
57| 需要 prove 流程 | 是 | 否 |
58| 服务器可否伪造 | 否(无用户私钥) | 否(需用户持有 AT 凭证) |
59
60DID-only 方案的签名不可伪造:createRecord 请求需用户的有效 AT Protocol 会话(push 时由 CLI 持有,merge 时由 appview 代持),这个会话本身就是用户主动授权的结果。
61
62---
63
64## Change 文件格式
65
66DID 写入 change 的 **hashed `[[authors]]`** 部分,参与 hash 计算:
67
68```toml
69# change 文件(hashed 部分,决定 change identity)
70message = 'Fix bug in parser'
71timestamp = '2026-04-03T10:00:00Z'
72
73[[authors]]
74did = "did:plc:xxxxxxxxxxxx" # DID 是唯一身份,无 key 字段
75
76# change 文件(unhashed 部分)
77# signature = "..." # pijul 私钥对 hash 的签名(现有机制,独立于 DID)
78```
79
80配置了 DID 的 identity 在 record 时只写 `did`,不写 `key`。未配置 DID 时保持原有 `key` 行为,向后兼容非 Tangled 服务器。hash 包含 DID,PDS record 里列出的 change hash 密码学绑定了 DID 与内容。
81
82---
83
84## Lexicon 设计
85
86### `sh.tangled.pijul.refUpdate`(更新现有)
87
88现有 lexicon 缺少 `committerDid` 和正确的 `repo` 格式,更新如下:
89
90```json
91{
92 "lexicon": 1,
93 "id": "sh.tangled.pijul.refUpdate",
94 "defs": {
95 "main": {
96 "type": "record",
97 "description": "Published to the committer's PDS when pijul changes are pushed. The PDS MST signature over this record is the verifiable proof of authorship.",
98 "key": "tid",
99 "record": {
100 "type": "object",
101 "required": ["repo", "channel", "newState", "changes", "committerDid"],
102 "properties": {
103 "repo": {
104 "type": "string",
105 "format": "at-uri",
106 "description": "AT URI of the repository (at://did:plc:owner/sh.tangled.repo/name)"
107 },
108 "channel": {
109 "type": "string",
110 "description": "Channel that was updated"
111 },
112 "oldState": {
113 "type": "string",
114 "description": "Pijul Merkle state hash before push (empty for new channels)"
115 },
116 "newState": {
117 "type": "string",
118 "description": "Pijul Merkle state hash after push"
119 },
120 "changes": {
121 "type": "array",
122 "items": { "type": "string" },
123 "description": "Base32 change hashes pushed in this operation, in application order"
124 },
125 "committerDid": {
126 "type": "string",
127 "format": "did",
128 "description": "DID of the pusher. Redundant when this record is in the committer's own PDS, but preserved for when it is replicated elsewhere."
129 },
130 "languages": {
131 "type": "object",
132 "description": "Optional map of language name to line count, computed by knot server"
133 }
134 }
135 }
136 }
137 }
138}
139```
140
141**关键决策**:此 record 发布到**执行操作者**的 PDS,语义是"我(操作者)将这些 change 推送/合并进了这个 repo"。
142
143- 直接推送(`pijul push`):操作者 = 推送者,record 发到推送者的 PDS
144- apply/merge:操作者 = 执行 merge 的人(通常是 repo owner),record 发到他的 PDS
145
146这样 appview 始终持有操作者的 AT Protocol 会话,可以直接调用其 PDS。`committerDid` 字段是执行操作者的 DID。原始 change 创作者的身份保留在 change 文件的 `unhashed.identity.did` 中,两者独立。
147
148---
149
150## 推送流程
151
152### pijul push(HTTP 或 SSH,CLI 认证)
153
154Pijul push 的 PDS 发布由 **CLI 本地完成**,无需服务器中转。用户先通过 `pijul identity link-at` 将 AT Protocol 凭证与本地 identity 绑定:
155
156```
157pijul identity link-at --pds https://bsky.social
158→ 提示输入 handle + app password
159→ 认证成功后:DID + at_pds 写入 identity config,access token 存入 keyring
160```
161
162push 流程:
163
164```
165用户: pijul push https://tangled.sh/did:plc:xxx/myrepo
166 ↓
167knot server:
168 1. 接收 change 文件,写入 repo
169 2. (TODO)将 DID 写入每个 change 的 unhashed.identity.did
170 (需 Bearer token 认证,knot server 从 token 解析 DID)
171CLI(push 成功后):
172 3. 从 keyring 取 AT access token(link-at 时存入)
173 4. 解析 remote URL 中的 DID,构造 at:// repo URI
174 5. 调用 com.atproto.repo.createRecord → 发布到用户 PDS
175 6. PDS MST 签名完成 → 归属证明建立
176```
177
178注意:remote URL 须使用 DID 格式(`https://tangled.sh/did:plc:xxx/repo`),不能用 handle,
179否则 CLI 无法直接推导 AT URI。未配置 `link-at` 时 push 照常工作,只是不发布 PDS record。
180
181SSH push 与 HTTP push 共用同一套 CLI 发布逻辑(均经过 `Push::run()` → `remote.finish()`)。
182
183### apply/merge 操作
184
185merge 由 appview 的 `discussions.go` 处理(pijul-only)。appview 已持有执行者的 AT 会话,
186可直接内联发布,无需 event queue 中转:
187
188```
189Bob merge 了 Alice 的 change:
190 appview: actorDid = Bob 的 DID(执行操作者)
191 appview: 用 Bob 的 OAuth 会话调用 com.atproto.repo.createRecord
192 committerDid = Bob 的 DID,record 发布到 Bob 的 PDS
193```
194
195Bob 的 PDS 记录了他将 Alice 的 change 合并进了这个 repo。Alice 的创作身份保留在 change 文件的 `unhashed.identity.did` 中。
196
197---
198
199## 验证流程
200
201### 验证 change X 的推送记录
202
203```
2041. 查询 knot server 的 changes 历史,得到 committerDid(谁推送/合并了这个 change)
2052. 查询 committerDid 的 PDS,列举 sh.tangled.pijul.refUpdate 记录,
206 找到 changes[] 包含此 change hash 的那条
2073. PDS 的 MST 签名(验证方法:AT Protocol 标准 repo 验证流程)
208 证明:该 DID 声明将此 change hash 推送/合并进了该 repo
209```
210
211### 验证 change X 的创作身份
212
213```
2141. 打开 change 文件,读取 unhashed.identity.did(原始作者)
2152. 该 DID 是 change 创作者的自声明身份
216 (无密码学签名,但 change hash 不变,内容完整性有保证)
217```
218
219### 查询某 DID 的所有推送/合并历史
220
221```
222GET <pds>/xrpc/com.atproto.repo.listRecords
223 ?collection=sh.tangled.pijul.refUpdate
224
225→ 按 repo 字段分组,得到该 DID 在所有 repo 的操作历史
226```
227
228这是一个**去中心化的操作图**:不依赖任何单一平台数据库,任何人都可以从 AT Protocol 网络重建完整的推送记录。
229
230---
231
232## 与 fedi-xanadu 的集成
233
234Fedi-xanadu 是本设计的第一个实现者,使用场景略有不同——用户通过 Web 界面编辑文章,服务器代为 record。
235
236流程:
237```
238用户通过 Web UI 编辑并保存文章
239→ fx-server 的 publish_article_content 调用 pijul.record(node_id, message, Some(&user.did))
240→ pijul change 的 unhashed 写入 user.did
241→ fx-atproto 在用户 PDS 上发布 sh.tangled.pijul.refUpdate
242→ 文章历史页面展示 DID,链接到用户主页
243```
244
245由于 fedi-xanadu 已有用户的 AT Protocol 会话,向其 PDS 写 record 不需要额外的认证步骤。
246
247---
248
249## 开放问题
250
251**1. 历史 change 的追溯**
252
253对于在本方案实施之前推送的 change,可以允许用户发布一条声明 record 来认领历史 change,由 knot server 验证用户确实有该 repo 的推送记录后接受。
254
255**2. `[[authors]]` 格式与 pijul 上游的协调**
256
257`did` 字段写入 hashed `[[authors]]` 目前是 Tangled 的约定。应与 pijul 上游协调是否纳入官方格式,或在文档中明确为扩展字段。