Skip to Content
核心概念欄位型別Principal 欄位

Principal 欄位:單一身分與清單

適用情境

三種單一 principal 指向身分而非一般資料:寫入是單一身分字串、讀取是顯示物件、只能等值比較、永遠不能排序。它們與陣列型 principal_list 共用後端所有權家族 PRINCIPAL_COLUMN_TYPES(src/schemas/enums.py);下節另行說明清單的契約。

型別寫入時的 cell指向
principal一個帶標籤的字串:user:<id>、smc:<id> 或 room:<id>內部使用者、社群客戶,或整個聊天室——該室的每位成員都算被指派
user一個原始 User.id本表租戶的一位內部使用者
social_client一個原始 SocialMediaClient.id一個 LINE/Messenger/Instagram/Agent 身分,其所屬聊天室屬於本表租戶

現在該選的是 principal。同一欄可以放「這筆指派實際上是什麼身分」,所以一位業務在某些列以 TS 帳號行動、在另一些列以 LINE 帳號行動時,不再需要兩個欄位再用 policy 去 or;而「這筆 deal 屬於北區業務室」也終於有了表示法,成員異動完全不必動到任何一列。

user 與 social_client 沒有任何改變,也繼續支援:它們存原始 id、加工成不帶 ref/kind 的 dict,既有的列、篩選、授權與匯出全部逐位元不變。它們沒有被淘汰,但新的負責人欄位應該用 principal。

這三種都把單一字串直接存在 record.data 裡。和 link 不同,這裡沒有關聯表、也沒有搬移;基數固定為一。多負責人請用獨立的 principal_list 型別,不是 principal 的模式開關。

principal_list

已合併的 CT 核心也支援陣列型 principal 欄位。建立時使用 {"name":"Reviewers","type":"principal_list"}。每個非 null cell 是最多 100 個不重複的帶標籤字串陣列,沿用 principal 的 user:、smc:、room: 語法:

{"data":{"Reviewers":["user:u1","room:north_sales"]}}

每個非空的輸入成員都必須通過租戶所有權檢查。重複 cell、裸 ID、顯示名稱或錯誤標籤會被拒絕。請送陣列,不是逗號分隔字串或讀取時的加工物件。IaC record 的值可使用 $user:、$smc:、$room: token;一般 REST 值不接受這些 token。

在 stored_filters 中,eq 表示包含指定帶標籤成員,in 表示包含輸入清單的任一成員,neq 表示非空清單且不含指定成員。is_null 匹配未設定/空清單;is_not_null 要求至少一個成員。這些是成員判斷,不是整個陣列的等值或順序比較。本頁後面的純量能力表只適用於三種單一 principal,不包含這個清單型別。

user 這個字指三件不同的事。在 ACL 與 IaC 裡,user 是授權對象種類(grant principal kind)——權限授予給誰。user 同時也是一種欄位型別——紀錄裡的一格。現在 user: 還是 principal 欄位裡的一種 cell 標籤。三者只是共用同一個字。

當你需要的是屬於自己 schema 的豐富人員 metadata 時,人員表加 link 仍然是正確模型。Principal 欄位處理的是身分:它對照真實目錄解析、寫入時受租戶把關,而且它才是 $me row policy 比對的對象。

principal 的 cell 文法

principal cell 是恰好一個帶標籤的字串,不是陣列:

<tag>:<id> tag ∈ { user, smc, room } id 符合 [A-Za-z0-9_-]{1,64}
標籤指向加工後的 kind
user:User.iduser
smc:SocialMediaClient.idsocial_client
room:Chatroom.idchatroom

標籤詞彙就是可用於 cell 的 IaC 身分 token 前綴($user:、$smc:、$room:)去掉 $ 記號;第四個前綴 $dept: 只用於 grant。id 本體既不允許 $ 也不允許 :,而且整個字串是用 fullmatch 比對、不是 $ 錨定的 regex——所以結尾多一個換行也會被拒絕。因此四個 id 空間在構造上互斥,任一空間的值都不可能被誤認成另一個:

  • principal cell(user:u1)——有標籤;
  • 舊的 user/social_client cell——裸 id,沒有標籤;
  • row policy token($me、$me.department)——以 $ 開頭;
  • IaC 身分 token($user:alice)與 actor key(client:<id>)——$ 記號與 client: 這個拼法都在文法之外。社群客戶的標籤之所以是 smc: 而不是 client:,正是為了這一點。

加工後的讀取契約講的仍然是 social_client 與 chatroom,不是 smc 與 room:標籤是傳輸拼法,kind 才是詞彙。

建立 schema

以下是 columns.create 的 body。三種型別都只接受 name、type、required、description:

{ "name": "Assignee", "type": "principal", "required": false, "description": "這筆紀錄屬於哪個使用者、客戶或聊天室" }
{ "name": "Reviewer", "type": "user", "required": false, "description": "內部審核者" }

default_value 被禁止,因為在編寫 schema 的當下無法驗證預設負責人是否屬於本租戶;max_length 與 options 對不透明的身分沒有意義;附件與計算欄的設定欄位一概不接受。required 可以使用。

欄位在任一 principal 欄位上的結果
default_value422 principal (user/social_client/principal) columns cannot have a default_value
max_length422 principal (user/social_client/principal) columns cannot set max_length
options422 principal (user/social_client/principal) columns cannot set options
附件上限欄位422 fields [...] are only valid on attachment columns
計算欄設定422 fields [...] are not valid on principal (user/social_client/principal) columns
precision / scale / allow_negative422 precision/scale/allow_negative are only valid on decimal columns

這些 422 來自建立表與建立欄位時執行的逐型別驗證器。欄位編輯介面(PATCH .../tables/{table_id}/columns/{column_id})沒有這個驗證器,所以同樣的錯誤在那裡會變成 400、而且措辭不同:Invalid default value: principal (user/social_client/principal) columns cannot have a default value,若是轉型則是 Default value incompatible with new type 'principal': principal (user/social_client/principal) columns cannot have a default value。

