Files
tech-log-backend/docs/superpowers/plans/2026-08-10-httpclient-platform-activation-and-env-ssot.md
T

59 KiB
Raw Blame History

HTTP Client Platform — Activation Boundary and ENV SSOT Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the HTTP Client platform an genuinely optional capability that is off unless APP_HTTPCLIENT_ENABLED=true, binds its settings strictly and only when on, and has a single declared environment surface that a test proves is complete.

Architecture: The repository already solved this problem once, for the HTTP Fileserver platform. CaSkeletonApplication excludes dev.caskeleton.bootstrap.autoconfigure.* from its component scan, so a configuration in that package is reachable only through an entry listed in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. Putting the master switch on that single entry makes "off" a structural fact: the class is never processed, so nothing it imports is discovered, no settings are bound and no runtime resource is created. HTTP Client is migrated onto that same shape — one gated @AutoConfiguration, importing the existing nine configurations, which move out of the scanned package with it.

The settings become one strictly-bound record tree rooted at app.httpclient, with clients and dynamic targets as indexed lists carrying their own name rather than maps keyed by name, so the environment form is unambiguous and duplicate/colliding names are a startup failure rather than a silent overwrite.

Tech Stack: Java 21, Spring Boot 4.0.0, Gradle multi-module, JUnit 5 + AssertJ, ApplicationContextRunner, Binder + NoUnboundElementsBindHandler.

Deviations found during execution

Two things in this plan turned out to be wrong when the tests were run. Both are recorded here rather than edited away, because the reason each was wrong is the useful part.

1. NoUnboundElementsBindHandler cannot police environment variables. Spring binds APP_HTTPCLIENT_CLIENTS_0_BASE_URL to app.httpclient.clients[0].base-url by mapping the requested property name back to an environment name, but it enumerates the same variable as app.httpclient.clients[0].base.url — underscores become dots, never hyphens. The strict handler compares against that enumeration, so pointing it at the system environment reports every correctly spelled hyphenated key as unbound while still saying nothing about a genuinely misspelled one. (The Fileserver platform does not hit this only because its prefix, app.fileserver-platform, makes the dotted forms fall outside the prefix entirely — an accident, not a design.)

The fix is HttpClientEnvironmentKeys: derive the accepted variable names from the record tree and reject an APP_HTTPCLIENT_ variable that is not among them. Strict binding still covers every other property source. This is stronger than the plan's original intent, not weaker — the handler could never have caught a misspelled environment variable at all.

2. The field manifest does not live in docs/registries/. That directory is a fail-closed catalog of exactly eight contract registries, enforced by ContractRegistrySchemaGovernanceTest, with a fixed per-row schema (owner_branch, compatibility_impact, required_test). A field-to-variable mapping has none of that shape, and adding a ninth file would have meant loosening a gate rather than satisfying one. The manifest is docs/httpclient/env-fields.yaml; every path below that says otherwise is superseded.

Global Constraints

  • Commit policy is human-only. Agents do not stage, commit, amend or push. Every "Commit" step in this plan is a stop point where the human commits; the agent reports the intended message.
  • Owning leaf for all Java changes: app-bootstrap, Gradle path :app-bootstrap (src/config/architecture/modules.json is the SSOT). Focused test: cd src && ./gradlew :app-bootstrap:test --console=plain.
  • No new project dependency edges. This plan moves and gates existing wiring; it must not make app-bootstrap depend on anything it does not already depend on.
  • Package root for new bootstrap code: dev.caskeleton.bootstrap.autoconfigure.httpclient.
  • Configuration prefix: app.httpclient. Canonical environment form: APP_HTTPCLIENT_*.
  • One public top-level type per file, file name equal to the type name (verifyOneTypePerFile, code-conventions I6).
  • verifyEnvKeys enforces a three-way lock-step between src/.env, src/app-bootstrap/src/main/resources/application.yml and docs/registries/env-keys.yaml: every required (${VAR} with no default) placeholder must exist in .env; every .env key must be referenced by some application.yml placeholder; every APP_ key in .env must be registered in env-keys.yaml. Only APP_HTTPCLIENT_ENABLED goes through that gate — see Task 3 for why the per-client surface is registered in a separate field manifest instead.
  • The activation contract, verbatim from the review:
    • toggle missing or false → zero HTTP properties/binder/provider/registry/gateway/endpoint/ thread/resource beans;
    • toggle present but not a strict boolean (blank, yes, 1) → not silently enabled;
    • true → strict bind, then full validation, then runtime resources;
    • true with no clients → startup failure carrying the code HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS;
    • the actuator endpoint is registered only under the master flag.

Out of scope for this plan

