From 0137263441f6cf1345074cfa7c29313926273c32 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Mon, 24 Aug 2026 18:26:40 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EA=B0=81=20=EC=96=B4=EB=8C=91?= =?UTF-8?q?=ED=84=B0=ED=84=B0=EB=B3=84=20=EB=A6=AC=ED=8E=99=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ci-gate-matrix.yml | 102 + .github/scripts/verify-gate-matrix.sh | 95 +- .github/scripts/verify-gradle-wrapper.sh | 5 +- .github/workflows/jpa-nightly.yml | 10 +- .github/workflows/jpa-release.yml | 9 +- .github/workflows/messaging-certification.yml | 59 + CLAUDE.md | 11 + docker-compose.infra.yml | 31 + ...1-graphql-context-and-storage-ownership.md | 70 + .../ADR-JPA-006-canonical-technical-audit.md | 67 + docs/architecture/graphql-api-surface.txt | 5 +- docs/architecture/jpa-api-surface.txt | 340 ++ docs/architecture/mongo-api-surface.txt | 9 +- docs/jpa/repository-adaptation.md | 2 +- docs/jpa/support-matrix.md | 38 +- docs/messaging/configuration-reference.md | 235 +- docs/messaging/support-matrix.md | 24 +- docs/notification/api-surface-snapshot.txt | 17 + docs/notification/configuration-reference.md | 70 + docs/registries/env-keys.yaml | 20 +- .../2026-08-19-p1-remediation-status.md | 101 + docs/runbooks/outbox-publish-failed.md | 4 +- docs/study/postgresql-jpa-walkthrough.ko.md | 3619 +++++++++++++++++ .../task1-architecture-gates.log | 41 + .../2026-08-15-wave6-final/task1-check.log | 901 ++++ .../2026-08-15-wave6-final/task1-compile.log | 265 ++ .../2026-08-15-wave6-final/task1-test.log | 378 ++ .../task1-wave0-red-set.md | 54 + .../task1-wave0RedReport.log | 28 + .../task2-b5-ghost-release-lanes.md | 59 + .../task2-focused-module-tests.log | 181 + ...2-graphql-runtime-qualification-NOT-RUN.md | 61 + .../task2-graphql-runtime-qualification.log | 31 + .../task2-graphql-stable-and-transport.log | 162 + ...ask2-jpa-release-gate-first-run-failed.log | 173 + .../task2-jpa-release-gate-pg16.log | 49 + .../task2-jpa-release-gate-pg17.log | 48 + .../task2-jpa-release-gate-pg18.log | 48 + .../task2-jpa-release-gate.log | 138 + .../task2-messaging-certification.log | 61 + .../task2-messaging-live-broker.log | 71 + .../task2-mongo-compatibility.log | 27 + .../task2-mongo-failover.log | 27 + .../task2-mongo-lanes-first-run-failed.log | 43 + .../task2-mongo-lanes.log | 33 + .../task2-mongo-migration.log | 27 + .../task2-mongo-performance.log | 27 + .../task2-mongo-replicaset.log | 27 + .../task2-mongo-security.log | 27 + .../task2-release-manifest-arbiter.log | 164 + .../task3-activation-matrix.md | 104 + .../task3-matrix-to-lane-map.md | 26 + .../task3-row8-dependency-error.log | 17 + .../task6-p0-reconciliation.md | 165 + infra/graphql/smoke/graphql-smoke.sh | 75 + src/.env.example | 2 + src/Dockerfile | 5 + src/README.md | 68 +- src/adapter/inbound/graphql/CLAUDE.md | 13 +- src/adapter/inbound/graphql/build.gradle | 108 +- .../GraphQlSubscriptionCancellation.java | 40 +- .../GraphQlPlatformAutoConfiguration.java | 91 + .../GraphQlRootAutoConfiguration.java | 11 +- .../compat/GraphQlSchemaComparator.java | 215 +- .../inbound/graphql/context/ActorRef.java | 21 +- .../context/GraphQlIdentityFingerprinter.java | 150 + .../graphql/context/TenantContext.java | 10 +- .../dataloader/GraphQlBatchExecutor.java | 28 +- .../execution/GraphQlCancellation.java | 21 +- .../moduleboundary/GraphQlAdvancedModule.java | 1 + .../moduleboundary/GraphQlStableModule.java | 1 + .../GraphQlMutationIdempotencyContext.java | 12 +- ...GraphQlMutationIdempotencyInterceptor.java | 13 +- .../GraphQlOperationNameCardinality.java | 20 + .../GraphQlRequestObservationConvention.java | 61 +- .../runtime/GraphQlBatchLoaderRegistrar.java | 29 +- .../runtime/GraphQlExecutionContext.java | 10 + .../GraphQlPlatformInstrumentation.java | 3 + ...QlRequestObservationConventionAdapter.java | 176 + .../ApplicationObjectAuthorization.java | 84 + .../graphql/security/GraphQlBatchContext.java | 7 +- .../GraphQlObjectAuthorizationPort.java | 14 +- .../GraphQlBatchLoaderWiringTest.java | 54 + .../GraphQlObservationWiringTest.java | 62 + .../compat/GraphQlSchemaComparatorTest.java | 63 + .../GraphQlIdentityFingerprinterTest.java | 135 + .../context/GraphQlRequestContextTest.java | 6 +- ...GraphQlMutationIdempotencyContextTest.java | 84 +- .../GraphQlBatchLoaderRegistrationTest.java | 431 ++ ...questObservationConventionAdapterTest.java | 172 + .../ApplicationObjectAuthorizationTest.java | 156 + .../GraphQlCancellationAggregationTest.java | 56 + src/adapter/inbound/grpc/build.gradle | 1 - src/adapter/inbound/web/build.gradle | 53 +- .../CallbackRequestConfiguration.java | 65 + .../CallbackRequestConfigurationTest.java | 70 + ...NotificationCallbackMvcControllerTest.java | 15 + src/adapter/inbound/websocket/build.gradle | 1 - src/adapter/outbound/cache-redis/build.gradle | 2 +- src/adapter/outbound/httpclient/build.gradle | 146 +- src/adapter/outbound/messaging/build.gradle | 1 - .../outbound/notification/build.gradle | 19 +- .../NotificationPlatformSettings.java | 29 +- .../NotificationSecretRequirements.java | 98 + .../platform/provider/EmailAttachments.java | 74 + .../ses/SesNotificationProviderAdapter.java | 61 +- .../provider/ses/SesProviderProperties.java | 6 +- .../provider/ses/SesRequestMapper.java | 151 +- .../provider/smtp/SmtpMimeMessageFactory.java | 36 +- .../smtp/SmtpNotificationProviderAdapter.java | 57 +- .../twilio/TwilioCallbackAdapter.java | 22 +- .../twilio/TwilioProviderProperties.java | 11 + .../TwilioReconciliationCapability.java | 25 +- .../twilio/TwilioSmsProviderAdapter.java | 15 +- .../WebhookNotificationProviderAdapter.java | 51 +- .../provider/webhook/WebhookSubscription.java | 26 +- .../AesGcmCallbackPayloadProtection.java | 70 +- .../AesGcmNotificationPayloadProtection.java | 10 + .../security/ProviderCredentialManager.java | 111 +- .../template/NotificationTemplateEngine.java | 12 +- .../template/PlaceholderTemplateEngine.java | 77 +- .../platform/template/TemplateSlotPolicy.java | 129 + .../ThymeleafStringTemplateEngine.java | 72 +- .../NotificationSecretRequirementsTest.java | 173 + .../dispatch/LeaseRecoveryServiceTest.java | 19 + .../http/EndpointGuardCallSiteTest.java | 126 + .../SesNotificationProviderAdapterTest.java | 278 +- .../provider/smtp/SmtpAttachmentBodyTest.java | 121 + .../TwilioCallbackAndProjectionTest.java | 42 + .../twilio/TwilioCallbackContractTest.java | 1 + .../twilio/TwilioSmsProviderAdapterTest.java | 3 +- ...ebhookNotificationProviderAdapterTest.java | 205 +- .../security/CallbackPayloadBoundTest.java | 13 +- .../security/CallbackPayloadRotationTest.java | 99 + .../security/CredentialDrainWindowTest.java | 171 + .../platform/security/SecurityFixtures.java | 49 +- .../BothEnginesHonourSlotModeTest.java | 100 + .../platform/testkit/ContractAdapters.java | 19 +- .../outbound/objectstorage/build.gradle | 33 +- .../outbound/persistence-jpa/build.gradle | 116 +- .../pool/PoolPressureContractTest.java | 18 +- .../rls/RlsTenantSessionBinder.java | 23 + .../fileserver/FileEntityMapper.java | 1 + .../fileserver/JpaCleanupQueue.java | 17 + .../fileserver/JpaUploadSessionStore.java | 14 + .../fileserver/entity/CleanupItemEntity.java | 10 + .../entity/UploadSessionEntity.java | 24 + .../FileserverCleanupRepository.java | 32 + .../repository/UploadLeaseRepository.java | 59 + .../DeduplicationClaimJpaRepository.java | 38 + .../platform/JpaDeliveryAttemptStore.java | 14 + .../platform/JpaPolicyStores.java | 60 +- .../platform/JpaProviderEventLedger.java | 14 + .../platform/JpaRecipientDeliveryStore.java | 64 + .../RecipientDeliveryJpaRepository.java | 59 + .../PostgreSqlPersistenceConfig.java | 30 + .../IdempotencyCapabilityGuard.java | 43 +- .../idempotency/IdempotencyClaimGateway.java | 175 + .../idempotency/IdempotencyRecordRow.java | 36 + .../idempotency/IdempotencyRowMapper.java | 114 + .../IdempotencyTransitionGateway.java | 222 + .../PostgreSqlOwnerSafeIdempotencyStore.java | 598 +-- .../V4__fileserver_upload_terminal_state.sql | 49 + .../PostgreSqlIdempotencyIntegrationTest.java | 109 + ...lNotificationDedupRaceIntegrationTest.java | 124 + .../PostgreSqlOptionalStreamLifecycle.java | 21 +- ...lRecipientLeaseFencingIntegrationTest.java | 70 + .../auditing/AuditingCandidateStatusTest.java | 85 + .../ExperimentalEntryConsentTest.java | 96 + .../platform/PoolLaneClaimTest.java | 105 + ...ndorFailureTranslatorRegistrationTest.java | 130 + .../OwnerSafeIdempotencyPreconditionTest.java | 119 + .../testkit/JpaAuditMechanismRuleTest.java | 69 + .../testkit/JpaReleaseRenderingTest.java | 229 ++ .../testkit/arch/JpaAuditMechanismRule.java | 156 +- .../testkit/release/JpaReleaseRendering.java | 144 + .../outbound/persistence-mongo/CLAUDE.md | 32 +- .../outbound/persistence-mongo/README.md | 26 +- .../outbound/persistence-mongo/build.gradle | 312 +- .../advanced/MongoAdvancedPromotionGate.java | 4 + .../MongoPlatformAutoConfiguration.java | 190 +- .../MongoPlatformHealthIndicator.java | 41 +- .../autoconfigure/MongoPlatformSettings.java | 28 +- .../MongoStableReleaseEvidence.java | 71 - .../autoconfigure/MongoStableReleaseGate.java | 46 - .../autoconfigure/MongoTopologyProbe.java | 30 +- .../changestream/MongoResumeTokenCodec.java | 38 + .../consumer/MongoChangeStreamSource.java | 54 + .../ReactiveMongoChangeStreamConsumer.java | 221 + .../SpringReactiveChangeStreamSource.java | 71 + .../DefaultMongoFailureTranslator.java | 32 +- .../imperative/BoundScopedOperations.java | 48 +- .../DefaultMongoImperativeExecutor.java | 13 +- .../MongoTemplateSupportContract.java | 22 + .../atomic/MongoAtomicOperationsTemplate.java | 22 +- .../atomic/MongoAtomicPolicyRegistry.java | 107 + .../imperative/bulk/MongoBulkExecutor.java | 41 +- .../mapping/MongoMappingConfiguration.java | 38 +- .../MongoCollectionMigrationLedger.java | 49 +- .../mongo/query/MongoRegexPolicy.java | 54 +- .../query/PolicyAwareMongoQueryBuilder.java | 36 +- .../ReactiveMongoConsistencyBinder.java | 30 +- .../transaction/retry/MongoRetryDecision.java | 17 + .../MongoTransactionRetryCoordinator.java | 38 +- .../architecture/MongoModuleBoundaryTest.java | 26 +- ...oPlatformHealthMeasuresTheClusterTest.java | 98 + .../MongoPlatformPropertiesTest.java | 7 +- .../MongoPlatformWiringTest.java | 15 + .../MongoReactiveWiringTest.java | 154 + .../MongoStableReleaseGateTest.java | 72 - .../MongoStartupValidationTest.java | 2 +- .../MongoStartupValidatorTest.java | 28 +- .../ReactiveWiringCarriesTheContextTest.java | 72 + .../autoconfigure/RotationOrderingTest.java | 106 + .../ChangeStreamConsumerLifecycleTest.java | 332 ++ .../MongoClientSettingsFactoryTest.java | 5 +- ...TranslatorCarriesOperationContextTest.java | 70 + .../ScopedOperationDeadlineTest.java | 85 + .../bulk/BulkSharesTheAtomicPolicyTest.java | 101 + .../MongoMappingConfigurationTest.java | 34 + .../migration/MongoMigrationLaneTest.java | 52 + .../query/LiteralSearchIsExpressibleTest.java | 88 + ...ctiveBinderCarriesRuntimeContractTest.java | 79 + .../MongoSecurityIntegrationLaneTest.java | 33 +- .../mongo/security/MongoTlsLaneTest.java | 164 + .../retry/CleanupFailureIsReportedTest.java | 156 + ...MongoAuthenticatedReplicaSetContainer.java | 37 +- .../rs/MongoTlsReplicaSetContainer.java | 203 + src/app-bootstrap/build.gradle | 21 - src/app-bootstrap/gradle.lockfile | 4 +- ...tionalTransportEvidenceFunctionalTest.java | 24 +- .../RuntimeMembershipFunctionalTest.java | 41 +- ...ificationTestConventionFunctionalTest.java | 44 +- ...abilityDependencyEnvironmentValidator.java | 62 + .../JpaPlatformRuntimeAutoConfiguration.java | 23 +- .../jpa/JpaTransactionAutoConfiguration.java | 25 +- .../IdempotencyProviderSelectionConfig.java | 9 +- .../IdempotencyProviderSettings.java | 12 +- .../PostgreSqlIdempotencyProviderConfig.java | 80 + .../mongo/MongoPlatformHealthConfig.java | 37 + .../mongo/MongoPlatformHealthContributor.java | 47 + .../NotificationPlatformCodecConfig.java | 18 +- .../NotificationPlatformSecretsConfig.java | 251 +- .../main/resources/META-INF/spring.factories | 1 + .../src/main/resources/application.yml | 19 +- .../DependencyErrorStartupContractTest.java | 142 + .../PortResolutionContractTest.java | 160 + .../architecture/CleanArchitectureTest.java | 34 + .../GraphQlInboundOwnershipBoundaryTest.java | 159 + .../JpaProductionArchitectureTest.java | 14 + .../MongoRawAccessBoundaryTest.java | 116 + ...aPlatformRuntimeAutoConfigurationTest.java | 85 + ...nalTransportQualificationContractTest.java | 36 +- .../DeveloperExperienceContractTest.java | 6 + .../GraphQlResolverReachabilityTest.java | 90 + .../MessagingPublicSurfaceContractTest.java | 350 ++ .../IdempotencyProviderCompositionTest.java | 173 + .../MongoPlatformHealthContributorTest.java | 29 + ...NotificationPayloadAtRestContractTest.java | 15 +- .../NotificationSecretsValidationTest.java | 280 +- .../MongoRegistryPermissionParityTest.java | 130 + src/application-core/build.gradle | 1 - .../api/metadata/UploadSession.java | 5 + .../api/metadata/UploadSessionStore.java | 23 + .../fileserver/cleanup/CleanupQueue.java | 13 + .../cleanup/DefaultCleanupService.java | 17 +- .../upload/DefaultFinalizeUploadService.java | 4 + .../DefaultUploadApplicationService.java | 4 + .../idempotency/IdempotencyClaimAttempt.java | 15 - .../idempotency/IdempotencyClaimOutcome.java | 85 - .../idempotency/IdempotencyClaimRequest.java | 60 - .../IdempotencyCompleteOutcome.java | 55 - .../idempotency/IdempotencyExecutorV2.java | 230 -- .../idempotency/IdempotencyFailOutcome.java | 51 - .../IdempotencyFailureDisposition.java | 7 - .../idempotency/IdempotencyInspection.java | 69 - .../IdempotencyInspectionRequest.java | 19 - .../idempotency/IdempotencyOwner.java | 18 - .../IdempotencyReleaseOutcome.java | 54 - .../idempotency/IdempotencyRenewOutcome.java | 50 - .../idempotency/IdempotencyStartOutcome.java | 50 - .../idempotency/IdempotencyStorePortV2.java | 35 - .../idempotency/IdempotencyV2Validation.java | 50 - .../callback/DeliveryAttemptResolverPort.java | 14 + .../callback/ProviderEventLedger.java | 18 + .../ProviderEventProjectionService.java | 33 +- .../callback/ProviderEventRecord.java | 12 +- .../callback/ProviderRequestIdHash.java | 38 + .../dispatch/NotificationDispatchService.java | 36 +- .../dispatch/RecipientDeliveryStorePort.java | 32 + .../platform/security/SecretPurpose.java | 9 +- .../application/outbox/NewOutboxEvent.java | 8 + .../OutboxPublishAmbiguousException.java | 24 + .../outbox/OutboxPublishRefusedException.java | 43 + .../PublishPendingOutboxEventsUseCase.java | 43 +- .../security/ObjectAccessDecision.java | 43 + .../security/ObjectAccessPolicy.java | 54 + .../security/ObjectAccessRequest.java | 39 + .../metadata/MetadataPortContractTest.java | 1 + .../cleanup/CleanupServiceTest.java | 116 + .../testkit/FileserverFixtures.java | 1 + .../testkit/InMemoryCleanupQueue.java | 23 +- .../testkit/InMemoryUploadSessionStore.java | 54 +- .../upload/UploadApplicationServiceTest.java | 28 + .../IdempotencyExecutorV2Test.java | 286 -- .../IdempotencyV2ContractSingularityTest.java | 72 + .../IdempotencyV2ContractTest.java | 162 - .../platform/callback/CallbackFixtures.java | 17 +- .../CallbackIngestionAtomicityTest.java | 5 + .../callback/ProviderEventLateMatchTest.java | 292 ++ .../platform/dispatch/PlatformFakes.java | 29 + .../outbox/OutcomeAwareRelayTest.java | 273 ++ .../security/ObjectAccessPolicyTest.java | 93 + src/build-logic/README.md | 71 + src/build-logic/build.gradle | 18 + src/build-logic/settings.gradle | 13 + .../src/main/groovy/ca.api-surface.gradle | 187 + .../ca.architecture-registry.settings.gradle | 43 + .../main/groovy/ca.dependency-policy.gradle | 119 + .../src/main/groovy/ca.evidence.gradle | 69 + .../main/groovy/ca.runtime-membership.gradle | 106 + .../groovy/ca.strict-qualification.gradle} | 67 +- .../main/groovy/ca.strict-test-lane.gradle | 351 ++ .../main/groovy/ca.testkit-publisher.gradle | 77 + .../buildlogic/JUnitEvidence.groovy | 147 + .../buildlogic/ModuleRegistry.groovy | 231 ++ .../groovy/ApiSurfaceConventionTest.groovy | 125 + .../src/test/groovy/JUnitEvidenceTest.groovy | 122 + .../src/test/groovy/ModuleRegistryTest.groovy | 157 + .../StrictTestLaneConventionTest.groovy | 347 ++ src/build.gradle | 252 +- src/config/architecture/modules.json | 6 +- src/config/mongodb/release-contracts.json | 2 +- .../runtime/compose-profile-contracts.json | 7 +- src/gradle/jpa-evidence.gradle | 41 +- src/gradle/junit-evidence.gradle | 101 - src/gradle/notification-api-surface.gradle | 20 + src/gradle/notification-configuration.gradle | 12 + src/gradle/runtime-membership.gradle | 157 - src/messaging/CLAUDE.md | 63 +- .../messaging/api/CorrelationId.java | 15 +- .../messaging/api/MessageEnvelope.java | 35 + .../caskeleton/messaging/api/MessageType.java | 33 +- .../caskeleton/messaging/api/ProducerId.java | 33 +- .../messaging/api/TraceContext.java | 111 + .../messaging/api/WireSafeText.java | 65 + .../api/header/CanonicalEnvelopeHeaders.java | 79 + .../messaging/api/header/HeaderValue.java | 15 +- .../messaging/api/header/MessageHeaders.java | 49 +- .../messaging/api/header/ReservedHeaders.java | 27 +- .../messaging/api/publish/PublishOptions.java | 30 +- .../messaging/api/publish/PublishResult.java | 34 +- .../api/WireBoundaryRejectionTest.java | 208 + .../api/publish/PublishResultTest.java | 86 +- src/messaging/messaging-kafka/build.gradle | 97 + .../messaging/kafka/KafkaDeliveryMapper.java | 6 +- .../messaging/kafka/KafkaHeaderMapper.java | 57 +- .../kafka/KafkaBrokerCertificationIT.java | 463 +++ .../kafka/KafkaEnvelopeRoundTripTest.java | 154 + .../kafka/ReservedHeaderForgeryTest.java | 2 +- .../nats/NatsAdapterContractTest.java | 3 +- .../messaging-observability/build.gradle | 19 - .../outbox/DebeziumOutboxEventRouter.java | 81 +- .../outbox/DebeziumOutboxRecordMapper.java | 30 +- .../outbox/JdbcOutboxRepository.java | 88 +- .../outbox/OutboxEnvelopeFactory.java | 75 +- .../messaging/outbox/OutboxRelay.java | 56 +- .../messaging/outbox/OutboxRelayReport.java | 22 +- .../messaging/outbox/OutboxRelayWorker.java | 199 + .../outbox/OutboxRetryScheduler.java | 29 +- ...4__messaging_outbox_canonical_metadata.sql | 66 + .../DebeziumOutboxRecordMapperTest.java | 67 +- .../JdbcOutboxTransactionRequirementTest.java | 1 + .../outbox/OutboxEnvelopeFactoryTest.java | 158 + .../messaging/outbox/OutboxPostgresIT.java | 159 +- .../messaging/outbox/OutboxRelayTest.java | 60 + .../outbox/OutboxRelayWorkerTest.java | 163 + .../policy/MessagingAdmissionController.java | 105 +- .../MessagingAdmissionControllerTest.java | 83 +- .../pulsar/PulsarAdapterContractTest.java | 3 +- .../rabbit/RabbitDeliveryMapper.java | 6 +- .../messaging/rabbit/RabbitHeaderMapper.java | 49 +- .../rabbit/RabbitEnvelopeRoundTripTest.java | 152 + .../reliability/OutboxCanonicalMetadata.java | 158 + .../messaging/reliability/OutboxRecord.java | 11 +- .../runtime/DefaultMessagePublisher.java | 103 +- .../runtime/DefaultMessagePublisherTest.java | 100 +- .../messaging-schema-avro/build.gradle | 5 +- .../schema/avro/AvroMessageCodec.java | 42 +- .../schema/avro/AvroHostileInputTest.java | 134 + .../messaging-schema-protobuf/build.gradle | 4 +- .../protobuf/ProtobufCompatibilityTest.java | 14 + .../build.gradle | 11 +- .../gradle.lockfile | 1 + .../autoconfigure/BrokerSecuritySettings.java | 50 + .../autoconfigure/BrokerSettings.java | 69 + .../CompiledMessagingConfiguration.java | 53 + .../autoconfigure/DestinationSettings.java | 177 + .../KafkaMessagingAutoConfiguration.java | 23 + .../MessagingConfigurationCompiler.java | 331 ++ .../MessagingConfigurationKeyValidator.java | 227 ++ .../MessagingCoreAutoConfiguration.java | 118 +- ...ssagingCredentialRequirementValidator.java | 21 +- .../autoconfigure/MessagingEndpoint.java | 18 +- .../MessagingOutboxRelayLifecycle.java | 69 + .../MessagingProviderSelection.java | 42 + ...MessagingReliabilityAutoConfiguration.java | 72 + .../autoconfigure/MessagingSettings.java | 70 + .../MessagingShutdownLifecycle.java | 124 + .../RabbitMessagingAutoConfiguration.java | 24 + .../StartupProfileValidation.java | 46 + .../autoconfigure/BlockingFacadeTest.java | 3 +- .../MessagingAutoConfigurationTest.java | 57 +- .../MessagingConfigurationBindingTest.java | 320 ++ .../autoconfigure/MessagingEndpointTest.java | 33 +- ...ssagingLiveRoundTripQualificationTest.java | 6 +- .../MessagingOutboxRelayLifecycleTest.java | 203 + .../MessagingShutdownLifecycleTest.java | 78 + .../MessagingStarterOffContractTest.java | 35 +- .../testkit/BrokerCertificationEvidence.java | 128 +- .../testkit/BrokerFailureMatrix.java | 71 +- .../messaging/testkit/CertifiedEvidence.java | 84 + .../testkit/CompatibilityMatrix.java | 43 +- .../testkit/NetworkFaultScenario.java | 24 + .../broker-certification-evidence.jsonl | 4 + .../messaging/testkit/CertifiedEvidence.java | 72 - .../testkit/CertifiedEvidenceTest.java | 127 + .../testkit/CompatibilityMatrixTest.java | 32 +- .../testkit/CrossBrokerContractSuite.java | 35 +- .../transport/BackpressureController.java | 125 - .../DefaultMessagingRuntimeRegistry.java | 56 +- .../BackpressureAndShutdownTest.java | 148 - .../transport/GracefulShutdownTest.java | 75 + .../MessagingRuntimeRegistryTest.java | 66 + .../transport/ResourceLeakGateTest.java | 28 - src/sample-portfolio/build.gradle | 15 +- ...sterImageV8MigrationQualificationTest.java | 11 +- src/settings.gradle | 189 +- src/shared-contract/build.gradle | 36 +- 439 files changed, 31935 insertions(+), 4719 deletions(-) create mode 100644 .github/workflows/messaging-certification.yml create mode 100644 docs/adr/ADR-GQL-001-graphql-context-and-storage-ownership.md create mode 100644 docs/adr/ADR-JPA-006-canonical-technical-audit.md create mode 100644 docs/architecture/jpa-api-surface.txt create mode 100644 docs/reviews/2026-08-19-p1-remediation-status.md create mode 100644 docs/study/postgresql-jpa-walkthrough.ko.md create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-architecture-gates.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-check.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-compile.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-test.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-wave0-red-set.md create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-wave0RedReport.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-b5-ghost-release-lanes.md create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-focused-module-tests.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-graphql-runtime-qualification-NOT-RUN.md create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-graphql-runtime-qualification.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-graphql-stable-and-transport.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-first-run-failed.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-pg16.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-pg17.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-pg18.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-messaging-certification.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-messaging-live-broker.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-compatibility.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-failover.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-lanes-first-run-failed.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-lanes.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-migration.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-performance.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-replicaset.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-security.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-release-manifest-arbiter.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task3-activation-matrix.md create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task3-matrix-to-lane-map.md create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task3-row8-dependency-error.log create mode 100644 docs/superpowers/plans/evidence/2026-08-15-wave6-final/task6-p0-reconciliation.md create mode 100755 infra/graphql/smoke/graphql-smoke.sh create mode 100644 src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlIdentityFingerprinter.java create mode 100644 src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlRequestObservationConventionAdapter.java create mode 100644 src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/ApplicationObjectAuthorization.java create mode 100644 src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlBatchLoaderWiringTest.java create mode 100644 src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlObservationWiringTest.java create mode 100644 src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlIdentityFingerprinterTest.java create mode 100644 src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBatchLoaderRegistrationTest.java create mode 100644 src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlRequestObservationConventionAdapterTest.java create mode 100644 src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/ApplicationObjectAuthorizationTest.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestConfiguration.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestConfigurationTest.java create mode 100644 src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationSecretRequirements.java create mode 100644 src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/EmailAttachments.java create mode 100644 src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/TemplateSlotPolicy.java create mode 100644 src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationSecretRequirementsTest.java create mode 100644 src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/EndpointGuardCallSiteTest.java create mode 100644 src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpAttachmentBodyTest.java create mode 100644 src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/CallbackPayloadRotationTest.java create mode 100644 src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/CredentialDrainWindowTest.java create mode 100644 src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/BothEnginesHonourSlotModeTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/IdempotencyClaimGateway.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/IdempotencyRecordRow.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/IdempotencyRowMapper.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/IdempotencyTransitionGateway.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/fileserver/V4__fileserver_upload_terminal_state.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlNotificationDedupRaceIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/auditing/AuditingCandidateStatusTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalEntryConsentTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/platform/PoolLaneClaimTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlVendorFailureTranslatorRegistrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/OwnerSafeIdempotencyPreconditionTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaAuditMechanismRuleTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaReleaseRenderingTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseRendering.java delete mode 100644 src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStableReleaseEvidence.java delete mode 100644 src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStableReleaseGate.java create mode 100644 src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeTokenCodec.java create mode 100644 src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/consumer/MongoChangeStreamSource.java create mode 100644 src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/consumer/ReactiveMongoChangeStreamConsumer.java create mode 100644 src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/consumer/SpringReactiveChangeStreamSource.java create mode 100644 src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicPolicyRegistry.java create mode 100644 src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformHealthMeasuresTheClusterTest.java create mode 100644 src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoReactiveWiringTest.java delete mode 100644 src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStableReleaseGateTest.java create mode 100644 src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/ReactiveWiringCarriesTheContextTest.java create mode 100644 src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/RotationOrderingTest.java create mode 100644 src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/consumer/ChangeStreamConsumerLifecycleTest.java create mode 100644 src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/failure/TranslatorCarriesOperationContextTest.java create mode 100644 src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/imperative/ScopedOperationDeadlineTest.java create mode 100644 src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/BulkSharesTheAtomicPolicyTest.java create mode 100644 src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/query/LiteralSearchIsExpressibleTest.java create mode 100644 src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveBinderCarriesRuntimeContractTest.java create mode 100644 src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/security/MongoTlsLaneTest.java create mode 100644 src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/CleanupFailureIsReportedTest.java create mode 100644 src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoTlsReplicaSetContainer.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/CapabilityDependencyEnvironmentValidator.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/PostgreSqlIdempotencyProviderConfig.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/mongo/MongoPlatformHealthConfig.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/mongo/MongoPlatformHealthContributor.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/DependencyErrorStartupContractTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/PortResolutionContractTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/GraphQlInboundOwnershipBoundaryTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/MongoRawAccessBoundaryTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/graphql/GraphQlResolverReachabilityTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingPublicSurfaceContractTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/idempotency/IdempotencyProviderCompositionTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/mongo/MongoPlatformHealthContributorTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/registry/MongoRegistryPermissionParityTest.java delete mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyClaimAttempt.java delete mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyClaimOutcome.java delete mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyClaimRequest.java delete mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyCompleteOutcome.java delete mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutorV2.java delete mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyFailOutcome.java delete mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyFailureDisposition.java delete mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInspection.java delete mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInspectionRequest.java delete mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyOwner.java delete mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyReleaseOutcome.java delete mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRenewOutcome.java delete mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStartOutcome.java delete mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStorePortV2.java delete mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyV2Validation.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/platform/callback/ProviderRequestIdHash.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxPublishAmbiguousException.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxPublishRefusedException.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/security/ObjectAccessDecision.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/security/ObjectAccessPolicy.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/security/ObjectAccessRequest.java delete mode 100644 src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyExecutorV2Test.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyV2ContractSingularityTest.java delete mode 100644 src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyV2ContractTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/platform/callback/ProviderEventLateMatchTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/outbox/OutcomeAwareRelayTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/security/ObjectAccessPolicyTest.java create mode 100644 src/build-logic/README.md create mode 100644 src/build-logic/build.gradle create mode 100644 src/build-logic/settings.gradle create mode 100644 src/build-logic/src/main/groovy/ca.api-surface.gradle create mode 100644 src/build-logic/src/main/groovy/ca.architecture-registry.settings.gradle create mode 100644 src/build-logic/src/main/groovy/ca.dependency-policy.gradle create mode 100644 src/build-logic/src/main/groovy/ca.evidence.gradle create mode 100644 src/build-logic/src/main/groovy/ca.runtime-membership.gradle rename src/{gradle/strict-qualification-test.gradle => build-logic/src/main/groovy/ca.strict-qualification.gradle} (55%) create mode 100644 src/build-logic/src/main/groovy/ca.strict-test-lane.gradle create mode 100644 src/build-logic/src/main/groovy/ca.testkit-publisher.gradle create mode 100644 src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/JUnitEvidence.groovy create mode 100644 src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/ModuleRegistry.groovy create mode 100644 src/build-logic/src/test/groovy/ApiSurfaceConventionTest.groovy create mode 100644 src/build-logic/src/test/groovy/JUnitEvidenceTest.groovy create mode 100644 src/build-logic/src/test/groovy/ModuleRegistryTest.groovy create mode 100644 src/build-logic/src/test/groovy/StrictTestLaneConventionTest.groovy delete mode 100644 src/gradle/junit-evidence.gradle delete mode 100644 src/gradle/runtime-membership.gradle create mode 100644 src/messaging/messaging-core-api/src/main/java/dev/caskeleton/messaging/api/WireSafeText.java create mode 100644 src/messaging/messaging-core-api/src/main/java/dev/caskeleton/messaging/api/header/CanonicalEnvelopeHeaders.java create mode 100644 src/messaging/messaging-core-api/src/test/java/dev/caskeleton/messaging/api/WireBoundaryRejectionTest.java create mode 100644 src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaBrokerCertificationIT.java create mode 100644 src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaEnvelopeRoundTripTest.java create mode 100644 src/messaging/messaging-outbox-jdbc-postgresql/src/main/java/dev/caskeleton/messaging/outbox/OutboxRelayWorker.java create mode 100644 src/messaging/messaging-outbox-jdbc-postgresql/src/main/resources/db/migration/messaging/V4__messaging_outbox_canonical_metadata.sql create mode 100644 src/messaging/messaging-outbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/outbox/OutboxEnvelopeFactoryTest.java create mode 100644 src/messaging/messaging-outbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/outbox/OutboxRelayWorkerTest.java create mode 100644 src/messaging/messaging-rabbit/src/test/java/dev/caskeleton/messaging/rabbit/RabbitEnvelopeRoundTripTest.java create mode 100644 src/messaging/messaging-reliability-api/src/main/java/dev/caskeleton/messaging/reliability/OutboxCanonicalMetadata.java create mode 100644 src/messaging/messaging-schema-avro/src/test/java/dev/caskeleton/messaging/schema/avro/AvroHostileInputTest.java create mode 100644 src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/BrokerSecuritySettings.java create mode 100644 src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/BrokerSettings.java create mode 100644 src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/CompiledMessagingConfiguration.java create mode 100644 src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/DestinationSettings.java create mode 100644 src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/MessagingConfigurationCompiler.java create mode 100644 src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/MessagingConfigurationKeyValidator.java create mode 100644 src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/MessagingOutboxRelayLifecycle.java create mode 100644 src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/MessagingShutdownLifecycle.java create mode 100644 src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/StartupProfileValidation.java create mode 100644 src/messaging/messaging-spring-boot-starter/src/test/java/dev/caskeleton/messaging/autoconfigure/MessagingConfigurationBindingTest.java create mode 100644 src/messaging/messaging-spring-boot-starter/src/test/java/dev/caskeleton/messaging/autoconfigure/MessagingOutboxRelayLifecycleTest.java create mode 100644 src/messaging/messaging-spring-boot-starter/src/test/java/dev/caskeleton/messaging/autoconfigure/MessagingShutdownLifecycleTest.java create mode 100644 src/messaging/messaging-testkit/src/main/java/dev/caskeleton/messaging/testkit/CertifiedEvidence.java create mode 100644 src/messaging/messaging-testkit/src/main/resources/messaging/broker-certification-evidence.jsonl delete mode 100644 src/messaging/messaging-testkit/src/test/java/dev/caskeleton/messaging/testkit/CertifiedEvidence.java create mode 100644 src/messaging/messaging-testkit/src/test/java/dev/caskeleton/messaging/testkit/CertifiedEvidenceTest.java delete mode 100644 src/messaging/messaging-transport-spi/src/main/java/dev/caskeleton/messaging/transport/BackpressureController.java delete mode 100644 src/messaging/messaging-transport-spi/src/test/java/dev/caskeleton/messaging/transport/BackpressureAndShutdownTest.java create mode 100644 src/messaging/messaging-transport-spi/src/test/java/dev/caskeleton/messaging/transport/GracefulShutdownTest.java diff --git a/.github/ci-gate-matrix.yml b/.github/ci-gate-matrix.yml index 1991f07c..bf9b2107 100644 --- a/.github/ci-gate-matrix.yml +++ b/.github/ci-gate-matrix.yml @@ -45,6 +45,96 @@ gates: workflow: ci-quality-gates.yml job: quality-gates execution: check + - id: documented-leaf-count + release_blocking: true + mechanism: gradle-custom-task + ref: verifyDocumentedLeafCount + workflow: ci-quality-gates.yml + job: quality-gates + execution: check + - id: declared-dependency-absence + release_blocking: true + mechanism: gradle-custom-task + ref: verifyDependencyPolicy + workflow: ci-quality-gates.yml + job: quality-gates + execution: check + - id: notification-api-surface + release_blocking: true + mechanism: gradle-custom-task + ref: verifyNotificationApiSurface + workflow: ci-quality-gates.yml + job: quality-gates + execution: check + - id: notification-configuration-contract + release_blocking: true + mechanism: gradle-custom-task + ref: verifyNotificationConfiguration + workflow: ci-quality-gates.yml + job: quality-gates + execution: check + - id: notification-support-grade-evidence + release_blocking: true + mechanism: gradle-custom-task + ref: verifyNotificationEvidence + workflow: ci-quality-gates.yml + job: quality-gates + execution: check + - id: runbook-reference-drift + release_blocking: true + mechanism: gradle-custom-task + ref: verifyRunbookReferences + workflow: ci-quality-gates.yml + job: quality-gates + execution: check + - id: graphql-api-surface + release_blocking: true + mechanism: gradle-custom-task + ref: verifyGraphQlApiSurface + workflow: ci-quality-gates.yml + job: quality-gates + execution: check + - id: mongo-api-surface + release_blocking: true + mechanism: gradle-custom-task + ref: verifyMongoApiSurface + workflow: ci-quality-gates.yml + job: quality-gates + execution: check + # The strongest evidence this repository produces, and CI does not run it. Fifteen Compose lanes + # start real PostgreSQL, MongoDB, Kafka, MinIO, Mailpit and Keycloak, take a real client-credentials + # JWT, and prove things no in-JVM test can: that all-off boots with no external resource, that the + # notification handoff delivers exactly once across a restart on the same volume, that the startup + # log is silent. It runs from a developer's machine via scripts/run-compose-runtime-smoke.sh and + # from nowhere else — no workflow invokes it, so nothing re-runs it on a pull request. + # + # Registered delegated-pending so the gap is a tracked absence rather than an unstated one. + # Executing it in CI needs a Docker-capable runner and a decision about the minutes fifteen + # container lanes cost, which is an infrastructure choice rather than a wiring oversight. + - id: runtime-smoke-matrix + release_blocking: conditional + mechanism: delegated-pending + ref: runtime-smoke-matrix-lane + workflow: ci-quality-gates.yml + job: release-gate + execution: job + # `conditional-transport-qualification` above is the registered GraphQL control, and it is a + # boundary test: a @SpringBootTest over a nested test application with in-memory Basic Auth. Its own + # javadoc says so — "the nested application deliberately owns only test authentication and CORS + # policy". That is a legitimate transport-boundary proof and it is not release evidence for the + # security posture, which is the distinction the Definition of Done draws. + # + # The real proof exists: the local-graphql Compose lane obtains a Keycloak client-credentials token + # and posts it to /graphql on the running bootJar, asserting that anonymous and malformed + # credentials are refused and the authenticated query answers. It is part of the runtime smoke + # matrix above, so it inherits that control's pending status rather than having none of its own. + - id: graphql-runtime-jwt + release_blocking: conditional + mechanism: delegated-pending + ref: graphql-runtime-jwt-lane + workflow: ci-quality-gates.yml + job: release-gate + execution: job - id: one-type-per-file release_blocking: true mechanism: gradle-custom-task @@ -287,3 +377,15 @@ gates: workflow: httpclient-release.yml job: release-gate execution: explicit + # The messaging platform's only claim that needs a real broker to be true. The gate is the + # evidence check rather than the lane, and it depends on the lane: passing means both that every + # fault scenario produced the outcome the shared contract fixes and that the committed manifest is + # what this run wrote. Before it existed, `CertifiedEvidence` was a hand-authored list and + # "certified against a live broker" was a sentence a developer could type. + - id: messaging-broker-certification + release_blocking: true + mechanism: gradle-custom-task + ref: verifyMessagingCertificationEvidence + workflow: messaging-certification.yml + job: broker-certification + execution: explicit diff --git a/.github/scripts/verify-gate-matrix.sh b/.github/scripts/verify-gate-matrix.sh index aa488ddd..add1646f 100644 --- a/.github/scripts/verify-gate-matrix.sh +++ b/.github/scripts/verify-gate-matrix.sh @@ -27,8 +27,15 @@ readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml" # Deliberately a literal: a gate silently appearing or disappearing is the drift this lint exists to # catch, so growing the matrix is an explicit edit here. 38 as of the HTTP Client platform hardening, # which registered httpclient-spring62-runtime as a delegated-pending control — the 6.2 *runtime* -# claim, distinct from the API-surface scan that was standing in for it. -readonly EXPECTED_GATE_COUNT=38 +# claim, distinct from the API-surface scan that was standing in for it. 40 after the Gradle +# convention wave registered documented-leaf-count and declared-dependency-absence, then 46 after +# the final qualification wave registered the four notification/runbook gates that existed but ran +# nowhere and the two API-surface gates the convention had already wired into check. 48 once the +# Compose runtime matrix and the GraphQL runtime JWT claim were registered as delegated-pending — +# both are real and neither runs in CI. 49 once the messaging broker certification lane registered +# its evidence gate — the first control in this repository whose subject is not "did the tests pass" +# but "is the committed evidence what the run produced". +readonly EXPECTED_GATE_COUNT=49 if [[ ! -f "${MATRIX}" ]]; then printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2 @@ -135,6 +142,25 @@ gradle_custom_task_is_registered_in_build_file() { return 0 fi + # A lane declared through the `ca.strict-test-lane` convention. The convention exists because the + # five lines every lane used to repeat were copied per lane and per leaf, and two copies had + # already lost `failOnNoDiscoveredTests`; registering through it is still registering, so this lint + # has to recognise the declaration or it reports every converted lane as missing. + if grep -qsE -- "lane\\(['\"]${task_name}['\"]\\)" "${build_file}"; then + return 0 + fi + + # An API surface gate declared through the `ca.api-surface` convention, which derives every task + # name from one label so a leaf cannot verify one surface while telling the reader about another. + # The name is computed, so there is no literal `tasks.register('verifyMongoApiSurface')` anywhere; + # what the build file says is `apiSurface { label = 'Mongo' }`. + if [[ "${task_name}" =~ ^verify(.+)ApiSurface$ ]]; then + local surface_label="${BASH_REMATCH[1]}" + if grep -qsE -- "label[[:space:]]*=[[:space:]]*['\"]${surface_label}['\"]" "${build_file}"; then + return 0 + fi + fi + awk -v required_task="${task_name}" ' index($0, "registerStrictQualificationTest(") > 0 { inside_registration=1 } inside_registration && /^[[:space:]]*name:[[:space:]]*/ { @@ -159,14 +185,64 @@ gradle_custom_task_is_registered_in_build_file() { ' "${build_file}" } +# Every `dependsOn ... named('x')` in the build, collected once. +# +# This used to be one recursive grep per gate. That was affordable at 38 gates and stopped being so +# at 48: the whole lint crossed the ten-second budget its own contract test asserts, and the first +# symptom was that test failing rather than anything about gate coverage. One pass, then membership +# tests against the result. +CHECK_WIRING_CACHE="" +load_check_wiring() { + [[ -n "${CHECK_WIRING_CACHE}" ]] && return 0 + CHECK_WIRING_CACHE="$(grep -RhoE -- "dependsOn[^\n]*named\((['\"])[A-Za-z0-9_.-]+\1\)" \ + "${REPO_ROOT}/src" --include='build.gradle' --include='ca.*.gradle' 2>/dev/null \ + | grep -oE "(['\"])[A-Za-z0-9_.-]+\1" | tr -d "\"'" | sort -u)" + # A build with no such wiring at all would leave this empty and make every membership test pass by + # vacuity, so an empty result is a marker rather than an answer. + [[ -z "${CHECK_WIRING_CACHE}" ]] && CHECK_WIRING_CACHE="" + return 0 +} + +gradle_custom_task_wired_into_check() { + local task_name="$1" + load_check_wiring + if printf '%s\n' "${CHECK_WIRING_CACHE}" | grep -qxF -- "${task_name}"; then + return 0 + fi + # `ca.api-surface` wires check as `dependsOn tasks.named(verifyName())`, where verifyName() is + # derived from the leaf's label. The declaration that makes the gate real is the label, so that is + # what proves the wiring — the convention has exactly one check wiring and it is unconditional. + if [[ "${task_name}" =~ ^verify(.+)ApiSurface$ ]]; then + local surface_label="${BASH_REMATCH[1]}" + if grep -RqsE -- "label[[:space:]]*=[[:space:]]*['\"]${surface_label}['\"]" "${REPO_ROOT}/src" \ + --include='build.gradle' \ + && grep -qsE -- "dependsOn tasks\.named\(verifyName\(\)\)" \ + "${REPO_ROOT}/src/build-logic/src/main/groovy/ca.api-surface.gradle"; then + return 0 + fi + fi + return 1 +} + +# The build files, found once rather than once per gate. Same reason as the wiring cache above: the +# per-gate `find` was a fixed cost multiplied by a number that grew. +GRADLE_FILE_CACHE="" +load_gradle_files() { + [[ -n "${GRADLE_FILE_CACHE}" ]] && return 0 + GRADLE_FILE_CACHE="$(find "${REPO_ROOT}/src" -type f -name '*.gradle' | sort)" + return 0 +} + gradle_custom_task_is_registered() { local task_name="$1" local build_file - while IFS= read -r -d '' build_file; do + load_gradle_files + while IFS= read -r build_file; do + [[ -z "${build_file}" ]] && continue if gradle_custom_task_is_registered_in_build_file "${task_name}" "${build_file}"; then return 0 fi - done < <(find "${REPO_ROOT}/src" -type f -name '*.gradle' -print0) + done <<< "${GRADLE_FILE_CACHE}" return 1 } @@ -325,9 +401,16 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do failures+=("gate '${id}' expects Gradle check in job '${job}'") continue fi + # Build files *and* convention plugins. A gate can now be wired into check from an included + # build's convention rather than from a leaf's build.gradle, and a lint that only reads + # build.gradle would call such a gate unwired while it runs on every leaf — a false failure + # that teaches the next author to delete the matrix row instead of trusting it. + # + # A convention that derives the task name from a label wires check by that derived name, so + # there is no literal to grep for either; `gradle_custom_task_wired_into_check` handles both + # the literal and the derived form. if [[ "${mechanism}" == "gradle-custom-task" ]] \ - && ! grep -RqsE -- "dependsOn.*named\\(['\"]${ref}['\"]\\)" "${REPO_ROOT}/src" \ - --include='build.gradle'; then + && ! gradle_custom_task_wired_into_check "${ref}"; then failures+=("gate '${id}' task '${ref}' exists but is not wired into Gradle check") continue fi diff --git a/.github/scripts/verify-gradle-wrapper.sh b/.github/scripts/verify-gradle-wrapper.sh index cd1338c7..d5c2f6f7 100755 --- a/.github/scripts/verify-gradle-wrapper.sh +++ b/.github/scripts/verify-gradle-wrapper.sh @@ -27,11 +27,12 @@ readonly EXPECTED_WORKFLOW_LOCK=( '3be84c9f15fa3b2ac5a085f8d725ec6d05e7007ae0b433da9e79b3bf340d57ea .github/workflows/jpa-next-hibernate8.yml' 'a2b74bfb3af12d6d03cd2ea8a5e48490dd131afb89b79694d498c5798387ac53 .github/workflows/jpa-next-jpa4.yml' 'cd955ef4af895df477896dad9577810f010b2beea8570b09b008f9e94e928bd0 .github/workflows/jpa-next-postgresql19.yml' - 'b56b548a867b74eaeccb42e7df4f4e52cf7ce657ab27f91e2c8d7ea9944d64af .github/workflows/jpa-nightly.yml' + '21e065880ef5d4c4ff973f52d8107ef08398ebaf9518ec6b2fd82d49c5d822c6 .github/workflows/jpa-nightly.yml' '04851f44ba94533bfbc8fabe2b3a2b408726a9996e86ed3864986d1499d16b50 .github/workflows/jpa-pr.yml' '59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml' - '4748f2ba0a0b77dc1a858ebcfa7db6e41627d97843df5f0aa978bc2facccaad2 .github/workflows/jpa-release.yml' + 'cf4f80134197dd6d7dc177f0d21294a6b9ffe8709be67d05089f7ff0ce6c9429 .github/workflows/jpa-release.yml' '5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml' + '8adafc59a2d87a6c65ef94b4726d7d036ac81b150ed3d301578308e6f9a3523f .github/workflows/messaging-certification.yml' '4e4ccfa267ecd63b9369803d49f2dbdb2fa899517ad4cf23ab11d29104557a91 .github/workflows/notification-platform.yml' '64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml' 'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml' diff --git a/.github/workflows/jpa-nightly.yml b/.github/workflows/jpa-nightly.yml index cc4da84b..58be9e33 100644 --- a/.github/workflows/jpa-nightly.yml +++ b/.github/workflows/jpa-nightly.yml @@ -119,10 +119,14 @@ jobs: src/**/*.gradle src/**/gradle-wrapper.properties src/**/gradle.lockfile - - name: Measure pool saturation and REQUIRES_NEW pressure + - name: Verify pool saturation and REQUIRES_NEW connection behaviour working-directory: src - # Machine-dependent bounds are reported rather than asserted unless explicitly enabled, so a - # noisy shared runner does not produce a red build that means nothing. + # A behaviour contract, not a measurement. This step used to switch assertions off with an + # explicit property and call the result a certification, so the only threshold it ever + # asserted was that thresholds were not being asserted. What + # it checks now — that REQUIRES_NEW needs two connections per concurrent thread, that a + # saturated pool reports its pending count, that a caller waits rather than proceeding + # without a connection — is true on any runner, so there is nothing to switch off. run: >- ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest diff --git a/.github/workflows/jpa-release.yml b/.github/workflows/jpa-release.yml index 6b41868e..e1c3ebbc 100644 --- a/.github/workflows/jpa-release.yml +++ b/.github/workflows/jpa-release.yml @@ -1,8 +1,13 @@ name: jpa-release -# The release gate. Every item in docs/jpa/support-matrix.md's gate table has a job or an assertion -# here, and JpaReleaseManifest parses that document so a gate removed from the docs fails the build +# The release gate. src/config/jpa/release-registry.json is the source: every gate it declares has a +# job or an assertion here, JpaReleaseRenderingTest holds this file's matrix and promotion lists to +# the registry's Stable majors, and verifyJpaReleaseGateTasks resolves each gate's task against the +# real Gradle graph. So a gate removed from the registry, or a major demoted in it, fails the build # rather than quietly ceasing to be checked. +# +# The matrix below is therefore not free to drift: editing it without editing the registry fails the +# unit lane. on: workflow_dispatch: diff --git a/.github/workflows/messaging-certification.yml b/.github/workflows/messaging-certification.yml new file mode 100644 index 00000000..f90d6e43 --- /dev/null +++ b/.github/workflows/messaging-certification.yml @@ -0,0 +1,59 @@ +# The messaging platform's broker certification lane. +# +# Separate from ci-quality-gates.yml because it needs a container runtime and several minutes of it. +# The lane deliberately carries no Docker guard: every other container suite in the messaging tree +# skips with a stated reason when Docker is absent, and a certification lane that skipped would +# report success for a broker nobody started — which is the exact claim the evidence exists to rule +# out. +# +# The job runs the evidence gate rather than the lane, and the gate depends on the lane. What it +# proves is not only that the scenarios pass but that the committed manifest +# (messaging-testkit/src/main/resources/messaging/broker-certification-evidence.jsonl) is what this +# run produced, so "certified against a live broker" cannot be restored by editing a file. +name: messaging-certification + +on: + pull_request: + paths: + - "src/messaging/**" + - ".github/workflows/messaging-certification.yml" + schedule: + - cron: "41 4 * * 3" + workflow_dispatch: + +permissions: + contents: read + +env: + # Reuse would hand one scenario the broker another scenario had already faulted. + TESTCONTAINERS_REUSE_ENABLE: "false" + +jobs: + broker-certification: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Certify the Kafka adapter against a real broker + working-directory: src + # GITHUB_SHA is read by the lane and written into every evidence line, because "certified" + # is a claim about one source tree. + run: ./gradlew :messaging:messaging-kafka:verifyMessagingCertificationEvidence --no-daemon --stacktrace + - name: Publish the certification evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2 + with: + name: messaging-broker-certification-evidence + path: src/messaging/messaging-kafka/build/messaging-certification/ + if-no-files-found: warn diff --git a/CLAUDE.md b/CLAUDE.md index 7c686f0c..7dd96594 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,9 +56,20 @@ build when a policy document states a leaf count that the registry does not agre | `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered | | `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge | | `shared-contract` | Skeleton-wide operational contracts | Java stdlib only | +| `messaging:*` | Vendored messaging platform: a product with its own API, SPI, adapters and composition boundary, not a layer of this application | `messaging:*` only — it depends on no `domain-core`, `application-core`, or `shared-contract` type | | `sample-portfolio` | Fixture/reference consumer | registered runtime leaves; never a production dependency | | `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves | +The `messaging:*` family is the one entry that is not a Clean Architecture layer, and it is listed so +that the exception is stated rather than inferred from a directory. It is a vendored library — its +own `*-api` leaves are its ports, its broker leaves are its adapters, its starter is its composition +root — and the messaging module review (`docs/reviews/2026-08-14-messaging-module-code-review.md` +MSG-023 §6.2) chose that layout deliberately over folding it into `adapter:outbound:*`. This +application is supposed to reach it the way it reaches any library: through an application-owned port +satisfied by an anti-corruption bridge in `adapter:outbound:messaging`. That bridge does not exist +yet (MSG-015), so today the composition root wires the starter directly; `src/messaging/CLAUDE.md` +holds the detail. + Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table. Read its `gradle_path`, `allowed_dependencies`, and `runtime_memberships` from `src/config/architecture/modules.json`; derive the focused test from that Gradle path. diff --git a/docker-compose.infra.yml b/docker-compose.infra.yml index 027f5fbc..68a998e5 100644 --- a/docker-compose.infra.yml +++ b/docker-compose.infra.yml @@ -325,6 +325,37 @@ services: - caskeleton-infra restart: "no" + # The GraphQL transport, as a request. auth-smoke proves a token can be obtained and that public + # health answers; this proves /graphql is guarded and that an authenticated query executes. + graphql-smoke: + profiles: + - local-graphql + - all-adapters + image: curlimages/curl:8.10.1 + depends_on: + keycloak: + condition: service_healthy + # uid 0 for the mounted 0600 client secret, same as auth-smoke. + user: "0:0" + entrypoint: ["/bin/sh", "/opt/graphql-smoke/graphql-smoke.sh"] + environment: + APP_BASE_URL: "http://app:8080" + KEYCLOAK_ISSUER: "http://keycloak:8080/realms/ca-skeleton" + KEYCLOAK_CLIENT_ID: "ca-skeleton-api" + # Spring for GraphQL serves its own endpoint through a router function rather than an + # annotated controller, so the presentation base-path prefix does not apply to it. + GRAPHQL_PATH: "${GRAPHQL_PATH:-/graphql}" + volumes: + - type: bind + source: ./infra/graphql/smoke + target: /opt/graphql-smoke + read_only: true + secrets: + - keycloak-graphql-smoke-client-secret + networks: + - caskeleton-infra + restart: "no" + # ---- One-shot smoke clients -------------------------------------------------- # Never `up --wait` targets. Each is run with `run --rm` and must exit zero; a missing, skipped or # non-zero one fails its lane rather than being treated as "not applicable". diff --git a/docs/adr/ADR-GQL-001-graphql-context-and-storage-ownership.md b/docs/adr/ADR-GQL-001-graphql-context-and-storage-ownership.md new file mode 100644 index 00000000..cacb5f0f --- /dev/null +++ b/docs/adr/ADR-GQL-001-graphql-context-and-storage-ownership.md @@ -0,0 +1,70 @@ +# ADR-GQL-001 — GraphQL context stays inbound; object authorization moves to application-core; the persisted-operation store stays an inbound SPI + +- Status: Accepted +- Date: 2026-08-24 +- Review: `docs/reviews/2026-08-14-graphql-module-code-review.md` GQL-026 + +## Context + +The GraphQL leaf's own documentation described three things crossing its boundary: a +`GraphQlRequestContext` with a deadline propagated into application, JPA, Mongo and the HTTP client; +object authorization decided inside the transport; and a persisted-operation registry implemented by +an external durable store. + +Two of those invert the dependency direction. If `application-core` or an outbound adapter +implements a type that lives in `adapter:inbound:graphql`, the registry edge that says inbound +depends on application is satisfied while the real compile-time dependency runs the other way. + +The third is a business rule in the wrong layer: whether an actor may see an object is a decision +about the domain, and GraphQL is one of four transports this skeleton ships. + +## Decision + +Three different answers, because the three problems are not the same problem. + +**GraphQL context stays inbound-local.** It is mapped explicitly onto application command fields — +actor, tenant, deadline — rather than travelling as a type. Nothing outside the leaf references +`GraphQlRequestContext`, and the boundary test is that grep returns nothing outside it. + +**Object authorization moves to `application-core`.** `ObjectAccessPolicy`, `ObjectAccessRequest` +and `ObjectAccessDecision` are transport-neutral and live with the other application policies; +`ApplicationObjectAuthorization` in the GraphQL leaf is the bridge that calls them. This is the one +of the three that was a real layering defect, and it is fixed rather than documented. + +**The persisted-operation store stays an inbound-owned SPI.** `GraphQlPersistedOperationRegistry` +remains in `advanced/persisted`, and no leaf outside GraphQL implements it. + +## Consequences + +The third decision is the one that needs defending, because it leaves the reported risk in place +rather than removing it. + +The risk is conditional: the direction inverts only when something outside the leaf implements the +interface. Nothing does. The template ships an in-memory registry and no durable one, because it +ships no persisted-operation store at all. + +The alternative was to introduce a generic operational key-value store port owned by a neutral +contract holder, with the GraphQL adapter owning only the key and value mapping. That port would +have exactly one interface, zero implementations and one speculative consumer — a new abstraction +whose shape is guessed from a requirement nobody has stated. This repository has spent a full +remediation pass deleting controls that existed and were reached by nothing, and inventing a port +for a store that does not exist is how the next one of those gets written. + +So the decision is to leave the SPI where it is and to move it when a durable store is actually +built. Moving it then is a rename across one leaf and one new adapter, which is cheaper than +carrying a wrong abstraction until then. What must not happen in the meantime is an outbound leaf +implementing the inbound interface, because that is the moment the direction actually inverts, and +it would happen in a commit whose diff looks like an implementation rather than a layering change. + +The composition root wires these and owns no business or storage policy of its own. + +## Enforcement + +`verifyCleanArchitectureDependencies` and `modules.json` hold the leaf's edges to +`domain-core`, `application-core` and `shared-contract`. `ObjectAccessPolicyTest` covers the +application-side policy and `ApplicationObjectAuthorizationTest` the bridge. + +The condition this ADR turns on — that nothing outside the GraphQL leaf implements the +persisted-operation SPI — is a claim about the whole repository, so it is checked at the +composition root rather than inside the leaf, next to the other GraphQL boundary rules in +`app-bootstrap`'s architecture suite. diff --git a/docs/adr/ADR-JPA-006-canonical-technical-audit.md b/docs/adr/ADR-JPA-006-canonical-technical-audit.md new file mode 100644 index 00000000..b26bd9a8 --- /dev/null +++ b/docs/adr/ADR-JPA-006-canonical-technical-audit.md @@ -0,0 +1,67 @@ +# ADR-JPA-006 — `audit` is the canonical technical audit model; `auditing` stays a frozen candidate + +- Status: Accepted +- Date: 2026-08-24 +- Review: `docs/reviews/2026-08-14-jpa-module-code-review.md` JPA-022 + +## Context + +Two complete technical-audit mechanisms live in this leaf and they disagree about the schema. + +`audit/AuditableEntity` stamps `created_at`/`created_by`/`updated_at`/`updated_by` with an actor +column of length 256, captured through explicit `initializeAudit`/`applyModification` calls and an +`AuditContextPort`. `auditing/AuditMetadata` is a Spring Data embeddable that stamps +`created_*`/`modified_*` with an actor column of length 64, captured by `@CreatedDate` and friends +through an `AuditorAware`. + +Only the first is real: it is what the sample entities extend and what the migrations were written +for. `JpaAuditingConfiguration` is not a Spring `@Configuration`, and nothing in production +constructs any of the three `auditing` types. + +The review asked for one canonical model with a migration or activation decision. The failure mode +it was protecting against is specific: an author of a new entity picks whichever package they find +first, and column names, actor lengths and capture lifecycles then diverge per table. + +## Decision + +`audit/AuditableEntity` is canonical. `auditing` stays in the tree as a candidate and is excluded +from the Stable capability report. + +The candidate is not deleted and not promoted. Deleting it would discard a working Spring Data +integration that a deployment preferring declarative auditing would want. Promoting it would mean +either renaming `modified_*` to `updated_*` and widening the actor column — a schema migration of +every audited table to gain nothing a caller asked for — or moving the sample entities onto +`modified_*`, which is the same migration in the other direction. + +Neither is worth doing now. What the divergence actually needed was not consolidation but a rule +that an entity cannot straddle the two, and that rule is cheaper than either migration. + +## Consequences + +Two audit mechanisms remain readable in one leaf, and a reader has to be told which one is live. +That cost is paid in this document, in the package javadoc and in a test whose name says so. + +Two failure modes stay silent unless they are asserted, so both are: + +- The candidate acquires a stereotype and starts stamping in every deployment that has this module + on the classpath, including the ones whose tables have no `modified_*` columns — where the result + is a failed startup rather than a feature. +- Somebody "harmonises" the two by editing one side's column names, at which point the schema a + deployed table was migrated for and the schema its entity expects diverge with no migration + between them. + +If the candidate is ever promoted, it is promoted atomically: forward migration, sample conversion, +`AuditContextPort → AuditorAware` and `Clock → DateTimeProvider` bridges land together, and this +ADR is superseded rather than amended. + +Bulk and native updates stamp nothing under either mechanism. That is a property of JPA, not of the +choice made here, so it is enforced separately rather than assumed away. + +## Enforcement + +`JpaAuditMechanismRule.entitiesUseExactlyOneAuditMechanism` and +`bulkUpdatesOfAuditedEntitiesStampAudit`, run against the real production graph by +`JpaProductionArchitectureTest` at the composition root — not against fixtures, which is how the +earlier version of this rule pack passed while applying to nothing. `AuditingCandidateStatusTest` +asserts the candidate carries no composing stereotype and that the two column sets stay distinct. +`JpaAuditMechanismRuleTest` exercises the rules' own negative cases. diff --git a/docs/architecture/graphql-api-surface.txt b/docs/architecture/graphql-api-surface.txt index 296ae957..cbf76d5f 100644 --- a/docs/architecture/graphql-api-surface.txt +++ b/docs/architecture/graphql-api-surface.txt @@ -5,7 +5,7 @@ # split into capability artifacts. # Update only after review with: # ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange -# types: 395 +# types: 398 dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlAdminPrincipal dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminAuthorization @@ -179,6 +179,7 @@ dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaUsage dev.caskeleton.adapter.inbound.graphql.context.ActorRef dev.caskeleton.adapter.inbound.graphql.context.GraphQlCommandAttribution dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline +dev.caskeleton.adapter.inbound.graphql.context.GraphQlIdentityFingerprinter dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext dev.caskeleton.adapter.inbound.graphql.context.TenantContext dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator @@ -358,6 +359,7 @@ dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformRejectionMapper dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver +dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlRequestObservationConventionAdapter dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrors dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter @@ -387,6 +389,7 @@ dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaHash dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaMappingException dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaOwnership dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaResource +dev.caskeleton.adapter.inbound.graphql.security.ApplicationObjectAuthorization dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticatedPrincipal dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationException diff --git a/docs/architecture/jpa-api-surface.txt b/docs/architecture/jpa-api-surface.txt new file mode 100644 index 00000000..7fdf56a2 --- /dev/null +++ b/docs/architecture/jpa-api-surface.txt @@ -0,0 +1,340 @@ +# JPA persistence leaf public API surface — every public top-level type in src/main/java. +# A public type in a single-jar leaf is reachable from every adopter's code, so +# additions are reviewed rather than discovered. `api` is the intended external +# surface; the rest is implementation that has not been moved under an internal +# root yet. +# Update only after review with: +# ./gradlew :adapter:outbound:persistence-jpa:updateJpaApiSurface -PapproveJpaApiSurfaceChange +# types: 332 +dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName +dev.caskeleton.adapter.outbound.persistence.api.capability.CapabilitySupport +dev.caskeleton.adapter.outbound.persistence.api.capability.JpaCapability +dev.caskeleton.adapter.outbound.persistence.api.capability.SupportLevel +dev.caskeleton.adapter.outbound.persistence.api.error.CheckConstraintViolationException +dev.caskeleton.adapter.outbound.persistence.api.error.ConnectionUnavailableException +dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintCode +dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintViolationDetails +dev.caskeleton.adapter.outbound.persistence.api.error.DataCorruptionException +dev.caskeleton.adapter.outbound.persistence.api.error.DeadlockDetectedException +dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory +dev.caskeleton.adapter.outbound.persistence.api.error.ForeignKeyViolationException +dev.caskeleton.adapter.outbound.persistence.api.error.JpaEntityNotFoundException +dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext +dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException +dev.caskeleton.adapter.outbound.persistence.api.error.NotNullConstraintViolationException +dev.caskeleton.adapter.outbound.persistence.api.error.OptimisticConflictException +dev.caskeleton.adapter.outbound.persistence.api.error.PessimisticLockTimeoutException +dev.caskeleton.adapter.outbound.persistence.api.error.QueryTimeoutException +dev.caskeleton.adapter.outbound.persistence.api.error.SchemaMismatchException +dev.caskeleton.adapter.outbound.persistence.api.error.SerializationFailureException +dev.caskeleton.adapter.outbound.persistence.api.error.SqlExceptionSqlStateResolver +dev.caskeleton.adapter.outbound.persistence.api.error.SqlStateResolver +dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException +dev.caskeleton.adapter.outbound.persistence.api.error.TransactionTimeoutException +dev.caskeleton.adapter.outbound.persistence.api.error.UniqueConstraintViolationException +dev.caskeleton.adapter.outbound.persistence.api.error.VendorFailureTranslator +dev.caskeleton.adapter.outbound.persistence.api.query.CursorCodec +dev.caskeleton.adapter.outbound.persistence.api.query.CursorPayloadCodec +dev.caskeleton.adapter.outbound.persistence.api.query.KeysetPageRequest +dev.caskeleton.adapter.outbound.persistence.api.query.KeysetSlice +dev.caskeleton.adapter.outbound.persistence.api.query.NoopQueryObservation +dev.caskeleton.adapter.outbound.persistence.api.query.QueryName +dev.caskeleton.adapter.outbound.persistence.api.query.QueryObservation +dev.caskeleton.adapter.outbound.persistence.api.query.QueryScope +dev.caskeleton.adapter.outbound.persistence.api.query.SignedJsonCursorCodec +dev.caskeleton.adapter.outbound.persistence.api.query.SortDirection +dev.caskeleton.adapter.outbound.persistence.api.transaction.IsolationLevel +dev.caskeleton.adapter.outbound.persistence.api.transaction.JitterMode +dev.caskeleton.adapter.outbound.persistence.api.transaction.JpaRetryPolicy +dev.caskeleton.adapter.outbound.persistence.api.transaction.JpaTransactionExecutor +dev.caskeleton.adapter.outbound.persistence.api.transaction.PropagationMode +dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDecision +dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDisposition +dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryEventListener +dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile +dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionAttempt +dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence +dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionProfile +dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort +dev.caskeleton.adapter.outbound.persistence.audit.AuditableEntity +dev.caskeleton.adapter.outbound.persistence.audit.DomainContextAuditContextPort +dev.caskeleton.adapter.outbound.persistence.auditing.AuditMetadata +dev.caskeleton.adapter.outbound.persistence.auditing.JpaAuditingConfiguration +dev.caskeleton.adapter.outbound.persistence.auditing.JpaAuditorProvider +dev.caskeleton.adapter.outbound.persistence.cache.CacheConcurrencyStrategy +dev.caskeleton.adapter.outbound.persistence.cache.CacheRegionCatalog +dev.caskeleton.adapter.outbound.persistence.cache.HibernateCacheGuard +dev.caskeleton.adapter.outbound.persistence.cache.HibernateCachePolicy +dev.caskeleton.adapter.outbound.persistence.cache.HibernateCacheSettings +dev.caskeleton.adapter.outbound.persistence.config.JpaAdapterComponentsConfig +dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig +dev.caskeleton.adapter.outbound.persistence.config.PersistenceVendorSettings +dev.caskeleton.adapter.outbound.persistence.envers.EntityRevision +dev.caskeleton.adapter.outbound.persistence.envers.EnversConfigurationGuard +dev.caskeleton.adapter.outbound.persistence.envers.EnversHistoryPolicy +dev.caskeleton.adapter.outbound.persistence.envers.EnversHistoryReader +dev.caskeleton.adapter.outbound.persistence.envers.EnversRevisionMetadata +dev.caskeleton.adapter.outbound.persistence.envers.HibernateEnversHistoryReader +dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeature +dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeatureGate +dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantDataSourceLifecycle +dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantDataSourceRegistry +dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantEntityManagerFactoryRegistry +dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantPoolBudget +dev.caskeleton.adapter.outbound.persistence.experimental.next.CompatibilityLane +dev.caskeleton.adapter.outbound.persistence.experimental.next.ExperimentalPromotionGate +dev.caskeleton.adapter.outbound.persistence.experimental.next.HibernateCompatibilityPolicy +dev.caskeleton.adapter.outbound.persistence.experimental.next.PromotionDecision +dev.caskeleton.adapter.outbound.persistence.experimental.next.PromotionEvidence +dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyAwareDataSourceRouter +dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyToken +dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReadConsistency +dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaLagMonitor +dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaRoutingDecision +dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaTarget +dev.caskeleton.adapter.outbound.persistence.experimental.replica.TransactionContext +dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsAdminBypassToken +dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsPolicyVerifier +dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsTenantSessionBinder +dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaMultiTenantConnectionProvider +dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaTenantMigrationOrchestrator +dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaTenantRegistry +dev.caskeleton.adapter.outbound.persistence.experimental.schema.TenantMigrationStatus +dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantAwareRepositoryGuard +dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantContext +dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantEntityListenerGuard +dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId +dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator +dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping +dev.caskeleton.adapter.outbound.persistence.failure.StandardSqlStateErrorMapping +dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverJpaPersistenceConfig +dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverSchemaActivation +dev.caskeleton.adapter.outbound.persistence.fileserver.JpaCleanupQueue +dev.caskeleton.adapter.outbound.persistence.fileserver.JpaContentReferenceLedger +dev.caskeleton.adapter.outbound.persistence.fileserver.JpaFileMetadataStore +dev.caskeleton.adapter.outbound.persistence.fileserver.JpaFileQuotaService +dev.caskeleton.adapter.outbound.persistence.fileserver.JpaQuotaCommitGateway +dev.caskeleton.adapter.outbound.persistence.fileserver.JpaQuotaReclaimGateway +dev.caskeleton.adapter.outbound.persistence.fileserver.JpaRecoveryQueue +dev.caskeleton.adapter.outbound.persistence.fileserver.JpaStagingUploadLocator +dev.caskeleton.adapter.outbound.persistence.fileserver.JpaUploadSessionStore +dev.caskeleton.adapter.outbound.persistence.fileserver.entity.CleanupItemEntity +dev.caskeleton.adapter.outbound.persistence.fileserver.entity.FileEntity +dev.caskeleton.adapter.outbound.persistence.fileserver.entity.QuotaReservationEntity +dev.caskeleton.adapter.outbound.persistence.fileserver.entity.RecoveryItemEntity +dev.caskeleton.adapter.outbound.persistence.fileserver.entity.UploadSessionEntity +dev.caskeleton.adapter.outbound.persistence.fileserver.entity.VerificationResultEntity +dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileTransitionRepository +dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverCleanupRepository +dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverQuotaRepository +dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverRecoveryRepository +dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaFileRepository +dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaUploadSessionRepository +dev.caskeleton.adapter.outbound.persistence.fileserver.repository.UploadLeaseRepository +dev.caskeleton.adapter.outbound.persistence.h2.H2IdempotencyClaimRepository +dev.caskeleton.adapter.outbound.persistence.h2.H2LocalTimeoutConfigurer +dev.caskeleton.adapter.outbound.persistence.h2.H2OutboxClaimRepository +dev.caskeleton.adapter.outbound.persistence.h2.H2PersistenceConfig +dev.caskeleton.adapter.outbound.persistence.h2.H2SqlStateErrorMapping +dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateProviderPolicy +dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateStatisticsCollector +dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateStatisticsSnapshot +dev.caskeleton.adapter.outbound.persistence.hibernate.JdbcBatchCounter +dev.caskeleton.adapter.outbound.persistence.hibernate.NamedStatementInspector +dev.caskeleton.adapter.outbound.persistence.hibernate.QueryNameContext +dev.caskeleton.adapter.outbound.persistence.hibernate.batch.BatchExecutionResult +dev.caskeleton.adapter.outbound.persistence.hibernate.batch.HibernateBatchConfigurationGuard +dev.caskeleton.adapter.outbound.persistence.hibernate.batch.HibernateJpaBatchExecutor +dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchExecutor +dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchProfile +dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchProfileRegistry +dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.AffectedRowsExpectation +dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkDmlExecutor +dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkDmlResult +dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkOperationName +dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.HibernateBulkDmlExecutor +dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.HibernateStatelessSessionRunner +dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessRowCapExceededException +dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessSessionRunner +dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessWorkName +dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessWorkResult +dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository +dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyReaper +dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyRecordJpaRepository +dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyResponseObjectStore +dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyStoreAdapter +dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity +dev.caskeleton.adapter.outbound.persistence.idempotency.mapper.IdempotencyRecordEntityMapper +dev.caskeleton.adapter.outbound.persistence.lock.DistributedLockPersistenceConfig +dev.caskeleton.adapter.outbound.persistence.lock.LockRegistryDistributedLockAdapter +dev.caskeleton.adapter.outbound.persistence.lock.LockSettings +dev.caskeleton.adapter.outbound.persistence.migration.ConcurrentIndexMigrationInspector +dev.caskeleton.adapter.outbound.persistence.migration.FailedConcurrentIndexRecovery +dev.caskeleton.adapter.outbound.persistence.migration.FlywaySchemaPolicy +dev.caskeleton.adapter.outbound.persistence.migration.FlywayValidationGate +dev.caskeleton.adapter.outbound.persistence.migration.MigrationResource +dev.caskeleton.adapter.outbound.persistence.migration.NonTransactionalMigrationPolicy +dev.caskeleton.adapter.outbound.persistence.migration.SchemaManagementMode +dev.caskeleton.adapter.outbound.persistence.migration.SchemaVersionSnapshot +dev.caskeleton.adapter.outbound.persistence.notification.NotificationJpaPersistenceConfig +dev.caskeleton.adapter.outbound.persistence.notification.NotificationSchemaActivation +dev.caskeleton.adapter.outbound.persistence.notification.NotificationSchemaStream +dev.caskeleton.adapter.outbound.persistence.notification.configuration.NotificationJpaPersistenceFacade +dev.caskeleton.adapter.outbound.persistence.notification.crypto.DirectAeadNotificationPayloadCrypto +dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationCiphertext +dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationCryptoException +dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationHmacDigester +dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationKeyMaterialHandle +dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationKeyMaterialProvider +dev.caskeleton.adapter.outbound.persistence.notification.platform.AdminAuditEntity +dev.caskeleton.adapter.outbound.persistence.notification.platform.AdminAuditJpaRepository +dev.caskeleton.adapter.outbound.persistence.notification.platform.ConsentEntity +dev.caskeleton.adapter.outbound.persistence.notification.platform.ConsentJpaRepository +dev.caskeleton.adapter.outbound.persistence.notification.platform.ContactPointEntity +dev.caskeleton.adapter.outbound.persistence.notification.platform.ContactPointJpaRepository +dev.caskeleton.adapter.outbound.persistence.notification.platform.DeduplicationClaimEntity +dev.caskeleton.adapter.outbound.persistence.notification.platform.DeduplicationClaimJpaRepository +dev.caskeleton.adapter.outbound.persistence.notification.platform.DeliveryAttemptEntity +dev.caskeleton.adapter.outbound.persistence.notification.platform.DeliveryAttemptJpaRepository +dev.caskeleton.adapter.outbound.persistence.notification.platform.JdbcNotificationServingState +dev.caskeleton.adapter.outbound.persistence.notification.platform.JdbcReconciliationJobStore +dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaAdminOperationStore +dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaContactPointStore +dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaDeliveryAttemptStore +dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaNotificationRequestStore +dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaNotificationSideEffectStore +dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaPolicyStores +dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaProviderEventLedger +dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaRecipientDeliveryStore +dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaRecipientLeaseStore +dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaSuppressionStore +dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaTemplateRegistry +dev.caskeleton.adapter.outbound.persistence.notification.platform.NotificationRecordMapper +dev.caskeleton.adapter.outbound.persistence.notification.platform.NotificationRequestEntity +dev.caskeleton.adapter.outbound.persistence.notification.platform.NotificationRequestJpaRepository +dev.caskeleton.adapter.outbound.persistence.notification.platform.PreferenceEntity +dev.caskeleton.adapter.outbound.persistence.notification.platform.PreferenceJpaRepository +dev.caskeleton.adapter.outbound.persistence.notification.platform.ProviderEventEntity +dev.caskeleton.adapter.outbound.persistence.notification.platform.ProviderEventJpaRepository +dev.caskeleton.adapter.outbound.persistence.notification.platform.RecipientClaimSql +dev.caskeleton.adapter.outbound.persistence.notification.platform.RecipientDeliveryEntity +dev.caskeleton.adapter.outbound.persistence.notification.platform.RecipientDeliveryJpaRepository +dev.caskeleton.adapter.outbound.persistence.notification.platform.SuppressionEntity +dev.caskeleton.adapter.outbound.persistence.notification.platform.SuppressionJpaRepository +dev.caskeleton.adapter.outbound.persistence.notification.platform.TemplateVersionEntity +dev.caskeleton.adapter.outbound.persistence.notification.platform.TemplateVersionJpaRepository +dev.caskeleton.adapter.outbound.persistence.notification.platform.TenantBoundRepositoryGuard +dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxCommitEventPublisher +dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxItemEntity +dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxItemJpaRepository +dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxOutboxRecordFactory +dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.JpaNotificationInbox +dev.caskeleton.adapter.outbound.persistence.observation.JpaMetricTags +dev.caskeleton.adapter.outbound.persistence.observation.JpaRetryObservation +dev.caskeleton.adapter.outbound.persistence.observation.JpaTransactionObservation +dev.caskeleton.adapter.outbound.persistence.observation.LowCardinality +dev.caskeleton.adapter.outbound.persistence.observation.MicrometerQueryObservation +dev.caskeleton.adapter.outbound.persistence.observation.SqlDiagnosticRedactor +dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository +dev.caskeleton.adapter.outbound.persistence.outbox.OutboxEventJpaRepository +dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper +dev.caskeleton.adapter.outbound.persistence.outbox.OutboxStoreAdapter +dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity +dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlIdempotencyClaimRepository +dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlLocalTimeoutConfigurer +dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository +dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlPersistenceConfig +dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlSqlStateErrorMapping +dev.caskeleton.adapter.outbound.persistence.postgresql.array.PostgreSqlArraySupport +dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintCatalog +dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintViolationTranslator +dev.caskeleton.adapter.outbound.persistence.postgresql.copy.BoundedCopyInputStream +dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyAdminCapability +dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyFormat +dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyLimits +dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyOperationName +dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyResult +dev.caskeleton.adapter.outbound.persistence.postgresql.copy.PostgreSqlCopyLoader +dev.caskeleton.adapter.outbound.persistence.postgresql.copy.RegisteredCopyStatement +dev.caskeleton.adapter.outbound.persistence.postgresql.copy.RegisteredPostgreSqlCopyLoader +dev.caskeleton.adapter.outbound.persistence.postgresql.error.ConstraintCatalog +dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlExceptionTranslator +dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlFailureClassifier +dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlServerErrorFields +dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlState +dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency.PostgreSqlOwnerSafeIdempotencyStore +dev.caskeleton.adapter.outbound.persistence.postgresql.inbox.PostgreSqlSameStoreInboxAdapter +dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonDocument +dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonDocumentCodec +dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonPathName +dev.caskeleton.adapter.outbound.persistence.postgresql.json.PostgreSqlJsonQuerySupport +dev.caskeleton.adapter.outbound.persistence.postgresql.lock.LockWaitObservation +dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlLockExceptionTranslator +dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlLockOptions +dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlWorkClaimExecutor +dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkClaim +dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkClaimExecutor +dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkQueueDefinition +dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkQueueName +dev.caskeleton.adapter.outbound.persistence.postgresql.outbox.PostgreSqlImmutableOutboxAppendAdapter +dev.caskeleton.adapter.outbound.persistence.postgresql.outbox.PostgreSqlPollingDeliveryAdapter +dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRange +dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRangeCodec +dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRangeJdbcType +dev.caskeleton.adapter.outbound.persistence.postgresql.range.PostgreSqlRangeQuerySupport +dev.caskeleton.adapter.outbound.persistence.postgresql.write.NativeWriteName +dev.caskeleton.adapter.outbound.persistence.postgresql.write.PostgreSqlUpsertExecutor +dev.caskeleton.adapter.outbound.persistence.postgresql.write.RegisteredPostgreSqlUpsertExecutor +dev.caskeleton.adapter.outbound.persistence.postgresql.write.RegisteredUpsertStatement +dev.caskeleton.adapter.outbound.persistence.postgresql.write.UpsertConflictTarget +dev.caskeleton.adapter.outbound.persistence.postgresql.write.UpsertResult +dev.caskeleton.adapter.outbound.persistence.postgresql.write.WriteDisposition +dev.caskeleton.adapter.outbound.persistence.querydsl.PredicatePolicy +dev.caskeleton.adapter.outbound.persistence.querydsl.QueryPage +dev.caskeleton.adapter.outbound.persistence.querydsl.QuerydslJpaSupport +dev.caskeleton.adapter.outbound.persistence.security.DatabasePrivilegeReport +dev.caskeleton.adapter.outbound.persistence.security.DatabaseRolePolicy +dev.caskeleton.adapter.outbound.persistence.security.PostgreSqlRuntimeRoleVerifier +dev.caskeleton.adapter.outbound.persistence.security.SearchPathPolicy +dev.caskeleton.adapter.outbound.persistence.springdata.EntityGraphCatalog +dev.caskeleton.adapter.outbound.persistence.springdata.EntityManagerAccess +dev.caskeleton.adapter.outbound.persistence.springdata.FetchPlanApplier +dev.caskeleton.adapter.outbound.persistence.springdata.FetchPlanName +dev.caskeleton.adapter.outbound.persistence.springdata.JpaKeysetQuerySupport +dev.caskeleton.adapter.outbound.persistence.springdata.JpaRepositoryFragmentSupport +dev.caskeleton.adapter.outbound.persistence.springdata.JpaStreamExecutor +dev.caskeleton.adapter.outbound.persistence.springdata.JpaStreamScope +dev.caskeleton.adapter.outbound.persistence.springdata.KeysetPredicateBuilder +dev.caskeleton.adapter.outbound.persistence.springdata.KeysetSliceAssembler +dev.caskeleton.adapter.outbound.persistence.springdata.KeysetTerm +dev.caskeleton.adapter.outbound.persistence.springdata.RegisteredQuery +dev.caskeleton.adapter.outbound.persistence.springdata.SafeSortField +dev.caskeleton.adapter.outbound.persistence.springdata.SafeSortMapper +dev.caskeleton.adapter.outbound.persistence.springdata.SafeSortRegistry +dev.caskeleton.adapter.outbound.persistence.springdata.ScrollPolicy +dev.caskeleton.adapter.outbound.persistence.springdata.SpecificationPolicy +dev.caskeleton.adapter.outbound.persistence.transaction.BackoffCalculator +dev.caskeleton.adapter.outbound.persistence.transaction.CommitFailureClassifier +dev.caskeleton.adapter.outbound.persistence.transaction.CompletionUnknownRecord +dev.caskeleton.adapter.outbound.persistence.transaction.CompletionUnknownRecorder +dev.caskeleton.adapter.outbound.persistence.transaction.DefaultJpaRetryPolicy +dev.caskeleton.adapter.outbound.persistence.transaction.EffectiveTransactionTimeouts +dev.caskeleton.adapter.outbound.persistence.transaction.EvidenceAwareJpaTransactionManager +dev.caskeleton.adapter.outbound.persistence.transaction.FullTransactionRetryCoordinator +dev.caskeleton.adapter.outbound.persistence.transaction.JpaTransactionConfig +dev.caskeleton.adapter.outbound.persistence.transaction.JpaTransactionSettings +dev.caskeleton.adapter.outbound.persistence.transaction.OptimisticConflictTranslator +dev.caskeleton.adapter.outbound.persistence.transaction.PersistenceFailureTranslatorChain +dev.caskeleton.adapter.outbound.persistence.transaction.RetryBudget +dev.caskeleton.adapter.outbound.persistence.transaction.RetrySleeper +dev.caskeleton.adapter.outbound.persistence.transaction.SpringJpaTransactionExecutor +dev.caskeleton.adapter.outbound.persistence.transaction.SpringTransactionPort +dev.caskeleton.adapter.outbound.persistence.transaction.ThreadRetrySleeper +dev.caskeleton.adapter.outbound.persistence.transaction.TransactionDefinitionMapper +dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceContext +dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceFrame +dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceScope +dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer +dev.caskeleton.adapter.outbound.persistence.transaction.TransactionProfileRegistry +dev.caskeleton.adapter.outbound.persistence.transaction.TransactionStartBudget +dev.caskeleton.adapter.outbound.persistence.transaction.UnknownOperation diff --git a/docs/architecture/mongo-api-surface.txt b/docs/architecture/mongo-api-surface.txt index 56787e15..85535c5c 100644 --- a/docs/architecture/mongo-api-surface.txt +++ b/docs/architecture/mongo-api-surface.txt @@ -5,7 +5,7 @@ # root yet. # Update only after review with: # ./gradlew :adapter:outbound:persistence-mongo:updateMongoApiSurface -PapproveMongoApiSurfaceChange -# types: 343 +# types: 346 dev.caskeleton.adapter.outbound.mongo.MongoOptInAutoConfigurationImportFilter dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig dev.caskeleton.adapter.outbound.mongo.MongoPersistenceSettings @@ -146,8 +146,6 @@ dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfigurati dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformHealthIndicator dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformSettings dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoProfileProperties -dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStableReleaseEvidence -dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStableReleaseGate dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStartupValidator dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoTopologyProbe dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity @@ -158,6 +156,10 @@ dev.caskeleton.adapter.outbound.mongo.changestream.MongoClusterTime dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition +dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeTokenCodec +dev.caskeleton.adapter.outbound.mongo.changestream.consumer.MongoChangeStreamSource +dev.caskeleton.adapter.outbound.mongo.changestream.consumer.ReactiveMongoChangeStreamConsumer +dev.caskeleton.adapter.outbound.mongo.changestream.consumer.SpringReactiveChangeStreamSource dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeClaim dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeDeduplicationStore dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjectionResult @@ -199,6 +201,7 @@ dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdateResult dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperations dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperationsTemplate dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy +dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoUpdateOperator dev.caskeleton.adapter.outbound.mongo.imperative.atomic.ReturnDocumentMode dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkExecutor diff --git a/docs/jpa/repository-adaptation.md b/docs/jpa/repository-adaptation.md index a0d6c3d1..7dfe1a69 100644 --- a/docs/jpa/repository-adaptation.md +++ b/docs/jpa/repository-adaptation.md @@ -85,7 +85,7 @@ in a fail-closed contract (`verifyJpaReadinessRegistry` in `src/build.gradle`). |---|---| | `test` | `src/test` — hermetic unit lane, `./gradlew :adapter:outbound:persistence-jpa:test` | | `contractTest`, `integrationTest`, `migrationTest`, `failureTest`, `compatibilityTest` | `src/postgresqlIntegrationTest` — real PostgreSQL containers; selected by the `jpaPlatform*` Gradle tasks | -| `performanceTest` | `src/jpaPlatformPerformanceTest` — machine-dependent bounds, never part of `check` | +| `performanceTest` | `src/jpaPlatformPerformanceTest` — pool and `REQUIRES_NEW` connection behaviour, run by `jpaPlatformPoolContractTest`; never part of `check`. The source set keeps the plan's name; the lane asserts behaviour rather than measuring, and no numeric performance bound is claimed anywhere from it. | Docker-dependent lanes fail closed rather than skipping, matching the existing `PostgreSqlReadinessSupport.assertDockerAvailable()` convention in this leaf. diff --git a/docs/jpa/support-matrix.md b/docs/jpa/support-matrix.md index c163710c..799560ca 100644 --- a/docs/jpa/support-matrix.md +++ b/docs/jpa/support-matrix.md @@ -10,6 +10,20 @@ major changed nothing so long as the string survived somewhere in the document. declares a support level per major as a field, each gate names the Gradle task that produces its evidence, and this document describes what the registry says. +Being a rendering used to be a claim rather than a mechanism: the tables below were still typed by +hand, so a major demoted in the registry stayed Stable here and kept its full release job. +`JpaReleaseRenderingTest` now compares the database table, the gate table and `jpa-release.yml`'s +matrix and promotion lists to the registry, and `verifyJpaReleaseGateTasks` resolves every gate's +task against the real Gradle task graph. Edit the registry; these tables follow, or the build fails. + +Two renderings stayed outside that comparison until they were added to it. `jpa-nightly.yml` runs +its own matrix and nothing checked it, so a demotion corrected the release lane and left the nightly +lane certifying the major. And an Experimental major's "compatibility lane only" named no file: the +lane existed, but the registry, this document and the release workflow could each be read end to end +without establishing that, so a reader looking for it concluded there was none. An Experimental major +now has to be recorded as the target of a lane in `.github/workflows`, and a Stable lane may not run +it. + ## Database | Database | Support | Evidence | @@ -17,7 +31,7 @@ evidence, and this document describes what the registry says. | PostgreSQL 16 | Stable | full contract suite, release lane (own matrix job) | | PostgreSQL 17 | Stable | full contract suite, release lane (own matrix job) | | PostgreSQL 18 | Stable | full contract suite, release lane (own matrix job) | -| PostgreSQL 19 | Experimental | compatibility lane only; promotion requires an ADR | +| PostgreSQL 19 | Experimental | [`jpa-next-postgresql19.yml`](../../.github/workflows/jpa-next-postgresql19.yml) — `NOT_EXECUTABLE`: no `postgres:19-alpine` is published, so no container of that major has been started; promotion requires an ADR | | H2 | Local convenience | **never** evidence of PostgreSQL behaviour | Each major gets its **own release job**, because for a while it did not. The release lane passed @@ -82,6 +96,8 @@ the difference visible instead of asserting a constant against itself. See | PostgreSQL `COPY` | Admin (J4) | | Hibernate second-level cache | Advanced | | Hibernate Envers | Advanced | +| Technical auditing — `audit/AuditableEntity` | Stable (canonical) | +| Technical auditing — `auditing/AuditMetadata` | Candidate, not composed | | Multi-tenancy (column, RLS, schema, database) | Experimental | | Consistency-aware read replica | Experimental | @@ -98,6 +114,26 @@ Each row is a way the platform could pass its tests and still be wrong in produc | `runtime-role-no-ddl` | gate | the application's own credential being able to alter or drop schema objects | | `collection-fetch-pagination` | gate | a paged collection fetch silently reading the whole table and paginating in memory | +### The two audit mechanisms + +`audit/AuditableEntity` is the canonical one: `created_*`/`updated_*`, a 256-character actor, +stamped explicitly by the repository adapter. It is what the sample entities extend and what the +migrations were written for. + +`auditing/AuditMetadata` is a second, complete mechanism with different column names +(`modified_*`), a different actor length (64) and a different capture lifecycle (Spring Data +listeners). Nothing embeds it and nothing composes `JpaAuditingConfiguration`, which is why it is +listed as a candidate rather than as a capability: promoting it means choosing between reshaping it +to the canonical columns and writing a forward migration for the new ones, and that choice has not +been made. Until it is, an entity picks one mechanism or none — enforced on the production graph by +`JpaAuditMechanismRule.entitiesUseExactlyOneAuditMechanism`. + +Neither mechanism reaches a bulk or native update. Both stamp on an ordinary save — one in the +adapter, one on a managed entity's lifecycle — so a statement that goes straight to the database +leaves the audit columns showing the previous save. A bulk update of an audited entity must +therefore set the audit column in the statement, which +`JpaAuditMechanismRule.bulkUpdatesOfAuditedEntitiesStampAudit` checks over the production graph. + ## Explicitly unsupported - Reactive JPA. JPA is a blocking specification; a reactive facade over it moves the blocking call diff --git a/docs/messaging/configuration-reference.md b/docs/messaging/configuration-reference.md index 0e864ee5..31afe67a 100644 --- a/docs/messaging/configuration-reference.md +++ b/docs/messaging/configuration-reference.md @@ -7,60 +7,76 @@ > either of the old prefixes now fails startup with a message naming the key — see > `MessagingPrefixMigrationValidator`. +> **이 페이지는 실행된다.** 아래 YAML 블록은 `MessagingConfigurationBindingTest`가 이 파일에서 직접 +> 읽어 컨텍스트에 올린다. 문서가 설명하는 모양이 곧 바인딩되는 모양이라는 뜻이고, 문서를 고치면서 +> 코드를 고치지 않으면 테스트가 깨진다. 이전 판은 destination·broker·security 세 섹션을 설명했지만 +> 어떤 binder도 그것을 읽지 않았다 — 문서대로 설정한 배포는 아무것도 바뀌지 않았고 아무 말도 듣지 +> 못했다 (MSG-008). ## Destination profile ```yaml app: messaging: - destinations: - order-events: - broker: kafka-primary - kind: EVENT_STREAM # ASYNC_COMMAND | DOMAIN_EVENT | INTEGRATION_EVENT + destinations: + order-events: + broker: kafka-primary + kind: EVENT_STREAM # ASYNC_COMMAND | DOMAIN_EVENT | INTEGRATION_EVENT # | WORK_QUEUE | PUBLISH_SUBSCRIBE | EVENT_STREAM | REQUEST_REPLY - tier: M1 # M1 | M2 | M3 - physical: - topic: order.events.v1 - schema: - codec: application/json - compatibility: BACKWARD_TRANSITIVE - message-types: [order.created] - guarantees: - delivery: AT_LEAST_ONCE # AT_MOST_ONCE | AT_LEAST_ONCE - ordering: KEY # NONE | DESTINATION | PARTITION | KEY - external-side-effect: INBOX_TRANSACTIONAL - producer: - confirmation: REPLICATION_OR_PERSISTENCE_ACK - timeout: 5s - mandatory-routing: true - idempotent: true - consumer: - group: order-projection - concurrency: 6 - max-in-flight-per-ordering-unit: 1 - prefetch: 16 - handler-timeout: 30s - manual-settlement: false - retry: - mode: PAUSE_PARTITION # NONE | INLINE | BLOCKING | PAUSE_PARTITION + tier: M1 # M1 | M2 | M3 + physical: + topic: order.events.v1 + schema: + codec: application/json + compatibility: BACKWARD_TRANSITIVE + message-types: [order.created] + guarantees: + delivery: AT_LEAST_ONCE # AT_MOST_ONCE | AT_LEAST_ONCE + ordering: KEY # NONE | DESTINATION | PARTITION | KEY + external-side-effect: INBOX_TRANSACTIONAL + producer: + confirmation: REPLICATION_OR_PERSISTENCE_ACK + timeout: 5s + mandatory-routing: true + idempotent: true + consumer: + group: order-projection + concurrency: 1 # DESTINATION 순서를 요구하면 1이어야 한다 + max-in-flight-per-ordering-unit: 1 + prefetch: 16 + handler-timeout: 30s + manual-settlement: false + retry: + mode: PAUSE_PARTITION # NONE | INLINE | BLOCKING | PAUSE_PARTITION # | RETRY_DESTINATION | BROKER_DELAYED - max-attempts: 3 - initial-delay: 200ms - max-delay: 2s - multiplier: 2.0 - jitter: true - ordering-impact: PRESERVE # PRESERVE | ALLOW_REORDER - dlq: - destination: order-events-dlq - max-redrive-count: 1 - payload: - max-bytes: 1048576 - claim-check-threshold-bytes: 1048576 - key-resolver-configured: true - production: true - topology-auto-create: false + max-attempts: 3 + initial-delay: 200ms + max-delay: 2s + multiplier: 2.0 + jitter: true + ordering-impact: PRESERVE # PRESERVE | ALLOW_REORDER + dlq: + destination: order-events-dlq + max-redrive-count: 1 + payload: + max-bytes: 1048576 + claim-check-threshold-bytes: 1048576 + key-resolver-configured: true + production: false + topology-auto-create: false + order-events-dlq: + broker: kafka-primary + kind: WORK_QUEUE + physical: + topic: order.events.v1.dlt + schema: + message-types: [order.created] ``` +`dlq.destination`이 가리키는 destination도 선언되어야 한다. 선언되지 않은 이름은 부팅 실패이며, +메시지가 갈 곳 없는 DLQ 설정이 조용히 통과하지 않는다. `retry.destination`과 `dlq.destination`이 +섞여 만드는 순환(A의 retry가 B로, B의 dlq가 A로)도 하나의 그래프로 검사되어 경로와 함께 거절된다. + ## 기본값 | 설정 | 기본값 | 근거 | @@ -81,26 +97,36 @@ app: | Outbox polling | 500ms | | | metric dimension 상한 | 200 | cardinality 폭발 방지 | +`schema.codec`은 `application/json`, `schema.compatibility`는 `BACKWARD_TRANSITIVE`, +`guarantees.delivery`는 `AT_LEAST_ONCE`, `retry.mode`는 `NONE`이 기본값이다. 자동 retry가 기본으로 +꺼져 있는 이유는 순서를 흐트러뜨리거나 비멱등 side effect를 두 번 실행하는 retry가 눈에 보이는 +실패보다 나쁘기 때문이다. + ## Broker profile +브로커는 `app.messaging.brokers` 아래에 한 번만 기술한다. `type`이 어느 계열의 설정이 적용되는지 +결정하며, 다른 계열의 키(Kafka 항목의 `prefetch` 같은)는 무시되지 않고 부팅 실패로 거절된다 — +무시하면 그 줄을 쓴 사람은 무언가가 적용됐다고 믿게 된다. + ### Kafka ```yaml app: messaging: - brokers: - kafka-primary: - type: kafka - stable: true - production: true - bootstrap-servers: [broker-1:9093, broker-2:9093] - enable-idempotence: true # stable에서 필수 - acks: all # stable에서 필수 - max-in-flight-requests-per-connection: 5 # 최대 5 - delivery-timeout: 30s - enable-auto-commit: false # 항상 금지 - tls-enabled: true # production 필수 - authentication-enabled: true # production 필수 + brokers: + kafka-primary: + type: kafka + stable: true + production: false + bootstrap-servers: [broker-1:9093, broker-2:9093] + enable-idempotence: true # stable에서 필수 + acks: all # stable에서 필수 + max-in-flight-requests-per-connection: 5 # 최대 5 + delivery-timeout: 30s + enable-auto-commit: false # 항상 금지 + consumer-group: order-projection + tls-enabled: false # production이면 필수 + authentication-enabled: false # production이면 필수 ``` ### RabbitMQ @@ -108,40 +134,52 @@ app: ```yaml app: messaging: - brokers: - rabbit-primary: - type: rabbitmq - stable: true - production: true - addresses: [rabbit-1:5671] - publisher-confirms: true # stable에서 필수 - publisher-returns: true # stable에서 필수 - mandatory: true # stable에서 필수 - confirm-timeout: 5s - auto-ack: false # 항상 금지 - prefetch: 16 - quorum-queues: true # durable work queue 필수 - tls-enabled: true - authentication-enabled: true + brokers: + rabbit-primary: + type: rabbitmq + stable: true + production: false + addresses: [rabbit-1:5671] + publisher-confirms: true # stable에서 필수 + publisher-returns: true # stable에서 필수 + mandatory: true # stable에서 필수 + confirm-timeout: 5s + auto-ack: false # 항상 금지 + prefetch: 16 + quorum-queues: true # durable work queue 필수 + tls-enabled: false + authentication-enabled: false ``` +`production: true`인 브로커는 `tls-enabled`와 `authentication-enabled`가 모두 참이어야 하고, +그렇지 않으면 `KafkaProfileValidator` / `RabbitProfileValidator`가 부팅을 거절한다. 위 예시가 +`production: false`인 것은 이 페이지가 그대로 실행되는 fixture이기 때문이며, 실 배포는 셋 다 참이다. + ## 보안 ```yaml app: messaging: - security: - kafka-primary: - producer: { type: SASL_SCRAM, credential-id: kafka-producer } - consumer: { type: SASL_SCRAM, credential-id: kafka-consumer } - # admin은 application runtime에 설정하지 않는다 - hostname-verification: true - access: - publishable: [order-events] - consumable: [] - administrable: [] + security: + kafka-primary: + producer: { type: SASL_SCRAM, credential-id: kafka-producer } + consumer: { type: SASL_SCRAM, credential-id: kafka-consumer } + # admin은 application runtime에 설정하지 않는다 + hostname-verification: true + access: + publishable: [order-events] + consumable: [] + administrable: [] ``` +키는 `app.messaging.brokers`에 선언된 브로커 이름과 같아야 한다. `tls-enabled`와 `production`은 +브로커 쪽에만 있고 여기에 중복되지 않는다 — 하나의 브로커가 두 곳에서 기술되면 두 값이 어긋나는 +날이 오고, 어느 쪽이 이기는지는 아무도 모른다. + +`credential-id`는 이름일 뿐이고 자격 증명 자체가 아니다. 실제 재료는 `CredentialProvider`가 +연결 시점에 해석하므로, 설정 덤프나 힙 덤프에서 나오는 것은 이름뿐이다. producer와 consumer는 +서로 다른 `credential-id`를 써야 하며, 같으면 부팅에 실패한다. + ## Experimental / Optional 기본값은 전부 `false`다. @@ -149,12 +187,12 @@ app: ```yaml app: messaging: - experimental: - kafka-share: false - pulsar: false - nats: false - bridge: - spring-cloud-stream: false + experimental: + kafka-share: false + pulsar: false + nats: false + bridge: + spring-cloud-stream: false ``` ## Backpressure @@ -162,9 +200,24 @@ app: ```yaml app: messaging: - backpressure: - global-limit: 512 - per-destination-limit: 64 # global-limit 이하여야 한다 + backpressure: + global-limit: 512 + per-destination-limit: 64 # global-limit 이하여야 한다 ``` `per-destination-limit > global-limit`이면 global limit이 limit이 아니게 되므로 부팅에 실패한다. + +## 바인딩되지 않는 키 + +섹션은 바인딩되는데 그 안의 키 하나가 오타인 경우는 접두사 오타와 달리 조용하다 — 섹션은 붙고, +플랫폼은 뜨고, 바꾸러 온 그 설정만 적용되지 않는다. `MessagingConfigurationKeyValidator`가 +`app.messaging.destinations|brokers|security` 아래의 모든 키를 settings 레코드에서 파생한 목록과 +대조하고, 없는 키는 그 키 이름을 담아 부팅을 거절한다. + +허용 키 목록은 이 문서가 아니라 레코드에서 나온다. 문서에 목록을 적으면 필드가 추가된 날 그 +목록이 틀리고, 오타를 잡으라고 만든 검사가 정상 필드를 거절하게 된다. + +환경변수(`APP_MESSAGING_...`)는 이 검사의 대상이 아니다. `APP_MESSAGING_DESTINATIONS_ORDER_EVENTS_ +CONSUMER_PREFETCH`에서 entry 이름과 leaf를 가르는 밑줄은 둘 안에 있는 밑줄과 구별되지 않으므로, +되돌려 쪼개려면 추측해야 한다. 여기서의 추측은 정상 배포를 거절하는 쪽으로 틀리며, 그것은 배포 +매니페스트에 손으로 적어야 하는 변수에서 오타 하나를 놓치는 것보다 나쁘다. diff --git a/docs/messaging/support-matrix.md b/docs/messaging/support-matrix.md index 000cfcae..38622f17 100644 --- a/docs/messaging/support-matrix.md +++ b/docs/messaging/support-matrix.md @@ -3,6 +3,12 @@ 플랫폼이 **무엇을 보장하는지**와 **무엇을 보장하지 않는지**를 브로커별로 고정한다. 여기 없는 조합은 지원되지 않는다. +> **등급은 증거를 따른다.** `CompatibilityMatrix.Entry.hasLiveBrokerCertification()`은 선언된 +> boolean이 아니라 `CertifiedEvidence`가 가진 레인 증거에서 파생된다. RabbitMQ가 Stable에서 내려온 +> 이유가 이것이다 — 어댑터는 공유 contract 7개를 통과하고 `RabbitBrokerIT`가 실 컨테이너에서 정상 +> 경로를 돌리지만, 이 저장소의 Stable 기준인 **장애 시나리오 증거**가 하나도 없다. 레인이 생겨 +> 증거를 내면 등급은 코드 수정 없이 따라 올라간다. + > **인증 근거.** 이 표의 버전은 이 저장소의 컨테이너 레인이 실제로 실행한 이미지다. 이전 판은 > Kafka 4.2/4.3을 선언했지만 fixture는 `apache/kafka:4.1.0`, lockfile client는 4.1.1이었다 — 표와 > 코드 상수가 서로 일치했을 뿐 어느 쪽도 실행된 적이 없었다. 장애 시나리오 커버리지도 마찬가지로 @@ -25,7 +31,7 @@ | 브로커 | 등급 | 인증 기준 | Stable 기능 | 제한 | |---|---|---|---|---| | Kafka | Stable | 4.1.x | producer idempotence, consumer group, batch, pause/resume, replay, transaction capability | Share Group은 Experimental | -| RabbitMQ | Stable | 4.3.x | exchange/routing, publisher confirm, mandatory return, manual ACK, quorum queue, retry queue, DLQ | stream 및 특수 plugin 미지원 | +| RabbitMQ | Experimental | 4.3.x | exchange/routing, publisher confirm, mandatory return, manual ACK, quorum queue, retry queue, DLQ | 장애 시나리오 레인 미실행 — 증거 없음. stream 및 특수 plugin 미지원 | | Pulsar | Experimental | 4.0 LTS + 4.2 | typed publish/consume, Shared, Key_Shared, schema | transaction 미승격, 기본 비활성 | | NATS JetStream | Experimental | 2.14.x | stream, durable consumer, explicit ACK, dedupe, replay | native DLQ 없음(플랫폼이 대행), 기본 비활성 | | Artemis/JMS | Extension | 범위 밖 | adapter SPI만 | 별도 ADR + Contract Suite 통과 필요 | @@ -86,11 +92,15 @@ Kafka와 RabbitMQ가 동일한 7개 테스트를 변경 없이 통과한다. 결 |---|---| | `KafkaBrokerIT` | `acks=all`이 실제 replication 증거를 만든다 / 잘못된 토픽은 `REJECTED` / 발행-소비 왕복에서 identity 보존 및 contiguous commit | | `KafkaAmbiguityChaosIT` | 브로커를 `docker pause`로 멈춘 상태의 publish가 **`AMBIGUOUS`** 로 보고된다 (broker acceptance 없음, confirmation level `NONE`, 비-retryable) | +| `KafkaBrokerCertificationIT` | 인증 레인. Toxiproxy를 broker 앞에 두고 connection cut / confirm 유실 / 지연 / settlement 유실을 각각 주입하고, 통과한 시나리오마다 `BrokerCertificationEvidence` 한 줄을 manifest에 쓴다 | | `RabbitBrokerIT` | exchange가 confirm했는데 어떤 큐에도 바인딩되지 않은 publish가 **`REJECTED` + `UNROUTABLE`** 로 보고된다 | | `OutboxPostgresIT` | 롤백된 트랜잭션은 발행 가능한 행을 남기지 않는다 / `SKIP LOCKED` lease가 두 relay를 분리한다 / ambiguous 행이 같은 `messageId`로 재클레임된다 | | `InboxPostgresIT` | 재전달이 side effect를 두 번 적용하지 않는다 / 롤백은 예약도 되돌린다 | Docker가 없으면 `DockerAvailability` 가드로 skip되며, 이 표의 항목은 그때 **검증되지 않은 것**으로 취급한다. +`KafkaBrokerCertificationIT`만 예외다 — 인증 레인은 가드를 달지 않고 Docker가 없으면 실패한다. skip하는 +레인은 아무도 켜지 않은 브로커에 대해 성공을 보고하기 때문이다. 그래서 이 레인은 `test`에서 태그로 +제외되고 `messagingCertificationTest`로만 실행된다. ### 3. 장애 시나리오 커버리지 (`BrokerFailureMatrix`) @@ -109,6 +119,18 @@ Docker가 없으면 `DockerAvailability` 가드로 skip되며, 이 표의 항목 커버해야 하고, Experimental 어댑터는 `LIVE_BROKER` 커버리지를 주장할 수 없다. 커버리지는 *능력*이 아니라 *무엇을 실제로 돌렸는지*의 기록이다. +**증거의 출처.** `CertifiedEvidence`는 더 이상 손으로 쓴 목록이 아니라 +`messaging-testkit/src/main/resources/messaging/broker-certification-evidence.jsonl`을 읽는다. 그 파일은 +`messagingCertificationTest` 레인이 실제 Kafka 컨테이너에 장애를 주입하며 만들어낸 출력이고, +`verifyMessagingCertificationEvidence`가 커밋된 manifest와 이번 실행의 출력을 대조해 다르면 빌드를 +실패시킨다. 즉 **manifest를 손으로 고치면 게이트가 깨지고, 레인을 돌리면 manifest가 다시 쓰인다.** + +오늘 Kafka가 가진 증거는 `connection-cut-after-write` · `confirm-timeout` · `high-latency` · +`settlement-lost` 네 개다. `connection-refused`는 남은 gap이며 그 이유가 있다 — Kafka producer는 연결 +존재 여부를 알기 전에 레코드를 버퍼에 넣으므로, 연결 거부는 전송에 대해 아무것도 증명하지 못하는 +delivery timeout으로 나타난다. 이를 `REJECTED`로 보고하는 것은 이 플랫폼이 금지한 추측이므로, +시나리오는 `CertifiedEvidence.knownGaps`가 이름으로 들고 있는 미커버 항목으로 남는다. + ### 실 브로커가 실제로 잡아낸 결함 이 스위트들은 장식이 아니다. 작성 과정에서 결정적 테스트가 통과하는데 실 인프라에서 실패한 diff --git a/docs/notification/api-surface-snapshot.txt b/docs/notification/api-surface-snapshot.txt index e51453a9..891d9582 100644 --- a/docs/notification/api-surface-snapshot.txt +++ b/docs/notification/api-surface-snapshot.txt @@ -26,8 +26,12 @@ dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.Notification dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformMode dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationProviderAssembly +dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationSecretRequirements +dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationSmtpProviderConfig +dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationSmtpSettings dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderRuntimeAssembler dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderType +dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.SmtpProviderRuntimeAssembler dev.caskeleton.adapter.outbound.notification.platform.dispatch.AttemptPermit dev.caskeleton.adapter.outbound.notification.platform.dispatch.CapabilityReconciliationGateway dev.caskeleton.adapter.outbound.notification.platform.dispatch.ConfiguredProfileCatalog @@ -56,6 +60,7 @@ dev.caskeleton.adapter.outbound.notification.platform.observation.LoggingNotific dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthReporter dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthSnapshot dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationServingThresholds +dev.caskeleton.adapter.outbound.notification.platform.provider.EmailAttachments dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults dev.caskeleton.adapter.outbound.notification.platform.provider.UnconfiguredAttachmentResolver dev.caskeleton.adapter.outbound.notification.platform.provider.apns.ApnsFailureClassifier @@ -89,6 +94,7 @@ dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesRequestMap dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesSuppressionUpdater dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SnsCertificateProvider dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SnsSignatureVerifier +dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.JavaMailSenderSmtpDispatch dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatch dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatchException dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier @@ -122,6 +128,7 @@ dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactorContextBrid dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactorNotificationOrchestrator dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmCallbackPayloadProtection dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector +dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmNotificationPayloadProtection dev.caskeleton.adapter.outbound.notification.platform.security.CredentialGeneration dev.caskeleton.adapter.outbound.notification.platform.security.HmacProviderRequestIdHasher dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager @@ -137,6 +144,7 @@ dev.caskeleton.adapter.outbound.notification.platform.template.NotificationTempl dev.caskeleton.adapter.outbound.notification.platform.template.PlaceholderTemplateEngine dev.caskeleton.adapter.outbound.notification.platform.template.Sha256MessageDigestAdapter dev.caskeleton.adapter.outbound.notification.platform.template.TemplateSlotMode +dev.caskeleton.adapter.outbound.notification.platform.template.TemplateSlotPolicy dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafNotificationRenderer dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafStringTemplateEngine dev.caskeleton.adapter.outbound.notification.provider.AttemptCorrelationId @@ -384,6 +392,7 @@ dev.caskeleton.application.notification.platform.callback.ProviderEventProjector dev.caskeleton.application.notification.platform.callback.ProviderEventRecord dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId dev.caskeleton.application.notification.platform.callback.ProviderEventSource +dev.caskeleton.application.notification.platform.callback.ProviderRequestIdHash dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector dev.caskeleton.application.notification.platform.callback.SuppressionFacts dev.caskeleton.application.notification.platform.callback.VerifiedCallback @@ -400,6 +409,7 @@ dev.caskeleton.application.notification.platform.contact.LegacyFcmRegistrationTo dev.caskeleton.application.notification.platform.contact.MobilePushTarget dev.caskeleton.application.notification.platform.contact.PhoneNumber dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue +dev.caskeleton.application.notification.platform.dispatch.AcceptNotificationApplicationUseCase dev.caskeleton.application.notification.platform.dispatch.ApplicationReceiptServiceImpl dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard dev.caskeleton.application.notification.platform.dispatch.CancelNotificationApplicationUseCase @@ -430,6 +440,7 @@ dev.caskeleton.application.notification.platform.dispatch.PolicyRoutePlanner dev.caskeleton.application.notification.platform.dispatch.ProviderDispatchGatewayPort dev.caskeleton.application.notification.platform.dispatch.ProviderProfileCatalogPort dev.caskeleton.application.notification.platform.dispatch.ProviderRequestIdHasherPort +dev.caskeleton.application.notification.platform.dispatch.PublishNotificationTemplateApplicationUseCase dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryRecord dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort dev.caskeleton.application.notification.platform.dispatch.RecipientLease @@ -499,12 +510,16 @@ dev.caskeleton.application.notification.platform.policy.SuppressionReason dev.caskeleton.application.notification.platform.policy.SuppressionScope dev.caskeleton.application.notification.platform.policy.SuppressionSource dev.caskeleton.application.notification.platform.policy.SuppressionStorePort +dev.caskeleton.application.notification.platform.port.in.AcceptNotificationCommand +dev.caskeleton.application.notification.platform.port.in.AcceptNotificationUseCase dev.caskeleton.application.notification.platform.port.in.CancelNotificationCommand dev.caskeleton.application.notification.platform.port.in.CancelNotificationUseCase dev.caskeleton.application.notification.platform.port.in.GetNotificationQuery dev.caskeleton.application.notification.platform.port.in.GetNotificationUseCase dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackCommand dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackUseCase +dev.caskeleton.application.notification.platform.port.in.PublishNotificationTemplateCommand +dev.caskeleton.application.notification.platform.port.in.PublishNotificationTemplateUseCase dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationCommand dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationUseCase dev.caskeleton.application.notification.platform.port.in.SubmitNotificationCommand @@ -539,6 +554,8 @@ dev.caskeleton.application.notification.platform.push.ReceiptKind dev.caskeleton.application.notification.platform.push.ReceiptResult dev.caskeleton.application.notification.platform.security.AccessContext dev.caskeleton.application.notification.platform.security.ContactPointProtector +dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection +dev.caskeleton.application.notification.platform.security.NotificationPayloadUnreadableException dev.caskeleton.application.notification.platform.security.NotificationRedactor dev.caskeleton.application.notification.platform.security.ProtectedContactPoint dev.caskeleton.application.notification.platform.security.SafeDiagnosticContext diff --git a/docs/notification/configuration-reference.md b/docs/notification/configuration-reference.md index 42c6bb22..9512ba13 100644 --- a/docs/notification/configuration-reference.md +++ b/docs/notification/configuration-reference.md @@ -41,6 +41,12 @@ still waiting on gets claimed by a second worker, and the recipient receives the | `callbacks.enabled` | `APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED` | `false` | boolean | | `callbacks.max-body-bytes` | `APP_NOTIFICATION_PLATFORM_CALLBACK_MAX_BODY_BYTES` | `65508` | 1..65508 | | `callbacks.replay-skew` | `APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW` | `5m` | positive | +| `callbacks.trusted-proxies` | `APP_NOTIFICATION_PLATFORM_CALLBACK_TRUSTED_PROXIES` | *(empty)* | CSV of peer addresses | + +여러 provider가 요청 URL에 서명하므로, 그 URL을 잘못 재구성하면 정상 webhook이 전부 서명 실패가 된다. +`trusted-proxies`가 비어 있으면 forwarded 헤더를 **믿지 않고** 컨테이너가 관측한 값을 쓴다. 무조건 믿으면 +아무 호출자나 자기 서명이 검증될 URL을 고를 수 있어 서명 자체가 무의미해진다. 로드밸런서 뒤에 있는 배포는 +그 peer를 명시한다. 65508 is not a round number by accident: it is the ciphertext column's 65536 bytes minus the AES-GCM nonce and tag. A larger configured value would pass every check above the database and fail the @@ -69,6 +75,43 @@ them, because the keys are deployment-chosen; supply them as YAML or as A profile pins provider type, environment, credential profile, timeouts, concurrency and rate limit. Sender identity and credential profile are separate concerns. +## SMTP relay + +The one provider profile the template ships, off. A deployment that wants the common case sets +`APP_NOTIFICATION_PLATFORM_SMTP_ENABLED=true` and the relay address; one that wants a different +profile id or a second family declares it in its own YAML instead. + +The profile and the relay are separate tables below because they answer different questions. The +profile says *which* provider serves EMAIL and under what limits; the relay says *what the transport +is*. Host, port and credentials are not here at all — they stay `spring.mail.*`, because Spring +already owns them and a second spelling would be a second thing to keep in step. + +| Property | Environment variable | Default | Bound | +|---|---|---|---| +| `providers.smtp.enabled` | `APP_NOTIFICATION_PLATFORM_SMTP_ENABLED` | `false` | boolean | +| `providers.smtp.primary-for-channel` | `APP_NOTIFICATION_PLATFORM_SMTP_PRIMARY` | `true` | boolean; exactly one primary per channel | +| `providers.smtp.environment` | `APP_NOTIFICATION_PLATFORM_SMTP_ENVIRONMENT` | `local` | required when enabled | +| `providers.smtp.credential-profile` | `APP_NOTIFICATION_PLATFORM_SMTP_CREDENTIAL_PROFILE` | `default` | resolved through `SecretMaterialProvider`, never inline material | +| `providers.smtp.timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_TIMEOUT` | `10s` | positive, finite | +| `providers.smtp.max-concurrency` | `APP_NOTIFICATION_PLATFORM_SMTP_MAX_CONCURRENCY` | `4` | positive | +| `providers.smtp.rate-per-second` | `APP_NOTIFICATION_PLATFORM_SMTP_RATE_PER_SECOND` | `10` | positive | + +| Property | Environment variable | Default | Bound | +|---|---|---|---| +| `smtp.tls-mode` | `APP_NOTIFICATION_PLATFORM_SMTP_TLS_MODE` | `STARTTLS_REQUIRED` | `STARTTLS_REQUIRED` or `IMPLICIT_TLS` | +| `smtp.sender-identity` | `APP_NOTIFICATION_PLATFORM_SMTP_SENDER_IDENTITY` | `no-reply@example.invalid` | address | +| `smtp.connect-timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_CONNECT_TIMEOUT` | `5s` | positive, finite | +| `smtp.read-timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_READ_TIMEOUT` | `10s` | positive, finite | +| `smtp.write-timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_WRITE_TIMEOUT` | `10s` | positive, finite | +| `smtp.max-concurrency` | `APP_NOTIFICATION_PLATFORM_SMTP_DISPATCH_CONCURRENCY` | `4` | positive | + +The TLS mode enum has no plaintext member. An unencrypted relay is refused by construction rather +than by a validator somebody has to remember to run. + +The default sender is an RFC 2606 reserved domain that resolves nowhere, so a deployment that forgot +to set one produces a traceable bounce instead of mail apparently sent from an address it does not +own. + ## Startup failures Startup fails rather than degrading when: @@ -91,6 +134,33 @@ All key material arrives through `SecretMaterialProvider`. Nothing is read from committed file, or from a plaintext log. Contact point encryption and lookup HMAC keys must be distinct, and the encryption key must be exactly 256 bits. +Eight purposes, eight keys. Each is base64 of at least 32 bytes and each must differ from every +other; the platform decodes them at startup and refuses to boot if one is blank, short or shared. A +blank value used to be skipped, which meant the platform started without the key and found out on +the first contact point — in production, on a recipient's notification. + +Every default below is **unset**, deliberately. Supply the values out of band, per environment. Do +not write one into this table, into `application.yml`, into an `.env` file that is tracked, or into +any example: a value that appears in the repository is a value that has been disclosed. + +| Purpose | Key material | Active key id | +|---|---|---| +| Contact point encryption | `APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY` | `APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY_ID` | +| Contact point lookup HMAC | `APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY` | `APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY_ID` | +| Callback signing | `APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY` | `APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY_ID` | +| Callback fingerprint HMAC | `APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY` | `APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY_ID` | +| Provider credential encryption | `APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY` | `APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY_ID` | +| Provider request lookup HMAC | `APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY` | `APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY_ID` | +| Payload encryption | `APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY` | `APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY_ID` | +| Web Push VAPID signing | `APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY` | `APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY_ID` | + +A key id is not secret — an id identifies key material without revealing it — but it is required, +and it has no default on purpose. A constant id makes a rotation indistinguishable from the key it +replaced, so nothing could decrypt what was written before it. Change the id in the same deployment +that changes the material, and keep the superseded key readable under its old id until the data it +wrote has been re-encrypted. The rotation sequence is in +[at-rest-threat-model.md](at-rest-threat-model.md). + ## Readiness The platform contributes a `notifications` actuator endpoint and a health indicator. It reports DOWN diff --git a/docs/registries/env-keys.yaml b/docs/registries/env-keys.yaml index 78bb7833..5566d3de 100644 --- a/docs/registries/env-keys.yaml +++ b/docs/registries/env-keys.yaml @@ -1672,9 +1672,12 @@ env_keys: required_test: idempotency-contract:ttl-applied - name: APP_IDEMPOTENCY_PROVIDER + # postgresql selects the owner-safe V2 store on the primary data source. It had no value here + # while the store, its schema stream and its integration suite all existed, so the capability + # could only be reached by constructing it in a test. type: enum default: jdbc - allowed_values: [disabled, jdbc, redis] + allowed_values: [disabled, jdbc, redis, postgresql] classification: public-config required: false reload_policy: restart-only @@ -4970,6 +4973,21 @@ env_keys: compatibility_impact: behavior-change required_test: adapter-contract:notification-callback-body-bound + - name: APP_NOTIFICATION_PLATFORM_CALLBACK_TRUSTED_PROXIES + # source: NTF-001 — peers whose forwarded headers may be believed when reconstructing the URL a + # provider signed. Empty means the resolver uses what the container observed; honouring + # forwarded headers unconditionally would let any caller pick the URL its signature is checked + # against, which defeats the signature. + type: csv + default: "" + allowed_values: null + classification: security-relevant + required: false + reload_policy: restart-only + owner_branch: worktree-notification-platform + validation: none + compatibility_impact: behavior-change + required_test: adapter-contract:notification-callback-url-resolution - name: APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW # source: NTF-025 — how far a callback timestamp may differ from local time before it is treated as a replay type: duration diff --git a/docs/reviews/2026-08-19-p1-remediation-status.md b/docs/reviews/2026-08-19-p1-remediation-status.md new file mode 100644 index 00000000..b540b393 --- /dev/null +++ b/docs/reviews/2026-08-19-p1-remediation-status.md @@ -0,0 +1,101 @@ +# P1 remediation status — the five module reviews + +**Reviews:** `docs/reviews/2026-08-14-{jpa,graphql,messaging,mongodb,notification}-module-code-review.md` +**Baseline:** the P0 pass was already complete when this pass began; this file records the P1 pass, +which is complete — all 31 findings closed, two of them by establishing that the review's own +accepted outcome was already met rather than by writing code. +**Verified at:** repo-wide `test`, `spotlessCheck`, `verifyCleanArchitectureDependencies`, +`verifyEnvKeys`, `verifyPublicPathSnapshot` and the root `CleanArchitectureTest` all green. + +This file exists because the status was previously carried only in conversation and had to be +reconstructed. A finding's row is the claim; the evidence column is where the claim is falsifiable. + +## The recurring defect + +Nearly every P1 in these five reviews is one shape: **a control that exists, passes its own tests, +and is reached by nothing.** Not a wrong algorithm — an unreachable one. The tests passed because +they constructed the class directly; the capability was absent because no configuration could. + +Examples closed in this pass: the Mongo typed-update path (`MongoBulkExecutor` and +`MongoAtomicOperationsTemplate` were constructed by nothing), the entire Mongo change-stream +capability (no production code opened a stream at all), `GraphQlBatchLoaderRegistrar.register` (no +caller, so the wrong-key refusal never ran on an executing query), `PublishOptions.timeout()` (read +by nobody on the real publish path), `markExhausted` (no caller, so a row that spent its budget +stayed `AMBIGUOUS` forever), `OutboxMessagePublishPort.publishForOutcome` (no caller, so every +ambiguous publish collapsed into an exception), the outbox relay itself (no bean ran a pass), and +`BackpressureController` (a limiter the publish path never consulted, reporting `globalInFlight: 0` +under any load). + +The lesson worth keeping: **a passing unit test is not evidence a capability exists.** The +reachability question — what constructs this, and on which request path — has to be asked +separately, and several of the tests added in this pass exist only to ask it. + +## Status + +| Finding | Verdict | Evidence | +| --- | --- | --- | +| JPA-005 | closed already | roll-up in `NotificationRequestStatusPolicy`; port is tenant-scoped; ArchUnit `PERSISTENCE_DOES_NOT_DEPEND_ON_APPLICATION_SERVICES` | +| JPA-006 | closed already | `PersistenceJpaRootAutoConfiguration` is in `AutoConfiguration.imports` and imports the real runtime config | +| JPA-007 | closed under item 6 | the review offers two accepted outcomes: full integration, or the interim state under item 6's two conditions. Both hold and were verified in code — `FullTransactionRetryCoordinator:89-93` resolves the policy per call from the calling profile, and `DefaultJpaRetryPolicy:61-64` returns on `!failure.retryable()` before consulting the category allowlist | +| JPA-008 | closed | nothing registered a `VendorFailureTranslator`, so every executor ran `withoutCatalogs()` and a 40001 never reached the retry classifier | +| JPA-026 | closed | a rehydrated callback event had no matcher once `attempt_id` was null; hash fallback + write-back added | +| JPA-028 | closed | the reaper query had no caller, uploads had no terminal state, cleanup decided from a lease it had read rather than claiming | +| JPA-029 | closed already | tuple cutoff implemented; the signal is documented best-effort by decision | +| GQL-004 | not a defect | every evidence bullet false at HEAD; no WebFlux dependency exists, transport disagreement fails startup | +| GQL-011 | closed | unkeyed truncated SHA-256 over actor/tenant replaced with a keyed, rotating HMAC; no default key | +| GQL-015 | closed | schema extensions were invisible to the comparator, so a field removed by `extend type` produced no change at all | +| GQL-016 | closed | the batch executor returned the loader's map verbatim, so the wrong-key refusal and missing-key policy never ran | +| GQL-017 | closed | the blocking bridge was opt-in and null by default, so a reactive runtime ran blocking chunks on the event loop | +| MNG-010 | closed | the guardrail was a `Set` asserted against itself; now an ArchUnit rule over the real production graph at the composition root | +| MNG-012 | closed | a failed abort or close on a committed transaction was discarded by a closing brace | +| MNG-018 | closed | TLS and auth were asserted against a settings object; four TLS cases now run against real servers | +| MNG-024 | closed | the reactive binder carried read preference and write concern only, so reactive writes skipped auditing and callbacks | +| MNG-026 | closed | both typed-update paths were unreachable, and the bulk executor could be built with no policy at all | +| MNG-028 | closed | no production code opened a change stream; the consumer now owns load → resume → stream → project → checkpoint | +| MSG-006 | closed | `markExhausted` had no caller and the scheduler's backoff was never written; a relay worker now runs passes | +| MSG-008 | closed | validators were beans nothing injected, and the documented configuration bound nowhere; destination/broker/security sections now bind under `app.messaging` and the reference document is executed by a test | +| MSG-010 | closed | `BackpressureController` deleted as an unreachable duplicate; its one unique capability moved into the gate that is called | +| MSG-012 | closed | header values accepted CR/LF/NUL, identifiers were bounded in chars not bytes, `traceparent` was any string, denylists matched exact spellings only | +| MSG-014 | closed | `hasLiveBrokerCertification` is derived from recorded evidence rather than declared, and the evidence is now a manifest a fault lane wrote against a real broker rather than a hand-authored list | +| MSG-015 | semantic half closed | the outcome-aware publish path is wired; the anti-corruption bridge needs a `modules.json` edge and is an architecture decision | +| MSG-016 | closed | no reserved name existed for tenant, so every consumed message was rebuilt with none; the canonical metadata is now columns, and the CDC event key moved off `destination`, which had put every message on a topic onto one partition | +| MSG-017 | closed | the timeout is an absolute deadline; contradictory `PublishResult` combinations are unrepresentable; `brokerHints` removed | +| NTF-015 | closed | webhook signing was one shared secret for every subscription; SES silently dropped attachments and now sends them as raw MIME | +| NTF-016 | closed | retired keys were forced to one purpose so a provider-credential drain failed; required purposes now follow enabled capabilities | +| NTF-019 | closed already | split inbound ports carry capabilities; four ArchUnit rules with negative fixtures close the gate | + +## Found while closing, not in any review + +`SmtpMimeMessageFactory` handed JavaMail the resolver's one-shot stream. JavaMail reads an +attachment twice — once to choose the part's transfer encoding, once to write it — so the second +read returned nothing and the message went out announcing a filename and carrying no bytes, with +the attempt recorded as accepted. Every existing test asserted on the outcome of the send rather +than on what was sent, which is why a bug that emptied every attachment on the one provider family +this platform can actually assemble survived a full review pass. + +`SmtpAttachmentBodyTest` now reads the attachment back off the serialised message the way a +receiving client would. It was confirmed to fail against the original code and pass against the +fix, because a regression test nobody has watched fail is a regression test of unknown shape. + +## Observations that are not open P1 items + +Both were checked against the reviews rather than assumed, because "looks unfinished" and "is an open +finding" are different claims. + +**The GraphQL cursor key gate.** `GraphQlPlatformStartupValidator` refuses to start a production +deployment without `backend.graphql.cursor.key-ids`, and nothing signs a cursor with it: +`GraphQlConnectionAssembler` and `HmacGraphQlCursorCodec` have no consumer anywhere in this +repository, because the template ships no paginating resolver. This is not GQL-010, which is about +the codec's framing, rotation and scope and is implemented — versioned framing, the codec choosing +the active key rather than the caller, v1 decode kept only for migration, tenant scope bound. It +belongs to the `modelled` grading the leaf's own `GraphQlPolicyRequestPathTest` already documents in +as many words. Declaring beans for it would create the unreachable-control defect this pass exists +to close, and the present behaviour fails closed, which is the safe direction. Left as it is, on +purpose. + +## Product work, not remediation + +**Notification provider transports.** Only SMTP has a `ProviderRuntimeAssembler`. +`NotificationProviderAssembly` refuses to start a profile whose family has no assembler, naming the +transport as a seam rather than an implementation — which is the honest fail-closed behaviour, not a +defect. Building SES, Twilio, FCM, APNs and WebPush transports is product work. diff --git a/docs/runbooks/outbox-publish-failed.md b/docs/runbooks/outbox-publish-failed.md index 52e29b55..45088e21 100644 --- a/docs/runbooks/outbox-publish-failed.md +++ b/docs/runbooks/outbox-publish-failed.md @@ -25,7 +25,9 @@ status: stub ### Step 1 — 확인 1. ERROR log에서 `OUTBOX_PUBLISH_FAILED` 라인 확인: `event_type`, `event_id`, `correlation_id`, `attempt_count` 추출 -2. broker 상태 확인: `APP_MESSAGING_BROKER` 값(공백이면 messaging 비활성)과 broker endpoint 가용성 +2. broker 상태 확인: `APP_MESSAGING_BROKER` 값과 broker endpoint 가용성. 이 키는 활성화 스위치가 + 아니라 **선택자**다 — messaging을 끄는 것은 `APP_MESSAGING_ENABLED=false`이고, 이 값을 비운다고 + messaging이 꺼지지는 않는다. - `APP_MESSAGING_BROKER`가 공백인 채로 relay가 켜져 있으면 **애플리케이션이 기동하지 않는다** (`OutboxRelayBrokerRequirementValidator`, MSG-024). 이 조합에서는 publish가 전부 `AdapterDisabledException`으로 실패하며 PENDING row가 DEAD까지 소진되기 때문이다. diff --git a/docs/study/postgresql-jpa-walkthrough.ko.md b/docs/study/postgresql-jpa-walkthrough.ko.md new file mode 100644 index 00000000..3622295e --- /dev/null +++ b/docs/study/postgresql-jpa-walkthrough.ko.md @@ -0,0 +1,3619 @@ +# PostgreSQL / JPA 모듈 완전 해부 + +> **성격**: 정책 문서가 아니라 **읽기 기록**이다. `adapter:outbound:persistence-jpa` leaf와 +> 그 주변(app-bootstrap 배선, application-core 포트, Flyway 마이그레이션, 테스트 레인)에 +> 실제로 구현되어 있는 것을 코드 기준으로 하나씩 확인하면서, "무엇이 있는가"와 "왜 그렇게 +> 되어 있는가"를 정리한 글이다. +> +> SSOT는 여전히 `src/config/architecture/modules.json`, 모듈 `CLAUDE.md`, +> `docs/jpa/support-matrix.md`다. 이 문서와 그것들이 어긋나면 그쪽이 맞다. +> +> 마지막 장(§17)에 **읽으면서 발견한 손볼 거리**를 우선순위대로 모아 두었다. + +> **커버리지 범위 (읽은 것과 안 읽은 것)** +> +> JPA와 PostgreSQL은 별개 모듈이 아니다. 레지스트리에 `adapter-outbound-persistence-jpa` +> 하나만 있고 그 leaf가 둘 다 갖는다 — 모듈 `build.gradle` 첫 줄이 +> *"merged RDBMS base + PostgreSQL vendor module"* 이라고 말한다. 벤더 중립부(`api`, +> `transaction`, `springdata`, `hibernate`, `migration`)와 벤더부(`postgresql`, `h2`)는 같은 +> leaf 안의 패키지로 갈라져 있고 ArchUnit이 그 경계를 지킨다. 분리해서 읽는 게 불가능하다. +> +> | 영역 | 파일 | 본문 비중 | 이 문서에서 | +> |---|---|---|---| +> | **JPA/PostgreSQL 플랫폼** | 266 | 66.3% | **전문 정독** (§2–10, §12–15) | +> | `fileserver` 스토어 | 25 | 8.7% | 스키마·경계·게이트만 (§11.1) | +> | `notification` 스토어 | 53 | 25.0% | 스키마·경계·게이트만 (§11.2) | +> +> 뒤의 둘을 전문 정독하지 않은 것은 **측정에 근거한 결정**이다. 두 패키지는 JPA 플랫폼 +> 타입을 **하나도 import하지 않고**(§11.0), 실제 의존은 각자의 `application-core` 포트다. +> 플랫폼 이해에 더해지는 것이 없고, 그 둘을 손볼 때는 시작점 자체가 `application-core`라 +> 별도 문서가 맞다. 이 문서는 그 둘에 대해 **JPA 모듈 쪽에서 알아야 할 것** — 왜 여기 +> 있고, 왜 entity scan에서 빠졌고, 어떤 게이트를 공유하는가 — 까지만 다룬다. + +--- + +## 0. 숫자로 먼저 보는 지도 + +무작정 파일을 열기 전에 규모부터 재 봤다. 규모를 모르면 "다 읽었다"는 말을 할 수가 없다. + +| 항목 | 값 | +|---|---| +| 레지스트리 전체 leaf 수 | 44 (`src/config/architecture/modules.json`) | +| 이 leaf의 Gradle path | `:adapter:outbound:persistence-jpa` | +| 허용 의존 | `domain-core`, `application-core`, `shared-contract` | +| runtime membership | `app-bootstrap`, `sample-portfolio` | +| `src/main` Java 파일 | 344개 / 약 26,657줄 | +| `src/main` 패키지 수 | 56개 (top-level child 22개) | +| top-level child 패키지 | `api audit auditing cache config envers experimental failure fileserver h2 hibernate idempotency lock migration notification observation outbox postgresql querydsl security springdata transaction` | +| `@Entity` 클래스 | 23개 | +| 매핑된 테이블 | 20개 (`idempotency_record`, `outbox_event`, `fs_*` 6개, `notification_*` 12개) | +| Spring Data 리포지토리 인터페이스 | 21개 | +| `src/test` (hermetic 단위) | 100개 | +| `src/postgresqlIntegrationTest` (실 PostgreSQL) | 69개 | +| `src/testkit` (재사용 규칙/픽스처) | 41개 | +| `src/jpaPlatformPerformanceTest` (풀 동작 계약) | 3개 | +| Flyway 마이그레이션 스크립트 | 29개 / 8개 독립 스트림 | + +PostgreSQL과 직접 관련된 레지스트리 leaf는 세 개다. + +| leaf | 성격 | +|---|---| +| `adapter-outbound-persistence-jpa` | 이 글의 주인공. JPA + PostgreSQL 어댑터이자 "관계형 퍼시스턴스 플랫폼" | +| `messaging-outbox-jdbc-postgresql` | vendored messaging 제품의 outbox 구현 (별개 계보) | +| `messaging-inbox-jdbc-postgresql` | 같음 | + +messaging 쪽 두 개는 **이 애플리케이션의 레이어가 아니라 vendored 라이브러리**라서 계보가 +완전히 다르다. 루트 `CLAUDE.md`가 그 예외를 명시적으로 적어 두고 있고, 이 글은 +`adapter:outbound:persistence-jpa` 하나에 집중한다. + +--- + +## 1. 이 모듈의 정체 — leaf 하나 안에 들어온 "플랫폼" + +처음 `src/main/java/.../persistence` 아래를 보고 당황했다. Clean Architecture 어댑터라면 +"엔티티 + 리포지토리 + 매퍼 + 어댑터" 정도를 기대하는데, `springdata`, `hibernate/batch`, +`postgresql/copy`, `migration`, `experimental/replica` 같은 게 나온다. + +이유는 `docs/jpa/repository-adaptation.md`에 적혀 있었다. + +- 원래 설계(`docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md`)는 + 이걸 **18개의 Stable Gradle 라이브러리 모듈 + 7개 Experimental 모듈**로 모델링한다. +- 그런데 이 저장소는 **fail-closed 레지스트리**를 쓴다. `src/settings.gradle`이 + `ca.architecture-registry` 플러그인을 통해 `modules.json`을 검증하고, 거기 등록되지 않은 + 프로젝트는 존재할 수 없다. Gradle 프로젝트를 25개 더 만드는 건 `AGENTS.md`의 HARD-STOP #5 + 위반이다. +- 그래서 **설계상의 모듈이 이 leaf 안의 패키지 경계가 됐다.** + +즉 이 leaf의 top-level 패키지 하나하나가 원래는 별도 라이브러리였다는 뜻이다. 그걸 알고 +나서야 패키지 구조가 읽힌다. + +| 패키지 | 원래 모듈 | 소유 | +|---|---|---| +| `api` (+`.capability/.error/.query/.transaction`) | `jpa-core-api` | 프레임워크 없는 계약: 이름, 안정 예외 계층, 트랜잭션/재시도 프로파일, keyset 커서 | +| `transaction` | `jpa-transaction` | 커밋 증거, Spring 실행기, 전체 트랜잭션 재시도, completion-unknown | +| `springdata` | `jpa-spring-data` | 리포지토리 fragment, 안전 정렬, fetch plan, keyset 실행, 스트림 가드 | +| `hibernate` (+`.batch/.bulk/.stateless`) | `jpa-hibernate` | statement inspector, 통계, batch, bulk DML, stateless session | +| `postgresql` (+ 7개 하위) | `jpa-postgresql`, `jpa-postgresql-copy` | SQLSTATE 분류, 락/작업 클레임, JSONB, 배열/범위, 업서트, COPY | +| `migration` | `jpa-migration-flyway` | Flyway 정책, validate 게이트, concurrent index 가드 | +| `auditing`,`cache`,`envers`,`querydsl`,`security`,`observation` | 각각 대응 모듈 | opt-in capability + 런타임 롤 검증 | +| `experimental` | `jpa-experimental/*` | 멀티테넌시, RLS, 리드 리플리카, forward-compat 레인 — 전부 플래그 게이트 | +| `testkit` (`src/testkit/java`) | `jpa-testkit*` | ArchUnit 규칙, 픽스처, 쿼리/플랜 어서션, PostgreSQL 매트릭스, 실패 주입 | + +그리고 **두 개는 이 leaf 밖으로 나갔다.** + +- `jpa-spring-boot-starter` → `:app-bootstrap`의 `dev.caskeleton.bootstrap.autoconfigure.jpa`. + 어댑터 leaf가 스스로를 auto-configure하면 안 되기 때문이다. 조립은 컴포지션 루트 소유다. +- `jpa-testkit*` → 별도 Gradle 프로젝트가 아니라 **별도 source set**. 의존이 test + configuration에만 선언되니 "production 모듈이 testkit에 의존하지 않는다"는 보장이 + 프로젝트를 새로 만들지 않고도 성립한다. + +경계 자체는 `JpaModuleBoundaryTest`가 지킨다. production root의 직계 자식 패키지 **닫힌 +카탈로그**를 들고, 실제 트리와 **정확히 같은지** 비교하고, 관측된 top-level edge가 선언된 +것인지 확인하고, 사이클을 거부한다. + +여기 재미있는 실패 기록이 문서에 남아 있다. + +> 카탈로그에 13개가 적혀 있고 트리에는 22개가 있었다. 그래서 `audit`, `config`, `failure`, +> `fileserver`, `h2`, `idempotency`, `lock`, `notification`, `outbox` 9개는 **아무 규칙의 +> 지배도 받지 않았고**, `transaction → postgresql` / `postgresql → transaction` 사이클이 +> 통과했다. + +"카탈로그와 트리의 정확한 동등성 검사"가 없으면 **새 패키지는 누락으로 인해 초록불이 된다**는 +것. 이게 이 저장소 전반에 반복되는 패턴이다 — *빠뜨림이 곧 통과가 되는 게이트는 게이트가 +아니다.* + +### 이 leaf가 명시적으로 아닌 것 + +모듈 `CLAUDE.md`가 못 박는다. + +- **NoSQL 중립 코어가 아니다.** MongoDB/Redis/DynamoDB 어댑터는 형제 모듈이고, 이 모듈에 + 의존해서는 안 된다. `adapter-persistence-nosql-core`를 선제적으로 만들지 말라고 명시. +- **`GenericRepository`도, 플랫폼 base repository도 없다.** 도메인이 자기 + 리포지토리를 소유한다. ArchUnit `noGenericRepository()`가 지킨다. +- **어댑터가 `@Transactional` 경계를 소유하지 않는다.** 유스케이스가 `TransactionPort`로 + 소유한다. +- **DB 드라이버(`org.postgresql..`, `org.h2..`)는 벤더 패키지 밖에 나올 수 없다.** + ArchUnit `PERSISTENCE_RDBMS_STAYS_VENDOR_NEUTRAL` / `..._NEUTRAL_OF_H2`가 지킨다. + +--- + +## 2. `api` — 프레임워크가 없는 계약층 + +가장 먼저 읽어야 하는 층이다. Spring도 Hibernate도 import하지 않는다. 여기 있는 결정이 +아래층 전부를 규정한다. + +### 2.1 "이름"을 타입으로 만든다 + +```java +public record PersistenceOperationName(String value) { + private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,95}"); + ... +} +``` + +같은 패턴이 `QueryName`, `ConstraintCode`, `FetchPlanName`, `BulkOperationName`, +`StatelessWorkName`, `WorkQueueName`, `NativeWriteName`, `CopyOperationName`, `JsonPathName`, +`TenantId`에 반복된다. 목적은 하나다. + +> 이 값은 metric tag / trace / retry policy의 키가 된다. 따라서 **엔티티 id, tenant id, SQL +> 조각, 요청 스코프 값을 절대 담을 수 없다.** 카디널리티 상한이 관례가 아니라 **타입 차원**에서 +> 보장된다. + +읽으면서 제일 인상적이었던 부분. "메트릭 태그에 id 넣지 마세요"를 코드 리뷰 규칙이 아니라 +**정규식을 통과 못 하면 생성자가 던진다**로 만들었다. + +### 2.2 `api.error` — 안정 예외 계층 + +23개 파일. 계층 루트는 `JpaPersistenceException`이고, **메시지를 서브클래스가 만들지 +않는다.** 루트가 `JpaFailureContext`의 bounded 조각들과 고정된 카테고리 라벨로 조립한다. + +```java +private static String describe(FailureCategory category, JpaFailureContext context) { + // 등록된 operation name, 검증된 SQLSTATE, bounded identifier, enum, int, boolean 뿐. + // row 데이터에서 유래할 수 있는 것이 하나도 없다. +} +``` + +원본 provider 예외는 `getCause()`로만 보존한다 — in-process 분류와 서버 사이드 진단용. + +`FailureCategory`는 16값 enum이다. SERIALIZATION_FAILURE, COMPLETION_UNKNOWN, DEADLOCK, +UNIQUE/FOREIGN_KEY/CHECK/NOT_NULL_CONSTRAINT, LOCK_NOT_AVAILABLE, OPTIMISTIC_CONFLICT, +QUERY_TIMEOUT, TRANSACTION_TIMEOUT, CONNECTION_UNAVAILABLE, SCHEMA_MISMATCH, DATA_CORRUPTION, +ENTITY_NOT_FOUND, UNKNOWN. + +**핵심 불변식**은 `JpaFailureContext` 생성자에 있다. + +```java +if (completionUnknown && retryable) { + throw new IllegalArgumentException("completion unknown failures are never retryable"); +} +``` + +이게 이 플랫폼 전체가 존재하는 이유다. *커밋됐을지도 모르는 작업을 자동 재실행하는 것*이 +할 수 있는 최악의 일이고, 그래서 **타입 시스템이 그 상태를 표현하는 것 자체를 거부한다.** + +이중 방어도 있다. `TransactionCompletionUnknownException` 생성자가 +`forceCompletionUnknown(context)`로 컨텍스트를 다시 만든다. 정책 버그가 retryable한 +completion-unknown을 만들어낼 방법이 없다. + +부속 결정들: + +- `sqlState`는 5자 영숫자 정규식을 통과해야 하고, 아니면 `"redacted"`. 없으면 `"00000"`. +- `constraintName`, `traceId`는 `[A-Za-z0-9._:-]{1,128}`을 통과해야 하고 아니면 `"redacted"`. +- `ConstraintViolationDetails`는 **애플리케이션이 분기할 수 있는 `ConstraintCode`**와 **운영자용 + 물리 인덱스 이름**을 분리한다. 인덱스를 rename하거나 partial index로 쪼개거나 concurrently + 재생성해도 애플리케이션 로직이 한 줄도 안 바뀐다. +- `SqlExceptionSqlStateResolver`는 cause chain **과** `SQLException.getNextException()`을 + 둘 다 따라간다. 드라이버가 batch 실패를 `getNextException`으로 엮고, 설명이 되는 상태가 + 두 번째 링크에 있는 경우가 잦기 때문. `IdentityHashMap` + `MAX_DEPTH=64`로 사이클 안전. + +### 2.3 `api.transaction` — 프로파일과 재시도 예산 + +12개 파일. 값 타입 위주다. + +`PropagationMode`는 **REQUIRED / MANDATORY / REQUIRES_NEW 셋뿐**이다. NESTED, SUPPORTS, +NOT_SUPPORTED, NEVER는 "호출자의 작업이 트랜잭션 안에 있는지 자체를 조용히 바꾸기 때문에" +의도적으로 없다. + +`IsolationLevel`에는 **READ_UNCOMMITTED가 없다**. PostgreSQL이 그걸 READ COMMITTED로 +취급하니까, 제공하면 프로파일이 DB가 제공하지 않는 격리를 주장하게 된다. + +`TransactionProfile`의 생성자 규칙: + +```java +if (!readOnly && (timeout == null || timeout.isZero() || timeout.isNegative())) { + throw new IllegalArgumentException("write transaction requires positive timeout"); +} +``` + +**쓰기 프로파일에는 유한 타임아웃이 필수.** 무제한 write 트랜잭션은 statement 하나가 막히면 +커넥션·락·row version을 무한정 잡는다. + +`RetryProfile`은 재시도 가능한 카테고리를 **화이트리스트**로 받는데, 생성자가 두 가지를 +거부한다. + +```java +if (retryableFailures.contains(FailureCategory.COMPLETION_UNKNOWN)) { throw ...; } +for (FailureCategory category : retryableFailures) { + if (!ELIGIBLE.contains(category)) { throw ...; } // ELIGIBLE = 5개 contention 카테고리 +} +``` + +즉 **설정으로도 completion-unknown을 재시도 대상에 넣을 수 없다.** + +`JitterMode`(NONE/FULL/EQUAL)는 장식이 아니다. 데드락/직렬화 폭풍에서는 모든 경쟁자가 같은 +순간에 실패하고, jitter가 없으면 전부 같은 backoff를 계산해서 같은 순간에 다시 충돌한다. + +`RetryDisposition`에 `RECONCILE`이 있는 게 핵심이다 — **"모른다"가 재시도의 위장이 아니라 +일급 결과**다. + +`TransactionCompletionEvidence`는 NOT_STARTED → ACTIVE → COMMITTING → COMMITTED / +ROLLED_BACK / UNKNOWN. **COMMITTING 단계에서 관측된 실패만** completion-unknown이 될 수 있다. + +### 2.4 `api.query` — keyset 페이지네이션과 서명 커서 + +10개 파일. + +`KeysetPageRequest`에 **offset 필드가 없다.** 없는 게 요점이고, "필드의 부재가 나중에 하나 +추가되는 걸 막는다"고 주석에 적혀 있다. `size`는 1..500으로 bounded. `fetchSize()`가 +`size + 1`을 돌려주는데, 이 여분 한 행이 **count 쿼리 없이 `hasNext`를 답하는 전체 +메커니즘**이다. + +`KeysetSlice`는 total count도, page number도 **의도적으로 없다.** 둘 다 같은 predicate에 +두 번째 집계 쿼리가 필요하고, 그건 keyset이 없애려고 존재하는 비용이다. 게다가 움직이는 +데이터셋에서 그 숫자는 클라이언트에 닿기 전에 이미 낡았다. + +`SignedJsonCursorCodec`이 이 패키지의 하이라이트다. 형식은 +`..`이고, MAC이 **버전과 payload를 함께** 덮는다 +(prefix를 다시 써서 옛 커서 포맷으로 다운그레이드하는 걸 막는다). + +서명하는 이유가 명확하게 적혀 있다. + +> 기밀성이 아니다 — payload는 읽을 수 있다. **무결성**이다. 서명 없는 커서는 +> 클라이언트가 제어하는 정렬 상태이고, 그걸 고쳐 쓰면 임의의 키로 seek할 수 있다. 스캔이 +> 어디서 시작했는지에 predicate가 의존하는 곳이라면 이건 **접근 제어 우회**다. + +방어 순서도 인상적이다. + +1. `MAX_ENCODED_LENGTH(4096)` 길이 검사가 **substring/decode/MAC 이전 첫 줄**에 있다. + 페이징 엔드포인트는 public이고, 그 아래 모든 코드가 caller가 보낸 크기에 비례해서 할당한다. +2. base64는 4/3으로 늘어나니, **인코딩된 segment 길이만으로** decode 후 크기를 정확히 + bound한다. 할당하기 전에 거부. +3. MAC 길이가 정확히 32바이트인지 먼저 확인. `MessageDigest.isEqual`은 **같은 길이 입력에 + 대해서만** constant time이다. +4. `MessageDigest.isEqual`로 constant-time 비교. short-circuit 비교는 정답 MAC을 한 바이트씩 + 흘린다. +5. **서명 검증 후에야** payload를 decode한다. 서명 없는 토큰은 애플리케이션의 JSON 파싱에 + 도달조차 못 한다. + +`CursorPayloadCodec`이라는 seam이 있어서 core 계약은 JSON 라이브러리 없이 Java 표준 +라이브러리만으로 성립한다. + +### 2.5 `api.capability` — 지원 수준을 "선언"한다 + +`JpaCapability` 16값 enum + `SupportLevel` (STABLE / ADVANCED / EXPERIMENTAL / UNSUPPORTED) ++ `CapabilitySupport` record. + +> 수준은 **추론이 아니라 선언**이다: evidence suite가 돌지 않은 capability는 컴파일이 +> 된다는 이유로 STABLE이 되지 않는다. + +그리고 `CapabilitySupport`는 provider 객체를 절대 담지 않는다 — `DataSource`도 +`EntityManagerFactory`도 `SessionFactory`도. 리포트로 직렬화되고 actuator 엔드포인트로 +publish될 수 있어야 하는데, 살아있는 리소스를 값 타입에 끌고 들어가면 리포트가 JDBC URL이나 +자격증명을 흘릴 수 있다. + +--- + +## 3. `transaction` — Spring 위에 얹힌 두 개의 트랜잭션 포트 + +29개 파일, 약 10만 바이트. 이 leaf에서 제일 밀도가 높은 패키지다. + +먼저 알아야 할 것: **트랜잭션 포트가 두 개다.** 하나는 레거시 4-mode, 하나는 정책 기반이고, +같은 빈이 둘 다 구현한다. + +``` +application-core: + TransactionPort inWrite / inRootWrite / inRead / inNew + PolicyTransactionPort → extends TransactionPort + + inTransaction(TransactionRequest, Supplier) : TransactionResult + +adapter: + SpringTransactionPort implements PolicyTransactionPort + ├─ 레거시 4모드 → 미리 만들어 둔 TransactionTemplate 3개 + └─ inTransaction → SpringPolicyTransactionPort (package-private 위임체) +``` + +### 3.1 `SpringTransactionPort` — 레거시 4-mode + +모드별로 `TransactionTemplate`을 **생성 시점에 하나씩 미리 만들어 둔다.** + +| 모드 | Propagation | Isolation | read-only | +|---|---|---|---| +| `inWrite` | REQUIRED | READ_COMMITTED | false | +| `inRootWrite` | REQUIRED | READ_COMMITTED | false | +| `inRead` | REQUIRED | READ_COMMITTED | true | +| `inNew` | REQUIRES_NEW | READ_COMMITTED | false | + +**왜 미리 만드나**: `TransactionTemplate`은 문서상 thread-safe지만 **mutable**하다. 호출마다 +propagation/readOnly를 바꿔 쓰면 같은 빈을 공유하는 동시 요청 사이에 race window가 생긴다. + +**`inRootWrite`가 왜 별도 템플릿을 안 만드나**: 실행 속성이 `inWrite`와 같다. 차이는 실행 전 +precondition이다. + +```java +public T inRootWrite(Supplier action) { + if (TransactionSynchronizationManager.isActualTransactionActive()) { + throw new NestedRootTransactionRejectedException(); + } + return executeLegacy(writeTemplate, action); +} +``` + +`REQUIRES_NEW`로 suspend해서 "root인 척"하지 **않는다.** 그러면 호출자 트랜잭션과 독립 +커밋되는 silent 의미 변경이 생긴다. 대신 fail-fast. + +그리고 `TransactionTemplate.execute`는 커밋까지 성공한 다음에 값을 반환한다. 따라서 +`inRootWrite`의 결과는 **post-commit에만** 호출자에게 보이고, 커밋 실패는 값 대신 원래 +트랜잭션 예외로 전파된다. + +`inNew`에는 풀 사이징 제약이 붙는다 (application-port D12). + +``` +hikari.maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1 +``` + +`REQUIRES_NEW`는 바깥 트랜잭션의 커넥션을 **핀한 채로** 새 물리 JDBC 커넥션을 딴다. +레코드마다 `inNew`를 도는 루프는 금지 (풀 고갈 + 데드락 위험). 배치로 묶거나 루프를 트랜잭션 +밖으로 빼야 한다. + +### 3.2 `SpringPolicyTransactionPort` — 정책 기반 실행기 + +`TransactionPolicyId` 7개를 Spring 정의로 매핑한다. + +| policy | propagation | isolation | readOnly | 비고 | +|---|---|---|---|---| +| `COMMAND_DEFAULT` | REQUIRED | READ_COMMITTED | false | | +| `INBOX_AND_HANDLER` | REQUIRED | READ_COMMITTED | false | | +| `COMMAND_SERIALIZABLE_REPLAY_SAFE` | REQUIRED | **SERIALIZABLE** | false | 유일한 재시도 대상 | +| `QUERY_PRIMARY` | REQUIRED | READ_COMMITTED | true | | +| `QUERY_REPLICA_ELIGIBLE` | REQUIRED | READ_COMMITTED | true | `replicaRequired=true` → **현재 admission 거부** | +| `OUTBOX_APPEND` | REQUIRED | READ_COMMITTED | false | | +| `MAINTENANCE_NEW` | REQUIRES_NEW | READ_COMMITTED | false | | + +`QUERY_REPLICA_ELIGIBLE`은 지금 **무조건 `TransactionAdmissionException`으로 거부**된다. +리플리카 capability가 qualified되기 전까지는 "지원하는 척"을 안 한다. + +반환 타입 `TransactionResult`가 sealed interface로 결과 대수를 만든다. + +| 변형 | 의미 | +|---|---| +| `Committed(value, operationId)` | 물리 커밋 확인됨 | +| `Participating(value)` | 바깥 트랜잭션에 참여 — **커밋을 주장하지 않는다** | +| `DeterminateRollback(failure)` | 롤백 확정 | +| `Indeterminate(operationId, lastObservedPhase, reconciliationReference)` | 결과 불명 — **replay 권한을 주지 않는다** | +| `CommittedWithPostCommitFailure(value, operationId, operationalFailure)` | 커밋됐지만 afterCommit 훅이 실패 | + +핵심은 실행 루틴이다. + +```java +tracker.observe(TransactionPhase.COMMIT_REQUESTED); +try { + transactionManager.commit(status); +} catch (RuntimeException commitFailure) { + if (sentinel.commitAcknowledged()) { + return CommittedWithPostCommitFailure(...); // afterCommit이 이미 왔다 + } + if (sentinel.rolledBack() + || commitFailure instanceof UnexpectedRollbackException + || TransactionRetryClassifier.isReplayCandidate(commitFailure)) { + return DeterminateRollback(translate(commitFailure)); // 롤백 확정 + } + return Indeterminate(operationId, tracker.lastObserved(), empty()); // 모른다 +} +``` + +`PhaseSentinel`은 `TransactionSynchronization` + `Ordered.HIGHEST_PRECEDENCE`로 등록되어 +`beforeCommit → FLUSHED`, `afterCommit → COMMIT_ACKED`, `afterCompletion → SYNCHRONIZATION_CLEANUP`을 +기록한다. **물리 소유자(`status.isNewTransaction()`)일 때만** 등록한다. + +재시도는 딱 한 정책만 받는다. + +```java +if (policyId != TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE + || !attemptResult.physicalOwner() + || attempt >= retryBackoff.maximumAttempts() + || Thread.currentThread().isInterrupted()) { + return false; +} +``` + +그리고 `DeterminateRollback`이면서 `isReplayCandidate`(40001 또는 40P01)일 때만 재시도. +**`Indeterminate`는 절대 재시도하지 않는다.** + +### 3.3 데드라인 계산 — CallBudget → Spring timeout → PostgreSQL local timeout + +`TransactionDeadlineCalculator`가 하는 일이 이 모듈에서 제일 실무적인 부분이었다. + +**획득 전 (`beforeAcquisition`)**: + +``` +required = connectionTimeout + beginBudget + minimumActionWindow + completionMargin +if (remaining < required) → TransactionAdmissionException +safeTransactionNanos = remaining - connectionTimeout - beginBudget - completionMargin +springTimeoutSeconds = min(safeTransactionNanos, settings.transactionTimeout) / 1s +if (springTimeoutSeconds < 1) → TransactionAdmissionException +``` + +Spring의 `TransactionDefinition#setTimeout`이 **초 단위**라서, 1초도 남지 않으면 아예 +시작하지 않는다. + +**begin 이후 (`afterBegin`)**: + +``` +springRemaining = springTimeoutSeconds*1s - elapsed +callRemaining = callBudget.remainingNanosAt(now) +statementWindow = min(callRemaining - completionMargin, springRemaining) - transactionMargin +statement = min(settings.statementTimeout, statementWindow) +lock = min(settings.lockTimeout, statement - lockMargin) +idle = min(settings.idleGuardTimeout, callRemaining - completionMargin) +셋 중 하나라도 1ms 미만 → TransactionAdmissionException +``` + +그 결과 `EffectiveTransactionTimeouts(statement, lock, idle)`가 나오고, +`TransactionLocalTimeoutConfigurer` SPI가 그걸 DB에 밀어 넣는다. + +PostgreSQL 구현: + +```java +"select set_config('statement_timeout', ?, true)" +"select set_config('lock_timeout', ?, true)" +"select set_config('idle_in_transaction_session_timeout', ?, true)" +``` + +세 번째 인자 `true`가 **transaction-local**이라는 뜻이다. 트랜잭션이 끝나면 되돌아간다. +`SET statement_timeout = ?`는 파라미터 바인딩 전에 파싱되어 syntax error가 나므로, +`set_config` 함수 호출로 우회하고 **동시에 값이 statement text에서 빠진다**. + +H2 구현은 두 가지가 다르다. 둘 다 H2의 성질이지 선택이 아니다. + +- **세션 스코프다.** H2의 `SET`은 session-wide라서 트랜잭션이 끝나도 pooled 커넥션에 남는다. + 실무에서 stale하지 않은 이유는 포트가 매 트랜잭션 전에 적용하니 매번 덮어쓰기 때문. 그 + 경로 밖에서 빌린 커넥션은 이전 트랜잭션의 가드를 들고 있다. +- **idle-in-transaction 가드가 없다.** H2에 대응물이 없어서 caller-side deadline에 맡기고, + "적용했다"고 거짓 보고하지 않는다. +- 값이 inline된다(H2 `SET`은 bind 파라미터를 안 받는다). 검증된 settings에서 온 + `Duration`이고 음수는 거부한다. + +설정은 `ca-skeleton.jpa.transaction.*`, 바인딩은 `JpaTransactionSettings` record다. 기본값과 +**교차 필드 불변식**이 전부 생성자에 있다. + +| 키 | 기본값 | +|---|---| +| `transaction-timeout` | 30s | +| `begin-budget` | 250ms | +| `minimum-action-window` | 1s | +| `completion-margin` | 500ms | +| `statement-timeout` | 10s | +| `lock-timeout` | 2s | +| `idle-guard-timeout` | 15s | +| `transaction-margin` | 250ms | +| `lock-margin` | 100ms | +| `retry-base-delay` | 10ms | +| `retry-maximum-delay` | 50ms | +| `retry-maximum-attempts` | 2 (1..5) | + +불변식: `statement ≤ transaction`, `lock < statement`, `transactionMargin < statement`, +`lockMargin < statement - lock`, `retryBase ≤ retryMax`, 모든 duration은 (0, 1일]. + +`JpaTransactionConfig`가 이 타입을 바인딩하는데, 그 존재 이유가 재밌다. 컴포지션 루트의 +`@ConfigurationPropertiesScan`이 persistence 트리를 **의도적으로 제외**한다(JPA 꺼진 배포에서 +persistence settings가 바인딩되지 않도록). 그 제외가 JPA **켜진** 배포에서도 타입을 unbound로 +남겨서, 트랜잭션 포트를 아예 생성할 수 없게 만들었다. 그래서 이 `@Configuration`이 +`transaction` 패키지 안에, settings 바로 옆에 산다. 중앙에서 enable하면 `config` 패키지가 +자기 settings를 명명한 모든 패키지로 edge를 가져야 하니까. + +### 3.4 커밋 증거 — `TransactionEvidenceContext` + +`EvidenceAwareJpaTransactionManager`가 `JpaTransactionManager`를 상속해서 단계를 기록한다. + +```java +protected void doCommit(DefaultTransactionStatus status) { + TransactionEvidenceContext.mark(COMMITTING); // ← provider commit 직전, 절대 이후가 아님 + try { + super.doCommit(status); + TransactionEvidenceContext.mark(COMMITTED); + } catch (RuntimeException failure) { + TransactionEvidenceContext.mark(UNKNOWN); + throw classifier.translateCommitFailure(failure); + } finally { + // 일부러 clear하지 않는다. + } +} +``` + +이 순서가 전체 메커니즘이다. JVM/네트워크/서버가 `super.doCommit` 안에서 죽으면 마지막으로 +기록된 것이 **"물어봤고, 모른다"**이고, 그게 롤백으로 오인되면 안 되는 상태다. + +`TransactionEvidenceContext`는 단일 슬롯이 아니라 **스택(`ArrayDeque`)**이다. +`REQUIRES_NEW`가 같은 스레드에서 바깥 트랜잭션을 suspend하고 안쪽을 시작하기 때문. 단일 +슬롯이면 안쪽 커밋이 바깥의 phase를 덮어쓰고, 나중에 바깥에서 커밋 실패가 나면 이미 끝난 +작업의 증거로 분류된다. + +여기 아주 좋은 버그 기록이 남아 있다. + +> pop하는 주인이 **둘**이었다. 트랜잭션 매니저가 commit/rollback에서 자기 프레임을 지웠고, +> executor의 `finally`가 operation과 attempt가 일치하면 top 프레임을 지웠다. 바깥과 같은 +> operation·같은 attempt 번호를 가진 `REQUIRES_NEW` 안쪽 트랜잭션(기본 경로에서는 둘 다 +> attempt 1이라 **항상** 그렇다)에서는, 안쪽 매니저가 안쪽 프레임을 pop하고 executor가 그 +> 다음에 바깥 프레임을 자기 것으로 오인해 pop했다. 이후 바깥의 커밋 실패는 operation 없이, +> attempt 1로, reconciliation key 없이 보고됐다. + +해결은 `TransactionEvidenceScope`다. **깊이(depth)로 소유권을 식별**하고, 자기 프레임이 +top일 때만 pop한다. 순서가 어긋나면 아무것도 pop하지 않고 `false`를 돌려준다 — +"nest되지 않은 lifecycle은 버그이고, 남의 프레임을 지워서 덮는 건 원래 결함이 밖에서 +보이던 모습 그대로다." + +ThreadLocal도 `withInitial`이 **아니다.** + +```java +private static final ThreadLocal> FRAMES = new ThreadLocal<>(); +``` + +initializing thread-local은 읽을 때마다 값을 설치하므로, 마지막 프레임이 정리된 뒤의 읽기가 +`clear()`가 방금 제거한 것을 정확히 다시 등록해 버린다. `clear()`는 스택이 비면 +`FRAMES.remove()`까지 한다 — pooled thread에서 빈 deque를 남기는 건 요청 스레드마다 쌓이는 +느린 누수다. + +`hasRawThreadLocalValue()`라는 테스트 전용 접근자까지 있다. 누수 없음을 검증하려고. + +### 3.5 `CommitFailureClassifier` — `57P01`의 발견 + +completion-unknown 판정 규칙은 **좁다.** 커밋 단계에서 발생했고 **동시에** 드라이버가 어느 +쪽인지 말해주지 못했을 때만이다. + +```java +COMPLETION_UNKNOWN_STATE = "40003" +CONNECTION_CLASS = "08" // startsWith +SERVER_TERMINATION_STATES = {"57P01", "57P02", "57P03"} ++ transport break: SocketException | IOException | SQLRecoverableException + | SQLTransientConnectionException | SQLNonTransientConnectionException +``` + +`57P0x`가 여기 들어온 경위가 `repository-adaptation.md` §4에 기록되어 있다. + +> **§17.2의 "커밋 모호성은 SQLSTATE class 08뿐"은 틀렸다.** 커밋이 in-flight인 백엔드에 +> `pg_terminate_backend`를 하면 connection-class가 아니라 `57P01`(admin_shutdown)이 +> 보고되고 — 그리고 그게 도착할 때 커밋 레코드는 **이미 WAL에 있을 수 있다.** +> 컨테이너 레인이 그걸 보여줬고, `CommitAmbiguityContractTest`가 SQLSTATE를 직접 +> assert해서 규칙이 다시 조용히 좁아지지 못하게 한다. + +넓히지 않는 이유도 명시되어 있다. + +> 커밋 단계의 모든 커넥션 에러를 completion-unknown으로 표시하면 평범한 풀 고갈과 서버 +> 재시작이 reconciliation 큐로 밀려들어가고, 운영자는 그 큐를 **읽지 않고 비우는 습관**을 +> 배운다 — 그러면 중요했던 한 건이 나머지와 같이 지워진다. + +### 3.6 실패 번역 체인과 재시도 코디네이터 + +`PersistenceFailureTranslatorChain`의 순서가 계약이다. + +1. 이미 분류된 실패는 **그대로 통과**. 다시 번역하면 이미 들고 있는 attempt/key/completion + 판정을 잃는다. +2. **낙관적 충돌** — provider 예외라 SQLSTATE가 없어서 SQLSTATE 기반 번역기가 못 알아본다. +3. **벤더 SQLSTATE** — 직렬화 실패, 데드락, 제약 계열. +4. 나머지는 **손대지 않고 반환.** 도메인 예외, assertion 실패, `NullPointerException`은 + 퍼시스턴스 실패가 아니고, 그렇게 포장하면 프로그래밍 에러가 retryable하게 보인다. + +여기에도 실패 기록이 있다. + +> 재시도 코디네이터는 `JpaPersistenceException`만 catch하는데, executor는 아무것도 번역하지 +> 않고 템플릿을 돌렸다. 그래서 경합이 실제로 만들어내는 실패 — Hibernate +> `OptimisticLockException`, Spring `OptimisticLockingFailureException`, raw 직렬화/데드락 +> `DataAccessException` — 이 번역되지 않은 채 executor를 떠나 코디네이터의 catch를 완전히 +> 비껴갔고, **production 경합은 재시도되지 않았다.** 단위 픽스처는 이미 번역된 예외를 +> 던져서 초록불이었다. + +`OptimisticConflictTranslator`는 cause chain 전체를 본다(`OptimisticLockException`, +`OptimisticLockingFailureException`, 그리고 이름으로 매칭하는 +`org.hibernate.StaleObjectStateException` / `StaleStateException`). 이름 매칭은 트랜잭션 +모듈을 Hibernate API 표면에서 떼어놓기 위해서다. 충돌 엔티티 타입은 **등록된 카탈로그**를 +통해서만 보고하고 나머지는 버린다 — provider 예외는 엔티티 *인스턴스*와 식별자를 들고 +있는데, 그걸 옮기면 row key가 모든 로그 라인과 메트릭 태그에 들어간다. + +`VendorFailureTranslator`가 `api`에 선언되어 있는 이유도 사이클 때문이다. `transaction`이 +`postgresql`을 직접 의존하면, `PostgreSqlPersistenceConfig`가 SPI 때문에 이미 +`transaction`을 의존하니까 사이클이 닫힌다. + +`DefaultJpaRetryPolicy`의 검사 **순서**도 계약이다. + +1. completion unknown → `RECONCILE`. **가장 먼저** 확인해서 뒤의 어떤 규칙도 못 건드린다. +2. 되돌릴 수 없는 외부 부수효과를 수행한 시도(`IrreversibleSideEffectContext.performed()`) + → `FAIL`. 예산이 남아도 안 된다. +3. 프로파일이 비활성 → `FAIL`. +4. **번역기가 non-retryable로 표시한 실패 → `FAIL`.** 프로파일의 화이트리스트는 어떤 + *카테고리*가 재시도될 수 있는지를 넓히지, *이 실패*에 대한 판정을 뒤집지 않는다. + (예전엔 화이트리스트를 먼저 봐서, 카테고리를 opt-in한 프로파일이 분류기가 이미 + terminal이라고 부른 실패를 되살릴 수 있었다.) +5. 프로파일이 명시적으로 opt-in한 카테고리만 재시도. +6. 나머지는 전부 실패. **미지의 SQLSTATE 포함.** 아무도 분류하지 않은 실패를 재시도하는 게 + 미인식 에러가 중복 쓰기가 되는 경로다. + +`FullTransactionRetryCoordinator`의 재시도 단위는 **statement가 아니라 유스케이스 전체**다. +낙관적 충돌/직렬화 실패에서 유일하게 옳은 granularity다 — 시도가 실패한 이유가 "계산의 +기준이 된 상태가 더 이상 커밋된 상태가 아니라서"이므로, 같은 statement를 다시 쏘면 같은 +틀린 답을 계산한다. executor에 다시 들어가면 **새 트랜잭션과 새 Persistence Context**를 +받고, 그게 도메인 규칙을 reload된 데이터에 대해 다시 돌게 만든다. + +`RetryBudget`은 **두 개의 독립적 상한**을 건다. + +> attempt 카운트만으로는 bound가 아니다. 타임아웃에 10초 걸리는 락에 3번 시도하면 30초짜리 +> 호출이고, 상류 호출자는 한참 전에 포기했다. elapsed bound가 재시도를 요청보다 오래 +> 살아남지 못하게 한다. + +`BackoffCalculator`는 지수 성장 → cap → jitter. `RetrySleeper`는 seam이라서 테스트가 실제로 +자지 않는다. `ThreadRetrySleeper`는 interrupt를 삼키지 않고 flag를 복원하고 던진다 — +셧다운이 시도 사이의 작업을 실제로 멈출 수 있게. + +`CompletionUnknownRecord` / `CompletionUnknownRecorder`가 reconciliation 핸드오프다. 채널은 +애플리케이션이 고르고, **결과가 불명인 그 트랜잭션 바깥**이어야 한다 — 커밋됐을지도 모르는 +같은 커넥션으로 감사 기록을 쓰면 그 기록이 문서화하려는 실패를 공유하게 된다. 구현체는 +원래 유스케이스를 호출해서는 안 된다. "이 메서드는 관찰한다; 절대 고치지 않는다." + +`TransactionProfileRegistry`는 fail-closed lookup이다. 기본값 fallback이 있으면 +`"order-wrtie"` 같은 오타가 남의 격리/타임아웃/재시도 예산으로 조용히 돈다. + +--- + +## 4. `failure` — 웹까지 새지 않는 SQLState 매트릭스 + +이건 `api.error`와 **다른 계층**이다. `api.error`는 플랫폼 내부의 안정 예외 계층이고, +`failure`는 **Spring `DataAccessException` → `shared.error.PersistenceFailureException`** +변환이다. 즉 웹 어댑터가 응답 코드를 정할 수 있는 형태로 바꾸는 층. + +`PersistenceExceptionTranslator`가 등록된 모든 `SqlStateErrorMapping` 빈을 **생성 시점에 +merge**한다. 그리고 merge 규칙이 강경하다. + +```java +OperationalError previous = merged.putIfAbsent(sqlState, candidate); +if (previous != null) { + throw new IllegalStateException("Duplicate exact SQLState mapping " + sqlState + ": ..."); +} +``` + +**서로 다른 contributor가 같은 exact SQLState를 등록하면 코드가 같아도 startup을 +실패시킨다.** last-writer-wins merge는 매핑 소유권 drift를 숨기니까 허용하지 않는다. + +매트릭스: + +**core (`StandardSqlStateErrorMapping`) — 모든 RDBMS 공통 5행** + +| SQLState | code | category | http | retryable | +|---|---|---|---|---| +| `40001` | `DB_SERIALIZATION_FAILURE` | CONFLICT | 409 | true | +| `23502` | `DB_NULL_VIOLATION` | DATA_INTEGRITY | 409 | false | +| `23503` | `DB_FK_VIOLATION` | DATA_INTEGRITY | 409 | false | +| `23505` | `DB_UNIQUE_VIOLATION` | CONFLICT | 409 | false | +| `23514` | `DB_CHECK_VIOLATION` | DATA_INTEGRITY | 409 | false | + +**`08*` prefix → `DB_UNAVAILABLE` (TRANSIENT_DEPENDENCY / 503 / retryable)** 는 맵 엔트리가 +아니라 translator가 직접 처리한다. 그래서 core 맵에 `08*`가 없다. + +**vendor 기여** + +| SQLState | code | vendor | +|---|---|---| +| `40P01` | `DB_DEADLOCK` | PostgreSQL | +| `25P03` | `DB_IDLE_IN_TX_TIMEOUT` | PostgreSQL | +| `57014` | `DB_QUERY_CANCELED` | PostgreSQL | +| `23513` | `DB_CHECK_VIOLATION` | H2 — H2는 CHECK을 23513으로 보고한다 | +| `HYT00` | `DB_QUERY_CANCELED` | H2 — statement/lock timeout을 하나로 합친다 | + +H2 행 두 개는 "표준에서 추론한 게 아니라 **실행 중인 H2 2.4.240에서 읽어낸 값**"이라고 +명시되어 있다. + +**Fallback**: 기여된 어떤 행에도 없는 SQLState — 또는 cause chain에 `SQLException` 자체가 +없는 경우 — 는 `Optional.empty()`. 호출부는 원본 예외를 web catch-all까지 전파시켜 detail +누설 없는 generic `INTERNAL` 봉투로 답한다. **translator는 미지의 상태에 대해 `DB_*` 코드를 +절대 지어내지 않는다.** + +변환된 carrier의 진단 메시지에 SQLState를 넣는 건 **server-log 전용**이다(웹 어댑터가 절대 +surface하지 않음). + +리포지토리 어댑터의 사용 패턴은 이렇다. + +```java +translator.translate(ex).ifPresent(e -> { throw e; }); +// 비어 있으면 원본을 다시 던져서 web catch-all이 generic INTERNAL로 답하게 한다 +``` + +카테고리 SSOT는 10값 `Category` enum이고, **`PERSISTENCE` 카테고리는 없다**(branch-note +§Audit CATEGORY_DRIFT). + +--- + +## 5. `springdata` — 쿼리 표면을 좁히는 17개 타입 + +이 패키지의 주제는 하나다. **caller가 SQL의 어떤 부분도 쓰지 못하게 하되, 필요한 표현력은 +등록(registry)으로 준다.** + +### 5.1 `JpaRepositoryFragmentSupport` — 없는 게 요점인 base 클래스 + +```java +public abstract class JpaRepositoryFragmentSupport { + protected final TypedQuery typedQuery(QueryName name, String jpql, Class resultType) + protected final Query nativeQuery(QueryName name, String sql) + protected final T observing(QueryName name, Supplier work) +} +``` + +주석이 직접 말한다. + +> 이 클래스에 **없는 것**을 보라: `save`도, `findById`도, `findAll`도, `delete`도 없다. +> 이건 리포지토리가 아니고 절대 리포지토리가 되어서는 안 된다. Spring Data가 이미 CRUD를 +> 구현하고 있고, 플랫폼이 소유한 base repository가 그걸 다시 구현하면 설계가 명시적으로 +> 지목하는 실패가 생긴다 — 모든 애그리거트가 강제로 통과해야 하는 generic API, 그리고 한 +> 애그리거트의 요구가 전부의 동작을 조용히 바꾸는 단일 지점. + +대신 제공하는 건 fragment가 혼자서는 얻을 수 없는 것: **이름 붙은 쿼리**. 모든 쿼리가 +등록된 `QueryName`을 들고 다니고, 그게 `org.hibernate.comment` 힌트로 SQL에 실려 +`pg_stat_activity`에 나타난다. + +### 5.2 `SafeSortMapper` / `SafeSortRegistry` / `SafeSortField` + +정렬 파라미터는 **바인딩 값이 아니라 ORDER BY 절의 일부**다. 클라이언트 문자열을 그대로 +넘기면 클라이언트가 statement의 일부를 쓴다는 뜻이다. + +두 규칙: + +1. **모든 필드는 조회된다. 절대 통과되지 않는다.** Spring Data의 `JpaSort.unsafe`는 + caller 문자열을 ORDER BY에 그대로 넣는다 — 사용자 제어 값에 대해서는 SQL 인젝션 + α. + 이 매퍼에는 거기 도달하는 경로가 없다. +2. **tie-breaker가 항상 append된다.** unique 컬럼으로 끝나지 않는 정렬에는 total order가 + 없고, non-total order 위의 페이징은 조용히 행을 중복시키거나 건너뛴다. + +`SafeSortField`의 `entityPath`는 JPA property path 정규식 +(`[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*`)으로 검증된다. **등록 실수조차** 함수 +호출이나 두 번째 절을 넣을 수 없다. + +여기도 버그 기록이 있다. + +> 매퍼가 tie-breaker를 생략한 모든 ordering에 `DESC`를 붙였다. 그래서 `createdAt ASC`로 +> 정렬하는 엔드포인트가 조용히 `createdAt ASC, id DESC`가 됐다 — 아무도 고르지 않은 +> total order이고, 어떤 ascending 커서도 올바르게 페이징할 수 없는 것이다. + +그래서 `SafeSortRegistry`에 `tieBreakerDirection`이 생겼다. + +`MAX_SORT_TERMS = 4`. + +### 5.3 keyset 페이지네이션 실행 + +`KeysetPredicateBuilder`가 핵심이다. **사전식(lexicographic)** predicate여야 한다. + +`(createdAt, id)` ordering에서 "`(t, x)` 이후"는 + +```sql +createdAt < t OR (createdAt = t AND id < x) +``` + +이지 `createdAt <= t AND id < x`가 **아니다.** 두 번째는 그럴듯하게 읽히고 틀렸다 — +`createdAt`이 더 이르면서 `id`가 더 큰 모든 행을 떨어뜨려서, **결과 집합 한가운데의 행을 +조용히 삭제한다.** + +`KeysetTerm`는 **term별로** direction과 타입을 갖는다. 이전 시그니처 +(`List>` + 스캔 전체 `SortDirection` 하나)는 둘 다 표현 못 했다. `(Instant, +UUID)` 커서는 하나의 `T`에 안 들어가고, `createdAt ASC, id DESC`는 하나의 direction에 안 +들어간다. 그래서 caller가 하나를 골라야 했고, ordering이 `id < x`를 원하는데 +`created_at > t AND id > x`가 만들어져서 **페이지 경계마다 행이 건너뛰거나 반복됐다.** + +마지막 term은 반드시 unique 컬럼이어야 한다(생성자가 `terms.size() < 2`를 거부). + +`KeysetSliceAssembler`는 `size+1` fetch를 페이지 + next cursor로 조립한다. **next cursor는 +반환된 마지막 행에서 뽑는다. 여분 행이 아니다** — 여분 행을 쓰면 다음 페이지에서 그 행을 +건너뛴다. + +`JpaKeysetQuerySupport`는 이 경로 어디에도 count 쿼리가 없다고 명시한다. keyset이 존재하는 +이유가 `OFFSET n`이 DB로 하여금 n행을 걷고 버리게 만드는 것이고, 거기에 `COUNT(*)`를 붙이면 +keyset이 없앤 full scan을 도로 넣는 것이다. + +### 5.4 `JpaStreamExecutor` / `JpaStreamScope` — 스트림은 커서다 + +```java +public static void requireActiveReadOnly() { + if (!isActualTransactionActive()) throw ...; // "커서가 첫 statement 후 닫히고 커넥션이 중간에 반납된다" + if (!isCurrentTransactionReadOnly()) throw ...; // "write 트랜잭션은 순회 내내 write 커넥션을 핀한다" +} +``` + +JPA `Stream`은 살아있는 커서다 — JDBC `ResultSet`을 들고, 그게 statement를 들고, 그게 +커넥션을 든다. 셋 다 스트림이 닫히고 트랜잭션이 끝나야 풀린다. 트랜잭션 경계 밖으로 +반환하면(리포지토리→컨트롤러) 그 체인이 풀이 눈치챌 때까지 열려 있고, 이건 **무관한 곳의 +타임아웃으로 나타나는 커넥션 누수**다. + +`consume`은 스트림을 절대 밖으로 내보내지 않는다. try-with-resources로 모든 경로(정상 반환, +예외, early termination)에서 닫는다. + +**reactive 반환을 거부**하는 것도 인상적이다. + +```java +private static final Set REACTIVE_TYPES = Set.of( + "org.reactivestreams.Publisher", "reactor.core.publisher.Mono", + "reactor.core.publisher.Flux", "io.reactivex.rxjava3.core.Flowable", + "kotlinx.coroutines.flow.Flow", "java.util.concurrent.Flow.Publisher"); +``` + +이유: publisher를 반환하면 컴파일은 되고 런타임에 최악으로 실패한다 — subscriber가 +스트림이 닫히고 트랜잭션이 사라진 뒤에 돌아서, 원인에서 멀리 떨어진 closed-cursor 에러로 +나타난다. 그리고 **타입 계층 전체를 검사한다**(패키지 이름만 보면 애플리케이션이 직접 +선언한 구현체를 전부 통과시킨다). 이름으로 매칭해서 Reactor를 이 블로킹 모듈의 컴파일 +클래스패스에서 뺀다. + +`ScrollPolicy(fetchSize, maxRows, adminToken)`: + +- `fetchSize`가 드라이버의 전체 result set 클라이언트 버퍼링을 막는다. PostgreSQL에서 + fetchSize 없는 statement는 첫 행 전에 전부 버퍼링한다 — 스트리밍의 의미가 사라진다. +- `maxRows`는 별개의 "끝난다"는 보장. +- `MAX_NON_ADMIN_ROWS = 100_000`. 그 이상은 명시적 admin 토큰 필요. + +여기도 버그 기록이 있다. supplier가 원래 plain `Supplier`여서 executor가 든 fetch size가 +스트림을 여는 쿼리에 **닿지 않았다**. 그래서 `Function>`로 바뀌었다. + +### 5.5 fetch plan과 specification 정책 + +`EntityGraphCatalog`는 **factory를 저장한다**(`Function>`). +graph는 자기를 만든 `EntityManager`에 속하고 공유할 수 없기 때문. + +`FetchPlanApplier`가 `fetchgraph`/`loadgraph`를 **별도 메서드**로 나눈 이유가 좋다. + +> fetch graph는 exhaustive다 — 매핑이 뭐라 하든 그 밖의 attribute는 lazy. load graph는 +> additive다 — 나열되지 않은 attribute는 매핑된 기본값. 잘못 고르면 데이터가 빠지거나, +> graph가 피하려던 증폭이 생긴다. + +그리고 왜 mapping에서 `EAGER`를 안 쓰나: 그 방법은 그게 필요했던 한 유스케이스를 고치고 **id만 +원했던 것 포함 그 엔티티에 대한 다른 모든 쿼리에 추가 조인을 부과한다.** + +`SpecificationPolicy`: + +> predicate 없는 specification은 builder 옷을 입은 full table scan이다. 보통 모든 optional +> 필터가 없을 때 나온다 — 빈 검색 화면 제출 — 그리고 **어느 한 줄도 틀리지 않았기 때문에** +> 코드 리뷰에서 무해해 보인다. + +`ALLOW_UNBOUNDED_TOKEN = "allow-unbounded-scan"` 명시 opt-in, 그리고 `pageable.isUnpaged()` +거부. + +### 5.6 `RegisteredQuery` — 지워진 가드의 기록 + +여기 좋은 자기비판이 남아 있다. + +> 여기 source-token 검사는 없다. 예전에는 완성된 statement에서 `"' +"`(Java 문자열 연결 +> 연산자)를 찾았는데, 그건 **컴파일러가 이미 평가해서 제거한** 뒤에 어떤 문자열이든 이 +> 생성자에 도달한다. 그 가드는 문자 그대로 그 문자를 담은 statement에만 발동할 수 있었고, +> 방지하려던 동적 구성에는 절대 발동하지 못했다. +> +> statement는 등록된 카탈로그에서 오고 파라미터는 typed binder로 받는다. **동적 구성 금지는 +> source에 대한 규칙이고, ArchUnit과 리뷰가 source 규칙이 사는 곳이다.** + +같은 문구가 `WorkQueueDefinition`에도 반복된다. 런타임 정규식으로 소스 규칙을 흉내 내려다 +실패한 걸 지우고 기록으로 남긴 것. + +--- + +## 6. `hibernate` — 프로바이더를 측정 가능하게 만들기 + +### 6.1 statement에 이름 붙이기 + +`QueryNameContext`(ThreadLocal) + `NamedStatementInspector`(`StatementInspector` 구현). + +```java +return "/* " + value + " */ " + sql; +``` + +이 주석이 SQL과 함께 `pg_stat_activity`, `auto_explain` 출력, slow-query 로그로 흘러간다. +없으면 "이 쿼리를 어느 엔드포인트가 쏘는가"를 코드베이스에서 SQL 조각을 grep해서 답해야 한다. + +**comment terminator `*/`를 거부**한다 — 이름이 주석을 닫고 SQL로 이어질 수 없게. + +`QueryNameContext.with(name, work)`는 clear가 아니라 **이전 이름을 복원**한다. 중첩 호출 +때문 — 바깥 named query 안에서 helper query를 쏘는 fragment가 안쪽이 끝났을 때 바깥 +statement를 unattributed로 남기면 안 된다. + +### 6.2 통계와 JDBC batch 카운터 + +`HibernateStatisticsCollector.snapshot()`은 statistics가 꺼져 있으면 **던진다.** + +> 비활성 registry를 조용히 측정하는 fetch 테스트는 아무것도 증명하지 않고 영원히 통과한다. + +`HibernateStatisticsSnapshot`이 entity **load**와 entity **fetch**를 따로 갖는 이유: + +> load는 Persistence Context에 hydrate된 행이고, fetch는 association을 만족시키려고 발행된 +> 별도 statement다. 한 statement로 100개를 hydrate한 쿼리와 100개의 statement를 쏜 쿼리는 +> **같은 load count를 보고할 수 있고**, fetch count만이 둘을 구별한다 — 그게 바로 N+1이다. + +`jdbcBatches`는 Hibernate `Statistics`에 없어서 **JDBC 층에서** 센다(`JdbcBatchCounter`, +모든 `PreparedStatement.executeBatch()`). + +> `hibernate.jdbc.batch_size`를 설정하는 건 아무것도 증명하지 않는다. IDENTITY generator, +> 중간에 낀 select, 잘못된 순간의 flush가 배칭을 조용히 끄는데 설정은 여전히 켜져 있다고 +> 말한다. + +기본 구현은 0을 반환하고 `instrumented() == false`라고 **말한다**. 계약 suite는 진짜 카운터를 +설치한다. + +### 6.3 `HibernateProviderPolicy` — 선언 baseline과 런타임 버전을 분리 + +```java +public static final String DECLARED_STABLE_BASELINE = "7.4"; +public String runtimeVersion() // org.hibernate.Version.getVersionString() +public boolean driftsFromDeclaredBaseline() +``` + +설계는 Hibernate 7.4를 Stable로 고정한다. 이 저장소의 Spring Boot BOM은 Boot가 관리하는 +버전(현재 **7.1.8.Final**)을 resolve하고, 설계는 BOM 밖에서 프로바이더 버전을 pin하지 말라고 +똑같이 명확하다. 둘 다 지킬 가치가 있으니 **조용히 하나를 고르는 대신 둘을 분리해서 든다.** + +> 대안 — 선언된 baseline을 런타임인 것처럼 assert하는 것 — 은 collection-fetch-pagination +> 게이트가 전혀 다른 프로바이더로 도는 동안 **상수가 자기 자신과 같다는 걸 증명하는 초록불**을 +> 준다. + +### 6.4 `hibernate.batch` — flush/clear 경계 + +`HibernateJpaBatchExecutor`의 핵심은 flush/clear 쌍이다. flush만 하면 statement는 나가고 +엔티티는 남아서 **이미 persist된 행으로 힙이 찬다** — 고전적 bulk-import OOM. + +여기 정말 좋은 버그 기록이 있다. + +> clear는 모든 managed 엔티티를 detach하고, **flush되지 않은 detached 엔티티는 그냥 +> 사라진다**: INSERT가 발행되지 않고 아무도 문제를 보고하지 않는다. `flushSize=100`, +> `clearSize=150`이면 150에서의 clear가 flush 조건이 false일 때 도착하고, **101–150행이 +> 유실**된 채 executor는 `processed=300`을 반환하고 테이블에는 250개가 있었다. + +그래서 지금은 **clear가 항상 먼저 flush한다.** 두 knob은 독립으로 남고(그게 API가 제공하는 +것), 독립을 안전하게 만드는 불변식이 caller의 배수 선택이 아니라 여기서 강제된다. + +`HibernateBatchConfigurationGuard`는 `GenerationType.IDENTITY`를 거부한다. + +> identity 컬럼 값은 insert 시 DB가 배정하므로, Hibernate는 Persistence Context에 넣을 id를 +> 알기 위해 각 insert를 즉시 실행해야 한다 — 보내지 않은 statement를 큐에 넣을 수 없다. +> 그래서 `hibernate.jdbc.batch_size`가 뭐라 하든 배칭이 **조용히** 꺼진다. +> +> "조용히"가 문제다. 설정은 맞아 보이고, import는 돌고, 유일한 증상은 예상보다 한 자릿수 +> 느리다는 것이다. + +`@Id` 필드를 찾을 때 **superclass를 걷는다** — mapped superclass가 식별자를 들고 있는 게 흔한 +모양이고, 선언 클래스에서 멈추면 이 가드가 쓰여진 바로 그 엔티티들을 전부 "identity 아님"으로 +분류한다. + +`executor`는 활성 트랜잭션을 요구하고 절대 열지 않는다. 각 flush가 auto-commit하게 두면 +실패 시 **롤백 방법 없이 부분 import된 데이터셋**이 남는다. + +### 6.5 `hibernate.bulk` — flush → statement → clear + +``` +flush 먼저: 안 하면 pending managed 변경이 bulk statement '뒤에' 쓰여서 덮어쓴다. +clear 나중: 안 하면 managed 엔티티가 pre-bulk 값을 계속 제공하고, + 나중 flush가 그 stale 값을 bulk가 방금 한 것 위에 쓴다. +``` + +`AffectedRowsExpectation`은 **blast-radius 가드**다. + +> `WHERE` 절을 잃은 bulk `UPDATE`는 여전히 성공한다 — 그냥 테이블 전체를 업데이트할 뿐이고, +> 아무도 눈치채기 전에 트랜잭션이 커밋된다. + +검증은 caller 트랜잭션 안에서 일어나서 롤백이 여전히 가능하다. + +bulk DML은 Persistence Context를 완전히 우회한다 — 엔티티 콜백 없음, `@Version` 체크 없음, +managed 엔티티 갱신 없음. **그게 부수적 한계가 아니라 bulk DML이 빠른 이유**다. + +### 6.6 `hibernate.stateless` — 유일하게 트랜잭션을 소유하는 executor + +```java +try (StatelessSession session = sessionFactory.openStatelessSession()) { + Transaction transaction = session.beginTransaction(); + ... +} +``` + +이게 이 플랫폼에서 executor가 애플리케이션 트랜잭션에 참여하지 않고 **자기 트랜잭션을 +소유하는 유일한 곳**이다. 경계 규칙의 예외라기보다 **귀결**이다: stateless session은 JPA +트랜잭션에 join할 수 없고(다른 session, 다른 커넥션), 아닌 척하면 caller가 믿는 트랜잭션 +밖에서 조용히 돈다. + +`StatelessWorkResult(value, affectedRows)`가 생긴 이유도 버그다. + +> runner가 `maxRows` 인자를 받아서 양수인지 확인하고는 **아무것과도 비교하지 않았다** — cap은 +> caller가 공급하고 아무도 강제하지 않는 파라미터였다. stateless session은 정확히 Persistence +> Context가 감당 못 할 만큼 많은 행을 건드릴 수 있는 작업을 위해 존재하니, "몇 개를 +> 건드렸나"가 bound가 말하는 유일한 질문이다. + +초과하면 **먼저 롤백하고** `StatelessRowCapExceededException`을 던진다. + +--- + +## 7. `postgresql` — 벤더 패키지 전부 + +### 7.1 SQLSTATE 분류 + +`PostgreSqlState` enum이 구조적으로 분류하는 13개 상태: + +| code | 이름 | → FailureCategory | +|---|---|---| +| `40001` | serialization_failure | SERIALIZATION_FAILURE | +| `40003` | statement_completion_unknown | COMPLETION_UNKNOWN | +| `40P01` | deadlock_detected | DEADLOCK | +| `23505` | unique_violation | UNIQUE_CONSTRAINT | +| `23503` | foreign_key_violation | FOREIGN_KEY_CONSTRAINT | +| `23514` | check_violation | CHECK_CONSTRAINT | +| `23502` | not_null_violation | NOT_NULL_CONSTRAINT | +| `55P03` | lock_not_available | LOCK_NOT_AVAILABLE | +| `57014` | query_canceled | QUERY_TIMEOUT | +| `25P03` | idle_in_transaction_session_timeout | TRANSACTION_TIMEOUT | +| `42P01` | undefined_table | SCHEMA_MISMATCH | +| `42703` | undefined_column | SCHEMA_MISMATCH | +| `42501` | insufficient_privilege | **UNKNOWN** | + +`08*` prefix는 `CONNECTION_UNAVAILABLE`. 그리고 **의도적으로 completion-unknown이 아니다** — +커넥션 유실이 커밋을 불명으로 남겼는지는 트랜잭션 phase에 달렸고, 그건 `CommitFailureClassifier`만 +안다. + +미인식 상태는 `UNKNOWN`이고, 이웃 카테고리로 **추측하지 않는다.** + +> 미지의 상태를 직렬화 실패로 분류하면 재시도 코디네이터가 **이미 성공한 쓰기를 기꺼이 +> 다시 돌린다.** + +`PostgreSqlExceptionTranslator`에서 retryable로 표시할 수 있는 카테고리는 +`SERIALIZATION_FAILURE`, `DEADLOCK`, `OPTIMISTIC_CONFLICT` **셋뿐**이다. + +### 7.2 `PostgreSqlServerErrorFields` — 리플렉션으로 구조적 필드 읽기 + +드라이버가 `runtimeOnly` 의존이라 `PSQLException`/`ServerErrorMessage`가 컴파일 +클래스패스에 없다. 그래서 리플렉션으로 `getServerErrorMessage().getConstraint()` / +`.getTable()`을 읽는다. + +대안은 메시지 텍스트에서 constraint 이름을 파싱하는 것인데, 그건 설계 §18.2가 금지하는 +바로 그것이다 — **메시지는 localized이고 서버 버전마다 바뀌므로, 텍스트 파서는 `lc_messages`가 +다른 서버에서 조용히 아무것도 반환하지 않는다.** + +모든 lookup은 `Optional.empty()`로 degrade한다. 드라이버 부재/shaded 드라이버/accessor +rename이 **에러 리포팅을 두 번째 에러로 만들면 안 된다.** + +### 7.3 `postgresql.lock` — SKIP LOCKED 작업 큐 + +`WorkClaimExecutor`가 **일반 "skip locked reads" 스위치가 아니라 큐 API**라는 게 핵심이다. + +> `FOR UPDATE SKIP LOCKED`는 의도적으로 테이블의 **불완전한 뷰**를 반환한다. 경쟁하는 +> worker에게 disjoint한 작업을 나눠주는 데는 옳고, **모든 매칭 행을 봐야 하는 것에는 조용히 +> 틀리다.** 리포지토리 플래그로 노출하면 두 번째 용도가 사고로 등장하니, 이름 붙은 큐를 +> 통해서만 도달할 수 있게 한다. + +`WorkQueueDefinition` 생성자가 등록 SQL을 검사한다. + +- `skip locked` 없으면 거부 — 없으면 worker들이 서로를 블록한다(claim 쿼리의 정반대). +- `order by` 없으면 거부 — 없으면 PostgreSQL이 임의 순서로 반환해서 우선순위가 plan에 따라 + 결정되고 starvation이 재현 불가능해진다. + +`PostgreSqlWorkClaimExecutor`는 batch size / lease expiry를 **파라미터로 바인딩**하고 queue +name은 **statement를 선택**할 뿐 interpolate되지 않는다. lease는 여기서 계산해서 claim을 +잡는 **같은 statement가 쓴다** — 나중 statement가 lease를 세팅하면 "이 트랜잭션이 잠갔지만 +아무도 소유하지 않은 것으로 표시된" 창이 생긴다. + +`MAX_BATCH_SIZE = 1_000`. + +단일 컬럼 프로젝션 정규화도 있다. + +```java +private static Object[] toColumns(Object row) { + return row instanceof Object[] columns ? columns : new Object[] {row}; +} +``` + +> JPA는 statement가 한 컬럼만 프로젝션하면 1-원소 배열이 아니라 bare value를 반환한다. +> `Object[]`로 쓴 매퍼는 **단일 컬럼 큐에서만** 실패한다 — 가장 틀리기 쉽고 가장 알아채기 +> 어려운 모양. + +`PostgreSqlLockOptions`는 `NOWAIT`와 wait timeout을 **별도 필드**로 모델링한다. + +> 하나의 필드와 magic zero로 모델링하는 게 "no wait"가 조용히 "wait forever"가 되는 방식이다. + +무한 타임아웃은 금지. 두 케이스는 **hint 경계에서만** 수렴한다(`jakarta.persistence.lock.timeout`의 +0이 JPA가 정의한 `NOWAIT` 인코딩). + +`PostgreSqlLockExceptionTranslator`는 `55P03`(statement-level, 트랜잭션 살아 있음, terminal)과 +`40P01`(transaction-level, 서버가 이미 롤백함, retryable)을 분리한다. + +> 둘을 합치면 — generic "lock error"가 그렇듯 — 커밋할 수 없는 살아있는 트랜잭션 위에서 +> 재시도 루프를 돌거나, 계속 진행할 수 있었던 트랜잭션을 불필요하게 abort시킨다. + +SQLSTATE가 chain에서 살아남지 못한 경우를 위해 `jakarta.persistence.LockTimeoutException` / +`PessimisticLockException`도 인식한다(Hibernate가 lock timeout 힌트를 스스로 강제할 때 발생). + +### 7.4 `postgresql.write` — 등록된 업서트 + +`RegisteredUpsertStatement` 생성자가 `insert into` + `on conflict`를 요구한다. + +`RegisteredPostgreSqlUpsertExecutor`가 statement 앞뒤로 하는 두 가지: + +- **앞: flush.** native statement는 DB로 직행하므로 flush되지 않은 pending managed 변경이 + upsert *뒤에* 쓰여서 덮어쓴다. +- **뒤: clear.** upsert가 context가 이미 든 행을 바꿨고 Hibernate는 알 방법이 없다. + +`WriteDisposition`(INSERTED / UPDATED / UNDETERMINED)은 **business-visible**하다 — "생성됨"과 +"이미 있어서 갱신됨"은 보통 다른 이벤트를 낸다. 판정은 PostgreSQL의 `(xmax = 0) AS inserted` +프로젝션으로 하고, statement가 그걸 생략하면 **가정하지 않고 UNDETERMINED**를 보고한다. + +`ON CONFLICT DO NOTHING`이 행을 반환하지 않으면 `UPDATED`가 아니라 `undetermined(null)`이다. + +> 쓰기가 일어나지 않았고, 그걸 update로 보고하면 caller에게 존재하지 않는 상태의 행이 +> 있다고 말하는 것이다. + +`UpsertConflictTarget`은 conflict/update 컬럼을 unquoted PostgreSQL identifier +정규식(`[a-z_][a-z0-9_]{0,62}`)으로 검증한다. 빈 update 컬럼 집합은 거부 — `DO UPDATE SET`에 +아무것도 없는 건 유효한 SQL이 아니고, 보통 의도하는 `DO NOTHING`은 반환이 다른 별개의 +statement라 따로 등록해야 한다. + +### 7.5 `postgresql.json` / `.array` / `.range` + +**JSONB**: `JsonDocument(schema, version, payload)`가 envelope다. + +> JSONB 컬럼은 DB 레벨에서 schema-less라서, envelope 없이는 저장된 문서의 의미를 기록한 게 +> **그걸 쓴 코드뿐**이다 — 그러면 두 릴리스 전에 쓴 문서와 지금 것을 구별할 수 없다. + +그리고 payload는 Java 클래스 이름을 절대 담지 않는다. + +> 문서 안의 타입 메타데이터는 JSONB 컬럼을 **역직렬화 가젯**으로 만든다: 행을 쓸 수 있는 +> 자가 reader가 인스턴스화할 클래스를 고른다. + +`@class` 키가 있으면 재귀적으로 찾아서 생성자가 거부한다. + +`JsonDocumentCodec`은 envelope 필드를 **저장 객체의 평범한 멤버**로 쓴다. 그래서 schema와 +version을 payload와 같은 JSONB 연산자로 질의할 수 있다 — "version 1에 아직 남아 있는 모든 +문서"를 찾는 마이그레이션이 애플리케이션을 통한 full scan이 아니라 평범한 인덱스 쿼리가 된다. + +decode 실패는 **offending value를 하나도 담지 않는** `DataCorruptionException`이다. + +`PostgreSqlJsonQuerySupport`의 보안 속성이 명확하다. + +> **path는 registry에서 오고 고정 statement의 일부다. value는 caller에서 오고 항상 bound +> parameter다.** JSON path는 parameterize할 수 없으니 path를 caller에서 받는 설계는 입력을 +> SQL에 연결하는 것이고, value는 할 수 있으니 안 할 이유가 없다. + +`@>` containment를 쓰는 이유는 그게 JSONB 컬럼의 **GIN 인덱스가 실제로 답할 수 있는 것**이기 +때문(`->>` 동등 비교가 아니라). + +**배열**: `Connection.createArrayOf`로 만든다. 손으로 literal을 포맷하는 건 quoting 버그가 +사는 곳이고, 콤마/중괄호/백슬래시를 담은 태그가 배열의 **내용이 아니라 모양**을 바꾸며 실패가 +조용하다. 지원 element type은 `text varchar uuid int4 int8 numeric boolean timestamptz` 8개. + +**범위**: `PgRange(lower, lowerInclusive, upper, upperInclusive)`. + +> 범위를 두 개의 평범한 컬럼으로 모델링하면 정확히 중요한 정보를 잃는다: `[09:00, 10:00)`과 +> `[10:00, 11:00)`이 겹치는지는 **값이 아니라 bracket**에 달렸고, `timestamptz` 컬럼 쌍은 +> 그걸 말할 수 없다. + +inverted range는 Java에서 거부한다 — PostgreSQL도 에러를 내지만 그때는 이미 statement가 +전송됐고 트랜잭션이 오염됐고 caller가 실수 지점의 argument 에러 대신 SQLSTATE를 받는다. + +`PgRangeCodec`의 두 디테일: + +1. **endpoint 함수를 caller가 공급한다.** PostgreSQL의 text form은 ISO-8601이 아니다 — + `tstzrange`는 `1970-01-01 00:00:00+00`(T가 아니라 공백)로 돌아온다. `Instant::parse`로 + 쓴 파서는 이 codec만 문자열을 만진 단위 테스트에서는 round-trip하고 **진짜 서버가 답하는 + 순간 던진다.** +2. **`empty`를 거부한다.** PostgreSQL은 값을 담을 수 없는 범위를 literal `empty`로 정규화하고, + 그건 endpoint가 아예 없다. 진짜 값이지 파싱 실패가 아니고, unbounded range로 읽으면 + **의미가 완전히 반전된다.** +3. separator 탐색이 quote를 인식한다. 순진한 `indexOf(',')`는 endpoint 값에 콤마가 있는 + 순간 엉뚱한 곳을 자르고 **에러가 아니라 조용히 틀린 bound**를 만든다. + +`PgRangeJdbcType`은 `Types.OTHER`로 literal을 보낸다. 그래야 서버가 `tstzrange`, +`daterange` 등 어떤 range type이든 컬럼 선언 타입에 따라 파싱한다. plain string으로 보내면 +서버가 `text`로 보고 cast 에러로 실패한다. + +### 7.6 `postgresql.copy` — 관리자 전용 벌크 로더 + +`COPY`는 J4 admin capability다. Persistence Context, 엔티티 콜백, 낙관적 버전 체크, Envers를 +**전부 우회한다.** + +`RegisteredCopyStatement` 생성자가 강제하는 두 속성: + +- `from stdin`이어야 한다. +- `COPY ... TO`면 안 된다. + +> `COPY ... FROM '/path'`는 **DB 서버의 파일**을 서버 OS 사용자로 읽고, `COPY ... TO '/path'`는 +> 하나를 쓴다. 정확히 그 이유로 둘 다 superuser 전용이고, 어느 쪽도 애플리케이션 API 뒤에 +> 있으면 안 된다. + +`CopyLimits(maxRows, maxBytes, timeout)` 셋 다 필수. + +`BoundedCopyInputStream`이 **드라이버가 읽는 동안** 센다. + +> source 크기를 미리 확인하는 건 옵션이 아니다 — `COPY` source는 스트림이고 길이는 보통 끝날 +> 때까지 모른다. 드라이버가 읽는 동안 세는 게 limit을 진짜로 만든다. + +예외 타입이 `IOException`인 것도 의도다 — 드라이버가 이 스트림을 읽고 있고, 그 read loop +안에서 던져진 unchecked 예외는 예상하지 못한 코드를 관통해 **커넥션에 copy 프로토콜을 +반쯤 끝난 상태로 남긴다.** + +timeout은 **서버 사이드** `statement_timeout`으로 건다. + +> 클라이언트 사이드 timeout은 서버가 계속 행을 쓰는 동안 호출을 버린다. `statement_timeout`은 +> 서버 자신이 멈추게 하고, 그게 작업을 실제로 멈추는 유일한 버전이다. + +그리고 `SET statement_timeout = ?`가 아니라 `set_config('statement_timeout', ?, true)`인 +이유가 여기도 나온다 — `SET`은 파라미터 바인딩 전에 파싱되므로 placeholder가 syntax error다. + +로더는 JPA 커넥션을 빌리지 않고 **자기 `DataSource`**를 받는다. `COPY`는 bulk/admin +credential로 도는데, 그건 설계상 런타임 롤과 다른 롤이다 — 런타임 롤은 DDL이 없음이 +검증되고, mass-insert를 해야 할 롤도 아니다. + +`CopyAdminCapability(operator, reason)`은 boolean 프로퍼티가 아니라 **capability 객체**다. + +> 그러면 운영자와 이유가 호출과 함께 이동해서, 로드가 익명 벌크 쓰기가 아니라 **누군가의 +> 행동**으로 감사 기록에 나타난다. + +`CopyFormat`에 `BINARY`가 없다 — PostgreSQL 내부 타입 표현을 인코딩해서, 한 서버 버전에서 +로드되는 파일이 다른 버전에서 조용히 오독될 수 있다. + +### 7.7 벤더 선택과 H2 대칭 + +두 벤더 composition이 각각 **같은 네 개의 SPI 빈**을 등록한다. + +| SPI | PostgreSQL | H2 | +|---|---|---| +| `OutboxClaimRepository` | `PostgreSqlOutboxClaimRepository` | `H2OutboxClaimRepository` | +| `IdempotencyClaimRepository` | `PostgreSqlIdempotencyClaimRepository` | `H2IdempotencyClaimRepository` | +| `SqlStateErrorMapping` | `PostgreSqlSqlStateErrorMapping` | `H2SqlStateErrorMapping` | +| `TransactionLocalTimeoutConfigurer` | `PostgreSqlLocalTimeoutConfigurer` | `H2LocalTimeoutConfigurer` | + +선택자는 `ca-skeleton.persistence.vendor` (`PersistenceVendorSettings`, enum 바인딩). + +> enum 바인딩이 미지의 벤더를 **startup 실패**로 만든다. raw string이면 두 +> `@ConditionalOnProperty` 벤더 config가 **둘 다 꺼진 채로 남고**, 첫 번째 누락 SPI 빈이 +> `OutboxClaimRepository`를 명명한 `NoSuchBeanDefinitionException`으로 나타난다 — 원인이 된 +> 오타에서 몇 층 떨어진 증상. + +부재는 PostgreSQL이다(`matchIfMissing = true`) — 선택자가 생기기 전 모든 배포가 돌던 벤더. + +**두 statement가 갈라지고, 이유는 추정이 아니라 측정이다 (H2 2.4.240).** + +- **outbox claim은 동일하다.** H2가 `FOR UPDATE SKIP LOCKED`를 받아들이고 잠긴 행을 진짜로 + 건너뛴다. 한 커넥션이 row lock을 든 상태에서 동시 `SKIP LOCKED` claim이 블록하거나 락을 + 뚫고 읽는 게 아니라 **0행을 반환**하는 걸 확인했다. 그래서 claim이 의미를 유지한다. +- **idempotency claim은 다르다.** H2에는 `INSERT ... ON CONFLICT ... RETURNING`이 없어서 + 같은 세 결과를 갖는 `MERGE ... USING`이다. `H2ClaimSqlTest`가 둘 다 실제 H2에 실행한다. + +H2의 `MERGE`가 세 결과를 어떻게 만드는지: + +| 상황 | 분기 | 반환 | +|---|---|---| +| scope에 행 없음 | `WHEN NOT MATCHED` insert | 1 | +| live 행 | 어느 분기도 발동 안 함 | 0 (live winner에게 짐) | +| expired 행 | `WHEN MATCHED AND expires_at <= now` | 1 (takeover) | + +한 statement인 게 SPI의 약속을 지킨다 — **claim 실패가 constraint violation이 아니라 0행**이라 +주변 트랜잭션을 오염시키지 않는다. + +`RETURNING`이 필요 없는 이유도 적혀 있다: PostgreSQL statement가 반환하는 `EXCLUDED.id`는 +양쪽 분기에서 proposed id이므로, claim된 행은 항상 이 caller의 proposed id다. + +**H2 vendor config에는 Flyway location customizer가 일부러 없다.** H2 등가 트리가 없고, +local profile이 Flyway를 끄고 Hibernate가 엔티티에서 스키마를 만들게 하기 때문. 두 가지 +귀결이 명시되어 있다. + +- 마이그레이션에만 있는 테이블 — capability schema registry, polling-delivery/inbox 스트림, + Spring Integration lock 테이블 — 은 H2에서 생성되지 않는다. +- Flyway를 켠 fork는 location override가 없어서 `classpath:db/migration`으로 fallback하고 + **H2가 파싱할 수 없는 PostgreSQL DDL을 포함한 트리 전체를 걷는다.** + +그리고 PostgreSQL vendor의 Flyway customizer에도 좋은 수정 기록이 있다. + +> 무조건 `locations(...)`를 호출했는데, 그건 Spring이 `spring.flyway.locations`에서 +> 바인딩한 것을 **대체**한다. 그래서 운영자가 `SPRING_FLYWAY_LOCATIONS`로 capability +> 스트림을 추가하고, Flyway가 성공적 마이그레이션을 보고하는 걸 보고, **벤더 스트림만** +> 얻을 수 있었다 — 프로퍼티는 읽히고 바인딩되고 그 뒤에 도는 customizer가 버렸다. +> `local-notification-ingest` 레인은 7개 location을 세팅하고 1개를 적용했다. + +지금은 "아무도 고르지 않았을 때만 기여하고, 누군가 골랐으면 비켜선다". + +--- + +## 8. `migration` — Flyway가 스키마의 주인 + +### 8.1 정책과 게이트 + +`FlywaySchemaPolicy`가 프로필별 모드를 정한다. + +| 프로필 | 모드 | +|---|---| +| local / test / dev | `MIGRATE_ON_STARTUP` | +| staging / prod | `VALIDATE_ONLY` | +| (fallback) | `VALIDATE_ONLY` | + +> local/test/dev는 startup에 마이그레이션해도 된다: 루프가 빠르고 자격증명이 로컬이다. +> staging/prod는 대신 **배포 파이프라인 소유 마이그레이션**을 지원한다. 애플리케이션 안에서 +> 마이그레이션한다는 건 rolling deploy의 모든 인스턴스가 같은 스크립트를 적용하려고 경주하고, +> **패자의 실패가 진짜 실패와 구별되지 않는다**는 뜻이기 때문. + +`ddl-auto` 허용값은 `none`, `validate` 둘뿐. `update`/`create`/`create-drop`은 거부. + +**Repair는 모드가 아니다.** 운영자가 의도적으로 호출하는 operation descriptor로만 존재하고, +startup 동작으로는 절대 아니다. + +`FlywayValidationGate`도 절대 repair하지 않는다. + +> Flyway의 `repair`는 schema history 테이블을 지금 디스크에 있는 스크립트에 맞게 다시 쓴다. +> 이건 **증거를 지워서 증상을 해결**한다: checksum mismatch는 배포된 스크립트가 적용된 것과 +> 다르다는 뜻이고, 흥미로운 질문은 **어떤 변경이 이 DB에 빠졌는가**다. Repair는 그 질문을 +> 물을 수 없게 만들어서 답한다. + +예외에는 **Flyway의 구조적 error code만** 넣는다. validation 메시지는 스크립트 경로와, 실패한 +마이그레이션의 경우 statement 일부를 담는데, 둘 다 클라이언트에 도달하는 에러에 들어가면 안 된다. + +### 8.2 `ConcurrentIndexMigrationInspector` + +PostgreSQL은 `CREATE INDEX CONCURRENTLY`를 트랜잭션 블록 안에서 아예 거부하고, Flyway는 +기본적으로 마이그레이션을 트랜잭션으로 감싼다. **그래서 첫 배포에서 실패하는데, 그게 좋은 +경우다.** + +> 나쁜 경우는 다른 statement와 섞인 마이그레이션이다: 그것들은 커밋되고, concurrent index는 +> 안 되고, 스키마는 **history row가 반대로 말하는 채로 부분 마이그레이션 상태로 남는다.** + +검사 대상: `create index concurrently`, `drop index concurrently`, `reindex index concurrently`, +`reindex table concurrently`, `reindex concurrently`. + +`requireIsolatedStatement`도 있다 — concurrent 빌드가 실패하면 invalid index가 남고, 그 +복구는 마이그레이션이 다른 걸 아무것도 안 했으면 `DROP INDEX` 하나지만 뭔가 했으면 부분 +상태를 손으로 재구성해야 한다. + +`FailedConcurrentIndexRecovery`가 `pg_index.indisvalid = false`를 조회해서 리포트한다. +**자동으로 지우지 않는다.** + +> invalid index는 **빌드가 아직 돌고 있다**는 뜻일 수도 있고, 카탈로그만으로는 둘을 구별할 수 +> 없다. 자동 drop은 곧 끝나려던 concurrent 빌드와 경주하게 되고, rolling deploy의 모든 +> 인스턴스에서 동시에 그렇게 한다. + +리포트는 운영자가 돌릴 statement를 준다(그 자체가 non-transactional인 `DROP INDEX CONCURRENTLY`). + +### 8.3 실제 마이그레이션 트리 — 8개 독립 스트림 + +여기가 이 모듈에서 제일 배울 게 많았던 부분이다. + +``` +db/migration/postgresql/ ← 기본 location, flyway_schema_history (legacy/adoption) +db/migration/jpa/core/ ← flyway_jpa_core_history (독립) +db/migration/jpa/idempotency/ ← flyway_jpa_idempotency_history (독립) +db/migration/jpa/outbox-storage/ ← flyway_jpa_outbox_storage_history (독립) +db/migration/jpa/outbox-polling/ ← flyway_jpa_outbox_polling_history (독립) +db/migration/jpa/inbox/ ← flyway_jpa_inbox_history (독립) +db/migration/jpa/fileserver/ ← (capability 스트림) (독립) +db/migration/jpa/notification-platform/ ← flyway_jpa_notification_history (독립) +db/experimental-rls/ ← Stable location이 절대 적용하지 않음 +``` + +**왜 독립 스트림인가**가 `NotificationSchemaStream` javadoc에 제일 명확하게 적혀 있다. + +> 마이그레이션이 `db/migration/jpa/notification-platform`에 있는데 primary location은 +> `db/migration/postgresql`이라 아무것도 적용하지 않았다. 뻔한 해법 — 디렉터리를 primary +> location 목록에 추가 — 이 틀린 이유: **두 트리가 다 V1부터 번호를 매기고**, 공유 history +> 테이블은 `V1__notification_platform_core`와 `V1__initial_schema`를 같은 버전으로 만든다. +> Flyway는 두 번째를 거부하거나, 더 나쁘게는 resolution 순서에 따라 하나를 기록하고 하나를 +> 건너뛴다. + +독립 스트림 + 자기 history 테이블이 두 버전 계열을 독립적으로 유지하고, **그게 capability를 +진짜로 optional하게 만든다.** + +`baselineVersion("0")` + `baselineOnMigrate(true)`가 필수인 이유도 좋다. + +> 이 스트림은 core 스트림이 이미 채운 스키마 위에 자기 history 테이블을 갖는다. 그래서 +> Flyway가 "history 테이블 없는 비어있지 않은 스키마"라고 시작을 거부한다. version 0 +> baseline은 **모든 notification 마이그레이션이 여전히 돈다**는 뜻이다 — 더 높은 버전 +> baseline이면 그것들을 건너뛰는데, 그게 이 설정이 보통 두려워하는 실패 모드다. + +그리고 `cleanDisabled(true)`, `validateOnMigrate(true)`, `outOfOrder(false)`. + +### 8.4 `capability_schema_registry` — 독립 스트림의 접착제 + +독립 스트림 설계의 핵심 테이블이다. + +```sql +CREATE TABLE capability_schema_registry ( + capability_id varchar(128) NOT NULL, + schema_stream varchar(128) NOT NULL, -- V2/V9에서 32 → 128로 확장 + installation_origin varchar(32) NOT NULL, -- FRESH | LEGACY_ADOPTED + core_epoch integer NOT NULL, + feature_revision integer NOT NULL, + lifecycle_state varchar(32) NOT NULL, -- INSTALLED_INACTIVE | ACTIVE + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + ... +); +``` + +각 capability 스트림의 V1이 하는 일이 항상 같은 패턴이다. + +1. **선행조건 검사** — `DO $$ ... RAISE EXCEPTION ... $$`로 core epoch가 ACTIVE인지, + legacy adoption이면 호환 테이블이 있는지 확인. +2. 테이블/인덱스/트리거 생성. +3. **레지스트리에 자기를 `INSTALLED_INACTIVE`로 등록.** + +즉 **스키마가 적용된 것과 capability가 사용 승인된 것이 분리**되어 있다. 어댑터들이 런타임에 +이걸 확인한다. + +```sql +select count(*) from capability_schema_registry + where capability_id = 'jpa-idempotency-owner-safe-v2' + and core_epoch = 1 and feature_revision = 2 and lifecycle_state = 'ACTIVE' +``` + +`schema_stream` 컬럼 확장(V2/V9)의 기록이 아주 좋다. + +> 컬럼이 `varchar(32)`였고, 작성 당시 존재하던 모든 스트림에 맞았고, +> `'db/migration/jpa/notification-platform'`(38자)에서 안 맞기 시작했다. 실패 모드가 나쁜 +> 종류다: 모든 면에서 올바른 capability 등록이 `value too long for type character varying(32)`로 +> 마이그레이션 타임에 실패하고, **뻔한 우회책은 스트림의 경로가 아닌 더 짧은 경로를 +> 기록하는 것** — 스키마가 어디서 왔는지에 대해 거짓말하는 레지스트리는 레지스트리가 없는 +> 것보다 나쁘다. + +그리고 왜 하필 128인가: `capability_id`가 `varchar(128)`이고 둘 다 같은 종류의 bounded +identifier이며, **하나의 bound가 두 개보다 추론하기 쉽기 때문.** + +같은 확장이 `db/migration/postgresql/V9`에도 있고, 번호가 **V9인 이유**가 또 재밌다. + +> V9이지 V8이나 V7이 아니다. `db/migration/postgresql`과 `db/sample-migration`이 sample +> composition root에 의해 하나의 Flyway location 목록으로 합쳐져서 **하나의 버전 공간을 +> 공유**한다: sample이 이미 V2, V7, V8을 소유한다. 중복 버전은 Flyway가 해결하는 merge +> conflict가 아니라 — **아예 시작을 거부한다.** + +### 8.5 `char(64)` → `varchar(64)` — H2가 숨긴 버그 + +내가 제일 좋아하는 기록이다. `V10__idempotency_request_hash_varchar.sql`. + +> V1이 `request_hash`를 `char(64)`로 선언했는데 `IdempotencyRecordEntity`는 `length = 64`로 +> 매핑하고 Hibernate는 그걸 `varchar(64)`로 읽는다. 실 PostgreSQL에 대해 `ddl-auto=validate`로 +> 시작하면 실패했다: +> +> ``` +> Schema-validation: wrong column type encountered in column [request_hash] in table +> [idempotency_record]; found [bpchar (Types#CHAR)], but expecting [varchar(64) (Types#VARCHAR)] +> ``` +> +> **로컬 개발이 H2로 도는 한 보이지 않았다.** H2의 create-drop은 엔티티에서 스키마를 만들고, +> 그래서 **절대 엔티티와 불일치할 수 없다.** + +그리고 왜 엔티티가 아니라 마이그레이션을 바꿨나: 64자 hex digest는 padding을 절대 안 쓰고, +코드베이스의 나머지(엔티티, H2 composition, JPQL)가 이미 varchar를 가정하고, bpchar→varchar +cast는 trailing blank를 제거하니 padding된 값도 무손실 변환된다. + +두 스트림(`db/migration/postgresql/V10`, `db/migration/jpa/idempotency/V2`)이 **똑같은 가드된 +변환**을 각각 갖는다. 상대 순서가 고정되어 있지 않아서, 테이블을 만든 쪽이 어느 쪽이든 다음에 +도는 쪽이 고쳐야 한다. + +```sql +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid + WHERE c.relname = 'idempotency_record' AND a.attname = 'request_hash' + AND NOT a.attisdropped + AND format_type(a.atttypid, a.atttypmod) = 'character(64)') + THEN ALTER TABLE idempotency_record ALTER COLUMN request_hash TYPE varchar(64); + END IF; +END $$; +``` + +> V1을 in-place로 고치는 게 더 작고 틀렸을 것이다: **적용된 마이그레이션의 checksum은 이미 +> 그것을 돌린 모든 배포에 대한 약속이다.** + +이 사건이 `application-local.yml`을 통째로 바꿨다. 지금은 local도 PostgreSQL + Flyway + +`ddl-auto=validate`가 기본이고, H2는 별도 개발자 task로 밀려났다. + +> `local`이 JPA 켜진 상태에서 dev/prod와 같은 벤더, 같은 마이그레이션 모드, 같은 스키마 +> 소유자를 갖고 주소와 자격증명만 다르다. H2가 기본이었을 때 "로컬에서는 됩니다"는 다른 +> 모든 환경이 도는 것과 **다른 데이터베이스**에 대한 진술이었다: 마이그레이션이 적용된 적 +> 없고, ddl-auto가 엔티티에서 스키마를 쓰고, 마이그레이션 트리와 불일치하는 매핑을 실 +> PostgreSQL이 볼 때까지 발견할 수 없었다. 이번 wave에 두 번 그랬다 — `fs_cleanup_item`과 +> `request_hash`. + +--- + +## 9. V1 인프라 테이블 — idempotency / outbox / INT_LOCK + +레거시(그러나 현역) 스키마 세 개. + +### 9.1 `idempotency_record` (V1) + +```sql +CREATE TABLE idempotency_record ( + id uuid, tenant varchar(128) NOT NULL DEFAULT '', principal varchar(256) NOT NULL, + idempotency_key varchar(256) NOT NULL, use_case_name varchar(256) NOT NULL, + request_hash char(64) NOT NULL, -- → V10에서 varchar(64) + status varchar(16) NOT NULL, response_payload text, response_ref varchar(512), + created_at timestamptz NOT NULL, expires_at timestamptz NOT NULL, + CONSTRAINT uq_idempotency_scope UNIQUE (tenant, principal, idempotency_key, use_case_name) +); +CREATE INDEX ix_idempotency_record_expires_at ON idempotency_record (expires_at); +``` + +**`tenant`가 절대 `null`이 아니다** (single-tenant는 빈 문자열). + +> PostgreSQL은 NULL을 서로 distinct로 취급하므로, null을 허용하면 single-tenant 행의 unique +> scope dedup이 깨진다. + +매퍼가 `null` 애플리케이션 tenant ↔ row의 `""`를 왕복시킨다. + +**엔티티에 setter가 없는 것도 의도**다. 상태 전이 때 row를 재구성·재저장한다(Vernon Option A) — +애플리케이션 관점에서 엔티티를 immutable로 유지. + +동시성 중재는 unique 제약이다. `tryBegin`: + +- SPI(`IdempotencyClaimRepository`)가 있으면 벤더 statement 한 방. +- 없으면 lookup → 만료 행 delete → `saveAndFlush`(flush가 unique 검사를 그 자리에서 강제) → + `DataIntegrityViolationException`이면 `false`. + +PostgreSQL SPI: + +```sql +INSERT INTO idempotency_record (...) VALUES (..., 'IN_FLIGHT', NULL, NULL, ...) +ON CONFLICT ON CONSTRAINT uq_idempotency_scope +DO UPDATE SET id = EXCLUDED.id, request_hash = EXCLUDED.request_hash, status = 'IN_FLIGHT', ... +WHERE idempotency_record.expires_at <= :now +RETURNING id +``` + +`DO UPDATE ... WHERE`가 실패하면 행이 반환되지 않는다 → 패자는 `Optional.empty()`. + +`tryClaim`이 하는 detach가 중요하다. + +```java +if (exactExpiredEntity != null && entityManager.contains(exactExpiredEntity)) { + entityManager.detach(exactExpiredEntity); +} +``` + +이 statement가 덮어쓰려는 행의 managed 복사본이 있으면 커밋 시점에 claim 위로 flush된다. + +만료는 **세 곳**에서 강제된다: read(`find`가 만료 행을 부재로 취급), reclaim(`tryBegin`이 +만료 행을 재claim 전에 처리), 그리고 `IdempotencyReaper`(`@Scheduled`, 기본 10분, +`deleteExpired` bulk delete). reaper는 유일한 만료 수단이 아니라 **테이블 성장을 묶는 +backstop**이다. + +§F responseRef split: payload ≤ 8KB는 row에 inline, 더 크면 `IdempotencyResponseObjectStore`로 +offload하고 reference만 보관. object store 빈이 없으면 **경고와 함께 inline으로 안전 degrade** +(D9 프로젝트 선택 — 8KB 임계와 object-store 분리는 cited normative basis가 없고 production +빈도도 미측정). + +### 9.2 `outbox_event` (V3) + +```sql +CREATE TABLE outbox_event ( + event_id varchar(64) PK, aggregate_id varchar(256), event_type varchar(256), + payload text, occurred_at timestamptz, + status varchar(16), -- PENDING | IN_FLIGHT | PUBLISHED | FAILED | DEAD + attempt_count integer DEFAULT 0, + next_attempt_at timestamptz, -- dual-purpose + correlation_id varchar(64), idempotency_key varchar(256) +); +CREATE INDEX ix_outbox_event_eligible ON outbox_event (next_attempt_at) + WHERE status IN ('PENDING','FAILED','IN_FLIGHT'); +CREATE INDEX ix_outbox_event_aggregate_occurred ON outbox_event (aggregate_id, occurred_at); +CREATE INDEX ix_outbox_event_published_occurred ON outbox_event (occurred_at) + WHERE status = 'PUBLISHED'; +CREATE INDEX ix_outbox_event_status_occurred ON outbox_event (status, occurred_at); +``` + +**`next_attempt_at` dual-purpose (I6 — 추가 컬럼 없음)**: + +| status | 값 | 효과 | +|---|---|---| +| `PENDING` | insert 시 `occurred_at` | 최초 claim 체크(`next_attempt_at <= now`)가 즉시 만족 | +| `IN_FLIGHT` | `claim_time + in_flight_timeout` | orphan 행이 visibility window 만료 후 재claim 가능 | +| `FAILED` | `now + backoff` | backoff 경과 후에만 재시도 | + +claim SQL이 **eligibility predicate와 per-aggregate FIFO gate를 전부 SQL에서** 강제한다. + +```sql +SELECT * FROM outbox_event o + WHERE o.next_attempt_at <= :now + AND o.status IN ('PENDING', 'FAILED', 'IN_FLIGHT') + AND NOT EXISTS ( -- I4: per-aggregate FIFO gate + SELECT 1 FROM outbox_event p + WHERE p.aggregate_id = o.aggregate_id + AND p.occurred_at < o.occurred_at + AND p.status <> 'PUBLISHED') + ORDER BY o.occurred_at ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED -- I3: concurrent-relay 안전 +``` + +한 배치에 **aggregate당 최대 한 행(head)**만 나타난다. 어댑터는 추가 in-memory 필터링을 +하지 않는다 — 리포지토리가 반환한 모든 행을 `IN_FLIGHT`로 전이시켜 호출자에 돌려준다. + +`markPublished` / `markFailed` / `markDead`가 **row 부재 시 throw**하는 이유: + +> relay가 방금 같은 서비스 인스턴스에서 claim한 row이므로, 부재는 프로그래밍/동시성 버그다. +> 조용히 no-op하면 row가 영원히 `IN_FLIGHT`로 남아 그 aggregate의 FIFO 큐를 막고, 호출자나 +> 로그에 아무 신호도 남지 않는다. + +`OutboxEventEntity`가 **`AuditableEntity`를 상속하지 않는** 것도 결정이다. + +> `IdempotencyRecordEntity`처럼 outbox row는 도메인 애그리거트가 아니라 인프라 record다. +> 자체 temporal 필드(`occurred_at`, `next_attempt_at`)가 도메인 의미를 갖고, generic +> `created_at`/`updated_at` audit 컬럼과 섞이면 안 된다. + +반대로 **mutating setter 노출은 의도**다 — relay 어댑터가 managed 엔티티 위에서 상태를 +전이시키되 full reload-and-replace 없이 한다. + +메트릭 쿼리 반환 형태의 비대칭도 기록되어 있다. + +```java +Map countByStatus() → EnumMap (키가 enum) +Map oldestUnpublishedAgeSecondsByEventType(Instant) → HashMap (키가 String event-type) +``` + +### 9.3 `INT_LOCK` (V4/V5) — 분산 락 + +Spring Integration JDBC `LockRegistry` 백킹 테이블. V4가 SI 6.5 PostgreSQL 스키마 그대로이고, +V5가 SI 7의 `EXPIRED_AFTER` 컬럼을 추가한다 (**V4를 immutable로 두고 앞으로 진화**). + +```sql +ALTER TABLE INT_LOCK ADD COLUMN IF NOT EXISTS EXPIRED_AFTER TIMESTAMP; +UPDATE INT_LOCK SET EXPIRED_AFTER = CREATED_DATE WHERE EXPIRED_AFTER IS NULL; +ALTER TABLE INT_LOCK ALTER COLUMN EXPIRED_AFTER SET NOT NULL; +``` + +`DistributedLockPersistenceConfig`의 provider 선택: + +| `ca-skeleton.runtime.multi-instance-enabled` | 빈 | +|---|---| +| `false` 또는 부재 | `DefaultLockRegistry` (in-process), `@Primary` | +| `true` | `DefaultLockRepository` + `JdbcLockRegistry` + `jdbcDistributedLock` | + +**`jdbcDistributedLock` 빈이 일부러 `@Primary`가 아니고 이름도 `distributedLockProvider`가 +아니다.** app-bootstrap이 이걸 metrics 데코레이터로 감싸 `@Primary`/`distributedLockProvider` +빈을 노출한다. 그래서 SI 타입이 컴파일 타임에 adapter-persistence 위 레이어에 보이지 않는다. + +`setCheckDatabaseOnStart(false)` — `INT_LOCK`은 첫 lock acquire 전에 Flyway V4/V5가 +provision하므로 DDL 체크를 건너뛴다. + +`JdbcLockRegistry(lockRepository, settings.leaseTtl())` — SI 7.0 이후 기본 TTL은 repository +setter가 아니라 **registry 생성자**에서 설정한다. + +`LockRegistryDistributedLockAdapter`의 계약: + +- **D4 — transaction-commit ordering invariant.** 이 어댑터는 트랜잭션 경계를 관리하지 않는다. + 호출자는 보호된 트랜잭션이 **커밋된 뒤에만** 핸들을 release해야 한다. 커밋 전에 release하면 + lost-update race가 생긴다. +- **D5 — finite waitTime + lease TTL.** 무한 블로킹은 쓰지 않는다. +- `leaseTtl > configuredTtl`은 `IllegalArgumentException` — provider 기본 TTL보다 긴 lease를 + 약속하는 건 false contract. +- `InterruptedException`은 interrupt flag를 복원하고 `LockAcquisitionTimeoutException`으로 변환. +- 반환 핸들은 `lock::unlock` 람다. + +**SI-LOCK-C5**: lease TTL이 `close()` 전에 만료된 `JdbcLockRegistry`에서는 내부 +`JdbcLock.unlock()`이 `ConcurrentModificationException`을 던진다(row가 이미 회수됨). 이 +어댑터는 **일부러 잡지 않는다** — metered 데코레이터가 로그 + `lock.lease.expired` metric 후 +정상 return해서, 만료가 호출자의 보호작업 예외를 가리지 않게 한다. + +`LockSettings`의 교차 필드 불변식: `leaseTtl >= waitTime`. + +> TTL이 waitTime보다 먼저 만료되면 첫 holder의 보호작업이 끝나기 전에 두 번째 holder가 락을 +> 잡을 수 있다. + +기본값: `waitTime = 3s`, `leaseTtl = 30s`. + +--- + +## 10. V2 후보 구현 — owner-safe 상태 기계 네 개 + +여기가 이 모듈에서 가장 최근이고 가장 정교한 부분이다. 전부 +`implemented-candidate` 상태이고, `readiness-cards.yaml`이 그걸 명시한다. + +네 구현이 **공통 패턴**을 공유한다. + +1. **capability guard** — `capability_schema_registry`에서 `ACTIVE` + 정확한 epoch/revision 확인. +2. **same-resource primary write transaction guard** — + `isActualTransactionActive() && !isCurrentTransactionReadOnly() && hasResource(dataSource)`. +3. **row lock 먼저, `clock_timestamp()` 그 다음** — 시간을 애플리케이션 시계가 아니라 DB에서, + 그리고 **행이 바뀔 수 없게 된 뒤에** 읽는다. +4. **owner CAS 튜플을 SQL where 절에 전부 반복** — scope, token, attempt, claim operation, + state revision. update count가 곧 답이다. +5. **transition digest** — 재시도가 같은 전이인지 다른 전이인지 구별. +6. **Spring stereotype 없음** — 두 composition root가 `dev.caskeleton.adapter`를 + component-scan하므로 `@Repository`를 붙이면 선택하지 않은 배포에서도 빈이 된다. + +### 10.1 `PostgreSqlOwnerSafeIdempotencyStore` + +`IdempotencyStorePortV2` 구현. 상태: +`CLAIMED → EXECUTING → COMPLETED | FAILED_RETRYABLE | ABANDONED`. + +클래스가 하는 일과 협력자로 분리한 것이 명확히 문서화되어 있다. + +| 협력자 | 책임 | +|---|---| +| `IdempotencyCapabilityGuard` | 3개 precondition (vendor, capability, transaction) | +| `IdempotencyRowMapper` | row 읽기 + `clock_timestamp()` | +| `IdempotencyClaimGateway` | 소유권을 **정하는** 3개 statement (insert / reset / abandon) | +| `IdempotencyTransitionGateway` | 이미 가진 claim을 **진행시키는** 5개 statement | +| `IdempotencyDigestPolicy` | transition digest 구성 | +| `IdempotencyRecordRow` | row 정의 (package-private) | + +협력자를 주입이 아니라 **생성자에서 조립**하는 이유: "이건 이 store의 부품이지 애플리케이션이 +조립하거나 교체하는 서비스가 아니고, 주입하면 public bean 표면이 1개에서 6개로 넓어진다." + +**claim 결정 트리**(`claim()`)를 순서대로: + +``` +insert (on conflict (scope_hash) where record_version = 2 do nothing) +row = findForUpdate(scope) ← 여기서 lock +dbNow = clock_timestamp() ← lock 이후 + +inserted == 1 → Acquired +COMPLETED이고 replay_until 지남 → resetClaim → TakenOverClaimed +request_hash 불일치 → FingerprintMismatch +COMPLETED이고 replay_until 있음 → CompletedReplay(payload, replayUntil) +같은 claim attempt(token+operationId) → ReplayedAcquire +같은 token, 다른 operationId → OwnerOperationConflict +CLAIMED이고 lease 만료 → resetClaim → TakenOverClaimed +FAILED_RETRYABLE → resetClaim → TakenOverClaimed +EXECUTING이고 lease 만료 → abandonExpiredExecution → RecoveryRequired +ABANDONED → RecoveryRequired +그 외 → InProgress(retryAfter, attempt) +``` + +**만료된 `CLAIMED`만 takeover하고, 만료된 `EXECUTING`은 `ABANDONED`로 닫고 reconciliation을 +요구한다.** 실행이 시작된 뒤의 blind retry를 하지 않는다는 뜻이다. + +`IdempotencyCapabilityGuard`에 아주 좋은 버그 기록이 있다. + +> store가 스레드에 활성 read-write 트랜잭션이 있는지 확인했는데, 그건 **어떤 data source에서든 +> 어떤 트랜잭션이든 열려 있으면 참**이다. 형제 outbox/inbox 어댑터는 +> `hasResource(dataSource)`를 확인하는데, 그게 실제로 중요한 질문이다. +> +> 차이는 data source가 둘인 애플리케이션에서 드러난다: **다른** 쪽의 트랜잭션 안에서 발행된 +> mutation이 옛 검사를 통과하고, 이 store의 커넥션에서 트랜잭션 밖으로 돌고, +> 원자적이어야 했던 작업과 **독립적으로 커밋됐다.** + +vendor 검사도 있다(`getDatabaseProductName()`, 한 번만, `volatile boolean`으로 캐시). + +> store의 statement는 `on conflict ... do nothing`, `clock_timestamp()`, `for update`를 쓴다 — +> 이 provider를 선택한 H2/MySQL 배포는 refusal이 아니라 **mutation 안 어딘가에서** syntax나 +> semantics 실패를 얻는다. 그리고 vendor가 더 싼 질문이다. + +`IdempotencyTransitionGateway`의 5개 statement가 전부 같은 모양이다. + +```sql +update idempotency_record + set status = ..., state_revision = state_revision + 1, + last_transition_operation_id = ?, last_transition_kind = ?, + last_transition_result_digest = ?, updated_at = clock_timestamp() + where scope_hash = ? and record_version = 2 + and status = ? -- 기대 상태 + and owner_token = ? and attempt = ? and claim_operation_id = ? + and state_revision = ? -- CAS + and processing_lease_until > clock_timestamp() -- START/RENEW만 +``` + +> update count가 **곧 답이다**: 1이면 이 owner가 이 revision에서 여전히 owner였고, 0이면 다른 +> 무언가가 record를 움직였으니 caller는 자기 view를 현재로 취급하면 안 된다. row를 읽고 나서 +> scope만으로 update하면 lease가 만료된 worker가 takeover한 worker의 상태를 덮어쓸 수 있다. + +`IdempotencyDigestPolicy`의 기록이 특히 좋다. + +> `transition|operationId|ownerToken|attempt|stateRevision`은 **누가, 언제**를 덮고 **무엇**은 +> 전혀 덮지 않는다: retryable로 기록된 `FAIL`과 abandoned로 기록된 `FAIL`이 같은 digest를 +> 냈고, 서로 다른 응답이나 서로 다른 retention을 가진 두 completion도 그랬다. digest를 +> 비교하는 replay는 **차이 전체가 중요한 부분인 두 전이**를 "같은 전이"라고 결론지었다. + +그래서 지금은 `semanticArguments` 가변 인자로 disposition/retention/response digest/codec +identity를 전부 포함하고, **length-framed**로 구성한다. + +```java +private static void write(StringBuilder out, String value) { + out.append(value.length()).append(':').append(value); +} +``` + +> 모든 구성요소가 가변 폭 텍스트이고 최소 하나(owner token)는 이 플랫폼이 제약할 것이 아니다. +> delimiter로 join하면 **서로 다른 두 구성요소 목록이 하나의 문자열로 렌더링될 수 있다.** + +`VERSION = 2`이고, 구성이 바뀌면 bump해서 저장된 digest가 그것을 가로질러 비교되지 않게 한다. + +`replayVerdict`가 3값이다: `NOT_A_REPLAY` / `SAME_ARGUMENTS` / `DIFFERENT_ARGUMENTS`. + +> kind와 operation id가 검사 전부였고 전이가 쓴 digest는 아무도 읽지 않았다. 그래서 하나의 +> operation id 아래 두 번째 `markFailed`는 **무엇을 요청하든** `ALREADY_MARKED_SAME_OPERATION`으로 +> 답했다: disposition을 retryable에서 abandoned로 바꾼 재시도나 retention을 바꾼 재시도가 +> 이미 적용된 전이로 보고되고 **조용히 일어나지 않았다.** caller의 증거는 record가 +> abandoned라고 말하는데 row는 retryable이라고 말했다. + +`INLINE_RESPONSE_MAX_BYTES = 8 * 1024`, `MAXIMUM_PROCESSING_LEASE = 1시간`, +`MAXIMUM_RETENTION = 30일`. + +owner token은 `SecureRandom` 32바이트 hex. + +**raw client key를 저장하지 않는다** — versioned HMAC scope digest만. + +스키마(`db/migration/jpa/idempotency/V1`)는 **전부 additive**다. V1 컬럼이 호환 기간 내내 +읽을 수 있게 남고, legacy COMPLETED 행에 synthetic owner를 만들어내지 않는다. + +세 개의 `CHECK` 제약이 전부 `NOT VALID`로 추가된다 — 기존 행을 재검증하지 않고 앞으로의 +쓰기에만 적용. + +partial unique index가 V1과 V2를 공존시킨다. + +```sql +CREATE UNIQUE INDEX uq_idempotency_record_v2_scope ON idempotency_record (scope_hash) + WHERE record_version = 2; +``` + +### 10.2 `PostgreSqlImmutableOutboxAppendAdapter` + +**immutable identity/envelope 분리**가 핵심이다. + +| 테이블 | 성격 | +|---|---| +| `outbox_publication_control_v2` | 단일 행(`scope_id='PRIMARY'`), 현재 epoch/authority/state | +| `outbox_publication_cutover_v2` | epoch별 immutable sentinel (UPDATE/DELETE 트리거로 거부) | +| `outbox_event_identity_v2` | compact global identity guard, aggregate order unique | +| `outbox_event_log_v2` | immutable envelope, **`PARTITION BY RANGE (retention_bucket)`** | + +append 순서: + +```java +requireSameResourcePrimaryWriteTransaction(); +requireActiveCapability(); +control = lockPublicationControl(); // FOR SHARE OF control +identity insert (on conflict do nothing) returning retention_bucket, created_at +envelope insert (같은 트랜잭션) +``` + +`FOR SHARE`가 핵심이다. + +> 활성 publication control 행을 caller 트랜잭션이 끝날 때까지 `FOR SHARE`로 든다. 따라서 +> identity/envelope insert가 **authority cutover를 가로지를 수 없다.** cutover는 control에 +> `FOR UPDATE`가 필요하니 이미 시작된 append를 추월하지 못한다. + +DB 트리거 3개가 불변식을 강제한다. + +| 트리거 | 대상 | 하는 일 | +|---|---|---| +| `trg_outbox_event_v2_authority` | BEFORE INSERT `outbox_event_log_v2` | control state가 ACTIVE인지, `publication_epoch`/`dispatch_authority`가 control과 일치하는지 | +| `trg_fence_legacy_outbox_writer` | BEFORE INSERT/UPDATE/DELETE `outbox_event` | authority가 `LEGACY_POLLING`이 아니면 **레거시 writer를 펜싱** | +| `trg_reject_outbox_cutover_mutation` | BEFORE UPDATE/DELETE `outbox_publication_cutover_v2` | 무조건 예외 — sentinel은 immutable | + +genesis cutover row가 legacy 상태의 digest를 담는다. + +```sql +md5(intent) || md5('outbox-v2:' || intent) -- intent = event_id:status:attempt_count 정렬 concat +``` + +`classifyExisting`이 identity insert가 졌을 때 세 가지를 구별한다. + +| 상황 | 결과 | +|---|---| +| 같은 `event_id`이고 intent 동일 | `ALREADY_APPENDED_SAME_EVENT` | +| 같은 `event_id`이고 intent 다름 | `EVENT_ID_CONFLICT` | +| 다른 `event_id`인데 같은 (type,id,version,ordinal) | `AGGREGATE_ORDER_CONFLICT` | +| 셋 다 아님 | `IllegalStateException` — 있을 수 없는 상태 | + +`mapStoredEvent`에 좋은 방어가 하나 더 있다. + +```java +if (dispatch == null) { + throw new IllegalStateException("outbox identity exists without its same-transaction immutable envelope"); +} +``` + +envelope 없는 identity는 원자성이 깨졌다는 뜻이다. + +payload 크기 제약이 스키마에 있다: `octet_length(payload) BETWEEN 1 AND 1048576`. + +### 10.3 `PostgreSqlPollingDeliveryAdapter` + +**mutable delivery state를 immutable identity/envelope에서 분리**한 테이블 +`outbox_delivery_v2`. 상태: `PENDING | CLAIMED | PUBLISHED | RETRY_WAIT | DEAD`. + +**delivery row는 애플리케이션이 만들지 않는다 — 트리거가 만든다.** + +```sql +CREATE TRIGGER trg_create_polling_delivery_v2 +AFTER INSERT ON outbox_event_log_v2 +FOR EACH ROW EXECUTE FUNCTION create_polling_delivery_v2(); +``` + +함수 안에서 `IF NEW.dispatch_authority = 'POLLING_V2'`일 때만 초기 `PENDING` 행을 넣는다. +그래서 **같은 트랜잭션**에 생긴다. + +claim SQL이 이 모듈에서 제일 복잡한 statement다. 4단계 CTE. + +```sql +with authority as ( -- control FOR SHARE, active_authority = 'POLLING_V2'만 + ... +), db_clock as ( + select clock_timestamp() as db_now +), eligible as ( + select delivery.retention_bucket, delivery.event_id, delivery.destination + from outbox_delivery_v2 delivery + join outbox_event_log_v2 event on (bucket, event_id) + cross join authority cross join db_clock + where delivery.destination = ? + and delivery.publication_epoch = authority.active_epoch + and ( (state in ('PENDING','RETRY_WAIT') and next_attempt_at <= db_now) + or (state = 'CLAIMED' and claim_until <= db_now) ) -- lease 만료 회수 + and not exists ( -- strict-order head gate + select 1 from outbox_delivery_v2 prior_delivery + join outbox_event_log_v2 prior_event on ... + where prior_delivery.destination = delivery.destination + and prior_event.aggregate_type = event.aggregate_type + and prior_event.aggregate_id = event.aggregate_id + and (prior_event.aggregate_version, prior_event.event_ordinal) + < (event.aggregate_version, event.event_ordinal) -- row-value 비교 + and prior_delivery.state <> 'PUBLISHED') + order by event.created_at, event.event_id + for update of delivery skip locked + limit ? +), claimed as ( + update outbox_delivery_v2 delivery + set state='CLAIMED', claim_owner=?, claim_token=?, + claim_until = db_now + (? * interval '1 millisecond'), + attempt = attempt + 1, version = version + 1, updated_at = db_now + from eligible, db_clock + where (bucket, event_id, destination) 매칭 + returning delivery.* +) +select claimed.* , event.* from claimed join outbox_event_log_v2 event on ... + order by event.created_at, event.event_id +``` + +핵심 디테일 셋: + +1. **row-value 비교** `(aggregate_version, event_ordinal) < (...)`로 사전식 순서를 SQL이 + 직접 표현한다. +2. **`for update of delivery`** — join된 `event` 테이블은 잠그지 않는다. +3. **head gate가 destination별**이다 — 같은 aggregate라도 destination이 다르면 독립. + +completion CAS 3개(`markPublished`/`markRetryable`/`markDead`)가 owner/token/attempt/version/ +epoch 전부에 더해 **authority가 여전히 유효한지**까지 `EXISTS` 서브쿼리로 확인한다. + +```sql +and exists (select 1 from outbox_publication_control_v2 control + where control.scope_id='PRIMARY' and control.state='ACTIVE' + and control.active_authority='POLLING_V2' + and control.active_epoch = delivery.publication_epoch) +``` + +CAS가 0행이면 `classifyFailedTransition`이 `INSPECT_SQL`로 이유를 판정한다. + +``` +last_operation_id 일치 → digest 같으면 ALREADY_APPLIED_SAME_OPERATION, 다르면 RESULT_CONFLICT +authority state/authority/epoch 불일치 → AUTHORITY_MISMATCH +owner/token/attempt 불일치 → NOT_OWNER +state != CLAIMED → NOT_CLAIMED +version 불일치 → STALE_VERSION +그 외 → RESULT_CONFLICT +``` + +**broker 호출은 트랜잭션 밖**이고, duplicate publish 가능성은 stable event ID로 consumer +inbox에서 처리한다. + +`errorCode` 정규식: `[A-Z][A-Z0-9_.-]{0,63}`. + +스키마의 CHECK 제약이 상태 모양을 강제한다. + +```sql +CONSTRAINT ck_outbox_delivery_v2_state_shape CHECK ( + (state = 'CLAIMED' AND claim_owner IS NOT NULL AND claim_token IS NOT NULL + AND claim_until IS NOT NULL AND published_at IS NULL AND dead_at IS NULL) + OR (state <> 'CLAIMED' AND claim_owner IS NULL AND claim_token IS NULL + AND claim_until IS NULL)) +CONSTRAINT ck_outbox_delivery_v2_terminal_shape CHECK ( + (state='PUBLISHED' AND published_at IS NOT NULL AND dead_at IS NULL) + OR (state='DEAD' AND dead_at IS NOT NULL AND published_at IS NULL) + OR (state NOT IN ('PUBLISHED','DEAD') AND published_at IS NULL AND dead_at IS NULL)) +``` + +### 10.4 `PostgreSqlSameStoreInboxAdapter` + +`inbox_record_v1`. 상태: `RECEIVED | PROCESSING | COMPLETED | RETRYABLE | DEAD`. + +> transactional claim이 business mutation / outgoing outbox / completion과 **caller의 한 +> primary write transaction에 참여**한다. broker ACK는 **commit 이후에만** 실행한다. + +claim 결정 트리가 idempotency와 대칭이되 한 곳이 다르다. + +``` +COMPLETED/DEAD이고 retention 지남 → resetClaim → TakenOver +intent digest 불일치 → IntentMismatch +같은 claim attempt이고 COMPLETED → Completed +같은 claim attempt → ReplayedAcquire +같은 token, 다른 operationId → OwnerOperationConflict +COMPLETED → Completed +RECEIVED이고 lease 만료 / RETRYABLE → resetClaim → TakenOver ← received lease는 takeover 가능 +PROCESSING이고 lease 만료 → expireProcessing(DEAD) → RecoveryRequired ← processing lease는 blind retry 안 함 +DEAD → RecoveryRequired +그 외 → InProgress +``` + +> **received lease expiry는 takeover할 수 있지만 processing lease expiry는 blind retry하지 +> 않고 recovery-required terminal state로 보낸다.** + +`scope_hash`는 consumer-group/handler/tenant/message-ID의 canonical digest이고, +**raw broker metadata는 저장하지 않는다.** + +`MAXIMUM_RETENTION = 30일`. + +`terminal_at` CHECK가 상태와 짝을 맞춘다. + +```sql +CHECK ((state IN ('COMPLETED','DEAD') AND terminal_at IS NOT NULL) + OR (state NOT IN ('COMPLETED','DEAD') AND terminal_at IS NULL)) +``` + +`FAIL_SQL`의 `terminal_at`이 조건부 표현식인 것도 재밌다. + +```sql +terminal_at = case when ? = 'DEAD' then clock_timestamp() else null end +``` + +--- + +## 11. 이 leaf 안의 다른 PostgreSQL 소비자 + +이 모듈은 "플랫폼"만 있는 게 아니라 **실제 capability 스토어 두 벌**을 더 들고 있다. +둘 다 자기 Flyway 스트림과 자기 capability 스위치를 갖는다. + +### 11.0 먼저 — 이들은 플랫폼과 결합되어 있지 않다 + +같은 leaf에 있으니 플랫폼을 쓸 거라고 짐작했는데, 세어 보니 아니었다. + +| 방향 | 결과 | +|---|---| +| `fileserver`/`notification` → JPA 플랫폼 타입 | **0건** | +| JPA 플랫폼 → `fileserver`/`notification` | **0건** (javadoc 주석 4줄 + exclusion용 String 상수 2개뿐) | + +```bash +B=src/main/java/dev/caskeleton/adapter/outbound/persistence +for pkg in fileserver notification; do + grep -rh "^import dev\.caskeleton\.adapter\.outbound\.persistence\." $B/$pkg \ + | sed 's/^import dev\.caskeleton\.adapter\.outbound\.persistence\.//' \ + | awk -F. '{print $1}' | sort | uniq -c +done +# fileserver → 29 fileserver (전부 자기 자신) +# notification → 28 notification (전부 자기 자신) +``` + +실제 의존은 이렇다. + +``` +fileserver : application.fileserver 63건 + 자기 자신 29건 +notification : application.notification 180건 + 자기 자신 28건 +기술 스택 : Spring Data JPA, @Entity/@Column, JdbcTemplate — 전부 순정 +``` + +`springdata`도, `hibernate.*`도, `postgresql.{write,lock,json,array,range,copy}`도, `api.*`도, +`transaction`도 **하나도 안 쓴다.** `SafeSortMapper` 대신 자기 `@Query`를 쓰고, +`PostgreSqlWorkClaimExecutor` 대신 자기 native `FOR UPDATE SKIP LOCKED`를 쓴다 +(`RecipientClaimSql`, `FileserverCleanupRepository.claim`). + +역방향 참조도 코드가 아니다. 플랫폼이 이 두 이름을 언급하는 곳은 전부 **"여기서 제외한다"는 +표시**다. + +``` +config/JpaAdapterComponentsConfig.java javadoc — 스캔에서 빠진 이유 설명 +config/PersistenceJpaConfig.java OPT_IN_NOTIFICATION_PACKAGE / OPT_IN_FILESERVER_PACKAGE 상수 +``` + +`docs/jpa/` 11개 문서 중 이들을 언급하는 것도 `repository-adaptation.md` 한 곳뿐이고, 그것도 +"패키지 카탈로그가 누락했던 9개" 목록에서다. `support-matrix.md`, `entity-mapping-guide.md`, +`transaction-guide.md` 어디에도 없다. + +**결론: 같은 leaf에 세들어 살 뿐 아키텍처적으로는 독립이다.** 소유자는 각자의 +`application-core` 포트이고, JPA 모듈이 소유하는 건 그 포트의 저장 구현뿐이다. + +그래도 **JPA 모듈 쪽에서 알아야 할 것 세 가지**가 있고, 이 장은 거기까지만 다룬다. + +1. **검증 체계는 공유한다.** 이 모듈 `build.gradle`이 이들의 readiness 태스크 4개를 등록하고 + (`postgresqlFileserverMigration/Metadata/Reclamation`, + `postgresqlNotificationSchemaActivation`), `readiness-cards.yaml`에 card 2개 + (`jpa-fileserver-metadata-v1`, `jpa-notification-platform-v4`)가 있고, evidence manifest에 + 포함된다. +2. **JPA 모듈의 규칙 세 개가 이들 때문에 생겼다.** `PersistenceJpaConfig`의 열거식 + `@EntityScan`(§14.3), `JpaModuleBoundaryTest`의 카탈로그 정확 동등성(§1), 그리고 모듈 + `CLAUDE.md`의 "Capability-gated stores" 절 자체. +3. **§17 P8의 가장 강한 증거다.** 같은 leaf에서 제일 큰 스토어 두 개(78파일, 350KB)가 + 플랫폼을 하나도 쓰지 않는다. + +### 11.1 `fileserver` — 6개 테이블, 조건부 전이 + +25개 파일. 엔티티 6개 / 리포지토리 7개 / 어댑터 9개. + +| 테이블 | 역할 | +|---|---| +| `fs_file` | 파일 메타데이터 + 상태 기계 (11 상태) | +| `fs_upload_session` | 업로드 세션 + writer lease + lifecycle_state | +| `fs_verification_result` | 검증 결과 이력 | +| `fs_quota_reservation` | 쿼터 예약/커밋 원장 | +| `fs_cleanup_item` | 물리 정리 큐 (fenced lease) | +| `fs_recovery_item` | 자동 조정 실패 목록 | + +V1의 헤더가 설계 의도를 요약한다. + +> **파일시스템이 아니라 관계형 레코드가** 파일이 공개적으로 읽을 수 있는지 결정한다. 그래서 +> 모든 상태 전이가 `state`와 `version` **둘 다**로 가드된다. 물리 경로/마운트/원본 물리 +> 파일명을 저장하지 않는다: `content_key`는 서버 생성 opaque key이고 `original_name`은 +> 신뢰할 수 없는 표시용 텍스트일 뿐이다. + +가장 좋은 제약이 이거다. + +```sql +CONSTRAINT ck_fs_file_ready_is_complete CHECK ( + state <> 'READY' + OR (content_key IS NOT NULL AND actual_size IS NOT NULL AND sha256 IS NOT NULL + AND strong_etag IS NOT NULL AND published_at IS NOT NULL)) +``` + +**READY가 유일하게 공개 읽기 가능한 상태이므로, 완전하고 검증된 identity를 반드시 들고 있어야 +한다.** 이걸 DB CHECK으로 강제한다. + +`FileTransitionRepository`의 모든 statement가 optimistic CAS다. + +```sql +update FileEntity f set f.state = :targetState, ..., f.version = f.version + 1 + where f.fileId = :fileId and f.state = :expectedState and f.version = :expectedVersion +``` + +> 반환 count가 0이면 precondition이 진 것이고 optimistic conflict로 번역된다 — **blind retry는 +> 절대 하지 않는다.** + +`coalesce(:param, f.column)` 패턴으로 부분 업데이트를 한 statement에 담는다. + +V2/V3/V4가 각각 실제 사고의 수정이다. + +**V2 — `upload_id` 컬럼 + `fs_recovery_item`** + +> staging 객체는 file이 아니라 upload로 주소지정된다. 그래서 큐에 넣은 staging cleanup이 +> **이미 published된 콘텐츠에 대해서만** 무엇을 회수할지 명명할 수 있었다. 이 컬럼 없이는 +> 취소되거나 만료된 업로드가 **아무도 찾을 수 없는 바이트를 남긴다.** + +`CHECK (upload_id IS NULL OR content_key IS NULL)` — 항목은 정확히 하나의 대상을 명명한다. + +`uq_fs_recovery_open` partial unique index (`WHERE status = 'PENDING'`) — 파일당 열린 항목 +하나. "큐는 worklist이지 모든 sweep의 로그가 아니다." + +**V3 — fenced cleanup lease** + +> claim이 항목을 IN_PROGRESS로 옮기고 **다른 건 아무것도 기록하지 않았다**: owner도, token도, +> lease expiry도. 같은 누락에서 두 결과가 나왔다. +> +> 물리 delete를 수행하고 DB를 정리하기 전에 죽은 worker는 행을 **영원히 IN_PROGRESS로** +> 남겼다. 어떤 쿼리도 그 항목을 살아있는 worker가 활발히 삭제 중인 것과 구별할 수 없어서 +> 아무것도 회수하지 않았고, 파일의 쿼터와 lifecycle이 미정착으로 남았다. +> +> 그리고 완료 update가 `cleanup_id`만으로 매칭했다. 합리적인 lease를 한참 지나 멈춰 있던 +> worker가 여전히 다른 worker가 그 사이 claim해서 반쯤 진행한 항목 위에 DONE을 쓸 수 있었다 — +> notification dispatcher가 겪은 것과 **같은 fenced-lease 문제.** + +`NULL` lease를 reaper가 **자동 회수하지 않고** 운영자에게 넘기는 것도 좋다. + +> `NULL` lease는 fencing이 존재하기 전에 claim됐다는 뜻이고, 자동 takeover는 **아무도 상태를 +> 기록하지 않은 작업**에 대해 추측하는 것이다. + +`reclaimExpiredClaim`이 `PENDING`이 아니라 `FAILED`로 되돌리고 attempt를 올리는 것도 의도다 — +매번 worker가 죽는 항목이 outright 실패하는 항목과 같은 retry 예산에 묶인다. + +**V4 — upload terminal state** + +> cleanup이 writer lease를 읽고, 없는 걸 확인하고, staging 바이트를 삭제했다. 그 read와 +> delete 사이에 writer가 바로 그 lease를 획득할 수 있다 — **업로드가 끝났다고 말하는 게 +> DB에 아무것도 없었으니까** — 그리고 cleanup이 제거한 객체는 업로드가 활발히 append 중이던 +> 것이었다. + +해법이 정석적이다. cancel/failed finalize가 **cleanup을 큐잉하는 같은 트랜잭션 안에서** +세션을 `TERMINAL`로 옮기고, acquire/renew/offset commit은 `ACTIVE`를 요구하고, cleanup은 +읽은 값으로 판단하는 대신 **조건부 update로 행을 claim**한다. + +`FileserverQuotaRepository.reduceCommitted`의 가드도 좋다. + +```sql +... set committed_bytes = committed_bytes - :amount ... where committed_bytes >= :amount +``` + +> 다른 reclaim이 이미 `amount` 아래로 끌어내린 행은 0행을 업데이트하고, caller는 원장을 +> 음수로 몰지 않고 다음 행으로 넘어간다. + +### 11.2 `notification.platform` — 12개 테이블, 4개 개정 + +53개 파일, 약 196KB. 이 leaf에서 단일 capability로는 제일 크다. + +테이블: `notification_request`, `notification_recipient_delivery`, `notification_delivery_attempt`, +`notification_provider_event`, `notification_contact_point`, `notification_template_version`, +`notification_consent`, `notification_preference`, `notification_suppression`, +`notification_deduplication_claim`, `notification_admin_audit`, `notification_inbox_item`. + +마이그레이션이 V1~V10까지 있고, capability는 `jpa-notification-platform-v4` +(`core_epoch=1, feature_revision>=4, ACTIVE`). + +**PostgreSQL 관점에서 볼 만한 것 두 개.** + +**(1) `RecipientClaimSql` — SQL을 상수로 뽑는 이유** + +```java +/** + * Kept as constants rather than inline in the repository annotation so a contract test can + * execute the statement the adapter actually runs. A test that retypes the SQL proves that the + * test author and the adapter author agreed about the query, which is not the property anybody + * needs; the property is that *this* statement, against a real PostgreSQL, claims what it should. + */ +``` + +이게 §15에서 다시 다룰 주제의 정답이다. + +claim SQL 자체: + +```sql +WITH claimable AS ( + SELECT id FROM notification_recipient_delivery + WHERE next_dispatch_at <= :now + AND delivery_state IN ('PENDING', 'READY_TO_DISPATCH', 'RETRY_WAITING') + AND (expires_at IS NULL OR expires_at > :now) + AND (lease_until IS NULL OR lease_until < :now) + ORDER BY next_dispatch_at, id + FOR UPDATE SKIP LOCKED + LIMIT :batchSize +) +UPDATE notification_recipient_delivery AS d + SET lease_owner = :owner, lease_until = :leaseUntil, + lease_fence = d.lease_fence + 1, + delivery_state = 'DISPATCHING', + version = d.version + 1, -- ← JPA @Version 컬럼을 native write가 직접 올린다 + updated_at = :now + FROM claimable WHERE d.id = claimable.id + RETURNING d.id, d.lease_fence +``` + +`version = d.version + 1` 주석이 특히 좋다. + +> JPA `@Version` 컬럼을 native write가 올린다. 이게 없으면 claim 전에 로드된 managed 엔티티가 +> 여전히 옛 version을 들고 있어서, 그 flush가 성공하고 **lease와 state를 pre-claim 값으로 +> 덮어쓴다** — native statement가 충돌이 있었다고 말해주지 않았기 때문에 **충돌을 보고하지 +> 않는 optimistic locking.** + +세 predicate의 의미도 적혀 있다. + +- `next_dispatch_at <= :now` — 예약 delivery를 평범한 queued delivery로 만드는 것. +- 상태 목록에 `PENDING`이 있는 이유: rolling deploy 중 옛 인스턴스가 쓴 행도 진짜 job이고, + **그게 없어서 모든 예약 요청이 좌초됐었다.** +- expiry 체크가 lease와 **같은 statement**에 있어서, 기다리는 동안 window가 닫힌 delivery는 + claim되고 나서 재고되는 게 아니라 **아예 claim되지 않는다.** + +**(2) `TenantBoundRepositoryGuard`** + +```java +public void assertTenant(String actualTenantId, TenantId expected) { + if (!expected.value().equals(actualTenantId)) { + // No identifiers in the message: a cross-tenant probe must not learn what it nearly reached. + throw new IllegalStateException("row does not belong to the requesting tenant"); + } +} +``` + +리포지토리가 이미 finder를 scope하지만, **identity만으로 행에 도달하는 경로**(이벤트가 자기 +attempt를 resolve, 운영자가 attempt id로 행동)가 있고 거기서 tenant 검사를 한다. +"`found by id`가 조용히 `readable by anyone`을 의미하지 않게." + +### 11.3 `NotificationSchemaActivation` / `FileserverSchemaActivation` + +두 capability 다 **operator-applied 스트림**이다. 애플리케이션은 +`db/migration/postgresql`만 마이그레이션하고, capability 스트림은 의도적으로 적용 + ACTIVE +승격된다. + +`NotificationSchemaActivation`의 javadoc이 이 검사가 없을 때의 두 실패 모양을 정확히 적는다. + +> `ddl-auto=validate`에서는 기능을 **끈** 배포에서 `notification_request`가 없다고 boot가 +> 실패했다 — 엔티티가 무조건 스캔됐기 때문. `ddl-auto=none`이고 기능이 켜졌으면 boot는 +> 성공하고 **첫 발송이 raw `relation "notification_request" does not exist`를** 처음 보낸 +> 사람에게 반환했다. + +그리고 **startup에서만 확인하고 호출마다 확인하지 않는** 이유: + +> 승격되지 않은 스트림은 **배포 상태**이고, 매 notification마다 레지스트리에 다시 물으면 +> 프로세스가 도는 동안 바뀔 수 없는 질문에 답하려고 send path에 round trip을 넣는 것이다. + +레지스트리가 읽히지 않으면 "괜찮다고 가정"이 아니라 **"승격 안 됨"으로 취급**한다. + +--- + +## 12. `security` / `observation` / `cache` / `envers` / `querydsl` / `audit` + +### 12.1 `security` — 런타임 롤은 DDL을 못 한다 + +`PostgreSqlRuntimeRoleVerifier`가 서버에 직접 묻는다. + +```sql +select current_user as current_user_name, + current_setting('search_path') as search_path, + has_schema_privilege(current_user, current_schema(), 'CREATE') as create_on_schema, + has_database_privilege(current_user, current_database(), 'CREATE') as create_on_database +``` + +> 질문은 **설정이 아니라 서버가** 답한다. 롤의 유효 권한은 직접 grant, 상속된 롤 멤버십, +> `PUBLIC` grant, 스키마 소유권에서 나오고, 어떤 배포 매니페스트를 읽어도 그 조합을 신뢰성 +> 있게 재구성할 수 없다. `has_schema_privilege`는 할 수 있다. + +startup에서 돌고 fail-closed다. + +> 사고 후에 애플리케이션 자신의 자격증명이 테이블을 drop할 수 있었다는 걸 발견하는 건 너무 +> 늦게 발견하는 것이다. + +`DatabaseRolePolicy`가 세 가지를 요구한다: 롤이 승인 목록에 있을 것, 스키마 CREATE 없을 것, +DB CREATE 없을 것. 그리고 `SearchPathPolicy`. + +> 런타임 롤을 마이그레이션 롤에서 분리하는 게 "Flyway가 스키마 변경을 소유한다"를 열망이 +> 아니라 **강제 가능**하게 만든다: 애플리케이션 자신의 자격증명이 DDL을 실행할 수 없으면, +> 어떤 코드 경로도, 어떤 라이브러리도, 어떤 주입된 statement도 런타임에 스키마를 바꿀 수 없다. + +`SearchPathPolicy`가 allowlist인 이유: + +> `search_path`는 unqualified 이름이 어느 스키마로 해석되는지를 정한다. 공격자가 객체를 만들 +> 수 있는 스키마(고전적으로 `public`, 구버전 PostgreSQL에서 `CREATE`가 널리 부여됨)를 담고 +> 있으면, 거기 심어진 테이블/함수/연산자가 진짜를 **가릴 수 있고** 애플리케이션은 눈치채지 +> 못하고 실행한다. + +`$user`는 건너뛴다 — 연결된 롤 이름의 스키마로 해석되고, 그 롤만 소유한다. + +`DatabasePrivilegeReport`는 JDBC URL도, 패스워드도, 호스트도 담지 않는다 — actuator에 +노출해도 안전하게 설계됐고, "publish 전에 마스킹해야 할 것은 애초에 들어가지 않는다." + +### 12.2 `observation` — 태그를 유한하게 + +`JpaMetricTags`는 **다섯 개 태그**뿐이다: `persistence.unit`, `persistence.operation`, +`persistence.query`, `outcome`, `failure.category`. 없는 것이 요점이다 — 엔티티 id 없음, +tenant id 없음, SQL 파라미터 없음, 예외 메시지 없음. + +검증이 **registry가 아니라 생성자**에서 일어난다 — unbounded 값이 대시보드가 안 뜰 때까지 +살아남는 게 아니라 도입된 자리에서 실패하게. + +`LowCardinality.REGISTERED = [a-zA-Z][a-zA-Z0-9._-]{0,95}`. **sanitize가 아니라 reject**다 — +sanitize하면 caller가 unbounded 값을 계속 넘기고 절대 눈치채지 못한다. + +메트릭: + +| 이름 | 종류 | +|---|---| +| `jpa.transaction.duration` | Timer | +| `jpa.transaction.rollback` | Counter | +| `jpa.transaction.timeout` | Counter | +| `jpa.transaction.completion.unknown` | Counter | +| `jpa.retry.attempt` | Counter (+ `retry.disposition` 태그) | +| `jpa.retry.attempts` | DistributionSummary | +| `jpa.retry.exhausted` | Counter | +| `jpa.query.duration` | Timer | +| `jpa.query.rows` | DistributionSummary | + +`completion.unknown`이 **자기 카운터를 갖는** 이유: + +> 사람이 봐야 하는 유일한 결과다: 다른 모든 실패는 **확실히 일어나지 않은** 트랜잭션이고, +> 이건 **일어났을 수도 있는** 트랜잭션이다. 일반 실패율에 묻는 게 눈치채지 못하게 되는 방식이다. + +`JpaRetryObservation`이 재시도를 로그가 아니라 메트릭으로 만드는 이유: + +> 재시도된 시도는 warning이 아니다. 낙관적 충돌과 직렬화 실패는 **동시성의 예상 비용**이고, +> 각각을 WARN으로 로깅하면 정확히 설계대로 동작하는 시스템 때문에 누군가를 호출하게 되고 — +> 그러고 나면 retry 로그가 필터링되면서 진짜 흥미로운 항목까지 같이 사라진다. + +`jpa.query.rows`가 duration과 별개인 이유: + +> 한 statement를 쏘고 2만 행을 hydrate하는 쿼리는 statement당 빠르고 요청당 재앙이며, +> duration 메트릭만으로는 **그냥 느리다**고 보고한다. + +`SqlDiagnosticRedactor`는 의도적으로 뭉툭하다 — 문자열 리터럴, 숫자, 이메일 같은 것을 통째로 +치환하고 512자로 자른다. + +> "무해한" 값을 보존하려는 redactor는 **어느 컬럼이 개인정보를 담는지 알아야 하는데**, 그게 +> 정확히 그것이 갖지 못한 지식이다. + +### 12.3 `cache` — L2 캐시의 기본값 거부 + +`HibernateCacheGuard`가 두 가지를 거부한다. + +**Query Cache는 꺼진 채로 둔다.** + +> 쿼리+파라미터로 키가 잡힌 result-id 목록을 캐시하고, 그 엔트리는 **table-space +> timestamp**로 무효화된다 — 그래서 캐시된 쿼리가 건드리는 테이블에 대한 **어떤 쓰기든** +> 그 위의 모든 캐시된 쿼리를 무효화한다. write-active 테이블에서는 절약보다 비용이 크고, +> **조용히 그렇다.** + +**`ENABLE_SELECTIVE`만.** `ALL`은 staleness가 성능 문제가 아니라 **정확성 문제**인 엔티티까지 +캐시한다. + +`requireBulkEviction`도 있다 — bulk DML은 L2를 완전히 우회하므로, 캐시된 엔티티를 bulk로 +업데이트하면 region이 만료될 때까지 pre-update 값을 계속 제공한다. + +`HibernateCachePolicy`가 **설정에 보이지 않는 두 운영 가정**을 기록한다. + +- **외부 writer.** L2 캐시는 이 애플리케이션이 유일한 writer일 때만 coherent하다. 배치 job, + 관리자 콘솔, 복제 스트림이 같은 테이블을 쓰면 캐시된 엔티티가 조용히 stale해지고, **어떤 + 캐시 설정도 그걸 감지할 수 없다.** +- **클러스터 무효화.** 인스턴스가 둘 이상이고 로컬 캐시면 한 노드의 eviction이 다른 노드에 + 도달하지 않는다. 분산 region 없이는 인스턴스 하나가 늘 때마다 **독립적인 stale 사본이 + 하나 더** 생긴다. + +### 12.4 `envers` — 히스토리는 데이터 보호 문제 + +`EnversConfigurationGuard`가 막는 두 실패. + +**Blanket enrolment.** 공유 base 클래스에 `@Audited`를 붙이면 그걸 상속하는 모든 엔티티가 +등록된다. + +**retention/PII 정책 없는 production.** + +> audit 테이블은 기본적으로 모든 이전 값을 **영원히** 보관한다 — 나중의 정정이나 삭제가 +> live row에서 제거한 개인 데이터까지. 그건 오래 돌수록 비싸지는 데이터 보호 문제다. + +`EnversHistoryPolicy`가 retention을 **필수**로 받고, `productionReady()`가 +`piiPolicyDeclared && !auditedEntities.isEmpty()`. + +`EnversRevisionMetadata`는 actor id와 correlation id만 담는다 — security principal 자체를 +저장하면 역할/토큰/개인 속성이 세션보다 오래 사는 append-only 테이블에 복사된다. + +Envers/Querydsl 둘 다 `compileOnly`다. + +> 플랫폼이 계약을 구현하지만 Stable 런타임 클래스패스는 **둘 다 실어서는 안 된다.** +> `compileOnly`가 support 클래스를 컴파일하면서도 모든 배포에서 아티팩트를 빼고, opt-in하는 +> 배포가 직접 아티팩트를 추가하며, 가드가 클래스 부재 시 capability를 거부한다. + +### 12.5 두 개의 감사 메커니즘 (이건 문제다) + +`docs/jpa/support-matrix.md`가 정직하게 적어 둔 부분. + +| | `audit/AuditableEntity` | `auditing/AuditMetadata` | +|---|---|---| +| 형태 | `@MappedSuperclass` | `@Embeddable` | +| 컬럼 | `created_at/by`, `updated_at/by` | `created_at/by`, `modified_at/by` | +| actor 길이 | 256 | 64 | +| 캡처 | 어댑터가 명시적으로 stamp | Spring Data 리스너 (`@CreatedDate` 등) | +| 상태 | **canonical** | **candidate, 조립 안 됨** | + +`JpaAuditMechanismRule.entitiesUseExactlyOneAuditMechanism`이 production 그래프에서 한 +엔티티가 둘 다 쓰는 걸 막는다. + +> 하나를 고른 엔티티는 일관된 계약을 얻는다. 둘 다 고른 엔티티는 **하나의 의미에 두 writer, +> 하나의 사실에 두 컬럼 계열**, 그리고 어느 엔티티가 뭘 골랐는지 알아야 하는 마이그레이션을 +> 얻는다. + +그리고 **어느 메커니즘도 bulk/native update에 도달하지 않는다.** +`bulkUpdatesOfAuditedEntitiesStampAudit`가 `@Modifying` 메서드의 `@Query` 텍스트를 파싱해서 +audited 엔티티에 대한 `update`가 audit 컬럼을 set하는지 확인한다. + +> stamp하지 않은 bulk update는 **행이 마지막 평범한 save를 한 사람에 의해 마지막으로 +> 수정됐다고 주장하게** 만든다 — 빠진 답이 아니라 **틀린 답**이다. + +`bulkUpdateViolation`이 public static으로 노출된 이유도 좋다. + +> 통과와 실패 케이스가 한 번도 실행된 적 없는 아키텍처 규칙은 **모양을 알 수 없는 규칙**이고, +> production 그래프에는 오늘 audited bulk update가 없어서 그래프만으로는 통과만 보여줄 수 있다. + +`DomainContextAuditContextPort`는 actor를 `DomainContextPropagator`에서 읽고, 빈/blank면 +`"system"`으로 떨어뜨린다 — scheduler / Flyway / anonymous 경로에서도 NOT NULL +`created_by`/`updated_by`를 항상 만족시키려고. `ACTOR_KEY`를 상수로 격리해서 canonical actor +key가 바뀌어도 **정확히 한 클래스**만 손대면 된다. + +--- + +## 13. `experimental` — 전부 플래그 뒤에 + +8개 feature, 각각 `backend.jpa.experimental.*` 프로퍼티. + +``` +multitenancy-column | multitenancy-rls | multitenancy-schema | multitenancy-database +read-replica | jakarta-persistence-4 | hibernate-8 | postgresql-19 +``` + +`ExperimentalFeatureGate`의 원칙: + +> **클래스패스에 있는 것은 동의가 아니다.** experimental 모듈은 transitive하게 도착할 수 +> 있고, jar가 있다는 이유로 스스로 켜지는 tenant-isolation이나 replica-routing 기능은 가능한 +> 최악의 기본값이다. 게이트가 **결정의 부재를 활성화가 아니라 에러**로 만든다. + +그리고 이게 실제로 강제되도록 세 클래스가 **생성자를 package-private으로 바꾸고 `enabledBy` +정적 팩토리만 남겼다**: `RlsTenantSessionBinder`, `ConsistencyAwareDataSourceRouter`, +`SchemaTenantMigrationOrchestrator`. + +> 게이트 자신의 문서가 "클래스패스에 있는 게 실행 동의는 아니다"라고 말하지만 **아무것도 +> 그걸 강제하지 않았다**: public 생성자는 `new` 하나가 완전한 우회라는 뜻이었고, 이건 +> 우발적 활성화가 read를 엉뚱한 곳으로 보낼 때까지 보이지 않는 종류의 동작이다. +> +> 게이트를 static이 아니라 **파라미터로** 받는 게 요구사항을 시그니처의 일부로 만든다: +> caller는 컴파일러가 요구하는 인자를 잊을 수 없다. + +### 13.1 RLS + +`db/experimental-rls/V1__tenant_rls.sql`이 RLS가 실제로 격리하려면 참이어야 하는 세 가지를 +전부 적어 둔다. + +1. `ENABLE ROW LEVEL SECURITY` — 테이블에 policy를 켠다. +2. `FORCE ROW LEVEL SECURITY` — **테이블 OWNER에게도** 적용. 없으면 owner는 자기 policy에서 + 면제되고, **owner는 흔히 마이그레이션 롤이고 그게 사람들이 테스트하는 롤이다.** +3. 런타임 롤이 `BYPASSRLS`를 가지면 안 된다 — 이건 테이블 속성이 아니라 롤 속성이라 + `RlsPolicyVerifier`가 startup에 assert한다. + +policy: + +```sql +create policy tenant_scoped_item_isolation on tenant_scoped_item + using (tenant_id = current_setting('app.tenant_id', true)) + with check (tenant_id = current_setting('app.tenant_id', true)); +``` + +`current_setting`의 두 번째 인자 `true`가 설정이 없을 때 raise 대신 NULL을 반환하게 한다. +**NULL은 절대 `tenant_id`와 같지 않으므로, tenant가 바인딩되지 않은 쿼리는 0행을 반환하고 +쓰기는 거부된다 — fail-closed.** + +`RlsTenantSessionBinder`: + +```java +private static final String BIND_SQL = "select set_config('app.tenant_id', ?, true)"; +``` + +`true`(transaction-local)가 안전 속성 전부다. + +> session-local 설정은 커넥션이 풀로 돌아가도 살아남고, 그 커넥션의 **다음 트랜잭션 — 아마도 +> 다른 tenant의 것 — 이 그걸 상속해서 이전 tenant로서 모든 RLS policy를 통과한다.** + +`RlsPolicyVerifier`가 RLS가 조용히 아무것도 안 하는 **세 가지 방법**을 전부 확인한다: +policy 없음/RLS 미활성, `BYPASSRLS` 보유, **런타임 롤이 테이블을 소유**(FORCE 없으면 면제). + +unique index 하나에도 좋은 코멘트가 있다. + +```sql +create unique index ux_tenant_scoped_item_tenant_value on tenant_scoped_item (tenant_id, value); +``` + +> tenant 격리가 이 컬럼에 의존하므로, 테이블에 대한 **모든 uniqueness 요구**에 들어가야 +> 한다: `(value)`만의 unique index는 다른 tenant가 그 값을 이미 썼다는 이유로 한 tenant의 +> insert를 실패하게 만드는데, 그건 버그이자 **정보 유출**이다. + +### 13.2 tenant column / schema / database + +`TenantContext`(ThreadLocal)는 fail-closed다. + +> shared-schema 배포에서 tenant가 바인딩되지 않았다는 건 tenant predicate 없는 쿼리라는 뜻이고, +> 그건 **모든 tenant의 행을 반환한다 — 성공한 요청처럼 보이는 cross-tenant 데이터 유출.** + +`TenantAwareRepositoryGuard`가 존재하는 이유: + +> **Hibernate filter는 보안 경계가 아니다.** filter는 엔티티 쿼리에 적용되고, native SQL, +> bulk DML, `getReference`, L2 캐시를 통해 도달하는 것에는 적용되지 않는다 — 그래서 filter만 +> 믿는 설계는 다른 tenant의 행으로 가는 경로 여러 개를 활짝 열어 둔다. + +`TenantEntityListenerGuard`가 `@PrePersist`에서 stamp하고 `@PreUpdate`에서 cross-tenant +이동을 거부한다. + +> 읽기는 격리 문제의 절반일 뿐이다. tenant 컬럼이 틀리거나 없는 쓰기는 **쓰는 tenant는 볼 수 +> 없고 다른 tenant는 볼 수 있는 행**을 넣는다. + +`TenantId` 정규식 `[a-z0-9][a-z0-9_-]{1,62}`. + +> tenant id가 스키마 이름, `set_config` 값, 라우팅 키에 들어간다. `../public` 같은 tenant id가 +> schema-per-tenant를 **path-traversal 문제**로 만든다. + +그리고 **절대 메트릭 태그가 되지 않는다** — tenant 카디널리티는 정의상 unbounded이고, 텔레메트리 +안의 tenant id는 그렇게 취급되지 않는 시스템 안의 고객 데이터다. + +`SchemaTenantRegistry`가 스키마를 사전 등록한다. 스키마 이름은 statement의 일부라 파라미터로 +바인딩할 수 없으므로, tenant id에서 유도하면 외부 영향 데이터로 SQL을 만드는 것이다. + +`SchemaMultiTenantConnectionProvider`의 **reset**이 중요하다. + +> `search_path`는 세션 설정이라, 풀로 돌아간 커넥션은 여전히 마지막 tenant의 스키마를 들고 +> 있다. 다음 borrower — 아마 다른 tenant, 또는 tenant 없는 백그라운드 job — 가 **어떤 +> statement도 틀리지 않은 채로** 거기서 읽고 쓴다. + +`NEUTRAL_SCHEMA = "pg_catalog"`로 되돌린다. + +`SchemaTenantMigrationOrchestrator`의 발견 하나: + +> `MigrateResult.targetSchemaVersion`은 **이미 최신이라 아무것도 적용하지 않은 실행에서 +> 비어 있다.** 비어 있는 걸 기록하면 partial rollout 동안 마이그레이션된 tenant를 +> 미마이그레이션으로 보고한다. 그래서 **history 테이블에서 읽어 온다.** + +`TenantMigrationStatus`의 `failureType`은 예외의 **simple name만** 담는다 — Flyway 실패 +메시지는 스크립트 경로와 실패한 statement 일부를 담는다. + +`TenantPoolBudget`이 **두 개의 ceiling**을 갖는 이유: + +> database-per-tenant는 특정한 방식으로 실패한다: 각 tenant의 풀은 개별적으로 합리적이고 +> 그 **합이 아니다.** 50개 tenant × 10 커넥션 = `max_connections`가 100인 서버에 500 커넥션, +> 그리고 실패는 **idle이던 것 포함 모든 tenant에 동시에** connection refusal로 도착한다. +> +> pool count만으로는 풀 크기가 다르다는 걸 무시하고, connection total만으로는 각자 스레드와 +> 모니터링을 가진 무한한 수의 작은 풀을 허용한다. + +`TenantDataSourceLifecycle.drainAndClose`가 close 실패를 삼키는 것도 의도다 — 서버가 이미 +사라진 풀은 close에서 던지는데, 그걸 전파하면 loop 중간에 나머지 풀을 방치해서 **eviction이 +해제하려던 바로 그 커넥션을 누수시킨다.** + +### 13.3 read replica + +`ConsistencyAwareDataSourceRouter`의 규칙이 전부 `readOnly=true`만으로는 틀리는 방식이다. + +- write 트랜잭션은 당연히 primary — **그런데 락을 잡는 read 트랜잭션도** primary다. **replica + 위의 락은 아무것도 지키지 않기 때문.** +- read-after-write는 replica가 **증명될 때까지** primary. 이게 "저장했는데 저장이 안 됐어요"를 + 만드는 케이스다. +- lag 증거를 못 얻으면 primary. **증거의 부재는 신선함의 증거가 아니다.** + +결정은 **한 번** 내려지고 트랜잭션 전체에 유지된다. + +> 중간에 바꾸면 하나의 작업 단위 안에서 두 DB에 두 커넥션을 갖게 되고, **각각이 보는 것 +> 사이에 아무 관계가 없다.** + +`ReplicaLagMonitor.replayedThrough()`가 `Optional`인 이유: + +> lag를 측정할 수 없을 때 — monitor가 죽었거나, replica가 unreachable하거나, metric이 +> stale하거나 — 올바른 답은 **"모른다"**이고, 라우터는 그걸 primary를 쓸 이유로 취급해야 한다. +> 실패 시 lag 0을 반환하는 monitor는 **정확히 뭔가 잘못됐을 때** replica로 read를 보낸다. + +`ReplicaRoutingDecision`이 `reason`을 들고 다니는 것도 좋다 — "이 read가 왜 stale 데이터를 +봤나"는 그렇지 않으면 로그에서 답할 수 없다. + +### 13.4 forward-compat lane + +`CompatibilityLane`의 `publicationEnabled`는 항상 false다. + +> lane은 플랫폼이 더 새로운 spec/provider/server에서 여전히 동작하는지 **알아내기 위해** +> 존재한다. lane에서 컴파일된 아티팩트를 publish하면 소비자가 그 답이 나오기 전에 그것에 +> 의존할 수 있게 된다. + +`ExperimentalPromotionGate`는 기술 증거 5개(compatibility/security/failure/migration/ +performance) **전부** + 리뷰된 ADR을 요구한다. + +> 기술 suite는 **동작한다**는 걸 확립하고, ADR은 **플랫폼이 그걸 약속해야 한다고 누군가 +> 결정했다**는 걸 기록한다 — 그 약속이 지는 지원 부담 포함. + +`PromotionEvidence`에 **버전 가용성이 없는 것**이 요점이다. + +> "PostgreSQL 19가 나왔다"는 세상에 대한 사실이고, "우리 계약이 그 위에서 통과한다"는 이 +> 플랫폼에 대한 사실이며, **두 번째만이** support matrix가 약속하는 것을 바꾸는 것을 +> 정당화한다. + +--- + +## 14. 조립 — app-bootstrap 배선 + +### 14.1 마스터 스위치와 구조적 off + +``` +ca-skeleton.persistence-jpa.enabled (APP_PERSISTENCE_JPA_ENABLED, 기본 false) + ↓ +PersistenceJpaRootAutoConfiguration @AutoConfiguration @ConditionalOnProperty + ↓ @Import +JpaAdapterComponentsConfig ← 6개 패키지 component scan +PostgreSqlPersistenceConfig ← vendor=postgresql (기본) +H2PersistenceConfig ← vendor=h2 +FileserverJpaPersistenceConfig ← app.fileserver-platform.enabled +NotificationJpaPersistenceFacade ← ca-skeleton.notification.platform.enabled +MigrationStartupConfig +JpaPlatformRuntimeAutoConfiguration ← 8개 add-on 빈 +``` + +> **모든 JPA 설정이 component scan이 아니라 이 import를 통해 도달**한다. 그래서 다음 달에 +> 그중 하나에 추가된 빈이 아무도 조건을 반복하는 걸 기억하지 않아도 게이트된다. 그게 **꺼진 +> capability**와 **알려진 빈들이 개별적으로 조건화된 capability**의 차이다. + +`JpaOffAutoConfigurationImportFilter`가 한 층 더 있다. + +> 이 저장소 자신의 설정만 조건화하는 건 부족하다: JPA/Flyway starter가 Boot의 import +> metadata를 통해 자기 것을 기여하므로, 평범한 `@EnableAutoConfiguration` 애플리케이션은 +> **어떤 프로젝트 조건이 뭐라 하든 풀을 열고 마이그레이션을 돌린다.** + +필터가 막는 10개: `DataSourceAutoConfiguration`, `JdbcTemplateAutoConfiguration`, +`JdbcClientAutoConfiguration`, `DataSourceTransactionManagerAutoConfiguration`, +`DataSourceHealthContributorAutoConfiguration`, `DataSourcePoolMetricsAutoConfiguration`, +`HibernateJpaAutoConfiguration`, `JpaBaseConfiguration`, `JpaRepositoriesAutoConfiguration`, +`FlywayAutoConfiguration`. + +그리고 질문을 **"JPA가 켜졌나"가 아니라 "관계형 커넥션이 필요한 capability가 있나"**로 던진다. + +`DataSourceRequirement.reasons(environment)`가 6개를 확인한다. + +``` +ca-skeleton.persistence-jpa.enabled = true +ca-skeleton.outbox.enabled = true +ca-skeleton.capabilities.idempotency.provider = jdbc +ca-skeleton.lock.multi-instance-enabled = true +notification.platform.enabled && persistence != none +app.fileserver-platform.enabled && transaction-provider != none +``` + +> 풀은 JPA의 사유물이 아니다. outbox, JDBC idempotency store, multi-instance lock, +> notification store, Fileserver 트랜잭션 경로가 전부 필요로 하고, "JPA 꺼졌나?"만 물으면 +> **아무도 안 쓰는 풀을 열거나, 정당하게 쓰던 capability를 조용히 망가뜨린다.** + +필터의 실패 모드도 정직하게 적혀 있다: 목록의 오타는 **조용히 fail-open**한다(필터가 그냥 +매치하지 않음). 그래서 테스트가 반환값이 아니라 **빈 부재**로 assert한다. + +### 14.2 `JpaAdapterComponentsConfig` — scan을 되살린 기록 + +이 클래스의 javadoc이 이 저장소 전체에서 제일 값진 사고 기록 중 하나다. + +> 컴포지션 루트의 `@ComponentScan`이 `dev.caskeleton.adapter.outbound.persistence.**`를 +> 정규식으로 제외하고, 그 제외는 **옳다**: 그게 optional capability를 optional하게 만든다. +> +> 빠진 건 나머지 절반이다. 이 leaf의 여덟 클래스가 scanned component로 쓰여 있는데 — +> `SpringTransactionPort`, `PersistenceExceptionTranslator`, `StandardSqlStateErrorMapping`, +> `DomainContextAuditContextPort`, idempotency store와 reaper, outbox store와 reaper — +> 넓은 scan이 그들에게 도달하기를 멈추자 **아무것도 도달하지 않았다.** `@Component`와 +> `@Repository`가 붙어 있고 **돌고 있는 어떤 애플리케이션에서도 빈이 아니었다**: +> 특히 `TransactionPort`는 구현이 전혀 없어서, **트랜잭션을 여는 모든 유스케이스가 열 +> 포트를 갖지 못했다.** +> +> 그게 unsatisfied dependency로 나타난 건 트랜잭션이 필요한 capability가 실제로 조립될 때 — +> `local-notification-ingest` 레인의 notification orchestrator — 였고, **단위 테스트가 볼 +> 수 있는 건 아무것도 아니었다.** 이 클래스들 각각이 자기 테스트에서 직접 생성되기 때문. + +scan 대상 6개: `audit`, `failure`, `idempotency`, `lock`, `outbox`, `transaction`. +`fileserver`와 `notification`은 일부러 빠졌다(각자 자기 스위치를 가진 config가 소유). + +`lock`이 목록에 있는 이유도 같은 역사다. + +> `DistributedLockPersistenceConfig`가 두 lock provider를 다 소유하는데 **테스트가 +> `ctx.register(...)`를 호출하는 것 말고는 아무것도 등록하지 않았다.** 그래서 단일 인스턴스 +> 배포는 `DistributedLockPort`가 아예 없었고, 다중 인스턴스 배포는 시작할 수 없었다: +> 컴포지션 루트 자신의 `DistributedLockConfig`가 그 설정만 선언하는 `jdbcDistributedLock` +> 한정 빈을 요구한다. + +### 14.3 `PersistenceJpaConfig` — 엔티티 스캔 목록 + +`@EntityScan` + `@EnableJpaRepositories`가 **패키지를 열거**한다(root를 주지 않는다). 그리고 +`notification`, `fileserver` 두 개가 의도적으로 빠져 있다. + +> 둘 다 **opt-in** capability이고 스키마 스트림이 기본 Flyway location에 없어서, 요청하지 +> 않은 배포에는 테이블이 존재하지 않는다. root 전체를 스캔하면 그 엔티티들이 무조건 +> persistence unit에 들어가고, `ddl-auto=validate`가 **두 capability를 다 끈 배포에서** +> `notification_request`와 `fs_cleanup_item`을 찾다가 boot를 실패시켰다. +> +> fileserver 쪽이 더 비쌌다: **JPA 켜진 모든 Compose 레인을 막았고**, H2에서는 보이지 +> 않았다 — H2의 `create-drop`은 엔티티가 기술하는 걸 그대로 만든다. **진짜 마이그레이션 +> 히스토리를 가진 진짜 PostgreSQL이 있어야 보였다.** + +새 always-installed 하위 패키지를 여기 추가하지 않는 건 컴파일 에러가 아니라 **조용한 누락**이라 +`PersistenceEntityScanCoverageTest`가 확인한다. + +### 14.4 add-on 빈과 조건 순서 버그 + +`JpaPlatformRuntimeAutoConfiguration`의 javadoc에 두 개의 좋은 기록이 있다. + +**(1) 이름만 AutoConfiguration이던 세 클래스** + +> 이 패키지의 세 클래스가 `...AutoConfiguration`이라 이름 붙었고 **plain factory였다**: +> `@AutoConfiguration`도, `@Bean`도, `AutoConfiguration.imports` 엔트리도 없었다 — +> 그리고 `CaSkeletonApplication`은 이 패키지를 component scan에서 일부러 제외한다. +> 그래서 capability 리포트는 transaction retry, completion evidence, observability를 +> Stable로 나열했고 **돌고 있는 컨텍스트에는 그중 아무것도 없었다.** +> 개발자가 **재시도되지 않는 재시도에 의존하는 코드를 배포할 수 있었다.** + +**(2) `@ConditionalOnBean(DataSource.class)`의 평가 시점** + +> 이 클래스는 `PersistenceJpaRootAutoConfiguration`이 **import**하지 auto-configure하는 게 +> 아니라, 그 조건이 **클래스가 파싱되는 동안 — datasource 빈 정의가 존재하기 전에** 평가됐고 +> 따라서 **모든 실제 배포에서 false**였다. 아래 여덟 빈이 전부 조용히 사라졌고, 아무것도 +> 그중 어느 것에도 의존하지 않아서 아무것도 그걸 보고하지 않았다. datasource validator가 +> 마침내 caller에 배선되고 JPA Compose 레인이 "No qualifying bean"이라고 답했을 때에야 +> 드러났다. + +`PersistenceJpaRootAutoConfiguration`의 `jpaResolvedDataSourceCheck`에도 같은 함정 회피가 +기록되어 있다 — validator를 주입받지 않고 **직접 생성**한다. + +> 그건 stateless이고, 그것을 공급하던 빈은 `JpaPlatformRuntimeAutoConfiguration`에서 +> 오는데 그 클래스가 plain `@Configuration`에 `@ConditionalOnBean(DataSource.class)`를 +> 달고 있었다 — 즉 **그 클래스가 사라진 것과 같은 이유로 이 검사도 사라졌을 것이다.** + +### 14.5 startup 가드 + +| 가드 | 하는 일 | +|---|---| +| `JpaDangerousConfigurationGuard` | `spring.jpa.open-in-view=true` (local convenience 프로필 외), 스키마 변경 `ddl-auto` 거부 | +| `JpaDataSourceProfileValidator` | resolved `DataSource`를 열어서 product/version 확인 | +| `PostgreSqlVersionPolicy` | PostgreSQL 16/17/18만 허용 | +| `PersistenceVendorProdSafetyValidator` | prod에서 H2 거부 | +| `HikariPoolConstraintValidator` | `spring.datasource.hikari.*` 제약 | +| `NotificationSchemaActivation` / `FileserverSchemaActivation` | capability 스트림 승격 확인 | + +`JpaSafetySettings.enabled`가 `@DefaultValue("true")`인 이유도 사고다. + +> primitive `boolean`은 프로퍼티가 없으면 `false`로 바인딩되므로, 이 record는 `defaults()`에서 +> "기본값은 enabled"라고 말하면서 아무것도 설정하지 않은 애플리케이션은 **안전 스위치가 +> 꺼진 채로** 받았다 — 두 답이 **한 번도 그것에 대해 생각해 본 적 없는 배포에서 정확히** +> 어긋났다. + +`localConvenienceProfiles`가 boolean이 아니라 목록인 이유: "local에서 켜짐"이 누군가 프로필 +이름을 재사용하는 첫 순간에 조용히 "staging에서 켜짐"을 의미하지 않게. + +`JpaDataSourceProfileValidator`의 자기비판도 좋다. + +> 다른 datasource를 검증하고 있었다. bound가 `app.jpa-platform.datasource.*`에 바인딩된 +> `JpaDataSourceSettings`에서 왔는데 **요청을 서비스하는 풀은 `spring.datasource.hikari.*`에서** +> 만들어진다 — 하나의 풀에 두 개의 기술, 그리고 validator는 **사용되지 않는 기술에 대해 +> 통과할 수 있다.** +> +> 더 나쁜 건, 그 평행 네임스페이스가 어떤 shipped YAML에도, env-key 레지스트리의 어떤 행에도 +> 나타나지 않아서 두 필드가 항상 null이었고 `requirePoolBounds`가 **모든 배포에서 던졌을 +> 것**이다. 아무것도 그걸 호출하지 않아서 아무것도 실패하지 않았다. **서로를 상쇄한 두 +> 결함이고, 애플리케이션이 시작한 이유는 두 번째가 첫 번째를 숨겼기 때문이다.** + +### 14.6 실제 설정 값 + +`ca-skeleton.jpa.transaction.*` (application.yml): + +```yaml +transaction-timeout: 30s begin-budget: 250ms minimum-action-window: 1s +completion-margin: 500ms statement-timeout: 10s lock-timeout: 2s +idle-guard-timeout: 15s transaction-margin: 250ms lock-margin: 100ms +retry-base-delay: 10ms retry-maximum-delay: 50ms retry-maximum-attempts: 2 +``` + +Hikari: + +```yaml +maximum-pool-size: ${APP_DATASOURCE_POOL_MAX_SIZE:10} +minimum-idle: ${APP_DATASOURCE_POOL_MIN_IDLE:2} +connection-timeout: ${APP_DATASOURCE_CONNECTION_TIMEOUT:5000} # 밀리초 정수만! +idle-timeout: 600000 max-lifetime: 1800000 keepalive-time: 120000 +leak-detection-threshold: 30000 validation-timeout: 3000 initialization-fail-timeout: 1 +``` + +`connection-timeout` 주석에 또 하나의 사고가 있다. + +> **PLAIN MILLISECONDS ONLY.** 이 키는 `HikariConfig#setConnectionTimeout`(long)에 +> 바인딩되므로 binder가 `"5s"`를 즉시 거부한다. `"5s"`로 출시됐고 **모든 prod/dev 배포가 +> 시작에 실패했는데** local은 `application-local.yml`이 값을 override해서 통과했다 — +> 그래서 **prod 레인만이 그걸 찾을 수 있었다.** `HikariPoolConstraintValidator`는 이 키를 +> `DurationStyle`로 읽어서 `"5s"`를 기꺼이 받아들인다; **그 관대함이 잘못된 기본값을 +> 지원되는 것처럼 보이게 만들었다.** + +Flyway는 세 옵션이 **pin**되어 있다(env-driven 아님): `baseline-on-migrate: false`, +`out-of-order: false`, `clean-disabled: true`. `FlywayProdSafetyValidator`가 prod에서 override를 +거부(exit 71). + +`ca-skeleton.outbox.*`: `poll-interval: PT5S`, `batch-size: 20`, `in-flight-timeout: PT5M`, +`reaper-interval: PT10M`, `published-retention: P7D`. + +`ca-skeleton.idempotency.ttl: 24h`, `reaper-interval: 10m`. + +--- + +## 15. 검증 — 레인, readiness card, evidence manifest + +### 15.1 소스 세트와 Gradle 레인 + +```groovy +strictTestLanes { + sourceSet('postgresqlIntegrationTest') { compilesAgainst 'main'; inherits ... } + sourceSet('testkit') { compilesAgainst 'main' } + sourceSet('jpaPlatformPerformanceTest') { compilesAgainst 'main', 'testkit' } +} +testkitPublisher { consumedBy 'test', 'postgresqlIntegrationTest'; publishAs 'jpaTestkit' } +``` + +| Gradle task | 태그/필터 | 내용 | +|---|---|---| +| `test` | — | hermetic 단위 | +| `jpaPlatformContractTest` | `jpa-contract` | 실 PostgreSQL 계약 | +| `jpaPlatformMigrationTest` | `jpa-migration` | Flyway 업그레이드 시나리오 | +| `jpaPlatformFailureTest` | `jpa-failure` | 데드락, 직렬화, 커밋 모호성 | +| `jpaPlatformQueryPlanTest` | `jpa-queryplan` | EXPLAIN 구조 | +| `jpaPlatformSecurityTest` | `jpa-security` | 런타임 롤 권한 | +| `jpaPlatformPoolContractTest` | — | Hikari 풀 / REQUIRES_NEW 동작 | +| `jpaPlatformReleaseGate` | — | 위 전부 집계 | + +그 밖에 **readiness task 15개**가 클래스별로 등록되어 있다 +(`postgresqlLifecycleIntegrationTest`, `postgresqlSecurityBaselineIntegrationTest`, +`postgresqlMigrationIntegrationTest`, `postgresqlTransactionIntegrationTest`, +`postgresqlAggregateIntegrationTest`, `postgresqlQueryIntegrationTest`, +`postgresqlIdempotencyIntegrationTest`, `postgresqlOutboxStorageIntegrationTest`, +`postgresqlOutboxPollingIntegrationTest`, `postgresqlInboxIntegrationTest`, +`postgresqlFileserver*IntegrationTest` 3개, `postgresqlNotificationSchemaActivationIntegrationTest`). + +**모든 레인이 fail-closed다.** + +```groovy +failOnNoDiscoveredTests = true +outputs.upToDateWhen { false } +``` + +> `failOnNoDiscoveredTests`가 여기서는 평소보다 중요하다: 아무것도 발견하지 못한 선택된 +> 레인은 **성공을 보고**하고, 조용히 돌기를 멈춘 계약 suite는 **통과하는 것과 구별되지 +> 않는다.** + +Docker 부재도 skip이 아니라 **에러**다. + +```java +throw new IllegalStateException( + "Docker is required for the PostgreSQL contract suite and is not available; this lane" + + " fails closed rather than skipping, because a skipped contract reports success" + + " for a database nobody tested"); +``` + +`PostgreSqlVersion`은 정확히 16/17/18. `parseSelection("")`은 **에러**다 — +"빈 PostgreSQL 매트릭스 선택은 빈 실행이 아니라 에러다." + +`PostgreSqlContractExtension`이 컨테이너를 **버전별로 JVM 전체에 캐시**한다. + +### 15.2 두 개의 SQL 안전 게이트 (Gradle 태스크) + +`verifyJpaSqlConstructionSafety`가 벤더 소스를 정규식으로 훑는다. + +```groovy +def concatenatedSql = text =~ /(?s)(createNativeQuery|queryForObject|update)\s*\([^;]*"\s*\+/ +if (line.contains("set_config('") && !line.contains('?')) { violation } +``` + +`verifyJpaSecurityFixtures`는 보안 픽스처에 `runtimeRoleCannotCreateInApplicationSchema`, +`assertDockerAvailable`, `42501` 문자열이 있는지 확인한다. + +### 15.3 `jpaPlatformPoolContractTest` — 이름을 바꾼 이유 + +이 build.gradle 코멘트가 특히 좋다. + +> `jpaPlatformPerformanceTest`였고, pool과 REQUIRES_NEW 압력을 **certify**한다고 기술됐고, +> **나타나는 모든 곳에서 off가 기본인 boolean 뒤에** 게이트되어 있었다 — 이 파일에서도, +> 명시적으로 off로 설정한 nightly workflow에서도. 그래서 릴리스 게이트가 **유일한 threshold +> assertion이 "threshold를 assert하지 않고 있다"인 레인**에 의존했고, "certified"는 **어떤 +> latency나 throughput bound도 무언가와 비교된 적 없는 실행**을 기술했다. +> +> 프로퍼티는 사라졌다; 그 이름은 여기 일부러 반복하지 않는데, **주석 속의 이름이 다음 +> 사람이 설정해 보려는 바로 그것**이기 때문이다. + +지금 이름은 약속하지 않는다 — REQUIRES_NEW depth 1이 동시 스레드당 커넥션 2개를 필요로 하고, +포화된 풀이 pending count를 보고하고, caller가 커넥션 없이 진행하지 않고 기다린다는 **행동 +계약**만 검증한다. 진짜 성능 게이트는 전용 러너, warmup/sample 수, 기록된 threshold가 필요하고 +그때는 별도 레인이어야 한다. + +### 15.4 `release-registry.json` — 문서를 렌더링으로 + +`config/jpa/release-registry.json`이 타입 있는 SSOT다. + +```json +"databases": [ + { "major": 16, "support-level": "stable", "image": "postgres:16-alpine" }, + { "major": 17, "support-level": "stable", "image": "postgres:17-alpine" }, + { "major": 18, "support-level": "stable", "image": "postgres:18-alpine" }, + { "major": 19, "support-level": "experimental", "image": "postgres:19-alpine" } +], +"provider": { "name": "hibernate-orm", + "stable-tested-baseline": "7.1.8.Final", + "compatibility-target": "7.4" }, +"gates": [ 6개, 각각 name + 실제 Gradle task path + blocking ] +``` + +이게 왜 생겼는지가 `_comment`에 있다. + +> 예전엔 산문이었다. `JpaReleaseManifest`가 support matrix가 언급하는 모든 `PostgreSQL NN`과 +> 그것이 담은 모든 `` `name` | gate ``를 수집했고, **매치가 어느 테이블에서 왔는지, 그 행이 +> 어떤 지원 수준을 선언했는지 전혀 몰랐다.** 그래서 Experimental PG19가 Stable major와 같은 +> 목록에 들어갔고, 문장에서 한 번 언급된 버전이 supported로 카운트됐고, **PG17을 +> Experimental로 강등해도 문자열이 문서 어딘가에 살아남는 한 아무것도 바뀌지 않았다.** + +그리고 릴리스 매트릭스 버그도 하나 더. + +> 릴리스 레인이 `-Pjpa.matrix.versions=16,17,18`을 `selectedVersions().get(0)`을 쓰는 +> `JpaPlatformContractSupport.start()`에 넘겼고, **통합 suite 전체가 PostgreSQL 16에 대해 +> 돌았으며**, 이 테이블은 **3개 assertion짜리 smoke test의 힘으로** 17과 18을 완전 커버로 +> 기록했다. + +지금은 `start()`가 다중 버전 선택을 아예 거부하고, `jpa-release.yml`이 major당 job으로 +fan-out하고, promotion job이 세 major의 증거가 **같은 commit SHA**를 담기를 요구한다. + +`JpaReleaseRenderingTest`가 DB 테이블, gate 테이블, workflow의 matrix/promotion 목록을 +레지스트리와 비교하고, `verifyJpaReleaseGateTasks`가 모든 gate의 task를 **실제 Gradle task +그래프에 대해 resolve**한다. + +### 15.5 `readiness-cards.yaml` — 17개 capability card + +`config/jpa/readiness-cards.yaml` (718줄)이 card/task/scenario 매핑의 SSOT다. + +| card | state | schema-stream | +|---|---|---| +| `jpa-observability-lifecycle` | selected | none | +| `jpa-security-baseline` | selected | none | +| `jpa-flyway-migration` | selected | owned | +| `jpa-transaction-runtime` | selected | none | +| `jpa-aggregate-store` | selected | contributes-to-core | +| `jpa-query-model` | selected | contributes-to-core | +| `jpa-primary-foundation` | selected | none | +| `jpa-idempotency-owner-safe-v2` | **implemented-candidate** | owned | +| `jpa-outbox-storage-v2` | **implemented-candidate** | owned | +| `jpa-outbox-polling-delivery-v2` | **implemented-candidate** | owned | +| `jpa-inbox-same-store-v1` | **implemented-candidate** | owned | +| `jpa-fileserver-metadata-v1` | **implemented-candidate** | owned | +| `jpa-notification-platform-v4` | **implemented-candidate** | owned | +| `jpa-outbox-cdc-retention-v1` | not-implemented | none | +| `jpa-primary-replica` | not-implemented | none | +| `jpa-tenant-discriminator-rls` | not-implemented | owned | +| `jpa-jdbc-efficiency-coordination` | not-implemented | owned | + +각 card가 `required-evidence` 목록과 `evidence.scenarios`(정확한 JUnit selector + +covers 목록)를 갖는다. `verifyJpaReadinessRegistryContract`가 unknown claim, duplicate +selector, 다른 card의 task 차용을 **mutation test로** 거부한다. + +`legacy-adoption` 블록: + +```json +{ "state": "transition-only", "location": "db/migration/postgresql", + "history-table": "flyway_schema_history", + "immutable-applied-versions": [1, 3, 4, 5], + "allowed-origin": "LEGACY_ADOPTED" } +``` + +### 15.6 evidence manifest와 R2 게이트 + +`gradle/jpa-evidence.gradle` (917줄). + +```bash +./gradlew :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence --console=plain +``` + +active card 11개의 producer를 실행하고 **JUnit XML에서 exact selector와 +executed/skipped/failure/error 수를 읽는다.** 각 manifest는 source revision/dirty digest, +prerequisite manifest ID, **PostgreSQL image digest**, pgjdbc/Hibernate/Flyway version, +topology, migration/dispatch metadata를 담고 canonical JSON SHA-256 이름으로 생성된다. + +``` +build/jpa-evidence/manifests//.json +``` + +후보 검증은 zero-skip / schema / content hash / prerequisite link가 맞으면 성공하지만 +**`attainedReadiness=R1`을 유지한다.** 각 manifest가 candidate profile, dirty source, 아직 +R2가 아닌 prerequisite를 `readinessBlockers`에 보존해서 **후보 통과를 R2로 오인할 수 없다.** + +진짜 aggregation 게이트는 별도다. + +```bash +./gradlew :adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence \ + -PjpaEvidenceProfile=r2 --console=plain +``` + +clean revision, `JPA_EVIDENCE_CI_JOB`, `JPA_EVIDENCE_ARTIFACT_LOCATION`, immutable PostgreSQL +image digest, 모든 required evidence, R2 prerequisite DAG가 있어야만 성공한다. 로컬 dirty +worktree나 unpublished 실행은 `worktree-is-dirty` / CI provenance blocker를 보고 **실패하는 +것이 정식 동작**이다. + +### 15.7 ArchUnit 규칙 팩 + +`testkit/arch`의 `JpaArchitectureRules`: + +| 규칙 | 막는 것 | +|---|---| +| `noEntityFromWeb()` | 컨트롤러가 엔티티 반환 → 트랜잭션 밖에서 lazy association 직렬화 | +| `entitiesFollowPortableMappingRules()` | `final` 엔티티(프록시 불가 → 모든 lazy 참조가 eager), no-arg 생성자 | +| `entitiesStayOutOfWebPackages()` | web 패키지 안의 엔티티는 노출될 엔티티 | +| `domainDoesNotDependOnHibernate()` | 도메인이 ORM에 의존 | +| `tenantScopedRepositoriesDoNotInheritBroadCrud()` | tenant-scoped 엔티티 리포지토리가 CRUD 상속 | +| `noGenericRepository()` | 플랫폼이 `CrudRepository` 재구현 | + +`tenantScopedRepositoriesDoNotInheritBroadCrud`의 설명이 좋다. + +> **이름은 중요한 속성이 아니다.** `tenantId`를 가진 엔티티에 대해 `JpaRepository`를 확장하는 +> `OrderRepository`는 `findById(UUID)`, `findAll()`, `deleteById(UUID)`를 노출한다 — 전부 +> tenant-blind, 전부 상속, 그리고 **리뷰어가 볼 만한 어디에도 적혀 있지 않다.** 그게 +> cross-tenant read가 사고로 쓰이는 방식이고, 이름 기반 규칙이 볼 수 없는 것이다. + +규칙 팩이 `main`이 아니라 `testkit`에 있는 이유: ArchUnit은 테스트 라이브러리고, +`main`에 두면 테스트에서만 도는 코드를 위해 **모든 배포의 런타임 클래스패스**에 끌려간다. + +`allowEmptyShould(true)`가 붙은 규칙들에도 정직한 코멘트가 있다. + +> 그런 타입이 없는 게 통과 상태이고, 이 규칙의 일은 그걸 유지하는 것이다. 중요한 emptiness — +> **아무것도 찾지 못한 import** — 는 어떤 규칙이 돌기 전에 production suite가 따로 assert한다. + +--- + +## 16. 읽고 나서 정리한 설계 원칙 14가지 + +코드 전체를 관통하는 원칙을 뽑아 봤다. 이 모듈에서 배울 게 있다면 대부분 여기에 있다. + +**1. 빠뜨림이 통과가 되는 게이트는 게이트가 아니다.** +`JpaModuleBoundaryTest`의 카탈로그 **정확한 동등성** 검사, `PersistenceEntityScanCoverageTest`, +`failOnNoDiscoveredTests = true`, `verifyDocumentedLeafCount`의 트리 walk. 전부 같은 교훈이다 — +"목록에 추가하는 걸 잊으면 초록불"인 구조를 제거한다. + +**2. 모르는 것은 일급 결과여야 한다.** +`RetryDisposition.RECONCILE`, `TransactionResult.Indeterminate`, +`WriteDisposition.UNDETERMINED`, `TransactionCompletionEvidence.UNKNOWN`, +`ReplicaLagMonitor.replayedThrough(): Optional`. "모른다"를 성공이나 실패로 접으면 그 +정보가 영원히 사라진다. + +**3. 위험한 상태는 타입이 표현할 수 없게 만든다.** +`JpaFailureContext`가 retryable + completionUnknown 조합을 거부한다. `RetryProfile`이 +`COMPLETION_UNKNOWN`을 화이트리스트에 못 넣게 한다. 리뷰 규칙이 아니라 생성자다. + +**4. 이름은 값이 아니라 registry key다.** +쿼리 이름, 큐 이름, 업서트 이름, JSON path 이름, COPY 이름 전부. **이름이 statement를 +선택하지, statement의 일부가 되지 않는다.** 이게 SQL injection 표면 전체를 없앤다. + +**5. path/identifier는 등록, value는 바인딩.** +JSON path, 정렬 필드, 스키마 이름, conflict 컬럼, `ON CONFLICT` 타깃 — 전부 파라미터로 바인딩할 +수 **없는** 것들이라 registry로 고정한다. 그 외 전부는 bound parameter. + +**6. 시간은 DB에서, 그리고 락 이후에 읽는다.** +`clock_timestamp()`를 `for update` 다음에. 애플리케이션 시계로 lease 만료를 판단하면 lease를 +쓴 머신이 아닌 곳에서 판단하는 것이고, 락 전에 DB 시계를 읽으면 행이 바뀔 수 있는 시점 이전의 +순간으로 판단하는 것이다. + +**7. CAS 튜플을 SQL where 절에 전부 반복하고, update count를 답으로 쓴다.** +읽고 나서 PK만으로 update하면 그 사이에 takeover한 worker의 상태를 덮어쓴다. V2 구현 넷과 +fileserver 전이가 전부 이 패턴이다. + +**8. 관측을 위한 데이터가 관측 대상보다 위험할 수 있다.** +메트릭 태그, 예외 메시지, 로그가 전부 PII 유출 경로다. `LowCardinality`, `JpaMetricTags`, +`SqlDiagnosticRedactor`, `JpaFailureContext`의 redaction이 전부 그것 때문이다. + +**9. 스키마는 마이그레이션이 소유하고, ORM은 확인만 한다.** +그리고 그걸 **런타임 롤에서 DDL 권한을 뺌으로써** 강제 가능하게 만든다. 정책 문서가 아니라 +`has_schema_privilege`. + +**10. 기능이 꺼졌다는 건 구조적이어야 한다.** +빈이 없고, 소켓/풀/스레드가 없고, 설정이 바인딩되지 않고, 스키마 기대치도 없다. 조건을 +빈마다 반복하는 게 아니라 **import 지점 하나**에 건다. + +**11. 로컬 환경이 다른 DB면 로컬 테스트는 다른 시스템에 대한 진술이다.** +`char(64)`와 `fs_cleanup_item` 두 사건이 `application-local.yml`을 PostgreSQL 기본으로 바꿨다. + +**12. 계약 테스트는 SQL을 재타이핑하지 말고 어댑터가 실제로 돌리는 statement를 실행해야 한다.** +`RecipientClaimSql`이 이걸 이름 붙여 놨다. "테스트 작성자와 어댑터 작성자가 쿼리에 대해 +합의했다"는 아무도 필요로 하지 않는 속성이다. + +**13. 벤더 차이는 추정하지 말고 측정한 뒤 근거를 적는다.** +"H2 2.4.240이 `FOR UPDATE SKIP LOCKED`를 진짜로 지킨다(측정함)"와 "그건 production 보장이 +아니다"를 동시에 기록한다. + +**14. 버그를 고칠 때 왜 그 버그가 가능했는지를 코드 옆에 남긴다.** +이 모듈의 javadoc 상당량이 사후 기록이다. 처음엔 장황하다고 느꼈는데, 다 읽고 나니 +**같은 실수가 다시 들어오는 걸 막는 유일하게 작동하는 장치**로 보인다. + +--- + +## 17. 손볼 것 — 읽으면서 발견한 것들 + +우선순위 순. 각 항목에 확인 방법을 같이 적었다. + +### P1 — 재시도 구현이 둘이고, 하나는 아무도 호출하지 않는다 + +**사실** + +- `FullTransactionRetryCoordinator`는 `JpaPlatformRuntimeAutoConfiguration#jpaRetryCoordinator`가 + 빈으로 등록하지만, **production 코드 어디에서도 주입되지 않는다.** 참조는 + app-bootstrap의 자기 config 2곳 + 자기 테스트뿐이다. +- 실제로 도는 재시도는 `SpringPolicyTransactionPort` + `TransactionRetryBackoff`이고, + **`COMMAND_SERIALIZABLE_REPLAY_SAFE` 정책에만** 적용된다. +- 두 재시도가 서로 다른 설정을 읽는다. + - `SpringPolicyTransactionPort` → `ca-skeleton.jpa.transaction.retry-*` (기본 최대 2회) + - `FullTransactionRetryCoordinator` → `RetryProfile.boundedContention("jpa-platform-default", 3)` + (하드코딩, 20ms~500ms, FULL jitter) +- `DefaultJpaRetryPolicy`의 정교한 6단계 순서, `IrreversibleSideEffectContext` 확인, + `RetryBudget`의 elapsed 상한 — **전부 아무도 호출하지 않는 경로**에 있다. + +**확인** + +```bash +grep -rn "FullTransactionRetryCoordinator" src | grep -v adapter/outbound/persistence-jpa +# → app-bootstrap의 두 config와 그 테스트뿐 +``` + +**왜 문제인가** +`docs/jpa/support-matrix.md`가 "Full-transaction retry | Stable"이라고 선언한다. 그런데 +`TransactionPort.inWrite()`를 쓰는 유스케이스는 그 경로를 절대 만나지 않는다. §14.4에 기록된 +"재시도되지 않는 재시도에 의존하는 코드를 배포할 수 있었다"와 **같은 모양의 문제**가 한 단계 +위에 남아 있는 셈이다. + +**선택지** + +(a) `SpringPolicyTransactionPort`가 `TransactionRetryBackoff` 대신 `DefaultJpaRetryPolicy` + +`RetryBudget`을 쓰도록 통합. 정책 순서와 elapsed 상한이 실제로 적용된다. +(b) `FullTransactionRetryCoordinator`를 명시적 "advanced 조립용"으로 강등하고 support-matrix +행을 그렇게 고친다. +(c) 최소한 두 재시도가 같은 설정을 읽게 한다. + +--- + +### P2 — 계약 테스트 8개가 production 타입을 하나도 실행하지 않는다 + +**사실** + +`src/postgresqlIntegrationTest/.../platform`의 테스트 클래스 중 production 패키지를 하나도 +import하지 않는 것들: + +| 클래스 | prod import | +|---|---| +| `PostgreSqlUpsertContractTest` | 0 | +| `PostgreSqlWorkClaimContractTest` | 0 | +| `PostgreSqlQueryPlanContractTest` | 0 | +| `JpaAuditingContractTest` | 0 | +| `JpaLifecycleAssociationContractTest` | 0 | +| `JpaValueMappingContractTest` | 0 | + +`PostgreSqlUpsertContractTest`와 `PostgreSqlWorkClaimContractTest`는 **SQL을 자체 상수로 다시 +쓴다.** + +```java +// PostgreSqlWorkClaimContractTest +private static final String CLAIM_SQL = + "select id from claim_queue where claimed = false order by priority, id limit ?" + + " for update skip locked"; +``` + +결과적으로 다음 production 클래스는 **`test`와 `postgresqlIntegrationTest` 어디에서도 참조되지 +않는다** — 즉 커버리지 0이다. + +``` +RegisteredPostgreSqlUpsertExecutor +PostgreSqlWorkClaimExecutor +PostgreSqlRangeQuerySupport +SpecificationPolicy +PgRangeJdbcType +``` + +**확인** + +```bash +cd src/adapter/outbound/persistence-jpa +grep -rn "UpsertExecutor\|WorkClaimExecutor\|RangeQuerySupport\|SpecificationPolicy\|PgRangeJdbcType" \ + src/test src/postgresqlIntegrationTest # → 결과 없음 +``` + +**왜 문제인가** +같은 저장소의 `RecipientClaimSql` javadoc이 정확히 이 안티패턴을 이름 붙여 놨다. + +> "테스트 작성자와 어댑터 작성자가 쿼리에 대해 합의했다"는 **아무도 필요로 하지 않는 +> 속성**이고, 필요한 속성은 **이** statement가 실제 PostgreSQL에 대해 claim해야 할 것을 +> claim한다는 것이다. + +`readiness-cards.yaml`은 `evidence.scenarios`로 selector를 고정하지만, **그 selector가 실행하는 +게 production 코드인지는 검사하지 않는다.** + +**제안** +- 각 계약 테스트가 registry를 실제로 구성하고 executor를 통해 실행하도록 바꾼다 + (`RecipientClaimSql`이 한 방식 — SQL을 production 상수에서 가져오기). +- 최소한 `readiness-cards.yaml`에 "이 시나리오가 실행하는 production 타입" 필드를 추가하거나, + ArchUnit/Gradle 게이트로 "vendor SQL 상수는 production에서만 선언된다"를 강제한다. + +--- + +### P3 — `docs/**` 문서의 leaf 수 drift가 게이트 사각지대에 있다 + +**사실** + +`docs/jpa/repository-adaptation.md` §1: + +> ...declares **exactly 19 leaf identities**, and `src/settings.gradle` throws when the registry +> does not contain exactly 19 modules. Creating 25 more Gradle projects would violate HARD-STOP #5. + +§4 표에도 "Fail-closed 19-leaf registry". + +그런데: + +- 레지스트리는 **44개** leaf를 갖는다. +- `src/settings.gradle`은 16줄이고 **`19`도, leaf 수 검사도 없다.** 검증은 + `ca.architecture-registry` 플러그인에 위임됐다. +- `verifyDocumentedLeafCount`는 `CLAUDE.md`, `AGENTS.md`, leaf `build.gradle`만 스캔한다. + **`docs/**`는 walk 대상이 아니다.** + +같은 drift가 다른 문서에도 있다. + +``` +docs/jpa/repository-adaptation.md "exactly 19 leaf identities" (×2) +docs/mongodb/repository-adaptation.md "exactly 19 leaf identities. Creating 31 more" +docs/httpclient/repository-adaptation.md "exactly 19 leaf identities. Creating 19 more" +docs/notification/module-mapping.md "19-leaf Clean Architecture template" +docs/adr/ADR-MONGO-001-platform-boundary.md "exactly 19 leaf identities" +``` + +**확인** + +```bash +python3 -c "import json;print(len(json.load(open('src/config/architecture/modules.json'))['modules']))" # 44 +grep -c "" src/settings.gradle # 16 +grep -rnE "[0-9]+ ?(개 ?)?-?(leaf|leaves)" docs | grep -v "^docs/superpowers/\|^docs/reviews/" +``` + +**왜 문제인가** +`verifyDocumentedLeafCount`의 코멘트가 스스로 이렇게 말한다. + +> 이름이 적힌 목록은 다섯 개의 모듈 `CLAUDE.md`와 네 개의 leaf `build.gradle`을 놓쳤고, +> 각각은 messaging 플랫폼 leaf가 등록되기 전의 "19-leaf"를 다시 진술하고 있었다 — **이 +> task가 실패시키려고 존재하는 바로 그 drift**를, **그 모듈의 독자가 가장 믿을 만한 +> 파일에서.** + +정확히 같은 이유로 `docs/**`도 walk 대상이어야 한다. 지금은 게이트가 "확장돼야 하는 목록"에서 +"세 종류의 파일명"으로 좁아진 것뿐이다. + +**제안** +- `policyDocuments` walk에 repository 루트의 `docs/**/*.md`를 추가한다 + (`docs/superpowers/`, `docs/reviews/`는 역사 기록이므로 제외하거나 별도 처리). +- 아니면 문서를 고친다. 다만 개별 수정은 다음에 leaf가 추가되면 또 드리프트한다 — + 게이트 확장이 낫다. + +--- + +### P4 — `outbox` 스토어/리퍼에 capability 조건이 없다 + +**사실** + +| 클래스 | 조건 | +|---|---| +| `IdempotencyStoreAdapter` | `@ConditionalOnProperty(ca-skeleton.capabilities.idempotency.provider = jdbc, matchIfMissing = true)` | +| `IdempotencyReaper` | 동일 | +| `OutboxStoreAdapter` | **없음** (`@Repository`만) | +| `OutboxReaper` | **없음** (`@Component` + `@Scheduled`) | + +`JpaAdapterComponentsConfig`가 `...persistence.outbox` 패키지를 component scan한다. 따라서 +`ca-skeleton.persistence-jpa.enabled=true`이면 `ca-skeleton.outbox.enabled=false`여도 +`OutboxStoreAdapter`와 `OutboxReaper`가 빈이 되고, reaper의 `@Scheduled`가 10분마다 +`delete from outbox_event where status='PUBLISHED' and occurred_at < ?`를 돈다. + +**확인** + +```bash +grep -rn "ConditionalOn" src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/ # 없음 +grep -n "basePackages" -A10 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/JpaAdapterComponentsConfig.java +``` + +**왜 문제인가** +`application.yml` 코멘트가 이 구분을 명시적으로 만들었다. + +> `enabled`는 **capability 스위치**다. off는 outbox settings, validator, leader token, metrics, +> store binding이 **아예 없다**는 뜻이고, `relay-enabled`는 스케줄러가 도는지만 결정한다. +> 둘을 합쳤더니 relay-off 배포가 **database-less 배포에는 없는 store 포트 위에 OutboxMetrics를 +> 조립했다.** + +app-bootstrap의 `OutboxConfig`는 `@ConditionalOnProperty(ca-skeleton.outbox.enabled=true)`로 +제대로 게이트되어 있는데, **어댑터 쪽 두 빈만 무조건**이다. §10 원칙("off는 구조적")과 +어긋난다. + +**추가 리스크**: `db/migration/jpa/outbox-storage/V1`이 `outbox_event`에 +`trg_fence_legacy_outbox_writer`(BEFORE INSERT/UPDATE/**DELETE**, FOR EACH ROW)를 건다. +authority가 `LEGACY_POLLING`이 아니면 예외를 던진다. 즉 **outbox storage V2로 cutover한 뒤에는 +V1 `OutboxReaper`의 bulk delete가 매 주기 실패한다.** 지금은 V2가 candidate라 활성화되지 +않았지만, cutover 런북에 "V1 reaper/relay 먼저 끄기"가 명시돼야 한다. + +**제안** +- `OutboxStoreAdapter` / `OutboxReaper`에 `@ConditionalOnProperty(prefix="ca-skeleton.outbox", + name="enabled", havingValue="true")` 추가. +- cutover 절차에 V1 writer 비활성화 단계를 명시. + +--- + +### P5 — `55P03`이 웹 표면 매트릭스에 없다 + +**사실** + +`55P03`(lock_not_available)은: + +- `PostgreSqlState`에 등록되어 있고 → `FailureCategory.LOCK_NOT_AVAILABLE` +- `PostgreSqlLockExceptionTranslator`가 `PessimisticLockTimeoutException`으로 번역하고 +- **`PostgreSqlSqlStateErrorMapping`에는 없다** (`40P01`, `25P03`, `57014` 셋뿐) + +따라서 웹 표면의 `PersistenceExceptionTranslator`는 `55P03`에 대해 `Optional.empty()`를 +반환하고, 클라이언트는 **generic `INTERNAL`**을 받는다. + +**확인** + +```bash +grep -rn "55P03" src/adapter/outbound/persistence-jpa/src/main/java +# PostgreSqlState, PostgreSqlLockExceptionTranslator, FailureCategory, H2SqlStateErrorMapping 주석에만 나옴 +``` + +`H2SqlStateErrorMapping` javadoc이 이걸 인지하고 있다. + +> PostgreSQL은 같은 영역을 57014(statement)와 55P03(lock)으로 나누고 **이 저장소는 57014만 +> 매핑한다.** + +**왜 문제인가** +락 타임아웃은 재시도 가능한(또는 최소한 클라이언트가 backoff해야 하는) 상황인데, 지금은 +"내부 오류"로 보인다. 두 층(`api.error` vs `failure`)의 커버리지가 어긋나 있다. + +**제안** +`OperationalError`에 적절한 코드가 있는지 확인하고, `55P03` 행을 추가한다 +(`DB_LOCK_TIMEOUT` 신설 또는 기존 코드 재사용 — 어느 쪽이든 결정이 필요하다). + +--- + +### P6 — `auditing/AuditMetadata`가 완성돼 있는데 아무것도 안 쓴다 + +**사실** + +`AuditMetadata`(`@Embeddable`) + `JpaAuditorProvider` + `JpaAuditingConfiguration`이 완전히 +구현되어 있는데: + +- 어떤 엔티티도 embed하지 않는다. +- `JpaAuditingConfiguration`을 조립하는 곳이 없다. +- 컬럼 이름이 canonical(`audit/AuditableEntity`)과 다르다: `modified_*` vs `updated_*`. +- actor 길이도 다르다: 64 vs 256. + +`docs/jpa/support-matrix.md`가 "Candidate, not composed"로 정직하게 표기했고, +`JpaAuditMechanismRule`이 한 엔티티가 둘 다 쓰는 걸 막는다. + +**왜 문제인가** +문제라기보다 **미결 결정**이다. 하지만 남겨 두는 비용이 있다. + +- 두 메커니즘 × 두 컬럼 계열을 아는 ArchUnit 규칙을 유지해야 한다. +- 새 엔티티를 쓰는 사람이 어느 쪽을 골라야 하는지 코드만으로는 알 수 없다. + +**제안** +support-matrix가 이미 선택지를 적어 뒀다 — canonical 컬럼으로 reshape하거나 새 컬럼용 +forward migration을 쓰거나. 셋째 선택지(**제거**)도 표에 올릴 만하다. + +--- + +### P7 — `postgresql.json`이 Jackson을 transitive로 쓴다 + +**사실** + +`JsonDocumentCodec`, `JsonDocument`, `PostgreSqlJsonQuerySupport`가 +`com.fasterxml.jackson.databind.*`를 import하는데, `build.gradle`에 **jackson 선언이 없다.** + +**확인** + +```bash +cd src/adapter/outbound/persistence-jpa +grep -i jackson build.gradle # (없음) +grep -i "jackson-databind" gradle.lockfile # compileClasspath, runtimeClasspath 등에 존재 +``` + +**왜 문제인가** +BOM 관리 하에 있고 lock 파일에 고정되어 있으니 지금은 깨지지 않는다. 다만 transitive 경로가 +바뀌면(예: 다른 starter가 빠지면) **컴파일이 깨진다.** 이 저장소의 다른 곳은 필요한 걸 +명시적으로 선언하는 편이다(예: `implementation 'io.micrometer:micrometer-core'`에 "Micrometer의 +observation API는 Spring과 함께 오지만 meter registry는 아니다"라는 코멘트까지 달려 있다). + +**제안** +`implementation 'com.fasterxml.jackson.core:jackson-databind'` 선언 + 왜 필요한지 코멘트 +(§8.3 JSONB envelope). 버전은 BOM이 관리한다. + +--- + +### P8 — 플랫폼 capability의 대부분에 production 소비자가 없다 + +**사실** + +leaf 밖 참조 수를 세어 보면: + +| 타입 | 외부 참조 | +|---|---| +| `JpaKeysetQuerySupport`, `SafeSortMapper`, `JpaStreamExecutor`, `FetchPlanApplier` | 0 | +| `HibernateJpaBatchExecutor`, `HibernateBulkDmlExecutor`, `HibernateStatelessSessionRunner` | 0 | +| `RegisteredPostgreSqlUpsertExecutor`, `PostgreSqlWorkClaimExecutor` | 0 | +| `PostgreSqlJsonQuerySupport`, `PostgreSqlRangeQuerySupport`, `PostgreSqlArraySupport` | 0 | +| `RegisteredPostgreSqlCopyLoader`, `HibernateEnversHistoryReader`, `QuerydslJpaSupport` | 0 | +| `CacheRegionCatalog`, `TransactionProfileRegistry`, `EntityGraphCatalog` | 0 | +| `FullTransactionRetryCoordinator` | 3 (전부 app-bootstrap 자기 config/test) | + +즉 이 leaf의 **`springdata` / `hibernate.*` / `postgresql.{write,lock,json,array,range,copy}` / +`cache` / `envers` / `querydsl` 전부가 "구현된 라이브러리"이고 이 애플리케이션은 쓰지 않는다.** + +**결정적 증거: 같은 leaf 안의 두 스토어도 안 쓴다.** + +`fileserver`(25파일)와 `notification`(53파일)은 이 leaf에서 제일 큰 production 코드 덩어리이고 +(합쳐 350KB, 본문의 33.7%), 매일 PostgreSQL에 쓰기를 한다. 그런데 **JPA 플랫폼 타입을 하나도 +import하지 않는다** (§11.0 측정). + +| 이 플랫폼이 제공하는 것 | 두 스토어가 실제로 하는 것 | +|---|---| +| `SafeSortMapper` (allowlist 정렬) | 자기 `@Query`에 `order by` 직접 작성 | +| `PostgreSqlWorkClaimExecutor` (등록된 SKIP LOCKED 큐) | `RecipientClaimSql`, `FileserverCleanupRepository.claim` — 자기 native SQL | +| `RegisteredPostgreSqlUpsertExecutor` (등록된 업서트) | 자기 `@Modifying` 조건부 UPDATE | +| `JpaKeysetQuerySupport` (keyset 페이지) | `Limit` + `order by` | +| `FullTransactionRetryCoordinator` (전체 트랜잭션 재시도) | 조건부 UPDATE의 count로 낙관적 충돌 판정 | + +**같은 저장소, 같은 leaf, 같은 DB, 같은 문제(큐 클레임 / 정렬 / 충돌 판정)를 풀면서 플랫폼을 +안 쓰고 각자 다시 만들었다.** 플랫폼이 안 쓰이는 이유가 "아직 소비자가 없어서"가 아니라 +**소비자가 있는데도 안 쓴다**는 뜻이라, 문제의 성격이 다르다. + +**왜 문제인가** +`support-matrix.md`가 대부분을 Advanced로 표기하니 **거짓말은 아니다.** 다만 조합이 비싸다. + +- P2와 겹치는 부분은 "구현됐고, 안 쓰이고, 테스트도 없다"가 된다. +- 26,657줄 중 상당 부분이 이 카테고리다. 리팩터링/업그레이드 때마다 유지비가 든다. +- 템플릿의 목적(파생 프로젝트가 가져다 씀)을 생각하면 정당화될 수 있지만, + **그 정당화가 문서에 명시적으로 없다.** + +**제안** + +셋 중 하나를 골라야 한다. 지금은 셋 다 아닌 상태다. + +1. **채택** — `fileserver`/`notification`의 큐 클레임·정렬·업서트를 플랫폼 타입으로 옮긴다. + 플랫폼이 소비자를 얻고, 두 벌로 존재하는 SKIP LOCKED 로직이 한 벌이 된다. +2. **명시적 라이브러리 선언** — support-matrix에 "Advanced = 구현되어 있으나 이 저장소의 + production 경로에서 사용되지 않으며, 파생 프로젝트가 가져다 쓰라고 존재한다"를 적고, + 각 Advanced capability에 어댑터를 통과하는 계약 테스트를 요구한다(P2와 같은 작업). +3. **제거** — 파생 프로젝트가 실제로 안 가져다 쓴다면 26,657줄의 상당 부분을 덜어낸다. + +1번이 P2까지 같이 해결한다: 어댑터를 통과하는 실제 사용처가 생기면 커버리지 0인 다섯 클래스에 +자동으로 실행 경로가 생긴다. + +--- + +### P9 — 확인만 하고 넘어간 것들 (문제 아님, 기록용) + +- **`42501` → `FailureCategory.UNKNOWN`**: `PostgreSqlState`에 등록된 상태 중 유일하게 + `UNKNOWN`으로 매핑된다. 권한 오류는 재시도 불가이고 스키마 문제도 아니니 UNKNOWN이 맞을 + 수 있지만, "등록했는데 UNKNOWN"은 코드만 보면 미완처럼 읽힌다. 한 줄 주석이면 해소된다. +- **`H2LocalTimeoutConfigurer` 세션 스코프**: javadoc이 이미 인정하고 근거를 적어 뒀다. +- **`SafeSortMapper.MAX_SORT_TERMS = 4`**: registry에 더 많이 등록할 수 있지만 요청은 4개까지. + 의도로 보인다. +- **`PgRangeCodec.parse("empty")` 거부**: 의도이고 근거가 적혀 있다. +- **`SpringTransactionPort`의 non-`@Autowired` 생성자들**: 테스트 전용 + (`withoutAcquisitionEnvelope`). package-private이라 표면이 넓어지지 않는다. + +--- + +## 18. 블로그 글감 후보 + +한 편에 다 넣기엔 너무 크다. 쪼갠다면 이렇게 나눌 것 같다. + +| # | 제목(가안) | 핵심 소재 | 분량감 | +|---|---|---|---| +| 1 | **"커밋했는지 모르겠습니다" — 분산 트랜잭션 없이 커밋 모호성을 다루는 법** | `TransactionCompletionEvidence`, `EvidenceAwareJpaTransactionManager`, `CommitFailureClassifier`, `57P01` 발견, `TransactionResult.Indeterminate` | 中 (이게 제일 강하다) | +| 2 | **타입으로 메트릭 카디널리티를 막기** | `PersistenceOperationName` 계열, `LowCardinality`, `JpaMetricTags`, `SqlDiagnosticRedactor` | 小 | +| 3 | **keyset 페이지네이션을 제대로 구현하면 생기는 6가지 함정** | 사전식 predicate, term별 direction, tie-breaker direction, `size+1`, 서명 커서, count 쿼리 없음 | 中 | +| 4 | **`ON CONFLICT`와 `SKIP LOCKED`로 만드는 owner-safe 상태 기계** | V2 idempotency/outbox/inbox 공통 패턴, CAS 튜플, transition digest, lock→`clock_timestamp()` 순서 | 大 | +| 5 | **Flyway 스트림을 쪼개는 이유** | V1 충돌, 독립 history table, `baselineVersion("0")`, `capability_schema_registry`, ACTIVE 승격 | 中 | +| 6 | **로컬을 H2로 돌리면 무엇을 못 보게 되는가** | `char(64)` 사건, `fs_cleanup_item` 사건, `ddl-auto=validate`, H2 SQLSTATE 차이 | 小 (임팩트 좋음) | +| 7 | **"꺼짐"을 구조적으로 만들기** | 마스터 스위치, `AutoConfigurationImportFilter`, `DataSourceRequirement`, capability별 EntityScan | 中 | +| 8 | **초록불인데 아무것도 검증하지 않는 게이트들** | `failOnNoDiscoveredTests`, Docker skip 금지, 카탈로그 동등성, 성능 레인 rename, release registry, P2/P3 발견 | 中 (자기비판 톤이 잘 먹힌다) | +| 9 | **PostgreSQL RLS가 조용히 아무것도 안 하는 세 가지 방법** | `FORCE ROW LEVEL SECURITY`, `BYPASSRLS`, 테이블 소유자, transaction-local `set_config` | 小 | +| 10 | **재시도해도 되는 실패와 안 되는 실패** | `FailureCategory`, `DefaultJpaRetryPolicy` 6단계 순서, `RetryProfile` 화이트리스트, `IrreversibleSideEffectContext` | 中 | + +**내 추천 순서**: 1 → 6 → 8 → 3 → 4. + +1번은 "왜 이런 게 필요한가"가 직관적이지 않아서 설명 가치가 크고, 6번은 짧으면서 누구나 +겪는 문제고, 8번은 이 저장소가 가진 자기비판 기록이 그대로 소재가 된다. + +--- + +## 부록 A. 자주 쓸 명령 + +```bash +cd src + +# 단위 (hermetic) +./gradlew :adapter:outbound:persistence-jpa:test --console=plain + +# 실 PostgreSQL 레인 +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformFailureTest +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest + +# 매트릭스 선택 +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest -Pjpa.matrix.versions=16,17,18 + +# 릴리스 게이트 (루트에서) +./gradlew jpaPlatformReleaseGate + +# SQL 안전 게이트 +./gradlew :adapter:outbound:persistence-jpa:verifyJpaSqlConstructionSafety +./gradlew :adapter:outbound:persistence-jpa:verifyJpaSecurityFixtures + +# evidence +./gradlew :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence +./gradlew :adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence -PjpaEvidenceProfile=r2 + +# 아키텍처 +./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +``` + +## 부록 B. 읽은 순서 (다시 읽는다면) + +1. `src/config/architecture/modules.json` — leaf 정체 +2. 모듈 `CLAUDE.md` — 규칙과 계약 표 +3. 모듈 `README.md` — 결정의 근거 +4. `docs/jpa/repository-adaptation.md` — 왜 패키지인가 +5. `api/**` — 계약층 (여기 먼저 읽어야 나머지가 읽힌다) +6. `transaction/**` — 제일 밀도 높음 +7. `postgresql/**` — 벤더 +8. `src/main/resources/db/migration/**` — 실제 스키마 +9. `postgresql/{idempotency,outbox,inbox}` — V2 상태 기계 +10. `app-bootstrap/.../autoconfigure/{jpa,persistencejpa}` — 조립 +11. `build.gradle` + `config/jpa/*` + `gradle/jpa-evidence.gradle` — 검증 체계 +12. `src/testkit/**`, `src/postgresqlIntegrationTest/**` — 무엇이 실제로 증명되는가 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-architecture-gates.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-architecture-gates.log new file mode 100644 index 00000000..fd25e8bf --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-architecture-gates.log @@ -0,0 +1,41 @@ +$ ./gradlew verifyCleanArchitectureDependencies verifyEnvKeys verifyRuntimeModuleMembership verifyPublicPathSnapshot verifyDocumentedLeafCount --console=plain +run-at: 2026-08-20T01:59:14Z +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :verifyCleanArchitectureDependencies +> Task :shared-contract:compileJava UP-TO-DATE +> Task :shared-contract:processResources UP-TO-DATE +> Task :shared-contract:classes UP-TO-DATE +> Task :shared-contract:jar UP-TO-DATE +> Task :application-core:compileJava UP-TO-DATE +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes UP-TO-DATE +> Task :application-core:jar UP-TO-DATE +> Task :adapter:outbound:support:compileJava UP-TO-DATE +> Task :adapter:outbound:support:processResources NO-SOURCE +> Task :adapter:outbound:support:classes UP-TO-DATE +> Task :adapter:outbound:support:jar UP-TO-DATE +> Task :adapter:outbound:cache-redis:compileJava UP-TO-DATE + +> Task :verifyEnvKeys +verifyEnvKeys: OK — 341 env keys, 6 required placeholders covered, 225 application APP_ references registered, 61 typed properties registered, 338 rows with a consumer or a deprecation. + +> Task :verifyRuntimeModuleMembership +verifyRuntimeModuleMembership: 2 runtime composition(s) match the registry + +> Task :verifyPublicPathSnapshot +verifyPublicPathSnapshot: OK — committed public paths are unchanged. + +> Task :verifyDocumentedLeafCount + +BUILD SUCCESSFUL in 8s +21 actionable tasks: 5 executed, 16 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-check.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-check.log new file mode 100644 index 00000000..da9925c1 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-check.log @@ -0,0 +1,901 @@ +$ ./gradlew check --warning-mode=fail --no-daemon --console=plain (re-run after the SpotBugs fix) +run-at: 2026-08-20T01:53:16Z +To honour the JVM settings for this build a single-use Daemon process will be forked. For more on this, please refer to https://docs.gradle.org/9.0.0/userguide/gradle_daemon.html#sec:disabling_the_daemon in the Gradle documentation. +Daemon will be stopped at the end of the build +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :verifyCleanArchitectureDependencies + +> Task :verifyConfigurationPropertiesProcessor +verifyConfigurationPropertiesProcessor: OK — all 44 registered leaves have exact configuration-processor parity. + +> Task :verifyDocumentedLeafCount +> Task :shared-contract:compileJava UP-TO-DATE +> Task :shared-contract:processResources UP-TO-DATE +> Task :shared-contract:classes UP-TO-DATE +> Task :shared-contract:jar UP-TO-DATE +> Task :application-core:compileJava UP-TO-DATE +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes UP-TO-DATE +> Task :application-core:jar UP-TO-DATE +> Task :adapter:outbound:support:compileJava UP-TO-DATE +> Task :adapter:outbound:support:processResources NO-SOURCE +> Task :adapter:outbound:support:classes UP-TO-DATE +> Task :adapter:outbound:support:jar UP-TO-DATE +> Task :adapter:outbound:cache-redis:compileJava UP-TO-DATE + +> Task :verifyEnvKeys +verifyEnvKeys: OK — 341 env keys, 6 required placeholders covered, 225 application APP_ references registered, 61 typed properties registered, 338 rows with a consumer or a deprecation. + +> Task :verifyJpaReadinessRegistryContract +verifyJpaReadinessRegistryContract: OK — unknown card, duplicate task, missing prerequisite, cycle, duplicate migration ownership, missing selected task, and malformed evidence ownership all fail closed. + +> Task :verifyJpaReadinessRegistry +verifyJpaReadinessRegistry: OK — 17 exact cards, 9 owned migration streams, acyclic prerequisites, unique tasks/locations/history tables, and selected task existence verified. + +> Task :verifyNoIgnoredSourcePackages +verifyNoIgnoredSourcePackages: OK — 5164 Java sources are all committable. + +> Task :verifyNoStaleTraceableJars +verifyNoStaleTraceableJars: OK — no stale traceable JARs in build/libs. + +> Task :verifyNotificationApiSurface +verifyNotificationApiSurface: OK — 586 public types, unchanged. + +> Task :verifyNotificationConfiguration +verifyNotificationConfiguration: OK — 42 platform settings, bound, documented and registered. + +> Task :verifyNotificationEvidence +verifyNotificationEvidence: OK — 4 claims proven, every grade in support-matrix.md is backed. + +> Task :verifyOneTypePerFile +verifyOneTypePerFile: OK — one public top-level type per file, names match. + +> Task :verifyQuarantineSunset +verifyQuarantineSunset: OK — 0 registered, 0 tagged (14-day sunset enforced). + +> Task :verifyReadmeCommands +verifyReadmeCommands: OK — executable commands in /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/README.md resolve. + +> Task :verifyRunbookReferences + +> Task :verifyRuntimeModuleMembership +verifyRuntimeModuleMembership: 2 runtime composition(s) match the registry + +> Task :verifySpotBugsAnalysisFailureContract +verifySpotBugsAnalysisFailureContract: OK — clean and advisory bug-only reports pass; missing classes and analysis errors fail closed. + +> Task :verifyTrivyignore +verifyTrivyignore: OK — 0 suppression(s) validated (reason + bounded, non-expired expiry). + +> Task :domain-core:compileJava UP-TO-DATE +> Task :domain-core:processResources NO-SOURCE +> Task :domain-core:classes UP-TO-DATE +> Task :domain-core:jar UP-TO-DATE +> Task :messaging:messaging-core-api:compileJava UP-TO-DATE +> Task :messaging:messaging-observability:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-api:compileJava UP-TO-DATE +> Task :messaging:messaging-policy:compileJava UP-TO-DATE +> Task :messaging:messaging-reliability-api:compileJava UP-TO-DATE +> Task :messaging:messaging-security:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-api:compileJava UP-TO-DATE +> Task :messaging:messaging-transport-spi:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-runtime:compileJava UP-TO-DATE +> Task :messaging:messaging-claim-check:compileJava UP-TO-DATE +> Task :messaging:messaging-cloudevents:compileJava UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:compileJava UP-TO-DATE +> Task :messaging:messaging-kafka:compileJava UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:compileJava UP-TO-DATE +> Task :messaging:messaging-rabbit:compileJava UP-TO-DATE +> Task :messaging:messaging-runtime-core:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-json:compileJava UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:compileJava UP-TO-DATE +> Task :adapter:inbound:graphql:compileJava UP-TO-DATE +> Task :adapter:inbound:graphql:processResources UP-TO-DATE +> Task :adapter:inbound:graphql:classes UP-TO-DATE +> Task :adapter:inbound:graphql:jar UP-TO-DATE +> Task :adapter:inbound:web:compileJava UP-TO-DATE +> Task :adapter:inbound:web:processResources NO-SOURCE +> Task :adapter:inbound:web:classes UP-TO-DATE +> Task :adapter:inbound:web:jar UP-TO-DATE +> Task :adapter:outbound:cache-redis:processResources UP-TO-DATE +> Task :adapter:outbound:cache-redis:classes UP-TO-DATE +> Task :adapter:outbound:cache-redis:jar UP-TO-DATE +> Task :adapter:outbound:fileserver:compileJava UP-TO-DATE +> Task :adapter:outbound:fileserver:processResources NO-SOURCE +> Task :adapter:outbound:fileserver:classes UP-TO-DATE +> Task :adapter:outbound:fileserver:jar UP-TO-DATE +> Task :adapter:outbound:httpclient:compileJava UP-TO-DATE +> Task :adapter:outbound:httpclient:processResources NO-SOURCE +> Task :adapter:outbound:httpclient:classes UP-TO-DATE +> Task :adapter:outbound:httpclient:jar UP-TO-DATE +> Task :adapter:outbound:identifier:compileJava UP-TO-DATE +> Task :adapter:outbound:identifier:compileGroovy NO-SOURCE +> Task :adapter:outbound:identifier:processResources NO-SOURCE +> Task :adapter:outbound:identifier:classes UP-TO-DATE +> Task :adapter:outbound:identifier:jar UP-TO-DATE +> Task :adapter:outbound:messaging:compileJava UP-TO-DATE +> Task :adapter:outbound:messaging:processResources UP-TO-DATE +> Task :adapter:outbound:messaging:classes UP-TO-DATE +> Task :adapter:outbound:messaging:jar UP-TO-DATE +> Task :adapter:outbound:notification:compileJava UP-TO-DATE +> Task :adapter:outbound:notification:processResources NO-SOURCE +> Task :adapter:outbound:notification:classes UP-TO-DATE +> Task :adapter:outbound:notification:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jar UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:jar UP-TO-DATE +> Task :app-bootstrap:compileJava UP-TO-DATE +> Task :app-bootstrap:processResources UP-TO-DATE +> Task :app-bootstrap:classes UP-TO-DATE +> Task :adapter:inbound:grpc:compileJava UP-TO-DATE +> Task :adapter:inbound:grpc:processResources NO-SOURCE +> Task :adapter:inbound:grpc:classes UP-TO-DATE +> Task :adapter:inbound:grpc:jar UP-TO-DATE +> Task :adapter:inbound:websocket:compileJava UP-TO-DATE +> Task :adapter:inbound:websocket:processResources NO-SOURCE +> Task :adapter:inbound:websocket:classes UP-TO-DATE +> Task :adapter:inbound:websocket:jar UP-TO-DATE +> Task :app-bootstrap:compileConditionalTransportTestJava UP-TO-DATE +> Task :app-bootstrap:processConditionalTransportTestResources NO-SOURCE +> Task :app-bootstrap:conditionalTransportTestClasses UP-TO-DATE +> Task :app-bootstrap:checkstyleConditionalTransportTest UP-TO-DATE +> Task :app-bootstrap:compileFunctionalTestJava UP-TO-DATE +> Task :app-bootstrap:processFunctionalTestResources NO-SOURCE +> Task :app-bootstrap:functionalTestClasses UP-TO-DATE +> Task :app-bootstrap:checkstyleFunctionalTest UP-TO-DATE +> Task :app-bootstrap:checkstyleMain UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:testkitJar UP-TO-DATE +> Task :app-bootstrap:compileSampleOffTestJava UP-TO-DATE +> Task :app-bootstrap:processSampleOffTestResources UP-TO-DATE +> Task :app-bootstrap:sampleOffTestClasses UP-TO-DATE +> Task :app-bootstrap:checkstyleSampleOffTest UP-TO-DATE +> Task :sample-portfolio:compileJava UP-TO-DATE +> Task :sample-portfolio:processResources UP-TO-DATE +> Task :sample-portfolio:classes UP-TO-DATE +> Task :sample-portfolio:jar UP-TO-DATE +> Task :app-bootstrap:compileTestJava UP-TO-DATE +> Task :app-bootstrap:processTestResources UP-TO-DATE +> Task :app-bootstrap:testClasses UP-TO-DATE +> Task :app-bootstrap:checkstyleTest UP-TO-DATE +> Task :app-bootstrap:runtimeClasspathManifest UP-TO-DATE +> Task :messaging:messaging-admin-api:processResources NO-SOURCE +> Task :messaging:messaging-admin-api:classes UP-TO-DATE +> Task :messaging:messaging-admin-api:jar UP-TO-DATE +> Task :messaging:messaging-admin-runtime:processResources NO-SOURCE +> Task :messaging:messaging-admin-runtime:classes UP-TO-DATE +> Task :messaging:messaging-admin-runtime:jar UP-TO-DATE +> Task :messaging:messaging-claim-check:processResources NO-SOURCE +> Task :messaging:messaging-claim-check:classes UP-TO-DATE +> Task :messaging:messaging-claim-check:jar UP-TO-DATE +> Task :messaging:messaging-cloudevents:processResources NO-SOURCE +> Task :messaging:messaging-cloudevents:classes UP-TO-DATE +> Task :messaging:messaging-cloudevents:jar UP-TO-DATE +> Task :messaging:messaging-core-api:processResources NO-SOURCE +> Task :messaging:messaging-core-api:classes UP-TO-DATE +> Task :messaging:messaging-core-api:jar UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:processResources UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:classes UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:jar UP-TO-DATE +> Task :messaging:messaging-kafka:processResources NO-SOURCE +> Task :messaging:messaging-kafka:classes UP-TO-DATE +> Task :messaging:messaging-kafka:jar UP-TO-DATE +> Task :messaging:messaging-observability:processResources NO-SOURCE +> Task :messaging:messaging-observability:classes UP-TO-DATE +> Task :messaging:messaging-observability:jar UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:processResources UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:classes UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:jar UP-TO-DATE +> Task :messaging:messaging-policy:processResources NO-SOURCE +> Task :messaging:messaging-policy:classes UP-TO-DATE +> Task :messaging:messaging-policy:jar UP-TO-DATE +> Task :messaging:messaging-rabbit:processResources NO-SOURCE +> Task :messaging:messaging-rabbit:classes UP-TO-DATE +> Task :messaging:messaging-rabbit:jar UP-TO-DATE +> Task :messaging:messaging-reliability-api:processResources NO-SOURCE +> Task :messaging:messaging-reliability-api:classes UP-TO-DATE +> Task :messaging:messaging-reliability-api:jar UP-TO-DATE +> Task :messaging:messaging-runtime-core:processResources NO-SOURCE +> Task :messaging:messaging-runtime-core:classes UP-TO-DATE +> Task :messaging:messaging-runtime-core:jar UP-TO-DATE +> Task :messaging:messaging-schema-api:processResources NO-SOURCE +> Task :messaging:messaging-schema-api:classes UP-TO-DATE +> Task :messaging:messaging-schema-api:jar UP-TO-DATE +> Task :messaging:messaging-schema-json:processResources NO-SOURCE +> Task :messaging:messaging-schema-json:classes UP-TO-DATE +> Task :messaging:messaging-schema-json:jar UP-TO-DATE +> Task :messaging:messaging-security:processResources NO-SOURCE +> Task :messaging:messaging-security:classes UP-TO-DATE +> Task :messaging:messaging-security:jar UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:processResources UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:classes UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:jar UP-TO-DATE +> Task :messaging:messaging-transport-spi:processResources NO-SOURCE +> Task :messaging:messaging-transport-spi:classes UP-TO-DATE +> Task :messaging:messaging-transport-spi:jar UP-TO-DATE +> Task :adapter:outbound:objectstorage:compileJava UP-TO-DATE +> Task :adapter:outbound:objectstorage:processResources NO-SOURCE +> Task :adapter:outbound:objectstorage:classes UP-TO-DATE +> Task :adapter:outbound:objectstorage:jar UP-TO-DATE +> Task :app-bootstrap:test UP-TO-DATE +> Task :app-bootstrap:functionalTest UP-TO-DATE +> Task :app-bootstrap:spotbugsConditionalTransportTest UP-TO-DATE +> Task :app-bootstrap:spotbugsFunctionalTest UP-TO-DATE +> Task :app-bootstrap:spotbugsMain UP-TO-DATE +> Task :app-bootstrap:spotbugsSampleOffTest UP-TO-DATE +> Task :app-bootstrap:spotbugsTest UP-TO-DATE +> Task :app-bootstrap:spotlessJava UP-TO-DATE +> Task :app-bootstrap:spotlessJavaCheck UP-TO-DATE +> Task :app-bootstrap:spotlessCheck UP-TO-DATE +> Task :app-bootstrap:check + +> Task :verifyApplicationCoreDependencyPurity +verifyApplicationCoreDependencyPurity: OK — application-core production declarations are project-only and application classpaths contain no Spring/logging/metrics frameworks. + +> Task :application-core:checkstyleMain UP-TO-DATE +> Task :application-core:compileTestJava UP-TO-DATE +> Task :application-core:processTestResources NO-SOURCE +> Task :application-core:testClasses UP-TO-DATE +> Task :application-core:checkstyleTest UP-TO-DATE +> Task :application-core:spotbugsMain UP-TO-DATE +> Task :application-core:spotbugsTest UP-TO-DATE +> Task :application-core:spotlessJava UP-TO-DATE +> Task :application-core:spotlessJavaCheck UP-TO-DATE +> Task :application-core:spotlessCheck UP-TO-DATE +> Task :application-core:test UP-TO-DATE +> Task :application-core:check +> Task :domain-core:checkstyleMain UP-TO-DATE +> Task :domain-core:compileTestJava NO-SOURCE +> Task :domain-core:processTestResources NO-SOURCE +> Task :domain-core:testClasses UP-TO-DATE +> Task :domain-core:checkstyleTest NO-SOURCE +> Task :domain-core:spotbugsMain UP-TO-DATE +> Task :domain-core:spotbugsTest NO-SOURCE +> Task :domain-core:spotlessJava UP-TO-DATE +> Task :domain-core:spotlessJavaCheck UP-TO-DATE +> Task :domain-core:spotlessCheck UP-TO-DATE +> Task :domain-core:test NO-SOURCE +> Task :domain-core:check +> Task :sample-portfolio:checkstyleMain UP-TO-DATE +> Task :sample-portfolio:compileTestJava UP-TO-DATE +> Task :sample-portfolio:processTestResources UP-TO-DATE +> Task :sample-portfolio:testClasses UP-TO-DATE +> Task :sample-portfolio:compilePosterImageMigrationTestJava UP-TO-DATE +> Task :sample-portfolio:processPosterImageMigrationTestResources NO-SOURCE +> Task :sample-portfolio:posterImageMigrationTestClasses UP-TO-DATE +> Task :sample-portfolio:checkstylePosterImageMigrationTest UP-TO-DATE +> Task :sample-portfolio:checkstyleTest UP-TO-DATE +> Task :sample-portfolio:spotbugsMain UP-TO-DATE +> Task :sample-portfolio:spotbugsPosterImageMigrationTest UP-TO-DATE +> Task :sample-portfolio:spotbugsTest UP-TO-DATE +> Task :sample-portfolio:spotlessJava UP-TO-DATE +> Task :sample-portfolio:spotlessJavaCheck UP-TO-DATE +> Task :sample-portfolio:spotlessCheck UP-TO-DATE +> Task :sample-portfolio:test UP-TO-DATE +> Task :sample-portfolio:check +> Task :shared-contract:compileEdgeRateLimitContractTestJava UP-TO-DATE +> Task :shared-contract:processEdgeRateLimitContractTestResources NO-SOURCE +> Task :shared-contract:edgeRateLimitContractTestClasses UP-TO-DATE +> Task :shared-contract:checkstyleEdgeRateLimitContractTest UP-TO-DATE +> Task :shared-contract:checkstyleMain UP-TO-DATE +> Task :shared-contract:compileTestJava UP-TO-DATE +> Task :shared-contract:processTestResources NO-SOURCE +> Task :shared-contract:testClasses UP-TO-DATE +> Task :shared-contract:checkstyleTest UP-TO-DATE +> Task :shared-contract:spotbugsEdgeRateLimitContractTest UP-TO-DATE +> Task :shared-contract:spotbugsMain UP-TO-DATE +> Task :shared-contract:spotbugsTest UP-TO-DATE +> Task :shared-contract:spotlessJava UP-TO-DATE +> Task :shared-contract:spotlessJavaCheck UP-TO-DATE +> Task :shared-contract:spotlessCheck UP-TO-DATE +> Task :shared-contract:test UP-TO-DATE +> Task :shared-contract:check +> Task :messaging:messaging-admin-api:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-admin-api:compileTestJava UP-TO-DATE +> Task :messaging:messaging-admin-api:processTestResources NO-SOURCE +> Task :messaging:messaging-admin-api:testClasses UP-TO-DATE +> Task :messaging:messaging-admin-api:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-admin-api:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-admin-api:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-admin-api:spotlessJava UP-TO-DATE +> Task :messaging:messaging-admin-api:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-admin-api:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-admin-api:test UP-TO-DATE +> Task :messaging:messaging-admin-api:check +> Task :messaging:messaging-admin-runtime:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-admin-runtime:compileTestJava UP-TO-DATE +> Task :messaging:messaging-admin-runtime:processTestResources NO-SOURCE +> Task :messaging:messaging-admin-runtime:testClasses UP-TO-DATE +> Task :messaging:messaging-admin-runtime:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-admin-runtime:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-admin-runtime:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-admin-runtime:spotlessJava UP-TO-DATE +> Task :messaging:messaging-admin-runtime:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-admin-runtime:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-admin-runtime:test UP-TO-DATE +> Task :messaging:messaging-admin-runtime:check +> Task :messaging:messaging-claim-check:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-claim-check:compileTestJava UP-TO-DATE +> Task :messaging:messaging-claim-check:processTestResources NO-SOURCE +> Task :messaging:messaging-claim-check:testClasses UP-TO-DATE +> Task :messaging:messaging-claim-check:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-claim-check:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-claim-check:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-claim-check:spotlessJava UP-TO-DATE +> Task :messaging:messaging-claim-check:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-claim-check:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-claim-check:test UP-TO-DATE +> Task :messaging:messaging-claim-check:check +> Task :messaging:messaging-cloudevents:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-cloudevents:compileTestJava UP-TO-DATE +> Task :messaging:messaging-cloudevents:processTestResources NO-SOURCE +> Task :messaging:messaging-cloudevents:testClasses UP-TO-DATE +> Task :messaging:messaging-cloudevents:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-cloudevents:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-cloudevents:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-cloudevents:spotlessJava UP-TO-DATE +> Task :messaging:messaging-cloudevents:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-cloudevents:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-cloudevents:test UP-TO-DATE +> Task :messaging:messaging-cloudevents:check +> Task :messaging:messaging-core-api:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-core-api:compileTestJava UP-TO-DATE +> Task :messaging:messaging-core-api:processTestResources NO-SOURCE +> Task :messaging:messaging-core-api:testClasses UP-TO-DATE +> Task :messaging:messaging-core-api:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-core-api:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-core-api:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-core-api:spotlessJava UP-TO-DATE +> Task :messaging:messaging-core-api:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-core-api:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-core-api:test UP-TO-DATE +> Task :messaging:messaging-core-api:check +> Task :messaging:messaging-inbox-jdbc-postgresql:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-testkit:compileJava UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:compileTestJava UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:processTestResources NO-SOURCE +> Task :messaging:messaging-inbox-jdbc-postgresql:testClasses UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-testkit:processResources UP-TO-DATE +> Task :messaging:messaging-testkit:classes UP-TO-DATE +> Task :messaging:messaging-testkit:jar UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:spotlessJava UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:test UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:check +> Task :messaging:messaging-kafka:compileTestJava UP-TO-DATE +> Task :messaging:messaging-kafka:processTestResources NO-SOURCE +> Task :messaging:messaging-kafka:testClasses UP-TO-DATE +> Task :messaging:messaging-kafka:compileJmhJava UP-TO-DATE +> Task :messaging:messaging-kafka:processJmhResources NO-SOURCE +> Task :messaging:messaging-kafka:jmhClasses UP-TO-DATE +> Task :messaging:messaging-kafka:checkstyleJmh SKIPPED +> Task :messaging:messaging-kafka:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-kafka:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-kafka:spotbugsJmh SKIPPED +> Task :messaging:messaging-kafka:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-kafka:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-kafka:spotlessJava UP-TO-DATE +> Task :messaging:messaging-kafka:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-kafka:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-kafka:test UP-TO-DATE +> Task :messaging:messaging-kafka:check +> Task :messaging:messaging-kafka-share-experimental:compileJava UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:processResources NO-SOURCE +> Task :messaging:messaging-kafka-share-experimental:classes UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:compileTestJava UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:processTestResources NO-SOURCE +> Task :messaging:messaging-kafka-share-experimental:testClasses UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:spotlessJava UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:test UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:check +> Task :messaging:messaging-nats-experimental:compileJava UP-TO-DATE +> Task :messaging:messaging-nats-experimental:processResources NO-SOURCE +> Task :messaging:messaging-nats-experimental:classes UP-TO-DATE +> Task :messaging:messaging-nats-experimental:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-nats-experimental:compileTestJava UP-TO-DATE +> Task :messaging:messaging-nats-experimental:processTestResources NO-SOURCE +> Task :messaging:messaging-nats-experimental:testClasses UP-TO-DATE +> Task :messaging:messaging-nats-experimental:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-nats-experimental:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-nats-experimental:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-nats-experimental:spotlessJava UP-TO-DATE +> Task :messaging:messaging-nats-experimental:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-nats-experimental:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-nats-experimental:test UP-TO-DATE +> Task :messaging:messaging-nats-experimental:check +> Task :messaging:messaging-observability:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-observability:compileTestJava UP-TO-DATE +> Task :messaging:messaging-observability:processTestResources NO-SOURCE +> Task :messaging:messaging-observability:testClasses UP-TO-DATE +> Task :messaging:messaging-observability:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-observability:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-observability:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-observability:spotlessJava UP-TO-DATE +> Task :messaging:messaging-observability:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-observability:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-observability:test UP-TO-DATE +> Task :messaging:messaging-observability:check +> Task :messaging:messaging-outbox-jdbc-postgresql:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:compileTestJava UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:processTestResources NO-SOURCE +> Task :messaging:messaging-outbox-jdbc-postgresql:testClasses UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:spotlessJava UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:test UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:check +> Task :messaging:messaging-policy:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-policy:compileTestJava UP-TO-DATE +> Task :messaging:messaging-policy:processTestResources NO-SOURCE +> Task :messaging:messaging-policy:testClasses UP-TO-DATE +> Task :messaging:messaging-policy:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-policy:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-policy:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-policy:spotlessJava UP-TO-DATE +> Task :messaging:messaging-policy:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-policy:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-policy:test UP-TO-DATE +> Task :messaging:messaging-policy:check +> Task :messaging:messaging-pulsar-experimental:compileJava UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:processResources NO-SOURCE +> Task :messaging:messaging-pulsar-experimental:classes UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:compileTestJava UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:processTestResources NO-SOURCE +> Task :messaging:messaging-pulsar-experimental:testClasses UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:spotlessJava UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:test UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:check +> Task :messaging:messaging-rabbit:compileTestJava UP-TO-DATE +> Task :messaging:messaging-rabbit:processTestResources NO-SOURCE +> Task :messaging:messaging-rabbit:testClasses UP-TO-DATE +> Task :messaging:messaging-rabbit:compileJmhJava UP-TO-DATE +> Task :messaging:messaging-rabbit:processJmhResources NO-SOURCE +> Task :messaging:messaging-rabbit:jmhClasses UP-TO-DATE +> Task :messaging:messaging-rabbit:checkstyleJmh SKIPPED +> Task :messaging:messaging-rabbit:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-rabbit:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-rabbit:spotbugsJmh SKIPPED +> Task :messaging:messaging-rabbit:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-rabbit:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-rabbit:spotlessJava UP-TO-DATE +> Task :messaging:messaging-rabbit:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-rabbit:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-rabbit:test UP-TO-DATE +> Task :messaging:messaging-rabbit:check +> Task :messaging:messaging-reliability-api:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-reliability-api:compileTestJava NO-SOURCE +> Task :messaging:messaging-reliability-api:processTestResources NO-SOURCE +> Task :messaging:messaging-reliability-api:testClasses UP-TO-DATE +> Task :messaging:messaging-reliability-api:checkstyleTest NO-SOURCE +> Task :messaging:messaging-reliability-api:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-reliability-api:spotbugsTest NO-SOURCE +> Task :messaging:messaging-reliability-api:spotlessJava UP-TO-DATE +> Task :messaging:messaging-reliability-api:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-reliability-api:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-reliability-api:test NO-SOURCE +> Task :messaging:messaging-reliability-api:check +> Task :messaging:messaging-runtime-core:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-runtime-core:compileTestJava UP-TO-DATE +> Task :messaging:messaging-runtime-core:processTestResources NO-SOURCE +> Task :messaging:messaging-runtime-core:testClasses UP-TO-DATE +> Task :messaging:messaging-runtime-core:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-runtime-core:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-runtime-core:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-runtime-core:spotlessJava UP-TO-DATE +> Task :messaging:messaging-runtime-core:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-runtime-core:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-runtime-core:test UP-TO-DATE +> Task :messaging:messaging-runtime-core:check +> Task :messaging:messaging-schema-api:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-schema-api:compileTestJava UP-TO-DATE +> Task :messaging:messaging-schema-api:processTestResources NO-SOURCE +> Task :messaging:messaging-schema-api:testClasses UP-TO-DATE +> Task :messaging:messaging-schema-api:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-schema-api:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-schema-api:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-schema-api:spotlessJava UP-TO-DATE +> Task :messaging:messaging-schema-api:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-schema-api:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-schema-api:test UP-TO-DATE +> Task :messaging:messaging-schema-api:check +> Task :messaging:messaging-schema-avro:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-avro:processResources NO-SOURCE +> Task :messaging:messaging-schema-avro:classes UP-TO-DATE +> Task :messaging:messaging-schema-avro:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-schema-avro:compileTestJava UP-TO-DATE +> Task :messaging:messaging-schema-avro:processTestResources UP-TO-DATE +> Task :messaging:messaging-schema-avro:testClasses UP-TO-DATE +> Task :messaging:messaging-schema-avro:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-schema-avro:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-schema-avro:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-schema-avro:spotlessJava UP-TO-DATE +> Task :messaging:messaging-schema-avro:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-schema-avro:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-schema-avro:test UP-TO-DATE +> Task :messaging:messaging-schema-avro:check +> Task :messaging:messaging-schema-json:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-schema-json:compileTestJava UP-TO-DATE +> Task :messaging:messaging-schema-json:processTestResources NO-SOURCE +> Task :messaging:messaging-schema-json:testClasses UP-TO-DATE +> Task :messaging:messaging-schema-json:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-schema-json:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-schema-json:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-schema-json:spotlessJava UP-TO-DATE +> Task :messaging:messaging-schema-json:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-schema-json:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-schema-json:test UP-TO-DATE +> Task :messaging:messaging-schema-json:check +> Task :messaging:messaging-schema-protobuf:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:processResources NO-SOURCE +> Task :messaging:messaging-schema-protobuf:classes UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:compileTestJava UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:processTestResources NO-SOURCE +> Task :messaging:messaging-schema-protobuf:testClasses UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:spotlessJava UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:test UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:check +> Task :messaging:messaging-security:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-security:compileTestJava UP-TO-DATE +> Task :messaging:messaging-security:processTestResources NO-SOURCE +> Task :messaging:messaging-security:testClasses UP-TO-DATE +> Task :messaging:messaging-security:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-security:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-security:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-security:spotlessJava UP-TO-DATE +> Task :messaging:messaging-security:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-security:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-security:test UP-TO-DATE +> Task :messaging:messaging-security:check +> Task :messaging:messaging-spring-boot-starter:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:compileTestJava UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:processTestResources UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:testClasses UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:spotlessJava UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:test UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:check +> Task :messaging:messaging-spring-cloud-stream-bridge:compileJava UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:processResources NO-SOURCE +> Task :messaging:messaging-spring-cloud-stream-bridge:classes UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:compileTestJava UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:processTestResources NO-SOURCE +> Task :messaging:messaging-spring-cloud-stream-bridge:testClasses UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:spotlessJava UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:test UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:check +> Task :messaging:messaging-testkit:compileTestJava UP-TO-DATE +> Task :messaging:messaging-testkit:processTestResources NO-SOURCE +> Task :messaging:messaging-testkit:testClasses UP-TO-DATE +> Task :messaging:messaging-testkit:compileJmhJava UP-TO-DATE +> Task :messaging:messaging-testkit:processJmhResources NO-SOURCE +> Task :messaging:messaging-testkit:jmhClasses UP-TO-DATE +> Task :messaging:messaging-testkit:checkstyleJmh SKIPPED +> Task :messaging:messaging-testkit:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-testkit:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-testkit:spotbugsJmh SKIPPED +> Task :messaging:messaging-testkit:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-testkit:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-testkit:spotlessJava UP-TO-DATE +> Task :messaging:messaging-testkit:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-testkit:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-testkit:test UP-TO-DATE +> Task :messaging:messaging-testkit:check +> Task :messaging:messaging-transport-spi:checkstyleMain UP-TO-DATE +> Task :messaging:messaging-transport-spi:compileTestJava UP-TO-DATE +> Task :messaging:messaging-transport-spi:processTestResources NO-SOURCE +> Task :messaging:messaging-transport-spi:testClasses UP-TO-DATE +> Task :messaging:messaging-transport-spi:checkstyleTest UP-TO-DATE +> Task :messaging:messaging-transport-spi:spotbugsMain UP-TO-DATE +> Task :messaging:messaging-transport-spi:spotbugsTest UP-TO-DATE +> Task :messaging:messaging-transport-spi:spotlessJava UP-TO-DATE +> Task :messaging:messaging-transport-spi:spotlessJavaCheck UP-TO-DATE +> Task :messaging:messaging-transport-spi:spotlessCheck UP-TO-DATE +> Task :messaging:messaging-transport-spi:test UP-TO-DATE +> Task :messaging:messaging-transport-spi:check +> Task :adapter:inbound:graphql:checkstyleMain UP-TO-DATE +> Task :adapter:inbound:graphql:compileTestFixturesJava UP-TO-DATE +> Task :adapter:inbound:graphql:compileTestJava UP-TO-DATE +> Task :adapter:inbound:graphql:processTestResources UP-TO-DATE +> Task :adapter:inbound:graphql:testClasses UP-TO-DATE +> Task :adapter:inbound:graphql:processTestFixturesResources NO-SOURCE +> Task :adapter:inbound:graphql:testFixturesClasses UP-TO-DATE +> Task :adapter:inbound:graphql:checkstyleTestFixtures UP-TO-DATE +> Task :adapter:inbound:graphql:spotbugsMain UP-TO-DATE +> Task :adapter:inbound:graphql:testFixturesJar UP-TO-DATE +> Task :adapter:inbound:graphql:spotbugsTest UP-TO-DATE +> Task :adapter:inbound:graphql:spotbugsTestFixtures UP-TO-DATE +> Task :adapter:inbound:graphql:spotlessJava UP-TO-DATE +> Task :adapter:inbound:graphql:spotlessJavaCheck UP-TO-DATE +> Task :adapter:inbound:graphql:spotlessCheck UP-TO-DATE +> Task :adapter:inbound:graphql:test UP-TO-DATE + +> Task :adapter:inbound:graphql:verifyGraphQlApiSurface +verifyGraphQlApiSurface: OK — the committed public API surface is unchanged. + +> Task :adapter:inbound:graphql:verifyGraphQlProductionJar UP-TO-DATE +> Task :adapter:inbound:grpc:checkstyleMain UP-TO-DATE +> Task :adapter:inbound:grpc:compileTestJava UP-TO-DATE +> Task :adapter:inbound:grpc:processTestResources NO-SOURCE +> Task :adapter:inbound:grpc:testClasses UP-TO-DATE +> Task :adapter:inbound:grpc:checkstyleTest UP-TO-DATE +> Task :adapter:inbound:grpc:spotbugsMain UP-TO-DATE +> Task :adapter:inbound:grpc:spotbugsTest UP-TO-DATE +> Task :adapter:inbound:grpc:spotlessJava UP-TO-DATE +> Task :adapter:inbound:grpc:spotlessJavaCheck UP-TO-DATE +> Task :adapter:inbound:grpc:spotlessCheck UP-TO-DATE +> Task :adapter:inbound:grpc:test UP-TO-DATE +> Task :adapter:inbound:grpc:check +> Task :adapter:inbound:web:checkstyleMain UP-TO-DATE +> Task :adapter:inbound:web:compileTestJava UP-TO-DATE +> Task :adapter:inbound:web:processTestResources NO-SOURCE +> Task :adapter:inbound:web:testClasses UP-TO-DATE +> Task :adapter:inbound:web:checkstyleTest UP-TO-DATE +> Task :adapter:inbound:web:spotbugsMain UP-TO-DATE +> Task :adapter:inbound:web:spotbugsTest UP-TO-DATE +> Task :adapter:inbound:web:spotlessJava UP-TO-DATE +> Task :adapter:inbound:web:spotlessJavaCheck UP-TO-DATE +> Task :adapter:inbound:web:spotlessCheck UP-TO-DATE +> Task :adapter:inbound:web:test UP-TO-DATE +> Task :adapter:inbound:graphql:checkstyleTest +> Task :adapter:inbound:web:webSecurityBoundaryTest +> Task :adapter:inbound:graphql:check +> Task :adapter:inbound:web:check +> Task :adapter:inbound:websocket:checkstyleMain UP-TO-DATE +> Task :adapter:inbound:websocket:compileTestJava UP-TO-DATE +> Task :adapter:inbound:websocket:processTestResources NO-SOURCE +> Task :adapter:inbound:websocket:testClasses UP-TO-DATE +> Task :adapter:inbound:websocket:checkstyleTest UP-TO-DATE +> Task :adapter:inbound:websocket:spotbugsMain UP-TO-DATE +> Task :adapter:inbound:websocket:spotbugsTest UP-TO-DATE +> Task :adapter:inbound:websocket:spotlessJava UP-TO-DATE +> Task :adapter:inbound:websocket:spotlessJavaCheck UP-TO-DATE +> Task :adapter:inbound:websocket:spotlessCheck UP-TO-DATE +> Task :adapter:inbound:websocket:test UP-TO-DATE +> Task :adapter:inbound:websocket:check +> Task :adapter:outbound:cache-redis:checkstyleMain UP-TO-DATE +> Task :adapter:outbound:cache-redis:compileTestJava UP-TO-DATE +> Task :adapter:outbound:cache-redis:processTestResources UP-TO-DATE +> Task :adapter:outbound:cache-redis:testClasses UP-TO-DATE +> Task :adapter:outbound:cache-redis:checkstyleTest UP-TO-DATE +> Task :adapter:outbound:cache-redis:spotbugsMain UP-TO-DATE +> Task :adapter:outbound:cache-redis:spotbugsTest UP-TO-DATE +> Task :adapter:outbound:cache-redis:spotlessJava UP-TO-DATE +> Task :adapter:outbound:cache-redis:spotlessJavaCheck UP-TO-DATE +> Task :adapter:outbound:cache-redis:spotlessCheck UP-TO-DATE +> Task :adapter:outbound:cache-redis:test UP-TO-DATE +> Task :adapter:outbound:cache-redis:check +> Task :adapter:outbound:fileserver:compileTestJava UP-TO-DATE +> Task :adapter:outbound:fileserver:processTestResources NO-SOURCE +> Task :adapter:outbound:fileserver:testClasses UP-TO-DATE +> Task :adapter:outbound:fileserver:checkstyleMain +> Task :adapter:outbound:fileserver:spotlessJava +> Task :adapter:outbound:fileserver:spotlessJavaCheck +> Task :adapter:outbound:fileserver:spotlessCheck +> Task :adapter:outbound:fileserver:test UP-TO-DATE +> Task :adapter:outbound:httpclient:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:httpclient:processTestkitResources NO-SOURCE +> Task :adapter:outbound:httpclient:testkitClasses UP-TO-DATE +> Task :adapter:outbound:fileserver:checkstyleTest +> Task :adapter:outbound:httpclient:compileHttpClientPerformanceTestJava +> Task :adapter:outbound:httpclient:processHttpClientPerformanceTestResources NO-SOURCE +> Task :adapter:outbound:httpclient:httpClientPerformanceTestClasses +> Task :adapter:outbound:httpclient:checkstyleHttpClientPerformanceTest +> Task :adapter:outbound:fileserver:spotbugsMain +> Task :adapter:outbound:fileserver:spotbugsTest +> Task :adapter:outbound:httpclient:compileJmhJava +> Task :adapter:outbound:httpclient:processJmhResources NO-SOURCE +> Task :adapter:outbound:httpclient:jmhClasses +> Task :adapter:outbound:httpclient:compileTestJava UP-TO-DATE +> Task :adapter:outbound:httpclient:processTestResources NO-SOURCE +> Task :adapter:outbound:httpclient:testClasses UP-TO-DATE +> Task :adapter:outbound:httpclient:checkstyleJmh + +> Task :adapter:outbound:httpclient:httpClientBlockHoundTest +OpenJDK 64-Bit Server VM warning: Option AllowRedefinitionToAddDeleteMethods was deprecated in version 13.0 and will likely be removed in a future release. + +> Task :adapter:outbound:httpclient:checkstyleMain +> Task :adapter:outbound:httpclient:checkstyleTestkit +> Task :adapter:outbound:httpclient:checkstyleTest +> Task :adapter:outbound:httpclient:httpClientSecurityTest +> Task :adapter:outbound:httpclient:httpClientStableContractTest +> Task :adapter:outbound:httpclient:spotbugsJmh SKIPPED +> Task :adapter:outbound:httpclient:spotlessJava +> Task :adapter:outbound:httpclient:spotlessJavaCheck +> Task :adapter:outbound:httpclient:spotlessCheck +> Task :adapter:outbound:httpclient:spring62ApiSurfaceScan +> Task :adapter:outbound:httpclient:test UP-TO-DATE +> Task :adapter:outbound:identifier:compileTestJava UP-TO-DATE +> Task :adapter:outbound:identifier:compileTestGroovy UP-TO-DATE +> Task :adapter:outbound:identifier:processTestResources NO-SOURCE +> Task :adapter:outbound:identifier:testClasses UP-TO-DATE +> Task :adapter:outbound:identifier:spotlessJava +> Task :adapter:outbound:httpclient:spotbugsHttpClientPerformanceTest +> Task :adapter:outbound:identifier:spotlessJavaCheck +> Task :adapter:outbound:identifier:spotlessCheck +> Task :adapter:outbound:identifier:test UP-TO-DATE +> Task :adapter:outbound:messaging:compileTestJava UP-TO-DATE +> Task :adapter:outbound:messaging:processTestResources UP-TO-DATE +> Task :adapter:outbound:messaging:testClasses UP-TO-DATE +> Task :adapter:outbound:identifier:checkstyleTest +> Task :adapter:outbound:identifier:checkstyleMain +> Task :adapter:outbound:messaging:spotlessJava +> Task :adapter:outbound:messaging:spotlessJavaCheck +> Task :adapter:outbound:messaging:spotlessCheck +> Task :adapter:outbound:messaging:test UP-TO-DATE +> Task :adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph +> Task :adapter:outbound:notification:compileTestJava UP-TO-DATE +> Task :adapter:outbound:notification:processTestResources UP-TO-DATE +> Task :adapter:outbound:notification:testClasses UP-TO-DATE +> Task :adapter:outbound:fileserver:check +> Task :adapter:outbound:messaging:checkstyleTest +> Task :adapter:outbound:messaging:checkstyleMain +> Task :adapter:outbound:httpclient:spotbugsMain +> Task :adapter:outbound:httpclient:spotbugsTest +> Task :adapter:outbound:httpclient:spotbugsTestkit +> Task :adapter:outbound:notification:checkstyleTest +> Task :adapter:outbound:notification:spotlessJava +> Task :adapter:outbound:notification:spotlessJavaCheck +> Task :adapter:outbound:notification:spotlessCheck +> Task :adapter:outbound:notification:test UP-TO-DATE +> Task :adapter:outbound:notification:verifyDependencyPolicy +> Task :adapter:outbound:objectstorage:compileTestJava UP-TO-DATE +> Task :adapter:outbound:objectstorage:processTestResources UP-TO-DATE +> Task :adapter:outbound:objectstorage:testClasses UP-TO-DATE +> Task :adapter:outbound:notification:checkstyleMain +> Task :adapter:outbound:objectstorage:compileObjectStorageAwsQualificationTestJava +> Task :adapter:outbound:objectstorage:processObjectStorageAwsQualificationTestResources NO-SOURCE +> Task :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses +> Task :adapter:outbound:objectstorage:checkstyleObjectStorageAwsQualificationTest +> Task :adapter:outbound:objectstorage:checkstyleMain +> Task :adapter:outbound:identifier:spotbugsMain +> Task :adapter:outbound:identifier:spotbugsTest +> Task :adapter:outbound:messaging:spotbugsMain +> Task :adapter:outbound:messaging:spotbugsTest +> Task :adapter:outbound:notification:spotbugsMain +> Task :adapter:outbound:notification:spotbugsTest +> Task :adapter:outbound:objectstorage:compileObjectStorageMinioContractTestJava +> Task :adapter:outbound:objectstorage:processObjectStorageMinioContractTestResources NO-SOURCE +> Task :adapter:outbound:objectstorage:objectStorageMinioContractTestClasses +> Task :adapter:outbound:objectstorage:checkstyleObjectStorageMinioContractTest +> Task :adapter:outbound:objectstorage:compileObjectStorageMinioFaultTestJava +> Task :adapter:outbound:objectstorage:processObjectStorageMinioFaultTestResources NO-SOURCE +> Task :adapter:outbound:objectstorage:objectStorageMinioFaultTestClasses +> Task :adapter:outbound:objectstorage:checkstyleObjectStorageMinioFaultTest +> Task :adapter:outbound:identifier:check +> Task :adapter:outbound:objectstorage:checkstyleTest +> Task :adapter:outbound:objectstorage:spotlessJava +> Task :adapter:outbound:objectstorage:spotlessJavaCheck +> Task :adapter:outbound:objectstorage:spotlessCheck +> Task :adapter:outbound:objectstorage:test UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileJpaPlatformPerformanceTestJava +> Task :adapter:outbound:persistence-jpa:processJpaPlatformPerformanceTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTestClasses +> Task :adapter:outbound:persistence-jpa:checkstyleJpaPlatformPerformanceTest +> Task :adapter:outbound:persistence-jpa:checkstyleMain +> Task :adapter:outbound:objectstorage:spotbugsMain +> Task :adapter:outbound:objectstorage:spotbugsObjectStorageAwsQualificationTest +> Task :adapter:outbound:objectstorage:spotbugsObjectStorageMinioContractTest +> Task :adapter:outbound:objectstorage:spotbugsObjectStorageMinioFaultTest +> Task :adapter:outbound:objectstorage:spotbugsTest +> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava +> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources +> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses +> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE +> Task :adapter:outbound:notification:check +> Task :adapter:outbound:persistence-jpa:checkstyleTestkit +> Task :adapter:outbound:persistence-jpa:checkstyleTest +> Task :adapter:outbound:persistence-jpa:checkstylePostgresqlIntegrationTest +> Task :adapter:outbound:persistence-jpa:spotlessJava +> Task :adapter:outbound:persistence-jpa:spotlessJavaCheck +> Task :adapter:outbound:persistence-jpa:spotlessCheck +> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE + +> Task :adapter:outbound:persistence-jpa:verifyJpaEvidenceHarnessContract +verifyJpaEvidenceHarnessContract: OK — skip, dirty/local R2, and content mutation fail closed. + +> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileMongoPerformanceTestJava +> Task :adapter:outbound:persistence-mongo:processMongoPerformanceTestResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:mongoPerformanceTestClasses +> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE +> Task :adapter:outbound:messaging:check +> Task :adapter:outbound:persistence-mongo:checkstyleMongoPerformanceTest +> Task :adapter:outbound:persistence-jpa:spotbugsJpaPlatformPerformanceTest +> Task :adapter:outbound:persistence-jpa:spotbugsMain +> Task :adapter:outbound:persistence-mongo:checkstyleTestkit +> Task :adapter:outbound:persistence-jpa:spotbugsPostgresqlIntegrationTest +> Task :adapter:outbound:persistence-jpa:spotbugsTest +> Task :adapter:outbound:persistence-jpa:spotbugsTestkit +> Task :adapter:outbound:persistence-mongo:checkstyleMain +> Task :adapter:outbound:persistence-mongo:checkstyleTest +> Task :adapter:outbound:persistence-mongo:mongoStableContractTest +> Task :adapter:outbound:persistence-mongo:spotlessJava +> Task :adapter:outbound:persistence-mongo:spotlessJavaCheck +> Task :adapter:outbound:persistence-mongo:spotlessCheck +> Task :adapter:outbound:persistence-mongo:test UP-TO-DATE + +> Task :adapter:outbound:persistence-mongo:verifyMongoApiSurface +verifyMongoApiSurface: OK — the committed public API surface is unchanged. + +> Task :adapter:outbound:persistence-mongo:verifyMongoReleaseContractLanes +> Task :adapter:outbound:httpclient:check +> Task :adapter:outbound:objectstorage:check +> Task :adapter:outbound:persistence-mongo:verifyMongoTestLaneDisjointness +> Task :adapter:outbound:support:compileTestJava UP-TO-DATE +> Task :adapter:outbound:support:processTestResources NO-SOURCE +> Task :adapter:outbound:support:testClasses UP-TO-DATE +> Task :adapter:outbound:support:spotlessJava +> Task :adapter:outbound:support:spotlessJavaCheck +> Task :adapter:outbound:support:spotlessCheck +> Task :adapter:outbound:support:test UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:check +> Task :adapter:outbound:support:checkstyleMain +> Task :adapter:outbound:support:checkstyleTest +> Task :adapter:outbound:persistence-mongo:spotbugsMongoPerformanceTest +> Task :adapter:outbound:persistence-mongo:spotbugsMain +> Task :adapter:outbound:persistence-mongo:spotbugsTest +> Task :adapter:outbound:persistence-mongo:spotbugsTestkit +> Task :adapter:outbound:support:spotbugsTest +> Task :adapter:outbound:support:spotbugsMain +> Task :adapter:outbound:support:check +> Task :adapter:outbound:persistence-mongo:check + +BUILD SUCCESSFUL in 5m 42s +547 actionable tasks: 117 executed, 430 up-to-date +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-compile.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-compile.log new file mode 100644 index 00000000..277040f9 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-compile.log @@ -0,0 +1,265 @@ +$ ./gradlew clean compileJava compileTestJava --warning-mode=fail --no-daemon --console=plain +run-at: 2026-08-20T01:18:16Z +To honour the JVM settings for this build a single-use Daemon process will be forked. For more on this, please refer to https://docs.gradle.org/9.0.0/userguide/gradle_daemon.html#sec:disabling_the_daemon in the Gradle documentation. +Daemon will be stopped at the end of the build +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :app-bootstrap:clean +> Task :application-core:clean +> Task :domain-core:clean +> Task :sample-portfolio:clean +> Task :shared-contract:clean +> Task :messaging:messaging-admin-api:clean +> Task :messaging:messaging-admin-runtime:clean +> Task :messaging:messaging-claim-check:clean +> Task :messaging:messaging-cloudevents:clean +> Task :messaging:messaging-core-api:clean +> Task :messaging:messaging-inbox-jdbc-postgresql:clean +> Task :messaging:messaging-kafka:clean +> Task :messaging:messaging-kafka-share-experimental:clean +> Task :messaging:messaging-nats-experimental:clean +> Task :messaging:messaging-observability:clean +> Task :messaging:messaging-outbox-jdbc-postgresql:clean +> Task :messaging:messaging-policy:clean +> Task :messaging:messaging-pulsar-experimental:clean +> Task :messaging:messaging-rabbit:clean +> Task :messaging:messaging-reliability-api:clean +> Task :messaging:messaging-runtime-core:clean +> Task :messaging:messaging-schema-api:clean +> Task :messaging:messaging-schema-avro:clean +> Task :messaging:messaging-schema-json:clean +> Task :messaging:messaging-schema-protobuf:clean +> Task :messaging:messaging-security:clean +> Task :messaging:messaging-spring-boot-starter:clean +> Task :messaging:messaging-spring-cloud-stream-bridge:clean +> Task :messaging:messaging-testkit:clean +> Task :messaging:messaging-transport-spi:clean +> Task :adapter:inbound:graphql:clean +> Task :adapter:inbound:grpc:clean +> Task :adapter:inbound:web:clean +> Task :adapter:inbound:websocket:clean +> Task :adapter:outbound:cache-redis:clean +> Task :adapter:outbound:fileserver:clean +> Task :adapter:outbound:httpclient:clean +> Task :adapter:outbound:identifier:clean +> Task :adapter:outbound:messaging:clean +> Task :adapter:outbound:notification:clean +> Task :adapter:outbound:objectstorage:clean +> Task :adapter:outbound:persistence-jpa:clean +> Task :adapter:outbound:persistence-mongo:clean +> Task :adapter:outbound:support:clean +> Task :shared-contract:compileJava +> Task :shared-contract:processResources +> Task :shared-contract:classes +> Task :shared-contract:jar +> Task :application-core:compileJava +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes +> Task :application-core:jar +> Task :domain-core:compileJava +> Task :domain-core:processResources NO-SOURCE +> Task :domain-core:classes +> Task :domain-core:jar +> Task :messaging:messaging-core-api:compileJava +> Task :messaging:messaging-observability:compileJava +> Task :messaging:messaging-schema-api:compileJava +> Task :messaging:messaging-policy:compileJava +> Task :messaging:messaging-reliability-api:compileJava +> Task :messaging:messaging-security:compileJava +> Task :messaging:messaging-admin-api:compileJava +> Task :messaging:messaging-transport-spi:compileJava +> Task :messaging:messaging-admin-runtime:compileJava +> Task :messaging:messaging-claim-check:compileJava +> Task :messaging:messaging-cloudevents:compileJava +> Task :messaging:messaging-inbox-jdbc-postgresql:compileJava +> Task :messaging:messaging-kafka:compileJava +> Task :messaging:messaging-outbox-jdbc-postgresql:compileJava +> Task :messaging:messaging-rabbit:compileJava +> Task :messaging:messaging-runtime-core:compileJava +> Task :messaging:messaging-schema-json:compileJava +> Task :messaging:messaging-spring-boot-starter:compileJava +> Task :adapter:inbound:graphql:compileJava +> Task :adapter:inbound:graphql:processResources +> Task :adapter:inbound:graphql:classes +> Task :adapter:inbound:graphql:jar +> Task :adapter:inbound:web:compileJava +> Task :adapter:inbound:web:processResources NO-SOURCE +> Task :adapter:inbound:web:classes +> Task :adapter:inbound:web:jar +> Task :adapter:outbound:support:compileJava +> Task :adapter:outbound:support:processResources NO-SOURCE +> Task :adapter:outbound:support:classes +> Task :adapter:outbound:support:jar +> Task :adapter:outbound:cache-redis:compileJava +> Task :adapter:outbound:cache-redis:processResources +> Task :adapter:outbound:cache-redis:classes +> Task :adapter:outbound:cache-redis:jar +> Task :adapter:outbound:fileserver:compileJava +> Task :adapter:outbound:fileserver:processResources NO-SOURCE +> Task :adapter:outbound:fileserver:classes +> Task :adapter:outbound:fileserver:jar +> Task :adapter:outbound:httpclient:compileJava +> Task :adapter:outbound:httpclient:processResources NO-SOURCE +> Task :adapter:outbound:httpclient:classes +> Task :adapter:outbound:httpclient:jar +> Task :adapter:outbound:identifier:compileJava +> Task :adapter:outbound:identifier:compileGroovy NO-SOURCE +> Task :adapter:outbound:identifier:processResources NO-SOURCE +> Task :adapter:outbound:identifier:classes +> Task :adapter:outbound:identifier:jar +> Task :adapter:outbound:messaging:compileJava +> Task :adapter:outbound:messaging:processResources +> Task :adapter:outbound:messaging:classes +> Task :adapter:outbound:messaging:jar +> Task :adapter:outbound:notification:compileJava +> Task :adapter:outbound:notification:processResources NO-SOURCE +> Task :adapter:outbound:notification:classes +> Task :adapter:outbound:notification:jar +> Task :adapter:outbound:persistence-jpa:compileJava +> Task :adapter:outbound:persistence-jpa:processResources +> Task :adapter:outbound:persistence-jpa:classes +> Task :adapter:outbound:persistence-jpa:jar +> Task :adapter:outbound:persistence-mongo:compileJava +> Task :adapter:outbound:persistence-mongo:processResources +> Task :adapter:outbound:persistence-mongo:classes +> Task :adapter:outbound:persistence-mongo:jar +> Task :app-bootstrap:compileJava +> Task :sample-portfolio:compileJava +> Task :messaging:messaging-kafka-share-experimental:compileJava +> Task :messaging:messaging-nats-experimental:compileJava +> Task :messaging:messaging-pulsar-experimental:compileJava +> Task :messaging:messaging-schema-avro:compileJava +> Task :messaging:messaging-schema-protobuf:compileJava +> Task :messaging:messaging-spring-cloud-stream-bridge:compileJava +> Task :messaging:messaging-testkit:compileJava +> Task :adapter:inbound:grpc:compileJava +> Task :adapter:inbound:websocket:compileJava +> Task :adapter:outbound:objectstorage:compileJava +> Task :app-bootstrap:processResources +> Task :app-bootstrap:classes +> Task :sample-portfolio:processResources +> Task :sample-portfolio:classes +> Task :sample-portfolio:jar +> Task :adapter:outbound:persistence-jpa:compileTestkitJava +> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testkitClasses +> Task :adapter:outbound:persistence-jpa:testkitJar +> Task :app-bootstrap:compileTestJava +> Task :application-core:compileTestJava +> Task :domain-core:compileTestJava NO-SOURCE +> Task :sample-portfolio:compileTestJava +> Task :shared-contract:compileTestJava +> Task :messaging:messaging-admin-api:processResources NO-SOURCE +> Task :messaging:messaging-admin-api:classes +> Task :messaging:messaging-admin-api:compileTestJava +> Task :messaging:messaging-admin-runtime:processResources NO-SOURCE +> Task :messaging:messaging-admin-runtime:classes +> Task :messaging:messaging-admin-runtime:compileTestJava +> Task :messaging:messaging-claim-check:processResources NO-SOURCE +> Task :messaging:messaging-claim-check:classes +> Task :messaging:messaging-claim-check:compileTestJava +> Task :messaging:messaging-cloudevents:processResources NO-SOURCE +> Task :messaging:messaging-cloudevents:classes +> Task :messaging:messaging-cloudevents:compileTestJava +> Task :messaging:messaging-core-api:processResources NO-SOURCE +> Task :messaging:messaging-core-api:classes +> Task :messaging:messaging-core-api:compileTestJava +> Task :messaging:messaging-inbox-jdbc-postgresql:processResources +> Task :messaging:messaging-inbox-jdbc-postgresql:classes +> Task :messaging:messaging-inbox-jdbc-postgresql:compileTestJava +> Task :messaging:messaging-kafka:processResources NO-SOURCE +> Task :messaging:messaging-kafka:classes +> Task :messaging:messaging-kafka:compileTestJava +> Task :messaging:messaging-kafka-share-experimental:processResources NO-SOURCE +> Task :messaging:messaging-kafka-share-experimental:classes +> Task :messaging:messaging-kafka-share-experimental:compileTestJava +> Task :messaging:messaging-nats-experimental:processResources NO-SOURCE +> Task :messaging:messaging-nats-experimental:classes +> Task :messaging:messaging-nats-experimental:compileTestJava +> Task :messaging:messaging-observability:processResources NO-SOURCE +> Task :messaging:messaging-observability:classes +> Task :messaging:messaging-observability:compileTestJava +> Task :messaging:messaging-outbox-jdbc-postgresql:processResources +> Task :messaging:messaging-outbox-jdbc-postgresql:classes +> Task :messaging:messaging-outbox-jdbc-postgresql:compileTestJava +> Task :messaging:messaging-policy:processResources NO-SOURCE +> Task :messaging:messaging-policy:classes +> Task :messaging:messaging-policy:compileTestJava +> Task :messaging:messaging-pulsar-experimental:processResources NO-SOURCE +> Task :messaging:messaging-pulsar-experimental:classes +> Task :messaging:messaging-pulsar-experimental:compileTestJava +> Task :messaging:messaging-rabbit:processResources NO-SOURCE +> Task :messaging:messaging-rabbit:classes +> Task :messaging:messaging-rabbit:compileTestJava +> Task :messaging:messaging-reliability-api:processResources NO-SOURCE +> Task :messaging:messaging-reliability-api:classes +> Task :messaging:messaging-reliability-api:compileTestJava NO-SOURCE +> Task :messaging:messaging-runtime-core:processResources NO-SOURCE +> Task :messaging:messaging-runtime-core:classes +> Task :messaging:messaging-runtime-core:compileTestJava +> Task :messaging:messaging-schema-api:processResources NO-SOURCE +> Task :messaging:messaging-schema-api:classes +> Task :messaging:messaging-schema-api:compileTestJava +> Task :messaging:messaging-schema-avro:processResources NO-SOURCE +> Task :messaging:messaging-schema-avro:classes +> Task :messaging:messaging-schema-avro:compileTestJava +> Task :messaging:messaging-schema-json:processResources NO-SOURCE +> Task :messaging:messaging-schema-json:classes +> Task :messaging:messaging-schema-json:compileTestJava +> Task :messaging:messaging-schema-protobuf:processResources NO-SOURCE +> Task :messaging:messaging-schema-protobuf:classes +> Task :messaging:messaging-schema-protobuf:compileTestJava +> Task :messaging:messaging-security:processResources NO-SOURCE +> Task :messaging:messaging-security:classes +> Task :messaging:messaging-security:compileTestJava +> Task :messaging:messaging-spring-boot-starter:processResources +> Task :messaging:messaging-spring-boot-starter:classes +> Task :messaging:messaging-spring-boot-starter:compileTestJava +> Task :messaging:messaging-spring-cloud-stream-bridge:processResources NO-SOURCE +> Task :messaging:messaging-spring-cloud-stream-bridge:classes +> Task :messaging:messaging-spring-cloud-stream-bridge:compileTestJava +> Task :messaging:messaging-testkit:processResources +> Task :messaging:messaging-testkit:classes +> Task :messaging:messaging-testkit:compileTestJava +> Task :messaging:messaging-transport-spi:processResources NO-SOURCE +> Task :messaging:messaging-transport-spi:classes +> Task :messaging:messaging-transport-spi:compileTestJava +> Task :adapter:inbound:graphql:compileTestFixturesJava +> Task :adapter:inbound:graphql:compileTestJava +> Task :adapter:inbound:grpc:processResources NO-SOURCE +> Task :adapter:inbound:grpc:classes +> Task :adapter:inbound:grpc:compileTestJava +> Task :adapter:inbound:web:compileTestJava +> Task :adapter:inbound:websocket:processResources NO-SOURCE +> Task :adapter:inbound:websocket:classes +> Task :adapter:inbound:websocket:compileTestJava +> Task :adapter:outbound:cache-redis:compileTestJava +> Task :adapter:outbound:fileserver:compileTestJava +> Task :adapter:outbound:httpclient:compileTestkitJava +> Task :adapter:outbound:httpclient:processTestkitResources NO-SOURCE +> Task :adapter:outbound:httpclient:testkitClasses +> Task :adapter:outbound:httpclient:compileTestJava +> Task :adapter:outbound:identifier:compileTestJava +> Task :adapter:outbound:messaging:compileTestJava +> Task :adapter:outbound:notification:compileTestJava +> Task :adapter:outbound:objectstorage:processResources NO-SOURCE +> Task :adapter:outbound:objectstorage:classes +> Task :adapter:outbound:objectstorage:compileTestJava +> Task :adapter:outbound:persistence-jpa:compileTestJava +> Task :adapter:outbound:persistence-mongo:compileTestkitJava +> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testkitClasses +> Task :adapter:outbound:persistence-mongo:compileTestJava +> Task :adapter:outbound:support:compileTestJava + +BUILD SUCCESSFUL in 4m 48s +170 actionable tasks: 162 executed, 8 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-test.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-test.log new file mode 100644 index 00000000..bcb2f2c7 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-test.log @@ -0,0 +1,378 @@ +$ ./gradlew test --warning-mode=fail --no-daemon --console=plain +run-at: 2026-08-20T01:33:26Z +To honour the JVM settings for this build a single-use Daemon process will be forked. For more on this, please refer to https://docs.gradle.org/9.0.0/userguide/gradle_daemon.html#sec:disabling_the_daemon in the Gradle documentation. +Daemon will be stopped at the end of the build +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :shared-contract:compileJava UP-TO-DATE +> Task :shared-contract:processResources UP-TO-DATE +> Task :shared-contract:classes UP-TO-DATE +> Task :shared-contract:jar UP-TO-DATE +> Task :application-core:compileJava UP-TO-DATE +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes UP-TO-DATE +> Task :application-core:jar UP-TO-DATE +> Task :domain-core:compileJava UP-TO-DATE +> Task :domain-core:processResources NO-SOURCE +> Task :domain-core:classes UP-TO-DATE +> Task :domain-core:jar UP-TO-DATE +> Task :messaging:messaging-core-api:compileJava UP-TO-DATE +> Task :messaging:messaging-observability:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-api:compileJava UP-TO-DATE +> Task :messaging:messaging-policy:compileJava UP-TO-DATE +> Task :messaging:messaging-reliability-api:compileJava UP-TO-DATE +> Task :messaging:messaging-security:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-api:compileJava UP-TO-DATE +> Task :messaging:messaging-transport-spi:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-runtime:compileJava UP-TO-DATE +> Task :messaging:messaging-claim-check:compileJava UP-TO-DATE +> Task :messaging:messaging-cloudevents:compileJava UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:compileJava UP-TO-DATE +> Task :messaging:messaging-kafka:compileJava UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:compileJava UP-TO-DATE +> Task :messaging:messaging-rabbit:compileJava UP-TO-DATE +> Task :messaging:messaging-runtime-core:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-json:compileJava UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:compileJava UP-TO-DATE +> Task :adapter:inbound:graphql:compileJava UP-TO-DATE +> Task :adapter:inbound:graphql:processResources UP-TO-DATE +> Task :adapter:inbound:graphql:classes UP-TO-DATE +> Task :adapter:inbound:graphql:jar UP-TO-DATE +> Task :adapter:inbound:web:compileJava UP-TO-DATE +> Task :adapter:inbound:web:processResources NO-SOURCE +> Task :adapter:inbound:web:classes UP-TO-DATE +> Task :adapter:inbound:web:jar UP-TO-DATE +> Task :adapter:outbound:support:compileJava UP-TO-DATE +> Task :adapter:outbound:support:processResources NO-SOURCE +> Task :adapter:outbound:support:classes UP-TO-DATE +> Task :adapter:outbound:support:jar UP-TO-DATE +> Task :adapter:outbound:cache-redis:compileJava UP-TO-DATE +> Task :adapter:outbound:cache-redis:processResources UP-TO-DATE +> Task :adapter:outbound:cache-redis:classes UP-TO-DATE +> Task :adapter:outbound:cache-redis:jar UP-TO-DATE +> Task :adapter:outbound:fileserver:compileJava UP-TO-DATE +> Task :adapter:outbound:fileserver:processResources NO-SOURCE +> Task :adapter:outbound:fileserver:classes UP-TO-DATE +> Task :adapter:outbound:fileserver:jar UP-TO-DATE +> Task :adapter:outbound:httpclient:compileJava UP-TO-DATE +> Task :adapter:outbound:httpclient:processResources NO-SOURCE +> Task :adapter:outbound:httpclient:classes UP-TO-DATE +> Task :adapter:outbound:httpclient:jar UP-TO-DATE +> Task :adapter:outbound:identifier:compileJava UP-TO-DATE +> Task :adapter:outbound:identifier:compileGroovy NO-SOURCE +> Task :adapter:outbound:identifier:processResources NO-SOURCE +> Task :adapter:outbound:identifier:classes UP-TO-DATE +> Task :adapter:outbound:identifier:jar UP-TO-DATE +> Task :adapter:outbound:messaging:compileJava UP-TO-DATE +> Task :adapter:outbound:messaging:processResources UP-TO-DATE +> Task :adapter:outbound:messaging:classes UP-TO-DATE +> Task :adapter:outbound:messaging:jar UP-TO-DATE +> Task :adapter:outbound:notification:compileJava UP-TO-DATE +> Task :adapter:outbound:notification:processResources NO-SOURCE +> Task :adapter:outbound:notification:classes UP-TO-DATE +> Task :adapter:outbound:notification:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jar UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:jar UP-TO-DATE +> Task :app-bootstrap:compileJava UP-TO-DATE +> Task :app-bootstrap:processResources UP-TO-DATE +> Task :app-bootstrap:classes UP-TO-DATE +> Task :sample-portfolio:compileJava UP-TO-DATE +> Task :sample-portfolio:processResources UP-TO-DATE +> Task :sample-portfolio:classes UP-TO-DATE +> Task :sample-portfolio:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:testkitJar UP-TO-DATE +> Task :app-bootstrap:compileTestJava UP-TO-DATE +> Task :app-bootstrap:runtimeClasspathManifest UP-TO-DATE +> Task :app-bootstrap:processTestResources UP-TO-DATE +> Task :app-bootstrap:testClasses UP-TO-DATE +> Task :messaging:messaging-admin-api:processResources NO-SOURCE +> Task :messaging:messaging-admin-api:classes UP-TO-DATE +> Task :messaging:messaging-admin-api:jar UP-TO-DATE +> Task :messaging:messaging-admin-runtime:processResources NO-SOURCE +> Task :messaging:messaging-admin-runtime:classes UP-TO-DATE +> Task :messaging:messaging-admin-runtime:jar UP-TO-DATE +> Task :messaging:messaging-claim-check:processResources NO-SOURCE +> Task :messaging:messaging-claim-check:classes UP-TO-DATE +> Task :messaging:messaging-claim-check:jar UP-TO-DATE +> Task :messaging:messaging-cloudevents:processResources NO-SOURCE +> Task :messaging:messaging-cloudevents:classes UP-TO-DATE +> Task :messaging:messaging-cloudevents:jar UP-TO-DATE +> Task :messaging:messaging-core-api:processResources NO-SOURCE +> Task :messaging:messaging-core-api:classes UP-TO-DATE +> Task :messaging:messaging-core-api:jar UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:processResources UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:classes UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:jar UP-TO-DATE +> Task :messaging:messaging-kafka:processResources NO-SOURCE +> Task :messaging:messaging-kafka:classes UP-TO-DATE +> Task :messaging:messaging-kafka:jar UP-TO-DATE +> Task :messaging:messaging-observability:processResources NO-SOURCE +> Task :messaging:messaging-observability:classes UP-TO-DATE +> Task :messaging:messaging-observability:jar UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:processResources UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:classes UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:jar UP-TO-DATE +> Task :messaging:messaging-policy:processResources NO-SOURCE +> Task :messaging:messaging-policy:classes UP-TO-DATE +> Task :messaging:messaging-policy:jar UP-TO-DATE +> Task :messaging:messaging-rabbit:processResources NO-SOURCE +> Task :messaging:messaging-rabbit:classes UP-TO-DATE +> Task :messaging:messaging-rabbit:jar UP-TO-DATE +> Task :messaging:messaging-reliability-api:processResources NO-SOURCE +> Task :messaging:messaging-reliability-api:classes UP-TO-DATE +> Task :messaging:messaging-reliability-api:jar UP-TO-DATE +> Task :messaging:messaging-runtime-core:processResources NO-SOURCE +> Task :messaging:messaging-runtime-core:classes UP-TO-DATE +> Task :messaging:messaging-runtime-core:jar UP-TO-DATE +> Task :messaging:messaging-schema-api:processResources NO-SOURCE +> Task :messaging:messaging-schema-api:classes UP-TO-DATE +> Task :messaging:messaging-schema-api:jar UP-TO-DATE +> Task :messaging:messaging-schema-json:processResources NO-SOURCE +> Task :messaging:messaging-schema-json:classes UP-TO-DATE +> Task :messaging:messaging-schema-json:jar UP-TO-DATE +> Task :messaging:messaging-security:processResources NO-SOURCE +> Task :messaging:messaging-security:classes UP-TO-DATE +> Task :messaging:messaging-security:jar UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:processResources UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:classes UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:jar UP-TO-DATE +> Task :messaging:messaging-transport-spi:processResources NO-SOURCE +> Task :messaging:messaging-transport-spi:classes UP-TO-DATE +> Task :messaging:messaging-transport-spi:jar UP-TO-DATE +> Task :adapter:outbound:objectstorage:compileJava UP-TO-DATE +> Task :adapter:outbound:objectstorage:processResources NO-SOURCE +> Task :adapter:outbound:objectstorage:classes UP-TO-DATE +> Task :adapter:outbound:objectstorage:jar UP-TO-DATE +> Task :app-bootstrap:test UP-TO-DATE +> Task :application-core:compileTestJava UP-TO-DATE +> Task :application-core:processTestResources NO-SOURCE +> Task :application-core:testClasses UP-TO-DATE +> Task :application-core:test UP-TO-DATE +> Task :domain-core:compileTestJava NO-SOURCE +> Task :domain-core:processTestResources NO-SOURCE +> Task :domain-core:testClasses UP-TO-DATE +> Task :domain-core:test NO-SOURCE +> Task :sample-portfolio:compileTestJava UP-TO-DATE +> Task :sample-portfolio:processTestResources UP-TO-DATE +> Task :sample-portfolio:testClasses UP-TO-DATE +> Task :sample-portfolio:test UP-TO-DATE +> Task :shared-contract:compileTestJava UP-TO-DATE +> Task :shared-contract:processTestResources NO-SOURCE +> Task :shared-contract:testClasses UP-TO-DATE +> Task :shared-contract:test UP-TO-DATE +> Task :messaging:messaging-admin-api:compileTestJava UP-TO-DATE +> Task :messaging:messaging-admin-api:processTestResources NO-SOURCE +> Task :messaging:messaging-admin-api:testClasses UP-TO-DATE +> Task :messaging:messaging-admin-api:test UP-TO-DATE +> Task :messaging:messaging-admin-runtime:compileTestJava UP-TO-DATE +> Task :messaging:messaging-admin-runtime:processTestResources NO-SOURCE +> Task :messaging:messaging-admin-runtime:testClasses UP-TO-DATE +> Task :messaging:messaging-admin-runtime:test UP-TO-DATE +> Task :messaging:messaging-claim-check:compileTestJava UP-TO-DATE +> Task :messaging:messaging-claim-check:processTestResources NO-SOURCE +> Task :messaging:messaging-claim-check:testClasses UP-TO-DATE +> Task :messaging:messaging-claim-check:test UP-TO-DATE +> Task :messaging:messaging-cloudevents:compileTestJava UP-TO-DATE +> Task :messaging:messaging-cloudevents:processTestResources NO-SOURCE +> Task :messaging:messaging-cloudevents:testClasses UP-TO-DATE +> Task :messaging:messaging-cloudevents:test UP-TO-DATE +> Task :messaging:messaging-core-api:compileTestJava UP-TO-DATE +> Task :messaging:messaging-core-api:processTestResources NO-SOURCE +> Task :messaging:messaging-core-api:testClasses UP-TO-DATE +> Task :messaging:messaging-core-api:test UP-TO-DATE +> Task :messaging:messaging-testkit:compileJava UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:compileTestJava UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:processTestResources NO-SOURCE +> Task :messaging:messaging-inbox-jdbc-postgresql:testClasses UP-TO-DATE +> Task :messaging:messaging-testkit:processResources UP-TO-DATE +> Task :messaging:messaging-testkit:classes UP-TO-DATE +> Task :messaging:messaging-testkit:jar UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:test UP-TO-DATE +> Task :messaging:messaging-kafka:compileTestJava UP-TO-DATE +> Task :messaging:messaging-kafka:processTestResources NO-SOURCE +> Task :messaging:messaging-kafka:testClasses UP-TO-DATE +> Task :messaging:messaging-kafka:test UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:compileJava UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:processResources NO-SOURCE +> Task :messaging:messaging-kafka-share-experimental:classes UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:compileTestJava UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:processTestResources NO-SOURCE +> Task :messaging:messaging-kafka-share-experimental:testClasses UP-TO-DATE +> Task :messaging:messaging-kafka-share-experimental:test UP-TO-DATE +> Task :messaging:messaging-nats-experimental:compileJava UP-TO-DATE +> Task :messaging:messaging-nats-experimental:processResources NO-SOURCE +> Task :messaging:messaging-nats-experimental:classes UP-TO-DATE +> Task :messaging:messaging-nats-experimental:compileTestJava UP-TO-DATE +> Task :messaging:messaging-nats-experimental:processTestResources NO-SOURCE +> Task :messaging:messaging-nats-experimental:testClasses UP-TO-DATE +> Task :messaging:messaging-nats-experimental:test UP-TO-DATE +> Task :messaging:messaging-observability:compileTestJava UP-TO-DATE +> Task :messaging:messaging-observability:processTestResources NO-SOURCE +> Task :messaging:messaging-observability:testClasses UP-TO-DATE +> Task :messaging:messaging-observability:test UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:compileTestJava UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:processTestResources NO-SOURCE +> Task :messaging:messaging-outbox-jdbc-postgresql:testClasses UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:test UP-TO-DATE +> Task :messaging:messaging-policy:compileTestJava UP-TO-DATE +> Task :messaging:messaging-policy:processTestResources NO-SOURCE +> Task :messaging:messaging-policy:testClasses UP-TO-DATE +> Task :messaging:messaging-policy:test UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:compileJava UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:processResources NO-SOURCE +> Task :messaging:messaging-pulsar-experimental:classes UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:compileTestJava UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:processTestResources NO-SOURCE +> Task :messaging:messaging-pulsar-experimental:testClasses UP-TO-DATE +> Task :messaging:messaging-pulsar-experimental:test UP-TO-DATE +> Task :messaging:messaging-rabbit:compileTestJava UP-TO-DATE +> Task :messaging:messaging-rabbit:processTestResources NO-SOURCE +> Task :messaging:messaging-rabbit:testClasses UP-TO-DATE +> Task :messaging:messaging-rabbit:test UP-TO-DATE +> Task :messaging:messaging-reliability-api:compileTestJava NO-SOURCE +> Task :messaging:messaging-reliability-api:processTestResources NO-SOURCE +> Task :messaging:messaging-reliability-api:testClasses UP-TO-DATE +> Task :messaging:messaging-reliability-api:test NO-SOURCE +> Task :messaging:messaging-runtime-core:compileTestJava UP-TO-DATE +> Task :messaging:messaging-runtime-core:processTestResources NO-SOURCE +> Task :messaging:messaging-runtime-core:testClasses UP-TO-DATE +> Task :messaging:messaging-runtime-core:test UP-TO-DATE +> Task :messaging:messaging-schema-api:compileTestJava UP-TO-DATE +> Task :messaging:messaging-schema-api:processTestResources NO-SOURCE +> Task :messaging:messaging-schema-api:testClasses UP-TO-DATE +> Task :messaging:messaging-schema-api:test UP-TO-DATE +> Task :messaging:messaging-schema-avro:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-avro:processResources NO-SOURCE +> Task :messaging:messaging-schema-avro:classes UP-TO-DATE +> Task :messaging:messaging-schema-avro:compileTestJava UP-TO-DATE +> Task :messaging:messaging-schema-avro:processTestResources UP-TO-DATE +> Task :messaging:messaging-schema-avro:testClasses UP-TO-DATE +> Task :messaging:messaging-schema-avro:test UP-TO-DATE +> Task :messaging:messaging-schema-json:compileTestJava UP-TO-DATE +> Task :messaging:messaging-schema-json:processTestResources NO-SOURCE +> Task :messaging:messaging-schema-json:testClasses UP-TO-DATE +> Task :messaging:messaging-schema-json:test UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:processResources NO-SOURCE +> Task :messaging:messaging-schema-protobuf:classes UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:compileTestJava UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:processTestResources NO-SOURCE +> Task :messaging:messaging-schema-protobuf:testClasses UP-TO-DATE +> Task :messaging:messaging-schema-protobuf:test UP-TO-DATE +> Task :messaging:messaging-security:compileTestJava UP-TO-DATE +> Task :messaging:messaging-security:processTestResources NO-SOURCE +> Task :messaging:messaging-security:testClasses UP-TO-DATE +> Task :messaging:messaging-security:test UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:compileTestJava UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:processTestResources UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:testClasses UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:test UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:compileJava UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:processResources NO-SOURCE +> Task :messaging:messaging-spring-cloud-stream-bridge:classes UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:compileTestJava UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:processTestResources NO-SOURCE +> Task :messaging:messaging-spring-cloud-stream-bridge:testClasses UP-TO-DATE +> Task :messaging:messaging-spring-cloud-stream-bridge:test UP-TO-DATE +> Task :messaging:messaging-testkit:compileTestJava UP-TO-DATE +> Task :messaging:messaging-testkit:processTestResources NO-SOURCE +> Task :messaging:messaging-testkit:testClasses UP-TO-DATE +> Task :messaging:messaging-testkit:test UP-TO-DATE +> Task :messaging:messaging-transport-spi:compileTestJava UP-TO-DATE +> Task :messaging:messaging-transport-spi:processTestResources NO-SOURCE +> Task :messaging:messaging-transport-spi:testClasses UP-TO-DATE +> Task :messaging:messaging-transport-spi:test UP-TO-DATE +> Task :adapter:inbound:graphql:compileTestFixturesJava UP-TO-DATE +> Task :adapter:inbound:graphql:processTestFixturesResources NO-SOURCE +> Task :adapter:inbound:graphql:testFixturesClasses UP-TO-DATE +> Task :adapter:inbound:graphql:testFixturesJar UP-TO-DATE +> Task :adapter:inbound:graphql:compileTestJava UP-TO-DATE +> Task :adapter:inbound:graphql:processTestResources UP-TO-DATE +> Task :adapter:inbound:graphql:testClasses UP-TO-DATE +> Task :adapter:inbound:graphql:test UP-TO-DATE +> Task :adapter:inbound:grpc:compileJava UP-TO-DATE +> Task :adapter:inbound:grpc:processResources NO-SOURCE +> Task :adapter:inbound:grpc:classes UP-TO-DATE +> Task :adapter:inbound:grpc:compileTestJava UP-TO-DATE +> Task :adapter:inbound:grpc:processTestResources NO-SOURCE +> Task :adapter:inbound:grpc:testClasses UP-TO-DATE +> Task :adapter:inbound:grpc:test UP-TO-DATE +> Task :adapter:inbound:web:compileTestJava UP-TO-DATE +> Task :adapter:inbound:web:processTestResources NO-SOURCE +> Task :adapter:inbound:web:testClasses UP-TO-DATE +> Task :adapter:inbound:web:test UP-TO-DATE +> Task :adapter:inbound:websocket:compileJava UP-TO-DATE +> Task :adapter:inbound:websocket:processResources NO-SOURCE +> Task :adapter:inbound:websocket:classes UP-TO-DATE +> Task :adapter:inbound:websocket:compileTestJava UP-TO-DATE +> Task :adapter:inbound:websocket:processTestResources NO-SOURCE +> Task :adapter:inbound:websocket:testClasses UP-TO-DATE +> Task :adapter:inbound:websocket:test UP-TO-DATE +> Task :adapter:outbound:cache-redis:compileTestJava UP-TO-DATE +> Task :adapter:outbound:cache-redis:processTestResources UP-TO-DATE +> Task :adapter:outbound:cache-redis:testClasses UP-TO-DATE +> Task :adapter:outbound:cache-redis:test UP-TO-DATE +> Task :adapter:outbound:fileserver:compileTestJava UP-TO-DATE +> Task :adapter:outbound:fileserver:processTestResources NO-SOURCE +> Task :adapter:outbound:fileserver:testClasses UP-TO-DATE +> Task :adapter:outbound:fileserver:test UP-TO-DATE +> Task :adapter:outbound:httpclient:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:httpclient:processTestkitResources NO-SOURCE +> Task :adapter:outbound:httpclient:testkitClasses UP-TO-DATE +> Task :adapter:outbound:httpclient:compileTestJava UP-TO-DATE +> Task :adapter:outbound:httpclient:processTestResources NO-SOURCE +> Task :adapter:outbound:httpclient:testClasses UP-TO-DATE +> Task :adapter:outbound:httpclient:test UP-TO-DATE +> Task :adapter:outbound:identifier:compileTestJava UP-TO-DATE +> Task :adapter:outbound:identifier:compileTestGroovy UP-TO-DATE +> Task :adapter:outbound:identifier:processTestResources NO-SOURCE +> Task :adapter:outbound:identifier:testClasses UP-TO-DATE +> Task :adapter:outbound:identifier:test UP-TO-DATE +> Task :adapter:outbound:messaging:compileTestJava UP-TO-DATE +> Task :adapter:outbound:messaging:processTestResources UP-TO-DATE +> Task :adapter:outbound:messaging:testClasses UP-TO-DATE +> Task :adapter:outbound:messaging:test UP-TO-DATE +> Task :adapter:outbound:notification:compileTestJava UP-TO-DATE +> Task :adapter:outbound:notification:processTestResources UP-TO-DATE +> Task :adapter:outbound:notification:testClasses UP-TO-DATE +> Task :adapter:outbound:notification:test +> Task :adapter:outbound:objectstorage:compileTestJava UP-TO-DATE +> Task :adapter:outbound:objectstorage:processTestResources +> Task :adapter:outbound:objectstorage:testClasses +> Task :adapter:outbound:objectstorage:test +> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:test +> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:test +> Task :adapter:outbound:support:compileTestJava UP-TO-DATE +> Task :adapter:outbound:support:processTestResources NO-SOURCE +> Task :adapter:outbound:support:testClasses UP-TO-DATE +> Task :adapter:outbound:support:test + +BUILD SUCCESSFUL in 2m 38s +200 actionable tasks: 6 executed, 194 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-wave0-red-set.md b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-wave0-red-set.md new file mode 100644 index 00000000..8e562c96 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-wave0-red-set.md @@ -0,0 +1,54 @@ +# Task 1 — the Wave 0 red set is empty, and the lanes are removed + +## Command + +```bash +cd src +./gradlew wave0RedReport --console=plain --no-daemon +``` + +`BUILD SUCCESSFUL in 43s` — but that says nothing on its own, and this is the important part of the +evidence rather than a caveat to it. Both `wave0Red` lanes set `ignoreFailures = true`, because they +were reports rather than gates: their job was to answer "what is still red from the baseline?", not +to fail a build. A green exit code from a report is not a claim about the tests. + +## What the results actually say + +``` +app-bootstrap/build/test-results/wave0Red: 0 classes, 0 tests, 0 failures, 0 skipped +messaging/messaging-observability/build/test-results/wave0Red: 0 classes, 0 tests, 0 failures, 0 skipped +``` + +Zero tests, because no test carries the tag any more: + +```bash +grep -rc 'wave0-red' $(git ls-files '*.java') # no match in any tracked Java source +``` + +The three Wave 0 characterizations — the full-`test` scanner failure, the five-switch-off bean and +resource inventory, and the local/dev boot and Compose merge reproductions — became ordinary tests as +the waves that fixed them landed, and their `@Tag("wave0-red")` markers came off with them. The red +set is empty by the only measure that matters: there is nothing left tagged. + +## Why the lanes are deleted rather than kept + +Two reasons, and the second is the one that generalises. + +1. The plan says so, and its reason holds: a permanent lane for an empty set is a lane that stops + being read. +2. These lanes are the exact shape the Wave 5 lane convention exists to refuse. A tag filter that + matches nothing does not fail — `failOnNoDiscoveredTests` applies to discovery and a tag excludes + after discovery — so the lane runs, executes zero tests, and reports success. Here that is + harmless, because the lanes are reports and their emptiness is the answer. But leaving two + hand-rolled lanes in that shape, outside the convention that would have refused them, is leaving + a template for the next lane somebody copies. + +Removed: + +- `src/build.gradle` — the `wave0RedReport` aggregate +- `src/app-bootstrap/build.gradle` — the `wave0Red` lane and the `test { excludeTags 'wave0-red' }` +- `src/messaging/messaging-observability/build.gradle` — the same pair + +The `excludeTags` removal matters as much as the lane removal: it was what kept tagged +characterizations out of the ordinary suite. With no tagged test left it is inert, and leaving it +would silently exclude any future test that reused the tag. diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-wave0RedReport.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-wave0RedReport.log new file mode 100644 index 00000000..2e828976 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task1-wave0RedReport.log @@ -0,0 +1,28 @@ +$ ./gradlew wave0RedReport --console=plain --no-daemon +run-at: 2026-08-20 (Wave 6 execution pass) + +FAILURE: Build failed with an exception. + +* What went wrong: +Task 'wave0RedReport' not found in root project 'ca-skeleton' and its subprojects. + +exit=1 + +-------------------------------------------------------------------------------- +READ THIS BEFORE TREATING THE LINE ABOVE AS A REGRESSION. + +This is the expected end state, not a failure. Task 1's checkbox is "confirm the report is empty, +*then delete the lanes and the aggregate*". The deletion happened, so the task it names no longer +exists and the command can no longer run. `grep -rn wave0Red src --include=*.gradle` returns +nothing, which is the same fact from the other direction. + +The substantive evidence — the empty red set measured while the lanes still existed — is in +`task1-wave0-red-set.md` beside this file: 0 classes / 0 tests / 0 failures in both lanes, with the +note that those lanes ran `ignoreFailures = true` and so a green exit code from them was never the +claim. + +Provenance note: this file previously held the raw log of that earlier successful run. It was +overwritten during the Wave 6 execution pass by re-running the command under the same filename +without checking what was already there. The original log is not recoverable (this directory is +untracked). What was lost is the raw transcript; what the checkbox depends on survives in the +`.md` above, which was written from it. diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-b5-ghost-release-lanes.md b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-b5-ghost-release-lanes.md new file mode 100644 index 00000000..c8c47be3 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-b5-ghost-release-lanes.md @@ -0,0 +1,59 @@ +# Task 2 — Wave 2 Task B5, the three ghost release lanes + +Wave 2 offered two outcomes for `mongoShardedTest`, `mongoAtlasTest` and `mongoKmsTest`: implement +them, or demote them and stop describing an unrunnable gate. **(b) Demote was taken**, and it is +reflected in all three places the plan names. + +## `src/config/mongodb/release-contracts.json` + +All three moved out of the Stable blocking set into `experimental_contracts`, each carrying +`"blocking": false`, `"promotion": "experimental"` and a `not_promoted_reason` that states the +mechanism rather than an intention: + +> A manifest entry pointing at an unregistered task does not fail; it is simply never run, and the +> release reports green for a capability nobody qualified. Demoted rather than implemented so the +> green means what it says. + +That is the correct reading. A release manifest naming a task that no build file registers is not a +failing gate — it is an absent one, and absence is indistinguishable from success in a report that +counts failures. + +## `docs/mongodb/advanced/sharding.md` + +The gate row now reads: + +> **Not promoted.** No `mongoShardedTest` lane is registered, and a sharded cluster is not an +> environment this repository stands up. Listed under `experimental_contracts` in +> `src/config/mongodb/release-contracts.json`; promoting it needs the lane, its required class, and +> protected-environment evidence to exist first. + +## `scripts/verify-mongodb-advanced.sh` + +Setting `MONGODB_SHARDED_URI` is now an explicit error rather than an invocation of a task that does +not exist: + +``` +sharded topology is experimental and has no registered lane; + MONGODB_SHARDED_URI was set but mongoShardedTest does not exist. + See experimental_contracts in src/config/mongodb/release-contracts.json. +``` + +The distinction the script draws is worth keeping: an operator who exported the URI expected a +qualification to run, so silence would be worse than failure. With the URI unset it records missing +evidence instead, which is a different statement from a pass. + +## The arbiter + +```bash +cd src +./gradlew :app-bootstrap:test --tests '*ReleaseManifestTaskExistenceTest' --console=plain +``` + +`BUILD SUCCESSFUL` — 1 test, 0 failures, 0 skipped. The manifest names no blocking task that the +build does not register. + +## Registered Mongo lanes, for the record + +`mongoStableContractTest`, `mongoReplicaSetTest`, `mongoFailoverTest`, `mongoMigrationTest`, +`mongoCompatibilityTest`, `mongoSecurityIntegrationTest`, `mongoPerformanceTest` — seven, and none of +the three demoted names among them, which is the state the demotion describes. diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-focused-module-tests.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-focused-module-tests.log new file mode 100644 index 00000000..8c149f90 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-focused-module-tests.log @@ -0,0 +1,181 @@ +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :shared-contract:compileJava UP-TO-DATE +> Task :shared-contract:processResources UP-TO-DATE +> Task :shared-contract:classes UP-TO-DATE +> Task :shared-contract:jar UP-TO-DATE +> Task :application-core:compileJava UP-TO-DATE +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes UP-TO-DATE +> Task :application-core:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:jar UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:test UP-TO-DATE +> Task :adapter:outbound:support:compileJava UP-TO-DATE +> Task :adapter:outbound:support:processResources NO-SOURCE +> Task :adapter:outbound:support:classes UP-TO-DATE +> Task :adapter:outbound:support:jar UP-TO-DATE +> Task :adapter:outbound:messaging:compileJava UP-TO-DATE +> Task :adapter:outbound:messaging:processResources UP-TO-DATE +> Task :adapter:outbound:messaging:classes UP-TO-DATE +> Task :adapter:outbound:messaging:jar UP-TO-DATE +> Task :adapter:outbound:messaging:compileTestJava UP-TO-DATE +> Task :adapter:outbound:messaging:processTestResources UP-TO-DATE +> Task :adapter:outbound:messaging:testClasses UP-TO-DATE +> Task :adapter:outbound:messaging:test UP-TO-DATE +> Task :adapter:outbound:notification:compileJava UP-TO-DATE +> Task :adapter:outbound:notification:processResources NO-SOURCE +> Task :adapter:outbound:notification:classes UP-TO-DATE +> Task :adapter:outbound:notification:jar UP-TO-DATE +> Task :adapter:outbound:notification:compileTestJava UP-TO-DATE +> Task :adapter:outbound:notification:processTestResources UP-TO-DATE +> Task :adapter:outbound:notification:testClasses UP-TO-DATE +> Task :adapter:outbound:notification:test UP-TO-DATE +> Task :adapter:inbound:graphql:compileJava UP-TO-DATE +> Task :adapter:inbound:graphql:processResources UP-TO-DATE +> Task :adapter:inbound:graphql:classes UP-TO-DATE +> Task :adapter:inbound:graphql:jar UP-TO-DATE +> Task :adapter:inbound:graphql:compileTestFixturesJava UP-TO-DATE +> Task :adapter:inbound:graphql:processTestFixturesResources NO-SOURCE +> Task :adapter:inbound:graphql:testFixturesClasses UP-TO-DATE +> Task :adapter:inbound:graphql:testFixturesJar UP-TO-DATE +> Task :adapter:inbound:graphql:compileTestJava UP-TO-DATE +> Task :adapter:inbound:graphql:processTestResources UP-TO-DATE +> Task :adapter:inbound:graphql:testClasses UP-TO-DATE +> Task :adapter:inbound:graphql:test UP-TO-DATE +2026-08-19T04:59:21.954Z INFO 432501 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete +2026-08-19T04:59:21.963Z INFO 432501 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete +2026-08-19T04:59:21.978Z INFO 432501 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete +2026-08-19T04:59:21.980Z INFO 432501 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete +> Task :adapter:inbound:graphql:graphqlStableTest + +> Task :verifyRuntimeModuleMembership +verifyRuntimeModuleMembership: 2 runtime composition(s) match the registry + +> Task :domain-core:compileJava UP-TO-DATE +> Task :domain-core:processResources NO-SOURCE +> Task :domain-core:classes UP-TO-DATE +> Task :domain-core:jar UP-TO-DATE +> Task :messaging:messaging-core-api:compileJava UP-TO-DATE +> Task :messaging:messaging-observability:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-api:compileJava UP-TO-DATE +> Task :messaging:messaging-policy:compileJava UP-TO-DATE +> Task :messaging:messaging-reliability-api:compileJava UP-TO-DATE +> Task :messaging:messaging-security:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-api:compileJava UP-TO-DATE +> Task :messaging:messaging-transport-spi:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-runtime:compileJava UP-TO-DATE +> Task :messaging:messaging-claim-check:compileJava UP-TO-DATE +> Task :messaging:messaging-cloudevents:compileJava UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:compileJava UP-TO-DATE +> Task :messaging:messaging-kafka:compileJava UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:compileJava UP-TO-DATE +> Task :messaging:messaging-rabbit:compileJava UP-TO-DATE +> Task :messaging:messaging-runtime-core:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-json:compileJava UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:compileJava UP-TO-DATE +> Task :adapter:inbound:web:compileJava UP-TO-DATE +> Task :adapter:inbound:web:processResources NO-SOURCE +> Task :adapter:inbound:web:classes UP-TO-DATE +> Task :adapter:inbound:web:jar UP-TO-DATE +> Task :adapter:outbound:cache-redis:compileJava UP-TO-DATE +> Task :adapter:outbound:cache-redis:processResources UP-TO-DATE +> Task :adapter:outbound:cache-redis:classes UP-TO-DATE +> Task :adapter:outbound:cache-redis:jar UP-TO-DATE +> Task :adapter:outbound:fileserver:compileJava UP-TO-DATE +> Task :adapter:outbound:fileserver:processResources NO-SOURCE +> Task :adapter:outbound:fileserver:classes UP-TO-DATE +> Task :adapter:outbound:fileserver:jar UP-TO-DATE +> Task :adapter:outbound:httpclient:compileJava UP-TO-DATE +> Task :adapter:outbound:httpclient:processResources NO-SOURCE +> Task :adapter:outbound:httpclient:classes UP-TO-DATE +> Task :adapter:outbound:httpclient:jar UP-TO-DATE +> Task :adapter:outbound:identifier:compileJava UP-TO-DATE +> Task :adapter:outbound:identifier:compileGroovy NO-SOURCE +> Task :adapter:outbound:identifier:processResources NO-SOURCE +> Task :adapter:outbound:identifier:classes UP-TO-DATE +> Task :adapter:outbound:identifier:jar UP-TO-DATE +> Task :app-bootstrap:compileJava UP-TO-DATE +> Task :app-bootstrap:processResources UP-TO-DATE +> Task :app-bootstrap:classes UP-TO-DATE +> Task :adapter:inbound:grpc:compileJava UP-TO-DATE +> Task :adapter:inbound:grpc:processResources NO-SOURCE +> Task :adapter:inbound:grpc:classes UP-TO-DATE +> Task :adapter:inbound:grpc:jar UP-TO-DATE +> Task :adapter:inbound:websocket:compileJava UP-TO-DATE +> Task :adapter:inbound:websocket:processResources NO-SOURCE +> Task :adapter:inbound:websocket:classes UP-TO-DATE +> Task :adapter:inbound:websocket:jar UP-TO-DATE +> Task :app-bootstrap:compileConditionalTransportTestJava UP-TO-DATE +> Task :app-bootstrap:processConditionalTransportTestResources NO-SOURCE +> Task :app-bootstrap:conditionalTransportTestClasses UP-TO-DATE +> Task :app-bootstrap:conditionalTransportCompositionTestRequiredClasses +> Task :app-bootstrap:conditionalTransportCompositionTest + +> Task :app-bootstrap:conditionalTransportCompositionTestEvidence +conditionalTransportCompositionTest: 3 tests, 0 skipped + +> Task :adapter:inbound:graphql:graphqlTransportQualificationTestRequiredClasses +2026-08-19T04:59:43.275Z INFO 433478 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete +2026-08-19T04:59:43.288Z INFO 433478 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete +> Task :adapter:inbound:graphql:graphqlTransportQualificationTest + +> Task :adapter:inbound:graphql:graphqlTransportQualificationTestEvidence +graphqlTransportQualificationTest: 8 tests, 0 skipped + +> Task :adapter:inbound:grpc:compileTestJava UP-TO-DATE +> Task :adapter:inbound:grpc:processTestResources NO-SOURCE +> Task :adapter:inbound:grpc:testClasses UP-TO-DATE +> Task :adapter:inbound:grpc:grpcTransportQualificationTestRequiredClasses +> Task :adapter:inbound:grpc:grpcTransportQualificationTest + +> Task :adapter:inbound:grpc:grpcTransportQualificationTestEvidence +grpcTransportQualificationTest: 15 tests, 0 skipped + +> Task :adapter:inbound:websocket:compileTestJava UP-TO-DATE +> Task :adapter:inbound:websocket:processTestResources NO-SOURCE +> Task :adapter:inbound:websocket:testClasses UP-TO-DATE +> Task :adapter:inbound:websocket:websocketTransportQualificationTestRequiredClasses +2026-08-19T05:00:00.518Z INFO 437360 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete +2026-08-19T05:00:00.523Z INFO 437360 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete +> Task :adapter:inbound:websocket:websocketTransportQualificationTest + +> Task :adapter:inbound:websocket:websocketTransportQualificationTestEvidence +websocketTransportQualificationTest: 5 tests, 0 skipped + +> Task :conditionalTransportQualification +conditional-transport-graphql: 8 tests, 0 skipped +conditional-transport-grpc: 15 tests, 0 skipped +conditional-transport-websocket: 5 tests, 0 skipped +conditional-transport-composition: 3 tests, 0 skipped + +BUILD SUCCESSFUL in 1m 8s +101 actionable tasks: 15 executed, 86 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +EXIT=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-graphql-runtime-qualification-NOT-RUN.md b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-graphql-runtime-qualification-NOT-RUN.md new file mode 100644 index 00000000..86942f93 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-graphql-runtime-qualification-NOT-RUN.md @@ -0,0 +1,61 @@ +# NOT RUN — the GraphQL bootJar JWT qualification + +Recorded as not-run with its reason, per this wave's rule that a gate which could not run is never +reported as passing. + +## What was attempted + +```bash +cd src +./gradlew :app-bootstrap:graphqlRuntimeQualification --console=plain +``` + +``` +* What went wrong: +Cannot locate tasks that match ':app-bootstrap:graphqlRuntimeQualification' as task +'graphqlRuntimeQualification' not found in project ':app-bootstrap'. +exit=1 +``` + +## Why it cannot run + +The task does not exist, and neither does the source set the spec names for it. +`app-bootstrap/src/` holds `main`, `test`, `functionalTest`, `conditionalTransportTest` and +`sampleOffTest` — there is no `graphqlRuntimeQualificationTest`. + +The spec (`…-five-adapter-runtime-remediation-review-design.md`, GraphQL section) fixes the +canonical task as `:app-bootstrap:graphqlRuntimeQualification`, depending on `bootJar`, running the +produced jar as a child process, taking a client-credentials token from a Keycloak container that +imported the same tracked realm artifact, and calling real HTTP `/graphql`. None of that machinery +was built. + +## What exists instead, and why it does not substitute + +| Artefact | What it actually proves | Why it is not release evidence | +| --- | --- | --- | +| `GraphqlHttpBoundaryQualificationTest` | `/graphql` and `/graphiql` answer over HTTP in a `@SpringBootTest` | authenticates with `withBasicAuth(USERNAME, PASSWORD)` and `httpBasic(Customizer.withDefaults())` — test-only Basic auth, not the JWT decoder a deployment runs | +| `ConditionalTransportCompositionContractTest` | the GraphQL types load and the leaf's runtime membership matches the registry | `assertThatCodeLoads(typeName)` is class existence; it makes no request and sees no token | + +The spec anticipates exactly these two and rules both out by name: the boundary test "may remain a +module contract test but is not aggregated into release evidence", and class existence is named as +the thing the qualification exists to replace. + +## Consequence for the Definition of Done + +Spec §13 item — *"GraphQL blocking qualification runs the bootJar JWT composition exactly once and +uses neither class-existence nor test-only Basic Auth as release evidence"* — **cannot be ticked**. +It is the one item of the twenty-four in that state. + +## This was already declared, not discovered + +`.github/ci-gate-matrix.yml` registers the gate as `mechanism: delegated-pending` with the comment +that it "inherits that control's pending status rather than having none of its own", and +`verify-gate-matrix.sh` reports `49 gates, 45 verified, 4 delegated-pending`. The gap is recorded in +the repository's own control plane; this file is the Wave 6 confirmation of it, not a new finding. + +## What closing it would take + +A new `graphqlRuntimeQualificationTest` source set, a Gradle lane depending on `bootJar`, a child +process launcher for the jar, a Keycloak container importing `infra/keycloak/realms/ +ca-skeleton-realm.json`, and the required-class / zero-discovery / stale-XML refusals the spec lists. +That is new capability, which this wave explicitly does not add. diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-graphql-runtime-qualification.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-graphql-runtime-qualification.log new file mode 100644 index 00000000..2d0313c9 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-graphql-runtime-qualification.log @@ -0,0 +1,31 @@ +$ ./gradlew :app-bootstrap:graphqlRuntimeQualification --console=plain +run-at: 2026-08-20T07:20:21Z +Mem: 30Gi 18Gi 1.4Gi 688Mi 12Gi 12Gi +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE + +[Incubating] Problems report is available at: file:///home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/build/reports/problems/problems-report.html + +FAILURE: Build failed with an exception. + +* What went wrong: +Cannot locate tasks that match ':app-bootstrap:graphqlRuntimeQualification' as task 'graphqlRuntimeQualification' not found in project ':app-bootstrap'. + +* Try: +> Run gradlew tasks to get a list of available tasks. +> For more on name expansion, please refer to https://docs.gradle.org/9.0.0/userguide/command_line_interface.html#sec:name_abbreviation in the Gradle documentation. +> Run with --stacktrace option to get the stack trace. +> Run with --info or --debug option to get more log output. +> Run with --scan to generate a Build Scan (Powered by Develocity). +> Get more help at https://help.gradle.org. + +BUILD FAILED in 2s +8 actionable tasks: 8 up-to-date +exit=1 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-graphql-stable-and-transport.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-graphql-stable-and-transport.log new file mode 100644 index 00000000..3c8f7e9f --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-graphql-stable-and-transport.log @@ -0,0 +1,162 @@ +$ ./gradlew :adapter:inbound:graphql:graphqlStableTest conditionalTransportQualification --console=plain +run-at: 2026-08-20T07:18:59Z + total used free shared buff/cache available +Mem: 30Gi 17Gi 2.0Gi 687Mi 12Gi 13Gi +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :shared-contract:compileJava UP-TO-DATE +> Task :shared-contract:processResources UP-TO-DATE +> Task :shared-contract:classes UP-TO-DATE +> Task :shared-contract:jar UP-TO-DATE +> Task :application-core:compileJava UP-TO-DATE +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes UP-TO-DATE +> Task :application-core:jar UP-TO-DATE +> Task :adapter:inbound:graphql:compileJava UP-TO-DATE +> Task :adapter:inbound:graphql:processResources UP-TO-DATE +> Task :adapter:inbound:graphql:classes UP-TO-DATE +> Task :adapter:inbound:graphql:jar UP-TO-DATE +> Task :adapter:inbound:graphql:compileTestFixturesJava UP-TO-DATE +> Task :adapter:inbound:graphql:processTestFixturesResources NO-SOURCE +> Task :adapter:inbound:graphql:testFixturesClasses UP-TO-DATE +> Task :adapter:inbound:graphql:testFixturesJar UP-TO-DATE +> Task :adapter:inbound:graphql:compileTestJava UP-TO-DATE +> Task :adapter:inbound:graphql:processTestResources UP-TO-DATE +> Task :adapter:inbound:graphql:testClasses UP-TO-DATE +2026-08-20T07:19:40.310Z INFO 3669807 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete +2026-08-20T07:19:40.317Z INFO 3669807 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete +2026-08-20T07:19:40.345Z INFO 3669807 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete +2026-08-20T07:19:40.347Z INFO 3669807 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete +> Task :adapter:inbound:graphql:graphqlStableTest + +> Task :verifyRuntimeModuleMembership +verifyRuntimeModuleMembership: 2 runtime composition(s) match the registry + +> Task :domain-core:compileJava UP-TO-DATE +> Task :domain-core:processResources NO-SOURCE +> Task :domain-core:classes UP-TO-DATE +> Task :domain-core:jar UP-TO-DATE +> Task :messaging:messaging-core-api:compileJava UP-TO-DATE +> Task :messaging:messaging-observability:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-api:compileJava UP-TO-DATE +> Task :messaging:messaging-policy:compileJava UP-TO-DATE +> Task :messaging:messaging-reliability-api:compileJava UP-TO-DATE +> Task :messaging:messaging-security:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-api:compileJava UP-TO-DATE +> Task :messaging:messaging-transport-spi:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-runtime:compileJava UP-TO-DATE +> Task :messaging:messaging-claim-check:compileJava UP-TO-DATE +> Task :messaging:messaging-cloudevents:compileJava UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:compileJava UP-TO-DATE +> Task :messaging:messaging-kafka:compileJava UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:compileJava UP-TO-DATE +> Task :messaging:messaging-rabbit:compileJava UP-TO-DATE +> Task :messaging:messaging-runtime-core:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-json:compileJava UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:compileJava UP-TO-DATE +> Task :adapter:inbound:web:compileJava UP-TO-DATE +> Task :adapter:inbound:web:processResources NO-SOURCE +> Task :adapter:inbound:web:classes UP-TO-DATE +> Task :adapter:inbound:web:jar UP-TO-DATE +> Task :adapter:outbound:support:compileJava UP-TO-DATE +> Task :adapter:outbound:support:processResources NO-SOURCE +> Task :adapter:outbound:support:classes UP-TO-DATE +> Task :adapter:outbound:support:jar UP-TO-DATE +> Task :adapter:outbound:cache-redis:compileJava UP-TO-DATE +> Task :adapter:outbound:cache-redis:processResources UP-TO-DATE +> Task :adapter:outbound:cache-redis:classes UP-TO-DATE +> Task :adapter:outbound:cache-redis:jar UP-TO-DATE +> Task :adapter:outbound:fileserver:compileJava UP-TO-DATE +> Task :adapter:outbound:fileserver:processResources NO-SOURCE +> Task :adapter:outbound:fileserver:classes UP-TO-DATE +> Task :adapter:outbound:fileserver:jar UP-TO-DATE +> Task :adapter:outbound:httpclient:compileJava UP-TO-DATE +> Task :adapter:outbound:httpclient:processResources NO-SOURCE +> Task :adapter:outbound:httpclient:classes UP-TO-DATE +> Task :adapter:outbound:httpclient:jar UP-TO-DATE +> Task :adapter:outbound:identifier:compileJava UP-TO-DATE +> Task :adapter:outbound:identifier:compileGroovy NO-SOURCE +> Task :adapter:outbound:identifier:processResources NO-SOURCE +> Task :adapter:outbound:identifier:classes UP-TO-DATE +> Task :adapter:outbound:identifier:jar UP-TO-DATE +> Task :adapter:outbound:messaging:compileJava UP-TO-DATE +> Task :adapter:outbound:messaging:processResources UP-TO-DATE +> Task :adapter:outbound:messaging:classes UP-TO-DATE +> Task :adapter:outbound:messaging:jar UP-TO-DATE +> Task :adapter:outbound:notification:compileJava UP-TO-DATE +> Task :adapter:outbound:notification:processResources NO-SOURCE +> Task :adapter:outbound:notification:classes UP-TO-DATE +> Task :adapter:outbound:notification:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jar UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:jar UP-TO-DATE +> Task :app-bootstrap:compileJava UP-TO-DATE +> Task :app-bootstrap:processResources UP-TO-DATE +> Task :app-bootstrap:classes UP-TO-DATE +> Task :adapter:inbound:grpc:compileJava UP-TO-DATE +> Task :adapter:inbound:grpc:processResources NO-SOURCE +> Task :adapter:inbound:grpc:classes UP-TO-DATE +> Task :adapter:inbound:grpc:jar UP-TO-DATE +> Task :adapter:inbound:websocket:compileJava UP-TO-DATE +> Task :adapter:inbound:websocket:processResources NO-SOURCE +> Task :adapter:inbound:websocket:classes UP-TO-DATE +> Task :adapter:inbound:websocket:jar UP-TO-DATE +> Task :app-bootstrap:compileConditionalTransportTestJava UP-TO-DATE +> Task :app-bootstrap:processConditionalTransportTestResources NO-SOURCE +> Task :app-bootstrap:conditionalTransportTestClasses UP-TO-DATE +> Task :app-bootstrap:conditionalTransportCompositionTestRequiredClasses +> Task :app-bootstrap:conditionalTransportCompositionTest + +> Task :app-bootstrap:conditionalTransportCompositionTestEvidence +conditionalTransportCompositionTest: 3 tests, 0 skipped + +> Task :adapter:inbound:graphql:graphqlTransportQualificationTestRequiredClasses +2026-08-20T07:19:56.193Z INFO 3670728 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete +2026-08-20T07:19:56.198Z INFO 3670728 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete +> Task :adapter:inbound:graphql:graphqlTransportQualificationTest + +> Task :adapter:inbound:graphql:graphqlTransportQualificationTestEvidence +graphqlTransportQualificationTest: 8 tests, 0 skipped + +> Task :adapter:inbound:grpc:compileTestJava UP-TO-DATE +> Task :adapter:inbound:grpc:processTestResources NO-SOURCE +> Task :adapter:inbound:grpc:testClasses UP-TO-DATE +> Task :adapter:inbound:grpc:grpcTransportQualificationTestRequiredClasses +> Task :adapter:inbound:grpc:grpcTransportQualificationTest + +> Task :adapter:inbound:grpc:grpcTransportQualificationTestEvidence +grpcTransportQualificationTest: 15 tests, 0 skipped + +> Task :adapter:inbound:websocket:compileTestJava UP-TO-DATE +> Task :adapter:inbound:websocket:processTestResources NO-SOURCE +> Task :adapter:inbound:websocket:testClasses UP-TO-DATE +> Task :adapter:inbound:websocket:websocketTransportQualificationTestRequiredClasses +2026-08-20T07:20:10.449Z INFO 3671395 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete +2026-08-20T07:20:10.454Z INFO 3671395 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete +> Task :adapter:inbound:websocket:websocketTransportQualificationTest + +> Task :adapter:inbound:websocket:websocketTransportQualificationTestEvidence +websocketTransportQualificationTest: 5 tests, 0 skipped + +> Task :conditionalTransportQualification +conditional-transport-graphql: 8 tests, 0 skipped +conditional-transport-grpc: 15 tests, 0 skipped +conditional-transport-websocket: 5 tests, 0 skipped +conditional-transport-composition: 3 tests, 0 skipped + +BUILD SUCCESSFUL in 1m 11s +88 actionable tasks: 15 executed, 73 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-first-run-failed.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-first-run-failed.log new file mode 100644 index 00000000..782f7a97 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-first-run-failed.log @@ -0,0 +1,173 @@ +===== PostgreSQL 16 ===== +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :shared-contract:compileJava UP-TO-DATE +> Task :shared-contract:processResources UP-TO-DATE +> Task :shared-contract:classes UP-TO-DATE +> Task :shared-contract:jar UP-TO-DATE +> Task :application-core:compileJava UP-TO-DATE +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes UP-TO-DATE +> Task :application-core:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest + +PostgreSqlNotificationSchemaActivationIntegrationTest > V1 to V4, disable, re-enable and an interrupted migration all recover FAILED + org.opentest4j.AssertionFailedError at PostgreSqlNotificationSchemaActivationIntegrationTest.java:184 + +43 tests completed, 1 failed + +> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest FAILED + +FAILURE: Build failed with an exception. + +* What went wrong: +Execution failed for task ':adapter:outbound:persistence-jpa:jpaPlatformMigrationTest'. +> There were failing tests. See the report at: file:///home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/reports/tests/jpaPlatformMigrationTest/index.html + +* Try: +> Run with --scan to generate a Build Scan (Powered by Develocity). + +BUILD FAILED in 4m 58s +21 actionable tasks: 3 executed, 18 up-to-date +EXIT(16)=1 +===== PostgreSQL 17 ===== +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :shared-contract:compileJava UP-TO-DATE +> Task :shared-contract:processResources UP-TO-DATE +> Task :shared-contract:classes UP-TO-DATE +> Task :shared-contract:jar UP-TO-DATE +> Task :application-core:compileJava UP-TO-DATE +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes UP-TO-DATE +> Task :application-core:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest + +> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest + +PostgreSqlNotificationSchemaActivationIntegrationTest > V1 to V4, disable, re-enable and an interrupted migration all recover FAILED + org.opentest4j.AssertionFailedError at PostgreSqlNotificationSchemaActivationIntegrationTest.java:184 + +43 tests completed, 1 failed + +> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest FAILED + +FAILURE: Build failed with an exception. + +* What went wrong: +Execution failed for task ':adapter:outbound:persistence-jpa:jpaPlatformMigrationTest'. +> There were failing tests. See the report at: file:///home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/reports/tests/jpaPlatformMigrationTest/index.html + +* Try: +> Run with --scan to generate a Build Scan (Powered by Develocity). + +BUILD FAILED in 5m 42s +21 actionable tasks: 3 executed, 18 up-to-date +EXIT(17)=1 +===== PostgreSQL 18 ===== +> Task :build-logic:extractPluginRequests +> Task :build-logic:generatePluginAdapters +> Task :build-logic:compileJava +> Task :build-logic:compileGroovy +> Task :build-logic:compileGroovyPlugins +> Task :build-logic:pluginDescriptors +> Task :build-logic:processResources +> Task :build-logic:classes +> Task :build-logic:jar +> Task :shared-contract:compileJava UP-TO-DATE +> Task :shared-contract:processResources UP-TO-DATE +> Task :shared-contract:classes UP-TO-DATE +> Task :shared-contract:jar UP-TO-DATE +> Task :application-core:compileJava UP-TO-DATE +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes UP-TO-DATE +> Task :application-core:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest FAILED + +FAILURE: Build failed with an exception. + +* What went wrong: +Execution failed for task ':adapter:outbound:persistence-jpa:jpaPlatformContractTest'. +> Multiple build operations failed. + Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.StablePostgreSqlMatrixContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.StablePostgreSqlMatrixContractTest.xml. + Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlArrayRangeContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlArrayRangeContractTest.xml. + Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlPessimisticLockContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlPessimisticLockContractTest.xml. + Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.OptimisticRetryIntegrationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.OptimisticRetryIntegrationTest.xml. + Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlCopyLoaderIntegrationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlCopyLoaderIntegrationTest.xml. + Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.experimental.ReadAfterWriteRoutingContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.experimental.ReadAfterWriteRoutingContractTest.xml. + Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlNotificationIdempotencyRaceIntegrationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlNotificationIdempotencyRaceIntegrationTest.xml. + Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantColumnIsolationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantColumnIsolationTest.xml. + Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantPoolCapacityContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantPoolCapacityContractTest.xml. + Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlWorkClaimContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlWorkClaimContractTest.xml. + ...and 7 more failures. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.StablePostgreSqlMatrixContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.StablePostgreSqlMatrixContractTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlArrayRangeContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlArrayRangeContractTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlPessimisticLockContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlPessimisticLockContractTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.OptimisticRetryIntegrationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.OptimisticRetryIntegrationTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlCopyLoaderIntegrationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlCopyLoaderIntegrationTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.experimental.ReadAfterWriteRoutingContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.experimental.ReadAfterWriteRoutingContractTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlNotificationIdempotencyRaceIntegrationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlNotificationIdempotencyRaceIntegrationTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantColumnIsolationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantColumnIsolationTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantPoolCapacityContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantPoolCapacityContractTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlWorkClaimContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlWorkClaimContractTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.JpaLifecycleAssociationContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.JpaLifecycleAssociationContractTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlUpsertContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlUpsertContractTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlJsonbContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlJsonbContractTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.JpaValueMappingContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.JpaValueMappingContractTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.JpaPlatformContractSupportOwnershipTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.JpaPlatformContractSupportOwnershipTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlSqlStateContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlSqlStateContractTest.xml. + > Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlRecipientLeaseFencingIntegrationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlRecipientLeaseFencingIntegrationTest.xml. + +* Try: +> Run with --stacktrace option to get the stack trace. +> Run with --info or --debug option to get more log output. +> Run with --scan to generate a Build Scan (Powered by Develocity). +> Get more help at https://help.gradle.org. + +BUILD FAILED in 5m 20s +19 actionable tasks: 9 executed, 10 up-to-date diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-pg16.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-pg16.log new file mode 100644 index 00000000..0d767a6d --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-pg16.log @@ -0,0 +1,49 @@ +$ ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate -Pjpa.matrix.versions=16 --console=plain +run-at: 2026-08-20T07:38:10Z +Mem: 30Gi 15Gi 8.9Gi 770Mi 8.2Gi 15Gi +Starting a Gradle Daemon, 5 stopped Daemons could not be reused, use --status for details +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :shared-contract:compileJava UP-TO-DATE +> Task :shared-contract:processResources UP-TO-DATE +> Task :shared-contract:classes UP-TO-DATE +> Task :shared-contract:jar UP-TO-DATE +> Task :application-core:compileJava UP-TO-DATE +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes UP-TO-DATE +> Task :application-core:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest +> Task :adapter:outbound:persistence-jpa:compileJpaPlatformPerformanceTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processJpaPlatformPerformanceTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest +> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate + +BUILD SUCCESSFUL in 8m 14s +27 actionable tasks: 6 executed, 21 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-pg17.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-pg17.log new file mode 100644 index 00000000..248181f3 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-pg17.log @@ -0,0 +1,48 @@ +$ ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate -Pjpa.matrix.versions=17 --console=plain +run-at: 2026-08-20T07:46:38Z +Mem: 30Gi 19Gi 4.6Gi 782Mi 8.5Gi 11Gi +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :shared-contract:compileJava UP-TO-DATE +> Task :shared-contract:processResources UP-TO-DATE +> Task :shared-contract:classes UP-TO-DATE +> Task :shared-contract:jar UP-TO-DATE +> Task :application-core:compileJava UP-TO-DATE +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes UP-TO-DATE +> Task :application-core:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest +> Task :adapter:outbound:persistence-jpa:compileJpaPlatformPerformanceTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processJpaPlatformPerformanceTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest +> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate + +BUILD SUCCESSFUL in 7m 26s +27 actionable tasks: 6 executed, 21 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-pg18.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-pg18.log new file mode 100644 index 00000000..bf9a60d1 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate-pg18.log @@ -0,0 +1,48 @@ +$ ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate -Pjpa.matrix.versions=18 --console=plain +run-at: 2026-08-20T07:54:35Z +Mem: 30Gi 19Gi 4.3Gi 1.1Gi 8.2Gi 10Gi +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :shared-contract:compileJava UP-TO-DATE +> Task :shared-contract:processResources UP-TO-DATE +> Task :shared-contract:classes UP-TO-DATE +> Task :shared-contract:jar UP-TO-DATE +> Task :application-core:compileJava UP-TO-DATE +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes UP-TO-DATE +> Task :application-core:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest +> Task :adapter:outbound:persistence-jpa:compileJpaPlatformPerformanceTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processJpaPlatformPerformanceTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest +> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate + +BUILD SUCCESSFUL in 5m 45s +27 actionable tasks: 6 executed, 21 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate.log new file mode 100644 index 00000000..9e3a9712 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-jpa-release-gate.log @@ -0,0 +1,138 @@ +===== PostgreSQL 16 ===== +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :shared-contract:compileJava UP-TO-DATE +> Task :shared-contract:processResources UP-TO-DATE +> Task :shared-contract:classes UP-TO-DATE +> Task :shared-contract:jar UP-TO-DATE +> Task :application-core:compileJava UP-TO-DATE +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes UP-TO-DATE +> Task :application-core:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest +> Task :adapter:outbound:persistence-jpa:compileJpaPlatformPerformanceTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processJpaPlatformPerformanceTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest +> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate + +BUILD SUCCESSFUL in 7m 26s +27 actionable tasks: 6 executed, 21 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +EXIT(16)=0 +===== PostgreSQL 17 ===== +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :shared-contract:compileJava UP-TO-DATE +> Task :shared-contract:processResources UP-TO-DATE +> Task :shared-contract:classes UP-TO-DATE +> Task :shared-contract:jar UP-TO-DATE +> Task :application-core:compileJava UP-TO-DATE +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes UP-TO-DATE +> Task :application-core:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest +> Task :adapter:outbound:persistence-jpa:compileJpaPlatformPerformanceTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processJpaPlatformPerformanceTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest +> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate + +BUILD SUCCESSFUL in 5m 55s +27 actionable tasks: 6 executed, 21 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +EXIT(17)=0 +===== PostgreSQL 18 ===== +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :shared-contract:compileJava UP-TO-DATE +> Task :shared-contract:processResources UP-TO-DATE +> Task :shared-contract:classes UP-TO-DATE +> Task :shared-contract:jar UP-TO-DATE +> Task :application-core:compileJava UP-TO-DATE +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes UP-TO-DATE +> Task :application-core:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest +> Task :adapter:outbound:persistence-jpa:compileJpaPlatformPerformanceTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processJpaPlatformPerformanceTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest +> Task :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest +> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate + +BUILD SUCCESSFUL in 5m 58s +27 actionable tasks: 6 executed, 21 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +EXIT(18)=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-messaging-certification.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-messaging-certification.log new file mode 100644 index 00000000..cb28ca06 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-messaging-certification.log @@ -0,0 +1,61 @@ +$ ./gradlew :messaging:messaging-kafka:verifyMessagingCertificationEvidence --console=plain +run-at: 2026-08-20T08:00:43Z +Mem: 30Gi 16Gi 7.8Gi 1.0Gi 8.3Gi 14Gi +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :messaging:messaging-core-api:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-api:compileJava UP-TO-DATE +> Task :messaging:messaging-policy:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-api:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-api:processResources NO-SOURCE +> Task :messaging:messaging-admin-api:classes UP-TO-DATE +> Task :messaging:messaging-admin-api:jar UP-TO-DATE +> Task :messaging:messaging-observability:compileJava UP-TO-DATE +> Task :messaging:messaging-security:compileJava UP-TO-DATE +> Task :messaging:messaging-transport-spi:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-runtime:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-runtime:processResources NO-SOURCE +> Task :messaging:messaging-admin-runtime:classes UP-TO-DATE +> Task :messaging:messaging-admin-runtime:jar UP-TO-DATE +> Task :messaging:messaging-core-api:processResources NO-SOURCE +> Task :messaging:messaging-core-api:classes UP-TO-DATE +> Task :messaging:messaging-core-api:jar UP-TO-DATE +> Task :messaging:messaging-kafka:compileJava UP-TO-DATE +> Task :messaging:messaging-kafka:processResources NO-SOURCE +> Task :messaging:messaging-kafka:classes UP-TO-DATE +> Task :messaging:messaging-testkit:compileJava UP-TO-DATE +> Task :messaging:messaging-kafka:compileTestJava UP-TO-DATE +> Task :messaging:messaging-kafka:processTestResources NO-SOURCE +> Task :messaging:messaging-kafka:testClasses UP-TO-DATE +> Task :messaging:messaging-observability:processResources NO-SOURCE +> Task :messaging:messaging-observability:classes UP-TO-DATE +> Task :messaging:messaging-observability:jar UP-TO-DATE +> Task :messaging:messaging-policy:processResources NO-SOURCE +> Task :messaging:messaging-policy:classes UP-TO-DATE +> Task :messaging:messaging-policy:jar UP-TO-DATE +> Task :messaging:messaging-schema-api:processResources NO-SOURCE +> Task :messaging:messaging-schema-api:classes UP-TO-DATE +> Task :messaging:messaging-schema-api:jar UP-TO-DATE +> Task :messaging:messaging-security:processResources NO-SOURCE +> Task :messaging:messaging-security:classes UP-TO-DATE +> Task :messaging:messaging-security:jar UP-TO-DATE +> Task :messaging:messaging-testkit:processResources UP-TO-DATE +> Task :messaging:messaging-testkit:classes UP-TO-DATE +> Task :messaging:messaging-testkit:jar UP-TO-DATE +> Task :messaging:messaging-transport-spi:processResources NO-SOURCE +> Task :messaging:messaging-transport-spi:classes UP-TO-DATE +> Task :messaging:messaging-transport-spi:jar UP-TO-DATE +> Task :messaging:messaging-kafka:messagingCertificationTest +> Task :messaging:messaging-kafka:verifyMessagingCertificationEvidence + +BUILD SUCCESSFUL in 39s +31 actionable tasks: 2 executed, 29 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-messaging-live-broker.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-messaging-live-broker.log new file mode 100644 index 00000000..30bcc94d --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-messaging-live-broker.log @@ -0,0 +1,71 @@ +$ ./gradlew :messaging:messaging-kafka:cleanTest :messaging:messaging-kafka:test --tests '*IT' :messaging:messaging-rabbit:cleanTest :messaging:messaging-rabbit:test --tests '*IT' --console=plain +(cleanTest first: the previous invocation reused 6.5-hour-old XML because the task was up-to-date) +run-at: 2026-08-20T08:03:01Z +Mem: 30Gi 17Gi 6.5Gi 1.0Gi 8.5Gi 13Gi +Starting a Gradle Daemon, 1 busy and 35 stopped Daemons could not be reused, use --status for details +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :messaging:messaging-kafka:cleanTest +> Task :messaging:messaging-core-api:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-api:compileJava UP-TO-DATE +> Task :messaging:messaging-policy:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-api:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-api:processResources NO-SOURCE +> Task :messaging:messaging-admin-api:classes UP-TO-DATE +> Task :messaging:messaging-admin-api:jar UP-TO-DATE +> Task :messaging:messaging-observability:compileJava UP-TO-DATE +> Task :messaging:messaging-security:compileJava UP-TO-DATE +> Task :messaging:messaging-transport-spi:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-runtime:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-runtime:processResources NO-SOURCE +> Task :messaging:messaging-admin-runtime:classes UP-TO-DATE +> Task :messaging:messaging-admin-runtime:jar UP-TO-DATE +> Task :messaging:messaging-core-api:processResources NO-SOURCE +> Task :messaging:messaging-core-api:classes UP-TO-DATE +> Task :messaging:messaging-core-api:jar UP-TO-DATE +> Task :messaging:messaging-kafka:compileJava UP-TO-DATE +> Task :messaging:messaging-kafka:processResources NO-SOURCE +> Task :messaging:messaging-kafka:classes UP-TO-DATE +> Task :messaging:messaging-testkit:compileJava UP-TO-DATE +> Task :messaging:messaging-kafka:compileTestJava UP-TO-DATE +> Task :messaging:messaging-kafka:processTestResources NO-SOURCE +> Task :messaging:messaging-kafka:testClasses UP-TO-DATE +> Task :messaging:messaging-observability:processResources NO-SOURCE +> Task :messaging:messaging-observability:classes UP-TO-DATE +> Task :messaging:messaging-observability:jar UP-TO-DATE +> Task :messaging:messaging-policy:processResources NO-SOURCE +> Task :messaging:messaging-policy:classes UP-TO-DATE +> Task :messaging:messaging-policy:jar UP-TO-DATE +> Task :messaging:messaging-schema-api:processResources NO-SOURCE +> Task :messaging:messaging-schema-api:classes UP-TO-DATE +> Task :messaging:messaging-schema-api:jar UP-TO-DATE +> Task :messaging:messaging-security:processResources NO-SOURCE +> Task :messaging:messaging-security:classes UP-TO-DATE +> Task :messaging:messaging-security:jar UP-TO-DATE +> Task :messaging:messaging-testkit:processResources UP-TO-DATE +> Task :messaging:messaging-testkit:classes UP-TO-DATE +> Task :messaging:messaging-testkit:jar UP-TO-DATE +> Task :messaging:messaging-transport-spi:processResources NO-SOURCE +> Task :messaging:messaging-transport-spi:classes UP-TO-DATE +> Task :messaging:messaging-transport-spi:jar UP-TO-DATE +> Task :messaging:messaging-kafka:test +> Task :messaging:messaging-rabbit:cleanTest +> Task :messaging:messaging-rabbit:compileJava UP-TO-DATE +> Task :messaging:messaging-rabbit:processResources NO-SOURCE +> Task :messaging:messaging-rabbit:classes UP-TO-DATE +> Task :messaging:messaging-rabbit:compileTestJava UP-TO-DATE +> Task :messaging:messaging-rabbit:processTestResources NO-SOURCE +> Task :messaging:messaging-rabbit:testClasses UP-TO-DATE +> Task :messaging:messaging-rabbit:test + +BUILD SUCCESSFUL in 1m 29s +35 actionable tasks: 4 executed, 31 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-compatibility.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-compatibility.log new file mode 100644 index 00000000..fe8d87ee --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-compatibility.log @@ -0,0 +1,27 @@ +$ ./gradlew :adapter:outbound:persistence-mongo:mongoCompatibilityTest --console=plain +run-at: 2026-08-20T07:31:58Z +Mem: 30Gi 19Gi 632Mi 834Mi 11Gi 11Gi +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:mongoCompatibilityTest + +BUILD SUCCESSFUL in 18s +13 actionable tasks: 1 executed, 12 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-failover.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-failover.log new file mode 100644 index 00000000..42c102a4 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-failover.log @@ -0,0 +1,27 @@ +$ ./gradlew :adapter:outbound:persistence-mongo:mongoFailoverTest --console=plain +run-at: 2026-08-20T07:33:19Z +Mem: 30Gi 19Gi 1.0Gi 839Mi 11Gi 11Gi +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:mongoFailoverTest + +BUILD SUCCESSFUL in 1m 17s +13 actionable tasks: 1 executed, 12 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-lanes-first-run-failed.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-lanes-first-run-failed.log new file mode 100644 index 00000000..ebf30dfc --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-lanes-first-run-failed.log @@ -0,0 +1,43 @@ +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:mongoStableContractTest +> Task :adapter:outbound:persistence-mongo:mongoReplicaSetTest +> Task :adapter:outbound:persistence-mongo:mongoFailoverTest + +> Task :adapter:outbound:persistence-mongo:mongoMigrationTest + +MongoMigrationLaneTest > aCheckpointSurvivesTheProcessThatWroteIt() FAILED + dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException at MongoMigrationLaneTest.java:155 + +6 tests completed, 1 failed + +> Task :adapter:outbound:persistence-mongo:mongoMigrationTest FAILED + +FAILURE: Build failed with an exception. + +* What went wrong: +Execution failed for task ':adapter:outbound:persistence-mongo:mongoMigrationTest'. +> There were failing tests. See the report at: file:///home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-mongo/build/reports/tests/mongoMigrationTest/index.html + +* Try: +> Run with --scan to generate a Build Scan (Powered by Develocity). + +BUILD FAILED in 2m 11s +16 actionable tasks: 4 executed, 12 up-to-date +EXIT=1 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-lanes.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-lanes.log new file mode 100644 index 00000000..248482e0 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-lanes.log @@ -0,0 +1,33 @@ +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:mongoStableContractTest +> Task :adapter:outbound:persistence-mongo:mongoReplicaSetTest +> Task :adapter:outbound:persistence-mongo:mongoFailoverTest +> Task :adapter:outbound:persistence-mongo:mongoMigrationTest +> Task :adapter:outbound:persistence-mongo:mongoCompatibilityTest +> Task :adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest +> Task :adapter:outbound:persistence-mongo:compileMongoPerformanceTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processMongoPerformanceTestResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:mongoPerformanceTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:mongoPerformanceTest + +BUILD SUCCESSFUL in 2m 50s +20 actionable tasks: 7 executed, 13 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +EXIT=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-migration.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-migration.log new file mode 100644 index 00000000..b90ec619 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-migration.log @@ -0,0 +1,27 @@ +$ ./gradlew :adapter:outbound:persistence-mongo:mongoMigrationTest --console=plain +run-at: 2026-08-20T07:31:22Z +Mem: 30Gi 19Gi 858Mi 817Mi 12Gi 11Gi +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:mongoMigrationTest + +BUILD SUCCESSFUL in 13s +13 actionable tasks: 1 executed, 12 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-performance.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-performance.log new file mode 100644 index 00000000..8b66210d --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-performance.log @@ -0,0 +1,27 @@ +$ ./gradlew :adapter:outbound:persistence-mongo:mongoPerformanceTest --console=plain +run-at: 2026-08-20T07:37:00Z +Mem: 30Gi 21Gi 664Mi 789Mi 9.5Gi 9.0Gi +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileMongoPerformanceTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processMongoPerformanceTestResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:mongoPerformanceTestClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:mongoPerformanceTest + +BUILD SUCCESSFUL in 26s +13 actionable tasks: 1 executed, 12 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-replicaset.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-replicaset.log new file mode 100644 index 00000000..7dcd64c7 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-replicaset.log @@ -0,0 +1,27 @@ +$ ./gradlew :adapter:outbound:persistence-mongo:mongoReplicaSetTest --console=plain +run-at: 2026-08-20T07:33:01Z +Mem: 30Gi 19Gi 1.0Gi 804Mi 11Gi 11Gi +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:mongoReplicaSetTest + +BUILD SUCCESSFUL in 9s +13 actionable tasks: 1 executed, 12 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-security.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-security.log new file mode 100644 index 00000000..4f07a615 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-mongo-security.log @@ -0,0 +1,27 @@ +$ ./gradlew :adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest --console=plain +run-at: 2026-08-20T07:34:53Z +Mem: 30Gi 19Gi 1.6Gi 795Mi 11Gi 11Gi +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE +> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest + +BUILD SUCCESSFUL in 1m 54s +13 actionable tasks: 1 executed, 12 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +exit=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-release-manifest-arbiter.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-release-manifest-arbiter.log new file mode 100644 index 00000000..a4741352 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task2-release-manifest-arbiter.log @@ -0,0 +1,164 @@ +Starting a Gradle Daemon, 1 busy and 61 stopped Daemons could not be reused, use --status for details +> Task :build-logic:extractPluginRequests UP-TO-DATE +> Task :build-logic:generatePluginAdapters UP-TO-DATE +> Task :build-logic:compileJava UP-TO-DATE +> Task :build-logic:compileGroovy UP-TO-DATE +> Task :build-logic:compileGroovyPlugins UP-TO-DATE +> Task :build-logic:pluginDescriptors UP-TO-DATE +> Task :build-logic:processResources UP-TO-DATE +> Task :build-logic:classes UP-TO-DATE +> Task :build-logic:jar UP-TO-DATE +> Task :shared-contract:compileJava UP-TO-DATE +> Task :shared-contract:processResources UP-TO-DATE +> Task :shared-contract:classes UP-TO-DATE +> Task :shared-contract:jar UP-TO-DATE +> Task :application-core:compileJava UP-TO-DATE +> Task :application-core:processResources NO-SOURCE +> Task :application-core:classes UP-TO-DATE +> Task :application-core:jar UP-TO-DATE +> Task :domain-core:compileJava UP-TO-DATE +> Task :domain-core:processResources NO-SOURCE +> Task :domain-core:classes UP-TO-DATE +> Task :domain-core:jar UP-TO-DATE +> Task :messaging:messaging-core-api:compileJava UP-TO-DATE +> Task :messaging:messaging-observability:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-api:compileJava UP-TO-DATE +> Task :messaging:messaging-policy:compileJava UP-TO-DATE +> Task :messaging:messaging-reliability-api:compileJava UP-TO-DATE +> Task :messaging:messaging-security:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-api:compileJava UP-TO-DATE +> Task :messaging:messaging-transport-spi:compileJava UP-TO-DATE +> Task :messaging:messaging-admin-runtime:compileJava UP-TO-DATE +> Task :messaging:messaging-claim-check:compileJava UP-TO-DATE +> Task :messaging:messaging-cloudevents:compileJava UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:compileJava UP-TO-DATE +> Task :messaging:messaging-kafka:compileJava UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:compileJava UP-TO-DATE +> Task :messaging:messaging-rabbit:compileJava UP-TO-DATE +> Task :messaging:messaging-runtime-core:compileJava UP-TO-DATE +> Task :messaging:messaging-schema-json:compileJava UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:compileJava UP-TO-DATE +> Task :adapter:inbound:graphql:compileJava UP-TO-DATE +> Task :adapter:inbound:graphql:processResources UP-TO-DATE +> Task :adapter:inbound:graphql:classes UP-TO-DATE +> Task :adapter:inbound:graphql:jar UP-TO-DATE +> Task :adapter:inbound:web:compileJava UP-TO-DATE +> Task :adapter:inbound:web:processResources NO-SOURCE +> Task :adapter:inbound:web:classes UP-TO-DATE +> Task :adapter:inbound:web:jar UP-TO-DATE +> Task :adapter:outbound:support:compileJava UP-TO-DATE +> Task :adapter:outbound:support:processResources NO-SOURCE +> Task :adapter:outbound:support:classes UP-TO-DATE +> Task :adapter:outbound:support:jar UP-TO-DATE +> Task :adapter:outbound:cache-redis:compileJava UP-TO-DATE +> Task :adapter:outbound:cache-redis:processResources UP-TO-DATE +> Task :adapter:outbound:cache-redis:classes UP-TO-DATE +> Task :adapter:outbound:cache-redis:jar UP-TO-DATE +> Task :adapter:outbound:fileserver:compileJava UP-TO-DATE +> Task :adapter:outbound:fileserver:processResources NO-SOURCE +> Task :adapter:outbound:fileserver:classes UP-TO-DATE +> Task :adapter:outbound:fileserver:jar UP-TO-DATE +> Task :adapter:outbound:httpclient:compileJava UP-TO-DATE +> Task :adapter:outbound:httpclient:processResources NO-SOURCE +> Task :adapter:outbound:httpclient:classes UP-TO-DATE +> Task :adapter:outbound:httpclient:jar UP-TO-DATE +> Task :adapter:outbound:identifier:compileJava UP-TO-DATE +> Task :adapter:outbound:identifier:compileGroovy NO-SOURCE +> Task :adapter:outbound:identifier:processResources NO-SOURCE +> Task :adapter:outbound:identifier:classes UP-TO-DATE +> Task :adapter:outbound:identifier:jar UP-TO-DATE +> Task :adapter:outbound:messaging:compileJava UP-TO-DATE +> Task :adapter:outbound:messaging:processResources UP-TO-DATE +> Task :adapter:outbound:messaging:classes UP-TO-DATE +> Task :adapter:outbound:messaging:jar UP-TO-DATE +> Task :adapter:outbound:notification:compileJava UP-TO-DATE +> Task :adapter:outbound:notification:processResources NO-SOURCE +> Task :adapter:outbound:notification:classes UP-TO-DATE +> Task :adapter:outbound:notification:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:jar UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE +> Task :adapter:outbound:persistence-mongo:jar UP-TO-DATE +> Task :app-bootstrap:compileJava UP-TO-DATE +> Task :app-bootstrap:processResources UP-TO-DATE +> Task :app-bootstrap:classes UP-TO-DATE +> Task :sample-portfolio:compileJava UP-TO-DATE +> Task :sample-portfolio:processResources UP-TO-DATE +> Task :sample-portfolio:classes UP-TO-DATE +> Task :sample-portfolio:jar UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE +> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE +> Task :adapter:outbound:persistence-jpa:testkitJar UP-TO-DATE +> Task :app-bootstrap:compileTestJava UP-TO-DATE +> Task :app-bootstrap:runtimeClasspathManifest UP-TO-DATE +> Task :app-bootstrap:processTestResources UP-TO-DATE +> Task :app-bootstrap:testClasses UP-TO-DATE +> Task :messaging:messaging-admin-api:processResources NO-SOURCE +> Task :messaging:messaging-admin-api:classes UP-TO-DATE +> Task :messaging:messaging-admin-api:jar UP-TO-DATE +> Task :messaging:messaging-admin-runtime:processResources NO-SOURCE +> Task :messaging:messaging-admin-runtime:classes UP-TO-DATE +> Task :messaging:messaging-admin-runtime:jar UP-TO-DATE +> Task :messaging:messaging-claim-check:processResources NO-SOURCE +> Task :messaging:messaging-claim-check:classes UP-TO-DATE +> Task :messaging:messaging-claim-check:jar UP-TO-DATE +> Task :messaging:messaging-cloudevents:processResources NO-SOURCE +> Task :messaging:messaging-cloudevents:classes UP-TO-DATE +> Task :messaging:messaging-cloudevents:jar UP-TO-DATE +> Task :messaging:messaging-core-api:processResources NO-SOURCE +> Task :messaging:messaging-core-api:classes UP-TO-DATE +> Task :messaging:messaging-core-api:jar UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:processResources UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:classes UP-TO-DATE +> Task :messaging:messaging-inbox-jdbc-postgresql:jar UP-TO-DATE +> Task :messaging:messaging-kafka:processResources NO-SOURCE +> Task :messaging:messaging-kafka:classes UP-TO-DATE +> Task :messaging:messaging-kafka:jar UP-TO-DATE +> Task :messaging:messaging-observability:processResources NO-SOURCE +> Task :messaging:messaging-observability:classes UP-TO-DATE +> Task :messaging:messaging-observability:jar UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:processResources UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:classes UP-TO-DATE +> Task :messaging:messaging-outbox-jdbc-postgresql:jar UP-TO-DATE +> Task :messaging:messaging-policy:processResources NO-SOURCE +> Task :messaging:messaging-policy:classes UP-TO-DATE +> Task :messaging:messaging-policy:jar UP-TO-DATE +> Task :messaging:messaging-rabbit:processResources NO-SOURCE +> Task :messaging:messaging-rabbit:classes UP-TO-DATE +> Task :messaging:messaging-rabbit:jar UP-TO-DATE +> Task :messaging:messaging-reliability-api:processResources NO-SOURCE +> Task :messaging:messaging-reliability-api:classes UP-TO-DATE +> Task :messaging:messaging-reliability-api:jar UP-TO-DATE +> Task :messaging:messaging-runtime-core:processResources NO-SOURCE +> Task :messaging:messaging-runtime-core:classes UP-TO-DATE +> Task :messaging:messaging-runtime-core:jar UP-TO-DATE +> Task :messaging:messaging-schema-api:processResources NO-SOURCE +> Task :messaging:messaging-schema-api:classes UP-TO-DATE +> Task :messaging:messaging-schema-api:jar UP-TO-DATE +> Task :messaging:messaging-schema-json:processResources NO-SOURCE +> Task :messaging:messaging-schema-json:classes UP-TO-DATE +> Task :messaging:messaging-schema-json:jar UP-TO-DATE +> Task :messaging:messaging-security:processResources NO-SOURCE +> Task :messaging:messaging-security:classes UP-TO-DATE +> Task :messaging:messaging-security:jar UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:processResources UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:classes UP-TO-DATE +> Task :messaging:messaging-spring-boot-starter:jar UP-TO-DATE +> Task :messaging:messaging-transport-spi:processResources NO-SOURCE +> Task :messaging:messaging-transport-spi:classes UP-TO-DATE +> Task :messaging:messaging-transport-spi:jar UP-TO-DATE +> Task :adapter:outbound:objectstorage:compileJava UP-TO-DATE +> Task :adapter:outbound:objectstorage:processResources NO-SOURCE +> Task :adapter:outbound:objectstorage:classes UP-TO-DATE +> Task :adapter:outbound:objectstorage:jar UP-TO-DATE +> Task :app-bootstrap:test + +BUILD SUCCESSFUL in 1m 6s +94 actionable tasks: 1 executed, 93 up-to-date +Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html +EXIT=0 diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task3-activation-matrix.md b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task3-activation-matrix.md new file mode 100644 index 00000000..6478f7f7 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task3-activation-matrix.md @@ -0,0 +1,104 @@ +# Task 3 — the ten-row activation matrix + +Every row below names where its evidence is, and nothing is ticked from reasoning about the code. +Rows 1–7 and 10 are runtime lanes from one Compose matrix run, `20260819T025416Z-200172`; rows 8 and 9 +needed work this wave and are described in full. + +The per-lane `activation.json` is the application's own answer about which switches are on, not the +flags the harness passed in — which is the point of reading it rather than the lane definition. + +| # | Matrix | Evidence | Resolved switches (from `activation.json`) | +| --- | --- | --- | --- | +| 1 | five off | `off-local`, `off-dev`, `off-prod` lanes + `FiveAdapterOffInventoryTest` | `local` / `dev` / `prod`, **none on** | +| 2 | JPA only | `local-jpa` lane | `local`, `persistence-jpa` | +| 3 | Mongo only | `local-mongo` lane | `local`, `persistence-mongo` | +| 4 | Messaging only | `local-messaging` lane | `local`, `messaging` | +| 5 | Notification + JPA, `INGEST_ONLY` | `local-notification-ingest` + `local-notification-handoff` | `local`, `persistence-jpa`+`notification.platform`, `APP_NOTIFICATION_PLATFORM_MODE: INGEST_ONLY` | +| 6 | Notification + JPA, `SERVING` | `local-notification-serving` | same switches, `MODE: SERVING` | +| 7 | GraphQL only | `local-graphql` lane | `local`, `graphql` | +| 8 | relay on, dependency missing | **this wave** — see below | n/a: the deployment is refused | +| 9 | JPA + Mongo | `all-adapters` lane + `PortResolutionContractTest` | `local`, both persistence switches on | +| 10 | five on | `all-adapters` lane | `local`, `graphql`,`messaging`,`persistence-jpa`,`persistence-mongo`,`notification.platform` | + +Row 5's exactly-once claim is not inferred from the lane passing; the smoke client says it: + +``` +notification-smoke: b0e083cb-06c5-4dde-b454-4c4e26f65edc delivered exactly once and stayed that way +``` + +## Row 8 — a defect, found by running the row + +The plan is explicit that Row 8 must be checked for the *name*, not merely for a failure. Checking it +that way found that the name was not what an operator got. + +`ca-skeleton.outbox.enabled=true` with the JPA switch off is a dependency error this repository +names, and `CapabilityDependencyValidator` produces the sentence that names it. But the outbox is +also a registered relational consumer, so `DataSourceRequirement` reports that a pool is required, +`JpaOffAutoConfigurationImportFilter` therefore keeps Boot's relational auto-configurations in the +candidate set, and Hibernate and Flyway were instantiated during refresh — ahead of the +`InitializingBean` that carried the check. What actually came out was: + +``` +Unable to obtain connection from database: Connection to localhost:5433 refused. +``` + +Both components were right on their own. A pool does have consumers besides JPA, and the outbox does +need the JPA switch. The disagreement was only ever visible in **which one spoke first**, and no test +could see it: the existing `CapabilityDependencyValidatorTest` calls the validator's static method +against a `MockEnvironment`, which proves the rule computes the right sentence and nothing about +whether anything runs it in time. + +**Fix.** The check moved to the environment stage +(`CapabilityDependencyEnvironmentValidator`, an `EnvironmentPostProcessor` at +`LOWEST_PRECEDENCE` alongside the master-switch and profile validators), where every property +source is resolved and nothing has been instantiated. The `InitializingBean` stays: a context built +without `spring.factories` — an `ApplicationContextRunner`, a slice test — never reaches the +post-processor, and the rule should not be optional there. + +**Evidence, from the built jar rather than from a test harness** — see +[task3-row8-dependency-error.log](task3-row8-dependency-error.log): + +``` +### outbox on, JPA off exit code: 1 +This deployment enables capabilities whose dependencies are off: + - ca-skeleton.outbox.enabled=true needs relational persistence to store rows; + set ca-skeleton.persistence-jpa.enabled=true or turn the outbox off. + - ca-skeleton.outbox.enabled=true needs somewhere to publish; + set app.messaging.enabled=true or turn the outbox off. + +### relay on, outbox off exit code: 1 +This deployment enables capabilities whose dependencies are off: + - ca-skeleton.outbox.relay-enabled=true only starts the scheduler for a capability that is off; + set ca-skeleton.outbox.enabled=true or turn the relay off. +``` + +`DependencyErrorStartupContractTest` pins this at the composition-root level, including a case that +boots all-off successfully — without it, every other assertion in that class would also be satisfied +by a validator that refuses everything. + +## Row 9 — the positive half runs; the conflict half is pinned as a rule + +`all-adapters` starts PostgreSQL and MongoDB and the application together, and the application +reports both persistence switches on. The two adapters implement disjoint ports, so there is no +ambiguity to resolve and no `@Primary` involved. + +That absence is what needs pinning, because manufacturing a conflict would test Spring's +`NoUniqueBeanDefinitionException` rather than this repository. What can silently change is the +property the row depends on: that no port is resolved by preferring one bean over another. A +`@Primary` added later to settle an ambiguity would convert a startup rejection into a silent pick, +and every existing test would stay green, because the composition would still boot. + +`PortResolutionContractTest` states the rule directly — a `@Primary` on a port implementation is +permitted only when a condition decides which one is active, which makes it a selector rather than a +tiebreak. The whole repository has three `@Primary` beans: + +| bean | type | guard | +| --- | --- | --- | +| `inProcessDistributedLock` | `DistributedLockPort` | `multi-instance-enabled` false or absent | +| `distributedLockProvider` | `DistributedLockPort` | `multi-instance-enabled` true | +| `applicationTaskExecutor` | `TaskExecutor` | none — Boot's own contract wants a primary executor, and it is not a port | + +The two lock beans cannot coexist, so neither is being preferred; one of them simply is not there. + +The rule was falsified before being trusted: removing the `@ConditionalOnProperty` from +`distributedLockProvider` fails the test, and restoring it passes. diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task3-matrix-to-lane-map.md b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task3-matrix-to-lane-map.md new file mode 100644 index 00000000..1f47c6a8 --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task3-matrix-to-lane-map.md @@ -0,0 +1,26 @@ +# Task 3 — the activation matrix, and where each row's evidence comes from + +The plan's matrix is ten rows; the Compose contract is fifteen lanes; the in-process activation +suite is nineteen classes. They are not three copies of one thing, so this records which artefact +answers which row before anything is run. A row whose evidence is a passing unit test is recorded as +that, not as a runtime lane. + +| # | Matrix row | Evidence | +| --- | --- | --- | +| 1 | five off | `off-local`, `off-dev`, `off-prod` lanes + `FiveAdapterOffInventoryTest` (bean/thread/endpoint inventory is in-process; a container cannot count beans) | +| 2 | JPA only | `local-jpa` lane (db + app) | +| 3 | Mongo only | `local-mongo` lane (mongo + rs-init + app) | +| 4 | Messaging only | `local-messaging` lane; `local-messaging-outbox` additionally proves the relay's database dependency | +| 5 | Notification + JPA, `INGEST_ONLY` | `local-notification-ingest`, and `local-notification-handoff` for the accept → restart → deliver-once half | +| 6 | Notification + JPA, `SERVING` | `local-notification-serving` | +| 7 | GraphQL only | `local-graphql` lane (keycloak + auth-smoke + graphql-smoke) | +| 8 | relay on, dependency missing | **no lane** — a startup that must be *rejected* is an in-process contract: `DependencyErrorStartupContractTest`, `CapabilityDependencyValidatorTest`. The plan requires the *name* of the missing switch, not merely a failure. | +| 9 | JPA + Mongo | `all-adapters` covers the both-on half; the two-implementations-of-one-port rejection is in-process (`CleanArchitectureTest` plus the composition tests), because it is a context-startup outcome | +| 10 | five on | `all-adapters` lane | + +Rows 8 and 9 are deliberately not Compose lanes. Both are assertions that a context *refuses* to +start for a stated reason, and a Compose lane can only observe that a container exited — which is +the same observation for a missing switch, a bad password and a typo in a YAML key. + +Lanes with no matrix row of their own — `shared-infra-local`, `shared-infra-dev`, `prod-smoke` — +carry Task 4's environment evidence rather than Task 3's activation evidence. diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task3-row8-dependency-error.log b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task3-row8-dependency-error.log new file mode 100644 index 00000000..739a447c --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task3-row8-dependency-error.log @@ -0,0 +1,17 @@ +# Task 3 Row 8 — a capability on with its dependency off: the real jar, the real exit code + +### outbox on, JPA off +``` +java.lang.IllegalStateException: This deployment enables capabilities whose dependencies are off: + - ca-skeleton.outbox.enabled=true needs relational persistence to store rows; set ca-skeleton.persistence-jpa.enabled=true or turn the outbox off. + - ca-skeleton.outbox.enabled=true needs somewhere to publish; set app.messaging.enabled=true or turn the outbox off. +exit code: 1 +``` + +### relay on, outbox off +``` +java.lang.IllegalStateException: This deployment enables capabilities whose dependencies are off: + - ca-skeleton.outbox.relay-enabled=true only starts the scheduler for a capability that is off; set ca-skeleton.outbox.enabled=true or turn the relay off. +exit code: 1 +``` + diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task6-p0-reconciliation.md b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task6-p0-reconciliation.md new file mode 100644 index 00000000..af9bc2eb --- /dev/null +++ b/docs/superpowers/plans/evidence/2026-08-15-wave6-final/task6-p0-reconciliation.md @@ -0,0 +1,165 @@ +# Task 6 — the five reviews' P0 findings, reconciled + +The remediation design this wave executed never referenced a review id (`grep -c` over it: zero). +It took one theme from the five reviews — five adapters ship and do not run — and Waves 0–6 executed +that theme. So "how much of the reviews is reflected" had never been measured. This is the first +measurement, and then the work that followed it. + +Three read-only audits ran in parallel, one per review family, each instructed to treat a passing +test as evidence only when it exercises the real composition. Their headline claims were then +re-checked by hand before being acted on; one was wrong and is recorded as such below. + +## Where the P0 set stood when measured + +| Review | P0 | CLOSED | PARTIAL | OPEN | +| --- | ---: | ---: | ---: | ---: | +| jpa | 6 | 5 | 1 | 0 | +| messaging | 5 | 2 | 3 | 0 | +| graphql | 2 | 1 | 1 | 0 | +| notification | 12 | 1 | 11 | 0 | +| mongodb | 9 | 0 | 9 | 0 | +| **total** | **34** | **9** | **25** | **0** | + +Nothing was OPEN: every P0 had been worked. What the audits found instead, in three independent +voices, was the same shape — **the implementation is substantially real and the gate under it is +thin**. Six notification adapter classes were referenced by zero tests. Both Mongo executors were +referenced by zero tests. Every messaging real-broker test skips silently without Docker. And the +Compose matrix, the strongest evidence this repository produces, runs in no workflow. + +## What was fixed, and how each was proven + +### NTF-004 / JPA-004 — the completion write was not fenced + +Two audits reached this independently from different reviews, which is why it was taken first. + +The claim is fenced (a single `FOR UPDATE SKIP LOCKED` CTE that bumps `lease_fence`) and the renew +is fenced. The *completion* was `findById → mutate → saveAndFlush` with no owner or fence predicate. +A worker whose lease expired during the provider call — the one stretch the platform deliberately +spends outside a transaction — came back and wrote its outcome over the row a new holder had already +claimed. The `@Version` column does not stop that: it detects a concurrent edit, not a superseded +writer, and the late worker's read is recent enough to win. + +Fixed with conditional statements in the same idiom as the renew, `saveHeldBy`/`transitionHeldBy` on +the port returning empty when the lease is gone, and all four branches of `applyNextAction` moved +onto them. Losing the lease is not an error: the new holder owns the job and will record its own +outcome. + +`@Modifying(clearAutomatically = true)` on both, because a native update bypasses the persistence +context and the immediate re-read would otherwise be served the values it just replaced — a trap one +of the audits had found elsewhere in this same platform. + +**Proof:** two real-PostgreSQL cases in the JPA contract lane. A superseded holder's completion +matches zero rows and the live holder's state is untouched; the current holder's completion writes. +The second exists because without it the first is satisfied by a statement that matches nothing ever. + +### NTF-012 — the SSRF guard had no callers + +`requireExternallyRoutable` refuses the cloud metadata service, RFC 1918, link-local, IPv6 local, +userinfo disguise and multi-answer DNS. It had an eight-case test suite, all green, and **one +occurrence in the repository: its own definition.** The two sites it was written for — +`WebhookSubscription` and `SesProviderProperties` — still called `requireSecureOrLoopback`, which +reads the scheme and nothing else. + +Both now call it. The new test goes through the constructors rather than the helper, because testing +the helper again is exactly what failed to catch this. + +What is not closed: `allowLoopback` is true, so a user-supplied target naming `localhost` still +passes. Closing it means the allowance becomes a decision the caller states, and the caller does not +exist — WEBHOOK has no `ProviderRuntimeAssembler`, so a webhook profile refuses to boot and nothing +in production constructs the record. Writing the policy now would mean choosing its default with no +caller to check it against. Recorded in the code at the call site. + +### NTF-001 — the documented callback switch broke startup + +`CallbackRequestFactory` is a constructor argument of the MVC controller, the WebFlux handler and the +WebFlux configuration, and was produced by no production code — the only instantiation was in a test. +So `APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED=true`, a key in the env registry and in the +configuration reference, did not enable callbacks; it failed the boot on an unsatisfied dependency. + +The beans now exist, conditioned on the same switch. The missing `trusted-proxies` setting came with +them, defaulting to empty — with no entry, forwarded headers are not believed, because honouring +them unconditionally lets any caller choose the URL its own signature is checked against. Registered +in `application.yml`, the env registry, `.env.example` and the configuration reference; both +notification gates pass. + +### MNG-007 — a health view Actuator could not read, and a reactive half nothing built + +`MongoPlatformHealthIndicator` computed topology mismatch, secondary availability and a bounded +detail map, and implemented neither `HealthIndicator` nor `HealthContributor`. The adapter carries no +Actuator dependency and should not: the delivery platform had already established the shape, where +the adapter computes the facts and the composition root maps them onto `Health`. Done the same way. + +Separately, every reactive class in the leaf — executor, consistency binder, session factory, cursor +guard — was declared by no configuration. They shipped and no configuration could construct them. +Now wired in a nested configuration conditioned on a `ReactiveMongoTemplate` *bean*, not just the +class: the class is on the compile classpath unconditionally, so a class condition alone would try to +build the reactive path in a servlet-only deployment and fail a startup for a capability nobody asked +for. Both the positive and the negative case are asserted. + +### MNG-005 / MNG-023 — a deadline that only produced a report + +The blocking executor compared elapsed time to the declared timeout *after* the callback returned, +and said so in its own comment: a Java callback cannot be interrupted mid driver call. That is an +overrun report, not a deadline. + +The scoped API is the narrowed surface where the number can actually be sent, so every method taking +a `Query` or an `Aggregation` now carries it as `maxTimeMS`, which the server enforces. `insert` has +no query to attach it to. `Duration.ZERO` is refused, because zero means "no limit" to the server and +accepting it would turn a misconfiguration into an unbounded operation. + +The raw escape hatches (`rawOperations()`, `executeInternal(...)`) are genuinely used inside the +platform by the geospatial, atomic and bulk operations, and their callers live in sibling packages, +so package-private cannot express the rule. It is enforced as a boundary from the composition root — +the only place that sees both the platform and everything consuming it. The rule was falsified by +widening its scope until it fired, and a second case asserts the platform still uses them, so the +rule cannot pass by the hatches having been deleted. + +### MNG-006 — a guard that compared a declaration against nothing + +`LocalDateTimeMappingGuard` was constructed `withoutConverters()` and then asked to validate the +manifest. It could only ever reject `LOCAL_DATE_TIME_WITH_REGISTERED_CONVERTER`: a deployment that +*had* registered the named converter was rejected exactly as loudly as one that had not, so the check +that exists to distinguish those two cases could not tell them apart. It now reads the converters the +deployment actually registered, and a test asserts the same manifest passes with the converter and +fails without it. + +### MNG-008 — a promotion gate that required five of the six categories it declares + +`MongoAdvancedPromotionEvidence.REQUIRED` listed six; `MongoAdvancedPromotionGate.verify()` required +five. `migration` was missing, so a promotion could pass with no migration evidence at all. + +### GQL-002 — the batch policy applied to nothing + +`GraphQlBatchLoaderRegistrar` carried the chunking, the budget and the request scope, was unit +tested, and was declared by no configuration — the only file mentioning it was itself. A field +resolving through `@BatchMapping` or a `DataLoader` met none of it. The chain +(`BatchPolicyRegistry → DataLoaderFactory → BatchLoaderRegistrar`) is now assembled by the platform. +The batch ceiling reuses `maximumPageSize` rather than adding a setting: both answer how many rows +one downstream call may ask for, and a batch limit above the page limit would let one request fan out +past the bound it already accepted. + +## One thing an audit got wrong, and one fix that was reverted + +The mongodb audit reported that the UUID axis is "declaration-only" and mapped onto the driver +nowhere. It is mapped — `MongoClientSettingsFactory` calls `.uuidRepresentation(...)`. Verified +before acting. + +Acting on the adjacent concern — the manifest and the profile describing the same fact independently +— a startup check was written to refuse a disagreement between them. Writing its test showed the +disagreement cannot occur: `MongoUuidRepresentation` has two values, only `STANDARD` is writable, and +an existing check already refuses the other. **A guard for a state that cannot arise is the same +"declaration nothing checks" this wave has been removing**, so it was reverted rather than kept with +an unfalsifiable test. + +## What remains, and why + +- **NTF-012 loopback residue** — belongs with the change set that gives WEBHOOK an assembler. +- **MSG-015** — the application-owned port and anti-corruption bridge between `application-core` and + the messaging platform. The review names it and specifies the target shape; it is a runtime wiring + change, and the review itself says the physical work is a separate change set. +- **MSG-003/004/005 residue** — on code no deployment can execute: the platform's outbox and inbox + migrations are applied only by tests, the production Kafka transport is publish-only, and Rabbit + declares no transport bean. +- **P1 and P2 — 100 findings, never audited.** They were not in this remediation's scope and their + state is unknown. Saying so is the honest position; the P0 audit took three parallel agents and + the P1/P2 set is three times larger. diff --git a/infra/graphql/smoke/graphql-smoke.sh b/infra/graphql/smoke/graphql-smoke.sh new file mode 100755 index 00000000..7927b459 --- /dev/null +++ b/infra/graphql/smoke/graphql-smoke.sh @@ -0,0 +1,75 @@ +#!/bin/sh +# The GraphQL transport, exercised as a request rather than as a bean inventory. +# +# auth-smoke proves the realm issues a usable token and that the application answers its public +# health path. It never sends an authenticated request, and never touches /graphql at all — so the +# JWT decoder, the security filter chain and the GraphQL execution path were each covered by their +# own tests and by nothing that put them in one line together. +# +# Three requests, in this order, because each is only meaningful given the one before: +# +# 1. an unauthenticated query, which must be refused — otherwise steps 2 and 3 prove nothing about +# authentication, they just prove the endpoint answers; +# 2. a malformed token, which must be refused without a server error — a 500 here means the +# decoder threw where it should have rejected; +# 3. the real token, which must return the schema's liveness field — the request path, the policy +# instrumentation and the resolver, in one call. +set -eu + +SECRET_FILE="/run/secrets/keycloak-graphql-smoke-client-secret" +GRAPHQL_PATH="${GRAPHQL_PATH:-/graphql}" +QUERY='{"query":"{ _health }"}' + +fail() { echo "graphql-smoke: $1" >&2; exit 1; } + +post() { + # $1 = Authorization header value, or empty for none. Prints the status code; body to /tmp/gql.json. + if [ -n "$1" ]; then + curl -s -o /tmp/gql.json -w '%{http_code}' -X POST "${APP_BASE_URL}${GRAPHQL_PATH}" \ + -H "Authorization: $1" -H 'Content-Type: application/json' -d "${QUERY}" + else + curl -s -o /tmp/gql.json -w '%{http_code}' -X POST "${APP_BASE_URL}${GRAPHQL_PATH}" \ + -H 'Content-Type: application/json' -d "${QUERY}" + fi +} + +# 1. no credential at all +ANON_STATUS="$(post '')" +case "${ANON_STATUS}" in + 401|403) : ;; + 200) fail "an unauthenticated GraphQL query was answered (${ANON_STATUS}); /graphql is not guarded" ;; + *) fail "an unauthenticated GraphQL query answered ${ANON_STATUS}: $(cat /tmp/gql.json)" ;; +esac + +# 2. a credential that is not a token +BAD_STATUS="$(post 'Bearer not-a-real-token')" +[ "${BAD_STATUS}" != "500" ] \ + || fail "a malformed token produced a server error rather than a refusal" +case "${BAD_STATUS}" in + 401|403) : ;; + *) fail "a malformed token answered ${BAD_STATUS}, which is neither a refusal nor a server error" ;; +esac + +# 3. the real thing +[ -r "${SECRET_FILE}" ] || fail "the client secret was not mounted" +TOKEN_RESPONSE="$(curl -sf -X POST "${KEYCLOAK_ISSUER}/protocol/openid-connect/token" \ + -d grant_type=client_credentials \ + -d "client_id=${KEYCLOAK_CLIENT_ID}" \ + --data-urlencode "client_secret=$(cat "${SECRET_FILE}")")" \ + || fail "client-credentials token request failed" +ACCESS_TOKEN="$(echo "${TOKEN_RESPONSE}" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')" +[ -n "${ACCESS_TOKEN}" ] || fail "the token response carried no access_token" + +OK_STATUS="$(post "Bearer ${ACCESS_TOKEN}")" +[ "${OK_STATUS}" = "200" ] \ + || fail "an authenticated GraphQL query answered ${OK_STATUS}: $(cat /tmp/gql.json)" + +# The body, not just the status. A 200 carrying a GraphQL `errors` array is how a refused or failed +# execution looks over HTTP, so a status-only check would pass on an unresolved field. +grep -q '"_health"' /tmp/gql.json \ + || fail "the response carried no _health field: $(cat /tmp/gql.json)" +if grep -q '"errors"' /tmp/gql.json; then + fail "the query returned GraphQL errors: $(cat /tmp/gql.json)" +fi + +echo "graphql-smoke: /graphql refused anonymous and malformed credentials and answered the authenticated query" diff --git a/src/.env.example b/src/.env.example index 95650cca..c2cf88b5 100644 --- a/src/.env.example +++ b/src/.env.example @@ -331,6 +331,8 @@ APP_NOTIFICATION_PLATFORM_ALLOW_AMBIGUOUS_FALLBACK=false APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED=false APP_NOTIFICATION_PLATFORM_CALLBACK_MAX_BODY_BYTES=65508 APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW=5m +# Empty: forwarded headers are not believed. List load-balancer peers to honour them. +APP_NOTIFICATION_PLATFORM_CALLBACK_TRUSTED_PROXIES= # ---- Secrets — supply out of band; never commit a value here ------------------ APP_DATASOURCE_PASSWORD= diff --git a/src/Dockerfile b/src/Dockerfile index 786ea06c..c7986542 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -40,6 +40,11 @@ COPY gradlew ./ COPY gradle/ gradle/ COPY config/ ./config/ COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./ +# The convention plugins, whole. The glob above copies files named build.gradle and gradle.lockfile, +# which picks up build-logic's own build script and misses the precompiled script plugins beside it — +# so settings.gradle's `includeBuild('build-logic')` resolved against a build that declared no +# plugins and every leaf failed on an unknown plugin id, in the dependency-resolution stage below. +COPY build-logic/ ./build-logic/ # Resolve every module configuration in STRICT mode (no --write-locks in a release build). This # custom task fails on drift; Gradle's diagnostic `dependencies` report can print FAILED entries diff --git a/src/README.md b/src/README.md index d229319c..177e86e5 100644 --- a/src/README.md +++ b/src/README.md @@ -241,6 +241,59 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 - **`APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`** — 백로그 큐 용량. **bounded(유한) 필수, unbounded 금지(D7)**. 1 이상 정수. +### 다섯 master switch (activation SSOT) + +한 bootJar가 다섯 어댑터를 모두 싣고, 각각은 아래 스위치 하나로만 켜집니다. **전부 기본 `false`** 이고, +다섯이 모두 꺼진 배포는 외부 자원 없이 기동합니다. 값의 SSOT는 +`dev.caskeleton.shared.activation.MasterSwitch`이며 `docs/registries/env-keys.yaml`이 같은 이름을 +등록합니다. + +| 환경 변수 | Spring property | 기본값 | +|---|---|---| +| `APP_PERSISTENCE_JPA_ENABLED` | `ca-skeleton.persistence-jpa.enabled` | `false` | +| `APP_PERSISTENCE_MONGO_ENABLED` | `ca-skeleton.persistence-mongo.enabled` | `false` | +| `APP_MESSAGING_ENABLED` | `app.messaging.enabled` | `false` | +| `APP_NOTIFICATION_PLATFORM_ENABLED` | `ca-skeleton.notification.platform.enabled` | `false` | +| `APP_GRAPHQL_ENABLED` | `backend.graphql.enabled` | `false` | + +**어댑터가 꺼진 것과 없는 것은 다릅니다.** 다섯 어댑터의 클래스는 언제나 아티팩트 안에 있고, 운영자는 +재빌드 없이 스위치만으로 켭니다. 클래스가 없으면 애초에 켤 수 없습니다. + +켜진 capability가 의존하는 것이 꺼져 있으면 기동이 거부되고, **거부 메시지는 설정해야 할 정확한 +property 이름을 말합니다**(`CapabilityDependencyValidator`). 예: + +``` +This deployment enables capabilities whose dependencies are off: + - ca-skeleton.outbox.enabled=true needs relational persistence to store rows; + set ca-skeleton.persistence-jpa.enabled=true or turn the outbox off. +``` + +종속 선택자 둘: + +- `APP_GRAPHQL_DEPLOYMENT_MODE` — GraphQL이 켜지면 **필수**이고 기본값이 없습니다. 예전의 boolean과 + enum 두 기본값이 서로 다른 말을 했기 때문에, 안전 태세는 배포가 명시적으로 고릅니다. 허용되는 값은 + 런타임 환경(`local`/`dev`/`prod`)마다 다릅니다. +- `APP_NOTIFICATION_PLATFORM_MODE` — `SERVING`(기본) 또는 `INGEST_ONLY`. 선택 사항이고, 허용 값은 + `NotificationModeSsotTest`가 enum에서 파생합니다. + +### Compose와 런타임 스모크 + +- **Docker Compose 최소 버전 `2.24.4`.** SSOT는 `src/config/runtime/compose-profile-contracts.json` + 이고, 정본 스크립트가 검증합니다. +- 진입점은 둘뿐이고, 그 둘만이 증거입니다. 워크플로에 명령 일부를 인라인하면 one-shot 없이 도는 레인이 + 초록으로 보고됩니다. + +```bash +# 정적: profile별 정확한 service set, 병합된 모델 전체, mount target 유일성 +./scripts/verify-compose-profile-contracts.sh + +# 동적: 15개 blocking 레인을 zero-skip으로. create → up --wait → 필수 one-shot → +# sanitized evidence → 고유 project teardown +./scripts/run-compose-runtime-smoke.sh --matrix src/config/runtime/compose-profile-contracts.json +``` + +`--lane `는 실패 재현용이고, 레인 하나가 초록인 것은 matrix가 통과했다는 증거가 아닙니다. + ### Optional integration adapters 선택형 Kafka / Redis / Slack / Google Email 어댑터 템플릿입니다. **기본은 전부 비활성**(비활성 = 선택 @@ -252,13 +305,18 @@ fail-fast sentinel 이 포트를 충족합니다(Layer 3). `disabled`(기본) | `redis`. `redis`는 canonical Redis CACHE role binding을 함께 요구합니다. - **`APP_CACHE_REDIS_CLIENT_MODE`** — `managed`는 내장 Lettuce runtime, `external`은 프로젝트가 제공한 `RedisClient` bean을 사용합니다. -- **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). 빈 값 = 메시징 비활성(사용 시 - fail-fast). +아래 세 키는 **활성화 스위치가 아니라 선택자**입니다. 어떤 capability가 켜지는지는 위의 master switch가 +정하고, 이 값들은 켜진 capability가 *무엇으로* 동작할지만 고릅니다. 예전에는 "빈 값 = 비활성"으로 +설명돼 있었고, 그 문장이 남아 있는 동안 두 개의 활성화 모델이 공존했습니다. + +- **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). `APP_MESSAGING_ENABLED=true`일 때 + **필수**이고, 빈 값이면 기동이 거부되면서 이 키 이름을 지목합니다. 이 키를 비워도 메시징이 꺼지지는 + 않습니다 — 끄는 것은 master switch입니다. - **`APP_MESSAGING_KAFKA_BROKERS`** — `host:port` CSV. `APP_MESSAGING_BROKER=kafka` 일 때만 필수, 아니면 빈 값. -- **`APP_NOTIFICATION_SLACK_PROVIDER`** — 활성 Slack provider id(예: `webhook`). 빈 값 = Slack 비활성. -- **`APP_NOTIFICATION_EMAIL_PROVIDER`** — 활성 email provider id(예: `google-email`). 빈 값 = email - 비활성. +- **`APP_NOTIFICATION_SLACK_PROVIDER`** — Slack provider id(예: `webhook`). notification 플랫폼이 + 켜졌을 때 어떤 provider를 쓸지 고르는 값입니다. +- **`APP_NOTIFICATION_EMAIL_PROVIDER`** — email provider id(예: `google-email`). 위와 같습니다. ### Outbound HTTP client diff --git a/src/adapter/inbound/graphql/CLAUDE.md b/src/adapter/inbound/graphql/CLAUDE.md index 914e59aa..434aeacc 100644 --- a/src/adapter/inbound/graphql/CLAUDE.md +++ b/src/adapter/inbound/graphql/CLAUDE.md @@ -69,7 +69,10 @@ SSOT(`src/config/architecture/modules.json`)까지 밀어올리지 않는다는 ## Allowed -- `:application-core`, `:domain-core`, `:shared-contract`. +- `:application-core`, `:domain-core`, `:shared-contract`. `:application-core` 는 선언만이 아니라 실제 + 의존이다 — object 인가의 **답하는 계약**(`dev.caskeleton.application.security.ObjectAccessPolicy`)이 + 거기 살기 때문이다. 이 leaf 가 그 계약을 소유했다면 application 구현체가 인바운드 전송을 컴파일 + 의존해야 했고, 그건 의존 방향이 뒤집힌다. - `spring-boot-starter-graphql` (Spring Boot BOM 관리 — 버전 명시 없음). - test scope 에 한해 `spring-boot-starter-web`(random-port 전송 테스트용), `spring-boot-starter-security`(HTTP 인증/CORS qualification 용), @@ -140,7 +143,9 @@ health 스키마만 소유한다. | preparsed document cache (`execution/`) | `wired` | `GraphQlPreparsedDocumentAdapter` + 같은 테스트의 캐시 hit 케이스 | | 커스텀 scalar (`scalar/`) | `wired` | 같은 테스트의 scalar coercion 케이스 | | 요청 크기/Accept 협상 (`http/`) | `wired` | `GraphQlRequestBoundsTest`, `GraphQlAcceptNegotiationTest` | -| DataLoader/batching (`dataloader/`) | `wired` | `runtime/GraphQlBatchLoaderRegistrar` + `dataloader/GraphQlBatchContractTest` | +| 관측 tag cardinality (`observation/`) | `wired` | `runtime/GraphQlRequestObservationConventionAdapter` 가 Spring 의 `ExecutionRequestObservationConvention` 을 구현해 Boot 의 `GraphQlObservationAutoConfiguration` 이 이 컨벤션을 가져간다. `autoconfigure/GraphQlObservationWiringTest`(프레임워크가 실제로 해석), `runtime/GraphQlRequestObservationConventionAdapterTest`(실제 `MeterRegistry` 에 임의 이름 10,000개 → series 1개) | +| object 인가 (`security/`) | `modelled` | 답하는 계약은 중립 `dev.caskeleton.application.security.ObjectAccessPolicy` 가 소유하고 이 leaf 는 `ApplicationObjectAuthorization` 매핑만 가진다. 실행 경로에 연결하는 configuration 은 없다 | +| DataLoader/batching (`dataloader/`) | `wired` | `runtime/GraphQlBatchLoaderRegistrationTest` (실제 graphql-java 실행 + Spring `BatchLoaderRegistry`, 50 parent → 3 downstream 호출), `dataloader/GraphQlBatchContractTest` | | cursor 서명 (`pagination/`) | `modelled` | `HmacGraphQlCursorCodec`·`GraphQlCursorKeyRing` 단위 테스트만. **auto-configuration 이 둘 중 무엇도 생성하지 않는다** — `autoconfigure/GraphQlPolicyRequestPathTest` 가 그 사실을 고정 | | mutation 멱등성 (`mutation/`) | `modelled` | `GraphQlMutationIdempotencyInterceptor` 를 참조하는 configuration 이 없다. 같은 테스트가 고정 | | persisted operation (`advanced/persisted/`) | `modelled` | 중립 `OperationalRecordStorePort` 기반 레지스트리 + 방향성 테스트. durable 구현체는 미제공 | @@ -190,7 +195,7 @@ cd src `quarantine`·`graphql-performance` 태그를 제외한다: ```bash -./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain # 551 tests +./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain # 605 tests ./gradlew :adapter:inbound:graphql:graphqlContractTest --console=plain # 9 tests ./gradlew :adapter:inbound:graphql:graphqlAdvancedTest --console=plain # 152 tests ./gradlew :adapter:inbound:graphql:graphqlPerformanceTest --console=plain # 실부하 인프라 필요 @@ -199,7 +204,7 @@ cd src `graphqlPerformanceTest` 는 `@Tag("graphql-performance")` 가 하나도 없으면 **실패한다** — 이는 버그가 아니라 "성능 증거 없음"을 통과로 위장하지 않기 위한 fail-closed 설계다. -위 숫자는 `build/test-results//*.xml` 의 실제 실행 결과다(기본 `test` 703, transport +위 숫자는 `build/test-results//*.xml` 의 실제 실행 결과다(기본 `test` 757, transport qualification 8). 문서에 옮겨 적은 숫자는 반드시 마지막 green 실행에서 다시 읽어 갱신한다 — 컴파일이 깨진 채로 남은 과거 숫자는 통과 증거가 아니라 통과했다는 인상일 뿐이다. diff --git a/src/adapter/inbound/graphql/build.gradle b/src/adapter/inbound/graphql/build.gradle index 1028d1a2..7b7cc4bc 100644 --- a/src/adapter/inbound/graphql/build.gradle +++ b/src/adapter/inbound/graphql/build.gradle @@ -19,12 +19,17 @@ description = 'Inbound adapter: GraphQL API (Spring for GraphQL, GraphQL executi // the second half rather than trusting it. apply plugin: 'java-test-fixtures' -apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle" apply from: "${rootProject.projectDir}/gradle/graphql-platform-conventions.gradle" dependencies { implementation project(':shared-contract') + // The object-access rule this platform consults belongs to the application, and so does its + // contract: an inbound adapter that declared it would force every implementation to compile + // against this transport. The leaf keeps only the mapping from a GraphQL request context to + // the four plain values that contract speaks. + implementation project(':application-core') + // The fixtures exercise the platform through the same contracts an adopter uses. testFixturesImplementation project(':shared-contract') testFixturesImplementation 'org.springframework.boot:spring-boot-starter-graphql' @@ -152,95 +157,14 @@ tasks.named('check') { // prerequisite for shrinking it: the `api` and `spi` packages are the surface an adopter is meant // to use, and everything else in this file is a candidate for becoming internal when the leaf is // split into capability artifacts. Until then the number cannot grow by accident. -def graphQlApiSurfaceFile = rootProject.file('../docs/architecture/graphql-api-surface.txt') - -Closure renderGraphQlApiSurface = { - def sourceRoot = file('src/main/java') - def typePattern = ~/(?m)^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(class|interface|enum|record|@interface)\s+(\w+)/ - def packagePattern = ~/(?m)^package\s+([\w.]+)\s*;/ - List types = [] - sourceRoot.eachFileRecurse { candidate -> - if (!candidate.isFile() || !candidate.name.endsWith('.java')) { - return - } - String text = candidate.getText('UTF-8') - def packageMatcher = packagePattern.matcher(text) - if (!packageMatcher.find()) { - return - } - String packageName = packageMatcher.group(1) - def typeMatcher = typePattern.matcher(text) - while (typeMatcher.find()) { - types << "${packageName}.${typeMatcher.group(2)}".toString() - } - } - types = types.unique().toSorted() - String header = - "# GraphQL leaf public API surface — every public top-level type in src/main/java.\n" + - "# A public type in a single-jar leaf is reachable from every adopter's code, so\n" + - "# additions are reviewed rather than discovered. `api` and `spi` are the intended\n" + - "# external surface; the rest are candidates to become internal when this leaf is\n" + - "# split into capability artifacts.\n" + - "# Update only after review with:\n" + - "# ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange\n" + - "# types: ${types.size()}\n" - header + (types.isEmpty() ? '' : types.join('\n') + '\n') -} - -// The approval flag is read at configuration time and carried in, not fetched from `project` -// inside doLast. Task.project at execution time is deprecated and fails under Gradle 10, and it is -// incompatible with the configuration cache — which this build will need before it can adopt one. -boolean graphQlApiSurfaceUpdateApproved = project.hasProperty('approveGraphQlApiSurfaceChange') - -tasks.register('verifyGraphQlApiSurface') { - group = 'verification' - description = 'Fails without mutation when the committed GraphQL public API surface drifts.' - - doLast { - if (graphQlApiSurfaceUpdateApproved) { - throw new GradleException( - 'verifyGraphQlApiSurface is read-only; use updateGraphQlApiSurface to record an ' + - 'approved change.') - } - String rendered = renderGraphQlApiSurface() - if (!graphQlApiSurfaceFile.isFile()) { - throw new GradleException( - "verifyGraphQlApiSurface: missing committed baseline ${graphQlApiSurfaceFile}") - } - String committed = graphQlApiSurfaceFile.getText('UTF-8') - if (committed != rendered) { - List committedTypes = committed.readLines().findAll { !it.startsWith('#') } - List renderedTypes = rendered.readLines().findAll { !it.startsWith('#') } - List added = (renderedTypes - committedTypes).toSorted() - List removed = (committedTypes - renderedTypes).toSorted() - throw new GradleException( - "verifyGraphQlApiSurface: the public API surface changed.\n" + - (added.isEmpty() ? '' : " added:\n " + added.join('\n ') + '\n') + - (removed.isEmpty() ? '' : " removed:\n " + removed.join('\n ') + '\n') + - "Review the change, then record it with:\n" + - " ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface " + - "-PapproveGraphQlApiSurfaceChange") - } - logger.lifecycle('verifyGraphQlApiSurface: OK — the committed public API surface is unchanged.') - } -} - -tasks.register('updateGraphQlApiSurface') { - group = 'verification' - description = 'Rewrites the committed GraphQL public API surface baseline after review.' - - doLast { - if (!project.hasProperty('approveGraphQlApiSurfaceChange')) { - throw new GradleException( - 'updateGraphQlApiSurface requires -PapproveGraphQlApiSurfaceChange: growing the ' + - 'public surface is a review decision, not a build step.') - } - graphQlApiSurfaceFile.parentFile.mkdirs() - graphQlApiSurfaceFile.setText(renderGraphQlApiSurface(), 'UTF-8') - logger.lifecycle("updateGraphQlApiSurface: wrote ${graphQlApiSurfaceFile}") - } -} - -tasks.named('check') { - dependsOn tasks.named('verifyGraphQlApiSurface') +apiSurface { + label = 'GraphQl' + baseline = rootProject.file('../docs/architecture/graphql-api-surface.txt') + description = 'GraphQL leaf public API surface — every public top-level type in src/main/java.' + rationale = [ + 'A public type in a single-jar leaf is reachable from every adopter\'s code, so', + 'additions are reviewed rather than discovered. `api` and `spi` are the intended', + 'external surface; the rest are candidates to become internal when this leaf is', + 'split into capability artifacts.', + ] } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionCancellation.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionCancellation.java index 73f10bbe..7b30a041 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionCancellation.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionCancellation.java @@ -1,9 +1,6 @@ package dev.caskeleton.adapter.inbound.graphql.advanced.subscription; -import dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextCleanup; -import java.util.Queue; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.atomic.AtomicBoolean; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlCancellation; /** * Propagates cancellation from the client to the source. @@ -12,47 +9,32 @@ import java.util.concurrent.atomic.AtomicBoolean; * consumer, the polling task and the nested publishers all continue for a subscriber that has gone, * and nothing in the request path notices. * - *

Every hook runs exactly once, and one that throws does not stop the rest. The loop used to - * abandon the queue at the first failure, so a broken consumer-close left the polling task and the - * nested publishers running — the leak the second and third hooks existed to prevent, caused by the - * first one failing. + *

The signal itself is {@link GraphQlCancellation}, not a second copy of it. There used to be + * two one-way cancellation state machines in this platform with the same queue, the same flag and + * the same late-registration rule, which is two places for the run-every-hook guarantee to be + * correct in — and it was correct in one of them. Whatever the request path guarantees, a + * subscription now guarantees by construction: every hook runs exactly once, one that throws does + * not stop the rest, and the first failure carries the later ones as suppressed. */ public final class GraphQlSubscriptionCancellation { - private final AtomicBoolean cancelled = new AtomicBoolean(); - private final Queue upstream = new ConcurrentLinkedQueue<>(); + private final GraphQlCancellation cancellation = GraphQlCancellation.create(); /** Registers upstream work to stop on cancellation. */ public void onCancel(Runnable stopUpstream) { if (stopUpstream == null) { throw new IllegalArgumentException("upstream cancellation hook is required"); } - upstream.add(stopUpstream); - if (cancelled.get()) { - drain(); - } + cancellation.onCancel(stopUpstream); } /** Cancels the subscription and everything upstream of it. */ public void cancel() { - if (cancelled.compareAndSet(false, true)) { - drain(); - } + cancellation.cancel(); } /** Whether the subscription has been cancelled. */ public boolean cancelled() { - return cancelled.get(); - } - - private void drain() { - GraphQlContextCleanup cleanup = GraphQlContextCleanup.create(); - Runnable hook = upstream.poll(); - while (hook != null) { - cleanup.register(hook); - hook = upstream.poll(); - } - // The same run-all-then-rethrow-with-suppressed semantics the request path already uses. - cleanup.close(); + return cancellation.cancelled(); } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformAutoConfiguration.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformAutoConfiguration.java index 0fd61823..a3a2824a 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformAutoConfiguration.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformAutoConfiguration.java @@ -32,6 +32,7 @@ import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformInstrumenta import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor; import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter; import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver; +import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlRequestObservationConventionAdapter; import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper; import dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter; import dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlScalarWiringConfigurer; @@ -60,6 +61,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.graphql.execution.DataFetcherExceptionResolver; import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.observation.ExecutionRequestObservationConvention; import org.springframework.graphql.server.WebGraphQlInterceptor; import org.springframework.stereotype.Controller; import org.springframework.util.ClassUtils; @@ -526,6 +528,23 @@ public class GraphQlPlatformAutoConfiguration { return new GraphQlRequestObservationConvention(filter, operationNames); } + /** + * Hands the bounded request tags to Spring for GraphQL's observation instrumentation. + * + *

The convention above is a value; this is the bean Boot's {@code + * GraphQlObservationAutoConfiguration} looks for. Without it the framework falls back to its own + * convention and the platform's cardinality policy applies to nothing that is exported — the + * shape of defect where a control passes its tests and no request reaches it. + * + * @param convention the platform's bounded tag policy + */ + @Bean + @ConditionalOnMissingBean(ExecutionRequestObservationConvention.class) + public GraphQlRequestObservationConventionAdapter graphQlExecutionRequestObservationConvention( + GraphQlRequestObservationConvention convention) { + return new GraphQlRequestObservationConventionAdapter(convention); + } + /** * Which operation names may become metric labels. * @@ -601,6 +620,78 @@ public class GraphQlPlatformAutoConfiguration { builder.configureGraphQl(graphQl -> graphQl.preparsedDocumentProvider(adapter)); } + /** + * The batch policies an adopter registers, empty until one does. + * + *

A bean rather than something each adopter constructs, because the two things downstream of + * it — the loader factory and the registrar — were beans nowhere, and a platform whose N+1 + * protection has to be assembled by hand is a platform whose N+1 protection is not applied. + * + * @return the registry + */ + @Bean + @ConditionalOnMissingBean( + dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry.class) + public dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry + graphQlBatchPolicyRegistry() { + return new dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry(); + } + + /** + * Supplies the per-loader batch policy and executor. + * + * @param policies the registered policies + * @param properties the platform settings, which supply the batch ceiling + * @param graphQlPlatformClock the clock deadlines are measured against + * @return the factory + */ + @Bean + @ConditionalOnMissingBean( + dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory.class) + public dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory + graphQlDataLoaderFactory( + dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry policies, + GraphQlPlatformSettings properties, + Clock graphQlPlatformClock) { + // The page ceiling is the batch ceiling. Both answer the same question — how many rows one + // downstream call may ask for — and a batch limit larger than the page limit would let a single + // request fan out past the bound the same request already accepted for its page. + return new dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory( + policies, properties.limits().maximumPageSize(), graphQlPlatformClock); + } + + /** + * Wraps an adopter's batch loader in the platform's chunking, budget and request scope. + * + *

This was the missing link. {@code GraphQlBatchLoaderRegistrar} existed, was tested, and was + * declared by no configuration — so a field resolving through {@code @BatchMapping} or a {@code + * DataLoader} met none of the platform's batch policy. The chunking and the budget were a set of + * well-tested objects no request could reach. + * + *

An adopter still supplies the downstream call, because only the adopter has one. What it no + * longer supplies is the machinery around it. + * + * @param factory the loader factory + * @param blockingBridge the bounded hand-off, when a runtime provides one + * @param properties the platform settings, which declare the runtime the loaders will execute on + * @return the registrar + */ + @Bean + @ConditionalOnMissingBean( + dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar.class) + public dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar + graphQlBatchLoaderRegistrar( + dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory factory, + ObjectProvider + blockingBridge, + GraphQlPlatformSettings properties) { + // The profile decides where a chunk may run, so the registrar receives it rather than assuming + // the servlet answer. A reactive deployment that registers a blocking loader with no bridge is + // refused at startup instead of discovering it as event-loop starvation. + return new dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar( + factory, blockingBridge.getIfAvailable(), properties.executionProfile()); + } + private static String schemaContractHash(ObjectProvider graphQlSource) { GraphQlSource source = graphQlSource.getIfAvailable(); if (source == null) { diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlRootAutoConfiguration.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlRootAutoConfiguration.java index c1923f49..a012a182 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlRootAutoConfiguration.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlRootAutoConfiguration.java @@ -20,5 +20,14 @@ import org.springframework.context.annotation.Import; @AutoConfiguration @ConditionalOnProperty(prefix = "backend.graphql", name = "enabled", havingValue = "true") @EnableConfigurationProperties(GraphQlPlatformSettings.class) -@Import(GraphQlPlatformAutoConfiguration.class) +@Import({ + GraphQlPlatformAutoConfiguration.class, + // The resolver for the schema's only field. It is a @Controller in a package the composition + // root's component scan excludes by regex — the exclusion that makes this capability optional — + // and no root imported it, so a deployment with GraphQL on served a schema declaring + // `_health: String!` with nothing to resolve it. Every query answered + // NullValueInNonNullableField. Its own tests passed throughout by registering the class + // themselves, which is the shape of the defect rather than a defence against it. + dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController.class +}) public class GraphQlRootAutoConfiguration {} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparator.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparator.java index 97407e15..a2dd6715 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparator.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparator.java @@ -2,18 +2,23 @@ package dev.caskeleton.adapter.inbound.graphql.compat; import graphql.language.AstPrinter; import graphql.language.EnumTypeDefinition; +import graphql.language.EnumTypeExtensionDefinition; import graphql.language.EnumValueDefinition; import graphql.language.FieldDefinition; import graphql.language.ImplementingTypeDefinition; import graphql.language.InputObjectTypeDefinition; +import graphql.language.InputObjectTypeExtensionDefinition; import graphql.language.InputValueDefinition; import graphql.language.InterfaceTypeDefinition; +import graphql.language.InterfaceTypeExtensionDefinition; import graphql.language.NonNullType; import graphql.language.ObjectTypeDefinition; +import graphql.language.ObjectTypeExtensionDefinition; import graphql.language.ScalarTypeDefinition; import graphql.language.Type; import graphql.language.TypeDefinition; import graphql.language.UnionTypeDefinition; +import graphql.language.UnionTypeExtensionDefinition; import graphql.schema.idl.ScalarInfo; import graphql.schema.idl.SchemaParser; import graphql.schema.idl.TypeDefinitionRegistry; @@ -57,26 +62,150 @@ public final class GraphQlSchemaComparator { List changes = new ArrayList<>(); - compareTypePresence(previous, candidate, changes); - compareTypeKinds(previous, candidate, changes); - compareOutputTypes(previous, candidate, changes); - compareInputTypes(previous, candidate, changes); - compareEnums(previous, candidate, changes); - compareUnions(previous, candidate, changes); + // Extensions folded in first. A registry keeps `extend type Query { … }` in a separate map from + // `type Query { … }`, so a comparison that reads only the base definitions cannot see a field + // an extension contributed — and cannot see it disappear either. Every schema that composes + // from several files is exactly this shape, which made the omission a breaking change the gate + // reported as no change at all. + Map previousTypes = withExtensions(previous); + Map candidateTypes = withExtensions(candidate); + + compareTypePresence(previousTypes, candidateTypes, changes); + compareTypeKinds(previousTypes, candidateTypes, changes); + compareOutputTypes(previousTypes, candidateTypes, changes); + compareInputTypes(previousTypes, candidateTypes, changes); + compareEnums(previousTypes, candidateTypes, changes); + compareUnions(previousTypes, candidateTypes, changes); compareScalars(previous, candidate, changes); compareDirectives(previous, candidate, changes); - compareAppliedDirectives(previous, candidate, changes); + compareAppliedDirectives(previousTypes, candidateTypes, changes); return new GraphQlCompatibilityReport(changes.stream().sorted(DETERMINISTIC_ORDER).toList()); } + /** + * Every type definition with the members its extensions contribute already merged in. + * + *

The merged form is what a client sees: the engine builds one type out of the base + * declaration and every extension of it, and a field's origin is invisible on the wire. + */ + private static Map withExtensions(TypeDefinitionRegistry registry) { + Map merged = new LinkedHashMap<>(); + registry.types().forEach((name, type) -> merged.put(name, mergeExtensions(registry, type))); + return merged; + } + + private static TypeDefinition mergeExtensions( + TypeDefinitionRegistry registry, TypeDefinition type) { + + String name = type.getName(); + if (type instanceof ObjectTypeDefinition object) { + List extensions = + registry.objectTypeExtensions().getOrDefault(name, List.of()); + if (extensions.isEmpty()) { + return object; + } + List fields = new ArrayList<>(object.getFieldDefinitions()); + List interfaces = new ArrayList<>(object.getImplements()); + List directives = new ArrayList<>(object.getDirectives()); + extensions.forEach( + extension -> { + fields.addAll(extension.getFieldDefinitions()); + interfaces.addAll(extension.getImplements()); + directives.addAll(extension.getDirectives()); + }); + return object.transform( + builder -> + builder.fieldDefinitions(fields).implementz(interfaces).directives(directives)); + } + if (type instanceof InterfaceTypeDefinition definition) { + List extensions = + registry.interfaceTypeExtensions().getOrDefault(name, List.of()); + if (extensions.isEmpty()) { + return definition; + } + List fields = new ArrayList<>(definition.getFieldDefinitions()); + List interfaces = new ArrayList<>(definition.getImplements()); + List directives = new ArrayList<>(definition.getDirectives()); + extensions.forEach( + extension -> { + fields.addAll(extension.getFieldDefinitions()); + interfaces.addAll(extension.getImplements()); + directives.addAll(extension.getDirectives()); + }); + return definition.transform( + builder -> builder.definitions(fields).implementz(interfaces).directives(directives)); + } + if (type instanceof InputObjectTypeDefinition input) { + List extensions = + registry.inputObjectTypeExtensions().getOrDefault(name, List.of()); + if (extensions.isEmpty()) { + return input; + } + List fields = new ArrayList<>(input.getInputValueDefinitions()); + List directives = new ArrayList<>(input.getDirectives()); + extensions.forEach( + extension -> { + fields.addAll(extension.getInputValueDefinitions()); + directives.addAll(extension.getDirectives()); + }); + return input.transform( + builder -> builder.inputValueDefinitions(fields).directives(directives)); + } + if (type instanceof EnumTypeDefinition enumeration) { + List extensions = + registry.enumTypeExtensions().getOrDefault(name, List.of()); + if (extensions.isEmpty()) { + return enumeration; + } + List values = new ArrayList<>(enumeration.getEnumValueDefinitions()); + List directives = new ArrayList<>(enumeration.getDirectives()); + extensions.forEach( + extension -> { + values.addAll(extension.getEnumValueDefinitions()); + directives.addAll(extension.getDirectives()); + }); + return enumeration.transform( + builder -> builder.enumValueDefinitions(values).directives(directives)); + } + if (type instanceof UnionTypeDefinition union) { + List extensions = + registry.unionTypeExtensions().getOrDefault(name, List.of()); + if (extensions.isEmpty()) { + return union; + } + List members = new ArrayList<>(union.getMemberTypes()); + List directives = new ArrayList<>(union.getDirectives()); + extensions.forEach( + extension -> { + members.addAll(extension.getMemberTypes()); + directives.addAll(extension.getDirectives()); + }); + return union.transform(builder -> builder.memberTypes(members).directives(directives)); + } + return type; + } + + /** The merged definitions of one kind. */ + private static Map typesOf( + Map types, Class kind) { + Map selected = new LinkedHashMap<>(); + types.forEach( + (name, type) -> { + if (kind.isInstance(type)) { + selected.put(name, kind.cast(type)); + } + }); + return selected; + } + private static void compareTypePresence( - TypeDefinitionRegistry previous, - TypeDefinitionRegistry candidate, + Map previous, + Map candidate, List changes) { - Set previousTypes = new TreeSet<>(previous.types().keySet()); - Set candidateTypes = new TreeSet<>(candidate.types().keySet()); + Set previousTypes = new TreeSet<>(previous.keySet()); + Set candidateTypes = new TreeSet<>(candidate.keySet()); previousTypes.stream() .filter(name -> !candidateTypes.contains(name)) @@ -94,12 +223,10 @@ public final class GraphQlSchemaComparator { * to another, or as nothing at all. */ private static void compareTypeKinds( - TypeDefinitionRegistry previous, - TypeDefinitionRegistry candidate, + Map previousTypes, + Map candidateTypes, List changes) { - Map previousTypes = previous.types(); - Map candidateTypes = candidate.types(); for (String name : new TreeSet<>(previousTypes.keySet())) { TypeDefinition after = candidateTypes.get(name); if (after == null) { @@ -119,12 +246,10 @@ public final class GraphQlSchemaComparator { * definition untouched. */ private static void compareAppliedDirectives( - TypeDefinitionRegistry previous, - TypeDefinitionRegistry candidate, + Map previousTypes, + Map candidateTypes, List changes) { - Map previousTypes = previous.types(); - Map candidateTypes = candidate.types(); for (String name : new TreeSet<>(previousTypes.keySet())) { TypeDefinition before = previousTypes.get(name); TypeDefinition after = candidateTypes.get(name); @@ -202,8 +327,8 @@ public final class GraphQlSchemaComparator { } private static void compareOutputTypes( - TypeDefinitionRegistry previous, - TypeDefinitionRegistry candidate, + Map previous, + Map candidate, List changes) { Map> previousTypes = implementingTypes(previous); @@ -349,14 +474,14 @@ public final class GraphQlSchemaComparator { } private static void compareInputTypes( - TypeDefinitionRegistry previous, - TypeDefinitionRegistry candidate, + Map previous, + Map candidate, List changes) { Map previousTypes = - previous.getTypesMap(InputObjectTypeDefinition.class); + typesOf(previous, InputObjectTypeDefinition.class); Map candidateTypes = - candidate.getTypesMap(InputObjectTypeDefinition.class); + typesOf(candidate, InputObjectTypeDefinition.class); for (String typeName : new TreeSet<>(previousTypes.keySet())) { InputObjectTypeDefinition after = candidateTypes.get(typeName); @@ -426,13 +551,12 @@ public final class GraphQlSchemaComparator { } private static void compareEnums( - TypeDefinitionRegistry previous, - TypeDefinitionRegistry candidate, + Map previous, + Map candidate, List changes) { - Map previousTypes = previous.getTypesMap(EnumTypeDefinition.class); - Map candidateTypes = - candidate.getTypesMap(EnumTypeDefinition.class); + Map previousTypes = typesOf(previous, EnumTypeDefinition.class); + Map candidateTypes = typesOf(candidate, EnumTypeDefinition.class); for (String typeName : new TreeSet<>(previousTypes.keySet())) { EnumTypeDefinition after = candidateTypes.get(typeName); @@ -462,14 +586,12 @@ public final class GraphQlSchemaComparator { } private static void compareUnions( - TypeDefinitionRegistry previous, - TypeDefinitionRegistry candidate, + Map previous, + Map candidate, List changes) { - Map previousTypes = - previous.getTypesMap(UnionTypeDefinition.class); - Map candidateTypes = - candidate.getTypesMap(UnionTypeDefinition.class); + Map previousTypes = typesOf(previous, UnionTypeDefinition.class); + Map candidateTypes = typesOf(candidate, UnionTypeDefinition.class); for (String typeName : new TreeSet<>(previousTypes.keySet())) { UnionTypeDefinition after = candidateTypes.get(typeName); @@ -512,7 +634,7 @@ public final class GraphQlSchemaComparator { changes.add(GraphQlSchemaChange.of("scalar " + name, GraphQlChangeKind.SCALAR_REMOVED)); continue; } - if (!print(previousScalars.get(name)).equals(print(after))) { + if (!declaration(previousScalars.get(name)).equals(declaration(after))) { changes.add( GraphQlSchemaChange.of("scalar " + name, GraphQlChangeKind.SCALAR_DECLARATION_CHANGED)); } @@ -555,13 +677,26 @@ public final class GraphQlSchemaComparator { } private static Map> implementingTypes( - TypeDefinitionRegistry registry) { + Map merged) { Map> types = new LinkedHashMap<>(); - registry.getTypesMap(ObjectTypeDefinition.class).forEach(types::put); - registry.getTypesMap(InterfaceTypeDefinition.class).forEach(types::put); + typesOf(merged, ObjectTypeDefinition.class).forEach(types::put); + typesOf(merged, InterfaceTypeDefinition.class).forEach(types::put); return types; } + /** + * A scalar's declaration without its description. + * + *

The declaration is compared because it is the only signal SDL carries about how a scalar + * coerces — the {@code Coercing} implementation behind it is Java, and swapping it changes the + * wire contract without changing a character of schema. Prose is not that signal: reporting a + * reworded sentence as a possible coercion change is how a review that matters gets approved + * without being read. + */ + private static String declaration(ScalarTypeDefinition scalar) { + return print(scalar.transform(builder -> builder.description(null))); + } + private static Map customScalars(TypeDefinitionRegistry registry) { return registry.scalars().entrySet().stream() .filter(entry -> !ScalarInfo.isGraphqlSpecifiedScalar(entry.getKey())) diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/context/ActorRef.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/context/ActorRef.java index 04f89e8e..a207b0c6 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/context/ActorRef.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/context/ActorRef.java @@ -10,9 +10,9 @@ import java.util.HexFormat; * *

The platform needs an actor identity for authorization, idempotency scoping and audit, but the * error contract and the observability contract both forbid a raw user identifier from reaching a - * response or a metric label. So the context carries this reference and exposes {@link - * #fingerprint()} for anything that must be shared outward, and it never carries an access token, - * cookie or raw provider claim. + * response or a metric label. So the context carries this reference and never carries an access + * token, cookie or raw provider claim; what may be shared outward is a keyed fingerprint from + * {@link GraphQlIdentityFingerprinter}. * * @param value opaque, stable caller reference supplied by the authentication adapter * @param authenticated whether a credential was actually verified @@ -49,12 +49,19 @@ public record ActorRef(String value, boolean authenticated) { } /** - * A stable, non-reversible fingerprint of this actor. + * A stable partition token for this actor, for use inside one running request. * - *

Safe to use where the raw reference must not appear — idempotency scoping, audit correlation - * and subscription principals. + *

Deliberately not called a fingerprint. An actor reference is low-entropy — a numeric user + * id, a service account name — and a digest of a guessable input is recovered by digesting the + * guesses, so this hides the reference from a casual reader and from nobody else. It is a + * separator for in-memory structures such as the request-scoped DataLoader cache, where the only + * requirement is that two actors never share one bucket. + * + *

Anything durable or outward-facing — an idempotency record, an audit trail, a value handed + * to another system — uses {@link GraphQlIdentityFingerprinter} instead, which is keyed and can + * rotate. */ - public String fingerprint() { + public String cachePartition() { return sha256Prefix(value); } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlIdentityFingerprinter.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlIdentityFingerprinter.java new file mode 100644 index 00000000..83ed00c4 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlIdentityFingerprinter.java @@ -0,0 +1,150 @@ +package dev.caskeleton.adapter.inbound.graphql.context; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Turns an actor or tenant identity into a value that may be stored outside this process. + * + *

A plain digest is not that value. An actor reference and a tenant name are low-entropy: they + * come from a bounded set a reader can enumerate — {@code tenant-a}, {@code acme}, a numeric user + * id, an email address — and a digest of a guessable input is recovered by digesting the guesses. + * An idempotency record keyed on such a digest therefore still names the caller to anyone holding + * the store, which is the one thing hashing it was meant to prevent. A keyed MAC removes the + * dictionary attack, because the attacker cannot compute the candidate values without the key. + * + *

Keys are addressed by identity and the identity travels with the fingerprint, so a deployment + * can rotate. Rotation matters here more than for a signature: a fingerprint is durable, it sits in + * stored idempotency records for as long as they are retained, and a key that can never change is a + * key that is compromised permanently. Records written under a retired key stay readable because + * their key identity still resolves; new ones are written under the active key. + * + *

The platform supplies no key and no default. A default key is public knowledge, and a + * fingerprint under a public key is a plain digest wearing a MAC's name — so the deployment's + * secret source is the only way to build this, and a mutation cannot derive an idempotency scope + * without one. + * + *

These values are not metric labels. A fingerprint is per actor and per tenant by construction, + * which is exactly the unbounded cardinality the observability contract refuses; {@link + * dev.caskeleton.adapter.inbound.graphql.observation.GraphQlSensitiveAttributeFilter} is what + * decides what a metric may carry. + */ +public final class GraphQlIdentityFingerprinter { + + /** Shortest key accepted, matching the block size a truncated key would be padded to anyway. */ + public static final int MINIMUM_KEY_BYTES = 16; + + private static final String ALGORITHM = "HmacSHA256"; + + /** + * Domain tags, so the same string used as an actor and as a tenant does not fingerprint the same. + * + *

Without them a service account named {@code acme} and the tenant named {@code acme} share a + * fingerprint, and an idempotency scope built from both halves collapses to one repeated value. + */ + private static final String ACTOR_DOMAIN = "actor"; + + private static final String TENANT_DOMAIN = "tenant"; + + private final Map keys; + private final String activeKeyId; + + private GraphQlIdentityFingerprinter(Map keys, String activeKeyId) { + this.keys = keys; + this.activeKeyId = activeKeyId; + } + + /** + * Creates a fingerprinter over a rotating key ring. + * + * @param keys secrets by key identity, from the deployment's secret source + * @param activeKeyId the key new fingerprints are computed under + */ + public static GraphQlIdentityFingerprinter of(Map keys, String activeKeyId) { + if (keys == null || keys.isEmpty()) { + throw new IllegalArgumentException("identity fingerprint key ring cannot be empty"); + } + if (activeKeyId == null || activeKeyId.isBlank()) { + throw new IllegalArgumentException("an active identity fingerprint key is required"); + } + if (!keys.containsKey(activeKeyId)) { + throw new IllegalArgumentException("active identity fingerprint key is not in the key ring"); + } + Map copy = new LinkedHashMap<>(); + keys.forEach( + (keyId, secret) -> { + if (keyId == null || keyId.isBlank() || keyId.indexOf(':') >= 0) { + // The key identity is the prefix of every fingerprint it produces, so a colon in it + // would make the prefix ambiguous and two rings could mint the same fingerprint text. + throw new IllegalArgumentException( + "identity fingerprint key id is required and opaque"); + } + if (secret == null || secret.length < MINIMUM_KEY_BYTES) { + throw new IllegalArgumentException( + "identity fingerprint key " + keyId + " is too short"); + } + copy.put(keyId, secret.clone()); + }); + return new GraphQlIdentityFingerprinter(copy, activeKeyId); + } + + /** A single-key ring, for a deployment that has not rotated yet. */ + public static GraphQlIdentityFingerprinter single(String keyId, byte[] secret) { + return of(Map.of(keyId, secret), keyId); + } + + /** The key new fingerprints are computed under. */ + public String activeKeyId() { + return activeKeyId; + } + + /** + * Key identities a stored fingerprint may still be attributed to. + * + *

A copy, so handing the set out cannot retire a key by removing it from the live view. + */ + public Set keyIds() { + return Set.copyOf(keys.keySet()); + } + + /** The fingerprint of an actor, safe to store alongside an idempotency record. */ + public String actor(ActorRef actor) { + if (actor == null) { + throw new IllegalArgumentException("actor is required"); + } + return fingerprint(ACTOR_DOMAIN, actor.value()); + } + + /** The fingerprint of a tenant, safe to store alongside an idempotency record. */ + public String tenant(TenantContext tenant) { + if (tenant == null) { + throw new IllegalArgumentException("tenant is required"); + } + return fingerprint(TENANT_DOMAIN, tenant.value()); + } + + private String fingerprint(String domain, String value) { + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(keys.get(activeKeyId), ALGORITHM)); + // Length-framed, for the reason the canonical mutation input is: concatenating a domain and a + // caller-influenced value lets one of them absorb the other's boundary, and two different + // identities then produce one fingerprint. + byte[] digest = + mac.doFinal( + (domain.length() + ":" + domain + "|" + value.length() + ":" + value + "|") + .getBytes(StandardCharsets.UTF_8)); + return activeKeyId + ":" + HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException | InvalidKeyException unavailable) { + throw new IllegalStateException( + "HMAC-SHA256 is required for identity fingerprints", unavailable); + } + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/context/TenantContext.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/context/TenantContext.java index 009ef326..7471079a 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/context/TenantContext.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/context/TenantContext.java @@ -51,12 +51,14 @@ public record TenantContext(String value, TenantSource source) { } /** - * A stable, non-reversible fingerprint of this tenant. + * A stable partition token for this tenant, for use inside one running request. * - *

The observability contract forbids a raw tenant identifier as a metric label; this is what - * goes outward instead. + *

A tenant name comes from a set small enough to enumerate, so a digest of it is recovered by + * digesting the candidates. That is acceptable for what this is used for — keeping one tenant's + * request-scoped cache entries out of another's — and not acceptable for anything stored or + * shared, which uses the keyed {@link GraphQlIdentityFingerprinter} instead. */ - public String fingerprint() { + public String cachePartition() { return ActorRef.sha256Prefix(value); } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchExecutor.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchExecutor.java index 26565d0c..f4030a67 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchExecutor.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchExecutor.java @@ -20,9 +20,18 @@ import java.util.function.BiFunction; * more downstream call and wait for it however long it took, which is the case the budget exists * for. Bounding the call itself is the loader's job, and the deadline is handed to it for that; * this check is what stops the batch continuing past a budget that has already gone. + * + *

Every chunk's answer goes through {@link GraphQlBatchResultMapper} before it is believed. The + * mapper is where "the loader answered a question nobody asked" and "this key has no row" are + * distinguished, and while the executor handed the loader's map straight back, neither distinction + * reached a request: a result under the wrong keys rendered every parent's child as null, and a + * loader declared as always resolving reported its absences as legitimate nulls. Both are the shape + * that produces plausible data instead of an error. */ public final class GraphQlBatchExecutor { + private static final GraphQlBatchResultMapper MAPPER = new GraphQlBatchResultMapper(); + private final GraphQlBatchPolicy policy; private final GraphQlBatchChunker chunker; private final Clock clock; @@ -61,7 +70,24 @@ public final class GraphQlBatchExecutor { for (List chunk : chunker.chunk(keys)) { requireBudget(started, budget); - loaded.putAll(loadChunk.apply(chunk, context)); + Map answered = loadChunk.apply(chunk, context); + if (answered == null) { + throw new IllegalArgumentException( + "loader " + policy.loaderName().value() + " returned no result for a chunk"); + } + // One outcome per requested key, so a missing row and a wrong-key answer are two different + // facts. A key whose policy is NULL_VALUE is simply absent from the returned map, which is + // how a mapped DataLoader spells "no value" without claiming one. + MAPPER + .map(chunk, answered) + .values() + .forEach( + (key, value) -> { + V resolved = MAPPER.resolve(value, policy.missingKeyPolicy()); + if (resolved != null) { + loaded.put(key, resolved); + } + }); // After, too: a chunk that overran the budget must not have its result used and must not be // followed by another one. requireBudget(started, budget); diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlCancellation.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlCancellation.java index 0b5e58ee..145cc782 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlCancellation.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlCancellation.java @@ -14,6 +14,13 @@ import java.util.concurrent.atomic.AtomicBoolean; *

Cancellation is one-way, and each listener runs exactly once — listeners are drained from a * queue rather than iterated, so a listener registered concurrently with cancellation is neither * dropped nor run twice. + * + *

A listener that throws does not stop the rest. The drain used to abandon the queue at the + * first failure, which meant a downstream client that refused to close left the statement and the + * publisher behind it running — the leaks the later listeners existed to prevent, caused by the + * first one failing and hidden behind the exception that stopped it. Every listener is now + * attempted; the first failure is rethrown once the queue is empty, with the later ones attached to + * it as suppressed, so a second broken listener is not invisible until the first is fixed. */ public final class GraphQlCancellation { @@ -65,10 +72,22 @@ public final class GraphQlCancellation { } private void drain() { + RuntimeException firstFailure = null; Runnable listener = listeners.poll(); while (listener != null) { - listener.run(); + try { + listener.run(); + } catch (RuntimeException failure) { + if (firstFailure == null) { + firstFailure = failure; + } else { + firstFailure.addSuppressed(failure); + } + } listener = listeners.poll(); } + if (firstFailure != null) { + throw firstFailure; + } } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlAdvancedModule.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlAdvancedModule.java index 4cedce4e..9a13c129 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlAdvancedModule.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlAdvancedModule.java @@ -83,6 +83,7 @@ public enum GraphQlAdvancedModule { "advanced.subscription", "advanced.security", "api", + "execution", "http", "security"), diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlStableModule.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlStableModule.java index ed535918..9a9c6520 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlStableModule.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlStableModule.java @@ -118,6 +118,7 @@ public enum GraphQlStableModule { "error", "execution", "http", + "observation", "policy", "security"), diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContext.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContext.java index b379c2f1..85663589 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContext.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContext.java @@ -17,8 +17,8 @@ package dev.caskeleton.adapter.inbound.graphql.mutation; * implement replay, record storage or locking: those need transactional guarantees the transport * layer cannot give. * - * @param actorFingerprint non-reversible actor identity - * @param tenantFingerprint non-reversible tenant identity + * @param actorFingerprint keyed actor fingerprint, from the deployment's identity fingerprinter + * @param tenantFingerprint keyed tenant fingerprint, from the same fingerprinter * @param coordinate the mutation the key belongs to * @param contractVersion the mutation contract version the key was issued under * @param key the client-supplied key @@ -74,10 +74,10 @@ public record GraphQlMutationIdempotencyContext( /** * The storage scope for the Application's idempotency record. * - *

Uses fingerprints rather than the raw actor and tenant, so the scope can be persisted and - * logged. Length-framed for the same reason the canonical input form is: joining five - * caller-influenced values with a separator lets one of them contain the separator and collide - * with a different scope. + *

Uses the keyed fingerprints rather than the raw actor and tenant, so the scope can be + * persisted and logged without naming the caller to whoever holds the store. Length-framed for + * the same reason the canonical input form is: joining five caller-influenced values with a + * separator lets one of them contain the separator and collide with a different scope. */ public String scope() { StringBuilder scope = new StringBuilder(); diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyInterceptor.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyInterceptor.java index ae63dea8..dc025868 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyInterceptor.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyInterceptor.java @@ -1,5 +1,6 @@ package dev.caskeleton.adapter.inbound.graphql.mutation; +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlIdentityFingerprinter; import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExtensionsPolicy; import java.util.Map; @@ -23,6 +24,7 @@ public final class GraphQlMutationIdempotencyInterceptor { * Derives the idempotency scope for one mutation. * * @param context the request context, whose actor scopes the key + * @param fingerprinter the deployment's keyed identity fingerprinter * @param coordinate the mutation being executed * @param extensions request extensions, which may carry the key * @param normalizedInput the mutation's normalised business input @@ -30,11 +32,18 @@ public final class GraphQlMutationIdempotencyInterceptor { */ public static Optional from( GraphQlRequestContext context, + GraphQlIdentityFingerprinter fingerprinter, GraphQlMutationCoordinate coordinate, String contractVersion, Map extensions, Map normalizedInput) { + if (fingerprinter == null) { + // No key, no scope. Falling back to a plain digest here would put a recoverable actor and + // tenant into a record the Application persists, which is the failure the keyed fingerprint + // exists to stop — and it would do it silently, on a deployment that never configured a key. + throw new IllegalArgumentException("an identity fingerprinter is required"); + } Object supplied = extensions == null ? null : extensions.get(GraphQlExtensionsPolicy.IDEMPOTENCY_KEY); if (supplied == null) { @@ -45,8 +54,8 @@ public final class GraphQlMutationIdempotencyInterceptor { } return Optional.of( GraphQlMutationIdempotencyContext.of( - context.actor().fingerprint(), - context.tenant().fingerprint(), + fingerprinter.actor(context.actor()), + fingerprinter.tenant(context.tenant()), coordinate, contractVersion, new GraphQlIdempotencyKey(key), diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlOperationNameCardinality.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlOperationNameCardinality.java index 4fb7086a..6be9e5ab 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlOperationNameCardinality.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlOperationNameCardinality.java @@ -57,6 +57,26 @@ public final class GraphQlOperationNameCardinality { return registered.contains(operationName.value()) ? operationName.value() : UNREGISTERED; } + /** + * The bounded label for one name as it arrived on the wire. + * + *

The wire carries a string, and it is a wider string than {@link GraphQlOperationName} + * accepts: graphql-java admits any GraphQL {@code Name}, so {@code ab} and {@code _internal} + * reach execution and would make the value type throw. A policy that can only be asked about + * names it already considers well formed is not a bound on what a client can send, so the + * question is answered against the raw string — and a raw string becomes a label only by being + * exactly one the deployment declared. + * + * @param wireOperationName the client-supplied name, or {@code null} for an anonymous operation + * @return the registered name, the anonymous value, or {@link #UNREGISTERED} + */ + public String labelForWireName(String wireOperationName) { + if (wireOperationName == null || wireOperationName.isBlank()) { + return GraphQlOperationName.ANONYMOUS_OBSERVATION_VALUE; + } + return registered.contains(wireOperationName) ? wireOperationName : UNREGISTERED; + } + /** * The number of distinct labels this policy can ever produce. * diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlRequestObservationConvention.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlRequestObservationConvention.java index af5d6ada..cdd5263b 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlRequestObservationConvention.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlRequestObservationConvention.java @@ -89,8 +89,67 @@ public final class GraphQlRequestObservationConvention { GraphQlComplexityResult complexity, int depth) { + return tags( + operationNames.labelFor(operationName), + operationType, + clientProfile, + persisted, + outcome, + errorCategory, + complexity, + depth); + } + + /** + * Builds the request tags from the operation name as it arrived on the wire. + * + *

The overload exists for the runtime seam, which sees the raw request rather than a validated + * name. Routing it through the same cardinality policy is what keeps one bound instead of two: a + * second place that decides which names become labels is a second place for the bound to be + * missing. + * + * @param wireOperationName client-supplied operation name, or {@code null} when anonymous + * @param operationType root operation type + * @param clientProfile bounded client profile + * @param persisted whether the operation came from the persisted registry + * @param outcome bounded outcome name + * @param errorCategory bounded error category, or {@code null} + * @param complexity computed complexity, or {@code null} when the request never reached costing + * @param depth measured selection depth + */ + public Map tagsForWireName( + String wireOperationName, + GraphQlOperationType operationType, + GraphQlClientProfile clientProfile, + boolean persisted, + String outcome, + String errorCategory, + GraphQlComplexityResult complexity, + int depth) { + + return tags( + operationNames.labelForWireName(wireOperationName), + operationType, + clientProfile, + persisted, + outcome, + errorCategory, + complexity, + depth); + } + + private Map tags( + String operationNameLabel, + GraphQlOperationType operationType, + GraphQlClientProfile clientProfile, + boolean persisted, + String outcome, + String errorCategory, + GraphQlComplexityResult complexity, + int depth) { + Map tags = new LinkedHashMap<>(); - tags.put("graphql.operation.name", operationNames.labelFor(operationName)); + tags.put("graphql.operation.name", operationNameLabel); tags.put("graphql.operation.type", operationType.name()); tags.put("graphql.client.profile", clientProfile.value()); tags.put("graphql.persisted", Boolean.toString(persisted)); diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBatchLoaderRegistrar.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBatchLoaderRegistrar.java index 6e44ef83..0818e629 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBatchLoaderRegistrar.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBatchLoaderRegistrar.java @@ -6,6 +6,8 @@ import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchExecutor; import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchTimeoutException; import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory; import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderName; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileException; import java.util.List; import java.util.Map; import java.util.Objects; @@ -24,11 +26,20 @@ import org.springframework.graphql.execution.BatchLoaderRegistry; *

The chunking, budget and scope arrive as a decorator around the adopter's loader rather than * as something the adopter has to remember. What the adopter supplies is the downstream call; what * this adds is everything that makes it safe to run on a shared request budget. + * + *

Where the chunk actually runs is a property of the runtime, so the registrar is told which one + * it is. On a servlet stack the answer is "here": Spring already put the request on a thread, and a + * second pool would only add a queue and a wait. On a reactive stack the same inline call runs on + * the event loop, where one slow downstream stalls every request the loop is serving — so a + * reactive deployment either supplies the bounded bridge or does not get to register a blocking + * loader at all. Refusing at registration makes that a startup failure rather than a latency + * mystery under load. */ public final class GraphQlBatchLoaderRegistrar { private final GraphQlDataLoaderFactory factory; private final GraphQlBlockingBridge blockingBridge; + private final GraphQlExecutionProfile profile; /** * Creates a registrar that runs loaders on the calling thread. @@ -37,9 +48,11 @@ public final class GraphQlBatchLoaderRegistrar { * a second pool adds a queue, a wait and a context hop, and buys nothing on a servlet stack. * * @param factory supplies the per-loader batch policy and executor + * @param profile the runtime this deployment declared */ - public GraphQlBatchLoaderRegistrar(GraphQlDataLoaderFactory factory) { - this(factory, null); + public GraphQlBatchLoaderRegistrar( + GraphQlDataLoaderFactory factory, GraphQlExecutionProfile profile) { + this(factory, null, profile); } /** @@ -47,11 +60,15 @@ public final class GraphQlBatchLoaderRegistrar { * * @param factory supplies the per-loader batch policy and executor * @param blockingBridge the bounded hand-off, for a runtime where blocking in place is unsafe + * @param profile the runtime this deployment declared */ public GraphQlBatchLoaderRegistrar( - GraphQlDataLoaderFactory factory, GraphQlBlockingBridge blockingBridge) { + GraphQlDataLoaderFactory factory, + GraphQlBlockingBridge blockingBridge, + GraphQlExecutionProfile profile) { this.factory = Objects.requireNonNull(factory, "data loader factory is required"); this.blockingBridge = blockingBridge; + this.profile = Objects.requireNonNull(profile, "execution profile is required"); } /** @@ -72,6 +89,12 @@ public final class GraphQlBatchLoaderRegistrar { GraphQlDataLoaderName loaderName, BiFunction, GraphQlBatchContext, Map> loadChunk) { + if (profile != GraphQlExecutionProfile.BLOCKING_MVC && blockingBridge == null) { + throw new GraphQlExecutionProfileException( + "loader " + + loaderName.value() + + " would block the event loop; declare a bounded blocking bridge"); + } GraphQlBatchExecutor executor = factory.executorFor(loaderName); registry diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionContext.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionContext.java index a5596da1..cb4cfd74 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionContext.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionContext.java @@ -36,6 +36,16 @@ public record GraphQlExecutionContext( GraphQlDocumentShape shape, GraphQlComplexityResult complexity) { + /** + * Key under which the settled pipeline state is published for the rest of the request. + * + *

The measured shape and the scored complexity are computed once, by the cost stage, and were + * then discarded when the chain returned. Anything later in the request that wants to describe + * how large this request was — an observation convention, a diagnostic — had no way to ask, so it + * either re-measured the document or reported a number it had not measured. + */ + public static final String CONTEXT_KEY = "dev.caskeleton.graphql.executionContext"; + public GraphQlExecutionContext { Objects.requireNonNull(request, "request is required"); Objects.requireNonNull(requestContext, "request context is required"); diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformInstrumentation.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformInstrumentation.java index 7c2f4bac..57b80b6c 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformInstrumentation.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformInstrumentation.java @@ -95,6 +95,9 @@ public final class GraphQlPlatformInstrumentation extends SimplePerformantInstru execution .getGraphQLContext() .put(GraphQlRequestContext.CONTEXT_KEY, completed.requestContext()); + // The whole settled state, not only the context: the cost stage has measured this document's + // depth and scored its complexity, and those numbers exist nowhere else once the chain returns. + execution.getGraphQLContext().put(GraphQlExecutionContext.CONTEXT_KEY, completed); return SimpleInstrumentationContext.noOp(); } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlRequestObservationConventionAdapter.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlRequestObservationConventionAdapter.java new file mode 100644 index 00000000..ae312f8c --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlRequestObservationConventionAdapter.java @@ -0,0 +1,176 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile; +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCategory; +import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlRequestObservationConvention; +import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationType; +import graphql.ExecutionResult; +import graphql.GraphQLError; +import graphql.execution.ExecutionContext; +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import org.springframework.graphql.observation.ExecutionRequestObservationContext; +import org.springframework.graphql.observation.ExecutionRequestObservationConvention; + +/** + * Makes the platform's bounded request tags the ones a metrics backend actually receives. + * + *

The convention was a well-tested object with no consumer. Spring for GraphQL emits the {@code + * graphql.request} observation from its own instrumentation and asks an {@link + * ExecutionRequestObservationConvention} bean what to tag it with; the platform's convention did + * not implement that interface, so the cardinality bound it computes was never applied to a series + * anybody stored. A tag policy that no exporter consults bounds nothing. + * + *

The adapter lives here rather than in the observation package because the bound has to be + * framework-free to be testable without a running application, and the seam that applies it has to + * speak Spring and graphql-java. So the decision stays a value, and this class translates. + * + *

Everything it reports is bounded by construction. The operation name goes through the + * deployment's registry, the type is an enum, the profile is a validated identity, the outcome and + * the error category are closed sets, and the size numbers are buckets. The per-request identity + * that a debugger needs — the execution id — is reported as a high-cardinality value, which + * Micrometer carries on the trace and keeps off the meter. + */ +public final class GraphQlRequestObservationConventionAdapter + implements ExecutionRequestObservationConvention { + + /** Outcome of a request that produced no error. */ + public static final String OUTCOME_SUCCESS = "SUCCESS"; + + /** Outcome of a request the caller could have avoided. */ + public static final String OUTCOME_REQUEST_ERROR = "REQUEST_ERROR"; + + /** Outcome of a request that failed for a reason the caller cannot fix. */ + public static final String OUTCOME_INTERNAL_ERROR = "INTERNAL_ERROR"; + + private static final GraphQlClientProfile ANONYMOUS_PROFILE = + new GraphQlClientProfile("anonymous"); + + private final GraphQlRequestObservationConvention convention; + + /** + * Creates the adapter. + * + * @param convention the platform's bounded tag policy + */ + public GraphQlRequestObservationConventionAdapter( + GraphQlRequestObservationConvention convention) { + this.convention = Objects.requireNonNull(convention, "observation convention is required"); + } + + @Override + public String getName() { + return convention.name(); + } + + @Override + public String getContextualName(ExecutionRequestObservationContext context) { + // The span name, and therefore as bounded as a tag: the operation type, never the client's + // chosen operation name. + return "graphql " + operationType(context).name().toLowerCase(Locale.ROOT); + } + + @Override + public KeyValues getLowCardinalityKeyValues(ExecutionRequestObservationContext context) { + GraphQlExecutionContext settled = settled(context); + Map tags = + convention.tagsForWireName( + context.getExecutionInput().getOperationName(), + operationType(context), + clientProfile(context), + // False rather than unknown: the platform's persisted-operation stage is not on this + // execution path, so no request reaching here came out of the persisted registry. + false, + outcome(context), + errorCategory(context), + settled == null ? null : settled.complexity(), + depth(settled)); + + KeyValues keyValues = KeyValues.empty(); + for (Map.Entry tag : tags.entrySet()) { + keyValues = keyValues.and(KeyValue.of(tag.getKey(), tag.getValue())); + } + return keyValues; + } + + @Override + public KeyValues getHighCardinalityKeyValues(ExecutionRequestObservationContext context) { + Object executionId = context.getExecutionInput().getExecutionId(); + return executionId == null + ? KeyValues.empty() + : KeyValues.of("graphql.execution.id", executionId.toString()); + } + + private static GraphQlOperationType operationType(ExecutionRequestObservationContext context) { + ExecutionContext execution = context.getExecutionContext(); + if (execution == null || execution.getOperationDefinition() == null) { + // The observation is stopped even when parsing never selected an operation. Reporting the + // read type is what keeps the series shape stable across a request that failed early. + return GraphQlOperationType.QUERY; + } + return switch (execution.getOperationDefinition().getOperation()) { + case QUERY -> GraphQlOperationType.QUERY; + case MUTATION -> GraphQlOperationType.MUTATION; + case SUBSCRIPTION -> GraphQlOperationType.SUBSCRIPTION; + }; + } + + private static GraphQlClientProfile clientProfile(ExecutionRequestObservationContext context) { + GraphQlRequestContext requestContext = + context.getExecutionInput().getGraphQLContext().get(GraphQlRequestContext.CONTEXT_KEY); + // Anonymous is a profile, not a missing value: a request that proved nothing is still one the + // operator has to be able to count. + return requestContext == null ? ANONYMOUS_PROFILE : requestContext.clientProfile(); + } + + private static GraphQlExecutionContext settled(ExecutionRequestObservationContext context) { + return context.getExecutionInput().getGraphQLContext().get(GraphQlExecutionContext.CONTEXT_KEY); + } + + private static int depth(GraphQlExecutionContext settled) { + return settled == null || settled.shape() == null ? 0 : settled.shape().depth(); + } + + private static String outcome(ExecutionRequestObservationContext context) { + if (context.getError() != null) { + return OUTCOME_INTERNAL_ERROR; + } + ExecutionResult result = context.getExecutionResult(); + if (result == null) { + return OUTCOME_INTERNAL_ERROR; + } + List errors = result.getErrors(); + if (errors == null || errors.isEmpty()) { + return OUTCOME_SUCCESS; + } + return internal(errors) ? OUTCOME_INTERNAL_ERROR : OUTCOME_REQUEST_ERROR; + } + + private static String errorCategory(ExecutionRequestObservationContext context) { + if (context.getError() != null) { + return GraphQlErrorCategory.INTERNAL.name(); + } + ExecutionResult result = context.getExecutionResult(); + List errors = result == null ? null : result.getErrors(); + if (errors == null || errors.isEmpty()) { + // Absent rather than a "none" label: an outcome tag already says the request succeeded, and a + // second tag saying the same thing doubles the series for no extra answer. + return null; + } + return internal(errors) + ? GraphQlErrorCategory.INTERNAL.name() + : GraphQlErrorCategory.REQUEST.name(); + } + + private static boolean internal(List errors) { + // A resolver that threw is the server's problem; a document the schema refused is the caller's. + // Splitting them is the difference between an alert and a client-side bug report. + return errors.stream() + .anyMatch(error -> error.getErrorType() == graphql.ErrorType.DataFetchingException); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/ApplicationObjectAuthorization.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/ApplicationObjectAuthorization.java new file mode 100644 index 00000000..78526b38 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/ApplicationObjectAuthorization.java @@ -0,0 +1,84 @@ +package dev.caskeleton.adapter.inbound.graphql.security; + +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlCommandAttribution; +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; +import dev.caskeleton.application.security.ObjectAccessDecision; +import dev.caskeleton.application.security.ObjectAccessPolicy; +import dev.caskeleton.application.security.ObjectAccessRequest; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Asks the application's object-access rule on the platform's behalf. + * + *

The direction is what this class is for. The platform decides when an object needs an + * access check; the application decides the answer, because the answer depends on domain state. + * Those two facts used to be expressed by a port declared in this leaf whose method took a {@link + * GraphQlRequestContext} — a contract the application layer could not implement without depending + * on the inbound GraphQL adapter, which the dependency gate forbids and which would have dragged + * the transport into every persistence adapter behind the use case. + * + *

So the contract moved to {@code application-core} in terms of plain values, and what stays + * here is the mapping: request context in, four strings out, decision back. It is the same shape + * the persisted-operation registry uses against a neutral store contract, for the same reason. + */ +public final class ApplicationObjectAuthorization implements GraphQlObjectAuthorizationPort { + + private final ObjectAccessPolicy policy; + + /** + * Creates the bridge. + * + * @param policy the application's object-access rule + */ + public ApplicationObjectAuthorization(ObjectAccessPolicy policy) { + this.policy = Objects.requireNonNull(policy, "object access policy is required"); + } + + @Override + public GraphQlAuthorizationDecision authorize( + GraphQlRequestContext context, String objectType, String objectId) { + + GraphQlCommandAttribution attribution = GraphQlCommandAttribution.from(context); + return map( + policy.decide( + new ObjectAccessRequest( + attribution.actorId(), attribution.tenantId(), objectType, objectId))); + } + + @Override + public Map authorizeAll( + GraphQlRequestContext context, String objectType, List objectIds) { + + GraphQlCommandAttribution attribution = GraphQlCommandAttribution.from(context); + Map decided = + policy.decideAll( + attribution.actorId(), attribution.tenantId(), objectType, List.copyOf(objectIds)); + + Map mapped = new LinkedHashMap<>(); + objectIds.forEach( + objectId -> { + ObjectAccessDecision decision = decided.get(objectId); + if (decision == null) { + // A missing answer is a denial, never an omission: a caller that dropped ids from its + // response would leave the loader with no decision for those objects, and "no decision" + // is the one state that must not read as permission. + mapped.put(objectId, GraphQlAuthorizationDecision.deny("OBJECT_NOT_AUTHORIZED")); + return; + } + mapped.put(objectId, map(decision)); + }); + return Map.copyOf(mapped); + } + + private static GraphQlAuthorizationDecision map(ObjectAccessDecision decision) { + if (decision.allowed()) { + return GraphQlAuthorizationDecision.allow(); + } + return decision.hideExistence() + ? GraphQlAuthorizationDecision.denyHidingExistence(decision.code()) + : GraphQlAuthorizationDecision.deny(decision.code()); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlBatchContext.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlBatchContext.java index c56d7fc5..7d930851 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlBatchContext.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlBatchContext.java @@ -37,9 +37,12 @@ public record GraphQlBatchContext(ActorRef actor, TenantContext tenant, GraphQlD /** * The cache-key prefix that keeps one tenant's loaded values out of another's. * - *

A fingerprint rather than the raw tenant, so the key is safe if it is ever logged. + *

Partition tokens rather than the raw identities, so a cache key that reaches a debug log + * does not read as a list of who called and for whom. The tokens are not a privacy control — the + * identities behind them are guessable — but this key never leaves the request that built it, and + * what does leave uses the keyed fingerprinter. */ public String cacheScope() { - return actor.fingerprint() + ":" + tenant.fingerprint(); + return actor.cachePartition() + ":" + tenant.cachePartition(); } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlObjectAuthorizationPort.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlObjectAuthorizationPort.java index e5260e11..12b0dc1b 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlObjectAuthorizationPort.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlObjectAuthorizationPort.java @@ -5,12 +5,18 @@ import java.util.List; import java.util.Map; /** - * The Application port that answers "may this caller see this object?". + * The platform-side seam for "may this caller see this object?". * - *

A port rather than a repository call from platform code: whether a caller may see an object + *

A seam rather than a repository call from platform code: whether a caller may see an object * depends on domain state — ownership, membership, workflow status — which the transport layer has - * no business querying. The platform decides when to ask; the Application decides the - * answer. + * no business querying. The platform decides when to ask. + * + *

It does not decide who answers. The contract that answers is {@code + * dev.caskeleton.application.security.ObjectAccessPolicy}, phrased in plain values so the + * application layer can own it; {@link ApplicationObjectAuthorization} maps between the two. This + * interface used to describe itself as the Application port, which no application code could have + * implemented — its method signature named a GraphQL request context, so implementing it required + * depending on this transport adapter. * *

The batch method exists because object authorization inside a DataLoader would otherwise * reintroduce the N+1 the loader was added to remove. diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlBatchLoaderWiringTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlBatchLoaderWiringTest.java new file mode 100644 index 00000000..9a96cc94 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlBatchLoaderWiringTest.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.inbound.graphql.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry; +import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory; +import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * The batch-loader chain is assembled by the platform, not by each adopter. + * + *

{@code GraphQlBatchLoaderRegistrar} carried the chunking, the budget and the request scope, + * was unit tested, and was declared by no configuration — the only file in the repository that + * mentioned it was itself. A field resolving through {@code @BatchMapping} or a {@code DataLoader} + * therefore met none of it: the platform's N+1 protection existed as a set of objects no request + * could reach. + * + *

An adopter still supplies the downstream call, because only the adopter has one. What it no + * longer supplies is the machinery around it, which is what this asserts. + */ +class GraphQlBatchLoaderWiringTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class)) + .withPropertyValues( + "backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL"); + + @Test + @DisplayName("the platform supplies the whole batch chain when GraphQL is on") + void theChainIsSupplied() { + runner.run( + context -> + assertThat(context) + .hasNotFailed() + .hasSingleBean(GraphQlBatchPolicyRegistry.class) + .hasSingleBean(GraphQlDataLoaderFactory.class) + .hasSingleBean(GraphQlBatchLoaderRegistrar.class)); + } + + @Test + @DisplayName("an adopter's own registry replaces the platform's, rather than colliding with it") + void anAdopterRegistryWins() { + runner + .withBean(GraphQlBatchPolicyRegistry.class, GraphQlBatchPolicyRegistry::new) + .run( + context -> + assertThat(context).hasNotFailed().hasSingleBean(GraphQlBatchPolicyRegistry.class)); + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlObservationWiringTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlObservationWiringTest.java new file mode 100644 index 00000000..d0f5181e --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlObservationWiringTest.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.inbound.graphql.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlRequestObservationConventionAdapter; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.graphql.autoconfigure.observation.GraphQlObservationAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.graphql.observation.ExecutionRequestObservationConvention; +import org.springframework.graphql.observation.GraphQlObservationInstrumentation; + +/** + * The tag policy has to reach the instrumentation that emits the observation. + * + *

Spring for GraphQL resolves one {@link ExecutionRequestObservationConvention} bean and falls + * back to its own when it finds none. The platform's cardinality policy used to be a bean of a type + * nothing looked for, so the framework took the fallback and the bounded operation-name label was + * computed for nobody — a control with tests, no consumer, and no way to tell from the metrics. + */ +class GraphQlObservationWiringTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + GraphQlRootAutoConfiguration.class, GraphQlObservationAutoConfiguration.class)) + .withBean(ObservationRegistry.class, ObservationRegistry::create) + .withPropertyValues( + "backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL"); + + @Test + void theFrameworkInstrumentationResolvesThePlatformConvention() { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(GraphQlObservationInstrumentation.class); + assertThat(context) + .getBean(ExecutionRequestObservationConvention.class) + .as("the only convention the framework can resolve must be the bounded one") + .isInstanceOf(GraphQlRequestObservationConventionAdapter.class); + }); + } + + @Test + void anAdopterConventionKeepsTheFrameworkFromTakingThePlatformOne() { + runner + .withBean( + "adopterConvention", + ExecutionRequestObservationConvention.class, + org.springframework.graphql.observation.DefaultExecutionRequestObservationConvention + ::new) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context) + .as("a platform default that cannot be replaced is not a default") + .doesNotHaveBean(GraphQlRequestObservationConventionAdapter.class); + }); + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparatorTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparatorTest.java index cc27d797..5dbaee0a 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparatorTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparatorTest.java @@ -7,6 +7,69 @@ import org.junit.jupiter.api.Test; /** Schema compatibility diff and breaking policy (Stable plan Task 10). */ class GraphQlSchemaComparatorTest { + @Test + void aFieldRemovedFromATypeExtensionIsBreaking() { + GraphQlCompatibilityReport report = + GraphQlSchemaComparator.compare( + "type Query { a: String } extend type Query { b: String }", "type Query { a: String }"); + + assertThat(report.changesOf(GraphQlChangeKind.OUTPUT_FIELD_REMOVED)) + .as("a field an extension contributed is on the wire like any other") + .hasSize(1); + } + + @Test + void aFieldAddedByATypeExtensionIsReported() { + GraphQlCompatibilityReport report = + GraphQlSchemaComparator.compare( + "type Query { a: String }", "type Query { a: String } extend type Query { b: String }"); + + assertThat(report.changesOf(GraphQlChangeKind.OUTPUT_FIELD_ADDED_NULLABLE)).hasSize(1); + } + + @Test + void anExtensionThatStrengthensAnInputIsBreaking() { + GraphQlCompatibilityReport report = + GraphQlSchemaComparator.compare( + "input OrderFilter { status: String } extend input OrderFilter { region: String }", + "input OrderFilter { status: String } extend input OrderFilter { region: String! }"); + + assertThat(report.changesOf(GraphQlChangeKind.INPUT_FIELD_STRENGTHENED)).hasSize(1); + } + + @Test + void anEnumValueRemovedFromAnExtensionIsReported() { + GraphQlCompatibilityReport report = + GraphQlSchemaComparator.compare( + "enum Status { NEW } extend enum Status { ARCHIVED }", "enum Status { NEW }"); + + assertThat(report.changesOf(GraphQlChangeKind.ENUM_VALUE_REMOVED)).hasSize(1); + } + + @Test + void aUnionMemberRemovedFromAnExtensionIsReported() { + GraphQlCompatibilityReport report = + GraphQlSchemaComparator.compare( + "type A { a: String } type B { b: String } union Result = A extend union Result = B", + "type A { a: String } type B { b: String } union Result = A"); + + assertThat(report.changesOf(GraphQlChangeKind.UNION_MEMBER_REMOVED)).hasSize(1); + } + + @Test + void rewordingAScalarsDescriptionIsNotACoercionChange() { + GraphQlCompatibilityReport report = + GraphQlSchemaComparator.compare( + "\"An ISO-8601 instant.\" scalar Instant type Query { at: Instant }", + "\"An instant, in ISO-8601.\" scalar Instant type Query { at: Instant }"); + + assertThat(report.changesOf(GraphQlChangeKind.SCALAR_DECLARATION_CHANGED)) + .as( + "prose is not a coercion; a review triggered by an edited sentence trains people to " + + "approve the report without reading it") + .isEmpty(); + } + @Test void requiredArgumentAdditionIsBreaking() { GraphQlCompatibilityReport report = diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlIdentityFingerprinterTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlIdentityFingerprinterTest.java new file mode 100644 index 00000000..10d33ed5 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlIdentityFingerprinterTest.java @@ -0,0 +1,135 @@ +package dev.caskeleton.adapter.inbound.graphql.context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * The fingerprint that leaves this process has to survive someone guessing the identity behind it. + * + *

The interesting case is the low-entropy one, so the tests use the identifiers a real + * deployment has: {@code user-42}, {@code tenant-a}. A plain digest of either is recovered in the + * time it takes to hash a wordlist, which is what the dictionary test measures directly. + */ +class GraphQlIdentityFingerprinterTest { + + private static final ActorRef ACTOR = ActorRef.authenticated("user-42"); + private static final TenantContext TENANT = TenantContext.fromTrustedSession("tenant-a"); + + @Test + void aGuessableIdentityIsNotRecoverableFromItsFingerprint() { + GraphQlIdentityFingerprinter fingerprinter = + GraphQlIdentityFingerprinter.single("k1", randomKey()); + + String fingerprint = fingerprinter.actor(ACTOR); + Map dictionary = new LinkedHashMap<>(); + for (int candidate = 0; candidate < 100; candidate++) { + dictionary.put(sha256Prefix("user-" + candidate), "user-" + candidate); + } + + assertThat(dictionary.keySet()) + .as("a digest of an enumerable identifier names its owner to anyone who enumerates it") + .contains(sha256Prefix("user-42")); + assertThat(dictionary) + .as("the same enumeration must not resolve the keyed fingerprint") + .doesNotContainKey(fingerprint.substring(fingerprint.indexOf(':') + 1)); + assertThat(fingerprint).doesNotContain("user-42"); + } + + @Test + void twoKeysProduceTwoFingerprintsForOneIdentity() { + assertThat(GraphQlIdentityFingerprinter.single("k1", randomKey()).actor(ACTOR)) + .as("a fingerprint a deployment cannot change is a fingerprint it cannot revoke") + .isNotEqualTo(GraphQlIdentityFingerprinter.single("k2", randomKey()).actor(ACTOR)); + } + + @Test + void theSameKeyProducesTheSameFingerprint() { + byte[] secret = randomKey(); + + assertThat(GraphQlIdentityFingerprinter.single("k1", secret).actor(ACTOR)) + .as("a retry has to fingerprint to the stored value or idempotency never matches") + .isEqualTo(GraphQlIdentityFingerprinter.single("k1", secret.clone()).actor(ACTOR)); + } + + @Test + void theFingerprintNamesTheKeyItWasComputedUnder() { + byte[] retired = randomKey(); + byte[] active = randomKey(); + GraphQlIdentityFingerprinter rotated = + GraphQlIdentityFingerprinter.of(Map.of("k1", retired, "k2", active), "k2"); + + assertThat(rotated.activeKeyId()).isEqualTo("k2"); + assertThat(rotated.actor(ACTOR)).startsWith("k2:"); + assertThat(rotated.keyIds()) + .as("records written under the retired key stay attributable while they are retained") + .containsExactlyInAnyOrder("k1", "k2"); + } + + @Test + void anActorAndATenantOfTheSameNameFingerprintDifferently() { + GraphQlIdentityFingerprinter fingerprinter = + GraphQlIdentityFingerprinter.single("k1", randomKey()); + + assertThat(fingerprinter.actor(ActorRef.authenticated("acme"))) + .isNotEqualTo(fingerprinter.tenant(TenantContext.fromAuthenticatedCredential("acme"))); + } + + @Test + void aTenantFingerprintNeverCarriesTheTenantName() { + assertThat(GraphQlIdentityFingerprinter.single("k1", randomKey()).tenant(TENANT)) + .doesNotContain("tenant-a"); + } + + @Test + void aRingWithoutAUsableKeyIsRefused() { + byte[] usable = randomKey(); + + assertThatThrownBy(() -> GraphQlIdentityFingerprinter.of(Map.of(), "k1")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> GraphQlIdentityFingerprinter.of(Map.of("k1", usable), "k2")) + .as("an active key that is not in the ring cannot fingerprint anything") + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> GraphQlIdentityFingerprinter.single("k1", new byte[8])) + .as("a short key is the part of a MAC an attacker attacks first") + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> GraphQlIdentityFingerprinter.single("k:1", usable)) + .as("the key id is the fingerprint's prefix, so it cannot contain the separator") + .isInstanceOf(IllegalArgumentException.class); + } + + /** + * One generator for the whole class. + * + *

Seeding a fresh {@code SecureRandom} per call is the wasteful shape SpotBugs reports as + * {@code DMI_RANDOM_USED_ONLY_ONCE}: each instance pays for seeding and then produces a single + * value. The keys still never appear in this file, which is the property that matters. + */ + private static final SecureRandom KEYS = new SecureRandom(); + + private static byte[] randomKey() { + byte[] secret = new byte[32]; + KEYS.nextBytes(secret); + return secret; + } + + /** The unkeyed form, reproduced here only so the dictionary attack on it can be demonstrated. */ + private static String sha256Prefix(String source) { + try { + byte[] digest = + MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8)); + byte[] prefix = new byte[16]; + System.arraycopy(digest, 0, prefix, 0, prefix.length); + return HexFormat.of().formatHex(prefix); + } catch (Exception unavailable) { + throw new IllegalStateException(unavailable); + } + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlRequestContextTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlRequestContextTest.java index 496021c9..3eea1e12 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlRequestContextTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlRequestContextTest.java @@ -48,12 +48,12 @@ class GraphQlRequestContextTest { } @Test - void actorAndTenantExposeFingerprintsInsteadOfRawIdentifiers() { + void actorAndTenantExposePartitionTokensInsteadOfRawIdentifiers() { ActorRef actor = ActorRef.authenticated("user-42"); TenantContext tenant = TenantContext.fromTrustedSession("tenant-a"); - assertThat(actor.fingerprint()).doesNotContain("user-42").hasSize(32); - assertThat(tenant.fingerprint()).doesNotContain("tenant-a").hasSize(32); + assertThat(actor.cachePartition()).doesNotContain("user-42").hasSize(32); + assertThat(tenant.cachePartition()).doesNotContain("tenant-a").hasSize(32); assertThat(ActorRef.anonymous().authenticated()).isFalse(); } diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContextTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContextTest.java index 9a124ad0..7c3df320 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContextTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContextTest.java @@ -4,7 +4,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import dev.caskeleton.adapter.inbound.graphql.context.ActorRef; +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlIdentityFingerprinter; import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; +import dev.caskeleton.adapter.inbound.graphql.context.TenantContext; import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExtensionsPolicy; import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts; import java.util.LinkedHashMap; @@ -15,11 +18,33 @@ import org.junit.jupiter.api.Test; /** Mutation idempotency scope and fingerprint (Stable plan Task 43). */ class GraphQlMutationIdempotencyContextTest { + /** + * One generator for the whole class, declared before the keys that draw from it. + * + *

Static initialisers run in textual order, so a generator declared below the two key + * constants would still be null when they are built. Seeding a fresh {@code SecureRandom} per + * call is also what SpotBugs reports as {@code DMI_RANDOM_USED_ONLY_ONCE}. + */ + private static final java.security.SecureRandom KEYS = new java.security.SecureRandom(); + private static final String TENANT = "tenant-fingerprint"; private static final String VERSION = "v1"; private static final GraphQlMutationCoordinate CREATE = new GraphQlMutationCoordinate("Mutation.createOrder"); + // Two rings standing for one deployment before and after a rotation. Both secrets are generated + // here rather than written down, so nothing in this file is a key anyone could reuse. + private static final GraphQlIdentityFingerprinter FIRST_KEY = + GraphQlIdentityFingerprinter.single("k1", randomKey()); + private static final GraphQlIdentityFingerprinter ROTATED_KEY = + GraphQlIdentityFingerprinter.single("k2", randomKey()); + + private static byte[] randomKey() { + byte[] secret = new byte[32]; + KEYS.nextBytes(secret); + return secret; + } + @Test void sameKeyWithDifferentFingerprintIsConflict() { var first = context(CREATE, new GraphQlMutationFingerprint("sha256:a")); @@ -189,6 +214,7 @@ class GraphQlMutationIdempotencyContextTest { var derived = GraphQlMutationIdempotencyInterceptor.from( context, + FIRST_KEY, CREATE, VERSION, Map.of(GraphQlExtensionsPolicy.IDEMPOTENCY_KEY, "request-1"), @@ -198,16 +224,68 @@ class GraphQlMutationIdempotencyContextTest { .hasValueSatisfying( scope -> { assertThat(scope.key().value()).isEqualTo("request-1"); - assertThat(scope.actorFingerprint()).isEqualTo(context.actor().fingerprint()); - assertThat(scope.tenantFingerprint()).isEqualTo(context.tenant().fingerprint()); + assertThat(scope.actorFingerprint()).isEqualTo(FIRST_KEY.actor(context.actor())); + assertThat(scope.tenantFingerprint()).isEqualTo(FIRST_KEY.tenant(context.tenant())); assertThat(scope.contractVersion()).isEqualTo(VERSION); }); assertThat( GraphQlMutationIdempotencyInterceptor.from( - context, CREATE, VERSION, Map.of(), Map.of())) + context, FIRST_KEY, CREATE, VERSION, Map.of(), Map.of())) .isEmpty(); } + @Test + void rotatingTheFingerprintKeyChangesTheStoredScope() { + GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a"); + + String first = derivedScope(context, FIRST_KEY); + String rotated = derivedScope(context, ROTATED_KEY); + + assertThat(first) + .as( + "an unkeyed digest of a guessable actor is recovered by digesting the guesses, and a " + + "deployment that suspects its stored scopes have been read has no way to change " + + "them unless the fingerprint depends on a key it controls") + .isNotEqualTo(rotated); + } + + @Test + void anActorAndATenantOfTheSameNameDoNotShareAFingerprint() { + assertThat(FIRST_KEY.actor(ActorRef.authenticated("acme"))) + .as("a service account and a tenant that happen to share a name are two identities") + .isNotEqualTo(FIRST_KEY.tenant(TenantContext.fromAuthenticatedCredential("acme"))); + } + + @Test + void derivingAScopeWithoutAFingerprintKeyIsRefused() { + GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a"); + + assertThatThrownBy( + () -> + GraphQlMutationIdempotencyInterceptor.from( + context, + null, + CREATE, + VERSION, + Map.of(GraphQlExtensionsPolicy.IDEMPOTENCY_KEY, "request-1"), + Map.of("customerId", "c-1"))) + .as("a deployment with no key must not silently fall back to a recoverable digest") + .isInstanceOf(IllegalArgumentException.class); + } + + private static String derivedScope( + GraphQlRequestContext context, GraphQlIdentityFingerprinter fingerprinter) { + return GraphQlMutationIdempotencyInterceptor.from( + context, + fingerprinter, + CREATE, + VERSION, + Map.of(GraphQlExtensionsPolicy.IDEMPOTENCY_KEY, "request-1"), + Map.of("customerId", "c-1")) + .orElseThrow() + .scope(); + } + @Test void fingerprintsAndScopesNeverCarryRawInputOrActor() { var fingerprint = GraphQlMutationFingerprint.of(Map.of("card", "4111111111111111")); diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBatchLoaderRegistrationTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBatchLoaderRegistrationTest.java new file mode 100644 index 00000000..21ea0079 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBatchLoaderRegistrationTest.java @@ -0,0 +1,431 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline; +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; +import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchContext; +import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchErrorPolicy; +import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicy; +import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry; +import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory; +import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderName; +import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlMissingKeyPolicy; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileException; +import dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextPropagator; +import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts; +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import graphql.GraphQL; +import graphql.schema.DataFetcher; +import graphql.schema.GraphQLSchema; +import graphql.schema.idl.RuntimeWiring; +import graphql.schema.idl.SchemaGenerator; +import graphql.schema.idl.SchemaParser; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.dataloader.DataLoaderRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.graphql.execution.DefaultBatchLoaderRegistry; + +/** + * The batch policy has to be reached by an executing query, not only by a unit test. + * + *

Everything the platform says about N+1 lived in objects that a request never met: the registry + * held {@code Object} and was connected to neither Spring's {@link + * org.springframework.graphql.execution.BatchLoaderRegistry} nor java-dataloader, and the chunk + * counts the contract suite asserted were numbers the caller had passed in. A suite that measures + * its own argument passes whatever the runtime does. + * + *

So this drives the real path — the registrar registers with Spring's registry, Spring builds + * the {@code DataLoaderRegistry} for one execution, graphql-java dispatches through it — and counts + * calls on a fake downstream. Fifty parents that produce fifty calls, or a batch that outlives the + * request budget, fail here and nowhere else. + */ +class GraphQlBatchLoaderRegistrationTest { + + private static final GraphQlDataLoaderName LOADER = new GraphQlDataLoaderName("customer-by-id"); + + private static final String SCHEMA = + """ + type Query { orders: [Order!]! } + type Order { id: ID!, customer: Customer } + type Customer { id: ID!, name: String! } + """; + + @Test + void fiftyParentsBecomeABoundedNumberOfDownstreamCalls() { + RecordingLoader downstream = new RecordingLoader(); + Fixture fixture = fixture(chunkSize(20), Clock.systemUTC()); + + ExecutionResult result = fixture.execute(orders(50, 50), downstream); + + assertThat(result.getErrors()).isEmpty(); + assertThat(resolvedCustomers(result)).hasSize(50); + assertThat(downstream.calls) + .as("fifty parents resolving one child each must not be fifty downstream calls") + .hasValue(3); + } + + @Test + void oneRequestLoadsARepeatedKeyOnce() { + RecordingLoader downstream = new RecordingLoader(); + Fixture fixture = fixture(chunkSize(20), Clock.systemUTC()); + + ExecutionResult result = fixture.execute(orders(50, 5), downstream); + + assertThat(result.getErrors()).isEmpty(); + assertThat(downstream.requestedKeys) + .as("the loader dedupes within a request; fifty orders share five customers") + .hasSize(5); + assertThat(downstream.calls).hasValue(1); + } + + @Test + void oneRequestsCacheIsNotHandedToTheNext() { + RecordingLoader downstream = new RecordingLoader(); + Fixture fixture = fixture(chunkSize(20), Clock.systemUTC()); + + fixture.execute(orders(5, 5), downstream); + fixture.execute(orders(5, 5), downstream); + + assertThat(downstream.calls) + .as( + "a DataLoader cache that outlived its request would serve one caller's rows to the next") + .hasValue(2); + } + + @Test + void everyChunkRunsUnderTheRequestsOwnActorAndTenant() { + RecordingLoader downstream = new RecordingLoader(); + Fixture tenantA = fixture(chunkSize(20), Clock.systemUTC()); + Fixture tenantB = fixture(chunkSize(20), Clock.systemUTC(), "tenant-b"); + + tenantA.execute(orders(50, 50), downstream); + Set afterTenantA = Set.copyOf(downstream.observedScopes); + tenantB.execute(orders(50, 50), downstream); + + assertThat(afterTenantA) + .as("one request's chunks all carry the same scope, or a chunk read for someone else") + .hasSize(1); + assertThat(downstream.observedScopes) + .as("two tenants must not share one batch scope") + .hasSize(2); + } + + @Test + void aLoaderWithoutThePlatformContextFailsClosedRatherThanLoading() { + RecordingLoader downstream = new RecordingLoader(); + Fixture fixture = fixture(chunkSize(20), Clock.systemUTC()); + + ExecutionResult result = fixture.executeWithoutRequestContext(orders(5, 5), downstream); + + assertThat(result.getErrors()) + .as("a batch with no deadline, tenant or actor has nothing to bound or scope it") + .isNotEmpty(); + assertThat(downstream.calls).hasValue(0); + } + + @Test + void aBatchThatOverrunsTheRequestBudgetStopsAtTheChunkThatSpentIt() { + MutableClock clock = new MutableClock(Instant.parse("2026-08-14T00:00:00Z")); + RecordingLoader downstream = new RecordingLoader(); + downstream.onCall = () -> clock.advance(Duration.ofMinutes(1)); + Fixture fixture = fixture(chunkSize(10), clock); + + ExecutionResult result = fixture.execute(orders(50, 50), downstream); + + assertThat(result.getErrors()).isNotEmpty(); + assertThat(downstream.calls) + .as("the chunk after an exhausted budget must never reach the downstream") + .hasValue(1); + } + + @Test + void aLoaderThatAnswersAKeyNobodyAskedForIsRefused() { + RecordingLoader downstream = new RecordingLoader(); + downstream.extraKey = "c-from-another-question"; + Fixture fixture = fixture(chunkSize(20), Clock.systemUTC()); + + ExecutionResult result = fixture.execute(orders(5, 5), downstream); + + assertThat(result.getErrors()) + .as("matching cardinality is not matching keys, and the platform declares that a violation") + .isNotEmpty(); + } + + @Test + void aRowTheLoaderReportsAsNullRendersAsAMissingChild() { + RecordingLoader downstream = new RecordingLoader(); + downstream.nullValueKey = "c-2"; + Fixture fixture = fixture(chunkSize(20), Clock.systemUTC()); + + ExecutionResult result = fixture.execute(orders(5, 5), downstream); + + assertThat(result.getErrors()) + .as("a nullable relation with no row is data, not a failure of the whole batch") + .isEmpty(); + assertThat(resolvedCustomers(result)).containsNull(); + } + + @Test + void aMissingRowUnderTheFieldErrorPolicyFailsTheField() { + RecordingLoader downstream = new RecordingLoader(); + downstream.omittedKey = "c-2"; + Fixture fixture = + fixture( + new GraphQlBatchPolicy( + LOADER, + 20, + Duration.ofSeconds(5), + true, + GraphQlMissingKeyPolicy.FIELD_ERROR, + GraphQlBatchErrorPolicy.PER_KEY), + Clock.systemUTC()); + + ExecutionResult result = fixture.execute(orders(5, 5), downstream); + + assertThat(result.getErrors()) + .as("a loader declared as always resolving must not render its absence as legitimate null") + .isNotEmpty(); + } + + @Test + void aReactiveRuntimeWithoutABridgeRefusesTheLoader() { + GraphQlBatchLoaderRegistrar registrar = + new GraphQlBatchLoaderRegistrar( + factory(chunkSize(20), Clock.systemUTC()), GraphQlExecutionProfile.REACTIVE_WEBFLUX); + + assertThatThrownBy( + () -> + registrar.>register( + new DefaultBatchLoaderRegistry(), LOADER, (keys, batchContext) -> Map.of())) + .as("an inline blocking chunk on an event loop stalls every request that loop is serving") + .isInstanceOf(GraphQlExecutionProfileException.class); + } + + @Test + void aBridgedLoaderRunsOffTheCallingThreadAndStillSeesTheRequest() { + RecordingLoader downstream = new RecordingLoader(); + try (GraphQlBlockingBridge bridge = GraphQlBlockingBridge.bounded(1, 4)) { + Fixture fixture = + new Fixture( + new GraphQlBatchLoaderRegistrar( + factory(chunkSize(20), Clock.systemUTC()), + bridge, + GraphQlExecutionProfile.REACTIVE_WEBFLUX), + "tenant-a", + Clock.systemUTC()); + + ExecutionResult result = fixture.execute(orders(5, 5), downstream); + + assertThat(result.getErrors()).isEmpty(); + assertThat(downstream.observedThreads) + .as("the bridge exists to move the blocking call off the thread that subscribed") + .doesNotContain(Thread.currentThread().getName()); + assertThat(downstream.observedBoundContexts) + .as("a batch that arrives on the bridge thread with no context has no tenant") + .containsExactly(Boolean.TRUE); + } + } + + @Test + void aServletRuntimeLoadsOnTheThreadItWasGiven() { + RecordingLoader downstream = new RecordingLoader(); + + fixture(chunkSize(20), Clock.systemUTC()).execute(orders(5, 5), downstream); + + assertThat(downstream.observedThreads) + .as("a second pool on a servlet stack is a queue and a wait that buys nothing") + .containsExactly(Thread.currentThread().getName()); + } + + private static GraphQlBatchPolicy chunkSize(int size) { + return new GraphQlBatchPolicy( + LOADER, + size, + Duration.ofSeconds(5), + true, + GraphQlMissingKeyPolicy.NULL_VALUE, + GraphQlBatchErrorPolicy.PER_KEY); + } + + private static Fixture fixture(GraphQlBatchPolicy policy, Clock clock) { + return fixture(policy, clock, "tenant-a"); + } + + private static Fixture fixture(GraphQlBatchPolicy policy, Clock clock, String tenant) { + return new Fixture( + new GraphQlBatchLoaderRegistrar( + factory(policy, clock), GraphQlExecutionProfile.BLOCKING_MVC), + tenant, + clock); + } + + private static GraphQlDataLoaderFactory factory(GraphQlBatchPolicy policy, Clock clock) { + return new GraphQlDataLoaderFactory( + new GraphQlBatchPolicyRegistry().register(policy), 100, clock); + } + + /** {@code count} orders spread over {@code customers} distinct customer ids. */ + private static List orders(int count, int customers) { + List orders = new ArrayList<>(); + for (int index = 0; index < count; index++) { + orders.add(new Order("o-" + index, "c-" + (index % customers))); + } + return orders; + } + + @SuppressWarnings("unchecked") + private static List> resolvedCustomers(ExecutionResult result) { + Map data = result.getData(); + return ((List>) data.get("orders")) + .stream().map(order -> (Map) order.get("customer")).toList(); + } + + private record Order(String id, String customerId) {} + + /** The adopter's downstream call, counting what the platform actually asked it for. */ + private static final class RecordingLoader { + + private final AtomicInteger calls = new AtomicInteger(); + private final Set requestedKeys = new LinkedHashSet<>(); + private final Set observedScopes = new LinkedHashSet<>(); + private Runnable onCall = () -> {}; + private final Set observedThreads = new LinkedHashSet<>(); + private final Set observedBoundContexts = new LinkedHashSet<>(); + private String extraKey; + private String nullValueKey; + private String omittedKey; + + Map> load(List keys, GraphQlBatchContext context) { + calls.incrementAndGet(); + requestedKeys.addAll(keys); + observedScopes.add(context.cacheScope()); + observedThreads.add(Thread.currentThread().getName()); + observedBoundContexts.add(GraphQlContextPropagator.current().isPresent()); + onCall.run(); + Map> rows = new LinkedHashMap<>(); + for (String key : keys) { + if (key.equals(omittedKey)) { + continue; + } + rows.put( + key, key.equals(nullValueKey) ? null : Map.of("id", key, "name", "customer " + key)); + } + if (extraKey != null) { + rows.put(extraKey, Map.of("id", extraKey, "name", "customer " + extraKey)); + } + return rows; + } + } + + /** Registrar, schema and execution wired the way Spring wires them at runtime. */ + private static final class Fixture { + + private final GraphQlBatchLoaderRegistrar registrar; + private final String tenant; + private final Clock clock; + + Fixture(GraphQlBatchLoaderRegistrar registrar, String tenant, Clock clock) { + this.registrar = registrar; + this.tenant = tenant; + this.clock = clock; + } + + ExecutionResult execute(List orders, RecordingLoader downstream) { + GraphQlRequestContext context = + GraphQlRequestContexts.testContext(tenant) + .withDeadline(GraphQlDeadline.after(Duration.ofSeconds(5), clock)); + return execute(orders, downstream, context); + } + + ExecutionResult executeWithoutRequestContext(List orders, RecordingLoader downstream) { + return execute(orders, downstream, null); + } + + private ExecutionResult execute( + List orders, RecordingLoader downstream, GraphQlRequestContext context) { + + DefaultBatchLoaderRegistry batchLoaders = new DefaultBatchLoaderRegistry(); + registrar.>register( + batchLoaders, LOADER, (keys, batchContext) -> downstream.load(keys, batchContext)); + + ExecutionInput input = + ExecutionInput.newExecutionInput("{ orders { id customer { id name } } }") + .graphQLContext( + builder -> { + if (context != null) { + builder.of(GraphQlRequestContext.CONTEXT_KEY, context); + } + }) + .build(); + // A registry per execution, exactly as Spring builds one per request: this is what makes the + // loader's cache request-scoped rather than a process-wide store of other people's rows. + DataLoaderRegistry dataLoaders = new DataLoaderRegistry(); + batchLoaders.registerDataLoaders(dataLoaders, input.getGraphQLContext()); + + return GraphQL.newGraphQL(schema(orders)) + .build() + .execute(input.transform(builder -> builder.dataLoaderRegistry(dataLoaders))); + } + + private static GraphQLSchema schema(List orders) { + DataFetcher customer = + environment -> { + Order order = environment.getSource(); + return environment + .>getDataLoader(LOADER.value()) + .load(order.customerId()); + }; + return new SchemaGenerator() + .makeExecutableSchema( + new SchemaParser().parse(SCHEMA), + RuntimeWiring.newRuntimeWiring() + .type("Query", builder -> builder.dataFetcher("orders", environment -> orders)) + .type("Order", builder -> builder.dataFetcher("customer", customer)) + .build()); + } + } + + /** A clock the test advances itself, so elapsed time is caused by the work under test. */ + private static final class MutableClock extends Clock { + + private Instant current; + + MutableClock(Instant start) { + this.current = start; + } + + void advance(Duration step) { + current = current.plus(step); + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return current; + } + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlRequestObservationConventionAdapterTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlRequestObservationConventionAdapterTest.java new file mode 100644 index 00000000..3501a98b --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlRequestObservationConventionAdapterTest.java @@ -0,0 +1,172 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile; +import dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId; +import dev.caskeleton.adapter.inbound.graphql.context.ActorRef; +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline; +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; +import dev.caskeleton.adapter.inbound.graphql.context.TenantContext; +import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlObservationNames; +import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlOperationNameCardinality; +import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlRequestObservationConvention; +import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlSensitiveAttributeFilter; +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.observation.DefaultMeterObservationHandler; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.graphql.observation.ExecutionRequestObservationContext; + +/** + * The bound, measured where a metrics backend would see it. + * + *

The platform's tag policy was a value object with no consumer: Spring for GraphQL emits the + * {@code graphql.request} observation and asks its own convention what to tag it with, so the + * cardinality policy applied to nothing that was ever exported. These cases drive the observation + * through a real {@code ObservationRegistry} and a registry that actually stores series, and count + * the meters afterwards. + */ +class GraphQlRequestObservationConventionAdapterTest { + + private static final int ARBITRARY_NAMES = 10_000; + + private final SimpleMeterRegistry meters = new SimpleMeterRegistry(); + private final ObservationRegistry observations = ObservationRegistry.create(); + + GraphQlRequestObservationConventionAdapterTest() { + observations.observationConfig().observationHandler(new DefaultMeterObservationHandler(meters)); + } + + @Test + void tenThousandArbitraryNamesProduceOneExportedSeries() { + var adapter = new GraphQlRequestObservationConventionAdapter(collapsingConvention()); + + for (int index = 0; index < ARBITRARY_NAMES; index++) { + observe(adapter, "Query" + index, null); + } + + assertThat(requestSeries()) + .as("a client that renames its operation per request must not rename the time series") + .hasSize(1); + assertThat(operationNameLabels()).containsExactly(GraphQlOperationNameCardinality.UNREGISTERED); + } + + @Test + void aNameTheValueTypeWouldRejectIsCollapsedRatherThanThrown() { + var adapter = new GraphQlRequestObservationConventionAdapter(collapsingConvention()); + + // Valid GraphQL names, invalid platform operation names: too short, and leading underscore. + observe(adapter, "ab", null); + observe(adapter, "_internal", null); + + assertThat(requestSeries()) + .as("an observation convention that throws takes the request down with it") + .hasSize(1); + assertThat(operationNameLabels()).containsExactly(GraphQlOperationNameCardinality.UNREGISTERED); + } + + @Test + void aRegisteredOperationKeepsItsOwnSeriesAndTheRestCollapse() { + var convention = + new GraphQlRequestObservationConvention( + GraphQlSensitiveAttributeFilter.standard(), + new GraphQlOperationNameCardinality(Set.of("OrderById"))); + var adapter = new GraphQlRequestObservationConventionAdapter(convention); + + observe(adapter, "OrderById", null); + for (int index = 0; index < 100; index++) { + observe(adapter, "Query" + index, null); + } + + assertThat(operationNameLabels()) + .containsExactlyInAnyOrder("OrderById", GraphQlOperationNameCardinality.UNREGISTERED); + } + + @Test + void theExportedTagsCarryTheRequestProfileAndOutcome() { + var adapter = new GraphQlRequestObservationConventionAdapter(collapsingConvention()); + + observe(adapter, "Anything", requestContext("partner")); + + Meter.Id id = requestSeries().get(0); + assertThat(id.getTag("graphql.client.profile")).isEqualTo("partner"); + assertThat(id.getTag("graphql.outcome")) + .isEqualTo(GraphQlRequestObservationConventionAdapter.OUTCOME_SUCCESS); + assertThat(id.getTag("graphql.operation.type")).isEqualTo("QUERY"); + assertThat(id.getTag("graphql.document")) + .as("the document is the unbounded value the allowlist exists to drop") + .isNull(); + } + + @Test + void anUnauthenticatedRequestIsCountedUnderTheAnonymousProfile() { + var adapter = new GraphQlRequestObservationConventionAdapter(collapsingConvention()); + + observe(adapter, "Anything", null); + + assertThat(requestSeries().get(0).getTag("graphql.client.profile")).isEqualTo("anonymous"); + } + + @Test + void theObservationNameMatchesTheOneSpringAlreadyEmits() { + var adapter = new GraphQlRequestObservationConventionAdapter(collapsingConvention()); + + assertThat(adapter.getName()).isEqualTo(GraphQlObservationNames.REQUEST); + } + + private static GraphQlRequestObservationConvention collapsingConvention() { + return GraphQlRequestObservationConvention.standard(); + } + + private void observe( + GraphQlRequestObservationConventionAdapter adapter, + String operationName, + GraphQlRequestContext requestContext) { + + ExecutionInput input = + ExecutionInput.newExecutionInput("{ __typename }").operationName(operationName).build(); + if (requestContext != null) { + input.getGraphQLContext().put(GraphQlRequestContext.CONTEXT_KEY, requestContext); + } + var context = new ExecutionRequestObservationContext(input); + context.setExecutionResult(ExecutionResult.newExecutionResult().data(Map.of()).build()); + Observation.createNotStarted(adapter, () -> context, observations).observe(() -> {}); + } + + private static GraphQlRequestContext requestContext(String profile) { + return new GraphQlRequestContext( + ActorRef.anonymous(), + TenantContext.system("public"), + new GraphQlClientProfile(profile), + Locale.ENGLISH, + new GraphQlOperationId("order.by-id"), + "trace-1", + new GraphQlDeadline(Instant.now().plus(Duration.ofSeconds(5)))); + } + + private List requestSeries() { + return meters.getMeters().stream() + .map(Meter::getId) + .filter(id -> GraphQlObservationNames.REQUEST.equals(id.getName())) + .toList(); + } + + private List operationNameLabels() { + return requestSeries().stream() + .map(id -> id.getTag("graphql.operation.name")) + .distinct() + .sorted() + .toList(); + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/ApplicationObjectAuthorizationTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/ApplicationObjectAuthorizationTest.java new file mode 100644 index 00000000..ceacff62 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/ApplicationObjectAuthorizationTest.java @@ -0,0 +1,156 @@ +package dev.caskeleton.adapter.inbound.graphql.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile; +import dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId; +import dev.caskeleton.adapter.inbound.graphql.context.ActorRef; +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline; +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; +import dev.caskeleton.adapter.inbound.graphql.context.TenantContext; +import dev.caskeleton.application.security.ObjectAccessDecision; +import dev.caskeleton.application.security.ObjectAccessPolicy; +import dev.caskeleton.application.security.ObjectAccessRequest; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * The GraphQL side of object access is a mapping, and nothing more. + * + *

What the application receives has to be four plain values, because a contract that names this + * leaf's request context cannot be implemented from application-core at all — the dependency gate + * refuses the edge, and the transport would arrive in every persistence adapter behind the use + * case. These cases pin the mapping: the actor and tenant come from the request context, the + * decision comes back unaltered in meaning, and an answer the application failed to give is a + * denial rather than an omission. + */ +class ApplicationObjectAuthorizationTest { + + @Test + void theApplicationSeesTheActorAndTenantFromTheRequestContext() { + List asked = new ArrayList<>(); + var bridge = + new ApplicationObjectAuthorization( + request -> { + asked.add(request); + return ObjectAccessDecision.allow(); + }); + + bridge.authorize(context(ActorRef.authenticated("actor-1"), "tenant-a"), "Order", "order-1"); + + assertThat(asked).hasSize(1); + assertThat(asked.get(0).actor()).contains("actor-1"); + assertThat(asked.get(0).tenantId()).isEqualTo("tenant-a"); + assertThat(asked.get(0).objectType()).isEqualTo("Order"); + assertThat(asked.get(0).objectId()).isEqualTo("order-1"); + } + + @Test + void anUnauthenticatedCallerReachesTheApplicationWithNoActor() { + List asked = new ArrayList<>(); + var bridge = + new ApplicationObjectAuthorization( + request -> { + asked.add(request); + return ObjectAccessDecision.allow(); + }); + + bridge.authorize(context(ActorRef.anonymous(), "public"), "Order", "order-1"); + + assertThat(asked.get(0).actor()) + .as("an anonymous reference is not an identity the rule may match on") + .isEmpty(); + } + + @Test + void aHiddenDenialStaysHiddenAcrossTheBoundary() { + var bridge = + new ApplicationObjectAuthorization( + request -> ObjectAccessDecision.denyHidingExistence("OBJECT_NOT_FOUND")); + + GraphQlAuthorizationDecision decision = + bridge.authorize(context(ActorRef.authenticated("actor-1"), "tenant-a"), "Order", "o-1"); + + assertThat(decision.allowed()).isFalse(); + assertThat(decision.code()).isEqualTo("OBJECT_NOT_FOUND"); + assertThat(decision.hideExistence()) + .as("dropping this flag turns a not-found into a forbidden, which discloses the object") + .isTrue(); + } + + @Test + void theBatchFormAsksOnceAndAnswersEveryId() { + List batches = new ArrayList<>(); + ObjectAccessPolicy policy = + new ObjectAccessPolicy() { + @Override + public ObjectAccessDecision decide(ObjectAccessRequest request) { + throw new AssertionError("the batch form must not fall back to one call per object"); + } + + @Override + public Map decideAll( + String actorId, String tenantId, String objectType, List objectIds) { + batches.add(String.join(",", objectIds)); + return Map.of( + "order-1", ObjectAccessDecision.allow(), + "order-2", ObjectAccessDecision.deny("OBJECT_NOT_AUTHORIZED")); + } + }; + + Map decisions = + new ApplicationObjectAuthorization(policy) + .authorizeAll( + context(ActorRef.authenticated("actor-1"), "tenant-a"), + "Order", + List.of("order-1", "order-2")); + + assertThat(batches).containsExactly("order-1,order-2"); + assertThat(decisions.get("order-1").allowed()).isTrue(); + assertThat(decisions.get("order-2").allowed()).isFalse(); + } + + @Test + void anObjectTheApplicationDidNotAnswerForIsDenied() { + ObjectAccessPolicy policy = + new ObjectAccessPolicy() { + @Override + public ObjectAccessDecision decide(ObjectAccessRequest request) { + return ObjectAccessDecision.allow(); + } + + @Override + public Map decideAll( + String actorId, String tenantId, String objectType, List objectIds) { + return Map.of("order-1", ObjectAccessDecision.allow()); + } + }; + + Map decisions = + new ApplicationObjectAuthorization(policy) + .authorizeAll( + context(ActorRef.authenticated("actor-1"), "tenant-a"), + "Order", + List.of("order-1", "order-2")); + + assertThat(decisions.get("order-2").allowed()) + .as("no decision is the one state that must never read as permission") + .isFalse(); + } + + private static GraphQlRequestContext context(ActorRef actor, String tenant) { + return new GraphQlRequestContext( + actor, + TenantContext.system(tenant), + new GraphQlClientProfile("first-party"), + Locale.ENGLISH, + new GraphQlOperationId("order.by-id"), + "trace-1", + new GraphQlDeadline(Instant.now().plus(Duration.ofSeconds(5)))); + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlCancellationAggregationTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlCancellationAggregationTest.java index ee48d371..8b6c4aa9 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlCancellationAggregationTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlCancellationAggregationTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionCancellation; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlCancellation; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; @@ -63,6 +64,61 @@ class GraphQlCancellationAggregationTest { assertThat(suppressed).isInstanceOf(IllegalStateException.class))); } + @Test + void aFailingRequestListenerNeverStopsTheOtherWorkFromStopping() { + List stopped = new ArrayList<>(); + var cancellation = GraphQlCancellation.create(); + cancellation.onCancel(() -> stopped.add("statement")); + cancellation.onCancel( + () -> { + stopped.add("downstream-call"); + throw new IllegalStateException("the downstream client refused to close"); + }); + cancellation.onCancel(() -> stopped.add("publisher")); + + assertThatThrownBy(cancellation::cancel).isInstanceOf(IllegalStateException.class); + + assertThat(stopped) + .as("a deadline that reaches only the first listener is not a cancellation") + .containsExactlyInAnyOrder("statement", "downstream-call", "publisher"); + assertThat(cancellation.cancelled()).isTrue(); + } + + @Test + void aRequestListenerFailingLateIsAttachedToTheFirstFailure() { + var cancellation = GraphQlCancellation.create(); + cancellation.onCancel( + () -> { + throw new IllegalStateException("registered-first"); + }); + cancellation.onCancel( + () -> { + throw new IllegalArgumentException("registered-second"); + }); + + assertThatThrownBy(cancellation::cancel) + .isInstanceOf(IllegalStateException.class) + .satisfies( + thrown -> + assertThat(thrown.getSuppressed()) + .hasSize(1) + .allSatisfy( + suppressed -> + assertThat(suppressed).isInstanceOf(IllegalArgumentException.class))); + } + + @Test + void aRequestListenerRegisteredAfterCancellationStillRunsExactlyOnce() { + List stopped = new ArrayList<>(); + var cancellation = GraphQlCancellation.create(); + cancellation.cancel(); + + cancellation.onCancel(() -> stopped.add("late")); + cancellation.cancel(); + + assertThat(stopped).containsExactly("late"); + } + @Test void aFailingUpstreamHookNeverStopsTheOthersFromStopping() { List stopped = new ArrayList<>(); diff --git a/src/adapter/inbound/grpc/build.gradle b/src/adapter/inbound/grpc/build.gradle index c02b8ebc..a43fffb3 100644 --- a/src/adapter/inbound/grpc/build.gradle +++ b/src/adapter/inbound/grpc/build.gradle @@ -9,7 +9,6 @@ // catalog, so the grpc-bom + protobuf-bom platforms are imported HERE (module scope) using the root // `ext.grpcVersion` / `ext.protobufVersion` SSOT — this keeps the strict-locking blast radius to // this module (the shared root dependencyManagement block stays io.grpc-free). -apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle" dependencyManagement { imports { diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index 6533cb8a..2f991fa1 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -27,44 +27,35 @@ dependencies { testImplementation 'io.projectreactor:reactor-test' } -tasks.register('jpaPersistenceRedactionContractTest', Test) { - group = 'verification' - description = 'Runs the exact persistence error log/trace redaction contract used by JPA evidence.' - testClassesDirs = sourceSets.test.output.classesDirs - classpath = sourceSets.test.runtimeClasspath - useJUnitPlatform() - filter { - includeTestsMatching( - 'dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandlerTest.persistenceFailureObservabilityDoesNotCarryRawDatabaseDetails') - includeTestsMatching( - 'dev.caskeleton.adapter.inbound.web.error.SpanErrorRecorderHookTest.persistenceFailureHandlerRecordsSanitizedExceptionWithClassifiedCode') - } - failOnNoDiscoveredTests = true - outputs.upToDateWhen { false } -} - tasks.named('test') { useJUnitPlatform { excludeTags 'security-boundary' } } -tasks.register('webSecurityBoundaryTest', Test) { - group = 'verification' - description = 'Runs hermetic JWT/JWKS and CORS filter-boundary contracts with no skips.' - testClassesDirs = sourceSets.test.output.classesDirs - classpath = sourceSets.test.runtimeClasspath - useJUnitPlatform { - includeTags 'security-boundary' +strictTestLanes { + lane('jpaPersistenceRedactionContractTest') { + description = 'Runs the exact persistence error log/trace redaction contract used by JPA evidence.' + // Named rather than tagged. This lane is JPA evidence's proof that a database failure never + // reaches a log line or a span, and it must stay exactly these two contracts — a tag would + // let a later test opt itself in and change what the evidence covers. + requires( + 'dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandlerTest.persistenceFailureObservabilityDoesNotCarryRawDatabaseDetails', + 'dev.caskeleton.adapter.inbound.web.error.SpanErrorRecorderHookTest.persistenceFailureHandlerRecordsSanitizedExceptionWithClassifiedCode') } - failOnNoDiscoveredTests = true - outputs.upToDateWhen { false } - shouldRunAfter tasks.named('test') - jvmArgs '-Duser.timezone=UTC' - afterSuite { descriptor, result -> - if (descriptor.parent == null && result.skippedTestCount > 0) { - throw new GradleException( - "webSecurityBoundaryTest forbids skipped tests: ${result.skippedTestCount}") + + lane('webSecurityBoundaryTest') { + tag = 'security-boundary' + description = 'Runs hermetic JWT/JWKS and CORS filter-boundary contracts with no skips.' + customize = { test -> + test.shouldRunAfter test.project.tasks.named('test') + test.jvmArgs '-Duser.timezone=UTC' + test.afterSuite { descriptor, result -> + if (descriptor.parent == null && result.skippedTestCount > 0) { + throw new GradleException( + "webSecurityBoundaryTest forbids skipped tests: ${result.skippedTestCount}") + } + } } } } diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestConfiguration.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestConfiguration.java new file mode 100644 index 00000000..d018e426 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestConfiguration.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback; + +import java.time.Clock; +import java.util.Set; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * The two beans the callback endpoints need, and neither of which existed. + * + *

{@code NotificationCallbackMvcController}, {@code NotificationCallbackWebFluxHandler} and + * {@code CallbackWebFluxConfiguration} all take {@code CallbackRequestFactory} as a constructor + * argument, and it was produced nowhere in production code — the only instantiation in the + * repository was inside a test. So the documented, env-registered switch {@code + * APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED=true} did not enable callbacks; it made the + * application fail to start on an unsatisfied dependency. An operator following the configuration + * reference got a deployment that would not boot. + * + *

Conditioned on the same switch as the controller, so a deployment that leaves callbacks off + * carries no URL resolver and no clock binding for them. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty( + prefix = "ca-skeleton.notification.platform.callbacks", + name = "enabled", + havingValue = "true") +public class CallbackRequestConfiguration { + + /** + * Resolves the URL a provider actually called. + * + *

The trusted-proxy set is empty by default, and that default is the safe one rather than the + * convenient one: with no entry, forwarded headers are never honoured and the resolver uses what + * the container observed. Honouring them unconditionally would let any caller choose the URL that + * gets signature-verified, which defeats the signature. A deployment behind a load balancer names + * its proxies explicitly. + * + * @param trustedProxies peers whose forwarded headers may be believed + * @return the resolver + */ + @Bean + @ConditionalOnMissingBean(ExternalRequestUrlResolver.class) + public ExternalRequestUrlResolver externalRequestUrlResolver( + @Value("${ca-skeleton.notification.platform.callbacks.trusted-proxies:}") + Set trustedProxies) { + return new ExternalRequestUrlResolver(trustedProxies); + } + + /** + * Builds the canonical callback request both transports share. + * + * @param urlResolver the external URL resolver + * @param clock the clock timestamps are read from + * @return the factory + */ + @Bean + @ConditionalOnMissingBean(CallbackRequestFactory.class) + public CallbackRequestFactory callbackRequestFactory( + ExternalRequestUrlResolver urlResolver, Clock clock) { + return new CallbackRequestFactory(urlResolver, clock); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestConfigurationTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestConfigurationTest.java new file mode 100644 index 00000000..3164cd29 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestConfigurationTest.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Clock; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Turning callbacks on produces the beans the endpoints need. + * + *

The endpoints already existed and were already conditioned on this switch; what did not exist + * was any producer of {@code CallbackRequestFactory}. Every reference to it in production code was + * a constructor parameter, so the switch documented in the configuration reference did not enable a + * feature — it failed the startup. Nothing caught that because the controller's own test builds the + * factory by hand, which is precisely the dependency a running application has to supply. + */ +class CallbackRequestConfigurationTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(CallbackRequestConfiguration.class)) + .withUserConfiguration(ClockConfiguration.class); + + @Test + @DisplayName("callbacks on supplies both beans the endpoints take") + void callbacksOnSuppliesTheBeans() { + runner + .withPropertyValues("ca-skeleton.notification.platform.callbacks.enabled=true") + .run( + context -> + assertThat(context) + .hasNotFailed() + .hasSingleBean(ExternalRequestUrlResolver.class) + .hasSingleBean(CallbackRequestFactory.class)); + } + + @Test + @DisplayName("callbacks off supplies neither, so an off deployment carries nothing") + void callbacksOffSuppliesNothing() { + runner.run( + context -> + assertThat(context) + .hasNotFailed() + .doesNotHaveBean(ExternalRequestUrlResolver.class) + .doesNotHaveBean(CallbackRequestFactory.class)); + } + + @Test + @DisplayName("no trusted proxy is configured by default") + void noTrustedProxyByDefault() { + // The safe default rather than the convenient one: with no entry, a forwarded header cannot + // choose the URL that gets signature-verified. + runner + .withPropertyValues("ca-skeleton.notification.platform.callbacks.enabled=true") + .run(context -> assertThat(context).hasSingleBean(ExternalRequestUrlResolver.class)); + } + + @Configuration(proxyBeanMethods = false) + static class ClockConfiguration { + @Bean + Clock clock() { + return Clock.systemUTC(); + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java index fff22fd4..29e2f463 100644 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java @@ -278,6 +278,16 @@ class NotificationCallbackMvcControllerTest { byProviderRequestId(ProviderProfileId profileId, String providerRequestIdHash) { return java.util.Optional.empty(); } + + @Override + public java.util.Optional< + dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot> + byProviderRequestIdHash( + ProviderProfileId profileId, + dev.caskeleton.application.notification.platform.callback.ProviderRequestIdHash + providerRequestIdHash) { + return java.util.Optional.empty(); + } } /** Never reached: projection runs only after a signature has been accepted. */ @@ -397,5 +407,10 @@ class NotificationCallbackMvcControllerTest { dev.caskeleton.application.notification.platform.api.DeliveryAttemptId attemptId) { return java.util.List.of(); } + + @Override + public boolean bindAttempt(ProviderEventRecordId eventId, DeliveryAttemptId attemptId) { + throw new UnsupportedOperationException(); + } } } diff --git a/src/adapter/inbound/websocket/build.gradle b/src/adapter/inbound/websocket/build.gradle index e304e81c..581692dd 100644 --- a/src/adapter/inbound/websocket/build.gradle +++ b/src/adapter/inbound/websocket/build.gradle @@ -10,7 +10,6 @@ // io.grpc coordinates the BOM does not manage). description = 'Inbound adapter: WebSocket (STOMP over SockJS, skeleton machinery)' -apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle" dependencies { implementation project(':domain-core') diff --git a/src/adapter/outbound/cache-redis/build.gradle b/src/adapter/outbound/cache-redis/build.gradle index 3086edcc..99c8f5be 100644 --- a/src/adapter/outbound/cache-redis/build.gradle +++ b/src/adapter/outbound/cache-redis/build.gradle @@ -1,6 +1,6 @@ // Redis SDK leaf — see docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md. // -// The design models the SDK as separate Gradle modules. This repository's fail-closed 19-leaf +// The design models the SDK as separate Gradle modules. This repository's fail-closed module // registry outranks that layout, so the module boundaries are packages under // dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them. dependencies { diff --git a/src/adapter/outbound/httpclient/build.gradle b/src/adapter/outbound/httpclient/build.gradle index 9d9d0c56..8300f452 100644 --- a/src/adapter/outbound/httpclient/build.gradle +++ b/src/adapter/outbound/httpclient/build.gradle @@ -3,7 +3,7 @@ // docs/httpclient/repository-adaptation.md (how the design's 19 library modules map here). // // The design models the platform as 19 separate Gradle modules. This repository's fail-closed -// 19-leaf registry (src/config/architecture/modules.json) outranks that layout, so the module +// module registry (src/config/architecture/modules.json) outranks that layout, so the module // boundaries are packages under dev.caskeleton.adapter.outbound.httpclient and // HttpClientModuleBoundaryTest enforces the design's module dependency table. description = 'Outbound adapter: HTTP client platform (typed clients, profiles, evidence-based retry)' @@ -73,33 +73,12 @@ dependencies { // Performance certification and JMH benchmarks are separate source sets for their own reason: they // are slow, they assert on resource bounds rather than behaviour, and they must never be part of // the default unit lane. -sourceSets { - testkit { - java.srcDir 'src/testkit/java' - compileClasspath += sourceSets.main.output - runtimeClasspath += output + compileClasspath - } - httpClientPerformanceTest { - java.srcDir 'src/httpClientPerformanceTest/java' - compileClasspath += sourceSets.main.output + sourceSets.testkit.output - runtimeClasspath += output + compileClasspath - } - jmh { - java.srcDir 'src/jmh/java' - compileClasspath += sourceSets.main.output + sourceSets.testkit.output - runtimeClasspath += output + compileClasspath - } -} - -configurations { - // The testkit compiles against exactly what a test does: testImplementation already extends - // implementation, so this is the module's own dependencies plus the test libraries. - testkitImplementation.extendsFrom testImplementation - testkitRuntimeOnly.extendsFrom testRuntimeOnly - httpClientPerformanceTestImplementation.extendsFrom testImplementation - httpClientPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly - jmhImplementation.extendsFrom testImplementation - jmhRuntimeOnly.extendsFrom testRuntimeOnly +strictTestLanes { + // The testkit compiles against exactly what a test does: `implementation` inheritance runs + // through testImplementation, so this is the module's own dependencies plus the test libraries. + sourceSet('testkit') { compilesAgainst 'main' } + sourceSet('httpClientPerformanceTest') { compilesAgainst 'main', 'testkit' } + sourceSet('jmh') { compilesAgainst 'main', 'testkit' } } // Every test lane compiles and runs against the testkit. @@ -166,69 +145,58 @@ tasks.named('test', Test) { } } -tasks.register('httpClientBlockHoundTest', Test) { - group = 'verification' - description = 'Proves no platform code blocks a Reactor event loop (design §18.2, §28.6).' - testClassesDirs = sourceSets.test.output.classesDirs - classpath = sourceSets.test.runtimeClasspath - useJUnitPlatform { includeTags 'httpclient-blockhound' } - applyContractSelection(it) - // BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them. - jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods' - // The lane exists to run BlockHound. Discovering nothing means it did not, which is a failure. - failOnNoDiscoveredTests = true - outputs.upToDateWhen { false } +// Four tag-selected lanes, declared rather than assembled. +// +// Two of them — the stable contract suite and the security suite — did not carry +// failOnNoDiscoveredTests at all. Five lanes were written by copying the block above, and the +// property that makes a lane mean anything was lost on two of the copies, so the cross-transport +// contract suite and the SSRF/credential-leak suite would each have reported success on discovering +// nothing. Declaring the lanes removes the opportunity: the convention has no opt-out. +strictTestLanes { + lane('httpClientBlockHoundTest') { + tag = 'httpclient-blockhound' + description = 'Proves no platform code blocks a Reactor event loop (design §18.2, §28.6).' + customize = { test -> + applyContractSelection(test) + // BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them. + test.jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods' + } + } + lane('httpClientStableContractTest') { + tag = 'httpclient-contract' + description = 'Runs the cross-transport stable contract suite (design §28.2, §33).' + customize = { test -> applyContractSelection(test) } + } + lane('httpClientSecurityTest') { + tag = 'httpclient-security' + description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).' + customize = { test -> applyContractSelection(test) } + } + // Its own source set rather than a tag, so the source set is the selection. + lane('httpClientPerformanceTest') { + sourceSet = 'httpClientPerformanceTest' + description = 'Certifies pool, streaming, retry, and rotation resource bounds (design §28.8).' + customize = { test -> + applyContractSelection(test) + test.systemProperty 'performance.assertions.enabled', + (project.findProperty('performance.assertions.enabled') ?: 'false').toString() + } + } + lane('httpClientFailureInjectionTest') { + tag = 'httpclient-fault' + description = 'Runs the Toxiproxy fault-injection suite; fails closed without Docker ' + + '(design §28.3).' + customize = { test -> + applyContractSelection(test) + // The upstream image is mutable by default. Passing a digest here is what makes a red + // fault run attributable to this repository rather than to someone else's image push. + test.systemProperty 'httpclient.fault.httpbin.image', + (project.findProperty('httpclient.fault.httpbin.image') + ?: 'kennethreitz/httpbin:latest').toString() + } + } } -tasks.register('httpClientStableContractTest', Test) { - group = 'verification' - description = 'Runs the cross-transport stable contract suite (design §28.2, §33).' - testClassesDirs = sourceSets.test.output.classesDirs - classpath = sourceSets.test.runtimeClasspath - useJUnitPlatform { includeTags 'httpclient-contract' } - applyContractSelection(it) - outputs.upToDateWhen { false } -} - -tasks.register('httpClientSecurityTest', Test) { - group = 'verification' - description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).' - testClassesDirs = sourceSets.test.output.classesDirs - classpath = sourceSets.test.runtimeClasspath - useJUnitPlatform { includeTags 'httpclient-security' } - applyContractSelection(it) - outputs.upToDateWhen { false } -} - -tasks.register('httpClientFailureInjectionTest', Test) { - group = 'verification' - description = 'Runs the Toxiproxy fault-injection suite; fails closed without Docker (design §28.3).' - testClassesDirs = sourceSets.test.output.classesDirs - classpath = sourceSets.test.runtimeClasspath - useJUnitPlatform { includeTags 'httpclient-fault' } - applyContractSelection(it) - // The upstream image is mutable by default. Passing a digest here is what makes a red fault - // run attributable to this repository rather than to someone else's image push. - systemProperty 'httpclient.fault.httpbin.image', - (project.findProperty('httpclient.fault.httpbin.image') ?: 'kennethreitz/httpbin:latest').toString() - // A fault suite that never injected a fault must not report success, so a selected lane with no - // discovered test is an error rather than an empty pass. - failOnNoDiscoveredTests = true - outputs.upToDateWhen { false } -} - -tasks.register('httpClientPerformanceTest', Test) { - group = 'verification' - description = 'Certifies pool, streaming, retry, and rotation resource bounds (design §28.8).' - testClassesDirs = sourceSets.httpClientPerformanceTest.output.classesDirs - classpath = sourceSets.httpClientPerformanceTest.runtimeClasspath - useJUnitPlatform() - applyContractSelection(it) - systemProperty 'performance.assertions.enabled', - (project.findProperty('performance.assertions.enabled') ?: 'false').toString() - failOnNoDiscoveredTests = true - outputs.upToDateWhen { false } -} tasks.register('jmh', JavaExec) { group = 'verification' diff --git a/src/adapter/outbound/messaging/build.gradle b/src/adapter/outbound/messaging/build.gradle index 05eaeb8c..d23001ca 100644 --- a/src/adapter/outbound/messaging/build.gradle +++ b/src/adapter/outbound/messaging/build.gradle @@ -1,4 +1,3 @@ -apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle" dependencies { implementation project(':application-core') diff --git a/src/adapter/outbound/notification/build.gradle b/src/adapter/outbound/notification/build.gradle index 01671d8d..517a9501 100644 --- a/src/adapter/outbound/notification/build.gradle +++ b/src/adapter/outbound/notification/build.gradle @@ -20,8 +20,13 @@ dependencies { implementation 'io.projectreactor:reactor-core' // JSON Schema 2020-12 validation of template variables, using the same validator and version the // messaging adapter already depends on rather than a second implementation of the same spec. - // The YAML dataformat is excluded: schemas are supplied as JSON strings, so pulling a YAML - // parser onto the runtime classpath would add attack surface for a format nothing reads. + // + // Jackson's YAML dataformat is excluded because schemas arrive as JSON strings and a second + // parser for a format this leaf never reads is surface for nothing. It does not remove YAML from + // the runtime — org.yaml:snakeyaml is on this classpath via spring-boot-starter, which is how + // Spring Boot reads application.yml. The comment here used to claim the stronger outcome, and + // the resolved graph had said otherwise for as long as it stood; dependencyPolicy below now + // states the claim the build can check. // Thymeleaf is the reference HTML renderer, added as the engine only — not the Spring // starter, which would drag a view resolver and a servlet integration onto an outbound // adapter that renders strings and never serves a request. @@ -37,3 +42,13 @@ dependencies { testImplementation 'io.projectreactor:reactor-test' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } + +// The exclusion above, stated as something the build verifies rather than something a comment +// asserts. verifyDependencyPolicy resolves runtimeClasspath and fails if the coordinate is present. +dependencyPolicy { + absent 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml', + because: 'schemas arrive as JSON strings; a second YAML parser is surface for a format ' + + 'this leaf never reads' + absent 'tools.jackson.dataformat:jackson-dataformat-yaml', + because: 'the Jackson 3 coordinate of the same parser, excluded for the same reason' +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettings.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettings.java index 22e71815..fb23f3b7 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettings.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettings.java @@ -86,13 +86,19 @@ public record NotificationPlatformSettings( /** * The largest body the platform can retain, derived rather than chosen. * - *

It was one mebibyte, while the ciphertext column holds 65,536 bytes and encryption adds a - * 12-byte nonce and a 16-byte tag. Three layers each enforced a different number: configuration - * allowed a mebibyte, the MVC controller hard-coded 65,536, and the database rejected anything - * over 65,536 *after* encryption — so a body of exactly the configured maximum passed every - * check above the database and failed the CHECK constraint, having already been acknowledged. + *

It was one mebibyte, while the ciphertext column holds 65,536 bytes and the envelope adds + * a version byte, a key id, a nonce and a tag. Three layers each enforced a different number: + * configuration allowed a mebibyte, the MVC controller hard-coded 65,536, and the database + * rejected anything over 65,536 *after* encryption — so a body of exactly the configured + * maximum passed every check above the database and failed the CHECK constraint, having already + * been acknowledged. + * + *

Read from the protector rather than restated, because a number written twice is a number + * that drifts the first time the envelope gains a field. */ - private static final long MAX_BODY_CEILING = 65_536L - 28L; + private static final long MAX_BODY_CEILING = + dev.caskeleton.adapter.outbound.notification.platform.security + .AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES; public Callbacks { Objects.requireNonNull(replaySkew, "replaySkew"); @@ -100,7 +106,12 @@ public record NotificationPlatformSettings( throw new IllegalArgumentException( "max-body-bytes must be 1.." + MAX_BODY_CEILING - + "; the ciphertext column holds 65536 bytes and encryption adds 28"); + + "; the ciphertext column holds " + + dev.caskeleton.adapter.outbound.notification.platform.security + .AesGcmCallbackPayloadProtection.MAX_CIPHERTEXT_BYTES + + " bytes and the envelope adds " + + dev.caskeleton.adapter.outbound.notification.platform.security + .AesGcmCallbackPayloadProtection.ENVELOPE_OVERHEAD_BYTES); } if (replaySkew.isNegative()) { throw new IllegalArgumentException("replay-skew must not be negative"); @@ -109,8 +120,8 @@ public record NotificationPlatformSettings( /** Conservative defaults. */ public static Callbacks defaults() { - // The storable maximum, not the column size: encryption adds 28 bytes, so a default of - // 65,536 was a default that could not be stored. + // The storable maximum, not the column size: the envelope adds a version byte, a key id, a + // nonce and a tag, so a default of 65,536 was a default that could not be stored. return new Callbacks(false, MAX_BODY_CEILING, Duration.ofMinutes(5)); } } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationSecretRequirements.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationSecretRequirements.java new file mode 100644 index 00000000..4ce10c47 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationSecretRequirements.java @@ -0,0 +1,98 @@ +package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure; + +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.util.EnumSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Which key purposes a given configuration actually needs. + * + *

Startup demanded all eight, always. That is fail-closed in the wrong direction: it made every + * deployment provision and rotate keys for capabilities it had switched off — a Web Push signing + * key for a platform with no Web Push profile, a callback signing key for a platform with no + * callback endpoint — and a key that exists but is never used is a key nobody notices leaking. It + * also made the eight look equally load-bearing, so nothing distinguished the four that every mode + * needs from the four that follow a capability. + * + *

The direction that must not weaken is the other one: a capability that is switched on + * and whose key is missing still refuses the boot, because the alternative is discovering it on a + * user's notification. This class decides only what is required; validation of whatever is supplied + * happens regardless, so an unused key that is configured is still checked rather than trusted. + */ +public final class NotificationSecretRequirements { + + private NotificationSecretRequirements() {} + + /** + * The purposes this configuration must supply. + * + * @param settings the bound platform configuration + * @return the required purposes, never empty + */ + public static Set requiredBy(NotificationPlatformSettings settings) { + Objects.requireNonNull(settings, "settings"); + Set required = EnumSet.copyOf(ALWAYS); + + if (settings.callbacks().enabled()) { + // The callback endpoint verifies a provider signature and fingerprints the payload for + // deduplication. Both happen on the first callback that arrives, so neither key can be + // deferred to "when it is needed". + required.add(SecretPurpose.CALLBACK_SIGNING); + required.add(SecretPurpose.CALLBACK_FINGERPRINT_HMAC); + } + + for (Map.Entry entry : + settings.providers().entrySet()) { + NotificationPlatformSettings.Provider profile = entry.getValue(); + if (!profile.enabled()) { + continue; + } + ProviderType type = ProviderType.parse(entry.getKey(), profile.type()); + if (authenticatesWithAPlatformCredential(type)) { + required.add(SecretPurpose.PROVIDER_CREDENTIAL); + } + if (type == ProviderType.WEB_PUSH) { + required.add(SecretPurpose.VAPID_SIGNING); + } + if (profile.callbackSigningSecretRef() != null + && !profile.callbackSigningSecretRef().isBlank()) { + // A profile that names a signing key ref intends to verify or produce signatures whatever + // the platform-wide callback switch says. + required.add(SecretPurpose.CALLBACK_SIGNING); + } + } + return Set.copyOf(required); + } + + /** + * Whether a family authenticates to its provider with a key this platform holds. + * + *

SMTP does not: its relay address, user and password come from Spring's own {@code + * spring.mail.*} through the injected mail sender, which is why {@code + * SmtpProviderRuntimeAssembler} never touches the secret store. Demanding a provider credential + * for an SMTP-only deployment asked an operator to invent a secret with nothing to authenticate + * to. + */ + private static boolean authenticatesWithAPlatformCredential(ProviderType type) { + return type != ProviderType.SMTP; + } + + /** + * The purposes every mode needs, including {@code INGEST_ONLY}. + * + *

Each of these is on the accept path rather than the dispatch path, so switching every + * provider off does not switch any of them off. Contact points are encrypted and their lookup + * hashes computed when a recipient is resolved; notification variables are encrypted at rest by + * the record mapper, which takes the protection as a constructor argument with no fallback; and + * provider request ids are hashed by the attempt store and the event ledger on every row they + * write. + */ + private static final Set ALWAYS = + Set.of( + SecretPurpose.CONTACT_ENCRYPTION, + SecretPurpose.CONTACT_LOOKUP_HMAC, + SecretPurpose.PAYLOAD_ENCRYPTION, + SecretPurpose.PROVIDER_REQUEST_LOOKUP_HMAC); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/EmailAttachments.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/EmailAttachments.java new file mode 100644 index 00000000..8eabe4b6 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/EmailAttachments.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider; + +import dev.caskeleton.application.notification.platform.api.TenantId; +import dev.caskeleton.application.notification.platform.api.content.EmailContent; +import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard; +import dev.caskeleton.application.notification.platform.provider.AttachmentAccessContext; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Opens the attachments an email submission declares. + * + *

Every email provider needs the same three things in the same order — resolve, verify, close — + * and each of them is a silent failure when a second copy gets one wrong: an unresolved attachment + * becomes a mail missing the document it is about, an unverified one becomes bytes nobody approved, + * and an unclosed one becomes a leaked stream that only shows up under load. + * + *

The integrity check runs before the provider call, not after. A digest or size that does not + * match what the caller pinned at submit time means these are not the approved bytes, and finding + * that out once the mail has left is finding it out too late. + */ +public final class EmailAttachments { + + private EmailAttachments() {} + + /** + * Resolves and verifies everything the content declares. + * + *

The caller closes the result on every path, including the failing ones. Content that is not + * email, or email that declares nothing, resolves to an empty list rather than to a failure — + * having no attachment is the normal case. + */ + public static List open( + AttachmentIntegrityGuard guard, ProviderSubmission submission) { + Objects.requireNonNull(guard, "guard"); + Objects.requireNonNull(submission, "submission"); + if (!(submission.content().content() instanceof EmailContent email) + || email.attachments().isEmpty()) { + return List.of(); + } + + AttachmentAccessContext context = + new AttachmentAccessContext( + new TenantId(submission.profile().environment()), submission.attemptId()); + List resolved = new ArrayList<>(email.attachments().size()); + try { + for (var reference : email.attachments()) { + resolved.add(guard.resolve(reference, context)); + } + return List.copyOf(resolved); + } catch (RuntimeException failure) { + // Everything already opened is closed before the failure propagates. Half a resolution is + // still half a set of open streams. + closeAll(resolved); + throw failure; + } + } + + /** Closes everything that was opened, whatever the send did. */ + public static void closeAll(List attachments) { + Objects.requireNonNull(attachments, "attachments"); + for (ResolvedAttachment attachment : attachments) { + try { + attachment.close(); + } catch (Exception ignored) { + // A stream that will not close is not a reason to change the send's outcome, and a failed + // send is exactly when a leaked one would otherwise go unnoticed. + } + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapter.java index 82874047..0d252ca5 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapter.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapter.java @@ -1,24 +1,28 @@ package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; +import dev.caskeleton.adapter.outbound.notification.platform.provider.EmailAttachments; import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager; import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; import dev.caskeleton.application.notification.platform.api.ProviderId; import dev.caskeleton.application.notification.platform.api.routing.Channel; import dev.caskeleton.application.notification.platform.contact.ContactPointValue; import dev.caskeleton.application.notification.platform.contact.EmailAddress; +import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard; import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities; import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment; import dev.caskeleton.application.notification.platform.security.AccessContext; import dev.caskeleton.application.notification.platform.security.ContactPointProtector; -import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; -import dev.caskeleton.application.notification.platform.security.SecretPurpose; import java.time.Clock; import java.time.Duration; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -42,25 +46,28 @@ public final class SesNotificationProviderAdapter implements NotificationProvide private final NotificationHttpGateway gateway; private final SesRequestMapper mapper; + private final AttachmentIntegrityGuard attachmentGuard; private final SesFailureClassifier classifier; private final ContactPointProtector protector; - private final SecretMaterialProvider secrets; + private final ProviderCredentialManager credentials; private final String accessKeyId; private final Clock clock; public SesNotificationProviderAdapter( NotificationHttpGateway gateway, SesRequestMapper mapper, + AttachmentIntegrityGuard attachmentGuard, SesFailureClassifier classifier, ContactPointProtector protector, - SecretMaterialProvider secrets, + ProviderCredentialManager credentials, String accessKeyId, Clock clock) { this.gateway = Objects.requireNonNull(gateway, "gateway"); this.mapper = Objects.requireNonNull(mapper, "mapper"); + this.attachmentGuard = Objects.requireNonNull(attachmentGuard, "attachmentGuard"); this.classifier = Objects.requireNonNull(classifier, "classifier"); this.protector = Objects.requireNonNull(protector, "protector"); - this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.credentials = Objects.requireNonNull(credentials, "credentials"); this.accessKeyId = Objects.requireNonNull(accessKeyId, "accessKeyId"); this.clock = Objects.requireNonNull(clock, "clock"); } @@ -77,8 +84,21 @@ public final class SesNotificationProviderAdapter implements NotificationProvide @Override public ProviderCapabilities capabilities() { + // The payload ceiling is the mapper's own constant rather than a second copy of the number: + // the runtime plans against what is declared here and the mapper refuses against what it holds, + // and two spellings of the same limit is one of them being wrong. return new ProviderCapabilities( - false, false, true, false, false, false, false, false, 1, 10_000_000L, Duration.ofDays(1)); + false, + false, + true, + false, + false, + false, + false, + false, + 1, + SesRequestMapper.MAX_MESSAGE_BYTES, + Duration.ofDays(1)); } @Override @@ -97,13 +117,28 @@ public final class SesNotificationProviderAdapter implements NotificationProvide throw new IllegalArgumentException("SES requires an email contact point"); } - var request = - mapper.map( - submission, - address.normalized(), - accessKeyId, - secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(), - clock.instant()); + // Resolved and verified before the request is shaped, and closed as soon as it is. An + // attachment only reaches the wire through the raw MIME message the mapper builds from these + // streams, so they have to be open for exactly that long and no longer. + List opened = EmailAttachments.open(attachmentGuard, submission); + NotificationHttpRequest request; + try { + request = + mapper.map( + submission, + address.normalized(), + opened, + accessKeyId, + // This profile's credential at the generation the submission was planned against, not + // the platform's one current provider credential. Sharing a single key across every + // profile made one leaked SES account's key a leak of every provider account, and + // made a per-profile rotation inexpressible. + credentials.materialFor( + submission.profile().profileId(), submission.profile().credentialGeneration()), + clock.instant()); + } finally { + EmailAttachments.closeAll(opened); + } try { NotificationHttpResponse response = gateway.exchange(request); diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesProviderProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesProviderProperties.java index 8c354419..05290f36 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesProviderProperties.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesProviderProperties.java @@ -20,7 +20,11 @@ public record SesProviderProperties( Objects.requireNonNull(senderIdentity, "senderIdentity"); Objects.requireNonNull(configurationSet, "configurationSet"); Objects.requireNonNull(timeout, "timeout"); - NotificationEndpoints.requireSecureOrLoopback(endpoint, "SES endpoint"); + // See WebhookSubscription for why this is the stronger guard now. An SES endpoint is operator + // configured rather than user supplied, so loopback stays available for local and contract + // profiles; what is refused is a configured endpoint that resolves into the deployment's own + // network. + NotificationEndpoints.requireExternallyRoutable(endpoint, "SES endpoint", true); if (region.isBlank() || senderIdentity.isBlank()) { throw new IllegalArgumentException("region and senderIdentity must not be blank"); } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesRequestMapper.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesRequestMapper.java index 42ee9811..26747b6b 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesRequestMapper.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesRequestMapper.java @@ -2,54 +2,98 @@ package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; +import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory; import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; import dev.caskeleton.application.notification.platform.api.content.EmailContent; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.NotificationValidationException; +import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException; import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment; +import jakarta.mail.MessagingException; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.util.Base64; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; -/** Builds the signed SES v2 send request. */ +/** + * Builds the signed SES v2 send request. + * + *

Two content shapes, chosen by what the notification actually carries. {@code Simple} is a + * subject and two bodies and nothing else, so an email that declares an attachment is assembled as + * a MIME message and sent as {@code Raw} content instead. Content that declared an attachment used + * to be sent as {@code Simple} regardless: the document was silently absent from the mail SES sent + * and the attempt was still recorded as delivered, which is a recipient told to read something that + * is not there. + * + *

The MIME message is built by the factory the SMTP provider already uses, rather than by a + * second assembly of the same rendered email. Multipart layout, UTF-8 and the header-separator + * rejection that keeps a subject from turning a notification into someone else's mail are decided + * once; two builders would be two places for those answers to drift apart. + * + *

What the raw path still cannot express is refused before the request is signed, so nothing has + * been sent when it happens: content that is not email, an attachment the resolver did not hand + * back, and a message larger than SES will accept. + */ public final class SesRequestMapper { + /** + * The largest message SES accepts, measured on the bytes that go on the wire. + * + *

Measured after assembly rather than against the declared attachment sizes, because base64 + * transfer encoding adds a third: a set of parts that clears the limit before encoding and + * exceeds it after would be rejected by SES with the attempt already made, and an attempt made is + * an attempt the evidence model has to reason about. + */ + public static final long MAX_MESSAGE_BYTES = 10_000_000L; + private static final String PATH = "/v2/email/outbound-emails"; private final SesProviderProperties properties; private final AwsSignatureV4Signer signer; + private final SmtpMimeMessageFactory mimeFactory; - public SesRequestMapper(SesProviderProperties properties, AwsSignatureV4Signer signer) { + public SesRequestMapper( + SesProviderProperties properties, + AwsSignatureV4Signer signer, + SmtpMimeMessageFactory mimeFactory) { this.properties = Objects.requireNonNull(properties, "properties"); this.signer = Objects.requireNonNull(signer, "signer"); + this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory"); } - /** Map one submission into a signed request. */ + /** + * Map one submission into a signed request. + * + * @param attachments the already resolved and verified attachments, open for the length of this + * call; the caller closes them + */ public NotificationHttpRequest map( ProviderSubmission submission, String recipientAddress, + List attachments, String accessKeyId, byte[] secretAccessKey, Instant signedAt) { Objects.requireNonNull(submission, "submission"); + Objects.requireNonNull(recipientAddress, "recipientAddress"); + Objects.requireNonNull(attachments, "attachments"); if (!(submission.content().content() instanceof EmailContent email)) { throw new IllegalArgumentException("SES requires email content"); } - Map simple = new LinkedHashMap<>(); - simple.put("Subject", Map.of("Data", email.subject(), "Charset", "UTF-8")); - Map bodyParts = new LinkedHashMap<>(); - bodyParts.put("Text", Map.of("Data", email.textBody(), "Charset", "UTF-8")); - email - .htmlBody() - .ifPresent(html -> bodyParts.put("Html", Map.of("Data", html, "Charset", "UTF-8"))); - simple.put("Body", bodyParts); - Map payload = new LinkedHashMap<>(); payload.put("FromEmailAddress", properties.senderIdentity()); - payload.put("Destination", Map.of("ToAddresses", java.util.List.of(recipientAddress))); - payload.put("Content", Map.of("Simple", simple)); + payload.put("Destination", Map.of("ToAddresses", List.of(recipientAddress))); + payload.put("Content", content(submission, email, recipientAddress, attachments)); properties.configurationSet().ifPresent(name -> payload.put("ConfigurationSetName", name)); byte[] body = @@ -83,4 +127,83 @@ public final class SesRequestMapper { body, properties.timeout()); } + + /** + * The content shape this email needs. + * + *

The decision is made from what the content declares, not from what was handed in: + * an email that declares an attachment and arrives with fewer than it declared must not fall back + * to {@code Simple}, because that is precisely the send that leaves the document behind and + * reports success. + */ + private Map content( + ProviderSubmission submission, + EmailContent email, + String recipientAddress, + List attachments) { + if (email.attachments().isEmpty()) { + return Map.of("Simple", simple(email)); + } + if (attachments.size() != email.attachments().size()) { + throw rejection( + new IllegalStateException( + "the submission declares " + + email.attachments().size() + + " attachments and " + + attachments.size() + + " were opened")); + } + return Map.of("Raw", Map.of("Data", rawMessage(submission, recipientAddress, attachments))); + } + + /** The MIME message, base64 encoded as the SES v2 JSON binding requires for a blob. */ + private String rawMessage( + ProviderSubmission submission, + String recipientAddress, + List attachments) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try { + mimeFactory + .create(submission, recipientAddress, properties.senderIdentity(), attachments) + .writeTo(buffer); + } catch (IOException | MessagingException failure) { + // The cause, never the content: which step of assembly failed is what an operator needs, and + // the bytes it failed on are the recipient's document. + throw rejection(failure); + } + + byte[] message = buffer.toByteArray(); + if (message.length > MAX_MESSAGE_BYTES) { + throw new ProviderPayloadLimitException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD)); + } + return Base64.getEncoder().encodeToString(message); + } + + private static Map simple(EmailContent email) { + Map simple = new LinkedHashMap<>(); + simple.put("Subject", Map.of("Data", email.subject(), "Charset", "UTF-8")); + Map bodyParts = new LinkedHashMap<>(); + bodyParts.put("Text", Map.of("Data", email.textBody(), "Charset", "UTF-8")); + email + .htmlBody() + .ifPresent(html -> bodyParts.put("Html", Map.of("Data", html, "Charset", "UTF-8"))); + simple.put("Body", bodyParts); + return simple; + } + + /** + * The refusal, carrying what caused it. + * + *

One descriptor for every shaping failure — it is what ends up on the delivery row, and a + * per-check code there is a metric-cardinality problem. The cause is what tells an operator which + * check fired. + */ + private static NotificationValidationException rejection(Throwable cause) { + return new NotificationValidationException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD), + cause); + } } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpMimeMessageFactory.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpMimeMessageFactory.java index c80942eb..cebb5f96 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpMimeMessageFactory.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpMimeMessageFactory.java @@ -10,6 +10,8 @@ import dev.caskeleton.application.notification.platform.provider.ResolvedAttachm import jakarta.mail.MessagingException; import jakarta.mail.Session; import jakarta.mail.internet.MimeMessage; +import java.io.ByteArrayInputStream; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Objects; @@ -72,8 +74,16 @@ public final class SmtpMimeMessageFactory { helper.setText(email.textBody(), false); } for (ResolvedAttachment attachment : attachments) { + byte[] bytes = read(attachment); + // A source that can be read again, not the resolver's one-shot stream. JavaMail reads an + // attachment twice — once to choose the part's transfer encoding, once to write the part — + // and the second read of an already drained stream returned nothing. The part that went out + // announced a filename and carried no bytes, so the mail arrived with an empty attachment + // and the attempt was still recorded as accepted. helper.addAttachment( - attachment.displayName(), () -> attachment.content(), attachment.contentType()); + attachment.displayName(), + () -> new ByteArrayInputStream(bytes), + attachment.contentType()); } for (var header : email.options().approvedHeaders().entrySet()) { requireHeaderSafe(header.getKey(), "approved header name"); @@ -86,6 +96,30 @@ public final class SmtpMimeMessageFactory { } } + /** + * Reads the attachment into memory once. + * + *

Bounded by the size the integrity guard already pinned against the reference, and the read + * is checked against it: a stream that turns out to be longer or shorter than the size that was + * verified is not the content that was approved, whatever its reported digest said. + */ + private static byte[] read(ResolvedAttachment attachment) { + byte[] bytes; + try { + bytes = attachment.content().readAllBytes(); + } catch (IOException unreadable) { + throw rejection(unreadable); + } + if (bytes.length != attachment.size()) { + // The count, never the bytes: how much was read is diagnostic, what was read is the + // recipient's document. + throw rejection( + new IllegalStateException( + "attachment declared " + attachment.size() + " bytes and read " + bytes.length)); + } + return bytes; + } + private static void requireHeaderSafe(String value, String field) { if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\0') >= 0) { // The field name, never the value: a header-injection attempt is exactly the payload that diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapter.java index 872a2f73..9d057c52 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapter.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapter.java @@ -1,10 +1,11 @@ package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; +import dev.caskeleton.adapter.outbound.notification.platform.provider.EmailAttachments; import dev.caskeleton.application.notification.platform.api.ProviderId; -import dev.caskeleton.application.notification.platform.api.content.EmailContent; import dev.caskeleton.application.notification.platform.api.routing.Channel; import dev.caskeleton.application.notification.platform.contact.ContactPointValue; import dev.caskeleton.application.notification.platform.contact.EmailAddress; +import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard; import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities; import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; @@ -38,8 +39,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid private final ContactPointProtector protector; private final SmtpProviderProperties properties; private final Executor executor; - private final dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard - attachmentGuard; + private final AttachmentIntegrityGuard attachmentGuard; public SmtpNotificationProviderAdapter( SmtpDispatch dispatch, @@ -48,8 +48,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid ContactPointProtector protector, SmtpProviderProperties properties, Executor executor, - dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard - attachmentGuard) { + AttachmentIntegrityGuard attachmentGuard) { this.dispatch = Objects.requireNonNull(dispatch, "dispatch"); this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory"); this.classifier = Objects.requireNonNull(classifier, "classifier"); @@ -96,7 +95,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid // Resolved, verified and closed around the send. The factory was handed List.of() whatever the // content asked for, so an email with attachments went out without them — the caller was told // it was accepted, and the recipient received a message missing the thing it was about. - List opened = resolve(submission); + List opened = EmailAttachments.open(attachmentGuard, submission); try { dispatch.send( mimeFactory.create( @@ -105,51 +104,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid } catch (SmtpDispatchException failure) { return classifier.classify(failure, elapsedSince(startedNanos)); } finally { - // Closed on every path. A resolver hands back an open stream, and a failed send is exactly - // when a leaked one goes unnoticed. - opened.forEach(SmtpNotificationProviderAdapter::closeQuietly); - } - } - - /** - * Resolves and verifies every attachment the content declares. - * - *

The integrity guard runs before the provider call, not after: a digest or size that does not - * match what the caller declared means the bytes are not the bytes that were approved, and - * discovering that after the mail has left is discovering it too late. - */ - private List resolve(ProviderSubmission submission) { - if (!(submission.content().content() instanceof EmailContent email) - || email.attachments().isEmpty()) { - return List.of(); - } - List resolved = new java.util.ArrayList<>(email.attachments().size()); - try { - for (var reference : email.attachments()) { - // The guard resolves and verifies size and digest in one step, so an attachment whose - // bytes are not the approved bytes never reaches the MIME factory. - resolved.add( - attachmentGuard.resolve( - reference, - new dev.caskeleton.application.notification.platform.provider - .AttachmentAccessContext( - new dev.caskeleton.application.notification.platform.api.TenantId( - submission.profile().environment()), - submission.attemptId()))); - } - return List.copyOf(resolved); - } catch (RuntimeException failure) { - // Everything already opened is closed before the failure propagates. - resolved.forEach(SmtpNotificationProviderAdapter::closeQuietly); - throw failure; - } - } - - private static void closeQuietly(ResolvedAttachment attachment) { - try { - attachment.close(); - } catch (Exception ignored) { - // A stream that will not close is not a reason to change the send's outcome. + EmailAttachments.closeAll(opened); } } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAdapter.java index b402483b..d56993d3 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAdapter.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAdapter.java @@ -52,7 +52,7 @@ public final class TwilioCallbackAdapter implements ProviderCallbackAdapter { properties.canonicalCallbackUrl(), parameters, request.header("x-twilio-signature").orElse(null), - secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material()); + signingKey()); return valid ? CallbackVerificationResult.valid(new VerifiedCallback(request, parameters)) : CallbackVerificationResult.invalid("TWILIO_SIGNATURE_MISMATCH"); @@ -65,6 +65,26 @@ public final class TwilioCallbackAdapter implements ProviderCallbackAdapter { return List.of(normalizer.normalize(callback.canonicalParameters(), occurredAt)); } + /** + * The key this profile's callbacks are signed with. + * + *

Verification used the platform's one current callback signing key, so every Twilio profile + * in a deployment shared it: a subaccount whose token leaked could forge status callbacks for any + * other, and a profile could not be rotated on its own. {@code callbackSigningKeyRef} is the + * profile's own reference, and a reference naming a key issued for another purpose is a + * configuration fault rather than a signature that quietly never matches. + */ + private byte[] signingKey() { + var key = secrets.keyById(properties.callbackSigningKeyRef()); + if (key.purpose() != SecretPurpose.CALLBACK_SIGNING) { + throw new IllegalStateException( + "twilio profile for account " + + properties.accountSid() + + " names a key that is not a callback signing key"); + } + return key.material(); + } + private static Map parseForm(byte[] body) { Map parameters = new LinkedHashMap<>(); String raw = new String(body, StandardCharsets.UTF_8); diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioProviderProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioProviderProperties.java index 8903e6a3..46c31c27 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioProviderProperties.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioProviderProperties.java @@ -12,6 +12,10 @@ import java.util.Optional; * request. Twilio signs the URL it called, and a reverse proxy that rewrites scheme or host makes a * server-side reconstruction disagree with the signature — the most common cause of "valid webhook, * failed verification". + * + *

{@code callbackSigningKeyRef} is this profile's own signing key, mirroring the profile's + * {@code callback-signing-secret-ref} setting. Verification used the platform's single current + * callback signing key, so every profile shared one secret. */ public record TwilioProviderProperties( URI endpoint, @@ -19,6 +23,7 @@ public record TwilioProviderProperties( Optional messagingServiceSid, Optional fromNumber, String canonicalCallbackUrl, + String callbackSigningKeyRef, Duration timeout, Duration maxReconciliationAge) { @@ -28,11 +33,17 @@ public record TwilioProviderProperties( Objects.requireNonNull(messagingServiceSid, "messagingServiceSid"); Objects.requireNonNull(fromNumber, "fromNumber"); Objects.requireNonNull(canonicalCallbackUrl, "canonicalCallbackUrl"); + Objects.requireNonNull(callbackSigningKeyRef, "callbackSigningKeyRef"); Objects.requireNonNull(timeout, "timeout"); Objects.requireNonNull(maxReconciliationAge, "maxReconciliationAge"); if (accountSid.isBlank()) { throw new IllegalArgumentException("accountSid"); } + if (callbackSigningKeyRef.isBlank()) { + // Blank would fall back to whatever key is current, which is the platform-wide sharing this + // reference exists to end. + throw new IllegalArgumentException("callbackSigningKeyRef"); + } if (messagingServiceSid.isEmpty() == fromNumber.isEmpty()) { throw new IllegalArgumentException( "exactly one of messagingServiceSid or fromNumber must be configured"); diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioReconciliationCapability.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioReconciliationCapability.java index 692e8282..6fcdd5b2 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioReconciliationCapability.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioReconciliationCapability.java @@ -5,13 +5,12 @@ import dev.caskeleton.adapter.outbound.notification.platform.provider.http.Notif import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager; import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot; import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot; import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability; import dev.caskeleton.application.notification.platform.provider.ReconciliationResult; -import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; -import dev.caskeleton.application.notification.platform.security.SecretPurpose; import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Clock; @@ -37,19 +36,19 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab private final NotificationHttpGateway gateway; private final TwilioProviderProperties properties; private final TwilioStatusNormalizer normalizer; - private final SecretMaterialProvider secrets; + private final ProviderCredentialManager credentials; private final Clock clock; public TwilioReconciliationCapability( NotificationHttpGateway gateway, TwilioProviderProperties properties, TwilioStatusNormalizer normalizer, - SecretMaterialProvider secrets, + ProviderCredentialManager credentials, Clock clock) { this.gateway = Objects.requireNonNull(gateway, "gateway"); this.properties = Objects.requireNonNull(properties, "properties"); this.normalizer = Objects.requireNonNull(normalizer, "normalizer"); - this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.credentials = Objects.requireNonNull(credentials, "credentials"); this.clock = Objects.requireNonNull(clock, "clock"); } @@ -75,7 +74,8 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab } try { - NotificationHttpResponse response = gateway.exchange(statusRequest(messageSid.get())); + NotificationHttpResponse response = + gateway.exchange(statusRequest(attempt, messageSid.get())); if (!response.isSuccessful()) { return CompletableFuture.completedFuture( new ReconciliationResult.Failed( @@ -111,14 +111,19 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab }; } - private NotificationHttpRequest statusRequest(String messageSid) { - String credentials = + private NotificationHttpRequest statusRequest( + DeliveryAttemptSnapshot attempt, String messageSid) { + // The credential the attempt was made with, not whichever one is current. A status query is a + // question about work that already happened, and asking it with a newer generation's token + // fails once a rotation has landed — precisely when reconciliation matters most. + String authorization = Base64.getEncoder() .encodeToString( (properties.accountSid() + ":" + new String( - secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(), + credentials.materialFor( + attempt.providerProfileId(), attempt.credentialGeneration()), StandardCharsets.UTF_8)) .getBytes(StandardCharsets.UTF_8)); @@ -131,7 +136,7 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab + "/Messages/" + messageSid + ".json"), - JdkNotificationHttpGateway.headers(Map.of("authorization", "Basic " + credentials)), + JdkNotificationHttpGateway.headers(Map.of("authorization", "Basic " + authorization)), new byte[0], properties.timeout()); } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapter.java index ad1cebda..dca3e434 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapter.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapter.java @@ -4,6 +4,7 @@ import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderRe import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager; import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; import dev.caskeleton.application.notification.platform.api.ProviderId; import dev.caskeleton.application.notification.platform.api.routing.Channel; @@ -15,8 +16,6 @@ import dev.caskeleton.application.notification.platform.provider.ProviderSubmiss import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; import dev.caskeleton.application.notification.platform.security.AccessContext; import dev.caskeleton.application.notification.platform.security.ContactPointProtector; -import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; -import dev.caskeleton.application.notification.platform.security.SecretPurpose; import java.time.Duration; import java.util.Objects; import java.util.Optional; @@ -40,19 +39,19 @@ public final class TwilioSmsProviderAdapter implements NotificationProviderAdapt private final TwilioRequestMapper mapper; private final TwilioFailureClassifier classifier; private final ContactPointProtector protector; - private final SecretMaterialProvider secrets; + private final ProviderCredentialManager credentials; public TwilioSmsProviderAdapter( NotificationHttpGateway gateway, TwilioRequestMapper mapper, TwilioFailureClassifier classifier, ContactPointProtector protector, - SecretMaterialProvider secrets) { + ProviderCredentialManager credentials) { this.gateway = Objects.requireNonNull(gateway, "gateway"); this.mapper = Objects.requireNonNull(mapper, "mapper"); this.classifier = Objects.requireNonNull(classifier, "classifier"); this.protector = Objects.requireNonNull(protector, "protector"); - this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.credentials = Objects.requireNonNull(credentials, "credentials"); } @Override @@ -93,7 +92,11 @@ public final class TwilioSmsProviderAdapter implements NotificationProviderAdapt mapper.map( submission, phone.e164(), - secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material()); + // This profile's auth token at the generation the submission was planned against. One + // platform-wide provider credential meant a leak of one Twilio account's token was a + // leak of every profile's, whichever provider they belonged to. + credentials.materialFor( + submission.profile().profileId(), submission.profile().credentialGeneration())); try { NotificationHttpResponse response = gateway.exchange(request); diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapter.java index 5318d75f..ec6b11f1 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapter.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapter.java @@ -10,6 +10,8 @@ import dev.caskeleton.adapter.outbound.notification.platform.template.Notificati import dev.caskeleton.application.notification.platform.api.ProviderId; import dev.caskeleton.application.notification.platform.api.error.FailureCategory; import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException; import dev.caskeleton.application.notification.platform.api.routing.Channel; import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities; @@ -82,10 +84,23 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro return Set.of(Channel.WEBHOOK); } + /** The largest body this adapter will put on the wire. */ + public static final long MAX_BODY_BYTES = 1_000_000L; + @Override public ProviderCapabilities capabilities() { return new ProviderCapabilities( - false, false, false, false, false, false, false, false, 1, 1_000_000L, Duration.ofHours(1)); + false, + false, + false, + false, + false, + false, + false, + false, + 1, + MAX_BODY_BYTES, + Duration.ofHours(1)); } @Override @@ -130,6 +145,14 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro NotificationJsonMapper.mapper() .writeValueAsString(envelope) .getBytes(StandardCharsets.UTF_8); + // Measured on the bytes that will be sent. The capability declared a ceiling and nothing + // enforced it, so an oversized body was discovered by the receiver rejecting it — after the + // request had been made, which for a webhook is after the receiver may already have acted. + if (body.length > MAX_BODY_BYTES) { + throw new ProviderPayloadLimitException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD)); + } Map headers = new LinkedHashMap<>(); headers.put("content-type", "application/json"); @@ -139,8 +162,7 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro WebhookSignatureStrategy.TIMESTAMP_HEADER, Long.toString(timestamp.getEpochSecond())); headers.put( WebhookSignatureStrategy.SIGNATURE_HEADER, - signatures.sign( - body, timestamp, secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material())); + signatures.sign(body, timestamp, signingKey(subscription))); } NotificationHttpRequest request = @@ -182,6 +204,29 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro } } + /** + * The key a subscription's signature is computed with. + * + *

{@code signingKeyRef} was read only to decide whether to sign at all, and the signature was + * then computed with the platform's current callback signing key. Every trusted subscription + * therefore shared one secret: a receiver holding its own key could verify — and forge — + * deliveries meant for any other, and rotating one subscription's key rotated all of them. + * + * @throws IllegalStateException if the reference names a key that is not a callback signing key, + * which is a configuration fault and is raised before the request is made rather than + * producing a signature the receiver will reject + */ + private byte[] signingKey(WebhookSubscription subscription) { + var key = secrets.keyById(subscription.signingKeyRef().orElseThrow()); + if (key.purpose() != SecretPurpose.CALLBACK_SIGNING) { + throw new IllegalStateException( + "webhook subscription " + + subscription.subscriptionId() + + " names a key that is not a callback signing key"); + } + return key.material(); + } + /** * The receiver's own backoff hint, when it sent a usable one. * diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSubscription.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSubscription.java index ea4911c4..614294a7 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSubscription.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSubscription.java @@ -11,6 +11,10 @@ import java.util.Optional; *

{@code trusted} decides which gateway carries the call. A trusted subscription is operator * configured and may use platform credentials; a dynamic one comes from user input and must not * inherit anything, because that is how a webhook feature becomes an SSRF credential-relay. + * + *

It decides the loopback allowance for the same reason. An operator naming a local endpoint is + * describing their own deployment; a client naming one is asking the platform to deliver a message + * body to an interface the client cannot otherwise reach. */ public record WebhookSubscription( String subscriptionId, URI target, boolean trusted, Optional signingKeyRef) { @@ -22,7 +26,27 @@ public record WebhookSubscription( if (subscriptionId.isBlank()) { throw new IllegalArgumentException("subscriptionId"); } - NotificationEndpoints.requireSecureOrLoopback(target, "webhook target"); + // requireExternallyRoutable, not requireSecureOrLoopback. The scheme check accepted any HTTPS + // URL, so `https://169.254.169.254/` — the cloud metadata service — and every RFC 1918 address + // passed. The stronger guard was written for exactly this call site and then called from + // nowhere: it existed, its own tests were green, and the two sites it was written for kept the + // weaker check. + // + // The loopback allowance is `trusted`, not a constant. It was `true` for every caller, which + // left one case open: a client-supplied target naming `localhost` reached the loopback + // interface. Closing it was deferred on the grounds that the allowance had to become a decision + // the caller states and no caller existed to state it — but the decision is this record's first + // boolean, and two lines below it already decides whether the target may inherit a platform + // signing key. A subscription an operator configured may address a local endpoint, because the + // operator profiles and the contract harness do exactly that. One that came from user input may + // not, for the same reason it may not inherit credentials: it is not the deployment's own + // address to name. + // + // What remains open is narrower and belongs to the guard, not here: the target is resolved once + // at construction and re-resolved independently by the HTTP client, so a name that changes its + // answer between the two is refused only if the first lookup already shows an internal address. + // NTF-012, docs/reviews/2026-08-14-notification-module-code-review.md. + NotificationEndpoints.requireExternallyRoutable(target, "webhook target", trusted); if (!trusted && signingKeyRef.isPresent()) { throw new IllegalArgumentException( "a dynamic target may not be paired with a platform signing key"); diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmCallbackPayloadProtection.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmCallbackPayloadProtection.java index d846e9a6..9346bef6 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmCallbackPayloadProtection.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmCallbackPayloadProtection.java @@ -4,18 +4,16 @@ import dev.caskeleton.adapter.outbound.notification.platform.template.Notificati import dev.caskeleton.application.notification.platform.api.ProviderProfileId; import dev.caskeleton.application.notification.platform.callback.CallbackPayloadProtectionPort; import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent; +import dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection; import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; import dev.caskeleton.application.notification.platform.security.SecretPurpose; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; -import java.security.SecureRandom; import java.util.Arrays; import java.util.HexFormat; import java.util.Objects; import java.util.TreeMap; -import javax.crypto.Cipher; import javax.crypto.Mac; -import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; /** @@ -25,35 +23,36 @@ import javax.crypto.spec.SecretKeySpec; * provider actually sent — but it routinely contains addresses and message metadata, so it is * encrypted and truncated rather than stored as received. * - *

The nonce is prefixed to the ciphertext so a rotation does not need a second column, and the - * fingerprint is keyed so that providers without an event id still get collision-resistant, + *

The stored bytes are the same versioned, key-identified envelope the notification payload + * column uses, and for the same reason. This class used to write a bare nonce and ciphertext: the + * day the payload encryption key rotated, every retained callback became unreadable and nothing in + * the row could say which key it had needed. A retention format whose whole justification is later + * diagnosis has to survive the rotation that happens in between. + * + *

The fingerprint is keyed so that providers without an event id still get collision-resistant, * non-enumerable duplicate detection. */ public final class AesGcmCallbackPayloadProtection implements CallbackPayloadProtectionPort { - private static final int NONCE_BYTES = 12; - private static final int TAG_BITS = 128; - private final SecretMaterialProvider secrets; - private final SecureRandom random; + private final NotificationPayloadProtection payloads; private final int maxRetainedBytes; - public AesGcmCallbackPayloadProtection(SecretMaterialProvider secrets, int maxRetainedBytes) { - this(secrets, new SecureRandom(), maxRetainedBytes); - } - - AesGcmCallbackPayloadProtection( - SecretMaterialProvider secrets, SecureRandom random, int maxRetainedBytes) { + public AesGcmCallbackPayloadProtection( + SecretMaterialProvider secrets, + NotificationPayloadProtection payloads, + int maxRetainedBytes) { this.secrets = Objects.requireNonNull(secrets, "secrets"); - this.random = Objects.requireNonNull(random, "random"); + this.payloads = Objects.requireNonNull(payloads, "payloads"); if (maxRetainedBytes < 1) { throw new IllegalArgumentException("maxRetainedBytes"); } if (maxRetainedBytes > MAX_PLAINTEXT_BYTES) { - // The database check constrains the *ciphertext*, and encryption adds a 12-byte nonce and a - // 16-byte GCM tag. Truncating the plaintext to the ciphertext bound produced a value 28 bytes - // over it, so a callback of exactly the configured maximum was accepted by every layer above - // and then rejected by a CHECK constraint after the provider had been told it was stored. + // The database check constrains the *ciphertext*, and encryption adds a version byte, a key + // id, a nonce and a GCM tag. Truncating the plaintext to the ciphertext bound produced a + // value larger than it, so a callback of exactly the configured maximum was accepted by + // every layer above and then rejected by a CHECK constraint after the provider had been told + // it was stored. throw new IllegalArgumentException( "callback retention of " + maxRetainedBytes @@ -75,8 +74,15 @@ public final class AesGcmCallbackPayloadProtection implements CallbackPayloadPro */ public static final int MAX_CIPHERTEXT_BYTES = 65_536; - /** The nonce and GCM tag every encryption adds. */ - public static final int ENVELOPE_OVERHEAD_BYTES = NONCE_BYTES + TAG_BITS / 8; + /** + * The most the envelope adds: version, key id, nonce and GCM tag. + * + *

Reserved at the largest key id the envelope allows rather than measured against the current + * one, because a rotation to a longer id would otherwise push a body that fit yesterday past the + * column's check constraint. + */ + public static final int ENVELOPE_OVERHEAD_BYTES = + AesGcmNotificationPayloadProtection.MAX_ENVELOPE_OVERHEAD_BYTES; /** The largest plaintext that still fits the column once encrypted. */ public static final int MAX_PLAINTEXT_BYTES = MAX_CIPHERTEXT_BYTES - ENVELOPE_OVERHEAD_BYTES; @@ -86,22 +92,10 @@ public final class AesGcmCallbackPayloadProtection implements CallbackPayloadPro Objects.requireNonNull(rawBody, "rawBody"); byte[] bounded = rawBody.length <= maxRetainedBytes ? rawBody : Arrays.copyOf(rawBody, maxRetainedBytes); - byte[] nonce = new byte[NONCE_BYTES]; - random.nextBytes(nonce); - try { - Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); - cipher.init( - Cipher.ENCRYPT_MODE, - new SecretKeySpec(secrets.activeKey(SecretPurpose.PAYLOAD_ENCRYPTION).material(), "AES"), - new GCMParameterSpec(TAG_BITS, nonce)); - byte[] ciphertext = cipher.doFinal(bounded); - byte[] stored = new byte[nonce.length + ciphertext.length]; - System.arraycopy(nonce, 0, stored, 0, nonce.length); - System.arraycopy(ciphertext, 0, stored, nonce.length, ciphertext.length); - return stored; - } catch (GeneralSecurityException failure) { - throw new IllegalStateException("callback payload encryption failed", failure); - } + // Delegated rather than reimplemented so the retained callback and the retained notification + // payload are one format with one reader. The alternative is two envelopes that drift, and the + // one that drifts is always the one nothing reads until an incident. + return payloads.protect(bounded); } @Override diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmNotificationPayloadProtection.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmNotificationPayloadProtection.java index de8aafbd..dba783d4 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmNotificationPayloadProtection.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmNotificationPayloadProtection.java @@ -50,6 +50,16 @@ public final class AesGcmNotificationPayloadProtection implements NotificationPa private static final int TAG_BITS = 128; private static final int MAX_KEY_ID_BYTES = 255; + /** + * The most this envelope can add to a plaintext. + * + *

The header is variable — a key id is one to 255 bytes — so anything that has to guarantee a + * ciphertext fits a fixed column reserves the largest header rather than the current one. A bound + * computed from today's key id stops holding the moment a rotation picks a longer one. + */ + static final int MAX_ENVELOPE_OVERHEAD_BYTES = + 2 + MAX_KEY_ID_BYTES + NONCE_BYTES + TAG_BITS / Byte.SIZE; + private final SecretMaterialProvider secrets; private final SecureRandom random; diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/ProviderCredentialManager.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/ProviderCredentialManager.java index 91ff330e..f2cc4135 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/ProviderCredentialManager.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/ProviderCredentialManager.java @@ -5,6 +5,7 @@ import dev.caskeleton.application.notification.platform.security.SecretKeyMateri import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; import dev.caskeleton.application.notification.platform.security.SecretPurpose; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.util.Map; import java.util.Objects; @@ -14,6 +15,15 @@ import java.util.concurrent.ConcurrentHashMap; /** * Tracks which credential generation is current for each provider profile. * + *

A rotation is a window, not an instant. Activating a new generation supersedes the previous + * one but keeps it resolvable for a bounded drain period, because work planned against the old + * generation is already in flight when the rotation lands: an attempt whose request may already + * have reached the provider cannot simply be failed, and cannot be replayed either. Callers ask for + * the generation their work was planned with — {@code ProviderProfileSnapshot} and {@code + * DeliveryAttemptSnapshot} both carry it — so the window covers exactly that work and nothing else. + * Past the window the old credential stops resolving, because a superseded credential that stays + * usable indefinitely is not a rotation, it is two live credentials. + * *

Two rotations are deliberately not handled here, because treating them as ordinary * credential swaps would silently lose data or delivery: * @@ -31,13 +41,46 @@ public final class ProviderCredentialManager { private final SecretMaterialProvider secrets; private final Clock clock; + private final Duration drainWindow; private final Map current = new ConcurrentHashMap<>(); + private final Map> draining = new ConcurrentHashMap<>(); - public ProviderCredentialManager(SecretMaterialProvider secrets, Clock clock) { + /** + * Creates the manager. + * + * @param secrets the key store + * @param clock the clock the drain window is measured against + * @param drainWindow how long a superseded generation keeps serving the work that started on it + */ + public ProviderCredentialManager( + SecretMaterialProvider secrets, Clock clock, Duration drainWindow) { this.secrets = Objects.requireNonNull(secrets, "secrets"); this.clock = Objects.requireNonNull(clock, "clock"); + Objects.requireNonNull(drainWindow, "drainWindow"); + if (drainWindow.isNegative()) { + throw new IllegalArgumentException("drainWindow"); + } + this.drainWindow = drainWindow; } + /** + * The default drain window: long enough for an in-flight attempt, short enough to be a window. + */ + public static final Duration DEFAULT_DRAIN_WINDOW = Duration.ofMinutes(15); + + /** + * Creates the manager with the default drain window. + * + * @param secrets the key store + * @param clock the clock the drain window is measured against + */ + public ProviderCredentialManager(SecretMaterialProvider secrets, Clock clock) { + this(secrets, clock, DEFAULT_DRAIN_WINDOW); + } + + /** A superseded generation and the instant it stops being usable. */ + private record Draining(CredentialGeneration generation, Instant usableUntil) {} + /** * Record the generation a profile starts on. * @@ -63,10 +106,76 @@ public final class ProviderCredentialManager { if (existing != null && !generation.supersedes(existing)) { throw new IllegalArgumentException("generation does not supersede the active one"); } + if (existing != null) { + // Superseded, not deleted. An attempt that was planned against the previous generation + // is already in flight when the rotation lands, and retiring the credential the instant + // the new one arrives fails exactly that work — the requests nobody can replay, because + // the provider may already have acted on them. The window bounds it: an old credential + // that stays usable forever is not a rotation, it is two live credentials. + retire(profileId, existing, activatedAt); + } return generation.activatedAt(activatedAt); }); } + private void retire( + ProviderProfileId profileId, CredentialGeneration superseded, Instant supersededAt) { + draining + .computeIfAbsent(profileId, id -> new ConcurrentHashMap<>()) + .put(superseded.generation(), new Draining(superseded, supersededAt.plus(drainWindow))); + } + + /** + * Credential material for one profile at the generation the work was planned against. + * + *

Every adapter resolved {@code activeKey(PROVIDER_CREDENTIAL)} instead: one credential for + * every profile in the deployment, so a leak of one provider account's key was a leak of all of + * them, and a per-profile rotation was not expressible at all. The generation is not a parameter + * a caller invents — {@code ProviderProfileSnapshot.credentialGeneration()} and {@code + * DeliveryAttemptSnapshot.credentialGeneration()} already carry the number the work was planned + * with, which is what makes the drain window mean something rather than being a grace period + * nobody claims. + * + * @param profileId the profile the work belongs to + * @param generation the generation the work was planned against + * @return the material + * @throws IllegalStateException if the profile has no active generation, or the requested one is + * neither current nor still inside its drain window + */ + public byte[] materialFor(ProviderProfileId profileId, long generation) { + Objects.requireNonNull(profileId, "profileId"); + CredentialGeneration active = current.get(profileId); + if (active == null) { + throw new IllegalStateException( + "provider profile " + profileId.value() + " has no activated credential generation"); + } + if (active.generation() == generation) { + return material(active).material(); + } + Draining retired = draining.getOrDefault(profileId, Map.of()).get(generation); + if (retired == null) { + throw new IllegalStateException( + "provider profile " + + profileId.value() + + " has no credential generation " + + generation + + "; the active generation is " + + active.generation()); + } + if (!clock.instant().isBefore(retired.usableUntil())) { + // Dropped rather than served: past the window, work still asking for the old generation is + // work that has been stuck long enough that using a retired credential is the larger risk. + draining.getOrDefault(profileId, Map.of()).remove(generation); + throw new IllegalStateException( + "credential generation " + + generation + + " for provider profile " + + profileId.value() + + " finished draining; it is no longer usable"); + } + return material(retired.generation()).material(); + } + /** Current generation of a profile. */ public Optional current(ProviderProfileId profileId) { return Optional.ofNullable(current.get(Objects.requireNonNull(profileId, "profileId"))); diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationTemplateEngine.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationTemplateEngine.java index 68838437..5c9fda8d 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationTemplateEngine.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationTemplateEngine.java @@ -10,7 +10,6 @@ import java.util.Map; * renderer per engine is how two implementations end up computing different digests for the same * template, which silently breaks the retry equality the digest exists to prove. */ -@FunctionalInterface public interface NotificationTemplateEngine { /** @@ -27,12 +26,17 @@ public interface NotificationTemplateEngine { *

The mode is required rather than inferred: the same template text is safe in a text part and * dangerous in an HTML one, and only the caller knows which it is filling. * + *

Abstract, not a {@code default} that forwards to the single-argument overload. It was that + * default, and one of the two engines never overrode it — so selecting that engine silently + * dropped every slot to the unescaped path: a subject could carry CR/LF, a deep link could carry + * a {@code javascript:} scheme, and plain text had its ampersands HTML-escaped on the wire. A + * default that discards its own argument is not a fallback; it is the rule not applying, and the + * engine that skipped it looked complete because the interface compiled. + * * @param mode what the rendered value will become * @param source the template text * @param variables the values to substitute * @return the rendered slot */ - default String render(TemplateSlotMode mode, String source, Map variables) { - return render(source, variables); - } + String render(TemplateSlotMode mode, String source, Map variables); } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/PlaceholderTemplateEngine.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/PlaceholderTemplateEngine.java index 80877fdd..50cc859f 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/PlaceholderTemplateEngine.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/PlaceholderTemplateEngine.java @@ -61,79 +61,8 @@ public final class PlaceholderTemplateEngine implements NotificationTemplateEngi /** Escapes one substituted value for its destination. */ private static String escape(TemplateSlotMode mode, String value) { - return switch (mode) { - case TEXT -> value; - case SUBJECT -> requireSingleLine(value); - case HTML_TEXT -> escapeHtml(value); - case URI -> requireAllowedScheme(value); - }; + // The rules moved to TemplateSlotPolicy so the other engine could reach them. They were private + // here, which is why that engine had none. + return TemplateSlotPolicy.escape(mode, value); } - - /** - * Refuses a value that would split a header. - * - *

A carriage return or newline in a subject is header injection: everything after it is read - * as a new header by the receiving agent. - */ - private static String requireSingleLine(String value) { - for (int index = 0; index < value.length(); index++) { - if (value.charAt(index) < 0x20) { - throw new TemplateRenderingException( - NotificationFailureDescriptor.preDispatch( - NotificationFailureCode.TEMPLATE_RENDERING_FAILED, - FailureCategory.TEMPLATE_FAILURE)); - } - } - return value; - } - - /** - * Escapes for HTML text and attribute content. - * - *

Quotes included, because a value substituted inside an attribute can otherwise close it and - * start an event handler — {@code " onerror="} needs no angle bracket at all. - */ - private static String escapeHtml(String value) { - StringBuilder escaped = new StringBuilder(value.length() + 16); - for (int index = 0; index < value.length(); index++) { - char character = value.charAt(index); - switch (character) { - case '&' -> escaped.append("&"); - case '<' -> escaped.append("<"); - case '>' -> escaped.append(">"); - case '"' -> escaped.append("""); - case '\'' -> escaped.append("'"); - default -> escaped.append(character); - } - } - return escaped.toString(); - } - - /** - * Allows only schemes a notification may legitimately link to. - * - *

{@code javascript:} in a link is script execution; {@code data:} is an arbitrary document - * the platform vouches for; {@code file:} points at the reader's own machine. The slot used to be - * parsed as a URI and otherwise accepted, and parsing succeeds for all three. - */ - private static String requireAllowedScheme(String value) { - String normalized = value.trim().toLowerCase(java.util.Locale.ROOT); - boolean allowed = - ALLOWED_URI_SCHEMES.stream().anyMatch(scheme -> normalized.startsWith(scheme + ":")); - if (!allowed) { - throw new TemplateRenderingException( - NotificationFailureDescriptor.preDispatch( - NotificationFailureCode.TEMPLATE_RENDERING_FAILED, FailureCategory.TEMPLATE_FAILURE)); - } - return value; - } - - /** - * The schemes a rendered link may use. - * - *

HTTPS, and the application's own deep-link scheme. Plain HTTP is absent deliberately: a link - * in a notification is followed by a person who has no way to check it. - */ - private static final java.util.Set ALLOWED_URI_SCHEMES = - java.util.Set.of("https", "caskeleton"); } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/TemplateSlotPolicy.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/TemplateSlotPolicy.java new file mode 100644 index 00000000..aa4c6fa3 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/TemplateSlotPolicy.java @@ -0,0 +1,129 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException; +import java.util.Locale; +import java.util.Set; + +/** + * What each slot mode means, in one place both engines use. + * + *

These rules lived as private helpers inside {@code PlaceholderTemplateEngine}, and the other + * engine had no equivalent — it did not override the mode-aware render at all, so selecting it + * dropped every slot to the unescaped path. One engine enforced the policy and the other did not + * have access to it. + * + *

Nothing here is engine-specific: a subject may not carry a control character whoever produced + * it, and a deep link may not use {@code javascript:} whoever rendered it. + */ +public final class TemplateSlotPolicy { + + /** + * The schemes a rendered link may use. + * + *

HTTPS, and the application's own deep-link scheme. Plain HTTP is absent deliberately: a link + * in a notification is followed by a person who has no way to check it. + */ + private static final Set ALLOWED_URI_SCHEMES = Set.of("https", "caskeleton"); + + private TemplateSlotPolicy() {} + + /** + * Escapes one substituted value for its destination. + * + * @param mode the slot being filled + * @param value the value to escape + * @return the escaped value + */ + public static String escape(TemplateSlotMode mode, String value) { + return switch (mode) { + case TEXT -> value; + case SUBJECT -> requireSingleLine(value); + case HTML_TEXT -> escapeHtml(value); + case URI -> requireAllowedScheme(value); + }; + } + + /** + * Checks a whole rendered slot, for an engine that substitutes internally. + * + *

An engine that does its own substitution cannot escape per value, so the guarantee is + * applied to what it produced. For SUBJECT and URI that is the stronger statement: no control + * character anywhere in the subject, and the finished link uses an allowed scheme. Escaping modes + * are the engine's own job — asking it to render HTML and then escaping the result would escape + * the operator's markup too. + * + * @param mode the slot that was filled + * @param rendered the engine's output + * @return the output, unchanged when it satisfies the slot + */ + public static String verifyRendered(TemplateSlotMode mode, String rendered) { + return switch (mode) { + case TEXT, HTML_TEXT -> rendered; + case SUBJECT -> requireSingleLine(rendered); + case URI -> requireAllowedScheme(rendered); + }; + } + + /** + * Refuses a value that would split a header. + * + *

A carriage return or newline in a subject is header injection: everything after it is read + * as a new header by the receiving agent. + */ + private static String requireSingleLine(String value) { + for (int index = 0; index < value.length(); index++) { + if (value.charAt(index) < 0x20) { + throw refuse(); + } + } + return value; + } + + /** + * Escapes for HTML text and attribute content. + * + *

Quotes included, because a value substituted inside an attribute can otherwise close it and + * start an event handler — {@code " onerror="} needs no angle bracket at all. + */ + private static String escapeHtml(String value) { + StringBuilder escaped = new StringBuilder(value.length() + 16); + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + switch (character) { + case '&' -> escaped.append("&"); + case '<' -> escaped.append("<"); + case '>' -> escaped.append(">"); + case '"' -> escaped.append("""); + case '\'' -> escaped.append("'"); + default -> escaped.append(character); + } + } + return escaped.toString(); + } + + /** + * Allows only schemes a notification may legitimately link to. + * + *

{@code javascript:} in a link is script execution; {@code data:} is an arbitrary document + * the platform vouches for; {@code file:} points at the reader's own machine. The slot used to be + * parsed as a URI and otherwise accepted, and parsing succeeds for all three. + */ + private static String requireAllowedScheme(String value) { + String normalized = value.trim().toLowerCase(Locale.ROOT); + boolean allowed = + ALLOWED_URI_SCHEMES.stream().anyMatch(scheme -> normalized.startsWith(scheme + ":")); + if (!allowed) { + throw refuse(); + } + return value; + } + + private static TemplateRenderingException refuse() { + return new TemplateRenderingException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.TEMPLATE_RENDERING_FAILED, FailureCategory.TEMPLATE_FAILURE)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafStringTemplateEngine.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafStringTemplateEngine.java index e119b706..ef52533c 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafStringTemplateEngine.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafStringTemplateEngine.java @@ -44,6 +44,12 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate private final TemplateEngine engine; + /** Whether the constructor-supplied engine is the HTML one. */ + private final boolean htmlMode; + + /** The text-mode engine, for every slot that is not HTML. */ + private final TemplateEngine textEngine = engineFor(TemplateMode.TEXT); + /** HTML-escaping engine, which is the safe default for email bodies. */ public ThymeleafStringTemplateEngine() { this(TemplateMode.HTML); @@ -54,12 +60,25 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate */ public ThymeleafStringTemplateEngine(TemplateMode mode) { Objects.requireNonNull(mode, "mode"); - StringTemplateResolver resolver = new StringTemplateResolver(); - resolver.setTemplateMode(mode); - resolver.setCacheable(false); - TemplateEngine created = new TemplateEngine(); - created.setTemplateResolver(resolver); - this.engine = created; + this.htmlMode = mode == TemplateMode.HTML; + this.engine = engineFor(mode); + } + + @Override + public String render(TemplateSlotMode mode, String source, Map variables) { + Objects.requireNonNull(mode, "mode"); + // One engine per Thymeleaf template mode, chosen by what the slot is. + // + // This class used to implement only the mode-less overload and inherit a `default` that threw + // the mode away, so every slot rendered under TemplateMode.HTML: a subject could carry CR/LF, a + // deep link could carry `javascript:`, and plain text — an SMS body — had its `&` turned into + // `&` on the wire. The interface compiled, so nothing said the policy was not applying. + // + // HTML_TEXT keeps the HTML engine, which is what escapes substituted values. Everything else + // renders as text and is then checked: Thymeleaf substitutes internally, so a per-value escape + // is not available, and verifying the finished slot is the stronger statement anyway. + String rendered = engineFor(mode).process(source, contextFor(source, variables)); + return TemplateSlotPolicy.verifyRendered(mode, rendered); } @Override @@ -68,10 +87,8 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate Objects.requireNonNull(variables, "variables"); requireEveryReferencedVariable(source, variables); - Context context = new Context(); - variables.forEach(context::setVariable); try { - return engine.process(source, context); + return engine.process(source, contextFor(source, variables)); } catch (RuntimeException failure) { // The message is dropped on purpose. Thymeleaf reports the offending expression, and a // template expression contains the variable it failed on — which for this platform is a @@ -105,4 +122,41 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate } } } + + /** The engine whose template mode matches the slot. */ + private TemplateEngine engineFor(TemplateSlotMode mode) { + return mode == TemplateSlotMode.HTML_TEXT ? htmlEngine() : textEngine; + } + + /** + * The HTML engine. + * + *

The constructor-supplied engine when this instance was built for HTML, and a dedicated one + * otherwise — a deployment that constructed the text engine still has HTML slots to render, and + * rendering them as text would emit an operator's markup unescaped. + */ + private TemplateEngine htmlEngine() { + return htmlMode ? engine : HTML_ENGINE; + } + + private static final TemplateEngine HTML_ENGINE = engineFor(TemplateMode.HTML); + + private static TemplateEngine engineFor(TemplateMode mode) { + StringTemplateResolver resolver = new StringTemplateResolver(); + resolver.setTemplateMode(mode); + resolver.setCacheable(false); + TemplateEngine created = new TemplateEngine(); + created.setTemplateResolver(resolver); + return created; + } + + /** Builds the variable context, refusing an absent variable rather than rendering it away. */ + private Context contextFor(String source, Map variables) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(variables, "variables"); + requireEveryReferencedVariable(source, variables); + Context context = new Context(); + variables.forEach(context::setVariable); + return context; + } } diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationSecretRequirementsTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationSecretRequirementsTest.java new file mode 100644 index 00000000..92052e37 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationSecretRequirementsTest.java @@ -0,0 +1,173 @@ +package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Which keys a deployment is actually asked for. + * + *

Startup demanded all of them, always, so an SMTP-only platform with callbacks switched off had + * to provision and rotate a Web Push signing key, a provider credential and two callback keys that + * nothing in that configuration could reach. Keys that exist and are never used are the ones nobody + * notices leaking, and requiring them made the four purposes every mode genuinely needs + * indistinguishable from the four that follow a capability. + */ +class NotificationSecretRequirementsTest { + + @Test + @DisplayName("the accept path's keys are required in every configuration") + void theAcceptPathKeysAreAlwaysRequired() { + var required = NotificationSecretRequirements.requiredBy(settings(false, Map.of())); + + assertThat(required) + .as( + "contact points are protected, variables are encrypted at rest and provider request " + + "ids are hashed whenever the platform runs, providers or no providers") + .containsExactlyInAnyOrder( + SecretPurpose.CONTACT_ENCRYPTION, + SecretPurpose.CONTACT_LOOKUP_HMAC, + SecretPurpose.PAYLOAD_ENCRYPTION, + SecretPurpose.PROVIDER_REQUEST_LOOKUP_HMAC); + } + + @Test + @DisplayName("an SMTP-only platform is not asked for a provider credential") + void anSmtpOnlyPlatformIsNotAskedForAProviderCredential() { + var required = + NotificationSecretRequirements.requiredBy(settings(false, Map.of("mail", smtp()))); + + assertThat(required) + .as("SMTP authenticates through spring.mail.*, so this platform holds no SMTP credential") + .doesNotContain(SecretPurpose.PROVIDER_CREDENTIAL); + } + + @Test + @DisplayName("a disabled profile does not demand its family's keys") + void aDisabledProfileDoesNotDemandItsKeys() { + var required = + NotificationSecretRequirements.requiredBy( + settings(false, Map.of("push", disabled(webPush())))); + + assertThat(required) + .doesNotContain(SecretPurpose.VAPID_SIGNING) + .doesNotContain(SecretPurpose.PROVIDER_CREDENTIAL); + } + + @Test + @DisplayName("an enabled Web Push profile demands a VAPID key and a provider credential") + void anEnabledWebPushProfileDemandsItsKeys() { + var required = + NotificationSecretRequirements.requiredBy(settings(false, Map.of("push", webPush()))); + + assertThat(required).contains(SecretPurpose.VAPID_SIGNING, SecretPurpose.PROVIDER_CREDENTIAL); + } + + @Test + @DisplayName("callbacks switched off do not demand the callback keys") + void callbacksOffDoNotDemandTheCallbackKeys() { + var required = + NotificationSecretRequirements.requiredBy(settings(false, Map.of("mail", smtp()))); + + assertThat(required) + .doesNotContain(SecretPurpose.CALLBACK_SIGNING) + .doesNotContain(SecretPurpose.CALLBACK_FINGERPRINT_HMAC); + } + + @Test + @DisplayName("callbacks switched on demand both callback keys") + void callbacksOnDemandBothCallbackKeys() { + var required = + NotificationSecretRequirements.requiredBy(settings(true, Map.of("mail", smtp()))); + + assertThat(required) + .as("verification and dedupe both run on the first callback that arrives") + .contains(SecretPurpose.CALLBACK_SIGNING, SecretPurpose.CALLBACK_FINGERPRINT_HMAC); + } + + @Test + @DisplayName("a profile naming its own signing key ref demands the signing purpose") + void aProfileNamingASigningRefDemandsTheSigningPurpose() { + var required = + NotificationSecretRequirements.requiredBy(settings(false, Map.of("sms", twilio()))); + + assertThat(required) + .as("a profile that names a signing key intends to verify signatures with it") + .contains(SecretPurpose.CALLBACK_SIGNING); + } + + private static NotificationPlatformSettings settings( + boolean callbacksEnabled, Map providers) { + return new NotificationPlatformSettings( + true, + NotificationPlatformMode.SERVING, + null, + new NotificationPlatformSettings.Callbacks(callbacksEnabled, 1024, Duration.ofMinutes(5)), + providers); + } + + private static NotificationPlatformSettings.Provider smtp() { + return new NotificationPlatformSettings.Provider( + "SMTP", + true, + true, + "PRODUCTION", + "smtp-main", + null, + null, + null, + Duration.ofSeconds(3), + 8, + 20); + } + + private static NotificationPlatformSettings.Provider webPush() { + return new NotificationPlatformSettings.Provider( + "WEB_PUSH", + true, + true, + "PRODUCTION", + "webpush-main", + null, + "BPublicKey", + null, + Duration.ofSeconds(3), + 8, + 20); + } + + private static NotificationPlatformSettings.Provider twilio() { + return new NotificationPlatformSettings.Provider( + "TWILIO", + true, + true, + "PRODUCTION", + "twilio-main", + null, + null, + "twilio-callback-2026-08", + Duration.ofSeconds(3), + 8, + 20); + } + + private static NotificationPlatformSettings.Provider disabled( + NotificationPlatformSettings.Provider provider) { + return new NotificationPlatformSettings.Provider( + provider.type(), + false, + provider.primaryForChannel(), + provider.environment(), + provider.credentialProfile(), + provider.topic(), + provider.vapidPublicKey(), + provider.callbackSigningSecretRef(), + provider.timeout(), + provider.maxConcurrency(), + provider.ratePerSecond()); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LeaseRecoveryServiceTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LeaseRecoveryServiceTest.java index 9f0b0dd9..0fd3c926 100644 --- a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LeaseRecoveryServiceTest.java +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LeaseRecoveryServiceTest.java @@ -301,5 +301,24 @@ class LeaseRecoveryServiceTest { transitions.add(Map.entry(id, state)); return null; } + + @Override + public Optional saveHeldBy( + RecipientDeliveryRecord record, + dev.caskeleton.application.notification.platform.dispatch.RecipientLease lease) { + return Optional.of(save(record)); + } + + @Override + public Optional transitionHeldBy( + RecipientDeliveryId id, + RecipientDeliveryState state, + Optional nextDispatchAt, + dev.caskeleton.application.notification.platform.dispatch.RecipientLease lease) { + // This fake belongs to lease *recovery*, which runs for jobs whose holder is gone; the fenced + // variants are the dispatch path's and are not exercised here. + transition(id, state, nextDispatchAt); + return Optional.empty(); + } } } diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/EndpointGuardCallSiteTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/EndpointGuardCallSiteTest.java new file mode 100644 index 00000000..d7067421 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/EndpointGuardCallSiteTest.java @@ -0,0 +1,126 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.http; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesProviderProperties; +import dev.caskeleton.adapter.outbound.notification.platform.provider.webhook.WebhookSubscription; +import java.net.URI; +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The endpoint guard is reached from the places that need it. + * + *

{@code EndpointRoutabilityTest} already proves {@code requireExternallyRoutable} rejects the + * metadata service, RFC 1918, link-local and the rest. It proved that for months while the function + * had no caller: both sites it was written for — a webhook target and an SES endpoint — kept + * calling {@code requireSecureOrLoopback}, which reads the scheme and nothing else. A green test on + * a control nothing invokes is the shape this repository keeps finding, and testing the helper + * again would not have caught it. + * + *

So these assertions go through the constructors an operator and a caller actually reach. + * + *

The loopback allowance is part of that. It was a constant {@code true} at both call sites, + * which left a client-supplied target naming {@code localhost} accepted — the residue this finding + * carried until the allowance became {@code trusted}, the flag the record already used to decide + * whether the same target may inherit a platform signing key. + */ +class EndpointGuardCallSiteTest { + + private static final URI METADATA = URI.create("https://169.254.169.254/latest/meta-data/"); + private static final URI PRIVATE_NETWORK = URI.create("https://10.0.0.5/hook"); + + @Test + @DisplayName("a webhook target on the cloud metadata service is refused") + void aWebhookTargetOnTheMetadataServiceIsRefused() { + assertThatThrownBy(() -> new WebhookSubscription("sub-1", METADATA, false, Optional.empty())) + .as("a client-supplied target that fetches instance credentials is the SSRF this guards") + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a webhook target inside the deployment's own network is refused") + void aWebhookTargetOnAPrivateAddressIsRefused() { + assertThatThrownBy( + () -> new WebhookSubscription("sub-1", PRIVATE_NETWORK, true, Optional.empty())) + .as("trusted decides credential inheritance, not whether an internal address is reachable") + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a webhook target carrying userinfo is refused") + void aWebhookTargetWithUserinfoIsRefused() { + assertThatThrownBy( + () -> + new WebhookSubscription( + "sub-1", + URI.create("https://evil.example.com@127.0.0.1/hook"), + true, + Optional.empty())) + .as("the text before '@' is what a log reader takes for the host") + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("an SES endpoint inside the deployment's own network is refused") + void anSesEndpointOnAPrivateAddressIsRefused() { + assertThatThrownBy( + () -> + new SesProviderProperties( + PRIVATE_NETWORK, + "ap-northeast-2", + "transactional@example.com", + Optional.empty(), + Duration.ofSeconds(3))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a client-supplied webhook target on the loopback interface is refused") + void aDynamicWebhookTargetOnLoopbackIsRefused() { + assertThatThrownBy( + () -> + new WebhookSubscription( + "sub-1", URI.create("https://localhost/hook"), false, Optional.empty())) + .as( + "the loopback allowance was a constant `true`, so the one case the guard could not " + + "cover was a user-supplied target that simply named localhost") + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a client-supplied webhook target on 127.0.0.1 is refused") + void aDynamicWebhookTargetOnTheLoopbackAddressIsRefused() { + assertThatThrownBy( + () -> + new WebhookSubscription( + "sub-1", URI.create("http://127.0.0.1:8080/hook"), false, Optional.empty())) + .as("naming the address rather than the host must not be the way around the refusal") + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("loopback stays available, because local and contract profiles address it") + void loopbackIsStillAccepted() { + assertThatCode( + () -> + new WebhookSubscription( + "sub-1", URI.create("http://127.0.0.1:8080/hook"), true, Optional.empty())) + .as( + "this is the case the allowance exists for, and it is the reason the refusal above " + + "has to be conditional rather than absolute") + .doesNotThrowAnyException(); + assertThatCode( + () -> + new SesProviderProperties( + URI.create("http://localhost:4566"), + "ap-northeast-2", + "transactional@example.com", + Optional.empty(), + Duration.ofSeconds(3))) + .doesNotThrowAnyException(); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapterTest.java index 2abb0e53..6b026127 100644 --- a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapterTest.java +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapterTest.java @@ -1,25 +1,49 @@ package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import dev.caskeleton.adapter.outbound.notification.platform.provider.UnconfiguredAttachmentResolver; import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory; import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager; import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderAdapterContract; import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness; import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.content.AttachmentDisposition; +import dev.caskeleton.application.notification.platform.api.content.AttachmentRef; +import dev.caskeleton.application.notification.platform.api.content.EmailContent; +import dev.caskeleton.application.notification.platform.api.content.EmailOptions; import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel; +import dev.caskeleton.application.notification.platform.api.error.AttachmentIntegrityException; +import dev.caskeleton.application.notification.platform.api.error.AttachmentUnavailableException; +import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException; import dev.caskeleton.application.notification.platform.api.routing.Channel; import dev.caskeleton.application.notification.platform.contact.EmailAddress; +import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard; +import dev.caskeleton.application.notification.platform.provider.AttachmentResolver; import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment; import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import jakarta.mail.Session; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Properties; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -39,21 +63,8 @@ class SesNotificationProviderAdapterTest extends ProviderAdapterContract { @Override protected NotificationProviderAdapter adapter() { - var properties = - new SesProviderProperties( - harness.baseUri(), - "ap-northeast-2", - "transactional@example.com", - Optional.empty(), - Duration.ofSeconds(3)); - return new SesNotificationProviderAdapter( - new JdkNotificationHttpGateway(Duration.ofSeconds(2)), - new SesRequestMapper(properties, new AwsSignatureV4Signer()), - new SesFailureClassifier(), - protector, - SecurityFixtures.keys(), - "AKIAEXAMPLE", - CLOCK); + return adapter( + SecurityFixtures.credentials("ses-primary"), new UnconfiguredAttachmentResolver()); } @Override @@ -101,4 +112,241 @@ class SesNotificationProviderAdapterTest extends ProviderAdapterContract { assertThat(recorded.header("X-Amz-Content-Sha256")).isPresent(); assertThat(recorded.uri().toString()).doesNotContain(ProviderFixtures.SECRET_EMAIL); } + + @Test + void contentWithNoAttachmentStaysOnTheSimpleShape() { + harness.respondWith(200, successBody(), Map.of()); + + adapter().submit(submission()).toCompletableFuture().join(); + + var content = requestContent(0); + assertThat(content.get("Simple")).isNotNull(); + assertThat(content.get("Raw")).isNull(); + } + + @Test + void aDeclaredAttachmentIsCarriedAsRawMimeContent() { + byte[] bytes = documentBytes(); + harness.respondWith(200, successBody(), Map.of()); + + var result = + adapter(SecurityFixtures.credentials("ses-primary"), resolverReturning(bytes)) + .submit(submissionWithAttachment(bytes)) + .toCompletableFuture() + .join(); + + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + var content = requestContent(0); + assertThat(content.get("Simple")) + .as("Simple content has no MIME part, so the declared attachment was simply not sent") + .isNull(); + String mime = + new String( + Base64.getDecoder().decode(content.get("Raw").get("Data").asString()), + StandardCharsets.UTF_8); + assertThat(mime).contains("Contract subject"); + assertThat(mime).contains("invoice.pdf"); + assertThat(mime) + .as("the document itself, base64 encoded as a binary part rather than merely named") + .contains(Base64.getEncoder().encodeToString(bytes)); + assertThat(harness.received().get(0).header("Authorization").orElseThrow()) + .startsWith("AWS4-HMAC-SHA256"); + } + + @Test + void anAttachmentThatCannotBeResolvedIsRefusedBeforeAnythingIsSent() { + byte[] bytes = documentBytes(); + harness.respondWith(200, successBody(), Map.of()); + + assertThatThrownBy( + () -> adapter().submit(submissionWithAttachment(bytes)).toCompletableFuture().join()) + .as("a mail that silently loses its attachment is worse than one that is not sent") + .isInstanceOf(AttachmentUnavailableException.class); + + assertThat(harness.received()).isEmpty(); + } + + @Test + void attachmentBytesThatAreNotTheApprovedBytesAreRefusedBeforeAnythingIsSent() { + byte[] approved = "invoice-bytes".getBytes(StandardCharsets.UTF_8); + byte[] substituted = "1nvo1ce-bytes".getBytes(StandardCharsets.UTF_8); + harness.respondWith(200, successBody(), Map.of()); + + assertThatThrownBy( + () -> + adapter(SecurityFixtures.credentials("ses-primary"), resolverReturning(substituted)) + .submit(submissionWithAttachment(approved)) + .toCompletableFuture() + .join()) + .as("same size, different bytes: only the digest separates them") + .isInstanceOf(AttachmentIntegrityException.class); + + assertThat(harness.received()).isEmpty(); + } + + @Test + void aMessageLargerThanSesAcceptsIsRefusedBeforeItIsSigned() { + // Base64 transfer encoding adds a third, so this clears the limit as bytes and exceeds it as a + // message — which is exactly the case a check against the declared attachment size misses. + byte[] oversized = new byte[8 * 1_000_000]; + assertThat(oversized.length).isLessThan((int) SesRequestMapper.MAX_MESSAGE_BYTES); + harness.respondWith(200, successBody(), Map.of()); + + assertThatThrownBy( + () -> + adapter(SecurityFixtures.credentials("ses-primary"), resolverReturning(oversized)) + .submit(submissionWithAttachment(oversized)) + .toCompletableFuture() + .join()) + .isInstanceOf(ProviderPayloadLimitException.class); + + assertThat(harness.received()).isEmpty(); + } + + @Test + void twoProfilesAreSignedWithTheirOwnCredential() { + // One submission, sent twice: the SES request body is derived from content and recipient alone, + // so anything that differs between the two signatures is the credential. + var submission = submission(); + harness.respondWith(200, successBody(), Map.of()); + adapter( + SecurityFixtures.credentials("ses-primary", "cred-1"), + new UnconfiguredAttachmentResolver()) + .submit(submission) + .toCompletableFuture() + .join(); + harness.respondWith(200, successBody(), Map.of()); + adapter( + SecurityFixtures.credentials("ses-primary", "cred-2"), + new UnconfiguredAttachmentResolver()) + .submit(submission) + .toCompletableFuture() + .join(); + + assertThat(harness.received().get(1).header("Authorization")) + .as( + "every profile signed with the platform's one current provider credential, so a leak " + + "of one SES account's key was a leak of every provider account") + .isNotEqualTo(harness.received().get(0).header("Authorization")); + } + + @Test + void aProfileWithNoActivatedCredentialIsRefusedBeforeTheRequest() { + harness.respondWith(200, successBody(), Map.of()); + + assertThatThrownBy( + () -> + adapter( + SecurityFixtures.credentials("some-other-profile"), + new UnconfiguredAttachmentResolver()) + .submit(submission()) + .toCompletableFuture() + .join()) + .isInstanceOf(IllegalStateException.class); + + assertThat(harness.received()).isEmpty(); + } + + private SesNotificationProviderAdapter adapter( + ProviderCredentialManager credentials, AttachmentResolver attachments) { + var properties = + new SesProviderProperties( + harness.baseUri(), + "ap-northeast-2", + "transactional@example.com", + Optional.empty(), + Duration.ofSeconds(3)); + return new SesNotificationProviderAdapter( + new JdkNotificationHttpGateway(Duration.ofSeconds(2)), + new SesRequestMapper( + properties, + new AwsSignatureV4Signer(), + new SmtpMimeMessageFactory(Session.getInstance(new Properties()))), + new AttachmentIntegrityGuard(attachments), + new SesFailureClassifier(), + protector, + credentials, + "AKIAEXAMPLE", + CLOCK); + } + + /** The {@code Content} object of a recorded request. */ + private tools.jackson.databind.JsonNode requestContent(int index) { + return NotificationJsonMapper.mapper() + .readTree(harness.received().get(index).bodyAsString()) + .get("Content"); + } + + /** + * A resolver that hands back exactly these bytes and describes them honestly. + * + *

The digest is computed from what is returned rather than copied from the reference, so a + * resolver that returns something other than the approved bytes is caught by the integrity guard + * instead of being waved through by a fixture that agrees with itself. + */ + private static AttachmentResolver resolverReturning(byte[] bytes) { + return (reference, context) -> + new ResolvedAttachment( + new ByteArrayInputStream(bytes), + bytes.length, + digestOf(bytes), + reference.contentType(), + reference.displayName()); + } + + private ProviderSubmission submissionWithAttachment(byte[] approvedBytes) { + return ProviderFixtures.submission( + ProviderFixtures.profile("ses-primary", "ses", Channel.EMAIL), + Channel.EMAIL, + new EmailContent( + "Contract subject", + "Contract body", + Optional.empty(), + List.of( + new AttachmentRef( + "storage://bucket/invoice.pdf", + "invoice.pdf", + "application/pdf", + approvedBytes.length, + digestOf(approvedBytes), + AttachmentDisposition.ATTACHMENT)), + EmailOptions.DEFAULT), + protector, + EmailAddress.parse(ProviderFixtures.SECRET_EMAIL), + Optional.of(CLOCK.instant().plus(Duration.ofHours(1)))); + } + + /** + * A short binary document. + * + *

Binary rather than text on purpose: MIME encodes an ASCII part as {@code 7bit} and leaves it + * legible, which would let the assertion pass on a part that was never really encoded at all. + */ + private static byte[] documentBytes() { + return new byte[] { + '%', + 'P', + 'D', + 'F', + '-', + '1', + '.', + '7', + '\n', + (byte) 0x80, + (byte) 0xC3, + 0x00, + 0x01, + 0x02, + (byte) 0xFF + }; + } + + private static String digestOf(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException unavailable) { + throw new IllegalStateException("SHA-256 is required by every supported JRE", unavailable); + } + } } diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpAttachmentBodyTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpAttachmentBodyTest.java new file mode 100644 index 00000000..4d8c7bce --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpAttachmentBodyTest.java @@ -0,0 +1,121 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.EmailAddress; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import java.util.Properties; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * That an attached document arrives with its bytes in it. + * + *

The factory handed JavaMail the resolver's stream directly. JavaMail reads an attachment twice + * — once to choose the part's transfer encoding, once to write the part — and the second read of an + * already drained stream returns nothing, so the message went out announcing a filename and + * carrying no content, and the attempt was recorded as accepted. Nothing noticed because every test + * asserted on the outcome of the send rather than on what was sent. + * + *

Asserted on the serialised message, because that is the only place the defect was visible: the + * part existed, its headers were right, and its body was empty. + */ +class SmtpAttachmentBodyTest { + + private static final byte[] DOCUMENT = "invoice-body-bytes".getBytes(StandardCharsets.UTF_8); + + private final ContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + + private final SmtpMimeMessageFactory factory = + new SmtpMimeMessageFactory(Session.getInstance(new Properties())); + + @Test + @DisplayName("an attached document is written into the message, not just named by it") + void anAttachedDocumentCarriesItsBytes() throws Exception { + MimeMessage message = + factory.create( + submission(), + "recipient@example.test", + "sender@example.test", + List.of(attachment(DOCUMENT, DOCUMENT.length))); + + assertThat(attachmentBytesOf(message)) + .as("the part announced a filename and carried nothing") + .isEqualTo(DOCUMENT); + } + + @Test + @DisplayName("content that is not the size the guard approved is refused") + void contentThatIsNotTheApprovedSizeIsRefused() { + // The integrity guard pins a size against the reference before the stream is handed over, so a + // stream that turns out to be a different length is not the document that was approved — + // whatever digest travelled with it. + assertThatThrownBy( + () -> + factory.create( + submission(), + "recipient@example.test", + "sender@example.test", + List.of(attachment(DOCUMENT, DOCUMENT.length + 1)))) + .isInstanceOf(RuntimeException.class); + } + + private static ResolvedAttachment attachment(byte[] content, long declaredSize) { + return new ResolvedAttachment( + new ByteArrayInputStream(content), + declaredSize, + "sha-256:not-checked-here", + "application/pdf", + "invoice.pdf"); + } + + /** + * The bytes of the attachment part, read back the way a receiving client reads them. + * + *

Read from the serialised message rather than from the part object, because the defect was + * exactly that the object described a part the serialisation could not fill: assertions taken + * before {@code writeTo} saw an attachment that was about to be written empty. + */ + private static byte[] attachmentBytesOf(MimeMessage message) throws Exception { + ByteArrayOutputStream wire = new ByteArrayOutputStream(); + message.writeTo(wire); + MimeMessage received = + new MimeMessage( + Session.getInstance(new Properties()), + new java.io.ByteArrayInputStream(wire.toByteArray())); + jakarta.mail.internet.MimeMultipart parts = + (jakarta.mail.internet.MimeMultipart) received.getContent(); + for (int index = 0; index < parts.getCount(); index++) { + jakarta.mail.BodyPart part = parts.getBodyPart(index); + if ("invoice.pdf".equals(part.getFileName())) { + return part.getInputStream().readAllBytes(); + } + } + throw new AssertionError("the message carries no attachment part at all"); + } + + private ProviderSubmission submission() { + return ProviderFixtures.submission( + ProviderFixtures.profile("smtp-primary", "smtp", Channel.EMAIL), + Channel.EMAIL, + ProviderFixtures.email(), + protector, + EmailAddress.parse(ProviderFixtures.SECRET_EMAIL), + Optional.empty()); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAndProjectionTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAndProjectionTest.java index faa5e274..7bdf8def 100644 --- a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAndProjectionTest.java +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAndProjectionTest.java @@ -29,6 +29,7 @@ class TwilioCallbackAndProjectionTest { Optional.of("MG123"), Optional.empty(), CALLBACK_URL, + "cb-1", java.time.Duration.ofSeconds(3), java.time.Duration.ofHours(12)); @@ -54,6 +55,47 @@ class TwilioCallbackAndProjectionTest { assertThat(events.get(0).providerRequestId()).contains("SM1"); } + @Test + void twoProfilesVerifyWithTheirOwnSigningKey() { + Map parameters = + new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered")); + var request = callback(parameters, signature(parameters)); + + // Signed with cb-1, presented to a profile whose reference names cb-2. Verification used the + // platform's one current callback signing key, so every Twilio profile shared one secret and a + // subaccount whose token leaked could forge status callbacks for any other. + assertThat(adapterWithSigningRef("cb-2").verify(request).valid()).isFalse(); + assertThat(adapterWithSigningRef("cb-1").verify(request).valid()).isTrue(); + } + + @Test + void aSigningRefNamingAKeyOfAnotherPurposeIsRefusedBeforeAVerdict() { + Map parameters = + new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered")); + var request = callback(parameters, signature(parameters)); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> adapterWithSigningRef("enc-1").verify(request)) + .as("a misfiled reference is a configuration fault, not a signature that never matches") + .isInstanceOf(IllegalStateException.class); + } + + private TwilioCallbackAdapter adapterWithSigningRef(String signingKeyRef) { + return new TwilioCallbackAdapter( + new TwilioSignatureValidator(), + new TwilioStatusNormalizer(), + new TwilioProviderProperties( + java.net.URI.create("https://api.twilio.example"), + "AC123", + Optional.of("MG123"), + Optional.empty(), + CALLBACK_URL, + signingKeyRef, + java.time.Duration.ofSeconds(3), + java.time.Duration.ofHours(12)), + SecurityFixtures.keys()); + } + @Test void invalidSignatureIsRejected() { Map parameters = diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackContractTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackContractTest.java index cf98a6af..93e57412 100644 --- a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackContractTest.java +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackContractTest.java @@ -29,6 +29,7 @@ class TwilioCallbackContractTest extends CallbackContract { Optional.of("MG123"), Optional.empty(), CALLBACK_URL, + "cb-1", Duration.ofSeconds(3), Duration.ofHours(12)); diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapterTest.java index 6cda768d..b1aaad2a 100644 --- a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapterTest.java +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapterTest.java @@ -39,6 +39,7 @@ class TwilioSmsProviderAdapterTest extends ProviderAdapterContract { Optional.of("MG123"), Optional.empty(), "https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary", + "cb-1", Duration.ofSeconds(3), Duration.ofHours(12)); } @@ -50,7 +51,7 @@ class TwilioSmsProviderAdapterTest extends ProviderAdapterContract { new TwilioRequestMapper(properties()), new TwilioFailureClassifier(), protector, - SecurityFixtures.keys()); + SecurityFixtures.credentials("twilio-primary")); } @Override diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapterTest.java index d232c06e..58f5fbea 100644 --- a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapterTest.java +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapterTest.java @@ -1,23 +1,37 @@ package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.security.SettingsSecretMaterialProvider; import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness; import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.content.InAppContent; import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation; +import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException; import dev.caskeleton.application.notification.platform.api.routing.Channel; import dev.caskeleton.application.notification.platform.contact.InAppRecipientRef; import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.net.URI; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Function; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -37,14 +51,52 @@ class WebhookNotificationProviderAdapterTest { @Test void dynamicTargetNeverInheritsTrustedCredentials() { - harness.respondWith(200, "{}", Map.of()); + // Both gateway arguments used to be the same instance, so "the request carried no + // Authorization header" was a property of the test's own wiring rather than of the adapter: + // there was nothing in the graph that could have added one, and the assertion would have held + // just as well for a trusted subscription. What actually decides credential inheritance is + // which of the two gateways the adapter hands the request to, and that is only observable when + // they are distinguishable. + // + // The target is a routable literal rather than the local harness because a client-supplied + // target may no longer name the loopback interface (NTF-012). A literal address also keeps the + // constructor's resolution check off DNS. + RecordingGateway trusted = new RecordingGateway(); + RecordingGateway dynamic = new RecordingGateway(); + WebhookSubscription subscription = + new WebhookSubscription("sub-2", ROUTABLE_TARGET, false, Optional.empty()); - adapter(dynamicSubscription()).submit(submission()).toCompletableFuture().join(); + adapter(trusted, dynamic, submission -> subscription) + .submit(submission()) + .toCompletableFuture() + .join(); - var recorded = harness.received().get(0); - assertThat(recorded.header("Authorization")).isEmpty(); - assertThat(recorded.header("Cookie")).isEmpty(); - assertThat(recorded.header(WebhookSignatureStrategy.SIGNATURE_HEADER)).isEmpty(); + assertThat(trusted.exchanged) + .as("a client-supplied target must not reach the gateway that carries platform credentials") + .isEmpty(); + assertThat(dynamic.exchanged).hasSize(1); + assertThat(dynamic.exchanged.get(0).headers()) + .doesNotContainKeys("authorization", "cookie", WebhookSignatureStrategy.SIGNATURE_HEADER); + } + + @Test + void trustedTargetReachesTheCredentialedGateway() { + // The counterpart, so the assertion above cannot pass by the adapter never reaching either + // gateway, and so the routing rule is watched working in both directions. + RecordingGateway trusted = new RecordingGateway(); + RecordingGateway dynamic = new RecordingGateway(); + WebhookSubscription subscription = + new WebhookSubscription("sub-1", ROUTABLE_TARGET, true, Optional.of("cb-1")); + + adapter(trusted, dynamic, submission -> subscription) + .submit(submission()) + .toCompletableFuture() + .join(); + + assertThat(dynamic.exchanged).isEmpty(); + assertThat(trusted.exchanged).hasSize(1); + assertThat(trusted.exchanged.get(0).headers()) + .containsKey(WebhookSignatureStrategy.SIGNATURE_HEADER); } @Test @@ -78,33 +130,160 @@ class WebhookNotificationProviderAdapterTest { assertThat(result.failure().orElseThrow().nativeCode().orElseThrow().length()).isLessThan(1000); } + @Test + void twoSubscriptionsWithDifferentKeyRefsAreSignedDifferently() { + // One submission for both deliveries: the attempt id is part of the body, so two submissions + // would differ in what was signed and the signatures would differ whatever key was used. + var submission = submission(); + harness.respondWith(200, "{}", Map.of()); + adapter(trustedSubscription("cb-1")).submit(submission).toCompletableFuture().join(); + harness.respondWith(200, "{}", Map.of()); + adapter(trustedSubscription("cb-2")).submit(submission).toCompletableFuture().join(); + + var first = harness.received().get(0).header(WebhookSignatureStrategy.SIGNATURE_HEADER); + var second = harness.received().get(1).header(WebhookSignatureStrategy.SIGNATURE_HEADER); + + assertThat(first).isPresent(); + assertThat(second) + .as( + "signingKeyRef only decided whether to sign; the signature came from one platform key, " + + "so every trusted receiver could verify and forge every other receiver's webhook") + .isPresent() + .isNotEqualTo(first); + } + + @Test + void aKeyRefThatIsNotACallbackSigningKeyIsRefusedBeforeTheRequest() { + assertThatThrownBy( + () -> + adapter(trustedSubscription("cred-1")) + .submit(submission()) + .toCompletableFuture() + .join()) + .isInstanceOf(IllegalStateException.class); + + assertThat(harness.received()).isEmpty(); + } + + @Test + void aBodyOverTheDeclaredCeilingIsRefusedBeforeTheRequest() { + assertThatThrownBy( + () -> + adapter(trustedSubscription("cb-1")) + .submit(oversizedSubmission()) + .toCompletableFuture() + .join()) + .isInstanceOf(ProviderPayloadLimitException.class); + + assertThat(harness.received()) + .as("the capability declared a ceiling and nothing measured the bytes against it") + .isEmpty(); + } + + /** + * A target outside every range the endpoint guard refuses, written as a literal. + * + *

TEST-NET-3, which is reserved for documentation and routes nowhere — and being a literal, it + * is never looked up, so the guard's resolution step does not make these tests depend on DNS. + */ + private static final URI ROUTABLE_TARGET = URI.create("https://203.0.113.10/hook"); + + /** Records what it was asked to send and answers 200, so nothing is dialled. */ + private static final class RecordingGateway implements NotificationHttpGateway { + + private final List exchanged = new ArrayList<>(); + + @Override + public NotificationHttpResponse exchange(NotificationHttpRequest request) { + exchanged.add(request); + return new NotificationHttpResponse( + 200, Map.of(), "{}".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + } + + private WebhookNotificationProviderAdapter adapter( + NotificationHttpGateway trustedGateway, + NotificationHttpGateway dynamicGateway, + Function subscriptions) { + return new WebhookNotificationProviderAdapter( + trustedGateway, + dynamicGateway, + new WebhookSignatureStrategy(), + keys(), + subscriptions, + Duration.ofSeconds(3), + CLOCK); + } + private WebhookNotificationProviderAdapter adapter(WebhookSubscription subscription) { var gateway = new JdkNotificationHttpGateway(Duration.ofSeconds(2)); return new WebhookNotificationProviderAdapter( gateway, gateway, new WebhookSignatureStrategy(), - SecurityFixtures.keys(), + keys(), submission -> subscription, Duration.ofSeconds(3), CLOCK); } - private WebhookSubscription trustedSubscription() { - return new WebhookSubscription( - "sub-1", harness.baseUri().resolve("/hook"), true, Optional.of("callback-sign")); + /** + * Two callback signing keys, so a per-subscription reference has something to distinguish. + * + *

{@code cred-1} is present as well: a reference naming a key issued for another purpose is a + * configuration fault this adapter has to catch rather than sign with. + */ + private static SecretMaterialProvider keys() { + return new SettingsSecretMaterialProvider( + Map.of( + SecretPurpose.CALLBACK_SIGNING, + new SecretKeyMaterial("cb-1", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x33)), + SecretPurpose.CONTACT_ENCRYPTION, + new SecretKeyMaterial("enc-1", SecretPurpose.CONTACT_ENCRYPTION, filled((byte) 0x11)), + SecretPurpose.CONTACT_LOOKUP_HMAC, + new SecretKeyMaterial("mac-1", SecretPurpose.CONTACT_LOOKUP_HMAC, filled((byte) 0x22))), + Map.of( + "cb-2", + new SecretKeyMaterial("cb-2", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x34)), + "cred-1", + new SecretKeyMaterial( + "cred-1", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x44)))); } - private WebhookSubscription dynamicSubscription() { + private static byte[] filled(byte value) { + byte[] material = new byte[32]; + java.util.Arrays.fill(material, value); + return material; + } + + private WebhookSubscription trustedSubscription() { + return trustedSubscription("cb-1"); + } + + private WebhookSubscription trustedSubscription(String signingKeyRef) { return new WebhookSubscription( - "sub-2", harness.baseUri().resolve("/hook"), false, Optional.empty()); + "sub-1", harness.baseUri().resolve("/hook"), true, Optional.of(signingKeyRef)); } private ProviderSubmission submission() { + return submission(ProviderFixtures.webhook()); + } + + private ProviderSubmission oversizedSubmission() { + return submission( + new InAppContent( + "Order shipped", + "x".repeat((int) WebhookNotificationProviderAdapter.MAX_BODY_BYTES + 1), + Optional.empty(), + java.util.List.of(), + "order")); + } + + private ProviderSubmission submission(InAppContent content) { return ProviderFixtures.submission( ProviderFixtures.profile("webhook-main", "webhook", Channel.WEBHOOK), Channel.WEBHOOK, - ProviderFixtures.webhook(), + content, protector, new InAppRecipientRef("user-1"), Optional.empty()); diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/CallbackPayloadBoundTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/CallbackPayloadBoundTest.java index c854c107..ed5627bb 100644 --- a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/CallbackPayloadBoundTest.java +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/CallbackPayloadBoundTest.java @@ -49,7 +49,7 @@ class CallbackPayloadBoundTest { () -> new AesGcmCallbackPayloadProtection( SecurityFixtures.keys(), - new java.security.SecureRandom(), + payloads(), AesGcmCallbackPayloadProtection.MAX_CIPHERTEXT_BYTES)) .as("this is exactly the configuration that produced 65,564 bytes of ciphertext") .isInstanceOf(IllegalArgumentException.class) @@ -62,7 +62,7 @@ class CallbackPayloadBoundTest { AesGcmCallbackPayloadProtection protection = new AesGcmCallbackPayloadProtection( SecurityFixtures.keys(), - new java.security.SecureRandom(), + payloads(), AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES); byte[] stored = @@ -78,7 +78,7 @@ class CallbackPayloadBoundTest { AesGcmCallbackPayloadProtection protection = new AesGcmCallbackPayloadProtection( SecurityFixtures.keys(), - new java.security.SecureRandom(), + payloads(), AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES); byte[] stored = @@ -96,8 +96,13 @@ class CallbackPayloadBoundTest { () -> new AesGcmCallbackPayloadProtection( SecurityFixtures.keys(), - new java.security.SecureRandom(), + payloads(), AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES)) .doesNotThrowAnyException(); } + + private static AesGcmNotificationPayloadProtection payloads() { + return new AesGcmNotificationPayloadProtection( + SecurityFixtures.keys(), new java.security.SecureRandom()); + } } diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/CallbackPayloadRotationTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/CallbackPayloadRotationTest.java new file mode 100644 index 00000000..3bb01206 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/CallbackPayloadRotationTest.java @@ -0,0 +1,99 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * What a retained callback is worth after the payload key rotates. + * + *

The retained raw body exists for one reason: a normalization bug is only diagnosable against + * what the provider actually sent. The stored bytes were a nonce and a ciphertext and nothing else, + * so the first rotation of the payload encryption key turned every retained callback into bytes + * that no key could be matched to — the retention outlived the key but not the ability to name it, + * which is the same as not retaining it. + */ +class CallbackPayloadRotationTest { + + private static final byte[] RAW = + "{\"MessageId\":\"m-1\",\"eventType\":\"Delivery\"}".getBytes(StandardCharsets.UTF_8); + + @Test + @DisplayName("a callback retained before a rotation is still readable after it") + void aCallbackRetainedBeforeARotationIsStillReadableAfterIt() { + byte[] stored = protection(beforeRotation()).protectRawPayload(RAW); + + byte[] revealed = + new AesGcmNotificationPayloadProtection(afterRotation(), new SecureRandom()).reveal(stored); + + assertThat(revealed) + .as("the envelope names the key it used, so the retired key can be asked for by id") + .isEqualTo(RAW); + } + + @Test + @DisplayName("the retained bytes name the key that encrypted them") + void theRetainedBytesNameTheKeyThatEncryptedThem() { + byte[] stored = protection(beforeRotation()).protectRawPayload(RAW); + + int keyIdLength = Byte.toUnsignedInt(stored[1]); + String keyId = new String(stored, 2, keyIdLength, StandardCharsets.UTF_8); + + assertThat(stored[0]) + .as("a format that cannot say which format it is can only change by rewriting every row") + .isEqualTo(AesGcmNotificationPayloadProtection.VERSION); + assertThat(keyId).isEqualTo("payload-1"); + } + + @Test + @DisplayName("the retained bytes are not the payload") + void theRetainedBytesAreNotThePayload() { + byte[] stored = protection(beforeRotation()).protectRawPayload(RAW); + + assertThat(new String(stored, StandardCharsets.UTF_8)).doesNotContain("MessageId"); + } + + private static AesGcmCallbackPayloadProtection protection(SecretMaterialProvider keys) { + return new AesGcmCallbackPayloadProtection( + keys, + new AesGcmNotificationPayloadProtection(keys, new SecureRandom()), + AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES); + } + + /** The key store as it stood when the callback arrived. */ + private static SecretMaterialProvider beforeRotation() { + return new SettingsSecretMaterialProvider( + Map.of( + SecretPurpose.PAYLOAD_ENCRYPTION, + payloadKey("payload-1", (byte) 0x55), + SecretPurpose.CALLBACK_FINGERPRINT_HMAC, + new SecretKeyMaterial( + "fp-1", SecretPurpose.CALLBACK_FINGERPRINT_HMAC, filled((byte) 0x88))), + Map.of()); + } + + /** The key store after the payload key was replaced and the old one retired. */ + private static SecretMaterialProvider afterRotation() { + return new SettingsSecretMaterialProvider( + Map.of(SecretPurpose.PAYLOAD_ENCRYPTION, payloadKey("payload-2", (byte) 0x56)), + Map.of("payload-1", payloadKey("payload-1", (byte) 0x55))); + } + + private static SecretKeyMaterial payloadKey(String keyId, byte fill) { + return new SecretKeyMaterial(keyId, SecretPurpose.PAYLOAD_ENCRYPTION, filled(fill)); + } + + private static byte[] filled(byte value) { + byte[] material = new byte[32]; + Arrays.fill(material, value); + return material; + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/CredentialDrainWindowTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/CredentialDrainWindowTest.java new file mode 100644 index 00000000..520a7a95 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/CredentialDrainWindowTest.java @@ -0,0 +1,171 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * A rotation as a window rather than an instant. + * + *

A superseded generation used to be overwritten outright. The attempts planned against it are + * already in flight when the rotation lands, and they are the ones that cannot simply be failed and + * cannot be replayed either — the provider may already have acted on the request. Retiring the + * credential at the moment the new one arrives fails exactly that work. + * + *

The opposite mistake is keeping it forever, which is not a rotation but two live credentials. + * The window is what makes the retirement real, so it is asserted from both ends. + */ +class CredentialDrainWindowTest { + + private static final ProviderProfileId PROFILE = new ProviderProfileId("ses-primary"); + private static final ProviderProfileId OTHER = new ProviderProfileId("ses-secondary"); + private static final Instant START = Instant.parse("2026-08-19T00:00:00Z"); + private static final Duration WINDOW = Duration.ofMinutes(15); + + private final MovableClock clock = new MovableClock(START); + private final ProviderCredentialManager manager = + new ProviderCredentialManager(credentialKeys(), clock, WINDOW); + + @Test + @DisplayName("a rotation switches new work to the new generation") + void aRotationSwitchesNewWorkToTheNewGeneration() { + manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1")); + manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-2")); + + assertThat(manager.current(PROFILE).orElseThrow().generation()).isEqualTo(2); + assertThat(manager.materialFor(PROFILE, 2)).isEqualTo(filled((byte) 0x44)); + } + + @Test + @DisplayName("in-flight work keeps the generation it was planned against") + void inFlightWorkKeepsTheGenerationItWasPlannedAgainst() { + manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1")); + manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-2")); + + clock.advance(WINDOW.minusSeconds(1)); + + assertThat(manager.materialFor(PROFILE, 1)) + .as("an attempt the provider may already have acted on cannot be failed or replayed") + .isEqualTo(filled((byte) 0x33)); + } + + @Test + @DisplayName("the retired generation stops resolving once the window closes") + void theRetiredGenerationStopsResolvingOnceTheWindowCloses() { + manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1")); + manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-2")); + + clock.advance(WINDOW); + + assertThatThrownBy(() -> manager.materialFor(PROFILE, 1)) + .as("a superseded credential that never expires is not a rotation, it is two live keys") + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("finished draining"); + } + + @Test + @DisplayName("a generation that was never activated is refused, drained or not") + void aGenerationThatWasNeverActivatedIsRefused() { + manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1")); + + assertThatThrownBy(() -> manager.materialFor(PROFILE, 7)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("no credential generation 7"); + assertThatThrownBy(() -> manager.materialFor(OTHER, 1)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("no activated credential generation"); + } + + @Test + @DisplayName("two profiles resolve different material") + void twoProfilesResolveDifferentMaterial() { + manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1")); + manager.activate(CredentialGeneration.candidate(OTHER, 1, "cred-2")); + + assertThat(manager.materialFor(PROFILE, 1)) + .as("one platform-wide provider credential made a leak of one account a leak of all") + .isNotEqualTo(manager.materialFor(OTHER, 1)); + } + + @Test + @DisplayName("a handle naming a key of another purpose never becomes a credential") + void aHandleNamingAKeyOfAnotherPurposeIsRefused() { + assertThatThrownBy( + () -> manager.activate(CredentialGeneration.candidate(PROFILE, 1, "callback-1"))) + .as("refused at the rotation, so no dispatch can ever resolve it") + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> manager.materialFor(PROFILE, 1)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("a negative drain window is refused") + void aNegativeDrainWindowIsRefused() { + assertThatThrownBy( + () -> new ProviderCredentialManager(credentialKeys(), clock, Duration.ofMinutes(-1))) + .isInstanceOf(IllegalArgumentException.class); + } + + /** Two provider credentials and one key of another purpose, to prove the separation holds. */ + private static SecretMaterialProvider credentialKeys() { + return new SettingsSecretMaterialProvider( + Map.of( + SecretPurpose.PROVIDER_CREDENTIAL, + new SecretKeyMaterial("cred-1", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x33)), + SecretPurpose.CALLBACK_SIGNING, + new SecretKeyMaterial( + "callback-1", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x55))), + Map.of( + "cred-2", + new SecretKeyMaterial( + "cred-2", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x44)))); + } + + private static byte[] filled(byte value) { + byte[] material = new byte[32]; + Arrays.fill(material, value); + return material; + } + + /** A clock the test moves, so the window is asserted rather than waited out. */ + private static final class MovableClock extends Clock { + + private Instant now; + + private MovableClock(Instant now) { + this.now = now; + } + + private void advance(Duration by) { + now = now.plus(by); + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now; + } + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/SecurityFixtures.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/SecurityFixtures.java index 6d1301f8..20aadd97 100644 --- a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/SecurityFixtures.java +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/SecurityFixtures.java @@ -35,7 +35,54 @@ public final class SecurityFixtures { "fp-1", SecretPurpose.CALLBACK_FINGERPRINT_HMAC, filled((byte) 0x88, 32)), SecretPurpose.VAPID_SIGNING, new SecretKeyMaterial("vapid-1", SecretPurpose.VAPID_SIGNING, filled((byte) 0x66, 32))), - Map.of()); + // A second provider credential, so a fixture can give two profiles genuinely different + // material rather than asserting per-profile binding against one shared key. + Map.of( + "cred-2", + new SecretKeyMaterial( + "cred-2", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x45, 32)), + "cb-2", + new SecretKeyMaterial( + "cb-2", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x34, 32)))); + } + + /** + * A credential manager holding generation 1 of each named profile. + * + *

Adapters resolve a profile's credential rather than the platform's one current provider key, + * so a contract test has to say which profile it is speaking for — which is the point: a fixture + * that could not name a profile was a fixture proving a shape the platform no longer has. + * + * @param profileIds the profiles to activate + * @return the manager + */ + public static ProviderCredentialManager credentials(String... profileIds) { + var manager = new ProviderCredentialManager(keys(), java.time.Clock.systemUTC()); + for (String profileId : profileIds) { + activate(manager, profileId, "cred-1"); + } + return manager; + } + + /** + * A credential manager holding generation 1 of one profile, backed by a named key. + * + * @param profileId the profile to activate + * @param keyId which provider credential it is bound to + * @return the manager + */ + public static ProviderCredentialManager credentials(String profileId, String keyId) { + var manager = new ProviderCredentialManager(keys(), java.time.Clock.systemUTC()); + activate(manager, profileId, keyId); + return manager; + } + + private static void activate(ProviderCredentialManager manager, String profileId, String keyId) { + manager.activate( + CredentialGeneration.candidate( + new dev.caskeleton.application.notification.platform.api.ProviderProfileId(profileId), + 1, + keyId)); } public static SecretMaterialProvider keysWithSameMaterial() { diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/BothEnginesHonourSlotModeTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/BothEnginesHonourSlotModeTest.java new file mode 100644 index 00000000..3635d368 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/BothEnginesHonourSlotModeTest.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.thymeleaf.templatemode.TemplateMode; + +/** + * Every engine honours the slot mode, not just the one the tests happened to instantiate. + * + *

{@code SlotAwareRenderingTest} constructs {@code PlaceholderTemplateEngine} and only that, and + * the mode-aware method was a {@code default} that forwarded to the unescaped overload. The + * Thymeleaf engine never overrode it, so with {@code template.engine=thymeleaf} — a supported, + * documented value — a subject could carry CR/LF and a deep link could carry a {@code javascript:} + * scheme. Nothing failed, because the interface compiled and the one engine under test was the one + * that implemented the rule. + * + *

Parameterized over the engines for that reason: a rule that only holds for the implementation + * somebody remembered to test is not a rule the platform has. + */ +class BothEnginesHonourSlotModeTest { + + static Stream engines() { + return Stream.of( + Arguments.of("placeholder", new PlaceholderTemplateEngine()), + Arguments.of("thymeleaf-html", new ThymeleafStringTemplateEngine(TemplateMode.HTML)), + Arguments.of("thymeleaf-text", new ThymeleafStringTemplateEngine(TemplateMode.TEXT))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("engines") + @DisplayName("a newline in a subject is refused, whichever engine renders it") + void aSubjectMayNotCarryAControlCharacter(String name, NotificationTemplateEngine engine) { + assertThatThrownBy( + () -> + engine.render( + TemplateSlotMode.SUBJECT, + subjectTemplate(name), + Map.of("code", "123\r\nBcc: attacker@example.com"))) + .as("everything after a CR/LF is read as a new header by the receiving agent") + .isInstanceOf(TemplateRenderingException.class); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("engines") + @DisplayName("a javascript: deep link is refused, whichever engine renders it") + void aDeepLinkMayNotUseAScriptScheme(String name, NotificationTemplateEngine engine) { + assertThatThrownBy( + () -> + engine.render( + TemplateSlotMode.URI, + linkTemplate(name), + Map.of("link", "javascript:alert(1)"))) + .isInstanceOf(TemplateRenderingException.class); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("engines") + @DisplayName("an https deep link is accepted, so the rule is a filter and not a refusal") + void anHttpsDeepLinkIsAccepted(String name, NotificationTemplateEngine engine) { + // Without this, the assertion above is satisfied by an engine that refuses every URI slot. + assertThat( + engine.render( + TemplateSlotMode.URI, linkTemplate(name), Map.of("link", "https://example.com/a"))) + .contains("https://example.com/a"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("engines") + @DisplayName("markup in an HTML slot is escaped, whichever engine renders it") + void markupInAnHtmlSlotIsEscaped(String name, NotificationTemplateEngine engine) { + assertThat( + engine.render( + TemplateSlotMode.HTML_TEXT, + bodyTemplate(name), + Map.of("name", ""))) + .as("a substituted value must not become markup") + .doesNotContain("