BodySense StreamEvent Trust Boundary
解释 BodySense Consultation 为什么必须把网络 JSON 视为 unknown,并在进入 StreamEvent discriminated union、reducer 和 UI 前做 runtime validation;同时记录当前代码仍以类型断言代替验证的真实缺口。
[!info] related notes
- 所属 MOC: bodysense-moc、frontend-engineering-moc
- 相关概念: typescript-static-types-and-runtime-validation、zod-runtime-schema-validation
- 易混淆概念: compile-time type vs runtime trust
- 相关资源: bodysense-consultation-streaming-architecture
BodySense StreamEvent Trust Boundary
一句话定义
StreamEvent Trust Boundary 是把来自 SSE / durable event API 的不可信 JSON,从 unknown 解析并验证成可信 StreamEvent 的边界;只有越过这道边界的数据,才应该进入 reducer、selector 和 UI。
Network Bytes
→ Text Framing
→ JSON
→ unknown
→ runtime validation
→ StreamEvent
→ domain projection
为什么这是“安全/架构边界”,而不只是 TypeScript 最佳实践
一旦把网络对象标成:
StreamEvent
下游 reducer 会开始相信:
type合法;payload与type匹配;seq有合理语义;- IDs 可以用于状态关联;
- safety event 不会缺关键字段。
所以把 unknown 提升为 StreamEvent 本质上是在授予一组信任权限。
如果这个升级只靠:
as StreamEvent
就等于:
untrusted external data
→ developer says “trust me”
→ domain control flow
中间没有 runtime evidence。
因此它和认证、DTO validation、Agent evidence admissibility 是同一种架构思想:在边界处先证明,再进入可信核心。
TypeScript Union 解决了什么,又没有解决什么
BodySense contracts 定义了共享 StreamEvent discriminated union:
StreamEvent =
| ConversationCreatedEvent
| RunStartedEvent
| MessageTextDeltaEvent
| ToolCallEvent
| InteractionRequiredEvent
| RedFlagDetectedEvent
| StreamDoneEvent
| ...
它非常有价值,因为在已经可信的数据上:
switch (event.type) {
case "message.text.delta":
// event.payload 自动收窄
}
编译器可以帮助:
- exhaustiveness;
- payload 类型收窄;
- 重构;
- IDE 提示;
- reducer contract。
但 TypeScript 类型会在编译后消失。
所以:
const data = JSON.parse(text);
const event = data as StreamEvent;
不是 validation。
它只是在说:
“开发者要求编译器暂时相信我。”
Compile-time Safety 与 Runtime Safety 的边界
可以记成:
TypeScript
保护:你在写代码时不要把已知类型用错
Runtime Validator
保护:外部世界不要把坏数据伪装成内部类型
因此:
TypeScript static safety
≠
Runtime data integrity
两者缺一不可。
当前 BodySense 的真实状态
共享 contract 源码自己已经明确写了类似原则:
TypeScript declarations disappear at runtime;
as StreamEvent不会验证网络数据,validation 应发生在 network boundary。
但当前两个主要入口仍然存在 cast:
Live SSE
useSSEProcessor.ts:
SSE data line
→ JSON.parse
→ as StreamEvent
→ dispatch
Durable Recovery API
consultationService.listRunEvents():
HTTP JSON
→ parse ids/payload
→ construct object
→ as StreamEvent
因此当前 L3 应明确记录:
Shared static contract 已存在,但 network runtime validation 尚未真正闭合。
这是一条真实待补边界,而不是教学假设。
为什么网络数据必须默认 unknown
即使服务端也是我们自己写的,仍可能出现:
- server/client 版本漂移;
- rollout 中旧后端 + 新前端;
- malformed JSON payload;
- event type 拼写变化;
- nullable/optional 字段变化;
- replay 数据来自旧历史 schema;
- proxy / storage corruption;
- backend bug;
- 某个事件 payload 不满足当前 union。
如果直接 cast:
Invalid Event
→ Reducer assumes valid
→ state corrupted
→ UI weird failure
最终可能表现为:
“React 怎么又渲染错了?”
但真正的第一个 contract violation 在 network boundary。
这和 Agent Failure Attribution 的思想完全一致。
Network Bytes 到 Domain Event 中间其实有多个 Boundary
完整链路可以拆成:
Uint8Array bytes
→ TextDecoder
→ text buffer
→ event framing
→ JSON.parse
→ unknown JSON value
→ envelope schema
→ discriminant-specific schema
→ trusted StreamEvent
每一步可能有不同失败:
- UTF-8 chunk 边界;
- 一条 JSON 被拆成两个网络 chunk;
- 一 chunk 包含多个 records;
- JSON syntax invalid;
- JSON valid but schema invalid。
所以:
ReadableStream chunk
≠
protocol event
也不能把 framing 和 schema validation 混成一个“try JSON.parse”。
Chunk Boundary 为什么和 Event Boundary 不一样
例如服务端逻辑事件:
{"type":"message.text.delta","payload":{"delta":"你好"}}\n
网络可能分成:
Chunk 1: {"type":"message.text.del
Chunk 2: ta","payload":{"delta":"你好"}}\n
也可能:
Chunk 1:
{event1}\n{event2}\n{part-of-event3
所以 parser 需要:
buffer incomplete tail
split complete records
parse only complete records
这也是为什么“网络流式协议”不能只当成普通 response.text()。
Validation 应该验证哪几层
1. Envelope
至少:
version
seq
channel
type
ids
payload
例如:
version must be supported
seq must be finite positive/allowed integer
channel/type must be known
ids must be object
payload must be object or exact event schema
2. Discriminant
type = "message.text.delta"
决定后续 payload schema。
3. IDs
例如 text delta 至少应该有合理的:
conversation_id
run_id
message_id
不同事件要求可以不同。
4. Event-specific Payload
例如:
message.text.delta
→ payload.delta: string
state.interaction.required
→ interaction_id / tool_call_id / question / status
safety.red_flag.detected
→ safety payload schema
不能只验证 envelope,然后把 payload 留成任意 unknown 再传给 reducer。
Cross-field Invariant 也可能需要验证
Schema 不只检查字段类型。
例如:
tool.result
可能要求:
tool_call_id present
run_id present
result status legal
而:
stream.done
可能要求 terminal payload 结构。
这类“字段之间的关系”也是 protocol contract。
所以 validator 可能包含:
shape validation
+
semantic/refinement validation
Parse 与 Validate 要分开理解
JSON.parse
只回答:
这是不是合法 JSON 文本?
Runtime Schema Validation 回答:
这是不是我们当前协议认可的 StreamEvent?
二者不是同一件事。
例如:
{"type":"message.text.delta","payload":{"delta":123}}
这是合法 JSON,但不是合法的 text-delta event。
unknown 为什么优于 any
如果 parser 返回:
any
TypeScript 会允许你:
data.payload.foo.bar.baz
而不会要求证明。
如果返回:
unknown
你必须先做 narrowing/validation 才能使用。
所以 unknown 是一种很好的静态信任边界表示:它强迫开发者承认“我还不知道这个值是什么”。
Validation 失败时应该怎么办
不建议:
catch error
→ silently ignore everything
也不建议:
invalid event
→ let reducer crash mysteriously
更合理的是把失败分类:
ProtocolValidationError
├─ unsupported_version
├─ unknown_event_type
├─ malformed_ids
├─ malformed_payload
└─ schema_mismatch
然后根据风险:
- 记录 observability;
- 停止本轮 projection;
- 尝试 durable refetch/recovery;
- 如果协议不兼容,明确失败;
- 不把坏 event 当成业务 state。
对于 safety 事件尤其不能“验证失败就当没发生”。
[!warning] Fail-open 风险
red_flagevent malformed → ignore → UI continues normal flow,是危险的协议错误处理。
为什么未知 Event Type 不能总是“一律 crash”或“一律忽略”
协议演化需要明确 compatibility policy。
例如:
unknown presentation-only event
未来可以考虑忽略。
但:
unknown authority/safety/control event
可能必须 fail closed。
所以更成熟的 protocol 可以给 event 分类:
critical control channel
semantic channel
presentation/optional channel
兼容策略也可以版本化。
Static Contract 与 Runtime Schema 应该怎样保持一致
理想状态不是:
TS interface 手写一份
Zod schema 再手写一份
Go struct 再手写一份
然后靠记忆同步。
更好的目标是建立单一或可验证的 contract source,例如:
Schema source
→ TS types
→ runtime parser
→ Go contract tests
或者至少建立 cross-language contract tests,确保:
Go emitted fixture
→ frontend parser accepts
以及 malformed fixtures 会被拒绝。
生成 Type 与 Runtime Schema 的两种方向
Schema-first
JSON Schema / Zod-like schema
→ infer TypeScript type
→ generate/validate Go contract
Types + Contract Test
TS types
Go DTO
runtime schemas
+
shared fixtures/cross-language tests
不一定必须追求“一份文件生成全世界”,但必须有自动化防漂移机制。
Protocol Version 为什么是运行时语义
如果 envelope 有:
version = 1
客户端不能只拿它做展示。
它应该参与:
parser selection
compatibility policy
migration strategy
例如:
v1 supported → parse
v0 migrated → adapter
v2 unknown critical → explicit incompatibility
这比让历史 replay “碰巧还能 cast”安全得多。
Live 与 Replay 必须经过同一信任模型
不要出现:
Live SSE → strict validate
Replay API → unchecked cast
因为 durable replay 并不因为“来自数据库”就天然可信于当前 frontend schema。
历史数据甚至更容易遇到 schema drift。
因此:
Live transport
┐
├→ parseStreamEvent() → trusted StreamEvent
Replay API ┘
最好共享同一 parser / validator。
“来自自己数据库”为什么仍然要 Validate
因为数据库可能保存的是:
- 旧 schema;
- 迁移前格式;
- malformed historical row;
- bug 产生的数据。
信任边界是当前 consumer contract,不是“数据来自内网所以可信”。
这与后端读数据库时仍做 domain reconstruction validation 是一样的。
Trust Boundary 之后 reducer 才能保持简单
如果 runtime validation 做得好:
function reduce(state: ActiveTurnState, event: StreamEvent) { ... }
Reducer 就不需要到处:
if (typeof payload?.foo === "string") ...
这让 architecture boundary 非常清晰:
Boundary layer = defensive
Domain reducer = trusted & deterministic
为什么 Reducer 内到处做 Validation 是坏味道
如果每个 case 都:
if (!payload || typeof payload.delta !== "string") return state
会导致:
- validator 分散;
- live/replay 行为不同;
- invalid event 被静默吞掉;
- reducer 既负责协议防御又负责业务投影。
集中 parser 让 reducer 保持 functional core。
为什么 as StreamEvent 是一个危险的“信任升级”
类型断言本质是:
untrusted data
→ developer assertion
→ trusted compile-time type
中间没有任何 evidence。
从安全/架构角度看,这相当于:
把权限升级写成一个语法操作。
所以在 network boundary 处,as DomainType 应该天然引起警惕。
Runtime Validation 也不能变成“万能自动修复”
另一个极端是 parser 自动把错误数据强行修成合法:
seq missing → 0
unknown enum → default normal
redFlag missing → false
这种“宽容解析”可能把 UNKNOWN 变成安全事实。
对 authority/safety relevant fields 更好的原则:
malformed / unknown
→ explicit error / fail closed
不要为了 parser 不报错而创造错误业务语义。
推荐测试
Valid fixtures
每个 event type 至少一个合法 fixture:
message.text.delta
interaction.required
red_flag
stream.done
...
Invalid fixtures
unknown type
wrong version
missing IDs
wrong payload type
negative/invalid seq
Cross-field invalid
tool.result missing tool_call_id
interaction.answered wrong interaction identity
Cross-language fixtures
由 Go 构造真实 dto.StreamEvent fixture,前端 runtime parser 必须接受。
Replay compatibility
旧版本 replay event 应明确:
supported / migrated / rejected
不能依赖 accidental cast compatibility。
Safety negative fixture
malformed safety event 必须显式失败,不能静默当作“无 red flag”。
与 L3 其他模块的关系
StreamEvent Trust Boundary
↓ trusted
ActiveTurnReducer
↓
ActiveTurnState
↓
Effects / UI
如果前面没有 trust boundary,后面再纯的 reducer 也只能“确定性地处理坏数据”。
与 Failure Attribution 的关系
一个 UI 错误可以按:
raw bytes
→ framed JSON
→ parser
→ trusted event
→ reducer
→ selector
→ render
逐层检查。
如果 parser 已经接受 malformed payload,first violation 在 trust boundary;不要先重写 React component。
自测题
- 为什么
JSON.parse()成功仍不能说明它是 StreamEvent? - TypeScript discriminated union 解决了哪一层问题?
unknown为什么比any更适合网络边界?- ReadableStream chunk 为什么不等于一条 event?
- 为什么 live 与 durable replay 必须共用同一个 parser?
- 为什么来自自己数据库的数据也应该经过当前 runtime schema validation?
- Safety event validation 失败为什么不能静默 ignore?
- Parser 自动填默认值为什么可能变成 fail-open?
- 为什么 reducer 内到处
typeof检查是边界泄漏? - Protocol version 应怎样参与兼容策略?
最终记忆
TypeScript protects code from developers;runtime validation protects the application from data.
在流式 Agent UI 里,真正的边界不是 interface StreamEvent 写在哪里,而是:
哪一行代码第一次有资格把 unknown 叫做 StreamEvent?
那一行必须有 runtime evidence。