Files
clean-architecture-backend-…/docs/superpowers/plans/evidence/2026-08-15-wave2-decisions.md
T

1103 lines
66 KiB
Markdown

# Wave 2 — decisions and evidence
Required by the Wave 2 exit criteria. One entry per task that made a choice a reader could
reasonably have made differently, with what was run rather than what was expected.
---
## E1 — GraphQL: the two safety axes collapsed into one deployment mode (GQL-INT-002)
### What the defect actually was
`GraphQlPlatformSettings` declared `@DefaultValue("false") boolean production` and
`@DefaultValue("PRODUCTION_PUBLIC") GraphQlPlatformEnvironment environment` **in the same record**.
The shipped default therefore described an internet-facing production endpoint whose protections
behaved as if it were a laptop, because the two axes were read by different code:
| Behaviour | Read from |
| --- | --- |
| GraphiQL refusal | both |
| cursor signing key required | `production` |
| introspection refusal | `environment` |
| allow-by-default authorization | `production` |
| anonymous principal handling in the web interceptor | `production` |
So `environment=PRODUCTION_PUBLIC` with the boolean left alone gave a deployment that refused
introspection while handing out an allow-by-default authorization policy — production by one axis,
development by the other.
### The decision
`backend.graphql.deployment-mode` is now the only axis. `GraphQlPlatformEnvironment` became
`GraphQlDeploymentMode`; `production()` is a derived accessor on the settings, not a component.
**No default, deliberately.** The mode is the one setting in this record with none. A guess that
lands on development is an unauthorized endpoint; a guess that lands on production is an outage an
operator cannot explain. Absence is refused by name, and `production()` reads fail-closed (`true`)
for the window between binding and that refusal.
**Four modes, not six.** `TEST` and `STAGING` were removed. `CapabilityDependencyValidator` permits
`local→LOCAL`, `dev→DEV`, `prod→{PRODUCTION_INTERNAL, PRODUCTION_PUBLIC}`, so neither constant was
selectable by any shipped runtime — a posture nobody can deploy and nobody notices is dead.
`GraphQlDeploymentModeRegistryParityTest` derives its cases from `GraphQlDeploymentMode.values()` and
asserts that the enum, `docs/registries/env-keys.yaml`, and the validator agree, so a fifth mode
cannot be added without a registry row and a runtime that accepts it.
**The retired keys fail rather than being ignored.** Spring's binder ignores unknown keys, so an
operator who set `backend.graphql.production` would have got a clean startup and a silently different
safety posture — worse than the split-brain, because the old configuration at least did something.
`GraphQlActivationEnvironmentPostProcessor` refuses either retired key while the master switch is on,
naming `APP_GRAPHQL_DEPLOYMENT_MODE`, including when the replacement is also set: two keys that can
disagree is the defect, and the new one winning silently is the same bug.
### Two defects the `local-graphql` lane found that the spec did not predict
Neither was reachable by reading; both took a real composition.
1. **Boot answers introspection by default and this platform does not.** With the switch on and
nothing else configured, `spring.graphql.schema.introspection.enabled` was `true` while
`backend.graphql.console.introspection-enabled` was `false`, and the runtime validator correctly
refused a deployment with two answers to one question — a contradiction nobody had configured.
The same post-processor now contributes the platform's console values as the framework's defaults
at the **lowest** precedence, so an operator who sets either key still wins and is still
validated; what is removed is the disagreement that existed with nothing set at all.
2. **The Keycloak realm artifact could never have imported.** Keycloak deserializes the realm into
`RealmRepresentation` with unknown fields **rejected**, not ignored, so the `"_comment"` key
documenting why the client secret is a `${...}` reference failed the entire import and exited the
container 1. Fixing it revealed a second one — `"_flowComment"` on a client — which had been
invisible behind the first. Both are gone, the rationale moved to `infra/keycloak/README.md`, and
`verify-compose-profile-contracts.sh` now fails on any key in that artifact starting with `_`.
That check runs in the ordinary suite through `ComposeMergeCharacterizationTest`.
A third, in the same lane: the wrapper writes the client secret at mode 0600 as the host user, the
Keycloak image happens to run as the same uid, and `curlimages/curl` runs as uid 100 — so
`auth-smoke` read `Permission denied` and the lane failed on the smoke client rather than on
anything it was checking. Compose ignores a secret's `uid`/`gid`/`mode` outside swarm, so the
container reads it as root. The two alternatives are worse: a world-readable host file leaves a
credential readable by every process on the machine, and an environment variable puts the value in
`docker compose config` output and in `ps`.
### Verification
```
$ cd src && ./gradlew :adapter:inbound:graphql:test :app-bootstrap:test BUILD SUCCESSFUL
$ ./gradlew :adapter:inbound:graphql:graphqlStableTest BUILD SUCCESSFUL
$ ./gradlew :adapter:inbound:graphql:verifyGraphQlApiSurface OK
$ ./scripts/verify-compose-profile-contracts.sh all 15 lanes match
$ ./scripts/run-compose-runtime-smoke.sh --lane local-graphql == local-graphql: passed
```
`GraphQlDeploymentModeContractTest` — 22 cases, 0 skipped. The lane's reported activation:
```json
{"activeProfile":"local",
"switches":{"backend.graphql.enabled":true,
"ca-skeleton.persistence-jpa.enabled":false,
"ca-skeleton.notification.platform.enabled":false,
"ca-skeleton.persistence-mongo.enabled":false,
"app.messaging.enabled":false},
"dataSourceRequiredBy":[],"persistenceVendor":"none"}
```
GraphQL is the first inbound transport proven on against a real identity provider with every other
switch off and no database connection required. `local-graphql` is now `blocking: true` in
`src/config/runtime/compose-profile-contracts.json`; its `notBlockingReason` is gone because the
reason is gone.
### Recorded, not fixed
- **`verifyGraphQlApiSurface` was already failing at HEAD.** The committed snapshot predates a rename
sweep: `GraphQlPlatformProperties``GraphQlPlatformSettings`, `GraphQlReleaseFailure`
`GraphQlReleaseException`, `GraphQlStructuralLimitViolation``GraphQlStructuralLimitException`,
`GraphQlAdvancedReleaseFailure``GraphQlAdvancedReleaseException`,
`GraphQlWebSocketProtocolError``GraphQlWebSocketProtocolException`, plus the Wave 1 root and import
filter. The approved update absorbed those alongside this wave's four entries. Noted rather than
buried: a snapshot that has been red for a while stops being a gate.
- **`BoundedPreparsedDocumentProviderTest.concurrentMissesOnOneKeyParseOnce` is flaky.** It failed
once under load (two modules compiling in parallel) with two parses where the single-flight
contract requires one, and passed 3/3 when re-run alone. Untouched by this task and not
investigated here — a genuine single-flight race under contention is a Wave 4 candidate, and a
cache test that only holds on an idle machine is not holding.
---
## Section A — the JPA-on lanes, and the five defects between them and green
Eight lanes were blocked on one recorded finding. Fixing it uncovered four more, each hidden behind
the one before it, and each invisible to the suite that existed. They are listed in the order they
surfaced, because that order is the point: no amount of reading found any of them.
### 1. The fileserver entity scan (the recorded blocker)
Six entities in `...persistence.fileserver` map six `fs_*` tables that live only in
`db/migration/jpa/fileserver`, a stream applied only when that capability is on. The primary Flyway
location creates none of them. The scan was unconditional, so `ddl-auto=validate` against real
PostgreSQL failed on `fs_cleanup_item` in every deployment that had switched the capability off.
Notification had already been given a gated scan for exactly this reason; fileserver had not.
`FileserverJpaPersistenceConfig` now carries the capability's own condition — the same one its
adapter beans already carried, so "disabled" stopped meaning two different things one annotation
apart.
**The half-fix that came with it.** Moving a scan out of the unconditional list registers it
nowhere: `dev.caskeleton.adapter.outbound.persistence..*` is excluded from the composition root's
component scan by design, and the leaf's `config` package may depend on `api` alone. So the
composition root is the only thing that can register it, and `NotificationJpaPersistenceConfig`
which nothing imports — has been in that state since it was created. The notification capability has
no JPA persistence at all. `CapabilityEntityScanRegistrationTest` asserts the fileserver import and
**asserts the notification gap**, with instructions to invert rather than delete the assertion when
Section D wires it. A silently unwired capability is what that file exists to make loud.
`FileserverRoundTripContractTest` declared its own `@EntityScan`/`@EnableJpaRepositories` over the
same packages, which both produced a duplicate bean definition and would have kept passing if the
shipped scan were registered nowhere. It uses the shipped one now.
### 2. `request_hash` — `char(64)` in the migration, `varchar(64)` in the entity
```
Schema-validation: wrong column type encountered in column [request_hash] in table
[idempotency_record]; found [bpchar (Types#CHAR)], but expecting [varchar(64) (Types#VARCHAR)]
```
Always-installed, so every JPA-on deployment failed. Invisible under H2, whose `create-drop` builds
the schema from the entities and therefore cannot disagree with them — a vendor that generates the
schema can never report a mismatch with it.
Forward migrations in both streams rather than an edit to `V1`: an applied migration's checksum is a
promise to every deployment that already ran it. Both are guarded on the current column type, because
the two streams keep separate histories and their relative order is not fixed.
`PostgreSqlDefaultPersistenceUnitIntegrationTest` now does for the default persistence unit what the
notification and fileserver suites already did for theirs, deriving its packages from
`PersistenceJpaConfig` so it covers the unit as it grows. Seconds, against a four-minute lane.
### 3. `connection-timeout: 5s` — a default only its overriders could survive
`spring.datasource.hikari.connection-timeout` binds onto `HikariConfig#setConnectionTimeout(long)`.
The shipped default was `5s`; `application-local.yml` overrides it with `30000`. So **every `prod`
and `dev` deployment failed to start** and every `local` one worked, which is why only a prod lane
could find it. The env registry declared `type: duration, default: 5s`, and `application.yml` copied
that — the registry documented a value the property cannot accept.
`HikariPoolConstraintValidator` reads these keys with `DurationStyle` and accepts `5s` happily. That
tolerance is what made the wrong default look supported. It is a **deliberate, tested contract**
(`durationStringsParticipateInCrossPropertyValidation`), so it is recorded as an open Section A
finding rather than quietly changed: a validator that passes values the binder rejects is answering a
different question than the one it appears to answer.
`ShippedDefaultBindabilityTest` is the cheap half — a scan, not a boot, over the millisecond-typed
pool knobs. Its first version matched on the key's leaf name and reported
`server.tomcat.connection-timeout: 20s`, a genuine `Duration`, as a defect; it flattens through
Boot's own `YamlPropertySourceLoader` now. **Verified by reverting the default and watching it fail.**
### 4. The dev stack put the application on a different network from its database
`UnknownHostException: db`, from a container running and healthy a metre away. The local overlay
joins `caskeleton-infra` and the dev overlay declared no `networks:` at all, so Compose put it on
`default` — a network of its own making. The omission reads as a working stack until something has to
resolve a name across it.
### 5. Generated credentials the database could never see
The wrapper generates a per-run password into `src/.env.lane`, which the application reads as an
`env_file`. The `db` service takes `POSTGRES_PASSWORD` from Compose **interpolation**
(`${APP_DATASOURCE_PASSWORD:-ca_skeleton}`), and interpolation reads the process environment and the
project `.env` file — never a service's `env_file`. The application got the generated password, the
database got the literal default, and they could not agree.
`shared-infra-local` passed only because the local overlay restates the value, which made this look
like a dev-specific problem rather than the general one it is. The wrapper exports the credentials
now, so one value serves both mechanisms.
### 6. The public health endpoint was not public anywhere except local
`presentation.api-base-path` defaults to `/v1`. `security.public-paths` defaulted to the literal
`/api/healthcheck`. Two shipped defaults describing one address, disagreeing — so health was
published at `/v1/healthcheck` while the allowlist opened a path no handler serves, and a load
balancer polling it would get a 401 and take the instance out of rotation. `local` pins both to
`/api` and could never reveal it.
The allowlist derives from the base path now, and `PublicHealthPathAgreementTest` holds the two
together in every profile. The smoke client had the same literal baked in; it takes the path from the
lane, which supplies the one matching the runtime.
The `/api` (local) versus `/v1` (everywhere else) split is left as found — changing it is a
user-facing decision — but it is a live trap for anyone following a local README against a dev host.
### Verification
```
$ cd src && ./gradlew test verifyCleanArchitectureDependencies verifyRuntimeModuleMembership \
verifyEnvKeys verifyPublicPathSnapshot BUILD SUCCESSFUL
$ ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest BUILD SUCCESSFUL
$ ./scripts/verify-compose-profile-contracts.sh all 15 lanes match
```
`local-jpa` reports `persistenceVendor: postgresql` — the assertion that previously caught it passing
against H2 while its PostgreSQL container sat untouched.
### Lanes promoted to blocking
`local-jpa`, `shared-infra-local`, `shared-infra-dev`, joining `off-local`, `off-dev`, `off-prod`,
`local-mongo` and `local-graphql`. Eight of fifteen.
The seven that remain each carry a reason that is now **specific to what is actually left**, not the
JPA finding they inherited: `prod-smoke` on transport security (closed below), the two messaging
lanes on MSG-INT-003, the three notification lanes on the unwired persistence above plus NTF-INT-001
and NTF-INT-006, and `all-adapters` on the union.
---
## D1 — the notification mode had a name nobody could bind
`NotificationPlatformMode` is `SERVING | INGEST_ONLY`. `docs/registries/env-keys.yaml` declared
`SERVING | ACCEPT_ONLY`, and so did the comment in `application.yml` and the row in
`docs/notification/configuration-reference.md`. `ACCEPT_ONLY` is a name the enum has never had.
The worst shape a drift can take: an operator follows the registry, sets
`APP_NOTIFICATION_PLATFORM_MODE=ACCEPT_ONLY`, and gets a binding failure naming a constant that none
of the three documents they can reach mentions. Every source they consulted agreed with every other
one, and all of them were wrong.
`NotificationModeSsotTest` derives its expectation from `NotificationPlatformMode.values()`, so a
third mode cannot be added without its registry row and a rename cannot land in one place only.
One correction while writing it: the first version scanned the raw file text for the retired name and
therefore failed on the comment that *records the retirement* — the opposite of the defect, since the
defect was a value nobody could tell had never existed. It strips YAML comments now and checks what
an operator would paste.
---
## The `prod-smoke` lane, and why it got a real certificate
The lane's remaining blocker was transport security: the prod runtime connects with
`sslmode=verify-full&sslrootcert=/run/secrets/postgres-ca`, and the lane's PostgreSQL was a stock
image with no TLS — `The server does not support SSL.`
**The rejected option was relaxing the lane.** `sslmode=disable` would have turned it green in one
line and made a prod smoke test a smoke test of a configuration production never runs. The one
failure mode it exists to catch — a chain or a host name that does not check out — is precisely the
one that appears nowhere else.
So the lane brings a certificate. `docker-compose.tls.yml` is a separate overlay, in the lane's file
stack rather than in the shared infra file, so no other lane pays for it. The wrapper generates a CA
and a server certificate **for the host name `db`**, valid one day, and removes both halves on
teardown — the realm-secret pattern applied to a keypair. `verify-full` rather than `verify-ca` is
deliberate: `verify-ca` proves who issued the certificate and says nothing about who presented it, so
it does not detect a redirected connection, which is most of what this is for.
**The uid problem, for the third time.** PostgreSQL refuses to start on a key that is group- or
world-readable and reads it as uid 70; the host generates it as uid 1000; a bind mount preserves
ownership. The same collision as the Keycloak client secret (uid 1000 vs the curl image's uid 100)
and it has the same shape: bind-mounted credentials and per-image uids do not compose. The
entrypoint wrapper copies the key at the only moment the container is still root, before the official
entrypoint drops privileges. The mount stays read-only and the CA certificate — public, so no mode
problem — is the only thing the application container sees.
### Verification
```
$ ./scripts/run-compose-runtime-smoke.sh --lane prod-smoke == prod-smoke: passed
```
The application's own log records the connection it made:
```
Database: jdbc:postgresql://db:5432/ca_skeleton?sslmode=verify-full&sslrootcert=/run/secrets/postgres-ca
(PostgreSQL 16.14)
```
Zero occurrences of `does not support SSL`, no certificate left on disk, no surviving container.
A pass *is* the proof here: `verify-full` fails closed, so a lane that silently lost TLS could not
have started.
`prod-smoke` is blocking. Nine of fifteen.
---
## C4 — one master-gated starter root, and a provider selection that can actually select
### The condition that could never select
The spec records MSG-INT-004 as "Kafka and Rabbit must never assemble together merely because both
client libraries are on the classpath". Reading the build files makes it sharper than that: they
always are.
```
messaging-kafka/build.gradle:16 api 'org.apache.kafka:kafka-clients'
messaging-rabbit/build.gradle:14 api 'org.springframework.amqp:spring-rabbit'
messaging-spring-boot-starter implementation project(':messaging:messaging-kafka')
implementation project(':messaging:messaging-rabbit')
```
Both are `api` dependencies of leaves the starter depends on, so **both client classes are on every
adopter's runtime classpath, always**. `@ConditionalOnClass(Producer)` and
`@ConditionalOnClass(Channel)` were therefore both true for everybody: selection by classpath could
not select. It assembled both providers and let a `@ConditionalOnMissingBean` race decide where a
message went. Nothing failed; the message simply went somewhere nobody chose.
`MessagingStarterOffContractTest.bothClientLibrariesArePresent` asserts that classpath fact directly,
so the argument for property-based selection stays checkable rather than becoming folklore.
### What replaced it
`MessagingPlatformRootAutoConfiguration` is the single `.imports` entry — five became one — and owns
`@ConditionalOnProperty(app.messaging.enabled=true)`. The five former auto-configurations are plain
`@Configuration` children reached only through it, so a bean added to any of them next month is gated
without anyone remembering to repeat a condition.
`MessagingProviderSelection` resolves `app.messaging.broker` against a closed registry through an
`ImportSelector`, and turns three silences into startup errors: an unregistered broker id, a
registered broker whose client library is absent, and a blank broker while the master switch is on.
Each message names the property and lists what is available.
### What the off-contract test found
The two "selecting X assembles X" cases failed on first run — not on selection, which worked, but on
`MessagingCoreAutoConfiguration.deadLetterOrchestrator` having no `MessagePublisher` to depend on.
**Neither provider configuration contributes one.** That is MSG-INT-003, reproduced at the starter
boundary at unit speed, where the Compose lane had found it as a missing `KafkaSender` in the legacy
adapter. Same gap, two doors.
The cases are separated rather than merged: selection is tested with a publisher supplied by the
test, and `noProviderSuppliesAProductionPublisher` states the gap as its own assertion, naming C3 and
instructing that it **invert rather than disappear** when C3 lands. Letting the missing publisher
fail the selection cases would have hidden a defect behind a defect.
### Not done here
C4 is the gate; C3 is the thing behind it. No membership changed: every messaging leaf still has
empty `runtime_memberships`, which is the registry's way of saying build-only. The wave's rule is
that the starter earns `app-bootstrap` membership **in the same change that proves a live broker
round trip**, so `local-messaging` and `local-messaging-outbox` stay non-blocking and
`ShippedRuntimeFacadePresenceTest.messagingPlatformFacadeIsShipped` stays the one Wave 2 entry in the
`wave0-red` report.
```
$ ./gradlew :messaging:messaging-spring-boot-starter:test 8 cases, 0 failures
$ ./gradlew test + the four architecture gates BUILD SUCCESSFUL
```
---
## C3 — the platform could not publish, and three separate things were why (MSG-INT-003)
`MessagingCoreAutoConfiguration` has consumed a `MessagePublisher` since it was written — the
dead-letter orchestrator, the blocking publisher, the reactive publisher and the batch publisher all
take one — and **no configuration produced one**. A selected transport failed on a missing bean
rather than publishing anywhere. The Compose lane found the same gap from the other side, as a
missing `KafkaSender` in the legacy adapter.
What made it hard to see is that nothing was *broken*. Three things were absent, each in a different
place:
| Absent | Where it should have been |
| --- | --- |
| the starter's dependency on `messaging-runtime-core` | `build.gradle` — the leaf holding `DefaultMessagePublisher` was not on the starter's path at all |
| `MessageCodecRegistry` implementation | anywhere — an interface the publisher's constructor named and nothing in the repository built |
| the producer, transport, access policy, admission controller and publisher beans | the starter's auto-configurations |
`DefaultMessagePublisher` and `KafkaMessagingTransport` existed the whole time and were unit-tested.
C3 is a wiring job with two small pieces written, not the transport implementation the spec's wording
suggests.
### The one real decision: what a deployment may publish to
`DestinationAccessPolicy` is three sets of destination names with a `denyAll()` factory, and neither
extreme is a usable default:
- **deny everything** and a correctly configured deployment assembles, starts and refuses every
publish, with an error naming a policy nobody knew they had to write;
- **allow everything** and the check is decoration — and the policy exists precisely because relying
on broker ACLs alone surfaces an accidental publish as a generic authorization error at runtime, in
the adapter, with no record of which module attempted it.
So the default is **the destinations the deployment declared**. Declaring a destination profile is
already an act of configuration — it states the ordering guarantee, the retry policy, the dead-letter
target — and a message to a destination nobody declared is not an access-control edge case; it is a
typo or a module reaching past its contract, which is what the check is for. Consume and administer
stay empty: a publisher's default has no business granting either.
### Two smaller decisions, both taken the same way as elsewhere this wave
**The broker address comes from `spring.kafka.bootstrap-servers`**, not from a second description
under `app.messaging.*` — one resource described twice is the defect already paid for in
`app.jpa-platform.datasource.*`, and the SMTP assembler took the same decision about
`spring.mail.*`. A selected broker with no address **fails at startup**: a producer built without one
silently defaults to `localhost:9092` and fails on the first publish, which is a deployment that
starts, reports healthy, and loses the first message somebody sends.
**`acks=all` and idempotence on.** `acks=1` loses an accepted publish to a leader failover, which is
exactly the outcome an outbox exists to prevent — inheriting that default would make the reliability
layer above it a formality.
### What the registry gate caught
Adding the dependency failed `verifyCleanArchitectureDependencies` immediately: the edge was not in
`modules.json`. That is the gate doing its job — the edge is now registered, in the same change that
needs it.
### The live round trip, and the four absences it had to cross
`MessagingLiveRoundTripQualificationTest` publishes through the assembled platform to a Testcontainers
Kafka and reads the bytes back with a consumer that shares nothing with the producing code but the
topic name. It passes, with `brokerAccepted=true`.
Getting one publish confirmed took crossing four separate absences, each stopping a message at a
different stage — and **no fake would have hit any of them**:
| Absent | Where the publish stopped |
| --- | --- |
| the starter's dependency on `messaging-runtime-core` | context assembly |
| `MessageCodecRegistry` implementation | context assembly |
| a declared message contract | `PUBLISH_PREPARATION_FAILED`, at encoding |
| `MessagingRuntime` implementation and its installation | `PUBLISH_RUNTIME_UNAVAILABLE`, after resolution, access and encoding |
The last is the same shape as the notification provider registry: a registry constructed empty that
nothing ever installed into, so a message got all the way to the wire and was refused there.
Two smaller decisions fell out. **An empty codec contract map is fail-closed** — a codec that accepted
an unregistered type would serialise whatever object it was handed onto a topic consumers read with a
different shape, so a deployment that publishes must declare what it publishes. And the contracts are
held in a named record rather than a bare `Map` bean, because a `Map<K, V>` injection point in Spring
means "every bean of type V, keyed by bean name" — a contract map registered as a bean is either
ignored or turned into something nobody wrote.
The destination registry taught the fixture two invariants on the way, which is the registry working:
a declared dead-letter destination must itself be registered, and it may not reference itself.
### Membership: qualified one, would ship eighteen
The wave's rule is exact — the starter earns membership in the same change unit that turns the round
trip green, and *"leaves that are unsupported or unqualified are excluded from both the starter's
dependencies and the registry."*
Adding the `app-bootstrap` edge surfaces **eighteen** leaves at once, which the plan predicted:
`messaging-{admin-api, admin-runtime, claim-check, cloudevents, core-api, inbox-jdbc-postgresql,
kafka, observability, outbox-jdbc-postgresql, policy, rabbit, reliability-api, runtime-core,
schema-api, schema-json, security, spring-boot-starter, transport-spi}`.
The round trip qualified **one transport**. Rabbit, the JDBC inbox and outbox reliability adapters and
the admin plane have no round trip. Promoting all eighteen on the strength of one Kafka publish is the
"wiring first, qualify later" the wave forbids, in the form that is easiest to rationalise: the code
is written, every test passes, and only the evidence for *this deployment* is missing.
So membership stays where it is and the fork is recorded rather than taken quietly:
- **qualify each** — a round trip per transport and per reliability adapter, the larger and more
honest path; or
- **trim the starter** to what one qualified transport needs, which changes what C4's provider
selection can select and removes the Rabbit path its contract test covers.
`MessagingMembershipQualificationTest` holds the state so neither happens by accident: the round trip
exists and asserts broker acceptance, no messaging leaf is a runtime member, and `app-bootstrap` does
not depend on the starter. `ShippedRuntimeFacadePresenceTest.messagingPlatformFacadeIsShipped` stays
the one Wave 2 entry in the `wave0-red` report — but its reason has changed from **"cannot assemble"**
to **"one of several transports qualified"**, which is a different and much smaller gap.
---
## C2 — the half that can be finished now, and why the other half is C3's
Two types declare `@ConfigurationProperties` on `app.messaging`: `MessagingSettings` in the outbound
adapter, which owns `broker`, and `MessagingProperties` in the starter, which owns everything else.
Spring binds both without complaint — each takes the fields it declares — so the split is invisible
at runtime and shows up only as two places to look, neither validating the other's view.
**The collapse is bound to C3's change unit, and saying so is the decision.** Deleting
`MessagingSettings` means the adapter stops selecting a broker and becomes a port bridge over the
platform's publish contract. It can only do that once the platform *has* a production publisher.
Removing the binding first would leave the adapter unable to select anything — a worse state than the
split it fixes, and precisely the "wiring first, qualifying later" the wave forbids.
What is finished is the guard that keeps the split honest and would catch the way C3 could go wrong
silently. `MessagingAuthorityContractTest` asserts the two owners by name, that **exactly one
production type implements `OutboxMessagePublishPort`** (two publishers emitting one event is the
dual write the wave forbids, and it reads as working — every message arrives, twice), and that the
adapter-local `MessagePublisher` reaches neither `application-core` nor `domain-core`.
A source scan rather than a context assertion, deliberately: no module sees both the adapter and the
starter. That is the boundary working — a test that could see both would be evidence it had gone.
### What C3 turns out to need
Reading the platform for C3's estimate changed the picture. The production publish path **already
exists**: `DefaultMessagePublisher` in `messaging-runtime-core`, `KafkaMessagingTransport` in
`messaging-kafka`, with a six-argument constructor whose collaborators all have concrete shapes.
C3 is a wiring job, not a write-from-scratch — but the starter does not depend on
`messaging-runtime-core` at all, and `MessageCodecRegistry` has an interface and no implementation
anywhere.
So C3 is: that missing dependency edge, a codec registry, a producer built from properties, a
transport bean, a publisher bean, four collaborator defaults each carrying a real policy decision
(what a default `DestinationAccessPolicy` permits is a security question, not a wiring one), then the
live round trip and the membership that only lands with it. Named here so the next session starts
from the shape rather than rediscovering it.
---
## B3 — a check conditioned on the bean whose absence it should report (MNG-INT-003)
`mongoPlatformStartupCheck` carries `@ConditionalOnBean(MongoTopologyProbe.class)`. Inside the
probe-present case it already fails closed — a partial set of inputs is refused rather than
half-validated, and its Javadoc records that earlier fix. The condition itself is the wider hole: a
deployment that enables the platform and supplies no probe gets **no validation at all**, silently.
Not supplying a bean is exactly what an operator who has not finished wiring will do.
**The capability flags were literals.** `true, true` went straight into `MongoStartupValidator`,
telling it that transactions and change streams were both wanted whatever the deployment had
configured — and the topology was then validated against that invented answer.
`MongoPlatformSettings` carried neither field. They are settings now: `transactions` is a subordinate
switch defaulting `false`, and `changeStreams` is **refused rather than stored** — the compact
constructor forces it to `false`, because the driver-side source is not shipped and accepting a flag
the platform cannot honour would leave an operator believing it took effect.
### Where the scope got settled, and by what
The first attempt required a probe whenever the module was on. It broke `MongoPersistenceConfigTest`
and would have broken the `local-mongo` lane — because **this repository ships no probe**. The probe
is built from the live data-plane client by the composition root that owns the connection, which is a
fork's decision, and the module's opt-in contract explicitly allows "switched on, no platform profile
configured yet" as a state that must start.
So the requirement is scoped to a platform that is actually configured — `profiles` non-empty — which
is the same line the settings record already draws. Fail-closed where the platform is in use,
unchanged where the module is merely enabled. Deliberately **not** conditioned on the probe: a
requirement that only applies when the thing it requires is present is not a requirement.
`local-mongo` passes unchanged.
### Recorded, not fixed
`verifyMongoApiSurface` was **already red at HEAD**, from a rename sweep this task did not make:
`MongoPersistenceProperties``MongoPersistenceSettings`, `MongoAdvancedProperties`
`MongoAdvancedSettings`, `MongoPlatformProperties``MongoPlatformSettings`, plus Wave 1's
`MongoRootAutoConfiguration`. B3 added no public type. The approved update absorbed the four, noted
here for the same reason the GraphQL one was: a snapshot that has been red for a while stops being a
gate.
---
## B4 — a security check that could never fire (MNG-INT-004)
`MongoCredentialReference.fingerprint()` hashed `role.name() + '|' + secretReference`. The spec calls
this "the same secret reference used under two roles looks like two different credentials, defeating
the separation it was meant to enforce". Reading the callers makes it concrete and worse.
`MongoSecurityProfileValidator.requireDistinctCredentials(runtime, admin)` exists to refuse a
deployment where one credential opens both the runtime and the admin plane. It is **always** called
with two different roles — that is what runtime and admin mean. With the role in the hash,
`sameCredentialAs` was therefore always false. **The check could not reject anything.** A deployment
pointing both planes at one secret passed a validator written for precisely that case, and the
message it would have printed — "separating the planes means nothing if one credential opens both" —
was unreachable.
`MongoCredentialRotationPolicy` is the counter-evidence that the role was never meant to be part of
the identity: it compares roles on its own line, immediately after asking whether the credential is
the same, because those are two questions. Folding one into the other left the security check
answering neither.
The fingerprint is the secret reference alone now. Rotation is unaffected — it kept its own role
comparison throughout — and the fingerprint still reveals nothing: 16 hex characters of SHA-256 over
a reference that is itself not a secret.
---
## B1 — the Mongo namespace, where only the documentation had drifted
`spring.data.mongodb.*` is deprecated at error level in Spring Boot 4's metadata; the canonical
namespace is `spring.mongodb.*`. The runtime was never on the wrong one — every Compose lane supplies
`SPRING_MONGODB_URI` and `local-mongo` passes against a real single-node replica set — but
`MongoPersistenceSettings`' own Javadoc pointed operators at the deprecated key.
That is the worst place for the drift to sit. Somebody reads the class that owns the switch, sets the
property it names, and inherits a deprecation they did not choose. Two Javadocs, corrected.
`MongoNamespaceContractTest` strips Java comments before checking, so a sentence recording that the
old namespace is deprecated survives while a reference a compiler would act on does not — the same
distinction D1's test had to learn. Resources are checked whole: a key in a YAML file is never
commentary.
## A1 — two defects that cancelled each other out (JPA-INT-002)
`JpaDataSourceSettings` bound `app.jpa-platform.datasource.*` while the pool that serves requests is
built from `spring.datasource.hikari.*` — one pool with two descriptions, and a validator can pass
against the description that is not in use. That is the spec's finding.
Reading it at HEAD makes it worse and simpler at once. **The parallel namespace appears in no shipped
YAML and no row of the env-key registry**, so both of its fields were always null — and
`requirePoolBounds` throws on a null. Had anything called it, every deployment would have failed to
start.
Nothing called it. `validateStable` and `requirePoolBounds` were reachable only from their own unit
test. So: a validator nobody calls is a comment, and a validator nobody calls that would fail
everything if called is a comment holding a trap. **The reason the application started was the second
defect hiding the first.**
### What it validates now, and what it deliberately does not
The namespace is deleted and the validator reads the resolved `DataSource` — product and version from
a connection it opens, which also turns an unreachable database into a startup failure instead of a
failure at whoever sends the first request. It is invoked from `PersistenceJpaRootAutoConfiguration`
as an `InitializingBean`, so it runs exactly when JPA is on and never when it is off.
**Pool bounds are deliberately not re-checked there.** `HikariPoolConstraintValidator` already reads
`spring.datasource.hikari.*` — the namespace that actually builds the pool — and owns the acquisition
floor and the inter-knob constraints. Adding a second opinion on the same properties is how the
parallel namespace started. It also keeps HikariCP off app-bootstrap's production classpath, where
the build file deliberately declares it `testImplementation` only.
The product check follows the vendor selector rather than applying always: local development runs H2
by design, and `PersistenceVendorProdSafetyValidator` is what keeps that out of production. Demanding
PostgreSQL unconditionally would refuse every laptop.
### A third defect, found by wiring the second
Injecting the validator bean failed the lane: `JpaPlatformRuntimeAutoConfiguration` carries
`@ConditionalOnBean(DataSource.class)` on a plain `@Configuration` imported by the root — a condition
evaluated during configuration-class parsing, **before the datasource bean definition is
registered**. That class therefore drops out silently in the real application, taking the whole JPA
add-on layer with it: the retry coordinator, the safety guard, the platform report, and the validator
itself.
It was invisible because nothing depended on any of it. The check constructs its own validator —
depending on a bean from that class would make the check disappear for the same reason the thing it
checks disappeared — and the condition-ordering defect was then closed on its own footing, below.
### And why the existing test could not have caught it
`JpaPlatformRuntimeAutoConfigurationTest` registers the class through `AutoConfigurations.of(...)`,
where `@ConditionalOnBean` is evaluated **after** the datasource definition exists and therefore
answers yes. `PersistenceJpaRootAutoConfiguration` imports it as a plain `@Configuration`, where the
same annotation is evaluated during parsing and answers no.
So the test proved the class works in a registration shape the application does not use, and it had a
case — "without a DataSource, nothing is built" — whose green depended on exactly the annotation that
was deleting the layer in production. A test can be green, precise, and about a different program.
The class-level condition is removed rather than reordered: the class is reached only through the JPA
root, which already carries the master switch, so "is there a datasource" has been answered yes by
the time it is parsed. The method-level `@ConditionalOnBean`s stay — those are evaluated at
bean-definition time and are the pre-existing design. The no-datasource case now asserts what should
happen: **the context fails**, rather than quietly delivering less. `JpaPlatformAddonAssemblyTest`
holds the class-level annotation absent and names the eight beans, so a future re-addition is a
decision instead of a silent regression.
Eight beans — the Hibernate provider policy, the runtime role verifier, the platform composition, the
dangerous-configuration guard, the datasource validator, the platform report supplier, the retry
event listener and the platform startup check — assembled for the first time in this change.
A unit test could not have found any of this: nothing constructs the real root. The `local-jpa` lane
found each failure in turn.
```
$ ./gradlew test + the four architecture gates BUILD SUCCESSFUL
$ ./scripts/run-compose-runtime-smoke.sh --lane local-jpa passed
$ ./scripts/run-compose-runtime-smoke.sh --lane prod-smoke passed
$ ./scripts/run-compose-runtime-smoke.sh --lane off-local passed
```
---
## A2 — "it works locally" was about a different database (JPA-INT-003)
`local` defaulted to H2 with `create-drop` and Flyway off. So the sentence every developer says was a
statement about a datastore no other environment runs: migrations never applied, Hibernate wrote the
schema from the entities, and **a mapping that disagrees with the migration tree cannot be discovered
there at all** — a vendor that generates the schema from the entities has nothing to disagree with.
That is not theoretical. Two such disagreements shipped, and both were found by a Compose lane
minutes at a time rather than by a developer seconds at a time:
| Defect | Why H2 could not see it |
| --- | --- |
| `fs_cleanup_item` missing | its table exists only in a capability migration stream H2 never applies |
| `request_hash` `char(64)` vs `varchar(64)` | `create-drop` builds the column from the mapping, so the two agree by construction |
### The decision
`local` now resolves the Compose PostgreSQL this repository already ships — same vendor, same schema
owner, same `ddl-auto=validate` as `dev` and `prod`, differing only in address and credential.
`LocalJpaVendorParityTest` asserts that equality key by key and would fail the moment they drift.
**H2 is not removed.** It is the right tool for a laptop with no container, and deleting it would
cost a developer their inner loop to fix a problem they did not cause. It becomes
`./gradlew :app-bootstrap:bootRunH2` — a named task rather than a property, because choosing a
datastore whose schema comes from somewhere else should be visible in the command somebody typed. The
task carries every value that makes H2 safe together: its own vendor, `create-drop`, Flyway off
(the migration tree is PostgreSQL DDL, `DO $$` blocks and all), and `DB_CLOSE_DELAY=-1`, without
which the in-memory schema vanishes the first time the pool goes idle. It is in no release lane.
**The cost is stated rather than hidden:** a developer running `local` with JPA on now needs the
Compose PostgreSQL running, where before they needed nothing. That is the trade the wave asked for —
local and dev sharing vendor semantics rather than only the word "local".
### Two tests asserted the old contract
`ProfileSeparationContractTest` had cases named `localDefaultsToAnInMemoryDatabaseWithNoMigrations`
and `localKeepsTheInMemoryDatabaseAliveAcrossPoolIdleness`, both green, both pinning exactly what
this change removes. They were rewritten rather than deleted: the first now asserts the shipped
vendor, and the second follows `DB_CLOSE_DELAY=-1` to `bootRunH2`, where the database it protects now
lives. A contract test that pins a defect is still a contract test; what it needs is to move with the
decision, not to be silently dropped.
---
## D3 — INGEST_ONLY was delivering (NTF-INT-003)
`NotificationPlatformWorkerConfig` carried the master switch and nothing else, and both of its worker
beans call `start()` inside the factory method. So a deployment in `INGEST_ONLY` — the mode whose
entire purpose is to accept and store *without* delivering — started the dispatch scheduler, the
lease recovery pass, the provider-event replay worker and the reconciliation job. It accepted
requests and then tried to deliver them, which is the mode not existing.
The gate is a nested configuration the parent loads only in `SERVING`, not a condition repeated on
each bean. A per-bean condition is one a future bean can forget, and what forgetting costs here is
not a stray bean: it is a process delivering notifications that an operator deliberately put into a
drain. A configuration that is not loaded cannot be forgotten.
`matchIfMissing = true`, because `SERVING` is the shipped default. Making it false would turn an
absent property into a silent drain — the same failure in the other direction.
`NotificationWorkerLifecycleTest` asserts the structural fact rather than booting the platform, and
says why in the file: a full notification context needs a database, provider credentials and a
secrets resolver, and the capability has **no JPA persistence wired at all** yet, so a context test
would be a test that cannot run. The live thread count belongs to the
`local-notification-ingest` lane, which is where a running thread can actually be counted — and that
lane stays non-blocking until Section D wires the persistence.
---
## E3 — a CI gate that was red in a lane nobody runs (GQL-INT-004)
Three defects, all verified. Two are closed here; the third turned out to be discharged by Wave 3's
Compose lanes rather than by the Gradle task the spec sketched, and that is recorded rather than
duplicated.
### 1. The composition contract was asserting something false
`ConditionalTransportCompositionContractTest` asserted that all three opt-in transports have
`runtime_memberships: []`. The five-adapter contract put GraphQL on the single `app-bootstrap`
bootJar behind `APP_GRAPHQL_ENABLED`, so its membership is `[app-bootstrap]` by design — **the test
went red the moment Wave 1 landed and nobody saw it**, because this suite runs in
`conditionalTransportQualification` rather than in `test`.
`conditionalTransportQualification` is one of the two commands the CI quality job runs. So a gate CI
depends on had been failing for the whole of this effort, and the only reason it was not noticed is
that nothing runs it locally. A gate that is red in a lane nobody runs reports whatever the last
person to run it saw.
The two groups are asserted for what they now are: gRPC and WebSocket build-only, no membership;
GraphQL shipped and switch-gated, with membership **and** a master switch — a stronger claim carrying
a stronger obligation.
### 2. Class existence was never composition evidence
The other half asserted `Class.forName` resolves. A type resolving proves a jar is on a classpath and
says nothing about whether a composition assembles the transport or whether the switch gates it. The
test now names where that evidence actually lives — the `off-local`, `off-dev` and `off-prod` lanes
proving zero beans, sockets and routes with the switch off, and `local-graphql` proving a real
`/graphql` against a real identity provider with it on — and asserts those lanes are declared, so the
reference cannot rot into a comment.
### 3. CI never ran `graphqlStableTest`
`check` does not depend on it, so the lane's required-class guard — the check that its
module-boundary suite has not silently stopped being discovered — protected nothing in CI. The
quality job now runs `:adapter:inbound:graphql:graphqlStableTest` alongside
`conditionalTransportQualification` in one invocation, so the GraphQL lane cannot execute twice.
### What the spec asked for that was already done differently
The spec specifies a `:app-bootstrap:graphqlRuntimeQualification` task that runs the bootJar as a
child process, obtains a client-credentials token from a Keycloak container importing the tracked
realm artifact, and calls real HTTP `/graphql`. **Wave 3's `local-graphql` lane already does exactly
that**, in containers rather than in a Gradle task: the real image, the same realm artifact, a
service-account token, and `auth-smoke` calling the application. It is blocking, it runs in the
matrix, and it is what found the deployment-mode split-brain, the Boot introspection contradiction
and two Keycloak realm defects.
Building a second qualification of the same thing in a different harness would double the maintenance
and halve the attention each gets. `GraphqlHttpBoundaryQualificationTest` keeps its role as a module
contract test — its Basic Auth is test-only scaffolding for the transport boundary, not a claim about
the shipped composition, and the composition claim is now made by the lane.
```
$ ./gradlew :adapter:inbound:graphql:graphqlStableTest conditionalTransportQualification BUILD SUCCESSFUL
$ ./gradlew test + the four architecture gates BUILD SUCCESSFUL
```
---
## D6 — the recommended branch was not available (NTF-INT-007)
D6 requires choosing between encrypting the stored payload and restricting the variable types to
non-sensitive values, and recommends the restriction branch **"if and only if the variable types can
genuinely be restricted to non-sensitive values"**.
They cannot, and the reason is not a gap to be tightened. `NotificationVariable` is a closed algebra —
a real improvement over the `Map<String, Object>` it replaced — but `TextValue` holds an arbitrary
UTF-8 string up to 8 KiB because **the variables are the message's own content**: a reset code, an
order total, a delivery address, an appointment time. A restriction to "non-sensitive" would be
either unenforceable (a comment about a field designed to carry exactly that) or enforced and
useless (a type refusing free text does not restrict the capability, it deletes it).
So the recommendation's precondition is false and branch (a), encryption, is the required one. The
decision and its full scope are on file in `docs/notification/at-rest-threat-model.md`.
### When it lands, and the fact that decided that
**With the persistence wiring, not before** — because of something the spec did not have:
`NotificationJpaPersistenceFacade`, which assembles `JpaNotificationRequestStore`, is imported by
nothing. The notification capability has no JPA persistence at all, so **no deployment writes this
payload anywhere today**. The defect is real in the code and latent in the runtime.
Designing key rotation and a row migration for rows no deployment produces would be building the
migration before the table, and would settle the envelope's shape before the store that must read it
is wired — the order that produces an envelope the store cannot use.
### What was explicitly not done
Requiring `PAYLOAD_ENCRYPTION` in `INGEST_ONLY`. That secret is consumed by exactly two files, and
the one that uses it protects raw **callback** bodies; nothing on the accept path reads it. Demanding
it would make a deployment supply a key that protects nothing while the payload it appears to be
about stays in plaintext. This repository already has one defect of that exact shape —
`backend.graphql.cursor.key-ids`, which production refuses to start without and which no code signs a
cursor with. Two would make it a habit.
`NotificationPayloadAtRestContractTest` pins all four facts the decision rests on, including that the
accept path contains no encryption — so **that case fails the moment somebody adds it**, which is the
change the threat model is waiting for. Notification stays not-promoted-to-Stable, and its three
lanes stay non-blocking.
---
## D2 — the two pieces that meant SERVING could not work (NTF-INT-001)
`NotificationPlatformProviderConfig` collects `List<ProviderRuntimeAssembler>` and production main
source implemented that interface **nowhere**. A fully configured SMTP profile therefore produced no
runtime, no route and no error: requests reached durable acceptance and then found nothing eligible
to send them, which from outside reads as the platform silently dropping notifications.
Two things were missing, and only one of them was the one the spec named:
- **`SmtpDispatch` had no implementation.** The adapter above it, its MIME factory and its failure
classifier were all complete and unit-tested against fakes, so the SMTP family looked finished from
every angle except that nothing could send. `JavaMailSenderSmtpDispatch` is that send.
- **No assembler.** `SmtpProviderRuntimeAssembler` is the first production one.
The relay's address comes from Spring's own `spring.mail.*` through the injected `JavaMailSender`,
not from a second description on the provider profile — one resource described twice is the defect
this repository already paid for in `app.jpa-platform.datasource.*`. The profile owns what is
per-profile: timeout, concurrency, rate. Capabilities are declared as what SMTP has, which is none of
callback, status query, provider idempotency, batch, scheduling, cancel or collapse: handing a
message to a relay is the end of what a sender can observe, and a capability declared here is a
promise the dispatch loop acts on.
One thing the type system had already settled, found while writing the fixture: `SmtpProviderProperties.TlsMode`
has exactly `STARTTLS_REQUIRED` and `IMPLICIT_TLS`. **Plaintext SMTP is unrepresentable** — the
transport refuses an unencrypted relay by construction rather than by a validator somebody has to
remember to run.
### The signal that fired, and the one that told us to stop
`NotificationLegacyNamespaceRetirementTest` asserted the platform had **zero** assemblers, with the
instruction that "when D2 lands, this assertion fails — and that failure is the signal to retire the
namespace". It failed. Updated to the new truth, with what still blocks the R0 removal stated more
narrowly: the remaining families, and the persistence below.
Then the second half of D2 — registering the assembler, which needs the notification persistence
wired — produced the opposite signal.
## D6 completed — the envelope, then the wiring it was blocking
The wiring below was reverted because the payload was unprotected. Building the envelope was
therefore the way to unblock D2's registration, D5's handoff and the notification lanes, so it was
built next.
```
byte version always 1
byte keyIdLength 1..255 UTF-8 bytes
byte[] keyId
byte[12] nonce
byte[] ciphertext + GCM tag
```
**The key id is the reason there is a format at all.** This repository's callback protection stores
nonce and ciphertext and nothing else, so the day the active key changes, every row written under the
previous one is unreadable and nothing in the row can say which key it needed. That is not a rotation
story with a gap — it is the absence of one. `SecretMaterialProvider` already exposed `keyById`, so
the envelope carrying the id makes rotation a change of default rather than a data migration, and
`aRetiredKeyStillReads` proves it.
The header is passed as **AAD**, not merely prefixed: otherwise the key id is attacker-editable and an
envelope can be redirected at a key of the attacker's choosing. A failed decryption throws rather than
returning empty — a caller handed an empty payload renders every variable as nothing and sends
"Hello , your code is " to a real person, which is the failure delivered instead of reported. Unknown
key, wrong key and modified ciphertext collapse into one message, because distinguishing them tells an
attacker which of the three they achieved.
Applied at the **storage boundary** (`NotificationRecordMapper`), as a **required** constructor
argument. "At rest" means in the row, and the application necessarily holds the plaintext because it
has to render it; what is removed is the plaintext sitting in the column for as long as the request is
retained. Required rather than optional because that was the whole risk: the store existed, wiring it
was one import away, and nothing about its shape said the row it wrote held caller content
unprotected.
`V10__variables_payload_envelope_guard.sql` is a **guard, not a backfill**. No deployment of this
repository can have written such a row, so re-encrypting them would be migrating rows that cannot
exist — and a migration has no business holding key material. A fork that wired the store itself meets
its plaintext rows at migration time rather than one failed request at a time, where it looks like a
decryption bug instead of an un-migrated table. Accepting both shapes was rejected: a protection that
can be bypassed by writing plaintext is a control that announces itself and then declines to hold.
## Two defects the notification lane found once persistence was real
Wiring the facade made the notification entity scan active for the first time, and the
`local-notification-ingest` lane immediately found two things nothing else could.
**1. A Flyway customizer outranked the operator.** `PostgreSqlPersistenceConfig` called
`configuration.locations(...)` unconditionally, which *replaces* whatever Spring bound from
`spring.flyway.locations`. An operator could set `SPRING_FLYWAY_LOCATIONS`, watch Flyway report a
successful migration, and get only the vendor stream. The lane set seven locations and applied one.
Same shape as `application-local.yml`'s literal pins, same fix: contribute the value when nobody has
chosen one, stay out of the way when somebody has.
**2. The lanes' own Flyway setting could never have worked.** With the override gone and the
operator's value finally honoured, Flyway refused: *"Found more than one migration with version 1"*
`db/migration/jpa/{core,fileserver,idempotency,inbox,notification-platform,outbox-polling,outbox-storage}`
each declare a V1, because **each is its own stream with its own history table by design**. Seven
lanes had been carrying a setting that was inert, and passing while their own configuration was
discarded.
The setting is removed from all nine lanes with the reason recorded in the contract file. Applying a
capability stream needs a Flyway execution *per stream* — the operator sequence both
`PostgreSqlNotificationPersistenceUnitIntegrationTest` and `FileserverRoundTripContractTest` already
perform in their fixtures — and that is what the notification lanes still need before they can block.
---
## The wiring that was reverted, and why that is the right outcome
Importing `NotificationJpaPersistenceFacade` from the JPA root makes `JpaNotificationRequestStore`
reachable. That store writes the accepted request's template variables to
`notification_request.variables_payload` **in plaintext** — caller content that can be a reset code,
an order total, an address (NTF-INT-007).
`NotificationPayloadAtRestContractTest` failed on exactly that: its case asserting the write path is
reachable from no composition is what turns the latent defect into a live one the moment somebody
wires it. It did its job on the first run.
The wave's rule is unambiguous — *"a P0 correctness or security finding on a runtime path is a
prerequisite, not a follow-up; connecting wiring over a known data-loss path is forbidden."* So the
wiring was reverted, not the test relaxed, and the reason is recorded at the import site rather than
in a commit message nobody reads at the point of decision.
This also **corrects the threat model's own sequencing**. It said the envelope "lands with the
wiring". Landing them together in one change means the plaintext write is reachable for the duration
of that change's review; the honest ordering is that the envelope lands **before or with** the
wiring, never after, and the contract test now enforces that by failing on the wiring alone.
**What did land** is the half that is safe on its own: the entity scan now travels with the stores it
serves, so when the facade is finally imported it cannot arrive without the mappings they need —
closing the "entity metadata nowhere" defect independently of the payload question.
D2 is therefore complete as far as it can be without D6's envelope: the platform can assemble a
production SMTP provider, and registering it is one import away, blocked by a security prerequisite
rather than by missing work.
---
## D4 — the retirement order is forced by which path works (NTF-INT-004)
Two namespaces own notification. The legacy R0 selectors live under `app.notification.*`
`slack-webhook.enabled`, `google-email.enabled`, `routes.<channel>.<route>` — and the delivery
platform lives under `ca-skeleton.notification.platform.*`. Both hang off the same master switch, so
a deployment turning notification on configures one capability through two vocabularies, neither of
which validates the other.
The plan's instruction is to isolate R0 behind a migration shim and then remove it. Counting what
each path can actually do settles the sequence, and it is not a preference:
| Path | Can deliver? |
| --- | --- |
| R0 (`app.notification.*`) | **yes** — real `NotificationProvider` implementations (Google Email, Slack webhook) |
| Platform (`ca-skeleton.notification.platform.*`) | **no** — zero `ProviderRuntimeAssembler` implementations (NTF-INT-001), and no JPA persistence wired |
**R0 is currently the only path in this repository that can send a notification.** Retiring it first
would not be a retirement; it would delete the only working capability and leave the replacement
unable to take over. The wave forbids exactly this shape in the other direction — connecting wiring
over a known-broken path — and it is no better performed backwards.
So D4 pins the state instead of asserting it away: the two namespaces, which one is canonical, and
the asymmetry that fixes the order. `NotificationLegacyNamespaceRetirementTest` asserts that the
platform has **zero** assemblers, so **when D2 lands, that assertion fails** — and the failure is the
signal that the shim can be raised and the namespace removed, not a regression. The negative half of
NTF-INT-004, that no legacy selector is documented as an activation switch, is already enforced in
`MasterSwitchRegistryContractTest`; this test asserts it is still in force rather than duplicating
it, so removing it there is visible from the task that depends on it.
---
## E2 — a key that protects nothing (GQL-INT-003)
The spec's premise for E2 is that "auto-configuration and a startup validator existing is not
evidence that cost, authz, cursor, and idempotency policies apply". Checking each one separately
splits them in two.
**Cost and authorization do apply.** They are two of the three handlers in `GraphQlExecutionChain`,
reached through the platform's `Instrumentation`, and `GraphQlPlatformExecutionPathTest` already
proves on a real random-port request that a depth violation, an alias bomb, a complexity overrun and
an unauthorized coordinate are each rejected *before any resolver runs*. E2 does not repeat that.
**Cursor signing does not.** `backend.graphql.cursor.key-ids` is read in exactly two places:
| Reader | What it does with it |
| --- | --- |
| `GraphQlPlatformStartupValidator` | refuses to start production without it |
| `GraphQlPlatformActuatorEndpoint` | reports it back as configured |
Nothing signs a cursor with it. `HmacGraphQlCursorCodec` and `GraphQlCursorKeyRing` exist and are
unit-tested; the platform never constructs either. So production demands a key identity, an operator
supplies one, the operations endpoint confirms it is configured — and cursors stay exactly as
client-editable as they were, which is the thing the validator's own message says the key prevents.
Every signal an operator can see says this is on. That is what makes it worse than an unfinished
capability: `persisted operation` is also `modelled`, and nothing pretends otherwise.
**Mutation idempotency does not either**`GraphQlMutationIdempotencyInterceptor` is referenced by
no configuration, so a repeated mutation is a repeated mutation.
### The decision: record, because closing it is a design question
`GraphQlCursorKeyRing.of` takes `Map<String, byte[]>`, and the settings contract says deliberately
that "the keys themselves never appear in configuration". So **where the key material comes from has
to be decided before anything can be wired** — a secrets question, not a wiring one, and the same
shape as NTF-INT-007's payload-encryption branch. Wiring a codec against key bytes pulled from
somewhere unconsidered would be the half-built envelope that section explicitly refuses.
`GraphQlPolicyRequestPathTest` pins all of it: what is wired, that a configured cursor key builds no
codec, that the idempotency interceptor is absent, and that the validator's demand **stays** — the
demand is right and the implementation is the missing half. The two absence cases invert when it
lands; the validator case does not change. The leaf's own capability-grade table gains both rows at
`modelled`, which is the table's stated purpose: never describe a capability above its grade.
```
$ ./gradlew :adapter:inbound:graphql:test --tests '*GraphQlPolicyRequestPathTest*' 4 cases, 0 failures
$ ./gradlew test + the four architecture gates BUILD SUCCESSFUL
```
---
## B5 — already closed, re-verified rather than assumed
The plan's Task B5 offers implement-or-demote for three release-manifest entries naming tasks no
build file registers. Re-reproducing it at current HEAD found the demotion already applied:
`MONGO-REL-010/011/012` sit in `experimental_contracts[]` with a `not_promoted_reason` each,
`ReleaseManifestTaskExistenceTest` reads only the blocking `contracts[]`, and
`verify-mongodb-advanced.sh` fails with a "not promoted" message instead of invoking a task that does
not exist. Recorded because the wave requires the decision on file, and because "already done" is
worth stating with the evidence rather than leaving a plan step ambiguous.