ADR-S-019:事件与 DTO 契约冻结(v1)¶
- 状态:Accepted
- 日期:2026-09-11
- 范围:把
synapse.runtime.service的 application DTO ports 与事件模型(现位于service/event_types.py,runtime/streaming只 re-export)正式登记为 v1 对外契约,定义单向生成的机器可读 manifest / TS 生成物、兼容规则与门禁。 - 不包含:附件支持、daemon 授权模型改造(属 ADR-S-014 后续)、任何外壳的迁移、wire v2。
Context¶
桌面 GUI 是继 TUI、ACP、web console 之后的第四个外壳,也是第一个需要完整管理能力(多项目、多会话、附件、成本)的消费者。在此之前必须先把契约形状固定,否则第二个消费者会把临时决策固化成永久契约。
冻结前的实际状态(均为代码事实):
下表是冻结前(本 ADR 决策时)的快照,保留作对照。实施后的真实状态见「实施状态与验证」;其中「删除影子 kind 分支」等原计划已按实施结论修正(见 Decision 2、Consequences)。
| 项 | 现状 | 证据 |
|---|---|---|
| 契约来源 | 三个来源手工同步:service DTO、wire 方法表、web TS 类型 | service/ports.py:123-221、transport/protocol.py:104-127、web/src/client/types.ts |
| 事件 payload | 开放 JSON(JSONValue),消费者只能防御性 .get() |
service/events.py:300、ui/turn/event_renderer.py:134-237 |
| 协议能力协商 | CAPABILITIES 是硬编码 4 个协议功能 flag,与 18 个授权 capability 及 24 个方法互不关联 |
transport/protocol.py:82-87 |
| 影子 kind | 消费者接受 warning / thinking_started|completed|updated|finished / tool_delta,枚举中不存在,且无生产者 |
ui/turn/event_renderer.py:140-211、web/src/stores/liveEventReducer.ts:369 |
| 跨端类型漂移 | web RuntimeEvent.timestamp 在 Python 侧不存在;web 类型缺 version |
web/src/client/types.ts:150 vs service/events.py:296-301 |
| 契约门禁覆盖 | CONTRACT_FILES 漏 recovery.py / artifacts.py / access.py;events.py → runtime.streaming、artifacts.py → runtime.tool_ignore 是未受检查的跨包耦合 |
tests/test_runtime_architecture_boundaries.py:85-101 |
版本常量已存在且一致:EVENT_VERSION = 1(runtime/streaming/events.py:9)、RUNTIME_WIRE_VERSION = "1"(runtime/transport/protocol.py:76)。
Decision¶
- 契约来源唯一(权威 = 契约层 DTO +
contract_registry.py,生成物只是快照)。已落地的权威是service/{ports,queries,commands,events,errors,runtime_config,history,recovery,artifacts}.py与service/event_types.py的公开 DTO / 事件 dataclass,加上service/contract_registry.py:它登记 24 个 wire 方法(22 service + 2 transport)的方法名 → service 方法 / 请求 DTO / 结果 DTO / 授权 capability / route scope 映射、24 个 kind → payload schema、4 个协议功能 flag、以及从service/access.pyre-export 的 18 个授权 capability 与 schema 清单。transport/protocol.py从该 registry 派生METHODS/CAPABILITIES(transport/protocol.py:54-58),scripts/export_contract_manifest.py经service/contract_export.py渲染两个生成物(Decision 10)。依赖方向严格单向:契约层运行时不读取任何生成物,生成物不可能成为第二权威。 - 事件契约 v1 = 24 个 kind 全量纳入,payload 采用 per-kind schema 表(不是开放映射):
| 分类 | kind | 处理 |
|---|---|---|
| 纳入 v1(21) | activity_started activity_updated activity_stopped reasoning_delta reasoning_completed answer_delta answer_completed tool_batch_started tool_batch_finished tool_started tool_updated tool_finished tool_result subagent_status_changed usage_updated approval_required info turn_completed turn_cancelled turn_waiting_approval turn_failed |
形状冻结 |
| 冻结但 UI 忽略(3) | plan_updated plan_removed diff_updated |
纳入 v1;TUI 与 web 明确不渲染,ACP 消费(acp/updates.py:103-128) |
| 语义标注 | tool_result = legacy 但三端仍在渲染,不标记 deprecated;subagent_status_changed = transient,不持久、可缺失 |
契约中显式写明 |
| 不纳入 v1 枚举 | 影子 kind warning / thinking_* / tool_delta |
不在 TurnEventKind 枚举、也不在 manifest 的 24 个 kind 声明中;消费者侧的历史兼容分支保留(ui/turn/event_renderer.py、web/src/stores/liveEventReducer.ts),本 ADR 不承诺删除它们。未来新增 kind 由「忽略未知 kind」规则覆盖 |
- per-kind payload schema 登记。每个 kind 的 payload 字段名、必填性、语义写入 manifest(形状来源即
service/event_types.py的 frozen dataclass,runtime/streaming/events.py原样 re-export 同一批对象)。注意 frozen dataclass 只冻结属性赋值,不在运行时校验字段类型(dataclass不检查注解):manifest 的「类型」列是从注解静态提取的契约声明,需由 producer fixture 与边界用例验证,而非由 dataclass 运行时保证。运行时不做 per-event 校验(见 Rejected Alternatives)。manifest 登记 89 个 schema(其中 17 个是origin: "transport"的手工声明)外加 4 个standalone_schemas;门禁断言「方法/事件引用的 schema 全部可达」,但这不表示所有 schema 的业务语义已被穷尽描述:字段含义、枚举取值与状态机仍以实现与测试为准。 infopayload 冻结为裸str。现状即runtime/streaming/adapters.py:267的str(message);v1 冻结其当前形状(字符串),不引入InfoPayload。若未来需要结构化,按 additive 规则新增可选字段或新 kind,不得改现有形状。RuntimeEvent.turn_sequence保留在契约中。它是 turn-local 序号,与 sessionsequence游标并列(ADR-005),现有 wire 投影与消费者已依赖该字段;v1 冻结其存在。若判定冗余,只能在 v2 移除。- DTO 契约 v1 = 22 个端口方法 + 全部请求/结果 DTO(
SessionRef作为共享值对象一并入册)。wire 的 24 个方法 = 22 个服务方法(含runtime.events.watch映射)+ 2 个连接态方法: runtime.events.watch映射AgentRuntimeService.watch_events,授权 capabilityevents.watch,属 22 个服务方法之一。runtime.protocol.negotiate:无副作用、无授权 capability,属连接态。runtime.events.unwatch:只影响自身订阅、无授权 capability,属连接态。negotiate与unwatch在契约中定位为传输契约,不冒充 service DTO。- 两层「能力」必须分开,不得混用:
- 协议功能 flag(4 个):
legacy_v1/raw_cursor/watch_resume/approval_resume,权威定义在service/contract_registry.py的PROTOCOL_FEATURES,transport/protocol.py从它派生CAPABILITIES。它们是runtime.protocol.negotiate返回的capabilities字段内容,协商语义不变。客户端只把 v1 的这 4 个 flag 当作必需(transport/client.py的_REQUIRED_PROTOCOL_FEATURES与 web 的REQUIRED_PROTOCOL_FEATURES都是显式字面量,刻意不从 registry 派生),因此 registry 新增的 flag 不会自动变成必需;额外 flag 保持 additive。 - 授权 capability(18 个):
service/access.py的ALL_RUNTIME_CAPABILITIES仍是常量真源,registry 只 re-export;「wire 方法 → capability」映射与它一并冻结(门禁用 AST 独立从access.py抽取method → capability再与 registry 对拍,而不是互相信任)。 两者当前互不相交:4 个 flag 不参与授权,18 个 capability 不出现在 negotiate 响应(门禁断言两个集合无交集)。同时如实记录:daemon 部署使用DaemonAuthorizer,它只校验principal.subject == "runtime-daemon"、从不查询AclGrant,因此 18 个授权 capability 在 daemon 部署下全部实际放行(service/access.py的DaemonAuthorizer)。契约冻结的是名称与映射;是否真正生效由 authorizer 决定,属 ADR-S-014 的后续范围,本 ADR 不改变该默认策略。 - 排除清单(明确不进契约):
| 类型 | 排除原因 |
|---|---|
Principal / AclGrant / AccessRequest / AclAuthorizer / DaemonAuthorizer / bind_access |
进程内安全装配 |
AgentRuntimeService / EventStream / EventWatch 协议对象 |
进程内端口与异步租约;wire 用 subscription_id 表达 |
SessionEventEnvelope(包裹内部 TurnEvent)/ SessionEventWindow / BrokerRecoveryState |
泄漏 broker 内部量与 turn-local sequence |
ProtocolError / WireProjectionError 等 Python 异常类型 |
实现细节,不是线上形状 |
(不排除)JsonRpcRequest / JsonRpcResponse / JsonRpcNotification / JsonRpcError / EventNotification / EventNotificationMeta(standalone_schemas)与 WatchSpec / Negotiation / NegotiateResult / UnwatchRequest / UnwatchResult 等 |
没有 service DTO,但出现在线上,因此在 manifest 中以 origin: "transport" 手工声明;JsonRpcRequest / JsonRpcResponse / JsonRpcNotification / EventNotificationMeta 另列入 standalone_schemas |
Local* 实现类 / RuntimeProject / RuntimeManagerRouter / ManagerFactory / ProjectProvider / _FilesystemContext / _ProjectionRejected / _BuildState / _ShutdownState |
实现与组合根 |
SubmitTurnCommand.attachments |
v1 显式声明不支持附件(wire 已拒绝非空,transport/protocol.py:551-553) |
SubmitTurnCommand.config_overrides 嵌套值 |
字段在册,但只声明为 wire: "restricted":wire 接受 JSON 对象、不承诺键值 schema,值保持 in-process 对象 |
(不排除)WatchSpec.queue_size / scan_limit 系列 |
作为 wire 参数/契约模块有界常量在册(WatchSpec.queue_size 记录 wire default 128, range 1..4096;limits 记录 default_scan_limit / min_scan_limit / max_scan_limit) |
- 版本与兼容规则:
contract_version = 1,与EVENT_VERSION = 1、RUNTIME_WIRE_VERSION = "1"三者对齐(门禁断言三者一致,且SUPPORTED_WIRE_VERSIONS == ("1",))。只允许 additive 演进:
| 变更 | 允许 | 规则 |
|---|---|---|
| 新增 kind | 是 | 消费者必须忽略未知 kind |
| 新增 payload 字段 | 是 | 必须可选且有安全默认值 |
| 新增 wire 方法 | 是 | 必须同时在 manifest 声明 capability |
| 重命名 / 删除 kind、字段、方法 | 否 | 破坏性变更 → 版本递增 |
| 改字段类型或语义 | 否 | 破坏性变更 → 版本递增 |
| 破坏性变更的落地方式 | — | 新版本 + negotiate 拒绝旧客户端(-32002 / -32003 语义不变) |
同版本内的兼容边界已实现并被 fixture 固定:未知 kind 与未知/额外 payload 字段、以及普通可选字段缺失,在 v1 内被容忍并透传(Python wire 客户端与 web core 各有一组用例)。不同 event version 不会被自动接受:web core 的 classifyRuntimeEvent 在版本不等时返回 unsupported_event_version 并走协议错误路径,Python 客户端只要求 version 是非负 int 且不实现其它版本语义。信封缺字段或类型错误一律 fail-closed。
- 两个生成物都是单向产物(不是权威来源):
src/synapse/runtime/service/contract_manifest.json(语言无关 JSON)与web/src/runtime-client/contract.generated.ts(web 消费的 TS 类型与常量)。两者由scripts/export_contract_manifest.py经service/contract_export.py从 registry 渲染,输出确定性(无本地路径、无时间戳、LF 换行),--check模式对提交内容做逐字节校验,漂移或缺失即失败。手工编辑生成物视为失败。 - 门禁 T1–T7 见下节。已落地的边界收口:
CONTRACT_FILES补齐recovery.py/runtime_config.py/artifacts.py/access.py/event_types.py;契约层禁止 importruntime.transport、runtime.streaming、runtime.tool_ignore、runtime.sessions.{runtime,manager,persistence}、service.{local,routing,history_store}。events.py对TurnEventKind的依赖:kind 枚举与全部 payload dataclass 已上移到契约层的service/event_types.py;service/events.py与runtime/streaming/events.py都引用它,streaming侧原样 re-export 同一批对象(不是副本)。artifacts.py对ToolIgnoreMatcher的依赖:已拆为纯 DTOservice/artifacts.py(ArtifactRef/ 元数据 / 查询 / 校验器与 limit 常量)与 FS 实现service/artifact_filesystem.py(注入ToolIgnoreMatcher并做有界 stat/list/read)。契约层只 import DTO 模块。- import 闭包用懒加载而非「不触发
__init__」来收口:Python 导入子模块必然先导入父包,所以service/__init__.py一定会执行;它已改为 PEP 562 的模块级__getattr__懒 re-export,__init__本身不再 importservice.local/service.routing。门禁用独立解释器子进程断言「只 import 具体契约子模块或裸synapse.runtime.service时,闭包内不出现实现模块 / 会话执行栈 / streaming / tool_ignore / langchain 等」。
Manifest 结构¶
生成物有两份,都由 registry 渲染:
| 生成物 | 路径 | 用途 |
|---|---|---|
| JSON manifest | src/synapse/runtime/service/contract_manifest.json |
语言无关校验;CI --check |
| TS 模块 | web/src/runtime-client/contract.generated.ts |
web console core 消费的类型与版本常量 |
顶层键(取自实际提交文件):contract_version、wire_version、event_version、methods(22)、transport_methods(2)、transport_notifications(3)、events(24)、schemas(89)、standalone_schemas(4)、type_aliases(3)、protocol_features(4)、authorization_capabilities(18)、approval_decision_kinds(4)、limits(26)。
下面是结构真实但内容截断的节选(字段名、嵌套层级、类型照抄提交文件,只是不列全 24 个方法 / 89 个 schema):
{
"contract_version": 1,
"wire_version": "1",
"event_version": 1,
"methods": [
{
"method": "runtime.artifacts.list",
"class": "service",
"service_method": "list_artifacts",
"request": "ListArtifactsQuery",
"result": "ArtifactPage",
"result_nullable": false,
"capability": "artifacts.list",
"scope": "session",
"scope_location": "params.session",
"wire_defaults": {"limit": 100, "path": "."},
"notes": ["``cursor`` is optional; ``limit`` is bounded to 1..1000 by the wire decoder."]
}
],
"transport_methods": [
{
"method": "runtime.protocol.negotiate",
"class": "transport",
"params_alias": "NegotiateParams",
"request": "Negotiation",
"result": "NegotiateResult",
"result_nullable": false,
"capability": null,
"scope": "connection",
"scope_location": null,
"wire_defaults": {},
"in_process": "Connection-state method: it has no service DTO, no authorization capability, and it is idempotent only for the identical proposal.",
"notes": ["An unsupported proposal answers -32002 and a second, different proposal", "answers -32003; the response never carries authorization capabilities."]
}
],
"transport_notifications": [
{
"method": "runtime.event",
"params": "EventNotification",
"notes": ["One live event per notification; ``cursor`` is the session cursor after it."]
}
],
"events": [
{
"kind": "info",
"payload": "str",
"status": "v1",
"legacy": false,
"transient": false,
"notes": ["Payload is a bare JSON string in v1 (the producer sends str(message)); no", "InfoPayload wrapper is introduced."]
},
{
"kind": "plan_updated",
"payload": "PlanPayload",
"status": "v1-ui-ignored",
"legacy": false,
"transient": false
}
],
"schemas": {
"TextPayload": {
"origin": "python",
"role": "result",
"fields": [
{
"name": "text",
"python_type": "str",
"ts_type": "string",
"nullable": false,
"optional": false,
"required": true,
"wire": "json"
},
{
"name": "message_id",
"python_type": "str | None",
"ts_type": "string | null",
"nullable": true,
"optional": false,
"required": true,
"wire": "json",
"default": null,
"default_kind": "value"
}
]
}
},
"standalone_schemas": ["EventNotificationMeta", "JsonRpcNotification", "JsonRpcRequest", "JsonRpcResponse"],
"type_aliases": {
"HistoryToolCall": "Record<string, JsonValue>",
"HistoryToolResult": "Record<string, JsonValue>",
"McpServerState": "McpServerStateView"
},
"protocol_features": {
"legacy_v1": true,
"raw_cursor": true,
"watch_resume": true,
"approval_resume": true
},
"authorization_capabilities": [
"artifacts.list", "artifacts.read", "artifacts.stat", "events.read", "events.watch",
"project.thinking", "session.close", "session.list", "session.mcp.reload",
"session.open", "session.read", "session.rebind", "session.thinking",
"turn.approval.read", "turn.approval.resume", "turn.cancel", "turn.steer", "turn.submit"
],
"approval_decision_kinds": ["allow_once", "allow_always", "reject_once", "reject_always"],
"limits": {
"default_scan_limit": 1024,
"max_scan_limit": 4096,
"min_event_bytes": 1024,
"default_max_event_bytes": 1048576,
"max_event_bytes": 8388608,
"max_list_limit": 1000
}
}
字段语义补充:
limits是契约模块当前的有界常量(实现的默认值与合法范围),不是「这些数字永久不变」的保证;只调数值、不改语义按普通实现变更处理。wire 专有边界(帧大小、subscription id、版本 token、MCP include-tool 上限等)仍留在 transport 常量里并逐方法记录。schemas[*].required/optional表达的是 wire 投影语义(result/valueschema 的字段在投影中总是出现),不等于 Python 默认值;每个方法的 wire 默认值单独声明在wire_defaults,并由门禁与手写 decoder 对拍。- 24 个 kind 的 payload 形状来自
service/event_types.py的 frozen dataclass;dataclass 只约束字段集,不校验运行时类型,类型与边界由 producer fixture 与边界用例保证。
门禁¶
| 编号 | 内容 | 落点 |
|---|---|---|
| T1 | 22 个 service 方法覆盖 AgentRuntimeService 端口;wire 表恰为 24 个方法(22 service + 2 transport) |
tests/test_runtime_contract_manifest.py |
| T2 | protocol_features 与 authorization_capabilities 分开断言且无交集;registry 的 capability 集合 == access.py 的 ALL_RUNTIME_CAPABILITIES;method → capability 由 AST 从 access.py 独立抽取后对拍 |
同上 |
| T3 | 24 个 kind 全部声明 payload schema;payload 样本与声明字段集一致;无 in-process 对象进 schema | 同上 |
| T4 | 生成 TS 覆盖 web console 当前使用的全部类型名;runtime-client/types.ts 只 re-export 生成物、不重复声明 wire 形状;npx tsc -b 通过 |
同上(test_generated_typescript_covers_every_web_console_type)+ web/tests/runtimeContractFixture.test.ts + CI tsc -b |
| T5 | 同版本未知 kind / 额外字段 / 缺失可选字段按 additive 处理;信封缺字段或版本不受支持 fail-closed | tests/test_runtime_transport_client_compatibility.py、web/tests/runtimeContractFixture.test.ts、web/tests/runtimeClientBoundary.test.ts |
| T6 | AST + import 闭包门禁:CONTRACT_FILES 覆盖、契约层禁止 import 实现包、懒 __init__ 不拉入实现 |
tests/test_runtime_architecture_boundaries.py、tests/test_runtime_service_import_purity.py |
| T7 | 提交的 JSON manifest 与 TS 模块逐字节等于 registry 渲染结果;共享 fixture 是声明 schema 的真实投影 | test_committed_artifacts_match_the_registry、test_shared_fixture_is_a_real_projection_of_the_declared_schemas、scripts/export_contract_manifest.py --check |
CI 在单次 contract job(ubuntu、不进 3.12/3.13 × Windows/Linux 矩阵)里跑:python scripts/export_contract_manifest.py --check → 定向 Python 门禁(test_runtime_contract_manifest.py、test_runtime_architecture_boundaries.py、test_runtime_service_import_purity.py、test_runtime_transport_client_compatibility.py)→ npm ci + npx tsc -b → node --test tests/runtimeContractFixture.test.ts tests/runtimeClientBoundary.test.ts tests/sourceGuard.test.ts。该 job 只运行上述定向门禁;本 ADR 不就项目整体 CI 的测试范围(例如是否在 CI 运行全量 pytest)作政策声明。
冻结前必须解决的不一致¶
| # | 项 | 实际处理 |
|---|---|---|
| 1 | web RuntimeEvent.timestamp 在 Python 侧不存在 |
已从契约类型删除;门禁断言 RuntimeEvent 无 timestamp |
| 2 | web 类型缺 version |
已补上(version 是信封六字段之一),纳入 T4 |
| 3 | 影子 kind 无生产者 | 不进入 TurnEventKind / manifest;消费者侧历史兼容分支保留(见 Decision 2)。原「删除分支」计划已撤销 |
| 4 | info payload 裸 str |
冻结为裸 str,保持现状形状(不引入 InfoPayload) |
| 5 | CAPABILITIES 硬编码 4 个协议功能 flag |
冻结取值;与 18 个授权 capability 分开登记(见 Decision 7),不改协商语义 |
| 6 | daemon 下 18 个 capability 全部放行 | 契约中如实记录;授权生效问题留给 ADR-S-014 后续,不在本 ADR 解决 |
| 7 | negotiate / unwatch 无 capability |
契约中显式定位为连接态方法,不参与授权 |
| 8 | ToolBatchPayload.items 生产者从不填充(恒空元组) |
保留字段(已是 v1 形状,门禁断言 items 仍在);生产者暂不填充属实现现状,不作为破坏性变更 |
| 9 | AccessRequest 无构造调用点;rebind_session 未列入 _REQUIRED_DELEGATE_METHODS 却无条件调用;RuntimeConfigView.can_toggle_mcp_global 恒 False |
T1 现在断言 registry 覆盖全部 22 个端口方法(含 rebind_session);AccessControlledAgentRuntimeService.rebind_session 现已把 rebind_session 当作可选 delegate 方法(与 set_thinking_level 同类):先 _authorize(session, session.rebind),再用 getattr(delegate, "rebind_session", None) 探测,缺失时抛 InvalidRequestError("session rebind is unavailable")——因此 wrapper 层不再是「无条件调用」,未授权的调用者在旧 delegate 上也被拒;_REQUIRED_DELEGATE_METHODS 仍是原先 13 个名字的 fail-fast 清单,未扩列(已知且未变的实现现状);第三项仍是恒 False 的预留字段 |
| 10 | CONTRACT_FILES 覆盖缺口 + 两条跨包耦合 |
已按 Decision 11 落地(event_types.py 上移、artifacts.py / artifact_filesystem.py 拆分、懒 __init__) |
Rejected Alternatives¶
- payload 保持开放映射:等于不冻结 payload,消费者永远无法获得可靠形状,跨端漂移会继续发生。
- 运行时 per-event schema 校验:producer 侧形状由 frozen dataclass 的字段集约束(但 dataclass 不校验字段类型),类型与边界由 producer fixture 与测试保证;运行时校验只增加热路径成本。
- 新增 UI 中性事件投影层:会与现有
RuntimeEvent并存两套事件模型,并把「消费者不理解形状」的问题用一层映射掩盖;RuntimeEvent的问题是没有被声明为契约,而不是不够中性。 - 让 TUI 迁移到 wire 客户端库:TUI 已在同一 DTO 契约上(ADR-S-018),它没有网络;迁移只会重做
ui/turn/与 6007 行 TUI 测试,收益为零。 - 手写 TS 类型并靠 review 对齐:这正是当前漂移的成因。
- 让契约层反向读取 manifest:会把生成物变成第二权威,形成「生成物 → 源」的循环依赖;manifest 必须是单向产物。
- 从 manifest 自动生成 wire decoder:decoder 保持手写,因为它同时承担逐字段边界检查与固定错误映射;改为由门禁把声明的
wire_defaults/submit_turn规则与手写 decoder 对拍——这已覆盖默认值与关键 submit 规则,但不能替代所有语义验证,也不牺牲可读的错误语义。 - 顺手删除影子 kind 的消费者兼容分支:分支已无生产者,但删除只会改动 TUI/web 渲染路径;v1 已用「忽略未知 kind」覆盖未来新增,所以选择保留分支、只把它排除在契约枚举与 manifest 之外。
Consequences¶
- 新增外壳的成本主要是「UI + 客户端实现」,service 与 wire 无需为每个外壳改动。但「新增能力所有外壳自动可见」不成立:每个外壳仍需各自实现渲染/消费,契约只保证形状一致,不保证 UI 呈现。
- TUI 的
event_renderer.py与 web 的liveEventReducer仍保留info裸字符串与影子 kind 的历史兼容分支;本 ADR 不承诺清理它们,也不把清理当作已完成项。 - web console 的
liveEventReducer与 TUI 的event_renderer消费同一契约,但跨端对齐仍需各自提交,不承诺 parity 提交消失。 - 契约冻结后,
DaemonAuthorizer全放行这一事实被显式记录,不再需要从代码反推。 - manifest 与生成 TS 已落地并进门禁,但它们冻结的是形状与映射:字段业务语义、状态机与错误分类仍以实现与测试为准,schema 清单「可达」不等于「语义已穷尽描述」。
实施状态与限定验证¶
已实施(均为代码事实):
- 权威与生成物:
service/contract_registry.py、service/contract_export.py、src/synapse/runtime/service/contract_manifest.json、web/src/runtime-client/contract.generated.ts、scripts/export_contract_manifest.py均已落地;transport/protocol.py从 registry 派生METHODS/CAPABILITIES。 - 事件契约:
service/event_types.py拥有 24 个TurnEventKind与全部 payload dataclass,runtime/streaming/events.py原样 re-export;info仍是裸str,RuntimeEvent.turn_sequence保留,ToolBatchPayload.items保留。 - 契约层边界:
CONTRACT_FILES已补齐;service/__init__.py改为 PEP 562 懒 re-export;artifacts.py(DTO)/artifact_filesystem.py(FS)已拆分。 - web:
web/src/runtime-client/是 DOM-free core(SocketLike可注入;默认defaultSocketFactory在调用时经globalThis运行时守卫读取宿主WebSocket,不直接引用浏览器WebSocket绑定,因此无需 DOM libs 即可类型检查/加载;无WebSocket的宿主必须注入socketFactory),web/src/client/*.ts保留为旧路径 re-export 薄壳;types.ts只 re-export 生成物。UI 文案与 store reducer 未全部迁移,这不是已发布的 SDK(web/package.json为private),也不支持桌面进程管理。 - 门禁与 CI:
tests/test_runtime_contract_manifest.py等四组定向 Python 门禁 + 三个node --test文件 +tsc -b已进入 CI 的contractjob;共享 fixture 为tests/fixtures/runtime_contract/fixtures.json与tests/fixtures/runtime_contract/v1/,由 Python 与 TypeScript 两侧共同消费。 - TUI/daemon/web host 既有语义保持不变:TUI 侧
LocalProjectRuntimeConsumer仍是线程 owner(rebind_agent_threadsafe/close_session_threadsafe+ 同 loop 阻塞拒绝),daemon 仍提供可注入的authenticator_factory/authorizer_factory且默认DaemonAuthorizer全权策略不变,浏览器宿主的 project allow-set 与 CSRF 链路不变。
定向验证命令(不跑全量):
uv run --no-sync python scripts/export_contract_manifest.py --check
uv run --no-sync pytest tests/test_runtime_contract_manifest.py tests/test_runtime_architecture_boundaries.py tests/test_runtime_service_import_purity.py tests/test_runtime_transport_client_compatibility.py -q
Push-Location web; try { npx tsc -b; node --test tests/runtimeContractFixture.test.ts tests/runtimeClientBoundary.test.ts tests/sourceGuard.test.ts } finally { Pop-Location }
文档结构变化时再跑 uv run --no-sync mkdocs build。
后续边界¶
本 ADR 只冻结形状、兼容规则与门禁。以下未实现,也不声称完成:
- 附件支持:
SubmitTurnCommand.attachments仍被 wire 拒绝非空数组。 - 契约补全:
project.list、会话 rename / delete 等管理方法未进 wire 方法表(现有 22 个 service 方法不含它们)。
后续 additive 记录(不修改上面的冻结结论):
runtime.project.list已按 v1 的 additive 规则新增(wire 表 25 个方法、project.list为第 19 个授权 capability、 新增纯 DTOListProjectsQuery/ProjectListItem/ProjectListPage);v1 的 24 个方法与 18 个 capability 仍是基线子集,门禁以「基线子集 + 显式新增清单」断言, 不改写历史基线。可信连接 scope(宿主私有握手头 + 只做减法的限定 ACL wrapper)与 Web 改走共享 runtime client 同批完成;会话 rename / delete、附件、远程身份仍 pending。 - 远程身份管理:多用户 / 远程 principal 管理与授权模型改造属 ADR-S-014 后续。 - artifact 写入、wire v2、桌面进程管理。