Finding #6 of the 2026-08 architecture review, carried over from the June 2026 review's design note. Deferred from #52.
Problem
OnEnterObserver implements "run something when entering state X" as a magic event name layered on ordinary transition events. To give approved an entry hook you must declare a sentinel self-transition (docs/observers.md):
.addTransition("approved", "approved", { event: "onEnter" })
That has real costs:
- Graph pollution. Sentinel self-loops appear in
GraphBuilder output as genuine edges, so every DOT/Mermaid export of a workflow with entry hooks is wrong as documentation — it shows transitions the domain does not have.
- Name collision. A workflow with a real domain event called
onEnter silently becomes an entry hook (or vice versa). The convention has no namespace.
- It leans on a subtle semantic. The mechanism works because event-driven self-transitions fire event observers without moving state — intentional and tested, but non-obvious, and it means entry hooks ride on an edge case rather than on a first-class concept.
- Ordering is implicit. The hook is enqueued as a separate top-level operation, so "on entry" really means "some time after the operation that entered it drains". The
ifStateName guard added in v4 fixed the wrong-state bug, but the timing is still surprising for anyone reading onEnter as a lifecycle hook.
Proposed direction
Declare entry hooks at build time as their own concept, keyed on the state rather than on an event name:
new ProcessBuilder("order")
.addState("approved", { onEnter: [chargeCard] })
// or: .addOnEnter("approved", chargeCard)
A generic after-observer then dispatches on frame.toState, with no sentinel edge in the graph and no event-name namespace to collide with. Decide explicitly, and document, whether hooks run inline within the committing operation or as a chained operation as they do today — the current asynchronous behavior is defensible, but it should be a stated contract rather than a side effect of the implementation.
OnEnterObserver can remain as a deprecated shim for one major if that eases migration.
Acceptance criteria
Finding #6 of the 2026-08 architecture review, carried over from the June 2026 review's design note. Deferred from #52.
Problem
OnEnterObserverimplements "run something when entering state X" as a magic event name layered on ordinary transition events. To giveapprovedan entry hook you must declare a sentinel self-transition (docs/observers.md):That has real costs:
GraphBuilderoutput as genuine edges, so every DOT/Mermaid export of a workflow with entry hooks is wrong as documentation — it shows transitions the domain does not have.onEntersilently becomes an entry hook (or vice versa). The convention has no namespace.ifStateNameguard added in v4 fixed the wrong-state bug, but the timing is still surprising for anyone readingonEnteras a lifecycle hook.Proposed direction
Declare entry hooks at build time as their own concept, keyed on the state rather than on an event name:
A generic after-observer then dispatches on
frame.toState, with no sentinel edge in the graph and no event-name namespace to collide with. Decide explicitly, and document, whether hooks run inline within the committing operation or as a chained operation as they do today — the current asynchronous behavior is defensible, but it should be a stated contract rather than a side effect of the implementation.OnEnterObservercan remain as a deprecated shim for one major if that eases migration.Acceptance criteria
onEnterno longer behaves differently from any other event.docs/migration/.