> ## Documentation Index
> Fetch the complete documentation index at: https://jephalabs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Architecture

> QTG's 3-layer clean architecture — domain, application, infrastructure, and interfaces — with dependency rules and bootstrap sequence.

# Architecture: 3-Layer Structure

## Why separate into layers?

"Can't we just put everything in one file?" — that's faster when vibe coding. But in a financial system like QTG:

1. **What if an exchange API changes?** → Only touch infrastructure (no changes to domain/application)
2. **Want to test without running real withdrawals?** → Swap infrastructure with a fake
3. **Want to add a new bridge (deBridge)?** → Just add a new executor to infrastructure

This is the essence of **Dependency Inversion**. Expensive rules (domain) know nothing about cheap implementation details (infrastructure).

***

## 3-Layer Architecture: The company org chart analogy

Mapping QTG to a company structure:

```mermaid theme={null}
flowchart TB
    subgraph interfaces["Entry Points (Interfaces)"]
        direction LR
        API["REST API<br/>accept HTTP requests"]
        WORKERS["Workers<br/>background loops"]
        TOOLS["Tools<br/>CLI utilities"]
    end

    subgraph application["Orchestration (Application Services)"]
        direction LR
        MOV["Movement lifecycle<br/>create/approve/reject"]
        DISP["Dispatch<br/>node dispatch"]
        OBS["Observe<br/>state observation"]
        REC["Recover<br/>recovery"]
        ADV["Advance<br/>state advance"]
        REG["Registry<br/>registry mgmt"]
    end

    subgraph domain["Core Rules (Domain)"]
        direction LR
        STATES["State machine<br/>transition rules"]
        TYPES["Domain types"]
        ERRORS["Error taxonomy"]
        PROTO["Protocol contracts"]
    end

    subgraph infrastructure["Implementors (Infrastructure)"]
        direction LR
        EXEC["Executors<br/>CEX, CCTP, Observe"]
        SIGN["Signers<br/>Noop, Local AWS KMS<br/>Remote(deferred)"]
        DB["Persistence<br/>ORM, Repository"]
        CB["Callbacks<br/>Outbox, Signing"]
        BOOT["Bootstrap<br/>initialization"]
    end

    interfaces --> application
    application --> domain
    application --> infrastructure
    infrastructure -.->|"domain types only"| domain

    style interfaces fill:#e1f5fe
    style application fill:#fff3e0
    style domain fill:#e8f5e9
    style infrastructure fill:#fce4ec
```

### Role of each layer

| Layer              | Analogy            | Role                  | Can do                                                                             | Cannot do                                     |
| ------------------ | ------------------ | --------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------- |
| **Domain**         | Corporate charter  | Define rules          | Determine valid state transitions, define types/errors, declare protocol contracts | DB access, HTTP calls, external I/O           |
| **Application**    | Department manager | Orchestration         | Compose services, update state, control business flow                              | Direct HTTP calls (must go through executors) |
| **Infrastructure** | Worker             | Actual I/O            | Call exchange APIs, DB CRUD, send callbacks, sign transactions                     | Make business rule decisions                  |
| **Interfaces**     | Reception desk     | External connectivity | HTTP routing, worker loop execution, CLI tools                                     | Business logic (delegated to services)        |

<Note>
  Inbound HMAC authentication runs in the interfaces layer. This layer's responsibility is to **transform requests into trusted internal commands and hand them off to application** — not to make business decisions.
</Note>

<Tip>
  **Interfaces and Infrastructure are "adapters that attach to application."** Interfaces are inbound adapters (external → internal), Infrastructure are outbound adapters (internal → external). Application acts as the hub.
</Tip>

***

## Dependency rules: who can import whom?

```mermaid theme={null}
flowchart LR
    I["Interfaces"] -->|import| A["Application"]
    A -->|import| D["Domain"]
    A -->|import| INF["Infrastructure"]
    INF -->|"domain types only<br/>import"| D

    I x-->|"no direct import"| D
    I x-->|"no direct import"| INF
    INF x-->|"no import"| A

    style D fill:#e8f5e9
    style A fill:#fff3e0
    style INF fill:#fce4ec
    style I fill:#e1f5fe
```

### Concrete dependency rules

