ADR-007: 既有下游 server 的 feature slice 採用路徑(provision migrate)
ADR 編號: 007 狀態: 已接受 (Accepted) 決策日期: 2026-07-10(2026-07-11 修訂:source-to-target 化、
adopt+add併為provision) 決策者: AppFuse Team + AI Assistant 範圍: docs-methodology(既有下游 server 如何接上 feature slice 的provision/sync) 後續修訂: ADR-012 取代 D-A′ 的後續更新方向與syncedSHA終態;本 ADR 的 provision migrate、首次 drift triage 與 per-Feature transaction 繼續有效
摘要
ADR-006 定義了 feature slice 模型,其「遷移順序」七個步驟全部作用在框架自身的 app-server。第 7 步讓 /scaffold-module 變成 feature-aware,只嘉惠新專案。既有下游如何接上 provision / sync,ADR-006 未定義——三個下游 server 仍是 layer-first、base package 各異、features.json 皆無,sync 無從定位檔案。
本 ADR 定義 provision 的 migrate 路徑:把一個既有的 layer-first server 逐 feature 遷移成 package-by-feature,並在過程中建立它自己的 features.json,使其成為 provision / sync 的合法參與者。
核心決定有四:provision 是 /feature 的統一入口(自偵測 present→migrate / absent→install,本 ADR 定義 migrate 路徑);source-to-target——由框架駕駛座驅動下游 fleet,比照 /methodology、/practices、/scaffold-*;逐 feature 增量、人確認、每步 build 綠(鏡像框架自身第 3 步的做法);drift 必須先分類再覆蓋——sync 的零分歧覆蓋不可盲目套用到既有下游。
不採用是安全的:代價僅「拿不到 core slice 改良」,與現況完全相同。provision 是可選、非破壞的加值,同 /methodology、/practices 當初的處境。
背景 (Context)
問題陳述
sync 的前提是「框架與下游對同一個 feature 有可對應的檔案集」。既有下游三項皆不滿足:
- 佈局不對應:框架已 package-by-feature(
{feature}/),下游仍 layer-first(controller/、service/、entity/、dto/、repository/)。無檔案對應關係。 - base package 各異:連組織前綴都不同。
sync不能是複製,必須複製 + retarget(package 宣告與 import 全需改寫)。ADR-006 的 D-D 只寫 install(帶入新 feature)要 retarget,sync只寫「零分歧覆蓋」——未言明。 installed無宣稱:下游沒有features.json,且各自手動增刪過 feature。
量測(2026-07-10,全量非抽驗)
以框架 app-server 的 112 個 core slice 檔(扣除 seam 與 {feature}/demo/;後者已由 ADR-006 D-B 更正 廢除,本數字為 2026-07 當時的量測)為地圖,依類別簡名血緣比對三個下游:
almanac-server | ict-server | tts-server | |
|---|---|---|---|
| base package | io.leandev.almanac | com.tqf.ict | io.talentonline.tts |
| Java 檔數 | 127 | 283 | 211 |
| 命中 core slice | 58 | 69 | 62 |
| 其中本體逐字相同 | 28 | 24 | 37 |
| 本體漂移 | 30 | 45 | 25 |
| 完整安裝的 feature | cache, calendar, mail, scheduling | cache, mail, platform-info, scheduling, signed-link | cache, mail, platform-info, scheduling |
| 完全未裝 | acting, delegation, impersonation, notification, signed-link, tenant | acting, calendar, delegation, impersonation, service-account, tenant | acting, calendar, delegation, impersonation, service-account, signed-link |
「本體」= 剝除
package與import行後的內容(搬遷本身必然改動這兩者,計入會使所有檔都「不同」)。
量測推翻的三個先前假設
-
「core slice 零分歧覆蓋成立」不成立於全量。 此宣稱源自對
EmailService、TokenBlacklistService兩檔的抽驗(皆 0 行差異)。全量下每個下游有 25–45 檔本體漂移。多數看似下游落後(UserResponse為框架後續改動、MailSetting、CodeData),但無法先驗地與真客製區分。盲目覆蓋會沖掉真客製。 -
installed不是布林,是覆蓋率。 三個下游的auth皆只命中 15/20 檔——缺的是框架在它們 scaffold 之後新增的檔。故 migrate 一個 feature 同時是「搬家」與「補齊」,它吸收了第一次sync。 -
下游的 feature package 是混合的。
ict-server的notification/同時住著框架血緣的NotificationTypes、EnvSubjectPrefixTemplateResolver,與 ict 自有的AccountActivationNotifier、TaskNotificationPublisher。migrate 必須在 package 內部做 ADR-006 D-B 的切分(core slice vs 下游自有的 wiring / 業務碼),不是整個目錄搬走。
核心張力
sync要能零分歧覆蓋,前提是 core slice 真的中性;而既有下游的 core slice 已漂移,且漂移的成因(落後 vs 客製)機器分不出來。
不解這個張力就不能 migrate:migrate 完成的那一刻,下游即成為 sync 的目標,下一次 sync 就會覆蓋它。
決策 (Decision)
D-A:provision 是 /feature 的統一入口,自偵測 migrate / install
/feature 的三個模式共用同一套機制(讀寫 catalog、base package retarget、core/seam 判定、不變量校驗):
| 模式 | 對象 | 作用 |
|---|---|---|
status | 任一 server(fleet 或單模組) | 盤點已裝 feature、與框架的 drift |
provision {target} {feature} | 任一下游 | 自偵測:present(散在 layer 目錄)→ migrate 路徑(搬家 + 補齊);absent → install 路徑(帶入框架版);partial → 兩者。首次對某下游 provision 時 bootstrap catalog |
sync [{target}] [{feature}] | 已 provision 的下游 | 以框架 core slice 覆蓋(seam 不動);可廣播 fleet |
provision 併掉舊 adopt 與 add:兩者終點相同({base}.{feature} package + catalog 登錄),差別只在起點——feature 在下游存在(散在 layer 目錄)走 migrate、不存在走 install。從框架駕駛座看,「讓 target 具備 feature 的 slice」是單一意圖,present/absent 是工具可自偵測的實作細節,不值得分裂成兩個動詞。本 ADR 定義的是 migrate 路徑(既有 layer-first 下游的採用);install 路徑見 ADR-006 D-D。
比照 /methodology 與 /practices:bootstrap 是 provision 的一個分支,不是另一個工具。
D-A′:source-to-target——框架駕駛座驅動 fleet
2026-08-18 修訂:來源端 fleet
status仍保留;後續有損更新改由下游依 immutable app release 執行/feature upgrade。本節的 source-side broadcast write 與syncedSHA終態已由 ADR-012 取代,provision 的明確 target 操作不變。
/feature 與 /methodology、/practices、/scaffold-* 一致,為 source-to-target:catalog 的 SoT 住在框架(role: source),由框架駕駛座驅動下游 fleet。
- 框架模式(在
app-serverrole: source執行):status盤 fleet drift dashboard、sync廣播、provision {target} {feature}選定下游、cd 進去驅動(比照/scaffold-module)。fleet 探索比照/practices(掃 sibling workspace 的方法論血緣下游,realpath/role 守衛)。 - in-target 模式(在下游 server 模組執行,保留選項):對本模組操作、discovery 出框架。下游團隊要在自己 context 發起時用;skill+asset 隨
/practices publish續下行以支援此模式。 - 這消掉了舊設計「下游 Glob 回頭找 framework」的反向 discovery——框架本就持 catalog SoT,讓它當 anchor 才一致。
D-B:逐 feature 增量,人確認,每步 build 綠
provision 一次只處理一個 feature。這鏡像框架自身走過的 ADR-006 第 3 步(逐 feature 搬 package),該步驟是本流程唯一已驗證的先例。
每個 feature 的處理是一個可獨立提交、可獨立回退的單元:
0. 基線 ./gradlew build 記 baseline(pre-existing 紅測試不誤判為 provision 引入)
1. 偵測+提案 工具依框架 core slice 清單,判定 present/absent/partial 並列出下游對應檔:
present(命中)/ missing(框架較新)/ drifted(本體不同)/ seam(不覆蓋)
混合 package 另列「下游自有、非框架血緣」的鄰接檔
2. 確認 人逐項裁決映射與 drift 分類(見 D-C);工具不自行判定「這是客製還是落後」
3. 搬遷/安裝 migrate:git mv 至 {base}.{feature}、改寫 package 宣告與 import;
install/補齊:自框架複製 missing 的 core slice → retarget;seam 保留下游版本;
設定依 D-G 兩道 guard 處理
4. 切分 下游自有的接線/業務碼一律留在業務 package(ADR-006 D-B 更正後無 demo/)
5. 登錄 寫入 features.json(該 feature 的 kind / requires / seams 由框架下行)
—— **必須先於 --check**:I3 驗的是 catalog 宣稱 vs 推導圖,未登錄則驗到空 catalog、vacuously 通過
6. 驗收 ./gradlew build 綠(零新增失敗)+ feature-inventory.py --check 三條不變量綠
7. commit 一個 feature 一個 commit
不採一次全搬:ict-server 283 檔、混合 package 需切分、drift 需判讀,一次全搬難以回退,且無法在中途發現「這個 feature 其實不該 provision」。
不採純引導:283 檔的 package/import 改寫是機械工作,人做只會出錯。工具搬、人裁決。
D-C:drift 三分類,sync 只覆蓋「落後」
provision(migrate 路徑)時對每個 drifted 的 core slice 檔,人須裁決其成因:
| 分類 | 意義 | 處置 |
|---|---|---|
| 落後(stale) | 下游是框架的舊版,差異來自框架後續改良 | 以框架版覆蓋。往後 sync 正常覆蓋 |
| 客製(customized) | 下游有意改動,且該改動是專案專屬 | 提升為 seam——登錄進該下游 features.json 的 localSeams,sync 永不覆蓋 |
| 改良(improvement) | 下游有意改動,且該改動對全 fleet 有價值 | 走 reflow(比照 m-methodology-sync.md 不變量三:重做、非盲 copy)回框架,再 sync 回正 |
這是 migrate 路徑存在的真正理由。若 core slice 真的零分歧,provision 只是機械搬家、可全自動;正因為它不是,migrate 必須是一道人在迴路的關卡。
更正:三分類之前要先濾掉「不是 drift 的 drift」(2026-07-17)
三分類預設每個 drifted 檔的成因都是下游的意圖。實測發現兩個它沒有位置的東西,且兩者被誤判的方向都指向「客製」→ localSeams → 永久脫離 sync:
| 不是意圖的成因 | 性質 | 正解 |
|---|---|---|
| 等價重構 | 文字不同、邏輯相同(變數提取、等價改寫)——根本不是 drift,是比對器的假陽性 | 覆蓋即可(無損)。機器分不出等價,故須人判 |
| 隔離取向的機械後果 | defaultDataIsolation 的後果,不是任何專案的決定 | 框架正交化或 variant,見 ADR-009 |
實測(MailConfig vs ict):原始 17 行 drift → 剝除識別正規化與註解後剩 3 行 → 人工判讀為等價重構 → 真實 drift = 0。若只剝 package/import(本 ADR 初版的「本體」定義),會看到 17 行而判成「有客製」。
「本體」的定義已隨之修正為「邏輯」(見
/feature的 Step 2):比對前依序剝除 ①package/import② 識別(retarget 會改到 javadoc 全限定名與字串裡的模組名)③ 註解。用意不是讓數字好看,是把噪音壓到人能判讀的規模——上例 17 → 3 行。噪音是誤判的來源,而誤判的代價是永久的機械稅。P2 判讀時先問**「行為會不會不同?」**——答不出具體差異就是等價,不是客製。
與
/practices首次 publish 同源:該機制對「機制誕生前從未被治理的下游」也採全面對帳而非盲蓋(見m-practices-sync.md「syncedSHA缺席 = 該模組從未被治理」)。migrate 是同一道理在程式碼層的對應。
D-D:installed 由 catalog 宣告,但初值由命中推導
下游的 features.json 只登錄已 provision 的 feature。尚未 provision 者即使檔案散在 layer 目錄裡,也不算 installed——因為 sync 定位不到它們。
provision 的提案階段以「core slice 命中率」推導候選(如 auth 15/20 → 判定 partial、migrate 15 檔並補 5 檔),但宣告權在 catalog,不在啟發式。這對齊 ADR-006 D-A(feature 為第一級概念、SoT 在 catalog)與 m-module-inventory.md 的「從 SoT 推導、不硬編」。
D-E:seam 清單由框架下行,下游不自訂
requires 與 seams 是框架該 feature 的性質,隨 feature 一起 provision 進下游的 catalog(seam 路徑改寫為下游 base package)。
例外是 D-C 判為「客製」而提升的檔——那是下游專屬的額外 seam(localSeams),登錄在下游 catalog、框架不知情。這與 m-practices-sync.md 的 local 清單形狀不同(那份由框架端持有),因為客製點本質上 per-project、框架無從預知。
D-F:過渡期的 skeleton 必須涵蓋殘存的 layer 目錄
features.json 的 skeleton 是 partition 的 SoT,業務 = 殘餘(見 FU-52 的決定)。一個部分 provision 的下游同時有 {feature}/ 與殘存的 controller/、service/、repository/、dto/——若後者未列入 skeleton,會被判為業務領域。
後果落在兩處:feature-inventory.py 的分組,與 /scaffold-module 以該下游為來源時的 @reference-surface stamp(partition 依 features.json 存在與否分派,見 m-reference-code.md)。
規則:provision 期間,skeleton 須列出所有殘存的 layer 目錄;每搬完一個 feature,其騰空的 layer 子目錄自然消失。全部 provision 完成後 skeleton 收斂為模組骨架本身。
feature-inventory.py已不硬編 base package(source root 以skeleton首項為錨點推導),故同一份腳本可直接在下游運作,無需 retarget。
D-G:設定搬遷須兩道 guard——只搬 live key、fragment 機制不存在時詢問
ADR-006 第 5 步把 feature 設定切成 config/feature-{id}.yml 片段(spring.config.import 匯入)。但那是框架自身的遷移;layer-first 下游未必採過第 5 步(無 spring.config.import、無 config/ 目錄)。provision migrate 一個 feature 時,其設定搬遷須守兩道 guard(2026-07-11 pilot 發現):
- 只搬 live 的
app.*key:純註解/文件區塊沒有功能性內容可搬;若設定全在 seam 的 inline@Value預設而 yml 無對應 live key(如 almanac 的cache),則無事可搬——不製造空的feature-{id}.yml。 - fragment 機制不存在時詢問,不擅自引入:下游無 fragment infra 而本 feature 有 live 設定要搬時,
provision以 AskUserQuestion 詢問 (a) 留在 inline/monolithic{moduleName}.yml(不引入 fragment infra、報告註記)/(b) 順帶為本模組導入 fragment 機制並搬入片段。第 5 步是模組級獨立遷移,是否此刻導入由人決定——不讓單一 feature provision 附帶 bootstrap 整套 infra(scope creep)。
判斷句:provision 的職責是「把一個 feature 帶成 slice」,不是「把下游遷移到 fragment 設定模型」。後者正當、但屬另一件事。
後果 (Consequences)
正面
- 既有下游首次獲得「拿到框架 core slice 改良」的管道。三個下游目前對
acting/delegation等 ADR-013 之後的能力完全沒有取得途徑。 - drift 分類是一次性成本,付完之後
sync即可零分歧覆蓋——把「無法治理」轉成「可持續治理」。 - 逐 feature 增量使 provision 可在任何時點停下。下游可以只 provision
mail、其餘不動。 - source-to-target 使框架能從單一駕駛座
status全 fleet、sync廣播改良,無需逐個下游進去操作。
負面 / 成本
- provision(migrate)是有成本的人力工作:
ict-server45 個 drifted 檔需逐一裁決。這不可自動化——那正是 D-C 的論點。 - 提升為 seam 的檔從此不再收框架改良。客製的代價是永久脫離
sync;下游應盡量走reflow而非 seam。 - 部分 provision 的下游處於混合佈局,
skeleton須手動維護到 provision 完成(D-F)。I3 會校驗路徑存在,但不會提醒「你漏列了一個 layer 目錄」——漏列的症狀是該目錄被判為業務。
不採用是安全的
不 provision 的下游維持現狀:拿不到 core slice 改良,但也不承擔任何風險。這與 /methodology、/practices 導入前的處境相同。provision 不是遷移債,是加值選項。
驗收與不變量
provision 一個 feature 後,下游須滿足:
| 檢查 | 工具 |
|---|---|
| 編譯與測試綠 | ./gradlew build |
| I1 required 不依賴 optional | feature-inventory.py --check |
| I2 core slice 不依賴業務 package | 同上 |
I3 catalog 與推導圖一致、skeleton 路徑存在 | 同上 |
| 該 feature 的 core slice 與框架本體一致(扣除 seam 與已提升為 seam 的客製檔) | provision 提案報告 |
基線紀律:provision 前先跑一次 ./gradlew build 記錄 baseline。ict-server 已知有 pre-existing 測試失敗(見 FU-24 / FU-26 的紀錄),不可把既有紅測試誤判為 provision 引入。
替代方案 (Alternatives)
| 方案 | 為何不採 |
|---|---|
| 下游重新 scaffold | 丟掉全部業務碼與歷史。ict-server 283 檔中大半是 ict 自有領域。 |
sync 支援 layer-first(雙佈局) | sync 的定位靠 package 邊界(ADR-006 D-C:「slice 邊界必須是編譯器看得見的事實」)。支援 layer-first 等於把佈局重新編碼成字串規則——m-reference-code 的 glob 剛因此失效(FU-52)。 |
| provision 全自動、不設人在迴路 | 25–45 檔的 drift 分不出「落後 vs 客製」。自動覆蓋會靜默沖掉客製,且無測試接住(同 FU-52 的失效形狀)。 |
| 一次全搬(非逐 feature) | 無法回退、無法中途停、無法在單一 feature 粒度驗收。框架自身第 3 步即採逐 feature,那是唯一已驗證的先例。 |
adopt / add 維持兩個獨立動詞 | 兩者終點相同、差別只在起點(present vs absent),從框架駕駛座是單一意圖。分裂成兩個動詞逼使用者先知道 feature 在下游存不存在——那是工具可自偵測的細節。 |
與其他文件的關係
| 文件 | 關係 |
|---|---|
| ADR-006 | 定義 feature slice 模型與框架自身的遷移順序;本 ADR 補其未定義的「既有下游怎麼接上」。install 路徑(帶入新 feature)在 ADR-006 D-D;本 ADR 定義 migrate 路徑 |
| server ADR-014 | core slice 的中性由框架側紀律擔保;本 ADR 的 D-C 處理的正是「該紀律施行前累積的分歧」 |
m-methodology-sync.md | reflow 的「重做而非盲 copy」直接被 D-C 的「改良」分類引用;source-to-target 方向(D-A′)與其 publish 下行同構 |
m-practices-sync.md | 「從未被治理的下游首次同步採全面對帳」與 D-C 同源;fleet 探索與 realpath/role 守衛被 D-A′ 借用;本 ADR 是其在程式碼層的對應 |
m-reference-code.md | partition 依 features.json 存在與否分派;D-F 處理部分 provision 下游的 skeleton 邊界 |
19-env-config.md(app-server) | 設定 fragment 模型(第 5 步);D-G 的兩道 guard 處理「下游未採 fragment 時的設定搬遷」 |
待實作與 pilot
本 ADR 定路徑與紀律,/feature provision 的實作隨 ADR-006 遷移順序第 6 步(/feature skill)落地。在此之前,下游維持現狀是安全的。
第一個 pilot 選 almanac-server:127 檔最小、headless 無前端、本機無 remote 故可安全回退,且它已是三者中最接近 feature-first 者(自長 capability / address / location / calendar 頂層 package)。pilot 起手式選 cache slice(core 3/3 本體零漂移、seam CacheConfig 為 almanac 客製)——零風險驗證流程。D-G 的兩道設定 guard 即此 pilot 發現(almanac 的 app.cache 全為註解、無 live key,且 almanac 未採 fragment 機制)。