回執
帳本的原子——規範化序列化、簽名回執信封、失敗也簽、分段與選擇性披露、分段代際。
規範化序列化:一切哈希的地基
任何簽名/哈希方案的第一個漏洞都是「同一內容有多種字節表示」。Open Tally 的規範化形式是 RFC 8785(JSON Canonicalization Scheme),外加一條約束:文檔中不得出現 JSON 數字。
為什麼禁用數字
RFC 8785 按 IEEE-754 double 序列化數字。對財務記錄而言有兩個致命後果:超過 2⁵³ 的整數不可精確表示(9007199254740993 往返後變成 …992),小數被近似且各產出方近似結果不一致。任一條都會讓「兩個都沒寫錯的實現」對同一份回執算出不同哈希。禁用數字是從設計上消除該失效模式,而不是要求實現者小心避開它。
所以全部數值以十進制字串承載。 金額全鏈路使用 int64 micro-USD(1 USD = 1,000,000),任何環節都不存在浮點。整數字串只有唯一合法寫法(^(0|[1-9][0-9]*)$):007 與 7 是同一個數、卻是不同的字串——而被哈希的是字串。
該禁令是校驗規則而非對 JCS 的偏離:輸出仍是標準 JCS,現成的 JCS 庫可逐字節復現。
必須檢查原始字節的規則
有四條校驗無法在解析之後進行——主流 JSON 解析器會靜默地把它們「解決」掉。只校驗解析後取值的實現會把四條全部放行:
| 規則 | 為什麼要緊 |
|---|---|
| 拒絕重複鍵 | {"amount":"1","amount":"1000000"}——人讀是一種讀法,解析器取後者且不報錯:讀者看到的文檔與被哈希的文檔可以不同。 |
| 拒絕未配對的代理項轉義 | Go 與 JavaScript 都會把未配對代理項替換為 U+FFFD,於是 "\ud800" 與字面 U+FFFD 會規範化成相同字節、共用同一個簽名。 |
| 在原始字節上拒絕畸形 UTF-8 | 不同語言「修復」非法字節的方式不同——兩個看起來都合規的實現會對同一輸入產出不同的規範化字節。合法性必須在任何解碼器運行之前、在原始字節上裁定。 |
| 拒絕尾部多餘數據 | 每份文檔恰好一個 JSON 值。 |
這些規則同樣約束簽發方,且簽發方無法靠字節檢查來履行——序列化器在序列化過程中就完成了 U+FFFD 替換。簽發方必須在序列化之前校驗字串取值;分段載荷裡帶著簽發方不控制的字串,例如用戶自取的 token 名、上游回顯的模型名。
每一條「看起來對的實現也會栽」的規則都有一致性向量——確切的輸入字節、期望輸出字節與其 SHA-256;完成的判據是兩個獨立實現(Go 與 JavaScript)對每個接受向量產出相同哈希。見自行實現驗證器。
回執:每筆可計費事件一張
每個佔用序號的事件恰好簽發一張回執。回執 = 一個信封加一個或多個分段。
信封
信封小而固定,是哈希鏈與簽名所承諾的對象。下面是一張真實的——來自 linkex.ai 生產帳本(自己取一份:/api/receipt/bundle?stream=ch:10&seq=2):
{
"spec": "linkex.usage-receipt",
"spec_version": "1",
"issuer": "linkex.ai",
"stream": "ch:10",
"seq": "2",
"issued_at_ms": "1788570354585",
"key_id": "ed25519:3d1aa5da8d326147165e10d4aa00cf8ef1daf690f708741fe7d4719dda3064e3",
"sections": {
"usage.customer.v1": "sha256:9e84ce945594491036f5fb9938a2923fd4bcc87bec8c5814005e9cba8b6fb925",
"usage.supplier.v4": "sha256:8bcd11cde66e315c1aa919fdb1c0bdf50e28a232b9978043d7ad0cf95528a28c"
},
"prev_hash": "sha256:421a7c8ed1e9a9b1d3639bef02b41de634196424f82e1ebc9747bf24cd30503e"
}| 字段 | 含義 |
|---|---|
issuer | 簽發主體——在簽名字節之內,故簽名無法被重放為另一簽發方的 |
stream | 該回執所屬的哈希鏈(每個上游渠道一條) |
seq | 流內單調遞增,不允許空洞 |
issued_at_ms | 簽發時刻——一個機械事實,與請求何時運行是兩回事 |
key_id | ed25519: + 原始 32 字節公鑰的 SHA-256——是指紋,不是標籤 |
sections | 分段名 → 分段載荷的內容哈希 |
prev_hash | 同流上一張回執的自哈希(流的第一張為全零) |
其中兩個選擇比表面上承重得多:
key_id是可推導的指紋。 驗證方從公開密鑰歷史取到公鑰後,自行復算sha256(原始公鑰),確認「拿到的就是回執指名的那一把」。不透明標籤(key-3)可以被靜默改指向另一把密鑰而無人察覺;指紋不能。- 信封中刻意不設會計期間字段。 一筆請求歸屬哪個期間是業務規則(跨期請求歸哪一期,由公布的歸屬規則裁定),它放在分段裡——且它是簽名之內某個時間戳的純函數,事後被改動的歸屬會與重算結果不符。把業務規則擋在密碼學層之外,意味著歸屬規則變更時,承諾結構無需重發。
失敗也簽
失敗、取消與零費率的呼叫同樣佔用序號並獲得回執,以計費狀態如實標記。這不是潔癖,而是完備性論證的前提:既然每個事件都必須有回執,序號上的空洞就沒有無辜的解釋。 若失敗乾脆不簽,序號中將充滿「合理的空洞」,被隱匿的記錄與一次服務失敗將無法區分——整套機制隨之不可證偽。
(對實現者的推論:序號只能在結果已知後分配,因為回執要記錄該結果。)
信封 + 分段:選擇性披露
業務數據放在按內容哈希承諾的分段裡——在 linkex.ai 實例中是供應側分段(消耗了什麼、上游成本幾何)與客戶側分段(終端客戶被收了多少)。
簽名覆蓋的是分段哈希而非載荷本身。由此得到兩個性質,且都是需求而非便利:
- 可證明的脫敏。 運營方可以把供應側分段披露給供應商、客戶側分段披露給客戶。未披露的一側不影響驗證已披露的內容——且接收方仍能確認被隱去的分段在簽發時已被固定、此後未被替換。任何一方都看不到運營方的完整毛利;每一方都能驗證自己收到的一切。收到的,你能驗證;沒收到的,你不必憑信。
- 可擴展而不作廢歷史。 新字段進新的分段版本;舊版本下簽發的回執永遠按舊版本規則可驗。
分段載荷之內,該版本定義的每個字段恆在——絕不因取值為空而省略。缺失只有一個含義:載荷不是它自稱的版本,予以拒絕。沒有這條規則,驗證方無法區分「值為空」與「該回執早於此字段引入」。
分段代際:演進公式而不動歷史
分段版本歸入代際。linkex.ai 實例已到第五代:
| 代際 | 改了什麼、為什麼 |
|---|---|
| 1 | 初始信封 + 分段。 |
| 2 | 補上重算輸入——分方向單價、幣種、時段係數、價目 id。在它之前,確定性重算(檢查 6)對按 token 計費的回執根本無法通過——而報告如實說明,不假裝。 |
| 3 | 把數字是如何算出來的簽進簽名,而不只是數字本身:tokenizer 身分與版本、計數模式、閘道自己的獨立 token 計數(與上游報數並列)、計價的快取用量、實際服務該呼叫的上游端點、以及某筆已回執請求未被計費的原因。一個無人能質疑的數字不是證據——披露輸入才使數字可被質疑,也因此才有意義。 |
| 4 | 修正 token 公式(reasoning token 雙計、快取 token 雙收、兩次截斷)並刪除一個「同一數字印兩遍、卻聲稱第二遍獨立」的字段。 |
| 5 | 增加客戶側逐項拆解,讓客戶不僅能驗證金額被簽了名,還能驗證它逐行乘得出來(檢查 7)。供應側不變。 |
協議規則是絕對的:
已簽名的歷史逐字不動。 驗證與重算按回執自己聲明的代際分派。
已公布的分段版本即凍結:版本之內永不增刪或改名任何字段。修正公式缺陷的方式是在舊代際旁邊鑄造新代際——第 3 代回執永遠按第 3 代公式重算,因為那才是它們承諾過的算術。代際切換兩側的回執屬於同一條不斷的鏈:鏈驗證只讀信封、從不讀分段內容。
回執為重算記錄了什麼
金額必須僅憑回執即可確定性重算(檢查 6)。因此回執在簽名內容中記錄:所施用的單價與價目版本、計量輸入及其來源(tokenizer id/版本、計數模式——見實例保證)、實際施用的時段係數,以及上游報數所遵循的口徑約定(上報的 prompt token 是否已含快取 token——沒有默認值:口徑缺失時驗證器報「無法重算」,而不是去猜)。
係數從回執讀取,不從今天的配置重新推導——否則一次尋常的調價會讓每一條歷史回執重算失敗,而什麼也沒被篡改。檢查 6 確立的東西刻意地窄:金額由被簽名的那些輸入推得。 那些輸入是否合同上正確,由價目表與對帳裁定。
披露遵循逐字段審定的白名單,對手方確切知道自己恆定會收到哪些字段。