* **Domain** has no external dependencies. It is pure business logic — no DB, no HTTP, no framework imports.
* **Infrastructure** may reference domain types (states, execution context, protocols) but never reaches "up" into application orchestration.
* **Application** is the only layer that combines both: it follows domain rules and drives infrastructure tools (executors, persistence) to carry them out.
* **Interfaces** delegate to application services only. They never reach directly into persistence or other infrastructure internals.

<Warning>
  QTG v3 follows a **practical 3-layer** direction. Rather than a separate abstract-port layer, application directly receives infrastructure models and sessions in a **Light DI** structure. It is not strict hexagonal architecture, but the core dependency direction is respected.
</Warning>

### Why does domain know nothing about infrastructure?

Consider this analogy. The constitution (domain) says:

> "A withdrawal request state can only transition in this order: PENDING\_APPROVAL → APPROVED → EXECUTING → COMPLETED."

This rule **does not change** regardless of whether the Upbit API changes, PostgreSQL gets replaced by MongoDB, or callbacks are sent to Slack. That is exactly why domain should know **nothing** about such implementation details.

```mermaid theme={null}
flowchart TB
    subgraph stable["rarely changes"]
        D["Domain<br/>state transitions<br/>error taxonomy<br/>protocol contracts"]
    end
    subgraph sometimes["changes sometimes"]
        A["Application<br/>business flow<br/>orchestration"]
    end
    subgraph often["changes often"]
        INF["Infrastructure<br/>exchange API changes<br/>new DB schemas<br/>new executor/signer"]
        I["Interfaces<br/>new API endpoints<br/>worker config changes"]
    end

    style stable fill:#e8f5e9
    style sometimes fill:#fff3e0
    style often fill:#fce4ec
```

Dependencies must always flow in only one direction: **things that change often → things that rarely change**. If this is reversed, you get situations like "changing one Upbit API call breaks domain code."

***

## Bootstrap sequence: what happens when the app starts?

When the server starts, initialization proceeds in this order:

```mermaid theme={null}
sequenceDiagram
    participant UV as Server start
    participant APP as App factory
    participant BOOT as Runtime bootstrap
    participant REG as Executor/Probe Registry
    participant LIFE as Lifespan
    participant DB as Database
    participant WORKERS as Worker Tasks

    UV->>APP: create app instance
    APP->>BOOT: bootstrap runtime (settings)

    Note over BOOT: Phase 1 — signers + CEX + observe + capital transfer
    BOOT->>REG: register local / remote / extra signers
    BOOT->>REG: register built-in CEX executors
    Note right of REG: withdrawal action<br/>withdrawal observe<br/>deposit observe
    BOOT->>REG: register capital-transfer executor (Binance)
    BOOT->>REG: register built-in observe executors
    Note right of REG: chain receive<br/>chain finality<br/>protocol observe

    Note over BOOT: Phase 2 — CCTP probe + balance fetchers
    BOOT->>REG: register CCTP attestation probe
    Note right of REG: Circle attestation API
    BOOT->>REG: register balance fetchers (CEX + EVM + Gateway)

    Note over BOOT: Phase 3 — bridge + EVM lanes
    alt MG_CCIP_ENABLED
        BOOT->>REG: register CCIP executors (sidecar)
    end
    BOOT->>REG: register CCTP + EVM erc20 executors
    BOOT->>REG: register EVM receive / finality probes
    BOOT->>REG: register Hyperliquid executors

    Note over BOOT: Phase 4 — Pro + Gateway + USDT0
    BOOT->>REG: register Pro lanes via sanctioned boundary
    Note right of REG: Pro lanes (e.g. Stargate)
    opt Gateway configured
        BOOT->>REG: register Gateway executors
    end
    opt MG_USDT0_ENABLED
        BOOT->>REG: register USDT0 / LayerZero executors
    end

    APP->>APP: mount routes (health, dashboard, whoami, audit,<br/>templates, movements, registry, verify-drift,<br/>agent-wallet topups, hyperliquid, ccip registry,<br/>admin cutover, admin signers, ...)
    APP->>APP: register audit descriptors for mutating routes

    UV->>LIFE: enter lifespan
    LIFE->>DB: validate schema invariants
    LIFE->>BOOT: bootstrap runtime (settings)
    LIFE->>DB: sync registry to DB (per network)

    alt MG_WORKERS_ENABLED = true
        LIFE->>WORKERS: start 8 always-on workers + conditional sidecars (per network)
        Note right of WORKERS: always-on: node_dispatcher (2s)<br/>node_observer (5s) / node_recovery (60s)<br/>callback_dispatcher (3s) / expiration_checker (30s)<br/>template_proposal_expiration / executor_health (60s)<br/>audit_outbox_promoter (1s)<br/>conditional: 9 flag-gated sidecars
    end

    Note over LIFE: yield — server running

    LIFE->>WORKERS: cancel + gather on shutdown
```

