跳至主要内容

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.jsondefaultDataIsolation 為 SoT),server ADR-017 已把框架 jar 對租戶中性化(jar 不擁有 @Entity、租戶經 TenantAware 探詢)。唯獨參考實作的 core slice 沒有被中性化——它是 defaultDataIsolation: tenant 的單一取向產物。

後果由 2026-07-17 的 ict-server provision 實證:ownership 下游有一類 drifted 檔是純機械的租戶剝除AuditableTenantEntityAuditableBase、去 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-serverdefaultDataIsolation: ownership)跑 provision 盤點,drifted 檔中有一類的成因是第四類,三分類裡沒有它的位置(下表為取樣,非全量——各 feature 的 drifted 另含落後/客製/改良,見「證據等級」的更正):

featuredrifted成因
mail6/6(全部)MailSetting extends AuditableTenantEntityAuditableBase、uk 去 tenant_idMailerService 快取鍵去租戶前綴、MailEndpoint/MailHealthIndicatorNO_TENANT_CONTEXT 分支
fileStagingFileControllergetCurrentTenantId() 換常數(tenant_id 在此是儲存分區鍵、非業務租戶,欄位保留)
authRoleRoleRepositoryAccountUserDetailstenant_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 全量分布:

featureauthnotificationreference-datamailsigned-linkfileplatform-infocache / scheduling / almanac
localSeams1019864310

三個 0與租戶無關的 feature——它們本來就沒有機械稅可付,正是「稅只落在耦合隔離取向的 feature」的對照組。

撰寫本 ADR 時親自核對兩例

  • MailSetting:框架 extends AuditableTenantEntityapp-server/src/main/java/io/leandev/app/mail/MailSetting.java:41)、ict extends 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 實測分離後:

featurelocalSeams真實機械 delta那個差額是什麼
file31另 2 個是改良Clock 注入)→ 走 reflow(FU-64),與隔離無關
mail61MailSetting主要是落後——ict 仍用 1f253acb 前的 configName 快取鍵;其餘為 log 字串
notification19≈12 →(正交化後估)5–10最大的 NotificationTemplateService(246 行 diff)幾乎全是落後:框架有 channel/locale/audit/SUPER_ADMIN 授權,ict 版一個都沒有
auth100(見下節)那 8 個「機械」drift 是 /remove-multi-tenancy 拔掉惰性面所製造;其餘 7 個是混同檔——機械剝除與 ict 業務客製(category、帳號生效日、@VersionLoginRequest.app)同檔

三個推論

  1. 本 ADR 的收益被 localSeams 表高估。 真實機械稅遠小於 38 個 localSeams 所暗示的規模。
  2. localSeams 高,不代表該 feature 難正交化——甚至可能相反。 notification(19)的形狀比 auth(10)好得多(見下方 worked-example 與 D-F)。用 localSeams 排序工作量會排錯。
  3. 混同檔限制的是收益的兌現速度,不是機制的正確性。 auth 的 7 個混同檔就算有了 variant 軸仍是 localSeam(因為 ict 的業務客製還在)。這筆帳該記在 reflow 線上(把其中「其實是改良」的收編進框架),不是記在 variant 軸上。兩件事必須分開記,否則本 ADR 會承諾它兌現不了的東西。

ict 是發現地點,但它是被污染的樣本——業務客製把水攪混,故不宜當本 ADR 的論證本體。逐 feature 的乾淨測量見下兩節。

auth 的 Java delta 是 0——隔離政策值由 resource overlay 承擔(2026-07-24 更新)

authkind: 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 本就 nullablenullable = falseAccount 不繼承 TenantAwareEntity、無 @PrePersist 守衛
登入本就對租戶中性AccountDetailsServicefindByUsername
無租戶是契約明文TenantAwareUserDetails.getTenantId() javadoc:「若使用者不屬於任何租戶則可返回 null」;jar 的 UserDetailsTenantIdResolver 對 null 直接回傳、不拋
消費它的解析鏈根本不裝TenantConfigtenant feature(kind: optional),ownership 專案不安裝(ADR-006 D-A 的 opt-out 點)
auth 不依賴 tenantcatalog 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 的:

authAccountmailMailSettingnotification 的實體
租戶欄位怎麼來手寫、nullableextends 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後果
file1 行getCurrentTenantId() → 常數)localSeams、凍結
mailMailerService3 行 log 參數localSeams、凍結
auth0(拔掉惰性面自找的 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 與維護者討論後否決,兩個缺口:

  1. 租戶語意拿掉後「用什麼補上」是選擇點,transform 給不出答案。 分區鍵該從哪來?DTO 的 tenantId 欄位去留?NotificationTemplate 的租戶覆寫表拿掉後,範本管理指向哪張表(ADR-017 決策六)?這些是設計決定,不是文字替換。transform 只能各專案自行發散——那正好是 fleet 分歧的定義。
  2. 真實檔常「租戶剝除+專案客製」混同(上節 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 個 ownershipict)。方案不得讓多數為少數付成本。
  • defaultDataIsolation 已是 SoT(ADR-004):不需要發明新的宣告面。
  • role: source 在 fleet 中唯一是機械守衛(realpath + role,見 m-practices-sync.md/feature Step 1)。任何方案不得打破它。
  • 業務領域(@reference-surface)注定被 retarget 或 prunem-reference-code.md 兩出口),且 AuditableTenantEntityskeleton、在 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:siblingB:overlayC:生成
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 個耦合 feature128有耦合,但只有一小部分檔(見下表)
9 個中性 feature(cache/scheduling/almanac/audit/calendar…)32
skeleton12 (+2)
業務領域(花店:customer/order/sales/dashboard)68,且注定 retarget/prune

