Guide

Fixed Families with .batch

Capture one fixed keyed membership as a single authored lifecycle.

.as gives a reusable definition another singular role. .batch gives one authored role a fixed, keyed membership supplied by data.

One site can own a fixed set of keyed work.

Use singular sites when the topology names every role. When data supplies a fixed set of jobs, a family captures those members as one authored lifecycle.

1 · Repeat a known singular role

Three roles require three named sites.

Alpha, Beta, and Gamma are independent exact-one occurrences of DeliverPackage. This is honest when those roles are part of the topology, but the chart must mount and handle every site separately.

import {
  Activity,
  State,
  Statechart,
  States,
  Transitions,
} from "@motive/motive";
import { Schema } from "effect";

const StartBatch = Schema.TaggedStruct("StartBatch", {});

export const DeliverPackage = Activity.make("DeliverPackage", {
  input: Schema.Struct({ label: Schema.String }),
  success: Schema.String,
});

const Alpha = DeliverPackage.as("Alpha");
const Beta = DeliverPackage.as("Beta");
const Gamma = DeliverPackage.as("Gamma");

class DeliveryBatch extends State.Compound<DeliveryBatch>()("DeliveryBatch") {
  static states = States.make(() => [Idle, Delivering]);
}

class Idle extends State.Atomic<Idle>()("Idle") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(StartBatch, Delivering),
  ]);
}

class Delivering extends State.Atomic<Delivering>()("Delivering") {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(Alpha, () => ({ label: "Alpha" })),
    invoke(Beta, () => ({ label: "Beta" })),
    invoke(Gamma, () => ({ label: "Gamma" })),
    on(Alpha.Done),
    on(Beta.Done),
    on(Gamma.Done),
  ]);
}

export const DeliveryBatchChart = Statechart.make(DeliveryBatch);

2 · Capture one fixed family

.batch turns keyed inputs into one lifecycle.

Deliveries captures the alpha, beta, and gamma entries when Delivering becomes active. One family site owns all three member executions and publishes one required Done after the fixed membership settles.

import {
  Activity,
  State,
  Statechart,
  States,
  Transitions,
} from "@motive/motive";
import { Schema } from "effect";

const StartBatch = Schema.TaggedStruct("StartBatch", {});

export const DeliverPackage = Activity.make("DeliverPackage", {
  input: Schema.Struct({ label: Schema.String }),
  success: Schema.String,
});

const Deliveries = DeliverPackage.batch("Deliveries");

class DeliveryBatch extends State.Compound<DeliveryBatch>()("DeliveryBatch") {
  static states = States.make(() => [Idle, Delivering, Finished]);
}

class Idle extends State.Atomic<Idle>()("Idle") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(StartBatch, Delivering),
  ]);
}

class Delivering extends State.Atomic<Delivering>()("Delivering") {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(Deliveries, () => ({
      alpha: { label: "Alpha" },
      beta: { label: "Beta" },
      gamma: { label: "Gamma" },
    })),
    on(Deliveries.Done, Finished),
  ]);
}

class Finished extends State.Atomic<Finished>()("Finished") {}

export const DeliveryBatchChart = Statechart.make(DeliveryBatch);

Use singular sites when the topology knows the roles

If Alpha, Beta, and Gamma are three enduring roles in the domain, three named occurrences are an honest model:

const Alpha = DeliverPackage.as("Alpha");
const Beta = DeliverPackage.as("Beta");
const Gamma = DeliverPackage.as("Gamma");

Each site is mounted separately, receives one input, and owns its own Done channel. That is useful when the chart should address and supervise the roles independently. It is repetition when the names are merely the current members of one batch.

.batch declares one fixed family site

Create a family from the reusable Activity definition:

const Deliveries = DeliverPackage.batch("Deliveries");

Deliveries is one authored site. It retains DeliverPackage's input, success, error, and provider contract, but its invocation accepts a record of keyed inputs rather than one input:

invoke(Deliveries, () => ({
  alpha: { label: "Alpha" },
  beta: { label: "Beta" },
  gamma: { label: "Gamma" },
}));

The record keys identify members inside this family occurrence. The source Activity remains the only handler authority, so all three executions run through the one DeliverPackage provider.

Membership is captured when the site arms

The entries producer is evaluated once as Delivering becomes active. Motive canonicalizes and commits that record as the membership authority before starting member work.

Changing the collection that originally informed the producer does not add or remove members from an already armed family. Recovery re-drives unresolved members from the committed membership; it does not evaluate the producer again and hope to reconstruct the same set.

This is what fixed means. It describes membership for one arming, not a permanently fixed set in the application. Leaving the owning state retires the family. A later entry arms a new occurrence and captures a new membership.

The family owns one completion boundary

A singular site publishes Done for one execution. A fixed family publishes Deliveries.Done after every captured member has settled:

on(Deliveries.Done, Finished);

That aggregate channel is required because .batch declares a barrier lifecycle. It fires at most once for the arming, including when the captured record is empty. Individual member progress does not make the family complete early.

The Done payload retains keyed member outcomes; a later page develops that settlement algebra and the optional member-level channels. For now, read the transition as one structural claim: the chart may enter Finished only after the fixed membership is fully settled.

Give every family a stable name

Unlike an Activity's default singular site, a family has no useful unnamed default. Its occurrence name roots member channels and durable member identity before any keys exist. Call .batch(name) explicitly.

DeliverPackage.batch() produces ActivityBatchNeedsName, and DeliverPackage.batch("DeliverPackage") is rejected by ActivityBatchMustNotUseDefaultId because it would collide with the source's default singular occurrence.

Choose .as or .batch by ownership

Use .as(name) when the model owns one named role: Thumbnail, Renderer, or Reconciler. Use .batch(name) when the model owns one fixed group and data supplies its members: Deliveries, Checks, or Replicas.

Do not turn several real roles into a family merely to shorten the code; that erases independently addressable topology. Do not generate a variable batch as a forest of .as declarations; that makes runtime membership pretend to be authored structure.

Test the capture boundary

  1. Count calls to the entries producer and verify one call per arming, including after recovery.
  2. Mutate the source collection after the family starts and verify active membership does not drift.
  3. Settle members in different orders and verify Deliveries.Done fires exactly once, after the last settlement.
  4. Return an empty record and verify the barrier completes in the arming macrostep.

Next, .each keeps a keyed family open and reconciles its desired membership while the owner stays active.