### What runtime bootstrap does

```mermaid theme={null}
flowchart TB
    START["runtime bootstrap (settings)"] --> SIGN["register signers<br/>(local KMS / remote / extra)"]
    SIGN --> CEX["register CEX executors<br/>(withdrawal action, withdrawal observe, deposit observe)"]
    CEX --> CAP["register capital-transfer executor<br/>(Binance master-signed)"]
    CAP --> OBS["register observe executors<br/>(chain receive, chain finality, protocol)"]
    OBS --> CCTP_PROBE["register CCTP attestation probe"]
    CCTP_PROBE --> FETCH["register balance fetchers<br/>(CEX + EVM + Gateway)"]

    FETCH --> CCIP{"CCIP enabled?"}
    CCIP -->|Yes| CCIP_EXEC["register CCIP executors<br/>(sidecar client)"]
    CCIP -->|No| EVM
    CCIP_EXEC --> EVM["register CCTP + EVM erc20 executors<br/>+ EVM receive/finality probes<br/>+ Hyperliquid executors"]

    EVM --> PRO["register Pro lanes<br/>(via sanctioned boundary — e.g. Stargate)"]
    PRO --> GW{"Gateway configured?"}
    GW -->|Yes| GW_EXEC["register Gateway executors"]
    GW -->|No| USDT0
    GW_EXEC --> USDT0{"USDT0 enabled?"}
    USDT0 -->|Yes| USDT0_EXEC["register USDT0 / LayerZero executors"]
    USDT0 -->|No| DONE["done"]
    USDT0_EXEC --> DONE

    style START fill:#e1f5fe
    style CCIP fill:#fff3e0
    style GW fill:#fff3e0
    style USDT0 fill:#fff3e0
    style PRO fill:#f3e5f5
    style DONE fill:#e8f5e9
```

<Tip>
  Each lane is **configuration-gated**: CCIP only loads when `MG_CCIP_ENABLED` is set, Gateway only when its API base URL is configured, USDT0 only when `MG_USDT0_ENABLED` is set. Pro lanes (such as Stargate) load only through the single sanctioned boundary between the Free core and the Pro package — Free code never reaches into the Pro package directly. In a CEX-only deployment none of the bridge code is registered.
</Tip>

***

## Each layer in detail

### Domain Layer — "These are the laws of physics"

Domain holds **business rules only**. It knows absolutely nothing about DB or HTTP.

```mermaid theme={null}
classDiagram
    class RequestState {
        <<enum>>
        RECEIVED
        VALIDATED
        PENDING_APPROVAL
        APPROVED
        EXECUTING
        WAITING_MANUAL_ACTION
        COMPLETED
        FAILED
        MANUAL_INTERVENTION
        REJECTED
        EXPIRED
        CANCELLED
    }

    class NodeState {
        <<enum>>
        BLOCKED
        READY
        PREPARING
        AWAITING_SIGNATURE
        SUBMITTING
        SUBMITTED
        OBSERVING
        COMPLETED
        FAILED
        UNKNOWN
        CANCELLED
        SKIPPED
    }

    class ExecutorProtocol {
        <<protocol>>
        +executor_key: str
        +preflight(context) ExecutionResult?
        +prepare(context) ExecutionResult
        +submit(context, action) ExecutionResult
        +observe(context) ExecutionResult
        +recover(context) ExecutionResult
        +health() dict?
    }

    class SignerProtocol {
        <<protocol>>
        +signer_key: str
        +sign(request) SignResult
        +health() dict?
    }

    class MovementError {
        +message: str
        +detail: object?
    }
    class TemporaryMovementError
    class FatalMovementError
    class AmbiguousMovementError

    MovementError <|-- TemporaryMovementError
    MovementError <|-- FatalMovementError
    MovementError <|-- AmbiguousMovementError
```

The domain layer owns four kinds of rules:

