StellaOps.Workflow.Analyzer
A Roslyn DiagnosticAnalyzer that rejects workflow C# code which cannot be serialized to canonical workflow JSON. Failures are surfaced as compile-time errors at the offending line — no runtime round-trip needed to discover that a workflow is non-canonical.
What the analyzer is for
IDeclarativeWorkflow<T> implementations build a WorkflowSpec<T> from a fluent builder. At runtime, WorkflowCanonicalDefinitionCompiler turns that spec into a canonical JSON document that the engine can execute, persist, diff, and replay.
For that to work, the spec must be pure and declarative: its shape can only depend on compile-time literals and the workflow builder surface, not on build-time state (time, environment, DB reads) or runtime C# semantics (imperative branching, exceptions, async).
The analyzer enforces this at compile time. Consumer projects pick it up via a <ProjectReference> to StellaOps.Workflow.Analyzer.csproj (with OutputItemType="Analyzer") or via an <Analyzer> item pointing at the built DLL. Once referenced, every IDeclarativeWorkflow<T> class in the consumer compilation is checked on every build.
Activation
The analyzer activates only when the compilation references StellaOps.Workflow.Abstractions— specifically, when it can resolve both IDeclarativeWorkflow<T> and WorkflowSpec<T>. Absent those, it is a no-op. Authoring workflows against the StellaOps abstractions needs no opt-in attribute; the attribute described under Trusted assemblies is only for an assembly that supplies its own workflow construction surface.
Scope
The analyzer inspects the Spec property of every IDeclarativeWorkflow<T>-implementing class in the compilation. From each Spec initializer it walks transitively into every method call whose target is available as source:
- Trusted leaves — any method/type in a trusted assembly. Not walked; always allowed.
- Same compilation / project references — source walked via
DeclaringSyntaxReferences. Violations in those methods are reported at their native location (same compilation) or surfaced as WF010 at the workflow call site with an additional location inside the helper (cross-project). - Metadata-only (compiled NuGet / framework) references — not walkable. Invocations, object creations, and non-const field/property reads on such assemblies are rejected. If you need a helper routine, put it in a project reference, not a binary dependency.
Rules
All diagnostics are Error severity. They fail dotnet build.
WF001 — Imperative control flow {#wf001}
Bad
public WorkflowSpec<R> Spec { get; } = Build();
private static WorkflowSpec<R> Build()
{
if (DateTime.UtcNow.Hour > 12)
{
return WorkflowSpec.For<R>().InitializeState(...).Build();
}
return WorkflowSpec.For<R>().InitializeState(...).Build();
}
Why it fails — The shape of the canonical JSON would depend on when the workflow class was constructed. Two callers building the same workflow get different definitions.
Fix — Express branching inside the canonical model:
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
.StartWith(flow => flow
.WhenExpression(
"After noon?",
WorkflowExpr.Gt(WorkflowExpr.Path("start.hour"), WorkflowExpr.Number(12)),
whenTrue => whenTrue.Call(...),
whenElse => whenElse.Call(...)))
.Build();
Forbidden constructs: if, for, foreach, while, do, switch (statement form), try / catch / finally, throw, lock, using, goto, break, continue, yield.
WF002 — Async/await {#wf002}
Bad
public WorkflowSpec<R> Spec { get; } = BuildAsync().GetAwaiter().GetResult();
private static async Task<WorkflowSpec<R>> BuildAsync()
{
await Task.Delay(1);
return WorkflowSpec.For<R>().Build();
}
Why it fails — The workflow Spec is constructed once, synchronously. await / async / Task.Run / Thread introduce runtime concurrency primitives that cannot be represented in the canonical definition.
Fix — Remove the async machinery. If the work you were doing was async only because it read from a DB or an API, move it out of the Spec: those reads should happen inside workflow steps at runtime (via builder.Call(...)), not at definition-construction time.
WF003 — Call into non-trusted assembly {#wf003}
Bad
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
.InitializeState(WorkflowExpr.Obj(
WorkflowExpr.Prop("machine", WorkflowExpr.String(Environment.MachineName))))
.Build();
Why it fails — Environment.MachineName is a property read on System.Environment (in System.Runtime, a metadata-only reference). The canonical JSON would bake whatever machine built the workflow.
Fix — For runtime data, bind through a path reference: WorkflowExpr.Path("start.machineName"). For literals, use the typed builders: WorkflowExpr.String("fixed-value"). For helper routines, put them in a project-referenced class so the analyzer can walk them.
Lambda invocations via func() (where func is an Action/Func<T>/custom delegate) are not flagged as WF003 — the lambda body is walked inline. nameof(...) expressions are also skipped.
WF004 — new on non-trusted type {#wf004}
Bad
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
.InitializeState(WorkflowExpr.Obj(
WorkflowExpr.Prop("id", WorkflowExpr.String(new Guid().ToString()))))
.Build();
Why it fails — new Guid() instantiates a BCL type at workflow-build time; every class construction produces a different Guid, and the canonical JSON is no longer deterministic.
Fix — Use a workflow expression. Workflow DTOs (LegacyRabbitAddress, WorkflowBusinessReferenceDeclaration, WorkflowNamedExpressionDefinition, etc.) that live in a trusted assembly are trusted and may be instantiated freely.
WF005 — C# conditional operator in workflow code {#wf005}
Bad
WorkflowExpr.Prop("route", WorkflowExpr.String(useFastLane ? "fast" : "slow"))
WorkflowExpr.Prop("name", WorkflowExpr.String(input.Name ?? "anonymous"))
WorkflowExpr.Prop("length", WorkflowExpr.Number(input?.Name?.Length ?? 0))
Why it fails — ?:, ??, and ?. resolve at workflow-build time. Whichever branch wins when the Spec is constructed is what ends up in the canonical JSON; the runtime condition never enters the workflow at all.
Fix — Use canonical equivalents:
WorkflowExpr.Prop("route", WorkflowExpr.Func("if",
WorkflowExpr.Path("state.useFastLane"),
WorkflowExpr.String("fast"),
WorkflowExpr.String("slow")))
WorkflowExpr.Prop("name", WorkflowExpr.Func("coalesce",
WorkflowExpr.Path("start.name"),
WorkflowExpr.String("anonymous")))
WF006 — Non-trusted field/property read {#wf006}
Bad
WorkflowExpr.Prop("year", WorkflowExpr.Number(DateTime.UtcNow.Year))
WorkflowExpr.Prop("cfg", WorkflowExpr.String(MyStaticConfig.CurrentRegion))
Why it fails — Reading a non-const field or property on a type that isn’t in a trusted or source-walkable assembly bakes the read value into the canonical JSON. DateTime.UtcNow.Year gives you the year-at-build, not a per-execution value. MyStaticConfig.CurrentRegion couples the workflow definition to whatever config was loaded when the plugin assembly initialized.
What is allowed
- Compile-time constants (
constfields) on any type.int.MaxValueand userpublic const string Route = "/x"are always fine. - Static readonly fields on types in the current compilation or a project reference — the analyzer can walk the initializer and verify it’s canonical. This is how
static readonly LegacyRabbitAddress QueryAddress = new("pas_query");stays legal. - Any member on a type from a trusted assembly.
Fix — Use WorkflowExpr.Path(...) to bind a runtime value, or a const on your own class for compile-time constants.
WF010 — Reachable helper violation {#wf010}
Emitted when a helper method reachable from a workflow’s Spec (in a project reference) itself contains a WF001–WF006 violation. The primary location points at the call site inside the workflow; the additional location points at the offending line inside the helper.
If the helper is in the same compilation as the workflow, the underlying rule (e.g., WF001) fires directly at the helper’s line instead. WF010 is specifically for cross-project indirection where the analyzer cannot emit diagnostics into the referenced project’s source.
Fix — Fix the helper. One fix clears the diagnostic at every call site.
Trusted assemblies {#trusted-assemblies}
A trusted assembly is one whose types the analyzer treats as canonicalizable leaves: it allows calls into them, instantiation of them, and member reads on them without walking their bodies. It also grants the compile-time construction factory exemption — a helper whose return type comes from a trusted assembly is exempt from WF010, because such a helper runs once while the Spec is built and its imperative C# is metaprogramming, not runtime workflow logic.
Trust is deliberately narrow. Granting it to an assembly that does runtime work silently disables the diagnostics that keep workflow definitions replayable.
What is trusted by default
Only this product’s own workflow assemblies: StellaOps.Workflow and its dotted children (StellaOps.Workflow.Abstractions, StellaOps.Workflow.Contracts, …). Nothing else is trusted out of the box, and this default cannot be turned off.
Opting an assembly in — the attribute
An assembly that supplies its own workflow builder, expression, spec, or task-definition types opts in by applying the assembly-level attribute from StellaOps.Workflow.Abstractions:
// AssemblyInfo.cs, or any file in the assembly
[assembly: StellaOps.Workflow.Abstractions.WorkflowTrustedAssembly]
This is the preferred mechanism. It requires no build configuration, it travels with the assembly to every consumer, and it is the only mechanism the canonical artifact generator honours (see the caveat below).
Apply it only to an assembly whose public surface is compile-time workflow construction.
Opting an assembly in — configuration
For a binary you consume but do not build, and therefore cannot annotate, name it in an analyzer configuration option. Put this in a .globalconfig, or under [*.cs] in .editorconfig:
stellaops_workflow_trusted_assembly_prefixes = Example.Workflow, Example.Flow.Extensions
- Values are assembly name prefixes, matched ordinally.
Example.Workflowtrusts the assemblyExample.Workflowand anyExample.Workflow.*; it does not trustOther.Example.Workflow. - Separate multiple entries with commas or semicolons.
- The option is additive. It cannot remove
StellaOps.Workflow*from the trust set, so a configuration file can never break workflow authoring. - Caveat: this option reaches the analyzer only. The canonical artifact source generator emits JSON that must stay byte-identical to what the runtime compiler produces, so it is not influenced by per-project configuration and honours the attribute alone. An assembly trusted only by configuration may still raise WF020 from the generator. Prefer the attribute wherever you can apply it.
Migrating from the previous behaviour
Earlier builds trusted a fixed list of assembly-name prefixes baked into the analyzer, which meant the analyzer had to know the name of every party that authored workflows against it. That list is gone: the default is now this product’s assemblies only, and every other party opts in explicitly by the two mechanisms above.
If a build that previously compiled now reports WF003, WF004, WF006, or WF010 against types from your own workflow assembly, that assembly was relying on the removed built-in trust. Add [assembly: WorkflowTrustedAssembly] to it, or list its name prefix in stellaops_workflow_trusted_assembly_prefixes. No other change is needed; the diagnostics and their meanings are unchanged.
Non-goals for v1
- No source generator. The analyzer only validates; the runtime
WorkflowCanonicalDefinitionCompilerremains the single source of truth for canonical JSON emission. Adding a generator would create two sources of truth that will drift. - No severity configuration. All rules are
Error. If a workflow would fail canonicalization at runtime, building it should fail too. - No per-rule escape hatch. There is no attribute or option that suppresses an individual diagnostic on a specific member.
[WorkflowTrustedAssembly]andstellaops_workflow_trusted_assembly_prefixes(see Trusted assemblies) work at whole-assembly granularity and exist to admit other workflow-construction surfaces, not to whitelist a vetted BCL helper.
When the analyzer will NOT catch something
- Reflection (
Type.GetMethod(...),Activator.CreateInstance(...)): flagged as WF003 (invocation of metadata-onlySystem.Reflection). - Runtime-configured workflows built by a service/DI: the
Specproperty’s initializer is the entry point. If the workflow class has a constructor that fetches data before buildingSpec, the analyzer only walks theSpecgetter — the constructor runs at runtime and is outside scope. Keep workflow definitions pure (no constructor state dependency).
Quick reference
| ID | Short name | Severity |
|---|---|---|
| WF001 | Imperative control flow | Error |
| WF002 | Async/await | Error |
| WF003 | Call into non-trusted assembly | Error |
| WF004 | new on non-trusted type | Error |
| WF005 | C# ?: / ?? / ?. | Error |
| WF006 | Non-trusted field/property read | Error |
| WF010 | Reachable helper violation (cross-project) | Error |
