Skip to content

Latest commit

 

History

History
1221 lines (993 loc) · 56.5 KB

File metadata and controls

1221 lines (993 loc) · 56.5 KB

24 — Getting Started

Status: Verified against the code · Audience: an engineer who has never used FlowX Answers: how do I get one flow running, and what will the compiler stop me doing?

This is a path, not a reference. It starts with a project that runs and adds one thing at a time: capabilities, the manifest, compensation, durability, an event, a test. Each step ends where the next begins, and the last two sections are the ones a reference document would leave out — the diagnostics you will hit in the first hour, and what the platform cannot do yet.

Every code block on this page is checked by a test. GettingStartedTests reads this file, compiles the C# with the FlowX generator and every FlowX analyzer, and asserts that each block produces exactly the diagnostics the page says it does — including the blocks that are supposed to fail. Blocks quoted from a file are compared against that file verbatim. A block with no verification comment fails the test, so nothing can slip in unchecked. How to read this page's checks explains the markers if you are editing it.


1. Before you start

You need the .NET 10 SDK. You do not need a database, a broker or a container for anything up to §8; §8 needs PostgreSQL.

Nothing is published to nuget.org yet, so there is one pre-release step: build the FlowX packages into a local feed.

templates/local-feed.sh                       # pre-release only
dotnet new install templates/FlowX.Templates

local-feed.sh packs eight packages into .artifacts/local-feed and registers it as a NuGet source. The day the packages publish, that line disappears and nothing else changes — the generated project already references FlowX as ordinary PackageReferences. templates/local-feed.sh --remove undoes it. The details, including why the script evicts those eight ids from the NuGet cache before packing, are in templates/README.md.


2. Your first flow

dotnet new flowx -o Ordering
cd Ordering
dotnet build -c Release
The template "FlowX application" was created successfully.

  Determining projects to restore...
  Restored /home/you/Ordering/Ordering.csproj (in 444 ms).
  Ordering -> /home/you/Ordering/bin/Release/net10.0/Ordering.dll

Build succeeded.
    0 Warning(s)
    0 Error(s)

Eight files: six of C#, the project file, and a README.

File What it holds
Contracts.cs The records on the wire and between steps. No behaviour.
Capabilities.cs Two capabilities and the one port they depend on. All the business rules.
OpenTicketFlow.cs The control flow: order, and where recovery would go. Two triggers on it.
Program.cs Composition. Registrations, MapFlowX() for every declared endpoint and MapFlowXMcp() for the agent surface.
Infrastructure.cs The in-memory adapter and the JSON serialiser context.
Authentication.cs Two demonstration tokens. A stand-in for your identity provider.

dotnet run it and post a ticket:

curl -X POST http://localhost:5000/api/v1/tickets \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer support-token' \
  -H 'Idempotency-Key: ticket-1' \
  -d '{"subject":"Printer on fire","reporter":"ops","contactPhone":"+44 7700 900000"}'
{"ticketId":"ticket-1","subject":"Printer on fire"}

Three things in that exchange are worth naming now, because the rest of the page builds on them.

The route is not in Program.cs. It is on the flow, as [HttpTrigger("POST", "/api/v1/tickets")], and the compiler generates the registration app.MapFlowX() calls. Change the route on the flow and the served address moves with it; there is nothing to keep in step because nothing restates it.

The Idempotency-Key header is required because the flow said so. Omit it and you get 400 with http.idempotency_key_required before any capability runs.

The Authorization header is required because the capability said so. ticket.validate declares Authorization.Authenticated, and the engine enforces that on the step — so drop the header and you get 403 with authorization.not_authenticated instead. Send Bearer reader-token and you get past that one and are refused by ticket.record with authorization.permission_denied, because it declares Permission = "ticket.write" and that caller does not hold it. Both tokens are constants in Authentication.cs, which is a stand-in for an identity provider and is meant to be replaced by AddJwtBearer; §12 says what that costs. The stance lives on the capability rather than the route, so it holds however the flow is triggered — and the generated project proves that rather than asserting it, because OpenTicketFlow also carries an [AgentTrigger]. The same flow is the MCP tool ticket_open at POST /mcp, and reader-token is refused there by ticket.record with the same authorization.permission_denied. Two transports, one decision, and no rule attached to either of them.

A business failure is not an exception. Post a blank subject and you get RFC 7807 problem details carrying the code the capability returned:

{"type":"https://flowx.dev/errors/ticket.subject_required","title":"The request is not valid",
 "status":400,"detail":"A ticket needs a subject.","instance":"/api/v1/tickets",
 "code":"ticket.subject_required","correlationId":"0HNNFDPRFSDTF:00000001"}

3. Capabilities and contracts

FlowX splits an application in two, and the split is the whole model.

A capability is one business operation: a class with one method, no knowledge of how it was invoked, and a declaration of what it is. A flow is the order those operations run in, and only the order — no business rules, no transport.

The contracts

Records, and nothing else. They are the vocabulary the steps pass between them.

public sealed record OpenTicket(
    string Subject,
    string Reporter,
    [property: Sensitive] string ContactPhone);

public sealed record ValidatedTicket(string Subject, string Reporter);

public sealed record TicketOpened(string TicketId, string Subject);

[Sensitive] is not a comment. The member is listed under the contract's sensitive array in the manifest, and it is redacted in every sink the platform owns: the generated HTTP endpoint strips it out of error responses, and — for a Durable flow — it is [redacted] in the stored input, in every step result, in the state-bag snapshot and in an emitted event body. That last group is one mechanism, not four: a value reaches a store only as a JournalPayload, which has no accessor for what it holds and one exit that redacts. It is still narrower than it sounds, in the direction that matters: nothing stops your own code writing the value somewhere the platform does not see. SensitiveAttribute says so at the declaration.

The error catalogue

Every failure the application can return, declared once so two capabilities cannot invent two spellings of the same condition. Each code reaches the manifest and the type URI a caller sees, which makes it part of the contract.

