Skip to Content

觸發器動作型別

一個 trigger 會依陣列順序執行 1 到 5 個 action,遇到第一個失敗就中止其餘動作。共有十種 action,其他 type 一律回 400,錯誤訊息會列出全部十種。

上限以資料表為單位:最多 50 個 trigger、每個 trigger 1 到 5 個 action、最多 5 個 when 條件、chain depth 最多 3、每條 chain 的 generated-run 預算 1,000、每個 record-write action 最多 100 筆目標列、模板字串上限 2000 字。Webhook URL 與 header 值也受同一個 2000 字模板上限約束,header 最多 20 個;header name 不得為空白,也不得含 :、CR 或 LF,而且 header name 不分大小寫,只差在大小寫的兩個名稱會被拒絕(在 PUT .../triggers 上是 400 trigger_secret_keep_refused,code 為 duplicate_header)。api_call 的 body 模板上限為 8192 bytes。執行時渲染後的 URL 或 header 值超過 8192 bytes,或渲染後的 api_call body、webhook payload 超過 262144 bytes,都會讓該 action 失敗。

Action 身分,以及為什麼一定要原樣送回

每個 action 都有穩定的 id。你在 action 上自行提供的 id(非空字串、最多 64 字、在同一個 trigger 內不可重複)會被保留;沒有提供時由伺服器產生 act_<12 hex>。讀取時會把 id 放進 action 物件的 id;儲存端則另外保存一份與 actions 一對一對齊的頂層 action_ids 清單。

這件事之所以重要,是因為失敗續跑與各 action 的冪等鍵都掛在這個 id 上,而不是掛在陣列位置上。重試時是用 id 去比對先前的 receipt,所以在一次 run 失敗到重試之間插入或重排 action,不會把已完成 action 的 receipt 錯位到別的 action 上。用戶端存 trigger 時沒有保留回傳的 id,伺服器就會重新產生。若某次 run 先前的 receipt 已經對不上任何現有 id,重試會退回依位置續跑,而這只有在 action 沒被插入或重排時才安全;若仍有部分 id 對得上,重新產生 id 的那些 action 找不到 receipt,會再執行一次。唯寫機密也依 action id 綁定,所以丟掉 id 可能讓保留機密的寫入變成 400 trigger_secret_keep_refused(見唯寫機密)。

一次嘗試執行哪一份定義,取決於 run 的來源:row-event run 每次嘗試都會重新讀取目前的 trigger 設定,所以在這種 run 處於 failed 時修改 trigger,會改變重試實際執行的內容;schedule run 則重新執行建立時凍結進 run 的定義。見執行紀錄與重試。

Receipt

每個 action 不論成功或失敗都會在 run 的 action_results 追加一筆 receipt,且每筆都帶自己的 action_id。失敗長這樣:

{ "type": "create_record", "ok": false, "detail": "…截斷到 300 字…", "action_id": "act_0a1b2c3d4e5f" }

Run 本身的 error 截斷到 1000 字。失敗的 run 不會自動重試,requeue 掃描也不會撿起它(掃描只重新送出 pending run 與已 stale 的 running run):復原方式是表格 moderator 的 retry 端點。見執行紀錄與重試。

webhook

{ "type": "webhook", "url": "https://integrator.example/hooks/orders", "headers": { "X-Source": "teamsync" } }

把 trigger 當下的 run payload 以 JSON POST 到一個靜態 HTTP(S) host,內容包括 event、table_id、record_id、record_version、以內部 col_<hex> 鍵表示的 data 與 diff、fired_at、trigger 與 actor 身分、chain_id 與 depth;列事件的 run 另有 source_permission_context(物件或 null),排程 run 則有 schedule。不受 ACL 閘門限制。每個 header 值、URL 的 userinfo 與每個 URL query 值都是唯寫機密 slot:讀取時會看到 "X-Source": "__SECRET__",PUT 時把佔位符原樣送回就會保留已儲存的值。

api_call

