Agent-readable wiki
Fincept Terminal — Mental Model Wiki
A reader-side mental model of Fincept Terminal v4: a native C++20/Qt6 modular monolith with embedded Python analytics, an in-process DataHub pub/sub data plane, and 16+ broker adapters. The pages teach how to predict behavior, where state lives, and which rules must hold when you change code.
Pages
- The Mental Model: A Layered Modular MonolithThe simplest useful picture of Fincept Terminal — six stacked layers (Presentation → Application → Data Plane → Adapters → Infrastructure → Platform), 13 bounded contexts that publish to each other only via DataHub topics or typed events, and one Qt6 binary per OS with embedded Python for analytics. Reading this page should let you guess which directory a new feature belongs in and why cross-context direct calls are forbidden.
- How Data Moves: DataHub, Adapters, and the Python BridgeThe runtime flow that explains every screen update: DataHub is a one-fetch/many-subscribers pub/sub keyed by topic (`market:*`, `news:*`, `broker:<id>:*`…); CacheManager backs it with a SQLite TTL store on a separate physical DB; BrokerInterface and HTTP/WebSocket clients are leaf adapters that may not call services back; PythonRunner is the subprocess bridge that crosses the C++/Python process boundary for the ~1,423 analytics and data scripts under `fincept-qt/scripts/`. Trace a quote from broker WebSocket → adapter → DataHub topic → subscribed screen and you have the system.
- Invariants, Failure Modes & Safe-Change RulesThe closing synthesis: the hard rules you must not break and the failure modes they prevent. Dependency direction is one-way down the stack; cross-context calls go through DataHub topics or typed events, never `#include`. `AuthManager::session()` is the canonical source for Fincept credentials — `SettingsRepository` is a fallback copy only. SecureStorage is SQLite + AES-256-GCM keyed off `machineUniqueId` and requires `Database` open first; platform keychains are intentionally unused. Schema migrations under `storage/sqlite/migrations/` are forward-only. Screens are lazy-instantiated via `DockScreenRouter` factories. Use this page as a checklist before merging: which layer am I in, what state do I own, and which invariant am I at risk of violating?
Complete Markdown
# Fincept Terminal — Mental Model Wiki
> A reader-side mental model of Fincept Terminal v4: a native C++20/Qt6 modular monolith with embedded Python analytics, an in-process DataHub pub/sub data plane, and 16+ broker adapters. The pages teach how to predict behavior, where state lives, and which rules must hold when you change code.
## Context Links
- [Agent index](https://grok-wiki.com/public/wiki/fincept-corporation-finceptterminal-b8b64fbc871f/llms.txt)
- [Human interactive wiki](https://grok-wiki.com/public/wiki/fincept-corporation-finceptterminal-b8b64fbc871f)
- [GitHub repository](https://github.com/Fincept-Corporation/FinceptTerminal)
## Repository Metadata
- Repository: Fincept-Corporation/FinceptTerminal
- Generated: 2026-05-23T06:41:59.277Z
- Updated: 2026-05-23T06:42:26.230Z
- Runtime: Claude Code
- Format: Mental Model
- Pages: 3
## Page Index
- 01. [The Mental Model: A Layered Modular Monolith](https://grok-wiki.com/public/wiki/fincept-corporation-finceptterminal-b8b64fbc871f/pages/01-the-mental-model-a-layered-modular-monolith.md) - The simplest useful picture of Fincept Terminal — six stacked layers (Presentation → Application → Data Plane → Adapters → Infrastructure → Platform), 13 bounded contexts that publish to each other only via DataHub topics or typed events, and one Qt6 binary per OS with embedded Python for analytics. Reading this page should let you guess which directory a new feature belongs in and why cross-context direct calls are forbidden.
- 02. [How Data Moves: DataHub, Adapters, and the Python Bridge](https://grok-wiki.com/public/wiki/fincept-corporation-finceptterminal-b8b64fbc871f/pages/02-how-data-moves-datahub-adapters-and-the-python-bridge.md) - The runtime flow that explains every screen update: DataHub is a one-fetch/many-subscribers pub/sub keyed by topic (`market:*`, `news:*`, `broker:<id>:*`…); CacheManager backs it with a SQLite TTL store on a separate physical DB; BrokerInterface and HTTP/WebSocket clients are leaf adapters that may not call services back; PythonRunner is the subprocess bridge that crosses the C++/Python process boundary for the ~1,423 analytics and data scripts under `fincept-qt/scripts/`. Trace a quote from broker WebSocket → adapter → DataHub topic → subscribed screen and you have the system.
- 03. [Invariants, Failure Modes & Safe-Change Rules](https://grok-wiki.com/public/wiki/fincept-corporation-finceptterminal-b8b64fbc871f/pages/03-invariants-failure-modes-safe-change-rules.md) - The closing synthesis: the hard rules you must not break and the failure modes they prevent. Dependency direction is one-way down the stack; cross-context calls go through DataHub topics or typed events, never `#include`. `AuthManager::session()` is the canonical source for Fincept credentials — `SettingsRepository` is a fallback copy only. SecureStorage is SQLite + AES-256-GCM keyed off `machineUniqueId` and requires `Database` open first; platform keychains are intentionally unused. Schema migrations under `storage/sqlite/migrations/` are forward-only. Screens are lazy-instantiated via `DockScreenRouter` factories. Use this page as a checklist before merging: which layer am I in, what state do I own, and which invariant am I at risk of violating?
## Source File Index
- `docs/ARCHITECTURE.md`
- `docs/CONTRIBUTING.md`
- `docs/CPP_CONTRIBUTOR_GUIDE.md`
- `docs/PYTHON_CONTRIBUTOR_GUIDE.md`
- `fincept-qt/CMakeLists.txt`
- `fincept-qt/scripts`
- `fincept-qt/src/app`
- `fincept-qt/src/auth`
- `fincept-qt/src/core`
- `fincept-qt/src/datahub`
- `fincept-qt/src/mcp`
- `fincept-qt/src/network`
- `fincept-qt/src/python`
- `fincept-qt/src/screens`
- `fincept-qt/src/services`
- `fincept-qt/src/storage`
- `fincept-qt/src/trading`
- `README.md`
---
## 01. The Mental Model: A Layered Modular Monolith
> The simplest useful picture of Fincept Terminal — six stacked layers (Presentation → Application → Data Plane → Adapters → Infrastructure → Platform), 13 bounded contexts that publish to each other only via DataHub topics or typed events, and one Qt6 binary per OS with embedded Python for analytics. Reading this page should let you guess which directory a new feature belongs in and why cross-context direct calls are forbidden.
- Page Markdown: https://grok-wiki.com/public/wiki/fincept-corporation-finceptterminal-b8b64fbc871f/pages/01-the-mental-model-a-layered-modular-monolith.md
- Generated: 2026-05-23T06:38:46.791Z
### Source Files
- `README.md`
- `docs/ARCHITECTURE.md`
- `fincept-qt/CMakeLists.txt`
- `fincept-qt/src/app`
- `fincept-qt/src/datahub`
- `fincept-qt/src/services`
- `fincept-qt/src/screens`
<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
- [fincept-qt/CMakeLists.txt](fincept-qt/CMakeLists.txt)
- [fincept-qt/src/datahub/DataHub.h](fincept-qt/src/datahub/DataHub.h)
- [fincept-qt/src/datahub/Producer.h](fincept-qt/src/datahub/Producer.h)
- [fincept-qt/src/app/DockScreenRouter.h](fincept-qt/src/app/DockScreenRouter.h)
- [fincept-qt/src/services](fincept-qt/src/services)
- [fincept-qt/src/screens](fincept-qt/src/screens)
- [fincept-qt/src/trading/BrokerInterface.h](fincept-qt/src/trading/BrokerInterface.h)
- [README.md](README.md)
</details>
# The Mental Model: A Layered Modular Monolith
Fincept Terminal is *one* native Qt6 desktop binary per OS, internally divided into six stacked layers and thirteen bounded contexts. The whole point of this page is that once you can name the six layers and the thirteen contexts, you can predict — without grepping — which directory a new feature belongs in, which layer it's allowed to call into, and which it must talk to only via DataHub topics or typed events. If you walk away with anything, walk away with the rule: **dependency direction is downward only, and cross-context conversations go through the data plane, never through a direct `#include`.**
This is deliberately a *modular monolith*, not a service mesh. The runtime is a single process; the modularity is enforced by topology and by the DataHub publish/subscribe seam — not by network boundaries.
Sources: [docs/ARCHITECTURE.md:11-78](docs/ARCHITECTURE.md), [README.md:1-40](README.md)
## The Six Layers
The codebase is sliced into six horizontal layers. Each layer may depend only on the layer beneath it; reversing the arrow is a bug, not a style issue.
```text
┌──────────────────────────────────────────────────────────────┐
│ PRESENTATION 54 screens · 13 dashboard widgets · ADS dock │ src/screens, src/ui
├──────────────────────────────────────────────────────────────┤
│ APPLICATION ~50 services across 13 bounded contexts │ src/services
├──────────────────────────────────────────────────────────────┤
│ DATA PLANE DataHub (pub/sub) + CacheManager (TTL/SQLite)│ src/datahub, src/storage/cache
├──────────────────────────────────────────────────────────────┤
│ ADAPTERS Broker · MCP · PythonRunner · HTTP · WS │ src/trading, src/mcp, src/python, src/network
├──────────────────────────────────────────────────────────────┤
│ INFRASTRUCTURE Logger · Config · EventBus · Auth · Storage │ src/core, src/auth, src/storage
├──────────────────────────────────────────────────────────────┤
│ PLATFORM Qt6 (Widgets/Charts/Network/Sql/WebSockets) │ vendor
└──────────────────────────────────────────────────────────────┘
```
The four invariants that hold the model together, copied verbatim from the architecture doc:
1. **Presentation → Application → Data Plane → Adapters → Infrastructure → Platform. Never reverse.**
2. **Adapters are leaves.** Two services may share an adapter; an adapter may not call a service.
3. **Cross-context calls go through DataHub topics or typed events, never direct includes.**
4. **Infrastructure has no business knowledge.** It does not know what a "watchlist" is.
If you violate (1), CMake will eventually catch you — the target shape splits the build into per-module library targets (`fincept_core`, `fincept_ui`, `fincept_network`, `fincept_storage`, `fincept_datahub`, …) so circular dependencies become *physically impossible* at link time. Today the build is still a single ~3,354-line `CMakeLists.txt`, so the discipline is currently social rather than mechanical.
Sources: [docs/ARCHITECTURE.md:31-79](docs/ARCHITECTURE.md), [docs/ARCHITECTURE.md:304-325](docs/ARCHITECTURE.md), [fincept-qt/CMakeLists.txt:1-80](fincept-qt/CMakeLists.txt)
## The Thirteen Bounded Contexts
The Application layer is not a flat pile of services. It is divided into thirteen bounded contexts, each of which owns its own screens, services, types, and DataHub topic prefixes. A feature lives entirely inside one context, or it is explicitly cross-cutting and tracked as such.
| # | Context | Topic prefix | Lives under |
|---|---------|--------------|-------------|
| 1 | Markets | `market:*`, `watchlist:*` | `services/markets`, `services/equity`, `services/sectors` |
| 2 | News | `news:*` | `services/news` |
| 3 | Economics | `econ:*` | `services/economics`, `services/dbnomics` |
| 4 | Geopolitics | `geopolitics:*` | `services/geopolitics`, `services/maritime` |
| 5 | Trading | `broker:<id>:*`, `paper:*` | `src/trading`, `services/algo_trading` |
| 6 | Portfolio | `portfolio:*` | `services/portfolio` |
| 7 | Crypto | `ws:<exchange>:*`, `wallet:*` | `services/crypto`, `services/wallet` |
| 8 | Derivatives | `derivatives:*` | `services/options` |
| 9 | Predictions | `prediction:*` | `services/polymarket`, `services/prediction` |
| 10 | Agents | `agent:<kind>:run:<id>` | `services/agents`, `services/alpha_arena` |
| 11 | AI Chat | event-driven | `services/llm` |
| 12 | Workflow | `workflow:*` | `services/workflow` |
| 13 | Identity | event-driven | `src/auth`, `core/identity`, `services/billing` |
The directory listing under `fincept-qt/src/services/` (40+ subfolders such as `markets/`, `news/`, `economics/`, `geopolitics/`, `polymarket/`, `wallet/`, `workflow/`, `agents/`) reflects this carving, with some contexts owning more than one folder (Markets owns `markets/`, `equity/`, `sectors/`, `asia_markets/`).
Two contexts deliberately do **not** use DataHub: AI Chat and Identity are event-driven (typed events on `EventBus`/typed signals) because their interactions are one-shot, not subscription-shaped.
Sources: [docs/ARCHITECTURE.md:101-122](docs/ARCHITECTURE.md)
## How a Feature Flows Across the Stack
The mental model only earns its keep if you can use it to *predict layout*. The diagram below shows the runtime topology for a representative read-path (a screen showing AAPL quotes) and the architectural rule each arrow obeys.
```mermaid
flowchart TB
subgraph Presentation["Presentation (src/screens, src/ui)"]
MarketsScreen["MarketsScreen<br/><i>QWidget · IStatefulScreen</i>"]
Dashboard["DashboardWidgets (13)"]
Router["DockScreenRouter<br/>(lazy factory of 54 screens)"]
end
subgraph Application["Application (src/services)"]
direction LR
Markets["MarketDataService<br/><i>Producer · market:quote:*</i>"]
News["NewsService<br/><i>Producer · news:*</i>"]
Trading["UnifiedTrading"]
Wallet["WalletService<br/><i>UI-coordinator</i>"]
end
subgraph DataPlane["Data Plane (src/datahub, src/storage/cache)"]
Hub["DataHub<br/>topic = domain:subdomain:id"]
Cache["CacheManager (SQLite TTL)"]
end
subgraph Adapters["Adapters (leaves — never call services)"]
HTTP["HttpClient"]
WS["WebSocketClient"]
Py["PythonRunner (QProcess)"]
Broker["BrokerInterface (16 impls)"]
MCP["McpService (40+ tools)"]
end
subgraph Infra["Infrastructure (src/core, src/auth, src/storage)"]
Logger["Logger"]
Auth["AuthManager · SessionGuard"]
DB["Database · SecureStorage<br/>(AES-256-GCM)"]
Bus["EventBus (typed events)"]
end
MarketsScreen -- "subscribe('market:quote:AAPL')" --> Hub
Dashboard -- subscribe --> Hub
Router -.lazy create.-> MarketsScreen
Hub -- "cache hit? deliver" --> Cache
Hub -- "stale? refresh()" --> Markets
Markets -- async --> HTTP
Markets -- async --> Py
Markets -- "publish('market:quote:AAPL', …)" --> Hub
Trading --> Broker
Wallet -- dialog parent --> MarketsScreen
MCP --> Trading
Markets --> Logger
Broker --> Auth
Auth --> DB
HTTP --> Bus
```
Two things in that diagram are worth dwelling on:
- **`MarketsScreen` never touches `HttpClient` directly.** The screen subscribes to a topic; the service decides whether to serve from cache or to refresh via an adapter. This is what makes the design *one fetch, many subscribers*: Markets, Watchlist, Dashboard, and AI Chat can all subscribe to `market:quote:AAPL` and the hub fans a single producer call out to all of them.
- **`WalletService` legitimately points back at `MarketsScreen`.** This is the documented exception class: **UI-coordinator services** own a modal dialog because the dialog is the user's authority surface (sign transaction, install update). They may accept a `QWidget*` for dialog parenting — a deliberate, scoped exception, not a leak.
Sources: [docs/ARCHITECTURE.md:235-243](docs/ARCHITECTURE.md), [docs/ARCHITECTURE.md:329-377](docs/ARCHITECTURE.md), [fincept-qt/src/datahub/DataHub.h:43-120](fincept-qt/src/datahub/DataHub.h), [fincept-qt/src/datahub/Producer.h:11-31](fincept-qt/src/datahub/Producer.h)
## Why DataHub Is the Load-Bearing Seam
The `DataHub` class is the *physical* enforcement mechanism for "no cross-context direct calls." Its public surface deliberately offers only three verbs to consumers — `subscribe`, `subscribe_pattern`, `subscribe_errors` — and to producers, only `publish` and `publish_error`. There is no method named `get_quote(symbol)` because that would let a News service call into Markets without going through a topic.
```cpp
// fincept-qt/src/datahub/DataHub.h
QMetaObject::Connection subscribe(
QObject* owner,
const QString& topic,
std::function<void(const QVariant&)> slot);
QMetaObject::Connection subscribe_pattern(
QObject* owner,
const QString& pattern,
std::function<void(const QString&, const QVariant&)> slot);
void publish(const QString& topic, const QVariant& value);
```
Three properties matter for predicting system behavior:
| Property | Consequence |
|---|---|
| `publish()` is safe to call from any thread (queued connection to hub thread) | Producers don't need to know about UI-thread affinity. |
| Subscriptions are owned by a `QObject*` and auto-cancel on `destroyed()` | A screen unloading cannot leak a callback that fires after `delete`. |
| Topic strings encode their shape (`domain:subdomain:id[:modifier]`, e.g. `market:quote:AAPL:1d`) | A topic key is *versioned by its name*; changing the shape means changing the topic. |
Producers are the symmetric side. A new data source implements [Producer](fincept-qt/src/datahub/Producer.h): declare your topic patterns, implement `refresh(topics)`, optionally cap requests-per-second. The hub takes care of scheduling, fan-out, last-known-good on failure, and TTL.
```cpp
// fincept-qt/src/datahub/Producer.h
virtual QStringList topic_patterns() const = 0; // e.g. {"market:quote:*"}
virtual void refresh(const QStringList& topics) = 0; // async; calls hub.publish() when done
virtual int max_requests_per_sec() const { return 0; } // 0 = unlimited
```
If you were to delete DataHub, every cross-context call would have to become either a direct `#include` (breaks the layering) or a typed signal on `EventBus` (which the doc flags as stringly-typed and `O(n)` lookup, with a typed-manifest wrapper planned). DataHub is what lets the monolith *act* modular.
Sources: [fincept-qt/src/datahub/DataHub.h:22-120](fincept-qt/src/datahub/DataHub.h), [fincept-qt/src/datahub/Producer.h:1-31](fincept-qt/src/datahub/Producer.h), [docs/ARCHITECTURE.md:160-167](docs/ARCHITECTURE.md), [docs/ARCHITECTURE.md:211-216](docs/ARCHITECTURE.md)
## Service Shapes: Three Flavors, Not One
A common misread is to assume *service* means one thing. The architecture explicitly recognises three:
| Flavor | Owns | Returns | Example |
|---|---|---|---|
| **Data service (default)** | One or more DataHub topic patterns | Nothing — publishes to topics | `MarketDataService`, `NewsService`, `EconomicsService` |
| **Imperative service** | A one-shot request/response over HTTP or Python | Signal or `Result<T>` | `MarketSearchService`, `DataMappingTestClient` |
| **UI-coordinator service** | A modal dialog lifecycle on behalf of UI | A signal *plus* may accept `QWidget*` for parenting | `WalletService`, `UpdateService` |
UI-coordinator is the surprising one: it is the *only* sanctioned reason a service may know about `QWidget`. The justification is that the dialog itself is the user's authority surface (wallet connect, sign transaction, install update) — but the business logic still belongs in producers, the dialog is a thin shell over a state machine the service already owns.
Sources: [docs/ARCHITECTURE.md:235-243](docs/ARCHITECTURE.md)
## Predicting Where a New Feature Lives
The whole point of the model is making placement obvious. Worked examples:
| Feature request | Layer | Context | Concrete location |
|---|---|---|---|
| "Add a new India equity quote source" | Application + Adapters | Markets | New `Producer` in `services/markets`, registers `market:quote:*` topics; new adapter under `src/network/` if needed |
| "Add Polymarket prediction screen" | Presentation | Predictions | New screen under `screens/polymarket/`, registered as a lazy factory with `DockScreenRouter`; subscribes to `prediction:*` |
| "Add a 17th broker" | Adapters | Trading | New `brokers/<name>/` directory implementing `BrokerInterface` (target: `BrokerAdapter` base); no service or screen changes |
| "Add a new LLM tool the agents can call" | Adapters | AI Chat | New tool registration in `mcp/McpProvider`; dispatcher handles routing |
| "Add agent crash-resume" | Infrastructure + Application | Agents | Schema migration under `storage/sqlite/migrations/`; `AgentService` writes per-step state to `agent_tasks`; UI subscribes to `agent:<kind>:run:<id>` |
| "Show portfolio P&L on dashboard" | Presentation | Portfolio | New `BaseWidget` subclass under `screens/dashboard/widgets/`; subscribes to `portfolio:*` topic; **no HTTP, no cache, no business logic in the widget** |
Notice that **none** of the rows say "add a new singleton" or "import another context's service header." Both are explicitly listed as anti-patterns.
Sources: [docs/ARCHITECTURE.md:425-447](docs/ARCHITECTURE.md), [docs/ARCHITECTURE.md:404-422](docs/ARCHITECTURE.md)
## What the Model Buys, and What Would Break If You Changed It
| Property | Why it holds | What breaks if you violate it |
|---|---|---|
| One fetch per (topic, source), shared across all subscribers | DataHub deduplicates subscribers per topic and caches via `CacheManager` | Removing the hub → N screens means N fetches → rate-limit failures on brokers |
| Subscriptions auto-cancel on owner destruction | DataHub holds a `QPointer` owner and listens to `destroyed()` | Bypassing subscribe → callbacks fire on freed memory after screen unload |
| Crash-resume of agents | Per-step state persisted to SQLite *before* publish | Treating DataHub as the only sink → losing in-flight task state on crash |
| Per-OS single binary | Monolith + per-module CMake targets (target state) | Splitting into network services → adds latency, deploy complexity, breaks the "Bloomberg-style desktop" UX intent |
| Provider neutrality (BYOC/BYOK) | LLM, broker, exchange adapters are leaves with no shared dependency on a single vendor SDK | Pulling a vendor SDK into Infrastructure → forces every context to take that dependency |
| 401 → auto-logout for fincept; per-broker for brokers | `SessionGuard` watches fincept HTTP responses; broker adapters own their own refresh | Routing broker HTTP through `SessionGuard` → would log a user out of the app because their Zerodha token expired |
The provider-neutrality point matters specifically for AI / data extensions: because LLM access lives in `services/llm` and tool invocation goes through `mcp/`, a new model provider is a new `ProviderAdapter` shim — no other layer changes. Skill packs and prompt sources stay file/repo/catalog-shaped rather than coupled to one vendor.
Sources: [docs/ARCHITECTURE.md:291-302](docs/ARCHITECTURE.md), [docs/ARCHITECTURE.md:379-401](docs/ARCHITECTURE.md), [docs/ARCHITECTURE.md:218-223](docs/ARCHITECTURE.md)
## Summary
The mental model is six layers + thirteen bounded contexts + one in-process pub/sub seam. If you remember only the downward dependency rule and the "cross-context conversations go through DataHub" rule, you can route 90% of new work to the right directory without asking. The CMake layout is moving toward making those rules *mechanically* enforceable; until then, DataHub's deliberately narrow public API and the documented anti-pattern list are what keep the monolith modular.
Sources: [docs/ARCHITECTURE.md:72-78](docs/ARCHITECTURE.md), [docs/ARCHITECTURE.md:425-447](docs/ARCHITECTURE.md)
---
## 02. How Data Moves: DataHub, Adapters, and the Python Bridge
> The runtime flow that explains every screen update: DataHub is a one-fetch/many-subscribers pub/sub keyed by topic (`market:*`, `news:*`, `broker:<id>:*`…); CacheManager backs it with a SQLite TTL store on a separate physical DB; BrokerInterface and HTTP/WebSocket clients are leaf adapters that may not call services back; PythonRunner is the subprocess bridge that crosses the C++/Python process boundary for the ~1,423 analytics and data scripts under `fincept-qt/scripts/`. Trace a quote from broker WebSocket → adapter → DataHub topic → subscribed screen and you have the system.
- Page Markdown: https://grok-wiki.com/public/wiki/fincept-corporation-finceptterminal-b8b64fbc871f/pages/02-how-data-moves-datahub-adapters-and-the-python-bridge.md
- Generated: 2026-05-23T06:41:59.275Z
### Source Files
- `docs/ARCHITECTURE.md`
- `fincept-qt/src/datahub`
- `fincept-qt/src/storage`
- `fincept-qt/src/network`
- `fincept-qt/src/python`
- `fincept-qt/src/trading`
- `fincept-qt/src/mcp`
- `fincept-qt/scripts`
<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
- [fincept-qt/src/datahub/DataHub.h](fincept-qt/src/datahub/DataHub.h)
- [fincept-qt/src/datahub/DataHub.cpp](fincept-qt/src/datahub/DataHub.cpp)
- [fincept-qt/src/datahub/Producer.h](fincept-qt/src/datahub/Producer.h)
- [fincept-qt/src/datahub/TopicPolicy.h](fincept-qt/src/datahub/TopicPolicy.h)
- [fincept-qt/src/storage/cache/CacheManager.h](fincept-qt/src/storage/cache/CacheManager.h)
- [fincept-qt/src/storage/cache/CacheManager.cpp](fincept-qt/src/storage/cache/CacheManager.cpp)
- [fincept-qt/src/storage/sqlite/CacheDatabase.h](fincept-qt/src/storage/sqlite/CacheDatabase.h)
- [fincept-qt/src/storage/sqlite/CacheDatabase.cpp](fincept-qt/src/storage/sqlite/CacheDatabase.cpp)
- [fincept-qt/src/storage/sqlite/Database.cpp](fincept-qt/src/storage/sqlite/Database.cpp)
- [fincept-qt/src/network/http/HttpClient.h](fincept-qt/src/network/http/HttpClient.h)
- [fincept-qt/src/network/websocket/WebSocketClient.h](fincept-qt/src/network/websocket/WebSocketClient.h)
- [fincept-qt/src/python/PythonRunner.h](fincept-qt/src/python/PythonRunner.h)
- [fincept-qt/src/python/PythonRunner.cpp](fincept-qt/src/python/PythonRunner.cpp)
- [fincept-qt/src/trading/BrokerInterface.h](fincept-qt/src/trading/BrokerInterface.h)
- [fincept-qt/src/trading/BrokerTopic.h](fincept-qt/src/trading/BrokerTopic.h)
- [fincept-qt/src/trading/DataStreamManager.h](fincept-qt/src/trading/DataStreamManager.h)
- [fincept-qt/src/trading/DataStreamManager.cpp](fincept-qt/src/trading/DataStreamManager.cpp)
- [fincept-qt/src/trading/websocket/AngelOneWebSocket.h](fincept-qt/src/trading/websocket/AngelOneWebSocket.h)
- [fincept-qt/docs/DATAHUB_TOPICS.md](fincept-qt/docs/DATAHUB_TOPICS.md)
</details>
# How Data Moves: DataHub, Adapters, and the Python Bridge
Every visible value on a Fincept Terminal screen — a quote in a watchlist, a news headline, a broker order line, an economic indicator — arrives through the same in-process data plane. Producers fetch once, the `DataHub` singleton fans the result out to every interested screen by topic name, and adapters at the edges (HTTP, WebSocket, broker REST, Python subprocess) never call back into business code. This page traces that path end-to-end so you can predict, given a new feature, where the wiring goes and which invariants you must not break.
The four landmarks are: `DataHub` (pub/sub layer, in-memory state with TTL), `CacheManager` over a separate physical SQLite (`cache.db`) for persistent TTL caching, the **adapter leaves** (`HttpClient`, `WebSocketClient`, `IBroker`) that may not call services back, and `PythonRunner` — the subprocess bridge that crosses the C++/Python process boundary for the ~1,423 analytics scripts under `fincept-qt/scripts/`.
## The data plane in one picture
```mermaid
flowchart TB
subgraph Presentation
SC[Screens / Dashboard widgets]
end
subgraph DataPlane["Data plane (in-process)"]
HUB["DataHub singleton<br/>topics_, subscriptions_, scheduler_"]
CM["CacheManager → cache.db<br/>(separate physical DB)"]
end
subgraph Services["Services / Producers"]
MDS[MarketDataService]
NWS[NewsService]
DSM[DataStreamManager<br/>broker:* producer]
end
subgraph Adapters["Adapters (leaves)"]
HC[HttpClient]
WS[WebSocketClient / broker WS]
PR[PythonRunner → subprocess]
end
subgraph Python["Python (separate process)"]
SCR["scripts/*.py (~1,423)<br/>venv-numpy1 / venv-numpy2"]
end
SC -->|subscribe topic| HUB
HUB -->|refresh topics| MDS
HUB -->|refresh topics| NWS
HUB -->|refresh topics| DSM
MDS --> HC
MDS --> PR
NWS --> HC
NWS --> CM
DSM --> WS
DSM --> HC
PR --> SCR
MDS -->|publish topic, value| HUB
NWS -->|publish topic, value| HUB
DSM -->|publish topic, value| HUB
HUB -->|topic_updated| SC
```
The arrows go one way past the data plane: presentation → application → adapters. Adapters are leaves — `HttpClient` does not import a service; `IBroker` does not call `DataHub`. The brokerage wiring stays this way because `DataStreamManager` is the single registered hub `Producer` for the `broker:*` family, owning translation from broker callbacks into hub publishes.
Sources: [docs/ARCHITECTURE.md:48-77](docs/ARCHITECTURE.md), [fincept-qt/src/trading/DataStreamManager.cpp:156-247](fincept-qt/src/trading/DataStreamManager.cpp), [fincept-qt/src/datahub/DataHub.h:43-82](fincept-qt/src/datahub/DataHub.h)
## DataHub: one fetch, many subscribers, keyed by topic
`DataHub` is a process-wide singleton living on whichever thread first called `instance()` — in practice the main thread, since `main.cpp` warms it at startup. Subscribers attach a callback to a topic string; producers later call `publish(topic, QVariant)`. The hub keeps the latest value in memory (`TopicState::value`), so a fresh subscriber receives an immediate replay of the cached value (see `deliver_initial_value`), and the next fetch is shared by everyone — that is the "one-fetch, many-subscribers" invariant.
Topic strings are colon-separated identifiers; pattern subscriptions support a single trailing `*`. Canonical families include `market:quote:<sym>`, `news:general`, `econ:<source>:<request_id>`, and the Phase 7 broker family `broker:<broker_id>:<account_id>:<channel>[:<sym>]`. The full registry is maintained in `DATAHUB_TOPICS.md`.
```cpp
// fincept-qt/src/datahub/DataHub.h — the public API
QMetaObject::Connection subscribe(QObject* owner, const QString& topic,
std::function<void(const QVariant&)> slot);
QMetaObject::Connection subscribe_pattern(QObject* owner, const QString& pattern,
std::function<void(const QString&, const QVariant&)> slot);
void publish(const QString& topic, const QVariant& value);
void publish_error(const QString& topic, const QString& error);
QVariant peek(const QString& topic) const; // TTL-checked, no fetch
void request(const QString& topic, bool force = false);
```
Three behavioural invariants matter when reading new screen code:
- **Owner-scoped lifetime.** Every `subscribe()` takes a `QObject* owner`; when the owner is destroyed, subscriptions auto-cancel. There is no manual `unsubscribe()` in normal use. The hub tracks owners in `owner_topics_` / `owner_patterns_` for fast cleanup.
- **Thread-safe publish.** `publish()` can be invoked from any thread — `QNetworkAccessManager` callbacks, broker WS parser threads, Python finished slots — and the hub marshals to its own thread via queued invocation. Slot dispatch then lands on the subscriber's thread via Qt's `AutoConnection`.
- **Last-known-good on failure.** `publish_error()` does *not* clear the cached value. Subscribers keep seeing the previous good payload while `topic_error(topic, error)` fires for UI surfacing. The cached value only changes on a successful `publish()`.
Sources: [fincept-qt/src/datahub/DataHub.h:43-203](fincept-qt/src/datahub/DataHub.h), [fincept-qt/src/datahub/DataHub.cpp:56-102](fincept-qt/src/datahub/DataHub.cpp), [fincept-qt/docs/DATAHUB_TOPICS.md:1-66](fincept-qt/docs/DATAHUB_TOPICS.md)
### TopicPolicy: TTL, min interval, push-only, coalesce
Per-topic refresh is controlled by `TopicPolicy`. A 1-second `QTimer` (`scheduler_`) walks the topic state every tick and asks a registered `Producer` to refresh anything stale — but only if there are live subscribers, no in-flight request, and the per-producer rate cap allows it.
| Field | Default | Effect |
|---|---|---|
| `ttl_ms` | 30 000 | Cached value is considered fresh for this many ms; scheduler waits until expiry to refetch. |
| `min_interval_ms` | 5 000 | Hard floor between two `refresh()` calls on the same topic, regardless of subscriber count. |
| `refresh_timeout_ms` | 30 000 | If neither `publish()` nor `publish_error()` arrives in this window, the hub clears `in_flight` and logs a warning. |
| `push_only` | false | Scheduler ignores the topic — producer pushes when feed messages arrive (e.g. WebSocket ticks). |
| `coalesce_within_ms` | 0 | Backpressure window for high-rate streams; only the latest payload within the window is fanned out. |
| `pause_when_inactive` | false | Suppresses fan-out (not cache update) when the subscriber's `WindowFrame` is hidden/minimised. Used sparingly for high-frequency streams. |
| `drop_on_idle` | false | Drops the entire `TopicState` when the last subscriber leaves. For unbounded topic keys (per-run agent outputs). |
Sources: [fincept-qt/src/datahub/TopicPolicy.h:1-60](fincept-qt/src/datahub/TopicPolicy.h), [fincept-qt/src/datahub/DataHub.cpp:61-92](fincept-qt/src/datahub/DataHub.cpp)
### Request coalescing
Producers don't get hammered when multiple panels light up at once. `DataHub::request()` queues topics per producer into `coalesce_pending_` and arms a single-shot `coalesce_timer_` (default 100 ms). When it fires, `flush_coalesced_requests()` collapses the burst into one `Producer::refresh(topics)` per producer. Tests commonly set the window to 0 for synchronous dispatch via `set_coalesce_window_ms()`.
Sources: [fincept-qt/src/datahub/DataHub.h:316-326](fincept-qt/src/datahub/DataHub.h), [fincept-qt/src/datahub/DataHub.cpp:71-92](fincept-qt/src/datahub/DataHub.cpp)
## CacheManager: separate physical DB, sibling not backing
`CacheManager` is *not* the in-memory store behind `DataHub` — `DataHub::TopicState::value` is a plain `QVariant` field, in RAM. `CacheManager` is a parallel data-plane component: a SQLite TTL key/value store on a **separate physical DB** (`fincept_cache` connection, `cache.db` file), used by services to avoid re-hitting upstream HTTP/Python sources across app restarts. The main DB (`fincept_main`, `fincept.db`) holds authoritative state (auth, watchlists, portfolios, repositories); the cache DB holds disposable rows.
```cpp
// fincept-qt/src/storage/sqlite/CacheDatabase.cpp
db_ = QSqlDatabase::addDatabase("QSQLITE", "fincept_cache");
db_.setDatabaseName(path);
// PRAGMA journal_mode = WAL, synchronous = NORMAL, busy_timeout = 3000…
// CREATE TABLE unified_cache (key, value, category, ttl_seconds, expires_at, …)
```
`CacheManager` operations are mutex-serialised at the `CacheDatabase` layer because callbacks may run on worker threads (`HttpClient`, `PythonRunner`). A startup sweep deletes expired rows so the table doesn't accumulate across sessions, and `expires_at > datetime('now')` is checked on every read.
Two notes that matter for predicting behaviour:
- The cache DB also stores `tab_sessions` and `screen_state` (per-tab UI state, screen restore data). The `synchronous=NORMAL` choice — not `OFF` — exists because losing those tables on power-loss would visibly break the next session.
- There is no in-memory layer in front of `CacheManager`. The single SQLite path is the source of truth: avoids double-storage and "stale-in-memory while fresh-on-disk" confusion.
Sources: [fincept-qt/src/storage/cache/CacheManager.h:9-33](fincept-qt/src/storage/cache/CacheManager.h), [fincept-qt/src/storage/cache/CacheManager.cpp:31-70](fincept-qt/src/storage/cache/CacheManager.cpp), [fincept-qt/src/storage/sqlite/CacheDatabase.cpp:14-187](fincept-qt/src/storage/sqlite/CacheDatabase.cpp), [docs/ARCHITECTURE.md:90-91](docs/ARCHITECTURE.md), [docs/ARCHITECTURE.md:187-192](docs/ARCHITECTURE.md)
## Adapters are leaves: HTTP, WebSocket, and brokers
The dependency rule in `docs/ARCHITECTURE.md` is explicit: *adapters are leaves; two services may share an adapter; an adapter may not call a service.* Three adapter surfaces follow this:
### HttpClient
A `QNetworkAccessManager` wrapper with context-scoped callbacks — callers pass `this` so the callback is dropped if the requester dies before the reply arrives. Singleton, GUI-thread safe, returns `Result<QJsonDocument>`. It does not import any service header.
```cpp
// fincept-qt/src/network/http/HttpClient.h
using JsonCallback = std::function<void(Result<QJsonDocument>)>;
void get(const QString& url, JsonCallback callback, const QObject* context = nullptr);
```
### WebSocketClient
Thin Qt6 WebSockets wrapper with auto-reconnect (`MAX_RECONNECT_ATTEMPTS = 10`). The socket is heap-allocated and parented to `this` so `moveToThread` on the wrapper relocates the socket plus its internal `QSocketNotifier`/`QTimer` children — a subtle invariant the comment in the header preserves.
### IBroker
The trading abstraction is wider than the network adapters but obeys the same rule. `IBroker` (32 virtual methods covering auth, orders, portfolio, market data, GTT, margin, WebSocket adapter name) is implemented by each of 16 brokers under `fincept-qt/src/trading/brokers/<name>/`. Brokers do not know `DataHub` exists. `DataStreamManager` is the single registered `Producer` for `broker:*` and the only place where broker callbacks are translated into hub publishes — see the trace below.
Sources: [docs/ARCHITECTURE.md:54-77](docs/ARCHITECTURE.md), [fincept-qt/src/network/http/HttpClient.h:14-45](fincept-qt/src/network/http/HttpClient.h), [fincept-qt/src/network/websocket/WebSocketClient.h:13-55](fincept-qt/src/network/websocket/WebSocketClient.h), [fincept-qt/src/trading/BrokerInterface.h:74-294](fincept-qt/src/trading/BrokerInterface.h)
## PythonRunner: the C++/Python process boundary
`PythonRunner` is the subprocess bridge for every Python entry point in `fincept-qt/scripts/` — analytics, agents, broker connectors, data fetchers. The repo carries ~1,423 such scripts (the architecture doc's figure; a current `find` in this checkout reports 1,425 `.py` files), organised under twelve top-level subpackages plus a flat top-level group:
```
fincept-qt/scripts/
├── agents/ algo_trading/ alpha_arena/ agno_trading/ ai_quant_lab/
├── Analytics/ exchange/ mcp/ strategies/ technicals/
├── vision_quant/ voice/
└── *.py (≈320 flat data fetchers — yfinance_data.py, fred_data.py, …)
```
The runner is the only place that crosses the C++/Python process boundary, and it makes a small number of opinionated choices that callers should know about.
### Venv routing and process pacing
`PythonRunner::run()` resolves Python at startup, preferring UV-managed venvs from `PythonSetupManager` (`venv-numpy2` default, `venv-numpy1` for legacy NumPy 1.x libs like vectorbt/gluonts), falling back to a venv next to the executable and finally to system `python3`. Concurrency is capped at three processes (`DEFAULT_MAX_CONCURRENT`) and surplus requests queue in `queue_`. Cold start is 0.5–1.5 s per call; results are returned as a `PythonResult { success, output, error, exit_code }` on the Qt event loop.
```cpp
// fincept-qt/src/python/PythonRunner.h
void run(const QString& script, const QStringList& args, Callback cb,
StreamCallback on_line = {});
void run_code(const QString& code, Callback cb); // inline cell code via temp file
static constexpr int DEFAULT_MAX_CONCURRENT = 3;
```
### Thread-affinity guard
Worker threads (notably MCP tool handlers) are allowed to call `run()`. The runner detects `QThread::currentThread() != this->thread()` and re-invokes itself via `QMetaObject::invokeMethod(... Qt::QueuedConnection)`. The hidden hazard this prevents is `new QProcess(this)` on a foreign thread — leaving the process with wrong thread affinity and corrupting the heap silently during destruction.
### Standard environment and credential discipline
Every spawn goes through `build_python_env()`, which pins encoding (`PYTHONIOENCODING=utf-8`, `PYTHONUNBUFFERED=1`), sets `FINCEPT_DATA_DIR` / `FINAGENT_DATA_DIR`, prepends `scripts_dir` to `PYTHONPATH`, and injects API keys from `SecureStorage`. Critically, any *other* credential-shaped env var the user inherited from their shell is stripped before the subprocess sees it — preventing leakage via `/proc/<pid>/environ`. That stripping is the inverse of the allow-list and is the reason `os.environ.get("FRED_API_KEY")` in a script reliably returns the value stored in SecureStorage rather than whatever shell quirk the user has.
### Daemon fast-path
For `yfinance_data.py` (called from many screens for quotes, news, financials, history), single-shot calls bypass the subprocess + import cost by routing to `PythonWorker` — a long-lived daemon. The runner translates CLI args into a JSON action+payload and submits it. If the action is unknown, the subprocess path runs as a fallback. This is why the same script call from Python may cost milliseconds on hot paths and ~3 s on cold paths.
### Sub-package vs flat scripts
If the script path contains `/` and every ancestor has an `__init__.py`, `PythonRunner` invokes it as `python -m pkg.sub.module` so relative imports resolve. Otherwise it falls back to direct script invocation so the script's own `sys.path.insert(...)` bootstrap can run.
### Argument spilling
Args larger than 8 KB are written to a temp file and passed as `@/path/to/file`; the Python script reads and deletes it. This sidesteps the Windows 32 KB command-line limit and is the contract behind the `resolve_arg` helper used by data scripts.
Sources: [fincept-qt/src/python/PythonRunner.h:14-103](fincept-qt/src/python/PythonRunner.h), [fincept-qt/src/python/PythonRunner.cpp:35-117](fincept-qt/src/python/PythonRunner.cpp), [fincept-qt/src/python/PythonRunner.cpp:200-265](fincept-qt/src/python/PythonRunner.cpp), [fincept-qt/src/python/PythonRunner.cpp:398-591](fincept-qt/src/python/PythonRunner.cpp), [docs/ARCHITECTURE.md:202-209](docs/ARCHITECTURE.md)
## End-to-end: a quote from broker WebSocket to a subscribed screen
The single most useful trace for understanding the runtime is a tick flowing from a broker WebSocket all the way to a panel repaint. Every claim below is a real link in the code, not an analogy.
```mermaid
sequenceDiagram
autonumber
participant WS as AngelOneWebSocket / ZerodhaWebSocket
participant ADS as AccountDataStream
participant DSM as DataStreamManager<br/>(Producer for broker:*)
participant HUB as DataHub
participant SCR as Screen / Widget<br/>(subscriber)
WS->>WS: on_binary_message: parse_tick() (binary frame)
WS->>ADS: emit tick_received(AoTick)
ADS->>DSM: emit quote_updated(account, symbol, BrokerQuote)
DSM->>HUB: publish("broker:angelone:default:quote:<sym>", QVariant::fromValue(quote))
Note over HUB: TopicPolicy: ttl 5s / min_interval 1s<br/>(ticks family: push_only + coalesce 100ms)
HUB->>SCR: topic_updated(topic, value) → slot on subscriber thread
```
1. The broker WS adapter (`AngelOneWebSocket`, `ZerodhaWebSocket`) is a pure leaf — it parses binary tick frames (`parse_tick`) and emits a Qt signal `tick_received(AoTick)`. It does not import `DataHub`.
2. `AccountDataStream` owns the per-account socket lifecycle and translates broker-specific ticks into a unified `BrokerQuote`, emitting `quote_updated(account_id, symbol, quote)`.
3. `DataStreamManager`, which implements `fincept::datahub::Producer` with `topic_patterns() == {"broker:*"}`, has wired every stream's `quote_updated` to its own `on_quote_for_hub` slot.
4. `on_quote_for_hub` builds the canonical topic string via `broker_topic(broker_id, account_id, "quote", symbol)` and calls `DataHub::publish(topic, QVariant::fromValue(quote))`.
5. The hub stores the value, looks up the topic's policy, and fans out via `emit_to_subscribers` — broker `ticks:<sym>` topics carry `push_only=true` and `coalesce_within_ms=100`, so a burst of 30 ticks within 100 ms delivers exactly one payload (the latest) to each subscriber.
6. Screens subscribed via `subscribe_pattern("broker:angelone:default:quote:*", …)` receive `(topic, value)` on their own thread and repaint.
The policies that govern this fan-out are not buried in random services — they live in one place, `DataStreamManager::ensure_registered_with_hub()`:
```cpp
// fincept-qt/src/trading/DataStreamManager.cpp:195-228
hub.set_policy_pattern("broker:*:*:positions", /* ttl 5s, min 3s */);
hub.set_policy_pattern("broker:*:*:orders", /* ttl 5s, min 3s */);
hub.set_policy_pattern("broker:*:*:balance", /* ttl 30s, min 10s */);
hub.set_policy_pattern("broker:*:*:holdings", /* ttl 30s, min 10s */);
hub.set_policy_pattern("broker:*:*:quote:*", /* ttl 5s, min 1s */);
hub.set_policy_pattern("broker:*:*:ticks:*", /* push_only, coalesce 100ms */);
```
Sources: [fincept-qt/src/trading/websocket/AngelOneWebSocket.h:63-91](fincept-qt/src/trading/websocket/AngelOneWebSocket.h), [fincept-qt/src/trading/DataStreamManager.h:39-78](fincept-qt/src/trading/DataStreamManager.h), [fincept-qt/src/trading/DataStreamManager.cpp:156-294](fincept-qt/src/trading/DataStreamManager.cpp), [fincept-qt/src/trading/BrokerTopic.h:17-30](fincept-qt/src/trading/BrokerTopic.h), [fincept-qt/docs/DATAHUB_TOPICS.md:52-71](fincept-qt/docs/DATAHUB_TOPICS.md)
## Invariants and failure modes worth memorising
| Invariant | Where it lives | What breaks if you violate it |
|---|---|---|
| Adapters are leaves | `docs/ARCHITECTURE.md` rules 2–3 | Cycle between trading and a service via a broker include → unbuildable target or runtime singleton race. |
| `publish()` is thread-safe; subscribe slot runs on subscriber thread | `DataHub::publish` queued invocation | Calling Qt UI APIs from inside a worker-thread publish path → Qt fatal. |
| Last-known-good on failure | `publish_error` does not clear value | UI flashing blank on transient HTTP errors. |
| Owner-scoped subscriptions | `subscribe(QObject* owner, …)` + `on_owner_destroyed` | Use-after-free if owner-less raw pointers subscribe. |
| Cache DB is separate, durable, mutex-serialised | `CacheDatabase` connection `fincept_cache` | Mixing cache rows into the main DB makes "clear cache" risky. |
| PythonRunner is thread-affinity guarded | `run()` re-marshals via `QueuedConnection` | Heap corruption from foreign-thread `QProcess` destruction. |
| Credential allow-list, strip everything else | `build_python_env()` strips unmanaged credential vars | API keys leak into Python subprocesses via inherited shell env. |
| Only one producer per topic family | `DataStreamManager` for `broker:*` | Duplicate publishes / racy refresh scheduling. |
Sources: [docs/ARCHITECTURE.md:72-77](docs/ARCHITECTURE.md), [fincept-qt/src/datahub/DataHub.h:115-132](fincept-qt/src/datahub/DataHub.h), [fincept-qt/src/python/PythonRunner.cpp:401-421](fincept-qt/src/python/PythonRunner.cpp), [fincept-qt/src/python/PythonRunner.cpp:241-265](fincept-qt/src/python/PythonRunner.cpp)
## Summary
The Fincept Terminal data plane works because every screen, every service, and every external integration agrees on the same shape: producers fetch once and publish typed values to a `DataHub` topic; subscribers attach by topic and let the hub handle TTL, coalescing, pause-when-inactive, last-known-good, and lifetime. `CacheManager` over a separate `cache.db` adds cross-session persistence without contaminating the authoritative `fincept.db`. Adapters — `HttpClient`, `WebSocketClient`, `IBroker`, `PythonRunner` — sit at the bottom of the dependency graph and never look upward, which is what lets the ~1,423 Python scripts and the 16 brokers coexist without circular wiring. Trace any value backwards from a screen and you should land on exactly one `Producer::refresh()` or one push path, with the topic policy explaining the timing.
Sources: [docs/ARCHITECTURE.md:1-77](docs/ARCHITECTURE.md), [fincept-qt/src/datahub/DataHub.cpp:56-115](fincept-qt/src/datahub/DataHub.cpp)
---
## 03. Invariants, Failure Modes & Safe-Change Rules
> The closing synthesis: the hard rules you must not break and the failure modes they prevent. Dependency direction is one-way down the stack; cross-context calls go through DataHub topics or typed events, never `#include`. `AuthManager::session()` is the canonical source for Fincept credentials — `SettingsRepository` is a fallback copy only. SecureStorage is SQLite + AES-256-GCM keyed off `machineUniqueId` and requires `Database` open first; platform keychains are intentionally unused. Schema migrations under `storage/sqlite/migrations/` are forward-only. Screens are lazy-instantiated via `DockScreenRouter` factories. Use this page as a checklist before merging: which layer am I in, what state do I own, and which invariant am I at risk of violating?
- Page Markdown: https://grok-wiki.com/public/wiki/fincept-corporation-finceptterminal-b8b64fbc871f/pages/03-invariants-failure-modes-safe-change-rules.md
- Generated: 2026-05-23T06:40:06.186Z
### Source Files
- `docs/ARCHITECTURE.md`
- `docs/CONTRIBUTING.md`
- `docs/CPP_CONTRIBUTOR_GUIDE.md`
- `docs/PYTHON_CONTRIBUTOR_GUIDE.md`
- `fincept-qt/src/auth`
- `fincept-qt/src/storage`
- `fincept-qt/src/core`
<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
- [docs/CPP_CONTRIBUTOR_GUIDE.md](docs/CPP_CONTRIBUTOR_GUIDE.md)
- [fincept-qt/src/auth/AuthManager.h](fincept-qt/src/auth/AuthManager.h)
- [fincept-qt/src/auth/AuthManager.cpp](fincept-qt/src/auth/AuthManager.cpp)
- [fincept-qt/src/storage/secure/SecureStorage.h](fincept-qt/src/storage/secure/SecureStorage.h)
- [fincept-qt/src/storage/secure/SecureStorage.cpp](fincept-qt/src/storage/secure/SecureStorage.cpp)
- [fincept-qt/src/storage/sqlite/Database.h](fincept-qt/src/storage/sqlite/Database.h)
- [fincept-qt/src/storage/sqlite/migrations/MigrationRunner.h](fincept-qt/src/storage/sqlite/migrations/MigrationRunner.h)
- [fincept-qt/src/storage/sqlite/migrations/MigrationRunner.cpp](fincept-qt/src/storage/sqlite/migrations/MigrationRunner.cpp)
- [fincept-qt/src/storage/sqlite/migrations/v029_secure_credentials.cpp](fincept-qt/src/storage/sqlite/migrations/v029_secure_credentials.cpp)
- [fincept-qt/src/app/DockScreenRouter.h](fincept-qt/src/app/DockScreenRouter.h)
- [fincept-qt/src/app/DockScreenRouter.cpp](fincept-qt/src/app/DockScreenRouter.cpp)
- [fincept-qt/src/app/DockScreenRouter_Materialize.cpp](fincept-qt/src/app/DockScreenRouter_Materialize.cpp)
- [fincept-qt/src/app/WindowFrame_Setup.cpp](fincept-qt/src/app/WindowFrame_Setup.cpp)
- [fincept-qt/src/datahub/DataHub.h](fincept-qt/src/datahub/DataHub.h)
</details>
# Invariants, Failure Modes & Safe-Change Rules
This is the closing synthesis page: the hard rules that hold the modular monolith together, the concrete failure modes they prevent, and a pre-merge checklist you can run against any patch. Each rule is stated together with what the code actually does today, where it lives, and what would break if you broke it. Use the table at the end as a sanity gate before opening a pull request.
Fincept Terminal is a Qt6/C++20 desktop binary with 54 lazy screens, ~50 services, 16 broker adapters, and an in-process DataHub. The invariants below are what keep that scale from collapsing into a tangle: clear dependency direction, one canonical source per piece of mutable state, and lifecycle preconditions that subsystems explicitly assert rather than hope for.
## 1. Dependency direction is one-way down the stack
The architecture is layered, and the layering is enforced by convention today and (target) by per-module CMake libraries tomorrow.
```text
Presentation ─► Application ─► Data Plane ─► Adapters ─► Infrastructure ─► Platform
(screens) (bounded ctx) (DataHub) (broker/MCP) (Database/Auth) (Qt6)
```
Rules:
- **Never reverse the arrow.** Infrastructure has no business knowledge — `Database` does not know what a "watchlist" is.
- **Adapters are leaves.** Two services may share an adapter; an adapter may not call a service.
- **Cross-context calls go through DataHub topics or typed events, never `#include`.** A Markets screen does not include a Trading header; both subscribe to topics on the hub.
The target CMake layout makes this enforceable mechanically: each layer becomes a library target that may only link against the layers below it. The current monolithic `CMakeLists.txt` (~3,300 LOC) still permits violations, which is exactly why they are tagged in the refactor plan rather than silently absent.
Failure mode if broken: circular dependency creeps in (a service `#include`s a screen header for a struct), build slows, and the layer collapses into one giant module that can't be tested in isolation.
Sources: [docs/ARCHITECTURE.md:72-78](docs/ARCHITECTURE.md), [docs/ARCHITECTURE.md:303-324](docs/ARCHITECTURE.md), [fincept-qt/src/datahub/DataHub.h:39-58](fincept-qt/src/datahub/DataHub.h)
## 2. `AuthManager::session()` is the canonical source of fincept credentials
`AuthManager` is the in-memory singleton that owns the live `SessionData` for the logged-in user. Every consumer — UI navigation chrome, MCP tools, broker chat services — reads tokens through `AuthManager::instance().session()`.
```cpp
// fincept-qt/src/auth/AuthManager.h:14-17
class AuthManager : public QObject {
static AuthManager& instance();
const SessionData& session() const { return session_; }
```
`SettingsRepository` holds a **fallback persistence copy** of the same JSON-serialised session blob (key `fincept_session`). `SecureStorage` additionally persists the durable `api_key` so it survives SQLite corruption or migration. Neither is a substitute for the live session struct; both are reload sources for `AuthManager::load_session()`.
| Source | Role | Lifetime |
|---|---|---|
| `AuthManager::session()` | Canonical, in-memory | Process lifetime |
| `SettingsRepository.get("fincept_session")` | Fallback JSON for cold start | Persistent (plaintext SQLite) |
| `SecureStorage.retrieve("api_key")` | Durable encrypted recovery of api_key | Persistent (AES-256-GCM) |
Invariant: **never cache fincept credentials anywhere else.** Capturing `session.api_key` into a member variable means the next `logout()` does not invalidate your copy, and the next `refresh_user_data()` leaves you stale.
Failure mode if broken: a screen or service authenticates with a revoked token after logout, the user appears logged in despite being signed out, and `SessionGuard`'s 401 auto-logout never fires for the stale caller.
Sources: [fincept-qt/src/auth/AuthManager.h:14-34](fincept-qt/src/auth/AuthManager.h), [fincept-qt/src/auth/AuthManager.cpp:69-101](fincept-qt/src/auth/AuthManager.cpp), [fincept-qt/src/auth/SessionGuard.cpp:13-46](fincept-qt/src/auth/SessionGuard.cpp), [docs/ARCHITECTURE.md:197](docs/ARCHITECTURE.md), [docs/ARCHITECTURE.md:445](docs/ARCHITECTURE.md)
## 3. SecureStorage = SQLite + AES-256-GCM keyed off `machineUniqueId`
`SecureStorage` is a SQLite-backed credential store. Values are encrypted with AES-256-GCM using a 256-bit key derived once per process from `QSysInfo::machineUniqueId()`, the OS product type, and a fixed app salt, hashed with SHA-256.
```cpp
// fincept-qt/src/storage/secure/SecureStorage.cpp:57-67
seed.append(QSysInfo::machineUniqueId());
seed.append('\x1f');
seed.append(QSysInfo::productType().toUtf8());
seed.append('\x1f');
seed.append(kAppSalt);
cached = QCryptographicHash::hash(seed, QCryptographicHash::Sha256);
```
Rows live in the `secure_credentials` table (created by migration v029) with columns `key | ciphertext | iv | tag | updated_at`. Each row carries its own random 96-bit IV and 128-bit GCM authentication tag; tag mismatch on `retrieve()` returns `"Not found"` rather than corrupted plaintext, so the recovery path (re-prompt PIN / re-login) kicks in cleanly.
### Hard invariants
1. **`Database::open()` must run first.** `store`, `retrieve`, and `remove` all begin with `if (!db.is_open()) return err("Database not open");`. Calling `SecureStorage::instance().retrieve(...)` from a static initializer or from `main()` before bootstrap will silently fail.
2. **Platform keychains are intentionally unused.** v029 explicitly replaced the old per-OS backends (Windows Credential Manager, macOS Keychain via `SecItem`, Linux libsecret/QSettings). Do not reintroduce them — the AES-256-GCM design is the chosen threat model, and a stale comment in `AuthManager::save_session()` mentioning "DPAPI / Keychain" describes intent that the v029 migration replaced.
3. **Copying the SQLite file to another machine yields unreadable ciphertext, by design.** This is the desired behaviour, not a bug.
4. **A process running as the same user on the same machine can decrypt the rows.** This matches the previous Linux fallback's threat model; do not rely on SecureStorage for cross-user isolation.
| What it protects against | What it does not |
|---|---|
| Casual `grep` of the on-disk SQLite file | Another process as the same user on the same machine |
| Profile copy moved to a different machine | Forensic tools that dump kernel memory |
| Bit-flips / partial writes (GCM tag fails) | Targeted malware that re-derives the key |
Failure mode if broken: calling `SecureStorage` before `Database::open()` returns an error that is easy to swallow, leaving the api_key absent from the session — the user appears logged out at startup despite having a valid SQLite session row. Reintroducing platform keychains would also drift the credential set across two stores and break the "one canonical encrypted location" assumption.
Sources: [fincept-qt/src/storage/secure/SecureStorage.h:6-43](fincept-qt/src/storage/secure/SecureStorage.h), [fincept-qt/src/storage/secure/SecureStorage.cpp:18-67](fincept-qt/src/storage/secure/SecureStorage.cpp), [fincept-qt/src/storage/secure/SecureStorage.cpp:195-281](fincept-qt/src/storage/secure/SecureStorage.cpp), [fincept-qt/src/storage/sqlite/migrations/v029_secure_credentials.cpp:1-50](fincept-qt/src/storage/sqlite/migrations/v029_secure_credentials.cpp), [docs/ARCHITECTURE.md:192](docs/ARCHITECTURE.md)
## 4. Schema migrations under `storage/sqlite/migrations/` are forward-only
The migration runner is a versioned, transactional, append-only ladder. There is no `down()` function on `Migration`, no `rollback()` entrypoint on `MigrationRunner`, and no downgrade path anywhere in the registry.
```cpp
// fincept-qt/src/storage/sqlite/migrations/MigrationRunner.h:14-37
struct Migration {
int version;
QString name;
std::function<Result<void>(QSqlDatabase&)> apply; // no `revert`
};
```
The current ladder is v001 → v031 (31 migrations, including `v029_secure_credentials`). Each migration:
1. Registers itself by calling `register_migration_v0NN()` before `Database::open()` (explicit calls exist so MSVC's linker does not strip the translation units).
2. Runs inside `BEGIN IMMEDIATE` / `COMMIT`. On failure the runner issues `ROLLBACK` and returns an error — the schema_version row is only written after success.
### Rules for adding migrations
| Rule | Why |
|---|---|
| New migration gets a new highest number `vNNN_short_name.cpp` | Re-numbering breaks every installed copy that already recorded the old number |
| Use `CREATE TABLE IF NOT EXISTS` and `ALTER TABLE … ADD COLUMN`, never `DROP` | Forward-only means a column you add today may have data tomorrow; you cannot undo it |
| Call `register_migration_vNNN()` from the runner's explicit list | Static-init alone is unreliable under MSVC link-time GC |
| Do not edit an applied migration | Already-upgraded installs will not re-run it, so the edit only affects fresh installs and silently bifurcates schema |
Failure mode if broken: editing v015 in place leaves existing users on the old shape forever (their `schema_version` already records 15 as applied) while new users get the edited version — silent schema divergence that surfaces as random query errors weeks later.
Sources: [fincept-qt/src/storage/sqlite/migrations/MigrationRunner.h:14-82](fincept-qt/src/storage/sqlite/migrations/MigrationRunner.h), [fincept-qt/src/storage/sqlite/migrations/MigrationRunner.cpp:31-80](fincept-qt/src/storage/sqlite/migrations/MigrationRunner.cpp), [fincept-qt/src/storage/sqlite/migrations/v029_secure_credentials.cpp:1-50](fincept-qt/src/storage/sqlite/migrations/v029_secure_credentials.cpp)
## 5. Screens are lazy-instantiated via `DockScreenRouter` factories
Screens never exist at startup. `WindowFrame_Setup.cpp` registers a `std::function<QWidget*()>` factory for each screen id; `DockScreenRouter` calls the factory the first time anyone navigates to that id.
```cpp
// fincept-qt/src/app/DockScreenRouter.h:30-47
using ScreenFactory = std::function<QWidget*()>;
void register_factory(const QString& id, ScreenFactory factory);
```
```cpp
// fincept-qt/src/app/WindowFrame_Setup.cpp:224-234
dock_router_->register_factory("dashboard", []() { return new screens::DashboardScreen; });
dock_router_->register_factory("markets", []() { return new screens::MarketsScreen; });
```
### Why this matters
- A 54-screen workspace would otherwise instantiate ~54 `QWidget` trees at first window open, each potentially loading charts, fonts, and Python helpers. Lazy factories defer that cost until the user actually opens the screen.
- `ensure_all_registered()` pre-creates `CDockWidget` placeholders (not full screens) so ADS's `restoreState()` can rebind by `objectName` — restoration does not force eager screen construction.
- `duplicate_panel()` only works for factory-registered screens; eager `register_screen()` reuses one widget instance and cannot be tabbed twice. New code should use `register_factory`, not `register_screen`.
### State ownership rule
A screen that needs its UI state to survive restart implements `IStatefulScreen` and routes save/restore through `ScreenStateManager`. A screen must not own caches; cached data belongs in `CacheManager` via DataHub. A `QHash<QString, ...>` field in a screen is only permitted as (a) a live-feed dispatch table cleared on hide, or (b) a view/index over data already owned by DataHub.
Failure mode if broken: registering screens eagerly returns the multi-window memory profile back to the 100-250 MB-per-window regime called out in the known-weaknesses table; bypassing `IStatefulScreen` means workspace restore leaves the user staring at empty tabs after a restart.
Sources: [fincept-qt/src/app/DockScreenRouter.h:29-96](fincept-qt/src/app/DockScreenRouter.h), [fincept-qt/src/app/DockScreenRouter.cpp:206-233](fincept-qt/src/app/DockScreenRouter.cpp), [fincept-qt/src/app/DockScreenRouter_Materialize.cpp:46-95](fincept-qt/src/app/DockScreenRouter_Materialize.cpp), [fincept-qt/src/app/WindowFrame_Setup.cpp:224-234](fincept-qt/src/app/WindowFrame_Setup.cpp), [docs/ARCHITECTURE.md:432-447](docs/ARCHITECTURE.md)
## 6. Cross-cutting failure modes worth memorising
These are the easy traps — each one is a single line of code that compiles cleanly and breaks at runtime.
| Trap | What goes wrong | Where the rule lives |
|---|---|---|
| Synchronous DB query on the UI thread | Frame stutter; per-thread cloned connection never created | [Database.h:33-37](fincept-qt/src/storage/sqlite/Database.h) |
| `BrokerHttp` called from the UI thread | Internal `QEventLoop` re-enters the UI event loop; reentrancy hazards | [docs/ARCHITECTURE.md:228-229](docs/ARCHITECTURE.md) |
| `qApp->setStyleSheet(...)` from a widget's own event handler | Wayland crash; theme changes must dispatch via `Qt::QueuedConnection` | [docs/ARCHITECTURE.md:443](docs/ARCHITECTURE.md) |
| Screen calling `HttpClient::instance()` directly | Bypasses caching, dedup, and DataHub fan-out | [docs/ARCHITECTURE.md:439](docs/ARCHITECTURE.md) |
| Data service exposing `QWidget*` | Couples data plane to UI; only UI-coordinator services may | [docs/ARCHITECTURE.md:240](docs/ARCHITECTURE.md) |
| Hardcoded broker enums in `if`-trees | Defeats `BrokerEnumMap<T>` table-driven dispatch | [docs/ARCHITECTURE.md:441](docs/ARCHITECTURE.md) |
| Adding a new `::instance()` singleton | Worsens the 40+ singleton tax; use the DI container | [docs/ARCHITECTURE.md:447](docs/ARCHITECTURE.md) |
| Unmanaged `*_API_KEY` env var in `PythonRunner` | Leaks via `/proc/<pid>/environ`; whitelist is intentional | [docs/ARCHITECTURE.md:299](docs/ARCHITECTURE.md) |
## 7. Pre-merge checklist
Run this against any patch before requesting review.
```text
┌─ LAYER ──────────────────────────────────────────────────────────────┐
│ Which layer does my code live in (Presentation / Application / │
│ Data Plane / Adapters / Infrastructure)? │
│ Does any new #include reach upward? If yes, refactor through DataHub │
│ or a typed event before merging. │
└───────────────────────────────────────────────────────────────────────┘
┌─ STATE OWNERSHIP ────────────────────────────────────────────────────┐
│ Am I touching fincept credentials? Read via AuthManager::session(); │
│ never cache a copy. │
│ Am I touching schema? Add v032_*.cpp; never edit an applied file. │
│ Am I touching SecureStorage? Confirm Database::open() runs first. │
│ Am I caching data in a screen? Move it to CacheManager via DataHub. │
└───────────────────────────────────────────────────────────────────────┘
┌─ LIFECYCLE ──────────────────────────────────────────────────────────┐
│ New screen → register_factory (lazy), not register_screen (eager). │
│ State to survive restart → implement IStatefulScreen. │
│ Async work → co_await via QCoro (new code) or signals (existing). │
│ Long DB / HTTP / Python call → not on the UI thread. │
└───────────────────────────────────────────────────────────────────────┘
┌─ SECURITY ───────────────────────────────────────────────────────────┐
│ New credential to persist → SecureStorage, not SettingsRepository. │
│ New API key env var for Python → add to the whitelist explicitly. │
│ No platform keychain reintroduction. │
└───────────────────────────────────────────────────────────────────────┘
```
## Summary
The page-level rule is simple: every layer in Fincept Terminal has one canonical owner of state and one canonical way to be called. `AuthManager::session()` owns the live fincept session; `SecureStorage` owns encrypted credentials and refuses to run before `Database::open()`; the migration ladder owns the schema and only moves forward; `DockScreenRouter` owns screen lifetime via factories. Violations of these invariants compile cleanly and fail at runtime in ways that are hard to debug, which is why the architecture doc lists them as anti-patterns and why this page exists as a checklist. When in doubt, ask which layer you are in, which canonical owner of state you should be calling, and whether your patch would survive being merged behind someone else's that already broke one of the rules above.
Sources: [docs/ARCHITECTURE.md:438-448](docs/ARCHITECTURE.md), [fincept-qt/src/auth/AuthManager.h:14-34](fincept-qt/src/auth/AuthManager.h), [fincept-qt/src/storage/secure/SecureStorage.cpp:190-281](fincept-qt/src/storage/secure/SecureStorage.cpp)
---