跳至主要内容

ADR-024: capability/feature/參考實作三層定位——feature 預期修改面限縮為組態接合,auth 資源實作降級業務層

ADR 編號: 024 狀態: 已接受 (Accepted) 決策日期: 2026-07-22 決策者: Development Team 取代: ADR-018 決策一、六(Account seam 模型與 catalog 宣告——帳號模型整域離開 feature 後,「canonical base + seam 具體類」二分不再是 feature 內的邊界,而是業務層自有碼的內部結構) 被取代: ADR-026 取代「Feature 只能含 組態接合」及「具體端點一律屬 reference implementation」的過度推論;auth 資源實作降級、 seam 收斂與低 drift 原則仍有效

2026-07-25 修訂提示:本 ADR 的「不得存在預設需要修改的非組態檔」不等於 「Feature 只能存在組態檔」。只依賴 capability、無合理 retarget 需求的固定 Actuator/health/ system handshake adapter 是零 drift core,依 ADR-026 留在 Feature。


摘要

確立 capability/feature/參考實作的三層定位原則:feature 存在的意義是低 drift 的同步管道,其預期修改面限縮為組態接合*Config*Properties/config fragment);凡預期會被消費端修改的內容都不屬 feature、屬參考實作(業務層、@reference-surface 生命週期)。據此把 auth 的資源實作(帳號模型、HTTP 殼、角色詞彙、seed)整域降級業務層,feature/auth 縮為純組裝 seam。前置:六個 feature 對帳號模型的 hard-import 須先契約化。


背景 (Context)

三層定位的張力:允許大量 drift 的 feature,與參考實作無差別

ADR-014 確立了 capability↔core slice 對偶,ADR-022ADR-023 分別把 admin 面降級業務層、把登入引擎上收 jar。走完這兩步,一個此前隱含的定位問題浮上檯面:

feature 與參考實作的差別到底是什麼?

feature 的存在理由只有一個:同步管道——canonical 檔零分歧下行,框架修正自動到達 fleet。這個管道的價值與 drift 成反比:seam(消費端會改的檔)越多,feature 能同步的面越小;當一個 feature 的 seam 涵蓋 wire 殼、實體結構、專案詞彙、seed 資料時,它實質上就是穿著 feature 外衣的參考實作——而「單純縫合框架能力、允許大量 drift」這件事,參考實作本來就做得到,不需要 feature 機制的任何一部分(catalog、sync、provision、drift 分類)來服務它。

auth 正是這個病的最大標本。ADR-023 之後,feature/auth 的 seams 清單涵蓋:Account(實體結構)、AuthController(wire 殼)、RoleHierarchyService(角色詞彙)、SecurityConfigStaticCorsConfigurationSource(組態接合)、Role.jsonAccount.json(seed)——只有組態接合那兩個是「縫合」,其餘全是參考實作。引擎上收後,slice 剩餘的 canonical(AccountRepositoryAccountDetailsService、wire DTO)也被 ADR-023 自己的表格承認為「參考接線」性質。

紀律四已經給過答案,只是 auth 未跟上

21-feature-surface.md 紀律四:「feature 出能力與接點,資源的實作歸消費端;參考實作住業務層,不住 feature/」。mail 已走完資源分離:能力上收 jar,feature 組裝預設 bean; MailSetting、CRUD 與 mail_setting:* 授權住附屬 mail reference domain。依 ADR-026 後續修訂,固定的 Actuator endpoint、health 與 mail:ops 授權屬零 drift Feature core, 不隨 MailSetting 降級。對 auth 資源實作而言,判定仍成立——FEDERATED 部署不曝露本地 login、身分在 IdP,帳號模型與 HTTP 殼整組可以不要且預期依專案重塑。

阻礙:帳號模型是六個 feature 的 import hub

實測掃描(2026-07-22),以下 feature hard-import feature/auth 的帳號模型型別(AccountAccountRepositoryRoleHierarchyServiceUserResponseTokenBlacklistService):

