自訂資料表 Commands
Command 是一段已儲存、具名、掛在 tag 上、可帶參數的程式:它在同一個資料庫 transaction 內寫入多張自訂資料表,並且以呼叫者本人的權限執行——沒有 definer rights、沒有 service account、也沒有任何權限提升。所有步驟要嘛全部生效,要嘛全部不生效。
模組裡沒有別的東西能提供這個保證。批次操作掛在單一資料表下,所以「主檔一列 + 明細數列」是兩個各自獨立的 transaction;用兩次 record 呼叫寫入的用戶端,必須自己發明第二次呼叫失敗時的補償邏輯。Command 用一段你只寫一次、命名、再交給表單、trigger 或 AI agent 使用的伺服器端程式取代那件事。
一個 command 擁有什麼
| 屬性 | 契約 |
|---|---|
| Scope | chatroom、department、company 三選一。company 掛載點同樣帶有明確的 {company_id} path 區段。 |
name | 1–64 字元,在該 scope 的活指令之間唯一(自 2026-07-28 起,刪除的指令立即釋放名字)。撞名為 409 {"error":"command_name_conflict","name":…}。 |
description | 最多 1024 字元(2026-07-30 由 512 放寬)。它同時是 AI agent 看到的工具說明。 |
agent_enabled | 布林值,預設 true。設為 false 會把 command 從 agent 工具清單中移除,REST 完全不受影響。 |
tag_id | 必填。每一張被引用的資料表都必須目前就掛在同一個 scope-local tag 上。 |
definition | 具型別的 inputs 與有序程式——版本 1 或版本 2。編譯後必須至少引用一張資料表。 |
dependency_contract | 伺服器建立且唯讀。用戶端送來的值會被丟棄並重建。 |
建立是 POST .../commands,取代是完整的 PUT(永遠不是 PATCH),DELETE 是軟刪除。完整清單見 commands 參考頁。
Tag 邊界在每個階段都會生效
tag_id 在 create 與 update 時必填,而且每次執行都會連同 row lock 重新檢查。它在讀取時也會檢查:當 tag 不再涵蓋全部被引用的資料表時,該 command 會直接消失——從 list/get 消失,也從 agent 工具清單消失,而且在有人真的去執行它以前,任何地方都不會出現錯誤。
- 缺
tag_id→422 {"error":"command_tag_required"} - Tag 不存在 →
404 Tag not found in this scope - 被引用的資料表不在 tag 內 →
422 {"error":"command_tables_outside_tag","tag_id":…,"table_ids":[…]}
Tag 與 manager 檢查適用於遞移可達的資料表集合,不只是你在 step 裡寫出來的那幾張。編譯完成後,伺服器會針對整份 dependency contract 再跑一次這兩項檢查,所以你從未寫下的 link 目標表、或 computed column 的來源表,也必須由你 manage、並且掛在同一個 tag 上。
Dependency contract 上限為 20 張資料表。編譯時超過就是 422 {"error":"query_limit_exceeded","phase":"author","message":"Command dependency table limit exceeded"}。
一個 command 至少要引用一張資料表
POST 與 PUT 都會檢查編譯後的相依集合,空集合直接拒絕:
{
"detail": {
"error": "command_references_no_table",
"message": "a command must reference at least one custom table in this scope"
}
}狀態碼是 422,而且這是補洞而不是加規則。Command 介面上的每一道權限閘門都在「被引用的資料表集合」上迭代,集合是空的就讓每一道都空虛地成立:只有 let、只有 callback、或完全沒有 step 的定義,過去任何租戶使用者都能以 201 建出來,然後讀它、改它、刪它、執行它、讀它的執行快照,並透過它的 callback 帶著 invoker context 對外送資料。
現在這些閘門在空集合上 fail closed,所以 2026-07-31 以前建立的資料列行為變成這樣:
| 路由 | 已存下的零資料表 command 得到 |
|---|---|
GET .../commands | 從清單消失,對任何呼叫者皆然,包含公司管理員 |
GET、PUT、/execute、/query、兩條 /executions 路由 | 404 Command not found |
DELETE | 仍然可用——manage 探測失敗後改用 command 自身 scope 的權威(聊天室建立者/管理員、部門管理員、公司管理員) |
IaC apply 的 create/update 分支 | applied: false,errors[] 內容為 command '<ref>' references no table: a command must operate on at least one custom table in this scope |
IaC apply 的 delete 分支 | 在閘門之前就返回,所以仍然刪得掉 |
資料列還活著時名字就一直被占著,所以殭屍列會卡住同名重建,直到有人刪掉它。刪除是唯一的修法——PUT 回 404,定義永遠改不回合法狀態。
授權:三道不同的門檻
Command 不會放寬任何人的存取權。每個介面對「被引用的資料表」問的是不同的問題。
| 介面 | 對被引用資料表的要求 | 失敗 |
|---|---|---|
| Create / update / delete | 每一張都要是 manager。僅 delete 例外:引用的表已解析不到時,改看 command 自身 scope 的權威(見修改與刪除) | 404 Referenced table not found in this scope |
| List / get / executions | 每一張都要能 read | 統一的 404 Command not found |
| Execute / query | 先要能 read 才看得到,接著逐個 action 套用呼叫者本人的 ACL 與 SCP 判定 | 該 action 本身的 ACL/SCP 錯誤 |
Authoring 的 404 刻意不列舉:資料表不存在、不在 scope 內、以及你不是 manager,三種情形回同一個 body。不要做出宣稱其中一種原因的 UI。讀取端的 404 是同一個想法的另一面——command 絕不會變成「你看不到的資料表是否存在」的探測器,所以同一個聊天室裡的兩個人看到不同的 command 清單是正常的。
當呼叫者能 read 但無法 manage 全部被引用的資料表時,回應仍會回傳,但 callbacks: []、triggers: [],並帶 lifecycle_redacted: true。空的 callbacks 陣列不代表「沒有設定 callback」——請先看這個旗標。
執行與查詢時,兩條通道解析部門資料表權限的方式不同,同一支 command 可能在一邊成功、在另一邊 403:
| 通道 | 部門資料表權限來自 |
|---|---|
人工 REST /execute、/query | 只看執行者本人——所有授予他這張表的聊天室的聯集。不綁任何 acting room。 |
| Agent 服務通道與資料表 trigger | 伺服器解析出的 acting room,作為硬性上限——與 agent 的資料表工具一致。 |
綁在某聊天室的 agent command 觸及一張該 acting room 從未被授權的部門資料表時,現在會以 403 Insert access not granted for this table.、403 Edit access not granted for this table. 或 403 Read access not granted for this table. 失敗。過去它會借用執行者其他聊天室的授權,動到 acting room 根本看不到的資料。
Command 路由直接拒絕 API key 認證。request.state.auth_method 必須是 jwt,否則回 403 Custom-table command routes require JWT authentication。程式對程式的存取請走獨立的 agent service-token 通道,而不是 UserAPIKey。
兩個定義版本
definition.version 在兩種共用同一個欄位、但本質上不同的產品之間做選擇。
| 版本 1 | 版本 2 | |
|---|---|---|
| Step 形狀 | 扁平的 {name, table, action, rows[]} | 以 kind 區分:select、let、assert、insert、update、delete |
| 讀取、join、彙總 | 沒有 | 有——每個 select 最多 4 個 join,可 group/having/order |
| 值如何送出 | 逐 step 的寫入後 row | 只透過宣告的 outputs;steps 只回 metadata |
| 上限 | ≤20 steps,最壞展開 ≤1000 | ≤40 steps,其中寫入 steps ≤20 |
| Query 模式 | 不支援 | mode: "query" |
| Runtime 的相依漂移 | 只檢查它實際碰到的欄位與資料表 | 重驗完整 contract,對未解決 drift fail closed |
outputs、join、彙總與 query 模式都需要版本 2。混用兩者會在 authoring 時被逐項指名拒絕。版本 1 仍然支援,對於「不需要先查資料的純寫入扇出」來說是比較簡單的選擇。
兩種版本的 dependency contract 都會由支援的 server mutation 維護。一般 column/rule/IaC/purge-repair path 會刷新 selected、mutation 前 valid 且之後仍可編譯的 command;若 mutation 使 command 失效,mutation 優先,command 保持 stale。Multi-table purge/repair probe 依每張 rewritten table 自己的 scope 分組;這種分組是完整的,因為 command contract 採 exact-scope-closed:每個 closure member 都必須通過 command 的精確 scope filter,跨 scope authoring 則以 A referenced table does not exist in this scope 失敗。透過 no-dependent fast path 併發建立的新 command,仍可能依 mutation 前形狀編譯並落成 stale。Trigger write 更嚴格:candidate graph 與所有 selected refresh 都必須成功,否則 trigger、history、IaC state 與 refreshed definitions 一起回滾。原本已 stale 的 command 絕不會被順便修復。見相依漂移。
明確寫出的 mode: "write" 在儲存時會被折成「完全不存在這個 key」,所以寫入型定義的序列化結果與功能上線前的定義位元組完全相同。只有 mode: "query" 會原樣保存。不要寫 mode: "write" 然後期待它被回傳。
delivery_reconciliation_ttl_seconds
定義外層在 version 與 mode 之外還有第二個與版本無關的 key。delivery_reconciliation_ttl_seconds 是選填整數,範圍 60–604,800 秒(超出範圍在 authoring 時就是 422),用來限制 AI agent 通道要重播一次已執行過的呼叫、而不是再寫一次,最長能持續多久。不設定代表採用 900 秒的執行期預設值,而且它與 mode 一樣採 omit-when-default 序列化,因此沒有設定它的定義與功能上線前的定義位元組完全相同,IaC diff 與 dependency contract 摘要都不會變動。版本 1 與版本 2 皆適用,而它對 REST 與 trigger 通道沒有任何作用。完整契約見Agent 派送調解視窗。
你拿回來的是編譯後的定義,不是你送出的定義
儲存的定義是伺服器編譯過的。GET 回來的內容與你 POST 出去的 body 本來就會不同:
data/match裡的顯示欄位名稱會變成內部的col_<hex>key,所以改欄位名稱不會弄壞 command;- 版本 1 的資料表參照會被改寫成 table id;
- 運算式參照會存成正規化的
{"$column":{…}}/{"$metadata":{…}}節點; mode: "write"會被丟掉;- 用戶端送來的
dependency_contract會被丟棄並重建。
拿送出的 body 去 diff 回應的用戶端永遠會看到差異。請改為比對編譯後的形式。
Command 的三種執行方式
直接呼叫 REST。 寫入型走 POST .../commands/{command_id}/execute,query 型走 POST .../commands/{command_id}/query。這是表單或前端按鈕使用的通道,細節見執行語意。
從資料表 trigger 觸發。 invoke_command action 讓一次列寫入原子性地扇出到多張資料表。該 command 會走完整的 execute pipeline,並以 trigger 的作者(created_by)身分執行,絕不是 service account;因此刪除、停用或把該使用者踢出聊天室,會讓這個自動化在每次觸發時都 403。Trigger 不能指向 query 模式的 command,也不能指向會寫入審批控管資料表的 command。它的 idempotency key 由 run id 與 action id 推導而來,所以在當機視窗內的重試會走 replay 而不是重新執行。該 action 的欄位與設定時驗證見觸發器動作型別。
從 AI agent 呼叫。 工具組會為每個可見的 command 產生一個工具,名稱為 custom_table_command_{slug}_{id8}——slug 會轉小寫、把非英數字元連續段落收斂成 _、截斷到 40 字元,id8 則是把 command uuid 去掉連字號後的前 8 個字元。參數是每個宣告 input 一個具名 kwarg;query 模式 command 的工具會多一個選填的 cursor。一個 command 只有在 agent_enabled 為 true、tag 仍然有效,且每一張被引用的資料表都能被當下的使用者或 social client 讀取時,才會變成工具——這就是「為什麼 AI 看不到我的 command」的答案。見 API 工具。
agent_enabled 只管這第三條通道。設成 false 之後,command 對任何讀得到它資料表的人仍然完全可用——照樣列得出、讀得到、執行得了——但工具組不會為它產生工具,而且可信 agent 路由在每一次呼叫時都會重讀這個旗標,所以對話進行中翻成 false,已經綁定的工具會立刻以 404 Command not found fail closed,內容與「id 不存在」完全相同。翻回 true 則不需要重新綁定就恢復執行。這個旗標出現之前,要讓 command 離開 agent 的手,只能刪掉它或弄壞它的 tag 綁定。
PUT 是整支取代,而 agent_enabled 預設是 true,所以更新時漏掉這個 key,等於默默地把 command 重新對 agent 打開。每一次必須維持隱藏的 PUT 都要送 agent_enabled: false。
同一個狀態碼有兩種錯誤形狀
/execute 與 /query 的 400、409、422 各自會回兩種 body 之一:該路由自己的區分聯集(cap_exceeded、idempotency_key_reuse、approval_required、not_a_query_command……),或 compose 程式的信封 CommandProgramErrorResponse。兩者一直都會出現在網路上,只是第二種沒有被寫進規格,所以依規格產生的用戶端解不出來。現在 schema 明確宣告:/execute 的 400/409/422 與 /query 的 400/409 都是 anyOf[<區域聯集>, CommandProgramErrorResponse]——/query 的 422 本來就一直是信封。
{
"detail": {
"error": "query_limit_exceeded",
"phase": "execute",
"step": "lines",
"message": "Select result exceeds max_rows",
"retryable": false,
"details": { "resource": "relation_rows", "limit": 500, "actual": 501 }
}
}| 欄位 | 契約 |
|---|---|
error | 判別欄位。封閉的 28 個錯誤碼列舉;告訴你發生什麼事的是它,不是狀態碼。 |
phase | author、execute、stage、release 或 callback。 |
step | 選填,^[a-z0-9_]{1,32}$。只有在錯誤可歸因到單一 step 時才會出現。 |
path | 選填,1–256 字元。 |
message | 1–512 字元。可以安全顯示;它永遠不含 driver 文字或綁定參數。 |
retryable | 一定會輸出,預設 false。true 代表真正的暫時性錯誤——command_lock_conflict、source_version_changed、source_link_changed、query_timeout。只重試這些,其餘一律不要重試。 |
details | 一定存在,經常是 {}。query_limit_exceeded/relation_limit_exceeded/mutation_limit_exceeded/output_limit_exceeded 帶 {resource,limit,actual};cardinality_failed 帶 {expected,actual};query_timeout/command_timeout 帶 {limit}。 |
請先判斷 detail.error 是否存在,再看它的值——絕不要用狀態碼分支。同一個 409 既可能是區域聯集的 {"error":"approval_required",…},也可能是信封的 {"error":"command_lock_conflict","retryable":true,…};同一個 422 既可能是版本 1 的 {"error":"command_rule_failed","rule":…},也可能是版本 2 那個沒有 rule 名稱的信封。用狀態碼分支的用戶端,每一對裡一定會處理錯一個。
修改與刪除
PUT 與 DELETE 都會先鎖住 command 這一列,所以檢查與變更之間不會被插隊。有五種衝突值得在編輯器 UI 裡分開呈現。
| 狀態 | Body | 原因 |
|---|---|---|
| 409 | {"error":"command_scope_unavailable"} | 無法鎖定 scope 根列——不存在、已刪除,或屬於其他租戶。 |
| 409 | {"error":"pending_staged_executions","staged_change_ids":[…]} | 該 command 還有 staged 狀態的執行時做 PUT 或 DELETE。請先處理完審批。 |
| 409 | {"error":"command_referenced_by_trigger","command_id":…,"references":[…]} | 刪除一個仍被有效(或可還原之垃圾桶內)資料表 trigger 引用的 command。 |
| 409 | {"error":"command_mode_trigger_conflict","from_mode","to_mode"} | PUT 把被 trigger 引用的 command 在 write 與 query 模式之間翻轉。 |
| 409 | {"error":"approval_trigger_conflict","target_table_id","target_table_name","rule_id"} | PUT 讓被 trigger 引用的 command 新增引用了一張審批控管資料表。 |
最後一項的不對稱是刻意的,也是真正的陷阱。REST PUT 的守衛走的是完整 dependency contract,所以在被 trigger 引用的 command 上就算只加入一個唯讀的控管資料表參照,也是 409。Trigger 端的守衛只走 insert/update/delete 分支,所以設定 trigger 時同樣的唯讀參照是被接受的。同一個錯誤碼,兩組不同的參照集合。
PUT 另外要求你對已儲存定義與新 body 所引用的每一張資料表都具有 manager 權限;同名的並行建立則會透過 name namespace lock 序列化成一次成功加一次 409 command_name_conflict。
2026-07-28 版變更了兩條生命週期規則,都與「資料表先於 command 消失」有關:
- **殭屍 command 刪得掉了。**過去刪除一支資料表已被刪掉的 command 會失敗,因為權限檢查要解析那張活表。現在 REST 與 IaC 兩條刪除路徑在表不在時,改以 command 自身 scope 的權威判定(聊天室建立者/管理員、部門管理員、公司管理員,租戶錨定不變);沒有該權威的呼叫者看到的仍是同一顆不可枚舉的
404。PUT刻意仍要求活表:它是整份重編譯,殭屍更新沒有東西可編譯。 - 刪除的 command 立即釋放名字。名稱唯一性現在只計活列。過去軟刪的 command 會永遠占住名字——與資料表不同,command 沒有還原機制,那個占名保留不到任何東西。先刪再以同名重建是合法順序,包括 tag 通道的 purge 掃掉整個系統 commands 的情況。