[PLAN-0051] Compono.Http: Handler-Based HTTP Client Testing Package¶
Status: Done
Implements: ADR-0051
Goal¶
A new Compono.Http package ships a TestHttpHandler (HttpMessageHandler subclass), reflection-free, depending only on core Compono, giving consumers OnGet/OnPost/etc. + When(...) request matching, last-match-wins dispatch, strict unmatched-request behavior, registration-handle verification, fresh-per-invocation responses (including AOT-honest JSON), a caller-owned lifecycle, and a thread-safe request log — per ADR-0051's Decision Outcome in full.
Done when: alexa-vox-craft's real 41-call-site ReturnsResponse reflection workaround is fully replaced by Compono.Http, its full test suite passes against freshly packed local packages (not just Compono's own suite), every behavior in ADR-0051's Decision Outcome has a passing automated test proving it (including the empirically-verified JSON/AOT attribute-propagation behavior), skills/compono (SKILL.md + a new references/http.md) teaches an agent Compono.Http's package boundary, matching semantics, unmatched behavior, verification model, and caller-owned lifetime — not just that the package exists — and alexa-vox-craft's broader test-composition surface has been migrated toward the Compono ecosystem wherever that migration is clean and supported (AutoFixture/AutoFixture.AutoNSubstitute → Compono, NSubstitute → Compono.TestDoubles, the reflection-based HTTP infrastructure → Compono.Http, and Compono.Bogus only where a real semantic-data need justifies it), with every blocked migration attempt reported as an explicit, classified gap rather than silently worked around. The skill update (task 12a) and the broader ecosystem migration (task 10) are both part of this plan's completion criteria, not optional follow-up work — this plan's dogfood goal is not merely "prove Compono.Http works" but "use Compono.Http as the trigger for a realistic consumer migration, and surface any gap that prevents it." ADR-0051 itself is unchanged by this — it defines Compono.Http only; the broader migration's architecture and findings are this plan's and its dogfood research document's scope (task 10g). The generalized, multi-package scripts/dogfood-validate.sh (task 11) is itself a required shipping artifact of this plan — a committed, reusable tool parameterized over consumer repo/solution/configuration/package set, not an ad-hoc command sequence documented only in this plan's prose.
Scope¶
Exactly ADR-0051's Decision Outcome — see that ADR for the full rationale behind each choice; this plan does not re-derive it. Summary of what's in scope, one bullet per ADR-0051 section:
Compono.Httppackage, depending only onCompono.TestHttpHandler : HttpMessageHandler,[Shared]-composable.OnGet/OnPost/OnPut/OnPatch/OnDelete(Match<string> path) +When(Func<HttpRequestMessage, bool>).- Ordered, append-only registration list; last-registered-first, first match wins.
- Strict unmatched-request behavior:
UnmatchedHttpRequestException, never a fabricated response; unmatched requests still recorded. HttpResponseRegistrationhandle: response-factory state (never a stored instance),Interlocked-counted matches,Verify()returningCallVerifier.Respond(HttpStatusCode),RespondText(...),RespondJson<T>(value, JsonSerializerOptions? options = null),RespondJson<T>(value, JsonTypeInfo<T>),Throws(Exception)(same instance rethrown every match).- JSON serialized once to immutable bytes at registration; fresh
ByteArrayContentper invocation. [RequiresDynamicCode]/[RequiresUnreferencedCode]on theJsonSerializerOptionsoverload; neither on theJsonTypeInfo<T>overload; no internal suppression.Requests: IReadOnlyList<HttpRequestMessage>— raw references, snapshot-per-access, recorded before matching, arrival order preserved.CreateClient(Uri? baseAddress = null), alwaysdisposeHandler: false; caller owns/disposes both clients and the handler; Compono composition never owns or disposes the handler.- Concurrency: concurrent
SendAsyncsupported; configuration concurrent with sends unsupported/not guaranteed; registration list stays a plainList<T>; match counts and request log are the only state requiring real synchronization.
Beyond ADR-0051's own Decision Outcome, this plan also scopes in the skills/compono agent-skill update (task 12a) as a required deliverable — the skill and docs/packages/compono-http.md are separate product surfaces (one teaches a human reader, the other teaches an agent), and shipping only the former leaves the skill silently unaware Compono.Http exists.
This ships as one coherent implementation PR — no artificial phase split. If implementation research surfaces a genuinely independent seam (one that could ship and be reviewed on its own without the rest of the package being meaningful), that's a plan amendment to record at the time, not something to pre-guess here.
Explicitly out of scope for this plan — do not implement any of these, even if implementation makes one look easy to add along the way:
IHttpClientFactory/named-client/typed-client integration or anyMicrosoft.Extensions.Httpdependency/helper.- Header/query-string/JSON-body dedicated matcher types (the
When(...)predicate escape hatch is the only mechanism for these). - Async request-matching predicates (
Func<HttpRequestMessage, Task<bool>>). - Retry/Polly-aware testing behavior.
- Callback-based responses, delayed/latency-simulated responses, sequential/queued responses per registration.
- WireMock-style stateful scenarios.
- Call-order verification (only count-based
Never/Once/Exactly). - A strict/loose unmatched-request mode toggle.
- A raw
HttpResponseMessage-acceptingRespond(HttpResponseMessage)overload. - Any change to core
Compono(Match<T>,CallVerifier,CompositionRow/[Shared]) — all reused unmodified. - A new Compono disposable-scope/lifetime mechanism —
TestHttpHandlerstays caller-owned per ADR-0051; composition-driven auto-disposal is a separate, out-of-scope core-capability decision.
Tasks¶
1. Package/project creation¶
- Create
src/Compono.Http/Compono.Http.csproj—TargetFrameworksmatching the repo's current sweep (net8.0;net9.0;net10.0;net11.0, confirmed againstCompono.DependencyInjection.csproj),LangVersion latest,ImplicitUsings enable,Nullable enable, packageTitle/Description. -
ProjectReferenceto..\Compono\Compono.csprojonly —PrivateAssets="none"per the existing integration-package pattern (Compono.DependencyInjection.csproj's comment explains why: so the dependency flows through to the packed nupkg). - Copy the
PinProjectReferenceVersionsExactMSBuild target fromCompono.DependencyInjection.csproj/Compono.XunitV3.csproj(ADR-0031/ PLAN-0008 Phase 0 pattern) so the packedCompono.Httpnupkg pins an exactComponoversion match. -
InternalsVisibleToforCompono.Http.Tests. - No
PackageReferencetoMicrosoft.Extensions.DependencyInjection.Abstractions,Microsoft.Extensions.Http,Moq, orNSubstituteanywhere in this project — confirmed via the same "why not" comment styleCompono.DependencyInjection.csprojuses (see the csproj'sItemGroupcomment), not silent omission. - Added
src/Compono.Http/Compono.Http.csprojandtest/Compono.Http.Tests/Compono.Http.Tests.csprojtoCompono.slnx(this repo has no local CI YAML -Directory.Build.props' own comment confirms CI is a shared external devops-templates workflow, so the solution file is the actual local wiring mechanism).test/Compono.Http.AotSmokeTest(and itsAnalyzerContract/sub-projects, task 5) is deliberately not added toCompono.slnx, matching the pre-existing, already-unlistedCompono.AotSmokeTest/Compono.TUnit.AotSmokeTest/Compono.TestDoubles.AotSmokeTestconvention - a manual, one-shot proof driven by its ownpack-compono.sh+dotnet publish, not part ofdotnet build Compono.slnx. No CPMPackageVersionentry needed inDirectory.Packages.props- likeCompono.DependencyInjection, this package has zero externalPackageReference.docs/packages//skill wiring is task 12/12a, not this task.
2. TestHttpHandler¶
-
TestHttpHandler : HttpMessageHandler, overridingSendAsync(HttpRequestMessage, CancellationToken). -
OnGet/OnPost/OnPut/OnPatch/OnDelete(Match<string> path) : HttpResponseRegistrationBuilder(or equivalent fluent return — exact type shape is an implementation detail, not a plan-level decision) — method fixed by helper name,pathmatched viaMatch<string>.Matches(request.RequestUri.PathAndQuery)(confirm exact URI component matched —PathAndQueryper the realalexa-vox-craftevidence in ADR-0051/research §1). -
When(Func<HttpRequestMessage, bool> predicate) : HttpResponseRegistrationBuilder. - Ordered
List<HttpResponseRegistration>(plainList<T>, per the narrowed concurrency contract — no lock on this list itself). - Dispatch: record request into the log first, unconditionally; walk the list last-registered-first; on first match, invoke its response factory and
Interlocked.Incrementits count; on no match, throwUnmatchedHttpRequestException(method, URI, "no configured registration matched" in the message). - Disposal — honor
HttpMessageHandler's own inherited disposal contract; do not add redundantIDisposablemachinery.HttpMessageHandleralready implementsIDisposable;TestHttpHandlerdoes not need (and must not add) its own independentIDisposableimplementation on top of it.- Override
Dispose(bool disposing)only if handler-owned state genuinely needs cleanup on disposal, or an explicit already-disposed guard is needed beforeSendAsyncacts on registrations/the request log — if neither is true, don't override it at all and let the base class's behavior stand as-is. SendAsynccalled after disposal must behave per that contract: throwObjectDisposedException, matchingHttpMessageHandler's own base behavior — whether that's inherited for free or requires an explicit disposed check depends on what the base class already does, confirm at implementation time rather than assuming a guard is needed.- Task 7's caller-owned lifetime rules (who calls
Dispose()and when) are unchanged by this — this bullet is about howTestHttpHandlerimplements disposal behavior once called, not who is responsible for calling it.
- Override
3. HttpResponseRegistration¶
- One instance per
OnX(...)/When(...)call — holds the compiled matcher (Func<HttpRequestMessage, bool>, uniform internal shape regardless of which public entry point produced it), the response factory (Func<HttpRequestMessage, HttpResponseMessage>), and anintmatch count field. - Match count mutated only via
Interlocked.Increment— mirrorsReturnConfig<T>.RecordCall()'s existing pattern insrc/Compono/ReturnConfig.cs. -
public CallVerifier Verify()— wraps the count and a member description (method + path/predicate description) in the existing, unmodifiedCallVerifierfromsrc/Compono/CallVerifier.cs. No new verification type. PR-review amendment (2026-08-24): the original singleOnX(Match<string> path)shape discardedpathwhen building this description (every literal-path registration produced the same generic"GET request"text) — Codex review caught this on the implementation PR. Fixed by splitting eachOnXintoOnX(string path)(retains the literal path verbatim in the description, e.g.GET /v1/customers/42) andOnX(Match<string> path)(unchanged signature forMatch.Any/Match.Is, honestly generic description sinceMatch<T>'s deliberate opacity means Compono.Http can't tell those two apart) — a small, source-compatible overload split, not a design change. Recorded as ADR-0051's Amendment 1, with regression tests added (TestHttpHandlerTests.cs:TwoLiteralGetRegistrations_...,OnGet_MatchAny_VerifyFailureMessage_...,OnGet_MatchIs_VerifyFailureMessage_...). A second PR-review finding in the same round —RespondJsonBytesreused a sharedstatic readonly MediaTypeHeaderValueacross every response, so mutating one response'sContent.Headers.ContentTypesilently affected every other response — was also fixed (a freshMediaTypeHeaderValueper response) with its own regression test (RespondJson_MutatingOneResponsesContentType_...). Re-ran the full gate after this fix, per the standing rule below:dotnet test Compono.slnx -c Release— 2490/2490 (16 more than the prior 2474, the 4 new regression tests × 4 TFMs); both AOT proofs — PASS; fresh dogfood run vs.alexa-vox-craft(0.0.0-local.20260824160923-28165-4975) — 2816/2784/32/0, consumer dirty state (28 files) unchanged before/after. Second PR-review round (2026-08-24): two more Codex findings. (1)Finish(HttpResponseRegistrationBuilder.cs) had no guard against being called twice on the same retained builder — a secondRespond*/Throwscall silently overwrote the first registration's response factory and re-added the sameHttpResponseRegistrationto the handler's list a second time, so a caller holding onto the "first" handle would see it silently start returning the "second" response. Fixed with a_finishedguard throwingInvalidOperationExceptionon reuse, with two regression tests (FinalizingTheSameBuilderTwice_ThrowsInvalidOperationException,FinalizingTheSameBuilderTwice_FirstRegistrationHandleKeepsItsOriginalResponse). (2)test/Compono.Http.AotSmokeTest/AnalyzerContract/verify-analyzer-contract.shonly clearedpack-compono.sh's own restore cache, not the two analyzer caller projects' own separate, isolatedRestorePackagesPathcaches — since every pack reuses the fixed version1.0.0, a rerun after editingCompono.Httpsource could silently keep validating a stale extracted copy and report a false PASS. Fixed by clearing both callers'obj/.nuget-packagesbefore building. Re-ran the full gate again:dotnet test Compono.slnx -c Release— 2498/2498; Proof A (rerun with the cache fix in place, confirming it now genuinely rebuilds against current source) — PASS; Proof B — PASS; fresh dogfood run vs.alexa-vox-craft(0.0.0-local.20260824162119-31825-24977) — 2816/2784/32/0, consumer dirty state (28 files) unchanged before/after. Third PR-review round (2026-08-24): three Codex findings, all documentation/CI-wiring gaps rather thanCompono.Httpcode defects — the package was never actually wired into this repo's own package- readiness gate or docs pipeline once shipped. (1).github/workflows/package-validation.yamland.github/scripts/inspect-packed-nupkgs.shhad hardcoded seven- package lists that never includedCompono.Http— it was never baseline-checked, never CS1591-enforced, never.nupkg-content- inspected by the pre-merge gate. Fixed: added to both scripts' package lists/loops, plusinspect-packed-nupkgs.sh's ownCompono.Httpcase branch (title, exact-pinComponodependency, no third-party dependency to range-assert). No new "local-feed packed-consumer smoke test" step added —Compono.Httphas noSampleTestsproject (unlikeCompono.XunitV3/Compono.TUnit/Compono.TestDoubles, which do), the same situationCompono.NSubstitute/Compono.Bogus/Compono.DependencyInjectionare already in, so this matches existing precedent rather than inventing new infrastructure. (2)mkdocs.yml,.github/scripts/generate-api-reference.sh, and.github/workflows/docs.ymlall still only knew about the prior seven packages — no nav entry, no generated API reference, andsrc/Compono.Http/**changes wouldn't even trigger the docs workflow. Fixed: addedCompono.Httptomkdocs.yml's Package Guides/API Reference nav,generate-api-reference.sh'sintegration_pkgsarray (also fixed two now-doubly-stale "four publishable"/"three integration packages" comments in the same file, predating this PR), and both ofdocs.yml's path triggers plus its build loop and drift-check error message; regenerateddocs/reference/api/Compono.Http/for real (dotnet buildall eight packages, thengenerate-api-reference.sh) — confirmed viagit statusthat no other package's generated pages drifted, onlyCompono.Http's were added — and ranuv run mkdocs build --strictclean (exit 0, no broken nav/links). (3) Several canonical current- state docs still said "seven" and omittedCompono.Http:docs/roadmap/index.md(Today's package list — also corrected the wording to sayCompono.Http, likeCompono.DependencyInjection, didn't graduate fromfuture-packages.md's own Gate A/Gate B candidate list — it came from a dedicated admission research doc instead),docs/roadmap/future-packages.md(intro package count + a new explanatory paragraph matching the existingCompono.DependencyInjectionone),docs/public-api.md,docs/contributing.md,docs/documentation-architecture.md(two separate stale counts),docs/packages/index.md(one more "seven" instance in its own Version Compatibility section, missed in this plan's original task 12 pass), anddocs/getting-started/installation.md(added aCompono.Httpinstall line for consistency with the other "add as your tests need it" packages, not itself named by the review but directly adjacent). Re-ran the full gate a third time:dotnet test Compono.slnx -c Release— 2498/2498 (unchanged, no test code touched this round);dotnet build src/Compono.Http/Compono.Http.csproj -c Release -p:WarningsAsErrors=CS1591— clean, matching the CI gate's own enforcement step; Proof A — PASS; Proof B — PASS; fresh dogfood run vs.alexa-vox-craft(0.0.0-local.20260824201408-40021-18964) — 2816/2784/32/0, consumer dirty state (28 files) unchanged before/after. -
registration.Verify().Never()/.Once()/.Exactly(n)all work unchanged via the reusedCallVerifierAPI.
4. Response APIs¶
-
Respond(HttpStatusCode)— freshHttpResponseMessageper call, no content. -
RespondText(string content, string mediaType = "text/plain", Encoding? encoding = null)(confirm exact defaultmediaType/encoding against realalexa-vox-craftevidence, if any beyond JSON, at implementation time) — freshStringContentper call. -
RespondJson<T>(T value, JsonSerializerOptions? options = null)— serializes once at registration time viaJsonSerializer.SerializeToUtf8Bytes(value, options)(or theSerialize+Encoding.UTF8.GetBytesequivalent, whichever avoids an intermediate string most cleanly) to an immutablebyte[]; each invocation constructs a freshByteArrayContent(bytes)withContent.Headers.ContentTypeexplicitly set (application/json; charset=utf-8) per instance. -
RespondJson<T>(T value, JsonTypeInfo<T> jsonTypeInfo)— same once-serialized-bytes model, via theJsonTypeInfo<T>-basedSerializeoverload. -
Throws(Exception exception)— response factory becomes_ => throw exception;, same instance rethrown on every match (verified behavior, ADR-0051 — no cloning, no factory parameter). - Every
Respond*/Throwscall finalizes and returns theHttpResponseRegistrationhandle (notvoid). -
RespondBytes(byte[] content, string mediaType = "application/octet-stream")(ADR-0051 Amendment 2, added after this plan's originalDonestatus) —contentis defensively copied ((byte[])content.Clone()) once at registration time, not retained by reference; each invocation constructs a freshByteArrayContentover that private copy with its ownMediaTypeHeaderValue, matchingRespondJson's serialize-once-to-bytes model.
5. JSON/AOT correctness¶
-
RespondJson<T>(T value, JsonSerializerOptions? options = null)carries[RequiresDynamicCode(...)]and[RequiresUnreferencedCode(...)], usingSystem.Text.Json's own attribute message text (naming theRespondJson<T>(T, JsonTypeInfo<T>)overload as the AOT-safe alternative). -
RespondJson<T>(T value, JsonTypeInfo<T> jsonTypeInfo)carries neither attribute. - No
[UnconditionalSuppressMessage]anywhere inCompono.Http's implementation. -
Two separate proofs, not one — do not conflate them. Proof A is a static/analyzer-contract check (does the right warning appear at the right call site); Proof B is a real native-executable proof (does the AOT-safe path actually publish and run under Native AOT). They test different things and have different success criteria — collapsing them into one project/assertion risks silently requiring the options overload to be warning-free under
PublishAot, which is not its contract (its contract is to advertise the framework's real constraint honestly, per ADR-0051 — see §12's "Attribute propagation, verified empirically").Proof A — analyzer-contract proof, implemented and passing (
test/Compono.Http.AotSmokeTest/AnalyzerContract/: two sibling throwaway console projects,OptionsOverloadCallerandJsonTypeInfoOverloadCaller, eachIsAotCompatible=trueand referencingCompono.HttpviaPackageReferenceagainst a locally packed nupkg — notProjectReference, and each declaring<TargetFrameworks>net10.0</TargetFrameworks>(plural, matchingtest/Directory.Build.props' own property name), not singular<TargetFramework>;verify-analyzer-contract.shpacks, builds each with-p:WarningsAsErrors="IL2026%3BIL3050", and asserts the first build fails with exactly those diagnostics while the second succeeds warning-free) confirms: -OptionsOverloadCaller(callingRespondJson(value, options)) surfaces IL2026 + IL3050 at that consumer's own call site; -JsonTypeInfoOverloadCaller(callingRespondJson(value, jsonTypeInfo)) produces zero IL2026/IL3050 warnings; - both captured as an automated, CI-checkable pass/fail script, not eyeballed once and left undocumented.Two real implementation discoveries surfaced empirically while building this proof, not assumed in advance: 1.
Compono.Http.csprojitself needs<IsAotCompatible>true</IsAotCompatible>. The .NET trim/AOT analyzer only enforces aRequires*attribute at a consumer's call site for a member defined in an assembly marked trimmable — without this,RespondJson(value, options)silently produced zero warnings anywhere, defeating the entire point of the attributes. Adding it doesn't affectCompono.Http's own build (still 0 warnings — the method's own attribute already covers its one internal unsafe call, per the same behavior verified in ADR-0051's original spike) but is required for consumers to see the warning. SeeCompono.Http.csproj's own comment. 2. A caller project must declare pluralTargetFrameworks, not singularTargetFramework, when overridingtest/Directory.Build.props' 4-TFM sweep down to one TFM. Setting singularTargetFrameworkinstead leaves the props'TargetFrameworks(plural) value still in effect alongside it — verified empirically that this dual state silently disables the AOT/trim analyzer (zero diagnostics, no build error,EnableAotAnalyzer/IsAotCompatiblestill both reportingtrue) with nothing to signal why.test/Compono.TestDoubles.AotSmokeTest.csproj(and this plan's ownCompono.Http.AotSmokeTest.csproj) already used the correct plural form; the two newAnalyzerContract/caller projects needed the same fix. SeeOptionsOverloadCaller.csproj's own comment for the full A/B-tested trail.Proof B — Native AOT publish-and-run proof, implemented and passing (
test/Compono.Http.AotSmokeTest, followingtest/Compono.TestDoubles.AotSmokeTest's establishedPublishAot=true-publish-and-run pattern against a packed local package viapack-compono.sh): uses only theRespondJson(value, jsonTypeInfo)overload —Compono.Http.AotSmokeTest.csprojexplicitly excludesAnalyzerContract/'s own.csfiles via<Compile Remove>(SDK-style implicit globbing would otherwise pull the sibling proof'sProgram.csfiles into this project's compilation and fail with duplicate-attributeCS0579errors, discovered empirically the first time both proofs were run back-to-back) — and proves the actual packedCompono.Httppackage publishes and runs successfully withPublishAot=trueonosx-arm64, exercisingOnGet+Match<string>, last-match-wins, strictUnmatchedHttpRequestException(withRequestsstill recording the request that caused it),registration.Verify(), andThrows' same-instance rethrow, end to end through the real dependency chain. TheJsonSerializerOptions-based overload is never required to publish warning-free (or at all) underPublishAotin this or any proof — that overload's correct behavior is surfacing the framework's realRequiresDynamicCode/RequiresUnreferencedCodeconstraint, not avoiding it.
6. Request logging¶
- Backing store for
Requests: a lock-guardedList<HttpRequestMessage>orConcurrentQueue<HttpRequestMessage>— pick whichever gives the cleaner snapshot-on-read implementation; either satisfies the contract (implementation detail, not a plan-level decision). -
Requestsgetter returns a fresh point-in-time copy (IReadOnlyList<HttpRequestMessage>, e.g. viaToArray()/ImmutableArray.CreateRange) on every access — never a live view over the mutable backing store. - Recording happens before matching is attempted, for every request including ones that go on to throw
UnmatchedHttpRequestException. - Recording order matches arrival order (the order
SendAsyncwas invoked), preserved even under concurrentSendAsynccalls (i.e. the backing store's append is itself ordered/atomic per call, not reordered by the synchronization mechanism chosen).
7. Lifecycle/disposal¶
-
CreateClient(Uri? baseAddress = null) : HttpClient— alwaysnew HttpClient(this, disposeHandler: false), withBaseAddressset from the parameter when provided. No overload or parameter ever setsdisposeHandler: true. - No Compono composition hook disposes
TestHttpHandler— confirm by a test asserting a[Shared]-composed handler is not disposed when its owningCompositionRow/scope goes out of scope (regression guard against accidentally wiring disposal in later). - Documented via XML doc (
TestHttpHandler/CreateClient's doc comments) that the caller must dispose both everyHttpClientit creates and the handler itself; the package-doc restatement is task 12, not yet done. - Test: dispose the handler, then attempt
SendAsyncthrough an existingHttpClientstill wrapping it →ObjectDisposedException.
8. Concurrency¶
- Registration list stays a plain
List<HttpResponseRegistration>— no lock added around it (per the narrowed contract: configuration isn't guaranteed concurrent with sends). - Match-count increments use
Interlocked.Incrementexclusively — no lock needed there. - Request log uses whichever synchronized structure task 6 settled on.
- Concurrency test: many parallel
SendAsynccalls (against one sharedHttpClient/handler) complete without exceptions,Requests.Countequals the number of calls made, and each matched registration'sVerify().Exactly(n)reports the correct count — no lost updates.
9. Behavioral tests (test/Compono.Http.Tests)¶
- Exact-path
OnGet/etc. match and respond correctly. -
Match.Any<string>()path matching. -
Match.Is<string>(predicate)path matching. - Whole-request
When(predicate)matching (method + URI + header + content-type combination, mirroring the realFormUrlEncodedContent-type-check evidence). - Each
OnGet/OnPost/OnPut/OnPatch/OnDeletehelper fixes its method correctly. - Last-match-wins: two overlapping registrations, later one wins.
- Explicit catch-all fallback (
When(_ => true)) composes correctly with a more specific override registered after it. - Unmatched request →
UnmatchedHttpRequestExceptionwith method + URI + "no match" in the message. - Unmatched request still appears in
Requestsafter the exception. - Repeated matches against one registration get a fresh
HttpResponseMessage/content each time (assert noObjectDisposedExceptionreading content on the second/third match after the first response's content was read/disposed by a consumer). -
RespondJsonsetsContent-Type: application/json; charset=utf-8correctly on every freshByteArrayContent. -
RespondBytesround-trips content and defaults toapplication/octet-stream, honors a suppliedmediaType, and a mutation to the caller's array after registration doesn't affect an already-registered response (ADR-0051 Amendment 2 — added after this plan's originalDonestatus). -
Throws(exception)rethrows the exact same instance (ReferenceEquals) on repeated matches. -
registration.Verify().Never()/.Once()/.Exactly(n)— including the failure path (wrong count throwsTestDoubleVerificationException, matchingCallVerifier's existing contract). - Concurrent
SendAsync/request-recording correctness (task 8). -
Requestssnapshot stability — mutate/append after taking aRequestsreference, assert the earlier reference didn't change. - Disposal semantics (task 7's tests, restated here for completeness of the test-plan enumeration — not duplicated work).
10. alexa-vox-craft dogfood migration (expanded scope)¶
This task's goal is no longer just "prove Compono.Http works." It is: use Compono.Http as the trigger for a realistic migration of alexa-vox-craft's test composition toward the Compono ecosystem where that migration is clean and supported, and surface — not silently paper over — every real product gap that prevents it. This does not expand ADR-0051 itself, which defines Compono.Http only; the migration's findings live in this plan and a dogfood research document (task 10g), the same way docs/research/0008-...md recorded trivia-platform's migration evidence for Compono.TestDoubles without touching that capability's own ADR.
Target direction (not a mandate to force every test onto it — see 10c's gap-handling rule and 10d's scope boundary):
AutoFixture / AutoFixture.AutoNSubstitute -> Compono
NSubstitute (direct Substitute.For<T>()) -> Compono.TestDoubles
reflection-based HttpMessageHandler fakes -> Compono.Http
hand-written/random fixture data -> Compono.Bogus, only where
real semantic-data value exists
10a. Inventory (before making any change)¶
- Inventory
alexa-vox-craft's full test-composition surface — AutoFixture references/customizations,AutoFixture.AutoNSubstituteusage, directSubstitute.For<T>()call sites, customAutoDataattributes (test/AlexaVoxCraft.TestKit/Attributes/BaseFixtureFactory.csand its subclasses/siblings across test projects), fixture factories/builders/customizations,[Frozen]usage, custom specimen builders (including but not limited to the HTTP TestKit's), shared test profiles/base fixtures, manually generated semantic test data, existing hand-written fake/test-double classes, HTTP-specific helpers (task 10's original scope), and any place semantic-data generation (realistic names/emails/addresses/IDs/domain text) is already done by hand or viaAutoFixturecustomization in a wayCompono.Bogusmight genuinely improve. - Classify every mechanism found into exactly one bucket, each with a concrete reason, not a guess:
- directly replaceable by core
Compono; - directly replaceable by
Compono.TestDoubles; - directly replaceable by
Compono.Http; - a good
Compono.Boguscandidate (real semantic-data value, not "arbitrary values that happen to need generating"); - project-local setup that should remain project-local;
- an unsupported Compono capability / product gap;
- an intentional difference where the existing solution should remain (with the reason recorded, not just "left alone").
- directly replaceable by core
- Do not mechanically delete or replace any inventoried mechanism before this classification exists — the inventory is a prerequisite to 10b, not parallel work. (Classification recorded in
docs/research/0010-...md§14.)
10b. Migration (in priority order, per 10d's scope boundary)¶
- Removed
test/AlexaVoxCraft.Http.TestKit/Extensions/HttpMessageHandlerExtensions.cs's reflection-basedReturnsResponse— the entireAlexaVoxCraft.Http.TestKitproject is deleted (all 4 of its files existed only to support the old reflection-based approach). - Migrated all real HTTP call sites across
AlexaInteractionModelClientTests.cs,AlexaSkillInvocationClientTests.cs,SmapiDeveloperAccessTokenProviderTests.cs,InSkillPurchasingClientTests.cstoCompono.Http's registration API. Assertion intent preserved per-file (spot-checked, not just "compiles and passes"): every.Received()/.ReceivedCalls().Should().HaveCount(n)became a realregistration.Verify().Once()/.Exactly(n)— including fixing a latent gap in the original code, wherehandler.Received();as a bare statement (no chained assertion) was a silent no-op that never actually asserted anything; the migrated version genuinely verifies. Full 10g write-up with before/after detail still pending (tracked separately, not blocking this checkbox). - Where the inventory classified a mechanism as bucket ½/3, migrated it toward normal Compono usage rather than layering
Compono.Httpunderneath the existing AutoFixture root — confirmed empirically, file by file:AutoDataAttribute/customAutoDatasubclasses (SmapiClientAutoDataAttribute,SkillInvocationClientAutoDataAttribute,IspClientAutoDataAttribute,ClientAutoDataAttribute) →Compono.XunitV3's[Compose]/[Compose<TProfile>](this repo's test framework is xUnit v3, confirmed);[Frozen] HttpMessageHandler→[Shared] TestHttpHandler;HttpClientSpecimenBuilder/HttpClientSpecification/theFreeze<HttpMessageHandler>()plumbing inClientAutoDataAttribute(+2 subclasses) → deleted entirely, replaced by[Shared] TestHttpHandlercomposition (Smapi.Tests) and plain[Compose](InSkillPurchasing.Tests, no shared profile needed there);InteractionModelDefinitionSpecimenBuilder,SkillRequestSpecimenBuilder,SkillInvocationResponseSpecimenBuilder,InSkillProductSpecimenBuilder,TransactionSpecimenBuilder(and theirRequestSpecifications/siblings) → deleted entirely; the domain types they hand-built (InteractionModelDefinition,SkillRequest,SkillInvocationResponse<SkillResponse>,Product,ProductResponse,PurchasingEnabled,TransactionResponse) all compose cleanly via plain Compono auto-composition with zero custom logic — confirmed by real compilation, not assumed (bucket 1, not bucket 6 — no capability gap for any of these).AutoNSubstitute-generated interface doubles: not applicable here — none of the HTTP-touched clients had a substitutable interface dependency onceHttpClientconstruction was handled directly;Compono.NSubstituteended up not needed by any converted file (confirmed by removing the reference and rebuilding clean).Compono.Bogus: not adopted for this slice — no bucket-4 semantic- data need surfaced in the HTTP-touched files themselves (theamzn1.*-prefixed ID pattern noted in 10a lives in sharedAlexaVoxCraft.TestKitspecimen builders, out of this pass's scope per 10d).
- Do not keep NSubstitute in a shared fixture merely because it's already wired in, if
Compono.TestDoublescan represent the real behavior needed — inertia is not bucket-7 justification. (Confirmed:Compono.NSubstitute/Compono.TestDoublesreferences were both speculatively added, then removed once the migrated files proved not to need either — recorded indocs/research/0010-...md§3.) - Do not remove NSubstitute by substituting a hand-written fake merely to make the dependency graph look cleaner. A hand-written fake replacing NSubstitute is acceptable only when it demonstrates a real, currently-unsupported
Compono.TestDoublescapability — and that occurrence must be documented as a workaround/evidence item in 10c's gap report, the same waydocs/research/0008-...mddocumentedtrivia-platform'sMultiStubLeaderboardRepositoryworkaround. (No such substitution occurred this pass —FakeHttpClientFactoryis a new capability this pass introduced, not a replacement for an existing NSubstitute double.) - Replaced
LocaleHandlerTests.cs's two predicate-side-effect request captures with directhandler.Requestsreads (innerHandler.Requests.Should().ContainSingle().Which.Headers.AcceptLanguage...). Its third test (InlineAlexaVoxCraftAutoData-based, non-HTTP) is deliberately left unchanged, out of scope. - Migrated
SmapiDeveloperAccessTokenProviderTests.cs'sIHttpClientFactoryusage using the small project-localFakeHttpClientFactoryshape from ADR-0051/research §8.2 — a private nested class insideSmapiHttpTestProfile, registered viaRegister<IHttpClientFactory>. Real, evidence-grounded refinement found during implementation:Register<IHttpClientFactory>'s own factory internally doescontext.Resolve<HttpClient>()/context.Resolve<string>()(both provider-resolved paths, safe at runtime) rather than composingSmapiDeveloperAccessTokenOptionsitself viacontext.Resolve<SmapiDeveloperAccessTokenOptions>()— the latter threwCompositionExceptionat runtime ("No ... generated plan could satisfy") because that record type is never independently reachable as a compile-time discovery root anywhere in the project; a type reached only via a nestedcontext.Resolve<T>()call inside a registration factory isn't itself a root the generator can see, unlike a provider-resolved primitive. Recorded as a real Compono discoverability nuance for 10g (not a blocking gap — worked around cleanly by building the record from resolved primitives instead). - Confirmed (not assumed) that
Register<T>provides no compile-time escape hatch for a concrete class reached through another composed type's own constructor parameter, when that concrete class has an ambiguous (multi-constructor) BCL shape:AlexaInteractionModelClient/AlexaSkillInvocationClient/InSkillPurchasingClientall take a rawHttpClientconstructor parameter, and requesting any of them as a composed theory parameter hitCMP0001("HttpClient ... has 3 accessible constructors") even withRegister<HttpClient>present in the active profile —TransitiveClosureWalker's compile-time walk never consults registrations for a structurally-reached constructor parameter (LeafTypeClassifier.IsProviderResolvedexcludes concrete non-value-type classes unconditionally). This matches and reconfirms the Compono skill's own already-documented ADR-0002 guidance ("no registration-based escape hatch... construct by hand in that one spot") with fresh real-world evidence, not a new gap — classified as previously known limitation per 10c. Fixed by hand-constructing each client via a smallCreateClient(handler)helper per test class (new AlexaInteractionModelClient(handler.CreateClient(baseAddress), NullLogger<AlexaInteractionModelClient>.Instance)), matching the skill's own documented pattern;Register<HttpClient>in the profile is still genuinely used (byIHttpClientFactory's own registration factory), just never by a directly-composed client type. - Stretch, non-blocking: add
BearerTokenHandlerTests.cs(currently absent) using the new package, if low-risk. Not attempted this pass — genuinely non-blocking, left for a follow-up. - Stretch, non-blocking: migrate the two legacy
ActionHandler/ActionMessageHandlerfakes if low-risk. Not attempted this pass — both live in "Legacy" test projects outside this pass's priority-½ scope (per 10d), left as-is.
10c. Gap-handling rule (adversarial dogfooding — do not paper over blockers)¶
- If a real
alexa-vox-crafttest is naturally expressible with AutoFixture/NSubstitute but cannot currently be expressed with the corresponding Compono package, do not silently work around it and report the migration as though it succeeded. Report it, in the dogfood research document (10g), with:- exact test/file;
- the interface/member/data shape involved;
- the existing AutoFixture/NSubstitute behavior;
- the attempted Compono equivalent;
- precisely why Compono cannot currently represent it;
- classification as one of: a bug, a previously known limitation (cite the existing ADR/research doc that already recorded it, if any), a newly evidenced capability gap, or project-local behavior Compono should not own.
- Classify every finding using the existing ADR-0029 dogfooding/ capability-gap decision framework (the same rubric
docs/research/0008-...md/0009-...mdalready apply) — don't invent a new classification scheme for this pass. - A newly evidenced capability gap discovered here does not block this plan's completion by itself — record it, and let the user decide separately whether it becomes its own ADR/plan candidate. What does block completion is silently avoiding the attempt or misreporting a blocked migration as done.
10d. Scope boundary — broad, not unbounded¶
- Prioritize, in this order: (1) all tests touched by the
Compono.Httpmigration itself; (2) their shared fixture/profile infrastructure; (3) adjacent test suites consuming the same AutoFixture/NSubstitute composition mechanisms as those touched suites; (4) further migration only where removing a shared dependency/composition root naturally cascades to it. - If changing one shared AutoFixture/AutoNSubstitute root turns out to affect many tests, treat that as real dogfood evidence to handle deliberately (documented in 10g), not a reason to avoid touching the shared root.
- If an unrelated area has unique, risky test infrastructure with no relationship to this migration, leave it alone and record that boundary explicitly in 10g — "not migrated, out of scope, because X" is an acceptable outcome; silent omission is not.
- This is not a "rewrite every test in
alexa-vox-craft" project — the priority order above is the actual scope limiter.
10e. Compono.Bogus policy¶
- Adopt
Compono.Bogusonly where 10a's inventory found a real semantic-data need (realistic names, emails, addresses, IDs, domain text, or similar) that it genuinely improves over generic object composition. - Do not use
Compono.Bogusfor ordinary arbitrary values merely to demonstrate package coverage. - "
Compono.Bogusprovides no meaningful value in this repo" is a fully acceptable outcome — record it as such in 10g if that's what the inventory shows, rather than forcing an adoption to avoid reporting a null result.
10f. Dependency-graph acceptance¶
- At the end of the migration, verify — against the actual resolved dependency graph/package assets (
obj/project.assets.jsonor equivalent, not a source-onlygrep) — whetherAutoFixture,AutoFixture.AutoNSubstitute, andNSubstituteare still referenced anywhere inalexa-vox-craft, and where. - Confirm whether
Compono.TestDoublesfully replaced NSubstitute usage, or whether some remains, and why. - Confirm where
Compono.Httpis consumed (which projects/test files). - Confirm whether
Compono.Boguswas justified and adopted per 10e, or deliberately not. - Enumerate every remaining legacy test helper/fake and the reason it remains (bucket 5 or 7 from 10a, or an explicitly-documented gap from 10c) — every remaining AutoFixture/NSubstitute use must have an explicit reason, not "not migrated yet" as a default.
10g. Dogfood acceptance evidence (research document)¶
- Write the findings above into a new
docs/research/0010-alexa-vox-craft-compono-ecosystem-migration.md(next sequential research number at implementation time), followingdocs/research/0008-...md's existing format for a real-migration dogfood record. Must include, beyond ADR-0051's own HTTP acceptance criteria:- before/after test-composition architecture description;
AutoFixture/AutoFixture.AutoNSubstitute/NSubstituteusage counts, before and after (call-site counts, not just "present/ absent");Compono/Compono.TestDoubles/Compono.Http/Compono.Bogususage introduced (call-site counts);- exact remaining non-Compono test-double/fixture infrastructure and why it remains (10f);
- every newly surfaced Compono gap (10c), classified;
- complete
alexa-vox-crafttest counts (before/after, confirming no test was silently dropped rather than migrated); - the exact locally packed Compono package versions consumed (Task 11);
- explicit confirmation the old reflection-based HTTP path is fully gone;
- explicit confirmation the migration did not weaken any test's assertion intent merely to make a Compono package fit — a per-file spot-check note, not a blanket claim.
11. Local-package validation (multi-package, migration-aware)¶
The generalized dogfood-validation script is itself a shipping artifact of this plan — committed in this repo, not an ad-hoc sequence of commands known only to whoever runs this migration. The real script already exists: scripts/dogfood-validate.sh (438 lines, extensively invariant-hardened across PR #108's review rounds — see its own header comment and docs/research/0008-...md/docs/adr/0050-...md for that history). It already generalizes over consumer repo/solution (--consumer-repo/ DOGFOOD_CONSUMER_REPO, --consumer-solution/DOGFOOD_CONSUMER_SOLUTION, auto-detected if omitted) and build configuration (--configuration/DOGFOOD_CONFIGURATION) — it is not hardcoded to trivia-platform architecturally, only defaulted to it (consumer_repo="${DOGFOOD_CONSUMER_REPO:-.../trivia-platform}"). The one genuinely hardcoded piece this plan must change is the package set: packages=(Compono Compono.NSubstitute Compono.TestDoubles Compono.XunitV3) is a fixed bash array, not a parameter.
- Add a
--packages/DOGFOOD_PACKAGESoption (space- or comma-separated package-id list, following this script's existing flag/env-var-pair convention exactly) that replaces the hardcodedpackages=(...)array — default it to the current four-package list so every existing invocation (CI, any other in-flight usage) is unaffected unless the new option is passed explicitly. This is a targeted extension of the existing script, not a rewrite — every other step (packing loop, tempnuget.config/Directory.Packages.propsgeneration, version-pinsedloop,project.assets.jsonresolved-version verification loop, git dirty-tree snapshot/restore trap, cross-process pack lock) already iterates over thepackagesarray generically and needs no further change once that array is parameterized. - Confirm, and preserve unchanged, every invariant the script already establishes (do not weaken any of these while adding the
--packagesoption):- unique local prerelease package version per run (
0.0.0-local.<timestamp>-<pid>-<random>, shared across every packed package in the run); - cross-process pack lock, keyed on the repo root's
src/Compono*build outputs, not the feed directory; - no edit to the consumer's real
Directory.Packages.props— a temp copy, referenced via-p:DirectoryPackagesPropsPath; - the selected
--configurationpropagated through bothpackandtest; - exact resolved-package/version verification for every package in the (now-parameterized) set, against every restored project's
project.assets.json— including the existing per-package "found in at least one assets file" check, so a consumer that doesn't actually reference one of the requested packages fails loudly instead of silently passing; - full consumer solution test execution (
dotnet test, propagating its real exit code); - the consumer repo's git status/diff snapshot-and-restore safety net (unconditional
trap cleanup EXIT), so the consumer's own dirty/uncommitted files are byte-identical before and after, regardless of outcome; - non-zero exit on any failed invariant (missing package, version mismatch, restore failure, test failure, safety-net restore failure).
- unique local prerelease package version per run (
- Invoked the generalized script for this plan's actual validation runs:
--consumer-repo /Users/ncipollina/source/repos/layered-craft/alexa-vox-craft --consumer-solution AlexaVoxCraft.slnx --packages "Compono Compono.XunitV3 Compono.Http" --configuration Release. The migration ended up consuming exactly these three (this repo's test-framework integration isCompono.XunitV3, confirmed;Compono.TestDoublesandCompono.NSubstituteturned out unneeded, per the 10b findings above;Compono.Bogusnot justified per 10e). Every package consumed by the selectedalexa-vox-craftsolution came from this same fresh local run's version set — confirmed by the script's own per-package resolved-version check, which passed. - Updated
scripts/dogfood-validate.sh --help's usage text and its own header comment to describe the generalized--packagesoption, matching the file's existing documentation style. - Cross-reference:
AGENTS.md's "Consumer/dogfood validation gate" section already states the standing repo-level policy this task's script serves (updated as part of this same review round, ahead of this plan's implementation, to describe multiple consumers and a parameterized package set) — this task makes the tooling match what that policy already requires, it doesn't establish the policy itself. - Ran, and recorded the result of, the full gate — initial implementation run done; must be repeated after every subsequent review-feedback round, before the PR is considered ready:
Compono/Compono.XunitV3/Compono.Httptest suites green — [x] (dotnet test Compono.slnx: 732/732, plus the two AOT proofs).- Pack fresh, uniquely-versioned local packages for every Compono package the migration consumes — [x] (
Compono,Compono.XunitV3,Compono.Http, not justCompono+Compono.Http— confirmed via the script's ownpackages:echo line). - Restore
alexa-vox-craftagainst those exact packages — [x] (script's own temp-Directory.Packages.props-override restore succeeded). - Verify every intended Compono package actually resolved from the fresh local build — [x] the script's per-package resolved-version check passed for all three (no "STALE VERSION" output; no package in the consumed set mixed freshly-packed with previously published).
- Run the complete
alexa-vox-crafttest suite — [x] green: 2816 total test executions (704 tests × 4 TFMs), 2784 succeeded, 32 skipped (pre-existing, unrelated[Fact(Skip = "Temporarily skipping due to CI issues")]markers already present before this migration), 0 failed. - Inspect the remaining
AutoFixture/NSubstitutedependency graph as part of this same gate run — done at the task 10f level (see that task's checkboxes); not re-run as a separate pass here. - Only once steps 1-6 all pass may the Compono working tree be pushed — steps 1-6 passed; the working tree has not been pushed (per explicit instruction, separate from this gate).
- Mandatory before the PR is ready, not optional polish — satisfied for the initial implementation.
- After every substantive PR review-feedback change, repeat the full fresh-package consumer-validation gate from step 1 — a dogfood run from before the latest change does not validate the revised code, and does not authorize a push. Record each re-run (even a short note, including which packages were re-packed) so a reviewer can see validation is current, not stale. This is a standing, repeat-each-round rule, not a one-time task — but a final re-run was in fact required and done, since tasks 12/12a's documentation/skill work landed after the initial round above. Final re-run (2026-08-24), full gate, in order:
dotnet test Compono.slnx -c Release: 2474/2474 passed, 0 failed, 0 skipped (includesCompono.Http.Testsacross all 4 TFMs).- Proof A (
AnalyzerContract/verify-analyzer-contract.sh): PASS —RespondJson(value, options)confirmed IL2026+IL3050 at the consumer call site;RespondJson(value, jsonTypeInfo)confirmed zero warnings. - Proof B (
Compono.Http.AotSmokeTest,dotnet publish -f net10.0 -r osx-arm64 -p:PublishAot=true): published and ran clean —PASS: TestHttpHandler (...) survived Native AOT through the packaged Compono.Http dependency chain. - Skill/eval validation: every code example in
skills/compono/references/http.md/docs/packages/compono-http.mdcompiled clean (0 errors, 0 warnings) against the real builtCompono.Http.dllvia a throwawayProjectReferenceconsole project;skills/compono-evals/evals.jsonre-validated as well-formed JSON after adding eval 22. - Fresh pack +
scripts/dogfood-validate.sh --consumer-repo .../alexa-vox-craft --consumer-solution AlexaVoxCraft.slnx --packages "Compono Compono.XunitV3 Compono.Http" --configuration Release: packedCompono/Compono.XunitV3/Compono.Httpat a fresh unique version (0.0.0-local.20260824154311-19509-3472, different from the initial round's — confirms this re-run genuinely repacked, not reused a stale artifact); resolved- version check passed for all three; fullalexa-vox-craftsuite: 2816 total, 2784 succeeded, 32 skipped, 0 failed — exact match to the initial round's numbers. - Consumer git dirty-state check:
git status --porcelain | wc -l→ 28, both immediately before and immediately after the gate run — byte-identical, confirming the script's safety net held. Result: PASS. The working tree is now eligible to be pushed per the standing gate policy — it has not been pushed (no push/PR authorization given in this session).
12. Documentation¶
-
docs/packages/compono-http.md(new, following the existingdocs/packages/*.mdper-package doc pattern) covering:- matching/precedence semantics (last-match-wins,
Match<string>vs.Func<HttpRequestMessage, bool>split and why); - strict unmatched-request behavior and how to configure an explicit fallback;
- the verification model (
registration.Verify().Once(), kept separate fromRequests); - request-log semantics (raw references, snapshot-per-access, recorded before matching);
- the caller-owned lifetime model (handler and every
HttpClientare caller-disposed; Compono composition never owns disposal); - JSON/AOT warning behavior and the
JsonTypeInfo<T>path, including a short "if you're publishing Native AOT, use this overload" callout; - explicit v1 non-goals (the list in this plan's Scope section, condensed for a package-doc reader).
- matching/precedence semantics (last-match-wins,
- XML doc comments on every public member — this repo's established bar per
references/coding-standards.md/documentation.md(confirm exact expectations there at implementation time rather than guessing). -
README.md/docs/mvp.mdpackage-list update if those docs currently enumerate shipped packages (confirm at implementation time whetherCompono.Httpneeds to be added there, following whatever precedentCompono.DependencyInjection's own addition set).
12a. Compono agent skill update — a shipping requirement, not follow-up¶
skills/compono/SKILL.md (plus its skills/compono/references/*.md files) is the separate product surface that teaches an agent which Compono packages exist, how to choose between them, and how to use them — confirmed by inspecting the actual current skill structure rather than guessing a path: skills/compono/SKILL.md is the entry point (Detection table, Default workflow, Guardrails, "When not to use Compono", a References table gating which references/*.md file loads for which package), and each already-shipped package (Compono.XunitV3/Compono.TUnit/Compono.NSubstitute/Compono.Bogus/ Compono.TestDoubles/Compono.DependencyInjection) has its own references/<package>.md file. Compono.DependencyInjection's own plan (PLAN-0047 finding 31/33) and Compono.TUnit's (PLAN-0040) both already establish the precedent this task follows exactly: a new package gets a Detection-table row, a SKILL.md description/guardrail update, and its own references/<package>.md file, landed in the same PR that ships the package's code — not deferred. docs/packages/compono-http.md above and this skill update are separate, both-required surfaces per the review feedback that opened this task; neither substitutes for the other.
This is written from the shipped, final public API once tasks 2-8 are implemented — not from ADR-0051's illustrative examples, which may not match the final method/type names exactly. Do not draft skill content speculatively ahead of the implementation; write or finalize it once the public API is locked, then verify every code example in it actually compiles against the real package (a mechanical check, not a guess).
-
skills/compono/SKILL.mdfrontmatterdescription: addCompono.Httpto the enumerated optional-package list (currently "Compono.XunitV3/Compono.TUnit/Compono.NSubstitute/Compono.Bogus/Compono.TestDoubles/Compono.DependencyInjection"). - Detection table: new row —
<PackageReference Include="Compono.Http"→ Definitive confidence → "TestHttpHandler/OnGet/OnPost/etc. available — loadreferences/http.md" (exact member names confirmed against the shipped API, per the note above). - Default workflow step 3 (mechanism-selection list): add a bullet for "a test deliberately needs to exercise the real HTTP client pipeline (real
HttpClient→TestHttpHandler→ configured response) rather than substitute an API abstraction away" →Compono.Http'sTestHttpHandler, if that package is referenced — cross-referencing the existing NSubstitute/TestDoubles bullet immediately above it, since the two are a real decision point (see new reference file's "When to use" / "When NOT to use" sections below). - Guardrails' shipped-packages enumeration (the "Never claim or write code against a Compono integration package that hasn't shipped" bullet): add
Compono.Httpto the list of packages that do ship, alongside a one-line note of what it ships (mirroring the existing one-line notes forCompono.TUnit/Compono.TestDoubles/Compono.DependencyInjectionin that same bullet) — e.g. "shipsTestHttpHandler, a reflection-freeHttpMessageHandler-based test double forHttpClient-consuming code; does not shipIHttpClientFactory/named-client integration." - Correct the existing "When not to use Compono"
HttpClientbullet (SKILL.md's current text:HttpClientas an ambiguous-constructor BCL type hittingCMP0001, worked around via a hand-builtIHttpClientProviderinterface wrapper). This bullet is about a different problem thanCompono.Httpsolves — composing an already-configured, realHttpClientvalue viaComposer.Create<HttpClient>()still hitsCMP0001and still needs that same interface-wrapper/hand-construction workaround;Compono.Httpdoes not change that. What changes is the adjacent question the same bullet's reader is likely actually asking: "how do I test code that consumes anHttpClient?" — add a clarifying cross-reference distinguishing the two ("if the goal is testing code that consumes anHttpClient/HttpMessageHandler, seeCompono.Http'sTestHttpHandlerinstead of hand-wrapping an interface just to fake HTTP responses; the interface-wrapper workaround described here is specifically for composing a real, already-configuredHttpClientvalue, which remains unsupported"). Get this distinction precise — conflating the two would misteach an agent thatCompono.HttpremovesCMP0001, which it doesn't. - References table: new row —
references/http.md|Compono.Httpis referenced —TestHttpHandler/matching/verification/lifetime work. - New
skills/compono/references/http.md, matching the shape and register of the existingreferences/*.mdfiles (short, example-driven, no restating whatSKILL.mdalready covers), and teaching at minimum — content sourced from ADR-0051 §"Decision Outcome" plus, once implemented, verified directly against the shipped API:- When to use
Compono.Http: a test deliberately needs to exercise the realHttpClientpipeline (real HttpClient -> TestHttpHandler -> configured HTTP response) — concrete clients built directly onHttpClient, testing URI/method/header/request construction, testing serialization/request-pipeline behavior, testingDelegatingHandlerbehavior, replacing a hand-written or reflection-basedHttpMessageHandlerfake. - When NOT to use it: the production seam is already an ordinary application interface (
ICustomerApi,IWeatherService, etc.) and the test doesn't care about HTTP behavior specifically — that stays aCompono.TestDoubles/Compono.NSubstitutecase. Explicit reminder: never special-caseHttpClient/HttpMessageHandlerthroughCompono.TestDoubles— that boundary is architectural (ADR-0051), not a v1-only limitation. - Core usage vocabulary: idiomatic examples using the actual shipped API — the equivalents of
OnGet(path).RespondJson(...),OnGet(Match.Is<string>(...)).RespondJson(...),When(predicate).Respond(...), capturing and using a registration handle,registration.Verify().Once(),handler.Requests,handler.CreateClient(...). Written from the final method/type names, not copied verbatim from the ADR's illustrative examples. - Matching semantics:
OnGet/OnPost/etc. useMatch<string>for the path (literal = equality,Match.Any<string>(),Match.Is<string>(...)all available); whole-request matching usesWhen(Func<HttpRequestMessage, bool>); last-match-wins. Explicitly do not teach a dedicated header/query/body matcher DSL — v1 intentionally has none (theWhen(...)predicate is the only mechanism for those dimensions). - Unmatched behavior: strict by default —
UnmatchedHttpRequestException. A test wanting fallback behavior configures an explicit catch-all registration; there is no loose-mode switch to look for. - Verification vs. request inspection:
registration.Verify()answers "how many times did this configured behavior match";handler.Requestsanswers "what actually reached the handler." Never suggest reconstructing a verification predicate or an expression-basedVerify(...)API — that shape was considered and rejected in ADR-0051. - Lifetime (called out as important since Compono has no composition-owned disposal today):
TestHttpHandleris caller-owned; theHttpClientfromCreateClient()is caller-owned;CreateClientalways usesdisposeHandler: false;[Shared]gives identity/reuse only; Compono does not currently dispose[Shared]-composedIDisposablevalues. Do not teach or imply automatic composition-scope disposal — if a later core ADR changes that, this reference updates then, not preemptively. IHttpClientFactoryboundary:Compono.Httpis not anIHttpClientFactorymocking package. For that seam: a tiny project-local fakeIHttpClientFactorywhen that's the smallest option, orCompono.TestDoublesif the project already uses it for other doubles (it's an ordinary single-method interface — no special machinery needed). Never suggest aMicrosoft.Extensions.HttphelperCompono.Httpdoesn't ship.- JSON/AOT:
RespondJson(value, options)is the ergonomic runtime-metadata path and carries the normalRequiresDynamicCode/RequiresUnreferencedCodetrimming/AOT warnings at the consumer's own call site;RespondJson(value, JsonTypeInfo<T>)is the source-generated, AOT-safe path — prefer it in an AOT/trim-sensitive project. Never claim allRespondJsonusage is automatically AOT-safe.
- When to use
-
skills/compono-evals/evals.json: add at least one new scenario, following the exact precedent PLAN-0040 set forCompono.TUnit(eval 21 there) — e.g. a routing/behavioral-correctness scenario confirming an agent recommendsCompono.Http'sTestHttpHandlerfor a real "test myHttpClient-consuming code" prompt rather than inventing an unshippedIHttpClientFactoryintegration or a header/body matcher DSL that doesn't exist in v1. Also review (don't blindly rewrite) existing eval 16 (skills/compono-evals/evals.json, theCMP0001/HttpClientreflection-refusal scenario) — its currentexpected_output/expectationsstill hold unchanged (Compono.Httpdoesn't removeCMP0001for composing a rawHttpClient, per the "When not to use Compono" correction above), so no rewrite is required there unless implementation reveals otherwise; record that review either way. Done: added eval 22 (routing scenario:AlertsClienttakingHttpClientin its constructor, project referencingCompono.Http— expectsTestHttpHandler, hand-constructed client viahandler.CreateClient(...),registration.Verify()/handler.Requests, no inventedIHttpClientFactoryhelper or matcher DSL). Reviewed eval 16: confirmed still correct unchanged —Compono.Httpdoes not removeCMP0001forComposer.Create<HttpClient>(), so the reflection-refusal expectations still hold; not rewritten. - Validate the skill update against the real shipped API — re-read every code example in the new
references/http.mdagainstCompono.Http's actual public members once implemented (task 2-8) and confirm each one compiles; this repo's skill-validation process for a prior package addition is the/skill-creator-style benchmark run PLAN-0035 established and PLAN-0040 reused (seeskills/compono-evals/benchmarks/2026-08-07/README.mdfor the methodology) — run at least the new/updated scenario(s) (full 18-scenario re-run is not required just for one package addition, per PLAN-0040's own scoping) and record the result the same way (skills/compono-evals/benchmarks/<date>/README.mdor an addendum to the existing one — confirm exact convention against what PLAN-0040 actually did at implementation time). Treat this as part of PLAN-0051's completion, not optional follow-up documentation — the plan's Goal ("done when...") is not satisfied while the skill still has no knowledge ofCompono.Http. Done: confirmed PLAN-0040's actual precedent (not its own aspirational plan text) — it did not run a live benchmark or create a newbenchmarks/<date>/folder for eval 21; it recorded the added eval directly in its own plan/status notes. This task follows that same real precedent: every code example inreferences/http.mdanddocs/packages/compono-http.mdwas compiled against the actual builtCompono.Http.dllvia a throwaway console project (ProjectReferencetosrc/Compono.Http/Compono.Http.csproj) — build succeeded, 0 errors, 0 warnings — rather than eyeballed. No new benchmark folder created, matching PLAN-0040's real practice.
Critical Files¶
New:
src/Compono.Http/Compono.Http.csprojsrc/Compono.Http/TestHttpHandler.cssrc/Compono.Http/HttpResponseRegistration.cssrc/Compono.Http/UnmatchedHttpRequestException.cssrc/Compono.Http/HttpResponseRegistrationBuilder.cs(or equivalent — exact fluent-return type shape is an implementation detail)test/Compono.Http.Tests/*(new test project, behavioral coverage per task 9)test/Compono.Http.AotSmokeTest/*(new, per task 5's mirroredCompono.TestDoubles.AotSmokeTestpattern)docs/packages/compono-http.mdskills/compono/references/http.md
Modified (this repo, skill surface — task 12a):
skills/compono/SKILL.md(frontmatter description, Detection table, Default workflow, Guardrails' shipped-packages list, "When not to use Compono"'sHttpClientbullet, References table)skills/compono-evals/evals.json(new scenario(s); eval 16 reviewed, not necessarily rewritten)
Modified (this repo, dogfood tooling/policy — task 11):
scripts/dogfood-validate.sh— new--packages/DOGFOOD_PACKAGESoption replacing the hardcodedpackages=(...)array; usage text and header comment updated to match.AGENTS.md— "Consumer/dogfood validation gate" section (already updated ahead of implementation, this review round, to state the standing multi-consumer/multi-package/no-stale-push policy this task's script serves).
Modified (alexa-vox-craft repo, separate from this repo's own PR — see Notes) — HTTP migration (task 10b), scope locked by evidence in this plan:
test/AlexaVoxCraft.Http.TestKit/Extensions/HttpMessageHandlerExtensions.cs(removed)test/AlexaVoxCraft.Http.TestKit/SpecimenBuilders/HttpClientSpecimenBuilder.cs,RequestSpecifications/HttpClientSpecification.cs,Attributes/ClientAutoDataAttribute.cs(+2 subclasses)test/AlexaVoxCraft.Smapi.Tests/Clients/AlexaInteractionModelClientTests.cs,AlexaSkillInvocationClientTests.cs,Auth/SmapiDeveloperAccessTokenProviderTests.cstest/AlexaVoxCraft.InSkillPurchasing.Tests/Clients/InSkillPurchasingClientTests.cs,Handlers/LocaleHandlerTests.cs
Modified (alexa-vox-craft repo) — broader ecosystem migration (tasks 10a-10f), exact file list determined by the 10a inventory at implementation time, not pre-enumerated here (the whole point of running the inventory first is not knowing this list in advance): likely candidates based on what's already known from the HTTP-focused research (docs/research/0009-...md §1) include test/AlexaVoxCraft.TestKit/Attributes/BaseFixtureFactory.cs and its subclasses, any AutoFixture.AutoNSubstitute-registering customization, and any shared base-fixture project the migrated test projects depend on — confirm the real set via 10a, don't assume this list is complete.
Modified (this repo):
docs/adr/README.md,docs/plans/README.md(status updates as work proceeds)docs/mvp.md/README.mdif the package-list update in task 12 appliesdocs/research/0010-alexa-vox-craft-compono-ecosystem-migration.md(new — task 10g)
Test Plan¶
test/Compono.Http.Tests— full behavioral coverage per task 9, matchingreferences/testing.md's conventions (xUnit v3, AwesomeAssertions, deterministic — noThread.Sleep-based concurrency tests; use bounded parallel-task fan-out with a completion barrier instead).- Task 5's two separate JSON/AOT proofs, kept distinct: Proof A (analyzer-contract, build-time only) — automated assertions that
RespondJson(value, options)warns IL2026/IL3050 at the consumer's own call site andRespondJson(value, jsonTypeInfo)warns nothing. Proof B (test/Compono.Http.AotSmokeTest) — a realPublishAot=truepublish-and-run through the packed dependency chain, exercising only theJsonTypeInfo<T>overload; theJsonSerializerOptionsoverload is never required to publish warning-free under Native AOT in either proof. alexa-vox-craftfull suite, green against freshly packed local packages covering every Compono package the migration ends up consuming (not justCompono/Compono.Http) — task 11's gate, mandatory, re-run after every substantive review-feedback change, not a one-time check, and the gate that authorizes a push (task 11, step 7).scripts/dogfood-validate.sh's generalization itself: confirm the default (no--packagespassed) invocation is behaviorally unchanged from before this plan — same hardcoded four-package default, so any existingtrivia-platform/Compono.TestDoublesusage isn't disturbed — and confirm a--packagesinvocation with a different set (e.g. addingCompono.Http, omittingCompono.NSubstitute) packs, restores, and version-verifies exactly that set, no more and no less.skills/compono-evals— the new/updated scenario(s) from task 12a run and pass under the repo's established/skill-creator-style benchmark process (PLAN-0035/PLAN-0040 precedent); every code example in the newreferences/http.mdverified to compile against the actual shippedCompono.Httppublic API, not copied from ADR-0051's illustrative examples unchecked.- The dogfood research document (task 10g) itself is a test-plan deliverable, not just a report — its assertion-intent-preservation and test-count claims must be independently checked (e.g. diffing test method names/counts before and after, not just "the suite is green"), since a green suite alone doesn't prove no test's assertion intent was weakened to make a Compono package fit.
Notes¶
alexa-vox-craft changes (task 10's file lists) happen in the alexa-vox-craft repo, not this one — this plan tracks them here because they're the acceptance evidence for Compono.Http's admission (ADR-0051) and, per this plan's expanded scope, for the broader ecosystem-migration question the review feedback that produced tasks 10a-10g raised. They ship as that repo's own change, coordinated with (not bundled into) this repo's Compono.Http PR. Record here, as work proceeds:
- whether the HTTP migration (10b's original 41-call-site scope) and the broader ecosystem migration (10a/10c-10g) happened as one companion PR in
alexa-vox-craftor were split, and why, if reality diverges from "migrate everything in scope in the same work session"; - the actual inventory-to-migration ratio from 10a — how much of what was inventoried actually got migrated versus left in bucket ⅚/7, so a reader can see the real proportion without re-reading the full 10g document;
- ADR-0051 itself does not change because of this broader migration — if the migration surfaces a finding that does warrant a core-Compono ADR (a genuine new capability gap from 10c), that's a separate future ADR, not an amendment to ADR-0051, which is scoped to
Compono.Httponly. - Findings A and B (10c/RESEARCH-0010 §10-11) were classified core-Compono capability gaps but, per a post-merge process check, had not yet been run through ADR-0029's rubric or entered in
docs/roadmap/post-mvp.md— corrected 2026-08-24: both apply the rubric to "Roadmap candidate," recorded as one roadmap entry (two evidence cases, deliberately not yet merged or split) via ADR-0052 (Proposed) and ADR-0002 Amendment 2. A future design dive (perdesign-decisions.md), not yet started, will determine the actual mechanism before any implementation plan exists.
The standing "no push before the consumer dogfood gate passes" rule (task 11) is not new policy invented by this plan — it's already recorded at the repository level in AGENTS.md's "Consumer/dogfood validation gate" section (updated in this review round to cover multiple consumers and a parameterized package set, ahead of this plan's own implementation) so future plans with an active dogfood consumer don't have to rediscover it. This plan's task 11 is that policy's concrete application to Compono.Http/alexa-vox-craft, plus the tooling change (scripts/dogfood-validate.sh's --packages option) the policy's multi-package case now depends on.