ForgeTrust.AppSurface.Flow
0.2.0-preview.4
See the version list below for details.
dotnet add package ForgeTrust.AppSurface.Flow --version 0.2.0-preview.4
NuGet\Install-Package ForgeTrust.AppSurface.Flow -Version 0.2.0-preview.4
<PackageReference Include="ForgeTrust.AppSurface.Flow" Version="0.2.0-preview.4" />
<PackageVersion Include="ForgeTrust.AppSurface.Flow" Version="0.2.0-preview.4" />
<PackageReference Include="ForgeTrust.AppSurface.Flow" />
paket add ForgeTrust.AppSurface.Flow --version 0.2.0-preview.4
#r "nuget: ForgeTrust.AppSurface.Flow, 0.2.0-preview.4"
#:package ForgeTrust.AppSurface.Flow@0.2.0-preview.4
#addin nuget:?package=ForgeTrust.AppSurface.Flow&version=0.2.0-preview.4&prerelease
#tool nuget:?package=ForgeTrust.AppSurface.Flow&version=0.2.0-preview.4&prerelease
ForgeTrust.AppSurface.Flow
ForgeTrust.AppSurface.Flow provides stable contracts for typed long-running app processes.
Release Guidance
AppSurface ships as a coordinated package family. Before installing this package from a prerelease feed, check the package chooser and release hub for current release risk, migration guidance, and readiness.
What It Includes
FlowNodeOutcome<TContext>and its append-onlyNext,Wait,TimedOut,Complete,Fault, and typedActivityoutcomes.FlowActivityCallsite<TWork, TResult>,IFlowActivityRequest<TContext>, andFlowActivityWorkResult<TResult>for reflection-free work dispatch and result resumption.FlowEventCallsite<TPayload>for exact external-event name and payload-contract metadata without persisting CLR type names.IFlowNode<TContext>,FlowExecutionContext<TContext>,FlowResumeEvent, andIFlowTransitionEvaluator<TContext>.FlowGraphBuilder<TContext>and immutableFlowDefinition<TContext>graph validation.IFlowDefinitionRegistryfor host and adapter lookup by context type, flow id, and version.IFlowRunner<TContext>andInMemoryFlowRunner<TContext>for local tests, including explicit activity pause/resume.- Generated-case authoring attributes:
FlowAuthoringAttribute,FlowNodeAttribute,FlowStartAttribute,FlowOutcomeAttribute,FlowGraphMappingAttribute, andFlowOutcomeKind. IFlowTransformerNode<TInput, TOutcome>andFlowTransformerContext<TInput>for generated authoring nodes.- Passive
AppSurfaceFlowModuleservice registration.
What It Does Not Include
- Durable persistence, external event queues, timers, replay, storage, or activity execution. Durable hosts consume the contracts in this package.
- ASP.NET Core endpoints, middleware, authorization handlers, or UI.
- Semantic Kernel. Keep agentic or LLM-assisted authoring in samples or future packages until the core process contract has settled.
- Required preview C# union syntax.
Basic Shape
Generated authoring
Generated authoring is the package-first path when transition coverage should fail at build time. Think of context types as typed ports: a node declares the input port it accepts, and each outcome declares the output port it emits. The generator connects a Next outcome to the node whose input port has the same nominal type.
[FlowAuthoring("approval")]
public partial class ApprovalFlow
{
[FlowStart]
[FlowNode("intake", typeof(ApprovalOpened))]
[FlowOutcome("ready-for-review", FlowOutcomeKind.Next, typeof(ReviewRequested))]
[FlowOutcome("approval-submitted", FlowOutcomeKind.Wait, typeof(ApprovalOpened))]
public partial class IntakeNode : IFlowTransformerNode<ApprovalOpened, IntakeNodeOutcomes>
{
public ValueTask<IntakeNodeOutcomes> ExecuteAsync(
FlowTransformerContext<ApprovalOpened> context,
CancellationToken cancellationToken = default)
{
if (context.ResumeEvent is null)
{
return ValueTask.FromResult<IntakeNodeOutcomes>(
IntakeNodeOutcomes.ApprovalSubmitted(context.State));
}
return ValueTask.FromResult<IntakeNodeOutcomes>(
IntakeNodeOutcomes.ReadyForReview(new ReviewRequested(context.State.RequestId)));
}
}
[FlowNode("review", typeof(ReviewRequested))]
[FlowOutcome("approved", FlowOutcomeKind.Complete, typeof(ApprovalCompleted))]
public partial class ReviewNode : IFlowTransformerNode<ReviewRequested, ReviewNodeOutcomes>
{
public ValueTask<ReviewNodeOutcomes> ExecuteAsync(
FlowTransformerContext<ReviewRequested> context,
CancellationToken cancellationToken = default) =>
ValueTask.FromResult<ReviewNodeOutcomes>(
ReviewNodeOutcomes.Approved(new ApprovalCompleted(context.State.RequestId)));
}
}
public sealed record ApprovalOpened(string RequestId);
public sealed record ReviewRequested(string RequestId);
public sealed record ApprovalCompleted(string RequestId);
Then build a definition from node instances:
var definition = ApprovalFlow.BuildDefinition(
new ApprovalFlow.IntakeNode(),
new ApprovalFlow.ReviewNode());
var result = await runner.RunAsync(definition, ApprovalFlow.CreateStartContext(new ApprovalOpened("APR-1001")));
The compact BuildDefinition(nodeInstances...) overload applies the generated default graph configuration. This works when each Next outcome output port has exactly one compatible node input port. In the example, ready-for-review emits ReviewRequested, and only ReviewNode accepts ReviewRequested, so the generator can infer that transition.
Use the explicit overload when you want the graph mapping visible at the call site:
var definition = ApprovalFlow.BuildDefinition(
graph => graph
.MapIntakeNodeReadyForReviewToReviewNode()
.MarkIntakeNodeApprovalSubmittedTerminal()
.MarkReviewNodeApprovedTerminal(),
new ApprovalFlow.IntakeNode(),
new ApprovalFlow.ReviewNode());
The explicit overload must receive an inline lambda. Delegate variables and method groups are rejected so analyzer coverage works even when the flow spec is compiled in a reusable library and the host project consumes the generated public builder.
The generator emits outcome records such as IntakeNodeOutcomes.ReadyForReviewOutcome, one serializable envelope context such as ApprovalFlowContext, adapter nodes that implement IFlowNode<ApprovalFlowContext>, a GraphBuilder helper, and BuildDefinition(...) helpers that lower the authored graph into the existing runtime contract.
Use generated authoring for application workflows, package samples, and public APIs where missing transitions should break the build. Use the low-level runtime shape below for tiny tests, custom graph construction, or hand-authored nodes that intentionally own all runtime behavior.
Typed activities
An activity is the only Flow outcome intended for external I/O. Generated authoring declares the persisted context plus typed work/result contracts. Contract versions and an explicit CallsiteId are recommended when instances will be durable:
[FlowNode("notify", typeof(NotificationState))]
[FlowOutcome(
"send-email",
FlowOutcomeKind.Activity,
typeof(NotificationState),
typeof(SendEmailWork),
typeof(SendEmailResult),
CallsiteId = "approval.send-email",
WorkContractVersion = 1,
ResultContractVersion = 1)]
[FlowOutcome("done", FlowOutcomeKind.Complete, typeof(ApprovalCompleted))]
public partial class NotifyNode : IFlowTransformerNode<NotificationState, NotifyNodeOutcomes>
{
public ValueTask<NotifyNodeOutcomes> ExecuteAsync(
FlowTransformerContext<NotificationState> context,
CancellationToken cancellationToken = default)
{
if (NotifyNodeOutcomes.SendEmailCallsite.TryGetResult(context.ActivityResult, out var result))
{
return ValueTask.FromResult<NotifyNodeOutcomes>(
NotifyNodeOutcomes.Done(new ApprovalCompleted(result.MessageId)));
}
return ValueTask.FromResult<NotifyNodeOutcomes>(
NotifyNodeOutcomes.SendEmail(
new SendEmailWork(context.State.RequestId),
context.State with { Status = "sending" }));
}
}
The generator emits SendEmailCallsite and lowers the case to the same low-level FlowNodeOutcome<TContext>.Activity(...) contract. If CallsiteId is omitted, its stable default is <node-id>.<outcome-name>. The generated graph treats Activity like Wait: it pauses and later resumes the same node, so its output context type must match the node input context.
InMemoryFlowRunner<TContext> returns FlowRunStatus.ActivityPending; it does not execute work. A test executes or fakes the work, creates the typed result through the callsite, and resumes explicitly:
var pending = await runner.RunAsync(definition, initialContext);
var work = (SendEmailWork)pending.Activity!.Work;
var activityResult = NotifyNodeOutcomes.SendEmailCallsite.CreateResult(
new SendEmailResult("provider-message-123"));
var completed = await runner.ResumeActivityAsync(
definition,
pending.NodeId!,
pending.Context!,
activityResult);
Typed external events
Use FlowEventCallsite<TPayload> when an external event carries a payload whose durable contract must be explicit. The
callsite is manifest metadata: it names the event and the payload contract, while TPayload tells the host which
runtime type its allowlisted codec must produce. It does not authorize delivery or decode the payload itself.
var approvalSubmitted = new FlowEventCallsite<ApprovalSubmitted>(
"approval-submitted",
"approval.submitted",
"v1");
return FlowNodeOutcome<ApprovalState>.Wait(
approvalSubmitted,
context.State with { Status = "waiting" });
When local execution stops at this wait, FlowRunResult<TContext>.EventCallsite preserves the typed contract while
WaitingEventName preserves the event name. String waits leave EventCallsite null. This makes the in-memory result
useful to host adapters and contract tests without implying that the runner performs authorization or decoding.
A durable host first loads the persisted wait registration, checks authorization and the exact case-sensitive event
name, selects an allowlisted codec from ContractName plus ContractVersion, validates the decoded value against
PayloadType, and only then supplies FlowResumeEvent to the evaluator. Delivery ownership, late-event policy, and
duplicate handling belong to that host. Use the string-based Wait overload when an event intentionally carries no
payload.
public sealed record ApprovalSubmitted(string ApprovedBy);
Low-level runtime contract
var definition = FlowGraphBuilder<ApprovalState>
.Create("approval-request")
.AddNode("review", new ApprovalReviewNode())
.StartAt("review")
.Build();
var result = await runner.RunAsync(definition, new ApprovalState("created"));
Nodes return explicit discriminated outcomes:
public sealed class ApprovalReviewNode : IFlowNode<ApprovalState>
{
public ValueTask<FlowNodeOutcome<ApprovalState>> ExecuteAsync(
FlowExecutionContext<ApprovalState> context,
CancellationToken cancellationToken = default)
{
if (context.ResumeEvent is null)
{
return ValueTask.FromResult<FlowNodeOutcome<ApprovalState>>(
FlowNodeOutcome<ApprovalState>.Wait("approval-submitted", context.State));
}
return ValueTask.FromResult<FlowNodeOutcome<ApprovalState>>(
FlowNodeOutcome<ApprovalState>.Complete(context.State with { Status = "approved" }));
}
}
One-node host boundary
IFlowTransitionEvaluator<TContext> is the host-neutral durable boundary. Unlike IFlowRunner<TContext>, it evaluates exactly one node and never follows a Next transition in the same call:
var transition = await evaluator.EvaluateAsync(
definition,
new FlowTransitionInput<ApprovalState>("review", persistedContext));
The evaluator maps every outcome to FlowTransition<TContext>, validates declared Next targets, and exposes activity metadata without reflection. A durable host must commit that transition before evaluating another node. For Activity, it must atomically persist the Flow context, activity command, and wait registration before dispatching external work.
Transition creation is deliberately package-owned so every runner shares one mapping and validation contract. Decorate IFlowTransitionEvaluator<TContext> and delegate to FlowTransitionEvaluator<TContext> when adding telemetry or other cross-cutting behavior; custom implementations cannot manufacture alternative transitions. Put custom process behavior in node outcomes instead of replacing evaluator semantics.
Decisions And Pitfalls
- Flow ids, versions, node ids, generated outcome names, event names, event contract names/versions, activity callsite ids, and activity contract versions are durable identifiers. Treat them like persisted schema once real instances exist.
- Durable identifiers reject empty values but otherwise preserve whitespace and compare ordinally. They are not trimmed, case-folded, or normalized. Avoid leading/trailing whitespace, choose one casing, and change an identifier only with a new Flow version while old definitions remain available for nonterminal instances.
- Flow nodes are transition-only. A host may evaluate a node again if the process dies before its decision commits, so nodes must not call providers, write application state, read wall-clock time, or generate hidden random identifiers. Pass nondeterministic values through explicit context/resume contracts and return Activity for external effects.
IFlowTransitionEvaluator<TContext>evaluates one node;IFlowRunner<TContext>may follow several in-memoryNexttransitions. Durable runtimes must use the evaluator and commit each decision, not call the multi-step runner.IFlowTransitionEvaluator<TContext>is an interception seam, not an alternative transition factory. Decorators must delegate transition creation toFlowTransitionEvaluator<TContext>; author different process decisions as node outcomes.- Generated authoring uses nominal context types as typed ports. Two record types with the same properties are different ports; one record type reused in multiple places is the same port.
- Generated authoring resolves
Nexttargets by matching an outcome output context type to exactly one declared node input context, then exposes that transition through a generatedGraphBuilder.Map...To...method. If no node or multiple nodes match, the generator reports an error. - If two nodes can accept the same kind of work, give the branches distinct nominal port types unless the graph really should be ambiguous. The generator does not guess between two nodes with the same input context type.
Wait,TimedOut, andActivityoutcomes resume the same node, so their output port must be the node input port.Faultoutcomes must carryFlowFault. Activity declarations must also provide concrete work and result types.- The compact
BuildDefinition(nodeInstances...)overload calls the generated default graph mapping. Prefer it when the typed ports make everyNexttransition unambiguous. - The explicit
BuildDefinition(graph => ..., nodeInstances...)overload requires every declared outcome to be mapped or marked terminal. Use it when graph visibility matters.Complete,Fault,Wait,TimedOut, andActivityoutcomes use generatedMark...Terminal()methods because they do not declareFlowNext<TContext>targets in the low-level graph. - Generated envelopes include concrete nullable context slots and a public serializer constructor so Durable Task JSON round-trip validation can inspect them.
FlowExecutionContext<TContext>is an immutable value-type snapshot. Runners populate it for each node call so tight synchronous flows avoid allocating a reference context per step. Do not treatdefault(FlowExecutionContext<TContext>)as a valid execution context; structs can be default-created without flow ids, node ids, or state.- Declare every
Nexttarget inAddNode. The builder validates missing targets, and runners reject undeclared runtime targets. - The in-memory runner stops at waits and activities. It does not persist state, deliver events, create timers, or execute activity work.
- The in-memory runner uses prevalidated internal routing metadata from
FlowDefinition<TContext>so local execution does not repeat graph-existence checks on everyNexttransition. This preserves the publicNodesgraph view and the undeclared target diagnostic fromFlowDefinition<TContext>construction. - Flow benchmarks isolate runner orchestration overhead. Use Flow for graph safety, long-running process contracts, and durable-host alignment; use a direct loop when all you need is a pure in-process tight state machine.
- Use
FlowWait<TContext>.Timeoutas metadata for durable hosts. The core runner reports the timeout request but does not race timers. FlowEventCallsite<TPayload>is passive manifest metadata. The core evaluator forwards a suppliedFlowResumeEvent; it does not prove that the event matches a prior wait, authorize its sender, decode its payload, or reject duplicates. Durable hosts must perform those checks before evaluation.- Public host boundaries snapshot extensible
IFlowEventCallsiteandIFlowActivityRequest<TContext>metadata after validating required values, declared CLR types, and positive contract versions. Custom implementations must still expose stable, side-effect-free getters; mutable work and context objects are not deep-cloned and should be serialized immediately. - Keep context types serializer-friendly if you plan to use Durable Task. The Durable Task adapter validates JSON round-trips before evaluating nodes.
- Expected business failure belongs in the typed activity result contract. Technical exhaustion, provider ambiguity, and retry safety belong to the durable activity host; a Flow node must not infer that a timed-out provider call had no effect.
FlowActivityCallsite<TWork, TResult>.TryGetResultreturns false for absent or mismatched results. UseGetResultwhen a mismatch is a definition/runtime invariant violation and should throw a focusedFlowDefinitionException.- Native C# discriminated unions can become authoring sugar later, but this package intentionally ships stable sealed records today.
Generated Authoring Diagnostics
| Diagnostic | Default | Meaning | Fix |
|---|---|---|---|
ASFLOWA001 |
Error | A declared outcome is missing generated graph mapping. | Add the matching Map...To... call for Next outcomes, add the matching Mark...Terminal() call for terminal outcomes, or use the compact generated BuildDefinition(nodeInstances...) overload. |
ASFLOWA002 |
Error | A declared Next outcome targets a missing context. |
Add the missing generated node or use the correct output context type. |
ASFLOWA003 |
Error | A declared Next outcome matches more than one node input context. |
Use distinct context types so the generated graph is unambiguous. |
ASFLOWA004 |
Error | The authored flow has zero or multiple [FlowStart] nodes. |
Mark exactly one generated node with [FlowStart]. |
ASFLOWA005 |
Error | The generator cannot produce the envelope or graph because declarations conflict. | Make the flow and generated nodes partial, unique, and fully declared. |
ASFLOWA006 |
Warning | Generated authoring is being mixed with low-level registration in a confusing way. | Keep generated definitions and hand-built definitions as separate entry points. |
ASFLOWA007 |
Warning | A generated Flow node directly reads wall-clock time or creates hidden random input. | Pass time, identifiers, and random values through persisted context or resume contracts so replay evaluates the same durable decision. |
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- CliWrap (>= 3.10.1)
- ForgeTrust.AppSurface.Core (>= 0.2.0-preview.4)
- Microsoft.Extensions.Hosting (>= 10.0.8)
- Microsoft.Extensions.Logging.Console (>= 10.0.8)
- Microsoft.Extensions.Options (>= 10.0.8)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on ForgeTrust.AppSurface.Flow:
| Package | Downloads |
|---|---|
|
ForgeTrust.AppSurface.Flow.DurableTask
ForgeTrust.AppSurface.Flow.DurableTask package for AppSurface application composition. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.2.0-preview.5 | 0 | 8/1/2026 |
| 0.2.0-preview.4 | 181 | 7/18/2026 |
| 0.2.0-preview.2 | 529 | 7/3/2026 |
| 0.2.0-preview.1 | 295 | 6/28/2026 |
| 0.1.0 | 109 | 7/2/2026 |
| 0.1.0-rc.4 | 269 | 6/16/2026 |
| 0.1.0-rc.3 | 131 | 6/8/2026 |
| 0.1.0-rc.2 | 61 | 6/3/2026 |