Guide

Member Outcomes and Aggregate Barriers

Observe keyed settlements individually and consume the complete family record once.

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

  1. Settle members in several orders and verify each member channel names the correct key.
  2. Verify aggregate Done does not fire while any captured member remains running.
  3. Verify the final member event and aggregate event occur in their documented macrostep order.
  4. Mix successful and modeled-error outcomes and inspect the complete keyed Exit record.
  5. Leave a defect undispositioned and verify it crosses ownership instead of satisfying Done.
  6. 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.