審批
當 command 的計畫觸及一張 require_approval 規則命中的資料表時,什麼都不會套用。該計畫會被凍結成型別為 command_plan 的 staged change,執行紀錄轉為 staged,呼叫者收到 409 approval_required。本頁完整說明這條路徑。
預掃描針對真正的計畫
這個決定不是從定義做出的。Command 會先在一個 savepoint 內執行,接著把跑出來的具體、已完成綁定的 actions,連同真實的前後影像,逐一比對每張被引用資料表的審批規則。這封住了以定義層級掃描會留下的前影像缺口。child_collection step 也以同樣方式掃描,依據的是它實際產生的 header 與子表寫入。
| 已執行計畫中受控管的資料表數 | 結果 |
|---|---|
| 零 | 保留 savepoint,寫入正常 commit。 |
| 恰好一張 | Savepoint 回滾、計畫被凍結、執行轉為 staged,呼叫者收到 409 approval_required。 |
| 超過一張 | 版本 1:422 {"error":"multiple_approval_gated_tables","tables":[…]}。版本 2:500 {"error":"command_execution_failed","phase":"stage","step":null,"path":null,"message":"A command plan spans multiple approval audiences","retryable":false,"details":{}} |
{
"detail": {
"error": "approval_required",
"process_id": "ffffffff-6666-4666-8666-ffffffffffff",
"rule_id": "rule_ab12cd34",
"rule_label": "Orders over 10k",
"staged_change_id": "99999999-7777-4777-8777-999999999999"
}
}兩張受控管的資料表是拒絕,不是排隊——版本 1 回 422,版本 2 則回 phase 為 stage 的 500(路由把每個 command_execution_failed 都回成 500,即使這是刻意的拒絕)。一次執行永遠不能橫跨兩個審批對象,所以任何具體計畫會寫入兩張受控管資料表的執行都會失敗,只碰到一張或完全沒碰到的執行則照常暫存或提交。解法是把它拆成兩個 command,而不是去批准任何東西。
「凍結」是什麼意思
Staged change 儲存的是已經跑過的計畫,不是能產生它的程式。具體來說包含:解析後的 action 清單與其綁定好的欄位值、為新增列預先鑄好的 id、被消耗的紀錄版本與 link 集合、每個 action 讀過的欄位、它綁定的變數與 relation、宣告的 output 契約、staging 當下擷取的授權上限,以及 callback 生命週期的接線。Upsert step 會凍結成它在 staging 時解析出的一般 insert 或 update,連同目標列;放行時不會重新選擇分支。
因為計畫被凍結而 command 沒有,所以在它底下修改或刪除 command 是被擋住的。只要該 command 還有 staged 狀態的執行,PUT 與 DELETE 都回 409 {"error":"pending_staged_executions","staged_change_ids":[…]}。這個檢查在 command 這一列被鎖住時進行,所以新的 staged 計畫無法在檢查與變更之間插隊。請先處理完審批。
即使來源仍然漂移了,release 也會 fail closed 而不是套用一半舊的計畫:409 frozen_manifest_changed、409 source_version_changed 與 409 source_link_changed 就是凍結計畫版本的 source_authority_changed。
Staged command plan 共用模組既有的審批預算:每個 actor 在每張表最多 500 筆待審變更(另有整張表最多 50 筆 pending create 的上限)、套用嘗試最多 5 次(超過即視為毒列),已儲存的回應快照上限 65 536 bytes——過大的 body 會被換成帶 truncated: true 的替身。佇列拒絕是 structured 429 staged_cap_exceeded;前端應顯示回傳的 scope 與 limit,不要內嵌固定上限。
審核者看到什麼
Staged command plan 是刻意去識別化的。Moderator 絕不會看到自己無法讀取的資料表中的列:那些 action 會依(資料表, 動作)對收斂成單一的 {table_id, action, row_count} 替身。版本 2 的計畫更進一步,要求審核者對某個 action 的每一張**來源(provenance)**資料表——也就是該 action 的值所衍生自的那些表——都有讀取權,才會顯示它的細節。
除了計畫本身,版本 2 的 staged change 還提供一份安全的審核 manifest,讓審核者不必看到資料也能判斷意圖:
- command id 與其定義摘要的
tcm1:金鑰承諾值(以 trigger 摘要加上金鑰後的定義計算); - invoker 的 identity 參照;
- rule 名稱與 phase;
- 資料表 trigger 的事件與型別計數;
- 每個 callback 的目的地、header 名稱(絕不含值)、去識別化的 body 形狀,以及它引用的 token;
- 宣告的 output 契約;
private_plan_sha256、review_manifest_sha256與semantic_sha256(manifest 的雜湊)摘要,在 staged change 與 Review process 的讀取中(包括 Review webhook 的$review.content)都以tcm1:<64 位 hex>金鑰承諾值回傳。
已合併 CT #1950 從第一個已編譯 delivery action 產生 callback 項目;沒有 compiled delivery 時,則使用直接 callback。項目顯示該 action 的 HTTP method,目的地只保留 scheme、host 與 path:URL 的使用者資訊、query 與 fragment 都移除。Header 值仍不出現;body shape 與參照 token 也描述同一個 action。這只是審核投影,不是執行 callback 或揭露其凍結 payload 的授權。
凍結的程式也帶著資料表 trigger 生命週期的遮罩副本——webhook 與 api_call 的機密 slot 顯示為 __SECRET__。儲存的值沒有改變,所以 release 仍會驗證它凍結的計畫,包括摘要加上金鑰之前雜湊的舊計畫。
Staged change 的 change_type 就是字面上的 command_plan。要篩選 staged change 佇列的 moderator UI 需要這個字串。
Release 全有或全無,而且會重新檢查一切
批准一個計畫並不等於重放一次權限授予。Release 在單一 transaction 內完成,並且會:
- 重放凍結的計畫;
- 即時重新載入 invoker——已刪除、已停用或已被踢出聊天室的 invoker 會讓 release 失敗——並在版本 2 通道上與凍結的 principal 比對。不相符時以
source_authority_changed失敗,其details.fields列出不相符的欄位名稱(絕不含值),例如{"fields": ["department_id"]},acting room 改變時則是["acting_room_id"]。執行時所屬部門會以暫存時相同的規則重新計算:部門範圍的 command 以 command 自己的部門執行,所以由其他部門執行者暫存的部門計畫現在可以 release(過去不論執行者當下權限為何,都會以source_authority_changed結束為apply_failed); - 先對凍結的 ACL 上限做 dry run,再獨立解析並鎖定當下的 ACL、SCP 與成員資格;
- 把 staged change 從
pending→applying→applied; - 把同一列執行紀錄轉為
succeeded,並回填result_refs與response_body。
任何例外都會整個回滾、寫入終態的 apply_failed(審核者拒絕時則是 discarded)、把執行紀錄翻成 failed,並派送 failed 生命週期 callback——但有兩個例外。Lock 錯誤在兩條通道上都會重新排回待處理(版本 2 會先在程序內重試最多三次);下方的驅動程式文字淨化則屬於版本 1 通道,因為版本 2 的原因一向是封包。
**鎖錯誤會把計畫重新排回待審,而不是丟掉。**Release 期間的 InnoDB 鎖等待逾時(1205)或死結(1213)過去是終態:一個已經被人批准的計畫,因為一次暫時性錯誤就被永久丟棄。現在它會回滾、讓執行紀錄維持 staged,並把 staged change 重新排回審批 sweep,受既有的 5 次套用預算限制。儲存的原因是正規化 JSON 信封
{"details":{},"error":"command_execution_failed","message":"Approved command release should be retried","phase":"release","retryable":true}**失敗原因會被消毒。**SQLAlchemy 的 DBAPIError 字串化後會帶 [SQL: …] [parameters: {…}],而套用迴圈過去把它原樣塞進 staged_change.error 與呼叫者讀得到的執行快照。那些綁定參數可能包含凍結計畫時重新注入的 ACL 隱藏欄位值,於是讀不到隱藏欄位的請求者,可以從一次失敗的已批准更新裡把它撈回來。現在只要錯誤的 orig/__cause__ 鏈上任何一層有 SQLAlchemy 成因,原因就會換成安全版本;本來就安全的 command 錯誤則保留自己的訊息。
審批凍結的是授權上限,不是授權本身。凍結的 ACL 界定計畫最多能做什麼;當下的 ACL 仍然必須允許它。因此已批准的計畫仍可能在 release 階段以 apply_failed 失敗——審核者的批准是必要條件,不是充分條件。
排除送審者與定義者權限
Command definition 可以設定 exclude_requester: true。它與資料表 require_approval 規則的旗標、以及 Review template 本身的旗標,三者任一個都會對它所暫存的計畫啟用排除送審者:送審者——呼叫背後已驗證的人類,對委派型 Agent client 而言就是它所委派的人——不能核准自己的計畫。暫存會在任何 Review process 或暫存變更出現之前就拒絕:必要 gate 沒有合格核准者時是 409 no_eligible_approver,invoker 沒有已驗證的人類身分時是 409 requester_unattributable。Release 會再檢查一次快照中的送審者是否投過核准票;若有,計畫會以 requester_cannot_approve 為原因被丟棄。
由 authority: definition Command 暫存的計畫,會把伺服器推導出的 authority_owner_id 一起凍結(版本 1 的 definer 計畫也會凍結 definition digest)。每個 Command 計畫凍結的主體也都帶有 authority_user_id,也就是委派型 Agent client 背後已驗證的人類。Release 會把它們與即時狀態比對:owner 改變或委派被重新綁定時,會以 source_authority_changed 失敗,details.fields 指出不符的欄位名稱(authority_owner_id 或 authority_user_id,也可能連同其他主體欄位),definition 改變則是 schema_dependency_changed。在這些欄位出現之前暫存的計畫會略過比對。
有審批閘門的 command 與 effect identity
宣告了 effect identity 的 command,會在 release 而不是 stage 時 claim 它的業務事件。Staged 計畫會凍結 occurrence 的資訊——namespace、kind、key 摘要與正規化的 key、semantic hash 與 replay policy——release 則在它自己的權限與 manifest 檢查之後、套用任何凍結動作之前插入 claim,並在 release 的 commit 定案。
- 待審的計畫不會佔住它的事件。第二個呼叫者可以 stage 同一個事件,先被核准並 release 的計畫就是記錄它的那一個。
- 後面的 release 會 fail closed:staged 變更以
apply_failed結束、什麼都不套用,而執行列是failed,error為ct.effect_identity_conflict封套(phase為release、帶details.original_execution_id,兩個計畫相符時details.semantic_match為true)。因其他原因失敗的 release 會讓事件維持未記錄。
執行 command 的當下若事件已經記錄,這次執行會在任何東西被 stage 之前先 replay 或 conflict,所以不會有新的項目進入審核佇列。見 effect identity 的 replay 與 conflict。
輪詢的用戶端看到什麼
執行紀錄是整個生命週期中唯一的事實來源,而且從 staging 到終態始終保有同一個 execution_id。
| 階段 | 執行 status | 用原本的 idempotency key 重放 |
|---|---|---|
| 已 staged、等待審核 | staged | 重新拋出同一個 409 approval_required body |
| 已批准並套用 | succeeded | 原樣回傳回填後的 response_body |
| 被拒絕或 release 失敗 | failed | Failed 紀錄會被忽略,新的呼叫會重新執行 |
以同一個 key 重播只會看到 staged 與 succeeded 的紀錄:被拒絕或 release 失敗之後,同一個請求會重新執行(還可能再次暫存),而權限或相依漂移會回 409 idempotency_context_conflict。要取得終態結果,請輪詢 GET .../executions/{execution_id},而不是重送 execute 請求。409 approval_required 的 body 不帶 execution_id;請用 staged_change_id 在執行紀錄列表中找到那一列。
重播會原樣回傳儲存的快照;被裁切到 65 536 bytes 稽核上限的快照,回來時會帶頂層的 truncated: true,而且沒有任何宣告的 outputs 與步驟資料列。在斷定「這次執行什麼都沒產出」之前請先讀這個旗標——兩種 body 在其他方面完全一樣。truncated 只屬於 execute 通道:執行紀錄路由以固定欄位清單組出 body,而那份清單沒有它,所以 GET .../executions 與 GET .../executions/{execution_id} 就算對被裁切的執行也不會帶這個欄位,標記只會留在巢狀的 response_body 裡。
已經確實 commit 的 succeeded 或 staged 執行紀錄,事後也不會再被改寫成 failed。過去只要 commit 落地但確認訊息遺失,或 commit 之後的 refresh 拋錯,失敗標記路徑就會覆寫那筆已提交的資料列——連帶毀掉重播快照,於是用戶端用同一把 key 重試時會重新執行 command,而不是重播終態結果。現在標記前會重新載入該列,狀態已是終態就原封不動地回傳。
審批與其他呼叫通道
資料表 trigger 不能指向會寫入審批控管資料表的 command——那在 trigger 存檔時就是 409 {"error":"approval_trigger_conflict","table_id","target_table_id","target_table_name","action_type":"invoke_command","command_id","rule_id"}。無人值守的 staging 在設計上就不可能發生:背景觸發永遠不會在別人的審核佇列裡留下一個待審計畫。
這兩道守衛使用的參照集合不同,而這個不對稱是真正的陷阱。Trigger 端的守衛只走 insert/update/delete 分支,所以「僅讀取」控管資料表的 command 是可以接上 trigger 的。REST PUT 的守衛走的是完整 dependency contract,所以在已被 trigger 引用的 command 上,就算只是新增一個唯讀的控管資料表參照,也會得到同一個 409。
延伸閱讀
- 審批總覽——模組共用的 staged change 模型。
require_approval規則——資料表一開始是怎麼變成受控管的。- 執行語意——未受控管的路徑,以及稽核紀錄的行為。