寫入契約

送出身分字串——principal 要送帶標籤的那個。不是顯示名稱、不是 username,也不是讀取時拿到的那個物件。

{ "data": { "Title": "印表機離線", "Assignee": "room:c8a91f22d0", "Reviewer": "44444444-4444-4444-8444-444444444444" } }

records.create 回應 201,而且 cell 已經是加工過的顯示物件——回應的形狀和你送出的不一樣。

對選填 principal 欄而言,省略 key、null 與 "" 都是合法空值。必填欄在建立紀錄時拒絕空值,在更新時也拒絕明確送出的空值;局部更新仍可省略該 key。

每一個非空值都會跑兩道檢查。

形狀。 principal cell 必須能解析成帶標籤的 cell。所有新增路徑都是先過下方的歸屬閘門、再過 schema 驗證,所以未帶標籤的字串會先在閘門失敗:400 Column '<internal key>' principal cell '<value>' must be user:<id>, smc:<id> or room:<id>。非字串的值(例如讀取物件)才會進到 schema 驗證:單筆建立時是 400 Validation errors: Field '<display name>' must be a principal cell (user:<id>, smc:<id> or room:<id>)(同步批次路徑則加上 Action <i> insert validation errors: 前綴)。單筆更新則是 schema 驗證先跑:400 Validation error for column '<display name>': Field '<display name>' must be a principal cell (user:<id>, smc:<id> or room:<id>)。user/social_client cell 必須是非空字串:Field '<display name>' must be a principal id string,同樣包在上述 400 之內。

租戶歸屬與存活。 每種 kind 各批次查一次、沒有 N+1;principal 依標籤分派,舊的兩種依欄位型別分派:

Cell必須解析到拒絕訊息
user:<id>,以及 user 欄位本表有效公司底下 is_deleted == False 的 User400 Unknown or inaccessible user ids: ['<id>']
smc:<id>所屬聊天室在該公司底下且未刪除的 SocialMediaClient400 Unknown or inaccessible social_client ids: ['<id>']
social_client 欄位所屬聊天室在該公司底下的 SocialMediaClient(刻意不檢查聊天室存活——收緊會讓既有已寫入的 cell 被拒)400 Unknown or inaccessible social_client ids: ['<id>']
room:<id>該公司底下未刪除的 Chatroom400 Unknown or inaccessible chatroom ids: ['<id>']

如果完全無法解析有效公司(表 → 部門 → 聊天室都是空),寫入 fail-closed 並回 400 Cannot resolve company for principal ownership check。

寫入者可以指派自己不在其中的聊天室。 room:<rid> 的閘門只有租戶與存活兩項,刻意不要求成員身分。把一筆紀錄指派給某個聊天室,永遠不會擴大寫入者自己看得到的範圍,所以沒有什麼要防;擋下它只會讓真正要交接的人交接不出去。

這道閘門接在所有寫入路徑上:單筆新增、單筆更新、批次新增、批次更新、非同步批次新增與批次更新 worker、compose command 寫入、IaC record 套用、簽核暫存套用路徑,以及版本還原路徑。它同時也是 replay 與 bulk 路徑(那些路徑會跳過 schema 驗證)必經的匯流點,所以無法解析的 principal cell 在那裡也會拋錯——400 Column '<internal key>' principal cell '<value>' must be user:<id>, smc:<id> or room:<id>,非同步 ticket 上則是逐列錯誤——而不是掉進「最後那一個」歸屬查詢。沒有任何後門能把跨租戶或格式錯誤的身分夾帶進來。

歸屬檢查只看 payload 中實際出現且非空的值。局部更新從不重新驗證沒有被碰到的 principal cell;用 null 或 "" 清除選填欄也不會查。人員離職或聊天室關閉因此不會讓歷史紀錄變成廢資料——一列上放著已被軟刪除的負責人,仍然可讀、其他欄位也仍然可編輯。軟刪除只在寫入時被拒絕,讀取不受影響。

值得寫進前端表單驗證的拒絕情境:

你送進 principal 欄位的內容結果
"Assignee": "Alice Chen"(顯示名稱)400 Column '<internal key>' principal cell 'Alice Chen' must be user:<id>, smc:<id> or room:<id>;單筆更新則是 Validation error for column 'Assignee': Field 'Assignee' must be a principal cell (user:<id>, smc:<id> or room:<id>)
"Assignee": "44444444-4444-…"(裸 id)400——與顯示名稱同一則訊息;標籤是必要的
"Assignee": {"ref": "user:u1", "kind": "user", …}(讀取物件)400 Validation errors: Field 'Assignee' must be a principal cell (user:<id>, smc:<id> or room:<id>);單筆更新則是 Validation error for column 'Assignee': Field 'Assignee' must be a principal cell (user:<id>, smc:<id> or room:<id>);要送 ref,不是整個 dict
其他公司使用者的 "Assignee": "user:u1"400 Unknown or inaccessible user ids: ['u1']
"Assignee": "$user:alice"(IaC token)400——與顯示名稱同一則訊息;$ 在 cell 文法之外
"Assignee": "client:c1"(actor key)400——與顯示名稱同一則訊息;標籤是 smc:,永遠不是 client:

讀取契約:加工後的顯示物件

私有 REST 的紀錄讀取——列表、搜尋、單筆、垃圾桶列表、匯出,以及建立、更新或還原所回傳的紀錄——都會把儲存的字串換成解析後的顯示物件,每頁每種 principal kind 最多批次查一次——一次加工同時服務帶標籤的 cell 與兩種舊型別,因為挑目錄的是標籤,不是欄位型別。

