.batch captures a keyed group when its owner enters. .each keeps a keyed group open and
reconciles it as the model changes.
Membership can remain live while its owner remains active.
Some groups should change without retiring their owner. An open family treats a query as desired membership and reconciles each keyed difference.
1 · Keep a fixed capture
.batch does not follow later membership changes.
Watchers captures guide and spec when Watching enters. Opening or closing a document updates Workspace data, but this already armed fixed family keeps the two members it committed at entry.
import { Activity, Query, State, Statechart, States, Transitions } from "@motive/motive";
import * as Effect from "effect/Effect";
import { Schema } from "effect";
export class OpenDocument extends Schema.TaggedClass<OpenDocument>()("OpenDocument", {
key: Schema.String,
path: Schema.String,
}) {}
export class CloseDocument extends Schema.TaggedClass<CloseDocument>()("CloseDocument", {
key: Schema.String,
}) {}
export const WatchDocument = Activity.make("WatchDocument", {
input: Schema.Struct({ path: Schema.String }),
success: Schema.Void,
});
const Watchers = WatchDocument.batch("Watchers");
const desiredWatchers = Query.gen(function* () {
return Object.fromEntries(
Object.entries((yield* Workspace).documents).map(([key, path]) => [key, { path }]),
);
});
export class Workspace extends State.Compound<Workspace>()("Workspace", {
documents: Schema.Record(Schema.String, Schema.String).pipe(
Schema.withConstructorDefault(Effect.succeed({ guide: "docs/guide.md", spec: "docs/spec.md" })),
),
}) {
static states = States.make(() => [Watching]);
}
class Watching extends State.Atomic<Watching>()("Watching") {
static transitions = Transitions.make(this, ({ invoke, on }) => [
invoke(Watchers, desiredWatchers),
on(Watchers.Done),
on(OpenDocument).update(Workspace, ({ event, target }) => ({
documents: { ...target.documents, [event.key]: event.path },
})),
on(CloseDocument).update(Workspace, ({ event, target }) => ({
documents: Object.fromEntries(
Object.entries(target.documents).filter(([key]) => key !== event.key),
),
})),
]);
}
export const WorkspaceChart = Statechart.make(Workspace);
2 · Reconcile while the owner stays active
.each keeps desired membership live.
The same query now defines an open family. When Workspace data adds changelog or removes guide, Watchers starts or withdraws exactly that key without leaving and re-entering Watching.
import { Activity, Query, State, Statechart, States, Transitions } from "@motive/motive";
import * as Effect from "effect/Effect";
import { Schema } from "effect";
export class OpenDocument extends Schema.TaggedClass<OpenDocument>()("OpenDocument", {
key: Schema.String,
path: Schema.String,
}) {}
export class CloseDocument extends Schema.TaggedClass<CloseDocument>()("CloseDocument", {
key: Schema.String,
}) {}
export const WatchDocument = Activity.make("WatchDocument", {
input: Schema.Struct({ path: Schema.String }),
success: Schema.Void,
});
const Watchers = WatchDocument.each("Watchers");
const desiredWatchers = Query.gen(function* () {
return Object.fromEntries(
Object.entries((yield* Workspace).documents).map(([key, path]) => [key, { path }]),
);
});
export class Workspace extends State.Compound<Workspace>()("Workspace", {
documents: Schema.Record(Schema.String, Schema.String).pipe(
Schema.withConstructorDefault(Effect.succeed({ guide: "docs/guide.md", spec: "docs/spec.md" })),
),
}) {
static states = States.make(() => [Watching]);
}
class Watching extends State.Atomic<Watching>()("Watching") {
static transitions = Transitions.make(this, ({ invoke, on }) => [
invoke(Watchers, desiredWatchers),
on(OpenDocument).update(Workspace, ({ event, target }) => ({
documents: { ...target.documents, [event.key]: event.path },
})),
on(CloseDocument).update(Workspace, ({ event, target }) => ({
documents: Object.fromEntries(
Object.entries(target.documents).filter(([key]) => key !== event.key),
),
})),
]);
}
export const WorkspaceChart = Statechart.make(Workspace);
A fixed family remembers its opening membership
Suppose Workspace owns a record of open documents and Watching starts one watcher for each
document. A fixed family can derive its entries from that record:
const Watchers = WatchDocument.batch("Watchers");
invoke(Watchers, desiredWatchers);
The query is evaluated as Watching enters. If OpenDocument later adds changelog to the
workspace facet, the model now desires three watchers—but the already armed family still owns the
two members it captured. That is the durability promise .batch makes.
.each makes membership a live relationship
Change the site constructor and leave the rest of the model alone:
const Watchers = WatchDocument.each("Watchers");
invoke(Watchers, desiredWatchers);
Now desiredWatchers is not an entry-time initializer. It is the desired-membership authority for
as long as Watching remains active. Each stable point compares that mapping with the current
members of Watchers and reconciles the difference.
Adding changelog starts exactly that member. Removing guide withdraws exactly that member. The
owner does not exit and re-enter, and retained keys keep their existing executions.
The record key is the member address
The entries mapping separates member identity from member input:
{
changelog: { path: "CHANGELOG.md" },
spec: { path: "docs/spec.md" },
}
changelog and spec are addresses within the Watchers site. Their values are the inputs sent
to the shared WatchDocument provider. Sort order is presentation; membership is determined by
the set of keys.
A retained key means retained membership. Changing only the value behind an existing key does not silently replace its running execution. When new input should mean new work, model that replacement with a new key or an explicit remove-and-add lifecycle.
Open families publish edges, not one final barrier
A fixed family has one terminal Done boundary because its captured membership can finish. An open
family may gain another member while its owner is still active, so it does not publish aggregate
Done.
Instead, .each exposes lifecycle channels for the relationship:
Watchers.MemberAddedandWatchers.MemberRemoveddescribe membership edges.Watchers.Emptydescribes the moment no members remain.Watchers.MemberDone,MemberError, andMemberDefectdescribe individual outcomes.Watchers.Settleddescribes the current membership becoming fully settled without claiming the family can never grow again.
Handle only the optional edges the domain needs. Modeled member errors still require a total disposition; successful membership churn does not.
Reconciliation belongs to the owning macrostep
Motive reconciles an open family from committed model data as part of stabilization. The resulting starts and withdrawals are not a background poller racing the chart. They belong to the same ordered statechart step that changed the desired mapping.
This lets tests assert one coherent boundary: after OpenDocument commits, the Workspace facet
contains changelog and the Watchers site contains a changelog member. After CloseDocument,
both authorities agree that guide is absent.
Choose the family by how membership changes
Use .batch(name) when one arming must remember a fixed cohort: a deployment batch, a set of checks,
or the recipients captured for one notification. Use .each(name) when membership should follow
model data while the owner remains active: open documents, supervised workers, connected sessions,
or currently assigned partitions.
Do not choose .each merely because the collection is variable before entry. The deciding question
is whether changes after entry should alter the running family.
Test the reconciliation boundary
- Add one desired key and verify exactly one member starts without re-entering the owner.
- Remove one running key and verify that exact execution is interrupted and withdrawn.
- Change input under a retained key and verify its current execution is preserved.
- Remove every key and verify
Emptyis published once for that edge. - Exit the owning state and verify all remaining members are withdrawn.
Next, keys become durable member identities. Removing and later re-adding the same key creates a new incarnation rather than reviving the old execution.