Architecture - 2026-09-28 - 5 分鐘閱讀

紀錄裡寫的那把憑證,不是真正發出呼叫的那一把

從設定檔讀憑證寫進紀錄,對輪替窗口裡的每一通呼叫都是錯的,而且錯得像是對的。紀錄該改抄什麼、這個界線在哪裡失效,以及我自己的決策紀錄缺的那個欄位。

Decision ProvenanceGovernanceAgent AccountabilityAI-Native Engineering

上一篇結尾我承認有件事答不出來。有位讀者問,長時間執行的 session 中途換了憑證會怎樣,我說那個問題落在我的 control plane 明文排除的範圍裡,所以沒東西可報。另一位讀者回我:你已經答過了。他說得對。那條規則本身站得住,出錯的是我畫的界線:我把排除範圍畫得太寬,連這一題也圈了進去。

答案就是第二條規則往下一層。那條規則說,紀錄只寫系統實際做了什麼,它被要求做什麼不算數。放在 context length 上,要記 runtime 實際給的值,請求裡帶的數字不算。放在憑證上,要記這通呼叫實際出示的那一把,不是設定檔現在指著的那一把。

那個看起來很完整的窗口

輪替不會同時在所有地方生效。設定檔立刻指向新金鑰。但一個已經解析過憑證、手上握著 token 的 session,會一路用到那個 token 過期或某通呼叫失敗為止。這段窗口裡有兩把憑證,真正在做事的只有一把。

寫入時從設定檔讀憑證,紀錄對窗口裡的每一通呼叫都是錯的。欄位有值,也不會報錯。它寫了一把真實存在、格式正確的金鑰,給一通那把金鑰從沒發出過的呼叫。這跟上一篇開頭那件事是同一個毛病:沒被記過的欄位,和剛好等於預設值的欄位,讀回來長得一模一樣。紀錄錯得像是對的;能撐過六週沒人發現的錯,也只有這一種。

而且窗口裡那幾通呼叫,幾乎一定就是事後有人要問的那幾通。憑證會被輪替,通常是因為出了事。

這要付出的是一個值,不是一個祕密

補這件事不需要碰任何敏感資料。要說出哪把憑證發了呼叫,一個識別碼就夠。JWT 的話,kid 和 jti 指出金鑰與那張特定的 token,exp 說出那張 token 什麼時候失去行動能力。這三個欄位都可以公開,憑證本來就把它們設計成讓人引用的部分。

第三個欄位做的事比它看起來多。輪替之後,緊接著要問的就是舊憑證還能動多久;有了 exp,紀錄自己就答得出來。少了它,「這通呼叫發生在切換之前還是之後」的誠實答案是「得重建才知道」。這份紀錄存在的理由,本來就是讓人不必重建。

要抄下來,不要事後查

有一種補法會把同樣的 bug 往上搬一層。

如果紀錄存了金鑰識別碼,exp 卻是寫入時才去查詢,更糟的是拖到讀取時才查詢,那拿到的就是那一刻的金鑰 metadata。下一次輪替之後,那份 metadata 描述的已經是另一把憑證。於是紀錄會權威地陳述一把跟這通呼叫毫無關係的金鑰的有效期,而且前後完全自洽,所以沒有任何東西會示警。

所以規則比「把憑證記下來」更窄:kid、jti、exp 三個都照出示的樣子抄,在呼叫發生的那一刻抄,之後凍結。事後才解析的欄位算不上留痕。它只是讀取當下取到的一個讀數,卻被放進一個自稱留痕的欄位裡。

這也是這個欄位通常不存在的原因。要抄下呼叫實際出示的東西,寫紀錄的那段程式得看得到呼叫出示了什麼,也就是說,記錄器得待在呼叫點,帶著傳輸層的視角。設定檔則幾乎到處都在 scope 裡,是手邊最好讀的東西。讀它產生的欄位,能通過任何你想得到的測試,因為測試很少會在跑到一半去輪替憑證。卡住這件事的是配線,政策上沒有阻力。配線這類工作總是輸,沒有人會基於原則反對它,它只是永遠排不到任何一週的第一名。

