diff --git a/docs/generators/composer.md b/docs/generators/composer.md
index 3d34ad9e..bc7ee54d 100644
--- a/docs/generators/composer.md
+++ b/docs/generators/composer.md
@@ -116,7 +116,7 @@ Main attribute for marking pipeline host types.
|---|---|---|---|
| `InvokeMethodName` | `string` | `"Invoke"` | Name of generated sync method |
| `InvokeAsyncMethodName` | `string` | `"InvokeAsync"` | Name of generated async method |
-| `GenerateAsync` | `bool?` | `null` | Explicit async control; null = infer from steps |
+| `GenerateAsync` | `bool` | Inferred when omitted | Explicit async control |
| `ForceAsync` | `bool` | `false` | Force async generation even if all steps are sync |
| `WrapOrder` | `ComposerWrapOrder` | `OuterFirst` | Determines wrapping order |
diff --git a/src/PatternKit.Generators.Abstractions/Composer/ComposerAttribute.cs b/src/PatternKit.Generators.Abstractions/Composer/ComposerAttribute.cs
index 484586f0..d1a00cd2 100644
--- a/src/PatternKit.Generators.Abstractions/Composer/ComposerAttribute.cs
+++ b/src/PatternKit.Generators.Abstractions/Composer/ComposerAttribute.cs
@@ -21,11 +21,10 @@ public sealed class ComposerAttribute : Attribute
///
/// Gets or sets whether to generate async methods.
- /// When null (default), async generation is inferred from the presence of async steps or terminal.
- /// Note: Nullable bool in attributes is non-standard but supported by C#.
- /// Set to true/false explicitly to control async generation, or leave unset for inference.
+ /// When omitted, async generation is inferred from the presence of async steps or terminal.
+ /// Set to true/false explicitly to control async generation.
///
- public bool? GenerateAsync { get; set; }
+ public bool GenerateAsync { get; set; }
///
/// Gets or sets whether to force async generation even if all steps are synchronous.
diff --git a/test/PatternKit.Generators.Tests/ComposerGeneratorTests.cs b/test/PatternKit.Generators.Tests/ComposerGeneratorTests.cs
index 369aa9f8..4ebb3c2d 100644
--- a/test/PatternKit.Generators.Tests/ComposerGeneratorTests.cs
+++ b/test/PatternKit.Generators.Tests/ComposerGeneratorTests.cs
@@ -1111,4 +1111,150 @@ private Response Terminal(in Request req)
ScenarioExpect.Equal("PKCOM006", diagnostic.Id);
ScenarioExpect.Contains("Step", diagnostic.GetMessage(), StringComparison.Ordinal);
}
+
+ [Scenario("GenerateAsyncFalse WithAsyncStep ReportsDiagnostic")]
+ [Fact]
+ public void GenerateAsyncFalse_WithAsyncStep_ReportsDiagnostic()
+ {
+ var source = """
+ using System;
+ using System.Threading.Tasks;
+ using PatternKit.Generators.Composer;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer(GenerateAsync = false)]
+ public partial class RequestPipeline
+ {
+ [ComposeStep(0)]
+ private ValueTask StepAsync(Request req, Func> next)
+ => next(req);
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req) => new(200);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateAsyncFalse_WithAsyncStep_ReportsDiagnostic));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out _);
+
+ var diagnostic = ScenarioExpect.Single(result.Results.SelectMany(r => r.Diagnostics));
+ ScenarioExpect.Equal("PKCOM008", diagnostic.Id);
+ ScenarioExpect.Contains("StepAsync", diagnostic.GetMessage(), StringComparison.Ordinal);
+ }
+
+ [Scenario("NamedOrderAndIgnoredStep GenerateDeterministicPipeline")]
+ [Fact]
+ public void NamedOrderAndIgnoredStep_GenerateDeterministicPipeline()
+ {
+ var source = """
+ using System;
+ using PatternKit.Generators.Composer;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial class RequestPipeline
+ {
+ [ComposeStep(99, Order = 2, Name = "Audit")]
+ private Response Audit(in Request req, Func next) => next(req);
+
+ [ComposeStep(1)]
+ private Response Auth(in Request req, Func next) => next(req);
+
+ [ComposeStep(0)]
+ [ComposeIgnore]
+ private Response Ignored(in Request req, Func next) => next(req);
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req) => new(200);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(NamedOrderAndIgnoredStep_GenerateDeterministicPipeline));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated);
+
+ ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics));
+
+ var generatedSource = result.Results.SelectMany(r => r.GeneratedSources).Single().SourceText.ToString();
+ ScenarioExpect.Contains("Audit(in arg, pipeline)", generatedSource);
+ ScenarioExpect.Contains("Auth(in arg, pipeline)", generatedSource);
+ ScenarioExpect.DoesNotContain("Ignored", generatedSource);
+
+ var emit = updated.Emit(Stream.Null);
+ ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics));
+ }
+
+ [Scenario("StructAsyncTerminalWithoutCancellationToken GeneratesAsyncPipeline")]
+ [Fact]
+ public void StructAsyncTerminalWithoutCancellationToken_GeneratesAsyncPipeline()
+ {
+ var source = """
+ using System;
+ using System.Threading.Tasks;
+ using PatternKit.Generators.Composer;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial struct RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response Audit(in Request req, Func next) => next(req);
+
+ [ComposeTerminal]
+ private ValueTask TerminalAsync(Request req)
+ => new(new Response(200));
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(StructAsyncTerminalWithoutCancellationToken_GeneratesAsyncPipeline));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated);
+
+ ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics));
+
+ var generatedSource = result.Results.SelectMany(r => r.GeneratedSources).Single().SourceText.ToString();
+ ScenarioExpect.Contains("terminalFunc", generatedSource);
+ ScenarioExpect.Contains("self.TerminalAsync(arg)", generatedSource);
+
+ var emit = updated.Emit(Stream.Null);
+ ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics));
+ }
+
+ [Scenario("InvalidStep WithFunctionPointerNext ReportsDiagnostic")]
+ [Fact]
+ public void InvalidStep_WithFunctionPointerNext_ReportsDiagnostic()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial class RequestPipeline
+ {
+ [ComposeStep(0)]
+ private unsafe Response Step(in Request req, delegate* next)
+ => new(200);
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req) => new(200);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(InvalidStep_WithFunctionPointerNext_ReportsDiagnostic));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out _);
+
+ var diagnostic = ScenarioExpect.Single(result.Results.SelectMany(r => r.Diagnostics));
+ ScenarioExpect.Equal("PKCOM006", diagnostic.Id);
+ ScenarioExpect.Contains("Step", diagnostic.GetMessage(), StringComparison.Ordinal);
+ }
}