Finding #5 of the 2026-08 architecture review. Deferred from #52 because every option here is breaking.
Problem
State, Transition, and Process are frozen at build time and protected by a symbol construction key — a genuinely strong invariant, and one of the best things about the v3/v4 design. Event is the one hole in it:
attach() / detach() (src/Event.ts:29,33) and setMetadataValue() / deleteMetadataValue() (src/Event.ts:61,69) stay mutable forever.
- The documented way to register a command is post-build mutation:
process.getState("draft").getEvent("publish").attach(...) (docs/observers.md).
Two consequences follow.
1. Event observers and metadata are shared by every machine built from the process. Events live on the Process graph, and neither Statemachine nor Factory clones it. Attaching a command "for one order" attaches it to every machine using that process. The v4 fix for the old finding #1 removed the per-invocation args race, but the observer set itself is still shared mutable state. PR #52 documented this hazard in docs/observers.md; it did not remove it.
This is a footgun in exactly the pattern the library recommends — Factory + one shared Process — and it fails silently: side effects land on the right subject only because args[0] happens to carry it.
2. Event.invoke() and Event.notify() are public and bypass the engine entirely. A caller who invokes an event directly gets observer side effects with no queue, no mutex, no re-entrancy guard, and no transition — outside every serialization guarantee Statemachine provides. Nothing in the type system discourages it.
Proposed direction
Register commands at build time and freeze Event with the rest of the graph:
new ProcessBuilder("article")
.addState("draft", { initial: true })
.addTransition("draft", "published", { event: "publish" })
.addCommand("draft", "publish", new SendEmailCommand())
.build();
Then either keep commands on the frozen graph (still process-wide, but now explicitly and immutably so), or move event-observer registration onto Statemachine so it is genuinely per-machine — the latter is the deeper fix and removes the sharing hazard rather than documenting it.
invoke() / notify() should leave the public surface, or become internal to the engine.
Related API naming
While the event-observer surface is open: Observer.update(subject, args) (src/interfaces/Observer.ts:9) calls its first parameter subject, but it is the observable Event — the domain subject arrives as args[0]. Two meanings of "subject" in one signature. Rename to source (or fold the arguments into a typed context object) as part of the same change.
Acceptance criteria
Finding #5 of the 2026-08 architecture review. Deferred from #52 because every option here is breaking.
Problem
State,Transition, andProcessare frozen at build time and protected by a symbol construction key — a genuinely strong invariant, and one of the best things about the v3/v4 design.Eventis the one hole in it:attach()/detach()(src/Event.ts:29,33) andsetMetadataValue()/deleteMetadataValue()(src/Event.ts:61,69) stay mutable forever.process.getState("draft").getEvent("publish").attach(...)(docs/observers.md).Two consequences follow.
1. Event observers and metadata are shared by every machine built from the process. Events live on the
Processgraph, and neitherStatemachinenorFactoryclones it. Attaching a command "for one order" attaches it to every machine using that process. The v4 fix for the old finding #1 removed the per-invocation args race, but the observer set itself is still shared mutable state. PR #52 documented this hazard indocs/observers.md; it did not remove it.This is a footgun in exactly the pattern the library recommends —
Factory+ one sharedProcess— and it fails silently: side effects land on the right subject only becauseargs[0]happens to carry it.2.
Event.invoke()andEvent.notify()are public and bypass the engine entirely. A caller who invokes an event directly gets observer side effects with no queue, no mutex, no re-entrancy guard, and no transition — outside every serialization guaranteeStatemachineprovides. Nothing in the type system discourages it.Proposed direction
Register commands at build time and freeze
Eventwith the rest of the graph:Then either keep commands on the frozen graph (still process-wide, but now explicitly and immutably so), or move event-observer registration onto
Statemachineso it is genuinely per-machine — the latter is the deeper fix and removes the sharing hazard rather than documenting it.invoke()/notify()should leave the public surface, or become internal to the engine.Related API naming
While the event-observer surface is open:
Observer.update(subject, args)(src/interfaces/Observer.ts:9) calls its first parametersubject, but it is the observableEvent— the domain subject arrives asargs[0]. Two meanings of "subject" in one signature. Rename tosource(or fold the arguments into a typed context object) as part of the same change.Acceptance criteria
ProcessBuilderand the graph is fully frozen afterbuild().Event.invoke()/notify()are no longer part of the public API surface.Observer.update's first parameter is no longer calledsubject.docs/migration/.