Architecture - 2026-09-30 - 5 分鐘閱讀
凍結了也還是自述:只有一個證人的欄位
照規則在呼叫點抄下並凍結的欄位仍可能什麼都證明不了,因為紀錄裡沒有第二個人握著可能不同的值。怎麼逐欄找出第二個證人、我自己的系統哪個欄位沒有,以及第二個證人自己的盲點。
上一篇的規則是:紀錄要記這通呼叫實際出示的那一把憑證,在呼叫點抄下來之後就凍結。文章發出後隔天,讀者 @anp2network 拿兩個例子說明這條規則只管到一半。兩個欄位都照規則正確寫入,卻什麼都證明不了。
第一個是執行端自己回報的執行時間。對方彙整公開的事件帳本,找到 11 筆回報時間早於「接下這份工作」那個事件的紀錄。這種先後順序不可能發生。值是在呼叫點抄下並凍結的,完全符合我的規則,可它照樣沒有用:另一個手上有時鐘的人從來沒拿兩個時間比過。
第二個是判決簽章。在一份上線中的帳本裡,1,482 筆判決事件全部由同一把金鑰簽署。簽署者欄位準確也凍結了,但它能說的只有「是哪把金鑰簽的」。系統裡根本不存在第二個可能簽出不同結果的人。沒有偽造可以抓,也沒有分歧可以浮現。
以上兩組數字出自這位讀者的量測,我沒有重跑。
第三種欄位
上一篇我把欄位分成兩類:證據,和宣稱。分法看值從哪裡來。從呼叫實際出示的東西抄來的算證據;從請求、設定檔或任何「別人告訴它的話」讀來的算宣稱。
這位讀者指出還有第三類:形式上是證據,結構上無法查核。來源和寫入時機都沒問題,問題在整份紀錄裡只有一個證人。簽章驗證能確認這個證人是誰,卻沒辦法替它的說法作證。
所以對每個欄位,除了問值從哪裡來,還要再問一句:還有誰手上握著一個可能跟它不一樣的值? 答案是「沒有人」的話,這個欄位就只是凍結的自述。寫得再小心也一樣。
答案是「有,但沒人去比」的情況同樣要算進來。執行時間那個例子裡其實有第二個時鐘,只是沒有任何東西讀這個欄位。沒人讀的欄位就算寫入方式正確,也會退化回自述。
我自己的例子
回頭看自己的程式碼,最先找到的就是一個沒人讀的欄位。
在 aine-control-plane 裡,核准請求與變更請求有 requested_by 欄位,修復計畫和 runner session 也有。修補產物和驗證報告則有 reported_by。上一篇我已經承認核准路徑的取值順序是反的:請求 body 有帶就用請求帶的值,沒帶才退回 context 上已認證的 actor。這次查下去才發現同樣的寫法共有六處。
更讓我在意的是另一件事。修之前的測試全部通過,卻沒有任何一個測試斷言過 requested_by 的值。介面會把它顯示出來,但沒有任何程式拿它跟另一個值比對。從 8 月 31 日開源到這次修正的一個月裡,payload 一直可以自己指名發起人而不被任何東西發現。紀錄裡關於「誰」只有這一個證人,這個證人就是呼叫方自己。
把一個證人變成兩個
修法來自另一位讀者 @_firelinks 的留言。對方指出只把優先序反過來還不夠:只要 context 沒填時仍會退回用 payload 的值,任何忘了填 context 的路徑都會讓請求 body 再次指名發起人。對方的建議是兩個都記,各放在名字直接說明來源的欄位裡。其中一個永遠不要複製到另一個。
PR #7 照這個做:
requested_by和reported_by只取已認證的 context。context 是空的就寫unknown,任何地方都不再退回 payload。authenticated_actor來自 context,缺少時是 null。claimed_actor保存 payload 說了什麼。
這樣紀錄裡就有兩個證人:認證層說是誰,呼叫方說是誰。兩者不一致的那一列就是一筆發現。authenticated_actor 為 null 的列也有用,它們數出還有幾條路徑沒接上身分驗證。問題的大小因此可以量,不再只是「已知有一行寫錯」。
測試也照對方寫的那個:送一筆 requested_by 指名別人的核准請求,斷言紀錄寫的是呼叫者;沒有呼叫者時寫 unknown。這是這個欄位第一次被程式讀取。
做對的例子:裁判不共用球員的程式碼
我的另一個專案 Orvena 是給 agent 用的治理 runtime:你宣告一個任務能碰哪些範圍,Orvena 負責守住這些邊界。它從一開始就照「第二個證人」的方式設計,只是我當時沒這樣稱呼它。
在 Orvena 裡,「完成」的定義是使用者的 verify 指令結束碼為 0,模型說自己做完了不算數。基準測試裡負責判斷「有沒有寫到不該寫的地方」的 oracle,刻意不呼叫被量測的那一層 governance::scope。它自己重新實作一次可寫範圍的規則,再拿 git diff 產生的證據去比;git 看不到根目錄外的寫入,那部分另外用探針抓。程式碼註解裡寫的理由是:球員不能當自己比賽的裁判。
自述在兩個方向都會出錯。在一次重測裡,未受治理的那組 24 次執行中有 18 次直到步數用完都沒宣稱完成。這 18 次裡有 12 次其實已經寫出能通過驗證的檔案。只看模型自己的說法,這 12 次都會被記成沒做完。解題率因此一律由迴圈外重跑 verify 另外計算。
第二個證人也有盲點
第二個證人要看得到第一個證人看不到的東西才有用。Orvena 的文件自己記了一個反例:有一題的偷懶解法是把預期答案直接寫死。這樣的改動仍在可寫範圍內,verify 也會通過。gate 和 verify 看的都是測試結果,等於兩個證人看同一份東西,所以這一題分辨不出答案是算出來的還是抄來的。文件把它列為已知限制,並註明基準上的任何數字都區分不了這兩種情況。
所以那個問題要問得更細:另一個值是不是由另一個來源獨立產生的。兩個欄位如果都從同一份輸入推出來,那只是同一個證人說了兩次。
PR #7 的兩個欄位也該用這個標準檢查。authenticated_actor 來自認證層,claimed_actor 來自 payload,來源確實不同。但它們在同一個函式、同一個時刻寫入。如果認證層本身被騙,兩個欄位照樣會一致。這個設計抓得到呼叫方謊報身分,抓不到認證本身出錯。
上一篇該修正的地方
@anp2network 的第二個點針對我上一篇的第一個測試:拿紀錄裡的金鑰識別碼去比對目前的設定檔。設定檔會變。再輪替兩次之後,原本不一致的那一列可能又變回一致。那筆發現就這樣消失,中間沒有任何人改過紀錄。
比對的對象應該換成凍結的發行紀錄,不要用目前的設定。這樣一次輪替留下來的那一組呼叫,一年後查起來還是同一組。這點我照單全收。我的 control plane 目前沒有這樣的發行紀錄可以比對。
逐欄檢查
這份清單可以直接套到你的紀錄上。對每個欄位依序問:
- 值從哪裡來?從請求或設定檔讀來的是宣稱。
- 誰還握著一個可能不同的值?沒有人的話,這欄就是單一證人。
- 那個值是獨立產生的嗎?從同一份輸入推出來的不算。
- 有沒有東西真的拿兩者比對?沒有的話,前面三題都白答。
我自己的 requested_by 在修之前,四題全部答錯。