- CI 단계 분리 계획 추가 (docs/superpowers/plans/2026-09-16-ci-stage-separation.md). 빌드·CI 레이어 전수 리뷰 133건의 결론과 Track A/B/C 작업 순서를 담는다. - public-path 보안 기준선을 실제 배포 기본값(/v1/healthcheck)으로 재생성. 이전 값은 gitignore 된 src/.env 에서 유래해 재현이 불가능했다. - 진행 중이던 ADR·리뷰·테스트 전략 문서 반영, 대체된 grpc 계획 문서 제거. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
38 KiB
Wave 0 — Red Baseline Evidence
- Repository HEAD at capture:
2f5d2fc21954286213c1474d19935f571ef896ea - Captured: 2026-08-15
- Toolchain: JDK 21.0.11, Gradle 9.0.0, Docker Engine 29.7.2, Docker Compose 5.4.0
- Plan:
2026-08-15-wave0-red-baseline.md
Exit state
$ cd src && ./gradlew wave0RedReport --console=plain --no-daemon
BUILD SUCCESSFUL in 1m 7s
12 red, 1 unexpectedly green. The red set matches the plan's expected table except for
StartupWarningZeroTest, recorded as a deviation below.
| Red test | Closed by | Confirmed cause |
|---|---|---|
SecretLeakScannerCharacterizationTest.methodCallWithSafeSuffixIsNotALeak |
Wave 2 C1 | safe-suffix $ anchor cannot match past a captured () |
SecretLeakScannerCharacterizationTest.numericFencingIsNotALeak |
Wave 2 C1 | every + read as string concatenation |
FiveAdapterOffInventoryTest.jpaOffHoldsNothing |
Wave 1 T4/T9 | see JPA inventory below |
FiveAdapterOffInventoryTest.messagingOffHoldsNothing |
Wave 1 T7 | see messaging inventory below |
FiveAdapterOffInventoryTest.notificationOffHoldsNothing |
Wave 1 T3/T8 | see notification inventory below |
ShippedRuntimeFacadePresenceTest.mongoFacadeIsShipped |
Wave 1 T5 | ClassNotFoundException — not on the runtime classpath |
ShippedRuntimeFacadePresenceTest.graphQlFacadeIsShipped |
Wave 1 T6 | ClassNotFoundException |
ShippedRuntimeFacadePresenceTest.messagingPlatformFacadeIsShipped |
Wave 2 C3 | ClassNotFoundException |
DefaultProfileBootCharacterizationTest.localProfileStartsWithShippedDefaults |
Wave 1 T9 | relay-enabled with blank broker |
DefaultProfileBootCharacterizationTest.devProfileStartsWithShippedDefaults |
Wave 1 T9 / Wave 3 T2 | same validator reached first |
ComposeMergeCharacterizationTest.devStackRenders |
Wave 3 T3 | duplicate /var/tmp/heap mount target |
ReleaseManifestTaskExistenceTest.mongoReleaseContractNamesOnlyRegisteredTasks |
Wave 2 B5 | three unregistered tasks |
Green as planned: StartupWarningRecorderTest, RuntimeMembershipClasspathAgreementTest,
ComposeMergeCharacterizationTest base/local, ShippedRuntimeFacadePresenceTest JPA/notification,
FiveAdapterOffInventoryTest Mongo/GraphQL (vacuously — see Task 4), the three non-red scanner cases,
DefaultProfileBootCharacterizationTest.prodProfileRefusesPlaintextJdbc.
Task 1 — secret scanner
$ ./gradlew :messaging:messaging-observability:test --tests '*SecretLeakScannerCharacterizationTest*'
SecretLeakScannerCharacterizationTest > RED: incrementing a fencing token is arithmetic, not concatenation FAILED
SecretLeakScannerCharacterizationTest > RED: a method call whose name ends in a safe suffix is not a leak FAILED
5 tests completed, 2 failed
The pre-existing repository failure this characterizes:
$ ./gradlew :messaging:messaging-observability:test --tests '*SecretLeakStaticScanTest*'
SecretLeakStaticScanTest > noSensitiveIdentifierIsConcatenatedIntoAString() FAILED
java.lang.AssertionError: [a concatenated secret never reaches the redactor, so it must not be written at all]
Expecting empty but was: ["KafkaSecurityConfigurer.java:104 + oauth.credentialId());",
"InMemoryAdminOperationJournal.java:110 existing.leaseToken() + 1,"]
Task 3 — off-state inventories
Captured from the failure messages, with all five switches off on local. These are the exact type
lists Wave 1 works down.
JPA off — a connection pool, the entity/repository scan, and the H2 vendor configuration all exist:
com.zaxxer.hikari.HikariDataSource
dev.caskeleton.adapter.outbound.persistence.audit.DomainContextAuditContextPort
dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig
dev.caskeleton.adapter.outbound.persistence.config.PersistenceVendorSettings
dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator
dev.caskeleton.adapter.outbound.persistence.failure.StandardSqlStateErrorMapping
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
(list truncated in the assertion message)
Confirms JPA-INT-001 and the JPA half of §4.3's consumer table: the Fileserver repositories are on
this list, which is why Wave 1 Task 4's DataSourceRequirement must name Fileserver as a relational
consumer rather than treating the pool as JPA's alone.
Messaging off:
dev.caskeleton.adapter.outbound.messaging.MessagingConfig
dev.caskeleton.adapter.outbound.messaging.MessagingSettings
dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher
dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterConfig
dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterSettings
dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePublisher
dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapter
Note DisabledMessagePublisher and DisabledOutboxMessagePublisher: the sentinel behaviour is
correct, but off-invariant item 9 requires the composition root to supply it rather than the
adapter. Wave 1 Task 7 moves it.
Notification off — settings bind with the master off, which is NTF-INT-005 exactly:
dev.caskeleton.adapter.outbound.notification.NotificationConfig
dev.caskeleton.adapter.outbound.notification.NotificationRoutesSettings
dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier
dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailNotificationAdapterConfig
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings
dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackNotificationAdapterConfig
dev.caskeleton.bootstrap.autoconfigure.notification.NotificationPlatformSecretsConfig$NotificationSecretsSettings
Task 5 — default-profile boot
Both local and dev fail on the same validator, with the message it was written to give:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name
'outboxRelayBrokerRequirementValidator' defined in class path resource
[dev/caskeleton/bootstrap/outbox/OutboxConfig.class]: ca-skeleton.outbox.relay-enabled is enabled
but app.messaging.broker is blank, so every claimed outbox row would fail to publish and be
exhausted to DEAD. Either configure a broker, or set ca-skeleton.outbox.relay-enabled=false so
PENDING rows are preserved until one exists.
dev reaches this validator before the ddl-auto=update conflict the spec recorded from bootRun,
so the ddl-auto failure is currently masked. It will surface once Wave 1 Task 9 sets
relay-enabled: false, and Wave 3 Task 2 closes it.
Task 7 — Compose merge
$ docker compose -f docker-compose.yml -f docker-compose.dev.yml config
services.app.volumes[1]: target /var/tmp/heap already mounted as services.app.tmpfs[1]
Base and local render cleanly (app, and app,db respectively).
Task 8 — runtime project closure
$ ./gradlew :app-bootstrap:runtimeClasspathManifest
$ cat app-bootstrap/build/architecture/runtime-project-closure.txt
The manifest resolves and RuntimeMembershipClasspathAgreementTest is green — direct
dependencies and the resolved closure agree today, because no build-only leaf is reachable. This is
the gate that must stay green while Waves 1 and 2 add the Mongo, GraphQL, and messaging edges.
Task 9 — release manifest
ReleaseManifestTaskExistenceTest FAILED
the Mongo release contract names task(s) that no build file registers, so a release manifest can
report them green without ever running them
missing: [mongoAtlasTest, mongoKmsTest, mongoShardedTest]
registered: [mongoReplicaSetTest, mongoFailoverTest, mongoMigrationTest, mongoCompatibilityTest,
mongoSecurityIntegrationTest, mongoPerformanceTest, mongoStableContractTest]
scripts/verify-mongodb-advanced.sh:95 also invokes mongoShardedTest, so that script cannot
currently succeed either.
Deviations from the plan
1. StartupWarningZeroTest is green, not red
The plan expected this red. It passes: an all-off, non-web local startup emits zero WARN and
zero ERROR.
That is not a contradiction of spec §9.1 — the warnings recorded there
(BeanPostProcessorChecker on the authorization beans, two Micrometer late-MeterFilter warnings,
a Flyway converter warning on dev) were observed during bootRun, which is a web application
with JPA active. This test is narrower than the configuration that produces them.
Consequence for Wave 4: StartupWarningZeroTest as written does not yet measure the warnings Wave 4
must remove. Wave 4 Task 6 already calls for extending it to all three environments and every one-on
combination; that extension must also use WebApplicationType.SERVLET, or the gate will keep
passing while the warnings remain. Recorded here so the omission is not discovered as a surprise.
2. A test-harness defect surfaced first, and is not a production defect
Booting CaSkeletonApplication in-JVM from app-bootstrap's own test source set fails before any
adapter is examined:
BeanDefinitionOverrideException: Invalid bean definition with name 'outboxEventJpaRepository'
defined in ... @EnableJpaRepositories declared on PersistenceJpaConfig: ... there is already
[...] defined in ... @EnableJpaRepositories declared on OutboxContainerTestSupport.OutboxRepositoryConfig
Cause: CaSkeletonApplication component-scans dev.caskeleton.bootstrap; this module's test sources
live in that package; so an in-JVM boot discovers a test-only configuration
(OutboxContainerTestSupport.OutboxRepositoryConfig) that the shipped jar has never contained.
This is a property of the measurement, not of the product — bootRun is unaffected. Left alone it
would have reported the same cause for every activation characterization and hidden the defects they
exist to name. ShippedCompositionHarness registers a TypeExcludeFilter that drops candidates
whose class file came from a test output directory, which is a rule about provenance rather than a
class-name list somebody has to maintain.
This is a workaround for measuring, not a fix. The faithful instrument is running the produced jar as a child process, which is what Wave 2 Task E3 builds for GraphQL. If Wave 1's off-invariant work needs stronger evidence than the harness can give, promote the activation tests to that shape rather than trusting the exclusion.
3. A property-precedence trap worth remembering
SpringApplicationBuilder#properties(String...) contributes to defaultProperties, the
lowest-precedence source, so the all-off set lost to application.yml's relay-enabled: true and
the "all-off" context died on the relay validator. The harness passes --key=value command-line
arguments instead. Any later test that sets an all-off baseline must do the same.
Files added by Wave 0
Production sources changed: none. Verified by inspection — every path below is a test source or a Gradle lane registration.
| File | Kind |
|---|---|
src/messaging/messaging-observability/src/test/java/.../SecretLeakScannerCharacterizationTest.java |
test |
src/app-bootstrap/src/test/java/.../activation/AdapterActivationInventory.java |
test fixture |
src/app-bootstrap/src/test/java/.../activation/ShippedCompositionHarness.java |
test fixture |
src/app-bootstrap/src/test/java/.../activation/FiveAdapterOffInventoryTest.java |
test |
src/app-bootstrap/src/test/java/.../activation/ShippedRuntimeFacadePresenceTest.java |
test |
src/app-bootstrap/src/test/java/.../activation/DefaultProfileBootCharacterizationTest.java |
test |
src/app-bootstrap/src/test/java/.../activation/StartupWarningRecorder.java |
test fixture |
src/app-bootstrap/src/test/java/.../activation/StartupWarningRecorderTest.java |
test |
src/app-bootstrap/src/test/java/.../activation/StartupWarningZeroTest.java |
test |
src/app-bootstrap/src/test/java/.../compose/ComposeMergeCharacterizationTest.java |
test |
src/app-bootstrap/src/test/java/.../registry/RuntimeMembershipClasspathAgreementTest.java |
test |
src/app-bootstrap/src/test/java/.../registry/ReleaseManifestTaskExistenceTest.java |
test |
src/app-bootstrap/build.gradle |
lane registration (runtimeClasspathManifest, wave0Red) |
src/messaging/messaging-observability/build.gradle |
lane registration (wave0Red) |
src/build.gradle |
lane registration (wave0RedReport) |
Wave 1 progress note (same session)
The repository-wide test is green
$ cd src && ./gradlew test --console=plain --no-daemon --continue
BUILD SUCCESSFUL in 3m 20s
It was red at HEAD before this work, on SecretLeakStaticScanTest (spec §3.1). Two changes made
it green, and neither is a suppression:
- The scanner defect is fixed (Wave 2 C1, pulled forward because it was the only thing keeping
the build red). A captured method call keeps its trailing
(), so the safe-suffix exemption's$anchor never matched — stripping the call fixes it, where unanchoring the pattern would also have exemptedcredentialIdentity, which does carry the value. And a+with a numeric literal on either side is arithmetic, which cannot put a secret into a string. The three true-positive cases in the characterization are what prove the fix was not a weakening. - Wave 0's deliberately-red characterizations are excluded from the ordinary
testtask and reported bywave0RedReportinstead. A permanently redtestis a build nobody can use, and a gate nobody can use stops catching the regressions it exists for. Wave 6 requires the tag set to be empty, so the exclusion cannot quietly become forgetting.
Remaining red set: 4
| Red | Closed by |
|---|---|
ShippedRuntimeFacadePresenceTest.graphQlFacadeIsShipped |
Wave 1 T6 |
ShippedRuntimeFacadePresenceTest.messagingPlatformFacadeIsShipped |
Wave 2 C3 |
ComposeMergeCharacterizationTest.devStackRenders |
Wave 3 T3 |
ReleaseManifestTaskExistenceTest.mongoReleaseContractNamesOnlyRegisteredTasks |
Wave 2 B5 |
Down from 12 at the Wave 0 baseline.
Known defect left as found: the shared-contract scope rule
CleanArchitectureTest.SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES has two faults
that mask each other:
- its subject pattern is
..shared.., which matches any package namedsharedanywhere rather than the shared-contract module; - its allowlist contains the bare module root
dev.caskeleton.sharedand the check matches by prefix, so every package inside the module is blessed automatically.
Net effect: the rule catches nothing inside the module it is named after, and does catch unrelated
leaves that happen to have a shared package — which is how it surfaced, when the Mongo leaf joined
the composition root's analysis scope.
Fixing it properly requires the violation fixture that proves the rule works to move inside
dev.caskeleton.shared, which then puts the fixture in the rule's own analysis scope, which requires
reworking how fixtures are scanned. That is a separate change from adapter activation, so the rule is
left as found with the defect documented at the rule itself, and the Mongo tenancy package added to
the allowlist to keep the build honest in the meantime. This is technical debt, not a fix.
Corrections to the spec and to these plans, found by executing them
| Claim | Reality |
|---|---|
ca-skeleton.idempotency.provider |
The property does not exist. It is ca-skeleton.capabilities.idempotency.provider. |
app.fileserver.enabled |
It is app.fileserver-platform.enabled. |
| Notification/GraphQL selectors are legacy master aliases | They are subordinate settings that stay valid while the adapter is on. Treating them as aliases made every shipped configuration ambiguous. |
AutoConfigurationImportFilter registers in .imports |
It registers in META-INF/spring.factories. Getting this wrong fails open silently — the filter simply never runs. |
| The JPA root can import one exported entry | Inverting the vendor→config import to create one produced a package cycle. The composition root names both vendor configs instead, and the export surface admits them with that reason recorded. |
Shipping Mongo pulled in reactive Mongo
Adding the Mongo leaf to app-bootstrap put spring-boot-starter-data-mongodb-reactive and
mongodb-driver-reactivestreams on the runtime classpath. The index's scope boundaries exclude
reactive Mongo from the shipped Stable runtime, and leaving the starter there would have let Boot
build a second client and pool from the same URI as soon as the master switch went on. Both are
excluded at the composition root rather than in the leaf, which still compiles the reactive paths for
a future promotion.
Session close — Wave 1 complete, Wave 2/3 partially landed
Verified state
$ cd src && ./gradlew test --console=plain --no-daemon --continue
BUILD SUCCESSFUL in 2m 58s
$ ./gradlew verifyEnvKeys verifyCleanArchitectureDependencies \
verifyRuntimeModuleMembership verifyPublicPathSnapshot
verifyRuntimeModuleMembership: 2 runtime composition(s) match the registry
BUILD SUCCESSFUL
$ ./gradlew wave0RedReport
1 red remaining
12 red at the Wave 0 baseline → 1. The survivor is
ShippedRuntimeFacadePresenceTest.messagingPlatformFacadeIsShipped, which Wave 2 C3 closes: the
messaging platform has no production sender, only a test fake, so giving it runtime membership now
would ship a path that cannot work.
Wave 1 — complete (13/13)
| Task | Delivered |
|---|---|
| T1 | MasterSwitch / MasterSwitchParser in shared-contract — five names in one place, strict parse |
| T2 | MasterSwitchEnvironmentPostProcessor — rejects before any detail namespace binds |
| T3 | Both composition-root scans narrowed; the properties scan names its packages because it has no excludeFilters |
| T4 | PersistenceJpaRootAutoConfiguration + DataSourceRequirement + JpaOffAutoConfigurationImportFilter |
| T5 | Mongo shipped, one authority, reactive starter excluded from the runtime |
| T6 | GraphQL shipped, one authority, Boot GraphQL auto-configurations filtered |
| T7 | Messaging bridge gated; disabled sentinels moved to the composition root |
| T8 | NotificationRootAutoConfiguration — secrets and registries named rather than scanned |
| T9 | Migration, outbox and idempotency under capability roots |
| T10 | CapabilityDependencyValidator — 8 rules, each naming the exact missing switch |
| T11 | DatabaseReadinessGroupPostProcessor — db membership derived from the capability closure |
| T12 | 8 env registry rows + YAML binding + a contract test derived from the enum |
| T13 | Membership gate reads the resolved runtime closure instead of declared dependencies |
Measured effect, JPA off: ~30 beans → 0 (pool, entity scan, repositories, Hibernate, Flyway, DB health all gone).
Pulled forward from later waves
- Wave 2 C1 — the secret scanner's two false positives fixed at the cause. This is what made a
repository-wide green
testpossible; it had been red at HEAD. - Wave 2 B5 — the three ghost Mongo lanes demoted to
experimental_contractsrather than implemented, with the script and doc updated to match. - Wave 3 T1 — profileless deploys refused, scoped to the deployable artifact so slice tests are unaffected.
- Wave 3 T3 — the dev Compose
tmpfs: !override []fix; merge verified and mount targets checked for uniqueness in the merged model.
The defaulting cascade, and what it cost
Removing the relay's blanket refusal exposed the failure underneath it, exactly as this document predicted — and then that one exposed a third. The sequence was:
- relay-enabled with a blank broker (fixed in T9);
ddl-auto=updateagainst a Flyway-owned schema (fixed by changing the tracked.env);logging.level.rootfailing to bind, because 62 placeholders inapplication.ymlhad no inline default at all and onlyapplication-local.ymlpinned enough of them for one profile to start.
55 of those 62 now carry a default. The remaining seven are deliberate: the datasource URL, username and password, the application name, and the JWT issuer and audience — a default for any of them is a deployment running against something nobody chose. CORS allowed-origins was moved out of that set after the fact: CORS is off by default and an empty origin list is the safest value rather than an arbitrary one, so it defaults to empty.
One of the added defaults was itself wrong — max-age-seconds got 600s while the field is a
long — which is worth recording because it only surfaced through a real boot. A bulk defaulting
pass needs a boot per profile to be believed.
Still open
ShippedRuntimeFacadePresenceTest.messagingPlatformFacadeIsShipped(Wave 2 C3).- The
..shared..ArchUnit rule remains defective and documented at the rule itself; see the earlier note. Unchanged this session. - Waves 2 (remaining), 3 (Compose lane matrix, Keycloak, MinIO), 4, 5 and 6 are untouched.
Continuation — Wave 3 T2 and Wave 4 T1 landed; a Wave 4 fix disproved
Verified state
$ cd src && ./gradlew test BUILD SUCCESSFUL
$ ./gradlew verifyEnvKeys verifyCleanArchitectureDependencies \
verifyRuntimeModuleMembership verifyPublicPathSnapshot
BUILD SUCCESSFUL
$ ./gradlew wave0RedReport 2 red
Wave 3 T2 — env source separation, complete
src/.env is untracked. src/.env.example (309 keys, generated from the registry, 12
secret-classified keys left empty) and src/.env.local.example are tracked in its place, and
.gitignore carries src/.env* with the two examples negated.
verifyEnvKeys now reads the example. Reading the real file made it false in both directions: it
passed only where an operator's own environment file happened to exist, and it would have passed with
no example at all — so the file an adopter actually copies was never verified, while a file full of
real credentials was a build input. Proven by deleting src/.env and re-running: green.
Its rule B was inverted while retargeting. It required every key in the file to be an
application.yml placeholder, which is true of a hand-maintained .env and false of a catalogue —
most registry keys are bound by typed settings inside a leaf. Inverted to "every registered APP_ key
appears in the example", it now catches the drift that actually matters: a key added to the registry
that never reached the file an adopter copies.
SPRING_PROFILES_ACTIVE was also corrected in the registry — type: enum, required: true, no
default — to match Wave 3 T1.
Wave 4 T1 — MeterFilter ordering, complete
MetricsContractConfig and SampleMetricsContractConfig both install their filters through a
MeterRegistryCustomizer instead of a @PostConstruct that fetched the registry. The warning was the
visible half of the real defect: a filter applies only to meters registered after it, so the
cardinality and distribution policies were being applied to some meters and not others. Both
composition roots changed together, because fixing one leaves the warning reproducible from the other.
The Boot 4 package is org.springframework.boot.micrometer.metrics.autoconfigure, found by inspecting
the resolved jars rather than assumed.
Wave 4 T4 — attempted, disproved, and left as found
The spec calls the structured log's profile field a drift: it reads SPRING_PROFILES_ACTIVE, so
overriding the profile on the command line while a stale value sits in the environment stamps lines
with the stale one. The drift is real.
The obvious fix — bind the field to spring.profiles.active — does not work, and an existing
contract test said so. LogProfileDriftCharacterizationTest was written to settle the question by
observation rather than argument, and it observed an empty string: Logback initialises before that
property resolves. A field that says nothing is not an improvement on a field that is sometimes wrong.
The binding was reverted to what it was, with the reason recorded at the declaration, and the characterization kept as a tagged red. The fix needs a different mechanism — setting the logger context property from the resolved environment once it is ready, rather than declaring the source in XML — which is Wave 4's to build.
Remaining red: 2
| Red | Closed by |
|---|---|
ShippedRuntimeFacadePresenceTest.messagingPlatformFacadeIsShipped |
Wave 2 C3 — no production sender exists, only a test fake |
LogProfileDriftCharacterizationTest |
Wave 4 — needs the mechanism above |
Continuation — Wave 3 Compose lane matrix (static half) and Keycloak realm
Verified state
$ cd src && ./gradlew test BUILD SUCCESSFUL
$ ./gradlew verifyEnvKeys verifyCleanArchitectureDependencies \
verifyRuntimeModuleMembership verifyPublicPathSnapshot BUILD SUCCESSFUL
$ ./scripts/verify-compose-profile-contracts.sh
all 15 lanes match src/config/runtime/compose-profile-contracts.json
$ ./gradlew wave0RedReport 2 red
What landed
src/config/runtime/compose-profile-contracts.json — the lane SSOT. 15 lanes, each fixing a
Compose profile, a file stack, an explicit Spring runtime, and the exact service set that stack must
render. Exact rather than superset: a lane that quietly gains a service is a lane whose evidence
describes a different stack than the one that ran.
docker-compose.infra.yml — every shared service, each carrying Compose profiles so nothing
starts unless a lane names it: PostgreSQL, a single-node Mongo replica set with an idempotent
initiator, Kafka, Mailpit, MinIO with bucket bootstrap, Keycloak, and the three one-shot smoke
clients. Infrastructure no longer lives inside environment overlays, which is what let local stop
meaning "the app plus a database".
docker-compose.prod-smoke.yml — a production-shaped runtime whose JDBC URL carries
sslmode=verify-full, so the prod validators are satisfied rather than bypassed.
scripts/verify-compose-profile-contracts.sh — the static entry point, wired into the ordinary
test suite so a lane cannot drift until somebody remembers to run a shell script. It checks the
Compose version floor, the exact service set per lane, the rendered SPRING_PROFILES_ACTIVE, and
mount-target uniqueness in the merged model.
Keycloak realm (infra/keycloak/) — ca-skeleton-api as a confidential client with a service
account, standard flow and direct access grant off, an audience mapper and realm/client role mappers.
The client secret is a ${...} reference; the entrypoint reads it from a mounted secret file and
execs kc.sh, so no value reaches Git, the rendered config, or docker inspect.
Smoke clients — auth-smoke (the seven realm checks, against the same issuer URL the app is
given), object-storage-smoke (upload → HEAD → download → delete → wrong-credential rejection, none
skippable), notification-smoke (three phases, so the handoff lane's accept and verify are the same
client talking about the same request id).
What the verifier caught immediately
Writing it was worth it before running anything. On first execution it failed four lanes:
off-localrenderedapp,db, because thedbservice was still in the local overlay as well as in the new infra file;- three lanes could not render at all, because the local overlay's
depends_on: dbpointed at a service their profile does not enable — Compose refuses that outright.
Both are the same mistake: infrastructure declared where the environment is described. db now lives
only in the infra file, and the depends_on is gone — ordering belongs to the runtime-smoke wrapper,
which knows which services a lane actually starts.
Two follow-on defects found and fixed
Untracking src/.env broke local Compose on a fresh clone. docker compose config failed
outright because the local overlay declared env_file: ./src/.env. Marked required: false, and
verified by moving the file aside: the stack renders. A convenience override had become a hard
prerequisite for rendering the stack at all.
The developer host-port contract moved with the service.
DeveloperExperienceContractTest.localComposePublishesTheHostPortTheCommittedDatasourceUrlTargets
asserted against the local overlay. It now asserts against the infra file and additionally that the
service carries the local-jpa profile — without which the port assertion would pass for a service
no lane ever brings up.
Still open in Wave 3
scripts/run-compose-runtime-smoke.sh — the dynamic half — is not written. Nothing here has been
started; what is verified is that all 15 lanes render exactly what they claim, with the right Spring
runtime and no mount collisions. The lanes have not been run, and this document does not claim they
have.
Continuation — the Compose lanes actually run
Verified state
$ cd src && ./gradlew test verifyEnvKeys verifyCleanArchitectureDependencies \
verifyRuntimeModuleMembership verifyPublicPathSnapshot BUILD SUCCESSFUL
$ ./scripts/verify-compose-profile-contracts.sh all 15 lanes match
$ ./scripts/run-compose-runtime-smoke.sh --lane off-local passed
$ ./scripts/run-compose-runtime-smoke.sh --lane off-dev passed
$ ./scripts/run-compose-runtime-smoke.sh --lane off-prod passed
Each lane's own report, fetched from the running application rather than asserted from the flags the lane passed in:
| lane | activeProfile | switches on | dataSourceRequiredBy |
|---|---|---|---|
off-local |
local |
none | not required |
off-dev |
dev |
none | not required |
off-prod |
prod |
none | not required |
docker ps -a and docker volume ls show no surviving casmoke project or volume.
This is the Wave 1 exit criterion, demonstrated for the first time in a real container: all five adapters off, three environments, no infrastructure of any kind, and the application reporting so itself.
What was built
scripts/run-compose-runtime-smoke.sh — the dynamic entry point. Unique project per lane, evidence
directory that refuses to reuse a previous run's, static contract then config then create,
up --wait on long-running services only, a bounded readiness poll, every declared one-shot with a
non-zero exit failing the lane, sanitized evidence, and a trap teardown scoped to the lane's own
project — logs collected before the teardown, not after.
AdapterActivationEndpoint / AdapterActivationReport — the application's own answer about what
resolved on. A lane asserting on its own environment passes whenever it set the variables correctly,
which is not the claim being made.
Six defects the lanes found, in the order they surfaced
Each was invisible to every check that existed before, and none would have been found by reading.
- A stale image. The app service declares both
build:andimage:, so Compose reused a tag from an older state of the repository — the first run failed on a class that no longer exists in the tree. The wrapper now builds explicitly. A lane running a stale image produces evidence about code nobody changed. - The actuator is on its own connector. Fetching
8080/actuatorreturned an empty file that read exactly like a failed assertion about the profile. - The activation endpoint was authenticated. Management auth is JWT, so only a lane with an
identity provider could have read it — excluding the all-off lanes, whose claim is the hardest to
check any other way. It is now permit-all alongside health/info/prometheus, and
AdapterActivationReportShapeTestholds it to property names and booleans so that stays true.ManagementActuatorSecurityContractTestrecords the allowlist decision rather than absorbing it. off-localwas passing by luck. It read the developer's ownsrc/.envfor the seven deliberately-undefaulted values. The wrapper now generates them per run, so a lane reproduces anywhere rather than on the machine it was written on.- The dev overlay has no healthcheck, so
up --waitreturned as soon as the container was created and the first fetch landed before startup finished. The wrapper polls with a bound rather than trusting--waitalone. environment:beatenv_file:in the prod overlay.APP_DATASOURCE_PASSWORD: "${APP_DATASOURCE_PASSWORD:-}"read the host shell, not the lane's generated file, and injected an empty string — which the prod env validator then refused, correctly, about a value the lane had actually supplied.
Not claimed
The twelve infrastructure-bearing lanes have not been run. --matrix exists and is untested against
them; what is demonstrated is the three all-off lanes end to end and that all fifteen render exactly
what they claim. Keycloak, MinIO, Mongo, Kafka and Mailpit have been written and rendered, not
started.
Continuation — the infrastructure lanes, and what running them found
Verified state
$ cd src && ./gradlew test verifyEnvKeys verifyCleanArchitectureDependencies \
verifyRuntimeModuleMembership verifyPublicPathSnapshot BUILD SUCCESSFUL
$ ./scripts/verify-compose-profile-contracts.sh all 15 lanes match
$ ./scripts/run-compose-runtime-smoke.sh --matrix src/config/runtime/compose-profile-contracts.json
all 4 blocking lanes passed
No surviving casmoke container or volume.
| lane | activeProfile | switches on | vendor |
|---|---|---|---|
off-local |
local | none | none |
off-dev |
dev | none | none |
off-prod |
prod | none | none |
local-mongo |
local | persistence-mongo |
none |
local-mongo is the first adapter proven on against real infrastructure: a single-node replica set,
the Mongo master switch on, no other switch on, and no relational connection required.
The contract gained two assertions, because a green lane was not yet a meaningful one
expectedSwitchesOn. A lane named local-jpa that ran with JPA off would render the right
services, start cleanly, and prove nothing. The wrapper now compares the switches the application
reports against what the lane asked for.
expectedPersistenceVendor. local-jpa passed for a while against H2 while the PostgreSQL
container it started sat untouched beside it — application-local.yml pinned an in-memory URL as a
literal, which outranks any environment a caller supplies. Every other field in the report looked
correct. The report now carries the vendor resolved from the JDBC URL, and the lane asserts it.
That pin was not unique. application-local.yml also pinned app.messaging.broker: "" and the two
notification provider selectors, so local-messaging started Kafka, set APP_MESSAGING_BROKER=kafka
and was refused by the dependency validator for a value it had supplied. All four are placeholders
now; the defaults are unchanged, so a developer who sets nothing gets exactly what they got before.
Four defects in the wrapper itself
- It reported success for lanes it never ran.
docker compose execconsumes stdin, and inside a plainwhile readloop it ate the remaining lanes — the first ran, the loop ended, and the script said all six passed. Reading on fd 3 fixes it; a ran-count guard makes a partial matrix a failure rather than a pass. A wrapper whose own success message is a false green is worse than no wrapper. - Compose project names reject uppercase, so the run id is lowercased for the project while the evidence directory keeps the readable timestamp.
env_filelists merge and the later file wins. The developer's optionalsrc/.env, declared by the local overlay after the base, silently overrode the lane's own values. Lane settings now go into a generatedenvironment:overlay, which beats everyenv_fileregardless of order.up --waitis not a readiness gate where no healthcheck exists — the dev overlay has none, so the first fetch landed before startup finished. The wrapper polls with a bound.
Two spec findings confirmed in a real composition, not inferred
MSG-INT-003. With a healthy Kafka and the broker selected, startup fails on a missing
KafkaSender bean: the legacy Kafka configuration requires a project-supplied sender, and production
has none — only the tests provide a fake. This is precisely why the messaging platform leaves must
not get runtime membership before a real transport bridge exists.
GQL-INT-002. APP_GRAPHQL_DEPLOYMENT_MODE is registered and bound, but the platform still reads
the old production boolean and environment enum, which default to false and PRODUCTION_PUBLIC.
The startup validator therefore sees a production deployment with introspection enabled and refuses.
A third was found that the spec did not predict: shipping GraphQL into the same context as the rest
of the application produced two Clock beans, because the platform's clock was conditioned on its
own bean name rather than on the type. Every injection point wanting a Clock failed to start. It
now backs off on the type, which is what auto-configuration is for — and this could not have happened
while the leaf was build-only.
Lanes marked not-blocking, with reasons recorded in the contract
local-jpa, local-messaging-outbox, the three notification lanes, shared-infra-local,
shared-infra-dev, prod-smoke and all-adapters are blocked on an open JPA finding: the entity
scan is unconditional while the Flyway migration streams are partitioned by capability, so
ddl-auto=validate against real PostgreSQL fails on fs_cleanup_item. Scoping the entity scan to
active capabilities is Wave 2 JPA work.
local-messaging is blocked on MSG-INT-003 and local-graphql on GQL-INT-002, both above.
Each carries its reason in compose-profile-contracts.json and each keeps its assertions, so the
lanes fail loudly rather than passing against the wrong thing.