| Concern            | Role                                            | Why it belongs in domain                                 |
| ------------------ | ----------------------------------------------- | -------------------------------------------------------- |
| State machine      | 12+12 states, transition table                  | "Which state can transition to which" is a business rule |
| Domain types       | Transport family, chain family, node kind, etc. | Domain vocabulary                                        |
| Error taxonomy     | Temporary/Fatal/Ambiguous error classification  | Error handling policy is a business decision             |
| Protocol contracts | Executor, Signer, and Probe contracts           | "What an executor must do" is a business contract        |

<Tip>
  The executor contract is defined as a structural-typing protocol. This is similar to Java's `interface`, except **implementations don't even need to import domain**. They just need to match the method signatures. This is true dependency inversion.
</Tip>

### Application Layer — "I only give directions"

Application follows domain rules and **composes** infrastructure tools to create business flows.

```mermaid theme={null}
flowchart TB
    subgraph services["Application Services"]
        MOV["Movement lifecycle<br/>────────<br/>create<br/>approve<br/>reject<br/>resume<br/>retry<br/>cancel<br/>expire pending requests"]

        DISP["Dispatch<br/>────────<br/>dispatch ready nodes<br/>preflight check<br/>prepare → sign → submit"]

        OBS["Observe<br/>────────<br/>observe active nodes<br/>SUBMITTED → OBSERVING<br/>completion detection"]

        REC["Recover<br/>────────<br/>recover unknown nodes<br/>UNKNOWN → recovery attempt"]

        ADV["Advance<br/>────────<br/>set node state<br/>set request state<br/>derive request state<br/>advance after completion<br/>build execution context"]

        REG["Registry<br/>────────<br/>executor/signer health<br/>DB sync"]
    end

    MOV -->|"delegate state transition"| ADV
    DISP -->|"delegate state transition"| ADV
    OBS -->|"delegate state transition"| ADV
    REC -->|"delegate state transition"| ADV

    style ADV fill:#fff3e0
```

<Info>
  **State advance is the central hub.** Nearly every service changes state through this one path. This embodies **Single Responsibility** — "state transitions and event recording happen in one place." Every time state changes, a movement event is automatically recorded, so the audit trail is always complete.
</Info>

### Infrastructure Layer — "I do the actual work"

Infrastructure handles **real I/O**. Every point of contact with the outside world — DB queries, HTTP calls, file reads — lives here.

```mermaid theme={null}
flowchart TB
    subgraph executors["Executors"]
        CEX["CEX Lane<br/>3-node"]
        CCTP["CCTP / CCIP / USDT0<br/>bridge lanes"]
        GW["Gateway / Hyperliquid<br/>lanes"]
        OBS["Observe Family<br/>3-probe"]
    end

    subgraph adapters["Exchange clients"]
        UPB["Upbit (Free)"]
        BIN["Binance (Free)"]
        BYB["Bybit (Free)"]
        CBA["Coinbase (Free)"]
        OKX["OKX (Free)"]
        BTH["Bithumb (Pro)"]
    end

    subgraph probes["Observers"]
        EVM_R["EVM Receive Probe<br/>JSON-RPC"]
        EVM_F["EVM Finality Probe<br/>JSON-RPC"]
        IRIS["CCTP Attestation Probe<br/>Circle API"]
    end

    subgraph storage["Persistence"]
        MODELS["ORM Models"]
        SESSION["Async Session"]
        REPOS["Repositories"]
    end

    CEX --> UPB
    CEX --> BIN
    CEX --> BYB
    CEX --> CBA
    CEX --> OKX
    CEX -.->|"Pro boundary"| BTH
    OBS --> EVM_R
    OBS --> EVM_F
    OBS --> IRIS

    style executors fill:#fce4ec
    style adapters fill:#f3e5f5
    style probes fill:#e1f5fe
    style storage fill:#e8f5e9
```

<Note>
  **Open-Core executor boundary.** QTG ships as Open Core. The Free package registers all of the lanes above at startup — CEX (Upbit, Binance, Bybit, Coinbase, OKX), CCTP, CCIP, EVM erc20, Gateway, USDT0/LayerZero, and Hyperliquid topup. **Pro** lanes (the Bithumb adapter and the Stargate bridge) are part of the separate Pro package and are registered through a single sanctioned boundary. Free code never reaches into the Pro package directly — that one graceful loader is the only door, and the boundary is mechanically enforced.
</Note>

### Interfaces Layer — "I'm the doorman"