public static class TicketErrors
{
    public static Error SubjectRequired() =>
        new("ticket.subject_required", "A ticket needs a subject.", ErrorCategory.Validation);
}

public interface ITicketStore
{
    ValueTask SaveAsync(string ticketId, string subject, CancellationToken ct);

    ValueTask DeleteAsync(string ticketId, CancellationToken ct);
}

ITicketStore is a port. The capability depends on it rather than on a database, which is what lets the whole flow be tested with no infrastructure in §10.

A read

[Capability("ticket.validate", Version = "1.0.0",
    Authorization = Authorization.Authenticated,
    Idempotent = true)]
public sealed class ValidateTicket : ICapability<OpenTicket, ValidatedTicket>
{
    public ValueTask<Result<ValidatedTicket>> ExecuteAsync(
        OpenTicket input,
        CapabilityContext ctx,
        CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(input);

        // An expected failure is a value, not an exception.
        return ValueTask.FromResult(string.IsNullOrWhiteSpace(input.Subject)
            ? Result.Fail<ValidatedTicket>(TicketErrors.SubjectRequired())
            : Result.Ok(new ValidatedTicket(input.Subject.Trim(), input.Reporter)));
    }
}

Four declarations on that attribute are contract, not documentation: the id, the version of the contract, who may call it, and whether calling it twice is safe. All four reach flowx.manifest.json. Two of them change what the compiler will let you write — see §11.

A write

[Capability("ticket.record", Version = "1.0.0",
    Authorization = Authorization.Permission, Permission = "ticket.write",
    Idempotent = true,
    SideEffects = ["ticket-store"])]
public sealed class RecordTicket(ITicketStore store) : ICapability<ValidatedTicket, TicketOpened>
{
    public async ValueTask<Result<TicketOpened>> ExecuteAsync(
        ValidatedTicket input,
        CapabilityContext ctx,
        CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(input);
        ArgumentNullException.ThrowIfNull(ctx);

        // The identity comes from the context. A new Guid would make the same request
        // produce a different ticket on every retry.
        await store.SaveAsync(ctx.IdempotencyKey, input.Subject, ct).ConfigureAwait(false);

        return new TicketOpened(ctx.IdempotencyKey, input.Subject);
    }
}

Idempotent = true is a promise the implementation keeps, here by keying the write on ctx.IdempotencyKey. Declaring it falsely is how a retry becomes a duplicate; declaring it honestly is what lets you attach a retry policy at all.

SideEffects names the external things this touches. It drives blast-radius analysis and one build error (FLOWX1018: caching a capability with side effects).

The flow

[Flow("ticket.open", Version = "1.0.0", Profile = ExecutionProfile.Ephemeral, Owner = "support")]
[FlowDeadline("PT10S")]
[HttpTrigger("POST", "/api/v1/tickets", Idempotent = true)]
public sealed partial class OpenTicketFlow : Flow<OpenTicket, TicketOpened>
{
    protected override void Define(IFlowBuilder<OpenTicket, TicketOpened> flow)
    {
        ArgumentNullException.ThrowIfNull(flow);

        flow
            .Step<ValidateTicket>()
            .Step<RecordTicket>()
            .Return(ctx => ctx.Get<TicketOpened>());
    }
}

partial, because the compiled plan, the step dispatcher and the projection are generated into the other half of the class at build time. They land on disk under obj/generated as ordinary C# with #line directives back into your file — set a breakpoint on a step and it stops in Capabilities.cs.

Steps bind by type: RecordTicket consumes a ValidatedTicket because ValidateTicket produced one. Reorder them and the build fails with FLOWX1020 — a step consuming a contract no earlier step produces — rather than throwing on the first request.

Wiring it up

Program.cs is the composition root: your own types, and one call for everything the compiler already knows about. This is the template's, verbatim:

builder.Services.AddFlowX(options => options.ApplicationName = "Ordering");

Then authentication, and the in-memory store the capabilities depend on:

builder.Services
    .AddAuthentication(StarterTokenHandler.SchemeName)
    .AddScheme<AuthenticationSchemeOptions, StarterTokenHandler>(StarterTokenHandler.SchemeName, null);

// Infrastructure. In memory here; the capabilities do not know or care.
builder.Services.AddSingleton<ITicketStore, InMemoryTicketStore>();

The authentication registration is what puts a ClaimsPrincipal on the request, and the engine decides every declared stance against it — so an application whose capabilities declare a stance and whose host registers no authentication refuses its own first step. app.UseAuthentication() further down is the other half: without it the handler is registered and never runs, every request is anonymous, and the failure looks exactly like a broken token.

builder.Services.AddFlowXCapabilities();

That one line registers every capability the flows step through and every generated Dispatcher, read off the constructors the generator wrote. There is no assembly scan, and the line does not grow when you add a capability.

They are singletons because that is the only lifetime the runtime can honour everywhere: the catalogues hold a resolved dispatcher for the life of the node, and a recovery sweep resumes an instance long after the invocation that started it, with no scope left to resolve another from. Everything that varies per invocation — tenant, principal, idempotency key, deadline, clock, ids — arrives on CapabilityContext instead, which is also what lets a resumed instance replay identically. The registrations are TryAdd, so anything you register yourself wins.

app.MapFlowX() registers every endpoint the flows declared. It names no method and no route, and templates/verify.sh asserts that it does not.

Once an application declares more than one trigger kind there is an aggregate, app.UseFlowX(), which maps the routes and registers the subscriptions, change subscriptions, schedules and streams — every one this assembly declared, and only the ones it declared. A worker with no routes calls app.Services.UseFlowX() instead. Forgetting one of the individual calls used to be silent, which is why the host now refuses to start when a declared address has nothing serving it; samples/event-driven uses the aggregate over all four kinds.


4. The manifest

Every build produces a manifest: the structure of the application as declared, with no behaviour in it. It comes from the same reading of your attributes that produced the plan, so it cannot describe a different program from the one that shipped (ADR-0005).

