昨天講的是怎麼讓兩個 session 分別扮演 SA 和 PG,以及怎麼把配額視窗釘在固定時間。今天回到系統本身。
這一輪要做的功能是決議項追蹤——會議談完之後,「誰要去做什麼、做完了沒」。這件事在目前的系統裡只存在於記錄內文的那段文字裡,沒有人查得到、也沒有人會被提醒。
前四輪把一整套慣例建立得很完整:送出即凍結、內容不可改、trigger 在資料庫層守著、決策事件只增不改。而這一輪的功能,有一半剛好是那套慣例的反面——決議項的完成狀態本來就該被反覆改。
如果照著前面的樣子做,會得到一個按錯了不能取消的待辦清單。而且它會做得很有說服力,因為它「跟前面一致」。
所以今天先把判斷標準講清楚,再講交給 AI 做的時候會撞到什麼。
「不可否認性」(non-repudiation)常常被跟「防竄改」混在一起,但它們防的不是同一件事。
後者比前者嚴格。要成立需要三個東西同時在場:
第二點最容易被漏掉。「王先生確認過」這句話,如果確認之後內容還能改,那它什麼都沒證明——王先生完全可以說「我確認的不是這個版本」,而且他是對的。
這就是為什麼這個系統把「必要確認人名單」在送出時凍結、而不是每次都去讀會議的現行參與者。讀現行名單的話,一個版本的「全員同意」定義會在它自己底下偷偷改變。
但不是所有資料都需要它
講完上面那段,很容易滑到另一個極端:那全部都凍結不就好了。
不行。凍結是有代價的,代價是使用者按錯就只能叫工程師改資料庫。所以要逐項判斷。我用的是三個問題:
三個都是「否」的,就不該凍結。拿這三題掃過這個系統,結果長這樣:
| 資料 | 它記的是 | 需要不可否認嗎 | 守在哪一層 |
|---|---|---|---|
| 版本的標題與內文 | 當時談定了什麼 | ✅ | 資料庫 trigger |
| 必要確認人名單 | 當時誰有資格表態 | ✅ | 資料庫 trigger |
| 確認/退回事件 | 誰在什麼時候說了什麼 | ✅ | 只增不改 |
| 決議項的來源版本 | 這件事是哪一版決定的 | ✅ | service |
| 決議項的完成時間 | 現在做完了沒 | ❌ | 不用守 |
最後兩列是同一張資料表。這是整件事最關鍵的一點:凍結範圍是逐欄位定的,不是逐表定的。
「這件事是 v3 決定的」是稽核軌跡的一部分,寫進去就不該再變。「做完了沒」是執行狀態,它的本質就是會變。它們住在同一張表裡,規則完全相反。
AI 最擅長的事情之一就是延續既有的樣子。前四輪的每一張新表都有 trigger,第五張它就會給你 trigger。
要打斷這個預設,只能在規格裡寫否定句,而且要附理由。這是實際寫進這一輪任務單的段落:
- **不要給 `note_action_items` 加 trigger。** 這張表的執行狀態本來就該可變,
加 trigger 等於把「送出即唯讀」錯套到不是記錄內容的東西上。
- **也不要因此以為它沒有約束。** `source_version_id` 一旦寫入就不該再變,
這條這輪靠 service 守(沒有任何 API 路徑能改它),不上 trigger。
兩句都是否定句,而且成對。只寫第一句,它會把整張表當成完全開放的;只寫第二句,它讀不出為什麼這張表跟前面不一樣。
但否定句不能亂寫。「不做的功能」如果要列,根本列不完,而且列出來的東西 90% 是廢話。所以我的判準是一句話:
如果它照著前一張做會做錯,那個點就要寫否定句。
不是列一張不做清單,是在 AI 最可能自動幫你補上的那一個位置插一句話。這種點通常很少——這一輪只有這兩句。
這條是這一輪最容易被做反的。
建立決議項的時候要記「這件事是哪一版決定的」。最自然的寫法是把 source_version_id 放進 request body——畢竟「建立一筆資料就把欄位帶齊」是一個比前面那些都更強的慣例,強到 AI 幾乎不會停下來想。
但那樣就完了。稽核軌跡如果是呼叫方填的,任何人都可以指定任何一版,那它記的就不是事實,是輸入。
所以實作長這樣,source_version_id 不在參數裡:
def create_action_item(
db: Session, *, note_id: uuid.UUID, user: User,
content: str, assignee_id: uuid.UUID,
) -> ActionItem:
"""Assign work decided by this note's *effective* version.
The source version is read here, not taken from the caller: it records
which agreed version this came from, and letting a client name it would
make the audit trail an input.
"""
伺服器自己去查目前的生效版本。前端那一側也留了同一句話,因為那裡是最有可能被「順手補齊」的地方:
// `source_version_id` 不在這裡:伺服器自己取目前的生效版本。呼叫方能指定的
// 稽核軌跡不是稽核軌跡。
這條原則還順手決定了一個錯誤碼。既然來源一定是「生效版本」,那沒有生效版本的記錄就不能指派決議項:
def no_effective_version() -> ConflictError:
"""Action items hang off what was actually agreed.
A version still awaiting confirmation may yet be returned, and a returned
one was never agreed at all — assigning work from either would be tracking
a decision nobody made.
"""
待確認的版本可能被退回,被退回的那版更是從來沒有被同意過。從任何一個指派工作,都是在追蹤一個沒有人做過的決定。
我把這件事記下來是因為:這個錯誤碼不是防呆,它是上面那條原則的直接後果。 這兩者的差別在於,防呆可以之後再補,原則的後果漏掉就是破洞。
這是我這一輪真正跌倒的地方。
契約的 §0 寫得很清楚,而且是我自己寫的:所有對外的時間欄位一律 UTC、以 Z 結尾,不分它是剛產生的還是從資料庫讀回來的。任務單裡也另外提醒了一次「done_at 也不例外」。
新表做完之後,我回頭數了一下:這條規則,新增的那兩個時間欄位一條測試都沒有在驗。功能全綠,規則沒人看。
補的時候還踩到第二層。寫這條測試的當下,實作其實已經是對的了——那就代表這條測試寫完會直接綠,而直接綠的測試等於沒有人證明過它會失敗。所以我把序列化的型別暫時換成裸的 datetime,讓它先紅一次:
2026-09-22T17:23:54+08:00
抓到了。而且這一條驗得到東西,靠的是測試用的連線刻意沒有設 timezone=UTC——從資料庫讀回來的值帶的就是本機時區。如果那個設定不小心被「修好」,這條測試會變成永遠綠的裝飾品。
還有第三層。第一版的斷言只檢查了剛建立的那一份回應,但那一份的值還在記憶體裡、本來就是 UTC,把整個序列化層拔掉它還是會綠。所以最後加了這兩行:
# 讀回來的那一份也要一樣——剛寫入的值還在記憶體裡,是 UTC;
# 從資料庫讀回來的才是會露餡的那一份。
reread = list_items(client, note, author)[0]
assert reread["done_at"] == done["done_at"]
assert reread["created_at"] == item["created_at"]
寫在契約最上面、標著「全域」的規則,是最沒有人會回頭檢查的規則。因為它不屬於任何一個功能,所以每一個功能的驗收條件裡都沒有它。
明天:把整條路走一次。會議建立 → 撰寫 → 送出 → 確認 → 生效 → 指派決議項 → 完成。前面每一天都只驗了自己那一段,串起來會不會有人接不上,到目前為止沒有任何一條測試回答過。