Interfaces should be **as thin as possible** — receive the request, delegate to the application service, format the result, and return it.

```mermaid theme={null}
flowchart LR
    subgraph api["API Routes (thin)"]
        direction TB
        H["GET /healthz"]
        T["POST/GET /v3/plan-templates"]
        M["POST/GET /v3/movements"]
        R["GET/PATCH /v3/executors<br/>GET/PATCH /v3/signers"]
    end

    subgraph workers["Workers (thin wrapper)"]
        direction TB
        W1["node_dispatcher<br/>→ dispatch ready nodes"]
        W2["node_observer<br/>→ observe active nodes"]
        W3["node_recovery<br/>→ recover unknown nodes"]
        W4["callback_dispatcher<br/>→ dispatch pending callbacks"]
        W5["expiration_checker<br/>→ expire pending approvals"]
        W6["executor_health<br/>→ refresh executor/signer health"]
        W7["template_proposal_expiration<br/>→ expire stale proposals"]
        W8["audit_outbox_promoter<br/>→ promote audit rows"]
        WC["conditional sidecars<br/>(verify_drift, balance_snapshot,<br/>evm_nonce_reaper, ...)"]
    end

    api -->|"create session then<br/>call service"| SVC["Application Services"]
    workers -->|"loop runner<br/>periodic call"| SVC

    style api fill:#e1f5fe
    style workers fill:#e1f5fe
```

<Warning>
  **Workers contain no business logic.** Each worker does nothing more than call the matching application service on a loop — `node_dispatcher`, for example, just dispatches ready nodes. Business logic in application, loop execution in interfaces — this separation makes testing easy. You can test the underlying service directly, without a running worker.
</Warning>

***

## Worker loop pattern

All workers share the same loop-runner utility:

```mermaid theme={null}
flowchart TB
    START["loop runner (name, service, interval)"] --> LOOP{"while running"}
    LOOP --> SESSION["create async session"]
    SESSION --> CALL["call the service"]
    CALL --> RESULT{"handle result"}
    RESULT -->|"success"| RESET["reset error counter"]
    RESULT -->|"failure"| COUNT["increment error counter"]
    COUNT --> CHECK{"errors >= 5?"}
    CHECK -->|"Yes"| BACKOFF["exponential backoff<br/>max 60s"]
    CHECK -->|"No"| SLEEP
    RESET --> SLEEP["sleep(interval + jitter)"]
    BACKOFF --> LOOP
    SLEEP --> LOOP

    CALL -->|"cancellation"| STOP["log and exit"]

    style START fill:#e1f5fe
    style STOP fill:#ffcdd2
```

There are **8 always-on workers** (started whenever `MG_WORKERS_ENABLED` is set) plus **9 conditional sidecars** that start only when their feature flag is on:

| Worker                         | Type        | Default interval | Role                                                                    |
| ------------------------------ | ----------- | ---------------- | ----------------------------------------------------------------------- |
| `node_dispatcher`              | always-on   | 2 seconds        | Execute READY nodes                                                     |
| `node_observer`                | always-on   | 5 seconds        | Poll SUBMITTED/OBSERVING nodes                                          |
| `node_recovery`                | always-on   | 60 seconds       | Recover UNKNOWN nodes                                                   |
| `callback_dispatcher`          | always-on   | 3 seconds        | Dispatch pending callbacks                                              |
| `expiration_checker`           | always-on   | 30 seconds       | Check approval TTL expiration                                           |
| `template_proposal_expiration` | always-on   | —                | Expire stale template proposals                                         |
| `executor_health`              | always-on   | 60 seconds       | Refresh executor/signer health                                          |
| `audit_outbox_promoter`        | always-on   | 1 second         | Copy `audit_outbox` rows into `audit_events`                            |
| `evm_nonce_reaper`             | conditional | —                | Reclaim stranded EVM nonce reservations (`MG_EVM_NONCE_REAPER_ENABLED`) |
| `ccip_stranded_reaper`         | conditional | —                | Reclaim stranded CCIP sends (`MG_CCIP_STRANDED_REAPER_ENABLED`)         |
| `capital_transfer_followup`    | conditional | —                | Follow up pending capital transfers                                     |
| `balance_snapshot`             | conditional | 30 seconds       | Periodic balance snapshots                                              |
| `balance_history_rollup`       | conditional | —                | Roll snapshots into balance history (`MG_BALANCE_HISTORY_ENABLED`)      |
| `cex_transfer_ingest`          | conditional | —                | Ingest exchange transfers (`MG_CEX_TRANSFER_INGEST_VENUES_CSV`)         |
| `notification_dispatcher`      | conditional | —                | Deliver operator notifications (`MG_NOTIFY_ENABLED`)                    |
| `verify_drift`                 | conditional | —                | Structural registry drift verification (`MG_VERIFY_DRIFT_ENABLED`)      |
| `ccip_drift`                   | conditional | —                | CCIP path drift verification (sidecar)                                  |

