A fixed family records every keyed settlement as it arrives. Its aggregate completion channel turns the finished record into one precise whole-cohort fact.
Members settle one by one; the family settles once.
A fixed family publishes each keyed settlement as it happens, then emits one aggregate Done event when every captured member has settled. Progress and barrier are separate views of the same retained record.
1 · Give every member an outcome slot
A fixed family remembers each captured key.
Regions captures three child occurrences. Each key now names one durable place in the family's outcome record, whether that member is still running or has already settled.
import { State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";
const StartRollout = Schema.TaggedStruct("StartRollout", {});
const Finish = Schema.TaggedStruct("Finish", {});
class RegionDeploy extends State.Compound<RegionDeploy>()("RegionDeploy") {
static states = States.make(() => [Deploying, Finished]);
}
class Deploying extends State.Atomic<Deploying>()("Deploying") {
static transitions = Transitions.make(this, ({ on }) => [
on(Finish, Finished),
]);
}
class Finished extends State.Done<Finished>()("Finished") {}
export const RegionDeployChart = Statechart.make(RegionDeploy);
const Regions = RegionDeployChart.batch("Regions");
export class Rollout extends State.Compound<Rollout>()("Rollout") {
static states = States.make(() => [Idle, RollingOut, Released]);
}
class Idle extends State.Atomic<Idle>()("Idle") {
static transitions = Transitions.make(this, ({ on }) => [
on(StartRollout, RollingOut),
]);
}
class RollingOut extends State.Atomic<RollingOut>()("RollingOut") {
static transitions = Transitions.make(this, ({ on, spawn }) => [
spawn(Regions, () => ["us-east", "eu-west", "ap-south"]),
on(Regions.Done),
]);
}
class Released extends State.Atomic<Released>()("Released") {}
export const RolloutChart = Statechart.make(Rollout);
2 · Observe progress without closing the family
MemberDone reports one keyed settlement.
FinishRegion addresses one child by key. Each completion publishes Regions.MemberDone immediately; even the last member can settle while RollingOut stays active because the aggregate transition is still targetless.
import { State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";
const StartRollout = Schema.TaggedStruct("StartRollout", {});
const FinishRegion = Schema.TaggedStruct("FinishRegion", {
key: Schema.String,
});
const Finish = Schema.TaggedStruct("Finish", {});
class RegionDeploy extends State.Compound<RegionDeploy>()("RegionDeploy") {
static states = States.make(() => [Deploying, Finished]);
}
class Deploying extends State.Atomic<Deploying>()("Deploying") {
static transitions = Transitions.make(this, ({ on }) => [
on(Finish, Finished),
]);
}
class Finished extends State.Done<Finished>()("Finished") {}
export const RegionDeployChart = Statechart.make(RegionDeploy);
const Regions = RegionDeployChart.batch("Regions");
export class Rollout extends State.Compound<Rollout>()("Rollout") {
static states = States.make(() => [Idle, RollingOut, Released]);
}
class Idle extends State.Atomic<Idle>()("Idle") {
static transitions = Transitions.make(this, ({ on }) => [
on(StartRollout, RollingOut),
]);
}
class RollingOut extends State.Atomic<RollingOut>()("RollingOut") {
static transitions = Transitions.make(this, ({ on, spawn }) => [
spawn(Regions, () => ["us-east", "eu-west", "ap-south"]),
on(FinishRegion).send(Regions.events.Finish, ({ event, to }) =>
to(event.key),
),
on(Regions.MemberDone),
on(Regions.Done),
]);
}
class Released extends State.Atomic<Released>()("Released") {}
export const RolloutChart = Statechart.make(Rollout);
3 · Consume the complete record as one barrier
Done fires once, after every member settles.
Regions.Done carries the complete keyed Exit record. Targeting Released turns that one aggregate fact into a parent transition—without a counter to reconstruct or a final member to special-case.
import { State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";
const StartRollout = Schema.TaggedStruct("StartRollout", {});
const FinishRegion = Schema.TaggedStruct("FinishRegion", {
key: Schema.String,
});
const Finish = Schema.TaggedStruct("Finish", {});
class RegionDeploy extends State.Compound<RegionDeploy>()("RegionDeploy") {
static states = States.make(() => [Deploying, Finished]);
}
class Deploying extends State.Atomic<Deploying>()("Deploying") {
static transitions = Transitions.make(this, ({ on }) => [
on(Finish, Finished),
]);
}
class Finished extends State.Done<Finished>()("Finished") {}
export const RegionDeployChart = Statechart.make(RegionDeploy);
const Regions = RegionDeployChart.batch("Regions");
export class Rollout extends State.Compound<Rollout>()("Rollout") {
static states = States.make(() => [Idle, RollingOut, Released]);
}
class Idle extends State.Atomic<Idle>()("Idle") {
static transitions = Transitions.make(this, ({ on }) => [
on(StartRollout, RollingOut),
]);
}
class RollingOut extends State.Atomic<RollingOut>()("RollingOut") {
static transitions = Transitions.make(this, ({ on, spawn }) => [
spawn(Regions, () => ["us-east", "eu-west", "ap-south"]),
on(FinishRegion).send(Regions.events.Finish, ({ event, to }) =>
to(event.key),
),
on(Regions.MemberDone),
on(Regions.Done, Released),
]);
}
class Released extends State.Atomic<Released>()("Released") {}
export const RolloutChart = Statechart.make(Rollout);
Each key owns one outcome slot
Regions captures three members when RollingOut enters:
spawn(Regions, () => ["us-east", "eu-west", "ap-south"]);
The family now owns three outcome slots, addressed by those keys. A slot begins running and later
retains the exact Exit produced by its child incarnation. Members may finish in any order; the
record remains keyed by domain identity rather than completion order.
This is stronger than keeping a counter. “Two of three” describes progress but forgets which two, what each returned, and whether either failed. The family retains that evidence as part of its published state.
Member channels publish progress
The second frame routes a parent command to one child and observes successful settlement:
on(FinishRegion).send(Regions.events.Finish, ({ event, to }) => to(event.key)),
on(Regions.MemberDone),
FinishRegion.key selects the captured occurrence. When that child reaches its terminal
State.Done, Regions.MemberDone carries both the same key and the child's decoded value.
The event is published as soon as that one member settles; it does not wait for its siblings.
Modeled failures follow the parallel Regions.MemberError channel with key, error, and the
complete cause. A locally dispositioned defect follows Regions.MemberDefect with key,
incident, and the representative defect; the incident record retains the complete Cause.
Those channels preserve the difference between expected failure and broken execution while keeping
every outcome associated with its member.
Member observation is optional
The targetless MemberDone listener makes progress observable without changing parent state. It
could instead update a facet, emit a notification, or select another transition. None of those
reactions is required for the family to remember the outcome.
Member listeners are optional observations. A chart may listen only for aggregate Done when it
does not need incremental behavior. Conversely, observing the final member does not consume or
replace aggregate completion. Per-member progress and whole-family settlement are different
events, even when both occur in the same macrostep.
The second frame makes that separation visible: all three members can be complete while the parent
intentionally remains in RollingOut.
Done is a settlement barrier
After the final captured member settles, Regions.Done fires once for that family arming. The last
frame gives it a target:
on(Regions.Done, Released);
The transition no longer asks whether a particular completion happened to be last. It responds to
the semantic fact the family owns: every member in this fixed membership has a terminal outcome.
Exiting RollingOut retires the settled placement.
Done means all members settled, not all members succeeded. Modeled child errors still
belong in the aggregate record. A defect must first receive a local disposition; an unhandled or
escalated defect crosses the ownership boundary immediately and prevents the barrier from
completing normally.
The barrier carries evidence, not just a signal
The aggregate event contains the complete keyed Exit record on event.value. Any transition
callback attached to Regions.Done receives it directly:
({ event }) => event.value;
For this rollout, its shape is conceptually:
{
"ap-south": Exit.succeed(/* child value */),
"eu-west": Exit.succeed(/* child value */),
"us-east": Exit.succeed(/* child value */),
}
The encoded record uses canonical key order, independent of settlement order. A target state, facet update, or action can consume the whole result without joining separate callbacks or reading an eventually consistent side table.
Use member channels for behavior that belongs to progress—status displays, incremental durable
updates, or per-key emissions. Use aggregate Done for behavior that requires the complete cohort:
release, commit, comparison, summary, or admission of the next membership epoch.
Empty membership settles vacuously
A fixed family with no captured keys is already completely settled, so its Done barrier fires
without waiting for an impossible member outcome. This is the correct identity for an “all
members” condition, but it matters when Done reenters the owning state.
As the previous page showed, an unconditional reentry can turn an empty cohort into an eventless loop. Guard continuation from domain backlog and provide a terminal branch for the empty case.
Test progress and barrier separately
- Settle members in several orders and verify each member channel names the correct key.
- Verify aggregate
Donedoes not fire while any captured member remains running. - Verify the final member event and aggregate event occur in their documented macrostep order.
- Mix successful and modeled-error outcomes and inspect the complete keyed
Exitrecord. - Leave a defect undispositioned and verify it crosses ownership instead of satisfying
Done. - Capture an empty membership and verify completion is vacuous and terminating.
Next, dynamic family views show how to reason about membership and settlement when keys may enter and leave while the owning state remains active.