這個界線在哪裡失效

exp 那套論證預設了 JWT,但現實裡很大一部分憑證不是 JWT。

靜態的 provider key,你最多拿到一個識別碼,常常只有供應商願意露出的那幾個字元。沒有有效期可抄,因為它根本沒有有效期。輪替之後那個問題,在紀錄裡找不到答案。原因出在結構上,紀錄並沒有漏記。

誠實的寫法是記下「這個窗口在撤銷被確認之前都是開著的」,並且把那個確認當成另一件要有人去寫下來的事實。這樣寫比留空更有價值。空欄位讀起來像「沒什麼要看的」,這件事恰恰相反。這是紀錄知道自己答不出來的情況,那本身就是一個發現,而且是讀者唯一能據以行動的東西。

我自己的紀錄,採信的是宣稱而不是事實

把上面這些論證完之後,我回去讀自己的程式碼,發現它沒照這套做法記錄。

control plane 的政策決策紀錄有十四個欄位。它寫了政策、模式、四值狀態、有沒有被擋、需要哪些檢查、缺了哪些、失敗項、未知項、衝突項、證據 id、理由。關於「誰」,它只帶了一個 request id。request id 指出是哪一個請求,沒說是哪一把憑證;輪替之後,這是兩個不同的問題。

身分在上一層是存在的。adapter context 帶著 actor,核准路徑會去讀它。所以系統知道誰在呼叫。卡住的地方在於身分進得了核准的紀錄,卻進不了決策的紀錄;輪替之後真正要看的,偏偏是決策那份。

然後是我不太想找到的那一段。核准紀錄確實寫了身分,但它的取值順序是:先看請求自己帶了什麼,只有請求沒帶才退回去用 context 上的 actor。

這裡有一個說得通的反駁,可惜站不住。control plane 是一個函式庫,它白紙黑字說認證不是它的職責,安全說明裡寫著消費者必須在對外提供服務之前給出已認證的 actor。照這個讀法,採信呼叫方就是那條明文邊界照設計運作。問題出在同一句話的後半:已認證的那個身分,就是放在 context 上的那個。而程式碼優先採用的是請求 body 裡那個,沒有任何東西認證過它。那條邊界不會為這個優先序開脫,它正是這個優先序之所以錯的原因。

所以這就是請求值贏過生效值,發生在身分欄位上,發生在一個整篇論證都在說「生效值才值得記」的系統裡。我自己的第二條規則,被我自己的程式碼違反,而且剛好違反在那個唯一會讓紀錄可以被指定要寫誰的欄位上。

我不會擺一個我還沒出的修法上來。優先序是反的,我也知道它怎麼變反的:context 還沒穩定填好之前,你會先寫成採信呼叫方的說法,然後它就留下來了。今天我能做的是把它講出來。

兩個你可以自己跑的測試

第一個一分鐘就跑完。找出你系統在上一次憑證輪替期間發出的某一通呼叫,問你的紀錄:是哪把金鑰發的。如果答案來自設定檔,那你沒有答案,你有的是今天的設定檔,被排版成一個看起來像歷史事實的東西。把這件事變成發現的方法是做比對:紀錄裡的金鑰識別碼跟設定檔裡的不一樣時,別把那個差異當錯誤清掉。它在告訴你「有一通呼叫,出自設定檔已經不再指名的憑證」,連時間都說了。把這個比對跑過整個輪替窗口,你就得到那次輪替留下來的那一組呼叫。

第二個是我自己沒過的那個。挑出你紀錄裡任何一個寫著人名或服務名的欄位,追它的值從哪裡來。如果寫入端在請求有帶的時候就採用請求帶的值,那這個欄位記的是一個宣稱,它不構成任何事情的證據。哪些身分欄位是證據、哪些只是在重複別人告訴它的話,這件事值得先弄清楚。

正在做類似的東西?

我協助團隊交付 AI 原生系統——架構、可治理的自主性,以及支撐它們的證據紀律。一次對話就能看出合不合適。

洽談合作