第三次審查匯出功能,工程師又看見同一個問題:查詢忘了限制租戶。

第一次,他在 PR 留言。第二次,他把「所有查詢都必須帶 tenant ID」寫進 AGENTS.md。第三次,另一個 Agent 為了完成報表,複製了舊程式裡一條沒有租戶條件的查詢。規則一直都在,錯誤也一直能被寫出來。

這是本文的虛構案例。它讓一個問題變得具體:每次糾正之後,下一個執行者究竟少了一條犯錯的路,還是只多了一段要記住的文字?

Lauren Tan 在這則 @poteto 原貼提出四層倒金字塔:程式庫結構、自動檢查、文字指引、人工審查。越靠前,越值得投入工程力氣;人工審查放在最窄的一端。本文於 2026-10-18(臺北時間)讀取原貼正文,以此整理工程投入的優先順序。

我認為這個主張最值得採用的地方,是把 review 留言當成環境缺口的線索。今天指出一個錯誤,明天還要靠人認出它,團隊就沒有從這次修正得到多少複利。

四層是在分配工程力氣,不是宣告哪一層可以刪掉

原貼把改善程式庫放在最前面,包括架構、型別、不變條件與邊界;其次是 lint、測試、CI 與形式驗證;接著才是 comments、AGENTS.md、skills;最後是人工作業。她特別推薦 verification skill,也提醒人審很難隨 Agent 的產出量等比例擴張。

這是作者的工作方法,原貼沒有提供四層投資的量化對照實驗。它也不是一份取消審查、開放自動合併的授權書。以下把它套到匯出功能,是本文的設計推演,沒有在真實客戶資料上實測。

有一個術語要拆開:原貼將第二層稱為 static analysis,但 tests、CI 與 TLA+ 不能全算成靜態分析。測試會執行程式,CI 是執行檢查的流程,形式化方法檢查的是明確建模的性質。把它們一起理解成「可重跑的檢查」,比較不會誤判每一種證據能保證什麼。

這四層也不是互斥的四個工具箱。模組邊界可能靠 lint 維持;型別需要編譯檢查;驗證 skill 可能負責啟動測試環境。判斷投資有沒有向上移,應看錯誤能不能被攔住,而不是檔案最後放在哪個目錄。

把重複糾錯的投入往上移四層由上而下逐漸收窄:程式結構、可執行檢查、文字與驗證技能、人工判斷。寬度表示投入優先順序,不代表量化比例。每一層仍然需要存在。讓下一個 PR 少依賴一次人的記憶01 程式結構授權入口收斂資料存取,減少繞路02 可執行檢查跨租戶反例與依賴邊界,持續攔錯03 文字與驗證技能保留理由、操作步驟與可核對證據04 人工判斷決定需求、授權範圍與風險取捨
01 程式結構

授權入口收斂資料存取,減少繞路

02 可執行檢查

跨租戶反例與依賴邊界,持續攔錯

03 文字與驗證技能

保留理由、操作步驟與可核對證據

04 人工判斷

決定需求、授權範圍與風險取捨

四層投入優先順序。寬度是概念示意,沒有量測比例;上游攔住重複錯誤,人審保留無法機械判定的決策。

第一層:讓合法入口收走容易做錯的決定

回到報表。假設每個功能都可以拿到全域資料庫 client,自己組查詢。要求大家記得加租戶條件,等於把隔離責任分散到每個呼叫端。功能越多,需要記得的地方就越多。

一個較小的改善,是提供經授權的租戶資料存取入口。HTTP 邊界先驗證身分與租戶關係,報表模組只能取得該範圍的 repository。匯出功能提出「取出這個租戶可見的訂單」,不用重新決定怎麼建立隔離條件。未經限制的 client 留在少數基礎設施模組,普通功能不能直接引用。

但這裡不能把漂亮介面當成安全保證。若 repository 裡仍然忘記套用租戶條件,錯誤只是集中到一個地方。集中責任的價值,是讓檢查有固定落點,並減少散落的繞路;隔離是否成立,仍要用反例驗證。

型別也有相同限制。TypeScript 的 discriminated union 能讓不同狀態攜帶不同欄位,降低漏處理分支的機會。它無法替資料庫證明眼前使用者有權讀取某筆訂單。HTTP 輸入、資料庫內容與外部服務回傳都在執行期,必須另有驗證。

因此,我會把「結構上不容易做錯」寫成兩個可檢查的問題:功能能否繞過授權入口?合法入口本身是否真的限制了資料?前者靠依賴邊界收斂,後者靠執行期驗證。兩個都要回答,不能只展示一個叫 TenantId 的型別。

第二層:檢查要能抓住那個已經發生過的錯誤

如果這次只新增一個正確匯出的測試,第三次事故仍可能重演。測試要故意準備兩個租戶,讓資料形狀相似,然後用租戶 A 的身分要求匯出。輸出的每一筆都必須屬於 A;把 B 的識別值放進請求,也不能取得 B 的資料。