{ "type": "api_call", "method": "POST", "url": "https://integrator.example/orders/$row.訂單編號", "headers": { "Authorization": "Bearer …" }, "body": { "record_id": "$row.訂單編號", "amount": "$row.金額" } }

支援 GET、POST、PUT、PATCH、DELETE(預設 POST);只有 POST、PUT、PATCH 會送出 body。Scheme、host 與 port 必須是靜態的:URL 內只有 path 與 query 可以插值,header 值與 body 葉節點則同樣可以放 $row token。URL、headers、body 的模板在儲存時會正規化成內部欄位鍵,所以欄位改名不會壞。不受 ACL 閘門限制。

Body 必須是 JSON 物件或陣列(api_call body must be a JSON object or array),最多巢狀 8 層(api_call body nests too deeply (max 8 levels)),每個字串葉節點最多 2000 字元。兩種 HTTP action 共用的 URL 檢查見對外 HTTP。

讀回這個 action 時會看到 "Authorization": "__SECRET__" 與 "amount": "$row.金額":每個 header 值、URL 的 userinfo、每個 URL query 值,以及每個不是剛好 $row.<欄位> 的 body 葉節點,都是唯寫機密 slot;純粹的資料列引用仍然可見。

對外 HTTP

webhook 與 api_call 和 Command 的生命週期 callback、Review webhook 共用同一套對外政策。

  • 儲存時,URL 必須是有主機的 http(s),主機必須能解析,而且解析出的每個位址都必須是可全域路由、非保留的位址——IPv4-mapped、IPv4-compatible(::/96)或 NAT64(64:ff9b::/96)的 IPv6 位址會以其內嵌的 IPv4 判斷,所以無法透過它們連到 169.254.169.254 這類私有或 link-local 的 IPv4:回 400 webhook url rejected: <reason>/400 api_call url rejected: <reason>,原因為 webhook_url must be http(s), got scheme '<s>'、webhook_url has no host、webhook_url is invalid,或 webhook_url host resolves to a private/loopback/link-local address; blocked (set WEBHOOK_ALLOW_PRIVATE_IPS=true on self-hosted deployments)(主機無法解析時也是這個)。
  • 觸發時會再次驗證並解析渲染後的 URL,連線會釘在驗證過的位址、忽略 proxy 環境變數、不跟隨轉址(3xx 視為已送達),逾時為 10 秒。整個部署的 WEBHOOK_ALLOW_PRIVATE_IPS=true 會關閉位址檢查與釘選。
  • 渲染後的上限: URL 與每個 header 值最多 8192 bytes,body 最多 262 144 bytes,header 最多 20 個;只有 POST、PUT 與 PATCH 會送 body,並在你沒有自行設定時加上 Content-Type: application/json。
  • 失敗時該 action 失敗,receipt 的 detail 為:狀態碼 400 以上時是 HTTP <status>,傳輸錯誤是 request failed(不會回顯 URL 或機密),url rejected: <reason>(api_call)、host resolves private/unresolvable (<scheme://host[:port]>)(webhook),或 rendered url exceeds 8192 bytes、rendered header '<name>' exceeds 8192 bytes、rendered body exceeds 262144 bytes。

create_record 與 update_record

{ "type": "create_record", "table_id": "22222222-2222-4222-8222-222222222222", "data": { "來源訂單": "$row.訂單編號" } } { "type": "update_record", "target": "$row", "data": { "狀態": "已同步" } }

明確指定的 target table 遵循共用的同公司 hierarchy:可以留在 host table 的 scope;chatroom host 可指向自己的 department 或 company;department host 可指向自己的 company。向下、sibling、無關 department 與跨公司 target 會以刻意統一的 target table not found or not accessible 拒絕(trigger PUT 回 400)。自後端 5.21.0 起,create_record、update_record 與 materialize_slots 先做這項 scope 檢查、再做 approval gate 檢查,所以超出該 hierarchy 的 target 不會走到會點名 target 資料表與其規則的 409 approval_trigger_conflict。省略 table_id 時使用 host table。

update_record 用 "$row" 指向觸發它的那一列,或用 { "via": "<link 欄位>" } 指向一跳之外的目標。兩者都受執行來源的 ACL 閘門限制,見下面的執行時權限。兩者都不能指向帶 require_approval 規則的資料表。write_policy 為 commands_only 的目標資料表,會在 flush 時以 governed_context_required 拒絕這些直接的資料列寫入,使該 action 失敗;請改用 invoke_command action 來完成這類寫入。

notify

{ "type": "notify", "chatroom_id": "11111111-1111-4111-8111-111111111111", "message": "新訂單:$row.訂單編號" }

對同公司的聊天室發送訊息。有兩個坑:訊息以原文儲存,所以被引用的欄位改名時模板會默默失效(其他 action 都不會);而這個 action 需要 trigger 作者的使用者資料仍然存在,否則 run 會記下 notify skipped: trigger owner user no longer exists。

send_channel_message

依觸發的那一列解析出一位社群客戶,推送一則訊息。

{ "type": "send_channel_message", "recipient": "created_by_client", "message": "您的訂單 $row.訂單編號 已可取貨。" }

recipient 可以是字串常值 "created_by_client"(在觸發當下讀取線上資料列的 created_by_client),或 { "column": "<欄位>" }(從 trigger 當下的快照取值)。該欄位必須是 string 或 social_client 型別。user 欄位會被拒絕,因為它存的是內部使用者 id,不是通訊管道身分;principal 欄位也會被拒絕,因為它的 cell 帶標籤、還可能指向一個聊天室——從帶標籤的 cell 做投遞刻意不在範圍內。

觸及範圍是整間公司,不是單一聊天室。 在欄位模式下,收件客戶自己的聊天室必須存在、未刪除,且屬於這張表解析出的公司;它不必是這張表所在的聊天室,所以這個 action 可以發給同公司任何聊天室的任何客戶。把它指向使用者可以編輯的欄位之前,請先確認這真的是你要的行為。

憑證一律取自收件客戶自己聊天室的通道設定,不會從 action 設定取。缺少設定檔屬於永久失敗,receipt 會明說:send_channel_message failed: permanent — do not retry: no {platform} profile for client {id} (external_channel_id={external_channel_id})(receipt 的 detail 截斷至 300 字)。支援的平台只有 line、messenger、instagram、agent,其他一律讓該 action 失敗。

message 為必填,是 $row 模板,設定時上限 2000 字,並會正規化成內部鍵以免改名壞掉。執行時渲染結果超過 8192 bytes 會讓該 action 失敗。$row token 會把 null 儲存格渲染成 "",把 dict 或 list 渲染成使用預設 ", "/": " 分隔符號的 JSON,其他值則用 Python 的 str()——float 會是 99.5 或 1e+20,純量布林值(含純量 json 儲存格)是 True/False;dict/list 裡的巢狀布林值則以 JSON true/false 序列化。Header 值與 URL 模板套用同樣規則(URL 之後再做百分比編碼);只有 api_call body 中剛好是 $row.<欄位> 的葉節點會送出原本的型別值。

選填的 line_flex 帶 LINE Flex 的 contents 物件(bubble 或 carousel),只會送給 line 客戶,其他平台退回成純文字。設定時的上限:必須是 JSON 物件、可序列化、序列化後不超過 10240 bytes、每個字串葉節點不超過 2000 字、嵌套不超過 8 層。執行時渲染後的 line_flex 超過 262144 bytes 會讓 action 失敗,而 Flex 的 altText 是被裁切到 400 字,不會失敗。在 Flex 裡,剛好等於 "$row.<欄位>" 的葉節點會渲染成字串(浮點數變成 "99.5"、null 變成 ""、dict 或 list 變成使用預設 ", " 與 ": " 分隔符的 JSON 文字,例如 {"a": 1}),而非代號的常值會保留原本的 JSON 型別,所以數值型的 Flex 屬性仍然可用。

只有 created 與 updated trigger 可以用 attachments 選取 1 到 5 個不重複的 attachment 欄位。每一項必須剛好是 { "column": "<attachment 欄位>" }:

{ "type": "send_channel_message", "recipient": "created_by_client", "message": "$row.訂單編號 的附件", "attachments": [ { "column": "發票" }, { "column": "照片" } ] }

Action 會依欄位順序從不可變的觸發快照讀取 blob IDs,保留 cell 內順序、移除重複 ID,解析後超過 20 個 blobs 就拒絕。執行時會重新驗證 run 的 table、record、version/history event、每個來源欄位仍是 attachment,以及 blob 的公司歸屬。CT #1994 還要求 blob 由這張表自己的附件上傳路徑產生,或是收件客戶聊天室的 native room-bound 檔案。Cell 持有 ID 或能透過其他 CT 表讀取檔案,不授予 delivery 權限;拒絕來源與未知 blob 使用相同診斷。外部 LINE、Messenger 與 Instagram delivery 還要求每個 blob 具備永久 HTTP(S) public URL;agent delivery 會直接關聯已解析的 blobs,完全不解析該 public URL。所選 cells 全空是合法的,只會送文字。deleted、restored 與 schedule trigger 不能設定 attachments。

每個送達的 part 都會在客戶聊天室留下 CRM 收件匣可見的真實 assistant message。LINE、Messenger 與 Instagram 的 delivery plan 是先送 text/Flex,再為每個 blob 各送一則 provider message;agent 則用一則 text-plus-blobs 組合訊息。Receipt 會回不含 URL 的 parts、逐 blob attachments、assistant_message_ids 與 delivery_plan_hash。每個 part 成功後都持久化進度,因此手動 retry 會跳過已成功 parts;部分送出後若 plan 漂移,retry 會 fail closed,必須建立新 trigger run。Receipt 的 message IDs 是 TeamSync row IDs,不是平台 IDs。LINE parts 帶 deterministic retry key,可在 LINE 的 24 小時視窗內去重;Messenger 與 Instagram 仍是 at-least-once。

Warning send_channel_message 在 delivery time 不會重新檢查 causing principal 的資料表 ACL 或 channel-scope policy。基礎 text/Flex 的守門是執行時對收件客戶的公司檢查,因此任何能寫出會觸發此 trigger 資料列的人,都能造成 customer-facing 文字送出。加上 attachments 會增加不可變 source record/history、attachment column、公司歸屬及收件 room/table-upload provenance 檢查(外部 channel 另有上述 public URL 檢查),但不會增加 causing-principal 的 row-read、hidden-column 或 SCP 授權閘門。

invoke_command

把複合指令接進自動化流程。

{ "type": "invoke_command", "command_id": "55555555-5555-4555-8555-555555555555", "inputs": { "order_id": "$row.訂單編號", "warehouse": "TPE-1" } }

command_id(uuid)與 command(名稱)只能給其中一個。名稱會在儲存時沿著資料表的範圍鏈解析:先是這張表自己的聊天室、再是它部門上不綁聊天室的指令、再是公司層不綁範圍的指令,最近的範圍優先,已軟刪除的排除。儲存端一律保存 command_id。

inputs 把指令已宣告的輸入名稱對應到「型別檢查過的常值」(檢查邏輯與 execute 端點完全相同)或「剛好等於 "$row.<欄位>" 的代號」。字串中間插值會被拒絕。指令標為必填的輸入全部都要對應,未宣告的名稱回 400,而 null 只有在輸入規格允許 nullable 時才可以。

可以餵 $row 代號進指令輸入的欄位型別只有這些:string 與 text 進字串輸入,integer、float、decimal、boolean、date、datetime 進各自型別,user 與 social_client 進身分輸入或字串,principal 只能進字串輸入——identity: 型別的參數預期的是裸 id,每次執行都會拒絕或錯誤解析 user:u1。其他型別一律不能當代號來源,包含 json、attachment、select、multi_select、link、interval 與所有計算型別。另外,沒有資料列的排程(interval、daily 與 cron)也不能用 $row 輸入,因為那種執行本身沒有資料列;錯誤訊息仍只寫 interval/daily。

由 trigger 呼叫的指令走的是真正的指令管線,但權限是以 trigger 作者當下的有效權限準備,不是造成觸發的人,也不是系統帳號。作者必須仍是這張表所屬公司裡活著、已驗證的帳號,而聊天室層的指令還要求他仍綁在那個聊天室。執行範圍由指令自己的範圍決定,指令自己的編譯合約、ACL 與 SCP 準備、核准掃描、冪等與稽核紀錄全部照常適用。

冪等鍵由 run 與 action id 推導,所以崩潰視窗內的重試會重播先前儲存的回應,而不是把指令執行第二次。Receipt 會帶 command_id、execution_id 與步驟數;execution_id 就是 trigger run 與指令執行之間的稽核連結。

宣告了 effect identity 的指令,每個業務事件只會記錄一次。當某次觸發碰上已經記錄過的事件——不論是手動執行、其他呼叫者或先前的觸發記錄的——指令不會寫入任何東西,receipt 還會帶 "replayed": true 與原本的 execution_id。若該事件是以不同的 inputs 或不同的程式記錄的,這個 action 會以 invoke_command failed: HTTP 409: … 失敗,內含 ct.effect_identity_conflict,重試該 run 會得到相同的拒絕。

指令那一側因此多了三個 409:

你嘗試做的事錯誤
刪除任何 trigger 引用中的指令,包含已被丟到垃圾桶的表上的 triggercommand_referenced_by_trigger,附上引用它的資料表與 trigger
把被引用的指令在寫入與查詢模式之間切換command_mode_trigger_conflict
讓被引用的指令新增一個指向帶 require_approval 資料表的引用approval_trigger_conflict

查詢模式的指令永遠不能當目標:設定時就是 400。

submit_sandbox_job

由不可變的 created 或 updated 資料列快照排入 Sandbox run:

{ "type": "submit_sandbox_job", "chatroom_id": "11111111-1111-4111-8111-111111111111", "task_version_id": "55555555-5555-4555-8555-555555555555", "timeout_seconds": 900, "input": { "order_id": "$row.訂單編號", "amount": "$row.金額" } }

它只適用 created 與 updated。task_version_id 必須釘住同公司的版本:task 為 active、version 為 published 且 active,並且 requires_confirmation 為 false。它也不得有 output_policy:Custom Table writeback credential 即使該旗標為 false 仍屬 confirmation-grade,而 trigger 沒有稍後的人類確認輪。設定時會以 submit_sandbox_job cannot use a task version that declares a custom-table writeback output_policy 拒絕。chatroom_id 預設為資料表的聊天室;department/company table 必須明確提供。Room 必須是同公司且未刪除,live 設定者必須能在該 room 執行此 task。timeout_seconds 預設取 version timeout,必須是 1 到 604680 的整數。

input 必填且必須是 JSON object。Template 最多嵌套 8 層、每個字串 leaf 最多 2000 字;canonical input 合約另限制 UTF-8 JSON 最多 1 MiB、深度 64、節點 100000,並拒絕 NUL。巢狀字串 leaf 裡的 $row ref 會被正規化。執行時 manifest 的 row 只暴露明確挑選的值,不會複製完整 row 或 diff;剛好等於 token 的 leaf 會保留原 JSON 型別。

設定中的 IDs 不是權限。Worker 在 staging input.json 之前與之後都會重新載入 causing principal、company/table/run、來源 row 與不可變快照、hidden-column 與 record-read ACL、channel-scope 權限、room、task 與 pinned version;撤權或快照漂移會在 run 排入前 fail closed。Fire-time 檢查也會拒絕後來新增 output_policy 的版本,訊息是 submit_sandbox_job denied: task version declares a custom-table writeback credential; trigger submissions cannot mint one。Receipt 回傳 run_id、trigger_run_id、task_version_id、input_digest、run_status 與 submission_source: "custom_table_trigger";由 run/action 推導的 idempotency key 避免 retry 建立重複 Sandbox runs。人類專屬的 credential lane 見 Sandbox 寫回 Custom Table。

delete_record

{ "type": "delete_record", "target": "$row" }

只軟刪除觸發它的那一列。target 必須剛好是 "$row":不支援連鎖目標({ "via": … }),也不接受 data。在 deleted trigger(列已經不在了)、沒有資料列的 interval、daily 與 cron 排程,以及 trigger 自己的表帶 require_approval 規則時,都會被拒絕。

它走真正的刪除路徑,所以資料列鎖、edit filter ACL、核准保險、unique 條目清理與歷史紀錄全部照常,而且會在下一層 chain depth 觸發這張表自己的 deleted trigger。對已刪除的列再刪一次是冪等成功,receipt 會回 "deleted": false。

materialize_slots

只能用在 on: "schedule" 且 schedule.type: "daily"。它會在滾動視窗內把班表資料列展開成同公司目標表裡實際可預約的時段列。Receipt 會回 created、skipped_existing、truncated 與這次掃描的 window。完整設定見產生可預約時段。

執行時權限

會寫入資料列的 action 由「是什麼造成這次執行」決定權限,而判斷依據只看事件名稱:

觸發來源create_record、update_record、delete_record、materialize_slots 的權限
created、updated、deleted、restored造成該次寫入的 principal
schedule系統

在由使用者造成的執行裡,實際檢查的是:create_record 與 materialize_slots 需要目標表的 insert 權限,update_record 與 delete_record 需要非 none 的 edit 權限。被拒絕時會變成 ok: false 的 receipt 並讓 run 失敗,沒有新的 HTTP 錯誤合約。

造成觸發的 principal 必須仍是活著的帳號:被軟刪除、停用或未驗證的使用者,即使資料列還在,也會失去執行時的寫入權限。agent 平台的社群客戶會委派給它 external id 指名的使用者,而那個帳號的存活與公司歸屬同樣要成立。

只有三種情況保留系統權限:由排程造成的執行、payload 早於這次變更部署的執行,以及造成寫入時既沒有使用者也沒有客戶 id 的執行(種子資料、匯入與內部機制都算這一類)。

由 trigger 寫出的資料列仍然標記為 trigger 的 created_by。權限與歸屬現在是兩件不同的事。

Action 內容裡可以用的代號

只有 $row.<欄位> 這一套。Row policy 的 $me、$me.department、$today、$now 在 when 條件與時段產生器的來源過濾裡都會被拒絕;而在 action 內容裡,字面上的 "$me" 就只是一個字串,會被原樣寫進去。create_record / update_record 的 data 或 invoke_command 的 inputs 中,剛好等於 "$row.<某個東西>" 的值必須指向真實欄位,否則儲存時回 400 template reference '$row.<某個東西>' does not name a column of '<資料表>'。在字串模板裡(api_call 的 URL、headers 與 body、notify 與 send_channel_message 的文字、line_flex、submit_sandbox_job 的 input),未知的代號在儲存時原樣保留,執行時就照字面渲染出來,因為替換表只由真實存在的欄位建立。模板請自己校對。

CT submit_sandbox_job input 有例外:儲存時保留未知模板文字,但執行時會掃描內部 $row.col_… 參照(hex/底線後綴,不分大小寫)。來源欄位已不存在時,送出工作前就會拒絕:submit_sandbox_job denied: a referenced source column is no longer available。儲存時保留字面值,不代表保證能送出。

在一次 run 內,session 會被固定成 SCP 拒絕姿態(因為 trigger 沒有 acting chatroom),也會固定成一種核准情境:符合的 require_approval 規則會直接拋衝突,而不是暫存一筆變更;否則每次執行與重試都會產生一筆垃圾覆核流程。

這些 action 在 JSONL 文件裡的寫法,包含那裡才有的 $table: 與身分代號,見 trigger 行類型。

Last updated on