It is not a file on disk. The compiler emits it as a generated type, FlowX.Generated.FlowXManifest, inside the assembly — so it travels with the binary and cannot be edited apart from it. The CLI writes it out:

dotnet run --project src/FlowX.Cli -- manifest \
  --assembly bin/Release/net10.0/Ordering.dll \
  --output flowx.manifest.json

This is what the project from §2 produces, abridged:

{
  "schemaVersion": "0.1.0",
  "application": { "name": "Ordering", "version": "1.0.0" },
  "flows": [
    {
      "id": "ticket.open",
      "version": "1.0.0",
      "profile": "Ephemeral",
      "deadline": "PT10S",
      "input": { "type": "Ordering.OpenTicket", "sensitive": [ "ContactPhone" ] },
      "output": { "type": "Ordering.TicketOpened" },
      "triggers": [
        { "kind": "Http", "method": "POST", "route": "/api/v1/tickets", "idempotent": true }
      ],
      "steps": [
        { "id": 0, "kind": "Capability", "capability": "ticket.validate@1.0.0" },
        { "id": 1, "kind": "Capability", "capability": "ticket.record@1.0.0" }
      ],
      "emits": [],
      "errors": [ "ticket.subject_required" ],
      "source": "OpenTicketFlow.cs:29"
    }
  ],
  "capabilities": [
    {
      "id": "ticket.record",
      "version": "1.0.0",
      "input": "Ordering.ValidatedTicket",
      "output": "Ordering.TicketOpened",
      "authorization": { "mode": "Permission", "value": "ticket.write" },
      "idempotent": true,
      "sideEffects": [ "ticket-store" ],
      "errors": []
    }
  ],
  "events": []
}

Three things it is for:

  • flowx diff compares two manifests and fails a build on a breaking change — a tightened authorisation stance, a renamed permission, a removed error code. See 22-CLI.
  • flowx graph renders the flow as Mermaid.
  • An agent reads it as a tool catalogue, which is why sideEffects and authorization are in it: a tool descriptor that cannot say what a call does is not one you let an agent invoke.

The manifest publishes declared facts. "profile": "Ephemeral" is what the attribute said, not a claim about what the runtime did with it — a distinction that matters, and that §12 returns to.


5. What the compiler does with all this

Worth pausing on, because it explains most of the rest of the page.

There is no reflection at run time and no assembly scan. At build time FlowX.Compiler — an analyzer package, not a library you ship — reads the Define chain and emits:

Emitted Where Why you care
OpenTicketFlow.Plan obj/generated a flat StepNode[]; the engine's only input
OpenTicketFlow.Dispatcher obj/generated calls your capabilities; takes them as constructor parameters
FlowXEndpoints.g.cs obj/generated what app.MapFlowX() registers; only exists if you reference FlowX.Http
FlowX.Generated.FlowXManifest obj/generated, then the assembly the declared contract; flowx manifest writes it out

Because it is all generated, the application publishes with NativeAOT — which the template turns on from the first build so the trim and AOT analyzers report a reflection call while it is still one line.

And because the compiler has read your flow, it can refuse things. That is the subject of §11.


6. Adding a step

Adding a capability is: a class with one ExecuteAsync, a [Capability] attribute, one registration in Program.cs, one .Step<T>() in the flow.

public sealed record NotificationSent(string TicketId);

[Capability("ticket.notify", Version = "1.0.0",
    Authorization = Authorization.Internal,
    Idempotent = true,
    SideEffects = ["email"])]
public sealed class NotifyReporter : ICapability<TicketOpened, NotificationSent>
{
    public ValueTask<Result<NotificationSent>> ExecuteAsync(
        TicketOpened input,
        CapabilityContext ctx,
        CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(input);

        return ValueTask.FromResult(Result.Ok(new NotificationSent(input.TicketId)));
    }
}

The moment a third step exists, a question appears that two steps did not raise: the notification can fail after the ticket was written. That is the next section.


7. Adding compensation

FlowX's answer to a step that fails after an earlier step took an effect is a saga: each compensable step declares its business inverse, and the engine unwinds the completed ones in reverse order.

[Capability("ticket.delete", Version = "1.0.0",
    Authorization = Authorization.Internal,
    Idempotent = true,
    SideEffects = ["ticket-store"])]
public sealed class DeleteTicket : ICapability<ValidatedTicket, TicketOpened>
{
    private readonly ITicketStore _store;

    public DeleteTicket(ITicketStore store)
    {
        ArgumentNullException.ThrowIfNull(store);
        _store = store;
    }

    public async ValueTask<Result<TicketOpened>> ExecuteAsync(
        ValidatedTicket input,
        CapabilityContext ctx,
        CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(input);
        ArgumentNullException.ThrowIfNull(ctx);

        await _store.DeleteAsync(ctx.IdempotencyKey, ct).ConfigureAwait(false);

        return new TicketOpened(ctx.IdempotencyKey, input.Subject);
    }
}

A compensation takes the input of the step it undoes, because that is the value it has to reverse. It is dispatched by the engine, never by you.

Now declare it — and read what the compiler says:

[Flow("ticket.open", Version = "1.0.0", Owner = "support")]
[FlowDeadline("PT10S")]
public sealed partial class OpenTicketFlow : Flow<OpenTicket, TicketOpened>
{
    protected override void Define(IFlowBuilder<OpenTicket, TicketOpened> flow)
    {
        ArgumentNullException.ThrowIfNull(flow);

        flow
            .Step<ValidateTicket>()
            .Step<RecordTicket>().CompensateWith<DeleteTicket>()
            .Step<NotifyReporter>()
            .Return(ctx => ctx.Get<TicketOpened>());
    }
}

FLOWX1012 — compensation on a flow that is not durable. The unwind stack is a field of an in-memory context. If NotifyReporter fails, the delete runs. If the process dies between the write and the notification, the instance is gone, the unwind never runs, and nothing anywhere records that a ticket was supposed to be removed. The first evidence is data that does not add up.