{ "data": { "Title": "印表機離線", "Assignee": { "ref": "room:c8a91f22d0", "kind": "chatroom", "id": "c8a91f22d0", "name": "北區業務", "department_id": "d1", "is_deleted": false }, "Reviewer": { "id": "44444444-4444-4444-8444-444444444444", "name": "Alice Chen", "username": "alice", "is_deleted": false } } }

principal cell 會解析成該 kind 的 dict,再加上 ref 與 kind:

標籤kind加工後形狀
user:user{ref, kind, id, name, username, is_deleted}
smc:social_client{ref, kind, id, platform, name}
room:chatroom{ref, kind, id, name, department_id, is_deleted}

ref 是來回契約:原封不動把這個字串寫回去,不要從 kind 反推標籤,也不要送裸 id。user 的 name 取暱稱、沒有就退回 username;social_client 的 name 是各通道的顯示名稱(可能為空);chatroom 的 name 就是聊天室名稱。

舊的 user 與 social_client cell 加工結果逐位元維持原狀——{id, name, username, is_deleted} 與 {id, platform, name},沒有 ref、沒有 kind。用 cell 形狀分辨這兩種欄位型別的前端可以繼續運作。判斷「這是不是來自 principal 欄位」的可靠依據就是 ref 是否存在。

加工是 fail-closed 而且不具破壞性。被軟刪除的使用者或聊天室仍然會解析出來,並帶 is_deleted: true,讓歷史指派保持可讀——存活與否是寫入閘門的事,永遠不是讀取過濾條件。無法在本租戶解析的值——懸空(包含已被硬刪除的 principal)、跨租戶或格式錯誤——會維持原本的原始字串(含標籤);當該表自己的租戶在讀取時無法解析,每個 cell 也都維持原始字串:既不捏造、也不丟棄。

讀取的 cell 是聯集型別:string | object。前端必須真的做型別判斷,不能假設。直接讀 cell.name 的元件會在遇到懸空 cell 時崩潰。

加工只會轉換 data 裡已經存在的 key,也不會新增任何 key。REST 上的欄位 ACL 剝除發生在加工之後,所以被隱藏的欄位會從回應中移除(是缺席,不是 null);agent 工具組的 query_records 根本不會解析被隱藏的欄位,find_records 則在加工後立即將其剝除。它以顯示名稱為鍵,透過 settings.column_mapping 反查,所以即使寫入時用的是內部 col_<hex> 鍵,加工後的 cell 仍會出現在顯示名稱底下——請參閱欄位身分。

以下讀取介面不會加工:

  • 公開讀取路徑只提供原始字串。
  • 透過 lookup 帶出的 principal 欄位回傳原始字串——加工器只走訪本表自己的 principal 型別欄位,而 lookup cell 宣告的型別是 lookup。同一個人在擁有該欄位的表上顯示為姓名,透過 lookup 卻是 user:<id>(舊型別則是一串 UUID)。
  • REST records/aggregate 的分組鍵是原始字串。要畫「每位負責人的紀錄數」圖表的前端,得自己把它換成姓名。
  • 紀錄與表的歷史(.../records/{record_id}/history、單一版本、版本 diff、.../tables/{table_id}/history)回傳的是原始、以內部鍵表示的快照。
  • 由其他讀取內嵌的連結列——expand_links 內容、.../records/{record_id}/incoming-links、.../records/lookup 與 .../links/resolve——帶的是連結表的原始 cell。

外部 agent 通道

在 agent 工具組的外部客戶通道上,加工後的聊天室 cell 會收斂成 {ref, kind, id}:聊天室名稱與 department_id 是內部組織結構,永遠不會送到客戶端。該通道上所有 principal dict 都會拿掉 username 與 is_deleted,資料 cell 也一樣。ref 在兩條通道都保留——那是呼叫端要寫回去的字串。

CSV 與 XLSX 匯出

POST .../records/export 會串流一份已渲染的檔案,principal cell 會渲染成給人看的樣子,而不是 Python dict repr:

Cell渲染結果
chatroom dict(kind == "chatroom")room:<name>
帶 platform 的 dict(社群客戶)<platform>:<name>
使用者 dict<name>
未加工的 cell(懸空/跨租戶)原始字串,含標籤

room: 判斷跑在 platform 判斷之前,所以聊天室指派絕不會被讀成某個人的名字。名稱為空時標籤退回 id,所以一格可能是 line:<uuid> 或 room:<uuid>。匯出與列表走同一條可見性管線——被隱藏的欄位不會變成檔案欄位,ACL 之外的列也不會進到檔案。

查詢矩陣:只能等值,永遠不能排序

Principal 身分是不透明的,因此字典序比較會給出「錯得很篤定」的答案而不是錯誤——而且 principal cell 會先依標籤排序(room:z 排在 user:a 前面),一個看起來有意義、其實沒有的順序。各處使用的運算子集合完全相同:

eq、neq、in、is_null、is_not_null——僅此而已。

