diff --git a/backend/src/RunCoach.Api/Infrastructure/ServiceCollectionExtensions.cs b/backend/src/RunCoach.Api/Infrastructure/ServiceCollectionExtensions.cs index 64af160..9087280 100644 --- a/backend/src/RunCoach.Api/Infrastructure/ServiceCollectionExtensions.cs +++ b/backend/src/RunCoach.Api/Infrastructure/ServiceCollectionExtensions.cs @@ -1,4 +1,3 @@ -using Microsoft.Extensions.Options; using RunCoach.Api.Infrastructure.Idempotency; using RunCoach.Api.Modules.Coaching; using RunCoach.Api.Modules.Coaching.Prompts; @@ -43,21 +42,18 @@ public static IServiceCollection AddApplicationModules( // Coaching module — scoped services (per-request lifetime). services.AddScoped(); - // ContextAssembler exposes two public constructors — a 3-arg legacy - // form for plan-only tests and a 6-arg onboarding-aware form. The - // default container's "most-resolvable parameters" heuristic picked - // the 3-arg constructor at runtime in this project, leaving - // `_sanitizer` null and breaking `ComposeForOnboardingAsync` with an - // InvalidOperationException on every onboarding turn. Explicit - // factory registration locks the production path to the 6-arg - // constructor regardless of constructor-selection quirks. - services.AddScoped(sp => new ContextAssembler( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService>(), - sp.GetRequiredService>())); + // ContextAssembler's 3-arg legacy constructor is `internal` (test-only via + // InternalsVisibleTo); the public surface is the single 6-arg + // onboarding-aware constructor, so the container unambiguously selects it + // from a plain implementation-type registration. This MUST stay a type + // registration, not an `sp => new ContextAssembler(...)` lambda factory: + // Wolverine 6 handler codegen (ServiceLocationPolicy.NotAllowed, DEC-071) + // cannot statically construct an opaque Scoped lambda factory, falls back + // to service location, and rejects it — breaking the OnboardingTurnHandler + // chain with an HTTP 500 on every onboarding turn. The no-opaque-factory + // rule is guarded by WolverineCodegenCompositionTests; correct 6-arg + // constructor selection is guarded by ContextAssemblerDiResolutionTests. + services.AddScoped(); // Prompt-injection sanitizer (Slice 1 § Unit 6 / DEC-059 / R-068). // Stateless layered sanitizer — singleton-safe; the pattern catalog diff --git a/backend/tests/RunCoach.Api.Tests/Infrastructure/WolverineCodegenCompositionTests.cs b/backend/tests/RunCoach.Api.Tests/Infrastructure/WolverineCodegenCompositionTests.cs new file mode 100644 index 0000000..5771415 --- /dev/null +++ b/backend/tests/RunCoach.Api.Tests/Infrastructure/WolverineCodegenCompositionTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using RunCoach.Api.Infrastructure; + +namespace RunCoach.Api.Tests.Infrastructure; + +/// +/// Regression guard (DEC-071) for Wolverine 6 message-handler code generation. +/// Wolverine 6 defaults to ServiceLocationPolicy.NotAllowed: when a handler +/// depends on a service registered as an "opaque" lambda factory with a Scoped or +/// Transient lifetime, Wolverine cannot construct it statically, falls back to +/// service location, and throws InvalidServiceLocationException while +/// compiling the handler chain — surfacing as an HTTP 500 on the affected endpoint +/// (the OnboardingTurnHandler / POST /api/v1/onboarding/turns chain). +/// +/// This blind spot existed because no green test compiled the production handler +/// graph: the *DiResolutionTests only assert GetRequiredService +/// succeeds (which an opaque lambda factory satisfies), the onboarding handler unit +/// tests call the static Handle method directly with mocks (bypassing +/// Wolverine codegen), and the HTTP/bus integration host swaps +/// IPlanGenerationService for a stub that severs the failing dependency edge. +/// +/// This test inspects the production registrations directly (no host boot, no DB, +/// no LLM) and fails if any module service uses an opaque Scoped/Transient lambda +/// factory, which would re-break handler codegen. If a future registration genuinely +/// needs a factory, allow-list the service via +/// opts.CodeGeneration.AlwaysUseServiceLocationFor<T>() in the Wolverine +/// configuration AND add it to in the +/// same change. +/// +public sealed class WolverineCodegenCompositionTests +{ + /// + /// Service types intentionally permitted to use Wolverine service location via + /// CodeGeneration.AlwaysUseServiceLocationFor<T>(). Empty by design — + /// the module convention is concrete implementation-type registrations so handler + /// codegen stays static. + /// + private static readonly HashSet KnownServiceLocationAllowList = new(); + + [Fact] + public void AddApplicationModules_RegistersNoOpaqueScopedOrTransientLambdaFactories() + { + // Arrange: build the exact production registration graph Program.cs assembles + // (see Program.cs `builder.Services.AddApplicationModules(builder.Configuration)`). + var services = new ServiceCollection(); + services.AddApplicationModules(new ConfigurationBuilder().Build()); + + // Act: find Scoped/Transient registrations that use an opaque implementation + // factory (lambda) rather than a concrete implementation type. Singletons are + // exempt — Wolverine resolves them as cached instances, not via per-handler + // constructor codegen. + var opaqueLambdaFactories = services + .Where(descriptor => + descriptor.ImplementationFactory is not null + && descriptor.Lifetime != ServiceLifetime.Singleton + && !KnownServiceLocationAllowList.Contains(descriptor.ServiceType)) + .Select(descriptor => descriptor.ServiceType.FullName) + .ToList(); + + // Assert. + opaqueLambdaFactories.Should() + .BeEmpty( + because: "Wolverine 6 handler codegen (ServiceLocationPolicy.NotAllowed) cannot " + + "statically construct an opaque Scoped/Transient lambda factory and throws " + + "InvalidServiceLocationException while compiling any handler that depends on " + + "it (DEC-071). Register a concrete implementation type, or allow-list the " + + "service via CodeGeneration.AlwaysUseServiceLocationFor()."); + } +}