Skip to content

Spec: Step Builder async assemble / BuildAsync (Phase 2) #324

Description

@Skymly

Problem Statement

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

  1. As a library consumer, I want async assemble when product materialization needs I/O, so that I do not bypass the generated builder.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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”.
  8. As a library consumer, I want BuildAsync(CancellationToken cancellationToken = default) always available, so that callers can always thread cancellation.
  9. 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.
  10. 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.
  11. As a library consumer, I want that CancellationToken parameter excluded from step-name binding, so that it is not confused with a [BuilderStep].
  12. As a library consumer, I want bare Task / bare ValueTask (no T) rejected with DP086, so that there is no product type to name.
  13. As a library consumer, I want two [BuilderAssemble] methods on one holder rejected with DP086, so that sync/async dual assemble cannot drift.
  14. As a library consumer, I want more than one CancellationToken on assemble rejected with DP086, so that binding stays unambiguous.
  15. 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.
  16. 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.
  17. 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.
  18. 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.
  19. As a maintainer, I want ADR-010 left in place for type-state markers, so that we do not supersede an unchanged proof model.
  20. 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.
  21. 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.
  22. 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.
  23. As a Verify-test author, I want diagnostic snapshots for DP086 async contract failures, so that illegal schemas stay locked.
  24. 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.
  25. As an AFK agent implementing tickets, I want clear module boundaries (Diagnostics → SourceGenerators → Docs), so that each PR stays single-module.
  26. 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.
  27. 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.
  28. As a consumer, I want no new Step Builder Analyzer or CodeFix in this phase, so that scope stays generator + DP086 text.
  29. As a consumer, I want no MSDI/Autofac/FromServices step injection in this phase, so that DI remains a later grill.
  30. As a consumer, I want no async [BuilderStep] / awaitable fluent chain in this phase, so that step methods remain sync schema signatures.

Implementation Decisions

  • Scope: Step Builder Phase 2 = async assemble / BuildAsync only.
  • 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):
    1. Diagnostics — DP086 descriptor copy
    2. SourceGenerators — detect async assemble, emit BuildAsync, contract validation, Verify (blocked by Diagnostics if descriptor text must match assertions)
    3. 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 NotSetSet; 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.
  • Primary seam: GenerateBuilderGenerator Verify / diagnostic snapshots in DesignPatterns.SourceGenerators.Tests (GenerateBuilderGeneratorTests) — same seam as MVP SourceGenerators: GenerateBuilderGenerator + Verify #290.
  • Coverage to add:
    • Sync assemble regression: still emits Build() only
    • Task<T> assemble → BuildAsync returning Task<T>, CT default parameter, optional CT forwarded
    • ValueTask<T> assemble → BuildAsync returning ValueTask<T>
    • 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions