revision=a24ece9cf797f7ea647e33bf846b115208ed1ba5 === JpaAdapterComponentsConfig package scan === 1 package dev.caskeleton.adapter.outbound.persistence.config; 2 3 import org.springframework.context.annotation.ComponentScan; 4 import org.springframework.context.annotation.Configuration; 5 6 /** 7 * Registers the JPA adapter's scanned components, which nothing registered (JPA-INT-006). 8 * 9 *

The composition root's {@code @ComponentScan} excludes {@code 10 * dev.caskeleton.adapter.outbound.persistence.**} by regex, and that exclusion is correct: it is 11 * what makes an optional capability optional, so a deployment with JPA off assembles no persistence 12 * beans rather than assembling them and hoping each one remembered to carry the switch. 13 * 14 *

What was missing is the other half. Eight classes in this leaf are written as scanned 15 * components — {@code SpringTransactionPort}, {@code PersistenceExceptionTranslator}, {@code 16 * StandardSqlStateErrorMapping}, {@code DomainContextAuditContextPort}, the idempotency store and 17 * its reaper, and the outbox store and its reaper — and once the broad scan stopped reaching them, 18 * nothing else did. They are annotated {@code @Component} and {@code @Repository} and were beans in 19 * no running application: {@code TransactionPort} in particular had no implementation at all, so 20 * every use case that opens a transaction had no port to open it with. 21 * 22 *

It surfaced as an unsatisfied dependency the first time a capability that needs a transaction 23 * was actually assembled — the notification orchestrator, in the local-notification-ingest lane — 24 * rather than as anything a unit test could see, because each of these classes is constructed 25 * directly by its own tests. 26 * 27 *

So the scan is restored, narrowed to the packages it should always have covered and reachable 28 * only through {@code PersistenceJpaRootAutoConfiguration}, which carries the JPA master switch. 29 * Off is still structural. 30 * 31 *

Two packages are deliberately absent: 32 * 33 *

39 * 40 *

Components under these packages keep their own {@code @ConditionalOnProperty} guards; being 41 * scanned makes them candidates, not unconditional beans. 42 * 43 *

{@code ..persistence.lock} is in the list for the same reason and with the same history. 44 * {@code DistributedLockPersistenceConfig} owns both lock providers — the in-process registry and 45 * the JDBC one — and was registered by nothing but a test calling {@code ctx.register(...)}. So a 46 * single-instance deployment had no {@code DistributedLockPort} at all, and a multi-instance one 47 * could not start: the composition root's own {@code DistributedLockConfig} asks for a bean 48 * qualified {@code jdbcDistributedLock} that only that configuration declares. 49 * 50 *

Each package's {@code @ConfigurationProperties} type is enabled by a configuration inside that 51 * same package — {@code JpaTransactionConfig} for {@code JpaTransactionSettings}, {@code 52 * DistributedLockPersistenceConfig} for {@code LockSettings} — rather than from here. Enabling them 53 * centrally would give {@code config} an edge to {@code lock} and {@code transaction} that the 54 * module map does not grant it, and the map is right: this class knows which packages to scan, not 55 * what is inside them. They need enabling at all because {@code @ConfigurationPropertiesScan} 56 * excludes this tree as deliberately as {@code @ComponentScan} does. 57 */ 58 @Configuration(proxyBeanMethods = false) 59 @ComponentScan( 60 basePackages = { 61 "dev.caskeleton.adapter.outbound.persistence.audit", 62 "dev.caskeleton.adapter.outbound.persistence.failure", 63 "dev.caskeleton.adapter.outbound.persistence.idempotency", 64 "dev.caskeleton.adapter.outbound.persistence.lock", 65 "dev.caskeleton.adapter.outbound.persistence.outbox", 66 "dev.caskeleton.adapter.outbound.persistence.transaction" 67 }) 68 public class JpaAdapterComponentsConfig {} === runtime-role policy production callers === src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/PostgreSqlRuntimeRoleVerifier.java:57: policy.requireSafe(verify(dataSource)); src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicy.java:19:public record DatabaseRolePolicy(Set allowedRoles, SearchPathPolicy searchPathPolicy) { src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicy.java:55: searchPathPolicy.requireSafe(report.searchPath()); === runtime-role policy all callers === src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlSecurityContractTest.java:89: new DatabaseRolePolicy( src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlSecurityContractTest.java:92: new PostgreSqlRuntimeRoleVerifier().requireSafe(runtime, policy); src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicyTest.java:15: new DatabaseRolePolicy( src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicyTest.java:21: policy.requireSafe(new DatabasePrivilegeReport("app_runtime", "app, pg_catalog", false, false)); src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicyTest.java:29: policy.requireSafe(new DatabasePrivilegeReport("app_runtime", "app", true, false))) src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicyTest.java:38: () -> policy.requireSafe(new DatabasePrivilegeReport("postgres", "app", false, false))) src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicyTest.java:48: policy.requireSafe( src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicyTest.java:57: policy.requireSafe(new DatabasePrivilegeReport("app_runtime", "\"$user\", app", false, false)); src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/PostgreSqlRuntimeRoleVerifier.java:57: policy.requireSafe(verify(dataSource)); src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicy.java:19:public record DatabaseRolePolicy(Set allowedRoles, SearchPathPolicy searchPathPolicy) { src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicy.java:55: searchPathPolicy.requireSafe(report.searchPath()); === runtime role advertised Stable + report semantics === src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformAutoConfiguration.java:61: CapabilitySupport.stable(JpaCapability.RUNTIME_ROLE_VERIFICATION), src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformAutoConfiguration.java:112: return roleVerifier.verify(dataSource); src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformReport.java:25: boolean runtimeRoleVerified, src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformReport.java:56: privileges != null && !privileges.holdsCreatePrivilege(), src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformReport.java:62: return openInViewDisabled && runtimeRoleVerified; src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java:194: public InitializingBean jpaPlatformStartupCheck( === startup contract docs === ## Startup verification `PostgreSqlRuntimeRoleVerifier` asks the *server* what the connection can do: ```sql select current_user, current_setting('search_path'), has_schema_privilege(current_user, current_schema(), 'CREATE'), has_database_privilege(current_user, current_database(), 'CREATE') ``` Configuration cannot answer this. Effective privileges come from direct grants, inherited role memberships, `PUBLIC` grants, and schema ownership, and no reading of a deployment manifest reconstructs that combination reliably. Startup fails when the runtime role is not on the allowlist, or holds `CREATE` on the schema or the database. ## search_path `SearchPathPolicy` is an allowlist. `search_path` decides which schema an unqualified name resolves to, so a writable untrusted schema on it — classically `public`, where `CREATE` was granted broadly before PostgreSQL 15 — lets a planted table, function, or operator shadow the real one, and the application executes it without noticing. `$user` is exempt: only the connected role owns it. Refusing the runtime role `CREATE` closes the same route from the other side. ### 41.2 search_path Application role의 `search_path`를 고정하고 untrusted schema의 object resolution을 차단한다. startup verifier가 current_user, current_schema, search_path, schema CREATE privilege를 검사한다. ### 41.3 Injection 방어 ```text JPQL/Native values → parameter binding Dynamic sort → allowlist Dynamic table/column → enum/catalog mapping만 Entity → API mass binding 금지 ``` ### 41.4 Secret DB password는 secret manager/workload identity에서 주입하고 config·log·metric에 기록하지 않는다. --- ## 42. Spring Boot AutoConfiguration ### 42.1 Properties ```yaml backend: jpa: enabled: true require-postgresql: true open-in-view: false schema-management: VALIDATE transaction-profiles: {} retry-profiles: {} observability: hibernate-statistics: true sql-parameters: false security: verify-runtime-role: true verify-search-path: true ``` ### 42.2 Startup Failures ```text spring.jpa.open-in-view=true prod ddl-auto != validate/none unsupported PostgreSQL version runtime role has DDL privilege migration checksum mismatch required transaction profile timeout missing Experimental module enabled without feature flag ``` === outbox composition path === src/app-bootstrap/src/main/resources/application.yml:662: # metrics, or store binding at all; relay-enabled below only decides whether the scheduler runs. src/app-bootstrap/src/main/resources/application.yml:666: relay-enabled: ${APP_OUTBOX_RELAY_ENABLED:false} src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxMetrics.java:5:import dev.caskeleton.application.outbox.OutboxStorePort; src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxMetrics.java:30: private final OutboxStorePort store; src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxMetrics.java:35: public OutboxMetrics(OutboxStorePort store, ObjectProvider meterRegistryProvider) { src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRelayBrokerRequirementValidator.java:10: *

{@code ca-skeleton.outbox.relay-enabled} defaults to true and {@code app.messaging.broker} src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRelayBrokerRequirementValidator.java:23: private static final String RELAY_KEY = "ca-skeleton.outbox.relay-enabled"; src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRelayScheduler.java:5:import dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase; src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRelayScheduler.java:19: private final PublishPendingOutboxEventsUseCase relayUseCase; src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRelayScheduler.java:24: PublishPendingOutboxEventsUseCase relayUseCase, OutboxMetrics metrics, Clock clock) { src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java:6:import dev.caskeleton.application.outbox.OutboxStorePort; src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java:7:import dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase; src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java:24: *

{@code ca-skeleton.outbox.enabled} is the capability switch; {@code relay-enabled} only src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java:52: name = "ca-skeleton.outbox.relay-enabled", src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java:56: OutboxStorePort store, src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java:64: PublishPendingOutboxEventsUseCase relayUseCase = src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java:65: new PublishPendingOutboxEventsUseCase( src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java:95: OutboxStorePort store, ObjectProvider meterRegistryProvider) { src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/CapabilityDependencyValidator.java:50: boolean relay = isOn(environment, "ca-skeleton.outbox.relay-enabled"); src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/CapabilityDependencyValidator.java:67: "ca-skeleton.outbox.relay-enabled=true only starts the scheduler for a capability that is " src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxStorePort.java:22: InboxTransitionOutcome markDead(InboxOwner owner, Duration retention, OperationId operationId); src/application-core/src/main/java/dev/caskeleton/application/fileserver/cleanup/DefaultCleanupService.java:87: markFailed(item, "BATCH_BYTE_BUDGET_EXHAUSTED", now); src/application-core/src/main/java/dev/caskeleton/application/fileserver/cleanup/DefaultCleanupService.java:123: markFailed(item, "CLEANUP_ATTEMPT_FAILED", now); src/application-core/src/main/java/dev/caskeleton/application/fileserver/cleanup/DefaultCleanupService.java:140: markFailed(item, "ACTIVE_WRITER_LEASE", now); src/application-core/src/main/java/dev/caskeleton/application/fileserver/cleanup/DefaultCleanupService.java:206: private void markFailed(CleanupItem item, String reasonCode, Instant now) { src/application-core/src/main/java/dev/caskeleton/application/fileserver/cleanup/DefaultCleanupService.java:209: queue.markFailed(item, reasonCode, now.plus(retryBackoff)); src/application-core/src/main/java/dev/caskeleton/application/fileserver/cleanup/CleanupQueue.java:21: void markFailed(CleanupItem item, String reasonCode, Instant nextAttemptAt); src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayResult.java:7: * Result returned by {@link PublishPendingOutboxEventsUseCase} after one relay cycle: the number of src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java:35:public class PublishPendingOutboxEventsUseCase src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java:38: private final OutboxStorePort store; src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java:60: public PublishPendingOutboxEventsUseCase( src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java:61: OutboxStorePort store, src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java:90: List claimed = tx.inWrite(() -> store.claimBatch(batchSize, now, inFlightTimeout)); src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java:141: tx.inWrite(() -> store.markPublished(event.eventId())); src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java:159: tx.inWrite(() -> store.markFailed(event.eventId(), nextAttemptAt)); src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java:175: tx.inWrite(() -> store.markDead(event.eventId())); src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxStorePort.java:14:public interface OutboxStorePort { src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxStorePort.java:27: List claimBatch(int batchSize, Instant now, Duration inFlightTimeout); src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxStorePort.java:35: void markPublished(String eventId); src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxStorePort.java:45: void markFailed(String eventId, Instant nextAttemptAt); src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxStorePort.java:54: void markDead(String eventId); src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEvent.java:7: * Immutable view of an outbox event row returned by {@link OutboxStorePort#claimBatch}. src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxPollingDeliveryPortV2.java:9: List claimBatch(OutboxDeliveryClaimRequest request); src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxPollingDeliveryPortV2.java:11: OutboxDeliveryTransitionOutcome markPublished(OutboxDeliveryTransition transition); src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxPollingDeliveryPortV2.java:16: OutboxDeliveryTransitionOutcome markDead(OutboxDeliveryTransition transition, String errorCode); src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyStorePortV2.java:28: IdempotencyFailOutcome markFailed( src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2.java:217: store.markFailed( src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2.java:282: store.markFailed( src/application-core/src/main/java/dev/caskeleton/application/notification/platform/callback/ProviderEventLedger.java:26: void markFailed(ProviderEventRecordId eventId, String errorCode); src/application-core/src/main/java/dev/caskeleton/application/notification/platform/callback/ProviderEventProjectionService.java:58: ledger.markFailed(event.id(), "NO_PROJECTOR"); src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaCleanupQueue.java:145: public void markFailed(CleanupItem item, String reasonCode, Instant nextAttemptAt) { src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java:8:import dev.caskeleton.application.outbox.OutboxStorePort; src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java:18: * JPA-backed {@link OutboxAppendPort} + {@link OutboxStorePort}. {@link #append} and the claim/mark src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java:24:public class OutboxStoreAdapter implements OutboxAppendPort, OutboxStorePort { src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java:29: public OutboxStoreAdapter( src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java:53: public List claimBatch(int batchSize, Instant now, Duration inFlightTimeout) { src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java:72: public void markPublished(String eventId) { src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java:84: public void markFailed(String eventId, Instant nextAttemptAt) { src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java:99: public void markDead(String eventId) { src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/inbox/PostgreSqlSameStoreInboxAdapter.java:350: public InboxTransitionOutcome markDead( src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlPollingDeliveryAdapter.java:297: public List claimBatch(OutboxDeliveryClaimRequest request) { src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlPollingDeliveryAdapter.java:313: public OutboxDeliveryTransitionOutcome markPublished(OutboxDeliveryTransition transition) { src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlPollingDeliveryAdapter.java:366: public OutboxDeliveryTransitionOutcome markDead( src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/PostgreSqlOwnerSafeIdempotencyStore.java:294: public IdempotencyFailOutcome markFailed( src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/RecipientDeliveryJpaRepository.java:36: List claimBatch( src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/JpaRecipientLeaseStore.java:47: return recipients.claimBatch(now, limit, workerId, leaseUntil).stream() src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/JpaProviderEventLedger.java:146: public void markFailed(ProviderEventRecordId eventId, String errorCode) { === durable/live adapter construction references === src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/JpaLiveEventReplayAdapter.java:54: public JpaLiveEventReplayAdapter( src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationStoreAdapter.java:39: public DurableOperationStoreAdapter(DurableOperationJpaRepository repository) {