A vibe coded tangled fork which supports pijul.

Pijul × AT Protocol:DID 作为一等公民的身份设计#

动机#

Git 在 Tangled 中的身份是事后关联的:commit 里的 author.email 是自声明字符串,平台通过维护 committerDid 字段来补救,但这个关联只存在于平台数据库,没有密码学保证。

Pijul 提供了重新设计的机会。Pijul change 的 [[authors]] 字段天然是结构化 KV map,可以直接承载 DID。更重要的是,AT Protocol 本身就有完整的签名基础设施——用户的 PDS 会对其 AT repo 的每次写入做 MST 签名,使用的正是 DID 的原生签名密钥。

因此,不需要为 pijul 另造一套密钥体系。DID 即身份,PDS 上的 AT record 即签名证明。


设计原则#

  1. DID 是唯一身份标识:change 文件中只记录 did,不引入独立的 pijul 签名密钥
  2. 签名由 PDS 完成:推送时在用户 PDS 上创建 sh.tangled.pijul.refUpdate record,PDS 用 DID 的原生密钥对 AT repo 做 MST 签名,这就是归属证明
  3. change hash 不变:DID 写入 change 的 unhashed 部分,不影响 hash 计算,客户端和服务器的 change 保持一致
  4. 验证依赖 AT Protocol 网络:这与"DID 本身需要联网解析"一致,不是额外代价

签名链#

pijul push(CLI 侧发布):

用户运行 pijul identity link-at → 本地 keyring 存入 AT access token
    ↓
pijul push → knot server 接收 changes
    ↓
CLI 从 keyring 取 token,以用户身份向其 PDS 发 createRecord 请求
    ↓
PDS 将 sh.tangled.pijul.refUpdate 写入用户的 AT repo
    ↓
PDS 用 DID 原生签名密钥对 MST 做签名
    ↓
结果:该 DID 密码学地声明了这些 change hash 归属于自己

apply/merge(appview 侧发布):

用户通过 OAuth 登录 appview → appview 持有用户 AT 会话
    ↓
用户 merge discussion → appview 以用户身份向其 PDS 发 createRecord 请求
    ↓
(同上)PDS MST 签名完成

与独立 pijul 签名密钥方案的对比:

独立 pijul 密钥方案 DID-only 方案
签名密钥 单独生成的 Ed25519 DID 原生签名密钥
信任链 pijul key → prove → DID(两跳) DID(直接)
签名位置 change 文件内 PDS 的 AT repo MST
需要 prove 流程
服务器可否伪造 否(无用户私钥) 否(需用户持有 AT 凭证)

DID-only 方案的签名不可伪造:createRecord 请求需用户的有效 AT Protocol 会话(push 时由 CLI 持有,merge 时由 appview 代持),这个会话本身就是用户主动授权的结果。


Change 文件格式#

DID 写入 change 的 hashed [[authors]] 部分,参与 hash 计算:

# change 文件(hashed 部分,决定 change identity)
message = 'Fix bug in parser'
timestamp = '2026-04-03T10:00:00Z'

[[authors]]
did = "did:plc:xxxxxxxxxxxx"   # DID 是唯一身份,无 key 字段

# change 文件(unhashed 部分)
# signature = "..."  # pijul 私钥对 hash 的签名(现有机制,独立于 DID)

配置了 DID 的 identity 在 record 时只写 did,不写 key。未配置 DID 时保持原有 key 行为,向后兼容非 Tangled 服务器。hash 包含 DID,PDS record 里列出的 change hash 密码学绑定了 DID 与内容。


Lexicon 设计#

sh.tangled.pijul.refUpdate(更新现有)#

现有 lexicon 缺少 committerDid 和正确的 repo 格式,更新如下:

{
  "lexicon": 1,
  "id": "sh.tangled.pijul.refUpdate",
  "defs": {
    "main": {
      "type": "record",
      "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.",
      "key": "tid",
      "record": {
        "type": "object",
        "required": ["repo", "channel", "newState", "changes", "committerDid"],
        "properties": {
          "repo": {
            "type": "string",
            "format": "at-uri",
            "description": "AT URI of the repository (at://did:plc:owner/sh.tangled.repo/name)"
          },
          "channel": {
            "type": "string",
            "description": "Channel that was updated"
          },
          "oldState": {
            "type": "string",
            "description": "Pijul Merkle state hash before push (empty for new channels)"
          },
          "newState": {
            "type": "string",
            "description": "Pijul Merkle state hash after push"
          },
          "changes": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Base32 change hashes pushed in this operation, in application order"
          },
          "committerDid": {
            "type": "string",
            "format": "did",
            "description": "DID of the pusher. Redundant when this record is in the committer's own PDS, but preserved for when it is replicated elsewhere."
          },
          "languages": {
            "type": "object",
            "description": "Optional map of language name to line count, computed by knot server"
          }
        }
      }
    }
  }
}

