immutable_when 規則
immutable_when 在列的 pre-image 符合條件時凍結指定欄位。它回答其他寫入規則都答不了的問題:「這列一旦 posted,數量就再也不能改。」
在它之前,這個契約沒有正確的寫法。transition 比對單一 scalar 欄位的前後值,因此一筆讓 Status 維持 posted、只偷改 Qty 的寫入根本不是 transition,什麼都不會觸發。其他規則的 when 前置條件都以 post-image 判斷,於是呼叫者只要在同一筆寫入裡同時解鎖並修改 —— {"Status": "draft", "Qty": 6} —— 條件 Status eq posted 就永遠不會命中那列它本來要守的資料。把鎖定條件改成以寫入之前的列來判斷,這兩個洞同時補上。
Payload
{
"type": "immutable_when",
"name": "posted-locks-qty",
"prior_when": [
{ "column": "狀態", "op": "eq", "value": "posted" }
],
"columns": ["數量", "單價"]
}prior_when 是鎖定條件:最多五個 predicate,以 AND 組合,使用與 when 完全相同的詞彙 —— 相同 operators、相同的型別值檢查、同樣接受 col_<hex> 或顯示名稱。columns 是被鎖欄位集合:最多 20 個 ref,存檔時去重。兩者都以 rename-stable 的內部 key 儲存,並由 rules.get 以顯示名稱回吐。
由於 prior_when 沿用 when 的驗證器,超過上限的錯誤訊息仍是 when 的用字:400,detail 為 when exceeds max 5 predicates。
沒有 when,這正是重點
immutable_when 不接受一般的 when。帶了非空的 when 會在設定階段回 400:
immutable_when rules do not take a when pre-condition — use `prior_when`, which evaluates against the row's PRE-image以 post-image 判斷的 when,會被它本該擋下的那筆寫入自己打敗。prior_when 讀的是已儲存的列,所以同一個 request 裡把 Status 改成 draft 解不了鎖:pre-image 依然是 posted。
被鎖欄位也可以同時出現在 prior_when。在條件 Status eq posted 下鎖住 Status,就讓 posted 成為終端狀態 —— 這是 transition allowlist 表達不出來的單向 latch,因為狀態機終究得列出某條出路。
哪些寫入會被判斷
| 變更 | 是否判斷 |
|---|---|
create | 否。新列沒有 pre-image,所以一列可以一出生就是 posted 且帶任意值 |
update、revert | 是 |
restore | 否。Restore 原樣重放已儲存的資料 |
delete | Delete 不執行任何 predicate 規則 |
被鎖欄位採用與 transition 相同的正規化:null 與 "" 視為同一個值,因此把本來就空的欄位清空不算變更。沒有列進 columns 的欄位,在被鎖的列上仍可自由編輯;pre-image 不符合 prior_when 的列也可自由編輯 —— 包括把自己改進鎖定狀態。
這道鎖位於與 compare、check、require、transition 相同的 before-insert / before-update 卡口,因此 REST、bulk actions、callback、trigger record action 與 agent toolkit 都會撞上它。唯一會跳過它的,是下面說明的核准套用重放。
違規回什麼
400,detail 是一個純字串:
{
"detail": "Rule 'posted-locks-qty' violated: '數量' cannot change while the row matches the lock condition (prior_when evaluates the row as it was before this write)"
}Rule '<label>' violated: 前綴取規則的 name,其次是伺服器產生的 id —— 每一條存下來的規則都有 id,所以沒命名的規則會顯示成 Rule 'rule_b2c3d4e5' violated,而不是印出型別。請替規則命名。欄位名稱是顯示名稱。
同一筆寫入若改動多個被鎖欄位,它們會被逗號串成一則訊息,而不是分開回報:
Rule 'posted-locks-qty' violated: '數量', '單價' cannot change while the row matches the lock condition (prior_when evaluates the row as it was before this write)不要靠解析這個字串取出欄位清單來做欄位級的標紅;你自己送出的 request 已經知道碰了哪些被鎖欄位。
設定階段的拒絕
以下每一項都是 rules.set 的 400(rules.preview 回同樣的字串),detail 都是純字串。有 hint 的錯誤,伺服器會在訊息後面接一個句點再把 hint 併進去。
| 原因 | detail |
|---|---|
帶了非空的 when | immutable_when rules do not take a when pre-condition — use `prior_when`, which evaluates against the row's PRE-image |
prior_when 缺少或為空 | immutable_when requires a non-empty prior_when predicate list (the lock condition, evaluated against the row's pre-image). e.g. [{"column": "status", "op": "eq", "value": "posted"}] |
columns 缺少或為空 | immutable_when requires a non-empty columns list (the fields locked while prior_when matches) |
| 被鎖欄位超過 20 個 | columns exceeds max 20 |
columns 內有非字串或空字串 | each locked column must be a column reference |
| 被鎖欄位不存在 | column '<ref>' not found in '<Table>' 後面接候選欄位 hint |
columns 內是計算欄或 link 欄 | column '<Name>' is type formula — immutable_when locks only columns stored inline in the record (computed columns are read-only already; link values live in link tables and the lock would never fire) |
全表上限不變:10 條規則,immutable_when 也算在內。
核准是官方認可的覆寫途徑
同一張表若有相符的 require_approval 規則,這道鎖根本沒有機會否決該筆寫入。寫入會在規則引擎執行前就被暫存,因此呼叫者收到的是一般的 409 approval_required 加上 process_id 與 staged_change_id,不是鎖的 400。見 require_approval 與核准流程指南。
Moderator 核准後,套用階段會帶著 session 的 approval bypass 旗標重放該筆寫入,規則評估會跳過 immutable_when —— 而且只跳過 immutable_when。其他 predicate 規則在重放時照常執行。核准後的值會穿過這道鎖,staged change 最終落在 applied。
這個跳過是刻意設計,不是漏洞。少了它,重放會拿仍然被鎖的 pre-image 再判一次,違規會逃出套用處理器,staged row 會卡在 applying,且每次 reclaim 都救不回來。
Warning 這道鎖要讀成「沒有人能悄悄改動它」,不是「永遠沒有人能改」。在有相符核准規則的表上,moderator 仍可放行 —— 而留下的軌跡是那筆 staged change,不是一次拒絕。
刪除或轉型被引用的欄位
條件欄位與被鎖欄位都是被依賴的引用。刪除或轉型其中任何一個都會回 409 與結構化的 conflicts 清單:
{
"detail": "Column is referenced by computed columns",
"conflicts": [
{
"type": "dependent_rule",
"field": "posted-locks-qty",
"message": "Write rule 'posted-locks-qty' references this column ('狀態'); delete the rule first"
}
]
}條件欄位和被鎖欄位一樣重要。若把 prior_when 的欄位刪掉,predicate 會把缺少的 key 讀成 null,條件對每一列都不再成立,鎖就靜靜地停止生效 —— 這正是這個 409 要擋下的「降級成放行」失效模式。
它不做什麼
- 它不管
create,因此無法約束初始值。那要用check或欄位預設值。 - 它不能鎖計算欄或 link 欄,兩者都在設定階段被拒。Link 成員的變動完全不在這條規則的範圍內。
- 它不阻止刪除,也不阻止先刪再重建。
- 它不要求欄位有值。被鎖的值若同時必須存在,請搭配
require。 - 它不約束你沒有列進
columns的欄位。
用 IaC 撰寫
不需要新的 line kind。rule line 的 spec 是自由 dict,直接交給 REST endpoint 用的同一個驗證器,所以 immutable_when 規則和其他規則一樣寫。prior_when[].column 與 columns 內的欄位引用,由 plan、apply、export 共用的同一個通用 ref walker 轉譯,因此一條鎖能通過 export → plan → apply 並在重新 plan 時是 noop。
先用 rules.preview 驗證候選清單,再以 rules.set 發布完整清單。