This plan is review step 1 and step 2 only. It does not address the call-execution kernel (P0 #3, #5, #6), dynamic-target DNS pinning (P0 #4), the Reactor Stable contract row (P0 #7), the HTTP/3 provider (P0 #8), or any P1/P2 item. Those are separate plans and each needs its own working software. What this plan must not do is make any of them harder: the settings tree it introduces is the input a later ValidatedClientPlan compiler will consume.

File Structure

Moved (git mv, package statement and imports updated, contents otherwise unchanged unless a task says so) — from src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/httpclient/ to src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/:

File Responsibility after the move
HttpClientTransportAutoConfiguration.java Stable transport providers. Imported, never scanned.
HttpClientSecurityAutoConfiguration.java TLS material and policy validator. Imported.
HttpClientAuthenticationAutoConfiguration.java Credential providers. Imported.
HttpClientObservationAutoConfiguration.java Tag policy and execution support. Imported.
HttpClientResilienceAutoConfiguration.java Resilience registry. Imported. Loses its Clock bean.
HttpClientProfileAutoConfiguration.java Profile factory, startup validator, runtime registry. Imported.
HttpServiceClientAutoConfiguration.java Caller-facing gateways and typed registries. Imported.
DynamicTargetAutoConfiguration.java Dynamic target policies, resolvers, gateway. Imported.
HttpClientManagementAutoConfiguration.java Actuator endpoint. Imported.
HttpClientActuatorEndpoint.java The endpoint itself.
HttpClientProfileFactory.java Settings → ClientProfile. Signature changes in Task 2.
HttpClientStartupValidator.java Unchanged.

Created:

File Responsibility
.../autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java The one entry point. Master switch, @Import of the nine configurations, settings bean.
.../autoconfigure/httpclient/HttpClientPlatformSettings.java The whole app.httpclient tree as one record, indexed clients and dynamic targets, aggregate validation in compact constructors.
.../autoconfigure/httpclient/HttpClientPlatformSettingsBinder.java Strict bind of the above, inside the gate.
docs/registries/httpclient-env-fields.yaml Field-path ↔ ENV-template manifest for the per-client surface.

Deleted (their content is absorbed by HttpClientPlatformSettings):

  • .../bootstrap/httpclient/HttpClientsProperties.java
  • .../bootstrap/httpclient/HttpClientsPropertiesBinder.java
  • .../bootstrap/httpclient/DynamicTargetProperties.java

Modified:

File Change
src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports Add the new entry.
src/app-bootstrap/src/main/resources/application.yml Add the app.httpclient.enabled placeholder only.
src/.env Add APP_HTTPCLIENT_ENABLED=false.
docs/registries/env-keys.yaml Register APP_HTTPCLIENT_ENABLED.
src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java Stop asserting an empty registry exists while disabled; assert the beans are absent.
docs/httpclient/configuration-reference.md New prefix, master switch, indexed form.
scripts/verify-httpclient-docs.py Read the new settings type.

Test files created:

  • .../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformActivationTest.java
  • .../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsTest.java
  • .../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformEnvManifestTest.java

Test files moved: HttpClientAutoConfigurationTest.java and UnsafeStartupConfigurationTest.java into the new test package, rewritten to run through the single auto-configuration.


Task 1: Master activation boundary

Files:

  • Create: src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java
  • Move: the twelve files listed above into .../bootstrap/autoconfigure/httpclient/
  • Modify: src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
  • Modify: .../autoconfigure/httpclient/HttpClientResilienceAutoConfiguration.java (remove the Clock bean)
  • Test: src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformActivationTest.java

Interfaces:

  • Consumes: nothing from earlier tasks.
  • Produces: HttpClientPlatformAutoConfiguration (public class, no-arg constructor). Task 2 adds a HttpClientPlatformSettings httpClientPlatformSettings(Environment) bean method to it. Task 4 relies on HttpClientPlatformSettings.PREFIX being "app.httpclient".

Why the Clock bean must go. HttpClientResilienceAutoConfiguration currently declares @Bean @ConditionalOnMissingBean(Clock.class) Clock httpClientClock(). Once the whole capability is gated, that bean would vanish whenever HTTP Client is off — and Redis, idempotency and the Fileserver all inject Clock. The application context is unaffected because dev.caskeleton.bootstrap.idempotency.IdempotencyConfig#systemClock declares one unconditionally in a scanned package, so the httpclient copy is redundant in the application and dangerous in the gate. Isolated ApplicationContextRunner tests must supply their own, exactly as FileserverPlatformAutoConfigurationTest supplies a MeterRegistry.

  • Step 1: Write the failing activation test

Create src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformActivationTest.java:

package dev.caskeleton.bootstrap.autoconfigure.httpclient;

import static org.assertj.core.api.Assertions.assertThat;

import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialProviderRegistry;
import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicCredentialBinding;
import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicTargetGateway;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRegistry;
import dev.caskeleton.adapter.outbound.httpclient.restclient.GenericHttpGateway;
import dev.caskeleton.adapter.outbound.httpclient.security.TlsMaterialProvider;
import dev.caskeleton.adapter.outbound.httpclient.service.HttpServiceRegistry;
import java.time.Clock;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * The HTTP Client platform exists only where a deployment asked for it.
 *
 * <p>Every case here was reachable before this boundary existed: a service that never made an
 * outbound call still built transport providers, credential providers, a resilience registry and
 * five caller-facing gateways, and still read — and could still be failed by — HTTP configuration
 * it had never written.
 */
class HttpClientPlatformActivationTest {

  private final ApplicationContextRunner runner =
      new ApplicationContextRunner()
          .withConfiguration(
              AutoConfigurations.of(HttpClientPlatformAutoConfiguration.class))
          .withUserConfiguration(SupportingBeans.class);

  @Test
  @DisplayName("absent toggle holds no HTTP bean at all")
  void theCapabilityIsAbsentUntilItIsExplicitlyEnabled() {
    runner.run(
        context -> {
          assertThat(context).hasNotFailed();
          assertThat(context).doesNotHaveBean(HttpClientPlatformSettings.class);
          assertThat(context).doesNotHaveBean(ClientRuntimeRegistry.class);
          assertThat(context).doesNotHaveBean(GenericHttpGateway.class);
          assertThat(context).doesNotHaveBean(HttpServiceRegistry.class);
          assertThat(context).doesNotHaveBean(DynamicTargetGateway.class);
          assertThat(context).doesNotHaveBean(ResilienceRegistry.class);
          assertThat(context).doesNotHaveBean(CredentialProviderRegistry.class);
          assertThat(context).doesNotHaveBean(TlsMaterialProvider.class);
          assertThat(context).doesNotHaveBean(HttpClientActuatorEndpoint.class);
        });
  }

  @Test
  @DisplayName("false toggle with a full valid profile still holds nothing")
  void aValidProfileDoesNothingWhileTheSwitchIsOff() {
    runner
        .withPropertyValues("app.httpclient.enabled=false")
        .withPropertyValues(validPaymentClient())
        .run(
            context -> {
              assertThat(context).hasNotFailed();
              assertThat(context).doesNotHaveBean(ClientRuntimeRegistry.class);
              assertThat(context).doesNotHaveBean(HttpClientPlatformSettings.class);
            });
  }

  @Test
  @DisplayName("malformed detail settings cannot fail a deployment that never enabled the platform")
  void detailSettingsAreNotBoundWhileTheCapabilityIsOff() {
    runner
        .withPropertyValues(
            "app.httpclient.clients[0].name=payment",
            "app.httpclient.clients[0].timeout.total-call=not-a-duration",
            "app.httpclient.clients[0].transport=NOT_A_TRANSPORT",
            "app.httpclient.clients[0].request.max-body-bytes=not-a-number")
        .run(
            context -> {
              assertThat(context).hasNotFailed();
              assertThat(context).doesNotHaveBean(HttpClientPlatformSettings.class);
            });
  }

  @Test
  @DisplayName("a non-boolean toggle does not silently enable the platform")
  void aToggleThatIsNotABooleanDoesNotEnableTheCapability() {
    for (String unusable : List.of("yes", "1", "TRUE ", "")) {
      runner
          .withPropertyValues("app.httpclient.enabled=" + unusable)
          .withPropertyValues(validPaymentClient())
          .run(
              context -> {
                assertThat(context).hasNotFailed();
                assertThat(context).doesNotHaveBean(ClientRuntimeRegistry.class);
              });
    }
  }

  @Test
  @DisplayName("enabling it assembles exactly the declared runtimes")
  void enablingItAssemblesTheDeclaredRuntimes() {
    runner
        .withPropertyValues("app.httpclient.enabled=true")
        .withPropertyValues(validPaymentClient())
        .run(
            context -> {
              assertThat(context).hasNotFailed();
              assertThat(context).hasSingleBean(ClientRuntimeRegistry.class);
              assertThat(context).hasSingleBean(GenericHttpGateway.class);
              assertThat(context).hasSingleBean(HttpServiceRegistry.class);
              assertThat(context.getBean(ClientRuntimeRegistry.class).names())
                  .singleElement()
                  .satisfies(name -> assertThat(name.value()).isEqualTo("payment"));
            });
  }

  private static String[] validPaymentClient() {
    return new String[] {
      "app.httpclient.clients[0].name=payment",
      "app.httpclient.clients[0].base-url=https://payment.test",
      "app.httpclient.clients[0].allowed-hosts[0]=payment.test",
      "app.httpclient.clients[0].allowed-ports[0]=443",
      "app.httpclient.clients[0].request.max-body-bytes=1048576",
      "app.httpclient.clients[0].tls.profile-id=payment"
    };
  }

  /**
   * The collaborators the composition root normally supplies.
   *
   * <p>The {@code Clock} is the interesting one: it used to come from the HTTP Client's own
   * resilience configuration, which meant every deployment inherited a clock from a capability it
   * might not use. It now comes from the composition root, and this fixture stands in for it.
   */
  @Configuration(proxyBeanMethods = false)
  static class SupportingBeans {

    @Bean
    Clock clock() {
      return Clock.systemUTC();
    }

    @Bean
    List<DynamicCredentialBinding> dynamicCredentialBindings() {
      return List.of();
    }
  }
}
  • Step 2: Run it and confirm it fails to compile
cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformActivationTest' --console=plain

Expected: compilation failure — HttpClientPlatformAutoConfiguration and HttpClientPlatformSettings do not exist. That is the correct red state; Task 1 makes the activation cases pass and Task 2 makes the settings type real. Until Task 2 lands, temporarily reference dev.caskeleton.bootstrap.autoconfigure.httpclient.HttpClientsProperties in place of HttpClientPlatformSettings and use the map-shaped http-clients.payment.* property names, then switch both back in Task 2 Step 6.

  • Step 3: Move the package
cd src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap
mkdir -p autoconfigure/httpclient
git mv httpclient/*.java autoconfigure/httpclient/

Then in every moved file change the package declaration to package dev.caskeleton.bootstrap.autoconfigure.httpclient; and fix any now-unresolved import. Do the same for the two existing tests:

cd src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap
mkdir -p autoconfigure/httpclient
git mv httpclient/HttpClientAutoConfigurationTest.java autoconfigure/httpclient/
git mv httpclient/UnsafeStartupConfigurationTest.java autoconfigure/httpclient/
  • Step 4: Delete the Clock bean

In .../autoconfigure/httpclient/HttpClientResilienceAutoConfiguration.java, remove the httpClientClock() method and the now-unused ConditionalOnMissingBean import if nothing else uses it. Leave ResilienceRegistry httpClientResilienceRegistry(Clock clock) taking Clock as a parameter — it is now supplied by the composition root.

  • Step 5: Write the master auto-configuration

Create .../autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java:

package dev.caskeleton.bootstrap.autoconfigure.httpclient;

import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Import;

/**
 * The one entry point through which the HTTP Client platform exists at all.
 *
 * <p>This is an auto-configuration rather than a component-scanned {@code @Configuration}, and it
 * lives in a package the composition root's scan explicitly excludes. When the master switch is
 * absent or false the class is never processed, so none of the configurations it imports are
 * discovered either — no properties are bound, no transport provider is constructed, no connection
 * pool, TLS context, credential or gateway exists, and a malformed HTTP setting in a deployment
 * that never wanted outbound HTTP cannot fail its startup.
 *
 * <p>The previous shape had nine independently annotated {@code @Configuration} classes inside the
 * scanned package. Gating a wrapper around them would have changed nothing: the component scanner
 * finds each child on its own. The children therefore had to move out of the scan with the switch,
 * which is why this is a package move and not an annotation.
 */
@AutoConfiguration
@ConditionalOnProperty(
    prefix = HttpClientPlatformSettings.PREFIX,
    name = "enabled",
    havingValue = "true")
@Import({
  HttpClientResilienceAutoConfiguration.class,
  HttpClientSecurityAutoConfiguration.class,
  HttpClientAuthenticationAutoConfiguration.class,
  HttpClientObservationAutoConfiguration.class,
  HttpClientTransportAutoConfiguration.class,
  HttpClientProfileAutoConfiguration.class,
  HttpServiceClientAutoConfiguration.class,
  DynamicTargetAutoConfiguration.class,
  HttpClientManagementAutoConfiguration.class
})
public class HttpClientPlatformAutoConfiguration {}
  • Step 6: Register the entry

Append to src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:

dev.caskeleton.bootstrap.autoconfigure.httpclient.HttpClientPlatformAutoConfiguration
  • Step 7: Run the activation test
cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformActivationTest' --console=plain

Expected: PASS for the four OFF cases. enablingItAssemblesTheDeclaredRuntimes is expected to fail until Task 2 introduces the indexed property names; keep it @Disabled("Task 2") if it blocks the loop, and remove the annotation in Task 2 Step 6.

  • Step 8: Prove nothing else regressed
cd src && ./gradlew :app-bootstrap:test --console=plain

Expected: OptionalAdapterBeanGatingTest fails — it asserts context.getBean(ClientRuntimeRegistry) returns an empty registry while "disabled". That assertion encodes the wrong contract and is fixed in Task 4. Every other failure is a real regression and must be fixed here.

  • Step 9: Commit (human)

Intended message:

refactor(bootstrap): move the HTTP Client platform behind a single gated auto-configuration

Task 2: Strict indexed settings and aggregate validation

Files:

  • Create: .../autoconfigure/httpclient/HttpClientPlatformSettings.java
  • Create: .../autoconfigure/httpclient/HttpClientPlatformSettingsBinder.java
  • Delete: .../autoconfigure/httpclient/HttpClientsProperties.java, .../autoconfigure/httpclient/HttpClientsPropertiesBinder.java, .../autoconfigure/httpclient/DynamicTargetProperties.java
  • Modify: .../autoconfigure/httpclient/HttpClientProfileFactory.java, .../autoconfigure/httpclient/HttpClientProfileAutoConfiguration.java, .../autoconfigure/httpclient/DynamicTargetAutoConfiguration.java, .../autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java
  • Test: .../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsTest.java

Interfaces:

  • Consumes: HttpClientPlatformAutoConfiguration from Task 1.
  • Produces:
    • HttpClientPlatformSettings — record with boolean enabled, List<ClientSettings> clients, List<DynamicTargetSettings> dynamicTargets; constant String PREFIX = "app.httpclient".
    • HttpClientPlatformSettings.ClientSettingsString name plus every field previously on HttpClientsProperties.ClientProperties, same names and defaults.
    • HttpClientPlatformSettings.DynamicTargetSettingsString name plus every field previously on DynamicTargetProperties.PolicyProperties.
    • HttpClientPlatformSettingsBinder.bind(Environment)HttpClientPlatformSettings, package-private static.
    • HttpClientProfileFactory.create(HttpClientPlatformSettings)Map<ClientProfileName, ClientProfile>, replacing create(HttpClientsProperties).
    • HttpClientProfileFactory.toProfile(HttpClientPlatformSettings.ClientSettings)ClientProfile, replacing toProfile(String, ClientProperties) — the name now comes from the settings object.

Why indexed lists rather than maps. A map keyed by client name renders in the environment as APP_HTTPCLIENT_CLIENTS_<NAME>_..., and the relaxed binder normalises that segment: two distinct names that differ only by a hyphen, an underscore or case collapse onto the same variable, so one profile silently overwrites the other. Carrying the name as a value under a numeric index removes the ambiguity, and the compact constructor can then reject the collision explicitly instead of letting the last writer win.

  • Step 1: Write the failing settings test

Create .../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsTest.java:

package dev.caskeleton.bootstrap.autoconfigure.httpclient;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

class HttpClientPlatformSettingsTest {

  @Test
  @DisplayName("an enabled platform with no client is a startup failure, not an idle platform")
  void anEnabledPlatformWithoutAnyClientIsRefused() {
    assertThatThrownBy(() -> new HttpClientPlatformSettings(true, List.of(), List.of()))
        .isInstanceOf(IllegalStateException.class)
        .hasMessageContaining("HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS");
  }

  @Test
  @DisplayName("a disabled platform with no client is the normal case")
  void aDisabledPlatformWithoutAnyClientIsFine() {
    assertThat(new HttpClientPlatformSettings(false, List.of(), List.of()).clients()).isEmpty();
  }

  @Test
  @DisplayName("two clients with the same name are refused")
  void duplicateClientNamesAreRefused() {
    assertThatThrownBy(
            () ->
                new HttpClientPlatformSettings(
                    true, List.of(client("payment"), client("payment")), List.of()))
        .isInstanceOf(IllegalStateException.class)
        .hasMessageContaining("payment");
  }

  /**
   * Two names that are distinct as properties but identical as environment variables.
   *
   * <p>{@code payment-api} and {@code payment_api} both render as {@code PAYMENT_API}. Under the
   * previous map-keyed shape one would have overwritten the other with nothing said about it.
   */
  @Test
  @DisplayName("client names that collide once normalised for the environment are refused")
  void environmentColludingClientNamesAreRefused() {
    assertThatThrownBy(
            () ->
                new HttpClientPlatformSettings(
                    true, List.of(client("payment-api"), client("payment_api")), List.of()))
        .isInstanceOf(IllegalStateException.class)
        .hasMessageContaining("normalise");
  }

  @Test
  @DisplayName("a client without a name is refused")
  void anUnnamedClientIsRefused() {
    assertThatThrownBy(() -> new HttpClientPlatformSettings(true, List.of(client("  ")), List.of()))
        .isInstanceOf(IllegalStateException.class)
        .hasMessageContaining("name");
  }

  private static HttpClientPlatformSettings.ClientSettings client(String name) {
    return new HttpClientPlatformSettings.ClientSettings(
        name,
        "TRUSTED",
        "https://payment.test",
        List.of("payment.test"),
        List.of(443),
        "REST_CLIENT",
        "APACHE",
        List.of("HTTP_1_1"),
        new HttpClientPlatformSettings.Pool(
            50, 25, 100,
            java.time.Duration.ofMillis(200),
            java.time.Duration.ofSeconds(30),
            java.time.Duration.ofMinutes(5),
            java.time.Duration.ofSeconds(5),
            java.time.Duration.ofSeconds(15),
            java.time.Duration.ofSeconds(5),
            false,
            false),
        new HttpClientPlatformSettings.Timeout(
            java.time.Duration.ofMillis(300),
            java.time.Duration.ofMillis(500),
            java.time.Duration.ofSeconds(1),
            java.time.Duration.ofMillis(500),
            java.time.Duration.ofSeconds(1),
            java.time.Duration.ofSeconds(2),
            java.time.Duration.ofSeconds(3),
            java.time.Duration.ofSeconds(4),
            java.time.Duration.ofSeconds(30)),
        new HttpClientPlatformSettings.Redirect(false, 0, false),
        new HttpClientPlatformSettings.Request(1048576L, false),
        new HttpClientPlatformSettings.Response(
            5242880L, 10485760L, List.of("application/json")),
        new HttpClientPlatformSettings.Authentication("NONE", null, List.of(), null, null, null),
        new HttpClientPlatformSettings.Retry(
            "none",
            1,
            java.time.Duration.ofMillis(50),
            java.time.Duration.ofMillis(200),
            "FULL",
            "HONOR",
            null),
        new HttpClientPlatformSettings.Observability(true, false, false),
        new HttpClientPlatformSettings.Tls(
            "payment", List.of("TLSv1.3"), true, false, false, null, null),
        new HttpClientPlatformSettings.Proxy(
            false, "", 0, "HTTP", null, java.time.Duration.ofMillis(500), false),
        null);
  }
}
  • Step 2: Run it to verify it fails
cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformSettingsTest' --console=plain

Expected: compilation failure — HttpClientPlatformSettings does not exist.

  • Step 3: Write the settings record

Create .../autoconfigure/httpclient/HttpClientPlatformSettings.java. Copy the nested records Pool, Timeout, Redirect, Request, Response, Authentication, Retry, Observability, Tls, Proxy verbatim from the deleted HttpClientsProperties, keeping every @DefaultValue unchanged, and add the root plus the two indexed element types:

package dev.caskeleton.bootstrap.autoconfigure.httpclient;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.boot.context.properties.bind.DefaultValue;

/**
 * The whole {@code app.httpclient} surface, bound once, strictly, and only while the platform is on.
 *
 * <p>Deliberately not annotated {@code @ConfigurationProperties}: the composition root's
 * {@code @ConfigurationPropertiesScan} is not selective, so an annotated class would be registered
 * and bound in every deployment — including one that never enables outbound HTTP, which is exactly
 * the coupling the master switch exists to remove.
 *
 * <p>Clients and dynamic targets are indexed lists carrying their own {@code name} rather than maps
 * keyed by name. A map key becomes a segment of the environment variable, and the relaxed binder
 * normalises that segment, so {@code payment-api} and {@code payment_api} would resolve to one
 * entry with nothing said about the one that was lost.
 */
public record HttpClientPlatformSettings(
    @DefaultValue("false") boolean enabled,
    @DefaultValue List<ClientSettings> clients,
    @DefaultValue List<DynamicTargetSettings> dynamicTargets) {

  /** Configuration prefix; the canonical environment form is {@code APP_HTTPCLIENT_*}. */
  public static final String PREFIX = "app.httpclient";

  public HttpClientPlatformSettings {
    clients = List.copyOf(clients == null ? List.of() : clients);
    dynamicTargets = List.copyOf(dynamicTargets == null ? List.of() : dynamicTargets);
    if (enabled && clients.isEmpty()) {
      throw new IllegalStateException(
          "HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS: "
              + PREFIX
              + ".enabled is true but no client is declared under "
              + PREFIX
              + ".clients[*]. An active platform with nothing to call holds transport providers, "
              + "gateways and a resilience registry that no caller can reach.");
    }
    requireDistinctNames(
        clients.stream().map(ClientSettings::name).toList(), PREFIX + ".clients");
    requireDistinctNames(
        dynamicTargets.stream().map(DynamicTargetSettings::name).toList(),
        PREFIX + ".dynamic-targets");
  }

  /**
   * Rejects blank, duplicate and environment-colliding names.
   *
   * <p>The normalised form is what an operator would have to type as an environment variable, so
   * two names that share it cannot both be configured from the environment even though they are
   * distinct as properties.
   */
  private static void requireDistinctNames(List<String> names, String where) {
    List<String> seen = new ArrayList<>();
    Map<String, String> byNormalisedForm = new LinkedHashMap<>();
    for (String name : names) {
      if (name == null || name.isBlank()) {
        throw new IllegalStateException(where + "[*].name must be non-blank");
      }
      if (seen.contains(name)) {
        throw new IllegalStateException(where + " declares '" + name + "' more than once");
      }
      seen.add(name);
      String normalised = name.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "");
      String previous = byNormalisedForm.putIfAbsent(normalised, name);
      if (previous != null) {
        throw new IllegalStateException(
            where
                + " declares '"
                + previous
                + "' and '"
                + name
                + "', which normalise to the same environment variable segment '"
                + normalised
                + "'. One would silently replace the other.");
      }
    }
  }

  /** One Named Client Profile. Every field keeps the name and default it had under the old map. */
  public record ClientSettings(
      String name,
      @DefaultValue("TRUSTED") String mode,
      String baseUrl,
      @DefaultValue List<String> allowedHosts,
      @DefaultValue List<Integer> allowedPorts,
      @DefaultValue("REST_CLIENT") String api,
      @DefaultValue("APACHE") String transport,
      @DefaultValue({"HTTP_1_1"}) List<String> protocols,
      @DefaultValue Pool pool,
      @DefaultValue Timeout timeout,
      @DefaultValue Redirect redirect,
      @DefaultValue Request request,
      @DefaultValue Response response,
      @DefaultValue Authentication authentication,
      @DefaultValue Retry retry,
      @DefaultValue Observability observability,
      @DefaultValue Tls tls,
      @DefaultValue Proxy proxy,
      String experimentalAcknowledgement) {}

  /** One Dynamic Target policy. */
  public record DynamicTargetSettings(
      String name,
      @DefaultValue({"https"}) List<String> allowedSchemes,
      @DefaultValue({"443"}) List<Integer> allowedPorts,
      @DefaultValue List<String> allowedHostSuffixes,
      @DefaultValue List<String> allowedHosts,
      @DefaultValue("0") int maxRedirectHops,
      @DefaultValue("false") boolean tracePropagation,
      @DefaultValue List<String> blockedCidrs) {}

  // ... Pool, Timeout, Redirect, Request, Response, Authentication, Retry, Observability, Tls and
  // Proxy copied verbatim from the deleted HttpClientsProperties, javadoc included.
}
  • Step 4: Run the settings test
cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformSettingsTest' --console=plain

Expected: PASS.

  • Step 5: Write the strict binder

Create .../autoconfigure/httpclient/HttpClientPlatformSettingsBinder.java:

package dev.caskeleton.bootstrap.autoconfigure.httpclient;

import org.springframework.boot.context.properties.bind.BindHandler;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.bind.handler.NoUnboundElementsBindHandler;
import org.springframework.core.env.Environment;

/**
 * Binds {@link HttpClientPlatformSettings} strictly, and only when asked.
 *
 * <p>Unknown keys under the prefix are refused rather than ignored. Silently dropping
 * {@code app.httpclient.clients[0].timeuot.total-call} leaves the client running the default
 * four-second budget while the configuration file says otherwise, which is the kind of divergence an
 * outbound call platform should never make an operator discover from an incident.
 */
final class HttpClientPlatformSettingsBinder {

  private HttpClientPlatformSettingsBinder() {}

  static HttpClientPlatformSettings bind(Environment environment) {
    BindHandler strict = new NoUnboundElementsBindHandler(BindHandler.DEFAULT);
    return Binder.get(environment)
        .bind(HttpClientPlatformSettings.PREFIX, Bindable.of(HttpClientPlatformSettings.class), strict)
        .orElseThrow(
            () ->
                new IllegalStateException(
                    HttpClientPlatformSettings.PREFIX
                        + " could not be bound although the platform is enabled"));
  }
}
  • Step 6: Rewire the consumers

