版號擋不住並行覆蓋,因為兩個作者會從同一基底取到同一個號
論述基礎與限制
一天之內同一支共用 skill 在發佈庫上被覆蓋三次,牽涉兩個專案的三個 session。三次的結構相同而角色互換:本專案在其中兩次是被覆蓋的一方,一次是覆蓋別人的一方。
三次都可在發佈庫的 git 歷史裡逐個 commit 查證,本卡的敘述以那份歷史為準,而不是以任何一方的回報為準——其中一次的當事人回報與實際歷史不符,成因寫在下面。
單一資產、單一工具、單日。 成立的是機制與修法,不是頻率。
核心原則
版號由作者指派,而不是由內容推導,所以它證明不了誰比誰新。 兩個作者從同一個基底出發,各自取「下一個號」會取到同一個號;版號比對在這種分岔上恆真通過,推送成功那一行照樣印出來,先推的那一份被整檔蓋掉而沒有任何訊號。
能偵測分岔的是基底:我這一份從哪裡長出來、對方那一份從哪裡長出來、兩者的共同祖先是什麼。三方比對回答得了這個問題,版號大小回答不了——版號只有一個維度,而分岔是兩個。
推論是:版號適合用來告訴讀者「這一份跟上一份差在哪」,不適合用來當並行寫入的閘門。 把它當閘門,等於用一個由被檢查者自己填寫的欄位去檢查被檢查者。
三次覆蓋
| 次序 | 誰蓋掉誰 | 版號關係 | 怎麼被發現 |
|---|---|---|---|
| 一 | B 的 1.8.0 蓋掉 A 的 1.8.0 | 相同 | A 在下一輪推送前比對版號時發現 |
| 二 | B 的 1.8.1 蓋掉 A 的 1.9.0 | 倒退 | B 自行發現,取回歷史版本為基底重推、讓號 1.9.1 |
| 三 | A 的 1.10.0 蓋掉 B 的 1.9.1 | 遞增(而基底是舊的) | A 推完以 git clone 回讀時發現 |
第一次是版號相同,比對恆真通過。第二次是版號倒退,工具本來會警告而推送端帶了旁路旗標。第三次的版號遞增且看起來完全正常——A 手上的基底是舊的,於是它算出來的「下一個號」比對方已發佈的號還大,比對照樣放行。三種形態在版號這一個維度上分別是相等、變小、變大,而三次的實質都是同一件事:推送方沒有對方那一份。
同型:一個信號承載兩種狀態,而讀它的邏輯只認得一種
版號不是唯一的載體。同一批事件裡另一個 session 回報了同構的一次:一筆比較並交換式的引用更新回傳退出碼 128,而該操作唯一預期的失敗是並行移動造成的衝突,於是執行者依先驗機率讀成衝突、退避重試八次全部失敗;取標準錯誤才看到真因是守衛擋下。同一個退出碼承載兩種語意,而讀它的邏輯只認得其中一種——與版號相等時有兩種可能(真的是同一版/兩個作者各自從同一基底取號)而比對只認得前一種,是同一件事。
這一例另給出一個可直接用的訊號:為條件 A 設計的重試迴圈一次都沒有成功時,那個信號多半代表 B。重試的成功率是免費的診斷資料,而它要在放棄之前讀,不是之後。
這條訊號單獨給不夠,由同一位回報者的自陳補上。 他當時查過那個引用的移動頻率(約三十秒一次),算出八次重試只跨八秒、在碰撞假設下不該全滅——矛盾算出來了,而接下來調整的是重試次數,不是假設。可調的參數就在手邊,換假設要先承認前一個是錯的,兩者的即時成本差很多,所以證據到手不等於假設會被換掉。處置要跟訊號綁在一起:重試全滅時先寫下這個結果在原假設下的機率,那個數字低到不合理就停止調參數、去讀原始錯誤輸出。
為什麼旁路會變成常態
推送工具本來有兩道閘門——portability 檢查與三方方向警告——而一個旗標同時關掉兩者,違規清單只印在標準錯誤。
真正讓它變成常態的是旁路的理由每一次都成立。那支 skill 帶著幾處教學用的票號示例,字面命中閘門的樣式而語意上正當,於是每一次推送都要靠旗標才過得去;旗標因此寫進了操作流程。真違規混在正當豁免裡,就永遠不會被單獨看見。(這個觀察由對方的 session 提出,本方的紀錄裡原本只有「旗標有未文件化的副作用」這一半。)
處置分兩步而順序不能顛倒:先把正當豁免消掉——那幾處票號示例改用不屬於任何專案的命名空間之後,閘門零違規通過,旗標當場不再需要——再把旗標從流程裡拿掉。只做第二步會讓每次推送都停在同一批已知無害的告警上,而人對重複出現的告警會停止閱讀。
有一種豁免消不掉,而它正好由修法產生。 那批票號改名之後,記錄這次改名的 changelog 為了說明改了什麼,必須把舊字面寫出來——於是裝有引用穩定性守衛的消費端拉到新版就提交不了,由該消費端回報才浮現。汰除某個字面時,記錄那次汰除的文字是它最後一個合法住址,而任何以字面為判斷標準的檢查對這個住址與對真違規一視同仁。這一類要用行內豁免標記處理,並在標記裡寫出理由;判別是問這個字面在這一句裡是被使用的還是被談論的。標記的類別要用執行者當下答得出來的維度命名——描述來源的類別(測試資料/示意/搬遷)要他知道這段文字打哪來,而那個判別句問的是它在這一句裡的用途,用途這一問才是他手上就有的資訊。這一條的一般形態(防護知識與需要它的時點錯位,分位置與維度兩軸)住在已經記錄過的失效仍然重演。
第三次的成因:拿快取端點當權威狀態
A 判定自己的內容「第二次被覆蓋」時,讀的是發佈庫的原始檔案端點。那個端點有內容分發快取,回讀到的是數分鐘前的內容,而它與真實狀態的落差沒有任何訊號——讀得到、格式正確、看起來就是答案。而 B 當時已經自行復原,也已經修好 A 打算修的那個問題。
於是第三次覆蓋的成因與它想修的錯誤同型:A 據一份過期的觀測做了一次多餘的重寫,而那次重寫蓋掉了 B 已經寫好的修正與紀錄。
查共用資產的實際狀態要用會取得完整歷史的方式(git clone),或用推送工具自己印的版號比對行——那一行比對的對象是它自己的複本,不是快取端點。這一項是代理比本體便宜的位置重合型實例:快取端點與來源在「讀得到內容」這個位置上重合,取用它與成本差無關,而重合正是它不現形的原因。
工具早就得出這個結論,而讀的人沒有跟上
這三次覆蓋發生在一支已經停用版號當判斷標準的工具上。它的同步清單產生函式在原始碼裡明寫理由:版本字串不再進入同步決策,因為兩個獨立演化的分支可能巧合共用同一版本號而內容不同,semver 比較又預設線性演進、對分支式分歧失準;內容同一性改判雜湊。那正是本卡的結論,而它比本卡早。
所以三次覆蓋的成因落在讀的人身上:工具的防線在,而讀的人仍然把一個已經被降級的顯示行當成閘門。降級發生在原始碼裡,而讀的人看到的還是同一行字——一個欄位的語意被改掉時,它在畫面上的樣子不會跟著改,而舊語意會由它的位置、它的措辭、以及所有引用過它的文件繼續供應。本站的操作規範就曾把它寫成第一順位的判斷標準,等於把那個誤讀固定下來。
降級失敗還有第二個原因,由另一位當事人的自陳提供:被降級的那一行看起來比真正的閘門更像判斷標準。 他讀工具輸出時同時看到方向警告與版號對照兩行,選了版號那一行,理由是它是數字、看起來可比較。一個警告句要人判斷它適不適用,一個版號對照直接給出大小關係——版面上更像判斷標準的那一個會贏,而它是被作廢的那一個。所以降級一個欄位時,光是改掉它在原始碼裡的角色不夠:接手的閘門要在輸出上比它更像判斷標準(給出可直接執行的結論,而不是一句要人自行判斷的描述),否則舊欄位會靠版面繼續當閘門用。
版面優勢還有第三層,由第三個來源指出:閘門在輸出裡的位置決定它過不過得了讀者的過濾。 方向警告印在開頭、成功訊息印在末尾,而讀長輸出的人用 tail 取尾段、或用 grep 撈「成功了沒」的關鍵字——兩種過濾方式最容易保留的都是最後那行,最容易丟掉的是最前面那行。本卡作者在同一天的多次推送裡逐次用 grep 過濾輸出,而過濾樣式裡有成功訊息與版號比對、沒有方向警告,直到後來才補進去。所以閘門要贏過被降級的欄位,還得贏過讀者的過濾器:印在成功訊息之後,或讓它在任何合理的過濾樣式下都留得住(例如把判定結果併進成功訊息那一行)。這條修法落在輸出層而不是原始碼層,理由由回報方點出:改掉欄位在原始碼裡的角色屬於文件規範,輸出樣式才是預設行為,而預設行為勝過文件規範。 把降級只做在程式碼與說明文件裡,等於用一份需要人去讀、去記得的規範,去對抗一個每次執行都出現在眼前的版面。
這一節與上面的退出碼是同一件事的兩種載體:語意變更不改變介面,於是舊讀法繼續成立地執行下去。退出碼在守衛加入之後多承載一種語意而數字沒變,版號在降級之後少承載一種語意而那一行沒變;兩者都沒有任何一處會通知既有的讀法它已經失效。
顯示行與檔案分家還有第二條路徑:抽取版號這件事本身會出錯。該工具的抽取邏輯對兩種寫法有不同的搜尋範圍(一種搜全文、一種只搜前置區塊),寫法互相覆蓋時取到的可能是別處那一個;而各專案自建的摘要工具在解析更新紀錄的版號行時,被破折號後緊接的反引號與日期括號截斷過(併行專案實測兩例,檔案本身正確而報告錯誤)。要確定一份資產的版本,讀它的來源欄位,不讀任何報告字串。
修法
閘門是方向警告,不是版號比對。 不帶旁路旗標推送時,工具會印出兩行:一行是「兩側自上次同步後都改動過」的方向警告,由三方基底以內容雜湊算出,看到它就去逐段比對;另一行是本地與遠端的版號對照,它是給人對照更新紀錄用的顯示,不是判定依據。本地版號高於遠端不構成推送的許可——三次覆蓋裡有一次的版號正是遞增而基底是舊的。
讓號規則:先發佈的一方保號,後發現的一方讓號。 理由是其他消費端可能已經拉走先發佈的那一版,改動已發佈的號會讓他們手上的內容與號對不起來。
推完立刻以權威來源回讀。 三次覆蓋裡有兩次是隔了一輪才發現的,而回讀的成本是一次 clone。回讀要驗的是雙方的內容各自還在,不只驗自己的。
合併時把對方的紀錄原文放回。 覆蓋事件最容易第二次遺失的是對方寫下的根因分析——它在 changelog 裡,而不在程式碼裡,所以任何以檔案為單位的比對都不會特別提醒它。
跟其他抽象層原則的關係
| 原則 | 關係 |
|---|---|
| #338 廢除共用資產時,前提與效果都在自己看不到的那一側 | 同一個共用資產家族的兩種操作。那張卡管刪除、本卡管更新;共同結構是本地證據對跨消費端的判斷不夠,而不夠的部分不會有訊號。 |
| #334 代理比本體便宜 | 本卡第三次覆蓋的直接成因是它的位置重合型:快取端點代理來源狀態。本卡另補一個形態——版號代理「誰比較新」,而它是被檢查者自己填的欄位。 |
| #291 壞掉的掃描會回報一個看起來合理的數字 | 同族的輸出形態:版號比對通過、推送成功、快取回讀成功,三者都完成了動作而量到的不是那件事。差別在那張卡的工具是壞的,本卡的工具是好的而量測對象選錯。 |
| #339 壓縮標籤省下的字數是一次,展開成本乘以引用點數 | 錨點寫法家族的延伸:兩個 1.8.0 在版號這個標識符上長得一樣,與兩個同名異義的標籤在字串上長得一樣是同一件事——標識符承載不了它被要求承載的區分。 |
| #342 寫出特定實例的值,就取代了讀者本來要做的查證 | 同一個處置形態的另一個載體:本卡是接手的閘門要在輸出上比被降級的欄位更像判斷標準,那張卡是不留可抄的東西。兩者都把正確行為做進結構,而不是交給人的記性——交給記性的代價由每一次遺忘承擔,做進結構的付一次。 |
| #221 檢查的作用域要寫成可查證的事實 | 旁路那一段的上位:豁免由被檢查者持有時,正當豁免與真違規在輸出上同形。本卡補一個處置順序——先消掉正當豁免,再收緊閘門。 |
判讀徵兆
| 訊號 | 該做的事 |
|---|---|
| 推送流程裡寫死了旁路旗標 | 先查它每次旁路掉什麼;理由每次都成立時,先消掉那個理由 |
| 本地版號與遠端相同或較低 | 先拉取合併,不要靠「我這份比較新」的印象 |
| 推送成功,而沒有人回讀過發佈端 | 覆蓋在推送端沒有訊號,回讀的成本是一次 clone |
| 兩個 session 在同一天動同一份共用資產 | 推送前互相要一個可比對的指紋,不要互相問「是不是同一份」 |
| 合併時只比對程式碼與正文 | 對方的根因紀錄住在 changelog,逐檔比對不會特別提醒它 |
核心:版號回答「這一份與上一份差在哪」,回答不了「我這一份有沒有蓋掉別人的」。後一個問題只有基底答得出來。