The engine, explained.
This page is for the people who will be asked "is this real?" Observatory is a .NET 10 application: 226 C# source files, 1,600+ passing tests, and a decision log that explains why each piece is shaped the way it is. What follows is how it actually works. Bring your engineers.
.NET 10, C#, SQLite. Deliberately boring.
Observatory is ASP.NET minimal APIs on .NET 10, with SQLite for storage: one private database per bank, plus a shared store for public FDIC-derived data. No exotic infrastructure. No Kubernetes requirement. It deploys as a single binary a bank's IT team can run and audit.
The codebase began as a Python prototype. The production system was rewritten in C#. That was a choice, not an accident. The rewrite bought compile-time type safety across every data boundary, a test harness that runs 1,600+ tests in seconds, and a stack DCI's own engineers can own after handoff, because it matches the tooling banks and their core providers already run. A prototype proves an idea. A rewrite in a typed language, under tests, is how the idea becomes infrastructure.
Not a chatbot. A loop.
When a banker asks a question, no model free-associates an answer. A pipeline runs, and every step is a separately testable component.
Assemble
AgentContextService builds layered context before each answer: bank profile, recent conversation, prior questions, behavioral patterns, peer data. The model never starts cold.
Pick the model
AgentRouter chooses per request. A balance lookup does not need a frontier model. A judgment call about concentration risk does. Cheap for retrieval, frontier for reasoning.
Call, don't guess
The agent answers by calling named, read-only tools from a static allowlist. Every number it cites came from a scoped tool call that is logged.
Check the numbers
FigureVerifier checks figures before they reach the screen. GrowthAnomalyDetector flags outliers over 100 percent YoY and triggers a proactive web search: plan, search, read, report.
The loop is split across AgentService components for the loop itself, context, streaming, and outcomes, each independently testable. That decomposition is why the test count is real coverage, not padding.
Private and public intelligence never share a store.
Observatory holds two fundamentally different kinds of data, and the architecture refuses to mix them. The reason is not tidiness. They have different security classifications, different query patterns, and different update cadences. Keeping them apart makes compliance review straightforward and the code honest.
One SQLite database per bank
Keyed by FDIC cert number. Holds what a banker tells the agent: conversation history, behavioral patterns, session memory.
- Bank A's database is never opened by Bank B's process
- A breach exposes one bank, not the fleet
- In scope for GLBA
- Written every conversation turn
Shared FDIC-derived relationships
Asset similarity, market overlap, shared core platforms, growth alignment. Queried across banks by design, because that is its job.
- A breach exposes nothing not already on the FDIC website
- Cross-bank lookups are the intended pattern
- Out of GLBA scope
- Refreshed on the quarterly FDIC cadence
A test enforces the boundary: the similarity service is verified to have no dependency on the behavioral store. The decision, including the alternatives that were rejected and why, is written down as ADR-029.
The model never sees a customer's name.
Customer names, account numbers, officer names, and addresses are nonpublic personal information under GLBA. Before any bank data reaches the language model, a tokenization layer replaces every one of those fields with a stable per-bank token. The model reasons over CUST-A1B2, not a real person. On the way back, the narrative is de-tokenized so the banker who is entitled to see the name sees it, and the model never did.
Tokenize before context
Names, accounts, officers, and addresses become stable per-bank tokens. Dollar amounts and ratios pass through, because the model needs the financial context to reason.
- Tax identifiers are stripped entirely, never tokenized
- Tokens are scoped per bank, so the same name differs across banks
- Misconfiguration fails closed to tokenize, never open
De-tokenize before the screen
The final narrative is restored to plaintext for the authorized banker only, with the same lookup and audit path whether the answer streams or arrives whole.
- Every token exchange is written to an audit log
- Streaming restores tokens split across chunk boundaries
- Config-driven per field: tokenize, strip, or preserve
The layer is built and tested, and stays off in the demo because demo data holds no real customer information. It switches on with one configuration value the day a bank connects live iCore360 data. The safety property does not depend on the model behaving: the NPI is gone before the model is ever called.
The data source is a function, not a constant.
Different banks are entitled to different data. A prospect has only public FDIC filings. An iCore360 customer has its core. A network-connected bank has cross-network signals. Observatory does not hardcode a source per screen. It resolves the richest source the bank's tier permits, through a ranked provider layer, into one canonical model per tile.
The same loan portfolio tile reads FDIC call-report data for a prospect and core-level detail for a connected bank. Upgrading a bank is a configuration change in the data layer, not a rebuild of the application.
Every response carries a provenance block stating exactly where its data came from and how fresh it is: live core data, public call-report data with its quarter, or not connected. The screen tells the truth about its own sources. Nothing renders a number it cannot attribute. This is documented as ADR-030.
Public FDIC
Call-report filings only. The same tile renders from what is public.
Core detail
The tile resolves to live core-level data for the bank that owns it.
Cross-network signals
The richest tier: peer and network intelligence layered on top.
The model is config, not architecture.
Observatory does not bet on a model vendor. All model access goes through one LLM client abstraction. Anthropic and Azure OpenAI are both implemented today, selected by an LLM_PROVIDER configuration value. Swapping providers is a config change, not a code change, and nothing above the abstraction knows the difference.
This matters for two reasons. Model economics: the router uses inexpensive models for lookups and frontier models for judgment, and as the market shifts, the routing table shifts with it. And longevity: the guardrails, the read-only tool boundary, the verification step, the per-bank isolation, live in the application, not in the model. A better model arrives, you swap it in, and every safety property survives, because none of them were ever the model's job.
abstraction
Tests named after the breach they prevent.
The test suite is not a quality metric here. It is the verification infrastructure that makes it reasonable to put an AI agent near bank data at all.
Sentinel tests are named after the failure they guard against, so a developer reading a red build knows exactly which guarantee just broke. Isolation tests prove that one bank's data never surfaces in another bank's answer, the boundary asserted, not assumed. Behavioral contract tests pin the agent's reasoning to expected outputs, so a model or prompt change that quietly degrades an answer fails the build instead of the demo.
Anyone can point a model at bank data. The hard part is the verification underneath that catches a wrong number before a banker ever sees it. That is what they are for.
Every major decision has an ADR.
When a DCI engineer wonders "why does it work this way," there is an answer on file, including the alternatives that were considered and rejected. That is uncommon in a system at this stage, and it is the difference between inheriting a codebase and inheriting a codebase you can reason about.