介面行為
舊版 filters map精確等值;不會走 string/text 那條不分大小寫的部分比對分支。在 search 與 export 端點上,principal 上未帶標籤的值是 400 Filter validation errors: Filter value for column '<key>' must be a tagged principal cell (user:<id>, smc:<id> or room:<id>),user/social_client 上的非字串值是 400 Filter validation errors: Filter value for column '<key>' must be a principal id string;<key> 是你送出的 col_<hex> 鍵
stored_filters / any_of上述五個運算子;其他運算子是 400 Op '<op>' is not supported on principal column '<col>' (user/social_client/principal accept: eq, in, is_not_null, is_null, neq)
運算元型別eq/neq 需要非空字串,in 需要非空的非空字串陣列。數字、布林或 "" 運算元是 400 principal column '<col>' filter value must be a non-empty raw principal id string (ids are opaque; numeric operands coerce silently at compare time)。缺少、清單或物件運算元、空的 in 清單,或 null/清單/物件的 in 項目,會在更早的請求 schema 階段被拒絕,回 422(filter op "eq" requires a scalar value、filter op "in" requires a non-empty list value 等)。在 principal 上每一筆還必須能解析成帶標籤的 cell:400 principal column '<col>' filter value must be a tagged principal cell (user:<id>, smc:<id> or room:<id>)
in 陣列大小最多 100 筆——schema 層 422 filter op "in" value list exceeds max 100 entries,SCP 純量葉節點則是 <path>: op 'in' value list exceeds max 100
name_eq / name_contains在 principal 上被拒絕:400 Op '<op>' is not supported on principal column '<column>' — resolve the principal first and filter by eq/in on its ref。user/social_client 仍然可用
sort_by、多鍵 sort一律 400 Sorting on principal (user/social_client/principal) columns is not supported。只有多鍵 sort,且表上同時有 link、rollup、lookup 或 formula 欄時,才會得到帶欄位名的版本 Sorting on principal (user/social_client/principal) column '<name>' is not supported,而且僅限部門與公司的 records/search、records/export,以及儲存檢視套用;sort_by 與聊天室的 records/search 通道一律用不帶欄名的訊息
records/aggregategroup_by 可以;count 與 count_distinct 可以;sum/avg 被數值閘門擋下,min/max 被可排序閘門擋下
Rollup filter、規則 when、REST aggregate filters、SCP 純量葉節點排序運算子是 400 filter op '<op>' requires an orderable column (integer/float/date/datetime), but '<display>' is type principal,contains 是 400 filter op 'contains' is for string/text columns, but '<display>' is type principal。SCP 純量葉節點則改報 filter op '<op>' cannot be applied to column '<display>' (type principal). principal id cells support only eq/neq/in/is_null/is_not_null。運算元不是帶標籤的 cell 則是 filter value '<v>' does not match column '<display>' (type principal),提示為 op '<op>' on a principal column requires a tagged principal cell (user:<id>, smc:<id> or room:<id>)

在 principal 欄位上,未帶標籤的運算元在型別化的查詢與政策路徑都會被拒絕——REST stored query、search 與 export 端點上的舊版 filters map、agent 工具組的 query/aggregate/join filter 驗證器、SCP 純量葉節點、rollup filter、規則的 when/check/transition 值,以及 row policy。這是刻意的:裸 id 或 $user: token 永遠不可能等於已儲存的帶標籤 cell,所以寬鬆的運算元規則會讓 eq 靜默比對不到任何列、讓 neq 比對到每一列——讀取時是一條死掉的 filter,編輯時就是一個權限漏洞。有些路徑不檢查標籤:trigger 的 when 與 source.filter(未帶標籤的值會通過驗證,在 eq/in 下永遠比對不到,在 neq 下則比對到每個非空 cell),以及套用儲存檢視或公開讀取查詢時的舊版 filters map(未帶標籤的值會靜默比對不到任何列)。

永遠不要把顯示名稱當成 eq/in 的 value。在 principal 上會被直接拒絕;在 user/social_client 上則會悄悄回傳零列,因為儲存的 cell 是一串原始 id。要依名稱過濾 user/social_client,正規做法是 name_eq/name_contains 運算子(伺服器端會把名稱解析成 id 再過濾);在 principal 上它們會被拒絕——請改用 custom_tables_resolve_principal,再用回傳的 ref 過濾。伺服器端對任何 principal 欄位都沒有全文姓名搜尋:agent find_records 的 search_text(如同 query_records 的 q 與 REST 的 q)只掃 string/text 欄位。

儲存檢視在設定當下不驗證 principal 運算子。stored_filters 對 principal 欄位使用 contains 或 gt 的檢視會在建立時被接受,只有在套用時才失敗。

伺服器端沒有辦法依 principal 欄位排序。請改用 group_by + count,或把姓名反正規化到一個 string 欄位再排序。principal 欄位的 group_by 以整個帶標籤字串為鍵,所以 user:x 永遠不會和 smc:x 混在一起。

Principal 欄位可以用的地方

三種型別都算已儲存純量,所以日常需求都是一級支援:

介面支援情形
unique 規則成分可以——這正是「負責人去重」的情境。空的或不存在的 cell 不受限制(SQL null 語意),使用 case_insensitive 時值會先轉小寫再雜湊
unique_tuple 規則成分可以。Principal 值以精確值比較:case_insensitive 與 unicode_normalization 只適用於 string/text/select 成分,"" 是真實的值,null 則依成分的 null_policy(forbidden/equal/distinct)處理
Upsert 的 match_column可以
lookup 目標欄可以,但帶出的 cell 是原始字串
rollup 的 count_distinct 目標可以
compare 規則只有 eq/neq,而且只能對照同一種 principal 型別的另一個欄位。排序運算子、跨型別(principal 對 user、user 對 social_client)與 principal 對 string 都會被拒絕:400 principal column '<display>' supports only eq/neq against another column of the same principal type。principal 自成一個可比較類別——帶標籤的 cell 永遠不可能等於裸 id,跟舊型別配對在構造上就是永遠為 null 的比較
check 規則只有 eq/neq/in。matches(正則)與排序運算子會被拒絕:400 principal column '<display>' supports only eq/neq/in/not_in checks;not_in 會通過那道檢查,但接著被值檢查拒絕:400 filter op 'not_in' cannot be applied to column '<display>' (type <principal type>)
transition 規則可以——重新指派的狀態機(room:sales → user:lead)和 select 的狀態機一樣合法。matches 仍然被拒絕:帶標籤的身分不是正則的比對對象
規則的 when與 rollup filter 採同一套驗證:is_null/is_not_null/eq/neq/in。排序運算子或 contains 會得到上述依運算子與型別而定的 400,而在 principal 上每個值都必須是帶標籤的 cell
Trigger 的 whenis_null/is_not_null/eq/neq/in:400 when column '<name>' is a principal (user/social_client/principal) — only is_null/is_not_null/eq/neq/in are supported。值必須是非空字串(… — op "<op>" value must be a non-empty id string、op "in" requires a non-empty list of ids)。標籤不會被解析:principal 上未帶標籤的值會通過驗證,之後在 eq/in 下永遠比對不到,在 neq 下則比對到每個非空 cell
Trigger action 的 source.filter同樣五個運算子:400 source.filter column '<name>' is a principal (user/social_client/principal) — only is_null/is_not_null/eq/neq/in are supported
send_channel_message 的收件人recipient.column 接受 string 或 social_client 欄位。user 被拒絕(內部 user id 不帶任何通道身分),principal 也被拒絕——從帶標籤的 cell 做投遞刻意不在範圍內:400 send_channel_message recipient.column must name a string or social_client column of this table holding a social-media client id
invoke_command 的 $row.<col> 輸入user 可餵給 identity:user 或 string;social_client 可餵給 identity:social_media_client 或 string;principal 只能餵給 string——identity 型別的參數預期的是裸 id,每次執行都會拒絕或錯誤解析 user:u1
Formula 引用永遠不行。公式欄會被 400 拒絕:[<column>] is type <principal type> — formulas may only reference numeric columns (integer/float scalars, numeric rollups, numeric lookups, numeric-output formulas)(作為 CONCAT/UPPER/LOWER/LEN 或日期函式的引數時則是 [<column>] is type <principal type> — CONCAT/UPPER/LOWER/LEN and the date functions require a text/date column);command 引擎則回報 Formula cannot reference a principal column。負責人不是算術輸入
Command DSLprincipal 運算元可以和 string 運算元做身分等值比較($input.* 與 $ctx.user_id 就是這樣進到負責人欄位的)。跨型別不相容(comparison requires compatible operand types),只要任一邊是 principal kind 就拒絕排序比較(ordered comparison requires ordered operands)。string 型別的運算式可以寫入 principal 欄位,而寫入 principal 欄位的字串常值本身必須能解析成帶標籤的 cell(Principal cell literal must be user:<id>, smc:<id> or room:<id>);principal 型別的運算式只能寫入同型別的欄位,租戶歸屬在執行時把關
IaC record 自然鍵可以——principal cell 就是一個普通純量字串,有正常的綁定表示法。(boolean、json 與 interval 不能當自然鍵,principal 欄位可以)
測試資料產生器絕不捏造身分——它輸出 null。含必填 principal 欄位的表完全無法使用產生器

compare 規則比 command DSL 更嚴格:規則完全禁止 principal 對 string 的比較,而 command 運算式允許 principal 等於 string。不要把兩者寫成同一條規則。

Agent 工具組與 REST 對齊,聚合集合稍寬。principal 的寫入只收一個帶標籤的 cell(Column '<name>' must be a single principal cell — user:<id>, smc:<id> or room:<id> (resolve the id first, not a name; for the person you are talking with write $me));舊型別的寫入收一個原始 id(Column '<name>' must be a single <user|social-media client> id string — pass the id (resolve it first), not a name — for the person you are talking with write $me)。principal 的 filter 運算元若未帶標籤,拒絕訊息會明確點名 ref——… must be a tagged principal cell (user:<id>, smc:<id> or room:<id>) — resolve the principal first and pass its ref. Reads return {ref,kind,id,name,…}: send value.get('ref') back verbatim, never the bare id or the whole dict.。在舊型別欄位上,把讀取得到的加工物件塞進 filter 會被明確拒絕並提示改傳 value['id'](不會默默拆解)。聚合允許 count、count_distinct、value_counts、mode、collect,但永遠不允許 min/max(principal id cells have no order. Supported: count, count_distinct, value_counts, mode, collect.)與 sum/avg。value_counts 上限 50 筆。

Row policy 與 $me

Principal 欄位加上 $me token 就是最標準的「只看到自己的資料」政策:

{ "read_filter": { "and": [ { "column": "col_a1a1a1a1_a1a1_4a1a_8a1a_a1a1a1a1a1a1", "op": "eq", "value": "$me" } ] } }

$me 可用於 principal、user、social_client、string、text 欄位;$me.department 可用於 principal、string、text。在 user 或 social_client 欄位上,$me 解析成單一 id。在 principal 欄位上,它改為解析成一個帶標籤 cell 的集合——行為者自己的身分,加上他所屬的聊天室——所以 room: 指派的紀錄會送達每一位成員,而那一列從頭到尾沒有點名任何人。該解析、各通道的收斂方式與 fail-closed 上限記載於 Row policy 動態代號,本頁不重複。

在寫入 grant 時,principal 欄位條件會被收斂到同樣那五個運算子。每一則 row policy 錯誤都會冠上該葉節點的樹狀路徑:頂層葉節點是 row policy,巢狀葉節點則是 row policy <path>——例如 row policy and[0]、row policy not.or[1],在 link_target 內則是 row policy target.and[0]。以下的 <label> 就代表這個前綴。

排序或 contains 運算子會被拒絕:422 <label> op '<op>' cannot be applied to column '<key>' — principal columns support only eq/neq/in/is_null/is_not_null in row policies。運算元不合法則是 <label> column '<key>' principal filter requires a non-empty string id operand 或 <label> column '<key>' principal 'in' filter requires a non-empty list of non-empty string ids。在 principal 欄位上,非 policy token 的常值運算元還必須能解析成帶標籤的 cell:<label> column '<key>' principal filter operand must be a tagged principal cell (user:<id>, smc:<id> or room:<id>)。把 $me 用在不適用的欄位型別上是 <label> token $me may only target user/social_client/principal/string/text columns — column '<key>' is type <type>,$me.department 則是 <label> token $me.department may only target string/text/principal columns — column '<key>' is type <type>。在這層收斂出現之前,負責人欄位上的排序條件會用字典序決定列的可見性——一個「已過濾」的授權卻悄悄放寬了範圍。