The fix is §8. Staying ephemeral is a legitimate answer for an effect that is cheap to leak or that something else reclaims, and FLOWX1012 says how to record that decision — but it is a decision, not a default.


8. Going Durable

Three parts, and the attribute on its own makes things worse, so change all three in the same commit.

Part one — the flow

[Flow("ticket.open", Version = "1.0.0", Profile = ExecutionProfile.Durable, Owner = "support")]
[FlowDeadline("PT10S")]
public sealed partial class OpenTicketFlow : Flow<OpenTicket, TicketOpened>
{
    protected override void Define(IFlowBuilder<OpenTicket, TicketOpened> flow)
    {
        ArgumentNullException.ThrowIfNull(flow);

        flow
            .Step<ValidateTicket>()
            .Step<RecordTicket>().CompensateWith<DeleteTicket>()
            .Step<NotifyReporter>()
            .Return(ctx => ctx.Get<TicketOpened>());
    }
}

FLOWX1012 is gone, and something else has appeared in its place.

FLOWX1006 — a state-bag contract is outside every generated JSON context. A Durable flow journals what each step produced and the flow's state bag as it stands after it, and a value reaches the journal only through JournalPayload, whose Of<T> requires the source-generated JsonTypeInfo<T> — there is no overload that reflects over a type, which is what keeps the write path trim- and AOT-safe. So every contract the bag holds needs the same [JsonSerializable] declaration an emitted event needs, and the compiler names the ones it cannot find. That is part two.

The block above reports it because these snippets are compiled on their own, with no serialiser context anywhere in the compilation. In a real project the context is the one the template already generates.

Part two — the contracts

Everything in the state bag: the flow's input, which the engine puts there before the first step, and the output of every capability step — plus, for a step that declares an Audit, the input the capability was handed. samples/banking is the shape to copy — ExecuteTransfer and TransferResult are on the wire, TransferCompleted is the event, the middle six are the step results the journal has to write, and the last three are the step inputs its audit records carry:

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(ExecuteTransfer))]
[JsonSerializable(typeof(TransferResult))]
[JsonSerializable(typeof(TransferCompleted))]
[JsonSerializable(typeof(ValidatedTransfer))]
[JsonSerializable(typeof(ScreeningDecision))]
[JsonSerializable(typeof(CorrespondentRoute))]
[JsonSerializable(typeof(DebitPosted))]
[JsonSerializable(typeof(CreditPosted))]
[JsonSerializable(typeof(Settlement))]
[JsonSerializable(typeof(DebitInstruction))]
[JsonSerializable(typeof(CreditInstruction))]
[JsonSerializable(typeof(SettlementInstruction))]
internal sealed partial class BankingJsonContext : JsonSerializerContext;

For the ticket flow that is OpenTicket, ValidatedTicket, TicketOpened and NotificationSent on AppJsonContext. The list is not maintained by reading the flow: add the attribute the compiler names, rebuild, repeat until it stops naming one.

This is what makes a resume work rather than merely happen. Without it the journal records which steps ran and nothing about what they produced, so a second node re-enters the loop with an empty state bag and the first step past the frontier that binds an earlier step's output fails. With it, the snapshot committed alongside each step is restored before the resumed loop starts.

A [Sensitive] member does not come back. It is stored as [redacted], because the journal never held anything else: the writer hands values to JournalPayload, whose only exit replaces every declared member by name at every depth. A flow that needs a secret after a resume has to fetch it, not remember it.

Part three — the host

A Durable flow on a host that registered no journal is refused before its first step, with the error flow.durability_not_configured. Not run ephemerally — refused. Running it on the ephemeral engine would be the silent gap the profile exists to close, one layer down and with no diagnostic left to raise it.

So the host has to register a journal and a lease store. Registering them is what opts a process into durability; there is no flag:

        services.AddFlowX(options =>
        {
            options.ApplicationName = "Sample.App";
            options.NodeName = LiveNode;
        });

        services.AddFlowXPostgres(
            "Host=localhost;Database=postgres;Username=postgres",
            new PostgresJournalOptions { RegisterRecoveryIndex = registerIndex });

AddFlowXPostgres registers three service types:

Service What it does Optional?
IFlowJournal commits one row per (instance, scope, step, attempt) no
ILeaseStore exclusive ownership plus a fencing token every commit carries no
IRecoveryIndex finds instances whose lease expired, so another node can finish them yes — a host without it runs durable flows and never sweeps

Both of the first two, or neither. A journal with no lease store would write under a token nothing issued; a lease store with no journal would fence nothing. AddFlowX resolves the pair together and treats "one of them" as "none" (FlowXServiceCollectionExtensions).

It does not migrate. Applying DDL as a side effect of building a container makes every replica of a rolling update race to migrate at start-up; call PostgresMigrator.MigrateAsync from wherever your deployment runs schema changes.

What Durable buys, exactly

One journal row per step boundary. When a node dies, the recovery scan finds the instance, another node takes the lease and re-enters the same step loop; the loop skips the steps the journal shows completed, and as it skips a completed compensable step it puts that step back on the unwind stack. A failure after the resume unwinds work a previous node did.

And what it does not

Two limits that are real today:

  1. The unwind is journaled at least once, not exactly once. CompensateAsync commits a row per undo attempt and a resumed instance does not repeat an undo whose row committed — but the row lands after the undo has run, so one that died in between runs again; and a composed child that already succeeded records nothing, its instance having been sealed Completed when the composition returned.
  2. A resumed parent does not rebuild a composed sub-flow's compensations. The engine skips the entry rather than approximating the child's stack, because a compensation stack that is silently short is the failure a saga exists to prevent.

Neither is a reason to stay ephemeral. Both are reasons not to read Durable as "solved".


9. Emitting an event