In HttpClientPlatformAutoConfiguration, add:

  @Bean
  @ConditionalOnMissingBean
  HttpClientPlatformSettings httpClientPlatformSettings(Environment environment) {
    return HttpClientPlatformSettingsBinder.bind(environment);
  }

In HttpClientProfileAutoConfiguration, delete the httpClientsProperties bean method and change httpClientRuntimeRegistry to take HttpClientPlatformSettings properties. In DynamicTargetAutoConfiguration, delete the dynamicTargetProperties bean method and derive both maps from HttpClientPlatformSettings#dynamicTargets, keying by new DynamicTargetPolicyName(target.name()). In HttpClientProfileFactory, change create and toProfile to the signatures declared in the Interfaces block; the body is otherwise unchanged apart from reading client.name() instead of a map key.

Finally, in HttpClientPlatformActivationTest, switch the property names to the indexed form and remove the @Disabled("Task 2") annotation from enablingItAssemblesTheDeclaredRuntimes.

  • Step 7: Add the strictness and aggregate cases to the activation test

Append to HttpClientPlatformActivationTest:

  @Test
  @DisplayName("an unknown key under the prefix is refused rather than ignored")
  void anUnknownKeyUnderThePrefixIsRefused() {
    runner
        .withPropertyValues("app.httpclient.enabled=true")
        .withPropertyValues(validPaymentClient())
        .withPropertyValues("app.httpclient.clients[0].timeuot.total-call=9s")
        .run(context -> assertThat(context).hasFailed());
  }

  @Test
  @DisplayName("an enabled platform with no client fails startup with the declared code")
  void anEnabledPlatformWithoutAnyClientFailsStartup() {
    runner
        .withPropertyValues("app.httpclient.enabled=true")
        .run(
            context ->
                assertThat(context)
                    .hasFailed()
                    .getFailure()
                    .hasStackTraceContaining("HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS"));
  }
  • Step 8: Run the whole module