$me 加負責人欄位的政策會讓所有人都看不到未指派的列。空的、缺少的或 "" 的 principal cell 永遠不會命中任何值運算子,只有 is_null 選得到它。租戶通常會回報成「新工單看不到」。如果未指派的列應該保持可見,請加上明確的 is_null 分支。

$me、$today、$now 只是 row policy 的 token:在 rollup filter、規則、SCP 純量葉節點或臨時的 REST aggregate 上,它們會被直接拒絕,訊息是 filter value '$me' on column '<display>' — identity/date tokens ($me, $today…) are only valid in ACL row policies. use a literal value here; tokens resolve only inside grant read_filter/edit_filter row policies。Trigger 的 source.filter 有自己的對應訊息:source.filter identity/date tokens ($me, $today…) are not valid (triggers evaluate with no acting user; use a literal value)。

把 $me 當成寫入值——只限 agent 通道

儲存用的 cell 文法沒有變動:它依然既不接受 $ 也不接受第二個 :,REST 寫入仍然必須帶已解析的 id 或 ref。自 backend PR #1190 起,agent 工具組接受一個例外,而那是在驗證之前完成的一次翻譯,不是文法放寬。

custom_tables_insert_record、custom_tables_update_record,以及 custom_tables_bulk_record_actions 的 insert 與 update 兩個分支,都允許人員 cell 寫成 $me 這個 token——去除前後空白、不分大小寫、僅此一種寫法。工具箱會在 _validate_insert_data 與 CRUD 歸屬閘門之前把它改寫成當事人,因此下游的一切(包含本頁的寫入契約)看到的都是普通的 cell,資料庫也永遠不會存進任何長得像 token 的東西。

欄位型別內部使用者通道外部 client 通道
principaluser:<id>(帶標籤的 cell)smc:<id>(帶標籤的 cell)
user裸的使用者 id不解析
social_client不解析裸的 client id

兩個舊型別各自綁定單一種身分、只存一個裸 id,所以「我」只在對應的通道上存在;無法為該欄位解析時,token 會原樣留著,接著被擋下來,而不是無聲地寫成另一個人——在 principal 欄位上由形狀檢查擋下,在舊型別欄位上則由歸屬閘門擋下($me 仍是非空字串:Unknown or inaccessible user ids: ['$me']/Unknown or inaccessible social_client ids: ['$me'])。伺服器沒有同義詞表——「我」「me」「myself」是模型的責任,由它的協定來教——而租戶歸屬與存活檢查仍然套用在解析後的 id 上。見 agent 工具箱。

IaC 的可攜身分

原始資料庫 id 會把 IaC 文件綁死在單一安裝環境。因此 principal cell 接受可攜的身分 token,在 plan 與 apply 時解析,以公司為錨點並且 fail-closed:

  • user 欄位用 $user:<username>——username 在公司內唯一,存下來是裸的 user id。
  • social_client 欄位用 $smc:<platform>:<platform_user_id>,存下來是裸的 social client id。
  • principal 欄位用 $user:、$smc:,或 $room:<聊天室名稱>/$room:<部門名稱>/<聊天室名稱>。三者都會存成該欄位文法要求的帶標籤 cell——user:<id>、smc:<id> 或 room:<id>——因為標籤就是 token 前綴去掉 $。

$dept: 只用於 grant。部門沒有對應的帶標籤 cell 形式,所以 cell 裡的 $dept:Sales 在任何查詢之前就是逐行的 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。同一張封閉對照表也會拒絕 user 欄位裡的 $smc: 與 social_client 欄位裡的 $user:,而且不論 token 帶的名稱是否存在都會觸發。

Trigger 的 when 條件值也帶同樣的 token:user/social_client 欄位上的 $user:/$smc: 解析成裸 id,principal 欄位上的三種前綴則解析成帶標籤 cell("value": "room:<id>",運算子維持不變)。identity 型別的 invoke_command 輸入常值只接受 $user: 與 $smc:——沒有 identity:principal 這種輸入型別,所以那裡一律解析成裸 id。Grant principal 依 principal.type 使用四種前綴:user 用 $user:、department 用 $dept:<部門名稱>、client 用 $smc:、chatroom 用 $room:<聊天室名稱> 或 $room:<部門名稱>/<聊天室名稱>。insight_selection.chatroom 也接受 $room:。kind 對前綴的對照表是封閉的:表上沒有的 kind 會得到明確的 unknown principal kind '<kind>' for token '<tok>',而不是靜默退回 $smc:。

{"kind":"column","table":"tickets","ref":"assignee","spec":{"name":"Assignee","type":"principal"}} {"kind":"column","table":"tickets","ref":"reviewer","spec":{"name":"Reviewer","type":"user"}} {"kind":"record","table":"tickets","data":{"ticket_no":"T-100","assignee":"$room:Support Desk/Tier 1","reviewer":"$user:alice"},"on_drift":"update"} {"kind":"grant","table":"tickets","principal":{"type":"user","id":"$user:alice"},"spec":{"can_read":"filtered","read_filter":{"and":[{"column":"assignee","op":"eq","value":"$me"}]},"can_insert":true,"can_edit":"none"}}

解析在 pass 0 執行,早於 record 分類,整份文件每種 kind 批次查一次,每個 token 得到的結果恰好是「一個 id」或「一則錯誤」二選一。永遠不會靜默跳過。principal 型別的 cell 裡任何以 $ 開頭的值都會被當成 token,所以格式錯誤的那一個會浮現自己的逐行錯誤,而不是以假的原始 id 流到寫入閘門。不以 $ 開頭的字串會原樣通過:user/social_client 欄位上的舊型原始 id,以及 principal 欄位上的原始帶標籤 cell,兩者都合法,但也都只是由寫入閘門(而非解析器)把關的同環境捷徑——可攜的寫法是 token。因為分類比對的是解析後的正規值,重新匯出已套用的文件會 plan 出 0 差異。

