安安~我是ChiYu~
昨天我補強了 Dependency Rule 與 Architecture Test,確保 Use Case 不會直接依賴資料庫或外部 SDK。不過,那組測試只守住 Use Case;Controller 仍能另開一條通知路徑,繞過既有的 Outbox/Dispatcher。
這次我請 Agent 新增「人工重送逾期通知」API。大約七分半後,找不到資料、狀態不符與單次成功三類明列測試全部通過。接著我連續呼叫同一條 API 兩次,Recording Gateway 卻留下兩次通知嘗試,新的 Controller Action 也完全沒有經過既有 Outbox。
候選沒有違反我交付的狹窄需求,問題出在完成定義太窄:它只驗收「單次呼叫能不能動」,沒有定義相同操作重送、Provider 失敗、Lost ACK,以及 202 Accepted 究竟承諾什麼。
唯讀審查完成後,我最後把 retry 定義為「重新排定同一筆失敗通知」,沿用原本的 Outbox Id 與 Idempotency Key;修正版本也讓 Controller 回到 Use Case,再由既有 Dispatcher 統一處理通知。今天要追查的是:為什麼功能能執行、測試全綠,仍然可能留下功能與結構傷害?
這個 Demo 管理一組工作項目。工作項目逾期後,系統必須送出通知。原本流程會先把「這則通知需要處理」記錄到資料庫,再由背景程序呼叫外部通知服務。
這條路徑用來處理外部服務失敗、程式中斷、重複呼叫與回應遺失。人工重送 API 一旦跳過它,單次呼叫雖然可能成功,原本的重試與去重能力也跟著消失。
flowchart LR
A[既有逾期處理] --> B[Use Case]
B --> C[Outbox]
C --> D[Dispatcher]
D --> H[Notification Sender]
H --> E[Provider]
F[AI 新增的人工重送 API] --> G[Controller]
G --> H
Clean Code 談完命名、函式、類別與架構後,到了軟體工藝,問題會回到工程責任:功能做錯、結構變差,或軟體開始影響真實世界時,不能只用「需求已完成」結案。
本篇把傷害分成三層,但只對有證據的範圍下結論:
| 層次 | 實際可能發生什麼 | 今天如何處理 |
|---|---|---|
| 功能傷害 | 系統交付錯誤結果,例如重複扣款、錯誤授權,或 API 回覆成功但工作沒有可靠保存 | 用重複 POST 檢查通知嘗試,再確認 202 Accepted 背後有沒有持久化的通知意圖 |
| 結構傷害 | 同一條規則分散在 Controller、Worker 與 Adapter,下一次修改容易只改到其中一條路徑 | 從完整 Diff 與 Architecture Test 檢查 Controller 是否繞過 Outbox,建立第二條通知流程 |
| 社會傷害 | 軟體錯誤開始影響安全、隱私、金融、醫療或公共服務 | 本篇沒有真實事故證據,只討論工程師不能忽略可能受影響的人 |
這個 Demo 沒有真實使用者或事故資料,因此本次只主張兩項結果:功能上觀察到兩次通知嘗試;結構上觀察到 Controller 繞過既有路徑。社會傷害只用來劃出工程責任,不會寫成已發生的事故。
今天也會多次碰到 202 Accepted。這個狀態碼表示伺服器已接受請求,但處理尚未完成;它不會替 Work Item API 定義「接受的是一筆已持久化工作、一次即時呼叫,還是只是收到 Request」。這層產品語意仍要由 User 補上。
我刻意把 Prompt 縮到正常路徑,只要求 Agent 處理找不到資料、狀態不符與單次成功;重複請求、外部失敗及可靠重送全部留到第二階段審查。
實驗從昨天已通過 Dependency Rule 檢查的接受版本開始,保留相同的 API、Outbox/Dispatcher、模型、推理強度與既有測試,只改變這次交給 Agent 的需求與驗收範圍。
| 實驗項目 | 設計 |
|---|---|
| 固定條件 | 相同基準版本、Codex GPT-5.6-SOL-HIGH、既有程式碼與測試 |
| Agent 收到的任務 | 找不到資料回 404、狀態不符回 409,單次重送被接受處理時回 202 |
| 刻意未指定 | 重複請求、Provider 失敗、Lost ACK 與 Outbox 契約 |
| 第一階段要觀察 | Agent 是否完成 User 明列的需求 |
| 第二階段要觀察 | 狹窄的完成條件漏掉哪些功能與結構風險 |
我交給功能交付 Agent 的需求如下:
新增:
POST /api/work-items/{id:guid}/overdue-notification/retry
明確行為:
- 找不到工作項目時回傳 404 Not Found。
- 工作項目不是 Overdue 時回傳 409 Conflict。
- 對逾期工作項目送出單次請求時,必須嘗試一次一般逾期通知,並回傳 202 Accepted。
- 加入能從公開 HTTP API 證明上述三條行為的必要測試。
- 維持所有既有測試通過。
限制:
- 不修改既有 Route、JSON Contract、SQLite Schema、背景 Worker、Outbox Dispatcher 或通知 Provider。
- 不新增套件、Migration、外部網路呼叫或部署項目。
- 以最小 Diff 完成,不順便重構其他 Controller Action。
- 本階段不要自行補上尚未明列的 HTTP 重送冪等、Outbox 持久化、Provider 失敗重試與 Lost ACK 契約。
第一個 Session 只驗證 Agent 能不能照明列需求交付;候選固定後,第二個唯讀 Session 再檢查完成條件漏了什麼。這樣才能分開兩個問題:Agent 有沒有照做,以及 User 給的完成定義夠不夠完整。
Agent 新增的測試確認三件事:找不到工作項目時回覆 404、狀態不符時回覆 409,單次正常請求會得到 202。
它們沒有回答相同請求送出兩次會怎樣、通知服務失敗時 Caller 會看到什麼,也沒有確認新的入口是否沿用既有 Outbox。綠燈的範圍,到測試斷言為止。
Action 本身不長,問題集中在三個設計決定:
IWorkItemNotificationSender。202 Accepted。完整 Action 如下:
[HttpPost("{id:guid}/overdue-notification/retry")]
public async Task<IActionResult> RetryOverdueNotification(
Guid id,
[FromServices] IWorkItemNotificationSender notificationSender,
CancellationToken cancellationToken)
{
var item = await database.WorkItems.FirstOrDefaultAsync(
workItem => workItem.Id == id,
cancellationToken);
if (item is null)
{
return NotFound();
}
if (item.Status != "Overdue")
{
return Problem(statusCode: StatusCodes.Status409Conflict);
}
_ = await notificationSender.SendOverdueAsync(
new WorkItemNotification(
item.Id,
item.Title,
item.Priority,
item.DueAtUtc,
item.Assignee),
Guid.NewGuid().ToString("N"),
cancellationToken);
return Accepted();
}
單次成功路徑可以執行,重送契約仍然沒有成立。這段程式碼把資料查詢、資格判斷、通知 Mapping、冪等身分與外部副作用全放進 Controller,也建立了第二條通知路徑。
把程式碼與既有通知路徑放在一起看,必須分開「已觀察」「可由 Code 判讀」與「依結構推論」三種證據:
| 發現 | 分類 | 證據強度 |
|---|---|---|
| 相同 POST 產生兩次通知嘗試 | 功能傷害 | 測試已重現 |
Sender 正常回傳 false 時仍回 202 |
契約缺口 | Controller 丟棄回傳值,可由 Code 直接判讀;false 應對應什麼 HTTP 行為仍待 User 定義 |
| 每次 Request 都建立新的 Idempotency Key | 功能風險 | 可由 Code 與既有去重規則推論 |
| Controller 繞過 Outbox/Dispatcher | 結構傷害 | Diff 已直接觀察 |
| Retry、Cancellation 與 Lost ACK 可能分岔 | 後續結構風險 | 依目前兩條通知路徑推論 |
我把這份輸出保存成固定候選 Commit。後續審查、紅燈測試與修正 Diff 都從同一份程式碼開始,不再追著持續變動的工作目錄跑。
我把第二階段稱為傷害審查(Harm Review):先固定候選版本,再逐項列出受影響對象、Repository 證據、證據強度與決策責任。這個 Session 仍使用相同模型與推理強度,但沒有前段對話,也沒有修改權限,只能審查,不能看到問題就順手把它修掉。
這樣可以保留原始問題與證據,也減少實作者替自己的設計補理由。它只能隔離工作角色與對話狀態,不能等同於不同模型或人工 Reviewer 的獨立判斷。
這份 Prompt 不要求 Reviewer 立刻修 Code。它要先回答誰可能受影響、手上有什麼證據、證據強度到哪裡,以及誰有權決定:
功能傷害:
- 404、409 與 202 是否和實際行為一致?
- Provider 回傳 false、擲出例外或收到 Cancellation 時,Caller 會看到什麼?
具證據的營運風險:
- 相同 HTTP 請求重送或使用者連點兩次,Provider 可能收到幾次通知?
- Idempotency Key 是否代表同一個邏輯操作?
- Lost ACK 後重送,是否可能再次產生外部效果?
- 失敗後是否有可查詢、可重試的持久化通知意圖?
結構傷害:
- Controller 是否新增資料查詢、Mapping、冪等鍵與副作用協調責任?
- 人工重送是否繞過既有 Outbox/Dispatcher?
- 兩條通知路徑的錯誤、Retry、Cancellation、Lost ACK 與 Idempotency 語意是否一致?
- 昨天的 Architecture Test 為什麼仍然可能全綠?
每個發現都要列出:
傷害類型、受影響對象、證據、證據強度、風險、決策責任與建議處置。
審查 Agent 判定這份候選必須修正後再審。我把它的發現和主流程驗證整理成下表:
| 類型 | 受影響對象 | 證據 | 證據強度 | 最後由誰決定 |
|---|---|---|---|---|
false 仍回 202 |
API Caller、客服 | Controller 丟棄 Sender 的回傳值 | 可由 Code 直接判讀 | API 契約負責人 |
| 相同 POST 造成兩次通知嘗試 | 通知接收者、客服、營運人員 | 主流程連續呼叫兩次後,記錄型測試替身留下同一 Work Item 兩筆紀錄 | 已在測試觀察 | 產品與工程共同決定重送語意 |
| Lost ACK 後可能再次送出 | 通知接收者、維運人員 | 每次 Request 都產生新的冪等鍵,Provider 無法辨認同一操作 | 由 Code 與既有去重規則直接推論 | 系統設計與營運負責人 |
| Controller 建立第二條通知路徑 | 後端維護者、Reviewer | Diff 顯示 HTTP Action 直接呼叫 Sender,繞過 Outbox/Dispatcher | 已觀察到結構變化;未來維護成本屬設計判斷 | 維護團隊與 Merge Reviewer |
這四項結果的證據強度不同。重複通知嘗試由測試實際重現;Controller 繞過 Outbox 可以從 Diff 直接判讀;Lost ACK 則是根據現有程式碼與去重規則推論的風險。
三種證據必須分開標示,才不會把合理擔憂寫成已經發生的事故。