cd src && ./gradlew :app-bootstrap:test --console=plain

Expected: the new tests pass; OptionalAdapterBeanGatingTest still fails on the stale assertion fixed in Task 4.

  • Step 9: Commit (human)
feat(bootstrap): bind the HTTP Client platform strictly from an indexed settings tree

Task 3: ENV SSOT and the field manifest

Files:

  • Modify: src/.env
  • Modify: src/app-bootstrap/src/main/resources/application.yml
  • Modify: docs/registries/env-keys.yaml
  • Create: docs/registries/httpclient-env-fields.yaml
  • Test: .../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformEnvManifestTest.java

Interfaces:

  • Consumes: HttpClientPlatformSettings from Task 2.
  • Produces: docs/registries/httpclient-env-fields.yaml, a flat YAML list of - field: <property path> / env: <ENV template> pairs covering every leaf field of the settings tree. Task 4's documentation verifier reads it.

Why the per-client surface is not in .env. verifyEnvKeys requires every .env key to be referenced by an application.yml placeholder. A client is an indexed list element, so templating one in application.yml would materialise app.httpclient.clients[0] in every deployment — with a blank name, which the aggregate validation from Task 2 correctly refuses. The review reaches the same conclusion: keep the enable key in application.yml, bind the detail from the environment directly, and prove the surface is complete with a manifest and a reflection test rather than with a template nobody can leave in place.

  • Step 1: Write the failing manifest parity test

