Skip to content
← How we build

architecture

Domain Models Design — Internal Application Contracts

This layer formalizes the internal contracts (RequestContext, RoutingDecision, SkillRequest, SkillResult, ToolResult, LLMRequest, LLMResponse) that every other design doc describes in prose, as an explicit, typed Models layer, replacing implicit dict-passing between components with Pydantic models. It does not introduce business/financial entities (Company, FinancialStatement, SECFiling, NewsArticle) — those remain out of scope until the data those entities represent actually enters the system in Phase 2–3. The Router is not a consumer of RequestContext (it scores the raw query — routing-design.md §2), and RequestContext is not always produced by the Context Builder (the Orchestrator constructs it inline for the high-confidence branch; only the low-confidence/no-route branch goes through the Context Builder). Phase 2 adds two fields, both additive, no interface change: RequestContext.user_id (mirrors the new AgentContext.user_id introduced by the Auth Foundation milestone, per security-design.md §4) and RequestContext.memory_items: list[MemoryItem] (populated by the Context Builder on the fallback branch, per memory-design.md §6). This is exactly the additive evolution §8's forward-compat table already anticipated — no model needed a shape-breaking change to accommodate it.


1. Why This Layer Exists

Every design doc up to Revision 2 already had implicit contracts — RoutingDecision, SkillResult, ToolResult were described as structured shapes in prose tables, but nothing in the architecture said where those shapes are defined or who owns them. In practice that means each layer's Python implementation would be tempted to pass plain dicts across boundaries, because there was no shared, importable definition to pass instead. That's a real maintainability risk once more than one person (or more than one Claude/Cursor session) is implementing different layers against the same conceptual contract — dict shape drift between the Router's output and the Orchestrator's expectations would only surface at runtime, not at review or type-check time.

This layer's entire job is to make every arrow in the architecture diagrams a real, importable Python type.

2. Placement Recommendation

Recommended: backend/src/models/, not backend/src/domain/models/.