圖:唯讀 Harm Review 先固定候選、列出受影響對象,再暫停合併、補紅燈與修正;速度越快,止損越要提前。
我把候選固定在 day-25-harm-candidate,停止合併與後續擴充。審查已經指出風險,但測試要有預期答案,我必須先決定:retry 到底代表重試同一筆通知,還是建立一筆新的通知?
Agent 可以提出方案,卻不能替產品決定 retry 代表什麼。這裡有兩種合理但完全不同的需求:
| 產品真正想做的事 | 操作身分 | 適合的設計 |
|---|---|---|
| 重試同一筆失敗通知 | 沿用原有身分與冪等鍵 | 重新排定既有 Pending Outbox |
| 再建立一筆新的通知 | 建立新的操作身分 | 新增 Outbox、錯誤契約與重送規則 |
這次已有一筆失敗後仍可追蹤的 Pending Outbox,所以我把 retry 定義為「重新排定同一筆失敗通知」。它必須沿用原本的 Outbox Id 與 Idempotency Key。
我把專案契約定成這樣:
retry 重新排定既有的 Pending Outbox。202 Accepted 表示既有通知意圖已成功重新排定。404;狀態不符或沒有 Pending 通知時回 409。RFC 9110 沒有替 Work Item API 保證通知已保存或送達,它只定義「請求已被接受,處理尚未完成」。本專案另外承諾既有 Pending Outbox 已成功重新排定;Demo 目前沒有公開的狀態查詢端點,因此非同步處理的可觀察性仍不完整。
如果產品要的是「再寄一封新的通知」,就應該建立新的操作身分、錯誤契約與重送規則。這次選擇重用既有通知意圖,因為 Repository 已有一條能追蹤與重試的 Outbox/Dispatcher 路徑。
連續兩次 POST 覆蓋的是「同一項邏輯操作再次進入 API」:可能來自使用者連點、Client 自動重送,或 Lost ACK 後重新送出。這項測試沒有在網路層真正製造 Lost ACK,只檢查重送進入端點後會發生什麼。
測試使用 Recording Gateway,也就是只記錄外部呼叫、不會真的寄送通知的測試替身。既然這次把 retry 定義成重新排定既有 Outbox,HTTP Request 內就不該直接呼叫 Provider;兩次 POST 完成後,通知嘗試紀錄應該仍是空集合:
await client.PostAsync(retryRoute, content: null, cancellationToken);
await client.PostAsync(retryRoute, content: null, cancellationToken);
Assert.Empty(factory.Notifications.AttemptedWorkItemIds);
候選卻留下同一個 Work Item 的兩筆紀錄,因此紅燈成立:
Assert.Empty() Failure: Collection was not empty
Collection: [同一個 WorkItemId, 同一個 WorkItemId]
這個實驗觀察到兩次通知嘗試,不能擴大寫成使用者真的收到兩封通知。Provider 最後是否去重、接受或實際送達,都不在這項測試的證據範圍內。
同一份契約也規定:沒有 Pending Outbox,就沒有可以重新排定的通知,API 應回 409 Conflict。候選只檢查 Work Item 是否為 Overdue,所以仍回沒有內容的 202 Accepted,第二項紅燈也成立。

