ADR-009: 參考實作的隔離 variant 軸(isolation variant)
ADR 編號: 009 狀態: 已取代 (Superseded by ADR-011) 決策日期: 2026-07-17 決策者: AppFuse Team + AI Assistant 範圍: docs-methodology(feature slice 的 variant 維度、
/feature與/scaffold-module的來源配對)
摘要
ADR-006 的 core slice 之所以能零分歧 sync,前提是它中性。ADR-004 已把方法論對資料隔離中性化(project.json 的 defaultDataIsolation 為 SoT),server ADR-017 已把框架 jar 對租戶中性化(jar 不擁有 @Entity、租戶經 TenantAware 探詢)。唯獨參考實作的 core slice 沒有被中性化——它是 defaultDataIsolation: tenant 的單一取向產物。
後果由 2026-07-17 的 ict-server provision 實證:ownership 下游有一類 drifted 檔是純機械的租戶剝除(AuditableTenantEntity→AuditableBase、去 TenantContext、快取鍵與 unique constraint 去租戶欄)——規模小於 localSeams 數量所暗示(見「證據等級」的更正),但成因獨特:而 ADR-007 D-C 的 drift 三分類只能把它們判為「客製」、登錄 localSeams。於是這些檔永久脫離 sync——框架對它們的任何改良都只能逐檔手動併入。這是一筆機械稅:付出的是永久同步權,換到的卻不是任何專案自己的決定。
本 ADR 在模組 type 之外增加一條 isolation variant 軸:core slice 出 tenant(base)與 ownership 兩個 variant,下游依 defaultDataIsolation 配對來源,零分歧同步機制原樣恢復。
核心決定有五:representation 採 overlay(三選一的拍板——base 全檔共用、只有真正相異的檔進 overlay 樹);overlay 檔集由目錄推導,catalog 只列機器推不出的例外;variant delta 由 overlay 承載,不得以擴張 seam 集規避;每個 variant 皆須組裝後 build 綠(I4),這是「ownership variant 是真實碼」的兌現機制;正交化先行——先縮小 delta 再建 overlay,否則會把可消除的耦合固化成永久維護成本。
variant 軸不泛化:只開給有 fleet 實需、且已有宣告 SoT 的軸(目前僅隔離)。
背景 (Context)
問題陳述:機械 localSeams 稅
/feature provision 的 drift 三分類(ADR-007 D-C)預設每個 drifted 檔的成因是下游的意圖:落後(下游是舊版)、客製(下游有意且專案專屬)、改良(下游有意且對 fleet 有價值)。這個三分類對 tenant 下游成立——它們與框架同取向,drift 真的來自意圖。
對 ownership 下游不成立。 2026-07-17 對 ict-server(defaultDataIsolation: ownership)跑 provision 盤點,drifted 檔中有一類的成因是第四類,三分類裡沒有它的位置(下表為取樣,非全量——各 feature 的 drifted 另含落後/客製/改良,見「證據等級」的更正):
| feature | drifted | 成因 |
|---|---|---|
mail | 6/6(全部) | MailSetting extends AuditableTenantEntity→AuditableBase、uk 去 tenant_id、MailerService 快取鍵去租戶前綴、MailEndpoint/MailHealthIndicator 的 NO_TENANT_CONTEXT 分支 |
file | StagingFileController | getCurrentTenantId() 換常數(tenant_id 在此是儲存分區鍵、非業務租戶,欄位保留) |
auth | Role、RoleRepository、AccountUserDetails | 去 tenant_id 欄位/index、去 findByTenantId* 衍生查詢、去 TenantAwareUserDetails。⚠️ 2026-07-17 實測推翻:這批 drift 是 /remove-multi-tenancy 自傷,非隔離取向的機械後果——見下「auth 的 delta 是 0」 |
這些沒有一項是 ict 的決定。ict 只做了一個決定——defaultDataIsolation: ownership——其餘要嘛是那個決定的機械後果(mail),要嘛是轉換工具拔過頭(auth,見「證據等級」的更正)。但 /feature 只有 localSeams 一條路可走,於是:
ict 為了宣告一次隔離取向,付出了那些檔的永久同步權。
代價是真實的:框架後續對 mail 的改良(如 @SchedulerLock 叢集安全、FU-64 的 Clock 注入)只能逐檔手動併入 ict——而它們與租戶毫無關係。
證據等級、親驗,與一則對本 ADR 不利的更正
上表源自 2026-07-17 的 provision 盤點,該次 provision 已落地(ict-server/.claude/features.json 存在、逐 feature commit),故為可重跑的事實而非報表快照。其 localSeams 全量分布:
| feature | auth | notification | reference-data | signed-link | file | platform-info | cache / scheduling / almanac | |
|---|---|---|---|---|---|---|---|---|
| localSeams | 10 | 19 | 8 | 6 | 4 | 3 | 1 | 0 |
三個 0 是與租戶無關的 feature——它們本來就沒有機械稅可付,正是「稅只落在耦合隔離取向的 feature」的對照組。
撰寫本 ADR 時親自核對兩例:
MailSetting:框架extends AuditableTenantEntity(app-server/src/main/java/io/leandev/app/mail/MailSetting.java:41)、ictextends AuditableBase。✅ 純機械。Role:兩邊皆extends AuditableBase——ict 的 drift 不在基類,而是「框架版減去tenant_id欄位、idx_role_tenant、CUSTOM 角色建構子」外加一行真實客製(javadoc 的角色範例由SUPER_ADMIN/TENANT_ADMIN改為 ict 自己的SYSTEM_ADMIN/TQF_ADMIN/CB_AUDITOR/FBO_COMPANY)。
第二例正是下節「為何 transform 不是解」的活體:同一檔混著機械剝除與專案客製,且此例的客製是註解——機械轉換既給不出它、也保不住它。
⚠️ 更正:localSeams 數量不是機械稅的量表(2026-07-17,逐 feature 實測後)
上表曾被本 ADR 當成機械稅的規模證據。那是錯的,且錯得系統性地對本 ADR 有利——必須更正。
localSeams 記的是「drift 過、且被判為客製」的檔,它混了四種成因:落後、真客製、改良、機械後果。本 ADR 主張的稅只有第四種。逐 feature 實測分離後:
| feature | localSeams | 真實機械 delta | 那個差額是什麼 |
|---|---|---|---|
file | 3 | 1 | 另 2 個是改良(Clock 注入)→ 走 reflow(FU-64),與隔離無關 |
mail | 6 | 1(MailSetting) | 主要是落後——ict 仍用 1f253acb 前的 configName 快取鍵;其餘為 log 字串 |
notification | 19 | ≈12 →(正交化後估)5–10 | 最大的 NotificationTemplateService(246 行 diff)幾乎全是落後:框架有 channel/locale/audit/SUPER_ADMIN 授權,ict 版一個都沒有 |
auth | 10 | 0(見下節) | 那 8 個「機械」drift 是 /remove-multi-tenancy 拔掉惰性面所製造;其餘 7 個是混同檔——機械剝除與 ict 業務客製(category、帳號生效日、@Version、LoginRequest.app)同檔 |
三個推論:
- 本 ADR 的收益被
localSeams表高估。 真實機械稅遠小於 38 個 localSeams 所暗示的規模。 localSeams高,不代表該 feature 難正交化——甚至可能相反。notification(19)的形狀比auth(10)好得多(見下方 worked-example 與 D-F)。用 localSeams 排序工作量會排錯。- 混同檔限制的是收益的兌現速度,不是機制的正確性。
auth的 7 個混同檔就算有了 variant 軸仍是 localSeam(因為 ict 的業務客製還在)。這筆帳該記在 reflow 線上(把其中「其實是改良」的收編進框架),不是記在 variant 軸上。兩件事必須分開記,否則本 ADR 會承諾它兌現不了的東西。
ict 是發現地點,但它是被污染的樣本——業務客製把水攪混,故不宜當本 ADR 的論證本體。逐 feature 的乾淨測量見下兩節。
auth 的 Java delta 是 0——隔離政策值由 resource overlay 承擔(2026-07-24 更新)
auth(kind: required,每個 ownership 專案必裝)一度被本 ADR 當成最壞案例:8 檔全結構、全走 overlay。實測推翻——Java delta 是 0。 帳號與登入鏈天生 tenant-optional;唯一需要依取向改變的是 M2M 的政策值:base 的
app.security.m2m.require-tenant=true,ownership 為 false。該值由
variants/ownership/resources/config/feature-auth.yml 在 provision/sync/scaffold
選 feature 前覆蓋,不為一個布林引入 SPI。
auth 天生就是 tenant-optional 的,因為認證層必須在租戶已知之前運作(登入的雞生蛋:要先查出這個人屬於哪個租戶)。這個設計不是意外:
| 事實 | 實證 |
|---|---|
Account.tenant_id / Role.tenant_id 本就 nullable | 無 nullable = false;Account 不繼承 TenantAwareEntity、無 @PrePersist 守衛 |
| 登入本就對租戶中性 | AccountDetailsService 走 findByUsername |
| 無租戶是契約明文 | TenantAwareUserDetails.getTenantId() javadoc:「若使用者不屬於任何租戶則可返回 null」;jar 的 UserDetailsTenantIdResolver 對 null 直接回傳、不拋 |
| 消費它的解析鏈根本不裝 | TenantConfig 住 tenant feature(kind: optional),ownership 專案不安裝(ADR-006 D-A 的 opt-out 點) |
auth 不依賴 tenant | catalog requires: [],經 I3 機械校驗;import 圖只到 auth 自身 + skeleton |
| 租戶查詢在 null 下仍正確 | Spring Data 對 null 參數衍生 IS NULL(非 = null)→ 退化為「全域範圍」,語意恰好對 |
∴ 那 8 個 Java 檔在 ownership 下一個字都不用改。是 /remove-multi-tenancy 的 4c/4d 拔掉了本來就惰性的東西,從而製造了那 8 個 drifted 檔,再被三分類判為客製 → localSeams → 永久凍結。已修正(skill v1.2.0:認證鏈預設不動)。M2M 的一個政策值另由 resource overlay 明確表達,不改變此結論。
由此收斂出的判準:機制容不容得下 null
這比「值 vs 結構」更準,且是 per-entity 的:
auth 的 Account | mail 的 MailSetting、notification 的實體 | |
|---|---|---|
| 租戶欄位怎麼來 | 手寫、nullable | extends AuditableTenantEntity / @TenantId |
| 可為 null | ✅ 設計如此 | ❌ nullable = false |
| 過濾 | 顯式查詢(呼叫端決定) | Hibernate 自動、無法不生效 |
| 持久化守衛 | 無 | @PrePersist 會擋 |
| ownership 下 | 惰性且連貫 → delta 0 | 不可能惰性 → overlay |
這是出口①的最強形式:delta 根本不必存在。
file的 ① 是「降格為設定值」,auth的是「發現它是自找的」。D-F 第 1 步的每個 feature 都應先問這一題。
代價(誠實記錄):account / role 各留一個恆 null 的死欄位、若干不呼叫的查詢、UserResponse.tenantId 在回應恆 null。用這些換永久同步權,划算。
與
m-server-common的「要嘛真租戶隔離、要嘛真擁有權隔離」的關係(該規則已同步標註):該原則戒的是機制仍在運轉的中間態(AuditableTenantEntity+ 灌常數 → filter 與@PrePersist守衛仍在跑、隔離實為退化的租戶機器)。auth不是中間態——手寫 nullable 欄位、無 filter、無守衛,隔離確實純由擁有權承擔,那只是 vestigial 欄位。但該原則列的四個理由中,有兩個對
auth成立(零資訊欄位、用不到的機器)——即上方「代價」節。之所以仍值得留,是因為該原則寫成時,天平另一端是零:拔掉沒有成本。feature slice 機制使它不再是零(拔掉=製造 drift=永久失去同步權),故權衡翻面。這是原則的權衡被新機制改變,不是原則錯了。
未證明:實測在 app-server(有裝 tenant feature)跑,非真的組一個 ownership 模組;推論靠 requires: [] 的 I3 校驗。且 ict 採用需把欄位加回(ddl-auto: update 處理,仍屬 schema 變更)。
「失去同步能力」是機制的後果,不是 delta 的大小
一個必要的區辨,否則會把本 ADR 的論據導向錯誤的結論:
| feature | 真實 delta | 後果 |
|---|---|---|
file | 1 行(getCurrentTenantId() → 常數) | localSeams、凍結 |
mail 的 MailerService | 3 行 log 參數 | localSeams、凍結 |
auth | 0(拔掉惰性面自找的 8 檔) | localSeams、凍結 |
1 行與 8 檔,後果一模一樣——三分類沒有第四格,任何 drift 的唯一出口都是永久凍結。故「轉 ownership 就失去同步」證明的是機制缺一個格子,不是「兩個取向差別大」。它支持的是 D-A(variant 軸),而 A/B/C 三個 representation 都需要 D-A;此論據對 D-B 的爭點是沉默的。
凍結的代價已兌現,非理論
不論凍結的成因是機械後果(mail)或工具自傷(auth),後果相同且已在 ict 現形:
框架 5e6d5714(FU-29「角色階層展開,回應 roles 對齊前端 mock」)新增了 RoleHierarchyService。ict 的 UserResponse 已凍結 → 該改良到不了 → ict 自己寫了一個 RoleHierarchy(static,住 entity/auth/ 舊佈局)。同一能力,兩份實作。
這正是 FU-19/ADR-005/ADR-006 三度戒除的 fork drift——在 fleet 裡長出來了。它也說明為何「凍結」不是可以拖的債:凍結期間下游會繞過框架自行解決,於是分歧從「少一個檔」變成「兩個互不相容的設計」。
已否決的前案:sync-time transform(存證)
FU-63 的初稿方向是讓 sync 在覆蓋時即時把 tenant 版轉成 ownership 版(剝基類、去 TenantContext、改 uk)。2026-07-17 與維護者討論後否決,兩個缺口:
- 租戶語意拿掉後「用什麼補上」是選擇點,transform 給不出答案。 分區鍵該從哪來?DTO 的
tenantId欄位去留?NotificationTemplate的租戶覆寫表拿掉後,範本管理指向哪張表(ADR-017 決策六)?這些是設計決定,不是文字替換。transform 只能各專案自行發散——那正好是 fleet 分歧的定義。 - 真實檔常「租戶剝除+專案客製」混同(上節
Role已證)。transform 分不開兩者:保守則保不住客製,激進則沖掉客製。
本 ADR 的 build-time 生成方案(方案 C)繼承同樣的缺口——把 transform 從 sync-time 移到 build-time 不改變它的本質。詳見「考量的方案」。
核心張力
core slice 要能零分歧
sync,前提是它中性;但「資料怎麼隔離」是一個 core slice 無法迴避、又必然二選一的取向。
ADR-004 解了方法論層(宣告 SoT + skill 分流)、ADR-017 解了 jar 層(jar 不擁有 entity、租戶經探詢)。兩者都靠把選擇推遲到 app 層。但 core slice 就是 app 層——它是推遲的終點,無處可推。
因此 core slice 的隔離取向只有兩條路:永遠只出一個取向(現況,另一取向付機械稅),或出兩個取向(本 ADR)。
限制條件
- fleet 是 6:1 的 tenant 多數:7 個下游中 6 個
tenant、1 個ownership(ict)。方案不得讓多數為少數付成本。 defaultDataIsolation已是 SoT(ADR-004):不需要發明新的宣告面。role: source在 fleet 中唯一是機械守衛(realpath + role,見m-practices-sync.md與/featureStep 1)。任何方案不得打破它。- 業務領域(
@reference-surface)注定被 retarget 或 prune(m-reference-code.md兩出口),且AuditableTenantEntity屬skeleton、在 ownership 專案是惰性但存在的類——故 ownership 專案 scaffold 出的花店業務 entity 照樣編得過。業務領域不需要 variant。
考量的方案 (Options Considered)
三個方案的差別只在一件事:ownership variant 的碼,物理上長什麼樣。
方案 A:整模組 sibling
框架出兩個完整的參考實作模組(app-server + app-server-tenantless),各自可 build、各自 CI。下游依 defaultDataIsolation 選來源。
- ✅ 最誠實的「真實碼」:sibling 是直接躺在 repo 裡、可編譯、可跑測試的模組,無組裝步驟
- ✅
/feature、/scaffold-module幾乎不用改——只是換一個 source 模組 - ❌ 重複量與 delta 量嚴重不成比例:17 個 feature 中僅 8 個耦合租戶(FU-59 盤點),其餘 9 個逐字相同;連 3 個業務領域與 skeleton 共 400+ 檔都要複製。框架每個改良要改兩份,且沒有任何機制擋住漂移——這正是本 repo 一再戒除的「同一份知識兩處實作」(FU-52 的病)
- ❌ 打破
role: source唯一性:兩個 source 模組並存,realpath/role 守衛失效或須重新設計 - ❌ 花店 demo 也得有兩份,否則 ownership sibling 不是「可運行的參考實作」(ADR-006 背景的角色 2 落空)
方案 B:overlay(✅ 採用)
單一 app-server 為 base(tenant),加一棵 overlay 樹只放與 base 相異的整檔:
app-server/
├── src/main/java/io/leandev/app/mail/MailSetting.java ← base(tenant)
└── variants/ownership/
└── java/io/leandev/app/mail/MailSetting.java ← 覆蓋同路徑的 base 檔
provision / sync / scaffold 對 ownership 下游時,該路徑取 overlay 版、其餘全取 base 版。
- ✅ delta 最小化(實測 10–20 檔 / 242,見「『delta 有界』的實測」):9 個中性 feature 零 overlay,其改良自動兩 variant 通吃、無同步義務
- ✅
role: source唯一性不變:仍是一個 source 模組、一份 catalog - ✅ overlay 集是正交化債的量表:某 feature 的 overlay 逼近整包 = 該 feature 尚未正交化的訊號(見 D-F)
- ✅ ownership variant 是真實碼——人寫的、可讀的完整檔,不是轉換產物
- ⚠️ 進 overlay 的檔在兩 variant 各一份,框架改 base 時須同步 overlay。這是本方案的主要成本,緩解見 D-E/D-F,且誠實地緩解不掉全部(見「後果」)
- ⚠️ overlay 樹本身不編譯(同 package 同名類無法與 base 共存於同一 compile unit),須經組裝才驗證得了——這是相對方案 A 的弱點,由 D-E 的 harness 承擔
方案 C:生成 + 選擇點手寫
從 base 機械生成 ownership 骨架(剝基類、去 TenantContext…),選擇點由框架手寫「答案檔」補上,兩者拼接為 ownership variant。
- ✅ 理論上 delta 更小(只手寫選擇點)
- ❌ 繼承 sync-time transform 的兩個缺口(見背景):把 transform 從 sync-time 移到 build-time 不改變它給不出選擇點答案、也分不開混同檔的本質
- ❌ 更糟一層:產物既非 base 也非人寫的 variant,而是「生成器輸出 + 答案檔」的拼接。CI 驗的是生成器對不對,不是碼對不對;而那份拼接出來的碼從未以整體被任何人讀過
- ❌ 直接違反 FU-63 的定向前提——「ownership variant 是真實碼(框架拍板選擇點、CI 可驗證)」
評比
| A:sibling | B:overlay | C:生成 | |
|---|---|---|---|
| delta 大小 | 242 檔(複製全部,來變動 10–20;114 檔在構造上不可能相異) | 10–20 檔(實測,見下) | 最小 |
| 是真實碼 | ✅ | ✅ | ❌ 拼接產物 |
| 可直接 build | ✅ | ⚠️ 須組裝 | ⚠️ 須生成 |
| 漂移防護 | ❌ 無 | ⚠️ harness(部分) | ⚠️ 生成器 |
role: source 唯一性 | ❌ 破壞 | ✅ | ✅ |
| 選擇點有答案 | ✅ | ✅ | ❌ |
B 勝出的關鍵不是 delta 最小(C 更小),而是它同時滿足「真實碼」與「delta 有界」。 A 有真實碼但 delta 失控,C 有小 delta 但沒有真實碼。
「delta 有界」的實測(2026-07-17,四個資料點)
「A 的 delta 失控」原為推估。以下為逐 feature 實測,是 A/B 之爭唯一能用數據回答的那一軸。
分母:app-server main java 242 檔,其中 114 檔(47%)在構造上與隔離無關——
| 檔數 | 與隔離的關係 | |
|---|---|---|
| 8 個耦合 feature | 128 | 有耦合,但只有一小部分檔(見下表) |
| 9 個中性 feature(cache/scheduling/almanac/audit/calendar…) | 32 | 零 |
| skeleton | 12 (+2) | 零 |
| 業務領域(花店:customer/order/sales/dashboard) | 68 | 零,且注定 retarget/prune |
分子:
| feature | 該 feature 檔數 | overlay 檔數 | 形狀 |
|---|---|---|---|
file | 15 | Java 0;resource 1 | 出口①(delta 是值 → feature-file.yml overlay 選 app.storage.partition-source=fixed) |
mail | 12 | 1(MailSetting) | 出口①+③混合:守衛是值→接點;基類/uk 是結構→overlay |
auth | 23 | Java 0;resource 1 | 登入鏈 delta 為零;M2M require-tenant 是值,走設定 overlay。一度誤判為 8 個 Java 檔/最壞案例 |
reference-data | 3 個 Feature contract + 附屬 reference domain | 1(CodeDataType) | 所有機制共用;ownership 只把產品型別宣告改為全域 |
notification | 42 | ≈12 →(正交化後估)5–10 | 5 個實體為結構;controller 疑可走①、template service 疑可走② |
外推剩餘 feature(reference-data 以實體為主、delegation 極小、impersonation
概念塌陷 → ownership 直接不裝)。service-account 的認證機制歸 auth capability,
資源面則由 optional Feature 的 referenceDomains 隨附;ownership 可選擇安裝其 tenantless
overlay 版本,也可完全不裝。
總 overlay 估 10–20 檔 / 242 = 4–8%。(
auth實測歸零後下修;原估 20–30 含誤判的 auth 8 檔)
∴ A 要複製 242 檔(其中 114 檔在構造上不可能相異、68 檔注定被刪),來變動其中的 10–20 檔。 「delta 失控」自此為實測,非推估。
而任何「那就別複製那 47%」的設計,推到底就是 B。這是結構性的,不是偏好。
兩個反直覺的實測發現
-
localSeams數量會排錯工作量。notification(19 localSeams)的形狀比auth(10)好得多——其 5 個實體已是 ADR-017 的形狀(jar 擁有中性*Base,app 的@Entity子類整個檔幾乎就是那個租戶決定:框架 21 行 vs ict 9 行)。overlay 這種檔近乎免費:檔本身就是 delta,且它們幾乎不會變。 -
overlay 的成本不在檔數,在「檔內 delta 佔比」與「該檔會不會長新東西」。 對比:
檔長 真實 delta overlay 雙份維護 該檔會長新東西嗎 InAppNotification(notification)21 行 ~12 行 2 × 21 ❌ 幾乎不會 Account(auth)99 行 ~5 行 2 × 99 ✅ 會(ict 已加 category)Account是 overlay 最弱的形狀——付 99 行雙份表達 5 行差異,且 D-E 的誠實邊界(I4 抓不到「base 長新功能、overlay 沒跟」)在此是真風險。這是 B 在auth上的實質代價,本 ADR 不迴避。但此代價 A 更糟而非更好:同樣的靜默漂移風險,檔數從 8 變 242。A 不解此病,是把它乘以 30。
決策 (Decision)
D-A:模組 type 之外增加 isolation variant 軸,配對讀 defaultDataIsolation
feature slice 的來源自此有兩個維度:
| 維度 | 值 | SoT |
|---|---|---|
| type | server(本 ADR 只涉 server) | project.json 的 modules[].type(m-module-inventory.md) |
| isolation variant | tenant(base)/ownership | project.json 的 defaultDataIsolation(m-server-common.md、ADR-004) |
- 不新增宣告面:variant 恆由
defaultDataIsolation推導。下游 catalog 不複製它——複製即是第二份宣稱、必然漂移(m-methodology-sync.md不變量一的一貫治法)。 tenant為 base、且是缺省:對 6 個 tenant 下游,本 ADR 落地後行為完全不變(overlay 不參與、取的仍是 base 全檔)。多數不為少數付成本。- 框架 catalog 頂層宣告它出哪些 variant(
"variants": ["tenant", "ownership"],首項為 base)。
D-B:representation 採 overlay
採方案 B。overlay 的三條紀律:
- 粒度是整檔替換,不做行級 patch。patch 脆弱、無法獨立閱讀、且編譯器看不見——同 ADR-006「D-C 的暗面」的字串耦合 family。
- overlay 只覆蓋、不刪除。 「某 feature 在某 variant 下整個不適用」由不裝表達(
tenantfeature 對 ownership 專案本就是 opt-out 點,ADR-006 D-A),不需要檔級刪除記號。 - base 恆為完整可 build 的模組。 overlay 是加法,不得使 base 殘缺。
D-C:overlay 檔集由目錄推導,catalog 只列機器推不出的例外
不在 catalog 手列 variant 檔集(此處修正 FU-63 初稿的「catalog 逐 feature 宣告 variant 檔集」)——理由與 ADR-006 D-C 拒絕 glob 清單、m-methodology-sync.md 不變量一拒絕手寫 canonical 清單完全相同:一份手維護的檔案清單每新增一檔就可能漏一行。
- overlay 檔集 =
variants/{variant}/**下的實際檔,路徑即其覆蓋目標。零宣稱。 - catalog 只列推導不出來的例外——per-variant 的
requires/beanRequires差異,且僅在與 base 不同時宣告。不隸屬 Feature、但所有 variant 恆需的 reference domain 以頂層variantSharedDomains明列;optional Feature 附帶者則寫在該 entry 的referenceDomains:
"variantSharedDomains": [
"auth", "authadmin"
],
"features": {
"acting": {
"referenceDomains": ["acting"]
},
"signed-link": {
"referenceDomains": ["signedlink"]
},
"service-account": {
"referenceDomains": ["serviceaccount"]
}
}
serviceaccount → tenant的 reference implementation 耦合由小型ServiceAccountManagementPolicyoverlay 消除。共同的ServiceAccountManagementService不 importtenant,因此 secret/API-key 生命週期改良仍可由兩種取向共用,不必 overlay 整個管理服務。
D-D:variant delta 由 overlay 承載,不得以擴張 seam 集規避
有一條看似便宜的替代路:把有 variant delta 的檔(MailSetting、Role…)全部改列為 seam——seam 本就不覆蓋,delta 自然消失。明文拒絕。
- 語意不符:seam 的判準是 ADR-014 判準零——是否承載消費端的宣告權。
MailSetting的租戶性不是 ict 的宣告,是defaultDataIsolation的機械後果(背景已證)。 - 它就是機械稅換個名字:seam = 永不覆蓋 = 框架改良到不了 fleet。這正是 FU-63 要消除的病。把
localSeams改叫seams不會讓 ict 拿回@SchedulerLock。
誘惑的來源要誠實記錄:ADR-017 決策一(「Entity 的表結構 → schema 屬應用的資料庫」)確實可推出「core slice 的
@Entity本來就該是 seam」。這個推論在別的脈絡可能正確,但不能用來解本問題——它的代價恰好是本 ADR 要消除的東西。若日後要重開「entity 是否為 seam」,須獨立於 variant 議題論證。
D-E:驗證 harness——I4:每個宣告的 variant 皆須組裝後 build 綠
這是「ownership variant 是真實碼」的兌現機制,也是 B 勝過 C 的關鍵。 缺了它,overlay 就是「沒人跑過的碼」,與 transform 的產物一樣不可信。
I4:對每個宣告的 variant,
base 覆以 overlay組裝出的樹須compileJava+test全綠。 掛框架./gradlew build,即 gate。
gate 形狀的實測校準(2026-07-20 拍板,混合策略):上句寫於 harness 建成之前;第 2 步實測後量到 nested build 約 3 分鐘/variant——照字面掛 build 會使日常 build 時間 ×2(且隨 variant 數線性增長)。拍板改為兩層:
- L1 常駐
./gradlew build(variantChecktask,掛check):組裝+overlay 死檔/殘餘 import 靜態檢查+per-variant I3,秒級——結構性漂移在當次 build 現形。- 完整 I4(nested build)掛 chokepoint 必跑:
/feature provision/sync前置(Step 1.5)與/scaffold-module出貨前置(Step 5.0.5)——壞的 variant 流不出框架;語意層破壞(如@TenantId殘留炸 SessionFactory)最晚在內容要離開框架的門口被擋。另可手動/里程碑執行variant-assembly.py --variant {v} --build。取捨依據:爆炸半徑由 chokepoint 決定、不由 build 決定;L1 便宜到可常開,L2 貴則守門口。若日後 CI 上線(FU-05),I4 掛 CI per-commit 可再關掉「語意破壞在框架內部存活至下次 chokepoint」的殘餘風險窗。
- 組裝是機械的整檔覆蓋、零語意轉換——這是它與方案 C 的本質差別。C 的生成器會推理,B 的組裝只複製。
- 測試隨 feature 走:租戶專屬測試住
tenantfeature 的 test package,ownership 組裝不裝該 feature → 測試自然不在。不需要 overlay 的排除機制。 - I3 隨之 per-variant 化:
feature-inventory.py --check --variant {v}對組裝樹跑同一套推導,校驗 D-C 的 per-variantrequires例外。另須新增校驗:overlay 的每個檔都必須對應到 base 的真實檔(覆蓋一個不存在的路徑 = 死檔,是最典型的漂移形狀)。
誠實的邊界:I4 抓得到「overlay 版編譯失敗 / 行為錯」,抓不到「base 長了新功能、overlay 版沒跟上」——那不會編譯失敗、也不會測試紅(除非該功能有 variant 中立的測試)。這是 overlay 的殘餘漂移風險,只能靠 D-F 把集合壓小來緩解,不能靠機制根除。機械檢查是下限訊號——這句話在 ADR-006 已被實例證實三次。
D-F:正交化先行——先縮小 delta,再建 overlay
順序不可顛倒:先跑 FU-59 的 8 個耦合 feature 正交化,再建 overlay。
理由是成本的性質不同:正交化消除的耦合是零成本的(那個檔從此兩 variant 共用、改一次通吃);進 overlay 的檔是永久成本(兩份、要同步、I4 抓不全漂移)。若先建 overlay 再正交化,等於把本可消除的耦合固化成永久維護成本——而且固化之後就沒人會回頭拆了。
ADR-017 已示範正交化能走多遠,其「兩原則如何互相成全」的推導是本 ADR 的方法論前身:
原則二把 tenant 逐出 jar(jar 的 base class 沒有
tenantId,故NotificationService寫不出findByTenantIdAndDedupeKey),原則一在 app 層把它自動補回來(@TenantId在 Session 建構時自動套 filter)。jar 完全不需要知道租戶的存在,租戶範圍卻是對的。
那個結果的 variant delta 是零——同一份 jar 碼對兩種模式都對。這是正交化的上限,也是每個耦合 feature 該先嘗試的目標。ict 原樣採用 ADR-017 泛型化後的 DbNotificationTemplateResolver 即為活證。
正交化的三個出口(依優先序):
| 出口 | 手段 | variant delta |
|---|---|---|
| ① 消除 | 比照 ADR-017:primitive 探詢(TenantAware)、@TenantId 自動 filter、jar 不擁有 entity | 0 |
| ② 推進 seam | 該差異真的是專案組裝選擇(如 ADR-017 決策六的 resolver 鏈:tenant 是「租戶表→全域表→NLS」、ownership 是「全域表→NLS」)→ 它本就該住 *Config | 0(就 sync 而言);overlay 只需承載 install 路徑的初值 |
| ③ 進 overlay | 消不掉、又不是專案選擇(如 MailSetting 的基類) | 1 檔 |
② 值得特別指出:正交化的極致,是把 delta 推進 seam。seam 本就不 sync,delta 落在那裡等於就同步而言消失。這也是為何 D-D 拒絕的是「擴張 seam 集來規避」,而非「delta 落在既有 seam」——前者是把該同步的東西趕出同步,後者是把本屬組裝的東西放回組裝。判準仍是判準零,不是方便。
D-G:variant 軸不泛化
variant 是一條窄門,只開給同時滿足三個條件的軸:① 已有宣告 SoT、② fleet 有實需、③ 值域小且非組合。
隔離軸合格:① defaultDataIsolation 已存在(ADR-004);② ict 是活的 ownership 專案、正在付機械稅;③ 二值。
為何必須設門:每開一條軸,overlay 集是笛卡兒積(N 軸 × M 值),且每個組合都要一份 I4 harness。兩條二值軸 = 4 個組合 = 4 次 build。這會在 harness 成本上爆炸,並讓「overlay 是正交化債的量表」這個訊號失去意義(分不出債來自哪條軸)。
目前僅隔離軸。 未來要加軸須獨立 ADR,並在該 ADR 論證三條件。
後果 (Consequences)
正面
- 機械 localSeams 稅消失:ict 的 mail 6/6、file、auth 等純機械 drift 整批降回
sync管理,框架改良(@SchedulerLock、FU-64 的Clock)自動下行。 localSeams回歸其本義:登錄在那裡的,從此只有專案真正的客製(如 ictRole的角色範例 javadoc)。這讓該欄位第一次成為有意義的訊號——它會很短,而短是對的。- ADR-004 的中性宣稱在參考實作層兌現:繼方法論層(ADR-004)與 jar 層(ADR-017)之後,第三層也中性化。「非中性的不是碼,而是專案的選擇」自此三層皆真。
- ownership 路徑有 CI:目前 ownership 的正確性只活在 ict 的下游 build 裡;I4 之後框架自己每次 build 都驗它。
/remove-multi-tenancy可功成身退:ownership 專案自 scaffold 起就取 ownership variant,不需要「先給 tenant 版、再轉換掉」。該 skill 依其自述計畫退場。- 正交化有了量表:overlay 集大小 = 未正交化的耦合量,可當 health invariant 報。
負面 / 成本
- overlay 檔是永久的雙份維護:改 base 時須同步 overlay,I4 抓不到「base 新增功能、overlay 沒跟」(D-E 的誠實邊界)。這是本方案不可根除的殘餘風險,只能靠 D-F 壓小集合。
- overlay 樹本身不可讀為完整模組:讀 ownership variant 要在腦中(或經組裝 task)疊 base + overlay。方案 A 無此問題——這是選 B 付的代價。
- 正交化(D-F)是先決條件且成本不小:FU-59 的 8 個耦合 feature 中,
mail(同檔混租戶與 Azure ADoauth2_tenant_id,盲改會斷 Office365 認證)、impersonation(cross-tenant概念塌陷,不只是改碼)皆非機械工作。 /feature、/scaffold-module、feature-inventory.py皆須 variant-aware:三處都要讀defaultDataIsolation並分派來源。一次性成本。- 第五個維度:下游的來源選擇從 type 一維增為 type × variant 二維。D-G 的窄門正是為了不讓它繼續長。
中性
- 6 個 tenant 下游行為零變化(D-A):base = tenant,overlay 不參與。
- 業務領域與 skeleton 不做 variant(限制條件):花店注定被 retarget/prune,
AuditableTenantEntity在 ownership 專案惰性存在故編得過。 - 框架 jar 不變:本 ADR 純屬參考實作層。jar 的中性由 ADR-017 承擔、已落地。
不採用是安全的
不做 variant 軸,ict 維持現狀:機械 localSeams 繼續累積、框架改良繼續手動併入。這不是新的破壞,是既有成本的延續。與 ADR-007「provision 不是遷移債,是加值選項」同構。
替代方案 (Alternatives)
representation 的三選一見「考量的方案」。以下是軸層級的替代:
| 方案 | 為何不採 |
|---|---|
| 維持現狀(ownership 付 localSeams 稅) | 稅是永久的、且與 ict 的任何決定無關;隨框架演進單調累積。1/7 的 fleet 少數不該為「框架只出一個取向」買單。代價已兌現:RoleHierarchyService 到不了 ict,ict 遂自行實作 RoleHierarchy——同一能力兩份碼 |
| sync-time transform | 選擇點給不出答案、混同檔分不開(背景已存證)。2026-07-17 討論後否決 |
| 擴張 seam 集吸收 variant delta | 見 D-D:語意不符判準零,且它就是機械稅換個名字 |
| 要求 ownership 專案自己維護 fork | 那是「不採用」加上一個好聽的名字。且 fork 無 CI、無回流管道,正是 FU-19/ADR-005/ADR-006 三度戒除的病 |
| 推翻 ADR-004、只支援多租戶 | fleet 已有 ownership 專案在生產。且租戶性本就是 per-entity 決策(ADR-004 的關鍵識別),全域強制是洩漏的抽象 |
拍板紀錄(2026-07-17)
接受 D-A ~ D-G。 拍板前經一輪對抗式檢驗,四處校準已納入本文;此節記錄拍板當時知道什麼,供日後判斷是否該重開。
拍板時已知的反對意見與回應
方案 A(兩個 app-server sibling)由維護者提出,經量化後未採納,理由依序:
| 反對意見 | 回應 | 強度 |
|---|---|---|
| 「工作區本來就很多模組,多一個不是問題」 | 接受——role: source 唯一性是機制細節,且 A 與 B 都需要 D-A(都得知道 ownership 配對哪個來源)。爭點因此收窄為純 representation | 已納入 |
| 「app-server 只支援 tenant 會簡單很多」 | 部分成立:檔數不變(242 檔本就是 tenant 版),複雜度少掉出口①的 indirection = 2 處(mail、file 各約 30 行)。出口③(overlay)對 base 零複雜度增加 | 已納入「負面/成本」 |
| 「重複的程式碼維護不困難」 | 未採納:實測 242 檔中 114 檔(47%)在構造上與隔離無關(32 中性 feature + 12 skeleton + 68 注定 prune 的花店),而 delta 僅 10–20 檔。且 A 的漂移防護為無——/methodology、/practices、m-*-sync 整套機器存在的理由就是手動複製會漂移(FU-19/ADR-005/ADR-006 三度戒除) | 決定性 |
| 「兩取向可能發展成不同方向」 | 數據回答不了、且是本 ADR 最大的殘餘風險。 四個 feature 的實測皆顯示 ownership 是 tenant 的減法、無分歧跡象;但那是現在。採 B 的主因不是 delta,是可逆性——見下 | 未解,靠可逆性承擔 |
決定性理由:B 可逆,A 不可逆
若日後兩取向真的分歧,overlay 集會長大——那正是 D-B 自己的量表。長到接近整包時,把組裝結果 commit 成模組,B 就變成 A,成本近乎零。
反向不成立:A 走一段時間後想收回成 B,得 diff 兩百多檔、逐檔判斷「這是刻意分歧還是漂移」——那正是 /practices reflow 在做的事,成本已知地高。
且 I4 會持續量出 delta,故該決定日後是帶著數據做,不是憑感覺。
重開本 ADR 的條件
出現下列任一,應重新評估 A:
- overlay 集持續成長且逼近耦合 feature 的整包(D-B 的量表觸頂)——代表兩取向真的在分歧,B 的前提失效
- 出現第二個 ownership 下游,且其需求與 ict 系統性不同——目前 6:1 的 tenant 多數是「多數不為少數付成本」的前提
- I4 反覆抓不到的 overlay 漂移造成生產事故——D-E 的誠實邊界兌現成真實代價
反之,不構成重開理由:單一 feature 的 overlay 較大(notification 5–10 已知且可接受)、或某次同步不便。
拍板時的證據等級(誠實聲明)
- 已實測:
file(已完成)、mail(已完成)、auth(delta 0,AuthWithoutTenantIT5/5)、notification(勘查,未動工) - 未實測:
reference-data/delegation/impersonation/service-account的 delta 為外推 - 未存在:overlay 樹與 I4 harness 尚未建(落地順序第 2 步)。「組裝後 build 綠」目前是設計主張,不是已驗證的事實
落地順序
順序即 D-F 的紀律,不可顛倒。
| # | 步驟 | 產出 / 驗收 |
|---|---|---|
| 0 | representation = overlay。拍板前補了四個 delta 實測資料點(file 0/mail 1/auth 0/notification 5–10)並更正 localSeams 表高估機械稅——見「證據等級」 | |
| 1 | 逐 feature 走 D-F 三出口,最終出口:file、mail 完成(worked-example 見下);auth delta = 0(出口①最強形式);notification 正交化完成(決策六管理面走 Store SPI+Config 組裝、fallback 探測改組裝注入;殘餘=4 個 @TenantId 實體 → overlay);delegation/reference-data/impersonation 實測 delta ≈ 0(*WithoutTenantIT 系列;impersonation「概念塌陷」僅塌 cross-tenant 面、裝得上);service-account = D-C per-variant requires | |
| 2 | variant-assembly.py(排除集=catalog 宣告+requires 閉包+業務殘餘、test 連動排除、yml 匯入同步、overlay 死檔檢查、per-variant I3 復用同一份 inventory)。實測證偽「空 overlay 即可綠」:組裝樹只要殘留任一 @TenantId 實體,SessionFactory 即進 multi-tenancy、而 resolver 已隨 tenant feature 排除 → 全面炸——第 2、3 步無獨立驗收點,最終一併轉綠:ownership 組裝樹 210/0/0、base 438/0/0、per-variant I3 綠 | |
| 3 | 作者化 ownership variant 初版 ✅ 核心完成(2026-07-18;2026-07-24 擴充 M2M、file、mail、reference-data) | 目前總 overlay = 11 檔:notification 實體 4、mail entity 1、reference-data 型別宣告 1、serviceaccount management policy 1、file/service-account 設定 2、tenantless Account seed 2。mail 與 reference-data 的其餘 provider/CRUD/維運碼兩 variant 共用。租戶專屬通知檔另抽成 requires 含 tenant 的微 feature,閉包自動排除。 |
| 4 | /feature + /scaffold-module variant-aware | 來源分派、零轉換:/feature Step 1.5({variant} 由 target workspace defaultDataIsolation 推導 → 非 base 以組裝樹為 {effectiveSource};provision/sync 前置跑完整 I4);/scaffold-module Step 5.0.5(server 複製來源分派+I4 出貨前置;一般業務參考領域不帶,variantSharedDomains 保留並照常 stamp;全 scaffold 一律丟 variants/ 面與 catalog variants 鍵——D-C 下游不持宣告);gate 拍板=混合策略(L1 variantCheck 常駐 build、I4 掛 chokepoint,見 D-E 校準註記) |
| 5 | 重跑 provision;純機械 localSeams 整批降回 sync(48→4:mail/notification/reference-data/platform-info 歸零、auth 剩 UserResponse、signed-link 剩 3 檔產品客製);全 feature syncedSHA 對齊框架、I3 PASSED、build 綠 | |
| 6 | /remove-multi-tenancy 退場 | 依其自述計畫(以 variant 軸取代後移除):skill 自 app-server 刪除;方法論引用清理(m-server-common/m-skill-execution/m-practices-sync//scaffold-project//prune-reference)。三類判準與惰性型實證留存於本 ADR「auth 的 delta 是 0」與 AuthWithoutTenantIT;仍帶該 skill 的既有下游由 /practices publish 的「框架已刪 canonical」閘門逐一收斂 |
第 1 步的每個 feature 都應先問「能不能走出口 ①(消除)?」——ADR-017 證明了那個出口比直覺以為的寬。只有問過且答不出來,才允許進 overlay。
worked-example:file(2026-07-17 完成,本 ADR 的第一個端到端實證)
以下為已執行的實例,非設計時的示意。
ict-server 的 file 有 3 個 localSeams,真客製 0 個——兩種成因各半:
| localSeam | 成因 | 出口 |
|---|---|---|
StagingFileController | 分區鍵 TenantContext.getCurrentTenantId() → businessProperties.getStoragePartition()(機械稅,ADR-007 三分類無此格) | ① 消除(最強形式:delta 降為設定):抽 FileStoragePartitionResolver,來源由 app.storage.partition-source: tenant|fixed 屬性選擇 |
DatabaseFileStorage、StagingCleanupScheduler | 注入 Clock,日期分目錄與清理 cutoff 跟業務時鐘走(改良,ADR-007 第三類被誤分類) | reflow(FU-64):框架以 ObjectProvider<Clock> + 系統時區 fallback 重做 |
結果:ict 的 file Java 15 檔全部 0 行本體差異、localSeams 與 seams
皆為空;隔離差異只剩來源 variant 的一個完整 resource overlay。原文一度把「可由設定表達」
誤寫成「base 不需替 ownership 選定設定」,使 ownership 組裝仍落到 tenant 的
matchIfMissing;2026-07-24 的 I4 審查補上 variants/ownership/resources/config/feature-file.yml。
四點值得記錄:
- 出口①有一個最強形式:Java delta 降格為「設定值」。 分區鍵是個 runtime 值,
故 tenant/fixed 共用逐字相同的 Java;但 variant 仍須以 resource overlay 選定自己的
production-safe 預設,不能把「可設定」誤當成「會自動知道 defaultDataIsolation」。
這條路不 generalize——
mail的基類與 unique key 等結構差異無法用屬性表達。本 ADR 初版預期此例走出口②(推進 seam),實際走了出口①。 過程中另發現
FileStorageConfig從一開始就被誤判為 seam(選後端由app.storage.type屬性表達、憑證在*Properties、journal 接線純機械——沒有一項承載宣告權),已連帶更正 ADR-006 D-D 的該例。誤判的代價是具體的:seam 永不同步,該下游的 SFTP 因此長期缺少 ADR-005 的交易補償。 - reflow ≠ 盲 copy 在此有具體代價(不變量三):ict 的
StagingCleanupScheduler(Clock)直接搬回框架會啟動失敗——框架無Clockbean。改用ObjectProvider+ fallback 後,ict 的Clockbean 被自動撿到、行為不變。 - feature 不得依賴 skeleton bean(ADR-006 D-C 推論):
ObjectProvider不是缺 bean 的權宜——skeleton不在/feature sync範圍,故 core slice 硬性要求一個 skeleton bean,會讓任何sync該 feature 的下游啟動失敗。即使框架後來補了Clockbean(見第 4 點),ObjectProvider仍須保留。 - 本 ADR 原本預測
file是「一行改常數」(承自 FU-59),實際不是:機械稅確實只有一行,但 3 個 localSeams 裡有 2 個是與隔離無關的改良。「localSeams 歸零」需要 FU-63 與 FU-64 兩條線同時收——單靠 variant 軸不足以清空 localSeams。這是 D-F 的三出口分類在真實 feature 上的第一次校準。
附帶產出:ict 的
Clockbean 經核對零獨特性(Clock.system(zoneId)讀設定,20 檔注入、33 處now(clock)),已 reflow 為框架 skeleton 的config/TimeConfig(app.time-zone,留空即退回系統時區故既有下游零影響),ict 改採框架版。這與 variant 軸正交,但同屬「把下游各自發明的通用形狀扶正為框架慣例」。
與其他文件的關係
| 文件 | 關係 |
|---|---|
| ADR-006 | 本 ADR 修補其未言明的前提:D-B 的 core slice「中性」隱含了「對隔離取向也中性」,而參考實作從未如此。本 ADR 的 variant 軸使該前提成立。service-account 以中性 core + referenceDomains 附帶管理面,tenant 耦合由小型 policy overlay 回應。 |
| ADR-007 | 本 ADR 補其 drift 三分類的第四類:ownership 下游的 drift 成因既非落後、亦非客製或改良,而是隔離取向的機械後果。三分類對它的唯一答案(localSeams)是誤判——本 ADR 讓這類 drift 在 provision 前就不存在 |
| ADR-004 | 同一條中性化路線的第三段:ADR-004 中性化方法論層並建立 defaultDataIsolation 這個宣告 SoT;本 ADR 直接消費它作為 variant 配對依據,並把中性化推進到參考實作碼層 |
| server ADR-017 | D-F 正交化的方法論前身與活證:其「兩原則如何互相成全」示範了正交化可達 variant delta = 0;決策六的 resolver 鏈是出口 ②(推進 seam)的範例。第 1 步的每個 feature 都應先嘗試複製其手法 |
| server ADR-014 | 判準零是 D-D 的裁判:seam = 承載消費端宣告權。隔離取向的機械後果不是宣告,故不得列 seam |
m-server-common.md | 「資料隔離策略(defaultDataIsolation)」節為 variant 配對的權威語意來源;其「消費此欄位的 Skill」表須增列 /feature、/scaffold-module |
m-methodology-sync.md | 不變量一(推導不手維護)直接支撐 D-C 的「overlay 檔集由目錄推導」;不變量三(reflow 是重做不是複製)約束落地順序第 3 步 |
m-reference-code.md | 一般花店業務領域不做 variant;Feature 以 referenceDomains 明確附帶、且需在 ownership 開箱可跑的預設資源域,則可用同路徑 overlay(例如 mail 的 entity) |
m-module-inventory.md | type 軸的 SoT(modules[].type);本 ADR 增加的 variant 軸與其正交,同採「從 project.json 推導、不硬編」的治法 |
| FU-63 / FU-59 / FU-64 | FU-63 為本 ADR 的來源與定向;FU-59 的耦合盤點是落地順序第 1 步的工作清單;FU-64(Clock reflow)為互補的另一面——本 ADR 消機械稅、FU-64 收編真改良 |