Skip to Content
核心概念JSONL IaCPlan 與 Apply

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。回應包含 versionkindsresource_types 與一條代表性的 line;response schema 才是權威的 discriminated IacLine union。見 iac.contract

Diff 類別

Action意義審查重點
create沒有 state 或可採用資源,將建立新 resource名稱、spec 與依賴順序
adoptref 將接管可唯一辨認的既有 resource候選是否正確;採用後哪些欄位成為 IaC-owned
moverenamed_from 把既有 state/ref 移到新 ref新舊 ref 是否代表同一資源
updatelive 值與宣告 owned fields 不同beforeafterdrifted_fields
noopresource 已符合宣告是否真的是預期的收斂狀態
delete明確 state: "absent" 將移除 live resource依賴、資料影響與復原方案
orphanedstate row 指向已不存在的 live resource是否應清理 state 或恢復資源
insertrecord natural key 尚不存在key 與資料值是否正確
skiprecord 已存在,或 drift 依策略保留on_drift 是否刻意選擇

Plan 以文件順序建立主要 action;record 在資源 refs 可解析後處理。Action 可能有 warning 或 error,必須逐項審查,不能只看 HTTP status。

破壞性變更

刪除從不由「文件裡沒寫」推導。只有 state: "absent" 會規劃 delete;table 是唯一允許 absent 行仍帶 spec 的 kind,用於先採用既有表格再刪除。Column、rule、trigger、view、grant、commandpublic_read 的 absent 行都不能帶 spec——這七種 kind 觸發的是完全相同的訊息。Table 刪除是 soft delete,但仍可能牽動欄位、資料、規則與外部流程,因此要把依賴 warning 當成 release gate。

state 存在於九種 kind:table、column、rule、trigger、view、grant、commandinsight_selectionpublic_readheaderrecordclient_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 或可解析的既有表格狀態決定,而且不能是 booleanjsoninterval 欄位。Principal(principalusersocial_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_readcommand 的 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 錯誤commandtrigger 的 before-fingerprint 在規劃與該行執行之間變動。該行回滾;apply 繼續
IacLineError.code == "retryable_lock_conflict"200 apply 回應內的 per-line 錯誤該行遇到 MySQL 12051213。該行回滾;apply 繼續

iac_plan_stale 涵蓋 command 的 resource、state、tag 或 dependency digest,以及 trigger 行的 before-fingerprint;對應 detail 分別是 command changed after planning; run plan againtrigger 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 只會是 submittingfinishedfailedfinishedIacApplyResponse 放在 body.resultfailed 則在 body.error.codescope_unavailabledoc_unavailableparse_errorplan_stalevalidate_errorinternal_error。Worker 一定會重新下載、parse 與 plan 暫存文件,所以 parse_errorvalidate_error 描述的是拿到 202 之後的防御性 recheck,不是原始 request-time 400422 的另一種 ticket 形式。Ticket 不存在或 Redis record 已過期時,poll 仍回 HTTP 200,但內容是 ttl: -1body: null。這是 public endpoint,因此要把不可猜的 ticket id 當作 capability,不要記錄或分享。

不是每個 apply 錯誤都對應得到某一行。IacLineError.linege=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 foundCompany not found,command mutation 也另有涵蓋三種 scope 的 command-root lock;但一般的 per-mutation root re-lock 目前只套用在 chatroom 文件。

審查清單

  • JSONL 與 plan 來自同一 commit,而且 plan_hash 仍與用目前 live state 重算的 plan 相符;hash 本身沒有 TTL。
  • 每個 adoptmovedelete 與 drifted update 都有明確理由。
  • Table/column 的跨 ref、filtered grant 的 policy,以及 record key 都解析到預期資源。
  • 沒有忽略 action-level error 或 warning。
  • Apply 後已排定 re-plan,預期 resource noop、record skip

動手試試

IaC 工作台載入一份 export,先改 description 觀察 update,再把 live 值手動變更後重新 plan,辨認 drifted_fields。全程使用測試 scope,直到你能解釋每個 action 才 apply。

Last updated on