接著,故意移除 repository 的租戶條件,確認同一個測試會失敗。這一步檢查的是測試對該缺陷是否敏感。若條件刪掉後依然全綠,可能是 fixture 沒有跨租戶資料,也可能測試只檢查 HTTP 200,根本沒看輸出。

這些都是建議的驗收情境,本文沒有替任何實際服務跑過。它們的用處,是讓團隊能指定下一步要取得的證據:

  • 跨租戶反例失敗在哪個斷言,而不是只有一個紅色狀態。
  • 修正後,同一輸入在最後提交上得到什麼結果。
  • 測試是否走到真正的查詢與匯出路徑,哪些外部部分仍以替身取代。
  • 匯出檔案、暫存檔與下載連結是否也維持同一個授權範圍。

lint 可以再阻止功能層直接 import 不受限的 client。這是把第一層邊界變成持續檢查,相關做法見深模組與 lint 的入口設計。lint 不理解完整商業語意,不能用「禁止某個 import」宣稱整套租戶隔離已證明。

最後,檢查必須成為交付流程會執行的條件。GitHub 的 required status checks 可以限制合併;但實際效果仍取決於規則套用對象與繞過權限。Repository 裡有一支測試,和每個要合併的提交都必須通過它,是兩回事。

第三層:文字留下理由,verification skill 指向可觀察的結果

把能機械檢查的規則上移之後,文字仍有工作。為什麼財務人員可以跨租戶?哪些跨租戶匯出需要依組織政策或適用規範保留稽核紀錄?測試 fixture 為什麼不能用正式客戶資料?這些理由不應因為「少寫文字」就消失。

適合留在 AGENTS.md 的,是執行入口、禁止越過的權限、必要文件與無法自行決定時的停止條件。適合放在註解或決策紀錄的,是程式本身沒有表達的限制與取捨。若只把程式動作改寫成中文,再要求 Agent 一定要讀,維護成本會增加,保證卻不會跟著增加。

verification skill 的特殊價值,是把「請驗證一下」展開成可執行程序。pstack 的建立驗證技能流程以啟動、環境診斷、操作、證據與清理串起驗收,並用 feature map 描述功能的到達方式。這些文字仍可能被略過,所以完成判斷必須看留下的證據,不能只看 Agent 說它載入了 skill。

用在匯出功能,驗證紀錄應指出使用哪個測試租戶、從哪個頁面觸發、下載內容如何核對、跨租戶請求如何被拒絕,以及暫存資料如何清理。若環境缺少下載所需的儲存服務,就寫清楚未驗證下載路徑;不能拿 API 測試成功補成整條使用者流程成功。

pstack 的安裝與首次操作已整理在第一個可重跑的修復。這篇關心的是它在四層裡的責任:把人會做的操作與取證說明白,讓執行者交出能核對的結果。它沒有自動取代資料庫約束、權限控管或合併決策。

最窄的一層,仍要有人問「這份報表該不該存在」

當機器能攔住跨租戶讀取、非法 import 與固定回歸,人就有餘裕看另一類問題:業務真的需要下載完整資料嗎?是否只需要彙總?既有權限是否過寬?這些問題即使測試全綠,也可能要求取消或重新設計功能。

原貼主張減少依賴人審的投入,我會把它理解為減少重複勞動。重複指出漏加條件,很適合變成測試;批准新的資料分享範圍,仍然需要有權做決定的人。安全邊界、不可逆資料變更與金流責任,也不能因為 Agent 產出變多就自動降級成抽樣。

同樣地,reviewer 發現測試漏掉一條路徑時,不應只修眼前這個 PR。應追問漏的是哪個共同入口、哪個 fixture,或哪個未被定義的要求。人的發現可以成為下一輪結構與檢查的輸入,這才讓最窄的一層有機會承受更多產出。

至於同一個 PR 要審到何時停止,是另一個決策;可接著讀AI 審查越勤,PR 為什麼反而合不進去?。本篇處理重複錯誤的防線放哪裡,該篇處理本輪審查的退出條件,兩者不能互相代替。

下一次糾正,留下可以重跑的反例

Lauren 也建議回看過往回饋與變更,找反覆出現的問題,再提出改善程式庫的計畫。把這個方法縮到一次小改動,不需要先整理所有歷史對話。

挑最近一則已確認、第二次出現的 review 留言。寫下觸發輸入、錯誤結果與應有結果,再決定它適合在哪裡被擋下:不合法狀態就收進型別或資料約束;繞過入口就收緊模組邊界;具體回歸就補會失敗的測試;需要人的風險取捨,留下理由與核准條件。

接著讓另一個執行者拿同一個反例驗證,不提供原本那段糾正對話。如果還得靠作者站在旁邊解釋「這次記得」,改善就還沒完成。

四層倒金字塔最後落到一個很實際的標準:每修正一次重複錯誤,就讓下一個 PR 少依賴一次人的記憶。做不到的部分明確留下給人判斷,不用更多規則文字假裝已經解決。

來源與閱讀範圍

本文聚焦反覆糾正應該轉成哪一種約束。關於並行 Agent、應用操作與交付證據的完整工作流,可讀Agent 要能並行,先把工作變成可驗收結果;兩篇分別處理投資選擇與交付流程。