rules/state-machines.md
Use an explicit state machine when a workflow has async races, queued work, or events that may arrive after an operation has been superseded.
Background and before/after examples of why this pattern exists: docs/why-state-machines.md.
state.ts, the pure total function in transition.ts,
side-effect execution in controller.ts or a main-process registry, command
adapters in commands.ts, and renderer bindings in a hook/provider.state.ts and transition.ts stay pure. They must not depend on React,
Electron, Jotai, TanStack Query, zod, timers, Date, or randomness.message field still accepts an Error instance
through property lookup. Wire schemas that exclude Error objects must
reject them before parsing the plain error-info object, and test that case.never
checks. Deliberate no-ops must use shared ignore(state, reason) so they are
distinguishable from omissions and observable in telemetry.defineFrameworkCoveredRemoteMachine; the constructor requires either a
native RuntimeRemoteIntentContract or the narrow protocol-v1 combination
of a declarative RemoteIntentContract plus RemoteOperationContract.
createProductionRemoteMachineManifest accepts only that capability or an
exact defineLegacyRemoteMachineCompatibility capability from the
production legacy-definition inventory.src/distributed_machines/boundary_inventory.test.ts derives production
definitions and semantic dispatch, waiter, subscription, fence, and routing
boundaries from the TypeScript AST. Definitions and production manifest
capabilities are exact symbol inventories. Noisy implementation boundaries
are aggregated by exact owning file and count, so private function/class
renames do not create inventory churn while additions, deletions, and file
moves still fail review-visible tests. Do not classify a migrated adapter as
unsafe or widen an unsafe list to make the test pass.compatibilityBoundaryInventory with its machine, exact file, mechanism,
expected boundary count, rationale, and conditional follow-up owner. File
moves, removals, and boundary-count changes require an explicit inventory
change. The mechanism-specific views are derived from those complete metadata
entries rather than from path-prefix allowlists.defineRuntimeRemoteIntentContract or defineRemoteIntentContract.
defineMachineConformance separately requires applicable tiers and explicit
exclusions, so purely local machines do not acquire irrelevant remote
capabilities.testing/framework_mechanism_conformance.test.ts; resource failures use
assertNoOwnedResources and identify the resource, owner, machine, key, and
generation. Do not call this cross-pilot runtime conformance unless one
reusable driver instantiates both domain façades and reads their inspectors.
Foundation and pilot review findings are exact-mapped in the corresponding
*_finding_catalog.ts files.TransactionalDispatcher use one event transaction:
enqueue FIFO; run the pure transition exactly once; validate; reserve the
command batch and any explicit post-commit outcome batch without running
domain code; cancel exiting state-owned leases; commit the snapshot (the
linearization point); update the authoritative projection; publish reserved
correlated outcomes, marking the entire correlated batch terminal before
invoking any settlement listener; notify snapshot subscribers and transition
observers; then hand the reserved commands to the injected domain scheduler.
Publishing authoritative outcomes before teardown-capable observers prevents
disposal from winning after the operation's state has already committed.
Re-entrant
callbacks may mutate transition-owned arrays, so both batches must be
shallow-copied before callbacks. Re-entrant sends append to the FIFO and run
after the current transaction. Ignored events skip commit, projection,
subscribers, outcomes, and commands, but notify observers at the equivalent
point in FIFO order.object or function; callable
functions can also define a .then property and be assimilated by await.void can still be implemented with an async
function. Validate authoritative outcome publishers as synchronous and
report rejected thenable results instead of relying on try/catch.TimerLeaseScope for migrated watchdogs. Carry the lease's operation or
state-instance token in its event, cancel it in the dispatcher's pre-commit
lease hook before exiting state, explicitly replace it on self-re-entry, and
dispose it with its controller.SnapshotStore from
src/state_machines/ instead of hand-rolling listener plumbing.webContents.id, so reconnect cannot bypass deduplication. Start
the retention window when the receipt settles, not when slow authorization
begins, or a lost receipt can be immediately evicted after acceptance. Evict
transient pre-admission transport-lifetime rejections after settlement, with
an identity check against the cached entry, so reconnect can retry work that
was never admitted while concurrent in-flight duplicates still coalesce.
Compute the complete retry fingerprint before fresh actor admission: a
matching retained receipt must replay after its subscription is released,
while a fresh dispatch still requires an admitted subscription.bigint. When an existing domain payload
legitimately exceeds the shared default, declare a bounded per-machine
ceiling and enforce aggregate projected state below its snapshot ceiling.ActorHost. Its actor map is
identity-keyed, but interning inside an untrusted wire decoder lets rejected
entity IDs grow a process-lifetime cache. Post-authorization canonicalization
must preserve the reserved encoded wire address; otherwise reject it or
atomically re-key pending quota, subscription, disposal, and reference
bookkeeping before admitting the actor.DyadErrorKind.Auth for expected access
denial. Convert only that explicit classification to an unauthorized receipt;
propagate unexpected hook failures so telemetry can distinguish dependency
failures and bugs from ordinary refusal. A named domain-revision policy needs
both an explicit renderer-observed domain token and a main-side resolver;
never compare it with, or silently substitute, the actor snapshot revision.finally. Registering only
before the terminal subscription leaves an admission-to-subscription race
where owner disposal can strand the request forever.observeTransition runs before a controller commits its next snapshot. If
an observer callback can re-enter the machine (for example, by submitting a
follow-up turn), defer that callback until the committed state is visible.await. Defer any
command callback that promises post-commit delivery (or reserve the batch
until after commit), and test the snapshot observed inside the callback—not
only the snapshot after the event returns.dispose() on provider
unmount or entity deletion. Renderer controller collections belong to a
provider-owned KeyedControllerHost; never keep them in module globals.this; passing a bare prototype method loses
its receiver when the registry invokes it.useManagerLifecycle hook; it preserves managers across React StrictMode
effect replay while still disposing managers that are genuinely replaced.stop() during synchronous effect cleanup. Keep only
irreversible final teardown in deferred dispose() so a replacement can
acquire the resource before the StrictMode-safe disposal microtask runs.await, clean up both immediately and again after the command settles.ensure(), which can recreate
retained authority for a deleted entity.InvocationRef from src/state_machines/,
minted by the injected IdSource at the authoritative start boundary and
echoed through every available correlation boundary. Registry claims use
InvocationRegistry.claim(ref). When a source cannot echo the ref, use
InvocationRegistry.claimStructurally(...) with a documented
structural-safety note at the claim site.Concurrency and staleness policy are domain behavior, not kernel behavior.
Document in each machine's state.ts or controller.ts what runs serially or
in parallel, which events may be dropped as stale, and which must never be
dropped. Main-process machines should use an explicitly constructed registry
with injected timers, IDs, and broadcasts; renderer machines use the shared
keyed host.
When independent async operations should overlap but both gate progress, start
both through commands and model their completion as separate events joined by
explicit state flags or substates. A serial command queue must not accidentally
turn prior Promise.all-style behavior into additive latency.
New machines must inject Clock and IdSource from src/state_machines/clock.ts
when they schedule timers, read wall time, or mint operation identities. Use
createFakeClock and createSequentialIdSource in tests instead of fake global
timers or nondeterministic UUIDs; retrofitting existing machines is optional.
plans/cleanup-state-machines.md. Do not reintroduce projectToAtom,
registerAtomWriter, or another lifecycle-mirroring helper. Renderer
consumers read the owner snapshot through domain hooks and pure selectors.useSyncExternalStore; it must not create a second writable
lifecycle authority in Jotai.selectCapabilities(state) whose named booleans express domain UI policy,
and exposes those capabilities through its projection. Do not derive
capability by probing the transition with a synthetic event: acceptance may
depend on payload, and accepted idempotent work may still warrant hidden UI.finally when a matching settlement
dispatch fails, because actor expiry cannot clear renderer memory. If the
claimant creates a durable resource before host acknowledgement, delete it
when acknowledgement fails or the claim expires.OperationRouteRegistry from
src/window_infrastructure/main/. Admit with the stable authoritative
operation ID and an owner containing stable owner/machine identities, an
optional window session, and an opaque route. Identical duplicates coalesce
(or replay while terminal retention remains); conflicts never replace the
first owner. The required snapshotRoute adapter must return an owned route
value so caller or inspector mutation cannot rewrite stored ownership, and
sameRoute must explicitly define equality for that opaque value. Both
adapters are trusted synchronous code; the registry fails closed if either
attempts to reenter ownership mutation, rejecting before stored ownership
changes even when the adapter catches the inner rejection. Runtime thenable
results from either adapter are rejected without assimilating arbitrary
thenables outside the guard.OperationRouteRegistry pins unresolved routes behind a separately bounded
admission limit and evicts only terminal routes, in settlement order, behind
a declared finite retention count. Its constructor snapshots validated
policy values and callbacks; later caller mutation cannot change accounting.
Call markTerminal(handle) only at authoritative publication/settlement,
then release with the opaque generation-bearing handle or an explicit
owner/window/machine disposal method. Owner disposal is scoped by both
machine and owner identity. Duplicate terminal/release calls and stale
handles are no-ops.inspectWindowRoutes() before the domain explicitly chooses drop,
entity-window, or focused-window fallback. Do not wire window unregister to
releaseWindow(): unresolved ownership survives renderer loss until the
authoritative operation settles or the domain explicitly disposes it.inspect() for route resource accounting and leak diagnostics; it
reports operation identity, owner, machine/window metadata, state, and
generation. Registry disposal is terminal and must leave the route count at
zero.src
from the epoch-changing snapshot. Do not let later same-epoch state updates
rewrite identity-defining DOM attributes and trigger an implicit reload.WebContents
can make safeSend a no-op and must not strand the authoritative actor.useSyncExternalStore snapshots must be referentially stable between store
changes. If an adapter overlays optimistic admission on a remote snapshot,
cache the projected object by base snapshot and operation identity instead of
allocating a new object from every getSnapshot() call.completed checkpoint must follow every authoritative effect, not
just the primary one. If a workflow mutates Git and then SQLite, persist an
explicit post-Git/next-database step until the database mutation finishes.[domain, appId, ...] so data
invalidated together remains scoped per app; do not use sibling keys for
those values.runControllerConformanceSuite for controllers built on the shared
dispatcher/lifecycle contract; domain tests remain responsible for domain
behavior. A command fixture may change domain state,
but its test adapter must model that real transition and expose the
controller's real snapshot. Disposal assertions compare with the snapshot
captured immediately before disposal; never normalize or fabricate snapshots
to make them pass./ and sort filesystem-derived
inventories before asserting literal repository paths. path.relative()
returns \ on Windows CI, and directory enumeration order differs by
filesystem and case-sensitivity.driveTransitionMatrix remains available for hand-enumerated totality
tests; new machines may instead use exploreReachableStates when a finite
event generator can discover the reachable graph. Existing bespoke suites
need not be migrated mechanically.stateKey so
the graph stays finite; keep direct transition tests for exact stale-token
acceptance and rejection.assertCapabilityTransitionConsistency with domain-supplied
representative valid events for every capability. Every enabled
capability/state pair must supply at least one valid representative so the
assertion cannot pass vacuously. When payload affects acceptance, include
representative invalid payloads; disabled capabilities may also pin their
expected ignore reason.boundaries.test.ts enforces kernel purity and machine-to-machine isolation;
add new machine directories to its inventory when they are introduced.dispatch or enqueue framework
path, classify it in the boundary inventory separately from raw compatibility
escape hatches; do not widen the raw allowlist to make the inventory pass.src/ipc/services/ for a main-owned actor).
Shared machine directories are scanned as renderer-reachable code and may
not import IPC, Electron, or WindowRegistry internals. Add every intentional
distributed-machine consumer to the exact inventory in
src/distributed_machines/boundaries.test.ts.remoteIntent contract, inject
authorization races through remoteIntent.authorizeSubscribe or
remoteIntent.authorizeDispatch. Replacing the legacy remote.authorize*
hooks exercises only compatibility definitions and can leave a native test
gate waiting forever.retain() needs its own live
token so releasing one owner cannot retire or leak another. A lease created
before the owning client/provider starts must let ready adopt that startup
bootstrap, and an in-flight completion retain must count as transport
interest during connection replacement and disposal teardown.runCosim suites, maxSchedules bounds visited configurations, not only
quiescent leaves. If one orthogonal action (for example quit at every phase)
causes a bound hit, split it into a focused exhaustive alphabet instead of
raising the bound and slowing the primary scenario.runCosim callbacks as frozen immutable views.
Do not mutate them, and do not replace the generic callback contract with a
serialization-based clone that rejects valid domain values or loses
prototypes.void has not provided a fence-trackable
completion boundary, even when that particular branch is synchronous; the
host cannot distinguish it from detached asynchronous work. Migrated
definitions must return a completion promise from every command branch.
Until a compatibility path adopts a completion promise or explicit tracked
lease, destructive fencing must fail closed rather than treating the handoff
as command completion.not-admitted.