<Info>
  `evm_nonce_reaper` and `ccip_stranded_reaper`, when enabled, join the **core fail-fast group** alongside the always-on workers; the remaining conditional workers run as independent sidecars. Workers run for the **process-selected** network class only (`MG_NETWORK_MODE`), and seed their heartbeats for that one. Serving mainnet and testnet means two separate deployments.
</Info>

<Tip>
  Errors *inside* a worker's loop are caught and backed off exponentially, so a loop never wedges. A worker that escapes its loop is a different case: a **sidecar** is logged and the rest keep running, while a **core** worker tears down the whole worker set. There is no automatic restart — the process must be restarted.
</Tip>

For the design rationale — why workers poll Postgres instead of being driven by an external scheduler like Airflow, how row-level locking lets you run safe worker replicas, how each node carries its own next-observe deadline instead of needing a retry queue, and how the core-vs-sidecar tiering works — see [Worker Orchestration](/concepts/orchestration).

***

## Configuration

All runtime configuration comes from environment variables. Every variable uses the `MG_` prefix, values can also be supplied via a `.env` file, and unknown variables are ignored.

| Environment variable        | Default                       | Purpose                                                      |
| --------------------------- | ----------------------------- | ------------------------------------------------------------ |
| `MG_NETWORK_MODE`           | **required — no default**     | `mainnet` or `testnet`. The server refuses to boot if unset. |
| `MG_DATABASE_URL`           | `postgresql+asyncpg://...`    | DB connection (PostgreSQL only)                              |
| `MG_WORKERS_ENABLED`        | `false`                       | Whether to start workers                                     |
| `MG_APPROVAL_TTL_SECONDS`   | `300` (5 min)                 | Approval wait TTL                                            |
| `MG_EVM_RPC_ENDPOINTS_JSON` | `{}`                          | RPC URL per EVM chain                                        |
| `MG_CCIP_ENABLED`           | `false`                       | Enable the CCIP bridge lane + sidecar                        |
| `MG_USDT0_ENABLED`          | `false`                       | Enable the USDT0 / LayerZero bridge lane                     |
| `MG_VERIFY_DRIFT_ENABLED`   | `false`                       | Enable the structural drift-verification worker              |
| `MG_CCTP_IRIS_BASE_URL`     | `https://iris-api.circle.com` | CCTP attestation API                                         |
| `MG_UPBIT_ACCESS_KEY`       | (none)                        | Upbit API key                                                |
| `MG_BINANCE_ACCESS_KEY`     | (none)                        | Binance API key                                              |

<Warning>
  **The runtime is network-class split and process-scoped.** PostgreSQL holds a `public` schema (api clients, nonce registry, global alembic version) plus the selected network schema (`mainnet` or `testnet`) with its own alembic head. `MG_NETWORK_MODE` selects the one network class this process serves; database sessions, registry sync, heartbeat seeding, and workers use that network. At startup the server rejects non-PostgreSQL URLs and validates `public` plus the selected schema and heads before accepting traffic; it does not require or query the unselected bucket. Fresh databases run the global Alembic chain followed by the selected `network_chain@head` — schema is never auto-created from ORM metadata. The in-memory engine registry can still represent both `NetworkClass` values without both physical schemas being present.
</Warning>

***

## Summary: the value of this architecture

| If...                | Without 3-layer                | With 3-layer                              |
| -------------------- | ------------------------------ | ----------------------------------------- |
| Binance API changes  | Fix code scattered everywhere  | Only the Binance adapter                  |
| Add a new bridge     | "Where do I put this?"         | Add one new executor in infrastructure    |
| State transition bug | "Where did the state change?"  | Just look at the single state-advance hub |
| Writing tests        | Need real exchange integration | Independent tests with a fake executor    |
