channel 規則(SCP)
channel 是 Scoped Constraint Policy(SCP)的房間範圍安全 floor。它讓同一張部門層級資料表可由多個 chatroom 共用,同時確保每個房間只能看到與寫入自己的範圍。這不是一般 ACL grant:ACL 先回答「能否使用這張表」,SCP 再回答「以呼叫者被授權的那些房間,可碰哪些列」。呼叫者永遠不指定房間——伺服器逐表解析 scope,解析結果由下方的 union 契約精確定義。
先選 flat form 或 policy form
Flat form 把列的 link targets 限制為呼叫者解析出的 channel scope 的子集合,也就是 chatroom grant 上宣告的 scope_values:
{
"type": "channel",
"name": "倉庫必須落在房間範圍",
"column": "倉庫",
"require_present": true,
"enforcement": "observe"
}require_present 必須明確提供。true 會拒絕空 link;false 允許空集合,因為空集合是任何 scope 的子集合。每個 chatroom grant 必須透過 chatroomPermissions.grant 宣告非空 scope_values;未宣告或空 scope 採 deny。
read_op:任一 target,或全部 target
Flat form 可以帶一個選填的 read_op,用來決定讀取 lane 與 scope_values 的比較方式:
| 值 | 何時可見 | 說明 |
|---|---|---|
subset | 該列所有 link target 都在 scope 內 | 預設值。會正規化為不存在的 key,所以 rules 清單的 GET 不會回這個欄位,Phase-1 規則能逐字往返。UI 應把它當成隱含預設值呈現,而不是要求該欄位存在。 |
overlaps | 任一 link target 在 scope 內 | 原樣儲存並回傳。必須搭配 require_present: true |
{
"type": "channel",
"name": "調撥單對兩邊倉庫的房間都可見",
"column": "倉庫",
"require_present": true,
"read_op": "overlaps",
"enforcement": "observe"
}read_op 只放寬讀取。寫入 floor 對 pre-image 與 post-image 一律要求完整子集合,因此在 overlaps 之下,只持有 A 的房間可以看到連到 {A, B} 的調撥單,儲存時卻會被 403、detail.error = "scp_out_of_scope" 拒絕。這個不對稱是刻意設計;前端不可把「列得出來」當成「改得動」。
以 read_op: "overlaps" 搭配 require_present: false 撰寫規則會得到 400。當 require_present 為 false 時,沒有 link 的列可以通過寫入 floor(∅ ⊆ scope 恆真)卻無法 overlap 任何房間,等於建立出一列寫得進去、但所有房間(含建立者自己)都看不到的資料,只有 table manager 找得到。與其產生這種孤兒列,不如直接拒絕這個組合。
因此 require_present 在兩條 lane 上意義不同。在 subset 之下,讀取與寫入都會檢查它;在 overlaps 之下,它在讀取上沒有意義——∅ 不與任何集合相交,沒有 link 的列本來就不可見——而在寫入上是關鍵,用來堵住 subset 的 ∅ ⊆ scope 恆真漏洞。
read_op 只屬於 flat form。與 policy 一起送出是 400;invariant rule 也會在頂層一併拒絕 read_op、column 與 require_present,因為這些是 channel lane 的每房間形式,在完全不解析房間 scope 的 lane 上沒有意義。
透過 overlaps 變成可見的列會暴露整列內容——link cell、link 展開,以及來源是 scope 外 target 的 lookup / rollup 欄位,沒有逐欄遮蔽。這是最容易出錯的設定:為了分享調撥單而打開 overlaps,卻透過 lookup 欄位順手洩漏了別的房間的主檔。要遮蔽請改為治理目標表本身,或使用 column ACL。
Policy form 使用固定、table-wide 的 policy tree:
{
"type": "channel",
"name": "只允許可出貨資料",
"policy": {
"and": [
{ "column": "狀態", "op": "neq", "value": "封存" },
{
"link": "產品",
"target": { "column": "可出貨", "op": "eq", "value": true },
"quantifier": "all",
"require_present": true
}
]
},
"enforcement": "observe"
}Policy leaf 的 value 是規則本身的固定值,不會自動代入房間 scope_values。Policy form 仍要求呼叫者解析出的 channel scope 非空——union 為空的呼叫者在 tree 被評估之前,讀取就已被遮蔽、寫入就已被 scp_scope_undeclared 拒絕——但這些值只負責通過 scope gate,不會改寫 policy tree。若同時需要「每房間子集合」與「全表條件」,在同一清單放一條 flat channel 與一條 policy channel;最多四條 channel 規則,全部以 AND 組合,且仍計入全表 10 條規則上限。
Warning
channel只能放在 department-scoped table,且不能與 triggers 共存。Trigger 沒有 acting chatroom,伺服器會在 authoring 時拒絕這個組合。
Policy node grammar
節點以 key 判別類型;未知或混合 key 會被拒絕,不會靜默忽略。Tree 最深 5 層、最多 24 個 leaf,其中 link leaf 最多 6 個。
| 節點 | Shape | 語意 |
|---|---|---|
| AND | { "and": [node, ...] } | 非空陣列,全部成立 |
| OR | { "or": [node, ...] } | 非空陣列,至少一個成立 |
| NOT | { "not": node } | 反轉二值結果 |
| IF | { "if": node, "then": node, "else": node? } | 條件成立走 then;省略 else 時,條件不成立即通過 |
| Scalar | { "column": name, "op": op, "value": scalar? } | 本表 stored scalar predicate |
| Link membership | { "link": name, "op": op, "value": [id]?, "require_present": bool? } | 比較本列 link target ID 集合 |
| Link target | { "link": name, "target": node, "quantifier": "any" or "all", "require_present": bool? } | 一跳讀取 linked record 的 stored scalar |
Scalar op 是 eq、neq、gt、gte、lt、lte、in、contains、is_null、is_not_null。Null operator 不帶有效 value;in 使用非空 scalar 陣列。
Link membership op 是:
subset_of:本列所有 target 都在value中。in:multi-id 集合的 subset sugar;eq:恰好一個 ID。none_in:本列 targets 與value無交集。is_empty、is_not_empty:不帶value。
subset_of、eq、in 對空集合會自然成立,因此必須明確提供 require_present。其他 membership op 不接受這個欄位;若要同時要求有值,可用 and 再加 is_not_empty。
Link target 只允許一跳,target 子樹只能讀 linked table 的 stored scalar 與 boolean nodes,不能再穿越 link。quantifier: "any" 在沒有可見 target 時為 false;"all" 對空集合自然為 true,所以 all 必須明確提供 require_present。
若 if 的 branch 會約束 link,condition 不能靠寫入者可自行填寫的 scalar 做自我宣告;請用 link_target 讀取 linked master record 的權威欄位。
Scalar leaf 只接受literal值。$me、$today 這類 row policy token 在這裡會被 400 拒絕——channel floor 評估時沒有 acting principal,token 在這條 lane 沒有意義。見 row policy tokens。
在 IaC 文件中,policy 內的 link_membership 與 link_target leaf 會被逐字複製:不翻譯,也不往下走訪。link_target 的 target 指的是被連結表的欄位,因此永遠不會拿去解析本表,規則也能 export → plan → apply 完全一致地往返。Leaf 自己的 link 仍會在 apply 時對本表真實的 link 欄位驗證。
observe 再 enforce
enforcement 可為 observe 或 enforce,省略時是 observe。Observe 會評估並留下診斷,但不拒絕寫入、也不在讀取時隱藏列;確認 preview 與實際診斷後,再把完整 rules 清單中的值切成 enforce。Enforce 會同時限制 reads 與 writes,避免只封住一側。
使用 rules.preview 比較各房間結果;用 rules.set 發布時仍遵守 full-replace 語意。
Union 契約
REST 從不詢問你代表哪個房間。自 2026-07-29 起,沒有任何 custom-table 路由接受 acting_chatroom_id,掛在它下面的整條重試階梯——帶 candidates 陣列的 400 acting_room_required、做交集檢查的 403 acting_room_unsatisfiable,以及 403 "This table is readable and writable only through a chatroom channel; you hold no chatroom grant on it."——全部刪除。
仍然附上 ?acting_chatroom_id=<id> 的用戶端會拿到 200 與完全相同的 body。FastAPI 會丟棄未知的 query parameter,因此這個值既不會被 422 拒絕、也不會被採用:你指名的房間毫無作用。沒有任何錯誤會告訴你這個參數失效了,所以請直接搜尋自己的呼叫端,不要等錯誤浮現。
在 enforcing channel table 上,floor 是依呼叫者是誰、逐表、每次請求重新解析:
| 呼叫者 | 解析出的 channel scope |
|---|---|
| 這張表的 manager | 豁免——不套用 floor。Manager 身分逐表解析,不是整個 session 一次決定 |
隸屬一個以上存活房間,且該房間持有這張表的 internal grant | 這些房間所宣告 scope_values 的聯集。同時在 A 與 B 兩房的成員,一次請求就讀到 A ∪ B |
| 沒有任何這樣的房間 | Deny |
scope_values 為 null 或 [] 的房間對聯集沒有貢獻——未宣告仍然是 deny,「不縮限」要靠不寫這條規則來表達,不是靠空 scope。軟刪除的房間會整個退出:刪除房間後,下一次請求起,該房的 grant 與 channel scope 都不再進入任何成員的解析。聯集可能超過 MAX_SCOPE_VALUES 的 200 上限——那是每一列 grant 的撰寫上限,不是解析上限,超過只是把單一 IN 清單變長。
Deny 在讀取 lane 是靜默的,在寫入 lane 是有代碼的:
- List、search、aggregate 與 export 回
200且零列。資料表清單與資料表詳情的record_count都是聯集計數,因此這裡讀到0;而詳情頁現在回200,不再像過去那樣對多房間成員拋400。清單計數同時也帶著呼叫者的 row ACL 底線,被收窄的授權會讓它再降低;見清單上的record_count。 - 單列
GET回 uniform404。 - 寫入回
403,detail.error是scp_scope_undeclared、message是"no chatroom of yours declares a scope on this table",並照常附上table_id與rule_id。若聯集非空但沒有涵蓋目標,代碼改為scp_out_of_scope。
修復方式不再是「選一個房間」,而是「由 moderator 透過 chatroomPermissions.grant 為你的房間在這張表上授予 scope_values」。空狀態請照這句寫,不要寫成「沒有資料」。
Agent session 仍然釘選單一房間
這次改動沒有動到 agent toolkit 的房間綁定。它為 session 一次性蓋上所在聊天室,並只透過那個房間的 grant 解析每一張部門表——既是釘選也是天花板,連表管理者都受它限制。兩個介面現在是刻意不對稱的:
| 介面 | 解析出的 scope | 同時在 A、B 兩房的成員看到什麼 |
|---|---|---|
| REST | 所有存活且被授權房間的聯集 | A ∪ B |
| 在房間 A 執行的 agent session | 只有房間 A 的 scope_values | A |
「API 給我 4 列,為什麼機器人只說 2 列?」現在是預期行為,不是 bug;而且 REST 呼叫者已經無法重現 agent 的切片——沒有任何參數可以拿來釘選單一房間。見 effective permissions。
SCP 與 ACL 的整體關係見 ACL 的 SCP 總覽。