BodySense StreamEvent Trust Boundary

解释 BodySense Consultation 为什么必须把网络 JSON 视为 unknown,并在进入 StreamEvent discriminated union、reducer 和 UI 前做 runtime validation;同时记录当前代码仍以类型断言代替验证的真实缺口。

#type / synthesis #status / growing #tech / dev / frontend #tech / architecture #resource / bodysense #resource / typescript #resource / javascript

[!info] related notes

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 合法;
  • payloadtype 匹配;
  • 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_flag event 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。

自测题

  1. 为什么 JSON.parse() 成功仍不能说明它是 StreamEvent?
  2. TypeScript discriminated union 解决了哪一层问题?
  3. unknown 为什么比 any 更适合网络边界?
  4. ReadableStream chunk 为什么不等于一条 event?
  5. 为什么 live 与 durable replay 必须共用同一个 parser?
  6. 为什么来自自己数据库的数据也应该经过当前 runtime schema validation?
  7. Safety event validation 失败为什么不能静默 ignore?
  8. Parser 自动填默认值为什么可能变成 fail-open?
  9. 为什么 reducer 内到处 typeof 检查是边界泄漏?
  10. Protocol version 应怎样参与兼容策略?

最终记忆

TypeScript protects code from developers;runtime validation protects the application from data.

在流式 Agent UI 里,真正的边界不是 interface StreamEvent 写在哪里,而是:

哪一行代码第一次有资格把 unknown 叫做 StreamEvent?

那一行必须有 runtime evidence。

创建于 2026/8/22 更新于 2026/8/23