Create .../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformEnvManifestTest.java:

package dev.caskeleton.bootstrap.autoconfigure.httpclient;

import static org.assertj.core.api.Assertions.assertThat;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.reflect.RecordComponent;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.BindHandler;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.bind.handler.NoUnboundElementsBindHandler;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.env.SystemEnvironmentPropertySource;

/**
 * Closes the loop between the settings tree and its documented environment surface.
 *
 * <p>"Every setting is managed through the environment" is a claim about two things at once: that
 * each field has an environment form, and that each documented environment name still maps to a
 * field. Nothing enforced either. A field added to a nested record acquired no documentation, and a
 * documented name whose field was renamed kept being published to operators who would set it and
 * see nothing happen.
 *
 * <p>The names are fed in as real environment variables through a
 * {@link SystemEnvironmentPropertySource}, not as hand-translated property names, so what is under
 * test is the mapping the runtime actually performs. Binding strictly settles the rest: an element
 * the binder cannot place fails here rather than in production.
 */
class HttpClientPlatformEnvManifestTest {

  @Test
  @DisplayName("every leaf field of the settings tree is in the manifest, and vice versa")
  void theManifestAndTheSettingsTreeAgree() {
    Map<String, String> derived = envTemplatesOf(HttpClientPlatformSettings.class, "APP_HTTPCLIENT");

    assertThat(manifest().keySet())
        .as(
            "docs/registries/httpclient-env-fields.yaml must list exactly the leaf fields of "
                + "HttpClientPlatformSettings")
        .containsExactlyInAnyOrderElementsOf(derived.keySet());
    assertThat(manifest()).containsAllEntriesOf(derived);
  }

  @Test
  @DisplayName("a client declared purely through environment variables binds strictly")
  void aClientDeclaredThroughTheEnvironmentBinds() {
    Map<String, Object> environmentVariables = new LinkedHashMap<>();
    environmentVariables.put("APP_HTTPCLIENT_ENABLED", "true");
    environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_NAME", "payment");
    environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_BASE_URL", "https://payment.test");
    environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_ALLOWED_HOSTS_0", "payment.test");
    environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_ALLOWED_PORTS_0", "443");
    environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_REQUEST_MAX_BODY_BYTES", "1048576");
    environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_TLS_PROFILE_ID", "payment");
    environmentVariables.put("APP_HTTPCLIENT_DYNAMIC_TARGETS_0_NAME", "webhook");
    environmentVariables.put("APP_HTTPCLIENT_DYNAMIC_TARGETS_0_ALLOWED_SCHEMES_0", "https");

    StandardEnvironment environment = new StandardEnvironment();
    environment
        .getPropertySources()
        .addFirst(
            new SystemEnvironmentPropertySource(
                StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, environmentVariables));

    HttpClientPlatformSettings bound =
        Binder.get(environment)
            .bind(
                HttpClientPlatformSettings.PREFIX,
                Bindable.of(HttpClientPlatformSettings.class),
                new NoUnboundElementsBindHandler(BindHandler.DEFAULT))
            .orElseThrow(() -> new AssertionError("the declared environment bound to nothing"));

    assertThat(bound.enabled()).isTrue();
    assertThat(bound.clients()).singleElement().satisfies(client -> {
      assertThat(client.name()).isEqualTo("payment");
      assertThat(client.baseUrl()).isEqualTo("https://payment.test");
      assertThat(client.allowedPorts()).containsExactly(443);
      assertThat(client.request().maxBodyBytes()).isEqualTo(1048576L);
      assertThat(client.tls().profileId()).isEqualTo("payment");
    });
    assertThat(bound.dynamicTargets())
        .singleElement()
        .satisfies(target -> assertThat(target.name()).isEqualTo("webhook"));
  }

