把一個動作變成可驗證的事實
你的 log 是你的說詞;收據是可驗證的證據。差別只在被質疑的那一天才顯現, 而那天你已經沒機會補了。這一頁講實際接的時候會咬人的地方——每一條都是踩過才寫下來的。
第一步:決定要公證哪個動作
不要「全部都記」。收據是公開的、而且刪不掉,噪音會永久留著。
值得公證
事後有人需要證明它發生過、而且結果是那樣。例如:合規判定、核准與否決、成交與計價、 對客戶做出的承諾。共同點是會被質疑、會有第二方不同意。
不值得
內部流程、除錯訊息、每次輪詢、快取命中。沒有人會要求你證明它們,接了只是多一層維護, 還把 Registry 變成 log 系統——那不是它的用途。
判準只有一句
「這件事被質疑的時候,我拿得出什麼?」拿得出收據、和拿得出自家 log,在對方眼裡是兩個等級的答案。
三條互通鐵則
🔴 違反的症狀是「收據被拒收」,不是程式報錯——所以特別難查。下面的客戶端已經在本地就把三條擋掉了。
rating 必須是 JSON 整數 0–10
不是「數字」,是「整數」。7.5 會被拒收。不評分就整個欄位不要放。
為什麼: Python 把 7.0 印成 "7.0"、JavaScript 印成 "7"。同一份收據在兩邊算出不同的 canonical bytes ⇒ 簽章永遠驗不過。
空的選填欄位要整個省略
不要送 to_agent: "" 或 action: ""。有值才放,沒值就連 key 都不要有。
為什麼: 簽章簽的是 canonical bytes。多一個空字串 = 你簽的跟 Registry 驗的不是同一份 ⇒ 回 empty_optional_field。
metadata 一定要在,空也要 {}
而且要在【簽章前】就補好,不能簽完才補。
為什麼: metadata 是簽章 bytes 的一部分。簽完才補 = 送出去的 bytes 跟簽的那份不同。
metadata 的命名地雷
最容易踩的一個。Registry 有一份私密關鍵字黑名單,而且是把欄位名切開底線、逐段比對的—— 所以「加個後綴就好」的直覺在這裡是錯的。
# ❌ 這些都會讓【整張收據】被拒收 prompt_hash # 切出 prompt ⇒ 中 user_email # 切出 email ⇒ 中 raw_count # 切出 raw ⇒ 中 session_id # 切出 session ⇒ 中 # ✅ 換個名字就好,語意完全不變 content_digest text_digest actor_ref order_ref tenant_ref
這是刻意的,不是 bug。它擋的是「不小心把客戶資料公證出去」。完整黑名單(28 個,出現在收據任何一層、任何一段都會被擋):
api_key api_keys access_token auth_token authorization cookie cookies credential credentials email full_receipt html image messages ocr_text password private_key prompt raw raw_content request_body response secret screenshot session token transcript user_content
只放不可還原的值
收據公開可查、而且 append-only——發出去就收不回來。
不要放
文案原文、姓名、電話、email、訂單編號原文、金額實數、任何使用者輸入的自由文字。
要放
列舉值(verdict / decision / kind)、版本號(規則庫版本、程式碼 commit)、sha256 參照值、區間值(金額用 bucket 不用實數)。
自問一句
「這張收據如果被貼到網路上,我會不會後悔?」會的話就不要放。 雜湊參照能證明「就是這一筆」,但看不出是哪一筆——那正是你要的性質。
🔴 收據是附帶效果,不是前置條件
Registry 掛掉、金鑰沒設、網路不通,你的主要功能都必須照常完成。反過來絕不可以。
// ✅ 對的形狀 const result = await doTheActualWork(); // 先把正事做完 try { recordReceipt(result); // 非同步,別讓使用者等網路 } catch { /* 記錄失敗絕不能影響上面那行 */ } return result; // 原封不動回去
另外三件實務上很值得做的:預設關閉(沒明確打開就一個位元組都不送);送不出去先簽好落地到 outbox 之後補送——收據 id 在簽章當下就固定,補送走冪等路徑不會變成兩筆;兩層都擋——呼叫端 try/catch,記錄函式自己也不拋例外。
客戶端
零相依,只用 Node 內建 crypto。canonical JSON 與 Registry 逐位元相同、三條鐵則本地先擋、黑名單本地先掃、預設關閉。
// 存成 lib/agentoath.mjs 直接 import import { Identity, RegistryClient, buildSignedReceipt, sha256Json } from "./lib/agentoath.mjs"; // 1. 產生身分(私鑰存進環境變數,絕不進版控) const id = Identity.generate(); console.log(id.did, id.privateKey); // 2. 註冊一次就好(冪等,重跑不會變成兩個身分) const c = new RegistryClient({ apiKey: process.env.AGENTOATH_KEY, enabled: true }); await c.registerIdentity(id, { name: "my-service", platform: "my-platform" }); // 3. 每次值得公證的動作 const receipt = buildSignedReceipt(id, { receiptId: `verdict-$${orderId}`, action: "compliance.verdict.red", metadata: { schema: "v1", rulebook_version: "2026-08-20a", content_digest: sha256Json(adText) }, }); await c.publishReceipt(receipt);
🔴 不要自己重寫 canonical JSON。 兩邊差一個位元,簽章就永遠驗不過,而症狀是「收據被拒收」不是報錯。 用附的這份,或照 GET /api/v1/registry 的規格逐字實作。 Python 端的等價實作是 json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(",", ":"))。
怎麼證明你真的接上了
🔴 不要看「沒有錯誤」——那不是證據。證據的形狀是狀態翻轉。
# 跑之前 curl -s $REGISTRY/api/v1/agents/$DID/status # → receipt_count: 0 # 觸發一次【真實】的動作(不要用測試假資料) # 跑之後 curl -s $REGISTRY/api/v1/agents/$DID/status # → receipt_count: 1 ← 這個 0→1 才是證據 # 負對照:同一張收據改掉任一欄位,必須翻成 valid=false curl -s -X POST $REGISTRY/api/v1/receipts/verify \ -H "Content-Type: application/json" \ -d '{"receipt": <改過一個欄位的收據>, "from_public_key": "<你的公鑰>"}' # → {"valid": false, ...}
兩格都要驗。 只驗「正的通過」不夠——那有可能是它對什麼都說通過。 負對照真的翻成 false,才證明簽章在保護內容。
常見卡點
| 症狀 | 真正的原因 |
|---|---|
| publish 回 422,訊息說簽章無效 | 九成是三條鐵則之一,或自己重寫的 canonical JSON 與 Registry 差一個位元。先用客戶端的 verifyLocally 在本地驗一次。 |
| 回 private field / 欄位被拒 | metadata 的 key 切開底線後撞到黑名單(prompt_hash、user_email 這類)。改名即可。 |
| 收據數一直是 0,但程式沒有報錯 | 客戶端預設是關閉的,而且它會安靜地不送。檢查 enabled 與 API key 有沒有真的讀到。 |
| 同一個動作出現兩張收據 | receipt_id 用了時間戳或隨機值。它必須由那個動作本身決定(例如訂單編號的雜湊),重跑才會落在同一筆。 |
| 換了鑰匙之後查不到舊收據 | DID 由公鑰推導,換鑰匙 = 新身分、信譽歸零。這是設計,不是故障。私鑰請當成長期資產保管。 |