規格裡寫著「這是資料庫層級的保證,不只是 service 檢查」,今天就用四條規則去驗證這句話。
昨天把 323 行的規格拆成幾份文件,其中 data-model.md 裡面有一節叫〈幾個刻意的選擇〉,一共五條,每一條都在解釋:
為什麼這個欄位要這樣設計?
那一節是訪談那一輪 AI 自己整理出來的,今天要一條一條驗證。
先看其中一條規定:
version_decisions
FK (version_id, user_id) → version_required_confirmers
規格對它的說明是:
所以「非名單內的人不可能留下確認紀錄」是資料庫層級的保證,不只是 service 檢查。
這句話有兩個值得注意的地方。
區分了層級:
同一條業務規則,寫在 service 裡和寫在資料庫裡,能擋住的範圍不一樣。service 的檢查,下一個人新增一支 endpoint、忘了呼叫那段檢查,就可能繞過去;資料庫 constraint 則是在寫入資料時直接介入,不管是 ORM、Repository,甚至直接下 SQL,只要碰到那個 constraint,就必須遵守。
這是一個可以被驗證的宣稱:
它不是「我們會小心處理」這種驗不出來的句子,如果真的做不到,資料庫會直接擋下來。今天會用四條規則去驗證規則是否有實行。
這系列前面幾天其實一直在推同一條線:能被落實的規則,是那些可以被寫成檢查的規則。D13 又補了一層:寫得出檢查,不代表不同文件之間不會互相矛盾。今天再往下走一層:
檢查寫在哪一層,決定它到底擋得住誰。
我先把常見的幾種情況攤開來看:
| 落在哪一層 | 擋得住 | 擋不住 |
|---|---|---|
| 資料庫約束 | 所有寫入路徑,包含手動 SQL、未來的批次腳本 | 有權限修改 schema / constraint 的人 |
| service 檢查 | 走正常流程的呼叫 | 繞過 service 的新 endpoint、repository、直接操作 Repository / DB |
| 路由與 API 結構 | 新增 endpoint 的人(如果有測試盯著) | 直接連資料庫的人 |
| 註解/docstring | 會讀註解、而且願意遵守的人 | 其他所有情況 |
由上往下,強度遞減。
這張表的重點不是「所有規則都應該推到資料庫」,有些規則本來就不適合放進 DB,可能是因為成本太高,也可能是因為會把資料庫 schema 綁死在某個應用流程上。真正重要的是:
要知道每一條規則目前在哪一層被保證。
驗證方式很直接,先建立一組最小資料,然後用 raw SQL 做四件業務規則明令禁止的事情。
整個過程:
def attempt(label, sql, params):
try:
with eng.begin() as c:
c.execute(text(sql), params)
print(f" ⚠️ {label}\n → 寫進去了,沒有任何一層擋住\n")
except IntegrityError as e:
first = str(e.orig).strip().split("\n")[0]
print(f" ✅ {label}\n → {first}\n")
四次嘗試:
直接對資料庫下 SQL,完全繞過 service 層:
✅ 名單外的人留下確認紀錄
→ insert or update on table "version_decisions" violates foreign key
constraint "fk_decision_is_required_confirmer"
✅ 同一份記錄開第二個待確認版本
→ duplicate key value violates unique constraint
"uq_note_versions_single_pending"
✅ 塞一個不存在的狀態
→ new row for relation "note_versions" violates check constraint
"version_status"
⚠️ 改掉已送出版本的內容
→ 寫進去了,沒有任何一層擋住
最後這個版本的內容:偷改的標題 / 偷改的內容
前三條完全如規格所述:第一條是 FK、第二條是 partial unique index、第三條則是 CHECK Constraint。
第二條值得多講一句:它不是 Unique Constraint,是帶 WHERE 條件的唯一索引——「同一份記錄只能有一個待確認版本」這種帶條件的唯一性,在 PostgreSQL 只寫得成 index,寫不成 constraint。已退回、已生效的舊版本不受它管。
第四條則是我刻意加進來的對照組,規格寫著:已送出的版本不可修改。我原本預期它也會被擋住,結果沒有。
症狀:第四次嘗試直接成功。已經送出的版本,標題和內容被 UPDATE 改掉,資料庫沒擋下。
而這條規則是 CLAUDE.md「不可違反的業務規則」中的第一條:
記錄一旦送出就唯讀,不分後續狀態(待確認、已退回、已生效皆然)。
真正的原因:不是漏了一個 constraint,是這條規則沒有被寫成資料庫層的機制。
models/version.py 的 docstring 長這樣:
class NoteVersion(Base, IdMixin, TimestampMixin):
"""A submitted version. Content is frozen at INSERT and never updated."""
「frozen at INSERT and never updated」是一句陳述,不是一個機制。它描述的是「我們打算這樣用」,讀起來卻像「資料庫已經保證這件事情」——而它落在最底下那一層:註解。對資料完整性來說,強度是零。
怎麼修:
在資料庫裡放一個守衛,任何人要改那張表,都得先過它。
這個守衛在 PostgreSQL 裡叫 trigger(觸發器)。它是一段存在資料庫裡的小程式,設定成「每次有人要 UPDATE 這張表,先執行我」。
比對「改之前」和「改之後」,
如果 標題 / 內容 / 版本號 / 作者 / 送出時間 任何一個變了 → 報錯,整筆取消
如果只有 狀態 / 決議時間 變了 → 放行
守衛只鎖內容,不鎖狀態。
加上去之後再測一次,同一行 UPDATE 就被擋下來了:
UPDATE note_versions SET title='偷改的標題' ...
→ ERROR: note_versions content is immutable once submitted
可以用三個問題判斷:
如果答案是「必須成立」,單純靠 Service 的「先查再寫」就不夠。
例如:一個 Meeting 只能有一個 pending version。
兩個請求同時查詢時,都可能得到「目前沒有 pending」,接著各自 INSERT。這時候就應該讓 Database 用 unique constraint 之類的機制,從資料層保證規則永遠成立。
如果會,就值得考慮把保護往 Database 推。
例如:已送出的 version 不可以修改。
如果有人直接 UPDATE 把內容蓋掉,而系統又沒有保存舊版本,原本的內容可能就永久消失。這種規則不能只靠「希望 Service 不要改」,而應該思考更強的資料層保護。
相反地,像是 Reject 時必須填寫理由,如果漏掉了,資料本身沒有消失,之後仍然可以補上。這種規則通常留在 Service 就足夠。
如果是在描述資料形狀:
這些是 Database 原生就很擅長保護的事情。
如果是在描述流程與權限:
因為流程會變,所以這些通常放在 Service 比較合理。
像這個系統的「同時只能有一個待確認版本」,submit_draft 裡兩層都寫了:
# 第一層:service 先查,查到就給一個人看得懂的錯誤
if version_repository.by_status(
db, note_id, VersionStatus.PENDING_CONFIRMATION
) is not None:
raise errors.pending_version_exists()
...
try:
version = version_repository.create(...)
db.commit()
except IntegrityError:
# 第二層:有人擠在我們的「檢查」與「寫入」之間,資料庫的 index 擋下來了
db.rollback()
raise errors.pending_version_exists() from None
資料庫和 service 兩條路徑收斂成同一個錯誤碼。
明天:規格四份都齊了,但怎麼知道 AI 有沒有幻覺、有沒有把待決事項悄悄變成已決定?交付給開發之前先過一輪自檢。