執行語意
綁定 Command 的機器寫入者新增範圍受限 service credential 與驗證過的 CT 輸出回呼。沿用既有 executor 與目前來源檢查,不授予原始資料表權限;回呼卡片為特權、僅供參考。
POST .../commands/{command_id}/execute 以呼叫者本人的權限(選擇採用的 definition 則是以它宣告的 definer ceiling),原子性地把一個寫入型 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 應省略此欄位。GET 或 list 的回應也不是那個物件:它會把未設定的選填 key 序列化成 null,而且自 trigger 機密改為唯寫後,舊 contract 的純 triggers_digest 會改以 tcm1: 承諾值回傳,所以對回應 body 計算的摘要不會相符。
只要有傳,伺服器會凍結可見 command,並在 command input 驗證、idempotency 保留、建立稽核列、row lock 或任何異動之前比較摘要。不符時恰好回:
{
"detail": {
"error": "command_contract_mismatch"
}
}狀態碼為 409;不會保留 key、不會寫入稽核、不會加鎖,也不會改動資料。人工 REST caller 應丟棄過期的審查依據、取得目前 definition,重新走自己的審查流程,而不是重試相同摘要。省略欄位會維持原有的人工執行行為。
動態生成的 agent write-command 工具透過另一條、完全由伺服器擁有的路徑使用這個欄位。工具在該回合建立時就記下儲存定義的摘要;模型可見的 schema 與使用者都不會提供它,query 模式的工具與 /query 也沒有這個欄位。
- Direct 通道(預設)。 每次呼叫都立即送出釘住的摘要——沒有 prepare 步驟,也沒有同意回合。
- 確認通道(聊天設定
requires_confirmation_for_ct_action開啟時)。第一次呼叫只準備提案,不寫入任何資料。它會依序重新驗證:input 大小、即時的 principal、確認政策、command 本身(未刪除、agent_enabled、仍在 scope 內、private 只給建立者、tag 與資料表仍可讀取)、釘住的摘要、每個identity:*input 的 scope 歸屬,以及 dependency contract。任何一項失敗都會回工具結果而不是提案——呼叫者已無法執行的 command 回custom_table_command_access_revoked(http_status403)、摘要漂移回proposal_mismatch、未歸屬的身分回command_input_invalid、contract 漂移回淨化後的 program error(例如schema_dependency_changed),無法建立寫入保護時則回confirmation_unavailable。使用者在後續回合明確確認後,agent 只把 32 位 hex token 傳給custom_table_command_execute_staged_action,伺服器 adapter 才把釘住的摘要當作expected_contract_digest轉送。 - 不論哪一條通道,在本回合的工具建立之後才被編輯的 command 都會回
409 command_contract_mismatch,工具會回報為{"status":"conflict","error":"command_contract_mismatch","retryable":false,"http_status":409,…}。在確認通道上,於提案與確認回合之間做的編輯會先被攔下:確認流程回報proposal_mismatch(The data or write contract changed after the proposal; NO write was performed.),不會送出任何請求。
客戶看到的提案由伺服器渲染:請確認是否執行「<名稱>」:(Please confirm running "<command name>":),每個有提供的 input 一行 - <標籤>:<值>(標籤是 input 的說明,沒有時用名稱),最後是「若內容正確,請直接回覆「確認」;目前尚未寫入或變更任何資料。」值在顯示前會先遮蔽:identity:* 的值顯示為「已依您指定的對象帶入(識別資訊已隱藏)」,其他任何 http(s) URL、UUID 或 64/128 位 hex 摘要都顯示為「系統識別資訊已隱藏」(清單與物件內也一樣),user:/smc:/room: 的儲存格值則顯示為識別資訊已隱藏的使用者、客戶或聊天室。其他內容完整顯示。提案絕不會露出定義摘要、確認 token 或 reference,也不會露出 developer header。
客戶的下一則訊息會對照那份確切的提案來判斷。只有當回覆的每個 token 都屬於平台的確認詞彙,而且至少有一個是同意時才會執行;回覆可以附帶提案本身的動作動詞,以及已宣告 identity:* input 的值,但附帶其他任何值——例如舊價格——都不算確認。拒絕會取消提案;更改值、更改對象或提出新要求,會取消並重新提出提案;語意不清的同意會讓提案維持待確認,並以原本的 15 分鐘期限重新顯示。見 Agent 工具箱的確認協定。
全有或全無
一次執行就是一個資料庫 transaction。所有被引用的資料表會依 id 排序後逐一 FOR UPDATE 上鎖,actions 走 flush-only 核心執行,被消耗的紀錄在 commit 前重新上鎖並重新驗證,最後只有一次 commit。任何失敗都會回滾全部。
沒有部分套用、沒有逐列續跑,也沒有「哪些 step 成功了」這個問題要回答。這個原子性就是整個功能存在的理由——批次操作掛在單一資料表下,跨不了兩張表。
失敗時呼叫者收到一個對應的 HTTP 錯誤;只要該次執行的稽核列已經建立,伺服器就會把它標為 failed。在建立稽核列之前就被拒絕的情況——contract 摘要不符、走錯通道、input 或身分無效、版本 1 rule、key 重用、idempotency 衝突或 idempotency store 無法使用——不會留下任何紀錄。
{
"execution_id": "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb",
"status": "failed",
"error": "{\"details\": {}, \"error\": \"assertion_failed\", \"message\": \"That order has no lines to ship.\", \"path\": \"steps.has_lines.condition\", \"phase\": \"execute\", \"retryable\": false, \"step\": \"has_lines\"}"
}error 是安全的錯誤細節,上限 4096 字元:program error 時它是錯誤封包的 canonical JSON,只有 step、展開與版本 1 rule 失敗才是純文字。result_refs 與 staged_change_id 為 null,並且會派送所有 failed 生命週期 callback——執行階段的 effect identity conflict 與被抑制的 replay 例外,它們不會派送任何 callback。失敗的紀錄永遠不會擋住之後用同一個 idempotency key 的重試。
被寫入的列上的資料表 trigger 會在 command commit 之後才觸發,與一般紀錄寫入完全相同。
併發執行
準備一次執行時會以 row lock 將 authority room 序列化,所以同一個 command 的兩次呼叫本來就會互相競爭。當這場競爭遇上短暫的資料庫 lock 衝突時,execute lane 回的是一個具名的 program error,而不是籠統的失敗:
{
"detail": {
"error": "command_lock_conflict",
"phase": "execute",
"step": null,
"path": null,
"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 衝突。
授權:呼叫者本人的權限,除非 definition 宣告了自己的
Command 不帶任何隱含權限。預設情況下,每個 action 都在執行期以呼叫者本人的 ACL 與 SCP 判定檢查;你看得到的 command,不代表其中每個 step 你都能跑。唯一的例外是選擇性採用的:帶 authority: "definition" 的 definition 改用它宣告的 table_access ceiling 執行(絕不是 manager,每張資料表的 row filter 與隱藏欄位仍然生效),要求 execute_policy: "restricted",並對非 manager 回覆 status-only receipt。以下說明的是預設通道;另一條請見 執行權限。
- Command 路由需要 JWT。使用
UserAPIKey會回403 Custom-table command routes require JWT authentication。 - Principal 必須是存活、
verified且未停用的使用者;在 chatroom scope 的掛載點上還必須仍是該聊天室成員。任何失敗都是統一的403 Command principal is not authorized,這同時隱藏了「不存在」與「租戶不符」。 - Principal 透過的房間——chatroom 掛載點上是路徑的聊天室,agent 與 trigger 通道上是工作階段的房間——會在取得權限鎖之後再檢查一次。若該房間已被刪除、移到其他公司或部門,或成員資格已不存在,執行會在寫入任何資料前回
403 {"detail":"Command principal is invalid"}。外部 principal 沒有存活的 acting room,或其 client 已換了房間時,也會得到同樣的回應。 - 投影一個呼叫者無法讀取的欄位是
403 {"error":"column_not_readable",…}——這是五個預設狀態碼為 403 的 program error 之一,其餘是ct.effect_identity_replay_forbidden與 action policy 的command_execute_denied、definition_authority_invalid、definition_ceiling_missing(其他的預設為 400、409、422、500 或 504)。
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(authority: definition 的 command 對不管理每一張被引用資料表的呼叫者,改回 status-only receipt,外加任何公開結果),不重新執行任何東西。 |
已存在 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 路由為路徑上的聊天室,部門與公司範圍的 REST 路由為 null)、公司與部門、audience、origin、ai、SCP binding、權限/寫入 ACL 摘要、command 的 id、版本、updated_at、tag 與定義摘要、相依摘要——已經不符。 |
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 時不會原樣回傳。
以 dry_run 預覽
POST .../commands/{command_id}/execute?dry_run=true(自 backend 5.21.0 起)會回答版本 2 的 command 現在會做什麼,然後把一切回滾。只需送 inputs(若有釘住定義,再加 expected_contract_digest);idempotency_key 會被忽略。預覽只存在於範圍路由的 .../commands/{command_id}/execute:agent 通道的 /agent/commands/{command_id}/execute 沒有預覽,也會忽略 dry_run,所以在那裡的呼叫會真正執行該 command。
{ "inputs": { "subject": "ACME", "day": "2026-10-06" } }回應是 200,附上這次執行會宣告的 outputs,且 status 為 preview,而不是執行 receipt:
{
"status": "preview",
"outputs": {
"saved": [{ "outcome": "updated", "record_id": "eeeeeeee-5555-4555-8555-eeeeeeeeeeee", "version": 2 }]
}
}- 找出 command、核對釘住的定義、驗證 input、編譯並執行時,所套用的可見性、contract digest、input、授權、規則與上限檢查都與真正的呼叫相同。它不檢查 command 的
mode:mode: "query"的 command 在這裡回200 preview,真正的/execute呼叫則回409 query_command_requires_query_route。 - 不會持久化任何東西:沒有資料列、version 或 history,沒有 trigger 或 callback,不會保留
idempotency_key,也不會產生待審項目。會被require_approval規則攔下的寫入,預覽時視同直接套用,不會回409 approval_required。 - 當 outputs 依與執行 receipt 相同的規則而不給這位呼叫者時,會以
redacted: true與空的outputs取代。預覽不會帶出真正的 receipt 會給 execute-only 呼叫者看的作者選擇公開的結果,所以對這類呼叫者,outputs仍然是空的。 - 自後端 5.22.0 起,失敗的預覽會和失敗的執行一樣被遮蔽。對
authority: definition的 command,若呼叫者並未管理它引用的每一張資料表(也就是只有執行權的 grantee),拿到的狀態碼與 body 會和/execute相同,即{"detail": {"error": "command_failed", "message": "The command could not be completed.", "redacted": true}};但會原樣通過的錯誤碼與公開的 assertion 訊息例外,兩條路由都照常顯示。5.22.0 之前,預覽會把完整的引擎錯誤交給這位呼叫者。管理每一張被引用資料表的呼叫者(作者通常就是),以及 invoker-authority command 的所有呼叫者,仍然看得到完整 detail。預覽一個不存在的 command 會回同樣被遮蔽的404;以前回的是404 Command not found。 - 版本 1 的 command 是
400 Preview requires a version-2 Command.,需要 frozen proposal 的 command 是409 ct.governed_context_required。definer command 的 execute-only 呼叫者拿到的這兩種拒絕也是遮蔽後的形式。
預覽讀的是當下的狀態。併發的寫入者可能改變真正呼叫的結果(預覽為 inserted,之後可能變成 updated),而真正的呼叫會做它自己的檢查。它最適合用來查看 upsert step 會走哪個分支,以及 outputs 會是什麼。
Effect identity 的 replay 與 conflict
宣告了 effect_identity 的 command,每個業務事件只會被記錄一次。事件指的是這次執行為 key 所選的那筆資料列,而不是承載它的請求。上方的 idempotency_key 保護的是某個呼叫者對某個請求的重試;effect identity 保護的是事件本身,跨所有呼叫者、key 與通道,而且存在資料庫而不是 Redis 裡。兩者互相獨立,並且同時有效。
記錄的是什麼
occurrence (company, namespace, kind, key) 在該次執行自己的交易內被 claim,時點是最後一個 key term 所讀的 select 之後、第一個寫入之前,並與業務資料列及儲存的 receipt 在同一個 commit 定案。所以「這個事件發生過」與它的效果會一起可見,或一起不可見:
- 執行失敗(不論在哪個 step、什麼原因)時,claim 會與其他所有變更一起 rollback。之後針對該事件的呼叫會當成第一次執行。
- 寫入 steps 沒有改到任何一列的執行——每個寫入都被
when略過或沒有符合的列,例如在列過濾的編輯權限下——不會記錄任何事件,可以重複執行;它的成功 callback 照常觸發。沒有寫入 step 的程式則把它的成功記錄為事件。 - 刪除來源資料列不會釋放事件。列不見了,選取結果就是空的(
$singlekey 為400 cardinality_failed),把列還原後同一個事件就回來,並且會 replay。 - 讓 command 仍可執行的 schema 變更不會把重試變成 conflict:比較的程式是去掉伺服器建立的
dependency_contract、examples與delivery_reconciliation_ttl_seconds之後的作者定義。會弄壞 command 的變更(例如刪除它寫入的欄位)仍會先以409 schema_dependency_changed失敗,在任何 identity 查詢之前。
各種結果
REST /execute、agent 通道、MCP commands_execute 與 trigger 的 invoke_command 都匯入同一個 executor,所以每個通道都會做出相同的判定(MCP 用它自己的詞彙回報拒絕,見下方)。查詢發生在 claim 時點,在任何效果、history、trigger 或 callback 之前:
| 情況 | 結果 |
|---|---|
| 事件的第一次執行 | 正常的 200 receipt。body 裡沒有任何額外欄位。 |
| 同一事件、相同的驗證後 inputs、相同的作者程式 | 200,回原本的 receipt 與 "replayed": true。不寫入任何東西。 |
| 同一事件、不同的 inputs 或改過的程式 | 409 ct.effect_identity_conflict。不寫入任何東西;重試也不會改變答案。 |
| 事件已記錄,而呼叫者的寫入權限是列範圍的 | 403 ct.effect_identity_replay_forbidden。不寫入任何東西,也不揭露先前的執行或結果。 |
| 事件已記錄,而呼叫者根本不能執行這個寫入 | 全新呼叫會得到的拒絕:403 沒有新增或編輯權限、隱藏的寫入欄位回 Column '…' does not exist in table schema、SCP 拒絕。不是 replay,也不是 conflict。 |
$first key term 沒選到列,或它的 step 被略過 | 422 effect_identity_unbound。不寫入任何東西。 |
$single key term 選到的不是剛好一列 | 400 cardinality_failed,維持不變。 |
比較時不看是誰呼叫、走哪個通道、用哪個 idempotency_key,也不看 expected_contract_digest。兩個呼叫者、一次 trigger 觸發加一次手動執行,或針對同一事件的兩次審批 release,只要 inputs 相同、程式相同,結果都只會有一個效果。
Replay 的回應內容
Replay 就是原本那次執行儲存的回應,再加上 replayed: true:
{
"execution_id": "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb",
"status": "succeeded",
"executed_at": "2026-07-21T03:02:11Z",
"steps": [
{ "name": "receipt", "kind": "select", "status": "succeeded", "schema": { "qty": { "type": "integer", "nullable": true }, "receipt_id": { "type": "string", "nullable": false } }, "row_count": 1 },
{ "name": "movement", "kind": "insert", "status": "succeeded", "schema": { "movement_id": { "type": "string", "nullable": false } }, "row_count": 1 }
],
"outputs": { "movement": [{ "movement_id": "cccccccc-3333-4333-8333-cccccccccccc" }] },
"replayed": true
}execution_id、executed_at 與 steps 都是原本那次執行的,所以 client 可以靠 replayed 認出這是重複,並到稽核紀錄找到原本那一列。replayed 與 redacted_outputs 在不適用時都會省略。被截到 65 536 bytes 上限的已儲存 receipt,replay 時帶 truncated: true 與原本的 executed_at。
只有在呼叫者通過全新呼叫直到 claim 時點為止會通過的所有檢查之後,才會回 replay:command 可見性、輸入驗證、before_execute rules,以及程式自己的讀取(依呼叫者當下的權限)。接著會用呼叫者的寫入權限去對照記錄該事件的程式(隨 claim 一起儲存)的每個寫入 step(包含原本那次執行略過的 step)——在比較任何東西之前——所以本來就不能寫入的呼叫者,無從得知事件是否存在。
只有當呼叫者能讀取一個 output 所依據的一切資料時,才會回傳它。否則該 output 會被扣留,名稱(排序後)列在 redacted_outputs:
- 呼叫者對某個來源資料表沒有
can_read: "all"、在該表上有read_filter、在該表上受 SCP 房間範圍限制,或讀不到某個有貢獻的欄位。經由$var或$rel參照到的資料表整張都算:那裡任何一個隱藏欄位都會扣留該 output; - 該值依賴
$ctx,而呼叫者不是執行原本那次的 principal; - 該值來自寫入 step 自己的影像(
$new、$old、current)所讀到的計算欄位(lookup、rollup、formula)。這種 output 對所有呼叫者都扣留,原本的呼叫者也不例外。
Replay 的那次呼叫本身會被記錄成一筆被抑制的重複。它的執行稽核列是 failed,error 為 {"error":"ct.effect_identity_replayed","original_execution_id":"…"},而且不會派送生命週期 callback、trigger 或 history。因此同一呼叫者用同一個 key 重試,會重新執行並再次 replay。
發生 conflict 時
{
"detail": {
"error": "ct.effect_identity_conflict",
"phase": "execute",
"step": null,
"path": null,
"message": "This business event was already recorded with different details; nothing was written",
"retryable": false,
"details": {
"original_execution_id": "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb",
"semantic_match": false
}
}
}狀態碼是 409。details 只指出先前那次執行的不透明 execution id,以及兩次執行是否相符——絕不含它的 inputs 或 outputs。semantic_match: false 表示驗證後的 inputs 或作者程式與記錄的不同,所以事件發生後再編輯 command 的 steps、rules、outputs 或 callbacks,會讓重複執行變成 conflict。這個拒絕是終局的:重試、或改變內容都不會成功,原本的記錄維持不變。它的稽核列是 failed,以這份 body 作為 error,而且不會為它派送 failed 生命週期 callback(審批 release 階段的 conflict 和任何 release 失敗一樣,會派送該 command 的 failed callback)。semantic_match: true 只會出現在審批 release。
Replay 被拒絕時
403 ct.effect_identity_replay_forbidden 表示事件已記錄,但這位呼叫者的權限無法重新證明可以存取原本那次寫入的列,所以平台既不回 receipt,也不回 conflict:
{
"detail": {
"error": "ct.effect_identity_replay_forbidden",
"phase": "execute",
"step": null,
"path": null,
"message": "This business event was already recorded, and a row-scoped right cannot re-prove access to its rows",
"retryable": false,
"details": {}
}
}當程式有 update 或 delete step、而呼叫者在該資料表上的編輯權限是 own 或 filtered(不是 all,且呼叫者不是 manager)時,或當任一寫入 step 所指向的資料表落在呼叫者的 SCP 房間範圍內時——不論原本那次執行有沒有改到那裡的列,也包含原本的呼叫者——就會套用。在 REST 上,資料表的 manager 不受 SCP floor 限制,可正常 replay;agent 通道會固定該對話的房間,不給 manager 任何豁免,所以寫入的資料表若落在該房間的範圍內,仍會回這個 403。body 不帶先前的 execution id 也不帶結果,重試會得到相同答案。在同樣的列範圍權限下,全新的呼叫仍然可以執行;被拒絕的只有對已記錄事件的重複執行。
Key 沒有綁到資料列
{
"detail": {
"error": "effect_identity_unbound",
"phase": "execute",
"step": "receipt",
"path": null,
"message": "The effect identity key selected no row; nothing was written",
"retryable": false,
"details": {}
}
}422,在 claim 時點就拒絕,所以沒有寫入任何東西,也沒有取得 claim。step 指出 key 的 select step。$first key term 在它的 select 沒回任何列、或被 when 略過時會回這個錯誤;$single term 則維持 400 cardinality_failed。
與 idempotency_key 的關係
idempotency_key 維持不變,而且它的檢查先執行:
| 呼叫 | 結果 |
|---|---|
| 同一個 principal、同一個 key、相同 inputs,在已記錄的執行之後 | 已儲存的 succeeded receipt,原樣回傳且沒有 replayed——也就是上方的 actor replay;authority: definition 的 command 對不管理每一張被引用資料表的呼叫者,則是 status-only receipt。 |
| 同一個 principal、同一個 key、不同 inputs | 與以前相同,409 idempotency_key_reuse。 |
| 針對同一事件,換 key 或不帶 key、另一個 principal、trigger 執行,或審批 release | 上表的結果。 |
| replay 之後再用同一個 key | replay 的稽核列是 failed,key 探測會忽略它,所以呼叫會重新執行並再次 replay。 |
因此 key 永遠不會讓呼叫跳過事件檢查,事件檢查也不會凌駕 key 自己的 replay。Claim 是一列資料庫紀錄,所以不依賴 Redis:Redis 寫入失敗時,不帶 key 的重複執行仍會 replay,而帶 idempotency_key 的請求仍會 fail closed 回 503。
針對同一事件的並行呼叫會在相同的鎖上序列化——command 列與每個被參照的資料表都在 claim 之前以 FOR UPDATE 上鎖——所以第二個呼叫會等待,在第一個 commit 之後 replay 或 conflict。等待超過鎖逾時的,就是既有、可重試的 409 command_lock_conflict。
審批、trigger、MCP 與 agent
- 有審批閘門的 command。 進入 stage 的執行在 stage 時不會 claim 任何東西,所以待審的計畫不會佔住它的事件,第二個呼叫者可以 stage 同一個事件。若執行 command 時事件已經記錄,它會在任何東西被 stage 之前先 replay 或 conflict。Claim 在 release 時取得,位於 release 自己的權限與 manifest 檢查之後、任何凍結動作套用之前,並在 release 的 commit 定案。針對同一事件的兩個 staged 計畫都被核准時,第二次 release 以
apply_failed結束、什麼都不套用;它的執行列是failed,error為{"details":{"original_execution_id":"…","semantic_match":true},"error":"ct.effect_identity_conflict","message":"This business event was already recorded; nothing was written","phase":"release","retryable":false},兩個計畫不同時semantic_match為false。和任何 release 失敗一樣,它會派送該 command 的failed生命週期 callback。因其他原因失敗的 release 會讓事件維持未記錄。 - Trigger。
invoke_command以 trigger 作者的身分經過同一個 executor,所以同一列的 trigger 觸發與手動執行會互相 replay。事件已記錄時,動作結果帶"replayed": true,並連到原本的execution_id。Conflict 會讓動作以invoke_command failed: HTTP 409: …失敗,重試該 run 會得到相同結果。 - MCP。
commands_execute在工具描述中說明此行為,它的 output schema 帶有replayed與redacted_outputs。OAuth principal 對每個commands_*工具仍回not_found;只有 assistant 自己由 JWT 發起的 run 能用到。拒絕以 MCP 的詞彙回報:ct.effect_identity_replay_forbidden(403)回統一的not_found,conflict 是工具錯誤conflict:後接 JSON 形式的錯誤封套,effect_identity_unbound與cardinality_failed則是invalid_argument:後接它。 - Agent。 這種 command 產生的寫入工具,描述中多兩句由伺服器撰寫的說明,共用的 command protocol 也會對模型說明相同的結果。工具回傳什麼、客戶看到什麼,請見 Agent toolkit。
能力探索
GET .../core/capabilities(tables.capabilities)會在 ct.decimal 旁邊列出 {"id":"ct.effect_identity","major":1,"available":true,"reason":null}。要把這個區塊送到不由你控制的部署之前先檢查:沒有這項能力的版本會把 effect_identity 當成未知欄位,以一般的 request-validation 422 拒絕。探索描述的是版本本身,不授予任何資料存取。
相依漂移
每個 command 都帶著伺服器建立的 dependency_contract:逐一記錄每張被引用資料表的 scope、tag,以及 schema definition、rules、審批設定與 triggers 的摘要,外加逐欄位的相依關係。Triggers 摘要是加上金鑰的承諾值(tcm1: 加 64 位 hex),讀者無法拿猜測的 trigger 機密去比對;舊的純摘要會在載入與讀取時重新加上金鑰,相同的 trigger 設定仍會得到相同的承諾值。
版本 2 的 command 在每次 /execute 與每次 /query 都會重新驗證這份契約,任何不一致都 fail closed:
{
"detail": {
"error": "schema_dependency_changed",
"phase": "execute",
"step": null,
"path": null,
"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 內,因此容忍不相干的編輯。在鎖定 link 所及的資料表時,已消失的連結資料表會回 409 {"error":"command_link_graph_changed"}——在取得鎖的過程中 link 或紀錄的連結關係發生變動時也一樣,這是暫時性的,請重試同一個呼叫——link 圖需要超過 256 張資料表時會回 400 {"error":"command_dependency_graph_too_large","limit":256},某個 action 的紀錄與連結閉包超過 2000 筆紀錄時則回 400 {"error":"command_dependency_graph_too_large","limit":2000,"actual":<n>};scope 沒有提供的 $ctx.<key> 則以 400 step '<step>': $ctx.<key> not available in this scope 失敗。版本選擇會改變漂移行為;不要對兩者宣稱同一條規則。
有兩個相鄰但不同的失敗,值得在 UI 上分開:
423 Table is currently locked for operation: <operation>——某張被引用資料表正被 schema 遷移持有(版本 1 通道)。遷移結束後即可重試,而且它在任何寫入之前就先拋出。409 {"error":"source_authority_changed","phase":"execute","step":null,"path":null,"message":"Command authority changed before execution","retryable":false,"details":{}}——(版本 2 通道)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 成員,/query 與 agent 路由的 response model 仍不宣告 403。自 backend PR #1768 起,有 scope 的 /execute 路由重新宣告 403:純字串、結構化的拒絕(例如 SCP floor 拒絕),或 program-error 封套(column_not_readable 或 ct.effect_identity_replay_forbidden;自 backend PR #1788 起還有 command_execute_denied、definition_authority_invalid 與 definition_ceiling_missing)。
Command 觸及的每一張受 channel 管制的資料表,都會各自從 invoker 獨立解析自己的 floor:在 REST 通道上該表的 manager 豁免(綁定房間的 agent 或 trigger 執行會釘住 acting room,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 |
| 渲染後的生命週期 callback body | 65 536 bytes → 400 output_limit_exceeded(phase: "callback"、resource: "callback_bytes"),整次執行失敗——見生命週期 callback |
超限會以 400 {"error":"query_limit_exceeded"|"relation_limit_exceeded"|"mutation_limit_exceeded"|"output_limit_exceeded","details":{"resource":…,"limit":…,"actual":…}} 呈現,逾時則是 504:command_timeout 帶 details: {},select 的 query_timeout 則帶 details.limit(每個 statement 的預算,單位毫秒,最多 5000)與 retryable: true;inline 計算欄位查詢逾時也是 query_timeout、retryable: true,但 details 為 {}。
請求大小的預算屬於 request 驗證:以 FastAPI 422 清單回應,發生在任何 idempotency 保留、稽核列或鎖之前。定義上限為 1 048 576 bytes(以緊湊、key 排序的 UTF-8 JSON 計算,command definition exceeds 1048576 bytes),深度 64/16 384 節點(command definition: JSON depth exceeds 64、command definition: JSON node count exceeds 16384),在建立、更新與 validate 上位於 loc ["body","definition"]。/execute、/query 與兩條 agent 路由的請求 inputs 也有同樣三個上限,位於 loc ["body","inputs"](command inputs exceed 1048576 bytes、command inputs: JSON depth exceeds 64、command inputs: JSON node count exceeds 16384),NaN 或 Infinity 則是 command value is not canonical JSON。
版本 1 的 rows 有自己的結構檢查:where 樹深度 12/128 節點、$case 深度 8/64 節點/20 個分支、每列 256 節點、每份定義的 row 運算式合計 8192 節點;rule 條件深度 8/128 節點。版本 2 的運算式每個上限為深度 12/256 節點(422 expression_limit_exceeded)。執行時,$json_parse 會把超過 256 KiB 的輸入,或解析後深度超過 32 層、超過 8192 節點的值視為失敗,並依其 on_error 政策處理;$json_get 對型別為 json 的結果套用同樣的 32 層/8192 節點上限——見 JSON 運算子。
所有 program error 共用同一個封閉封套——{error, phase, step, path, message, retryable, details},其中 phase 為 author、execute、stage、release 或 callback,details 依錯誤碼限制在一份很小的允許清單內。在 /execute、/query 與 agent 路由上,step 與 path 一定存在,未設定時為 null——但 authority: definition 的 command 對不管理每一張被引用資料表的呼叫者所放行的錯誤例外,它們兩個 key 都沒有;建立類回應與審批 release 的原因則省略它們(PUT 的 re-key 鎖 effect_identity_locked 例外,它帶有 "step": null)。未註冊或不公開的細節會降級成通用的 500 {"error":"command_execution_failed","phase":"execute","step":null,"path":null,"message":"Command execution failed","retryable":false,"details":{}},確保什麼都不外洩。每個錯誤碼的狀態列在錯誤碼表。自後端 5.22.0 起,不含 child_collection step 的 Command 在 /execute 時,若觸發的 approval 規則所對應的來源資料表不在 Command 的 tag 內,不再降級成那個 500:它會回 422 command_tables_outside_tag(見 Tag 邊界)。
有六個錯誤碼代表「明確什麼都沒寫入」
assertion_failed、cap_exceeded、cardinality_failed、command_input_invalid、command_rule_failed 與 effect_identity_unbound 都是伴隨 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 字元。非 manager 執行 definer Command 時,只有公開的 assert才會附上 reason,並同時帶 "public_assertion": true;不公開的 assert 會以沒有原因的 command_failed 送達。另外五個錯誤碼沒有作者文字。provider 或資料庫的任何內容都不會被複製進這個封套。
客戶接著會讀到什麼——作者寫的原因,或該錯誤碼在房間語言下的固定後備字串——見 agent 工具箱。手動 REST 呼叫端不受影響,仍然收到上面那套對應後的 HTTP 錯誤。
Agent 工具被拒絕時回傳什麼
寫入工具被拒絕時回 {"status","action":"execute_command","error","retryable","guidance"},服務回 400–599 時再加上 http_status,服務 detail 帶有的 execution_id、staged_change_id、process_id、rule_id 也會一併帶上(approval_required 會複製流程、rule 與 staged change 的 id,不含 rule 標籤)。status 在 approval_required 時是 pending_approval,任何 409 是 conflict,401/403/404 或結尾為 access_revoked 的錯誤碼是 denied,其餘是 failed。error 只有在服務的錯誤碼屬於工具的安全錯誤碼允許清單時才會照原樣帶出,否則為 custom_table_command_failed;服務的 message 永遠不會被複製。guidance 是兩段固定文字之一。預設是 This change belongs to the command lane. Do NOT retry it in this turn with rebuilt inputs and do NOT attempt it through the generic record tools - that would bypass business validation. Report the outcome to the customer instead.;retryable 為 true 時則是 This was a transient failure. A single retry of this EXACT request is safe - it is idempotency-protected. Do NOT rebuild the inputs and do NOT attempt the change through the generic record tools.。確認通道的失敗——{"status","action","error","guidance","retryable"},confirmation_unavailable 的 status 是 failed,其餘是 conflict——只有 confirmation_in_progress 使用可重試的文字;confirmation_outcome_uncertain 與 confirmation_requires_new_turn 雖然也是 retryable: true,帶的卻是預設文字。查詢工具被拒絕時只回 {"error","retryable"},加上選填的 http_status 與 id;成功時則原樣回傳服務的 JSON。
寫入工具——direct 通道的呼叫或確認通道的 prepare——在送出或暫存任何東西之前,會先檢查模型自己給的 inputs;參數不對時,回傳的拒絕內容是為了讓模型一次修正:{"status":"denied","action":"execute_command","error":"command_input_invalid","retryable":false,"validation_error":"<message>",…}。訊息點名某個 input(input '<name>' …)時會加上 field;超過大小上限時會加上 cap 與 actual——序列化後超過 1 048 576 bytes 的 inputs 會得到 command inputs exceed 1048576 bytes,無法序列化的 inputs 則得到 command inputs cannot be serialized safely,不帶這兩個欄位;另外附上宣告的 expected_inputs(名稱、型別、是否必填,有設定時再加上 nullable 與 item_schema——從不含值)、example(command 作者寫的第一個範例的 inputs;渲染後超過 1500 字元時改為 example_omitted: "too_large"),以及提示帶齊所有 inputs 再呼叫一次的 hint。
傳輸失敗有各自的錯誤碼:custom_table_command_timeout(retryable: true、http_status 504)、custom_table_command_unavailable(retryable: true、503),服務通道未設定時則是 custom_table_command_service_unavailable。detail 標示 retryable: true 的 409 會先在原地重試——最多三次,間隔 0.3 秒與 0.6 秒——之後工具才回答。
來自 record core 的拒絕會保留自己的理由(v5.10.11)
寫入步驟裡的失敗不一定都是 program error。當 insert_record_core 或 update_record_core 拒絕某個步驟解析出的值——未知或無法存取的 attachment id、該 scope 沒有的 principal id——現在會帶著真正的訊息呈現,而不是被壓平:
{ "detail": "Unknown or inaccessible attachment ids: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa']" }400、純字串 detail,執行紀錄的 error 也存同一段文字。過去這些會拋出理由允許清單不認得的原生 ValueError,所以 HTTP 錯誤與稽核列都只說「Command execution failed」——沒說錯,但無從處理。請直接顯示 detail,不必再為舊文字做特例。
Command 內的 unique_tuple、same_dimension_reference 或 date_interval_exclusion 拒絕會讓整個 command 回滾,並把該 rule 自己的 409 body 原樣傳出——{"error":"unique_tuple_conflict","constraint":…,"columns":[…]}(呼叫者讀不到某個 component 時,會依 unique_tuple 衝突的說明遮蔽)、{"error":"same_dimension_reference_violation","message":"Reference must share a live required dimension."} 或 {"error":"date_interval_exclusion_overlap","message":"Date period overlaps another period in this partition."}(date_interval_exclusion_invalid_period 與 date_interval_exclusion_retry 的形狀相同)——而不是 program error 信封。這些 body 都不帶紀錄 id、tuple 的值或期間邊界,而且 date interval 規則在每個寫入 step 之後都會檢查,所以請先關閉舊期間、再插入接續的新期間;agent 工具會回報為 custom_table_command_failed,並帶 http_status: 409。若呼叫者並非管理每一張被引用的資料表,而執行的是 definer authority 的 command,得到的則是遮蔽過的 command_failed body,因為這些代碼都不在放行清單上。
資料列資料政策是步驟可能從 record core 繼承的另一種拒絕:400 inline_binary_not_allowed 與 413 record_too_large,兩者都記在附件 blob。在需審核的資料表上,上限檢查是在前置階段執行,也就是送進暫存之前,所以過大的受審寫入在送出當下就被拒,不會在有人核准之後卡住 apply worker。
執行稽核紀錄
每次寫入型執行都會留下一列。用 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,
"company_id": "33333333-3333-4333-8333-333333333333",
"acting_chatroom_id": "22222222-2222-4222-8222-222222222222",
"department_id": "44444444-4444-4444-8444-444444444444",
"idempotency_context": {
"version": 1,
"digest": "tcm1:6f1e…",
"context": { "…": "principal、scope、SCP binding、權限與定義/相依摘要" }
}
},
"created_at": "2026-07-25T09:11:02"
}讀取時,快照中的摘要——definition_sha256、idempotency_context.digest、context.dependency_digest 與 context.command.definition_digest——會以加上金鑰的 tcm1: 承諾值回傳;儲存的值沒有改變,所以 replay 與 release 仍比對原始值。
自後端 5.22.0 起,在附件複製 grant下複製過檔案的 succeeded execution,還會帶 authorization_snapshot.attachment_copies:每個目的資料列與欄位一個項目,記錄 grant 的作者、來源資料列、目的資料列、檔案 id 與呼叫者。
status 為 succeeded、staged 或 failed。steps 上限 40 筆,result_refs 上限 20 筆。每個 execution 與 query 載荷都會省略頂層值為 null 的欄位,請把「key 不存在」視為 null;巢狀在 authorization_snapshot、input_snapshot、response_body、outputs 與 query 列中的 null(例如 acting_chatroom_id: 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 執行的指令——不論是預設的 direct 通道還是確認流程——都會比 REST 執行多留下一個持久產物:執行紀錄 lifecycle_snapshot 底下 agent_response_delivery 這個私有的派送標記。它只存在於伺服器產生的 agent-confirmed: idempotency key,手動 REST 執行永遠不會有。兩條 agent 通道都會產生 agent-confirmed:{identity}:{command_id}:{inputs} 形式的 key:identity 是請求連同客戶已保存訊息 id 的雜湊(direct 通道取最新一則訊息,確認通道取確認的那一則),inputs 是正規化 input 的雜湊;因此同一則訊息帶同樣的 input 會重播,新訊息則會再次執行,除非有 pending 標記強制重播。Direct 通道的呼叫若沒有已保存的訊息 id,會回 confirmation_unavailable 且不寫入任何資料。
當這個標記還是 pending 時,相同的 agent 呼叫會重播原本那次執行,而不是再寫一次。這正是「使用者在沒收到回執後又講一次同樣的話」不會變成重複排班的原因。標記會在自然語言結果跨過頻道可見性邊界時被釋放。
視窗長度由指令作者決定。definition.delivery_reconciliation_ttl_seconds(60–604,800 秒,預設 900)在執行當下解析,並以絕對時間 expires_at 寫進標記,因此事後編輯定義永遠不會回頭改動已提交標記的視窗。
| 標記狀態 | 意義 |
|---|---|
pending | 仍在視窗內。相同呼叫會重播原本那次執行,不會產生新的寫入 |
expired | 已超過 expires_at。標記不再強制重播,相同呼叫會真的執行——這是有界的重複風險,由指令作者在選擇 TTL 時承擔 |
acknowledged | 結果已送達,或由維運人員釋放。終態 |
到期是惰性的,不是排程作業。下一次派送查找會掃描同一個 principal 在同一個 channel 與 group scope(每個 LINE 群組各自獨立)中,這支指令最近 65 筆 pending 的 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,這支指令對該 principal、channel 與 group scope 的變更通道都會回覆寫入保護失敗,直到容量釋出為止;其他指令與其他 principal 不受影響。到期標記不再計入這個 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紀錄不論多舊都不會被清除。- 被 effect identity claim 參照的執行,就是該事件持久的 replay receipt,永遠不會被刪除。超過截止時間後,清除只會清空它私有的
lifecycle_snapshot(可能帶有 callback 憑證),並保留它的 id、狀態、principal 欄位、input_snapshot與公開的response_body。pruned的數字只計算被刪除的列。 - 回應為
{pruned, retention_days: 180, cutoff}。
input_snapshot 保存每次執行綁定後的 inputs,在該清除作業執行之前都可能持有終端使用者的個人資料。請據此規劃營運人員的執行頻率,不要假設 180 天會自動到期。被 effect identity claim 參照的執行是例外:它的 input_snapshot 會作為持久 receipt 的一部分保留。
另一個配套作業 POST /root/hotfix/custom-table-command-callback-runs/requeue(older_than_seconds 預設 300、limit 1–2000 預設 500)用於回收遺失的 command callback 派送。上限由三組依序共用:停滯的 pending run 會重新發布,嘗試次數少於 5 的 failed run 會清掉錯誤並重設為 pending,超過 max(older_than_seconds, 900) 秒的 running run 也會重設為 pending。它只在營運人員呼叫時執行——排程 tick 不會跑它。
每個 run 依序經過 pending → running(計一次嘗試)→ done 或 failed。派送以 run id 設 15 分鐘的 fence,所以同一個 run 的並行派送會被拒絕(command callback delivery is already in flight),或在 run 已完成時什麼都不做。嘗試達 5 次時,run 會以 maximum callback attempts exceeded 結束為 failed,不再送出。非 2xx 的回應會把 run 標為 failed(錯誤與回應細節最多 1024 字元);是否重新派送取決於部署的 worker,否則由清掃重新排入佇列。每次派送都帶著穩定的 X-TeamSync-Callback-Run-ID header——正確性邊界是接收端以該 id 去重,而不是精確一次派送。兩個作業都收錄在 root 營運參考頁。
延伸閱讀
- Query 模式——不加鎖、也不留稽核紀錄的通道。
- 審批——
409 approval_required的意義,以及凍結的計畫如何收尾。 - Commands 參考——路由、參數與完整錯誤表。