A vibe coded tangled fork which supports pijul.

fix: auto-reset working tree after pijul apply

Without this, subsequent applies that modify or delete files fail
with "Applying this patch would delete unrecorded changes" because
the working tree diverges from pristine after each apply+output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+265 -1
+257
docs/pijul-did-identity.md
··· 1 + # Pijul × AT Protocol:DID 作为一等公民的身份设计 2 + 3 + ## 动机 4 + 5 + Git 在 Tangled 中的身份是事后关联的:commit 里的 `author.email` 是自声明字符串,平台通过维护 `committerDid` 字段来补救,但这个关联只存在于平台数据库,没有密码学保证。 6 + 7 + Pijul 提供了重新设计的机会。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 + 15 + 1. **DID 是唯一身份标识**:change 文件中只记录 `did`,不引入独立的 pijul 签名密钥 16 + 2. **签名由 PDS 完成**:推送时在用户 PDS 上创建 `sh.tangled.pijul.refUpdate` record,PDS 用 DID 的原生密钥对 AT repo 做 MST 签名,这就是归属证明 17 + 3. **change hash 不变**:DID 写入 change 的 `unhashed` 部分,不影响 hash 计算,客户端和服务器的 change 保持一致 18 + 4. **验证依赖 AT Protocol 网络**:这与"DID 本身需要联网解析"一致,不是额外代价 19 + 20 + --- 21 + 22 + ## 签名链 23 + 24 + **pijul push(CLI 侧发布):** 25 + 26 + ``` 27 + 用户运行 pijul identity link-at → 本地 keyring 存入 AT access token 28 + 29 + pijul push → knot server 接收 changes 30 + 31 + CLI 从 keyring 取 token,以用户身份向其 PDS 发 createRecord 请求 32 + 33 + PDS 将 sh.tangled.pijul.refUpdate 写入用户的 AT repo 34 + 35 + PDS 用 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 + 60 + DID-only 方案的签名不可伪造:createRecord 请求需用户的有效 AT Protocol 会话(push 时由 CLI 持有,merge 时由 appview 代持),这个会话本身就是用户主动授权的结果。 61 + 62 + --- 63 + 64 + ## Change 文件格式 65 + 66 + DID 写入 change 的 **hashed `[[authors]]`** 部分,参与 hash 计算: 67 + 68 + ```toml 69 + # change 文件(hashed 部分,决定 change identity) 70 + message = 'Fix bug in parser' 71 + timestamp = '2026-04-03T10:00:00Z' 72 + 73 + [[authors]] 74 + did = "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 + 154 + Pijul push 的 PDS 发布由 **CLI 本地完成**,无需服务器中转。用户先通过 `pijul identity link-at` 将 AT Protocol 凭证与本地 identity 绑定: 155 + 156 + ``` 157 + pijul identity link-at --pds https://bsky.social 158 + → 提示输入 handle + app password 159 + → 认证成功后:DID + at_pds 写入 identity config,access token 存入 keyring 160 + ``` 161 + 162 + push 流程: 163 + 164 + ``` 165 + 用户: pijul push https://tangled.sh/did:plc:xxx/myrepo 166 + 167 + knot server: 168 + 1. 接收 change 文件,写入 repo 169 + 2. (TODO)将 DID 写入每个 change 的 unhashed.identity.did 170 + (需 Bearer token 认证,knot server 从 token 解析 DID) 171 + CLI(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 + 181 + SSH push 与 HTTP push 共用同一套 CLI 发布逻辑(均经过 `Push::run()` → `remote.finish()`)。 182 + 183 + ### apply/merge 操作 184 + 185 + merge 由 appview 的 `discussions.go` 处理(pijul-only)。appview 已持有执行者的 AT 会话, 186 + 可直接内联发布,无需 event queue 中转: 187 + 188 + ``` 189 + Bob 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 + 195 + Bob 的 PDS 记录了他将 Alice 的 change 合并进了这个 repo。Alice 的创作身份保留在 change 文件的 `unhashed.identity.did` 中。 196 + 197 + --- 198 + 199 + ## 验证流程 200 + 201 + ### 验证 change X 的推送记录 202 + 203 + ``` 204 + 1. 查询 knot server 的 changes 历史,得到 committerDid(谁推送/合并了这个 change) 205 + 2. 查询 committerDid 的 PDS,列举 sh.tangled.pijul.refUpdate 记录, 206 + 找到 changes[] 包含此 change hash 的那条 207 + 3. PDS 的 MST 签名(验证方法:AT Protocol 标准 repo 验证流程) 208 + 证明:该 DID 声明将此 change hash 推送/合并进了该 repo 209 + ``` 210 + 211 + ### 验证 change X 的创作身份 212 + 213 + ``` 214 + 1. 打开 change 文件,读取 unhashed.identity.did(原始作者) 215 + 2. 该 DID 是 change 创作者的自声明身份 216 + (无密码学签名,但 change hash 不变,内容完整性有保证) 217 + ``` 218 + 219 + ### 查询某 DID 的所有推送/合并历史 220 + 221 + ``` 222 + GET <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 + 234 + Fedi-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 上游协调是否纳入官方格式,或在文档中明确为扩展字段。
+8 -1
knotserver/pijul/repo.go
··· 73 73 args = append(args, "--channel", p.channelName) 74 74 } 75 75 _, err := p.runPijulCmd("apply", args...) 76 - return err 76 + if err != nil { 77 + return err 78 + } 79 + // Reset working tree to match pristine state after apply. 80 + // Without this, subsequent applies that modify/delete files will fail 81 + // with "Applying this patch would delete unrecorded changes". 82 + _, _ = p.runPijulCmd("reset", "--force") 83 + return nil 77 84 } 78 85 79 86 // Unrecord removes a change from the channel.