失敗模式全都是明確的:

Token失敗訊息
$user:aliceprincipal token '$user:alice': no user with username 'alice' in this company / principal token '$user:alice': user 'alice' is deleted
$smc:line:U123principal token '$smc:line:U123': no social client with platform user id 'U123' on platform 'line' in this company / principal token '$smc:line:U123': ambiguous — 2 social clients match, in chatrooms ['<room-a>', '<room-b>']
grant 裡的 $dept:Salesno department named 'Sales' in this company / department 'Sales' is deleted / ambiguous — <n> departments share the name 'Sales' / malformed principal token '$dept:': empty department name (...)
$room:Supportno live chatroom named 'Support' in this company / ambiguous — 2 live chatrooms are named 'Support' (['<id-a>', '<id-b>']); qualify it as '$room:<department name>/Support'
$room:Support Desk/Tier 1no live chatroom named 'Tier 1' in department 'Support Desk' in this company / ambiguous — 2 live chatrooms match (['<id-a>', '<id-b>'])
cell 裡的 $dept:Salesprincipal token '$dept:Sales' does not match the column's type: a principal column expects $user:<...> or $smc:<...> or $room:<...> or a raw id——$dept: 只用於 grant
前綴與欄位或 grant 種類不符principal token '<tok>' does not match the column's type: a user column expects $user:<...> or a raw id
格式錯誤malformed principal token '$user:': empty username (expected '$user:<username>', '$smc:<platform>:<platform_user_id>', '$room:<chatroom name>' or '$room:<department name>/<chatroom name>')——在 grant 通道上,格式錯誤的 $dept: 或 $room: token,以及無法辨識的前綴,其提示還會多列出 '$dept:<department name>';格式錯誤的 $user:/$smc: token 則維持上面的提示
無法解析 scopecannot resolve this scope's company for principal token resolution

$room: 是唯一一個自然鍵完全沒有唯一性約束的 token——聊天室名稱是自由文字。請把限定形式 $room:<部門名稱>/<聊天室名稱> 當成預設寫法,而不是備案。名稱是以最後一個斜線切分的,所以 $room:A/B/C 代表部門 A/B、聊天室 C。

$smc: token 也可能合理地無法解析:SocialMediaClient 的唯一性是以聊天室為單位,所以同一組 (platform, platform_user_id) 出現在兩個聊天室就是模稜兩可,會 fail-closed 而不是挑第一筆。

IacGrantPrincipal.id 為此放寬了限制。現在是 1–160 字元、符合 ^([a-z0-9][a-z0-9_.-]{0,35}|\$(user|dept|smc|room):[^\r\n]{1,150})$——原始 id 仍然必須不含冒號與空白,好讓推導出的 grant:<type>:<id> ref 保持合法;而 token 的內容是最多 150 字元的自由文字,因此含空白與冒號的部門與聊天室名稱都寫得出來。Token 會在推導那個 ref 之前先正規化成解析後的原始 id;無法解析的 token 會得到佔位 ref <prefix>unresolved-<8 位十六進位>,讓 plan 回報逐行錯誤而不是 500。

匯出會把原始 id 反向對應回 token,每種 kind 批次查一次並限定租戶,涵蓋 user/social_client 的 record cell、principal cell、trigger when 值、identity command 輸入、link 自然鍵清單,以及四種 grant principal。principal cell 會先被解析,再用它自己的標籤查對應的表——user: 查使用者、smc: 查 social client、room: 查聊天室。聊天室在公司內存活房間中裸名唯一時輸出 $room:<聊天室名稱>;否則在該 (部門, 聊天室) 組合唯一、且部門名稱本身不含 / 時,輸出 $room:<部門名稱>/<聊天室名稱>。對應不出來的一律原樣輸出:principal 欄位輸出原始帶標籤 cell(user:<id>、smc:<id>、room:<id>),舊型別欄位與 grant 則輸出原始 id。這涵蓋兩種形式都模稜兩可的聊天室、名稱本身含 / 的聊天室、被軟刪除或懸空的聊天室、(platform, platform_user_id) 在公司內對到多筆的 smc、模稜兩可或已軟刪除的部門,以及跨租戶或 username 為空的 id。被軟刪除的使用者仍然會被 token 化,這樣重新套用時會在解析器大聲失敗,而不是把一個死掉的原始 id 帶下去。同一個未變動系統的兩次匯出是位元相同的;帶有原始帶標籤退路的匯出,在原本的 scope 重新 plan 依然是 0 差異。

匯出與公開曝光

公開讀取 token 把 principal 欄位當成整個功能的 PII 邊界,而且這道閘門是從「家族」推導出來的——所以 principal 上線的第一天就繼承了預設隱藏。除非管理員明確指名——同時出現在檢視的 columns 與/或 token 的 visible_columns,並通過兩者的交集——否則三種型別一律被強制隱藏。即使公開,公開路徑也只提供原始字串;它從不執行顯示加工器,所以匿名讀者永遠拿不到姓名。沒有任何方式可以公開一個人或一個聊天室的名稱。

鑄造公開 token 時也會拒絕引用 $me 或 $me.department 的 read_filter:read_filter may not reference $me / $me.department on a public token (no principal exists); $today is allowed(422;IaC public_read line 回報的同一錯誤字尾是 …; $today and $now are allowed)。讀取時公開路徑會把 $me 解析成永不命中——在 principal 欄位上也一樣,因為兩個 principal 集合的 getter 都刻意不傳。若檢視自己的過濾或排序指向 token 與檢視未公開的 principal 欄位,會回傳統一的防探測 404,與未知、已撤銷或已過期的 token 完全相同。呼叫端 /query 的過濾或排序指向這類欄位,則是 400 Column '<name>' does not exist in table schema——與欄位不存在時的回應相同。

