revision=a24ece9cf797f7ea647e33bf846b115208ed1ba5 plan: 7:**Architecture:** Experimental module은 Stable `jpa-core-api` 계약만 소비하며 Stable starter에 자동 포함되지 않는다. 각 기능은 명시적 feature flag와 별도 compatibility/failure suite를 요구한다. 실험 결과가 Stable 의미론과 충돌하면 Core를 왜곡하지 않고 capability 또는 별도 profile로 유지한다. 16:- 모든 기능은 `backend.jpa.experimental.*` feature flag를 요구한다. 56:- Consumes: Stable `jpa-core-api` and explicit environment feature flags. production gate calls: src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeatureGate.java:22: public void requireEnabled(ExperimentalFeature feature, Map flags) { src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsTenantSessionBinder.java:51: .requireEnabled(ExperimentalFeature.MULTITENANCY_RLS, flags); src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantMigrationOrchestrator.java:60: .requireEnabled(ExperimentalFeature.MULTITENANCY_SCHEMA, flags); src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ConsistencyAwareDataSourceRouter.java:52: .requireEnabled(ExperimentalFeature.READ_REPLICA, flags); public constructors / direct entry points: src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantEntityManagerFactoryRegistry.java:28: public TenantEntityManagerFactoryRegistry( src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantDataSourceRegistry.java:23:public final class TenantDataSourceRegistry implements AutoCloseable { src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantDataSourceRegistry.java:34: public TenantDataSourceRegistry( src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaMultiTenantConnectionProvider.java:22:public final class SchemaMultiTenantConnectionProvider { src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaMultiTenantConnectionProvider.java:30: public SchemaMultiTenantConnectionProvider(DataSource dataSource, SchemaTenantRegistry registry) { src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantEntityListenerGuard.java:23: public TenantEntityListenerGuard( src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantAwareRepositoryGuard.java:17:public final class TenantAwareRepositoryGuard { default-runtime refs outside experimental: TenantDataSourceRegistry=3 TenantEntityManagerFactoryRegistry=1 SchemaMultiTenantConnectionProvider=2 TenantAwareRepositoryGuard=2 TenantEntityListenerGuard=1 entry-consent allowlist: 27- * that difference because they always ask. 28- */ 29-class ExperimentalEntryConsentTest { 30- 31- /** The behaviour-bearing entry points; the surrounding value types are not activations. */ 32: private static final List> ENTRY_POINTS = 33- List.of( 34- ConsistencyAwareDataSourceRouter.class, 35- RlsTenantSessionBinder.class, 36- SchemaTenantMigrationOrchestrator.class); 37- 38- private final ExperimentalFeatureGate gate = new ExperimentalFeatureGate(); 39- 40- @Test 41- @DisplayName("no experimental entry point can be constructed from outside its package") 42- void noEntryPointCanBeConstructedFromOutsideItsPackage() { 43: for (Class entryPoint : ENTRY_POINTS) { 44- for (Constructor constructor : entryPoint.getDeclaredConstructors()) { 45- assertThat(Modifier.isPublic(constructor.getModifiers())) 46- .as( 47- "%s has a public constructor, so one `new` bypasses the flag entirely", 48- entryPoint.getSimpleName()) 49- .isFalse(); 50- } 51- } 52- } 53- 54- @Test 55- @DisplayName("every experimental entry point offers a gate-taking factory and nothing else") 56- void everyEntryPointOffersAGateTakingFactory() { 57: for (Class entryPoint : ENTRY_POINTS) { 58- assertThat(entryPoint.getDeclaredMethods()) 59- .as("%s must be obtainable, and only through the gate", entryPoint.getSimpleName()) 60- .anySatisfy( 61- method -> { 62- assertThat(method.getName()).isEqualTo("enabledBy"); 63- assertThat(method.getReturnType()).isEqualTo(entryPoint); 64- assertThat(method.getParameterTypes()[0]).isEqualTo(ExperimentalFeatureGate.class); 65- }); 66- } 67- } 68- 69- @Test 70- @DisplayName("the RLS binder refuses to exist when its flag is absent") 71- void theRlsBinderRefusesToExistWithoutItsFlag() { 72- assertThatThrownBy(() -> RlsTenantSessionBinder.enabledBy(gate, Map.of())) 73- .isInstanceOf(IllegalStateException.class) 74- .hasMessageContaining(ExperimentalFeature.MULTITENANCY_RLS.property()); 75- } 76- 77- @Test