變更紀錄
自訂表格模組的變更紀錄,最新的發布排在最上面。每一筆都會標出它動到哪些端點,並連到教你怎麼用的頁面,讓你從「好像有東西變了」一步跳到「合約長這樣」。
怎麼讀一筆紀錄:
- 破壞性:既有呼叫的結果會不一樣。每一筆都附上「你要改什麼」,那段就是完整的遷移步驟。
- 新增:多了一個能力,你現在呼叫的東西不會改變行為。
- 調整:既有呼叫被放寬或收緊,例如多一個欄位、多一個運算子、驗證變嚴格。
- 修正:文件本來就這樣寫,但實作沒做到,現在修好了。
日期是測試環境(https://api.scfg.io)的發布日期。標示為已合併,尚未部署到測試環境的發布,表示它已經在 master 上、也已經寫進文件,但還沒部署,所以在部署之前那些端點仍是舊行為。正式環境走一般部署流程,所以請把日期讀成「可以開始接了」,而不是「每個租戶都已經生效」。API 參考頁與 OpenAPI 快照都跟下面每一筆同一批更新,所以只要路由出現在這裡,就一定已經在 API 參考。支援 user/public 驗證的路由也會成為可執行的 Playground 項目;特權 service 與 root 路由則會清楚標示為僅供參考。
把這份紀錄當成一張自訂表格
下面每個欄位都是本模組真實存在的欄位型別:類別是 select、範圍是 select、端點清單是 text,發布日期是排序鍵。把這幾行貼進 IaC 工作台就能建出這張表。
{"kind":"table","ref":"changelog","spec":{"name":"Changelog","description":"Release log for the custom tables module"}}
{"kind":"column","table":"changelog","ref":"released_on","spec":{"type":"date","required":true}}
{"kind":"column","table":"changelog","ref":"change_kind","spec":{"type":"select","options":["breaking","new","changed","fixed"],"required":true}}
{"kind":"column","table":"changelog","ref":"area","spec":{"type":"select","options":["commands","column-types","querying","acl","iac","triggers","public-read","agent","ops"],"required":true}}
{"kind":"column","table":"changelog","ref":"title","spec":{"type":"string","max_length":160,"required":true}}
{"kind":"column","table":"changelog","ref":"detail","spec":{"type":"text"}}
{"kind":"column","table":"changelog","ref":"endpoints","spec":{"type":"json"}}
{"kind":"view","table":"changelog","ref":"breaking_only","spec":{"filter":{"change_kind":{"eq":"breaking"}},"sort":[{"field":"released_on","direction":"desc"}]}}2026-09-10
5.10.0v5.10.0 已通過 staging E2E 並發佈到 production。來源 pin 為 9b8b95e598a7b1f606c030d8dd0f27f26c748c83(版本提交;執行程式碼與已驗收的 9393591ae 相同)。Private 資料列新增可編輯性資訊,查詢可篩選編輯/待審狀態,部門匯入前置作業改依公司歸屬授權;API snapshot 與雙語範例同步至部署中的 OpenAPI。 日期採 Asia/Taipei;staging 標記表示這些 API 已可在測試環境使用,production 上線則由 v5.10.0 release 明確確認。兩列 Excel 流程、拒絕寫入與撤權以 staging 驗證,production 的映像/版本/健康另行讀回確認。
Private 資料列提供目前的 can_edit#
Private 資料列回應必定含 can_edit,表示目前資料列的 edit ACL。它與審核暫存獨立,也不略過值、欄位、規則與授權檢查;更新及 command post-image 會依新持久化的資料列重新判斷。 前端請讀此旗標,不要從角色、建立者或 insert 權限推測列可編輯性;審核狀態與之後的寫入拒絕仍應分別處理。
以 editable 與 pending_approval 篩選可讀列#
Private POST search body 接受兩個獨立、可省略的布林值;GET list 不接受這兩個 query parameters,於 total、排序與分頁前以 AND 套用。editable=false 包含 own-only edit grant 下沒有建立者的列;尚無實體列的待審新增不在其中。已儲存 view config、匯出與 public-read 不接受這兩個本次請求欄位。 前端的篩選分頁請使用 POST search;把這兩個名稱加進 GET query string 不會篩選結果。
部門匯入的上傳與預覽不再要求管理權限#
已登入使用者可在同公司部門進行匯入前置作業;map 仍要求目標表 insert ACL,建立新表仍要求部門管理權限。Session 的 scope 綁定及撤權後的拒絕仍有效;issue #1261 已通過真實 XLSX staging E2E。 前端可對同公司使用者提供上傳/預覽,再分別判斷 create 與 map 權限;上傳成功不是寫入授權。
小型查找回傳可見欄位,確認使用業務名稱#
find_records 的 match 現在包含 id、label 與 data,簡單文字查找不必再呼叫 analyst。Analyst 的 lookup mode 使用一次篩選查詢,衍生指標仍需驗證;欄位提示說明型別與限制,批次工具只宣告允許的動作,遭拒的異動不會被說成已寫入。
Command 結果型別與布林 null 語意對齊 compiler#
Execution column 的 value_type 包含 user、social_client、principal,對齊 compiler 已允許的 scalar join。執行期 $and/$or 採 SQL 三值邏輯:false AND null 為 false、true OR null 為 true,沒有決定性布林值則維持未知;請重新產生結果型別並保留 metadata,不要把未知值轉成 false。
2026-09-05
5.8.4IaC 身分 token 現在可用於 `principal` 欄位的 cell(teamsync-backend PR #1161,以 origin/master HEAD 3012a2940bce3085f5c354e925ed16c595e1a185 合併,VERSION 5.8.4,staging 已上線)。`principal` cell 可以寫成 `$user:<username>`、`$smc:<platform>:<platform_user_id>` 或 `$room:<聊天室名稱>`,並存成該欄位要求的帶標籤 cell;export 則依標籤把這些 cell 反向對應回 token。`$dept:` 仍然只用於 grant。PR #1155 與 #1166 只是 staging E2E 測試套件,沒有路由、schema 或 ORM 變更。同一天的第二波把手冊同步到 origin/master HEAD b4baa266c478f0f5c2a8cea09f8f2078d112bab5,也就是 staging 上正在服務的那個 commit(teamsync-backend PR #1171、#1172、#1173、#1178、#1179、#1181、#1182、#1184、#1187、#1188、#1190、#1191、#1192、#1193)。pin 是那個 commit,不是某個發行版:該 HEAD 上 VERSION 檔的內容是 5.8.4,那是檔案內容,不是發行版宣告。agent 通道多了可寫入的 `$me` token、每個 envelope 只留一個 record id,計算欄位的過濾與排序改為跟著 principal 而不是分析 scope,異動 receipt 會保留模型自己的回答,agent 寫入通道上的 4xx 變成終局拒絕而不是內部錯誤,明確沒有寫入的 command 拒絕會說明原因,資料表清單的 `record_count` 會被呼叫者的 row ACL 收窄,被拒絕或被截斷的模型輪次不再是空白訊息,整段對話都會載入的協定新增了運作模型段落,而同時啟用兩種資料表 scope 的房間也不再重複載入該協定兩次。這一波沒有新增路由也沒有新增 schema——兩個 pin 之間,過濾後的 Custom Tables OpenAPI 文件位元完全相同。
`principal` cell 開始接受 `$user:`、`$smc:` 與 `$room:` 身分 token#
`principal` 欄位上的 `record` cell、trigger `when` 值與 link 自然鍵現在都可以帶可攜身分 token,於 plan 與 apply 兩側以公司為錨點、fail-closed 解析,並存成帶標籤 cell——`$user:alice` 變成 `user:<id>`、`$smc:line:U123` 變成 `smc:<id>`、`$room:Support Desk/Tier 1` 變成 `room:<id>`。`user` 與 `social_client` 欄位不變,仍然存裸 id。`$dept:` 只用於 grant,放進任何 cell 都是 kind 閘門錯誤 `principal token '$dept:Sales' does not match the column's type: a principal column expects $user:<...> or $smc:<...> or $room:<...> or a raw id`;原始帶標籤 cell 仍然原樣通過。若 record 行用到的 principal 欄位是同一份文件較早處宣告的,分類會延到 apply:屆時壞掉的 token 回報 `200` 與 `applied: false`、`principal column '<internal key>': …`,而不是 422。
`$me` 在 agent 通道上成為可寫入的值,並在驗證前解析#
`custom_tables_insert_record`、`custom_tables_update_record`,以及 `custom_tables_bulk_record_actions` 的 insert 與 update 兩個分支,現在都接受把 `$me` 這個 token——去除前後空白、不分大小寫、僅此一種寫法——寫進人員 cell(teamsync-backend PR #1190)。工具箱會在驗證**之前**把它改寫成當事人,因此驗證器、CRUD 歸屬閘門與資料庫看到的都是普通 cell;儲存用的 cell 文法沒有改變,也永遠不會存進這個 token。在 `principal` 欄位上,它在內部通道變成帶標籤的 `user:<id>`、在外部 client 通道變成 `smc:<id>`;在舊的 `user` 或 `social_client` 欄位上則變成裸 id,而且只在該欄位自己的通道上成立——無法解析時 token 會原樣留著,被一般形狀檢查擋下,而不是寫成別人。伺服器沒有同義詞表:「我」「me」「myself」是模型的責任。租戶歸屬與存活檢查仍然套用在解析後的 id 上。REST 不變,仍然要求已解析的 id 或 `ref`。
Export 會把 `principal` cell 反向對應成 token,對應不出來則保留原始帶標籤形式#
Export 不再原樣輸出每一個 `principal` cell。它會先解析該 cell,再用它自己的標籤查對應的表——`user:` 查使用者、`smc:` 查 social client、`room:` 查聊天室——聊天室裸名在公司存活房間中唯一時輸出 `$room:<聊天室名稱>`,否則在該組合唯一時輸出 `$room:<部門名稱>/<聊天室名稱>`。對應不出來的一律維持原始帶標籤 cell:兩種形式都模稜兩可的聊天室、名稱本身含 `/` 的聊天室、被軟刪除或懸空的聊天室、`(platform, platform_user_id)` 對到多列的 `smc`,以及跨租戶 id。同一個未變動系統的兩次匯出位元相同;帶有原始帶標籤退路的匯出,在原本的 scope 重新 plan 依然是零差異。
模型看得到的 envelope 只留一個 record id,「找不到」也會導向下一步#
每一張表在 `record.data` 裡都帶著一個自動產生的 `id` 主鍵欄位,它的 uuid 與工具實際定址用的 `record_id` 是不同的值。現在在 agent 通道上,insert 與 update 結果、`custom_tables_query_records` 的分頁,以及其中深度 1 的 link 展開,都會拿掉那個 `data.id` cell(teamsync-backend PR #1190);REST 回應仍然保留它,因為前端就是用它定址 cell。統一的「找不到」拒絕現在也會導向下一步——`Record '<id>' not found or has been deleted. Re-read the record id from the insert or find result and retry the update with it; never re-insert the row.`——而且它是固定文字,401、403、404 完全相同,所以這條通道依然不是存在性的 oracle。兩項變更源自同一起事故:agent 從 insert 結果複製了 `data.id`、把 update 花在它上面、讀到「找不到」,然後把整列重新 insert 了一次。
`computed_filters` 與計算欄位的 `sort_column` 改為跟著 principal,而不是分析 scope#
計算欄位的**值**本來就會查 principal 的完整即時權限表;但 `computed_filters` 與計算欄位的 `sort_column` 這兩條通道仍然在模型自選的 `table_ids` 下 deny-default,於是在窄的分析 scope 裡,你讀得到某個 rollup 卻不能對它過濾或排序。現在這兩條通道改用同一個不受 scope 限制的解析器(teamsync-backend PR #1173);而且 filter、sort 與渲染儲存格共用同一個 term builder,三者不可能各自漂移。deny-default 沒有改變,而且是無聲的:principal 真的讀不到的來源仍然解成 `can_read: "none"`,計算項會編譯成 SQL `NULL`——每個值運算子都比對到零列、`is_null` 會命中、排序鍵落在 MySQL 預設的 NULL 位置。沒有錯誤也沒有拒絕訊息,所以計算欄位過濾「沒有結果」並不代表沒有這樣的資料列。
異動 receipt 改為附加在模型回答之後,不再整段取代它#
伺服器過去會把模型寫的成功或失敗敘述整段換成有界 receipt。現在它保留模型的回答,並把 receipt 附在下方——模型的話在前,伺服器的事實在後(teamsync-backend PR #1182 與 #1188)。當敘述留了下來、**而且**每一個 mutation step 都套用成功時,footer 是一句括號附註:「(已為你新增 1 筆資料)」/`(1 record added for you)`;同一輪既新增又更新時,只渲染一句合併附註「(已為你新增 1 筆、更新 1 筆資料)」/`(1 record added, 1 updated for you)`。其他情況維持獨立句——「資料已新增完成。」/`The data was added successfully.`——兩個以上相同的已套用 step 且沒有留下敘述時,則計數成「已新增 3 筆資料。」/`3 records were added.`。只有與實際結果矛盾的句子會被拿掉;敘述被整段丟棄只有一種情況:這一輪並非全部套用、且同時綁定了 staged-execute 工具。被同一輪後續一次成功寫入取代的拒絕或失敗嘗試——`record_id` 相同,或寫錯 id 重試時 payload 相同——完全不會渲染;站得住腳的拒絕則渲染成「這項變更沒有執行,資料沒有被改動。」/`This change was not made, and nothing in your data was altered.`
Customer-interaction tip 改為不指定語域,協定則寫明自己的運作模型#
每一則 Custom Table ToolMessage 都會帶的那句提醒,現在是 `Speak in the room's own voice and language; answer the person's question naturally. Never expose JSON, record/table ids, UUIDs, tokens, digests, URLs, or system headers — describe things by their business names and keep every business value exact.`(teamsync-backend PR #1187)。舊的寫法讀起來像在指定語域,只因為讀了一張表,就把 LINE 上的育兒助理推進客服口吻;這則提醒只談資料揭露。整段對話都會載入的協定也補上了對應段落:Voice 段(persona、語言與語氣決定**怎麼說**,協定決定**可以說什麼**)、「How A Table Works」(scope 與可見性、結構、各欄位型別的值形狀、原子寫入——身分只存在於 `record_id`)、「How A Command Works」(定義、一次全有全無的執行、模式與政策、結果、權限——teamsync-backend PR #1192),以及被削減成只講後果的 Permissions 條目:結果送到手上時已經收窄,不推論也不解釋權限,拒絕就是政策(teamsync-backend PR #1191)。`custom_tables` 與 `custom_tables_department` 共用同一份協定檔,`custom_table_commands` 有自己的一份。
空 cell 不再從 REST search 與 agent 工具組的 `neq` 漏出(也不再被 `eq "null"` 選中);`is_null` 在每一條通道上意思相同#
MySQL 會把省略的欄位存成明確的 JSON `null`,unquote 之後是字串 `null`。REST `stored_filters` 的純量編譯與 agent 工具組的兩個 filter 主體直接拿這個字串比較,因此 `status neq "closed"` 會回傳每一列沒有 status 的資料、`status eq "null"` 會選中它們——string、text、select、principal、user、social_client、boolean 欄位都受影響。Command 的 query 通道與 row policy 本來就會把空 cell 擋掉,所以同一個 filter 在不同通道答案不同(teamsync-backend PR #1169)。現在三條通道的每一個值運算子都經過 presence gate,而且 `is_null` / `is_not_null`(PR #1170)在所有通道上都把 key 不存在、明確的 JSON `null`、空字串 `""` 視為相同——`json` 欄位例外,JSON 的 `""` 在那裡是真實的文件,只有 key 不存在或 JSON `null` 才算空。數字、日期與 `json` 欄位本來就有 gate,維持不變。沒有任何 wire 形狀改變,只有原本錯誤的列集合被修正。
Agent 寫入通道上的 4xx 是終局拒絕,不再是「An internal error occurred」#
從 CRUD 呼叫逃出來的 4xx——最常見的是 row policy 的寫入後檢查 `403 This record is outside your editable scope.`——過去到 agent 手上會變成泛用的內部錯誤訊息;那讀起來像暫時性問題,會誘發一次不可能成功的重試。現在寫入工具會依序對應(teamsync-backend PR #1184):`scp_*` 的 detail 保留自己的 SCP 代碼;已淨化的 lock 409 保留 `{"error":"write_conflict","code":"write_conflict",…}` 且沒有 `status` key;401/403/404 收斂成 `{"status":"denied","code":"mutation_denied","error":<統一文字>}`;其他任何 409 變成 `{"status":"conflict","code":"mutation_conflict",…}`;400/422 變成淨化後的驗證文字並帶 `status: "failed"`;其餘(含 5xx)維持泛用訊息。拒絕 envelope 的 `error` 依呼叫形狀統一——有指名 record 的呼叫拿到會導向下一步的「找不到」,沒有指名的拿到 `This change is not allowed with the current table permissions.`——所以 401、403、404 仍然無法分辨。
明確什麼都沒寫入的 command 會告訴客戶原因#
有五個 command 結果代碼屬於明確沒有寫入,因為每一個都伴隨 rollback 拋出:`assertion_failed`、`cap_exceeded`、`cardinality_failed`、`command_input_invalid`、`command_rule_failed`。它們面向客戶的 receipt 現在會講出原因,而不是停在「失敗了」——「這項變更沒有套用,資料沒有被改動:未通過商業規則檢查」/`This change was not applied, and nothing in your data was altered: a business rule check did not pass`(teamsync-backend PR #1179)。`assert` step 失敗時,原因就是 command 作者自己寫的 `message`,在 tool result 上與 `"error": "assertion_failed"` 並列成 `reason`,會被壓成單行純文字並限制在 200 字元;另外四個代碼沒有作者文字,改用該代碼的固定後備字串。provider 或資料庫的文字永遠不會送到客戶眼前,而且「結果不確定」的分支仍然會先被檢查,所以可重試或 5xx 的失敗仍然使用「無法證明發生了什麼」的文案。
Command 寫入後列的身分 cell 會像 REST 讀取那樣被加工#
版本 1 command 逐 step 寫入後列 `rows` 中的 `user`、`social_client` 或 `principal` cell,過去是原樣回傳儲存字串;於是下一次呼叫用 REST 單筆 GET 讀同一筆資料,看起來卻像另一個物件。現在 executor 會把每一份寫入後列交給該讀取路徑所用的同一個共用加工器,並以該表自己的公司為錨點(teamsync-backend PR #1171):`principal` cell 變成 `{ref, kind, id, name, …}`,而舊的 `user` cell 維持 `{id, name, username, is_deleted}`、舊的 `social_client` cell 維持 `{id, platform, name}`——刻意沒有 `ref`、沒有 `kind`,因為前端就是靠 cell 形狀分辨這兩種欄位型別。在本租戶解析不出來的 id 會保留原始字串。相對於 column ACL 的順序與 REST 相反——寫入後列在加工執行時早已被移除過——但結果相同,因為加工器只改寫列中已存在的 key。
資料表清單的 `record_count` 會被呼叫者的 row ACL 收窄#
資料表清單上的 `record_count` 過去是該表未經過濾的存活列數,所以授權為 `own` 或 `filtered` 的成員等於被告知 policy 後面藏了幾列他打不開的資料。現在清單改為套用與資料表詳情計數相同的條件,並逐表解析(teamsync-backend PR #1172):表管理者、或 `can_read: "all"` 且沒有 SCP 收窄時維持完整計數,而且仍由單一批次查詢一次算完;`own` 計算呼叫者自己建立的列;`filtered` 計算編譯後的 row policy,無法執行的 policy 會讓整棵樹 fail closed;受 SCP 規則管轄但沒有任何被授權房間的呼叫者則拿到 `0`,與被遮蔽的詳情讀取一致。chatroom、department、company 三種 scope 的清單都走同一個 helper。因此清單計數是逐呼叫者的數字,不可以跨使用者快取。SCP 會自己逐表重新判定管理者身分,而不是從權限階梯上讀取。
被拒絕、被截斷或帶 JSON 圍欄的回答,不再被存成空白輪次#
兩個不同的成因在 Custom Tables 房間造成同一個症狀:助理看起來完全沒有回話。provider 的拒絕(Anthropic `refusal`、OpenAI `content_filter`、Gemini/Vertex 的 `safety`、`prohibited_content`、`blocklist`、`spii`、`recitation`、`image_safety`)或截斷(`max_tokens`、`length`、`model_context_window_exceeded`)過去會被存成一則空的、**成功的** assistant 訊息;現在會存下一則看得見的婉拒,帶著 `succeed = false` 與 `error_code` `model_refusal` 或 `model_output_truncated`——「抱歉,這個請求我無法回覆。」/`Sorry, I can't respond to that request.`,以及「回覆在產生時被截斷,請縮小問題範圍後再試一次。」/`The reply was cut off while being generated; please narrow the question and try again.`。文字由伺服器撰寫,語言則由該輪對話文字的 CJK 偵測決定(teamsync-backend PR #1181)。另外,chat 後處理過去會刪掉每一個帶 `json` 標記的圍欄區塊;現在只有當內容看起來像外洩的 tool call 才會刪除——內容含有 `"tool"` 或 `"tool_calls"`,或同時含有 `"name"` 與 `"arguments"`/`"parameters"`——因此使用者要求的 JSON 回答會被保留(teamsync-backend PR #1178)。
同時啟用兩種資料表 scope 的房間,只會載入一次共用協定#
`custom_tables` 與 `custom_tables_department` 兩個 job 共用同一份協定檔——它們的差別只在「哪些表在視野內」,協定本身從不指名 scope。同時啟用兩者的房間,每一輪都會收到整份檔案兩次;而且因為出現了兩段 prompt 文字,builder 還會把兩者一起降級塞進「Assigned Capabilities」外框:43,428 個字元,而單一 scope 只有 21,616,每個段落都重複一次。現在 `SimplePromptBuilder.add_job_prompts` 會跳過已經加入過的相同 prompt 文字(teamsync-backend PR #1193),因此同時啟用兩種 scope 的房間,拿到的就是單一 scope 那份、沒有被降級的單一段落;commands 協定因為是另一份檔案,仍然照常出現在旁邊一次。沒有路由、schema 或 ORM 變更,協定文字本身也沒有改動。
2026-09-04
5.8.4部門級動態授權(teamsync-backend PR #1127、#1135、#1141、#1147、#1153 與治理相關的 #1090–#1098,以 origin/master HEAD 6d8748b031816e19d6177644ec2245ba5542884b 合併,VERSION 5.8.4,staging 已上線)。現在一筆 grant 就能表達「業務看指派給自己的、主管看整個部門的」:新的帶標籤 principal 欄位型別、在其上解析成 principal SET 的 $me 與 $me.department、and/or/not row-policy 樹,以及沿 link 的動態授權。Agent 工具組新增 command 治理表、有上限的送達對帳視窗與兩條 root 檢視路由、聊天室解析與 principal 分組標籤。紀錄匯出不再回傳空內容。
新增 `principal` 欄位型別:一個帶標籤的 cell 指向使用者、社群客戶或聊天室#
`principal` cell 是恰好一個帶標籤的字串——`user:<id>`、`smc:<id>` 或 `room:<id>`——所以單一負責人欄位可以放內部使用者、社群客戶,或整個聊天室(該室成員都算被指派)。寫入閘門檢查租戶與存活(指派聊天室不需要成員身分);讀取回傳 `{ref, kind, id, name, …}`,而 `ref` 就是用戶端要寫回去的字串。`user` 與 `social_client` 逐位元不變,加工結果仍然沒有 `ref`/`kind`。`ColumnType` 現在共 19 個值。
以 `link_target` leaf 沿 link 動態授權#
Row policy 可以沿本表一個 link 欄位跨到被連結的表,對該列的 cell 做判斷:`{"link": …, "quantifier": "any"|"all", "target": {…}}`。沒有被連結列時 `all` 為空真,因此必須明確寫出 `require_present`;`any` 則不可帶它。只有未刪除的被連結列會被計入,且被連結表的 SCP channel floor 會一併 AND 進來,但它自己的 row 與 column ACL 不會套用——由 policy 作者決定要揭露什麼。上限為每條 policy 6 個 link leaf、只能一跳;無法解析的 link 會在編譯任何 SQL 之前就帶著 polarity 讓該條 leaf fail closed。
`principal` 欄位上的 `$me` 解析成 principal SET#
在帶標籤的 `principal` 欄位型別上,`$me` 不再是單一 scalar id,而是解析成一個 tagged cell 的 SET——呼叫者的 `user:<id>`,加上其所屬每個存活、同公司房間的 `room:<id>`——並透過既有的 `in` operator 編譯,因此一筆 department grant 就能同時涵蓋業務走 TS、LINE 或共用房間的情況。Social client 只解析成 `smc:<id>`、永遠不帶 room 腿,public-read token 則永遠不命中。SET 永不截斷:空集合、無法解析、或超過 1000 筆都會讓該條 leaf 帶著 `and`/`or`/`not` polarity fail closed。
`principal` 欄位上的 `$me.department` 解析成 department SET#
`$me.department` 現在可用於 `principal` 欄位,解析成呼叫者部門的每一位存活、同公司 user,加上該部門每一個存活、同公司房間。它以部門為範圍,而不是以成員身分為範圍:部門房間不管呼叫者有沒有在裡面都算。搭配 `$me` 就構成兩筆 grant 的模型——業務用 department grant `{負責人 eq $me}`,主管用 per-user grant `{負責人 eq $me.department}`——而且兩個 token 可以放在同一個 `in` list,編譯成單一個對其聯集的 `in`。
指令作者可自訂 agent 派送調解視窗的 TTL#
指令定義現在可以帶 `delivery_reconciliation_ttl_seconds`(60–604800,預設 900),並在每次執行時以絕對時間 `expires_at` 寫進派送標記,因此事後編輯定義不會回頭改動已提交標記的視窗。超過期限後標記會惰性到期:相同的 agent 呼叫不再被強制重播,也不再計入 16 個標記的調解容量,於是壞掉的確認路徑最多只能卡住一個 TTL,而不是永久卡死。不設定時採 omit-when-default 序列化,IaC diff 與 dependency contract 摘要都維持位元組相同。
兩個 root 端點可列出並釋放卡住的 agent 派送標記#
`GET /root/hotfix/custom-table-agent-deliveries` 列出 pending 或 expired 的標記,附上 `expires_at`、`in_window` 旗標、所屬指令與範圍,以及該列的存在時間。`POST …/acknowledge` 釋放指定的執行紀錄;`execute: false`(預設)只預覽逐筆結果、完全不寫入。要確認視窗仍然有效的標記必須加 `force: true`,因為那會取消仍在運作的 replay guard。兩個端點都不會回滾已提交的寫入。
受 command 治理的資料表拒絕原始 agent 寫入#
作者若把 `settings.agent_writes_via_commands_only` 設為 `true`,在跑著 command 通道的房間裡,只要該表是某個可見、已啟用 agent 的 command 的寫入目標,`custom_tables_insert_record`、`update_record`、`delete_record` 與 `bulk_record_actions` 就不再接受寫入,而是回 `{"error":"table_governed_by_commands"}` 並指向對應的 `custom_table_command_*` 工具;讀取、搜尋與分析不受影響。這道閘門在名稱解析之後、staged pin 之後才執行,因此 prepare、execute-staged 與 direct 三條通道都會經過它。列舉治理集合的動作 fail open,短暫的讀取錯誤不會變成整個房間的寫入中斷。
Row policy 支援任意 `and` / `or` / `not` 布林樹#
`read_filter` / `edit_filter` 現在是一棵布林樹——`{"and": […]}`、`{"or": […]}`、`{"not": node}`,或一個 predicate leaf——不再限於扁平的 `and` list,上限為深度 5、每條 policy 24 個 predicate。既有儲存的 policy 位元組完全不變、判定結果也完全相同,因此沒有任何 migration。`{"not": {col eq X}}` 是嚴格補集,包含空白 cell,而 `{col neq X}` 仍然排除它們;驗證錯誤訊息現在會帶節點路徑(`row policy or[1].not …`)。
`principal` 欄位的 filter 與 row policy 運算元必須帶標籤#
未帶標籤的運算元——裸 id、讀取回來的加工 dict、IaC `$user:` token 或 `client:` actor key——在 REST stored query、舊版 `filters` map、agent 工具組的 query/aggregate/traverse 驗證器、SCP 純量葉節點、rollup 與 lookup 設定 filter,以及 grant row policy 上一律回 400。否則它會編譯成靜默零列的 `eq` 或比對每一列的 `neq`。`name_eq` 與 `name_contains` 在此型別上被拒絕:請先解析 principal,再用它的 `ref` 過濾。所有 principal 欄位仍然不可排序;`group_by` 以整個帶標籤字串為鍵。
Acting room 會縮小 principal SET 的 room 腿#
在 agent session 或 compose command 內,acting room 會釘住兩個身分 SET 的 room 腿,優先序固定:`ScpDeny` 載體(trigger 執行、public callback 寫入)最優先且完全沒有 room 腿,其次是明確傳入的 acting room,再其次是已 stash 的 channel binding,都沒有時套用 REST 語意。被釘住的房間仍必須通過存活、公司錨點,以及 `$me` 的成員身分或 `$me.department` 的部門檢查,因此釘住永遠只會減少;keyword 與 binding 指向不同房間時,結果是沒有 room 腿加上一則 error log。
Direct 寫入通道才是文件上的預設值,拒絕文案也改為與通道無關#
`requires_confirmation_for_ct_action` 的預設值是 `false`,與 schema 和 `DEFAULT_CHAT_SETTINGS` 一直以來的宣告一致;tool loader 中兩處把「設定不存在」讀成 `true` 的 fallback 現在都讀 `false`,因此從未寫過這個值的房間不再表現得像開啟了 proposal 輪。`confirmation_unavailable` 的客戶文案不再說「確認保護」——direct 通道的房間沒有這個對使用者可見的概念——改成「寫入保護暫時無法完成安全確認,因此這次沒有變更資料」。
CSV 與 XLSX 紀錄匯出回傳 200 但內容是空的#
請求日誌 middleware 用一個「每次呼叫都回應」的 stub 換掉了 `request._receive`,破壞了每個 `StreamingResponse` 併行執行的斷線監聽器,response task group 因此在標頭已送出後被取消。兩種匯出格式都回 200 但 0 bytes。該 stub 已移除;匯出現在會串流已渲染的檔案,principal cell 也渲染成可讀形式(`room:<name>`、`<platform>:<name>` 或使用者姓名)。
分析 scope 不再把來源在 scope 外的計算欄位清成 null#
`custom_tables_analyze(table_ids=[…])` 是模型自己挑的委派聚焦範圍,因此它不再決定 rollup 或 lookup 的值能不能渲染:計算欄位的值授權與 `restricted` 標註改為查詢 principal 的完整即時權限表。deny-default 的形狀沒有變——讀取層級、row policy、SCP 底線與隱藏欄位照舊生效,真正讀不到的來源仍然是 null。Link 展開、host targeting、名稱解析、join 與 traversal 維持在分析 scope 內,`computed_filters` 與計算欄位的 `sort_column` 也是。
自訂資料表的節流不再毒化共用的 Redis Cluster client#
`execute()` 之後呼叫一次 `ClusterPipeline.close()`,會把共用 cluster client 的 default node 清成 null,於是該行程之後每一個 pipeline 都失敗,直到 pod 重啟為止——包含 staged 寫入提案、確認狀態與各種快取。這個 close 已從 passphrase 節流(每 client 每表 300 秒 5 次)與名稱解析節流(內部每 300 秒 30 次、外部 10 次)移除,並有回歸測試用「close() 之後每次 execute() 都壞掉」的假物件同時驅動兩個節流。
2026-08-27
5.8.1Schedule protocol V2(teamsync-backend PR #1058,以 origin/master HEAD 026132f2a8aecba48d2ea12da0bffbdc4ed69ef1 合併,VERSION 5.8.1,staging 已上線)。Schedule trigger 取得持久的 per-occurrence identity:date_column_reached 每個 (trigger, record, 正規化後的值) 只觸發一次,值改變時重新武裝;interval/daily 把錯過的視窗合併成一次補跑並且永不重疊;失敗的 schedule run 會消耗其 identity,並以同一個 run、依凍結的定義重試;在既有 id 下更改 schedule.type 會被拒絕。新增嚴格五欄位 cron schedule type,以及 moderator 專用、唯讀的 schedule preview 端點。遷移是 lazy 的,不需要任何維運步驟;唯一的部署規則仍是慣例的 migration-before-traffic。
date_column_reached 改為每個值觸發一次,而不是每筆 record 永久一次#
觸發 identity 是 (trigger, record, 正規化後的日期值)。同一個值無論 run 結果如何最多觸發一次;未見過的新值會重新武裝該 record;改回已觸發過的值不會再觸發。舊規則下已觸發過的 record,其目前的值視為已消耗,只有真正的值改變才會觸發。「已觸發集合」現在是持久的 occurrence ledger,刪除 run log 不再重新武裝任何東西。
interval/daily 合併錯過的視窗,且同一 trigger 不會同時跑兩個 run#
每個 clock trigger 都保有持久的視窗 cursor。停機後只會為最新一個到期視窗建立一次補跑,中間錯過的視窗不會重播。當該 trigger 的 run 仍在 pending 或 running 時,後續視窗不會被插入;run 進入終端狀態後,下一次 tick 最多插入一個。終端 failed 的 run 會消耗其視窗,tick 不會再重建它。在時段之後才建立的 daily trigger 仍會在當天觸發那個時段,與以前相同。
重試 schedule run 會執行 run 建立當時凍結的 trigger 定義#
每個新的 schedule run 都會儲存建立它的正規化 trigger 定義。重試會重新執行該 snapshot,仍會重新驗證目前的 schema、權限與目標是否存在,因此修改或刪除 live trigger 只影響未來的 run。本版之前建立的 run 沒有 snapshot,仍依 live 定義重試。當同一 trigger 仍有另一個 run 在執行時,重試 interval/daily/cron run 會回 409。
在既有 trigger id 下更改 schedule.type 會被拒絕#
當保留的 trigger id 在 date_column_reached、interval、daily、cron 之間切換時,REST PUT 與 IaC apply 會回 400 schedule_kind_change_requires_new_trigger_id。修改 schedule 的其他欄位會保留 id:date schedule 改變會重設輪替 cursor 但保留已消耗的值;clock schedule 改變會從新的 anchor 重新起算視窗。
schedule.type "cron":嚴格五欄位 expression,可選 timezone#
{"type":"cron","expression":"0 9 * * 1-5","timezone":"Asia/Taipei"}。欄位為 minute、hour、day-of-month、month、day-of-week,接受數值、*、清單、範圍與步進;星期日為 0;兩個日期欄位同時受限時採 POSIX OR。秒、macro、名稱、DOW 7、?、L、W、# 都是 400。春季前移時不存在的分鐘永遠不觸發,秋季回撥時重複的分鐘只觸發一次。cron 是 recordless:when 會被拒絕,$row token 與以列為目標的 action 會驗證失敗,而且永遠不會補跑 authoring 之前已過去的分鐘。
GET .../triggers/schedule-preview 說明 schedule 為何觸發或沒觸發#
三種 scope 都有的 moderator 專用、唯讀診斷。它執行 scheduler 自己的 evaluator,對每個 schedule trigger 回傳一個 arm:date arm 帶有下一頁 1,000 列 cursor page 內互斥的計數(missing_schedule_value、invalid_schedule_value、already_fired、scheduled_for_future、when_filter_mismatch、due_now);interval/daily/cron arm 帶有 last_consumed_window_at、due_at、due_now 與 active_run;損毀的儲存 trigger 會成為帶穩定 error_codes 的 invalid arm,不會遮蔽其他 trigger。它永遠不會建立 run,也不回傳任何 record id 或值。
record_limit 改為輪替的 cursor page,大表不再餓死新列#
每個 date_column_reached trigger 都保有自己的 cursor,依 created_at、id 排序。每次 tick 讀取 cursor 之後的 record_limit 筆實體 live 列(最多繞回一次),在該頁內判定已觸發的列,即使沒有到期也推進 cursor;整頁都沒有到期列時,同一次 tick 最多再往前推進 10 頁。因此每一筆 live 列都會被輪替到;最壞情況的偵測延遲是 ceil(live_records / (10 × record_limit)) 次 tick,而不是永遠不會。
清除被其他表的 exists/not_exists rule 指向的表不再失敗#
清除的 cascade 會剝除指向被刪表的跨表 rule,但那段程式引用了從未 import 的模組,因此任何這類清除都回 500 Database operation failed。import 已補上,cascade 可以完成;schedule state、trigger runs 與 occurrences 會在父列之前依明確順序移除。
2026-08-23
5.7.12這個 code-first checkpoint 將手冊同步到 teamsync-backend origin/master HEAD fec260e4ee78573fa1eaf6e857fca6a9bfc36e3d。該 commit 的 VERSION 檔仍是 5.7.12,那是 HEAD 上的檔案內容,不是發行版宣告。內容涵蓋 Custom Tables 在 chatroom、department、company 三種 scope 的全部 family。ETL tables 仍是另一個模組;本站只收錄附件上傳會重用的那一條 public multipart chunk 路徑。Agent 寫入改由聊天室設定 requires_confirmation_for_ct_action 決定(先 prepare 再 token-only execute,或鎖定的 direct policy);每一則 Custom Table 工具結果都帶 customer-interaction tip;每張表最多 50 個 trigger;generated trigger chain 有預算;live source 列超過 1,000 時 materialize_slots 會 fail closed。
Agent 寫入改為 prepare 後 token-execute,或在 direct policy 下鎖定執行#
聊天室設定 requires_confirmation_for_ct_action(從來不是 tool argument)決定 roster。房間把它設為 true 時,預設第一輪只暴露 custom_tables_prepare_*;自然語言 proposal 真正可見後,後續肯定答覆才暴露對應的 custom_tables_execute_staged_* 或 custom_table_command_execute_staged_action,schema 只有 {token}。客戶仍然不必輸入 32 位 hex token。旗標為 false 時只暴露直接 business-argument 工具,並在 READ COMMITTED、FOR UPDATE 與保留的 direct-policy fence 下立即執行。每一則 Custom Table ToolMessage 也以同一句 customer-interaction tip 結尾。這份契約取代 2026-08-18「同一工具、相同 business arguments、模型路徑無 token」的指引。
live source 列超過 1,000 時 materialize_slots 會 fail closed#
每次 tick 仍最多插入 1,000 個時段列,插入截斷仍算成功。但 live 班表/覆寫列超過 1,000 時,現在會在插入任何時段之前失敗,錯誤為 materialize_slots source row limit exceeded; archive unused source rows or split the template table before retrying。每條 chain 的 generated child runs 預算也是 1,000。
每張表最多 50 個 trigger;run 以原子方式認領 pending#
REST 與 IaC 的 MAX_TRIGGERS_PER_TABLE 現為 50(rules 仍是 10)。At-least-once worker 以原子 pending→running 更新認領 run,同時送達的重複會在任何 action 前退出。Chain 在 depth 3 與 1,000 generated runs 停止;record-write action 最多針對 100 列。Webhook URL 與 header 值仍受 2,000 字模板上限約束,header 最多 20 個,且禁止空白/冒號/CR/LF 名稱。
2026-08-20
5.7.9這個 code-first checkpoint 是直接查核已合併的 runtime code、route mounts、schemas 與 tests 後,將手冊同步到 teamsync-backend origin/master 的 32f532089bd90bbaaaf7e49f921e53c2ec62372f(version 5.7.9),並未把 PR body 當成契約。內容包含 interval 欄位、排程規則、saved-view 版型、lookup fallback、原子性 publish session、Sandbox writeback、confirmation recovery、command dependency refresh、擴充 grants,以及 REST/IaC/OpenAPI parity。PR #980 另外釘住 exact-scope command closure、完整的 own-scope refresh 分組、caller-scope tenant gate、依序取得的 multi-scope locks,以及仍存在的 concurrent-authoring fast-path 視窗。
IaC table name 上限為 64 字元,伺服器也會公開完整 line contract#
IaC table name 上限現在與 ORM 一致,為 64 而不是 100 字元。Table moderators 可接受並匯出 raw user UUID 或 $user:<username>;$dept、$smc 與 $room token 在此仍不合法。三種 scope 都掛載 GET .../tables/iac/contract,讓 OpenAPI 帶出完整 discriminated IacLine union;apply 也完整記錄文件層級的 400、404、409 與 422。
Schema 新增 interval,達到 18 種 column type 與 13 種 rule type#
interval 以 canonical {start,end} minute-datetime object 儲存,且 end 必須嚴格晚於 start;它支援 overlaps、contains_point、presence filters 與依 start time 排序,但不屬於 scalar、key、formula、lookup、rollup、grouping 或 toolkit-query lane。新的 count_limit rule 可限制同表或 matched foreign table 的符合列數;no_overlap 也可用 date-days/datetime-minutes 表示 min_gap,並能以單一 interval_column 取代分離的 start/end 欄。
Saved view 增加 matrix/timeline 版型;lookup 增加有界 defaults-table fallback#
Saved view 現在可為 list、matrix 或 timeline,並有精確的 kind-specific config gate;backend 會保存與重新驗證 layout,record read 維持一般 response shape,由 frontend 負責渲染。Cardinality-one lookup,或帶 pick 的 many lookup,可宣告一段 fallback:目標必須是同公司、相同或更廣 scope 的 table,以一到兩組 equality pair 配對後讀取 target column。Fallback 僅供 evaluation,絕不跨越 permission denial,也不能用於 computed filter 或 sort。其 page-wide distinct-probe budget 為 min(5000, max(50, page rows × fallback 欄位數));budget 耗盡時會記錄 warning,未執行的 probe 呈現 null。不存在的 primary 可取得 default,而被拒絕的 primary 維持 null,因此保留一個刻意接受的 one-bit residual signal;唯一 target 若已 soft-delete,則視為未連結。
部門與公司資料表可用一次原子性 flip 發布 staged dataset#
Moderator 可為每張表開啟一個 24 小時 session,把最多 5,000 筆 create-shape rows 暫存在可安全重試的 seq 0 到 63 slot,再非同步 commit。Staged rows 沒有 read path,也不接受 caller 提供的 record id。Commit 會重新授權 owner、限制 optional replace set 並套用 ACL floor,最後在同一 transaction soft-delete 與 insert;at-least-once delivery 只會 replay 同一張 ticket,不會重複 flip。
聊天室與部門擁有的 Sandbox job 可取得 run-scoped Custom Table writeback credential#
Published task version 可宣告 output_policy {kind: custom_table_writeback, table_id, allowed_ops: create|create,update}。Chatroom-owned job 只能在精確的 owner room 鑄造;department-owned job 則可在 owning department 的 room 鑄造。只有互動式 JWT user 可鑄造,且 author 與 submitter 權限都會重查。Company-owned policy 會在 publish 時被拒絕;owner audience 以外的 borrowed run 則永遠不會鑄造。Secret 只注入該次 run,並在每個 terminal transition 撤銷。Agent submission 必須走明確兩輪確認;submit_sandbox_job 則拒絕任何帶 output_policy 的 version,因此 trigger inventory仍精確維持十種 action type。
公司資料表補齊 department/client grant parity,public query 也公開 request schema#
Department grant 現在可用於 department 與 company table;direct client grant 及 passphrase client-access 設定則掛載於 chatroom、department、company 三種 scope。Public token query route 現在會在 OpenAPI 公開具封閉 grammar 的 PublicQueryRequest,而 raw request body 在 parsing 前仍受 16 KiB 上限約束。
Schema、rule、reference 與 purge mutation 會更新 selected、原本 valid 的 commands#
支援的 dependency-changing mutation 現在會更新 selected、變更前仍 valid 的 commands,涵蓋 column/schema edit、rules、cross-table reference、purge 與 repair path。原本 stale 或同時被修改的 definition 絕不會復活。一般 mutation refresh 與 business change 同 commit、採 best-effort,無法 compile 的 command 保持 stale;trigger replacement 仍保有更強的 atomic veto 與 rollback contract。Command dependency contract 採 exact-scope-closed:table 本身可以合法向上 link 或套用 rule,但 command 的 transitive closure 若觸及另一個 scope,authoring 會以 `A referenced table does not exist in this scope` 失敗。因此 multi-table purge/repair 可依 rewritten table 自己的 scope 分組,同時讓每個 participating scope 都收到完整 mutated-table set。Caller 與 table scope 不符時是 inert tenant gate,不做 command probe 或 lock;multi-scope prepare 則依 company → department → chatroom 順序鎖定參與的 roots。仍存在的 lock-free no-dependent fast path,代表 concurrent authoring 仍可能依 mutation 前形狀編譯並落成 stale。
Affirmative turn 不再卡死於 expired 或 completed mutation confirmation#
若 affirmative 抵達時 proposal 已遺失、過期、取消或從未 visible,伺服器會重新呈現一份 fresh inert PREPARED proposal,該回合完全不寫入。相同 affirmative 在 mutation 完成後重送只會回 already_completed,不會再次執行;若要刻意重複同一 business change,使用者必須明確重述,再確認新的 proposal。
2026-08-18
這個僅代表 source 的 checkpoint,把 2026-08-17 的 merged-source 批次與 PR #955 整併為同一份最新紀錄。它以伺服器擁有的自然語言與 durable-delivery 契約取代模型可見的 Custom Table confirmation token、維持 realtime voice 唯讀、把受信任的 agent command delivery 綁定到其 channel,並原子性更新 trigger-dependent commands,搭配明確的 IaC drift 與 lock-conflict code;同時保留前一批的 bounded Analyst toolkit、逐表 staged-change 額度、sandbox-job/attachment trigger actions 與 Review Centre response 改善。以下項目只描述已合併的後端 source,不代表 staging 已部署,也不代表真實 LINE/Meta delivery 已驗證。
Agent 異動確認改為自然語言、由伺服器擁有並感知 delivery#
這個項目取代 2026-08-17 的 token migration,而目前的整合指引已由 2026-08-23 的 agent-staged-token-execute-and-direct-policy 取代。歷史上,insert、update、delete、bulk 或 write-command 的第一次呼叫只送精確 business arguments,且完全不寫入。伺服器只渲染一份有界的自然語言 proposal;該 proposal 確實 durable-visible 後,後續明確且無歧義的確認才以相同 business arguments 呼叫同一工具,並由伺服器解析綁定 tenant、conversation、channel、actor 與 group 的 proposal。使用者可見訊息永遠不帶 proposal/receipt JSON、內部或結構性 UUID、伺服器持有的 digest、confirmation reference、token 或 tool lifecycle 資料;但外觀為 UUID/digest 的 business value 仍可能作為一般資料顯示。異動與 durable receipt 在同一 transaction 提交;回應或 delivery acknowledgement 遺失時會 reconcile 原本的執行,不會鑄出第二個副作用。
Direct realtime voice 的 Custom Tables 改為唯讀#
OpenAI 與 ElevenLabs direct realtime session 保留 instructions、Analyst reads、record lookup、link-schema lookup 與已授權的 attachment viewing;row insert/update/delete/bulk、attachment write-input discovery、permission/passphrase mutation,以及整個 Custom Table Commands roster 都會被隱藏,因為直接串流的模型音訊沒有伺服器可驗證的 later-turn confirmation boundary。一般文字聊天仍保留自然語言兩輪寫入流程。
受信任的 agent command execute 綁定 channel 與 LINE group scope#
隱藏的 server-operator execute route 現在必須帶 X-TeamSync-Agent-Channel。X-TeamSync-Agent-Group-Scope 是 64 字元小寫 SHA-256 digest,只有 line_group 與 line_room 必須提供,其他 channel 反而不可提供。這些由伺服器注入的值會與 principal identity 一起進入 idempotency 與 pending-delivery context;header 是否存在與 channel 不符時回 400 invalid_agent_group_scope,缺少或格式錯誤的 typed header 則是 request-validation error。
中繼的 token-based confirmation checkpoint 已被取代#
這個永久 anchor 記錄 2026-08-17 merged-source 的中繼契約,但不是目前的整合指引。PR #955 已從模型可見的 pending-confirmation result 移除 opaque confirmation reference/token 與由模型轉送的 command digest;模型仍會收到經 allow-list、無 token 的 proposal/instruction ToolMessage,而使用者可見回覆則強制為有界自然語言。那份 2026-08-18 流程本身已由 2026-08-23 的 agent-staged-token-execute-and-direct-policy 取代。
第十種 trigger action 可提交已授權的 sandbox task version#
submit_sandbox_job 只適用於 created 與 updated 事件。設定會釘住已發布且啟用、無需確認的 task version、同公司 live chatroom、有上限的 timeout,以及從不可變事件 snapshot 解析 $row 樣板的 JSON-object input。觸發時會重新載入造成事件的 principal,並在建立 run 前重查 room、task、table、row、hidden column 與 channel-scope 權限。
send_channel_message 可傳送附件欄,並以可續傳的 part receipt 記錄進度#
Created/updated trigger 可指定最多五個不重複的附件來源欄,並從事件 snapshot 解析最多二十個同公司 blob。外部 channel 會把每個 blob 當成獨立 part;平台與內容型別支援時使用 provider-native media/file 訊息,只有 fallback 或不支援的形式才改送含永久公開 blob URL 的文字。Agent delivery 則把文字與 blobs 一起保存。每個完成的 part 都有 receipt,因此重試只接續未完成部分,不會重送已完成的 part。
Trigger 編輯會原子性更新原本 valid 的 dependent commands#
REST trigger PUT 與 IaC trigger apply 會鎖住 command scope 與 table,挑出編輯前 stored contract 仍然 valid 的 dependent commands,安裝候選 trigger set,並在同一 transaction 重新編譯那些 commands。原本已 stale 的 command 仍保持 stale,而且每條失敗分支都會回滾該行 bundle。在 REST 上,locked-graph 重新驗證或非資料庫的 compile/refresh failure 是結構化 409 command_dependency_refresh_failed;MySQL 1205/1213 是另一種 flat-detail 409,其他 database operational failure 則是淨化後的 flat-detail 500。成功 refresh 也會因 definition digest 改變,使編輯前尚未完成的 agent command confirmation 失效。
IaC 明確分類 trigger plan drift 與可重試 lock 衝突#
IacLineError.code 現在接受 iac_plan_stale 或 retryable_lock_conflict。Trigger 的 before-fingerprint 在 plan 與 apply 之間改變時,會回 iac_plan_stale,detail 為 trigger changed after planning; run plan again。Per-line 或 line-0 maintenance boundary 遇到 MySQL 1205/1213 時回 retryable_lock_conflict;其他行可能已經執行,因此必須取得並審查 fresh plan 後才能再次 apply。同一文件中的 command fingerprint 會使用伺服器 refreshed dependency digest,不會只因前面的 trigger 行更新它而被誤判 stale。
Toolkit V2 讓探索與分析具備界限、明確範圍、evidence 與即時授權#
custom_tables_get_instructions 現在是可分頁的 roster,可用 table_ids 或 name_contains 聚焦;custom_tables_analyze 接受有上限的明確資料表集合;custom_tables_lookup_link_schema 只回安全組合寫入所需的兩個 opaque link 欄位。分析結果以 ok/partial/denied/failed 搭配 evidence、warnings 與 allow-list reason codes 回傳。每次會讀取資料或指向特定資料表的頂層呼叫,都會重新整理帳號、tenant、room 與 table roster;委派操作在執行時還會再次檢查 row、column、ACL 與 channel-scope 權限。靜態的 custom_tables_cheat_sheet 不會開資料庫 session,也不會授予任何權限。
Staged-change actor 額度改為每位 actor、每張表 500 筆,不再是跨表共用 10 筆#
某張繁忙資料表上的待審工作,不再吃掉同一 actor 在其他資料表的額度。獨立的全表 pending-create 額度仍是 50;staged_cap_exceeded 也仍會回結構化 scope 與 limit,client 應直接呈現這些欄位,不要硬編碼任一數字。
Review list 增加相對於呼叫者的 facet;process detail 增加可渲染簽名#
GET /private/module/review/processes 接受 role=requested|assigned,每筆 list summary 也會回 requested_by_me 與 assigned_to_me。只有 requester_id 能在呼叫者公司內解析成使用者時,才會展開顯示名稱與部門資料;否則 requester 為 null,包括送審人已無法在該 tenant 解析的歷史真人流程。另一條 GET /private/module/review/processes/{process_id} 會回 custom-table approval 的在地化送審人/資料表脈絡,且已決定的 assignment 可在 signature_blob_id 之外內嵌 signature_blob,讓 review UI 不必另查 blob 就能渲染簽名圖片。List summary 與 decision POST response 都不含這個內嵌 blob object。
2026-08-02
Acting room 從「你指名的東西」變成「你本來就是的東西」。REST 現在把每一張受治理的部門表,從「授權你這張表的所有房間」聯集解析——acting_chatroom_id 參數、它的 400 重試階梯與 403 雙生錯誤全部刪除,還在送這個參數的 client 會被靜默忽略。周邊還有:部門表有了自己的 department-permissions 介面與兩條不帶 scope 的探索路由、command 多了 agent_enabled 開關並且不再允許不引用任何資料表、agent toolkit 在讀取面追平 REST 並補上寫入,以及第十二種規則型別——在資料列符合前像條件時鎖住欄位。
?acting_chatroom_id 從所有 REST 路由消失——現在是無聲忽略,不是拒絕#
五十七個 custom-table 操作移除了 acting_chatroom_id query 參數:36 個部門的 table/record/view/history/staged-changes/import 操作、15 個 blob 與 attachment 操作(5 條路由分別掛在裸的 /chatroom、/department、/company 三個掛載點下),以及 6 個 command execute/query 操作。帶 candidates 陣列的 400 acting_room_required 階梯、403 acting_room_unsatisfiable 交集檢查、兩個公司層的 422 守衛,以及 403 "This table is readable and writable only through a chatroom channel; you hold no chatroom grant on it.",全部連同它們的 response schema 一起刪除。還在送這個參數的 client 會拿到 200 與一模一樣的 body——FastAPI 會丟掉未知的 query 參數,所以它指名的房間既不會被拒絕、也不會被採用,而且沒有任何訊號告訴你它已經失效。
手動 command execute/query 不再綁定 acting room——acting_room_unsatisfiable 整族刪除#
POST /{scopeWithId}/commands/{command_id}/execute 與 /query 在三個 scope 上都不再接受 acting room。跨表的 set.intersection 候選房間交集已經移除,所以 403 {"error": "acting_room_unsatisfiable", "tables": [...]} 與 400 {"error": "acting_room_required", "candidates": [...]} 都不可能再發生,acting_room_unsatisfiable 也從 execute 的錯誤 union 中拿掉。指令計畫觸及的每一張 channel 受管資料表,改為各自從呼叫者身上解析自己的 floor,所以呼叫者搆不到的那一張表,會以它自己的寫入 floor 403 scp_scope_undeclared 或 scp_out_of_scope 浮現。受信任的 agent 通道仍由伺服器從已驗證的 service principal 推導房間。
引用不到任何資料表的 command 被拒絕,存量的零表 command 全面隱形#
編寫一個編譯後相依集合為空的 command,現在在建立與更新兩條路徑上都回 422 {"error": "command_references_no_table", "message": "a command must reference at least one custom table in this scope"}。空的引用表集合讓 command 介面上每一道權限閘門都變成恆真:任何租戶使用者都能寫一個只有 let 或只有 callback 的 command,然後讀它、改它、刪它、執行它、讀它的執行快照,並透過它的 callback 帶著呼叫者身分把資料送出去。這些閘門現在全部 fail closed,所以閘門之前存下的資料列不會出現在任何列表,而且在 GET、PUT、execute、query 與兩條 executions 路由上對所有呼叫者(包含公司層管理者)都回 404。
省略的 optional input 不能再放寬 match 條件,也不能藏在 $case 裡#
在 v1 定義裡,match 條件內綁不到值的 optional input 現在會讓執行失敗,回 400 step '<name>': optional input cannot omit a match key。把該鍵丟掉在 data 上是對的(表的預設值會接手),但在 match 上等於無聲放寬了 update/delete 的資料列選取條件,讓破壞性寫入打到比作者預期更多的資料列;where 本來就是 fail closed。另外,$case 裡任何位置省略 optional——某個分支的 then,或是 else——都會回 400 step '<name>': optional input cannot omit a $case result,data 與 match 兩種脈絡皆然。分支選擇是稍後對著鎖定的 pre-image 才發生,所以「這個分支綁不起來」必須是錯誤而不是猜測:先前的做法是在未被選中的分支綁不起來時把整個鍵丟掉,欄位停在舊值,執行卻仍回報 succeeded。
讀不到的 select 來源或 join 目標回 403,不再是 200 加一個空關聯#
v2 program 的 select 來源與每一個 join 目標,現在都和它的寫入目標一樣嚴格地受權限管制:讀不到的那一張回 403 Read access not granted for this table.。先前 row-ACL 層會把 can_read: "none" 編成一個永不匹配的條件,於是 /query 回 200 加一個空關聯、agent 答「no matching records」;在寫入型 program 裡,先讀後寫的去重步驟看不到符合的資料列,就插入了一筆重複資料。這道閘門刻意比相依契約窄:只用來 insert 的目標表可以合理地是 can_read: "none" 搭配 can_insert: true,而被相依契約拉進來的 link 或 lookup 表在讀不到時仍然渲染成只有 id 的 stub。
agent 與 trigger 的指令通道把部門表權限釘在所在聊天室#
v1 plan 與 v2 program 的權限解析器現在會接收 acting room,並把它轉交給 resolve_effective_permissions。在受信任的 agent 與 trigger 通道上,部門層資料表的授權被釘在所在聊天室、當成硬天花板,與 agent 的表格工具一致:以前綁在某間房的 agent 指令會折疊呼叫者所屬所有房間的 REST 聯集,等於可以借用另一間房的授權,動到這間房從未被授權的資料。手動 REST 明確不帶 acting room,維持以使用者為中心的聯集,所以同一個指令走 REST 合理地會比走 agent 觸及更多資料列。
覆核閘門 condition 裡的未知 key 回 422,不再被無聲丟掉#
三個 condition 節點 model——group leaf、user leaf 與 and/or 節點——都宣告了 extra="forbid",所以 condition 樹裡任何位置的未知 key 都會在業務驗證之前,以標準的 422 envelope(type: "extra_forbidden")擋下請求。{"type": "user", "user_id": "<uuid>", "mode": "all"} 現在直接被拒絕:user leaf 沒有 mode。以前那個多出來的 key 會被丟掉、請求回 200,於是呼叫者會以為自己送出的限制生效了,實際上從來沒有。文件記載的形狀不受影響、仍可原樣往返,未知的 type 值也仍由 discriminated union 擋成 422。
部門表有了自己的 department-permissions 端點——部門授權以前只有 IaC 寫得進去#
把某個部門的存取權授予另一個部門的資料表,以前完全沒有 REST 端點。聊天室那組端點用 chatroom id 綁定資料表,而部門層級的表 chatroom_id IS NULL,所以唯一的寫入途徑是 principal.type 為 "department" 的 IaC grant line,也沒有任何介面列得出這些授權列。現在部門 scope 下多了四條路由——列表,以及以 target_department_id 定位的授權、更新與撤銷(叫這個名字是因為 {department_id} 已經是資料表自己的 scope)——全部由 table manager 依賴把關。POST 會解析目標部門;目標必須存在於 caller 的公司,否則回 404 Department not found。PATCH 會先要求 grant row 存在(404 Department permission not found),再執行相同的 company-anchored target validation;因此 missing-grant 的 404 precedence 保持不變,而已移出該公司的 target 仍會被拒絕。read_filter、edit_filter 與 visible_columns 會被接受、寫入,resolver 也真的照做,但 DepartmentPermissionResponse 沒有這些欄位——列表永遠不會回吐它們。
settings.default_permissions.audience:"company" 讓部門表對全公司每個部門開放#
default_permissions 以前只對 in-scope 的主體生效,所以要把部門表擴大到整間公司,就得為每個部門各建一列部門授權。現在 payload 收 audience——"scope"(預設)或 "company"——在部門表上填 "company" 會讓同公司的每位使用者都算 in-scope,並同時取得通過 membership door 的鑰匙。其他地方一律拒絕:default-permissions 通道(只有 PATCH,沒有 PUT)回 422 settings.default_permissions.audience "company" is only valid on department-scoped tables;建表通道收的是原始 settings dict,回 400,訊息是同一句或 settings.default_permissions.audience must be "scope" or "company", got '<value>'。只有 "company" 這個值會被存下來,所以之後一次沒帶 audience 的 PATCH 會無聲地把分享收回。
shared-with-me 列出從你自己 scope 之外分享給你的資料表#
所有列表路由都掛在 scope 之下,被授權者又無從得知是哪個部門或哪間聊天室分享給自己,於是在呼叫任何 by-id 路由之前,table id 只能靠人工遞過來。這條不帶 scope 的新路由會列出可透過四種授權之一抵達的活資料表——個人授權、給呼叫者所屬部門的授權、透過自己所屬的活聊天室取得的 internal 授權,或帶 audience:"company" 的部門表——每一筆都標出 sources(依字母排序),以及 resolver 算出的 my_permissions。skip 與 limit 分頁(limit 上限 200,scope 列表則是 1000),total 是分頁前的存活筆數。缺口是刻意留的:它是互補而非涵蓋,所以自己部門的表、公司層級的表、自己所屬房間的表都不會出現;沒有建立者/moderator 這一臂,跨部門 moderator 拿 id 進得去卻永遠不會被列出;而 can_read:"none" 的拒絕列會讓資料表在這裡隱形,儘管 membership door 仍然放行。
用完全相同的名稱解析出 table id,橫跨公司與部門 scope#
get_current_user 就是全部的把關:沒有角色檢查,路徑上也沒有 scope 段。查詢橫跨呼叫者公司裡的公司層級與部門層級資料表,回傳 resolver 判定 can_read != "none" 的那一筆,附上 scope、department_id、department_name 與 my_permissions。比對是逐位元組精確的——name 欄位是 utf8mb4_bin,所以 registry 與 Regist 都比不中名為 Registry 的表——而 uq_custom_table_name_per_company 同時涵蓋兩種 scope,最多只會有一筆命中,因此沒有 tie-break、也沒有優先順序。所有落空都是同一顆 404 {"detail": "Table not found"}:不存在、讀不到、在垃圾桶、別家公司的、聊天室層級的,看起來一模一樣,不會開出存在性 oracle。它之所以存在,是因為公司列表路由是 role>=3 的硬閘門,而 shared-with-me 又刻意排除公司層級與自己部門的表——一張讀得到的表,它的 id 可能不出現在呼叫者搆得到的任何列表上,這也是前端以前逐環境寫死 id 的原因。
immutable_when 是第十二種 rule:以資料列的 pre-image 鎖住欄位#
文法是 {"type": "immutable_when", "name"?, "prior_when": [≤5 個 predicate], "columns": [≤20 個欄位]},而 prior_when 是對資料列的 PRE-image 求值——這補上了其他機制補不了的兩個洞。transition 規則看不見一次讓 Status 維持 posted、卻偷改 Qty 的寫入;而其他所有 rule 的 when 都跑在 post-image 上,於是 {"Status": "draft", "Qty": 6} 在同一個請求裡解鎖又改值,條件從頭到尾沒有命中過。它不收一般的 when(送了就是設定期 400),只鎖 inline 儲存的欄位,也只在 update 與 revert 上執行。違反時回 400,detail 是一整串 Rule '<name>' violated: 'Qty', 'Price' cannot change while the row matches the lock condition (prior_when evaluates the row as it was before this write)——所有被改動的鎖定欄位會用逗號併進同一句訊息,請不要去 parse 它。在掛有相符 require_approval 規則的表上,這道鎖在 stage 階段不會否決(寫入在 rules engine 之前就先被 stage),而簽核 apply 的重播會特地跳過 immutable_when,所以這道鎖的意思是「沒人能靜悄悄改掉它」,不是「永遠不能改」。
同步的 records/bulk 收 expected_version——不符就整批回滾#
每個 entry 只能在 action:"update" 上帶 expected_version(整數 ≥ 1);insert 或 delete 帶了就是 422(expected_version is only valid on update actions — an insert has no prior version and delete carries no data to guard)。這是最後一條完全沒有版本護欄的寫入通道:model 禁止未知的 key,所以以前送這個欄位會 422,而批次裡一筆過期的 update 會無聲蓋掉併發的贏家。因為這批是原子的,答案不是 2026-07-28 release 記載的非同步通道那種逐筆跳過——而是 409 {"error": "version_conflict", "message": "Action i: record was modified concurrently (current version X, expected Y); the atomic batch was rolled back — re-read and retry.", "action_index", "record_id", "current_version", "expected_version"},而且所有動作一起回滾。在掛有相符 require_approval 規則的表上,原子呼叫者拿到的是另一顆 409 {"error": "record_changed_during_staging", "message": "a target record changed while the batch was being staged; nothing was staged or executed — retry", "record_ids": [...]},沒有 action_index、也沒有版本號,所以別只認 version_conflict。同一批裡較早的動作若動到同一列會先把 version 加上去,後面帶護欄的動作就會 fail closed。
agent_enabled 把 command 從 AI 手上收走,REST 完全不受影響#
存下來的 command 以前沒有對 AI 的可見性開關:只要通過 tag 與資料表讀取權檢查,就會被載入成 agent 工具,想讓 AI 碰不到它只能刪掉或拆掉 tag 綁定。現在建立與更新都收 agent_enabled(布林,預設 true),會被寫入,並在每個 command 回應裡回吐——單筆 GET、列表項目,以及建立/更新/刪除的回應。設成 false 會把它從工具清單移除,而受信任的 agent 通道每次呼叫都會重新檢查這個旗標,因此在一個進行中的 session 裡早已綁定的工具會 fail closed,回 404 Command not found,與不存在的 id 逐位元組相同——這條通道不洩漏存在性訊號。REST 毫無變化:它仍然列得到、取得到、執行得了、查詢得了,旗標照實回傳 false。PUT 是整份取代,所以一次沒帶這個 key 的更新會把它重新開啟。
outputs[].description 以 Outputs: 區塊進入 agent 的工具描述#
scalar 與 relation 兩種 output 形式都收一個選填的 description,上限 1024 字元。在此之前 definition.outputs 根本不會送到模型面前——output 名稱只以 key 的形式出現在執行/查詢的回應 body 裡,AI 只能從名字猜它是什麼意思。現在每個宣告的 output 都會被附加到 agent 工具描述的 Outputs: 區塊,一個 output 一行:- <name>: <description>,沒寫描述時就是單純的 - <name>。這個欄位預設是 null、未設定就省略,所以既有的定義與 IaC 文件都能逐位元組穩定往返;要注意的是,明確寫成空字串會被當成沒有,不會留進編譯後的儲存內容。
truncated 標示出重播的是被 audit 上限裁掉的快照#
當一次大型執行的回應快照被裁到 65,536 位元組的 audit 上限時,裁切標記只存在於儲存的快照裡。同 key 的冪等重播會透過一個沒有這個欄位的 model 重建回應,旗標因而被丟掉:呼叫者拿到的 body 少了宣告的 outputs 與 step 資料列,卻沒有任何辦法把它跟「真的沒有結果」區分開。現在 CommandExecutionResponse 帶有 truncated,只在 execute 通道的重播路徑上出現——那條路徑會原封不動回傳儲存的 body。執行歷史那兩條路由是用固定的欄位清單組出 body 的,不含這個欄位,所以那裡永遠不會出現——在那些路由上,裁切標記只留在巢狀的 response_body 裡。欄位為 null 時會省略,所以沒被裁切的執行回應與從前完全相同。
Agent 工具箱在相對日期、計算欄位與全文搜尋上追平 REST#
query_records 與 aggregate_records 現在可以在 date/datetime 欄位以及 created_at/updated_at 上使用 within_last、within_next 與 older_than,邊界由伺服器時鐘透過 REST 編譯器自己的 helper 算出,而不是另寫一份。query_records 另外拿到 computed_filters——rollup、cardinality 為 one 的 lookup,以及單純算術/比較的 formula predicate,最多 3 條,與 filters、any_of、q 以 AND 結合——可用計算欄位的 sort_column,還有上限 200 字元的全域 q 文字搜尋,對所有可見的 string/text 欄位以 OR 比對。兩項計算欄位能力在活資料列超過 50,000 筆時一律拒絕,而且這道護欄是在任何 predicate 編譯之前先讀資料表的活列數,所以就算用一般 filters 收窄也解不開——儘管拒絕訊息聽起來像可以。IF/AND/OR 的 formula 仍然不能篩選也不能排序。
aggregate_records 可以用 cardinality 為 one 的 link 欄位分組#
group_by 裡只要出現 link 欄位就會撞上一體適用的 non-queryable 護欄,所以「每個客戶的成交總額」得對每個目標各跑一次 traverse_links,或是自己手動彙總。現在 cardinality 為 "one" 的 link 欄位是合法的 group_by key:資料列會透過一個帶著 SCP/ACL floor 的相關子查詢,依其活的目標 record id 分組。分組的 key 是 id 不是名稱——名稱請自己回目標表解析。NULL 這一組刻意把三種情況混在一起:這列沒有連結、目標被軟刪、或被 floor 擋住。要區分它們就等於把一張受管控資料表的 id 列舉出來,所以這種混淆是設計本身,落在 NULL 底下的總計並不代表資料缺失。cardinality 為 "many" 的 link 仍然被拒絕,現在的訊息會直接點名 cardinality。
AI 拿到了 upsert、樂觀鎖,以及 50 個動作的原子批次#
custom_tables_insert_record 收 match_column:恰好命中一列就在 row lock 下更新那一列,沒命中就插入,命中多列則拒絕這種語意不明的 upsert,回應會帶 "upsert":"updated" | "created",更新分支還會附上 version。「dora 在就更新、不在就新增」以前是先找再寫的兩段式操作,中間有競態窗口,找不到時就多長出一列重複資料。custom_tables_update_record 收 expected_version(≥ 1),在 row lock 下驗證,不符時回 {"error": "version_conflict", "current_version", "expected_version"},不再覆蓋掉 AI 從沒看過的那次編輯;在 2026-08-02 當時,不帶這個參數就維持 last-writer-wins。PR #955 之後收窄了 agent write 的這句話:省略它仍讓「較早讀取→提案」窗口沒有 guard,但確認層一定會釘住提案當下的 record version,並拒絕後續 drift。新工具 custom_tables_bulk_record_actions 一次套用最多 50 個 insert/update/delete 動作,全有或全無,只註冊給同時具備 insert 與 edit 的內部主體——批次只帶 changed_by、沒有客戶身分,外部客戶的批次會讓建立者歸屬無處可放——而 own 與 filtered 範圍的編輯者,每一列都會重跑一次編輯權限檢查。
Gate 條件可以直接指名一個人——不必再開一人群組#
條件樹以前只接受兩種節點:組合節點與 group leaf,所以要指名一個人當簽核者,得先開一個只有一位成員的覆核群組再引用它的 id。現在 union 多了 {"type": "user", "user_id": "<36 字元 uuid>"}(沒有 mode key),可以單獨當成整個條件,也可以掛在任何 and/or 節點底下,與 group leaf 自由混用,範本 gate 與行程內嵌 gate 都適用。被指名的人會成為一人份的虛擬選舉人團,拿到恰好一筆 assignment,其 group_ids 是 []——內部的鍵值永遠不會流到客戶端。同時被 group leaf 與 user leaf 指到的人仍然只有一筆 assignment,只列出真實的 group id,而他投的一票對他所代表的每個 leaf 都算數。若 user_id 不是呼叫者公司裡的有效使用者,回 422 review users not found or inactive: ['<user_id>', ...]。
command line 收 agent_enabled——可 diff、apply 會寫入、預設值不會出現在 export#
IaC 的 command spec 多了 agent_enabled(布林,預設 true),文件因此可以發佈一個在 REST、IaC 與 triggers 上完全可呼叫、卻被排除在所有 agent 端查詢之外的 command。它和 name、description 一樣參與 desired/last-applied/live 的三方欄位比對,所以在文件裡翻動它會 plan 出恰好一次 command update,changes[] 會點名這個欄位;而在 IaC 之外用 REST 翻動它,會出現在 drifted_fields。Export 採預設即省略——只有被隱藏的 command 才會寫出 "agent_enabled": false——所以在這個欄位存在之前撰寫的系統,匯出結果仍逐位元組穩定,早於此欄位寫下的 state row 也會重新 plan 成 noop、drifted_fields 為空。陷阱就是同一條省略規則反過來讀:重放一份較舊的文件,會無聲地把某人用 REST 隱藏起來的 command 重新曝光。
IaC 拒絕在部門 scope 之外使用 default_permissions.audience:"company"#
table line 的 spec.settings 以前是逐個 key 併進線上資料表、完全不驗 default_permissions.audience,而逐行解析器沒有 scope 上下文——所以在聊天室層級的文件裡寫下 "company",會被原樣存成一段看起來像全公司分享、resolver 卻不理會的 settings。現在每個非 absent 的 table line 都會在 validate 階段對照文件自身的 scope 檢查:違反時是該行的驗證錯誤,訊息為 settings.default_permissions.audience "company" is only valid on department-scoped tables(或 settings.default_permissions.audience must be "scope" or "company", got '<value>'),plan 會回報 applyable: false。executor 在套用 settings 變更時會對線上資料表自己的 scope 再檢查一次,所以直接 apply,或拿已 stale、不再與 live state 相符的 plan 來 apply,也繞不過去;在那裡它是該行的 apply 錯誤。這個 key 會經由 export 完整往返,所以名正言順帶著它的部門文件重新 plan 會是 noop。
在強制的 channel SCP 下,REST 解析的是你被授權的所有聊天室的聯集#
REST 的每一條 custom-table 通道現在都只帶著呼叫者本人,並逐表解析 channel floor:該表的管理者豁免,否則 scope 是呼叫者所屬、且對該表持有 internal 授權的每一間「活著的」聊天室的 scope_values 聯集,聯集為空即拒絕。同時在 A、B 兩房的成員一次請求就讀到 A ∪ B——以前多房成員會直接解析成拒絕、什麼都看不到。拒絕在讀取端是沉默的:list/search/aggregate 回 200 零筆、單筆 GET 回統一的 404、列表的 record_count 為 0。拒絕在寫入端則有代碼:聯集為空時回 403 {"error": "scp_scope_undeclared", "message": "no chatroom of yours declares a scope on this table"},聯集非空但不涵蓋目標時回 scp_out_of_scope。scope_values 為 null 或空的房間不貢獻任何值,軟刪除的房間會退出聯集,而聯集可以合法地超過 MAX_SCOPE_VALUES 的 200 個值——那始終只是單一授權列的撰寫上限。
權限解析第 4 步改為合併所有聊天室授權,不再排名後只留一列#
第 4 步以前會把呼叫者的 internal 授權列排名後只留一列,同分時先比 granted_at、再比 chatroom_id——於是透過 A 房拿到北區、B 房拿到南區的使用者只看得到其中一半,而決定權來自他觀察不到的 tie-break。REST 現在逐軸摺疊所有符合資格的列:can_read/can_edit 取最高等級,平手時 filtered 確定性地優先於 own;can_insert 取 OR;有貢獻的 filtered row filter 則合併——只有一個相異 filter 時原樣沿用,多個時包成 {"or": [arms]},相同的 filter 會去重回到裸的單一 arm。這個聯集形狀只在解析時產生:從不被撰寫、從不被儲存、從不進入 agent 通道,而且執行時逐 arm fail-closed,某一房為空或帶未解析 token 的 filter 不會把另一房合法授權的資料列一起遮掉。撰寫端的文法完全沒變——所有寫入介面仍然只接受 {"and": [predicates]},送出 {"or": …} 依舊回 'row policy must be an object of the form {"and": [ predicates ]}'。agent 通道仍然釘住 session 的房間,不做任何摺疊。
多房摺疊後的 visible_columns 可能比任何單一房間授權的還窄#
摺疊時會為每一列有貢獻的授權算出一個 coverage key——(can_read, read_filter, can_edit, edit_filter, can_insert)。當所有有貢獻的列 coverage 完全相同,欄位白名單取聯集,而其中只要有一列沒有白名單就整個解除上限,因為「沒有」代表不受限。當 coverage 不同——filter arm 相異,或等級混雜——已宣告的白名單改成取交集,沒有白名單的那一列視為全集而退出交集。理由是列×欄的相關性:只有 A 房授權的那些資料列上,不該冒出 B 房才有的欄位;而且同一份白名單也管住寫入,這裡給多了同時是讀取洩漏與寫入提權。少給是刻意選擇的安全近似,所以在兩間 row filter 不同的房間裡的使用者,最後可能看到比任何一房單獨宣告的還少的欄位。
同一個身分在 REST 與 AI session 裡得到不同答案,這是刻意的#
REST 回答的是「你是誰」——你被授權的所有房間的聯集。agent toolkit 回答的是「這個 session 在哪一間房」:它在建立時蓋上一次該聊天室的 binding,之後每一張**部門層**資料表都只用那一房的授權列解析,既是釘選也是天花板;那一房沒有授權列就是無權存取,不管是誰在問、表管理者也一樣,結果再逐軸與使用者自身權限取交集。聊天室層與公司層的表沒有聊天室授權列,不受這道天花板限制。兩個介面現在刻意不對稱,而且沒有任何參數能重現 agent 的切片——已經沒有參數可以釘房間了。同一個身分同時在 room_north 與 room_south,透過 HTTP 列出 4 筆、在 room_north 的 session 裡只有 2 筆:「API 顯示 4 筆,機器人卻說 2 筆」是預期行為,不是缺陷。
新增資料表 moderator 改以資料表的有效公司為錨點,不再看路徑上的 scope#
目標使用者現在在每個 scope 都改用 resolve_table_company_id(table) 解析出的有效公司驗證,而不是路徑上的 scope id,所以同公司但不在擁有該表的部門裡的使用者,也能被指派為部門層資料表的 moderator——以前那是硬性的 404「User not found or not in this department」。這個家族只掛在部門與公司兩個 scope,兩句 per-scope 訊息收斂成同一句 404 {"detail": "User not found or not in the same company"}——真正改掉的只有部門那一句,所以只有比對部門字串的客戶端會看到訊息變了。它 fail-closed:有效公司解不出來時整段查詢直接跳過並丟出同一顆 404,誰都加不進去。沒有變的部分:路由仍然掛在 CustomTableModeratorRequired 上,重複新增仍是 400「User is already a moderator」,而移除完全不帶成員資格條件——跨部門或 IaC 產生的 moderator 列都移除得掉。
execute/query 的 400/409/422 除了原本的區域 union 也宣告 CommandProgramError envelope#
POST .../commands/{command_id}/execute 的 400、409、422 現在宣告 anyOf[<原本的區域 union>, CommandProgramErrorResponse],/query 則是 400 與 409——它的 422 本來就是這個 envelope。線上行為沒有改變:compose executor 一直都會用這些狀態碼丟出 CommandProgramError,只是形狀從未被文件化,照著 spec 產生的 client 解不開 {"detail": {"error", "phase", "step", "path", "message", "retryable", "details"}}。請以 detail.error 分流,絕對不要以狀態碼分流——同一個 409 既可能是區域 union 的 approval_required,也可能是 envelope 的 command_lock_conflict。details 一定存在(可能是 {}),step 與 path 則經常缺席,phase 是 author|execute|stage|release|callback 其中之一。隱藏的 agent 通道沒有跟著放寬。
Command 與 input 的 description 上限提高到 1024 字元#
compose command 的 description 從 512 字元、每個宣告 input 的 description 從 256 字元,一起提高到 1024,REST 的 request/response model 與 IaC 的 command spec 都一樣。舊上限太緊,不足以向 agent 與 REST 呼叫者說明這個 command 到底在做什麼——而 command 的 description 正是最後變成 agent 工具描述的那段字。超長仍然是標準的 422。
PUT records/order 回 400 並點名 slot,不再是原始的 uq_table_sort_order 409#
reorder 的衝突預檢以前只查活的資料列,於是被軟刪除資料列佔住的 sort_order 會通過探測,寫入到 flush 才炸成 409「Duplicate value '<table_id>-<slot>' violates unique constraint 'uq_table_sort_order'.」——一個資料庫形狀的答案,既沒點名請求裡出問題的那個 slot,也沒給出空的 slot,而且無從查起,因為被刪掉的資料列在每個讀取介面都是隱形的。探測現在涵蓋所有資料列,所以這一類輸入會在任何寫入落地之前撞上既有的 400「sort_order values [...] already used by other records in this table」。這個 400 字串本身不是新的;新的是會走到它的輸入類別。探測之上的資料列存在性檢查刻意仍然只看活的、在 scope 內的資料列——reorder 的兩趟 bulk UPDATE 不會觸發資料列寫入 floor,那道 SELECT 是唯一的 scope 守門。
審核者資格對齊驗證邊界——未驗證與已過期的帳號不再算數#
現在有單一共用述詞同時管住建立時的驗證與啟用時的快照:未刪除、未停用、verified 為 true,而且未過期——除非 role 是 -2,因為 get_current_user 也豁免它。群組裡沒有任何成員通過這個述詞的 gate,建立時就被 422「review groups have no active members: ['<group_id>']」擋下;混合群組則把不合格的成員整個排除在快照之外——沒有 assignment 列、沒有 review_assigned 通知,all 模式的法定人數也縮到真的能行動的那一群。這種 template 以前會以 200 通過驗證、gate 1 啟用,然後替 get_current_user 會用 401「Account not verified yet」或 451「Account expired」拒絕的人開出選票,流程永遠卡在 in_review,唯一的出口是取消。群組成員資格本身沒變:POST /private/module/review/groups 仍然只擋已刪除的使用者,過期成員照樣出現在 member_ids 裡。另外,每個 gate 的 leaf 上限訊息少了一個字——「condition has {n} group leaves, exceeds max 20」變成「condition has {n} leaves, exceeds max 20」——因為 user leaf 現在與 group leaf 一起計入同一個上限 20;它是包在標準 422 envelope 的 detail[].msg 裡,不是裸的 detail 字串。
部門授權終於打得開部門層級資料表的成員門#
部門層級資料表 by-id 介面前的那道成員門以前只認兩把鑰匙——使用者本人的 grant 列,以及透過自己所屬的活聊天室取得的 internal 聊天室 grant——而權限解析器一直都認部門列。於是「部門有授權、人不在該部門」的使用者,授權列在、解析得出真實層級,每一條讀取與資料列路由卻都回 403 {"detail": "Insufficient permissions for department-scoped table."}:解析器放行、門口擋下。現在這道門多了一支以 user.department_id 為鍵的部門臂。權限仍由解析器決定:can_read: "none" 的部門列會開門,然後在讀取時用它自己那顆不同的 403 擋下;角色達到 CAN_MANAGE_COMPANY 的呼叫者本來就繞過這道門,不受影響。
明確的 moderator 列同樣打得開部門資料表那道門#
同一類「解析器放行、門口擋下」的問題,只是高一層:users_custom_tables_association 裡的明確列會讓解析器直接短路成 is_manager: true,但那道門根本不認識它。角色低於 CAN_MANAGE_COMPANY 的跨部門 moderator——現在能從 REST 指派,過去也一直能用 IaC 造出來——在整個 by-id 介面上都拿到 403 {"detail": "Insufficient permissions for department-scoped table."}:資料表詳細、資料列、views、欄位、匯出、staged changes 全部。現在 moderator 列本身就是一把鑰匙。角色達到 CAN_MANAGE_COMPANY 的跨部門 moderator 從來沒被擋過,行為不變。
跨公司的殘留 moderator 列不再能授權 schema、rule、trigger 或授權寫入#
管理者 dependency 一找到明確的 moderator 關聯列就立刻返回,部門/公司的租戶錨點只跑在後面的預設管理者路徑上。於是一個被設為資料表 moderator、之後調到別家公司的使用者,仍然握有該表的 schema、rules、triggers、授權、預設權限與欄位 ACL 寫入權——掛在這個 dependency 上的每一條路由都繼承了這個洞。現在有效公司(走 resolve_table_company_id 解析,而不是可為 NULL 的 CustomTable.company_id 原值)會在 explicit-moderator 捷徑之前先錨定,並回 403 {"detail": "Insufficient permissions. (Different Company)"}。同公司的 moderator 不受影響;有效公司完全解析不出來的資料表仍然通過這一項檢查。
踢人與離開回了 200,成員列卻還在#
兩條路由都回 200,訊息是 "Successfully kicked member from the chatroom" / "Successfully left the chatroom",卻沒有為成員關係本身提交任何東西:暫存的移除被權限撤銷 helper 內部的鎖定重讀丟掉,user_chatroom_association 列因此存活——正式環境有一間房連踢四次,成員還在名單上。授權列與 moderator 列確實被刪了,但前成員仍握有進入該房自訂資料表的成員門,資料列路由照樣回 200。現在暫存的移除會在那些重讀之前先 flush:一個 200 之後,前成員在 table-id 資料列路由上拿到 403 {"detail": "Not a member of this chatroom."},在該房的資料表清單上拿到 403 {"detail": "Insufficient permissions, not a member (Required department manager)."}——除非還有不依賴成員身分的授權,或 CAN_MANAGE_DEPARTMENT/CAN_MANAGE_COMPANY 的角色後備仍替他開門。
被 SCP 藏起來的連結目標在 REST 寫入時回有歸因的 403,不再是裸 404#
連結目標存在、卻落在呼叫者頻道範圍之外的寫入,在任何攜帶裸 principal 的 REST 通道上都會掉進統一的 404,補救資訊整個消失——而在每條 REST 通道都改為以使用者為中心之後,這等於讓所有 REST 連結寫入都失去歸因。現在兩種 principal 載體都會歸因:403 {"error": "scp_out_of_scope", "message": "the link target exists but is outside your channel scope"};若目標資料表的 floor 解析為 deny,則是 403 {"error": "scp_scope_undeclared", "message": "no channel scope is declared for you on the link target's table (grant your room scope_values, or act through a room that has them)"}。兩段訊息都改寫成不綁通道的說法,不再提「acting room」。真的不存在或已軟刪的目標,以及被 row ACL 藏起來的目標,仍然回統一的 404——不給預言機的規則沒有變。
record_count 改為呼叫者的聯集,部門資料表詳細頁也不再回 400#
在受治理的部門資料表上,清單通道對任何有一個以上候選聊天室的人都解析成 deny,於是部門資料表清單對呼叫者明明讀得到的資料表回報 record_count: 0——而同一個人、同一張表,詳細頁卻回 400 acting_room_required。現在兩條通道放的是同一個載體,並且逐表以相同方式解析:該表的管理者豁免、拿到完整筆數;否則就以呼叫者所有被授權聊天室範圍的聯集來計數;只有在完全沒有被授權的聊天室時才是 0——與被遮蔽的詳細讀取一致。這個數字仍然不會超過同一個呼叫者在詳細端點看得到的內容,而詳細頁回 200。
殘留的 sort_order 位置不再讓每一次寫入都撞上 uq_table_sort_order 409#
uq_table_sort_order 的唯一性是 (table_id, sort_order),範圍涵蓋整張資料列表、包含已刪除的列,但每一處「下一個位置」的計算都只看未刪除的列。在刪除開始把位置設為 NULL 之前就被軟刪的列——或被 migration 原樣搬過來的列——仍然佔著一個位置,於是只看活列的 MAX 讀少了,寫入監聽器發出一個已被佔用的位置,寫入就死在 409 "Duplicate value '<table_id>-<slot>' violates unique constraint 'uq_table_sort_order'.",而且是該表的每一次寫入都死,是必然而非偶發;現場的樣子是一個新開的部門一筆資料都建不起來。現在五處「下一個位置」的計算都涵蓋與約束相同的資料列範圍:寫入監聽器、records/bulk、records/bulk-insert、測試資料寫入,以及 restore 的 max+1 後備。健康的資料表完全不受影響——每條刪除通道都會把 sort_order 設為 NULL,而 MAX() 會忽略 NULL。保證的範圍是刻意窄的:被佔用的位置不會再被重複發出,但刪除仍然會把那個整數釋放給之後的寫入重用。
非同步 bulk-update 的單筆錯誤帶的是拒絕內容,不是框架的 repr#
背景工作對每一筆只捕捉裸的 Exception,於是以 HTTPException 失敗的資料列——SCP 拒絕就是 HTTPException——被 str(e) 渲染成 FastAPI 的 repr:一個以 "403: {'error': 'scp_out_of_scope', ...}" 開頭的字串,狀態碼被黏在最前面,而且與 upsert 迴圈對同一種拒絕寫出的內容不一致。現在會先捕捉 HTTPException 分支,只附上 detail 內容,與 upsert 迴圈一致;任何在解析那個字串、或在剝掉 "403: " 前綴的 client 都必須修改。它在 errors[] 裡仍然是 Python repr 字串、不是結構化物件,而以裸 Exception 失敗的資料列沒有變。
一筆壞掉的 command plan 不再讓整頁 staged-changes 回 500#
每一筆 command_plan 的 staged 列都會為 moderator 渲染,而格式壞掉或屬於舊版的 payload——沒有 frozen_program 的第 2 版 payload、plan 不是清單的第 1 版 payload——會在渲染器裡丟出一個裸 ValueError。它沒有被接住,所以一筆壞列就讓整個清單請求以 500 收場:moderator 看不到、因此也無法丟棄該表上的任何 staged change。現在渲染被包起來了。清單回 200,健康的列保留原本的 payload,壞掉那一列的 payload 變成 {"error": "unrenderable_staged_payload", "message": "This staged command plan is malformed or predates the current payload format and cannot be rendered; it can still be discarded."}——該列仍然看得到、也仍然丟得掉。分頁、排序與逐呼叫者的可見性規則都沒有變。
核准計畫釋出時遇到鎖錯誤會重新排隊,不再把計畫丟掉#
釋出已核准的第 1 版計畫時,任何例外都被當成終局,於是一次暫時性的 1205 鎖等待逾時或 1213 死結,就永久丟掉一份人類已經核准的計畫。現在它會 rollback、把執行留在 staged,並在一般的 apply 嘗試預算下把 staged change 重新排回簽核掃描;原因記成正規化 JSON 信封 {"details":{},"error":"command_execution_failed","message":"Approved command release should be retried","phase":"release","retryable":true}。另外,只要不可重試的失敗在 orig/__cause__ 鏈上任何一層帶有 SQLAlchemy 成因,原因就會被換成安全字串:過去原始的 [SQL: …] [parameters: {…}] 驅動文字會被寫進 staged_change.error 與客戶端讀得到的執行快照,而那些綁定參數可能包含凍結計畫時被重新注入、ACL 本來藏起來的欄位值。
已提交的 succeeded 或 staged 執行不會再被改寫成 failed#
執行路徑在同一個 try 裡先 commit、再 refresh 執行列。如果 commit 其實成功了但回應遺失,或是 commit 之後的 refresh 丟出例外,控制流就掉進失敗分支,把已提交的列覆寫成 failed——這會毀掉 response_body 裡的重播快照,於是客戶端用同一把 key 的冪等重試會重新執行指令,而不是重播已存下的終局結果。現在失敗標記會重新載入該列,若狀態已是 succeeded 或 staged 就原封不動回傳;commit 之後的 refresh 也搬出 commit 的 try、放進自己的區塊,失敗只記錄不擴散。呼叫端仍然拿到原本那個錯誤。
決定閘門結果的那張同意或否決票不再被蓋成 obsolete#
決議端點回的信封是對的,但造成該次轉換的那張票被寫成 status: "obsolete"——於是流程詳細把做決定的審核者列在 gates[].assignments[] 底下顯示為 obsolete,comment、decided_at、signature_blob_id 都還在,決議卻被抹掉,而稽核紀錄仍然寫著 decision_approved/decision_denied。兩份紀錄對「是誰結掉這道閘門」說法不一致。負責讓未使用票作廢的掃描跑的是一次 bulk UPDATE,而它看到的仍是尚未 flush、狀態還是 pending 的那一列;現在會先 flush,只有真正還在 pending 的票會被翻掉。所有結掉閘門的票都受影響——單一審核者的閘門、any 模式的核准者、all 模式的最後一位核准者、否決掉閘門的那個人——取消流程也有同樣的保證:obsolete 現在確實只代表「沒有投票,閘門在他之外就結束了」。
IaC parse 會實際量測 JSON 巢狀深度——超過 128 層括號的行回 400#
唯一的巢狀防護是包在 json.loads 外面的 except RecursionError,而現代 CPython 根本不會觸發它,於是一顆巢狀炸彈會被完整解析、通過 schema 驗證、深拷貝並排進 plan,才輪得到任何更窄的上限。現在 parse 會自己數括號巢狀,逐一實體 JSONL 行、在 json.loads 之前就數:上限硬寫成 128,不是 CUSTOM_TABLE_IAC_MAX_* 那組可調參數;量測範圍是整行、包含行本身的外層信封;掃描會辨識字串字面值與跳脫字元;判斷是嚴格大於——128 通過,129 中止並回 line {n}: excessively nested JSON structure (max depth 128)。它是每行的第一項檢查,所以在過深的行上會搶在 invalid JSON、header 必須是首行且唯一的規則,以及 record/line/table 上限之前;只有文件層級的位元組上限與 UTF-8 解碼仍跑在更前面。原始 malformed apply 會在 dispatch 前同步被拒絕。若文件已被非同步通道接受,之後在 worker 強制 reparse 時才失敗,poll body 會以 body.status: "failed" 與 body.error.code: "parse_error" 回報同一段訊息。
2026-07-28
永久刪除成為租戶端的正式兩步驟操作;一批「靜默給錯答案」的缺陷改為大聲失敗。資料表與整個 tag 系統可以走 preview–ticket 同意流程 purge,非同步批次更新支援樂觀鎖,未知的 request key 全面改回 422 而不是被丟掉,而對系統 id 欄位的篩選終於真的比對得到資料列。
租戶 purge:preview → 一次性 ticket → 核准執行,單表或整個 tag 系統#
永久刪除不再需要 root 操作員。preview 會算出完整爆炸半徑——資料列、外部表會被剝除的連結欄位與 rules、saved views、IaC state rows、將被刪除或變殭屍的 commands——並發出綁定呼叫者與當下結構、效期 15 分鐘的一次性 ticket。execute 無論成敗都燒掉 ticket;preview 之後任何結構漂移會回 409 purge_preview_stale。tag lane 一次銷毀所有成員表、該 tag 的 commands(名字立即釋放)、其 IaC state rows 與 tag 本身;成員表掛著別的 tag 是 fail-closed blocker。
非同步 bulk-update 的 item 支援 expected_version,在 row lock 下比對#
每個 update item 可以帶 expected_version(整數 ≥ 1),語意鏡射單筆 PUT。不符的 item 會被跳過,並在輪詢的 ticket 裡回報:status 為 completed_with_errors,errors[] 內含 {"error": "version_conflict", "expected_version", "current_version"},error_count 精確,逐筆明細上限 100 筆。提交回應在結構上仍是 200 + ticket——非同步通道不可能回 per-item 的 HTTP 409。沒帶欄位的 item 維持 last-writer-wins;bulk-delete 不收這個欄位。簽核閘門下,版本衝突的資料列不會被 stage,HELD 批次在 apply 時洗不掉這道 guard。
未知的 request key 全面改回 422,不再無聲丟掉騙你成功#
十五個 request model 補上 extra="forbid":aggregate 請求與其 metrics、四個 bulk payload 及其內層的單筆 action/update item model、search body(expand_links 屬於 query string、不是 body)、saved view 的建立與更新、export、欄位建立與更新,以及 view config。這些以前全都回 200、同時無聲忽略未知 key——對 aggregate 送 stored_filters 等於整個查詢沒有過濾就跑完。已存在、帶著雜 key 的 view 仍然讀得到(讀取側寬鬆剝除未知 key);IaC 的 view apply 則逐行驗證 key。
設定裡引用 id 的存量 config 從無效變成生效#
同一個幽靈 id 缺陷也藏在存量設定裡:rules 的 exists/not-exists where 條件、rollup 與計算欄位的 filter、row policy、upsert 的 match-by-id,以前全部無聲比對不到任何東西。現在它們對準真正的主鍵——rules 開始擋寫入、row policy 開始給出可見範圍、upsert match-by-id 真的會比中而不是無聲插入重複列。請盤點任何提到 id 的 rule、policy、rollup filter 或 upsert 設定:你從未見過的行為現在會被強制執行。撰寫端則在 id 撐不起語意的地方直接拒絕:SCP 的 scalar leaf 與 no_overlap 的 start/end/date 欄位。
非正規的日期時間篩選邊界回 400,不再是悄悄偏移的邊界#
日期與日期時間儲存格是正規字串(YYYY-MM-DD、YYYY-MM-DD HH:MM)、以字典序比較,所以帶秒數——或帶斜線——的邊界會讓 eq/lte/gte 悄悄偏移。現在邊界跟其他通道一樣先驗證:in 逐項驗(而且必須是清單)、between 驗兩端與個數。存了非正規邊界的 saved view 或 IaC spec,讀取時會拿到 400 而不是悄悄偏移的結果;請修正存下來的邊界。真正的 created_at/updated_at 邊界不變,那裡的秒數仍然合法。
鎖等待逾時第一次發生就回可重試的 409#
伺服器行程內的重試現在只涵蓋死結(1213)。鎖等待逾時(1205)在浮出水面前已經把資料庫的整個等待窗口堵滿,行程內再重跑等於把那段等待放大約三倍——超過多數 client 與 proxy 的時限。它現在第一次發生就回同一個可重試的 409。你的 client 該做的事完全不變:兩種情況仍是暫時性、沒有任何部分資料落地、請重試的契約一字不差——只是伺服器不再霸著你的連線慢慢重試那個慢的。
對系統 id 欄位篩選現在真的找得到資料列#
客戶端真正拿在手上的 id 是資料列的主鍵,但查詢通道把 id 條件編譯到 data blob 裡的幽靈 uuid 上——於是 id 篩選回 200 卻永遠 0 筆、以 id 排序毫無意義、count::id 只是碰巧等於列數。現在所有讀取通道都對準真正的主鍵。id 支援不透明識別碼的運算子集合——eq、neq、in、is_null、is_not_null——排序或子字串運算子用在 id 上是 400;legacy filters dict 收精確 id(字串或清單);對 id 的 aggregate 指標只有 count/count_distinct。
併發的 settings PATCH 不再無聲互相覆蓋#
以前每個 settings PATCH 都是整包 read-modify-write、完全沒有鎖:兩個寫入者改不同的 key,兩邊都拿到 200,晚 commit 的把早的那個 key 蓋回去——收緊成「僅管理者可見」的欄位 ACL 可能無聲退回全員可見。現在六個 scope 端點(三個 scope 的 default-permissions 與 column-acl)、聊天室 client-access 通行碼設定、IaC executor 的 settings 寫入,全部改在資料表列鎖下合併單一 key;schema 寫入端在同一把鎖下重讀,加欄位撞上 settings PATCH 也蓋不掉 column_mapping。代價:競爭時 PATCH 可能等鎖、然後回可重試的 409,而不是回一個假的 200。
附件 complete-upload 遇到鎖衝突不再毀掉整個上傳#
以前在寫入 blob 資料列時遇到暫時性鎖衝突,會直接走進補償路徑:已完整上傳的物件被刪掉、session 被銷毀、呼叫端拿到 500。現在補償前先辨識衝突——物件與 session(24 小時 TTL)都保留,回應是標準的可重試 409,拿同一個 session 重呼 complete 就能把上傳收尾。complete 同時具備冪等性:儲存層已經組裝完物件時,重試會確認並回傳 blob,而不是拿著已被消耗的 upload id 再失敗一次。
表刪了之後留下的 command 刪得掉了,而且刪掉的 command 會釋放名字#
刪除一個表已經先被刪掉的 command 以前會失敗——權限檢查試圖解析那張已不存在的活表。現在兩條刪除路徑(REST 與 IaC)在表不在時改用 command 自身的 scope 判斷權限;沒有權限的呼叫者看到的仍是同一顆不可枚舉的 404。另外,command 名稱唯一性現在只計活列:刪掉的 command 名字立即可以重用。更新仍要求活表——PUT 是整份重編譯,殭屍更新沒有意義。
AI session 讀部門表用的是所在聊天室的授權,不是你最好那間房的#
同時在 A、B 兩間房的使用者,以前可以在 A 房的 AI session 裡用 B 房比較寬的授權讀部門表——而回答會渲染進共享聊天室,等於把資料列、欄位甚至整張表洩漏給從未被授權的聽眾。現在 agent toolkit 解析部門表時,所在聊天室的授權列既是釘選也是天花板:該房沒有授權列就是無權存取,不管是誰在問、表管理者也一樣;結果再逐軸與使用者自身權限取交集,個人較窄的限制仍然生效。REST 端點、聊天室/公司層級的表、commands toolkit 都沒有變。
直接授權外部客戶現在會同時結案他的申請、並主動告訴他#
從成員權限畫面直接授權,以前只寫入權限,申請單永遠掛在待審、客戶也沒有任何通知——對話裡最後一句話停在「請等待審核」,agent 就一直拒絕客戶其實已經有權做的事。現在管理者授權會同時結案該客戶的待審申請,並推播訊息說明他現在能做什麼。同一次追查一併修好:LINE 群組/聊天室客戶以前根本送不出申請、新開房間的申請送達零個審核者、部門表的申請完全跳過管理者通知,以及拒絕無聲——現在會把審核者的 review note 一起送給申請人。
JSON body 裡的裸 NaN/Infinity 回 422,不再是 500#
Python 的 JSON 解析器接受裸的 NaN/Infinity 字面值,但預設 422 會把出錯的值原樣塞回嚴格 JSON 回應——那一步會炸掉,於是任何驗證器拒絕這種輸入的端點都以 500 收場。現在的全域 handler 保留標準 {"detail": [{loc, msg, type}]} 形狀,只把嚴格 JSON 編不出來的葉子字串化。一般驗證錯誤與過去逐位元組相同。
2026-07-27
「撞在一起」從伺服器錯誤變成有文件的正常結果。在資料庫層相撞的寫入會由伺服器替你重試,真的傳到呼叫端的那一種衝突是有型別、可重試的 409,排程觸發器動作不會再因為一次衝突就把工作丟掉,而錯誤內容也不再夾帶 SQL 語句與參數。
command_lock_conflict:競爭到的指令是可重試,不是壞掉#
執行通道會用資料列鎖把授權房間序列化,所以併發呼叫本來就會競爭。這種情況以前會變成 500 command_execution_failed、retryable 為 false,等於告訴呼叫端「這個指令壞了」。現在它回 409 command_lock_conflict、retryable 為 true,而且同一個冪等鍵可以立刻重用,因為失敗路徑會釋放預約。
寫入在鎖競爭中落敗會回 409「請重試」,不再是 500#
MySQL 死結(1213)與鎖等待逾時(1205)被視為暫時性狀況。擁有自己 transaction 的寫入路徑會以抖動退避重試三次,所以大多數競爭根本不會傳到你這邊。三次都輸時,請求會回 409 並附上「Concurrent write conflict (lock); please retry the request.」,而且沒有任何一半的資料落地。涵蓋的路徑包含建立資料表、欄位異動、資料列寫入、批次 sort-order、授權寫入與觸發器執行器。
排程動作不會再因為一次衝突就把工作丟掉#
排程觸發器動作只要遇到一次暫時性鎖衝突,run 就會在第一次嘗試被記成失敗,而業務動作被無聲丟掉;在每日排程上,那天的工作等於沒做。現在動作會在 run 內重試,有上限也有抖動。它之所以安全,理由和續跑相同:進度逐 action 保存,已完成的 receipt 會依穩定 action id 跳過。
AI 通道現在真的跑得動公司層的指令#
所有透過 AI 工具呼叫的公司層 v2 指令都會失敗,回 422「acting_chatroom_id is not applicable to a company-scoped table」:路由本來信任呼叫端,但重新準備存取時把這個判斷弄丟了。現在這個信任跟著已準備好的存取一起走,同時也修好了「AI 暫存的公司層指令通過核准後放行」以及「觸發器呼叫公司層指令」這兩條路。
競爭中的授權寫入不再回一個光禿禿的 500#
授權寫入路徑完全沒有鎖處理,所以它在取列鎖時遇到死結,呼叫端拿到的是 {"detail":"Internal Server Error"}:沒有重試提示,連清理過的訊息都沒有。現在它和其他寫入路徑一樣會重試,重試用盡則回可重試的 409。
錯誤內容不再夾帶 SQL 語句與參數#
驅動錯誤轉成字串時會帶出 SQL 語句與其參數,而有三條面向客戶端的路徑原樣送出,包含客戶端會輪詢的批次工單 error_message。現在客戶端看得到的訊息會在單一收斂點清理:暫時性衝突變成 please-retry 的 409,其他都變成「Database operation failed.」,細節留在伺服器日誌。
2026-07-25
這是本站建立以來最大的一次更新。複合指令帶來跨表交易寫入,新增三種欄位型別(json、user、social_client),資料表可以唯讀公開給匿名讀者,row policy 有了每次請求才解析的動態代號,觸發器動作從五種變九種,JSONL IaC 從九種行類型變十二種,而本手冊過去描述的那些部署開關都已刪除:這些能力不再需要「開啟」。其中四項會改變你既有的行為,請先看那四項。
Row policy 不再匹配空白儲存格#
「filtered」授權在 SQL 端過濾資料列的邏輯已修正,與 Python 端一致。值運算子(eq、neq、範圍、in、contains)不再匹配空白儲存格;空白包含缺少該鍵、SQL NULL、明確的 JSON null,以及空字串。因此用 neq $me 寫成的「不是我的」條件,不再回傳負責人欄位從未填寫的資料列。
觸發器的寫入改用觸發者的權限執行#
當使用者的寫入觸發了一次執行(created、updated、deleted、restored),該次執行的 create_record、update_record、delete_record、materialize_slots 動作會以那位使用者自己的 ACL 執行,而不再擁有完整系統權限。由排程觸發的執行仍保有系統權限。歸屬不變:資料列仍記為觸發器作者所建立。
撤銷使用者授權會回傳撤銷後的權限#
三種範圍的撤銷端點都不再只回傳成功訊息,而是回傳 RevokePermissionResponse:使用者撤銷後的實際權限,加上 widened_access 與 warning,前後狀態在同一個鎖內解析。移除一個「收窄」用的授權,有可能反而讓使用者看到更多資料列,現在回應會直接告訴你,而不是讓你自己踩到。
附件的 MIME 與大小限制真的會擋下寫入#
allowed_mime_types 與 max_file_bytes 以前只是裝飾。現在上傳時是建議性檢查,寫入資料列時則是決定性檢查,且所有寫入路徑都適用;S3Object.size_bytes 會在 multipart complete 時實際量測,並出現在 AttachmentInfo 上。
複合指令:多張表的寫入放進同一個交易#
複合指令是一段儲存起來、有名字、有標籤、可帶參數的程式:它在同一個資料庫交易裡跨多張表寫入,並且用呼叫者自己的權限執行。三種範圍都有完整的 CRUD、執行端點與執行稽核紀錄,另外有一條給 AI 代理用的 service token 通道。執行是全有全無,冪等鍵依呼叫者分桶並可持久重播,而任何被引用的 schema 有變動時,整次執行會 fail closed,不會做一半。
查詢型指令:唯讀、可用 cursor 分頁#
版本 2 的指令若完全沒有寫入步驟,可以宣告 mode: "query",改由專屬路由呼叫:不上鎖、不寫稽核紀錄,用不透明 cursor 分頁,page.limit 上限 100。用 execute 呼叫查詢型指令,或用 query 呼叫寫入型指令,兩邊都會得到 409。
json 欄位型別#
json 儲存格把一份任意結構的文件直接放在資料列裡,序列化上限 64 KiB、深度 32、節點 8192,任何深度出現非有限數值都會被拒絕。它刻意不是純量型別:彙總、查找、公式、unique 規則、row policy、group_by 與 CSV 匯入都會明確拒絕它。REST 只支援等值過濾、不支援排序;AI 工具則可以走 json path、算長度、依葉節點分組,還能剖析結構。
user 與 social_client 欄位型別#
身分欄位存的是一個原始 id:同租戶內的使用者,或聊天室屬於該租戶的社群客戶。寫入只接受 id,不接受顯示名稱;讀取會解析成顯示物件,解析不到的 id 就保留原字串。所有介面都只支援等值比較,且不可排序。這同時也讓本站舊的「沒有 user 欄位型別」說法正式失效。
Row policy 動態代號:$me、$me.department、$today±Nd、$now#
授權的資料列過濾條件現在可以只寫一次、每次請求才解析。代號以原字串存在授權上,只在權限解析器那一層被替換,所以「我的資料列」或「未來七天」不再需要一人一條授權、也不用每天重寫。row policy 的值裡整個 $ 前綴都是保留字;查詢通道不會解析代號,貼到 stored_filters 裡它只是一個匹配不到任何東西的死字串。
把單一檢視唯讀公開給匿名讀者#
資料表管理者可以鑄造一個權杖,公開某張表的某一個已存檢視。權杖分兩種:無密鑰(URL 本身就是憑證),或帶 bearer secret(只在鑄造時顯示一次)。匿名讀者只會拿到 id、時間戳與 data,不會拿到 created_by、核准狀態或租戶 id;附件、連結、彙總、查找欄位則完全不能公開。所有失敗一律回同一個 404,每個權杖各有自己的流量限制。
公開表單可以先問出自己能寫哪些欄位#
公開回呼通道多了一個 form-schema 路由,用與寫入相同的驗證方式回傳目標資料表可寫入的欄位,失敗時同樣回統一的 404。外部表單現在可以直接照合約長出畫面,不必硬寫遲早會過期的欄位名稱。
四種新的觸發器動作#
觸發器原本有五種動作,現在有九種。send_channel_message 會依資料列解析出一位社群客戶並推送訊息,範圍涵蓋聊天室屬於該公司的任何客戶,並支援 LINE Flex。invoke_command 以觸發器作者的身分執行一個複合指令。delete_record 只軟刪除觸發它的那一列。materialize_slots 把班表資料列展開成可預約的時段。
可預約時段改用產生,不再手動塞資料#
materialize_slots 掛在每日排程上,把「每週班表 + 例外」的資料列展開成同公司目標表裡實際可預約的時段列,滾動視窗最長 92 天,並可切成固定長度。它以自然鍵去重,且被軟刪除的時段仍算存在,所以取消掉的時段不會又悄悄長回來。
相對日期、空值判斷、OR 分組、多欄排序#
查詢條件新增 within_last、within_next、older_than,每次執行時都會解析成絕對區間;另外新增 is_empty 與 is_not_empty,這兩個也是附件欄位唯一支援的運算子。請求層新增 any_of(OR of AND,最多 10 組、每組 10 個條件)與 sort(最多三個排序鍵,並以資料列 id 決勝)。這四項在資料列、搜尋、匯出與已存檢視都可用,而規則、row policy 等「設定」介面則刻意拒絕它們。
一對多的查找可以挑一列,之後就像純量欄位#
一對多的查找欄位可以加上 pick(排序欄位與方向),把它收斂成勝出的那一列。因此它第一次可以被 computed filters 過濾、可以排序,也可以當公式的運算元:「最近一筆訂單的狀態」現在是一個欄位,而不是一個彙總加一堆繞路。
SCP 可以只放寬讀取,不放寬寫入#
扁平的 channel 規則可以帶 read_op: "overlaps":只要有任一個連結目標落在目前聊天室的範圍內,該列就可讀,而寫入門檻仍要求完整包含。這個後果是刻意設計的,也值得你在設計時考慮:一列可以被列出來,儲存時卻被拒絕。若 require_present 不為 true,overlaps 會被拒絕。
三種新的 IaC 行類型:command、insight_selection、public_read#
JSONL 文件現在有十二種行類型,不是九種。複合指令、單一聊天室的洞察系統啟用、以及已公開的讀取權杖都能宣告,並和其他資源一樣走 plan/apply,狀態模型也追蹤這三種新資源。已公開的權杖可以就地更新,且憑證 URL 保持不變。
JSONL 文件可以在環境之間搬動#
可攜式身分代號($user:、$dept:、$smc:、$room:)現在可以用在原本必須寫死環境 id 的地方:授權主體、洞察系統選擇,以及身分欄位的儲存格。規則與觸發器的設定支援跨環境的 $row 欄位引用。匯出時會直接輸出代號,所以從測試環境搬到正式環境不必再手動替換一堆 uuid。資料表管理者也能宣告,語意是整組取代。
三個維運掃描:指令與跨表引用#
維運端新增三個工具:依保留期限清理已完成的指令執行紀錄、重新排入卡住的指令回呼執行,以及在資料表自己的遷移鎖下修復損壞的公式跨表引用。修復工具有預覽模式,正式執行前先跑預覽。
排程可以設定位移與時區#
「日期到達」排程可以設定 offset_minutes,範圍 -43200 到 43200:負值是提前提醒,正值是寬限期。每日排程可以設定 IANA 時區,所以「09:00」是營運所在地的早上九點。兩者在等於預設值時不會存入,所以文件裡明寫預設值仍然會 plan 成 noop。
這個模組不再有任何部署開關#
過去看起來需要「部署時開啟」的九項能力現在一律開啟,對應的環境變數與關閉分支都已刪除:row policy 與欄位 ACL、聊天室授權、SCP、規則、計算欄位、JSONL IaC、核准閘門、AI job scope 拆分、資料表標籤。任何端點都不會再回「此部署未啟用」,也沒有人需要請維運去開功能。只剩兩個開關:一個是事故用的,可以把 SCP 強制切成只記錄不阻擋;另一個是檔案匯入時要不要用 LLM 命名欄位,預設關閉。
動作有了穩定 id,重排順序不再打亂重試#
每個動作現在都有伺服器產生的 id,放在與 actions 平行的頂層 action_ids 清單裡,讀取時會回填到各個動作上。失敗續跑與各動作的冪等判斷都改用這個 id,而不是在清單中的位置,所以編輯或重排動作不會再讓做到一半的執行搞混。
改動線上計算欄位會直接是 plan 錯誤#
以前修改線上計算欄位的設定會被 plan 靜靜丟掉,作者看到的是「套用成功」但什麼都沒變。現在這是明確的 plan 錯誤,並會指出是哪個欄位。當 plan 已經跟現況不符時,apply 也會回傳穩定的逐行錯誤碼 iac_plan_stale。
AI 不再隨口說「這筆資料不存在」#
讀取回傳零列時,現在會附帶 absence 判定:not_found、not_visible 或 held,而 AI 只有在 not_found 時才可以說「這筆資料不存在」。「我看不到」和「它不存在」是兩件不同的事,而使用者以前收到的是自信的那一句。
沒有「日曆區間」運算子,這是決定,不是漏做#
「這個月」這類運算子在 2026-07-25 被提出並否決:滾動視窗回答的是另一個問題,而日曆區間取決於呼叫端的時區與週界定義。要表達日曆區間,請自己算出上下界,用 between 明確傳進來。
手寫的觸發器行不會再永遠 drift#
手寫的觸發器行以前每次 apply 都會被重新規劃成 update,因為儲存端會補上兩個作者從未輸入的欄位。現在差異比對與執行端共用同一個正規化邏輯來補齊,所以第二次 plan 就是 noop,這才是應有的行為。
SCP 的 link_target 子樹不再拿錯資料表解析#
link_target 葉節點的 target 子樹原本會用近端資料表去解析,導致合法的政策被拒絕,也讓部分政策在 IaC 來回時被錯誤轉換。現在它會原樣帶過,跨連結的政策就照字面意思生效。
日期採 Asia/Taipei。Staging 狀態表示可在測試環境串接,不表示尚未進 production;已驗證的 production rollout 會在該筆紀錄註明 release 與驗證範圍。