plans/claude-correct-state-machines.md
Proposal. Successor to plans/state-machines-hardening.md, which analyzed
the 2026-07-16 → 07-23 migration wave and proposed the shared kernel
primitives. Those primitives shipped: TransactionalDispatcher,
discriminated TransitionResult, InvocationRef, TimerLeaseScope,
TaskScope/lifecycle_scope, capability-consistency tests, conformance
suites, co-simulation, and replay-grade traces all exist in
src/state_machines/ today.
This plan synthesizes the review threads and fix-commit iterations of the
next wave — the distributed-actor migration merged 2026-07-25 → 07-28
since 1014afff — kernel/infra (#4086, #4090, #4097, #4100, #4102,
#4104, #4105, #4106), codecs and services (#4092, #4093, #4095, #4098,
#4099, #4101), authority moves (#4108, #4109, #4116, #4119, #4121, #4122,
#4123), deletions (#4109, #4117, #4120), and semantics fixes (#4126) —
plus the ADRs (#4096, #4103).
The Wave-1 bet paid off where it was placed. Across ~150 review findings in this wave, almost none were pure-transition bugs: the discriminated results, exhaustive matrices, reference-stability validation, and transition test kits held. The churn moved one layer up, into exactly the territory the distributed migration opened:
await
(authorization, bootstrap, construction, activation) was stale when
the continuation committed. The single largest class by HIGH count.rules/state-machines.md L307–329 by name — the invariant is
documented, not constructed.ensure().revision-conflict/stale-actor
receipts silently discarded (~13 findings across six PRs); UI mutated
local presentation before the receipt arrived.The conclusion mirrors Wave 1's: the invariants that recur across every
machine must move from rules/state-machines.md prose into kernel types,
runtime mechanics, and conformance tests — enforced by construction
instead of by reviewer. The rules doc is load-bearing (bots cite it by
line in nearly every finding), which is precisely the evidence that these
are framework invariants wearing convention costumes.
Every item is a real review comment or fix iteration from this wave.
authorizeSubscribe → stale
ownership recorded, no-subscriber eviction blocked forever (HIGH);
unsubscribe-during-pending-subscribe installs a dead subscription;
dispatch authorization inspected state, awaited, then acted on a
different actor instance.activate() re-entering dispose via the injected clock
after admission checks ran; disposal missing actors in synchronous
construction (three variants); key removed from the host map before
finishDisposal completed, letting a replacement overlap the old
lifetime (HIGH).await bootstrap(); superseded-generation payloads flushed after the
replacement's bootstrap.waitForChatActorIdle read the snapshot before
registering its listener (P2 — "mirrors the settlement-waiter
invariant").~15 findings across #4108, #4109, #4119, #4123:
START
from ready reused the runtime invocation, so PROCESS_SPAWNED
settled under the old operation ID and leaked the new waiter — three
fix iterations in #4108 alone before request-operation-ID queues
landed.PROCESS_FAILED fulfilled
runApp/stopApp promises (flagged three times independently).~14 findings across #4108, #4116, #4119, #4121, #4123:
localRef dispatch skipping the fences
authorizeDispatch enforces (P1, #4119); subscriptions recreating
actors mid-deletion (#4119, #4123); late producer callbacks
resurrecting disposed actors via creating ensure() (#4108);
disposeMachine leaving new-key admission open during async cleanup
(#4100).~13 findings across #4108, #4109, #4116, #4119, #4121, #4123:
sendWithoutReceipt discarding revision-conflict — GitHub sync,
disconnect, and branch mutations silently no-oped (#4116).APP_CHANGED ignoring conflict receipts left the
repo on a historical checkout (MEDIUM + P1 duplicate); CLOSE hid the
pane locally before the dispatch was accepted (#4123).dispatchQueueEvent fell back to the latest revision and
a stale Clear could delete prompts the user never saw (HIGH, #4120).start() returned before the receipt; the dialog
closed and the prompt cleared on rejection; no in-flight guard →
double-click duplicated generations (#4121).revision-conflict surfacing as an unhandled rejection
instead of resync-and-retry (#4109).#4108, #4119 (5+ findings), #4123:
start() on the client — one
bootstrap snapshot, then silence (HIGH, found in chat_stream and
plan_handoff managers).stale-actor (P1);
fixed by awaiting bootstrap before dispatch, per machine.accepted persisted pre-admission → restart
hides accept buttons with nothing running (P2, three rounds until
draft → admitting → accepted checkpointing); post-admission metadata
failure marked the handoff failed so retry duplicated the run (HIGH);
compensation raw-deleted chats without disposing their actors (HIGH);
second-resolution timestamp ties rehydrated the wrong row.originWindowSessionId breaking the hash (HIGH, two rounds);
edited queue entries keeping stale payloadHash (#4119).lastCompletion replayed terminal toasts on fresh renderer bootstrap,
and the fix's cursor-seeding swallowed a pending submission's
completion (#4119).applied (#4100 —
in the Wave-1 dispatcher itself; fixed).notifyStreamFinished computed outcome from a wasCancelled flag, so
errored finalize fired a "completed" notification (#4095); external
errors ignored during finalizing were silently dropped (three
reports).DyadErrors rewritten to Auth by broad catches;
infrastructure failures indistinguishable from denials; expected
admission refusals surfaced as product exceptions (#4105, #4108 ×3,
#4119, #4121, #4123).recoveryScopes growing
for process lifetime (#4102).invocationRef.entityKey) with no enforced equality → cross-app stop
and wrong-key snapshot publication; three escalating rounds until "the
actor key is the sole intent identity" (#4099, HIGH).originWindowSessionId (spoofable; derive from
event.sender), forged owner.kind = plan-handoff privileged queue
entries, trusted intent.appId routing invalidations to the wrong app
(#4096, #4119); output-supplied invocationRef preferred over the
constructor-captured one (#4108); dash-prefixed git refs passing the
remote schema after the legacy validated contract was deleted (P1,
#4123).forget(operationId) ran before the command-phase
notify-error, losing the failure toast — "the dispatcher runs
observers before command batches" is observable and there is no
post-command hook (#4123, P2).Ordered by leverage: (findings prevented) × (machines affected) ÷ (risk of the primitive itself). Non-goals from Wave 1 stand: no XState, no generic controller owning domain policy. Everything below is transaction and lifecycle mechanics that this wave shows every machine reinvents.
OperationWaiters)Kills E2 and half of E1. A per-actor (or per-service) waiter registry the kernel owns, with an API whose shape enforces the invariants:
// Registration returns synchronously, BEFORE any await can run.
const waiter = waiters.register({
requestId: idSource.next(), // never a reusable invocation
invocationRef, // correlation, not identity
onDisposed: "reject", // classified DyadError, kind: Disposed
});
const receipt = await actor.dispatch(event, waiter);
return waiter.settled; // Promise<Outcome>
Contract, each line traceable to a finding:
register() is synchronous
and must precede the dispatch call that references it; there is no API
to attach a waiter to an in-flight operation (#4108, #4119
waitForChatActorIdle).settle(requestId, outcome) where a
failed outcome rejects with the projected, classified operation error —
resolving a waiter on failure is unrepresentable (#4108's
PROCESS_FAILED-fulfills-runApp, flagged 3×).ActorHost.disposeKey/dispose and
entity fences (P2 below) sweep the registry and reject outstanding
waiters with a distinguishable kind before cleanup completes (#4108,
#4116 Reset Everything).superseded outcome carrying the correlated error
payload (#4108 "superseded failures lose their error").Adopt in app_run, chat_stream (idle waiter + pending submissions),
github_ops (claim acks), version_preview, image_generation
admission, replacing five hand-rolled implementations.
Kills E3. Move entity-deletion/reset fencing from per-service convention
(chat_actor_deletion_fence.ts, app_chat_creation_fence.ts, ad-hoc
service booleans) into the host:
const fence = await host.fence({
scope: { machine: "chat_stream", key }, // or machine-wide, or host-wide
drain: { event: CANCEL, timeoutMs }, // phase 1: cancellation allowed
});
try {
await deleteFromDb(); // destructive commit
fence.commit(); // actor disposed, waiters swept
} catch (e) {
fence.abort(); // admission reopens, clients resync
}
localRef, and
subscription-creates. A fenced key cannot be admitted or created by
any path (#4119 localRef bypass P1, #4123 subscription-recreates,
#4108 late producers).commit() after the durable
delete succeeds; abort() on failure resyncs subscribers instead of
stranding them with stale-actor until remount (#4116 failed-deletion
finding). The #4116 "fence lifted while deletion still running" class
becomes impossible because disposal is fence-driven, not
fence-adjacent.WeakActorHandle wrapping peek() —
there is no creating ensure() reachable from a producer callback
(#4108's resurrection class, reviewers' "tombstone or non-creating
lookup").settleWaiters/onDisposed
lifecycle hooks exactly once, ordered by the existing disposal-barrier
machinery from #4100's fixes.Kills E4. Three coordinated changes:
RemoteActorRef.dispatch returns a
DispatchResult discriminated union; add
dispatchExpectingApplied(event, {onConflict}) where onConflict is
mandatory: "resync-retry" | "surface" | handler. The
resync-retry policy encapsulates #4109's converged semantics (treat
conflict-on-already-satisfied as success after resync, else retry
with the current revision) once, instead of per call site.sendWithoutReceipt-style helpers. A
boundary test (the boundaries.test.ts pattern) inventories dispatch
call sites and flags unconsumed results — the same enforcement
mechanism that already polices atom ownership.allowStaleWrite opt-in at the call site (#4120's stale-Clear HIGH
becomes a type error, not a review catch).Companion convention, promoted to capability projection where possible:
local presentation changes commit on receipt or settlement, never on
dispatch (dialog close #4121, pane hide #4123, composer clear #4120).
assertCapabilityTransitionConsistency already exists; extend queue
entry capabilities (editable/removable) into projected snapshots so
the UI cannot render enabled controls the transition will reject
(#4119/#4120 delete-button findings).
createRemoteManager)Kills E5. Extract the base class three managers already convergently
evolved (chat_stream/remote_manager.ts, app_run/remote_manager.ts,
plan_handoff/remote_manager.ts):
start()/stop()/dispose() with disposed-guards after awaits;
auto-start on first actor access so observer-only windows work
(#4119 HIGH ×2).dispatch awaits (or
reserves) the bootstrap for dispatchCreates: false machines; the
reviewers' explicit ask on #4119.Managers keep their domain surface (methods, projections); they lose their hand-rolled lifecycle. Add manager cases to the conformance suite: StrictMode replay, unsubscribe-during-bootstrap, dispatch-before- bootstrap, dispose-with-pending-waiters.
Kills the rest of E1. The transport fixes in #4100/#4105 each hand-built the same shape; extract it:
const guard = admission.open(actor); // captures instanceId, revision, fence state
const decision = await authorize(...); // arbitrary awaits / reentrant callbacks
guard.revalidate(); // throws AdmissionChanged if anything moved
createGenerationGate) as a kernel utility for the
subscribe/attach/bootstrap shape (#4102, #4106), replacing per-file
reinventions.runLocalActorHostConformanceSuite and the transport tests
with the adversarial cases this wave found, as named regression
tests: dispose-during-synchronous-construction (done in #4100 — keep),
sender-destroyed-during-authorize, unsubscribe-during-pending-
subscribe, stale-bootstrap-vs-new-generation, fence-during-drain,
waiter-registered-then-disposed-before-await-returns.Addresses E6. Wave 1 deferred the durable-handoff primitive pending a pilot; #4116/#4119 were the pilot, run without the primitive. Extract what their converged fixes agree on:
draft → admitting → accepted → executing → settled, where each durable write names the checkpoint it
represents and recovery code branches on checkpoint, not on inferred
state (#4119 plan_handoff's three rounds, generalized). Monotonic
autoincrement ordering, never wall-clock timestamps, for rehydration
(#4119 tie bug).Addresses E7.
canonicalIntentHash(schema, value) in the kernel: hash over the
schema-parsed canonical serialization (stable key order, codec-
round-trip-invariant), with a type-level marker so raw JSON.stringify
hashing of wire payloads fails the boundary inventory (#4119 P1).
Delivery/session metadata (originWindowSessionId) lives outside the
hashed envelope by type, not by careful omission (#4119 HIGH ×2).SettlementCache primitive: keyed receipts where unsettled entries
are never evicted — capacity bounds apply to settled history and to
admission of new in-flight entries (reject, don't evict). This is
the #4105 transport fix and the #4122 mcp_oauth fix, which are the
same data structure written twice; mcp_oauth's synchronous-
reservation-before-first-await dedupe also folds in.Addresses E8.
outcome: succeeded | failed | cancelled | superseded plus the classified error; deriving user-facing outcome
from side flags (wasCancelled) has no API to do so (#4095).allow | deny(classified) ; throwing from them is an infrastructure
failure by definition and is reported, not converted to a denial
(#4105). Broad catches at the transport preserve DyadErrorKind
(#4108 ×3, #4123 Auth-vs-NotFound).ActorAdmissionError, fence rejections,
stale-operation ignores) have a dedicated telemetry channel distinct
from product exceptions (#4105, #4121 duplicate-job-receipt-as-error).Addresses E9.
RoutingTable<OperationId, Destination> owned by the actor service,
with entries whose lifetime is tied to the operation: recorded at
authorized admission, forgotten on terminal publication or authorize-
rejection, swept by fences — bounded with never-evict-live and an
explicit reject-on-overflow (#4123's four leak variants, #4121's
immortal toast).Small, surgical: add an optional afterCommands observer phase to
TransactionalDispatcher that runs after the command batch is handed to
the scheduler (and, for serial schedulers, after synchronous execution
completes). Documented ordering becomes: commit → project → subscribers
→ observers → commands → afterCommands. Routing-table forgetting (P9)
and "operation fully settled" bookkeeping move there (#4123). No other
ordering changes — #4100's commit-result check already landed.
Addresses E11's byte-ceiling churn. A test helper,
assertEnvelopeBudget(definition, worstCasePayloadFactory), that
constructs the worst-case valid domain payload (max attachments × max
size, full queue projection), runs it through the actual codec +
structured-clone measurement, and asserts the declared ceilings exceed
it with stated headroom. Every remote definition gets one. Ceiling
mismatches become test failures at the PR that changes either side, not
production snapshot drops (#4119, #4120 ×2).
Also: command-only signals that must reach snapshot consumers get an
explicit monotonic token in state (the #4108 reload-token pattern),
recorded in rules/state-machines.md as the standard answer to
"revision didn't change but consumers must react."
Addresses E10, mostly by codifying what #4099/#4119 converged on:
canonicalizeKeyAfterAuthorization
is the single place identity is established. Add a manifest-validation
check: an intent schema containing a field named like an entity key
(appId, chatId) without a refinement fails registration.event.sender via
WindowRegistry in main; renderer-supplied origin fields are rejected
by the envelope schema (#4096, #4119 forged-owner class).OperationWaiters"; the fence, receipt, manager, and admission-window
sections likewise point at P1–P5 primitives. Rules that survive as
convention: presentation commits on settlement; compensation scope;
cancellation point-of-no-return declaration; reload-token pattern.boundaries.test.ts enforcement pattern (which this wave
proved out — and #4090 showed needs its writer-set seeded from the
allowlist, not filename patterns) with: unconsumed dispatch results,
raw-JSON hashing of wire payloads, creating-ensure reachable from
producer scopes, unavailableSnapshot defined in two places
(host vs client definition — make the client derive it).MACHINE_DIRECTORIES, distributed
consumer lists) generated or glob-verified so a new machine cannot
silently skip the guardrails (#4090's class).Unchanged from Wave 1: no XState/statecharts, no kernel ownership of
state shapes, concurrency policy, or staleness policy; no forced
migration of stable machines; the C2 specialized registries
(connection_flow, mcp_oauth) keep their disposition — though mcp_oauth
adopts SettlementCache (P7), which is mechanics, not policy.
Also out of scope here: the persistence/hydration framework
(MachinePersistencePolicy is contract-only today) beyond the P6
checkpoint helpers; multi-window product policy (owned by the ADR);
cloud topology ADRs.
Bundling rule as before: wide PRs must be behavior-neutral; semantic
changes stay small and bisectable; heavier PRs get /code-review ultra.
OperationWaiters,
admission.open/revalidate, createGenerationGate, conformance
cases; pilot adoption in app_run (the machine with the worst waiter
history) with named regression tests mirroring #4108's three
iterations. Semantic change is localized to app_run settlement.ActorHost,
WeakActorHandle for producers, migration of
chat_actor_deletion_fence / app deletion / Reset Everything onto it.
This is the highest-risk PR (touches every teardown path) — its spec
is the conformance suite plus the golden characterization suite, and
it gets ultra review.dispatchExpectingApplied, rendered-revision stamping, deletion of
fire-and-forget helpers, boundary inventory of call sites. Mechanical
where possible; call sites that previously dropped conflicts choose a
policy explicitly (behavior change: silent no-ops become surfaced or
retried — bisectable per machine).createRemoteManager + the
three manager migrations + manager conformance cases. Behavior-
preserving relative to the already-fixed managers.canonicalIntentHash,
SettlementCache (transport + mcp_oauth adoption), replay
classification helper, typed authorize results, refusal telemetry
channel.afterCommands phase, RoutingTable, version_preview and
image_generation adoption (their leak findings become the regression
tests).assertEnvelopeBudget for all six remote definitions, manifest
identity-refinement validation, boundary-test extensions, rules-doc
rewrite pointing at the primitives.PR 1 ──┬── PR 2 ── PR 7
├── PR 4
└── PR 6
PR 3 ──┘ (independent start; PR 4 consumes its dispatch types)
PR 5 (independent) PR 8 last