# 架构：五层 → 代码目录的逐层映射

> **实现状态（2026-07-27）**：本文描述目标架构。当前已落地控制平面的模型、
> 安全门禁、事件记录、Run 转移表、恢复决策、最小证据包、内置模拟执行器，以及
> 独立 Host Agent 的注册、token、心跳和 Host 状态老化链路；Step 租约、Adapter
> 执行、消息总线替换和生产对象存储仍未落地。

方案 §5.1 定义了五层架构。这里给出每一层**落到哪个目录、边界在哪、谁不能调用谁**。

```
┌─────────────────────────────────────────────────────────────┐
│ 交互与集成层   apps/console (Next.js) · CLI · Open API       │
├─────────────────────────────────────────────────────────────┤
│ 控制平面      services/control-plane/flashops_control/       │
│               services/ 编排  safety/ 门禁  api/ 接口        │
├─────────────────────────────────────────────────────────────┤
│ 执行平面      services/host-agent/flashops_agent/            │
│               adapters/ 工具适配器                            │
├─────────────────────────────────────────────────────────────┤
│ 带外平面      services/host-agent/flashops_agent/oob/        │
│               （独立进程/独立网络，模拟实现在 simulator/）      │
├─────────────────────────────────────────────────────────────┤
│ 证据与智能层  control-plane/.../evidence/ · signatures/      │
│               events/ 事件总线                                │
└─────────────────────────────────────────────────────────────┘
```

## 依赖方向（单向，不可逆）

```
console ──HTTP/SSE──▶ control-plane ◀──HTTP──── host-agent
                            │                        │
                            │                        ├─▶ adapters ─▶ 客户工具
                            ▼                        │
                          数据库                      └─▶ oob ─▶ 带外控制器
```

**硬规则：**

- `host-agent` **不得**导入 `control-plane` 的任何模块。两者只通过 `docs/agent-protocol.md`
  定义的 HTTP 契约通信。理由：Agent 要能单文件分发到客户 Windows 机器，
  控制平面代码不进客户测试主机。
- `control-plane` **不得**主动连接 Agent（Agent 是拉模式）。理由：客户测试主机在
  内网/NAT 后面，反向连接在真实实验室里活不下来。
- `engine/` **不得**直接读写数据库。它只操作 `Snapshot` 值对象，返回 `Effect` 列表。
  持久化由 `runtime.py` 施加。理由：状态机要能不起数据库单测。
- `api/` **不得**包含业务判断。路由只做校验 + 调用 `engine`/`services`。
- 任何危险动作（刷写、Format、Sanitize、断电）**必须**先过 `safety/`，
  且 `safety/` 的拒绝发生在下发给 Agent 之前。

## 控制平面内部分层

```
flashops_control/
├─ config.py          配置（产品名、DSN、路径、超时策略）
├─ db.py              引擎/会话/建表
├─ models/            SQLAlchemy 表定义（贫血模型，只管持久化）
├─ schemas.py         Pydantic 出入参（当前最小 API 契约）
├─ events/
│  ├─ bus.py          进程内事件总线（生产换 NATS/Redis，接口不变）
│  └─ recorder.py     事件溯源写入：唯一允许改 Run 状态的入口
├─ engine/
│  ├─ states.py       Run/Step 状态枚举 + 转移表（纯数据）
│  ├─ recovery.py     五级恢复阶梯策略（纯函数）
│  └─ （目标）planner/runtime：独立 Agent 接入后补齐
├─ safety/
│  ├─ gates.py        DUT 绑定 / 系统盘保护 / 破坏性白名单
│  └─ templates.py    签名命令模板注册表 + 参数 schema 校验
├─ signatures/        （目标）失败签名（8 要素哈希）与聚类
├─ evidence/          最小 Evidence Bundle（manifest + 事件流）
├─ services/runs.py   当前内置模拟编排（后续拆成 Agent + runtime）
├─ seed.py            可重复执行的模拟资产与工作流
└─ api/               FastAPI 路由
```

**`engine/` 的纯度是刻意的。** 当前 `states.py` / `recovery.py`
不导入 SQLAlchemy、不导入 FastAPI、不做 I/O、不看时钟
（时间从 `Snapshot.now` 传入）。这让状态机的全部分支都能用普通 pytest 覆盖，
不需要起库、不需要 sleep。后面几波会不断往状态机里加分支（新故障类型、
新恢复策略、多工位调度），这条边界是唯一能防止它烂掉的东西。

## 开发形态 vs 生产形态

| 组件 | 开发（`make dev`） | 生产（`docker-compose`） | 换的时候动哪 |
|---|---|---|---|
| 数据库 | SQLite (aiosqlite) | PostgreSQL 16 | 只改 `DATABASE_URL` |
| 队列/总线 | 进程内 asyncio | Redis / NATS | `events/bus.py` 换实现 |
| 对象存储 | `var/objects/` 本地目录 | MinIO | `storage.py` 换实现 |
| Agent | 内置模拟执行器 + 独立 Agent 心跳 | 客户主机上的 systemd/Windows 服务 | 补齐 Step lease/runtime |
| 带外 | 模拟控制器 | 树莓派 / JetKVM / 智能 PDU | `oob/` 换实现 |

三处"换实现"都在接口后面，且开发态实现本身就是测试替身——不存在"生产才发现跑不通"。

## 为什么不用 Temporal / Airflow

见 [ADR-0003](decisions/0003-own-state-machine-over-temporal.md)。一句话：
恢复语义（带外断电、冻结现场、误盘防护）是本产品的差异化本身，
不能交给通用工作流引擎的重试模型。等到多工位跨节点调度成为瓶颈时再评估 Temporal，
`runtime.py` 是唯一需要替换的文件。
