跳至主要内容

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 有可對應的檔案集」。既有下游三項皆不滿足:

  1. 佈局不對應:框架已 package-by-feature({feature}/),下游仍 layer-first(controller/service/entity/dto/repository/)。無檔案對應關係。
  2. base package 各異:連組織前綴都不同。sync 不能是複製,必須複製 + retarget(package 宣告與 import 全需改寫)。ADR-006 的 D-D 只寫 install(帶入新 feature)要 retarget,sync 只寫「零分歧覆蓋」——未言明。
  3. installed 無宣稱:下游沒有 features.json,且各自手動增刪過 feature。

量測(2026-07-10,全量非抽驗)

以框架 app-server112 個 core slice 檔(扣除 seam 與 {feature}/demo/;後者已由 ADR-006 D-B 更正 廢除,本數字為 2026-07 當時的量測)為地圖,依類別簡名血緣比對三個下游:

almanac-serverict-servertts-server
base packageio.leandev.almanaccom.tqf.ictio.talentonline.tts
Java 檔數127283211
命中 core slice586962
其中本體逐字相同282437
本體漂移304525
完整安裝的 featurecache, calendar, mail, schedulingcache, mail, platform-info, scheduling, signed-linkcache, mail, platform-info, scheduling
完全未裝acting, delegation, impersonation, notification, signed-link, tenantacting, calendar, delegation, impersonation, service-account, tenantacting, calendar, delegation, impersonation, service-account, signed-link

「本體」= 剝除 packageimport 行後的內容(搬遷本身必然改動這兩者,計入會使所有檔都「不同」)。

量測推翻的三個先前假設

  1. 「core slice 零分歧覆蓋成立」不成立於全量。 此宣稱源自對 EmailServiceTokenBlacklistService 兩檔的抽驗(皆 0 行差異)。全量下每個下游有 25–45 檔本體漂移。多數看似下游落後UserResponse 為框架後續改動、MailSettingCodeData),但無法先驗地與真客製區分。盲目覆蓋會沖掉真客製。

  2. installed 不是布林,是覆蓋率。 三個下游的 auth 皆只命中 15/20 檔——缺的是框架在它們 scaffold 之後新增的檔。故 migrate 一個 feature 同時是「搬家」與「補齊」,它吸收了第一次 sync

  3. 下游的 feature package 是混合的。 ict-servernotification/ 同時住著框架血緣的 NotificationTypesEnvSubjectPrefixTemplateResolver,與 ict 自有的 AccountActivationNotifierTaskNotificationPublisher。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 併掉舊 adoptadd:兩者終點相同({base}.{feature} package + catalog 登錄),差別只在起點——feature 在下游存在(散在 layer 目錄)走 migrate、不存在走 install。從框架駕駛座看,「讓 target 具備 feature 的 slice」是單一意圖,present/absent 是工具可自偵測的實作細節,不值得分裂成兩個動詞。本 ADR 定義的是 migrate 路徑(既有 layer-first 下游的採用);install 路徑見 ADR-006 D-D。

比照 /methodology/practicesbootstrap 是 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-server role: 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.jsonlocalSeamssync 永不覆蓋
改良(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.mdsyncedSHA 缺席 = 該模組從未被治理」)。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 清單由框架下行,下游不自訂

requiresseams框架該 feature 的性質,隨 feature 一起 provision 進下游的 catalog(seam 路徑改寫為下游 base package)。

例外是 D-C 判為「客製」而提升的檔——那是下游專屬的額外 seamlocalSeams),登錄在下游 catalog、框架不知情。這與 m-practices-sync.mdlocal 清單形狀不同(那份由框架端持有),因為客製點本質上 per-project、框架無從預知。

D-F:過渡期的 skeleton 必須涵蓋殘存的 layer 目錄

features.jsonskeleton 是 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 發現):

  1. 只搬 live 的 app.* key:純註解/文件區塊沒有功能性內容可搬;若設定全在 seam 的 inline @Value 預設而 yml 無對應 live key(如 almanac 的 cache),則無事可搬——不製造空的 feature-{id}.yml
  2. 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-server 45 個 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 不依賴 optionalfeature-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-014core slice 的中性由框架側紀律擔保;本 ADR 的 D-C 處理的正是「該紀律施行前累積的分歧」
m-methodology-sync.mdreflow 的「重做而非盲 copy」直接被 D-C 的「改良」分類引用;source-to-target 方向(D-A′)與其 publish 下行同構
m-practices-sync.md「從未被治理的下游首次同步採全面對帳」與 D-C 同源;fleet 探索與 realpath/role 守衛被 D-A′ 借用;本 ADR 是其在程式碼層的對應
m-reference-code.mdpartition 依 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 機制)。