型別轉換

把欄位轉成或轉離任何一種 principal 型別——包括 user ⇄ social_client 與 user ⇄ principal(值空間互不相交)——都不會做任何轉換。每筆 live 紀錄的每個 cell 都會重設為新欄位的預設值(垃圾桶內的紀錄會被略過):目標是 principal 型別時永遠是 null(它不能宣告預設值),從 principal 轉成其他型別時則是同一請求中送出的 default_value。沒有「加上前綴」的 user → principal 轉換:把既有的負責人欄位轉成 principal,資料會全部消失。請改為新增一個 principal 欄位,再透過寫入路徑回填。

還有四個相關邊界。必填欄位完全無法轉成 principal 型別:型別變更驗證器要求非空欄位必須提供 default_value,而 principal 欄位禁止 default_value,這是一個死路(Type change from '<old>' to 'principal' requires a default value for required (non-nullable) columns. Please provide a default_value.)。若該欄位被計算欄引用,轉型會是結構化的 409,其 detail 為 {"detail": "Column is referenced by computed columns", "conflicts": [...]},而不是 400。屬於 unique_tuple 規則成分的欄位完全不能轉型(400 column type is protected by a unique_tuple rule; remove the rule first)。已有紀錄的表把欄位轉成或轉離 decimal 也會被拒絕(400 decimal type/config changes on a table with records require an explicit data migration/decimal type changes on a table with records require an explicit data migration)。

還原歷史版本時,principal cell 會先被以字串做型別檢查,接著整列還原後的資料會依目前的欄位規則驗證:不是帶標籤 cell 的 principal cell 會回 409,detail.conflicts 帶 invalid_value 類型的項目,不論這次還原有沒有變更該 cell。然後還原路徑會對這次還原會變更的 principal cell 重跑歸屬閘門——所以簽核的時間差無法把已刪除、已關閉聊天室或跨租戶的身分洗回一列裡。還原一個負責人已離職的版本會明確失敗,回閘門的 400(Unknown or inaccessible user ids: ['<id>'])。

常見陷阱

  • 寫入形狀不等於讀取形狀。 寫進去是字串,讀回來是物件。把剛 GET 到的物件直接 PUT 回去會被拒絕。在 principal 欄位上要送 value.ref,在舊型別欄位上要送 value.id。這是最常見的整合錯誤。
  • 讀取的 cell 是 string | object,兩種都要處理。
  • principal cell 一定要有標籤。裸 id 不是簡寫,而是 400。
  • 有沒有 ref 就是分辨 principal cell 與舊 cell 的方法。舊 cell 從來沒有 ref/kind。
  • principal filter 上未帶標籤的運算元在型別化的查詢與政策路徑都是 400,這是刻意的:否則 eq 會靜默回零列、neq 會回每一列。Trigger 的 when 與 source.filter 值,以及套用儲存檢視或公開讀取查詢時的舊版 filters map,都不會檢查標籤——請寫成帶標籤的 cell。
  • name_eq/name_contains 在 user/social_client 上可用,在 principal 上被拒絕——先解析,再用 ref 過濾。
  • 沒有任何紀錄查詢能直接依 principal 欄位排序;REST 與 agent 的 query_records 都會拒絕。仍會依 principal 值排序的途徑——REST 的 sort_by 與 agent query_records 的 sort_column 對帶出 principal 欄位的 cardinality-one 或帶 pick 的 lookup 排序、agent aggregate 對 principal group_by 鍵的 sort_by,以及 command SELECT 的 order_by——都依原始儲存字串排序。
  • 轉成或轉離 principal 型別會清空整欄資料。
  • 負責人欄位上的 $me 政策會讓所有人都看不到未指派的列。
  • lookup 帶出的 principal 欄位、REST aggregate 的分組鍵、歷史快照,以及由 expand_links、incoming links 或 records/lookup 內嵌的連結列,都是原始字串——不會加工。
  • IaC 身分 token 只在 IaC 有效。在 principal 欄位上它現在會解析——$room:Support Desk/Tier 1 會存成 room:<id>——但把 $user:alice 送進 REST 紀錄 API 仍然會被拒絕。
  • 指派 room:<rid> 不需要成員身分——但該聊天室必須存活且屬於本租戶。
  • CSV/XLSX 匯入會原樣寫入 cell 文字:principal 目標需要帶標籤的 cell、舊型別目標需要原始 id——沒有姓名或 $ token 解析——而且每一列仍會通過歸屬閘門,所以有問題的 cell 會變成匯入 ticket 上的逐列錯誤。
  • 含必填 principal 欄位的表完全無法使用測試資料產生器:產生器對 principal 欄位輸出 null,不會捏造身分。≤2000 列的路徑會在 schema 驗證失敗;背景路徑則在插入任何資料前拋出 Cannot generate test data: required principal column '<internal key>' cannot be auto-populated。做展示時請把該欄位設為選填。
  • lookup 的設定錯誤訊息仍然列出加入 principal 之前的純量清單。不要把那段錯誤文字當成允許型別的參考——principal 欄位是合法的 lookup 目標。

試試看

在 API Playground 用 columns.create 加一個 principal 欄位,用 room:<某個存活聊天室的 id> 寫一筆紀錄,比較回應的 cell 與你送出的內容——接著在更新時把 ref 原封不動送回去。然後在該欄位上鑄造一個 "value": "$me" 的過濾式授權,再以該聊天室成員的身分讀取整張表——請見 Grant 類型與 row policy。

Last updated on