圖:相同 POST 被賦予新冪等鍵,造成兩次通知嘗試;Provider 回傳 false 仍得到 202,都是原本斷言沒有涵蓋的行為。
原本的 Architecture Test 只檢查 Use Case,確保高階流程不直接依賴 EF Core、ASP.NET Core 與外部 SDK。這份候選沒有修改 Use Case,而是從 Controller 直接呼叫 Notification Sender,因此躲過了原本的掃描範圍。
行為測試與架構測試保護的是不同問題。重複 POST 測試檢查外部結果;Architecture Test 則檢查已明列的依賴規則。
我因此再補一項 Source Scan,明確禁止 WorkItemsController 直接出現 IWorkItemNotificationSender。候選立刻亮紅燈,錯誤也指出這次的違規來源。
這項檢查能抓住本次發現的直接依賴,卻不能證明整個 Repository 都不存在其他繞道。未來若出現其他 Controller、別名或 Wrapper,規則仍要跟著擴充。

圖:既有路徑透過 Outbox/Dispatcher;Controller 直送新增第二條路徑,讓 Retry、Cancellation 與 Lost ACK 語意分岔。
flowchart TD
A[AI 完成明列需求<br/>現有測試全部通過]
A --> B[固定候選版本<br/>交給全新唯讀 Session 審查]
B --> C[暫停合併]
C --> D[由 User 定義 retry、202 與冪等契約]
D --> E[相同 Request 觸發兩次通知嘗試<br/>功能傷害]
B --> F[Controller 繞過 Outbox<br/>結構傷害]
E --> G[修正後重新執行行為與架構驗證]
F --> G
修正後,由 Use Case 定義人工重試需要的能力與結果:
public interface IOverdueNotificationRetryScheduler
{
Task<OverdueNotificationRetryScheduleResult> ScheduleAsync(
Guid workItemId,
CancellationToken cancellationToken);
}
EF Core Adapter 負責尋找既有的 Pending Outbox,將 NextAttemptAtUtc 調整成現在,並清除已過期的 Lease。Controller 只把 Use Case 結果轉成 202、404 或 409。
修正前後的差別,可以直接從通知路徑看出來:
| 比較項目 | 原候選 | 接受版本 |
|---|---|---|
| 外部通知呼叫位置 | Controller 直接呼叫 Sender | Dispatcher 統一處理 |
| 重複 POST | 每次建立新身分並嘗試通知 | 重新排定同一筆 Pending Outbox |
| 冪等鍵 | 每次產生新值 | 沿用原本的值 |
| Provider 呼叫時機 | HTTP Request 內立即呼叫 | 背景流程稍後執行 |
| 架構保護 | 原測試沒有掃描這個入口 | 已知的直接 Sender 依賴會讓測試失敗 |
連續呼叫兩次相同 API 後,Provider 還沒有被直接呼叫,資料庫也仍然只有同一筆 Pending Outbox。後續由既有 Dispatcher 執行時,才會產生一次通知嘗試。
功能與結構驗證都通過後,我才接受這份版本。理由是重送契約已經明確,人工與自動通知也重新共用 Outbox/Dispatcher;測試總數與 Diff 大小不參與這次決策。
就那份狹窄需求而言,原候選完成了 Make It Work:Route 可以呼叫,三種明列狀態都有回應,測試也全數通過。修正後才完成這個情境下的 Make It Right:相同操作不會悄悄換身分,通知路徑也回到同一個可靠邊界。
行為方面,人工重送必須沿用同一筆通知意圖,不能因 Client 重送而產生新的冪等身分。結構方面,人工與自動通知都要經過 Outbox/Dispatcher,否則 Retry、Cancellation、Lost ACK 與 Mapping 會分散成兩套規則。
工程師也是系統技術健康的利害關係人。「盡力做到最好」不等於追求完美或無限延期。低風險的結構問題可以登記成技術債;已知會造成重複副作用、錯誤成功回應或可靠性路徑分岔時,工程師就有責任先停止交付。
這次實驗只確認 Agent 約七分半就產生了候選,不能用來證明 AI 一定會放大傷害。我的工程判斷是:產碼速度變快後,錯誤模式也能更快被複製到其他入口,所以止損必須提早發生。
重複 POST 測試與 Architecture Test 分別失敗,因此我把 AI 產出的完成條件拆成兩道 Gate。
行為 Gate 檢查成功、失敗、取消、重送、冪等與外部副作用;結構 Gate 檢查新增路徑、重複政策、依賴繞道與下一次修改位置。任何一道沒有通過,候選就先停止合併。
| Gate | 要檢查什麼 | 這次如何驗證 |
|---|---|---|
| 行為 Gate | 成功、失敗、取消、重送、冪等、錯誤語意與外部副作用 | 公開 HTTP 測試、Outbox 狀態、Provider 嘗試次數與系列 Smoke |
| 結構 Gate | 重複路徑、政策散落、Mapping 重複、依賴繞道與下一次需求的修改位置 | 完整 Diff、Architecture Test、通知路徑比較與傷害審查 |
這些判斷適合整理成可重複使用的 Repository Instruction。以下先用 AGENTS.md 格式示範,尚未寫入 API Demo;系列後段再把累積的 Clean Code Policy 整理成 Skill。
## Harm Review Policy
- 測試全綠只代表已執行的斷言通過,不得直接視為可以合併。
- 資料、授權、交易、通知、背景工作或公開契約異動,合併前必須執行傷害審查。
- 完成定義同時包含行為 Gate 與結構 Gate;任一 Gate 失敗,就停止合併目前候選,也不得再以這份候選為起點擴充後續功能。
- 停止後固定候選 Commit,搜尋相同模式的修改範圍,再補上能重現問題的行為測試或 Architecture Test。
- 行為審查至少涵蓋成功、失敗、取消、重送、重複執行、冪等與錯誤語意。
- 結構審查至少涵蓋新增路徑、重複政策、Mapping、依賴繞道與下一次修改位置。
- 外部副作用允許重送時,實作前必須定義邏輯操作身分與穩定的 Idempotency Key。
- 審查發現必須標記為「已觀察」「可由 Code 推論」或「仍待驗證」,並列出受影響對象與決策責任。
- 暫時接受風險或技術債時,必須留下理由、影響範圍、負責人與重新處理條件;高風險變更保留人工否決權。
### Harm Review Decision
- 行為與結構 Gate 都通過:可以接受候選。
- 契約明確,而且問題能以測試重現:修正候選後重新驗證。
- User 尚未定義副作用、重送或失敗語意:停止實作,先補決策。
- 候選建立第二條高風險副作用路徑:拒絕合併,除非能提出必要性與隔離方式。
- 暫時接受技術債:記錄理由、影響範圍、負責人、檢討日期與重新處理條件。
「測試全綠」只能證明已執行的斷言通過。這次真正影響決策的證據包含原始 Prompt、候選 Diff、重複 POST 的紅燈、Architecture Test 的掃描缺口,以及 User 最後定義的重送契約。哪些由測試觀察、哪些能從 Code 判讀、哪些仍是風險推論,都要分開記錄。
相同邏輯操作被重送時,系統不應悄悄建立新的通知身分。Caller 收到 202 Accepted,代表既有通知意圖已重新排定,後續仍由 Dispatcher 處理;它不代表通知已經送達。
本篇只使用 A — Auditable by Evidence 實據可審 與 N — Non-Surprising Behavior 符合預期:A 負責區分觀察、判讀與推論,N 負責固定重送及 202 Accepted 的對外語意。C、L、E 不是這次接受候選的主要判準。
唯讀審查不是免費的,它多花了一個完整 Session。兩個 Session 的任務不同,Token 不能直接比較;本次能確認的是,額外審查找出了重複請求、回應語意與依賴繞道。
通知、交易、授權與背景工作涉及高風險副作用,我會保留這道審查;低風險格式調整則不必一律採用相同強度。
回到標題,這份候選會在測試全綠後重複通知,是因為原本的斷言只驗收單次成功,並且允許 Controller 直接建立新的通知身分與外部路徑。Agent 完成了被明列的工作,沒有替 User 補上未定義的重送契約。
我最後接受的不是「多補幾個測試」而已,而是先由 User 定義 retry、202 Accepted 與冪等身分,再讓人工與自動通知重新共用 Outbox/Dispatcher。行為 Gate 防止相同操作產生第二次副作用;結構 Gate 則防止另一條路徑再次繞過既有保證。
這次實驗只能說明:在這份 Prompt 與 Work Item API 裡,成功路徑全綠仍漏掉兩次通知嘗試與一條依賴繞道。它不能證明所有 AI 候選都會如此,也不能把測試全綠直接當成傷害已經不存在。
明天再處理下一個問題:當 AI、測試與 Reviewer 都說完成,另一位工程師或另一個乾淨執行流程,需要哪些版本、環境與原始輸出,才能重跑並確認同一項結論?
閱讀本文不需要先下載 Demo。完整 Prompt、實驗起點、候選版本、接受版本與原始驗證結果都保留在公開 Repository,想進一步檢查時再使用以下連結與 Git Tag。
實驗起點:
git fetch --tags
git switch --detach day-24-dependency-rule
候選版本:
git fetch --tags
git switch --detach day-25-harm-candidate
接受版本:
git fetch --tags
git switch --detach day-25-harm-behavior-structure