Feature檔案依賴什麼
delegationDelegationService帳號/角色查詢(合併授權人權限)
impersonationImpersonationServiceImpersonationPolicyAntiEscalation*ConsentAware*ImpersonationConsentService(5 檔)帳號/角色查詢、TokenBlacklistService
service-accountClientTokenControllerServiceAccountManagementService(原 ServiceAccountService服務帳號載入與驗證
signed-linkSignedLinkAppServiceemail 查找帳號
notification(inapp)InAppNotificationController當前使用者身分解析

今天這些是合法的 feature→feature 邊(requires: auth);帳號模型搬到業務層後它們變成 feature→業務邊,違反 ADR-014 中性不變量(ArchUnit 擋)、並使這些 feature 無法單獨 provision。契約化這些邊是本 ADR 的硬前置。連帶暴露的既有壞味道:UserResponse 這個 wire DTO 被跨 feature 共用。

限制條件

  • ADR-014 中性不變量:feature 碼不得 import 業務層(ArchUnit 強制)。
  • UI 軌專案需要可運行的本地登入面——降級不得使 scaffold 交付不可登入的系統。
  • wire 契約(/api/v1/auth/*)與行為零變化。
  • 時機同 ADR-023:SNAPSHOT、fleet 小,搬遷成本最低的窗口。

假設前提

  • ADR-023 已落地:登入引擎在 jar,slice 對引擎無編譯依賴。
  • 安全承重面的同步已由 Maven 管道承擔;slice 剩餘 canonical 的同步價值低(帳號模型本就 per-project)。

考量的方案 (Options Considered)

方案 A: 維持現狀(寬 seam 的 feature)

說明: auth 保持 ADR-023 後的形狀:引擎在 jar、slice 帶寬 seam 集(實體、殼、詞彙、seed)。

優點:

  • ✅ 零遷移成本
  • AccountBaseAccountDetailsService 保留 canonical 同步

缺點:

  • ❌ feature 與參考實作的定位持續模糊——「什麼該是 seam」無判準,seam 集會繼續膨脹(historical:AccountAuthControllerRoleHierarchyService → seed,一路加)
  • ❌ sync/provision/drift 分類持續為一堆「下游本來就會改」的檔運轉,報告噪音掩蓋真訊號
  • ❌ 紀律四對 mail 成立、對 auth 不成立——同一 workspace 兩套標準

評分: 2


方案 B: 只搬 auth、不立原則

說明: 把 auth 資源實作降級業務層(比照 ADR-022),但不確立通用定位原則。

優點:

  • ✅ 解掉最大標本

缺點:

  • ❌ 「seam 該多寬」仍無判準——下一個 feature 還會長出寬 seam,每次都要重新吵一遍 auth 這場架
  • ❌ 既有其他 feature 的 seam 集無稽核依據

評分: 3


方案 C: 確立三層定位原則 + auth 整域降級(採用)

說明: 先立原則(決策一),auth 作為第一個套用案例(決策二~四),既有 feature 的 seam 集依原則盤點(決策五)。

優點:

  • ✅ 定位一次說清楚:capability=能力、feature=低 drift 縫合、參考實作=預期重塑的起點
  • ✅ seam 有了機械判準(*Config*Properties),膨脹被結構性擋住
  • ✅ auth 完成 ADR-022→023→024 的三部曲:admin 面、引擎、資源實作各歸其位

缺點:

  • ❌ 前置(六 feature 契約化)工作量大,須分期
  • AccountBase 等剩餘 canonical 的同步權喪失

評分: 5


決策 (Decision)

方案 C,五個決策。

決策一:三層定位原則(本 ADR 的核心貢獻)

定位住哪drift 期望同步管道
capability能力本體+SPI 邊界appfuse-server jar零(消費端碰不到)Maven 版本升級
featurecapability 無法在 jar 內完成的組合:bean 組裝、config 匯入、貢獻點接線feature/{id}/(slice)趨零——預期修改面限縮為組態接合/feature sync(零分歧覆蓋)
參考實作預期依專案重塑的資源實作:實體、殼、詞彙、seed業務層必然(這就是它的生命週期)無——@reference-surface,retarget 或 prune

不變量:feature 內除 *Config*Properties(含 config fragment feature-{id}.yml)外,不得存在「預設需要修改」的檔。

  • 判定句:「這個檔預期會被消費端修改嗎?」預期會、且不是組態接合 → 它不屬 feature,屬參考實作(業務層)。
  • seam 的語意收斂:seam 從「feature 內任何消費端擁有的檔」收斂為「組態接合點」。組態接合天生 per-project(jar 不做 autoconfiguration 的直接後果),是 feature 唯一合法的 drift 面。
  • feature 內 drift 是訊號,不是常態:非組態檔出現持續 drift = 設計錯位,出口只有兩個——變異點反覆出現 → 抽 SPI 上收 capability(ADR-023 路徑);整個資源的實作分歧 → 降級參考實作(ADR-022 路徑)。沒有「把它列成 seam 繼續住在 feature」這個第三出口。
  • 為什麼:feature 的存在意義是同步管道;允許大量 drift 的「feature」與參考實作提供的東西完全相同,卻多付 catalog/sync/drift 分類的機制成本。低 drift 不是 feature 的品質指標,是它的定義
  • 對 seam 設計的推論:既然 seam 只剩組態接合,它就必須從最佳實踐出發設計——組態接合的形狀(裝哪些 bean、接哪些 SPI)本身應當是 drift 機率極低的 canonical 知識,消費端改的是值與選擇,不是形狀。

決策二:auth 資源實作整域降級業務層

搬遷清單(app-server 為 layer-first,業務域名 auth,與既有 authadmin 域並列):

現位置(feature/auth/去處(業務層)性質
AccountAccountBaseRoleAuthorityRoleTypeentity/auth/帳號模型(AccountBase 自此為業務層自有碼——canonical/seam 二分退役,見「與 ADR-018」)
AccountRepositoryRoleRepositoryAuthorityRepositoryrepository/auth/持久化
AccountDetailsServiceAccountUserDetailsRoleHierarchyServiceTokenBlacklistServiceAuthSeedContributorservice/auth/adapter 與詞彙服務(AccountDetailsService 續任 jar AuthPrincipalLookupUserDetailsService 實作)
AuthControllerLoginRequestLoginResponseRefreshTokenResponseUserResponsecontroller/auth/HTTP 殼與 wire DTO(UserResponse 自此不得再被 feature import——見決策三)
data/auth/Role.jsonAccount.jsonAuthSeedContributor 走(seed 路徑不變或併整)專案詞彙 seed

feature/auth 殘餘(純組裝,符合決策一不變量):SecurityConfigStaticCorsConfigurationSourceH2ConsoleSecurityConfig(皆組態接合 seam)+ config fragment。catalog 的 seams 縮減為此集合;kind: required 不變(每個專案都要組 filter chain)。

決策三:前置——六條 feature→帳號模型的邊契約化(硬前置,分期)

依 ADR-023 的同一手法逐 feature 收斂,全部斷完才能搬(否則 ArchUnit 中性檢查擋)。最難的一條邊(delegation/impersonation 的「查他人」)已於 2026-07-22 spike 定稿,結論:一個新 surface 就夠,且不需要角色階層 SPI(展開歸 wire 層,見下)。

契約定稿:PrincipalDirectoryPrincipalView(jar,io.leandev.appfuse.security.directory

從 delegation/impersonation/signed-link 的實際用法逐行萃取(非臆測需求)的唯讀主體目錄視圖:

/// 主體目錄視圖(唯讀)——feature 對「他人」的全部合法認知
public interface PrincipalView {
String username(); // 身分識別子
default String id() { return username(); } // 穩定識別(無獨立 id 的身分模型退回 username)
String displayName(); // 顯示名(實作以 username 退回,不回 null)
default Optional<String> tenantId() { return Optional.empty(); } // ownership 專案恆 empty
default boolean interactiveLoginAllowed() { return true; } // 同 AuthPrincipal 語意
Optional<String> primaryRoleName(); // 主角色裸名
List<String> roleNames(); // 直接指派的角色裸名(順序=帳號角色順序)
List<String> authorityNames(); // 攤平的權限名
}

public interface PrincipalDirectory {
Optional<? extends PrincipalView> findByUsername(String username);
Optional<? extends PrincipalView> findById(String id); // 簽章連結等以不可變 id 為 subject 的兌換載入(ADR-007)
List<? extends PrincipalView> findByEmail(String email); // 重複容忍(ADR-018 決策三:List、不選第一筆)
List<? extends PrincipalView> findAllInteractive(); // 候選池原料;scope 過濾(同租戶/跨租戶)歸 feature
}

實作期修訂(2026-07-23):①補 findById(signed-link 的連結 subject=不可變帳號 id,兌換須精準載入); ②啟用 email 的 fallback 條款——PrincipalView選用 default Optional<String> email()(預設 empty)。 consent 通知須以 email 組收件人,改走 RecipientResolver 路徑會迫使遞送流程改變+新增 app 端 resolver, 成本高於一個預設 empty 的選用成員;「email 是通道不是身分」的立場由 javadoc 與預設值承載。

刻意排除的成員(每個排除都是少一分偏見):

排除理由
email(聯絡通道)ADR-018:email 是通道不是身分。實作期已採 fallback 條款(見上修訂):降格為選用成員(default Optional<String> email(),預設 empty)而非契約必要成員——無 email 概念的身分模型零負擔,消費端對 empty 須退 RecipientResolver 等通道解析
角色階層展開(可達角色、主角色置首)那是 wire 契約(前端 guard 吃展開陣列、roles[0]=主角色),不是目錄事實。展開留在業務層 wire 組裝(RoleHierarchyService——它本身是專案角色詞彙,本就要隨決策二搬業務層)。連帶語意修訂(已落地):jar ActingLogin.roles() 由「合併展開角色」改為「合併後的直接角色」,展開由業務層 wire 面(AuthController.buildUser)執行
密碼/憑證認證走 AuthenticationManager,目錄永不曝露 secret

逐邊處置(定稿)

處置
impersonationTokenBlacklistService改直接吃 jar TokenBlacklistStore(純機械,ADR-023 引擎已示範)
notification(inapp)→ 當前使用者改吃 Spring Security context/AuthPrincipal,不觸 entity
signed-link → email 查找PrincipalDirectory.findByEmail(重複容忍語意隨行)
delegation → 帳號/角色查詢contribute() 的合併基底改 AuthPrincipalLookup(grantee 現時 authorities)+ PrincipalDirectory(grantor 的 roleNames/authorityNames);候選池 findAllInteractive()tenantId() 過濾;同租戶/服務帳號守衛用 tenantId()/interactiveLoginAllowed()
impersonation → 帳號查詢與政策同上走 directory;ImpersonationPolicy 簽章改 allowed(PrincipalView actor, PrincipalView target)(比照 ADR-023 對 LoginPolicy 的處理,SPI 不得洩漏 entity——ADR-014 規範一)
(橫向)UserResponseLoginResponse 跨 feature 共用session wire 組裝一律住業務層:impersonate/exit/exchange 回應的是「登入形狀」payload,屬 app 的 session wire 面——feature 曝露引擎級結果(ActingSessionExchangedSession)、不 import 業務 wire DTO。實際落點(2026-07-23):wire 殼按 feature 分域住 controller/acting/ImpersonationController)與 controller/signedlink/SignedLinkController)——不併入 controller/auth/,使 prune 域集清晰。delegation 的 ActingLogin 已是引擎級(jar record),僅語意修訂(見上)
service-account → 服務帳號載入與驗證本 ADR 定稿(Phase 1d,2026-07-22;token 端點的暫時落點其後由 ADR-025 取代):不開帳號 provisioning SPI。管理面(現名 ServiceAccountManagementServiceController/authorities——建帳號、輪替 secret、啟停用)是寫入型資源實作,降級業務層;M2M 發放操作後續上收 capability,ClientTokenController 移至業務層 controller/auth/service-account feature 移除

2026-07-24 生命週期修訂:上表「資源實作降級 reference implementation」仍成立; 「Feature 移除」則由 ADR-025 的修訂 取代。Catalog referenceDomains 現可讓 optional Feature 附帶可客製資源實作而不把它誤當 core, 因此 service-account 恢復為 optional;controller/service/entity/repository 仍屬 serviceaccount reference domain,M2M/API-key 固定機制仍在 capability。

PrincipalDirectory 的參考實作住業務層(service/auth/AccountRepository 實作),feature 只認介面。每斷一條邊該 feature 即恢復可單獨 provision;全斷完後執行決策二的搬遷。

決策四:搬遷物的分類與生命週期

  • 業務殘餘、scaffold stamp @reference-surface(比照 ADR-022):帳號治理模式本就 per-project,「retarget」的語意是「重塑成你的身分模型」而非改詞彙。
  • prune 出口存在但有條件:FEDERATED/外部身分源部署可整域剝離(本地登入面不存在);UI 軌專案不可 prune(登入是必要面)——/prune-reference 對此域應以「專案是否有本地登入需求」提示,而非放行後建構失敗才發現。
  • 不新增 keep-as-is 例外:對預設身分模型滿意的專案,其「retarget」可以極薄(確認即拔標記,由 /impl/domain-model 認證),但確認這一步不可省——這是把「帳號模型是你的」明確化的儀式。

決策五:既有 feature 的 seam 盤點義務

catalog 內全部 feature 的 seams 依決策一不變量盤點一輪:非 *Config*Properties/config fragment/的 seam 逐一判定——抽 SPI 上收、降級業務層、或(少數)論證留置的理由寫進 catalog 註記。盤點結果與處置另立 FU 追蹤,不阻塞本 ADR 的 auth 主線。

實作補充:複雜度優先序與端點所有權(2026-07-24)

後續 auth、service-account 與 API-key 重構把本 ADR 的判準再具體化:

  1. 機制的首要目標是降低 reference implementation 的複雜度,其次才是降低 Feature 的複雜度;固定且容易寫錯的技術複雜度可由 capability 承擔一次。
  2. 產品資源的 URL、HTTP method 與 public/authenticated policy 是 reference implementation 的對外契約;固定 application integration surface 則由 Feature 擁有(ADR-026 修訂)。
  3. Feature seam 可以是 reference implementation 寫入這些政策的組態接合點,但位址本身不因 住在 seam 檔中就成為 Feature contract,也不得進入 capability。
  4. 公開端點採 exact method + path;不以共用 prefix wildcard 讓未來端點自動匿名可及。
  5. platform-info 的設定遮蔽/來源追蹤/DB 檢查與版本缺值語意上收 appfuse-server capability;Feature 組裝 ConfigurationDiagnosticsSystemInfo 與 Actuator info contributor。依 ADR-026 修訂,固定的 /actuator/configcheck/api/v1/system/info 及其授權亦屬 Feature core;該 feature 沒有 reference domain。與標準 health、 system-info 重疊且會反射完整 request headers/Authentication/actuator/helo 仍維持移除。

目前 app-server SecurityConfig 的 auth matcher 即屬 reference implementation 對 seam 的內容: 它精確列出 login、refresh、logout、M2M token、exchange 與 signed-link consume。 這些路徑是參考值,不是 /feature sync 應強加到下游的固定 API。


後果 (Consequences)

正面

  • 三層定位一次說清:capability=能力(jar、Maven 同步)、feature=低 drift 組態縫合(sync 同步)、參考實作=預期重塑的起點(無同步、有標記)。之後每個「這該放哪」的爭論都有判定句可用。
  • feature 機制回到它值得的形狀:sync 零分歧覆蓋的面=slice 全部非 seam 檔;drift 報告只剩真訊號。
  • auth 三部曲完成:ADR-022(admin 面歸消費端)+ ADR-023(引擎歸框架)+本 ADR(資源實作歸消費端)——「引擎歸框架、資源歸消費端」的完整佈局。
  • 帳號模型的所有權誠實化AccountAccountBase 的「canonical+seam」二分(ADR-018 決策一)是「引擎住在 slice」時代的妥協;引擎走後,整個帳號模型歸消費端,空體紀律、FQN seam 引用等機制隨之退役,認知負擔下降。

負面/代價(誠實列出)

  • 前置工作量大:六條邊的契約化各是一次 ADR-023 尺寸的設計(雖然手法已熟)。分期執行,週期內 auth 維持現狀。
  • 剩餘 canonical 同步權喪失AccountBase(username 唯一性、憑證欄位)、AccountDetailsService(authority 組裝、啟停日期衍生旗標)的框架改良(如 FU-66)不再自動下行。緩解:安全承重面已在 jar;帳號模型的改良本就常是 per-project 需求。
  • jar 契約面再擴大:決策三新增的 SPI 都是公開 API,受 CHANGELOG/deprecation cycle 約束——又一個「趁 SNAPSHOT」的理由,也是一個「契約要窄」的警鐘。
  • feature/auth 變得很薄:殘餘只剩三個組態檔。這是 feature 該有的樣子(決策一),不是退化——但 catalog 上它會顯得「小」,需要這個 ADR 解釋為什麼小才是對的。

遷移步驟(分期)

  1. Phase 0(本 ADR 接受時)21-feature-surface.md 補決策一不變量(seam 語意收斂+判定句+兩出口);決策五的 seam 盤點開 FU。
  2. Phase 1(逐 feature 契約化,可並行):依決策三定稿逐邊實作(PrincipalDirectory 進 jar → 業務層參考實作 → feature 改接)→ ArchUnit 驗證該 feature 對 feature.auth 帳號型別 import 歸零;service-account 邊於 1d 定稿其 SPI。
  3. Phase 2(搬遷):決策二整域搬遷(git mv + package 調整);features.json seams 縮減;wire 契約零變化以 AuthAdminIT 等既有 IT 驗證。✅ 已完成(2026-07-23)——帳號模型→entity/authrepository/auth、adapter 與詞彙服務→service/auth、wire 殼與 DTO→controller/authfeature/auth 殘餘 SecurityConfigStaticCorsConfigurationSource(seam)+H2ConsoleSecurityConfig(canonical),seams 縮至兩個組態檔;SecurityConfig 改以 Spring UserDetailsService 介面接 DAO provider(不 import 業務類);殘餘推導報表修正 layer-first 下「域名與 feature 同名」的誤過濾(authserviceaccount 等現形為 stamp 目標)。 → 後續修訂(2026-07-23,FU-71 I4 閘門第一滴血):帳號模型降級使 ownership 組裝樹(skeleton+features)無法開機——SecurityConfig 組裝引擎所需的 UserDetailsServiceAuthPrincipalLookupPrincipalDirectory 實作全在業務殘餘。解法為框架 catalog 頂層新宣告 variantSharedDomains: ["auth", "authadmin"]:所有組裝樹都需要的降級參考起點隨 variant 下行(scaffold 照常 stamp @reference-surface);optional 的 acting 與 signed-link 改由各自 referenceDomains 附帶,只有選用 feature 時才出貨。判定句有兩個條件:①是降級參考起點、非來源領域業務(花店域不進);②隔離中性(auth delta=0 家族)——serviceaccount 管理面 hard-import tenant feature、mailMailSetting@TenantId 血緣,皆不合格、不進共用域(其 capability 面不受影響:mail 無 provider 可運作、M2M token 端點隨 feature 下行)。variant-assembly.py 依宣告自排除集扣除;AuthAdminIT 於 ownership 樹全綠,實證 claim-based 租戶邊界政策(ADR-020 D-C)隔離中性。此修訂同時緩解 ADR-022 的負面條目「非 base scaffold 不帶帳號管理起點」——ownership scaffold 自此帶可登入的帳號模型與 authadmin 管理面參考起點。
  4. Phase 3(收尾):scaffold stamp 驗證(新 scaffold 對 {layer}/auth/ 打標);/prune-reference 域集推導涵蓋本域+「本地登入需求」提示;ADR-018 標頭補決策一、六取代備註;guides 對齊。

相關

  • ADR-014(capability↔core slice 對偶;決策一是其「slice 該有多薄」的補完)
  • ADR-022(admin 面降級——本 ADR 決策二的直接前例與姊妹)
  • ADR-023(引擎上收——本 ADR 的前置;決策三沿用其契約化手法)
  • ADR-018(決策一、六由本 ADR 取代;決策三〔email 重複容忍〕語意隨 signed-link 契約沿用)
  • 21-feature-surface.md 紀律四(「參考實作住業務層」——決策一是其一般化:連 seam 都收斂為組態接合)
  • m-reference-code.md@reference-surface 生命週期——決策四的分類依據)