.Emit<TEvent>(…) publishes a domain event through a transactional outbox: the row is written in the same transaction as the step, so it is never lost and never published before the step is durable.

Add one to the durable flow, and the compiler has something to say:

public sealed record TicketRaised(string TicketId, string Reporter);

[Flow("ticket.open", Version = "1.0.0", Profile = ExecutionProfile.Durable, Owner = "support")]
[FlowDeadline("PT10S")]
public sealed partial class OpenTicketFlow : Flow<OpenTicket, TicketOpened>
{
    protected override void Define(IFlowBuilder<OpenTicket, TicketOpened> flow)
    {
        ArgumentNullException.ThrowIfNull(flow);

        flow
            .Step<ValidateTicket>()
            .Step<RecordTicket>().CompensateWith<DeleteTicket>()
            .Emit<TicketRaised>(ctx => new TicketRaised(
                ctx.Get<TicketOpened>().TicketId,
                ctx.Input.Reporter))
            .Return(ctx => ctx.Get<TicketOpened>());
    }
}

FLOWX1006 is §8's and is here for the same reason it was there: these blocks compile with no serialiser context in the compilation. FLOWX1024 is the new one, and it is the same requirement reaching a different payload.

FLOWX1024 — the emit step stages no event. The event body is written through a source-generated JsonSerializerContext; JournalPayload.Of takes a JsonTypeInfo<T> and has no overload that reflects over a type, which is what keeps the write path trim- and AOT-safe. No context declares TicketRaised, so nothing could write the body.

