SessionRouter¶
This document defines the SessionRouter — the unified session-level coordinator that sits above JourneyOrchestrator, host-supplied trigger routing policy, and MFJ.
The concrete refinement re-entry policy is framework-owned. The generic control-plane contracts and runtime now live under mozaiksai/control_plane/, while the first-party factory pack stays declarative under factory_app/control_plane/. The Studio/Mozaiks host injects the harness into SessionRouter through a trigger-route resolver seam. SessionRouter does not import factory pack policy directly.
Why This Layer Exists¶
The platform already has the right subsystems:
| Subsystem | Scope |
|---|---|
GlobalPackGraph | Static macro topology — workflows, dependencies, journeys, transitions |
JourneyOrchestrator | Reactive auto-advance — listens to run_complete, spawns next step |
| MFJ | Workflow-local parallel execution — internal to a single workflow |
| Refinement re-entry policy | Change-class + artifact ownership → re-entry workflow |
What is missing is a first-class, persisted session record and a routing decision point that sees all trigger types uniformly.
Currently: - Session position is implicit — scattered journey_id, journey_key, journey_step_index fields embedded across individual chat session documents. - JourneyOrchestrator reads those fields reactively but is hardwired to transport internals. - Refinement triggers via /api/workflows/trigger bypass JourneyOrchestrator entirely. - Approval callbacks, transition resolutions, and resume events have no single coordination point.
The result: lifecycle state lives nowhere; each trigger type re-derives position from scratch.
SessionRouter closes that gap. It does not replace any existing subsystem — it coordinates them.
Non-Goals¶
- Does not replace MFJ. MFJ is workflow-local orchestration and stays that way.
- Does not replace
JourneyOrchestrator. Journey auto-advance remains event-driven. - Does not classify natural-language change requests. SessionRouter consumes a prior classification result for refinement re-entry.
- Does not stream workflow state across processes. Streaming is a transport/observability concern. Add it later if cross-process live coordination becomes necessary.
- Does not own agent routing. Agent handoffs live in
handoffs.yaml.
Relationship to Existing Layers¶
[ User / API ]
│
SessionRouter ← unified decision point
/ | | \
dependency check | journey refinement
| advance re-entry
transition surface |
| |
chat-ui transition system re-entry policy helper
| |
GlobalPackGraph |
| |
MFJ / AG2 runtime |
JourneyOrchestrator becomes a sub-handler for the run_complete case. Refinement re-entry policy becomes a host-supplied helper for the refinement case. GlobalPackGraph is consulted by SessionRouter (currently also by JourneyOrchestrator — that duplication goes away once SessionRouter owns the journey position read).
Control-Flow Primitives¶
SessionRouter should reason about four distinct primitives. These should not be collapsed into one overloaded routing model.
1. Dependencies¶
Dependencies are the hard prerequisite system.
- They answer: "what must already be complete before this workflow may run?"
- They are declared on
workflows[]. - They are enforced by SessionRouter before a workflow starts.
- They are not UI.
Example:
AgentGeneratordepends onDesignDocsDesignDocsdepends onValueEngine
If a user tries to enter AgentGenerator directly, SessionRouter should detect the first unmet dependency and reroute to it.
2. Journeys¶
Journeys are the guided sequence layer.
- They answer: "what is the intended happy-path order across workflows?"
- They are declared on
workflow_sequences[]. - They may include serial or parallel step groups.
- They are optional UX guidance, not the universal prerequisite source.
Dependencies remain the hard truth. Journeys are the guided path.
3. Transition Surfaces¶
Transitions are optional session-router-driven UX surfaces that appear between workflow phases or before routing.
- They answer: "should this routing decision be surfaced to the user?"
- They may render overlay, inline, progress, confirm, or no UI at all.
- They are presentation wrappers around router decisions, not the router itself.
Some transitions are fully silent. In those cases SessionRouter simply reroutes or resumes without mounting any UI.
4. MFJ¶
MFJ remains workflow-local orchestration.
- It answers: "inside this workflow, do we need fan-out/fan-in?"
- SessionRouter does not inspect child chat details.
- SessionRouter only sees the outer workflow run lifecycle.
Responsibilities¶
SessionRouter owns exactly seven things:
-
Trigger classification — identify what kind of event arrived (initial start, transition resolution, run completion, refinement, approval, resume)
-
Session load/create — load the
Sessionrecord for(app_id, user_id)scope, or create a new one -
Dependency enforcement — before any workflow start or resume, verify hard prerequisites from
workflows[].dependencies -
Position resolution — given the session's current
journey_positionand theGlobalPackGraph, determine what is valid next -
Delegation — call the correct subsystem for the routing decision
-
State write-back — persist updated
Sessionstate after each decision -
Lifecycle event emission — emit structured events for observability (
session.started,session.phase_advanced,session.refinement_entered,session.awaiting_transition,session.completed,session.stale)
SessionRouter does not execute workflows. It decides, persists, and delegates.
Trigger Types¶
| Trigger source | Description | Delegates to |
|---|---|---|
initial | New session, no prior state | GlobalPackGraph → dependency check → transition or first workflow |
transition | User resolved a transition surface | Transition lookup → next transition or spawn workflow |
run_complete | A workflow run finished | JourneyOrchestrator → next journey step group |
refinement | User requested a change against a prior artifact | re-entry policy helper → new session phase |
decision | User resolved a pending harness decision | Clear awaiting_decision → continue selected path |
resume | Client reconnect / session reload | Reload Session → re-wire transport → continue active run |
Transition Surfaces¶
Transition surfaces are router-driven UI or no-UI routing moments. They should be declared minimally and rendered by registered React components when needed.
Recommended direction:
{
"id": "coding_journey_selector",
"transition_type": "user_choice_context",
"ui": { "component": "CodingJourneySelector", "mode": "screen" },
"options": [
{ "id": "autonomous", "route_to": "DesignDocs", "context_variables": { "design_docs_hitl": false } },
{ "id": "guided", "route_to": "DesignDocs", "context_variables": { "design_docs_hitl": true } }
]
}
Important:
- Single-route transitions use
route_to; user-choice transitions useoptions[].route_to ui.componentis a renderer name resolved from the UI registry- transition components emit
option_id; SessionRouter owns option resolution - transition declarations may seed
options[].context_variables - target workflow creation filters transition context against declared
context_variables.yamlkeys - richer visuals belong in the component implementation, not in the generic routing schema
Programmatic workflow starts should use mozaiksai.core.session.launcher so they still pass through SessionRouter validation, dependency rerouting, context filtering, and chat-session binding.
Recommended transition types:
| Transition type | Purpose | UI required |
|---|---|---|
user_choice | User chooses one of multiple paths | Yes |
user_choice_context | User chooses context while continuing to a shared target | Yes |
user_choice_route | User chooses between explicit workflow/transition targets | Yes |
confirm | User approves or rejects a route | Yes |
progress_view | Show progress for backend-only work | Yes |
prerequisite_redirect | Explain why router is redirecting to an unmet prerequisite | Optional |
silent | No UI, just continue | No |
SessionRouter should decide whether to surface a transition at all.
Examples:
- direct start blocked by dependency ->
prerequisite_redirectorsilent - DesignDocs runs invisibly ->
silentorprogress_view - branch selection ->
user_choice
Session State Model¶
One Session record per (app_id, user_id, journey_instance).
@dataclass
class Session:
session_id: str # unique
app_id: str
user_id: str
# Journey position
journey_key: str # which journey definition (e.g. "build")
journey_position: int # index into journey.steps (normalized step groups)
journey_total_steps: int
# Builder-sequence status
sequence_status: SequenceStatus
# in_progress | completed | stale | revising
sequence_completed_at: datetime | None
active_revision_id: str | None
active_change_request_id: str | None
current_revision_scope: str | None # patch | design | feature | core
revision_origin_workflow: str | None
restart_from_workflow: str | None
# Lifecycle
lifecycle_state: SessionLifecycle
# initial | active | awaiting_transition | awaiting_decision | completed | stale
# Active execution
current_run_id: str | None # chat_id of the currently executing workflow run
current_workflow_name: str | None
active_mfj_run_ids: list[str] # parallel run ids when in a parallel step group
# Blocked state
pending_transition_id: str | None
pending_harness_decision: PendingHarnessDecision | None
# persisted deferred decision envelope:
# {
# decision_id, decision_type, message, rationale, confidence,
# recommended_workflow_id, selected_paths, clarification_question,
# change_request_id, revision_id,
# trigger_source, requested_workflow_id, journey_id,
# context_variables, trigger_payload,
# actions[], metadata, created_at
# }
# Artifact layer refs
artifact_version_refs: dict[str, str]
# keys: "concept" | "design_docs" | "workflow_bundle" | "app_bundle"
# values: artifact_version_id of the latest committed version per layer
stale_layers: dict[str, str]
# keys: artifact layer names; values: invalidation reasons or revision ids
# Revision history
revision_history: list[RevisionEntry]
# [{revision_id, change_request_id, scope, origin_workflow, from_version_refs, to_version_refs, timestamp}]
created_at: datetime
updated_at: datetime
class SequenceStatus(str, Enum):
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
STALE = "stale"
REVISING = "revising"
class SessionLifecycle(str, Enum):
INITIAL = "initial"
ACTIVE = "active"
AWAITING_TRANSITION = "awaiting_transition"
AWAITING_DECISION = "awaiting_decision"
COMPLETED = "completed"
STALE = "stale" # upstream artifact invalidated by a core change
Lifecycle Transitions¶
initial
→ active (first workflow started)
→ awaiting_transition (entry transition presented)
awaiting_transition
→ active (transition resolved → workflow spawned)
→ awaiting_transition (transition resolved → next transition)
active
→ active (journey auto-advance spawned next step)
→ awaiting_transition (router surfaces a transition)
→ awaiting_decision (runtime persisted a deferred harness decision)
→ active (refinement trigger resolved to a workflow start)
→ completed (last journey step completed)
awaiting_decision
→ active (user resolved the pending harness decision)
completed
→ active (post-completion refinement request resolves to a workflow start)
→ stale (core change invalidated upstream layer)
stale
→ active (new concept revision started ValueEngine)
Important:
lifecycle_stateanswers "what is the router/execution doing right now?"sequence_statusanswers "what is the build journey's revision state?"- refinement does not need a separate
REFININGlifecycle enum ifsequence_status=revisingis persisted alongsidelifecycle_state=active
Examples:
- user is mid-build in
AppGenerator, asks for a core change:lifecycle_state=active,sequence_status=revising - full build finished, user asks for a patch:
lifecycle_state=active,sequence_status=revising, priorsequence_completed_atstays populated - core revision invalidates downstream layers before rerun:
sequence_status=stale, thenrevisingonce the newValueEnginerun starts
How SessionRouter Consults GlobalPackGraph¶
GlobalPackGraph is the static source of truth for topology. SessionRouter never mutates it — it only reads.
Session Start¶
1. load_global_pack_graph()
2. resolve requested workflow or requested journey
3. enforce hard dependencies before starting anything
4. if router wants a transition surface → set awaiting_transition
5. else start the first valid workflow immediately
Dependency Enforcement¶
Dependencies are checked before direct workflow starts and before router-driven re-entry.
1. user requests workflow W
2. load W.dependencies
3. find the first unmet required dependency D
4. if none -> continue to W
5. if D exists:
- either silently reroute to D
- or emit a prerequisite transition surface for D
Dependencies are the gating truth. Journeys do not need to act as a second hard prerequisite system.
Transition Resolution¶
1. load transition declaration by pending_transition_id
2. find the selected option_id
3. if route_to is another transition id -> remain in awaiting_transition
4. if route_to is a workflow id -> enforce dependencies, then spawn workflow run
5. update Session.pending_transition_id, Session.current_workflow_name, Session.current_run_id
Journey Auto-Advance (delegated from JourneyOrchestrator)¶
1. JourneyOrchestrator fires on run_complete as today
2. SessionRouter receives the completion event first
3. increments Session.journey_position
4. checks whether the next phase needs a transition surface
5. if no transition is needed -> delegates spawn to JourneyOrchestrator
6. updates Session.current_workflow_name, Session.current_run_id
Parallel Step Groups (MFJ-adjacent)¶
When journey.steps contains a parallel group such as ["ThemeCapture", "ExistingAppDiscovery"]:
1. SessionRouter spawns both runs (via JourneyOrchestrator as today)
2. records both run ids in Session.active_mfj_run_ids
3. remains in ACTIVE until all sibling runs complete
4. JourneyOrchestrator's existing sibling-check logic handles the "advance only when
all siblings done" barrier — SessionRouter only adds the state write-back
Parallel groups must not contain required dependency edges between siblings. If AppGenerator depends on AgentGenerator, they must be authored as separate serial steps.
MFJ (within a single workflow) is invisible to SessionRouter. SessionRouter only sees the outer workflow's run_complete event.
How SessionRouter Invokes Refinement Re-Entry¶
1. trigger_source == "refinement" arrives at /api/workflows/trigger
2. SessionRouter loads Session for (app_id, user_id) plus accepted artifact refs
3. derives the re-entry workflow and `workflow_sequence` from change class +
app-declared control-plane routing
4. receives RoutingDecision(workflow_id, journey_id, context_seed, is_full_restart)
5. control plane persists artifact-level stale status for affected
artifact_version_refs using the new `change_request_id`
6. if the harness defers launch and returns a decision UI first:
- persist active_revision_id and active_change_request_id immediately
- keep the current chat/workflow binding until a new run is launched
- persist the full pending harness decision in SessionRouterState
- set Session.lifecycle_state = AWAITING_DECISION
- when the follow-up action is submitted, reuse the persisted
`change_request_id` and `revision_id`
7. if is_full_restart:
- allocate active_revision_id and active_change_request_id
- mark Session.sequence_status = STALE
- mark downstream artifact_version_refs as stale in stale_layers
- transition to new ValueEngine revision run
- set Session.sequence_status = REVISING
8. else:
- allocate active_revision_id and active_change_request_id
- set Session.sequence_status = REVISING
- spawn re-entry workflow bound to the selected workflow_sequence with
context_seed merged into context_variables
- append RevisionEntry to Session.revision_history
9. emit session.revision_requested
10. emit session.reentry_selected
11. emit session.revision_started when the workflow run is bound
Refinement re-entry creates a new workflow run against the same Session. The selected control-plane workflow_sequence becomes the run's journey binding when one is declared. The original build sequence is not reused unless the control-plane route explicitly selects it. On completion, Session transitions back to ACTIVE or COMPLETED depending on prior state.
Rules:
- build completion affects decision shaping, not semantic classification
- a
corereroute toValueEngineis still revision mode, not blank-slate intake - the workflow receives a revision context containing prior concept, design, artifact lineage, change request, and impact metadata
Revision Context Contract¶
Every workflow re-entry should receive the same revision envelope, regardless of whether the session was still mid-build or already completed:
{
"build_mode": "revision",
"revision_scope": "patch" | "design" | "feature" | "core",
"revision_id": "...",
"change_request_id": "...",
"artifact_kind": "...",
"artifact_version_id": "...",
"revision_origin_workflow": "...",
"sequence_status": "in_progress" | "completed" | "stale" | "revising",
"refinement_request": "...",
"refinement_request_meta": {...},
"change_intent": {...},
"impact_set": {...},
}
This contract exists so:
ValueEnginecan reopen the concept with awareness of prior decisionsDesignDocscan revise design artifacts without pretending the build is newAgentGeneratorandAppGeneratorcan decide between targeted edits and wider regeneration- downstream workflows can detect stale layers without scraping transcript history
Session Events¶
SessionRouter should emit explicit revision-aware session events:
session.startedsession.phase_advancedsession.awaiting_transitionsession.sequence_completedsession.revision_requestedsession.reentry_selectedsession.layers_invalidatedsession.revision_startedsession.revision_completedsession.completedsession.stale
Migration Path From Current JourneyOrchestrator¶
The current JourneyOrchestrator embeds session position in individual chat docs (journey_id, journey_key, journey_step_index). This is the bootstrapping state that SessionRouter formalizes.
Migration is additive, not a rewrite:
- SessionRouter is introduced as a new layer in
mozaiksai/core/session/. JourneyOrchestrator.handle_run_completeis wrapped — it reports to SessionRouter before/after its current logic./api/workflows/triggerroutes all incoming triggers through SessionRouter.- The legacy
journey_id/journey_step_indexfields on chat docs are kept as fallback until Session records are fully backfilled. - Once Session records are the canonical source, the legacy fields can be cleaned up.
File Location¶
mozaiksai/core/session/
├── __init__.py # exports SessionRouter, Session, SessionLifecycle, SequenceStatus
├── router.py # SessionRouter class — decision logic
├── model.py # Session, SessionLifecycle, SequenceStatus, RevisionEntry dataclasses
└── persistence.py # Session CRUD against MongoDB (sessions collection)
SessionRouter is a runtime primitive. It belongs in mozaiksai/core/, not in any platform workflow directory.
Open Questions (pre-implementation)¶
-
Multi-device / multi-tab: Can two clients share one Session record, or is Session per-connection? Recommended: per
(app_id, user_id)scope, transport re-wired on reconnect. -
Parallel journeys: Can a user run
buildand a standaloneAgentGeneratorin parallel? Recommend: one active journey perapp_idscope; standalone workflows get a synthetic journey with one step. -
Pending decision protocol: the runtime now persists a replayable
pending_harness_decisionand the shell restores it fromchat_meta.session_state. The remaining open question is whether futureclarify_scopeflows should support freeform text replies in addition to action buttons. -
Transition declarations:
GlobalPackGraphnow usesentrypoints[]for shell entry routes andtransitions[]for router decisions. Sequence metadata does not own entry UI.