# ADR-0023: flow 执行审计持久化 -- flow_dispatches 一行一执行 + 租户作用域读面 (INF-4) - **Status**: Accepted (PM 2026-07-13 "我要的结果": flytoCall 调用要有记录, 能看到工作流和调用者) - **Date**: 2026-07-13 - **Consumers**: 平台运维 (谁在调 / 调了什么 / 花了多久 / 结果如何) · flytoCall (排查自己的调用) · P2 前端 runtime 视图 (dispatches 列表 + SSE 重放) · 未来合规回放 - **关联**: ADR-0020 (具名 flow 执行器, 本 ADR 的写入点) · ADR-0022 (租户上下文 + subject, 本 ADR 的身份来源) · ADR-0017 (db.platformMigrations 模式) · ADR-0002 (REST 业务通道) ## 1. 背景 / Context PM 问 "flytoCall 调平台工作流, 有记录么? 能看到这条工作流和调用者么?" -- 核实后的答案是不能: `handleFlowRun` 从收到请求到返回零业务日志零落库, 唯一留痕是通用 HTTP access log (stdout, 只有 path/status/耗时/IP). `dispatches` 读端点 (`GET /api/v1/dispatches*`) 是 P2 stage-1 stub 返回硬编码假数据. 调用者身份更是根本没读: tenant 只用于 provider 解析, subject 从未被触碰. trace_id 在响应体里给了消费者, 平台自己却不留 -- 消费者拿着 trace_id 来问, 平台无从查起. 这在发布前必须补上 (INF-4 原排 P2, PM 拍板提前). ## 2. 决策 / Decision ### 2.1 flow_dispatches 表: 一次执行一行, 身份 + 调用 + 过程 + 结果 `db.platformMigrations` 追加 `flow_dispatches`: `trace_id` 主键 (执行前生成, 消费者看到的就是落库的) / 身份 `tenant_id` + `subject` (OIDC sub, 未鉴权为空 -- R1 双轨现实) / 调用 `flow_name` + `flow_version` + `provider_instance` + `model` + `idempotency_key` / 过程 `input` (JSONB) + `raw_output` (模型经 layer-3 校验前原始文本, 复盘 "模型到底说了什么") / 结果 `status` (ok, invalid_output, error, rejected) + `output` (JSONB) + `error` / 计时 `started_at` + `finished_at` + `duration_ms`. 索引 (tenant_id, started_at DESC) + (flow_name, started_at DESC). ### 2.2 dispatchstore 包 (镜像 sessionstore) + fail-open 写入 `internal/server/dispatchstore`: `Store` 接口 (Record / List / Get) + `PostgresStore` (--postgres-dsn 时接线) + `InMemoryStore` (dev 回落, 有界 ring 10k 防泄). Record 幂等 (ON CONFLICT DO NOTHING, 首写胜出). handler 四个出口全记: provider 解析拒绝 (rejected) / 引擎失败 (error) / layer-3 闸失败 (invalid_output) / 成功 (ok). 写入 **fail-open**: 审计失败只记日志绝不影响业务响应; 且用脱离请求的 5s context -- 引擎错误常常就是客户端取消, 已取消的 ctx 不能连审计一起杀. ### 2.3 读面: stub 转正, 租户隔离 - `GET /api/v1/dispatches`: 本租户执行历史, 最新在前, summary 不含 payload (列表轻), 过滤 since/limit. - `GET /api/v1/dispatches/{id}` (新增): 单条全量 -- input + output + raw_output, 复盘用. - `GET /api/v1/dispatches/{id}/events`: SSE 重放, 从落库行派生时间线 (started -> input -> raw_output -> result -> done) -- flow 执行是单发, 重放即行展开, 非采样事件流. - 三端点全按 `requestTenant` (ADR-0022) 隔离: 跨租户 trace_id 一律 404, 探不到别家 id. 未接 store 时 503, 不再返回假数据. ## 3. 替代方案 / Alternatives - **只加结构化日志不落库**: 回答不了 "上周谁调过什么" (日志轮转即失忆), 也撑不起 P2 前端 dispatches 视图. 否决. - **复用 audit 包 agent_events 表**: 那是引擎事件流 (per-session 细粒度), flow 执行是单发请求-响应, 硬塞进事件模型要发明伪 session; 且 agent_events 无租户列. 表模型不匹配, 否决. - **挂进 quote_dispatch_sessions**: 那是 billcost 专用 (xlsx blob / phase 状态机), 语义完全不同. 否决. - **审计写失败让请求 5xx (fail-closed)**: 审计是旁路, 让 Postgres 抖动打挂消费者业务调用得不偿失; 合规要求 fail-closed 时再加开关. 否决. - **记录 input/raw_output 做脱敏/截断**: v1 逐字存 (复盘价值就在原文), 数据保留/脱敏策略是运营决策, 见 §6. ## 4. 影响 / Consequences - **正面**: "谁在什么时候调了哪条 flow, 传了什么, 结果如何" 从查不到变一条 SQL/一个 GET; 消费者报 trace_id 可直查; P2 前端 dispatches 视图有真数据源; idempotency_key 落库为将来真去重 (INF-4 残余) 铺路. - **负面 / 债务**: input/raw_output 逐字存, 大 payload 表增长快 (JSONB TOAST 兜底, 保留策略见 §6); IdempotencyKey 仍只落库不去重; in-memory 档历史不跨重启 (prod 用 Postgres 无此问题); access log 与 dispatch 行暂无 request_id 关联. ## 5. 验证 / Validation - 单测 (-race 全绿): handler 写侧 (OK 行带身份/payload/trace_id 与响应一致 + 引擎失败行 status=error 无 output + nil store 不打断执行) / 读面 (无 store 503 x3 + list 只见本租户 + detail 含三 payload + SSE 五事件齐 + 跨租户 404) / store (Record 幂等 + List 排序/limit/since + Get 租户隔离). - prod 真调验证 (fastpush 后): 打一次真 flow, `GET /api/v1/dispatches` 看到该行, detail 对照响应 trace_id. ## 6. 触发重新评估的条件 / Trigger conditions - 表增长成本可见 (月百万行级) -> 按月分区 + 保留窗口 (运营定天数) + input/raw_output 截断策略. - 合规要求审计不可丢 -> fail-open 换 fail-closed 开关 + 写失败告警. - `idempotency_key` 真去重需求落地 -> 查 flow_dispatches 即可实现 (唯一索引 tenant_id + idempotency_key + 窗口). - flow 长出多步编排 (工具调用/多轮) -> 单行模型升级为 dispatch 主行 + 事件子表 (audit 包接缝). ## 7. 工程量 / Engineering footprint platform/common: 新 `internal/server/dispatchstore/` (接口 + postgres + inmem, 3 文件) + `internal/server/dispatches.go` (attach + 3 读 handler); 改 `internal/db/pool.go` (DDL) + `internal/server/flows_run.go` (4 出口织入 + fail-open helper) + `internal/server/server.go` (字段 + 路由) + `cmd/common/main.go` (接线) + 删 `server_p2_stubs.go` dispatch stub; 测试改 2 文件. core: 零改动. 零新依赖. ## 8. 修订记录 / Revision history - 2026-07-13 v1 Accepted: 表 + store + 四出口写入 + 三读端点落地, 全量 -race 绿.