Architecture
End-to-end flow (the big picture)
HTTP request received
REST API receives {conversationId, text},
audits USER_INPUT, and forwards control to
DefaultConversationalEngine.
Engine initializes execution context
EngineSession is a working POJO holding intent, state, context_json, payload, and history. The engine dynamically builds the pipeline from Spring-managed steps.
Conversation row ensured and restored
ce_conversation is created (if missing) and loaded.
Existing state_code, intent_code, and
context_json are restored into the session.
Intent classified using DB + LLM
Intent resolution is driven by
ce_intent,
ce_intent_classifier, and
ce_prompt_template.
AGENT classifiers invoke the LLM under strict JSON contracts.
Planner selects and executes tools
MCP context is cleared first.
Planner selects tools from
ce_mcp_tool +
ce_mcp_db_tool,
executes SQL safely, and stores observations and
finalAnswer under context_json.mcp.
DB-driven state machine applied
Rules from ce_rule run in priority order,
mutating state, intent, or short-circuiting execution
using resolver factories.
Final payload generated
ce_response selects EXACT vs DERIVED output.
DERIVED responses may invoke LLM; EXACT responses are
deterministic and bypass generation.
Conversation saved and returned to client
Updated intent, state, context_json, and payload are
persisted. The full execution trace is visible via
ce_audit.
1) Request entry point - controller
File: api/controller/ConversationController.java
What happens here
- Accepts
ConversationMessageRequestwithconversationId+text - Writes a
USER_INPUTaudit so UI can replay the chain - Delegates to
ConversationalEngine.process(...) - Returns
OutputPayload(TextPayload or JsonPayload)
@PostMapping("/message")
public OutputPayload message(@RequestBody ConversationMessageRequest request) {
UUID conversationId = request.getConversationId();
audit.audit(
"USER_INPUT",
conversationId,
"{\"text\":\"" + JsonUtil.escape(request.getText()) + "\"}"
);
EngineSession session = engine.process(conversationId, request.getText());
audit.audit(
"ENGINE_OUTPUT",
conversationId,
"{\"payload\":\"" + JsonUtil.escape(String.valueOf(session.getPayload())) + "\"}"
);
return session.getPayload();
}
2) ConversationalEngine - session + pipeline
File: engine/provider/DefaultConversationalEngine.java
This is the single orchestration point that:
- creates a new
EngineSession - creates the pipeline (dynamic steps)
- executes it
- returns the session to the controller
EngineSession is intentionally a POJO working state.
It should not “reach into DB”.
DB reads/writes are done inside steps/services (like ConversationService, AuditHistoryProvider).
3) EnginePipeline - the core loop (StepResult)
File: engine/pipeline/EnginePipeline.java
Every step returns a StepResult:
Continue→ next stepStop→ pipeline terminates early and returns final OutputPayload
public OutputPayload execute(EngineSession session) {
for (EngineStep step : steps) {
StepResult r = step.execute(session);
if (r instanceof StepResult.Stop stop) {
return stop.result();
}
}
return session.getPayload();
}
4) Pipeline composition - how steps are discovered and ordered
File: engine/factory/EnginePipelineFactory.java
Steps are:
- discovered from Spring as beans of
EngineStep - filtered (conversation persisted requirement, etc.)
- ordered using annotations:
@MustRunAfter@MustRunBefore@RequiresConversationPersisted
- Create a Spring component that implements EngineStep
- Annotate with MustRunAfter / MustRunBefore relative to existing steps
- If it requires a ce_conversation row, add @RequiresConversationPersisted
- Add audits inside the step so UI shows it
5) Database “brain” - all configuration tables (don't skip any)
Below is an overview of every core table that drives behavior.
🗄️ Configuration tables
| Table | What it controls | Used by (code) | Notes / key columns |
|---|---|---|---|
| ce_intent | Allowed intents, priority ordering, description + llmHint | AllowedIntentService, AgentIntentResolver | intent_code, enabled, priority, llm_hint |
| ce_intent_classifier | How intents are classified (AGENT/REGEX/EXACT etc) and which resolver to use | IntentResolutionStep | classifier_type, enabled, priority |
| ce_prompt_template | Prompt templates by purpose + optional intent scoping | AgentIntentResolver, MCP planner, Response resolvers | purpose, intent_code, system_prompt, user_prompt, enabled |
| ce_output_schema | Strict JSON schema control for LLM calls (by intent/state) | AgentIntentResolver, response format resolvers | intent_code, state_code, json_schema, enabled |
| ce_response | Which response to use for (state,intent) and how to generate it | ResponseResolutionStep + ResponseTypeResolverFactory | state_code, intent_code, response_type, output_format, priority |
| ce_rule | DB-driven state machine + short-circuit logic | RulesStep + RuleTypeResolverFactory + RuleActionResolverFactory | intent_code, rule_type, match_pattern, action, action_value, priority |
| ce_mcp_tool | Tool registry (code/group/enabled/description) | McpToolStep planner & tool loader | tool_code, tool_group, enabled |
| ce_mcp_db_tool | DB tool configuration (dialect, sql template, param schema, safe mode, max rows) | McpDbToolExecutor | tool_id (FK), sql_template, param_schema (jsonb), safe_mode, max_rows |
🗄️ Runtime tables
| Table | What it stores | Written/Read by | Key columns |
|---|---|---|---|
| ce_conversation | One row per conversation with current state/intent + context_json + last response payload | PersistConversationBootstrapStep, LoadOrCreateConversationStep, PersistConversationStep | conversation_id, intent_code, state_code, context_json, last_assistant_json |
| ce_audit | Append-only timeline of everything that happened during processing | AuditService implementations; UI reads this for the chain | conversation_id, stage, payload_json (json), created_at |
6) Step-by-step pipeline walk-through (actual steps)
Below is the practical flow you'll see in your engine.
(Exact ordering can vary slightly based on annotations, but this is the intended lifecycle.)
Step 1 - PersistConversationBootstrapStep
File: engine/steps/PersistConversationBootstrapStep.java
Purpose:
- Ensures the conversation exists in DB when needed (bootstrap)
- Audits the bootstrap action so your UI can see it
Step 2 - LoadOrCreateConversationStep
File: engine/steps/LoadOrCreateConversationStep.java
Purpose:
- Loads existing conversation row OR creates a new one
- Syncs conversation's
intent_code,state_code,context_jsoninto EngineSession
Step 3 - IntentResolutionStep
File: engine/steps/IntentResolutionStep.java
Purpose:
- Picks which classifier to run (from
ce_intent_classifier) - Classifiers can be AGENT / REGEX / EXACT
- Sets
session.intentand audits
Step 4 - (Validation / schema / extraction steps)
You have these steps in your engine to enrich session before MCP/Response:
- ValidationStep
- SchemaExtractionStep
- AutoAdvanceStep (Exact behavior depends on your table config and your program features.)
Step 5 - McpToolStep (MCP loop)
File: engine/steps/McpToolStep.java
Purpose:
- Clears old
context_json.mcp(audit: MCP_CONTEXT_CLEARED) - Planner calls LLM to decide tool usage
- Executes tools from DB tables:
- ce_mcp_tool
- ce_mcp_db_tool
- Writes:
mcp.observations[]mcp.finalAnswer
{
"mcp": {
"observations": [
{
"tool": "postgres.schema",
"result": { "tables": ["..."] }
},
{
"tool": "postgres.move_status",
"result": { "rows": [ { "connection_id": "...", "status": "MOVED" } ] }
}
],
"finalAnswer": {
"answer": "The status of your move for connection ... is MOVED."
}
}
}
Step 6 - RulesStep (DB-driven state machine + shortcuts)
File: engine/steps/RulesStep.java
Purpose:
- Iterates enabled rules ordered by priority
- Uses factory pattern:
- RuleTypeResolverFactory → resolves whether rule matches (JSON_PATH / REGEX / …)
- RuleActionResolverFactory → applies mutation (SET_STATE / SET_INTENT / SHORT_CIRCUIT)
- Audits RULE_APPLIED / RULE_NOT_APPLIED etc
This is where you build your state machine
Example: if MCP produced mcp.finalAnswer, you can SHORT_CIRCUIT deterministically (no LLM formatting required).
Step 7 - ResponseResolutionStep (ce_response driven)
File: engine/steps/ResponseResolutionStep.java
Purpose:
- Finds the matching
ce_responserow - Delegates to
ResponseTypeResolverFactory:- EXACT → return configured response content directly
- DERIVED → uses templates + LLM (through OutputFormatResolverFactory)
- Writes audits including:
- RESOLVE_RESPONSE
- RESOLVE_RESPONSE_LLM_INPUT
- RESOLVE_RESPONSE_LLM_OUTPUT
Step 8 - PersistConversationStep + PipelineEndGuardStep
Purpose:
- PersistConversationStep syncs final
intent/state/context/last_assistant_jsonto ce_conversation - PipelineEndGuardStep ensures payload exists; otherwise returns STOP with error payload
7) Putting it together - example conversation + “behind the scenes”
FAQ Example - How ConvEngine Answers FAQs (No MCP)
This document explains exactly how ConvEngine processes a FAQ-style question. Unlike MOVE or other operational intents, FAQ does NOT use MCP tools.
If MCP runs for FAQ, that is a bug - not a feature.
🗣️ Example FAQ Conversation
Can I move my connections within zapper?
Yes, internal account moves are supported.
🔄 High-level Flow (FAQ)
Controller → Engine → Pipeline → Intent → Response → Output
Key difference from MOVE:
- ❌ No MCP planner
- ❌ No MCP tools
- ❌ No DB queries via MCP
- ✅ Direct FAQ resolution
1️⃣ Controller receives the request
2️⃣ Engine creates session and pipeline
EngineSession is a POJO.
It does NOT decide behavior - the pipeline + DB do.
3️⃣ Pipeline executes steps in order
- Allows SHORT_CIRCUIT
- Allows rules to stop flow early
- No hardcoded branching
4️⃣ IntentResolutionStep → FAQ
Tables involved:
🗄️ ce_intent
Allowed intents
- intent_code = FAQ
- priority
- enabled
🗄️ ce_prompt_template
Intent agent prompt
- purpose = INTENT_AGENT
- intent_code = null
The AgentIntentResolver:
- Sees allowed intents
- Uses LLM only to classify
- Returns
FAQ
FAQ intent is resolved BEFORE any MCP logic exists.
5️⃣ MCP is SKIPPED (IMPORTANT)
There is no MCP planning because:
- FAQ intent has no MCP tools
- No ce_mcp_tool applies
- No ce_mcp_db_tool is loaded
If MCP runs for FAQ, your configuration is wrong.
You will not see:
- MCP_PLAN_LLM_INPUT
- MCP_TOOL_CALL
- MCP_FINAL_ANSWER
6️⃣ RulesStep (usually no-op for FAQ)
Typical FAQ rules:
- none
- or cosmetic state changes
🗄️ ce_rule
Optional FAQ rules
- intent_code = FAQ
- rule_type = REGEX / JSON_PATH
FAQ usually has zero rules.
7️⃣ ResponseResolutionStep
🗄️ ce_response
FAQ responses
- intent_code = FAQ
- state_code = IDLE
- response_type = EXACT or DERIVED
Two patterns:
Pattern A - EXACT (no LLM)
- exact_text is returned
- fastest path
Pattern B - DERIVED (LLM formatting)
- derivation_hint used
- still no MCP
FAQ responses NEVER depend on MCP context.
🔍 What you'll see in ce_audit for FAQ
Typical stages:
- USER_INPUT
- INTENT_AGENT_LLM_INPUT
- INTENT_AGENT_LLM_OUTPUT
- RESOLVE_RESPONSE
- RESOLVE_RESPONSE_LLM_OUTPUT (only if DERIVED)
No MCP stages. Ever.
✅ Final Takeaways (FAQ)
- Intent = FAQ
- No MCP planning
- No DB tools
- Response comes from ce_response
- Fully deterministic unless DERIVED
If FAQ ever:
- reuses previous answers
- runs MCP
- hits DB tools
👉 the bug is in configuration, not engine code.
MOVE_CONNECTIONS - Architecture Deep Dive
This document explains exactly how ConvEngine processes a
MOVE_CONNECTIONS request - from REST controller to MCP tools to final response.
If you read this once, you should be able to:
- Debug any MOVE flow issue
- Add new MCP tools safely
- Understand where stale answers come from
- Explain the engine to another engineer confidently
🗣️ Example Conversation
What is the status of my move for connection USPSC003BA100SA277CON1388
The status of your move for connection USPSC003BA100SA277CON1388 is MOVED.
What is the status of my move for connection USPSC003BA100SA277CON1128
The status of your move for connection USPSC003BA100SA277CON1128 is IN_PROGRESS.
🔁 High-level Flow (One Turn)
Controller → Engine → Pipeline → Intent → MCP → Rules → Response
Every User → Assistant turn follows this exact lifecycle.
1️⃣ Request enters via REST controller
File: ConversationController.java
- UI replay: ce_audit becomes a full timeline
- Debug truth: broken JSON or stale state is visible immediately
- No hidden behavior: every request is auditable
2️⃣ EngineSession + Pipeline orchestration
File: DefaultConversationalEngine.java
EngineSession is a POJO.
All intelligence lives in pipeline steps + DB configuration.
3️⃣ Pipeline execution model
File: EnginePipeline.java
- Allows SHORT_CIRCUIT responses
- Allows rules to stop execution
- Keeps pipeline generic & extensible
4️⃣ Core pipeline steps (order matters)
Selects resolver (AGENT / REGEX / EXACT) from DB.
5️⃣ Intent resolution for MOVE_CONNECTIONS
Tables involved
🗄️ ce_intent
Allowed intents
- intent_code
- priority
- llm_hint
- enabled
🗄️ ce_prompt_template
Intent prompts
- purpose = INTENT_AGENT
- system_prompt
- user_prompt
🗄️ ce_output_schema
Strict JSON control
- intent_code
- state_code
- json_schema
The LLM returns structured JSON like:
{
"intent": "MOVE_CONNECTIONS",
"confidence": 0.92,
"needsClarification": false,
"clarificationResolved": false,
"clarificationQuestion": ""
}
6️⃣ MCP Tool Step (MOVE_CONNECTIONS only)
The FIRST thing MCP does is clear old context.
If this does not happen, stale answers WILL appear.
MCP execution loop
- MCP_CONTEXT_CLEARED
- MCP_PLAN_LLM_INPUT
- MCP_PLAN_LLM_OUTPUT
- MCP_TOOL_CALL
- MCP_TOOL_RESULT
- Repeat until MCP_FINAL_ANSWER
7️⃣ MCP database tools
🗄️ ce_mcp_tool
Tool registry
- tool_code
- tool_group
- enabled
🗄️ ce_mcp_db_tool
SQL-backed tools
- tool_id (FK)
- sql_template
- param_schema
- safe_mode
- max_rows
Example SQL template:
select status
from move_request
where connection_id = :connection_id
8️⃣ MCP writes final answer to context
{
"mcp": {
"observations": [
{
"tool": "postgres.move_status",
"rowsJson": "[{ status: 'MOVED' }]"
}
],
"finalAnswer": {
"answer": "The status of your move is MOVED."
}
}
}
9️⃣ RulesStep - optional short-circuit
Rules can:
- SET_STATE
- SET_INTENT
- SHORT_CIRCUIT
Typical MOVE rule:
- rule_type = JSON_PATH
- match_pattern =
$.mcp.finalAnswer - action = SHORT_CIRCUIT
🔟 ResponseResolutionStep
Uses ce_response:
🗄️ ce_response
Final output configuration
- state_code
- intent_code
- response_type (EXACT / DERIVED)
- output_format (TEXT / JSON)
Two valid patterns
- Rules SHORT_CIRCUIT
- No LLM call
- Fast & safe
- derivation_hint checks mcp.finalAnswer
- LLM formats response
🐞 Debugging: stale move ID bug
If you see:
Second request returns previous move result
The cause is almost always one of:
- MCP context not cleared early enough
- derivation_hint blindly reusing mcp.finalAnswer
- Tool param extraction failed and reused old params
✅ Final takeaway
ConvEngine MOVE flow is:
- Deterministic
- Auditable
- DB-driven
- MCP-powered
- Safe when configured correctly
If you understand this file, you understand the MOVE engine end-to-end.