分子

feature該 feature 檔數overlay 檔數形狀
file15Java 0;resource 1出口①(delta 是feature-file.yml overlay 選 app.storage.partition-source=fixed
mail121MailSetting出口①+③混合:守衛是→接點;基類/uk 是結構→overlay
auth23Java 0;resource 1登入鏈 delta 為零;M2M require-tenant 是值,走設定 overlay。一度誤判為 8 個 Java 檔/最壞案例
reference-data3 個 Feature contract + 附屬 reference domain1CodeDataType所有機制共用;ownership 只把產品型別宣告改為全域
notification42≈12 →(正交化後估)5–105 個實體為結構;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。這是結構性的,不是偏好。

兩個反直覺的實測發現

  1. localSeams 數量會排錯工作量。 notification(19 localSeams)的形狀比 auth(10)好得多——其 5 個實體已是 ADR-017 的形狀(jar 擁有中性 *Base,app 的 @Entity 子類整個檔幾乎就是那個租戶決定:框架 21 行 vs ict 9 行)。overlay 這種檔近乎免費:檔本身就是 delta,且它們幾乎不會變。

  2. overlay 的成本不在檔數,在「檔內 delta 佔比」與「該檔會不會長新東西」。 對比:

    檔長真實 deltaoverlay 雙份維護該檔會長新東西嗎
    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
typeserver(本 ADR 只涉 server)project.jsonmodules[].typem-module-inventory.md
isolation varianttenant(base)/ownershipproject.jsondefaultDataIsolationm-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 的三條紀律:

  1. 粒度是整檔替換,不做行級 patch。patch 脆弱、無法獨立閱讀、且編譯器看不見——同 ADR-006「D-C 的暗面」的字串耦合 family。
  2. overlay 只覆蓋、不刪除。 「某 feature 在某 variant 下整個不適用」由不裝表達(tenant feature 對 ownership 專案本就是 opt-out 點,ADR-006 D-A),不需要檔級刪除記號。
  3. 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 耦合由小型 ServiceAccountManagementPolicy overlay 消除。共同的 ServiceAccountManagementService 不 import tenant,因此 secret/API-key 生命週期改良仍可由兩種取向共用,不必 overlay 整個管理服務。

D-D:variant delta 由 overlay 承載,不得以擴張 seam 集規避

有一條看似便宜的替代路:把有 variant delta 的檔(MailSettingRole…)全部改列為 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 buildvariantCheck task,掛 check):組裝+overlay 死檔/殘餘 import 靜態檢查+per-variant I3,秒級——結構性漂移在當次 build 現形。
  • 完整 I4(nested build)掛 chokepoint 必跑/feature provisionsync 前置(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 走:租戶專屬測試住 tenant feature 的 test package,ownership 組裝不裝該 feature → 測試自然不在。不需要 overlay 的排除機制。
  • I3 隨之 per-variant 化feature-inventory.py --check --variant {v} 對組裝樹跑同一套推導,校驗 D-C 的 per-variant requires 例外。另須新增校驗: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 不擁有 entity0
② 推進 seam該差異真的是專案組裝選擇(如 ADR-017 決策六的 resolver 鏈:tenant 是「租戶表→全域表→NLS」、ownership 是「全域表→NLS」)→ 它本就該住 *Config0(就 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 回歸其本義:登錄在那裡的,從此只有專案真正的客製(如 ict Role 的角色範例 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 AD oauth2_tenant_id,盲改會斷 Office365 認證)、impersonationcross-tenant 概念塌陷,不只是改碼)皆非機械工作。
  • /feature/scaffold-modulefeature-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 處mailfile 各約 30 行)。出口③(overlay)對 base 零複雜度增加已納入「負面/成本」
「重複的程式碼維護不困難」未採納:實測 242 檔中 114 檔(47%)在構造上與隔離無關(32 中性 feature + 12 skeleton + 68 注定 prune 的花店),而 delta 僅 10–20 檔。且 A 的漂移防護為——/methodology/practicesm-*-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:

  1. overlay 集持續成長且逼近耦合 feature 的整包(D-B 的量表觸頂)——代表兩取向真的在分歧,B 的前提失效
  2. 出現第二個 ownership 下游,且其需求與 ict 系統性不同——目前 6:1 的 tenant 多數是「多數不為少數付成本」的前提
  3. I4 反覆抓不到的 overlay 漂移造成生產事故——D-E 的誠實邊界兌現成真實代價

