Plan 與 Apply
Plan 與 apply 使用同一份 JSONL,但職責不同:plan 只讀並產生可審查差異;apply 重新計算同一差異,核對已審查的 hash 後才執行。可靠的紀律不是「先試 apply」,而是「每一次 apply 都源自剛審查的 plan」。
兩個端點,一套紀律
POST .../tables/iac/plan 回傳 actions 與 plan_hash,不寫入 resource 或 IaC state。POST .../tables/iac/apply 接受相同 NDJSON,並以 plan_hash query parameter 綁定審查版本。API 為相容性把該 query 標成 optional,但本章的標準流程一律提供;不帶 hash 就失去 plan-stale 保護。端點與完整 request/response schema 可查閱 plan 參考與 apply 參考。
Plan 與 apply 刻意接收原始 JSONL,所以它們的 OpenAPI request body 無法呈現有型別的 line union。生成或驗證文件前,請在同一個 chatroom、department 或 company scope 呼叫經驗證的 GET .../tables/iac/contract。回應包含 version、kinds、resource_types 與一條代表性的 line;response schema 才是權威的 discriminated IacLine union。見 iac.contract。
Diff 類別
| Action | 意義 | 審查重點 |
|---|---|---|
create | 沒有 state 或可採用資源,將建立新 resource | 名稱、spec 與依賴順序 |
adopt | ref 將接管可唯一辨認的既有 resource | 候選是否正確;採用後哪些欄位成為 IaC-owned |
move | renamed_from 把既有 state/ref 移到新 ref | 新舊 ref 是否代表同一資源 |
update | live 值與宣告 owned fields 不同 | before、after、drifted_fields |
noop | resource 已符合宣告 | 是否真的是預期的收斂狀態 |
delete | 明確 state: "absent" 將移除 live resource | 依賴、資料影響與復原方案 |
orphaned | state row 指向已不存在的 live resource | 是否應清理 state 或恢復資源 |
insert | record natural key 尚不存在 | key 與資料值是否正確 |
skip | record 已存在,或 drift 依策略保留 | on_drift 是否刻意選擇 |
Plan 以文件順序建立主要 action;record 在資源 refs 可解析後處理。Action 可能有 warning 或 error,必須逐項審查,不能只看 HTTP status。
破壞性變更
刪除從不由「文件裡沒寫」推導。只有 state: "absent" 會規劃 delete;table 是唯一允許 absent 行仍帶 spec 的 kind,用於先採用既有表格再刪除。Column、rule、trigger、view、grant、command 與 public_read 的 absent 行都不能帶 spec——這七種 kind 觸發的是完全相同的訊息。Table 刪除是 soft delete,但仍可能牽動欄位、資料、規則與外部流程,因此要把依賴 warning 當成 release gate。
state 存在於九種 kind:table、column、rule、trigger、view、grant、command、insight_selection 與 public_read。header、record 與 client_access 沒有。不要用這三種表達資源刪除;grant 的 state: "absent" 才是撤銷授權的明確方式。
有兩種 absent 生命週期不是一般意義上的刪除:
public_read的 absent 行是撤銷 token。Plan action 仍顯示delete,但 apply 只設定revoked = true,永不硬刪除該列。燒掉的 capability URL 永遠是死的,之後的present行會發行 URL 全新的 token,而不是讓舊的復活。command的 absent 行會軟刪除受管理的 command;若該 command 有進行中的 staged execution,這一行會被擋住。
Drift 與欄位擁有權
IaC 使用三方比較:本次 desired spec、state 裡的 last-applied spec,以及目前 live resource。只有曾由宣告提供、且本次仍提供的欄位屬於 IaC 管理範圍;省略欄位不等於重設預設值,而是保留 live 值並放棄該欄位的宣告擁有權。
如果 managed field 在上次 apply 後被手動修改,plan 會把它列入 drifted_fields 並以 desired value 規劃 update。先判斷 live 修改是意外 drift,還是應先 export/編輯文件納入的新意圖。
Record Drift
Record 以表格 natural key 比對,不寫入 IaC state。on_drift 控制 key 已存在但其他 declared cells 不同時的行為:
"skip"(預設)保留 live row,action 為skip,並警告declared cells drifted; on_drift=skip leaves the live row unchanged。"update"規劃更新 declared cells;未宣告欄位不受影響。
Natural key 必須能由 table spec.key 或可解析的既有表格狀態決定,而且不能是 boolean、json 或 interval 欄位。Principal(principal/user/social_client)欄位可以當 key,因為 principal cell 就是一個純量字串。
已審查 Hash 與 409
Apply 會用目前 live system 重新 plan。如果重新計算的 hash 與 plan_hash query parameter 不同,伺服器不執行任何 action,回傳:
{
"detail": {
"code": "plan_stale",
"message": "The live system changed since this plan was computed; re-plan and retry.",
"plan_hash": "<recomputed hash>"
}
}這個 409 是 optimistic concurrency contract。回應中的 hash 只能指出伺服器已有新 plan;仍須重新取得完整 plan 並審查差異,不能直接拿新 hash 重送。
plan_hash 現在折入的東西比「文件位元組 + table 指紋 + grant 指紋 + 觸及的 state 列」更多:它還折入 view、public_read 與 command 的 live 指紋、grant principal 與 insight_selection.chatroom 中每一個可攜身分 token 的解析結果(record cell、trigger when 值與 link 自然鍵裡的 token 走同樣的解析,但不會綁定),以及每一行的 before-fingerprint。因此 hash 變 stale 的理由比以前更多——別人在別處改了一個 public-read token 或一個 command 就足夠。
這個擴大也讓「釘住 hash」更值得多打一次 API。不帶 plan_hash 時,grant 與 insight selection 的身分 token 會綁到 apply 當下持有該名稱的人;帶了它,審查與 apply 之間的改名會重新蓋章,該次 apply 會被拒絕。
具型別的 Apply 與並行訊號
不要把文件層級守衛與兩種具型別的 per-line 訊號混為一談:
| 訊號 | 形狀 | 意義 |
|---|---|---|
409 {"code": "plan_stale"} | 文件層級的 HTTP status | 釘住的 plan_hash 不再相符。完全沒有執行 |
IacLineError.code == "iac_plan_stale" | 200 apply 回應內的 per-line 錯誤 | command 或 trigger 的 before-fingerprint 在規劃與該行執行之間變動。該行回滾;apply 繼續 |
IacLineError.code == "retryable_lock_conflict" | 200 apply 回應內的 per-line 錯誤 | 該行遇到 MySQL 1205 或 1213。該行回滾;apply 繼續 |
iac_plan_stale 涵蓋 command 的 resource、state、tag 或 dependency digest,以及 trigger 行的 before-fingerprint;對應 detail 分別是 command changed after planning; run plan again 與 trigger changed after planning; run plan again。因此,規劃與該行執行之間的外部 trigger drift 可能呈現為 per-line iac_plan_stale。
有一種同次 apply 內的精確變動不算 drift。前一個成功的 column、rule 或 trigger 行可能透過 server-owned dependency maintenance 刷新相依 command 並推進 dependency digest,不限 DSL version;後面的 command 行若看到的是已記錄的精確 digest 推進,executor 會接受,不產生假的 iac_plan_stale。未記錄或外部造成的推進仍 fail closed。
retryable_lock_conflict 是 MySQL 1205 lock-wait timeout 或 1213 deadlock 的穩定分類。其 detail 會淨化成 Concurrent write conflict (lock); please retry the request.;回應絕不包含原始資料庫文字、SQL 或參數。由於更早或更後面的行可能已套用,遇到任一具型別的 per-line 訊號後,應先讀取目前狀態,再執行全新的 plan/審查/apply;不要盲目重送舊 hash。其他舊有 apply diagnostics 的 code 不設值。
Apply 執行
Parse/validate error 會在執行前讓整份文件失敗。通過 gate 後,apply 依文件順序 per-line best-effort 執行;某一行失敗會回滾自己的 transaction、記錄 phase: "apply",然後繼續,先前成功的行不會整份回滾。Column/rule 行使用一般 dependency refresh:成功 recompile 與 business mutation 共用 commit;非資料庫 refresh failure 則略過並讓 command 維持 stale,不 veto 該行。Trigger 行更強:trigger、schema history、trigger IaC state 與所有 selected refreshed command definitions 構成同一 rollback bundle,refresh failure 會 veto 該 trigger 行。Column/rule/trigger/view 的 free-dict spec 還會在持久化路徑經過完整 CRUD validator,因此 clean plan 後仍可能出現 per-line apply error。Apply 回應必須逐 result 檢查,修正後重新 export/plan,不能只看 HTTP status。
可執行的 resource actions 會更新 ref state,再處理 record。大量 record 可能由非同步工作完成;以 apply 回應與後續 plan/state 查詢確認最終結果,不要把 request 已接受等同所有變更已收斂。
Ticketed Apply
當 record-line count 超過 CUSTOM_TABLE_IAC_SYNC_RECORD_LIMIT,apply 仍會在任何 dispatch 之前完成 parse、validation、plan 計算,並檢查 supplied plan_hash。所以 request-time parse 400、validation 422 或 hash mismatch 409 絕不會回 ticket;只有通過這層 preflight 的文件才會被暫存、dispatch,並回 202 {"message", "ticket_id"}。
請輪詢 GET /public/task/{ticket_id}。Poll endpoint 本身固定回 HTTP 200;IaC 狀態嵌在 body 下,最上層沒有 status:
{
"ttl": 86399,
"body": {
"ticket_id": "11111111-1111-4111-8111-111111111111",
"status": "finished",
"updated_at": "2026-08-18T02:30:00+00:00",
"result": { "applied": {}, "results": [], "errors": [] }
},
"string": null,
"message": "The task has been found, and it is in JSON format."
}body.status 只會是 submitting、finished 或 failed。finished 把 IacApplyResponse 放在 body.result;failed 則在 body.error.code 回 scope_unavailable、doc_unavailable、parse_error、plan_stale、validate_error 或 internal_error。Worker 一定會重新下載、parse 與 plan 暫存文件,所以 parse_error 與 validate_error 描述的是拿到 202 之後的防御性 recheck,不是原始 request-time 400 或 422 的另一種 ticket 形式。Ticket 不存在或 Redis record 已過期時,poll 仍回 HTTP 200,但內容是 ttl: -1 與 body: null。這是 public endpoint,因此要把不可猜的 ticket id 當作 capability,不要記錄或分享。
不是每個 apply 錯誤都對應得到某一行。IacLineError.line 是 ge=0,其中 0 代表「與任何文件行無關」——apply 結束時的 tag 同步與孤兒回收都以這種形式回報,kind 為 "table"、ref 為 header 的 system:
tag sync failed: <exception>
orphan prune failed: <exception>Apply 的錯誤 detail 也刻意做過衛生處理,因為原始例外字串可能夾帶明文密語或未經淨化的 integrity error。Pydantic 失敗會變成 invalid configuration (N field error(s))、client_access 的例外變成 client_access configuration is invalid ({ExcType})、command 的例外變成 command configuration is invalid ({ExcType});大括號代表實際代入的 exception class 名稱。真正的原因不在回應裡,要去伺服器日誌找。
Chatroom 在 apply 中途消失
對 chatroom scope 文件,apply 會在每次 mutation 與最後的孤兒回收前重新鎖定 live chatroom root。若該房間在 validation 後被 soft-delete,目前 transaction 會回滾,request 回應:
404 Chatroom not found不要把這項行為推廣成 department 與 company IaC 的整份文件 mutation fence。這兩種 route 在 scope resolution 時仍可能回 Department not found 或 Company not found,command mutation 也另有涵蓋三種 scope 的 command-root lock;但一般的 per-mutation root re-lock 目前只套用在 chatroom 文件。
審查清單
- JSONL 與 plan 來自同一 commit,而且
plan_hash仍與用目前 live state 重算的 plan 相符;hash 本身沒有 TTL。 - 每個
adopt、move、delete與 driftedupdate都有明確理由。 - Table/column 的跨 ref、filtered grant 的 policy,以及 record key 都解析到預期資源。
- 沒有忽略 action-level error 或 warning。
- Apply 後已排定 re-plan,預期 resource
noop、recordskip。
動手試試
在 IaC 工作台載入一份 export,先改 description 觀察 update,再把 live 值手動變更後重新 plan,辨認 drifted_fields。全程使用測試 scope,直到你能解釋每個 action 才 apply。