執行語意
POST .../commands/{command_id}/execute 以呼叫者本人的權限,原子性地把一個寫入型 command 執行恰好一次。本頁說明這個保證的內容、replay 如何運作,以及執行被拒絕的每一種方式。
{
"inputs": { "order_id": "11111111-1111-4111-8111-111111111111", "carrier": "DHL" },
"idempotency_key": "fe-checkout-9f2c1b"
}釘住已核准的 command 定義
expected_contract_digest 是選填的 SHA-256 守衛,供已持有「準備執行之精確 command definition 快照」的受信任 caller 使用。它必須是恰好 64 個小寫十六進位字元(^[0-9a-f]{64}$),而且必須雜湊精確的現行儲存版 definition 物件。這不一定等於建置時送入的 payload:後端會先正規化程式,再附上伺服器建立的 dependency_contract 才儲存。後端的摘要算法是 canonical ASCII JSON 的 SHA-256:object key 排序、,/: 緊密分隔,並跳脫非 ASCII 字元。不要複製範例中的摘要,也不要把建置時或 lifecycle-redacted 的 definition 當成儲存版來雜湊。沒有受信任精確快照的一般人工 REST caller 應省略此欄位。
只要有傳,伺服器會凍結可見 command,並在 command input 驗證、idempotency 保留、建立稽核列、row lock 或任何異動之前比較摘要。不符時恰好回:
{
"detail": {
"error": "command_contract_mismatch"
}
}狀態碼為 409;不會保留 key、不會寫入稽核、不會加鎖,也不會改動資料。人工 REST caller 應丟棄過期的審查依據、取得目前 definition,重新走自己的審查流程,而不是重試相同摘要。省略欄位會維持原有的人工執行行為。
動態生成的 agent write-command 工具透過另一條、完全由伺服器擁有的路徑使用這個欄位。Prepare 呼叫會把精確 definition digest 釘在私有 confirmation state;使用者看見的 bounded natural-language proposal 不會暴露伺服器持有的 definition digest、confirmation token/reference、結構性 UUID 或 developer header。外觀為 UUID/digest 的 command input 仍會作為 business data 顯示。使用者在後續回合明確確認後,agent 只把 32 位 hex token 傳給 custom_table_command_execute_staged_action,伺服器 adapter 才把已釘住的摘要當作 expected_contract_digest 轉送。模型面向的 business schema 與使用者都不提供它。Query 模式 command 與 /query 沒有這個欄位。見 Agent 工具箱的確認協定。
全有或全無
一次執行就是一個資料庫 transaction。所有被引用的資料表會依 id 排序後逐一 FOR UPDATE 上鎖,actions 走 flush-only 核心執行,被消耗的紀錄在 commit 前重新上鎖並重新驗證,最後只有一次 commit。任何失敗都會回滾全部。
沒有部分套用、沒有逐列續跑,也沒有「哪些 step 成功了」這個問題要回答。這個原子性就是整個功能存在的理由——批次操作掛在單一資料表下,跨不了兩張表。
失敗時呼叫者收到一個對應的 HTTP 錯誤,伺服器則留下一列稽核紀錄:
{
"execution_id": "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb",
"status": "failed",
"error": "That order has no lines to ship."
}error 是安全的錯誤細節,上限 4096 字元。result_refs 與 staged_change_id 為 null,並且會派送所有 failed 生命週期 callback。失敗的紀錄永遠不會擋住之後用同一個 idempotency key 的重試。
被寫入的列上的資料表 trigger 會在 command commit 之後才觸發,與一般紀錄寫入完全相同。
併發執行
準備一次執行時會以 row lock 將 authority room 序列化,所以同一個 command 的兩次呼叫本來就會互相競爭。當這場競爭遇上短暫的資料庫 lock 衝突時,execute lane 回的是一個具名的 program error,而不是籠統的失敗:
{
"detail": {
"error": "command_lock_conflict",
"phase": "execute",
"message": "Concurrent write conflict (lock); please retry the request.",
"retryable": true,
"details": {}
}
}狀態碼是 409,retryable 為 true。這不代表 command 壞了——它以前是以 500 {"error":"command_execution_failed","retryable":false} 抵達,那個錯誤讀起來像定義層面的失敗、值得發警報,而這個不是。依照上面的原子性,沒有任何部分變更落地。
請原樣重送同一個請求,並沿用同一個 idempotency_key:execute 的失敗路徑會釋放該保留,正是為了讓同一個 key 能立刻取得一次合法的重試。哪些資料庫錯誤算短暫、以及伺服器在回應前已經自行重試了多少次,見併發與 lock 衝突。
授權:沒有 definer rights
Command 不帶任何隱含權限。每個 action 都在執行期以呼叫者本人的 ACL 與 SCP 判定檢查;你看得到的 command,不代表其中每個 step 你都能跑。
- Command 路由需要 JWT。使用
UserAPIKey會回403 Custom-table command routes require JWT authentication。 - Principal 必須是存活、
verified且未停用的使用者;在 chatroom scope 的掛載點上還必須仍是該聊天室成員。任何失敗都是統一的403 Command principal is not authorized,這同時隱藏了「不存在」與「租戶不符」。 - 投影一個呼叫者無法讀取的欄位是
403 {"error":"column_not_readable",…}——這是唯一一個註冊在 400 以上的 program error。
Idempotency key 與 replay
idempotency_key 選填,1–128 字元。Key 以 actor 分桶,鍵為 (command_id, actor_bucket, key),其中 bucket 是 user:{id} 或 client:{id}。兩個不同的 principal 可以安全地使用同一個 key 字串,任一方都永遠讀不到對方儲存的回應。
保留動作是 24 小時 TTL 的 Redis SETNX,而持久化紀錄就是執行稽核列。Replay 只會探測 succeeded 與 staged 的紀錄——failed 的紀錄會被忽略,讓重試正常進行。執行失敗時會在回應前先釋放它的保留,所以任何失敗之後——包含 command_lock_conflict——用同一個 key 重試都能乾淨地重新保留,而不是在剩下的 TTL 期間一直撞上 idempotency_in_progress。
| 結果 | 意義 |
|---|---|
已存在 succeeded 紀錄 | 原樣回傳已儲存的 response_body,不重新執行任何東西。 |
已存在 staged 紀錄 | 重新拋出原本的 409 approval_required body。 |
409 {"error":"idempotency_key_reuse"} | 同一個 actor、同一個 key,但 inputs 與先前的 succeeded/staged 執行不同。 |
409 {"error":"idempotency_context_conflict"} | 凍結的授權情境——principal、scope、acting room(手動 REST 執行時為 null)、SCP binding、權限/寫入 ACL 摘要、command 定義摘要、相依摘要——已經不符。 |
409 {"error":"idempotency_in_progress"} | 另一個請求持有該 key 的保留,而它的持久化紀錄尚不可見。稍後重試。 |
503 Command idempotency service is unavailable | Redis 無法完成保留。寫入 fail closed,而不是冒重複 commit 的風險。 |
Replay 不是讀快取。第一次呼叫與重試之間的權限異動會讓它以 idempotency_context_conflict 失效。請把這四種結果分開處理——只有 idempotency_in_progress 與 503 屬於「稍後再試」。
已儲存的回應快照上限為 65 536 bytes。過大的 body 會被換成帶 truncated: true 的替身,所以大型結果在 replay 時不會原樣回傳。
相依漂移
每個 command 都帶著伺服器建立的 dependency_contract:逐一記錄每張被引用資料表的 scope、tag,以及 schema definition、rules、審批設定與 triggers 的摘要,外加逐欄位的相依關係。
版本 2 的 command 在每次 /execute 與每次 /query 都會重新驗證這份契約,任何不一致都 fail closed:
{
"detail": {
"error": "schema_dependency_changed",
"phase": "execute",
"message": "Command schema dependencies changed",
"retryable": false,
"details": {}
}
}造成 mismatch 的來源包括 schema 編輯、rules 編輯、審批規則編輯、未經協調的 trigger 變更、改名、軟刪除、換 scope 或換 tag。重新驗證會從契約中第一張資料表推導出當前的 scope 篩選條件、要求整組資料表仍能在其下解析,然後以契約中凍結的 tag_id 重新檢查——不是 command 目前的 tag_id。
不要把所有 dependency maintenance 當成同一種 transaction policy。
一般 refresh 涵蓋 column mutation、rule write、IaC column/rule path、purge-cascade survivor 與 root cross-reference repair。Business mutation 之前,只選 stored contract 仍可重新驗證的 dependent commands;原本 stale 的 command 保持 stale。Mutation 之後,仍可編譯且未逃出 locked closure 的 selected command 會在同一個 commit寫入 refreshed definition。Command 被刪除或 concurrent edit、非資料庫 prepare failure、compile failure、closure escape 都會降級或略過:business mutation 繼續,而該 command 保持 stale。因此,使 command 失效的 mutation 優先,絕不被 dependent veto。DBAPIError 不受這層 containment;lock/deadlock failure 會向上傳遞,讓 caller 中止或重試 transaction。
Multi-table purge 與 cross-reference repair 會依每張被改寫 table 自己的 scope,從寬到窄分組 probe,並讓每個 participating scope 都看見完整的 rewritten-table set。這種分組是完整的,因為 command dependency contract 採 exact-scope-closed:每個 closure member 都必須通過 command 的精確 scope filter。Table 本身可以向上連結,但 command closure 若企圖觸及較寬 scope table,authoring 會以 A referenced table does not exist in this scope 失敗,因此不可能存在落在自己 scope probes 之外的 stored contract。直接依賴被 purge table 的 command 依設計保持 fail-closed stale;surviving rewritten table 上被選中的 command 若仍可編譯則刷新。剩下的是另一個 fast-path 視窗:scope 沒有 dependent command 時,prepare 不先取鎖;若此時併發建立新 command,它可能依 mutation 前形狀編譯並落成 stale。一般 refresh 對被 mutation 本身弄成 invalid 的 previously valid command,也仍維持上段所述的 best-effort 政策。
Trigger refresh 則 fail-closed。Candidate trigger graph 會在鎖內重驗,所有 selected、先前 valid 的 command 都必須刷新成功。Trigger、schema history、適用時的 IaC state 與全部 refreshed definitions 構成單一 atomic commit。Compile、closure、concurrent-edit 或 refresh failure 會 veto 並回滾 trigger mutation;REST 回結構化 409 {"detail":{"error":"command_dependency_refresh_failed","message":"…"}}。MySQL 1205/1213 回 flat-detail 409 {"detail":"Concurrent write conflict (lock); please retry the request."};其他 database operational failure 回淨化後的 500 {"detail":"Database operation failed."}。
任一成功 refresh 都會推進 stored command/dependency digest,因此編輯前尚未完成的 agent confirmation 會失效,但 command 不需人工重新存檔即可繼續執行。
版本 1 的 command 不會重新驗證整份契約。它只預先驗證每個正規化後的欄位 key 仍然存在、且資料表仍在 scope 內,因此容忍不相干的編輯。版本選擇會改變漂移行為;不要對兩者宣稱同一條規則。
有兩個相鄰但不同的失敗,值得在 UI 上分開:
400 Table is currently locked for operation: <operation>——某張被引用資料表正被 schema 遷移持有。遷移結束後即可重試,而且它在任何寫入之前就先拋出。409 {"error":"source_authority_changed","message":"Command authority changed before execution"}——command 這一列在驗證與套用之間被刪除,或它的 scope、tag、定義摘要改變了。在別人執行 command 的同時修改它,會中止那次執行,而不是執行一半舊的計畫。
手動執行時的 channel scope
手動 POST /execute 或 /query 不綁定任何 acting room。acting_chatroom_id query 參數、帶交集 candidates 的 400 acting_room_required 階梯、403 acting_room_unsatisfiable 檢查,以及 422 acting_chatroom_id is not applicable to a company-scoped command 守衛,全部已於 2026-07-29 移除。acting_room_unsatisfiable 不再是 execute 錯誤 body 的 discriminated union 成員,command 路由的 response model 現在也完全不宣告 403。
Command 觸及的每一張受 channel 管制的資料表,都會各自從 invoker 獨立解析自己的 floor:該表的 manager 豁免,否則就是 invoker 所屬、存活、且對該表持有 internal grant 的每個房間,其 scope_values 的聯集。已經沒有那個會整組失敗的跨表交集,所以多表 command 不再比它所執行的寫入更難滿足;invoker 碰不到的資料表,會以該表自己的寫入 floor 拒絕浮現:
| 情況 | 結果 |
|---|---|
| Invoker 沒有任何存活房間在某張受管制資料表上宣告 scope | 該表回 403 {"error":"scp_scope_undeclared","message":"no chatroom of yours declares a scope on this table"} |
| 聯集非空,但沒有涵蓋 command 要寫入的列 | 該表回 403 {"error":"scp_out_of_scope", …} |
Invoker 讀不到的 SELECT 來源或 JOIN 資料表 | 403,detail 是純字串 "Read access not granted for this table.",不是 program-error envelope |
| Chatroom scope 掛載點 | Path 上的房間仍是 principal 的成員資格錨點,但不會釘選 channel floor |
| Company scope 掛載點 | 沒有東西可拒絕。參數已不存在,任何 422 都不可能觸發 |
仍然送出 ?acting_chatroom_id= 的用戶端 | 200,靜默忽略。FastAPI 會丟棄未知的 query parameter |
可信任的 agent 通道仍然釘選房間,也仍是唯一能把房間帶進 company scope command 的呼叫者。它的房間來自已驗證的 service principal,永遠不是 query 參數;這正是 company scope command 觸及受管制的部門資料表時,agent 的房間 ACL 仍然有效的原因。因此同一個人執行同一個 command,走 REST 可能合法地碰到比走 agent 更多的列:REST 解析他所有房間的聯集,agent 只解析 session 所在的那一個房間。這個不對稱是刻意設計的——見 union 契約。
外部(social client)principal 仍然只能透過自己那個存活的房間行動;受管制資料表若對該房間沒有 external grant,仍然是 403 This chatroom has no external grant on this table.。這種 principal 是走可信任的 service 通道進入 command,不是走 JWT REST 路由。
執行期資源預算
每次執行都在一組固定上限之下運作。每次超限都帶有機器可讀的細節,用戶端不必解析自由文字。
| 資源 | 上限 |
|---|---|
| 整體期限 | 30 秒 → 504 command_timeout |
| 單一 select 語句逾時 | 5 秒 → 504 query_timeout |
| 每個 relation 的列數 | 1000 |
| 暫存列數/位元組 | 5000 列、5 MB |
| Mutations/寫入嘗試 | 各 1000 |
| 運算式節點訪問次數 | 1 000 000 |
| 資料庫語句數 | 2000 |
| Join 候選 | 每個 select 20 000,每次執行 50 000 |
| Work rows | 每個 select 20 000,每次執行 50 000 |
| Output 列數/位元組 | 100 列、65 536 bytes |
| 每個 command 的相依資料表 | 20 |
超限會以 400 {"error":"query_limit_exceeded"|"relation_limit_exceeded"|"mutation_limit_exceeded"|"output_limit_exceeded","details":{"resource":…,"limit":…,"actual":…}} 呈現,逾時則是 504 {"error":"command_timeout"|"query_timeout","details":{"limit":…}}。
Authoring 期的結構性預算是另一組:定義上限 1 MB、請求 inputs 上限 1 MB、整份定義 JSON 深度 64/16 384 節點、where 樹深度 12/128 節點、$case 深度 8/64 節點/20 個分支、rule 條件深度 8/128 節點、單列運算式 256 節點、整份定義的運算式 8192 節點。
所有 program error 共用同一個封閉封套——{error, phase, message, retryable, details, step?, path?},其中 phase 為 author、execute、stage、release 或 callback,details 依錯誤碼限制在一份很小的允許清單內。未註冊或不公開的細節會降級成通用的 500 {"error":"command_execution_failed"},確保什麼都不外洩。
有五個錯誤碼代表「明確什麼都沒寫入」
assertion_failed、cap_exceeded、cardinality_failed、command_input_invalid 與 command_rule_failed 都是伴隨 rollback 拋出的,所以不像逾時或 5xx 那樣,對「交易究竟有沒有 commit」留有不確定。在 agent 通道上,這個區別現在對呼叫端與客戶都是可見的(backend PR #1179)。
產生出來的 command 工具會回 {"status", "action", "error": "<code>", "retryable": false, "guidance"}——請注意錯誤碼掛在 error 底下,不是 code;code 是下游 pipeline 正規化 receipt 上的 key。只有 assertion_failed 會額外附上 reason:那是 command 作者自己寫在失敗 assert step 上的 message,會把空白壓成單行純文字並截到 200 字元。另外四個錯誤碼沒有作者文字。provider 或資料庫的任何內容都不會被複製進這個封套。
客戶接著會讀到什麼——作者寫的原因,或該錯誤碼在房間語言下的固定後備字串——見 agent 工具箱。手動 REST 呼叫端不受影響,仍然收到上面那套對應後的 HTTP 錯誤。
執行稽核紀錄
每次寫入型執行都會留下一列。用 GET .../commands/{command_id}/executions(skip ≥ 0、limit 1–200、預設 50,由新到舊)讀取清單,用 GET .../commands/{command_id}/executions/{execution_id} 讀取單筆。
{
"execution_id": "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb",
"command_id": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa",
"status": "succeeded",
"invoker_id": "77777777-8888-4888-8888-777777777777",
"idempotency_key": "fe-checkout-9f2c1b",
"duration_ms": 184,
"input_snapshot": { "order_id": "…", "carrier": "DHL" },
"authorization_snapshot": {
"version": 1,
"scope": { "kind": "chatroom", "id": "22222222-2222-4222-8222-222222222222" },
"tag_id": "55555555-5555-4555-8555-555555555555",
"table_ids": ["77777777-…", "88888888-…"],
"command_updated_at": "2026-07-20T04:11:07",
"audience": "internal",
"origin": "manual",
"ai": false
},
"created_at": "2026-07-25T09:11:02"
}status 為 succeeded、staged 或 failed。steps 上限 40 筆,result_refs 上限 20 筆。每個 execution 與 query 載荷都會丟掉值為 null 的 key,請把「key 不存在」視為 null。
可見性由凍結的快照決定,而不是由目前的定義決定。 一列在下列任一條件成立時可見:
- 呼叫者就是 invoker(
invoker_id或invoker_client_id相符且非 null);或 - 凍結的
authorization_snapshot.scope等於掛載的 scope,且呼叫者目前 manageauthorization_snapshot.table_ids中的每一張資料表。
快照格式錯誤或缺失時視為不可見——這個檢查 fail closed。實務上的結果是:一筆執行的可見性取決於它當時碰到的資料表,即使 command 後來被改成碰別的表也一樣。其餘情況一律是 404 Command execution not found。
Agent 派送調解視窗
透過 AI agent 確認流程執行的指令,會比 REST 執行多留下一個持久產物:執行紀錄 lifecycle_snapshot 底下 agent_response_delivery 這個私有的派送標記。它只存在於伺服器產生的 agent-confirmed: idempotency key,手動 REST 執行永遠不會有。
當這個標記還是 pending 時,相同的 agent 呼叫會重播原本那次執行,而不是再寫一次。這正是「使用者在沒收到回執後又講一次同樣的話」不會變成重複排班的原因。標記會在自然語言結果跨過頻道可見性邊界時被釋放。
視窗長度由指令作者決定。definition.delivery_reconciliation_ttl_seconds(60–604,800 秒,預設 900)在執行當下解析,並以絕對時間 expires_at 寫進標記,因此事後編輯定義永遠不會回頭改動已提交標記的視窗。
| 標記狀態 | 意義 |
|---|---|
pending | 仍在視窗內。相同呼叫會重播原本那次執行,不會產生新的寫入 |
expired | 已超過 expires_at。標記不再強制重播,相同呼叫會真的執行——這是有界的重複風險,由指令作者在選擇 TTL 時承擔 |
acknowledged | 結果已送達,或由維運人員釋放。終態 |
到期是惰性的,不是排程作業。該房間的下一次派送查找會掃描最近 65 筆 agent-confirmed 執行紀錄,在另一個 best-effort 交易中把所有已過期限的標記翻成 expired(該交易只記錄錯誤、永遠不會擋住查找),並把它們排除在存活集合之外。由此衍生三件事:
- 這個功能上線前寫下的標記沒有
expires_at,它們以created_at + 900 秒到期,因此既有的殘留標記在第一次被碰到時就會自行清除,不需要維運人員掃描。無法解析的expires_at適用同一條舊規則。 - 尚未落定的 in-flight 紀錄——稽核狀態為
failed但error為空,可能屬於一個仍在執行的組合交易——在created_at + 3600 秒之前不能到期,即使作者設定的 TTL 更短也一樣。提早讓它到期,會讓相同呼叫產生新的 key 並且併發地重複執行。 - 每次調解查找最多容許 16 個存活的 pending 標記。超過這個數量,或 65 筆的掃描視窗被塞滿時,查找會 fail closed,該房間所有變更類指令都會回覆寫入保護失敗,直到容量釋出為止。到期標記不再計入這個 16——這就是為什麼壞掉的確認路徑現在最多只能卡住一個 TTL,而不是永久卡死。
在既有指令上設定或修改 delivery_reconciliation_ttl_seconds 會改變儲存的定義,因此也改變定義摘要。任何已針對該指令備妥的 agent 確認提案都會失效——agent 收到 proposal_mismatch,或伺服器回 409 {"error":"command_contract_mismatch"}——使用者必須重新確認一個新提案。這是正確行為而不是退步;完全不設這個 key 則什麼都不會改變,因為它採 omit-when-default 序列化。
有兩個維運端點負責讀取與釋放這些標記:GET /root/hotfix/custom-table-agent-deliveries 列出標記及其狀態、expires_at 與 in_window 旗標;POST /root/hotfix/custom-table-agent-deliveries/acknowledge 釋放指定的標記——execute: false 只預覽逐筆結果、完全不寫入。要確認一個視窗仍然有效的標記必須加上 force: true,因為那會取消仍在運作的 replay guard,重新開放視窗內的重複執行。確認標記永遠不會回滾已提交的寫入。兩者都收錄在 root 營運參考頁。
保留期
終態稽核紀錄在 180 天後被清除,但只有在營運人員執行 POST /root/hotfix/custom-table-command-executions/prune 時才會發生。這不是背景排程工作。
- 只刪除
created_at早於截止時間的succeeded與failed紀錄。 staged紀錄不論多舊都不會被清除。- 回應為
{pruned, retention_days: 180, cutoff}。
input_snapshot 保存每次執行綁定後的 inputs,在該清除作業執行之前都可能持有終端使用者的個人資料。請據此規劃營運人員的執行頻率,不要假設 180 天會自動到期。
另一個配套作業 POST /root/hotfix/custom-table-command-callback-runs/requeue(older_than_seconds 預設 300、limit 1–2000 預設 500)用於回收遺失的 command callback 派送:重新發布停滯的 pending 列、重試符合條件的 failed 列、回收停滯的 running 列。派送以 run id 為單位設有 fence,而每次派送都帶著穩定的 X-TeamSync-Callback-Run-ID header、最多 5 次嘗試——正確性邊界是接收端以該 id 去重,而不是精確一次派送。兩個作業都收錄在 root 營運參考頁。
延伸閱讀
- Query 模式——不加鎖、也不留稽核紀錄的通道。
- 審批——
409 approval_required的意義,以及凍結的計畫如何收尾。 - Commands 參考——路由、參數與完整錯誤表。