反之,不構成重開理由:單一 feature 的 overlay 較大(notification 5–10 已知且可接受)、或某次同步不便。

拍板時的證據等級(誠實聲明)

  • 已實測file(已完成)、mail(已完成)、auth(delta 0,AuthWithoutTenantIT 5/5)、notification(勘查,未動工)
  • 未實測reference-datadelegationimpersonationservice-account 的 delta 為外推
  • 未存在:overlay 樹與 I4 harness 尚未建(落地順序第 2 步)。「組裝後 build 綠」目前是設計主張,不是已驗證的事實

落地順序

順序即 D-F 的紀律,不可顛倒。

#步驟產出 / 驗收
0接受本 ADR2026-07-17 拍板representation = overlay。拍板前補了四個 delta 實測資料點(file 0/mail 1/auth 0/notification 5–10)並更正 localSeams 表高估機械稅——見「證據等級」
1正交化 FU-59 的 8 個耦合 feature2026-07-17/18 完成逐 feature 走 D-F 三出口,最終出口:filemail 完成(worked-example 見下)auth delta = 0(出口①最強形式);notification 正交化完成(決策六管理面走 Store SPI+Config 組裝、fallback 探測改組裝注入;殘餘=4 個 @TenantId 實體 → overlay);delegationreference-dataimpersonation 實測 delta ≈ 0*WithoutTenantIT 系列;impersonation「概念塌陷」僅塌 cross-tenant 面、裝得上);service-account = D-C per-variant requires
2建 overlay 樹 + I4 harness2026-07-18 完成(與第 3 步核心一併收口)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。mailreference-data 的其餘 provider/CRUD/維運碼兩 variant 共用。租戶專屬通知檔另抽成 requires 含 tenant 的微 feature,閉包自動排除。
4/feature + /scaffold-module variant-aware2026-07-20 完成,2026-07-24 補 shared domain來源分派、零轉換/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 校準註記)
5ict 重 baseline2026-07-20 完成重跑 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 退場2026-07-20 完成依其自述計畫(以 variant 軸取代後移除):skill 自 app-server 刪除;方法論引用清理(m-server-commonm-skill-executionm-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-serverfile 有 3 個 localSeams真客製 0 個——兩種成因各半:

localSeam成因出口
StagingFileController分區鍵 TenantContext.getCurrentTenantId()businessProperties.getStoragePartition()機械稅,ADR-007 三分類無此格)① 消除(最強形式:delta 降為設定):抽 FileStoragePartitionResolver,來源由 app.storage.partition-source: tenant|fixed 屬性選擇
DatabaseFileStorageStagingCleanupScheduler注入 Clock,日期分目錄與清理 cutoff 跟業務時鐘走(改良,ADR-007 第三類被誤分類)reflow(FU-64):框架以 ObjectProvider<Clock> + 系統時區 fallback 重做

結果:ict 的 file Java 15 檔全部 0 行本體差異localSeamsseams 皆為空;隔離差異只剩來源 variant 的一個完整 resource overlay。原文一度把「可由設定表達」 誤寫成「base 不需替 ownership 選定設定」,使 ownership 組裝仍落到 tenantmatchIfMissing;2026-07-24 的 I4 審查補上 variants/ownership/resources/config/feature-file.yml

四點值得記錄:

  1. 出口①有一個最強形式: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 的交易補償。

  2. reflow ≠ 盲 copy 在此有具體代價(不變量三):ict 的 StagingCleanupScheduler(Clock) 直接搬回框架會啟動失敗——框架無 Clock bean。改用 ObjectProvider + fallback 後,ict 的 Clock bean 被自動撿到、行為不變。
  3. feature 不得依賴 skeleton bean(ADR-006 D-C 推論):ObjectProvider 不是缺 bean 的權宜——skeleton 不在 /feature sync 範圍,故 core slice 硬性要求一個 skeleton bean,會讓任何 sync 該 feature 的下游啟動失敗。即使框架後來補了 Clock bean(見第 4 點),ObjectProvider 仍須保留。
  4. 本 ADR 原本預測 file 是「一行改常數」(承自 FU-59),實際不是:機械稅確實只有一行,但 3 個 localSeams 裡有 2 個是與隔離無關的改良「localSeams 歸零」需要 FU-63 與 FU-64 兩條線同時收——單靠 variant 軸不足以清空 localSeams。這是 D-F 的三出口分類在真實 feature 上的第一次校準。

附帶產出:ict 的 Clock bean 經核對零獨特性Clock.system(zoneId) 讀設定,20 檔注入、33 處 now(clock)),已 reflow 為框架 skeleton 的 config/TimeConfigapp.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-017D-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.mdtype 軸的 SoT(modules[].type);本 ADR 增加的 variant 軸與其正交,同採「從 project.json 推導、不硬編」的治法
FU-63 / FU-59 / FU-64FU-63 為本 ADR 的來源與定向;FU-59 的耦合盤點是落地順序第 1 步的工作清單;FU-64(Clock reflow)為互補的另一面——本 ADR 消機械稅、FU-64 收編真改良