[T-79] 換版交代單:owner 留給特助的待辦,每次換版交出去,打勾才停 - #420
Open
pkyosx wants to merge 18 commits into
Open
Conversation
[why] 站台換版對艦隊的影響今天沒有任何訊號:seeds/ 是每個成員開機才讀 的文字,正在跑的 session 不會重讀;spec/ 是連線那一刻就定住的工具面, 不匹配時不報錯。實測 2026-09-04 一天換 8 次版、其中 2 次踩到這兩塊, 而唯一的訊號是 listener 那一行 sha 變了,要人自己去比對才知道。 owner 連續否決三種「先寫好一段字、換版時送出去」的形狀,理由同一句: 預先寫好的訊息只能複述收件人自己查得到的東西。真正沒有人查得到的是 「這次改動要求艦隊做什麼」—— 那要讀了 diff 才知道。站台自己沒有 LLM, 所以它不猜:它把材料送到 AI 成員面前,判斷與執行都交給她。 (rc-754f28ac53cd,owner 圈 [2]:不開票,直接發一則帶足材料的訊息。) [how] 換版落地時(runUpgrade,兩個觸發器共用的那一點)寫一筆 durable marker,記下 FROM/TO 的版本與 commit;下一次開機才投遞。這樣訊息可以用 過去式說「已經換到 X」而且為真、re-exec 不被網路拖住、投遞天然只有一次。 exec 失敗(舊版繼續服務)時 sha 對得上就留著不送,那是「還沒換成功」而 不是「換完了」。 檔案清單走一次免 token 的 GitHub compare(release 回應裡本來就有 target_commitish,零額外呼叫)。取不到就降級成「我不知道動了什麼」+ 比對連結,絕不降級成沉默 —— 「查不到」和「沒事」是相反的兩件事。 每次換版都發,但長度分流:沒踩到共用層的只有一行。完全靜默會製造 「沒消息=沒發生」的盲區,而一天 8 次的完整材料會把人訓練成忽略。 (觸發條件這一格 owner 沒裁,是執行者判斷,會在驗收卡上攤開。) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PyRZw1HSFVQq2Vvxre9TxD
[why] mutant 實驗抓到的:原本的測試連續呼叫兩次 recordPendingUpgradeNotice 而沒有換掉 processSHA,於是有沒有保留最早的 FROM 都得到同一個結果 —— 把那段程式整段刪掉,測試照樣綠。等於那條護欄沒有人守。 [how] 改成重現真正會發生的情境:A→B 換版後開機、投遞失敗所以 marker 留著、 接著再 B→C。這時執行中的 build 已經是 B,從行程身上讀 FROM 就會把第一段 路程弄丟。刪掉保留邏輯後這支測試會紅在 FromSHA 那一行。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PyRZw1HSFVQq2Vvxre9TxD
[why] 獨立審查(不是實作者)實測:把 runUpgrade 裡記 marker 那一行、與 cmdServe 裡掛投遞那一行同時刪掉,整包 go test 仍然全綠。也就是說這個功能 的兩個接線點可以一起消失、換版不再留 marker、開機不再投遞、特助永遠收不到 訊息,而 CI 判綠。上一顆 commit 才在檔案內層修掉同一種空護欄,它在更外層 原封不動又出現一次。 另外兩件也都是「壞掉不會有訊號」那一類: ① 投遞完是無條件刪 key。投遞會在 GitHub 上停到 20 秒,而 owner 手動觸發的 換版不參與這個檔案的鎖 —— 期間寫進去的新 marker 會被舊那則的刪除抹掉, 那次換版從此沒有人被告知,也沒有任何紀錄。 ② 未標記版本的 build 記下的 sha 是字串 "unknown",做出來的比對連結是 compare/unknown...<sha>,點下去 404 —— 而那則訊息的全部價值就是那條連結。 [how] 接線:讓開機那一步「看」變成同步並且把看到的講出來,只有投遞留在背景。 一個不出聲的開機步驟沒有人證明得了它還接著;順帶讓「marker 壞掉」這種本來 每次開機都靜默失敗的情況變成開機當下就講出來。兩個接線點各自有一支測試, 刪掉任一行都會紅。 刪除改成 compare-and-delete:刪之前重讀,不是我剛投遞的那一則就留著給下一次 開機。"unknown" 與空字串同格處理:不給連結,改說「commit 不明」;同時讓 「換版有沒有真的生效」那道守衛只在兩邊都是真 commit 時才成立,否則未標記的 build 會永遠沉默。 順帶:compare 回應超過上限時明說是「太大被截斷」而不是含糊的解析失敗; 空 diff 不再印裸的「共 0 個檔案」。 這一輪新增六條 mutant(合計 12 條),每條都確認紅在對的斷言。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PyRZw1HSFVQq2Vvxre9TxD
[why] 複驗抓到的:上一顆把兩個接線點釘住了,但接線內部那一行 `go s.deliverPendingUpgradeNotice()` 換成 no-op 之後測試仍然全綠 —— 而開機照樣印「delivering to the assistant in the background」。 那句話從此是謊話,訊息永遠不會送出。 一個會說謊的開機訊息比沉默更糟:它正好是讀的人會相信的那一句。 未測的縫是往內移了一格,不是被關掉。 [how] 照 auth_alert.go 既有的 authAlertDeliver 那個 seam 做一個 upgradeNoticeDeliver(production 為 nil)。測試裝一個 deliverer 就能 斷言「有 pending 時真的派了投遞」,不必碰會 flaky 的 held-port pattern。 順序也調成先派再宣告,讓那句話只在真的排出投遞之後才印。 順帶收掉複驗點名的病句:commit 不明時,⚪ 那一行原本會寫成 「要自己看就點 (無法產生比對連結…)」—— 叫人去點一個不是連結的東西。 兩條新 mutant,都確認紅在對的斷言(合計 14 條)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PyRZw1HSFVQq2Vvxre9TxD
[why] 獨立審查點出的:把 Fprintf 換回 go deliver() 前面,測試照樣綠 —— 也就是「先派再宣告」這個順序沒有任何東西釘住。原本的註解讀起來像是那個 順序有意義而且被保護著,下一個人會據此以為動它會有東西擋。 不補一支只為了鎖住語句順序的測試:那本身就是下一個會擋路的空護欄。 改成把「什麼有被守住、什麼沒有」寫清楚。 [how] 純註解,零可執行改動。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PyRZw1HSFVQq2Vvxre9TxD
pkyosx
force-pushed
the
t-79/upgrade-migration-notice
branch
from
September 4, 2026 19:03
fa06485 to
5f9ed06
Compare
[why] owner 兩次否決自動生成的通知訊息,第二次講明了缺的那一半: 「換版通知,我是要我們可以指定這次換版要送什麼訊息給特助的功能,不是固定訊息」。 接著他自己定了形狀:「交代單在db 中 是一筆筆 record 執行完以後 Mira會打勾」。 所以單位是「一列帶完成狀態的記錄」,不是一則訊息 —— 訊息會被讀到的人消耗掉, 而且沒有任何地方記得那件事到底有沒有被做完。 送出時機不綁 commit:綁 commit 的唯一理由是「綁下一次換版會被不相干的那一次 領走」,而沒打勾的列會一直留著 ⇒ 被領走不是損失,下一次換版還會再交一次。 (實測:2026-09-05 這台站台一天換版九次,全部沒有通知任何人。) done 用旗標不用刪列:特助需要「不要再被交同一件事」,刪列也做得到;但 owner 需要看見「還有幾張在等」—— 這個設計唯一的失敗模式就是交代單一直沒人做, 而沒有未完成計數的話那個失敗是完全靜默的。 [how] 版號 00085:2026-09-05 對所有遠端分支的兩個來源(migrations/*.sql 與 goose.AddNamedMigrationContext)掃過,main 最大 00080、在飛的 T-33 佔 00081-00084, 由 OffiCraft 開發者自己重掃後核可。不取任何 gap。⚠️ 本包必須排在 T-33 之後 land,這是實測不是推測:正式庫已把 00081-00083 記成 已套用而那三支檔只在 T-33 分支上。量測結果(goose v3.27.2 + modernc sqlite v1.53.0, 呼叫方式對齊 migrate.go、未傳 allowMissing):檔案不在磁碟上的已套用版本會被忽略、 本包在今天的正式庫狀態下 exit 0;但號比資料庫現版本小又沒套用過的檔案會失敗, 而且不會自己好 —— 資料庫停在 85、00084 永遠套不上、之後每次啟動撞同一個錯。 四個條件的原始輸出(含陽性與陰性對照、以及六項沒有量到的限制)已釘在 T-79 上。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8pAWnL7snEpJwUb8B2XSa
[why] 交代單要能被人建立、被特助打勾、被 owner 收回,還要能查「還有幾張沒完成」。 最後那一項不是順手加的:這個設計唯一的失敗模式就是交代單一直沒人做, 而沒有未完成計數的話那個失敗是完全靜默的,所以 open_count 進契約, 不是留給每個 client 自己從陣列長度推。 權限刻意不是一個平面: - 寫入與收回是 owner 一個人的。特助如果能自己寫給自己的交代單,那一列就不再是 任何事情的證據 —— 這是它不能跟其他兩個動詞共用 admin_agent 樓層的理由。 - 打勾是 owner 或特助。她打她做過的,他打他自己做的或親眼看到的。 路由表的 Requires 只能表達樓層、表達不了「限這一位成員」,所以兩個收窄寫在 handler, 並且就寫在執行它的那段碼旁邊。 打勾是「第一次贏」,而且用 SQL 的 WHERE 擋、不是先讀再寫:特助每次換版都會被交 整批未完成的交代單,她有兩個 session 同時處理同一張是常態不是意外。 第二次打勾回 200 且什麼都不改 —— 讓競爭的輸家讀成錯誤,只會教她不要相信正確的答案。 刪除路徑存在的理由要講明:打勾是特助的動詞、意思是「我做了」。沒有收回路徑的話, 一張寫錯的交代單會永遠每次換版都被交出去,而唯一能停下它的方法是請特助去證明 一件從沒發生過的工作。 [how] spec 用一次性腳本以文字方式編輯(bin/t79_spec_upgrade_instructions.py), 不做 json round-trip —— 那會重排 21k 行、把真正的改動埋掉。前例是 bin/t646a_spec_update_task.py。腳本會拒絕跑第二次,並自己重新解析驗證 x-mcp.order 仍然是連續的 0..N-1。 四個產生器都重產過:gen-ocapi、gen-mcp-catalog、openapi-typescript、gen-migration-lock。 drift-ocapi / drift-mcp-catalog / drift-schema-ts 三個閘門都過。⚠️ 本包沒有做 SSE:交代單的變動不會即時推給座艙,owner 要重新整理才看得到特助的 打勾。加一個新的 SSE topic 屬於 wire spec 的擴張,這一包刻意不做,寫在這裡免得 下一個人以為是漏了。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8pAWnL7snEpJwUb8B2XSa
[why] 交代單的資料表與 API 已經有了,但沒有任何東西把它們交到特助手上。 這一顆把「還沒打勾的交代單」接進換版時那一則訊息。 交代單放在 diff 的前面,不是為了強調:diff 那一半是可以略讀的背景(大多數換版 不會動到她關心的東西,而 upgradeNoticeBody 的單行形狀就是為了讓她能略讀), 交代單那一半才是有人在等的。把可略讀的放前面,等於把要做事的那一半推到摺線以下。 沒有未完成的交代單時,這一段完全不印。一個永遠印「0 張交代單」的區塊,會把每一次 無害的換版都變成有標題的東西 —— 那正是訊號被訓練成雜訊的機制。 「讀不到交代單」不會被摺進「沒有交代單」。那是相反的兩件事(他沒交代 vs 我查不出 他交代了什麼),分不出來的讀者會把第二種讀成第一種。查詢失敗時照 compare 失敗的 同一條原則處理:降級成比較少的材料,不降級成沉默。 [how] deliverPendingUpgradeNotice 讀 ListOpenUpgradeInstructions,交給新的 upgradeNoticeMessage 組裝;upgradeNoticeBody 的簽名沒有動,所以既有十個呼叫點 與它們釘住的行為都不受影響。Meta 多帶一個 upgrade_instructions 的 id 清單, 永遠是非 nil 的陣列 —— JSON null 與空陣列對 client 讀起來不一樣,而「這次沒有」 是一件值得說出來的事實,不是一個缺席。 清單有上限(20 張),超過的只報數量:特助每次換版都會被交整批未完成的, 沒有上限的話一個沒人處理的積壓會把這則訊息撐到讀不完 —— 那跟沒有訊息是同一個結果, 只是比較貴。 🔴 [尚未完成] 這一顆沒有測試。下一輪要補的最少四條,都是失敗會靜默的性質: 1. 沒有未完成交代單時,安靜的單行形狀要保持安靜(section 為空)。 2. 交代單要排在 diff 之前。 3. 讀取失敗的訊息不能讀起來像「沒有交代單」。 4. 上限生效時要說出漏了幾張。 另外還要補:DAL 的「第一次打勾贏」、handler 的兩個權限收窄、以及送出時 Meta 帶到 id。 每一條都要有我親手做出來的 mutant 讓它變紅。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8pAWnL7snEpJwUb8B2XSa
[why] 這個功能的每一種失敗都不會報錯。訊息安靜地少一段、交代單安靜地不再 被交出去、讀取失敗安靜地讀成「他沒交代」、第二次打勾安靜地改掉誰做的、 某個動詞安靜地放進不該進來的人 —— 沒有 panic、沒有 error、沒有任何一支 既有測試會紅。一個工作是「把交代送到讀的人手上」的功能,失敗的方式就是 變安靜,而安靜正是一般測試套件會判成通過的樣子。 同時補上 bd55ebe 漏掉的一格:那一包的兩個 handler 層授權窄化 (寫入/收回限 owner、打勾限 owner 或特助)從來沒有登記進 authzOutsideRouteTable,所以 TestAuthzOutsideTheRouteTableIsEnumerated 在 bd55ebe 上就已經是紅的(已在無新測試檔的樹上覆核過)。那道閘防的正是 T-6020 漏掉 webhook 那一族:治理複審讀 routes.go 的 Requires 欄, 在 handler 裡做的決定它看不見。 [how] 新增 upgrade_instructions_t79_test.go,七條性質:空集合完全不印、 交代單排在 diff 之前、讀取失敗永不被折成「沒有」、上限說出漏了幾張且 標題報誠實總數、第一次打勾贏、兩個窄化各自的准與拒、以及換版路徑真的 去讀那個開放集合並把 id 帶進 Meta。每一條都用一個親手做的 mutant 驗證過 會紅、而且紅在該紅的那條斷言上(共 12 個 mutant,全數確認)。 授權那一格走登記路線(選項 b)而不是搬進 route table:Requires 講的是 「階梯上的一階」,而這兩條講的是「這一位成員」,階梯沒有那一階;把 floor 提到 owner 會把特助擋在她自己的交代單外面。 點名測試涵蓋 124/全部 2180。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017NuBtjxkrHi9FDwVaayPYo
[why] 到這一顆為止,交代單只有指令建得起來 —— 而它是寫給 owner 用的東西。 沒有面板,這個功能對它唯一的作者不存在。 [how] 沿 wire → mappers → types → adapter → mock/http → hook → component 一條線補齊,掛在 設定 › 系統更新與備份,緊接簽章金鑰之後(它是「這一次換版 要帶什麼」的唯一入口,屬於換版那一段)。 三個刻意的決定,每個都對應一種「壞掉不會報錯」的失敗: - **打完勾的留在清單上。** 藏起來會讓「她做完了」跟「這張從來沒被寫過」長 得一樣,而打完勾的那一列是這件事曾被接手過的唯一證據。 - **未完成的張數取自 server,不是數畫面上有幾列。** 兩者今天一致,等到任 一邊分頁就不再一致,而那一天沒有任何東西會叫。 - **草稿只有在寫入真的成功時才清掉。** 被拒絕的那一次正是他要重試的那一 次,清掉等於把他打的字丟掉還沒有東西可以retry。 - mapper 把 `done_ts: 0` / `done_by: ""` 收成 null:照原樣畫會在一張沒人碰 過的交代單旁邊印出 1970 與空白的人名。 顏色一律走 theme token(座艙的外觀是執行期改寫 --color-* 的主題包,寫死色 值只在其中一個主題成立);`lint:tokens` 與 `lint:token-roles` 都過。 交代單本文可以到 2,000 字且可能含不換行的 id,所以列用 grid 讓文字獨佔一欄 並 `overflow-wrap: anywhere`,避免整頁橫向捲動。⚠️ 本包**沒有** SSE:特助打勾不會即時到座艙,要重新整理。加新 topic 屬 wire spec 擴張,不在本包 —— 但「她剛打完勾」與「這份清單是舊的」在畫面上長得一 樣,所以卡片用一句話講出來,而不是讓過期的清單冒充現況。 測試:卡片 13 條、mock↔server 對帳 9 條。mock 的字數上限直接讀 `api_upgrade_instructions.go` 的常數,改一邊另一邊就紅。 前端整套 321 檔 / 2,973 條全過。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017NuBtjxkrHi9FDwVaayPYo
[why] jsdom 沒有版面引擎,所以卡片那支 vitest 斷言「收回按鈕存在」時, 按鈕在畫面上還是被擠到卡片右緣外,兩者是同一句話。手機上不是。一顆碰不到 的收回按鈕等於不存在。 而且看圖才看得到的東西,測試一條都不會叫:badge 那一欄原本是 `auto`, 每一列又各自是一個 grid ⇒「已完成」比「還沒完成」窄,於是每一列的文字 起點 x 都不一樣。三列並排讀起來不像同一個物件。改成固定寬度。 [how] mock 的第二張未完成交代單改成帶 migration 路徑與完整 40 字 sha —— 那不是刻意刁難的資料,「告訴特助去看哪個檔」正是這個功能的用途,而路徑跟 sha 都不能在空白處斷行。那一列又是未完成的,所以同時掛著兩顆按鈕,是這張 卡最寬的一列。 護欄在 320/375/1040 量四件:沒有任何一層橫向溢出(列、卡片、 `.settings` 這個 overflow-y:auto 的面、整頁)、兩顆按鈕都在卡片內、 輸入框也在卡片內、以及**文字至少佔卡片寬度一半**。 最後那一條是量出來的,不是照直覺加的。兩個 CSS mutant 在真瀏覽器跑過: - 拿掉 `overflow-wrap: anywhere` ⇒ 320/375 紅(sha 斷不了,整列衝出去)。 - 拿掉 ≤520px 的 media query ⇒ **溢出斷言全綠**。我原本在檔頭寫它會紅, 照實改掉了。它真正的代價是可讀性:320 時交代單文字從 166px 被擠成 36px(大約兩個字),375 時從 221px 到 91px。那正是 css-layout-traps.md 的〈把句子擠成一條〉,所以護的是可讀性不是溢出 —— 補上那條斷言之後, 這個 mutant 就紅在它身上。 CT 整套 497 條全過。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017NuBtjxkrHi9FDwVaayPYo
[why] 掃全樹之後,本包唯一讓現在式句子變假的地方是 `docs/guide/` 裡三份 「系統更新與備份這一頁上有什麼」的列舉 —— 換版交代單掛上去了,三份都還沒 提到它。那不是會漂移的數字,是文件自己選擇寫出來的具名清單(簽章金鑰當初 就補進去過),所以照根 CLAUDE.md〈同一事實的複本檢查〉要一起更新。 [how] - `settings.md` 的子頁表格補一項。 - `settings.md` 的段落補一段完整說明:寫下去就一直在、沒有「幾點送」、 第一次打勾的人算數、收回是永久刪除且說明它為什麼必須存在、打完勾的留在 清單上,以及**特助打勾之後這一頁不會自己更新**(那是本包刻意不做的那一 格,使用者只在畫面上看得到一句話,文件應該也講)。 - `interface.md` 那一份是第三份複本,而且在本包之前就已經漏了簽章金鑰。 **改成不再逐項列、指向 `settings.md`** —— 那正是同一個檔案自己在〈頭像 下拉〉那一行採用的做法,逐字寫著「寫死在文件裡的清單只會悄悄過期」。 掃過但確認不用動的(照實記,免得下一個人重掃):`seeds/` 零命中 —— seed 教的是用 `tools/list` 發現工具、並明講「不要依記憶硬寫工具名稱」, 所以四個新 MCP tool 不需要 seed 同批更新;而且那則訊息本身就把工具名寫進 內文,agent 在需要的當下就被告知。`spec/sse.md` 沒有變假(本包沒有 SSE, `SSE_RESYNC_TOPICS` 未動)。`spec/mcp.md` 明講不寫下數量。 `server/CLAUDE.md`、`frontend/CLAUDE.md`、`frontend/.claude/rules/*` 沒有任何 現在式不變量因本包變假。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017NuBtjxkrHi9FDwVaayPYo
Kyle(OffiCraft 開發者,跨票協調的持有人)於 c-32dec104120c 裁定現在就改, 並於 c-9d5402dfd5db 更新:#432 已按下,main 5f6aa93 → 283ee51,站台正在 套用 00086。從那一刻起 00085 是「編號小於 current version 且未套用」,goose 在收集階段就 panic、ocserverd exit 1,而且不會自己好——所以 00087 不是最佳化, 是正確性下限。 取號依據(我自己跑的,腳本 /tmp/claude-501/t79_migno_scan.sh 可重跑): git for-each-ref refs/remotes/origin/ ⇒ 439 個 ref,兩個來源都掃 (migrations/*.sql 與 AddNamedMigration*("NNNNN)。帶 00001/00080 當陽性 對照並全數命中,所以「掃到的空號」與「掃法壞了」分得開。全域已佔到 00086。 T-33 的 00088 由 Kyle 分配並已由 O-197 登記——版號分配是跨票協調,不是我 自己替別人決定的。⚠️ 已分配但還沒 push 的號對任何重掃都是隱形的(for-each-ref 看不到不存在的 ref)。所以「重掃看到 87 是空的」不是證據,Kyle 那本帳才是。 改動範圍: - migrations/00085_… → 00087_…(git mv,Down 段不帶號碼,不需另改) - 檔頭號碼註解整段重寫:把「必須排在 T-33 之後」改寫成正確的不變量 ——每包各自的、在被套用那一刻我的號要大於站台已套用最大號;並寫明 「掃過」不等於「取號完成」,最後按下合併的人必須當場重新量。 - migration.lock 以 bin/gen-migration-lock 重生成,diff 只有結尾那一行 加上 roll 雜湊(00001–00080 一行未動,append-only 成立) - dal_upgrade_instructions.go 檔頭指路的 (migrations/00085) 一併改 驗證(本機): - 全庫 grep 00085 ⇒ 零命中(含 spec/、frontend/、cli/、docs/、註解與散文; Kyle 提醒 T-33 那邊 38 處裡有 36 處是散文而測試不會紅,我照著數過了) - go test ./ocserverd -run 'Migration|Upgrade|T79' ⇒ 181 支全過 - 真 binary 實跑:全新 DB 從零跑到底 ⇒ successfully migrated to version: 87,rc=0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAjprHAkfKpvi5Saem6fJU
…n-notice # Conflicts: # server/ocserverd/migration.lock
PR #420 的 go-checks 在 43b68e7 上是紅的, TestEveryMCPToolDescriptionAgreesWithItsSources 報四筆 NEW description drift (route_summary),四個 upgrade-instruction 工具各一筆。 規則(spec_catalog_conformance_test.go:494):route table 的 Summary 與 openapi 的 summary 都必須逐字等於該 operation 的 x-mcp.description,除非 在 knownToolDescriptionDrift 有明列的 baseline。新工具不該進 baseline。 成因:spec 是用一次性腳本 bin/t79_spec_upgrade_instructions.py 編輯文字寫進去 的,而 routes.go 的 Summary 是另一手寫的一份 —— 同一件事的兩份說法,寫下去 的當下就分岔了。openapi 的 summary 那一份是對的(四個都與 x-mcp.description 相同),只有 route_summary 這一份漂了。 修法:把四個 Summary 逐字換成 openapi 對應 operation 的 x-mcp.description, 不動 x-mcp.description 本身(它是 owner 過目的那一份文字)。⚠️ 為什麼本機沒抓到:上一輪點名跑的是與主題同名的測試,而這支治理閘的名字 裡沒有 upgrade / T79 / migration 任何一個字。點名跑測試的分母要含被改動檔案 所屬的治理閘 —— 這是本票第二次踩到同一個形狀(第一次是 TestAuthzOutsideTheRouteTableIsEnumerated)。 驗證:go test ./ocserverd -run TestEveryMCPToolDescriptionAgreesWithItsSources ⇒ ok(改前實跑是紅的,四筆逐字重現 CI 的訊息)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAjprHAkfKpvi5Saem6fJU
PR #420 的 conformance job 在 43b68e7 上 5 failed / 1439 passed: test_mcp.py::test_catalog_hash_algorithm test_mcp.py::test_catalog_hash_keys_off_tool_surface_only test_mcp.py::test_t6020_withheld_routes_stay_owner_only_and_unlisted test_mcp.py::test_tools_list_equals_frozen_snapshot_elementwise test_rest_happy.py::test_openapi_covers_manifest 成因:conformance/routes_manifest.json 這份 committed、凍結的 route snapshot 從頭到尾沒被這一包碰過(git diff origin/main...HEAD -- conformance/ 是空的)。 四條新 route 沒登記 ⇒ manifest ≠ spec/openapi.json;而 catalog_hash 是從 manifest 的非排除 route 重算的,所以連帶紅四支 test_mcp。conformance/CLAUDE.md §1 明講「沒有生成器可替代這個裁決」—— 這份是手維護的,漏了不會有人提醒。 補了三塊: 1. routes_manifest.json 四列(auth=gated、requires=admin_agent,取自 routes.go 的 Requires: principalAdminAgent;mcp_tool 取自各 RouteSpec)。 2. test_auth_matrix.py 四列 + 一支獨立測試。🔴 重點是把 handler 層那三段 「比路由樓層更窄」的授權真的釘住:create 與 delete 只有 owner(admin_agent 403)、done 是 owner 或 mira(其他 admin_agent 403)。兩條 {instruction_id} 的路由用 deny-first 探針——被拒身分打的是不存在的 uin-nope,403 才證明 選擇在解析那一列之前就發生(回 404 等於讓呼叫者問出它存不存在)。 mira 不在 IDENTITIES 裡,所以她的三面另開一支測試:寫 403、收回 403 (鬼 id 與真 id 都試,並回頭確認那一列還在)、打勾 200 且 done_by == mira。 3. test_rest_happy.py 四列 happy + 三支語意測試:第一次打勾贏(mira 先打、 owner 後打 ⇒ 200 且 done_by/done_ts 不變)、空白 body 422、不存在 id 404。 驗證: - bash conformance/run.sh --target go ⇒ 1490 passed, 0 failed。 我自己另跑一次覆核(不只信代跑者):同樣 1490 passed。 - test_mcp 那四支是補完 manifest 後自己轉綠的——實跑確認,不是推論。 - 3 個親手 mutant,各自只紅一支且紅在該紅的斷言上:把 admin_agent 對 create 的預期改成 200 ⇒ 該 cell 紅;把 mira 寫入的斷言改成 200 ⇒ 該支紅;把 done 的 admin_agent 預期改成 404(deny-first 洩漏的形狀)⇒ 該 cell 紅。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAjprHAkfKpvi5Saem6fJU
第二輪獨立審查的「建議但不擋」第 ② 條。原註解寫「It carries the COUNT the message was built from, which is the honest number even when the list itself was truncated for length」——但那個欄位的值是 upgradeInstructionIDs(...), 一個 id 清單,不是數字。 實際的不變量是相反的方向:被截斷的是散文那一半(body 有長度上限),id 清單 沒有上限,所以兩者不一致時**這個欄位才是誠實的那一份**。照這個意思重寫。 零行為改動(只有註解)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAjprHAkfKpvi5Saem6fJU
Owner
Author
|
Cross-PR integration note from the independent review of PR #455: this PR deletes |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
這個 PR 現在做的事,比它原本的標題大得多
Owner 在 2026-09-05 連續三次補規格,把這張票從「換版之後送一則說明給特助」改成
「一份跨換版存活的待辦清單」。他的原話:
所以這一包不是通知,是 換版交代單:owner 寫下來,它就一直在;站台每次換版,
還沒打勾的全部被交出去一次;打勾了才停。
流程與設計(給 owner 看的版本):https://claude.ai/code/artifact/359c3423-4e5a-48a5-8a89-da07f7f564ef
內容
migrations/00085_upgrade_instruction.sql—upgrade_instruction(id, body, created_ts, created_by, done, done_ts, done_by),索引(done, created_ts),id 前綴uin-dal_upgrade_instructions.goapi_upgrade_instructions.go+routes.go四列 + spec + 四個產生器upgrade_notice.go— 換版那一刻讀開放集合,交代單排在 diff 之前UpgradeInstructionsCard.tsx、useUpgradeInstructions.ts、.upgrade-instr*、zh/en i18n權限:路由樓層一律
admin_agent;寫入與收回在 handler 收窄成 owner 一個人,打勾是 owner 或特助。caller identity 一律取自 verified token,不從 request body 取。
這兩個窄化已登記進
authzOutsideRouteTable(階梯講的是「哪一階」,這兩條講的是「哪一位成員」,
Requires表達不了)。幾個刻意的決定,每個都對應一種「壞掉不會報錯」的失敗
幾天之後她就學會不看了 —— 訊號被訓練成雜訊,正是這張票要避免的失敗。
訊息看起來完全正常。
常態;第二次打勾覆蓋
done_by會把「誰做的」變成「誰最後讀到的」,回應還是 200。驗證
(後端 12、前端 12、CSS 2)。
83 的狀態),表與索引確認建出。
(三個寬度:不溢出、兩顆按鈕都在卡片內、文字至少佔卡片寬一半)。
TestAuthzOutsideTheRouteTableIsEnumerated在bd55ebe0上就紅了 —— 那兩個 handler層授權窄化從來沒有登記。已在
eec757dc補上。🔴 合併前必做(migration 取號)
按下合併之前要現場重算 migration 號碼,因為別的包可能已經先落地:
migrations/*.sql與AddNamedMigration*),帶一個一定會中的號碼當陽性對照確認掃法沒被截斷。
goose_db_version最大值」與「所有比本包小且尚未落地的號」的最大值 +1;不大於它就當場改號。
grep舊號零命中,重跑bin/gen-migration-lock。不變量是每包各自的:「我被套用的那一刻,我的號必須大於站台當時已套用的最大號」。
排序只是成本最佳化,不是安全機制(Kyle 在
c-1d3afd665326改正了我原本相反的說法)。出事時怎麼回去:這一包在 server 端是純新增(新表、新四列 route、送出點多讀一次),
沒有改任何既有資料的形狀。revert 掉 commit 就回到原狀;但
00085一旦被套用過,goose_db_version就記著它 —— 要真正退掉必須跑它的 Down(DROP TABLE),不能只revert 檔案,否則下一次啟動會變成「檔案缺席的已套用版本」(那一格我實測過是被忽略、
不會擋開機,但表會留著)。
沒有 SSE:交代單的變動不會即時推給座艙,owner 要重新整理才看得到特助的打勾。
加新的 SSE topic 屬 wire spec 擴張,不在本包 —— 但「她剛打完勾」與「這份清單是舊的」
在畫面上長得一樣,所以卡片用一句話把它講出來。
🤖 Generated with Claude Code
https://claude.ai/code/session_017NuBtjxkrHi9FDwVaayPYo