  /** The shipped default must be off. */
  @Test
  @DisplayName("src/.env ships the platform disabled")
  void theShippedEnvironmentKeepsThePlatformOff() {
    assertThat(readEnvFile())
        .anySatisfy(line -> assertThat(line.trim()).isEqualTo("APP_HTTPCLIENT_ENABLED=false"));
  }

  /**
   * Walks the record tree and renders each leaf as the environment name an operator would set.
   *
   * <p>A {@code List} of records becomes an indexed segment; a {@code List} of scalars becomes an
   * indexed leaf. Both are rendered with a literal {@code N} so the manifest describes a template
   * rather than one deployment's cardinality.
   */
  private static Map<String, String> envTemplatesOf(Class<?> type, String prefix) {
    Map<String, String> templates = new LinkedHashMap<>();
    collect(type, prefix, "", templates);
    return templates;
  }

  private static void collect(
      Class<?> type, String envPrefix, String pathPrefix, Map<String, String> into) {
    for (RecordComponent component : type.getRecordComponents()) {
      String property = camelToKebab(component.getName());
      String path = pathPrefix.isEmpty() ? property : pathPrefix + "." + property;
      String env = envPrefix + "_" + camelToScreamingSnake(component.getName());
      Class<?> componentType = component.getType();
      if (componentType.isRecord()) {
        collect(componentType, env, path, into);
        continue;
      }
      if (List.class.isAssignableFrom(componentType)) {
        Class<?> element = elementTypeOf(component);
        if (element != null && element.isRecord()) {
          collect(element, env + "_N", path + "[N]", into);
          continue;
        }
        into.put(path + "[N]", env + "_N");
        continue;
      }
      into.put(path, env);
    }
  }

  private static Class<?> elementTypeOf(RecordComponent component) {
    if (component.getGenericType()
        instanceof java.lang.reflect.ParameterizedType parameterized
        && parameterized.getActualTypeArguments()[0] instanceof Class<?> element) {
      return element;
    }
    return null;
  }

  private static String camelToKebab(String name) {
    return name.replaceAll("([a-z0-9])([A-Z])", "$1-$2").toLowerCase(Locale.ROOT);
  }

  private static String camelToScreamingSnake(String name) {
    return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toUpperCase(Locale.ROOT);
  }

  /** Reads the manifest without a YAML parser: it is a flat two-key list by construction. */
  private static Map<String, String> manifest() {
    Map<String, String> entries = new LinkedHashMap<>();
    String field = null;
    for (String line : readLines(repositoryRoot().resolve("docs/registries/httpclient-env-fields.yaml"))) {
      String trimmed = line.trim();
      if (trimmed.startsWith("- field:")) {
        field = trimmed.substring("- field:".length()).trim();
      } else if (trimmed.startsWith("env:") && field != null) {
        entries.put(field, trimmed.substring("env:".length()).trim());
        field = null;
      }
    }
    return entries;
  }

  private static List<String> readEnvFile() {
    Path fromModule = Path.of(System.getProperty("user.dir")).resolve(".env");
    return readLines(Files.exists(fromModule) ? fromModule : Path.of("..").resolve(".env"));
  }

  /** The module's working directory is {@code src/app-bootstrap} under Gradle. */
  private static Path repositoryRoot() {
    Path candidate = Path.of(System.getProperty("user.dir")).toAbsolutePath();
    List<Path> tried = new ArrayList<>();
    for (int depth = 0; depth < 4 && candidate != null; depth++) {
      tried.add(candidate);
      if (Files.exists(candidate.resolve("docs/registries/httpclient-env-fields.yaml"))) {
        return candidate;
      }
      candidate = candidate.getParent();
    }
    throw new AssertionError("repository root not found from " + tried);
  }

  private static List<String> readLines(Path path) {
    try {
      return Files.readAllLines(path);
    } catch (IOException exception) {
      throw new UncheckedIOException(path + " could not be read", exception);
    }
  }
}
  • Step 2: Run it to verify it fails
cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformEnvManifestTest' --console=plain

Expected: FAIL — docs/registries/httpclient-env-fields.yaml does not exist, so repositoryRoot() throws.

  • Step 3: Generate the manifest from the settings tree

Run the derivation once and write its output. The quickest honest way is to let the test print it: temporarily add System.out.println(...) over derived in theManifestAndTheSettingsTreeAgree, run the single test, capture the output, and write it as docs/registries/httpclient-env-fields.yaml in this shape (header plus one entry per leaf):

# HTTP Client platform — Java field path to environment variable template.
#
# The SSOT is HttpClientPlatformSettings. HttpClientPlatformEnvManifestTest derives this list from
# the record tree and fails when the two disagree in either direction, so an added field with no
# entry and an entry whose field was renamed are both build failures.
#
# `N` is a list index, not a literal. `app.httpclient.clients[N].base-url` is set as
# APP_HTTPCLIENT_CLIENTS_0_BASE_URL for the first client.
#
# Only APP_HTTPCLIENT_ENABLED is registered in docs/registries/env-keys.yaml and shipped in
# src/.env: it is the only key with a deployment-independent value. Everything below is per
# deployment and is set directly in the environment.
fields:
  - field: enabled
    env: APP_HTTPCLIENT_ENABLED
  - field: clients[N].name
    env: APP_HTTPCLIENT_CLIENTS_N_NAME
  - field: clients[N].base-url
    env: APP_HTTPCLIENT_CLIENTS_N_BASE_URL
  # ... one entry per leaf, in the order the derivation emits them

Then remove the temporary println.

  • Step 4: Add the master key to the three-way gate

src/.env — append beside the other capability master switches:

# === HTTP Client platform (app.httpclient.*) ===
# Off by default. While false no HTTP client property is bound, no transport provider, connection
# pool, TLS context, credential or gateway is created, and no HTTP thread exists. Per-client
# settings are set directly in the environment; docs/registries/httpclient-env-fields.yaml is their
# registry.
APP_HTTPCLIENT_ENABLED=false

src/app-bootstrap/src/main/resources/application.yml — under app::

  httpclient:
    # Master switch for the outbound HTTP Client platform. Only this key lives here: the per-client
    # surface is an indexed list whose element cannot be templated without materialising a nameless
    # client in every deployment, so it is bound from the environment directly.
    # docs/registries/httpclient-env-fields.yaml is the registry for those names.
    enabled: ${APP_HTTPCLIENT_ENABLED:false}

