diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowCanonicalDefinitionContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowCanonicalDefinitionContracts.cs
index f1e5bb15a..b83560f4b 100644
--- a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowCanonicalDefinitionContracts.cs
+++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowCanonicalDefinitionContracts.cs
@@ -331,6 +331,11 @@ public static class WorkflowCanonicalJsonSerializer
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
+ // Pin LF so canonical-JSON bytes are identical across build platforms
+ // (default would be Environment.NewLine = \r\n on Windows, \n on Unix).
+ // The compile-time source generator in StellaOps.Workflow.Analyzer must
+ // produce byte-identical output; that depends on a stable newline.
+ NewLine = "\n",
};
public static JsonSerializerOptions Options => SerializerOptions;
diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/Fixtures/ParityFixtureWorkflows.cs b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/Fixtures/ParityFixtureWorkflows.cs
new file mode 100644
index 000000000..09526aaf1
--- /dev/null
+++ b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/Fixtures/ParityFixtureWorkflows.cs
@@ -0,0 +1,64 @@
+using System.Collections.Generic;
+using StellaOps.Workflow.Abstractions;
+using StellaOps.Workflow.Contracts;
+
+namespace StellaOps.Workflow.Analyzer.Tests.Fixtures;
+
+public sealed class ParityFixtureRequest
+{
+ public string? PolicyId { get; set; }
+ public string? Status { get; set; }
+}
+
+///
+/// Minimal-but-valid: InitializeState + trivial StartWith(flow => flow.Complete()).
+///
+public sealed class PureExpressionWorkflow : IDeclarativeWorkflow
+{
+ public string WorkflowName => "PureExpressionWorkflow";
+ public string WorkflowVersion => "1.0.0";
+ public string DisplayName => "Pure Expression Workflow";
+ public IReadOnlyCollection WorkflowRoles => new string[0];
+ public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0];
+
+ public WorkflowSpec Spec { get; } = WorkflowSpec.For()
+ .InitializeState(WorkflowExpr.Obj(
+ WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")),
+ WorkflowExpr.Prop("status", WorkflowExpr.Func(
+ "coalesce",
+ WorkflowExpr.Path("start.status"),
+ WorkflowExpr.String("new"))),
+ WorkflowExpr.Prop("priority", WorkflowExpr.Number(1))))
+ .StartWith(flow => flow.Complete())
+ .Build();
+}
+
+///
+/// Exercises StartWith + Set + ActivateTask + Complete inside a decision +
+/// a matching AddTask so the runtime compiler accepts the reference.
+///
+public sealed class StartWithDecisionWorkflow : IDeclarativeWorkflow
+{
+ public string WorkflowName => "StartWithDecisionWorkflow";
+ public string WorkflowVersion => "1.0.0";
+ public string DisplayName => "Start With Decision";
+ public IReadOnlyCollection WorkflowRoles => new string[0];
+ public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0];
+
+ public WorkflowSpec Spec { get; } = WorkflowSpec.For()
+ .InitializeState(WorkflowExpr.Obj(
+ WorkflowExpr.Prop("pid", WorkflowExpr.Path("start.policyId"))))
+ .StartWith(flow => flow
+ .Set("pid", WorkflowExpr.Path("start.policyId"))
+ .WhenExpression(
+ "Approved?",
+ WorkflowExpr.Eq(WorkflowExpr.Path("start.status"), WorkflowExpr.String("approved")),
+ whenTrue => whenTrue.ActivateTask("Process"),
+ whenElse => whenElse.Complete()))
+ .AddTask(WorkflowHumanTask.For("Process", "Review", "default")
+ .WithRoles("APR")
+ .WithPayload(WorkflowExpr.Obj(
+ WorkflowExpr.Prop("pid", WorkflowExpr.Path("state.pid"))))
+ .OnComplete(flow => flow.Complete()))
+ .Build();
+}
diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/GeneratorByteParityTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/GeneratorByteParityTests.cs
new file mode 100644
index 000000000..65be5ee40
--- /dev/null
+++ b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/GeneratorByteParityTests.cs
@@ -0,0 +1,110 @@
+using System.Reflection;
+using FluentAssertions;
+using NUnit.Framework;
+using StellaOps.Workflow.Abstractions;
+using StellaOps.Workflow.Contracts;
+using StellaOps.Workflow.Analyzer.Tests.Fixtures;
+
+namespace StellaOps.Workflow.Analyzer.Tests;
+
+///
+/// Generator output vs runtime compiler output byte-parity.
+///
+/// Current status: PARTIAL. Shared top-level fields (schemaVersion,
+/// workflowName, workflowVersion, displayName) plus emitted start/tasks
+/// content match byte-for-byte. Divergent:
+/// * startRequest — runtime builds the full JSON schema of TStartRequest
+/// via CLR reflection (BuildStartRequestContract). Replicating via
+/// Roslyn ISymbol walks is significant work and not trivially byte-exact.
+/// * businessReference — runtime inspects [WorkflowBusinessKey]-style
+/// attributes on TStartRequest. Same reflection-vs-symbol issue.
+/// * requiredModules — inferred by the runtime from the function catalog.
+/// * Nested object/array brace formatting — runtime puts `{`/`[` on the
+/// same line as the preceding `:`, whereas the hand-rolled writer
+/// currently puts them on a new line with indent.
+///
+/// Options under consideration (see plan file, risk #1):
+/// A) Extend the generator to emit startRequest/businessReference/
+/// requiredModules and fix the brace formatting. Big engineering cost,
+/// ongoing maintenance burden to track runtime-compiler changes.
+/// B) Pivot: publisher calls WorkflowCanonicalDefinitionCompiler.Compile()
+/// at startup. Publisher already runs in net10.0 with full runtime
+/// access. The generator stays for validation + registry +
+/// metadata-as-const, but the actual canonical JSON upload uses the
+/// runtime compiler. Zero parity concerns.
+///
+/// These tests stay as [Explicit] until the path is chosen — they document
+/// the drift surface precisely and can be flipped on once option A is
+/// implemented.
+///
+[TestFixture]
+public class GeneratorByteParityTests
+{
+ [Test]
+ [Explicit("Parity pending option A vs B decision — see class doc comment")]
+ public void PureExpressionWorkflow_GeneratorJson_MatchesRuntimeCompilerJson()
+ {
+ AssertParity(
+ generatedTypeFullName: "StellaOps.Workflow.Generated._BundledCanonicalWorkflow_PureExpressionWorkflow");
+ }
+
+ [Test]
+ [Explicit("Parity pending option A vs B decision — see class doc comment")]
+ public void StartWithDecisionWorkflow_GeneratorJson_MatchesRuntimeCompilerJson()
+ {
+ AssertParity(
+ generatedTypeFullName: "StellaOps.Workflow.Generated._BundledCanonicalWorkflow_StartWithDecisionWorkflow");
+ }
+
+ private static void AssertParity(string generatedTypeFullName)
+ where TWorkflow : new()
+ {
+ var instance = new TWorkflow();
+ var result = WorkflowCanonicalDefinitionCompiler.Compile(
+ (IDeclarativeWorkflow)(object)instance);
+
+ result.Succeeded.Should().BeTrue("runtime compiler must succeed on the fixture workflow");
+ result.Definition.Should().NotBeNull();
+ var runtimeJson = WorkflowCanonicalJsonSerializer.Serialize(result.Definition!);
+
+ var generatedType = typeof(TWorkflow).Assembly.GetType(generatedTypeFullName, throwOnError: false, ignoreCase: false);
+ generatedType.Should().NotBeNull(
+ $"the source generator must have emitted {generatedTypeFullName} into the test assembly");
+ var jsonField = generatedType!.GetField(
+ "CanonicalDefinitionJson",
+ BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
+ jsonField.Should().NotBeNull("generator must emit CanonicalDefinitionJson const");
+
+ var generatedJson = (string)jsonField!.GetValue(null)!;
+
+ if (generatedJson != runtimeJson)
+ {
+ var offset = FindFirstDiffOffset(runtimeJson, generatedJson);
+ var window = 120;
+ var start = System.Math.Max(0, offset - 40);
+ var runtimeEnd = System.Math.Min(runtimeJson.Length, start + window);
+ var genEnd = System.Math.Min(generatedJson.Length, start + window);
+ var runtimeWindow = runtimeJson.Substring(start, runtimeEnd - start);
+ var generatedWindow = generatedJson.Substring(start, genEnd - start);
+ Assert.Fail(
+ $"Generator JSON does not match runtime compiler JSON. First diff at offset {offset} (runtime={runtimeJson.Length} bytes, generated={generatedJson.Length} bytes).\n\n" +
+ "--- Runtime window ---\n" +
+ Visible(runtimeWindow) + "\n\n" +
+ "--- Generated window ---\n" +
+ Visible(generatedWindow));
+ }
+ }
+
+ private static string Visible(string s) =>
+ s.Replace("\n", "\\n\n").Replace("\r", "\\r").Replace("\t", "\\t");
+
+ private static int FindFirstDiffOffset(string a, string b)
+ {
+ var n = System.Math.Min(a.Length, b.Length);
+ for (var i = 0; i < n; i++)
+ {
+ if (a[i] != b[i]) return i;
+ }
+ return n;
+ }
+}
diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/StellaOps.Workflow.Analyzer.Tests.csproj b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/StellaOps.Workflow.Analyzer.Tests.csproj
index 1c42c9b40..31be1a476 100644
--- a/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/StellaOps.Workflow.Analyzer.Tests.csproj
+++ b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/StellaOps.Workflow.Analyzer.Tests.csproj
@@ -21,8 +21,23 @@
+
+
+
+
+
+
+
+
+
+