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:

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

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

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

When the analyzer will NOT catch something

Quick reference

IDShort nameSeverity
WF001Imperative control flowError
WF002Async/awaitError
WF003Call into non-trusted assemblyError
WF004new on non-trusted typeError
WF005C# ?: / ?? / ?.Error
WF006Non-trusted field/property readError
WF010Reachable helper violation (cross-project)Error