The fix is one attribute on the context the template already generates:

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(OpenTicket))]
[JsonSerializable(typeof(TicketOpened))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

Add [JsonSerializable(typeof(TicketRaised))] and the diagnostic goes. Exactly one context, not at least one: two contexts declaring the same contract is the same answer as none, because picking one would make the event's wire shape depend on file order.

The other trigger for FLOWX1024 is the profile. An Ephemeral flow keeps no journal, so there is no transaction for the event to be part of, and the diagnostic's message names Profile = Durable as the fix. Both cases are asserted in EmitStagingTestsAnEmitWhoseContractNoContextDeclaresIsReportedAndSaysSo, AnEphemeralEmitIsReportedAndSaysWhichProfileWouldPublishIt, and the silent case, AStageableEmitRaisesNothing.

One honest residue, now smaller. This box said "published" means "handed to an IEventPublisher" because the repository shipped no broker plugin. It ships one: AddFlowXRedisStreams registers RedisStreamEventPublisher, which appends each event to its partition_key's own Redis stream, and PublisherConformance holds it and the recording double to one contract. What is left is the list of brokers: there is no Kafka, RabbitMQ, Service Bus, Event Hubs or SNS publisher — see §12 below and ADR-0018.


10. Testing with FlowTestHost

Three levels, and picking the wrong one is the most common waste of effort (23-Testing-Strategy).

Question Level What you need
Does this business rule hold? capability construct the class, call the method, TestCapabilityContext
Does the flow run the steps in the right order, and unwind correctly? flow FlowTestHost
Does the endpoint return the right status code and media type? endpoint a real server

FlowTestHost runs the real runtime: the real engine, the real generated plan, the real pooled context, the real compensation stack. It contributes exactly two things — a dispatcher that can substitute a capability by id, and a trace. A test that passes here is a statement about the runtime, not about a simulation of it.

This is the reference sample's flow test, verbatim — a declined payment must release the reservation:

        var host = FlowTestHost
            .For(
                PlaceOrderFlow.Plan,
                new PlaceOrderFlow.Dispatcher(
                    capturePayment: new CapturePayment(new AlwaysApprovesGateway()),
                    releaseInventory: new ReleaseInventory(inventory),
                    reserveInventory: new ReserveInventory(inventory),
                    validateOrder: new ValidateOrder()))

            // The gateway above always approves, so a substitution that did not take
            // effect would leave this flow succeeding — which is what makes the
            // assertions below statements about the substitution as well as the saga.
            .Substitute("payment.capture", OrderErrors.PaymentDeclined("insufficient funds"))
            .WithInvocation(new FlowInvocation("corr-5", "key-5"))

            // payment.capture declares Authorization.Permission naming payment.write, so a
            // flow run by nobody is refused at that step and never reaches the substituted
            // decline this test is about. The caller holds exactly the one permission the
            // flow needs — not a blanket one — so the stance is still doing its job here.
            .As(TestPrincipal.Holding("payment.write"))
            .Build();
        run.Error!.Code.ShouldBe("payment.declined");
        run.Compensation.ShouldBe(CompensationOutcome.Succeeded);

Five things to copy from that:

  • The plan and the dispatcher are passed in by name. There is no For<TFlow>(): discovering the generated members would need reflection, which the AOT constraint forbids. One line, and the host stays reflection-free.
  • Substitution is by capability id, "payment.capture" — the identity the plan carries, not the class name. It survives the capability being renamed or replaced.
  • The substituted capability is one the happy path would have passed. If the substitution silently failed to apply, the test would go green for the wrong reason.
  • The flow is run as somebody. payment.capture declares Authorization = Authorization.Permission, Permission = "payment.write", and the engine decides that stance before it dispatches the step — so a host with no principal is refused there and never reaches the decline. .As(...) is how a test says who is calling, and the default is anonymous on purpose: a test that forgets gets the same answer a real anonymous caller would. TestPrincipal.Holding(...) grants the named permissions and nothing else, which is why there is no TestPrincipal.Admin — a caller who satisfied every stance would let a test pass over a permission it never held.
  • run.Trace.Executed and run.Trace.Compensated are what the endpoint test could not see: an endpoint returns one status code whether the reservation was released before, after, or instead of anything else.

What FlowTestHost does not do: mock verification, auto-wiring from a container, a journal, AwaitSignal or AwaitCompletion.


11. The six diagnostics you will meet first

The compiler is the framework teaching you. These six are the ones a newcomer hits in the first hour, in roughly that order. Every one has a page under docs/diagnostics arguing the case; this is the one-line version.

Id Fires when Fix
FLOWX1010 a capability declares no Authorization add a stance
FLOWX1030 Authorization.Permission names no permission add Permission = "…"
FLOWX1014 a retry policy on a capability that is not Idempotent make it idempotent and declare it, or handle the failure in the flow
FLOWX1012 .CompensateWith<T>() on a flow that is not Durable Profile = ExecutionProfile.Durable and register a journal
FLOWX1024 .Emit<T>() whose event no JsonSerializerContext declares [JsonSerializable(typeof(T))] on one context
FLOWX1006 a Durable flow's state bag holds a contract no JsonSerializerContext declares the same attribute, for the contract the message names — see §8

FLOWX1010 — a capability must declare an authorisation stance

There is no permissive default anywhere in FlowX. Authorisation attaches to the business operation rather than to a route, so it holds identically over HTTP, over a bus and from an agent — but only if it is declared.

[Capability("ticket.archive", Version = "1.0.0")]     // no Authorization
public sealed class ArchiveTicket : ICapability<TicketOpened, TicketOpened>
{
    public ValueTask<Result<TicketOpened>> ExecuteAsync(
        TicketOpened input, CapabilityContext ctx, CancellationToken ct) =>
        ValueTask.FromResult(Result.Ok(input));
}

[Flow("ticket.archive", Version = "1.0.0", Owner = "support")]
[FlowDeadline("PT10S")]
public sealed partial class ArchiveTicketFlow : Flow<TicketOpened, TicketOpened>
{
    protected override void Define(IFlowBuilder<TicketOpened, TicketOpened> flow)
    {
        ArgumentNullException.ThrowIfNull(flow);

        flow.Step<ArchiveTicket>().Return(ctx => ctx.Get<TicketOpened>());
    }
}

You will see CS9035 first, and that is worth knowing in advance: Authorization is a required member, so the C# compiler objects before FlowX gets a chance to. FLOWX1010 then fires at the flow's .Step<ArchiveTicket>() rather than at the attribute — a known rough edge, recorded in CHECKLIST.md, not a subtlety you are missing.

The one-line fix: Authorization = Authorization.Authenticated — or Permission, Internal, or Public with an [ApprovedBy] naming the reviewer. The IDE quick action offers only Authenticated and Internal; Public is deliberately absent, because clearing a security error with one keystroke is the outcome the rule exists to prevent.

FLOWX1030 — a Permission stance with no permission named

Authorization.Permission is a claim that some named grant is required. Without the name it publishes "authorization": { "mode": "Permission" } — a manifest entry that reads as enforced and names nothing to enforce. It also kills half a security gate: flowx diff reports a changed permission as breaking, and a stance with no value has nothing to change.

[Capability("ticket.close", Version = "1.0.0",
    Authorization = Authorization.Permission)]        // required — which permission?
public sealed class CloseTicket : ICapability<TicketOpened, TicketOpened>
{
    public ValueTask<Result<TicketOpened>> ExecuteAsync(
        TicketOpened input, CapabilityContext ctx, CancellationToken ct) =>
        ValueTask.FromResult(Result.Ok(input));
}

[Flow("ticket.close", Version = "1.0.0", Owner = "support")]
[FlowDeadline("PT10S")]
public sealed partial class CloseTicketFlow : Flow<TicketOpened, TicketOpened>
{
    protected override void Define(IFlowBuilder<TicketOpened, TicketOpened> flow)
    {
        ArgumentNullException.ThrowIfNull(flow);

        flow.Step<CloseTicket>().Return(ctx => ctx.Get<TicketOpened>());
    }
}

The one-line fix: Permission = "ticket.write" — or, if no named grant is really required, Authorization.Authenticated, which is complete in itself. Authorization.Policy takes Policy = "…"; the message names whichever property the declared mode reads.

FLOWX1014 — no retry on a non-idempotent capability

Retrying a non-idempotent operation duplicates its effect. For a payment capture that is a duplicate charge, and this is the single most expensive bug the platform prevents structurally.

[Capability("ticket.charge", Version = "1.0.0",
    Authorization = Authorization.Permission, Permission = "billing.write",
    SideEffects = ["billing"])]                       // Idempotent defaults to false
public sealed class ChargeForTicket : ICapability<TicketOpened, TicketOpened>
{
    public ValueTask<Result<TicketOpened>> ExecuteAsync(
        TicketOpened input, CapabilityContext ctx, CancellationToken ct) =>
        ValueTask.FromResult(Result.Ok(input));
}

public static class Policies
{
    public static readonly PolicySet Billing = PolicySet.Named("billing").Retry(attempts: 3);
}

[Flow("ticket.charge", Version = "1.0.0", Owner = "support")]
[FlowDeadline("PT10S")]
public sealed partial class ChargeFlow : Flow<TicketOpened, TicketOpened>
{
    protected override void Define(IFlowBuilder<TicketOpened, TicketOpened> flow)
    {
        ArgumentNullException.ThrowIfNull(flow);

        flow
            .Step<ChargeForTicket>().WithPolicy(Policies.Billing)
            .Return(ctx => ctx.Get<TicketOpened>());
    }
}

The one-line fix: Idempotent = true on the capability — and mean it. Pass ctx.IdempotencyKey downstream so the provider deduplicates, then the declaration is honest. If you cannot, delete the retry and handle the failure in the flow. There is no suppression for this one.

FLOWX1012 — compensation on an ephemeral flow

Covered in §7. One line: an ephemeral unwind stack lives in one process's memory, so a crash between the effect and the failure loses the undo silently.

The one-line fix: Profile = ExecutionProfile.Durableand register a journal and a lease store on the host, or you have traded a warning for a start-up refusal. That is also why there is no quick action: a one-click fix would produce a flow that stops working.

FLOWX1024 — an emitted event needs a JsonSerializerContext

Covered in §9.

The one-line fix: [JsonSerializable(typeof(TicketRaised))] on exactly one context — or, if the flow is Ephemeral, Profile = ExecutionProfile.Durable, because an ephemeral execution has no transaction to stage the row in.

Suppressing any of them

FLOWX1012 and FLOWX1024 are warnings; the rest are errors. The template — and this repository — build with TreatWarningsAsErrors, so in practice all five stop a build.

A suppression must carry a FLOWX-DEBT marker with an owner and an expiry, or the build fails on the suppression itself (21 §6). The reference sample carries two, both in samples/ecommerce/PlaceOrderFlow.cs and both with the argument inline: FLOWX1012, because an ephemeral saga is the deliberate choice for a sample that must run without a database, and FLOWX1024, which follows from it — an ephemeral flow has no transaction to stage an event in. The second carries a FLOWX-DEBT id, owner and expiry; the first is argued as a decision rather than debt, on the grounds docs/DEBT.md draws. They are left visible on purpose: this is the file people copy.


12. What FlowX cannot do yet

Read this before you plan around FlowX rather than after you hit it. None of it is hidden — each item is stated where it is relevant — but a newcomer who discovers it by walking into it will discount everything else on this page.

Three trigger kinds of eight. [HttpTrigger] produces a registration for app.MapFlowX(), [CronTrigger] one for services.AddFlowXSchedules(), and [BusTrigger] and [KafkaTrigger] one for services.AddFlowXSubscriptions(). StreamTriggerAttribute, AgentTriggerAttribute and the Change kind compile and reach the manifest's triggers block — and nothing serves them.

This paragraph said "one transport", and said that a Kafka trigger "will never receive a message" and that "no diagnostic reports a trigger that nothing serves". All three expired on 2026-08-01. A bus subscription is consumed by whichever IBusConsumer the host registers, and FLOWX1039 now refuses the two bus declarations nothing could serve — which is what this block had to be changed to satisfy:

// The step that turns the delivered body into your own contract. This is the layer with a
// serialiser context in scope, and the layer whose failures are Results — the host hands the
// body over undeserialised, because naming a JsonTypeInfo for your type is something only
// generated code can do.
[Capability("ticket.read", Version = "1.0.0",
    Authorization = Authorization.Internal,
    Idempotent = true)]
public sealed class ReadRaisedTicket : ICapability<BusMessage, TicketOpened>
{
    public ValueTask<Result<TicketOpened>> ExecuteAsync(
        BusMessage input,
        CapabilityContext ctx,
        CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(input);
        ArgumentNullException.ThrowIfNull(ctx);

        return ValueTask.FromResult(input.Payload is { Length: > 0 } body
            ? Result.Ok(new TicketOpened(ctx.IdempotencyKey, body))
            : Result.Fail<TicketOpened>(TicketErrors.SubjectRequired()));
    }
}

[Flow("ticket.import", Version = "1.0.0", Owner = "support")]
[FlowDeadline("PT10S")]
[BusTrigger("tickets.raised", Group = "ticket-import")]
public sealed partial class ImportTicketFlow : Flow<BusMessage, TicketOpened>
{
    protected override void Define(IFlowBuilder<BusMessage, TicketOpened> flow)
    {
        ArgumentNullException.ThrowIfNull(flow);

        flow
            .Step<ReadRaisedTicket>()
            .Return(ctx => ctx.Get<TicketOpened>());
    }
}

That block does not build, and the diagnostic is the point: FLOWX1039 refuses it because the flow is not Durable. A broker delivers at least once — that is its contract, not its defect — and the answer is that the delivery derives the instance id it starts, so the journal's primary key refuses the second one. Without a journal that id is inert and every redelivery would import the ticket again, with nothing anywhere recording that it had.

Adding Profile = ExecutionProfile.Durable fixes it, and then a durable flow needs [JsonSerializable(typeof(BusMessage))] in one of your serialiser contexts, because it journals its input — FLOWX1006 asks for exactly that one declaration. samples/ecommerce/RepriceOrderFlow.cs is the complete, compiled version, and tests/Ecommerce.Tests/EmitStartsAFlowTests.cs runs it against a real PostgreSQL and a real Redis.

Telemetry, on all three signals. This paragraph read "No telemetry. Not 'partial', not 'basic' — none. There is no ActivitySource and no Meter anywhere under src/." There are both, and a log bridge: FlowX emits spans per flow and per step and metrics through an ActivitySource and a Meter both named FlowX, which is the seam an OpenTelemetry SDK attaches to with AddSource("FlowX") and AddMeter("FlowX"). FlowXLogBridge puts the flow, the step and the correlation id into a log scope, and a [Sensitive] member reaches that sink redacted like every other JournalPayload exit. Nothing allocates per step until something is listening, which is budget B6. samples/ecommerce/Telemetry.cs is a hand-written collector — spans to the console, Prometheus text at /metrics — written that way because that project is published with NativeAOT and takes no SDK reference.

All nine policy kinds execute. PolicySet has Retry, Timeout, CircuitBreaker, Bulkhead, Cache, RateLimit, Idempotency, Audit and CompensationRetry, and every one of them is applied: .WithPolicy(… .Retry(3)) on a forward step makes three attempts, a Timeout is armed per attempt and clamped to the flow deadline, a CircuitBreaker opens per capability, a Bulkhead refuses a caller past its queue depth, a RateLimit admits or refuses the caller before the step, an Idempotency window claims a key and replays what a previous caller recorded under it, a Cache is consulted before the dispatch, an Audit record is written after the step's commit, and CompensationRetry wraps the undo. FLOWX1032 — the rule that reported a declared policy nothing applied — is deleted with the last of the gap it described.

Four of them need a store, and three of the four refuse without one. RateLimit needs an IRateLimiterStore, Idempotency an IIdempotencyStore and Audit an IAuditSink; a step declaring one with nothing registered fails rather than running unpoliced. Cache is the exception: with no IResultCache registered the step simply dispatches, because an unconsulted cache costs latency and never correctness.

And four things you may expect around a policy are missing. There is no [Timeout], [Retry] or [CircuitBreaker] attribute — a policy attaches through .WithPolicy(...) on a step and nowhere else; there is no flow-level policy surface; there is no runtime configuration that reaches a policy parameter. The fourth item here read "no policy emits a metric, because there is no metrics infrastructure at all … a breaker that opens does so silently". Both halves are false: all seven metrics 10 §9 specifies are emitted, including flowx_circuit_state when a breaker opens and flowx_policy_invocations_total as the denominator without which the rest have no scale.

Waits work, and a schedule fires them without a scheduler process. A Durable flow that reaches an AwaitSignal<TSignal>(timeout) or a Delay(duration) suspends: the invocation returns, the instance is Suspended in the journal at its resume frontier holding no thread and no lease, and it records which wait it is parked at and when it is due. FlowHost.SignalAsync resumes it on a signal; FlowTimerScan — a sweep on an interval, not a timer per instance — resumes it when the instant passes. An .OnTimeout(…) block runs when the declared duration expires, and a wait with no block ends the flow with flow.signal_not_received. FLOWX1017 refuses either construct on a flow that journals nothing, correctly: an in-memory wait does not survive a deployment, and a timer outside a journal has nowhere to record when it is due. What that costs you: a wait is a lower bound, because it is resolved by a sweep — FlowXOptions.TimerScanInterval is ten seconds by default. SubFlowMode.AwaitCompletion is still refused outright by FLOWX1026, and an inline composed child that suspends is refused at run time — give a flow that waits its own trigger, or compose it Detached.

This paragraph's heading read "there is no scheduler engine behind them". A [CronTrigger] is served: app.Services.AddFlowXSchedules() is generated from the attribute, every node sweeps for the same occurrence and derives the same instance id from it, so the firing happens once across a cluster and the replicas that lost are refused by the lease store and then by the journal's primary key. A firing that fell due while every node was down happens late. samples/workflow's offer.window.close is the whole of it, and no line of its Program.cs names a time. MissedFire and PerTenant are read into the schedule; Overlap and Jitter are declared on the attribute and reach nothing.

Multi-tenancy at two of four levels. This paragraph read "No multi-tenancy. Nothing consumes it … every isolation level is the same level, and it is 'none enforced by the platform'". That is no longer true of any clause in it. A tenant is resolved at admission from validated claims and refused when absent — FlowXOptions.TenantIsolation is None by default and costs nothing, Row binds every journal connection to an unprivileged role under PostgreSQL row-level security, and Schema gives each tenant a schema and a connection pool of its own. Five fairness mechanisms bound what one tenant may cost the others: admissions per window, a longer quota, in-flight concurrency, a share of each recovery page, and per-tenant weights. samples/banking runs at both levels one environment variable apart. What is left: L3 and L4 — a database or a region per tenant — and residency.

No Studio, and no flowx dev. The CLI has five verbs — graph, manifest, diff, verify --cost and replay --mode inspect (22-CLI). That count was four until replay landed. There is no visual designer, no live reload, no start-up banner.

And one smaller one you will meet sooner than you expect. FLOWX.Sdk, the metapackage the SDK document's table promises, does not exist; a project references six packages by hand.

Authentication is yours to supply. The declared authorisation stance is enforced at run time — the engine decides it on the step, from validated claims — but the identity it decides against has to come from somewhere, and Authentication.cs in the generated project is a dictionary of two constant tokens with no signature, issuer, audience or expiry. It is a stand-in, and the first thing to replace: delete that file and register a real scheme — builder.Services.AddAuthentication().AddJwtBearer(...) with your authority and audience — which needs the Microsoft.AspNetCore.Authentication.JwtBearer package, the seventh this project would reference by hand. Nothing else in the project changes, because nothing else in it knows how the principal was obtained.


13. Where to go next

If you want Read
the model in full 04 Core Concepts, 07 Capability Model, 08 Flow Definition
branching, parallel, ForEach, sub-flows 08 Flow Definition
what the engine actually does 06 Execution Engine
how to test each level properly 23 Testing Strategy
every diagnostic, with the argument for its severity diagnostics
a complete application samples/ecommerce
the template itself templates/README.md

14. How this page is verified

tests/FlowX.Compiler.Tests/GettingStartedTests.cs reads this file, finds every fenced block, and requires each one to carry an HTML comment saying how it is checked. A block without one fails the test — which is the half that matters, because otherwise the page could grow an unchecked snippet and stay green.

Marker What the test does
verify: preamble compiles the block, and adds it to the sources every later compiled block gets
verify: compiles compiles it and asserts no diagnostic — FlowX or C#
verify: reports FLOWX1012 compiles it and asserts the diagnostics are exactly those
verify: excerpt <path> asserts the block appears verbatim in that repository file
verify: prose <why> not C#; never accepted on a csharp block

Compiled blocks are separate syntax trees with a fixed header supplying the usings and namespace Ordering;, so two blocks declaring the same type collide exactly as two files would. They are run through FlowPlanGenerator and every FlowX analyzer, and the generated code is compiled too — so a snippet that makes the generator emit something that does not bind fails here rather than in your project.

Excerpts point at files that are already built elsewhere: the template (templates/verify.sh builds it with TreatWarningsAsErrors and asserts zero warnings) and test projects in the solution. Template excerpts are matched after the name substitution dotnet new performs, so the page quotes the file you get rather than the file in templates/.

The terminal transcripts and the manifest in §2 and §4 are marked prose: they are output, not source, so nothing compiles them. They were produced by running exactly the commands shown, against the commit that added this page — with two edits, both stated here because a transcript is only worth what its provenance is: the working directory was shortened to /home/you, and the manifest in §4 is abridged to one flow and one capability. The whole of it is what templates/verify.sh asserts on, and running the four commands is the cheapest way to check this page has not gone stale.