Severity: Warning · Category: FlowX · Since: 0.1.0 Applies to: any flow containing
.CompensateWith<T>()whoseProfileis notExecutionProfile.Durable— including a flow that names no profile, becauseEphemeralis the default. Code fix: none, deliberately — see Why there is no quick action.
Note
This id was reserved for two phases and deliberately left unraised. Not because the
check was hard — it is one predicate — but because its remedy was not true.
Profile = Durable changed nothing at all while FlowX.Runtime read no profile; once
WP-52 made the runtime read it, the remedy briefly changed something worse than
nothing, because a durable flow with no journal is refused before its first step. WP-53
and WP-55 gave a host a journal and a lease store to register. A rule whose fix is a lie
is worse than an unraised id, which is why this one waited; the fix stopped being a lie,
which is why it no longer does.
The flow declares compensation — a .CompensateWith<T>() somewhere in its chain — and
runs under a profile that keeps the whole instance in the memory of one process.
Compensation on FlowX works by pushing each successfully completed compensable step onto
an unwind stack, and running that stack in reverse when a later step fails
(06 §7). On an Ephemeral flow that stack is a field of a
pooled in-memory context. It is not written anywhere. So:
| What happens | Ephemeral | Durable |
|---|---|---|
| A later step fails | the unwind runs | the unwind runs |
| The step's own attempt fails | the unwind runs | the unwind runs |
| The flow's deadline expires | the unwind runs | the unwind runs |
| The process dies mid-flow — crash, deploy, scale-in, OOM kill | the instance is gone and the unwind never runs | the recovery scan finds the instance, another node resumes it, and the completed compensable steps go back on the stack |
The last row is the whole rule. It is also the row that never shows up in a test, because nothing in a test suite kills a process between two steps.
What it costs in practice. A compensation exists because a step took an effect that has to be given back: a stock line reserved, a seat held, a card authorised, a quota consumed. When the unwind is lost, that effect stays taken and nothing anywhere records that it was supposed to be released. There is no dead letter, no failed-compensation metric, no instance to inspect — the instance never existed outside one process's heap. The operator's first evidence is inventory that does not add up.
ADR-0003 named flowx verify --cost as the
standing mitigation for "a wrong profile is a real bug class" and stated that it "flags
exactly the accident FLOWX1012 would have caught at build time — from the manifest rather
than the source, and after the build rather than during it". That is not what the check
does. Reading src/FlowX.Cli/Verification/ProfileCostCheck.cs:
var durable = manifest.Flows
.Where(flow => string.Equals(flow.Profile, DurableProfile, StringComparison.Ordinal))It selects the flows whose manifest profile is Durable and reports the ones with no
compensation, no signal and no timer. A compensable Ephemeral flow is never in the set
it examines, and could not be: the finding it emits reads "profile is Durable, with no
compensation, no signal and no timer", which is the opposite sentence.
So the verb catches the expensive half of a wrong profile — durability bought and not used — and until this rule shipped, the lossy half had no check anywhere, at build time or after it. The claim that a post-build report already covered it is what made the reservation look cheaper to keep than it was.
The two are complementary rather than overlapping, which is why neither retires the other:
flowx verify --cost |
FLOWX1012 |
|
|---|---|---|
| Asks | is this flow paying for durability it does not use? | is this flow losing an unwind it declared? |
| Sees | one manifest, every flow in an application | one compilation, one flow's source |
| When | after the build, where somebody wired the command in | while the .CompensateWith is being typed |
| Could the other do it? | no — "nothing in this flow needs a journal" is a judgement across a whole application | no — the manifest records that a step has a compensation, but the decision being questioned is the profile the author defaulted into |
[Flow("order.place", Profile = ExecutionProfile.Ephemeral, Owner = "orders")]
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FLOWX1012
public sealed partial class PlaceOrderFlow : Flow<PlaceOrder, OrderPlacedResult>
{
protected override void Define(IFlowBuilder<PlaceOrder, OrderPlacedResult> flow) => flow
.Step<ValidateOrder>()
.Step<ReserveInventory>().CompensateWith<ReleaseInventory>()
.Step<CapturePayment>()
.Return(ctx => new OrderPlacedResult(/* … */));
}The build succeeds today. ReserveInventory holds stock; if the node dies between the
reservation and the end of the flow, ReleaseInventory never runs and the hold stands
until someone notices.
A flow that names no profile triggers it too, and that is the common case:
[Flow("order.place", Owner = "orders")]
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FLOWX1012 — the default is EphemeralADR-0003 makes durability something you opt into,
so silence means Ephemeral. This is the one rule in the catalogue whose subject is the
default: every other profile rule is quiet about it and speaks up about a declaration.
Silent: a Durable or Streaming flow with compensation; any flow without compensation,
under any profile; a CompensateWith on something that is not a FlowX builder (the call is
resolved semantically, not matched by name).
Note
Streaming triggered this rule until 2026-08-02, and the justification was wrong. The
condition was not Durable rather than does this journal?, defended with "Streaming
runs on the ephemeral engine" — a sentence P7 falsified. A window's flow journals a row per
step boundary; PostgresRecoveryIndex lists an unfinished instance on
state IN ('Pending', 'Running', 'Compensating') with no profile in the predicate;
FlowStreamSubscriptionRegistration.Add registers the plan in FlowCatalog for the stated
purpose of letting FlowRecoveryScan take such an instance over; and the resume puts each
skipped compensable step back onto the unwind stack off cursor.IsJournaled. The window a
surviving node rebuilds does not race that unwind — its derived id meets flow_instance's
primary key and FlowStreamScan.DispositionFor reads that as deduplicated
(ADR-0055). So the loss this
rule reported does not happen, and the fix it prescribed was one the flow could not take:
Profile = Durable on a stream-triggered flow is FLOWX1042, emits no
subscription, and is refused by FlowStreamCatalog.Add at start-up.
FLOWX1017 and ExecutionPlan.Create had the same literal and changed the
same day the runtime did; this was the last of them.
A Streaming flow that no stream starts is still FLOWX1028 — the profile
buying nothing — but its instances are journaled per invocation, so its compensations survive
and this rule has nothing to add.
[Flow("order.place", Profile = ExecutionProfile.Durable, Owner = "orders")]and register the stores the profile requires, on the host:
builder.Services.AddFlowX(options => options.ApplicationName = "Orders");
builder.Services.AddFlowXPostgres(connectionString);Both halves, or the fix is not one. FlowInvocation refuses a Durable flow that was
started with no journal — flow.durability_not_configured, before its first step — rather
than running it ephemerally and pretending. That refusal is deliberate
(FLOWX1028 documents the silence it replaced), and it means the attribute
alone converts a lost compensation into a flow that does not start. Change the attribute
and the wiring in the same commit.
A durable flow commits 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 every step 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 everything the flow has done, including the parts a previous node did. That is the guarantee this diagnostic is asking you to buy.
Since WP-57, CompensateAsync commits one row per undo attempt — keyed past the forward row
it reverses, carrying the compensating capability's id, JournalOutcome.Compensated when
the undo worked and Failure when it did not, and moving the instance to Compensating. A
resumed instance reads those rows and leaves a step whose undo already committed off the
rebuilt stack, so a crash halfway through an unwind resumes the unwind instead of refunding
the same payment twice (06 §7 rule 5).
- An undo whose row never landed runs again. The row is committed after the undo has
run, so a process that dies in between leaves no record and the resumed instance puts the
step back on the stack. That is the same limit
ADR-0006 states for a forward effect that landed
before its commit, and it is why
FLOWX1014's idempotency rule is checked against the compensating capability. A composed child that already succeeded records nothing at all: the parent seals the child's instanceCompletedwhen the composition returns, a journal correctly refuses a write to a finished instance, so when the parent later unwinds that child the undos run and nothing writes down that they did. - A resumed parent does not rebuild a composed sub-flow's compensations. The parent
records a
SubFlowstep as one entry bound to the child's context, and that context died with the node. The engine skips the entry and deliberately does not approximate the child's stack, because a compensation stack that is silently short is the exact failure a saga exists to prevent. Rebuilding it from the child's own rows needs a "which instances are under this parent" queryIFlowJournaldeliberately does not answer — it is a recovery scan's — so WP-57 left this one standing rather than widen a contract every store implements.
Neither is a reason to stay ephemeral. Both are reasons not to read Durable as "solved".
Delete the compensation, and handle the effect some other way — a TTL on the reservation, a nightly sweeper, an idempotent overwrite. This is a real answer for cheap, self-expiring effects, and it is honest in a way that "keep the compensation and hope" is not: it says out loud that nothing is going to undo the step.
It is the wrong answer whenever the effect is money, or is visible to a customer, or has no natural expiry.
FLOWX1017 has one — AwaitSignalRequiresDurableCodeFixProvider writes
Profile = ExecutionProfile.Durable — and the same edit would clear this rule. It is
deliberately not offered here, and the difference is the point.
For FLOWX1017 the source is internally inconsistent: an in-memory flow cannot suspend
for a signal at all, so the author's AwaitSignal and their profile cannot both be what
they meant, and the profile is the only one of the two a fix may change. The result compiles
and works.
Here the source is not inconsistent. A compensable ephemeral flow runs, and unwinds
correctly on every failure that is not a crash. What a one-click fix would produce is a flow
that stops working: durable, refused at run time with flow.durability_not_configured
until somebody registers a journal and a lease store, which is a change to a host the code
fix cannot see and may not even be in the same repository. Trading a build warning for a
start-up failure is the fix that silences the rule rather than the fix that is correct, and
that is the one thing a quick action may not be.
When you have decided the flow stays ephemeral and know what that costs. Say which of the two grounds it is:
- The effect is cheap to leak, or something already reclaims it. A hold with a TTL, a cache entry, a reservation a nightly job sweeps.
- The crash window is acceptable for this operation — a short flow, a low-value effect, and a documented manual recovery path.
Neither of those is technical debt. docs/DEBT.md draws exactly this line:
"a documented trade-off recorded in an ADR is a decision, not debt… Ephemeral flows losing
state on a crash is ADR-0003." So the suppression carries a reason, not a FLOWX-DEBT
id with an expiry — there is nothing to expire, because nobody intends to change it.
For one flow, at the declaration:
#pragma warning disable FLOWX1012 // Deliberate: holds expire after 15 minutes; see ADR-0003
[Flow("cart.reserve", Profile = ExecutionProfile.Ephemeral, Owner = "checkout")]
#pragma warning restore FLOWX1012The SuppressionsAreAccountable gate fails the build on a bare pragma with no same-line
reason. A sentence a reader can evaluate is the minimum.
Do not disable it project-wide. Ephemeral is the default profile, so a project-wide
downgrade is not a decision about your sagas — it is a decision to stop being told which of
your flows are sagas at all.
Three reasons, and the first two are about truth rather than about adoption.
The source is not wrong. A compensable Ephemeral flow compensates correctly on every
failure that is not a crash. What it loses is one window, and losing it is a trade
ADR-0003 ratified — the profile is a per-flow
decision precisely so that a flow may decline durability. An error would make a decision the
architecture record ratified inexpressible, which is not a compiler's job.
The remedy has a prerequisite the compiler cannot see. The fix is complete only when a
host registers a journal and a lease store. An error would stop a build until the author
made an edit whose correctness depends on a deployment fact no analyzer can check — and the
most likely response to that is Profile = Ephemeral again with the rule turned off
globally, which is strictly worse than the warning.
Ephemeral is the default. An error here breaks every compensable flow in every
codebase that has not already opted into durability, on the day it ships. A rule that cannot
be adopted incrementally is a rule that gets adopted by .editorconfig and then never read.
An Info diagnostic never appears in a build log: dotnet build does not print it, MSBuild
does not fail on it, and no gate in 21-Quality-Gates notices it.
That argument sank Info for the determinism set at WP-58, and it is stronger here — those
rules at least escalate to errors on durable flows, whereas this one reports only on
flows that declined durability. An Info FLOWX1012 would be invisible in essentially every
build that could contain it, which is precisely the state the reserved id was already in.
The determinism set ships "Warning by default, Error where the compilation can prove the code is on a durable flow's replay path". Read quickly, that looks like a house style this rule should inherit. It cannot: this rule reports because the flow is not durable. Its trigger and that escalation are mutually exclusive — there is no compilation in which FLOWX1012 fires and that proof is available.
There is exactly one escalation a reader will propose: a Durable parent composing this
flow as a sub-flow, by the same transitive reasoning the determinism set uses for
capabilities. It is refused, and not for tidiness. A sub-flow's steps do run inside the
parent's instance — but the parent journals the composition as a single entry bound to the
child's context, and a resumed parent skips that entry without rebuilding the child's
compensation stack (the second limit above, which WP-57 left standing). Escalating on the
parent's profile would stop somebody's build on a guarantee the engine does not deliver.
ADurableParentComposingThisFlowDoesNotEscalateIt pins it.
This repository sets TreatWarningsAsErrors, so the rule stops this build. It did, on
the first build after it was written, in the one place that mattered.
samples/ecommerce/PlaceOrderFlow.cs is a compensable saga on Ephemeral: it reserves
stock, compensates with a release, and then captures a payment. It is, almost word for word,
the flow ADR-0003's negative bullet describes when it says "Ephemeral on a payment saga
loses work on deploy". It was the rule's first and only finding across src/, plugins/,
samples/ and tests/, and it broke the build.
The finding is a true positive and the sample keeps Ephemeral anyway. The reasoning,
recorded here rather than left as a quiet pragma:
- The sample's value is that
dotnet runserves an order with nothing behind it — CI publishes it and then runs it, because linking and serving a request are different facts. The only journal FlowX ships is PostgreSQL (plugins/FlowX.Postgres); there is no in-process store insrc/. - So
Profile = Durablein the sample means the reference application cannot place an order without a database, and seven of its forty tests need one to pass. That is a real cost paid by every reader. - And it would buy a crash-safe unwind for an inventory store that is a
Dictionaryand a payment gateway that always approves. The durability would be real and the thing being made durable would not.
A future in-process journal in src/FlowX.Testing would change this answer, and it is the
thing to watch for: at that point the sample can be durable and still start with no
infrastructure, and the pragma should go.
What did change is the sample's account of itself. Its README used to say the flow is ephemeral because "durable execution … is P2" — a reason that expired when P2 landed. It now says the flow is ephemeral because the sample chose to be runnable with nothing behind it, and states what that costs. The claim 06 §7 made — "no analyzer warns: FLOWX1012 does not exist and never has" — is likewise no longer true and has been rewritten.
Back to: diagnostics index · Execution engine §7 · ADR-0003 · FLOWX1017 · FLOWX1028 · DEBT register