11.14 契約條款的送達
契約條款要生效,消費者得在做出會踩到它的那個決定的當下知道它。寫進文件讓這件事變得可能,而不讓它變得會發生——讀文件的時刻跟寫程式的時刻多半不重合,而條款愈細,兩者重合的機率愈低。這一層的失效有個容易誤判的性質:「條款寫了但沒人讀」與「條款根本沒寫」的觀察結果完全相同——消費者踩到、雙方都判定不了契約上誰違約。因此「我們有寫在文件裡」不構成已送達的證據,也不構成免責。本章寫的是把條款推到消費者面前的手段與各自的邊界。組織怎麼產出並維持規範本身(owner、review 流程、linting 進 CI)是另一個題目,在 11.10 API 規範治理。
送達手段有兩個維度:強制力與射程
把送達手段排成一條梯子會出錯,因為它們沒有全序關係。強制力是「在射程內時,消費者能不能不知道就合規」;射程是「這個手段涵蓋哪些消費者」。型別層對走 codegen 的人強制力極高、對手寫 HTTP 的人是零;SDK 預設值對走官方 SDK 的人是零成本合規、對其餘的人不存在。只有機制層的射程是全部。
操作規則因此是兩步:選一個射程涵蓋你在意的那群人、而強制力最高的手段;射程外的那群人再往下找一個涵蓋得到的補上。「寫進文件」之所以是最後一手,不是因為它排在梯子底端,是因為它的強制力最低——射程雖然是全部,卻要對方主動來讀。
| 層 | 消費者知道的時機 | 承載得了什麼 | 例子 |
|---|---|---|---|
| 機制層 | 踩不到,所以不用知道 | 可由服務端單方面保證的性質 | cursor 綁呼叫者、natural key 去重 |
| 型別與 schema | 編譯或 codegen 時 | 結構性約束 | 必填欄位、列舉值、方法簽章、Retry-After 型別 |
| 執行期回饋 | 第一次踩到時 | 條件性行為 | 冪等衝突回 409、cursor 過期回明確錯誤 |
| 文件 | 主動去讀時 | 為什麼、未來會怎樣、承諾 | 支援窗口、保存期的理由、未知欄位請忽略 |
選手段之前要先確認消費者是不是「會讀東西的人」。proxy、快取層、負載平衡器、監控系統、泛型 HTTP client 這類非人格中介讀不到上面任何一層——型別、執行期回饋、推播與文件對它們一律為零,只有機制層與 transport 層的語意(status、標準 header)成立。而錯誤格式與 status 語意的決策,主要受害者正是這一類(形態分野見 API Consumer Shape)。
機制層嚴格說不是送達,是讓送達變得不必要。cursor 綁定呼叫者之後,拿別人的 cursor 續翻直接失敗,這件事不需要任何人讀到任何句子。能推到這一層的條款有一個共同特徵:它保護的性質可以由服務端單方面保證,不需要消費者配合。這一層的容量比多數人以為的大,而它常被略過,因為想到「這要寫進文件」比想到「這可以做成做不到」自然。
SDK 預設值是這份清單裡唯一能替消費者做決定的位置,也是握有 SDK 的組織最常沒有用滿的槓桿。把保守行為做成預設——重試自動帶退避與 jitter、列表方法自動處理翻頁、寫入方法自動生成冪等鍵——消費者不做任何事就已經合規,而他甚至不需要知道有這些條款。它的邊界很清楚:只涵蓋走官方 SDK 的那部分消費者,社群自製 client 與直接打 HTTP 的都在範圍外,而那個比例通常沒有人統計過(該槓桿的完整條件見 Consumer Coordinability)。
型別與 schema 層的觸及率高、承載力窄。OpenAPI 的 required 與列舉、protobuf 的欄位定義、SDK 方法簽章上的型別,這些會在 codegen 或編譯時擋下違規,而消費者不必知道背後的規則是什麼。它的界線畫在單一請求上:schema 看得到這一次請求的形狀(JSON Schema 的 dependentRequired 之類還表達得了請求內欄位的相依),看不到請求跟請求之間的關係。「同一個 key 配不同參數會失敗」與「這個 cursor 過期後失效」都是跨請求狀態,因此都落在下一層。
執行期回饋層是跨請求條款的主場,而它的品質差距全在錯誤的表達力上。同一個違規回 400 Bad Request 與回一個帶明確錯誤碼加說明的回應,對消費者是「不知道自己做錯什麼」與「知道並且改得掉」的差別。這一層還有一個常被忽略的形式:Deprecation 與 Sunset header 這類 in-band warning,它把「未來會怎樣」推進了執行期,訊號出現在開發者一定會看的地方,觸及率高於任何公告渠道(工具箱見 11.5)。
送達還有一個維度不在這張表上:時機。一個手段送到了,不代表對方來得及反應——Deprecation 標頭在每個回應裡出現,而消費者要能發一版新的 client 才改得掉,那個週期由他們的發布節奏決定而非你的。因此推播與執行期回饋這兩層的提前量要以對方的變更節奏為輸入(那個數怎麼量、怎麼進排程,見 11.13 既有 API 的改造路徑 的第零層);一季才發一次版的消費者,提前三週通知等於沒有通知。
文件層承載力最強、觸及率最低。它是唯一放得下「為什麼」「未來三年打算怎麼做」「這個承諾的邊界在哪」的地方,而這些東西無法編碼——一個 header 說不出「保存期至少 24 小時」背後的容量取捨。它的定位因此是承載那些推不下去的,而非承載全部。
先問要不要往上推,再問推到哪一層
分兩步,因為它們用的是不同的判準。
第一步問現形時間:消費者違反這個條款之後,多久會發現。
後果立刻且明顯的條款,留在低層就夠。分頁參數帶錯回一個錯誤,消費者當場就知道,這種條款寫在文件裡的價值主要是節省他除錯的時間,而非避免事故。
後果延遲或靜默的條款必須往上推,而幾條最常被強調的契約條款——冪等鍵的保存期、cursor 的有效期與跨部署有效性、支援窗口——共同特徵正是這一項:違反了不會當場報錯。冪等鍵的保存期是最清楚的例子:消費者假設 key 保存七天而實際只有 24 小時,超過窗口的重送變成一筆新的扣款,而這件事在事故發生前完全沒有訊號——它不會回錯誤,它會成功。這種條款留在文件層等於沒有送達。可推的形式是把它編進執行期回饋:replay 的回應帶一個標明「這是重放」的欄位,附上這個 key 的剩餘有效期。消費者不必讀文件也知道自己在窗口內還是窗口外,而這同時解掉了另一件事——replay 認不認得出來,本來就是冪等契約裡槓桿最大的一項(見 Idempotency key 標準化之爭)。
第二步問落點,而它跟現形時間無關,只看這個條款的性質:可由服務端單方面保證的,進機制層;能做成 SDK 的預設行為的,進 SDK;只涉及單一請求形狀的結構約束,進型別層;其餘進執行期回饋。推不進上面任何一層的才留在文件,而那時候要補的是推播渠道。
兩步套到幾組典型條款上,落點各不相同。cursor 的有效期跟目標被刪除的行為,後果是「翻到一半失敗」,屬於立刻現形,執行期回饋層足夠——但前提是回一個說得出原因的錯誤,回空集合就變成靜默,因為消費者會把它讀成「翻完了」。支援窗口的後果延遲數月且在退場當天集中爆發,因此除了文件之外要有 in-band 的 Sunset 或 deprecation 標頭。cursor 綁不綁呼叫者在集合套用了列級權限、或 cursor 會流經第三方時,後果是橫向枚舉、靜默且嚴重;這兩個條件成立時它該落在機制層而非任何一種宣告,不成立時它是一條普通的文件條款。
編碼成功的檢驗是那一句從規範降級成說明
文件裡的一句話通常同時做兩件事:告訴消費者該怎麼做(行為指引),以及告訴他服務端承諾什麼(承諾與規則)。條款推下去之後,前者從規範性降級成資訊性——語氣從「必須」變成說明、位置從主要條款移到參考章節——而它不該被刪掉。整合方在設計階段仍然需要知道這個限制,否則會設計出一個到執行期才發現要重做的架構;而服務端自己也需要那段文字當錨,否則下一個工程師會把機制層的限制當成沒必要的約束拿掉。
承諾與規則那一半則完全不動:它要能被引用、被比對、被拿去排未來的計畫,那些用途都需要一段可讀的文字。
檢驗因此要對準行為指引那一半的語氣。cursor 綁呼叫者之後,「請勿轉發 cursor 給其他使用者」從一條要求降級成一句說明(「cursor 綁定發出它的呼叫者,轉發後無效」)——不再需要對方配合,但仍然要讓人看得到。同時要在內部留一個錨:一條 conformance test 或內部規範,說明這個綁定是契約而非實作細節。Sunset 標頭加上去之後,「請在這個日期前完成遷移」這句行為指引可以刪(日期已經在每個回應裡),而「新版釋出後至少支援 24 個月」這條規則刪不掉,也不該刪:它是承諾,消費者要據以排未來的計畫。這兩者一起發生時,編碼是成功的。
還有一種降不了級是真的失敗:型別層編碼之後,那句話對不走 codegen 的消費者仍然是規範。判斷方式回到射程——問這個編碼涵蓋了多少比例的消費者,涵蓋不到的那部分,文件仍是他們唯一的行為指引,語氣不能降。
降不了級的行為指引構成一份清單,而那份清單就是這個 API 真正只能靠人讀的部分。它應該很短——長的話多半是編碼的機會沒被看見。承諾與規則那一份清單則不受這條約束:成熟的 API 承諾本來就多,它長不代表編碼失敗。
要求消費者行為的條款要靠主動製造違規情境
有一類條款的內容是「請你這樣做」,而服務端沒有任何手段強制。最典型的是錯誤格式的演化條款——「client 必須忽略不認識的欄位」(見 錯誤格式之爭)。它要求的是對方程式碼的行為,因此編不進機制、編不進型別,執行期也回饋不了,因為違反它的 client 是在自己那邊崩潰的。
這類條款的下推路徑有一個共同形狀:主動製造合規 client 不會壞、不合規 client 會壞的情境,讓違規在測試階段就現形。沙箱是其中一種載體,做法是讓沙箱環境注入這些情境:在回應裡注入一個文件沒寫過的額外欄位、對同一個冪等鍵回一次重放、在正常流量中間插一個 429、回一個 cursor 過期錯誤。合規的整合方完全不受影響,沒忽略未知欄位的 client 則在測試階段就壞掉——而那正是它該壞掉的時刻。
沙箱的代價要算清楚:這是一個要維護的環境,而且它會製造「沙箱能過、production 也能過」以外的第三種結果——沙箱壞了但整合方認為是沙箱的問題。
同樣形狀的手段另有四種。production brownout 是其中觸及率唯一達到全部的——退場前刻意讓舊行為短時間失效,用一次真實故障找出所有還在依賴它的整合方;代價也最重(那是真的故障),因此它的時機與時程設計屬於退場計畫的一部分(見 11.5 版本策略與 deprecation)。其餘三種成本都低於維護一個沙箱。公開一份 conformance test 讓消費者在自己的 CI 跑,觸及率更高,而且不必維護一個環境。SDK 的 strict mode 在開發環境對違規行為直接拋錯,適合握有 SDK 的組織。發 key 前的整合審核把檢查放在一個一次性的關卡上,適合整合方數量有限而每一家都很重要的情境。選擇條件很直接:握有 SDK 就用 strict mode,有整合審核流程就放前置 gate,兩者都沒有、而條款的違反後果又嚴重到值得投資時,才建沙箱。
不論用哪一種,注入的每一種情境都要在文件裡對得上一條條款,否則它會被當成環境不穩定。
受監理或有合約義務的服務還要多一層:送達本身要留得下證據。稽核與爭議處理問的不是「對方知不知道」而是「你證不證明得了已經通知」——通知發了什麼、何時發、送達與否、對方回應如何。這一層跟強制力無關,純粹是留存形式的設計,而它通常在事後才被想起。
判讀訊號
| 訊號 | 判讀 |
|---|---|
| 事故檢討的結論是「文件裡有寫」 | 把宣告當成送達,而兩者的觀察結果在事故現場相同 |
| 條款清單很長,而它們全部只存在於文件 | 沒有做過分層,編碼的機會沒有被盤點 |
違規的回應是通用 400 或空集合 | 執行期回饋層存在但沒有表達力,消費者知道失敗、不知道為什麼 |
| 有 SDK,而條款只寫在文件不寫進 SDK 的預設值 | 槓桿最大的一層被跳過,SDK 是唯一改得動對方程式碼的位置 |
| 沙箱跟 production 行為完全相同 | 沙箱只驗連得上,沒有承擔驗證合規的責任 |
| 新條款的提案只討論「寫在哪裡」 | 送達層級沒有進入設計,預設落在觸及率最低的那一層 |
多數訊號回到同一個動作:對每一條條款問「違反之後多久會發現」,答案是「很久」或「不會」的往上推。
SDK 那一列則有自己的判讀方式,而且它可以量。問得出走官方 SDK 的呼叫佔多少比例,就表示這一層在管理範圍內;問不出來,表示它實際上不存在——一個沒有人知道涵蓋率的預設值,保護不了任何特定的消費者(涵蓋率為什麼是這一層的邊界,見 Consumer Coordinability)。
下一步路由
- 組織怎麼產出並維持規範本身:11.10 API 規範治理
- 退場宣告的通知鏈與 in-band warning:11.5 版本策略與 deprecation
- 各章那些需要被送達的條款:Idempotency key 標準化之爭 的六項條款、分頁之爭 的 cursor 條款清單、錯誤格式之爭 的演化條款
- 判定要指定答案的產物形態:11.1 API 作為服務邊界的責任
- 條款送達之後怎麼知道消費者實際遵不遵守:11.12 API 消費者用量觀測
- 既有 API 補條款時的順序:11.13 既有 API 的改造路徑
本章的分層、落點判準與編碼檢驗為機制推導,未見公開規範明文處理。