Architecture Overview
How Eunice is put together: the chat-first design, the tool dispatch system at its core, module layout, dependency injection, and the Display Protocol.
This document describes the conceptual architecture of the Eunice platform, how components are organized, how they communicate, and why they are wired this way. Source paths are repo-relative.
Platform Shape
Chat-first by construction
Every capability funnels through the chat interface. A user message lands at /api/v1/assistant/... (router registered in app.py:504 from engine/orchestrator.py), the orchestrator runs an agentic loop, tools execute, and a StreamingResponse of Server-Sent Events flows back. Sidebar pages, Predict, Knowledge, Insight, Fabric, Apps, Skills, Entities, Approvals, Track, Telemetry, FinOps, Admin, are views over the same data, not parallel feature surfaces.
The cardinal three-layer split: the LLM decides what to do, the platform resolves how, the tool executes. The LLM never executes code directly. The platform never picks a tactic. Tools never know which model invoked them.
Everything is a tool
engine/tool_registry.py:ToolRegistry is the only true infrastructure. LLM calls, document generation, embeddings, RAG search, Excel export, corpus management, workflow execution, spatial joins, all are registered tools dispatched through one call site:
result = await registry.execute_tool(name, inputs, context)
New capabilities are tools, not standalone functions bolted onto a router. This is what makes them composable, auditable, and discoverable by the assistant at runtime (search_tools is itself a tool).
Self-extension
Two paths grow the registry without a deploy:
workflow/factory.py:PrimitiveFactory, write → test → verify loop. Takes a description and I/O schema, generates Python with thetool_build_*model role, exercises it against real data, registers as aPrimitiveContractif postconditions hold.- The
build-toolplatform skill (skills/platform/build-tool/):engine/tool_builder.py(as thewrite_tool_codetool) writes a candidate,review_tool_candidateruns two adversarial reviews on thetool_auditmodel, andtools/register_tool.pyis the one approver (engine/tool_approval.py: evidence validator, test adapter, test authorization).
Both are bounded by the same registry contract everything else uses.
Module Map
app.py is the FastAPI entry point: it bootstraps logging, runs production safety checks (app.py:90-115 reject default JWT secret or dev bypass on a non-loopback bind), wires the DI container in lifespan(), registers every module’s router, and exposes liveness/readiness/version probes (/api/v1/health, /ready, /version).
| Package | Responsibility |
|---|---|
engine/ | Orchestrator, agentic loop, tool registry, dispatcher, prompt builder, session state, turn types |
tools/ | Tool handler implementations grouped by domain |
conversations/ | Conversation entity CRUD, access control |
knowledge/ | RAG pipeline: extraction, chunking, embedding, vector search |
predict/ | ML training, advisor, SHAP explanations |
insight/ | Document classification, schema-based and schemaless extraction |
workflow/ | Mostly retired (2026-07). Only factory.py (PrimitiveFactory) and seed_primitives.py survive; the assembler, semantic validator, and run engine are gone. Primitive execution moved to skills/primitive_runtime.py. |
skills/ | SKILL.md capability layer + primitive_runtime, the successor to the retired Workflow feature |
fabric/ | Catalog, Kits, temperature, relationship graph, dream cycle, user profile |
admin/ | Model configuration, MCP server management, tenant settings |
infrastructure/ | AsyncExecutionService and executor backends (thread pool today) |
shared/ | Cross-cutting infrastructure (see Shared Layer) |
api/ | Downloads, chart/map snapshots, bookmarks, small REST islands tied to artifacts |
frontend/ | React/TypeScript UI (Vite) |
Modules register their routers with app.py at startup. They are not supposed to import each other; cross-module communication goes through the DI container or the tool registry. The actual codebase is mostly faithful to this, engine/orchestrator.py is the largest violator, and its in-progress decomposition (see Engine) is intentionally peeling out the contract surface.
Dependency Injection
Wiring is dependency-injector-based. shared/container.py:CatalystContainer declares every provider as a providers.Singleton placeholder; shared/container_config.py:wire_phase_1a swaps in concrete implementations during lifespan() startup (app.py:126).
Protocols (shared/protocols.py)
Typed interfaces define what a service does, not how. Implementations are swappable per-environment without touching call sites.
| Protocol | Implementation wired | Notes |
|---|---|---|
TextExtractor | knowledge/extractor.py:PyPDFTextExtractor | Used by RAG ingest |
DocumentChunker | knowledge/chunker.py:StructuralChunker | RAG chunking |
EmbeddingGenerator | shared/embeddings.py:BGEEmbeddingGenerator | Local BGE model |
VectorSearcher | knowledge/search.py:PgVectorSearcher or SQLiteVectorSearcher | Dispatch on shared.database.is_postgres() |
DataAnonymizer | shared/anonymizer.py:PresidioAnonymizer | PII strip before external LLM calls |
CostTracker | shared/finops.py:SQLCostTracker | Writes usage_log |
LLMSynthesizer | implemented by RAG path | Synthesizes answers from retrieved context |
Other singletons wired in container_config.py (no PEP 544 protocol but injected by the same machinery): CatalystLLM, KnowledgeService (gatekeeper for all embedding-backed indexing, nothing outside it imports the raw embedder), ModelTrainer, ModelAdvisor, ToolRegistry, ToolBuilder, MCPManager, EmailService, NotificationService, AsyncExecutionService (backed by infrastructure/backends/thread_pool.py:ThreadPoolBackend(max_workers=8)).
Why cost tracking is a first-class protocol. Every LLM operation has a cost, and cost is a constraint the user should be able to see and govern. Making cost a protocol means it is applied consistently across every call and can be swapped for a mock in tests without touching call sites. The platform never silently refuses an operation because of cost, the user always has the final say.
Container injection into tools and routers
Routers and tool modules expose set_container(container); lifespan() calls each one explicitly after wiring (app.py:128-151). Tool modules that need DI services (tools/knowledge.py, tools/utility.py, tools/mcp.py, tools/prediction.py, tools/documents.py, tools/factory_tools.py) follow the same pattern. Direct cross-module imports are avoided.
The Tool Dispatch Boundary
ToolRegistry.execute_tool(name, inputs, context) (engine/tool_registry.py) is the single dispatch point. It looks up the handler (system, custom, or MCP-bridged), invokes it, awaits if the handler is a coroutine, and returns a ToolResult dataclass (engine/tool_result.py) carrying content, cards, cost, datasets, exports, error.
Underneath, handler return shapes still vary, ToolResult for the majority, legacy dict ({"message", "cards", "metadata"}) for a fraction, and a couple of async def outliers, and ToolResult.from_any() (tool_result.py:42) normalizes the inbound shape. What changed is that the Uniform Tool Contract (below) now sits at the dispatch boundary and presents one shape to the model regardless of what the handler returns.
Uniform Tool Contract
The Uniform Tool Contract is the structural rewrite of this boundary, and it has landed on this branch. It is applied at the dispatch boundary (engine/tool_registry.py:_apply_contract wraps every handler that has a registered ToolSpec or is source-decorated; envelope_to_tool_result adapts back to ToolResult), so a migrated tool returns one of three named result variants rendered into a single textual format the model sees identically at every tool boundary. The deferred baseline is empty, but an empty baseline is not full coverage: two newer tools are registered nowhere and bypass the contract at dispatch, and the “only-shrinks” ratchet test is currently failing both for that reason and because its own discovery of migrated tools is hand-maintained rather than derived. Full coverage is the invariant the ratchet exists to enforce; compute the live migrated/deferred split from the registry rather than trusting a count.
Three result variants, engine/contract.py:
| Variant | When | Carries |
|---|---|---|
Ok | success | rows, schema: list[ColumnSpec], data, warnings: list[Warning], effects: Effects |
Hint | recoverable correction (schema / fetch / ambiguous) | message, alternatives: list[dict] |
Err | actual bug / infra failure | message, err_kind |
Hint is the load-bearing addition. “Did you mean STATE_CODE?” or “this source requires a filter” used to be either a ToolResult.error (looked like a platform bug) or free-text content (looked like success). A Hint is neither, the test harness counts it as a recoverable correction rather than an error, the model can recover by re-calling with the supplied alternatives, and the model never has to learn each tool’s idiosyncratic recovery vocabulary.
Effects are typed side-effect channels, cards, datasets, exports, cost, that ride on Ok.effects and are consumed by the orchestrator, never text-rendered. Hint and Err carry no effects. Whether a tool’s cost is a rollup (delegate / delegate_batch) is a ToolSpec.is_cost_rollup attribute on the registry, not a per-result flag, so leaves can’t be double-counted.
Dispatch path under the contract:
- Handler module imports stamp
register_tool_spec(ToolSpec(name, handler, is_tabular, is_cost_rollup))and either source-decorate with@uniform_contractor use the bulk-migration helpermigrate_tool(handler, name, ...)which leaves the handler unchanged and lets the registry wrap it at registration. ToolRegistry.register_system_handlercalls_apply_contract(name, handler): if the handler has a spec and isn’t already decorated, wrap it. Idempotent.execute_toolpopulatescontext["_tool_spec"]andcontext["_current_tool_name"]before invocation so the decorator can read them at call time.- The decorator (sync and async variants chosen by
asyncio.iscoroutinefunction) runs the handler intry/except, catchesHintExceptionfrom the resolver layer (engine/contract_resolvers.py) and translates toHint, catchesExceptionand translates toErr, runs_validate_envelope(warns ifis_tabularand schema is empty), forwards acontract_envelopetelemetry event, and persists effects. - A compatibility adapter
envelope_to_tool_resulttranslatesOk/Hint/Errback toToolResultso the rest of the orchestrator path runs unchanged during the migration.
Why bulk migration is safe. Decorating handlers at the source breaks every direct caller (tests, primitives that call other tools, etc.). migrate_tool returns the handler unchanged and wraps at the dispatch boundary, the contract takes effect when the registry invokes the tool, not when the source module imports.
Knowledge-is-routing composition. Catalog/namespace fuzzy resolution inside resolve_dataset / resolve_catalog_entry queries the data_catalog system corpus via the platform’s VectorSearcher, with the local-embedder name cosine as fallback for namespace variables. The contract’s resolvers and search_catalog use the same retrieval path and never disagree about what the catalog contains.
Migration ratchet. tests/contract_deferred_baseline.json is a CI snapshot of the set of unmigrated tools, computed from the registry by absence of the _uniform_contract marker. The baseline can only shrink. New tools must not be added to the deferred set.
Companion contracts. Two adjacent design decisions compose with the Uniform Tool Contract by defining what Ok.data looks like for data-acquisition tools (a {catalog_ref, modality, schema, intelligence, sample, size} envelope with per-column rendering rules, geometry/JSON/vector/binary/long-text become metadata blocks, not cell values) and by treating silent analyze_data save-failures as Hints instead of no-ops.
The Agentic Loop and Turn Lifecycle
A “turn” is one iteration: the assistant reads conversation state, calls the LLM with a streaming request, executes any tool calls the LLM emits, persists results, and decides whether to continue.
Where the code lives
The agentic loop lives in engine/executor.py:_execute_agentic_turns (moved out of engine/orchestrator.py in the orchestrator refactor). That refactor decomposes the former 891-line shell across:
| Module | Role |
|---|---|
engine/runner.py | Top-level loop shell; builds TurnState + TurnConfig at request entry; reads cancellation signal each turn; populate_state_from_persistent_store mirrors module-dict state onto typed fields |
engine/executor.py | Per-turn execution path; streaming LLM call; tool-gap detection; emits done |
engine/dispatcher.py | dispatch_tool_call / dispatch_tool_calls; policy-rule checks; failed-approach tracking; tool execution via AsyncExecutionService |
engine/persister.py | Assistant message persistence, writes the chat_messages row with structured_content |
engine/turn_types.py | TurnState, TurnConfig, TurnResult, TurnError, ToolRuntime, Effect catalog, Clock/IDProvider protocols |
engine/state.py | Cancellation signals, per-process state container (post-extraction home) |
engine/effects.py | apply_effect, the single chokepoint that writes to the real recorder, display, and persister |
engine/effect_accumulator.py | _RecordingRecorder / _RecordingDisplay proxies that build typed effects and route through apply_effect |
engine/session_state.py | Ephemeral in-memory per-conversation state: active plan, task stage, failed approaches |
engine/prompt_builder.py | _build_system_prompt and dynamic state blocks |
The orchestrator file remains as a thin shell that re-exports the underscore-prefixed names so legacy import sites (from engine.orchestrator import ...) keep working without churn. New code should import from the focused modules directly.
Typed effects and the apply_effect chokepoint
Pre-refactor, the loop body interleaved recorder.record(...) calls inline with stream handling, DB writes, and tool execution, a single function with five module-level state dicts and effects fired wherever convenient. A streaming bug could break telemetry; a telemetry fix could break persistence.
Today, three closed Effect kinds (engine/turn_types.py:259-313) describe every side-effect a turn produces:
| Effect | What it represents |
|---|---|
PersistAssistantMessage | Write the chat_messages row at end of turn |
TelemetryEvent(event_type, data, blob) | Append to conversation_events; subsumes earlier behavioral-observation effect (event_type="behavioral_observation") |
StreamChunks(chunks: list[SSEChunk]) | All SSE chunks the turn produced, batched |
The recording proxies wrap the real EventRecorder and DisplayHandle. Every recorder.record(...) call:
- Builds a typed
TelemetryEvent/SSEChunk. - Appends it to the shared effects list.
- Calls
apply_effect(effect, ctx)(engine/effects.py), the single chokepoint that writes to the real backends.EffectContext(recorder, display, persister)carries the live services;_SSE_DISPATCHmaps event names to display methods;_apply_telemetry_eventhandles thetext_deltabatching path;_apply_persistdrives the persister callable.
Net runtime behavior is byte-identical to the pre-refactor path, but tests can mock apply_effect, replay can drive it with synthetic effects to reconstruct a run, and audit can inspect effects independently of when they were created.
Per-turn flow
- Runner reads cancellation signal and sets
state.cancelled(precondition input, not effect, seeTURN_CONTRACT_SPEC.md). - Runner builds
TurnState+TurnConfigat request entry;populate_state_from_persistent_storemirrors_conversation_namespaces,_session_states,_conversation_tool_historyonto typed fields. Read-through wiring today, the executor still reads and writes the module dicts directly; a follow-on PR migrates consumers field by field. - Executor streams the LLM call, emitting
text_delta/thinkingchunks via the display protocol. - Dispatcher runs realtime policy checks (
_check_policies_realtime), computesargs_hashforstate.failed_approaches, populates_tool_speconcontext, invokes the handler viaAsyncExecutionService. - (Post-contract) Decorator catches
HintException/Exception, validates the envelope, firescontract_envelopetelemetry before_persist_effects. The registry adapter translates the envelope back toToolResult. - Loop continues until
state.end_reason != "in_progress". Termination:end_turn | max_turns | cost_budget | cancelled | error.
Circuit breakers
| Mechanism | Default |
|---|---|
| Agentic turn limit | max_agentic_turns=75 (configurable) |
| Per-delegate turn limit | max_turns=10 (overridable per task) |
| Per-delegate cost budget | $1.00 (overridable per task) |
| Per-tool execution timeout | 300s default |
| Parallel sub-agents | Max 10 per delegate_batch |
| Cancellation check | Every turn (engine/state.py:is_conversation_cancelled) |
| Context size guard | Estimated token count before each LLM call |
Display Protocol
The display protocol (shared/display.py) is the output layer between tool execution and the SSE stream. It is modeled on Jupyter’s IOPub messages: emitters describe what happened, the frontend decides where and how to render.
| Component | Role |
|---|---|
DisplayEvent | Wire format, event_type, data, channel, scope, display_id, verb, priority, persistent, metadata |
DisplayHandle | Scoped, fire-and-forget interface tools call (status, busy, idle, progress, thinking, text, card, error, warning, cost) |
DisplayFactory | Owns the event queue; creates handles; drains as SSE; bridges telemetry |
ChildDisplayFactory | Writes to a parent factory without closing it, a nested-scope helper left from the retired workflow-node model; no live call sites today |
Event types and channels
EventType constants (shared/display.py:102): text_delta, text_complete, reasoning, tool_call, tool_result, busy, idle, status, progress, card, error, warning, cost, done.
Channel (shared/display.py:Channel): response (main chat bubble), thinking (steps tab), status (status bar), artifact (side panel), system (errors/warnings/cost).
Verb (shared/display.py:Verb): append, update, replace, clear, controls how the frontend mutates rendered output for a given display_id.
Priority (shared/display.py:Priority) orders events when multiple compete: IDLE=0, LOW=5, NORMAL=10, IMPORTANT=20, URGENT=30.
Session-scoped ambient context
DisplayFactory is session-scoped via Python contextvars (shared/display.py:52). Tools call get_display_factory() to obtain the ambient factory; they don’t receive it as a parameter. Delegated sub-agents and primitives all produce output, without ambient infrastructure, every producer would have to thread an explicit display parameter through call chains. This is what makes the chat-first design practical at depth: any code path can reach the output channel without knowing anything about the HTTP layer.
Telemetry bridge
DisplayFactory.drain() is the SSE generator, it IS the StreamingResponse body. As events drain, _record_event forwards persistent events to the EventRecorder (shared/telemetry.py). Transient events (progress, busy, idle) are skipped from telemetry, they exist for UX, not audit. text_delta and reasoning are batched via record_text_delta / record_thinking_delta (~500 chars per row) so a 100-chunk stream lands as a handful of rows, not a hundred.
drain() emits both new-format (event: display) and legacy SSE event types (event: text, event: thinking, event: card, event: status, etc.) for backward compatibility during the frontend migration. The legacy emitter is in _format_legacy_sse; it should go once the frontend fully consumes the new protocol.
Telemetry
shared/telemetry.py:EventRecorder accumulates events in memory and flushes in a single batch INSERT at stream end, zero DB writes during streaming. Payloads above BLOB_THRESHOLD = 32 KB (shared/telemetry.py:23) are stored in conversation_event_blobs with a foreign-key reference from conversation_events.
The event log is immutable and never truncated. Display truncation (thinking-stream preview length, tool-input preview in the legacy SSE shape) is a separate, UI-only concern. There is no setting that affects what gets written to conversation_events.
The full replay surface is exposed at GET /conversations/{id}/replay and per-message at /conversations/{id}/messages/{msg_id}/events. The dedicated /telemetry page renders these with type filtering, search, and a full-JSON drill-down.
Persistence & Data Architecture
PostgreSQL is the database in every deployed environment (postgresql+psycopg://localhost/eunice locally, RDS in production), and the platform’s hybrid keyword-plus-vector retrieval runs only there. A development fallback backend still exists for local work, but she refuses to boot on it: the default is PostgreSQL and a startup guard exits on any other backend unless CATALYST_ALLOW_NON_POSTGRES_BACKEND is set deliberately. All schema changes are surgical SQL (UPDATE, targeted INSERT, targeted DELETE). The database is never dropped.
Key table groups
| Group | Tables |
|---|---|
| Auth | users, tenant_memberships, role_capabilities |
| Tenants | tenant_config, tenant_model_overrides |
| Conversations | conversations, chat_messages |
| Telemetry | conversation_events, conversation_event_blobs |
| Catalog | catalog_entries, catalog_entry_types, dataset_activity |
| Knowledge | documents, chunks, corpora |
| Tools | tool_definitions, tool_jobs, tool_execution_log |
| Workflows (retired) | workflows, workflow_runs, workflow_queue, workflow_app, legacy rows retained per the no-DB-deletion rule; nothing writes to them |
| Primitives | primitives |
| Insight | taxonomies, extraction_schemas, processing_results, data_domains |
| Kits | kits, kit_memberships, execution_snapshots |
| Relationships | relationship_edges |
| Behavioral | observations, policy_rules, routing_rules |
| External | datasources |
| Notifications | notifications, notification_receipts |
| Spatial | geojson_blob_refs |
| Config | model_config |
| MCP | mcp_connections |
| FinOps | usage_log, budget_alerts |
| Eval | eval_runs |
| Files | generated_files |
| System | seed_log |
Tombstone, never delete
Catalog entries are never hard-deleted; they receive a deleted_at timestamp. This preserves conversation replay integrity. All queries filter WHERE deleted_at IS NULL by default.
ConversationCatalog
Each active conversation maintains an in-memory ConversationCatalog, a typed registry of every dataset and document the conversation has access to, backed by catalog_entries. Tools look up datasets by name or ID through the catalog; they do not hit the filesystem directly. Scoping enforces the access boundary: if it is not in the catalog, it does not exist from the tool’s perspective. See Fabric module and Glossary for the catalog model.
Multi-Provider LLM Support
Four providers (anthropic, openai, google, deepseek) sit behind a single canonical-message interface in shared/llm.py. DeepSeek is OpenAI-compatible and reuses the OpenAI SDK with a different base_url.
Role-based selection
The model_config table maps abstract roles to concrete models. Code never references a model ID directly.
| Role | Use |
|---|---|
chat | Primary assistant conversations |
fast | Classification, intent, short-form extraction |
strong | Complex synthesis, code generation, detailed reports |
compaction | Conversation history compression |
embedding | Vector embedding generation |
tool_build_simple | Simple primitive/tool code generation |
tool_build_complex | Multi-step tool generation |
tool_audit | Validating generated tool code |
peer_review | Independent review of assistant answers |
kit_curator | Kit naming/description during dream cycle |
create_llm_client_for_role(role) reads model_config, resolves provider, model ID, and API key environment variable, and returns a provider-specific client.
Choosing a role for call_llm: fast when speed matters more than depth; strong when quality matters more than latency; chat is the default. tool_build_* is reserved for the factory and builder agents.
Token limits and capabilities
Token limits come only from model_config.max_output_tokens via get_model_for_role(). They are never hardcoded. The capabilities JSON column carries feature flags (web_search, vision, structured_output). Capabilities filter tools (native web search suppresses the platform web_search tool). Filtering is DB-driven via ToolDefinition.replaced_by_model_capability, applied to the resident tool surface by engine/tool_surface.py:_filter_resident_by_capabilities, the old hand-maintained _CAPABILITY_TOOL_MAP was deleted, and there is no capability prompt-guidance layer.
Authentication & Authorization
shared/auth.py defines RequestContext and the @requires(capability) decorator. shared/capabilities.py enumerates capability strings and role mappings. shared/auth_router.py exposes register, login, refresh, me, switch-tenant.
app.py:auth_middleware (app.py:320-476) validates JWTs, resolves user/tenant/capabilities, and sets the TenantContext and RequestContext context vars per request. Tenant selection precedence: JWT defines reachable tenants; X-Tenant-ID header selects the active one; a header picking an unowned tenant returns 403; in the absence of a header, the first membership wins.
System roles (on User): system_admin, security_auditor. Tenant roles (on TenantMembership): admin, member, viewer. Endpoints gate on capability strings, not role checks.
Dev bypass. CATALYST_AUTH_DEV_BYPASS=true accepts unauthenticated requests as a seeded dev user with system_admin capabilities and accepts expired tokens to preserve identity across long sessions. Production safety checks in app.py:90-115 refuse to start with the default JWT secret when bypass is disabled, and refuse to start with bypass enabled on a non-loopback bind, a network-exposed system_admin was the audit’s worst-flagged misconfiguration.
System Prompt Architecture
engine/prompt_builder.py:_build_system_prompt() assembles the system prompt dynamically per-conversation in layers:
- Base identity (
CATALYST_SYSTEM_BASEinshared/prompts.py), persona, output contract, data-first behavior. - Task decomposition, inspection vs retrieval vs analysis vs action.
- Resilience rules, diagnose failures before switching tactics; partial results beat no results.
- Tool guidance, dynamically generated from the registered tool list filtered by model capabilities.
- Model capabilities, injected from
model_config.capabilities. [AVAILABLE_DATA], loaded datasets with variable names, corpus IDs, document IDs, trained models, MCP connections.[SESSION_STATE], active plan status, task stage, failed approaches, created datasets (engine/session_state.py).[USER_CONTEXT], compact behavioral observations about how this user works (fabric/user_profile.py).
All multi-line prompt strings live in shared/prompts.py. Inline multi-line prompts in module code are an architecture violation.
Deployment Shape
app.py is a single FastAPI process. lifespan() (app.py:118) drives the order-of-operations every developer should know:
init_db(), create tables idempotently.wire_phase_1a(container), swap singletons.set_container(container)on every router and tool module.- Wire
AsyncExecutionServiceintoNotificationServicefor future external delivery. - Reload persisted ML models from disk via the trainer.
seed_tools(), populatetool_definitionsfromengine/tool_definitions.py:SYSTEM_TOOLS.- Backfill
document_idinto chunk metadata if needed (idempotent, fast). - Auto-connect enabled MCP servers (stdio or streamable HTTP); persist
connected/errorstatus onmcp_connections.
Shutdown: drain AsyncExecutionService (30s timeout) and close MCP connections.
Background work runs out-of-process. Dream cycle and the datasource probe loop run as python scripts/background_jobs.py --loop, kept out of the FastAPI process so embedding-model load (~1 GB) and ~70 HTTP probes don’t sit on the request path. See Deployment.
Operational endpoints. /api/v1/health (liveness), /api/v1/ready (DB connectivity), /api/v1/version (release / SHA / branch / built-at) are intentionally public, canary checks, liveness probes, and on-call dashboards poll them without credentials. Version metadata is resolved in priority order from CATALYST_* env vars, build_info.json, then live git invocation in dev.
Middleware order matters. CORS first, gzip second (minimum_size=1024, large /fabric/datasets/{id}/data payloads can run hundreds of MB; gzip compresses 5-10× on tabular JSON), then auth middleware. The catch-all exception handler pushes a deduped high-priority alert via shared.push before returning a 500.
Known Tensions and Structural Debt
These are real and load-bearing, don’t paper over them when working in the area.
engine/orchestrator.py is still the legacy shell
891 lines today, down from 3,330. The decomposition into runner.py, executor.py, dispatcher.py, persister.py is functionally complete, but the orchestrator file still re-exports underscore-prefixed legacy names for backward compatibility with import sites inside _execute_agentic_turns and external callers. Several module-level state dicts (_conversation_namespaces, _session_states, _conversation_tool_history) remain the canonical storage; runner.populate_state_from_persistent_store is a read-through wiring step that mirrors them onto TurnState. A follow-on PR migrates each consumer field-by-field and drops the dicts. Until then, the typed state is a typed view, not the source of truth.
Two tool-result envelopes coexist during contract migration
The dispatch boundary today is ToolResult (and legacy dict for some handlers). The Uniform Tool Contract introduces Ok | Hint | Err; envelope_to_tool_result adapts back to ToolResult so the rest of the orchestrator runs unchanged. Both shapes live in the tree for the duration of the contract migration. Deletion of engine/tool_result.py and the compatibility shims is conditional on the deferred set going empty.
Tool capability filtering deviates from “LLM decides”
When a model has a native capability (e.g., built-in web search), the platform removes the corresponding tool from the registered list for that conversation. This is a pragmatic call: offering both the native capability and a tool for the same function confuses models. The platform is making a decision the LLM could, in principle, make. The rule lives in the database (ToolDefinition.replaced_by_model_capability) and is applied by engine/tool_surface.py:_filter_resident_by_capabilities; the earlier hardcoded _CAPABILITY_TOOL_MAP was removed.
Catalog is a registry, not a store
ConversationCatalog is an index. Actual data lives in Parquet on disk and chunks rows in PostgreSQL. If backing stores diverge from catalog entries (e.g., a Parquet file deleted without going through the catalog API), tools will fail. New data-producing tools must register catalog entries before returning results, not after.
Legacy SSE shape is still emitted
DisplayFactory.drain() emits both event: display (new protocol) and legacy event types (event: text, event: thinking, event: card, etc.). The legacy emitter exists for frontend backward compatibility during the display-protocol migration and should be removed once the frontend fully consumes the new protocol.
Cross-References
- Engine, Orchestrator, dispatcher, registry, prompt builder
- Tools, Tool handler inventory and conventions
- Shared Layer, DI container, LLM, telemetry, finops, auth
- Fabric Module, Catalog, Kits, dream cycle, user profile
- Skills Module, SKILL.md authoring, discovery, and script/primitive execution (successor to the retired Workflow feature)
- Knowledge Module, RAG pipeline
- Database Schema
- Runtime Flow
- Tool System
- Deployment
- Glossary