iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0

前言

昨天講的是怎麼讓兩個 session 分別扮演 SA 和 PG,以及怎麼把配額視窗釘在固定時間。今天回到系統本身。

這一輪要做的功能是決議項追蹤——會議談完之後,「誰要去做什麼、做完了沒」。這件事在目前的系統裡只存在於記錄內文的那段文字裡,沒有人查得到、也沒有人會被提醒。

前四輪把一整套慣例建立得很完整:送出即凍結、內容不可改、trigger 在資料庫層守著、決策事件只增不改。而這一輪的功能,有一半剛好是那套慣例的反面——決議項的完成狀態本來就該被反覆改。

如果照著前面的樣子做,會得到一個按錯了不能取消的待辦清單。而且它會做得很有說服力,因為它「跟前面一致」。

所以今天先把判斷標準講清楚,再講交給 AI 做的時候會撞到什麼。

不可否認性

「不可否認性」(non-repudiation)常常被跟「防竄改」混在一起,但它們防的不是同一件事。

  • 防竄改防的是:資料被人偷偷改掉。
  • 不可否認性防的是:當事人事後說「我沒做過」或「我不是那個意思」。

後者比前者嚴格。要成立需要三個東西同時在場:

  1. 身分可辨識——知道是誰做的,而且那個身分不能被冒用(這就是為什麼 Day 21 換掉明文 header 是前提,在那之前這個系統的稽核軌跡其實是不成立的)
  2. 內容被凍結——當事人表態的那一刻,他看到的是什麼,事後必須查得出來
  3. 時間有記錄——什麼時候做的

第二點最容易被漏掉。「王先生確認過」這句話,如果確認之後內容還能改,那它什麼都沒證明——王先生完全可以說「我確認的不是這個版本」,而且他是對的。

這就是為什麼這個系統把「必要確認人名單」在送出時凍結、而不是每次都去讀會議的現行參與者。讀現行名單的話,一個版本的「全員同意」定義會在它自己底下偷偷改變。

但不是所有資料都需要它

講完上面那段,很容易滑到另一個極端:那全部都凍結不就好了。

不行。凍結是有代價的,代價是使用者按錯就只能叫工程師改資料庫。所以要逐項判斷。我用的是三個問題:

  1. 這筆資料事後會不會被拿來當證據?
  2. 它記的是「當時的事實」,還是「現在的狀態」?
  3. 如果當事人說「我不是那個意思」,需不需要有東西反駁他?

三個都是「否」的,就不該凍結。拿這三題掃過這個系統,結果長這樣:

資料 它記的是 需要不可否認嗎 守在哪一層
版本的標題與內文 當時談定了什麼 ✅ 資料庫 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"]

寫在契約最上面、標著「全域」的規則,是最沒有人會回頭檢查的規則。因為它不屬於任何一個功能,所以每一個功能的驗收條件裡都沒有它。

結論

  • 「哪些動作需要不可否認」是分析的工作,不是實作的工作。 做不出這個判斷,就只剩兩種結局:全部凍結(使用者按錯就叫工程師改資料庫),或全部不凍結(稽核軌跡可以被改)。兩種都不是設計,是逃避。
  • 凍結範圍逐欄位定,不要逐表定。 同一張表裡混著「當時的事實」和「現在的狀態」是常態,不是例外。
  • 規範只寫肯定句的時候,AI 的預設值是「跟前面一樣」。 要打斷它,就得在那個特定的點寫一句否定句加理由。判準是「照前一張做會不會做錯」,不是「有沒有可能做錯」。
  • 標著「全域」的規則,是最沒有人會回頭檢查的規則。 它不屬於任何一個功能,所以每個功能的驗收條件裡都沒有它。新增欄位的時候要自己回去對一次。

明天:把整條路走一次。會議建立 → 撰寫 → 送出 → 確認 → 生效 → 指派決議項 → 完成。前面每一天都只驗了自己那一段,串起來會不會有人接不上,到目前為止沒有任何一條測試回答過。


上一篇
Day 22| 實用的 Claude 小技巧
下一篇
Day 24|系統做完之後:哪些測試給 Claude,哪些一定要自己來
系列文
30 天打造我的 AI 開發工作流:從需求分析到上線 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言