Effects, Actions, and Why Workflow Runtimes Need Side-Effect Boundaries

First published in my LinkedIn newsletter. Corsac is still a work in progress; this describes it as it was then.
The annoying part of a workflow runtime isn't running code.
It's knowing what code is safe to run again.
That one question shapes almost everything: retries, replay, recovery, failure handling, run history, operator trust. If a process dies halfway through a workflow, the runtime has to decide what probably happened and what it's allowed to repeat.
Get that wrong and you charge a card twice. Or you don't charge it at all. Or you send the same Slack message six times during a deploy and pretend it was a glitch.
That's why Gust and Corsac now distinguish effect from action.
The distinction is small in syntax, large in runtime meaning.
A workflow is mostly side effects
Consider an order notification workflow:
effect parse_order_json(body: String) -> OrderPayload
effect format_slack_message(order: OrderPayload) -> String
action post_slack(channel: String, text: String, credential_id: String) -> String
effect compute_retry_eligible(step: String, attempt: i64) -> bool
All four declarations are side-effectful from the language's point of view because the workflow calls out to host code.
But operationally, they aren't the same.
Parsing a JSON body isn't the same as posting to Slack. Formatting a message isn't the same as charging a card. Computing retry eligibility isn't the same as sending an email.
If a runtime repeats parsing or formatting, that's usually fine. If it repeats a Slack post, a card charge, or a webhook call, the user sees duplicated work — and the user is the one who notices first.
A workflow runtime needs that distinction in the contract, not hidden in handler names.
The rough model
The current model:
effect: replay-safe, idempotent, or non-committing work in the workflow pathaction: non-idempotent or externally visible committed work
That's intentionally practical language.
An effect can still talk to the outside world. It might read from a cache, call a validation service, or compute something through host code. The point is that the workflow runtime is allowed to treat it as safe enough to repeat in the durability model being used.
An action is different. It's the operation where the workflow commits something visible outside the runtime. Sending the message. Charging the card. Creating the ticket. Posting the webhook.
That operation needs a boundary. Otherwise the runtime is guessing in the dark.
The failure case
The easiest way to see the problem: imagine a crash at the worst possible time.
Suppose a handler does this:
on notify(ctx: MessageFormattedCtx) {
let ts = perform post_slack("#orders", ctx.slack_text, "cred-slack-prod");
let retry = perform compute_retry_eligible("notify", 1);
goto NotificationSent(ctx.order.order_id, ts);
}
Now imagine this timeline:
| Step | What happened | What the runtime knows |
|---|---|---|
| 1 | The workflow entered MessageFormatted. |
This state was recorded. |
| 2 | post_slack sent the message. |
Maybe nothing was recorded yet. |
| 3 | The process died before goto NotificationSent. |
The last durable boundary still looks like MessageFormatted. |
| 4 | The runtime retries from the last boundary. | It might call post_slack again. |
That's the duplicate-action bug.
Not theoretical. Just bad timing.
The issue isn't Slack specifically. It could be a payment charge, a ticket creation, a webhook, an email, or any operation where the outside world observes the result. Once that operation succeeds, the runtime needs a way to avoid pretending it didn't happen.
Why Corsac cares
Corsac Phase 1 uses node-boundary durability.
Which means it records progress around meaningful workflow boundaries instead of implementing full event-sourced replay of every internal step and effect result. That's the right Phase 1 scope because it keeps the runtime small enough to build and inspect.
But node-boundary durability creates a constraint: handlers have to be honest about committed side effects.
If a handler performs an action, then performs more effects, then transitions, the runtime has the problem above. If the process dies after the action but before the later work completes, a naive retry could run the action again.
Which is why Corsac's accepted Phase 1 rule is conservative:
- a handler path may perform many effects
- a handler path may perform at most one action
- that action should be the last side-effectful step before transition
This isn't the only possible workflow durability model. It's the one that matches the current Corsac runtime. Picking a model and being honest about its constraints is more useful than promising a model the runtime can't actually back up.
What the rule looks like
This is the safe shape:
on notify(ctx: MessageFormattedCtx) {
let ts = perform post_slack("#orders", ctx.slack_text, "cred-slack-prod");
goto NotificationSent(ctx.order.order_id, ts);
}
The action is the final side-effectful call before the transition.
The suspicious shape is the one from the failure timeline. The problem isn't that compute_retry_eligible is bad. The problem is that it happens after the committed action. If the runtime needs to reason about replay from a boundary, that path is harder to make safe.
In a stronger future runtime with full event logging, checkpointed action results, and replay semantics, the rule might evolve. In Phase 1, conservative is the correct default.
What Gust contributes
The important design move was pushing this distinction into Gust itself.
Gust now has an action keyword alongside effect, and the compiler preserves that distinction through the AST, formatting, code generation metadata, and MCP parse output.
The validator also warns when action usage violates the handler-safety rules:
- more than one action on a path
- an action followed by later side-effectful work before transition
Which means the language can help runtime authors before Corsac even enters the picture.
It also means tooling can see the distinction. A parser, MCP tool, editor integration, or workflow runtime can inspect a machine and know which declared operations are effects and which are actions.
What Corsac contributes
Corsac also performs conservative workflow linting before check, build, and run.
That linter scans .gu source for declared effects/actions and performed calls. It rejects workflows that violate the Phase 1 safety model.
This isn't presented as a perfect proof. The current Corsac linter is source-level, not a full Gust parser. It can be confused by comments, strings, quoted braces, or future Gust syntax.
That limitation is acceptable for Phase 1 because it's explicit. The stronger long-term answer is to consume Gust compiler metadata or use a parser-backed validation layer.
The important thing is that Corsac doesn't silently ignore the boundary. It enforces the rule its current runtime model depends on. Which also makes the limitation visible — if a workflow violates the side-effect placement rule, it fails early during check, build, or run instead of becoming an ambiguous recovery problem at 3am.
Why not make every side effect an action?
Because then the runtime loses useful information.
If every call is treated as non-idempotent committed work, the workflow becomes harder to retry, harder to replay, and harder to optimize. The runtime can't distinguish between formatting a message and sending it.
That pushes too much caution into the system. It also trains operators to ignore the warnings, because the warnings are everywhere.
The goal isn't to make workflows afraid of side effects. The goal is to make side-effect categories explicit enough that the runtime can make better decisions.
Why not make actions automatically safe?
Because a keyword can't make an external operation idempotent.
Calling something an action doesn't prevent Slack, Stripe, or another API from receiving duplicate requests if the runtime is careless. The keyword is a signal. The runtime still has to decide how to record action results, how to pass idempotency keys, how to retry, and how to surface uncertainty to an operator.
In Corsac Phase 1, the answer is intentionally modest: keep action placement constrained and record run history clearly.
Later phases can go further:
- checkpoint action results
- add idempotency-key conventions
- persist effect/action event logs
- replay from recorded results
- expose action boundaries in run inspection
- derive stronger metadata from Gust directly
Those are runtime features. The language distinction is the foundation.
What changes later
The Phase 1 rule is conservative because the Phase 1 runtime is conservative.
A more advanced runtime could relax parts of it if it records enough information:
- action request ID
- action arguments
- action result
- idempotency key
- state before the action
- state after the action
- whether replay should return the recorded result or fail for operator review
At that point, the runtime is no longer relying only on action placement. It has an event log or checkpoint model strong enough to remember what happened inside the boundary.
Corsac isn't there yet.
That's fine. The point of Phase 1 is to make the honest boundary explicit before building a larger durability system. Promising the larger system before the smaller one works is how workflow projects accumulate the kind of vague durability guarantees that fall apart on the third production incident.
The bigger point
The effect versus action split is one of the places where Gust and Corsac meet.
Gust needs to express side-effect intent in the workflow contract.
Corsac needs that intent to operate compiled workflows safely enough to be useful.
That's the pattern I want for both projects: the language shouldn't pretend runtime problems don't exist, and the runtime shouldn't reinvent information the language already knows.
A workflow runtime doesn't need every advanced durability feature on day one.
It does need honest boundaries.
effect and action are the first version of that boundary.





