StageConfig.modelId; InferenceRouter gains a backward-compatible model-aware
route() overload (default ignores modelId, so static routers and test fakes are
unaffected). New ManagedInferenceRouter (infra commons) resolves a target model
per stage (stage.modelId > capability match > default) and ensureLoaded()s it
before routing, returning the live managed provider — its id changes with the
resident model, so there is no stale health cache to invalidate on swap.
ModelManager.ensureLoaded default delegates to the idempotent load(). Main wires
the managed router on the [[models]] path; the static path keeps DefaultInferenceRouter.
Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 2 of 5).
[[models]] config (ModelConfig/ModelsSettings) parsed by ConfigLoader;
InfrastructureModule.createModelManager + modelConfigToDescriptor; Main.kt
managed boot path spawns the default llama-server when [[models]] is present
(static [[providers]] path preserved when absent) and kills it on shutdown.
Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 1 of 5).
TurboVec persists vectors via the sidecar but kept entry metadata
(sessionId/turnId/text) only in an in-memory map, so restarting with
backend=turbovec silently dropped every query hit (the id→metadata
lookup missed). Rebuild that map from the ChatTurnEvent log at startup.
- RehydratableL3MemoryStore: capability interface for stores whose
vectors persist independently of the JVM. TurboVec implements it;
InMemory deliberately does not (its vectors are volatile too).
- L3MetadataRehydrator replays ChatTurnEvents into the metadata map.
No embedder: the query path never reads entry.vector, so vectors
stay in the sidecar and metadata comes from the event log (the
source of truth, invariant #1). Short-circuits for non-rehydratable
backends before scanning the log.
- Wired into Main before the server accepts queries; no sidecar spawn.
Backfill of never-embedded history (needs the embedder) is a separate
follow-up. The in_memory non-durability warning already exists.
Final plane-2 step: the tool-call intent assessment was decided
server-side but never reached the client — DomainEventMapper dropped
ToolCallAssessedEvent through the else/null branch.
- ServerMessage.ToolAssessed (tool.assessed): disposition as a plain
String + AssessedIssueDto list; observations stay off the wire
(internal replay facts, invariant #9).
- DomainEventMapper maps the event 1:1 (no filtering — rendering
decisions belong in the client).
- Go TUI consumes tool.assessed, records an event entry only for
non-PROCEED or issue-bearing assessments (noise control), and is
added to IsEventBearing() so it buffers correctly during snapshot
replay like its sibling tool.* events.
Implements the full vertical slice for invariant #9 tool-call assessment:
- core:toolintent — new module with ToolCallRule seam, ToolCallAssessor,
WorldProbe/FileSystemWorldProbe, WorkspacePolicy, PathContainmentRule,
and RiskMapping (assessment → RiskSummary / AssessedIssue)
- core:tools — ToolCallAssessmentRecord + ToolInvocationRecord.assessment
field; DefaultToolReducer handles ToolCallAssessedEvent (replay proof)
- core:config — ToolsConfig gains workspaceRoot + privilegedLocations;
ConfigLoader parses both; DEFAULT_PRIVILEGED_LOCATIONS built-in
- core:kernel — OrchestratorEngines gains toolCallAssessor/workspacePolicy/
worldProbe fields; SessionOrchestrator.dispatchToolCalls runs
runPlane2Assessment before the tier gate: BLOCK → hard-reject without
executing; PROMPT_USER → elevates tier into approval path with plane2Risk
in the ApprovalRequestedEvent
- apps/server — constructs PathContainmentRule + ToolCallAssessor +
WorkspacePolicy from config and wires them into OrchestratorEngines
Assessment is recorded as ToolCallAssessedEvent (environment observed once,
facts stored, replay reads events — invariant #9). Assessor and WorldProbe
are only invoked on the live orchestrator path, never in replay.
Remove the wall-clock approval timeout that auto-rejected pending approvals.
The timeout modeled machine latency, but approvals are human latency
(unbounded); it also created an intent/outcome divergence where a human
approving at the same instant the timeout fired was silently overridden by
the auto-reject. Approvals now block until the operator decides; the
resolved-request dedup still guards against double-submit.
Add ServerMessage.ApprovalResolved and map ApprovalDecisionResolvedEvent to
it in DomainEventMapper so clients are notified when any approval is resolved
(by anyone) — letting a second connected client clear its prompt. This is the
event-sourced replacement for the removed timeout notification path.
Drop the now-dead ApprovalConfig (timeout_ms) and its loader/test/sample-config
references. ApprovalStatus.TIMED_OUT is retained for replay of historical events.
Carry the full RiskSummary (level, signals, rationale) inline on
ApprovalRequestedEvent instead of discarding it after assessment. The two
server-side DTO builders (live ApprovalCoordinator path and replay
DomainEventMapper path) now render real risk level, recommended action,
signal factors, and the [code] rationale lines into the approval WebSocket
message, with a consistent enum-name fallback when no assessment ran (tool
path). RiskSummary/RiskSignal made @Serializable for event embedding;
shared RiskSummary.toDto() lives next to RiskSummaryDto.
Flags when a workflow stage's allowedTools references a tool name that
is not registered in the tool registry (config drift), surfaced as a
non-blocking WARNING.
- Add ValidationContext.availableTools (nullable; null = registry
unavailable, skip the check)
- MissingToolRule emits one MISSING_TOOL warning per (stage, tool) pair,
deterministically ordered
- Populate availableTools from the ToolRegistry at the orchestrator
construction site; wire the rule into the prod SemanticValidator
Wire SemanticValidator into the production validator pipeline and
consolidate cycle-policy types under core:validation.
- Add SemanticValidator(CycleExitRule(requirePolicyForCycles=false))
to the prod ValidationPipeline (no-op default, preserves behavior)
- Move CyclePolicy/Binding/Signature/Factory from core:transitions.policy
to core:validation.policy (validation already owns ValidationContext)
- Rename CyclePolicyBindingRule -> CycleExitRule (issue code unchanged)
- Delete dead CyclePolicyResolver + PolicyValidation
Adds [router.embedder] and [router.l3] sections to CorrexConfig with
backend selectors. Ships LlamaCppEmbedder that hits llama.cpp's
/embedding endpoint (handles OpenAI-compatible, simple, and array
response shapes; validates dimension). InfrastructureModule gains
createEmbedderFromConfig and createL3MemoryStoreFromConfig that
dispatch on backend value.
Defaults preserve current behavior (noop embedder + in-memory L3).
Switching to "llamacpp" / "turbovec" is a config-only change — no code
edits required. For turbovec backend, the bundled python sidecar
script is extracted from classpath to ~/.cache/correx/ on first use.
Adds ProviderConfig + providers list and per-tool enabled flags to
CorrexConfig, and rewires apps/server/Main to build the provider list
from config instead of the hardcoded buildLlamaProvider() factory. Env
vars (CORREX_MODEL_ID, CORREX_MODEL_PATH, CORREX_LLAMA_URL) remain a
fallback only when no providers are declared in config.
Completes slice A of the config layer alongside commit 0834c70 which
already shipped the TOML parser upgrade and sample config — those
parser changes referenced these types but were committed prematurely
in isolation; this commit makes HEAD buildable.
Replace raw string routerMessages with structured RouterEntry(role, content)
entries. User messages are stored locally on SubmitInput (both IDLE and
IN_SESSION). Router responses arrive as 'router' entries via
RouterResponseMessage. Tool diffs arrive as 'tool' entries.
RouterPanel renders as a styled conversation log with role prefixes:
▸ user msgs in accent, router replies in strong body, tool output in dim.
Multi-line content (diffs, long responses) is split on \n for proper display.
Panel title changed from 'router' to 'output' to reflect the log role.
Complete TUI visual redesign: Theme.kt with full palette + 10 styles,
3-row grid layout with 1.7:1 horizontal split, redesigned StatusBar
with context meter, EventStreamPanel, FooterBar with contextual
keybinds, WelcomePanel for IDLE state, InputBar as left panel footer.
All existing components updated to use Theme colors. RouterPanel keeps
'not connected — epic 14' placeholder.
The diffScrollOffset in NavUp/NavDown has no upper bound — it can
scroll past the last line. The ApprovalSurface clamps the viewport
with coerceIn/minOf, so it doesn't crash, but the offset keeps
growing. Marked with TODO(diff-scroll) in both handlers.
- Add STAGE_COMPLETE_TOOL constant and meta-tool handler in executeStage
loop: when LLM calls stage_complete, break out of tool dispatch loop
and proceed to normal validation/transition
- Inject stage_complete tool definition in every inference request so
the LLM always has the option to signal stage completion
- Replace expand-all diff with scrollable diff window in approval
surface (ctrl+x toggles, up/down scrolls, shows line range)
P2-1: Guard SessionId NPE with safe-call in SessionsReducer
P2-2: Remove 16 dead event classes + reducer branches; replace with live orchestration events in tests
P2-3: Standardize ChatInput divergences — guard CancelSession, single-arg ProtocolError
P2-4: Remove dead TuiToolRecord.diff and ToolDisplayStatus.REQUESTED
P2-5: Remove dead streamLive from SessionEventBridge
P2-6: Extract SessionsReducerContext data class for 6+ param method
P2-7: Replace StageId("none") sentinel with TypeId.NONE
P2-9: Add TypeId.random() factory on all type-alias IDs
P2-10: Add KDoc on schemaVersion documenting reserved-for-migration
P2-8: Verified RouterReducer string-template already correct (TypeId value class toString)
P1-1: Replace check-then-act with computeIfAbsent for activeSessionJobs to
prevent double-launch. Register pendingApprovals[requestId] BEFORE emitting
ApprovalRequestedEvent to close the approve-before-register window.
P1-2: ToolCompleted with a diff now appends msg.diff to routerMessages
instead of the static "--- diff from $toolName ---" marker.
P1-3: Change routerMessages from List<String> to Map<String, List<String>>
keyed by sessionId.value. RootReducer appends to the correct session's list;
RouterPanel renders only routerMessages[selectedId].
P1-4: On IDLE chat submit, SessionsReducer creates an optimistic
SessionSummary(status="STARTING") and sets selectedId.
processSessionStartedMessage removes any STARTING session and inserts the
real one on echo. RootReducer sets sessionEntered=true for the optimistic
session.
Tests: LaunchRegistrationRaceTest, ApprovalRegisterBeforeEmitTest,
RootReducerTest (diff/per-session/isolation/optimistic-submit),
SessionsReducerTest (optimistic/reconciliation). All P0/P1 tests pass.
./gradlew check green.
Event log integrity (sole source of truth):
- SqliteEventStore: serialize append/appendAll under a Mutex, enable WAL +
busy_timeout + synchronous=NORMAL, roll back on any Throwable. Replaces the
app-computed `SELECT MAX(seq)+1` read-modify-write that dropped events under
concurrent appends (race was invisible to CI: only the in-memory store and
single-threaded :memory: tests were ever exercised).
- Serialization: encodeDefaults + ignoreUnknownKeys, explicit @SerialName on all
46 EventPayload subclasses and event-referenced enum constants, @Serializable on
RiskLevel/RiskAction. Guards the append-only log against silent corruption from
future class renames, field removals, or default-value changes.
Approval/grant security (Invariant: approvals cannot widen authority):
- Tier authorization is now a ceiling (request.tier.level <= max granted) instead
of set membership; SESSION grants bind to a specific toolName instead of matching
everything; handleCreateGrant rejects empty/over-tier/scopeless grants.
Router chat wiring (Phase 0-2): ChatInput round-trip, StartChatSession from IDLE,
router-panel layout, workflows behind Ctrl+W.
Tests: SqliteEventStore concurrency, serialization round-trip + discriminator
stability + unknown-key tolerance, adversarial grant scope/tier; updated existing
approval and reducer tests for the new grant semantics and StartChatSession effect.
- Fix duplicate inference requests after approval resume by registering orchestrator
jobs in activeSessionJobs (ServerModule.launchSessionRun), so the guard in the
OrchestrationResumedEvent subscription prevents spurious duplicate resume.
- Replace blocking Files.readString in computeToolPreview with withContext(Dispatchers.IO)
by making the function suspend — no blocking I/O on the coroutine dispatcher.
- Guard orderedIds.add() in rebuildTools against duplicate invocation IDs on replayed
ToolInvocationRequestedEvent to prevent duplicate tool records in snapshots.
- Add unified-diff preview for file_write tools: computeToolPreview reads existing
file and generates ---/+++ diff before emitting ApprovalRequestedEvent.
- Render diff preview with color coding in ApprovalSurface (@@ yellow, +/- green/red).
- Include current-stage tool records in SessionSnapshot via rebuildTools event replay.
- Fix RouterContextBuilderTest: recursive buildPack helper and 2 tests using class-level
builder instead of their local builder instances (conversationKeepLast mismatch).
- Add suspend keyword to RouterFacadeTest mock, fix buildPack recursion.
- GlobalStreamHandler.handleCreateGrant validates scope (SESSION/STAGE)
and sends ProtocolError to client on validation failure instead of
silent log.warn + drop.
- Derive event stageId directly from GrantScope type, removing
redundant stageIdForEvent tuple.
- SessionOrchestrator integrates ApprovalEngine to check active grants
before requesting user approval for T2+ tool calls.
- ContextEntry gains EntryRole (SYSTEM/USER/ASSISTANT/TOOL) field for
proper chat message role mapping.
- RouterContextBuilder.build is now suspend; uses Tokenizer for
accurate token estimation with fallback to character-based estimate.
- LlamaCppInferenceProvider maps EntryRole to ChatMessage role instead
of heuristic layer-based inference.
Three coordinated changes:
1. DefaultOrchestrationReducer now handles TransitionExecutedEvent,
updating currentStageId so the projected state always reflects the
actual current stage rather than staying stuck at the start stage.
2. DefaultSessionOrchestrator.resume() re-enters the execution loop at
the current stage without re-emitting WorkflowStartedEvent. It reads
currentStageId from the projected state, creates an ExecutionContext
there, and calls enterStage → step exactly as run() does after its
first stage.
3. ServerModule.start() calls resumeAbandonedSessions(), which scans all
RUNNING/PAUSED sessions on startup and launches resume() for each one
whose orchestrator is no longer in memory. Sessions that have a live
deferred are unaffected; only sessions with no coroutine (e.g. the
server was restarted while a tool was executing or approval was pending)
are picked up and re-executed from their current stage.
When a TUI client connects at the exact moment between an
OrchestrationPausedEvent being stored and its corresponding
ApprovalRequestedEvent being stored, the approval projector sees empty
pendingApprovalRequests while orchState.pendingApproval=true. The
stuck-session fix would then append a spurious OrchestrationResumedEvent,
clearing the TUI's pendingApproval display and hiding the approval dialog.
Fix: count OrchestrationPausedEvents vs ApprovalRequestedEvents in the
event log. If there is an unpaired pause (more pauses than requests),
the session just entered the gate — skip the fix entirely.
Also remove spurious ToolExecutionCompleted/Failed emissions from the
kernel orchestrator; those events are emitted by SandboxedToolExecutor
in the infrastructure layer. Keep ToolExecutionRejectedEvent which the
orchestrator does own (rejection happens before executor.execute is
called).
Sessions that had an approval resolved (or never needed one) but never
received OrchestrationResumedEvent would show the last tool as RUNNING
forever with no subsequent events. Three coordinated fixes:
1. SessionOrchestrator now emits OrchestrationResumedEvent in both the
approved and rejected approval branches, so the paused flag clears
correctly going forward.
2. SessionEventBridge.replaySnapshot() detects the stuck pattern
(pendingApproval=true with no unresolved requests) and appends a
synthetic OrchestrationResumedEvent so historical sessions self-heal
on next TUI connect.
3. DomainEventMapper maps OrchestrationResumedEvent → SessionResumed;
TUI SessionsReducer/SnapshotPhaseReducer handle the new message,
clearing pendingApproval and setting status back to ACTIVE.
Also adds server-restart recovery path in DefaultSessionOrchestrator
(emit decision + resume events directly when no live deferred exists)
and pre-registers pending approvals in ServerModule.start() to close
the race between reconnect and ApprovalResponse routing.
Reordered handleStartSession so protocol messages are sent before the
orchestrator launches in module.moduleScope. If the WS is already closed,
session.send() throws and the orchestrator never launches — avoiding silent
data loss where the session runs but the client never learns it exists.
- Stop silently booting user from session view on completion
(SessionsReducer.processSessionCompletedMessage no longer deselects)
- Wrap StageToolManifest send in runCatching to survive WS disconnect
after orchestrator launch in module scope
- Fix navigateUp dead state: wrap to last workflow instead of -1
- Consolidate event store reads in SessionEventBridge (single read
for both recentEvents and approval state), removing
DefaultApprovalRepository dependency
- Add unified diff viewer for file_write/file_edit tools across all layers:
ToolReceipt.diff → DiffUtil (LCS-based) → SandboxedToolExecutor → ToolCompleted
→ TuiToolRecord.diff → DiffViewer widget (colored +/- lines)
- Add workflow selection panel: WorkflowList server message, WorkflowListPanel
widget, ↑↓ seamlessly extends into workflow picker from session list
- Add cursor/caret support: inputCursor in TuiState, CursorLeft/CursorRight
actions and key events, AppendChar inserts at cursor, Backspace deletes before
- Colorize event history strip (green/red/yellow/blue by type) and input bar
(blue/yellow prompt, cyan session name, blue keybindings)
- Show 5 recent events instead of 3; increase event strip height to 6
- Remove lastEventAt sort so display and navigation use consistent order
- Fix approval reconnect: register pending approvals with ApprovalCoordinator
during snapshot replay so lookupSession works after reconnect
- 167 tests pass (126 TUI + 41 server)
- Change EventHistoryStrip take(3) to takeLast(3) so it shows the 3 most
recent events instead of the 3 oldest (chronological order is oldest-first)
- Add bridge test confirming replaySnapshot populates recentEvents from the
event store (with 3 different event types mapped correctly)
- Add TUI SessionsReducer test confirming processSessionSnapshotMessage maps
EventEntryDto entries from the snapshot into TuiEventEntry on the summary
Three fixes:
1. Status bar now shows model/provider from ProviderStatusChanged:
- RootReducer sets currentModel and providerType when the message arrives
- providerType is LOCAL for llama/local providers, REMOTE otherwise
- Removes the stale '(no model) (local)' display
2. SessionSnapshot now carries recent event history:
- New EventEntryDto in the protocol with timestamp/type/detail
- SessionSnapshot.recentEvents populated by bridge from event store
- Bridge reads last 7 display-relevant events per session
- TUI processSessionSnapshotMessage maps them to TuiEventEntry
- Completed sessions finally show their event history
3. SessionStarted now switches selectedId to the new session:
- RootReducer cross-field weave updates selectedId alongside sessionEntered
- Previously kept the old selectedId, navigating user into the wrong session
- Works whether starting a session from the list or during replay
KeyResolver was swallowing Enter in IDLE state when input text was blank,
so SubmitInput never reached RootReducer to set sessionEntered=true.
Now Enter always dispatches SubmitInput in IDLE — RootReducer handles
blank text by entering the selected session, non-blank by starting a
new session (as before). IN_SESSION and FILTER mode unchanged (blank
Enter still returns null there).
Restructure the TUI around a DisplayState enum (IDLE, IN_SESSION, APPROVAL) replacing
the previous InputMode-based navigation (NAVIGATE, STEER removed). Key changes:
- **DisplayState model**: New `DisplayState` enum governs per-mode key resolution and UI
rendering. Removes `ROUTER`/`NAVIGATE`/`STEER`/`FILTER` InputMode complexity.
- **Input history navigation**: Up/Down arrows in IN_SESSION cycle through per-session
input history (ARCH-HISTORY-2/3). `savedInputBuffer` preserves in-flight text.
- **Input history filter**: `Tab` toggles between ROUTER and FILTER modes; history nav
disabled while filtering.
- **Ctrl-C → Cancel**: Rebind Ctrl-C from Quit to Cancel across all states.
- **Approval flow**: `ApproveActive`/`RejectActive` moved from SessionsReducer to
RootReducer via `pendingDecision`/`SubmitApprovalDecision`. Approval overlay
replaced by `ApprovalSurface` component.
- **Event history strip**: Replaces old overlay with a persistent `EventHistoryStrip`
component, toggled via Ctrl-E.
- **Background update badge**: New `backgroundUpdateCount` on SessionsState tracks
events arriving for non-selected sessions (ARCH-BADGE-1).
- **StageToolManifest**: New `ServerMessage.StageToolManifest` and `ToolManifestEntry`
model for pre-declared tool shapes per stage (RF-3).
- **Cleanup**: Removed `ActiveSession`, `ApprovalPanel`, `ToolsPanel` components.
Removed `InputMode.NAVIGATE`, `InputMode.STEER`, approval overlay state fields.
Various reducer simplifications (no more overlaid effects for approval responses).
Two follow-ups for the approval refactor:
- InputReducer STEER input mode hardcodes APPROVE; needs the future
approve/reject overlay UX to let the user pick.
- GlobalStreamHandler derives scopeSessionId from requestId as a
placeholder; needs a real requestId → sessionId lookup once exposed.
ApprovalCoordinator (added in epic-13) was never instantiated. Its four
capabilities — multi-client fan-out, single-resolver dedupe, request
timeout, and a unified domain mapper — were duplicated piecemeal across
GlobalStreamHandler and SessionStreamHandler, with a STEER-outcome
divergence between them (coordinator mapped STEER → APPROVED while
GlobalStreamHandler mapped STEER → REJECTED). This refactor wires the
coordinator and removes the duplication.
Protocol: drop ApprovalDecision.STEER. STEER is no longer a discriminator;
it's expressed as (APPROVE | REJECT) + non-null steeringNote, so the user
can steer alongside either choice. TUI's current single-keybind STEER
input flow now sends APPROVE + note (matching the original epic-13
intent); a future overlay UX will let the user pick APPROVE or REJECT
explicitly.
Mapper: ClientMessage.ApprovalResponse → DomainApprovalDecision lives in
a single shared `toDomain` helper (apps/server/approval/
ApprovalResponseMapper.kt). Both stream handlers and the coordinator
use it.
Config: delete the duplicate apps/server/approval/ApprovalConfig.kt;
ApprovalCoordinator now takes core.config.ApprovalConfig (already had
timeoutMs=300_000L). ConfigLoader already populates it.
Wiring:
- ServerModule owns a SupervisorJob-backed moduleScope, constructs
ApprovalCoordinator, and on start() subscribes eventStore.subscribeAll()
to route ApprovalRequestedEvent payloads to onApprovalRequested.
- GlobalStreamHandler delegates ApprovalResponse to
approvalCoordinator.handleResponse and registers/unregisters via the
new registerGlobalClient/unregisterGlobalClient (global sockets aren't
bound to one SessionId, so broadcasts union session and global sets).
- SessionStreamHandler delegates ApprovalResponse and registers per-session
via registerClient/unregisterClient.
- Timeout is on by default at 5 minutes via config.timeoutMs.
Tests: ApprovalCoordinatorWiringTest covers delegate path, dedupe
producing ProtocolError, and event-stream subscription routing.
The cleanup-04 spec's write side was missing: Action.ApproveActive and
Action.RejectActive were dispatched by KeyResolver but had no reducer
branch, making the approve/reject keybindings silent no-ops. STEER
SubmitInput emitted an ApprovalResponse effect without clearing
`pendingApproval` on the selected session, violating the spec invariant
that the effect emission and the clear happen in the same tick.
SessionsReducer now handles both actions via a private
respondToSelectedApproval(sessions, decision, note) helper that reads
state.selectedPendingApproval(), clears the field, and emits
Effect.SendWs(ApprovalResponse(...)). InputReducer's STEER SubmitInput
branch clears `pendingApproval` on the selected session in the same
returned TuiState as the response effect.
Adds four tests covering APPROVE, REJECT, no-pending no-op, and
STEER atomicity.
Complete the tui-02 spec acceptance criteria: delete the orphan
`connectSession`, `disconnectSession`, `sessionStreamJob`, and
`sessionWsSession` members from TuiWsClient, and remove the `send()`
fallback that previously routed ApprovalResponse over a per-session
socket. All ClientMessage types now travel the single global socket.
Server side: GlobalStreamHandler.handleClientMessage now actually
handles ApprovalResponse (was returning a protocol error), converting
to a domain ApprovalDecision and dispatching to the orchestrator —
mirrors SessionStreamHandler's prior logic.
Adds ApprovalResponseGlobalSocketRoundTripTest covering APPROVE and
STEER encode/decode through ProtocolSerializer.
Close the narrow race where `streamGlobal` launched the subscribeAll
collector and immediately read `lastGlobalSequence()` — events appended
in that window were dropped by the replay=0 SharedFlow before the
collector actually subscribed. Now use a CompletableDeferred barrier
signaled via `onSubscription` (SharedFlow) or `onStart` (cold fallback),
awaited before `bridge.replaySnapshot()`.
Closes the remaining gap on finding #1 from
docs/specs/2026-05-24-review-fixes.
Effect.Quit is appended at the tail of the effect list rather than
prepended, so all sub-reducer cleanup effects dispatch before runner
teardown. Combined with the sequential dispatch from effects-01, list
position is a runtime guarantee.
Closes finding #9.
Replace per-effect launch with a single launch that iterates effects
sequentially via dispatchAll, closing the ordering race (I-E1 invariant).
Add EffectDispatchOrderTest proving sequential preserves order while
fire-and-forget does not.