关键决策:此 record 发布到执行操作者的 PDS,语义是"我(操作者)将这些 change 推送/合并进了这个 repo"。

  • 直接推送(pijul push):操作者 = 推送者,record 发到推送者的 PDS
  • apply/merge:操作者 = 执行 merge 的人(通常是 repo owner),record 发到他的 PDS

这样 appview 始终持有操作者的 AT Protocol 会话,可以直接调用其 PDS。committerDid 字段是执行操作者的 DID。原始 change 创作者的身份保留在 change 文件的 unhashed.identity.did 中,两者独立。


推送流程#

pijul push(HTTP 或 SSH,CLI 认证)#

Pijul push 的 PDS 发布由 CLI 本地完成,无需服务器中转。用户先通过 pijul identity link-at 将 AT Protocol 凭证与本地 identity 绑定:

pijul identity link-at --pds https://bsky.social
→ 提示输入 handle + app password
→ 认证成功后:DID + at_pds 写入 identity config,access token 存入 keyring

push 流程:

用户: pijul push https://tangled.sh/did:plc:xxx/myrepo
    ↓
knot server:
    1. 接收 change 文件,写入 repo
    2. (TODO)将 DID 写入每个 change 的 unhashed.identity.did
        (需 Bearer token 认证,knot server 从 token 解析 DID)
CLI(push 成功后):
    3. 从 keyring 取 AT access token(link-at 时存入)
    4. 解析 remote URL 中的 DID,构造 at:// repo URI
    5. 调用 com.atproto.repo.createRecord → 发布到用户 PDS
    6. PDS MST 签名完成 → 归属证明建立

注意:remote URL 须使用 DID 格式(https://tangled.sh/did:plc:xxx/repo),不能用 handle, 否则 CLI 无法直接推导 AT URI。未配置 link-at 时 push 照常工作,只是不发布 PDS record。

SSH push 与 HTTP push 共用同一套 CLI 发布逻辑(均经过 Push::run()remote.finish())。

apply/merge 操作#

merge 由 appview 的 discussions.go 处理(pijul-only)。appview 已持有执行者的 AT 会话, 可直接内联发布,无需 event queue 中转:

Bob merge 了 Alice 的 change:
    appview: actorDid = Bob 的 DID(执行操作者)
    appview: 用 Bob 的 OAuth 会话调用 com.atproto.repo.createRecord
    committerDid = Bob 的 DID,record 发布到 Bob 的 PDS

Bob 的 PDS 记录了他将 Alice 的 change 合并进了这个 repo。Alice 的创作身份保留在 change 文件的 unhashed.identity.did 中。


验证流程#

验证 change X 的推送记录#

1. 查询 knot server 的 changes 历史,得到 committerDid(谁推送/合并了这个 change)
2. 查询 committerDid 的 PDS,列举 sh.tangled.pijul.refUpdate 记录,
   找到 changes[] 包含此 change hash 的那条
3. PDS 的 MST 签名(验证方法:AT Protocol 标准 repo 验证流程)
   证明:该 DID 声明将此 change hash 推送/合并进了该 repo

验证 change X 的创作身份#

1. 打开 change 文件,读取 unhashed.identity.did(原始作者)
2. 该 DID 是 change 创作者的自声明身份
   (无密码学签名,但 change hash 不变,内容完整性有保证)

查询某 DID 的所有推送/合并历史#

GET <pds>/xrpc/com.atproto.repo.listRecords
    ?collection=sh.tangled.pijul.refUpdate

→ 按 repo 字段分组,得到该 DID 在所有 repo 的操作历史

这是一个去中心化的操作图:不依赖任何单一平台数据库,任何人都可以从 AT Protocol 网络重建完整的推送记录。


与 fedi-xanadu 的集成#

Fedi-xanadu 是本设计的第一个实现者,使用场景略有不同——用户通过 Web 界面编辑文章,服务器代为 record。

流程:

用户通过 Web UI 编辑并保存文章
→ fx-server 的 publish_article_content 调用 pijul.record(node_id, message, Some(&user.did))
→ pijul change 的 unhashed 写入 user.did
→ fx-atproto 在用户 PDS 上发布 sh.tangled.pijul.refUpdate
→ 文章历史页面展示 DID,链接到用户主页

由于 fedi-xanadu 已有用户的 AT Protocol 会话,向其 PDS 写 record 不需要额外的认证步骤。


开放问题#

1. 历史 change 的追溯

对于在本方案实施之前推送的 change,可以允许用户发布一条声明 record 来认领历史 change,由 knot server 验证用户确实有该 repo 的推送记录后接受。

2. [[authors]] 格式与 pijul 上游的协调

did 字段写入 hashed [[authors]] 目前是 Tangled 的约定。应与 pijul 上游协调是否纳入官方格式,或在文档中明确为扩展字段。