Option Assessment
backend/src/models/ (adopted) Correctly signals "internal application contracts" — the actual scope of this layer. Flat, discoverable, matches the flat style already used by backend/src/router/, backend/src/skills/, backend/src/tools/.
backend/src/domain/models/ Rejected for Phase 1. "Domain" carries a specific meaning in DDD — business/financial concepts like Company, FinancialStatement, SECFiling. Naming this layer domain/ now, while explicitly not modeling those entities yet (per this task's own constraints), would mislabel the layer and create false expectations about what lives there.

Deferred, not rejected: when Phase 2–3 introduce real financial/business entities, backend/src/domain/ becomes the right name for that new layer — distinct from backend/src/models/, which stays scoped to inter-layer application contracts. At that point the relationship is: models/ = "how layers talk to each other," domain/ = "what the business is actually about." Keeping them separate from the start avoids a rename/relayout later.

backend/
└── src/
    └── models/
        ├── __init__.py
        ├── context.py     # RequestContext
        ├── routing.py      # RoutingDecision
        ├── skills.py        # SkillRequest, SkillResult, Citation
        ├── tools.py           # ToolResult
        └── llm.py              # LLMRequest, LLMResponse

Each file's ownership maps directly onto an existing architectural layer, so "which file does this model live in" is never ambiguous: the file is named for the layer that produces the model, not the layer that consumes it.

3. Initial Phase 1 Models

All models are Pydantic (BaseModel), matching the stack already in use (pydantic, pydantic-settings per pyproject.toml).

models/context.py

class RequestContext(BaseModel):
    user_query: str
    session_id: str | None
    trace_id: str
    user_id: str | None = None                              # None until Auth Foundation milestone; populated on both branches once it lands
    conversation_history: list[ConversationTurn] = []         # [] on high-confidence branch always; Phase 2+ populated on fallback branch
    retrieved_context: list[RetrievedItem] = []                 # [] on high-confidence branch always; Phase 2+ populated on fallback branch
    memory_items: list[MemoryItem] = []                            # new (Revision 5) — same population rule as retrieved_context

class ConversationTurn(BaseModel):
    role: str            # "user" | "assistant"
    content: str
    # Phase 1: type exists, never populated (ContextBuilder is a no-op). Phase 2+: populated by MemoryService via the Context Builder, fallback branch only.

class RetrievedItem(BaseModel):
    source: str
    content: str
    score: float
    # Phase 1: type exists, never populated. Phase 2+: populated by rag/retriever.py via the Context Builder, fallback branch only.

class MemoryItem(BaseModel):
    content: str
    created_at: datetime
    # New (Revision 5). Phase 2+: populated by MemoryService.load() via the Context Builder, fallback branch only. See memory-design.md §6.

Produced by (Revision 4): the Orchestrator, either inline (trivial wrap, high-confidence branch) or via ContextBuilder.build() (low-confidence/no-route branch — no-op in Phase 1, real LangChain retrieval + memory Phase 2+). See architecture-overview.md §8, "How SkillRequest.context gets populated on each branch." Consumed by: Skills only, via SkillRequest — the Router is not a consumer (routing-design.md §2, Revision 5); it scores the raw query directly and never sees a RequestContext.

user_id (Revision 5): mirrors AgentContext.user_id, introduced by the Auth Foundation milestone (security-design.md §4, phases/phase2-implementation-plan.md). Populated on both branches once Auth Foundation lands (it's an identity fact, not a retrieval result — cheap to carry everywhere, unlike conversation_history/retrieved_context/memory_items, which stay fallback-only per ADR-006-memory-strategy.md). Its only consumer today is memory/long_term.py's user_id-scoped queries (memory-design.md §7); carried on RequestContext regardless, in case a Skill needs it for its own logging.

Revision 3 cleanup: the entities field that lived on RequestContext in Revision 2 is removed. Entities are computed by the Router (ticker/date/company extraction) and were never actually owned by the Context Builder — carrying them on RequestContext blurred ownership. They now live solely on RoutingDecision.matched_entities, where they were already duplicated. This is a genuine model-ownership improvement this revision surfaced, not just a rename.

models/routing.py

class RoutingDecision(BaseModel):
    route_name: str | None          # None when no route matched — see routing-design.md §3
    skills: list[str]              # kept as a list — see naming note below
    confidence: float
    matched_entities: dict = {}
    low_confidence: bool = False
    reasoning: str                  # kept — see naming note below

Produced by: Router. Consumed by: Orchestrator (directly) and Skills (via SkillRequest).

models/skills.py

class SkillRequest(BaseModel):
    context: RequestContext
    routing: RoutingDecision

class Citation(BaseModel):
    source: str
    excerpt: str
    # Phase 1: type exists, always []  on SkillResult.citations

class SkillResult(BaseModel):
    content: str
    citations: list[Citation] = []
    confidence: float
    metadata: dict = {}

SkillRequest is new in this revision (it didn't exist even conceptually before — Skills previously received context and routing as two separate parameters). Produced by: Orchestrator. Consumed by: Skill.execute().

models/tools.py

class ToolResult(BaseModel):
    data: Any
    success: bool
    error: str | None = None
    latency_ms: float
    source: str

Produced by: Tools. Consumed by: Skills. No ToolRequest model — see §5 for why.

models/llm.py

class LLMRequest(BaseModel):
    prompt: str
    context: RequestContext | None = None    # optional, for provenance/tracing only
    model_hint: str | None = None

class LLMResponse(BaseModel):
    content: str
    model_used: str                # e.g. "anthropic:claude-sonnet-5"
    input_tokens: int
    output_tokens: int
    latency_ms: float
    estimated_cost_usd: float
    retries_used: int = 0
    metadata: dict = {}             # e.g. {"prompt_version": "v1"}

Produced/consumed pair around LLMService.generate(request: LLMRequest) -> LLMResponse. Called by: Skills. Implemented by: LLMService.

4. Deliberate Deviations From the Requested Minimal Examples

Two fields were kept different from the exact minimal examples given, on purpose — noted here rather than silently, since blind renaming for its own sake isn't good engineering judgment:

Requested field Kept as Why
RoutingDecision.skill_name (singular) RoutingDecision.skills: list[str] Existing routes (financial_analysis) already require multiple Skills to cooperate on one request (routing-design.md). Singularizing this field would silently break that capability. The list form is a strict superset — a single-skill route is just a one-element list.
RoutingDecision.route_reason RoutingDecision.reasoning reasoning is already the established field name across every Revision 1–2 doc (routing-design.md, architecture-overview.md, observability-design.md, ADRs). It serves the identical purpose. Renaming it purely for verbatim consistency with a new request would touch a dozen files for zero functional gain and would be exactly the kind of low-value churn worth pushing back on.

Everything else in §3 — user_query, conversation_history, content/model_used on LLMResponse, and the introduction of SkillRequest/LLMRequest themselves — is adopted directly as requested, including the RequestContext.user_query rename (queryuser_query) and the LLMService field renames (textcontent, prompt_tokens/completion_tokensinput_tokens/output_tokens, LLMResultLLMResponse) propagated through llm-service-design.md.

5. Why No ToolRequest Model

The requested model list (§2 of the original ask) does not include a ToolRequest, and this design deliberately doesn't add one either, for a reason worth stating explicitly: Tools are heterogeneous by design (CompanyProfileTool takes a ticker; NewsTool takes a ticker and a date range; SECFilingsTool takes a ticker and a filing type). A single shared ToolRequest model would either need to be a loose bag of optional fields (defeating the purpose of typing) or each Tool would need its own request subtype (real value, but out of scope for "minimal Phase 1 models" and not something any Phase 1 requirement currently needs). BaseTool.call(**kwargs) stays as the input contract; only the output (ToolResult) is standardized, because every Tool's result needs to be handled uniformly by Skills (success/failure, latency, source), while every Tool's input is inherently tool-specific. This mirrors why LLMRequest does get a model — every LLM call, regardless of provider, has the same three logical inputs (prompt, context, model hint) — while Tool calls don't share that uniformity.

6. Model Flow Through the Architecture (Revision 4 — Router-first)

raw query + AgentContext
   │
   ▼
Router  (scores the RAW query — no RequestContext dependency)
   │  produces
   ▼
RoutingDecision
   │
   ├── HIGH CONFIDENCE ──────────────────────┐
   │                                          ▼
   │                              Orchestrator builds RequestContext
   │                              inline (trivial wrap, no retrieval)
   │                                          │
   └── LOW CONFIDENCE / NO ROUTE              │
                │                             │
                ▼                             │
      ContextBuilder.build(query, agent_context)
                │  produces                   │
                ▼                             │
         RequestContext                       │
      (retrieved_context populated,           │
       Phase 2+)                              │
                │                             │
                └─────────────┬───────────────┘
                              ▼
             Orchestrator ──constructs──▶ SkillRequest { context, routing }
                                        │
                                        ▼
                                     Skill
                              ┌─────────┼─────────┐
                              ▼                    ▼
                        Tool.call(**kwargs)   LLMService.generate(LLMRequest)
                              │                    │
                              ▼                    ▼
                          ToolResult          LLMResponse
                              │                    │
                              └─────────┬──────────┘
                                        ▼
                                   SkillResult
                                        │
                                        ▼
                                  Orchestrator (assembles AgentResponse)

Every arrow above is a typed model, not a dict, from the Router all the way to the Orchestrator's final response assembly. See architecture-overview.md §12/§14 for this rendered as Mermaid component and sequence diagrams (the standalone docs/diagrams/phase1-diagrams.md this section used to point to has been retired in favor of those inline diagrams).

7. Model Ownership Table

Model File Constructed By Consumed By
RequestContext models/context.py Orchestrator (inline, high-confidence branch) or ContextBuilder (low-confidence/no-route branch) Skills (via SkillRequest) — not Router
RoutingDecision models/routing.py Router Orchestrator, Skills (via SkillRequest)
SkillRequest models/skills.py Orchestrator Skills
SkillResult models/skills.py Skills Orchestrator
ToolResult models/tools.py Tools Skills
LLMRequest models/llm.py Skills LLMService
LLMResponse models/llm.py LLMService Skills

Rule going forward: a layer only imports the models it produces or consumes directly — e.g. Tools never imports models/routing.py, because a Tool never sees a RoutingDecision. This mirrors the one-way dependency rules already established in ADR-003-skills-tools-separation.md and makes them enforceable by import-boundary lint rules, not just convention.

8. Preserving Future Extensibility (Phases 2–4)

The stated goal is additive evolution — new fields, not new interfaces — and every model above was shaped with that in mind:

Phase New fields Where they land Interface impact
Phase 2 (RAG + Memory + Auth) retrieved_context (populated), memory_items, user_id — all delivered, Revision 5 RequestContext.retrieved_context (already typed as list[RetrievedItem], just populated); memory_items: list[MemoryItem] = [] (new field); `user_id: str None = None(new field, mirrorsAgentContext.user_id`) — see §3
Watchlists / saved reports (further future, not Phase 2) watchlists Would land on a user-preferences model referenced by RequestContext, not inline — still speculative, no Phase 2 milestone builds this None anticipated
Phase 3 (Rules Engine) rule_explanations, rule_evaluation_results New fields on SkillResult.metadata, or a dedicated RuleEvaluationResult model referenced by SkillResult once volume justifies it None — SkillResult's shape already has a metadata: dict escape hatch, and adding a typed field is additive
Phase 4 (Analyst Agent) analyst_workflow_state, portfolio_context A new WorkflowState model owned by the Orchestrator (since multi-step planning is an Orchestrator concern per architecture-overview.md §15), threaded through repeated SkillRequest constructions across steps None — SkillRequest already carries one RequestContext + one RoutingDecision per step; a multi-step Orchestrator just constructs several SkillRequests in sequence, optionally carrying WorkflowState inside RequestContext or alongside it

No model in §3 needs a shape-breaking change to support any of the above — every future field has an obvious, additive landing spot already implied by the Phase 1 shapes.

9. Architectural Tradeoffs

Benefits of explicit contracts vs. dictionaries

Dimension Dicts (implicit) Pydantic models (adopted)
Correctness A typo in a dict key ("confidnce") fails silently or at first access, possibly deep in a Skill Fails immediately at construction, often at type-check time before the code even runs
Self-documentation The shape of RoutingDecision lives only in prose (this doc, previously) The shape is the doc — models/routing.py is the single source of truth, importable and inspectable
Refactoring safety Renaming a dict key requires grepping string literals across the codebase, with no compiler/IDE help Renaming a Pydantic field is a standard IDE rename-refactor, and every consumer breaks visibly at the call site
Validation None, unless manually written per dict Free, via Pydantic's built-in validation — e.g. confidence: float rejects a string accidentally passed through

Impact on maintainability

High positive impact, low cost. This is the strongest-signal change in this revision precisely because it costs almost nothing (Pydantic is already a project dependency) and closes a real gap: every design doc already implied these shapes existed, so formalizing them isn't new design work, it's making existing design decisions enforceable.

Impact on testing

Skill/Tool/LLM Service unit tests (already designed to use mocked dependencies per skills-design.md §6, tools-design.md §5, llm-service-design.md §7) get a genuine improvement: a test can construct a SkillRequest(context=..., routing=...) directly with keyword arguments and get validation for free, rather than hand-building a dict and hoping it matches what the real Orchestrator would have produced. Contract tests (already planned — "every Skill's declared Tools exist in the registry") extend naturally to "every model constructed in a test round-trips through the same validation the real code path uses."

Impact on interview discussions

This is exactly the kind of decision that differentiates "built a chatbot" from "built a system." Being able to say "every boundary in this architecture is a typed contract, here's the Pydantic model, here's who owns it, here's how it evolves without breaking callers across four planned phases" is a concrete, verifiable claim — not an architecture-diagram assertion. See docs/interview/technical-decisions.md and docs/interview/interview-questions.md for how this shows up in prep material.

Is this appropriate, or over-engineering, for a portfolio AI platform?

Appropriate, with one important caveat. The test applied throughout this project's revisions (see phase1-revision-notes.md) has been: does this addition prevent a real, specific problem, or is it structure for its own sake? This layer passes that test — dict-shape drift between independently-implemented layers is a real risk in exactly this kind of project (built solo, likely across multiple Cursor/Claude sessions, over a constrained timeline), and Pydantic models are the cheapest possible fix, not a heavyweight one.

The caveat: this would be over-engineering if it were paired with premature business-entity modeling (Company, FinancialStatement) before that data exists in the system — which is exactly why the task constraints explicitly excluded that, and why this document doesn't do it. Typed internal contracts now, typed business domain later, in that order, is the appropriately-sequenced version of this idea. Doing both at once, now, would be the over-engineered version.

10. Anti-Patterns Explicitly Rejected

  • ❌ A shared dict-typed "envelope" object with optional fields for every layer's data, instead of distinct models — reintroduces the exact untyped-boundary problem this layer exists to remove.
  • ❌ Business/financial entities (Company, FinancialStatement, SECFiling, NewsArticle) added now "since we're touching models anyway" — explicitly out of scope; these belong in a future domain/ layer once Phase 2–3 data sources exist to justify their shape.
  • ❌ A ToolRequest model added purely for symmetry with LLMRequest — see §5; Tools are heterogeneous by design, and forcing symmetry here would either weaken typing (optional-everything) or add per-Tool subtypes with no current requirement driving their shape.
  • ❌ Renaming every field to match the original request's naming verbatim regardless of existing convention — see §4; two deliberate, justified deviations were kept rather than silently complying for consistency's own sake.

Source: docs/architecture/domain-model-design.md

Follow the work.

Occasional updates on SignalFoundry, MarketCompass, and what we are building at CompassFoundry Labs.

No spam. Unsubscribe anytime.