docs/registries/env-keys.yaml — add an entry in the same shape as APP_FILESERVER_PLATFORM_ENABLED:

  # === HTTP Client platform (app.httpclient.*) ===

  - name: APP_HTTPCLIENT_ENABLED
    # Master switch. While false the platform block is not bound at all: the auto-configuration that
    # binds it is not processed, so no bean, pool, TLS context, credential, thread or gateway exists.
    type: boolean
    default: false
    allowed_values: [true, false]
    classification: public-config
    required: false
    reload_policy: restart-only
    owner_branch: httpclient-platform-activation-boundary
    validation: boolean_strict
  • Step 5: Run the manifest test and the env gate
cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformEnvManifestTest' --console=plain
cd src && ./gradlew verifyEnvKeys --console=plain

Expected: both PASS.

  • Step 6: Commit (human)
feat(config): declare the HTTP Client platform's environment surface

Task 4: Migrate the existing tests, gate the actuator endpoint, update the docs

Files:

  • Modify: src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java
  • Modify: .../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientAutoConfigurationTest.java
  • Modify: .../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/UnsafeStartupConfigurationTest.java
  • Modify: docs/httpclient/configuration-reference.md
  • Modify: scripts/verify-httpclient-docs.py

Interfaces:

  • Consumes: everything produced by Tasks 13.

  • Produces: no new production types.

  • Step 1: Fix the gating test's stale contract

In OptionalAdapterBeanGatingTest, replace the six HttpClient*AutoConfiguration entries in withUserConfiguration(...) with nothing, and replace

          assertThat(context.getBean(ClientRuntimeRegistry.class).names()).isEmpty();

with

          // Not "an empty registry": with the platform off there is no registry, no transport
          // provider and no gateway. An empty registry bean was the previous contract and it is
          // what made the capability mandatory-with-a-switch rather than optional.
          assertThat(context.getBeansOfType(ClientRuntimeRegistry.class)).isEmpty();

Add the platform to the runner as a real auto-configuration so the OFF path is exercised through the same entry the application uses:

          .withConfiguration(
              AutoConfigurations.of(
                  dev.caskeleton.bootstrap.autoconfigure.httpclient
                      .HttpClientPlatformAutoConfiguration.class))
  • Step 2: Route the two moved tests through the single entry

In both HttpClientAutoConfigurationTest and UnsafeStartupConfigurationTest, replace the multi-entry AutoConfigurations.of(...) with AutoConfigurations.of(HttpClientPlatformAutoConfiguration.class), add "app.httpclient.enabled=true" to every runner that expects beans, convert every http-clients.<name>.<key> property to app.httpclient.clients[0].<key> plus app.httpclient.clients[0].name=<name>, convert every http-dynamic-targets.<name>.<key> to app.httpclient.dynamic-targets[0].<key> plus a name, and add the Clock bean to the supporting configuration (the platform no longer supplies one).

UnsafeStartupConfigurationTest#anUnconfiguredDeploymentHoldsNoHttpRuntimeResources asserted that an enabled-but-empty platform yields an empty registry. That case is now HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS and is covered in HttpClientPlatformActivationTest; delete it here rather than restating it.

  • Step 3: Run the module
cd src && ./gradlew :app-bootstrap:test --console=plain

Expected: PASS, no skips beyond the module's pre-existing ones.

  • Step 4: Update the configuration reference

In docs/httpclient/configuration-reference.md: replace the http-clients.<name> prefix with app.httpclient.clients[N], replace http-dynamic-targets.<name> with app.httpclient.dynamic-targets[N], document app.httpclient.enabled as the master switch with its APP_HTTPCLIENT_ENABLED form and the "off means nothing is bound" contract, and correct the default protocol from "HTTP2+HTTP1" to HTTP_1_1 — the code has always said HTTP_1_1 and the document has always said otherwise.

  • Step 5: Point the documentation verifier at the new type

In scripts/verify-httpclient-docs.py, change the source of code-derived names from HttpClientsProperties.java and DynamicTargetProperties.java to HttpClientPlatformSettings.java, and extend it to walk nested records rather than only top-level components.

python3 -B scripts/verify-httpclient-docs.py

Expected: exits 0 and reports a name count at least as large as the previous 90.

  • Step 6: Commit (human)
test(bootstrap): assert the HTTP Client platform's off state through its real entry point

Verification

Run from src/ after Task 4:

./gradlew :app-bootstrap:test --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew verifyEnvKeys --console=plain
./gradlew verifyOneTypePerFile --console=plain
./gradlew :adapter:outbound:httpclient:test --console=plain

and from the repository root:

python3 -B scripts/verify-httpclient-docs.py
git diff --check

:adapter:outbound:httpclient:test is in the list because nothing in this plan should touch the adapter leaf; a failure there means a change leaked across the boundary.

Self-Review

Spec coverage. Review P0 #1 is Tasks 1, 2 and 4 (single scan-excluded entry, strict boolean toggle, zero beans while off, HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS, actuator under the master flag via HttpClientManagementAutoConfiguration being imported rather than scanned, and the three tests that encoded the wrong OFF semantics rewritten). Review P0 #2 is Tasks 2 and 3 (indexed clients with the name as a value, unknown-field rejection, duplicate and normalisation-collision rejection, the field manifest, the reflection parity test, and the real SystemEnvironmentPropertySource binding test).

Two P0 #2 sub-items are deliberately not covered and belong to the settings-compiler plan that follows: "every declared setting is used in ValidatedClientPlan or explicitly rejected", and "ResilienceRegistry.ofDefaults()'s hidden circuit/rate/bulkhead policy is either configurable or documented as a safe constant". Both need the compiler this plan does not build; recording them here so the next plan starts from a known gap rather than rediscovering it.

Placeholder scan. No step says "add validation" or "handle edge cases" without the code. The one elision is the ten nested settings records in Task 2 Step 3, which are an explicit verbatim copy of a file the plan names and which the step's comment marks; reproducing 90 lines of unchanged record declarations would have obscured the four types that actually change.

Type consistency. HttpClientPlatformSettings.PREFIX is "app.httpclient" in Task 2 and is what Task 1's @ConditionalOnProperty and Task 3's binder test both reference. HttpClientProfileFactory.create(HttpClientPlatformSettings) and toProfile(HttpClientPlatformSettings.ClientSettings) are declared once in Task 2's Interfaces block and used with those signatures in Step 6. dynamicTargets is the record component name throughout, rendering as app.httpclient.dynamic-targets[N] in properties and APP_HTTPCLIENT_DYNAMIC_TARGETS_N_* in the environment, which is what Task 3's binding test asserts.