You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Step Builder MVP proves required-step completeness at compile time via generic type-state and materializes the product through a synchronous [BuilderAssemble] + generated Build(). Real product construction often needs I/O at assemble time (config, HTTP, persistence). Today authors must keep assemble synchronous or bypass the generated builder, which undermines the type-state gate and the library’s async-first convention.
Solution
Extend Step Builder so a single [BuilderAssemble] may be an async assemble (Task<T> or ValueTask<T>). When that is the case, the generator emits BuildAsync(CancellationToken cancellationToken = default) instead of Build(), preserving the same required-step type-state gate. The product type remains the inner T; the task is only transport. Sync and async remain mutually exclusive per holder (one assemble signature → one exit). Illegal assemble contracts continue to report DP086 (message/description tightened). Decisions are recorded in ADR-011; ADR-010 remains the type-state decision.
User Stories
As a library consumer, I want async assemble when product materialization needs I/O, so that I do not bypass the generated builder.
As a library consumer, I want BuildAsync gated by the same required-step type-state as Build, so that missing required steps still fail at compile time.
As a library consumer with a synchronous assemble returning T, I want only Build() to be generated, so that my existing schemas keep working unchanged.
As a library consumer with assemble returning Task<T>, I want only BuildAsync to be generated, so that I cannot accidentally call a sync Build that does not exist.
As a library consumer with assemble returning ValueTask<T>, I want only BuildAsync returning ValueTask<T>, so that the generated exit matches my assemble transport.
As a library consumer, I want BuildAsync’s return type to follow assemble (Task<T> vs ValueTask<T>), so that I do not fight an unnecessary wrapper conversion.
As a library consumer, I want the product type to mean the inner T even when assemble returns a task, so that docs and mental model stay “build an HttpRequest”, not “build a Task”.
As a library consumer, I want BuildAsync(CancellationToken cancellationToken = default) always available, so that callers can always thread cancellation.
As a library consumer whose assemble ignores cancellation, I want assemble without a CancellationToken parameter to remain legal, so that pure DTO assembly stays simple.
As a library consumer whose assemble observes cancellation, I want a single CancellationToken parameter on assemble to receive the token passed to BuildAsync, so that I/O can stop promptly.
As a library consumer, I want that CancellationToken parameter excluded from step-name binding, so that it is not confused with a [BuilderStep].
As a library consumer, I want bare Task / bare ValueTask (no T) rejected with DP086, so that there is no product type to name.
As a library consumer, I want two [BuilderAssemble] methods on one holder rejected with DP086, so that sync/async dual assemble cannot drift.
As a library consumer, I want more than one CancellationToken on assemble rejected with DP086, so that binding stays unambiguous.
As a library consumer, I want optional steps, mutex groups, and After/Before ordering to behave as in MVP when using BuildAsync, so that async does not weaken those rules.
As a library consumer, I want required-step cap (≤8) and DP078–DP085 unchanged, so that Phase 2 is additive at the assemble exit only.
As a diagnostics reader, I want DP086’s message/description to mention sync T or Task<T>/ValueTask<T> and the illegal cases above, so that the IDE tells me how to fix the contract.
As a maintainer, I want ADR-011 to record async assemble exclusivity, return-type rules, CT rules, and product-type unwrap, so that future readers are not surprised that ADR-010 listed async as an MVP non-goal.
As a maintainer, I want ADR-010 left in place for type-state markers, so that we do not supersede an unchanged proof model.
As a maintainer, I want Design Doc StepBuilder.md and ROADMAP updated for Phase 2 async delivery and remaining non-goals, so that backlog status matches reality.
As a generator author, I want generated BuildAsync to return the assemble task/value-task directly when possible (no pointless async state machine), so that generated code stays thin.
As a Verify-test author, I want snapshot coverage for sync regression and both Task<T> and ValueTask<T> async exits, so that public generated API stays reviewable.
As a Verify-test author, I want diagnostic snapshots for DP086 async contract failures, so that illegal schemas stay locked.
As a Samples maintainer, I want a sibling Samples follow-up ticket (not blocking library PRs), so that demos can show BuildAsync after the library lands.
As an AFK agent implementing tickets, I want clear module boundaries (Diagnostics → SourceGenerators → Docs), so that each PR stays single-module.
As a consumer on netstandard2.0 or net8.0, I want both TFMs to support async assemble the same way, so that dual-TFM baseline is preserved.
As a consumer using instance (non-static) assemble, I want the same accessibility rules as MVP to apply to async assemble, so that generation rules stay consistent.
As a consumer, I want no new Step Builder Analyzer or CodeFix in this phase, so that scope stays generator + DP086 text.
As a consumer, I want no MSDI/Autofac/FromServices step injection in this phase, so that DI remains a later grill.
As a consumer, I want no async [BuilderStep] / awaitable fluent chain in this phase, so that step methods remain sync schema signatures.
Exclusivity: Exactly one [BuilderAssemble] per holder. Return T → generate Build() only. Return Task<T> or ValueTask<T> → generate BuildAsync(CancellationToken cancellationToken = default) only. Never both exits for one holder; never sync-over-async Build.
Product type: Always the inner T. BuildAsync’s declared return type equals assemble’s return type (Task<T> or ValueTask<T>).
CancellationToken: Always on BuildAsync (default allowed). Assemble may omit CT or take at most one; if present, generator passes the BuildAsync token through. CT does not participate in step-name binding. Multiple CT parameters → DP086.
Diagnostics: Extend DP086 only (no new DP093+ for this feature). Tighten message/description for async-valid and async-invalid contracts. Other Step Builder diagnostics unchanged.
ADR: Add ADR-011 (async assemble exit). Do not supersede ADR-010.
Runtime module: No new attributes or types.
Modules / PR order (one module per PR):
Diagnostics — DP086 descriptor copy
SourceGenerators — detect async assemble, emit BuildAsync, contract validation, Verify (blocked by Diagnostics if descriptor text must match assertions)
Docs — ADR-011, docs/design/StepBuilder.md, ROADMAP (and AGENTS summary only if needed)
Generated body: Prefer returning assemble’s Task/ValueTask directly; await only if required for CT plumbing that cannot be a straight call.
Type-state: Unchanged per ADR-010; required steps still flip NotSet→Set; BuildAsync is only exposed when all required type parameters are Set (same gating pattern as Build).
Glossary: Use Step Builder, product type, async assemble as in CONTEXT.md.
Testing Decisions
Good tests assert observable generator output and diagnostics, not private helpers.
DP086: bare Task/ValueTask, duplicate assemble, multiple CT parameters
Secondary seam: StepBuilderDiagnosticDescriptorsTests if DP086 text changes.
Not in scope for tests: new runtime unit surface, Analyzer tests, full consumer project compile as primary seam.
Prior art: existing GenerateBuilderGeneratorTests + *.verified.txt snapshots; Factory async signature diagnostics (DP053) for “async contract” tone only — Step Builder still uses DP086.
Out of Scope
Async [BuilderStep] / awaitable fluent chains
MSDI / Autofac / FromServices step injection
Step parameter validation diagnostics
Sync-over-async Build() when assemble is async
Mutex / partial-order promotion into type-state erasure
New Analyzer / CodeFix for Step Builder
Runtime API additions
Samples repo updates (file a sibling follow-up; do not block library PRs)
API freeze / stable NuGet release work
Further Notes
Relates to closed MVP Spec #287 and Design Doc docs/design/StepBuilder.md.
Grilling consensus: Phase 2 = Async only; assemble-exit only; signature exclusivity; both Task<T> and ValueTask<T>; CT on BuildAsync with optional assemble CT; DP086 extension; ADR-011; non-goals as above; PR slice Diagnostics → SourceGenerators → Docs.
Next: /to-tickets should split into Diagnostics, SourceGenerators, Docs (and optional Samples sibling) with blocking edges Diagnostics → SourceGenerators; Docs may follow or parallel after decisions are stable.
After implementation PRs land, use a fresh session + /ship-pr (or /ship-pr-reviewed) per ticket.
Problem Statement
Step Builder MVP proves required-step completeness at compile time via generic type-state and materializes the product through a synchronous
[BuilderAssemble]+ generatedBuild(). Real product construction often needs I/O at assemble time (config, HTTP, persistence). Today authors must keep assemble synchronous or bypass the generated builder, which undermines the type-state gate and the library’s async-first convention.Solution
Extend Step Builder so a single
[BuilderAssemble]may be an async assemble (Task<T>orValueTask<T>). When that is the case, the generator emitsBuildAsync(CancellationToken cancellationToken = default)instead ofBuild(), preserving the same required-step type-state gate. The product type remains the innerT; the task is only transport. Sync and async remain mutually exclusive per holder (one assemble signature → one exit). Illegal assemble contracts continue to report DP086 (message/description tightened). Decisions are recorded in ADR-011; ADR-010 remains the type-state decision.User Stories
BuildAsyncgated by the same required-step type-state asBuild, so that missing required steps still fail at compile time.T, I want onlyBuild()to be generated, so that my existing schemas keep working unchanged.Task<T>, I want onlyBuildAsyncto be generated, so that I cannot accidentally call a syncBuildthat does not exist.ValueTask<T>, I want onlyBuildAsyncreturningValueTask<T>, so that the generated exit matches my assemble transport.BuildAsync’s return type to follow assemble (Task<T>vsValueTask<T>), so that I do not fight an unnecessary wrapper conversion.Teven when assemble returns a task, so that docs and mental model stay “build an HttpRequest”, not “build a Task”.BuildAsync(CancellationToken cancellationToken = default)always available, so that callers can always thread cancellation.CancellationTokenparameter to remain legal, so that pure DTO assembly stays simple.CancellationTokenparameter on assemble to receive the token passed toBuildAsync, so that I/O can stop promptly.CancellationTokenparameter excluded from step-name binding, so that it is not confused with a[BuilderStep].Task/ bareValueTask(noT) rejected with DP086, so that there is no product type to name.[BuilderAssemble]methods on one holder rejected with DP086, so that sync/async dual assemble cannot drift.CancellationTokenon assemble rejected with DP086, so that binding stays unambiguous.BuildAsync, so that async does not weaken those rules.TorTask<T>/ValueTask<T>and the illegal cases above, so that the IDE tells me how to fix the contract.StepBuilder.mdand ROADMAP updated for Phase 2 async delivery and remaining non-goals, so that backlog status matches reality.BuildAsyncto return the assemble task/value-task directly when possible (no pointless async state machine), so that generated code stays thin.Task<T>andValueTask<T>async exits, so that public generated API stays reviewable.BuildAsyncafter the library lands.FromServicesstep injection in this phase, so that DI remains a later grill.[BuilderStep]/ awaitable fluent chain in this phase, so that step methods remain sync schema signatures.Implementation Decisions
[BuilderAssemble]per holder. ReturnT→ generateBuild()only. ReturnTask<T>orValueTask<T>→ generateBuildAsync(CancellationToken cancellationToken = default)only. Never both exits for one holder; never sync-over-asyncBuild.T.BuildAsync’s declared return type equals assemble’s return type (Task<T>orValueTask<T>).BuildAsync(default allowed). Assemble may omit CT or take at most one; if present, generator passes theBuildAsynctoken through. CT does not participate in step-name binding. Multiple CT parameters → DP086.BuildAsync, contract validation, Verify (blocked by Diagnostics if descriptor text must match assertions)docs/design/StepBuilder.md, ROADMAP (and AGENTS summary only if needed)Task/ValueTaskdirectly; await only if required for CT plumbing that cannot be a straight call.NotSet→Set;BuildAsyncis only exposed when all required type parameters areSet(same gating pattern asBuild).CONTEXT.md.Testing Decisions
GenerateBuilderGeneratorVerify / diagnostic snapshots inDesignPatterns.SourceGenerators.Tests(GenerateBuilderGeneratorTests) — same seam as MVP SourceGenerators: GenerateBuilderGenerator + Verify #290.Build()onlyTask<T>assemble →BuildAsyncreturningTask<T>, CT default parameter, optional CT forwardedValueTask<T>assemble →BuildAsyncreturningValueTask<T>Task/ValueTask, duplicate assemble, multiple CT parametersStepBuilderDiagnosticDescriptorsTestsif DP086 text changes.GenerateBuilderGeneratorTests+*.verified.txtsnapshots; Factory async signature diagnostics (DP053) for “async contract” tone only — Step Builder still uses DP086.Out of Scope
[BuilderStep]/ awaitable fluent chainsFromServicesstep injectionBuild()when assemble is asyncFurther Notes
docs/design/StepBuilder.md.Task<T>andValueTask<T>; CT onBuildAsyncwith optional assemble CT; DP086 extension; ADR-011; non-goals as above; PR slice Diagnostics → SourceGenerators → Docs./to-ticketsshould split into Diagnostics, SourceGenerators, Docs (and optional Samples sibling) with blocking edges Diagnostics → SourceGenerators; Docs may follow or parallel after decisions are stable./ship-pr(or/ship-pr-reviewed) per ticket.