Files
trade-message-center/.trellis/spec/server/backend/service-foundation.md
T

44 KiB
Raw Blame History

服务端基础设施契约

1. Scope / Trigger

  • Trigger:服务端首次引入 Fastify 启动、WebSocket 传输和 PostgreSQL ORM。
  • Scopeapps/server/src/ 的配置、应用组合、健康路由、WebSocket 生命周期和数据库连接。
  • ExcludedOneTalk 业务字段规则和 repository 细节由数据库规范与 src/onetalk/ 领域模块定义;发送队列仍不在本服务基础设施内。协议帧与可替换授权端口由共享 contract 包定义,数据库迁移由数据库规范单独定义。Mind 生产认证通过两个 HTTP 接口接入,具体目标契约见 Mind HTTP 授权

2. Signatures

loadConfig(environment: Record<string, string | undefined>): ServerConfig
createDatabase(databaseUrl: string): { db: Database; close(): Promise<void> }
createApp(config: ServerConfig, dependencies?: AppDependencies): FastifyInstance
createServerRuntime(config: ServerConfig, dependencies?: ServerRuntimeDependencies): ServerRuntime
startServer(): Promise<void>

3. Contracts

  • 必填环境变量:HOSTPORTDATABASE_URL
  • 可选的 NODE_ENV 在配置边界规范化;只有 trim 后精确等于 development 的值才启用仅开发环境的 loopback Mind HTTP 配置,缺失或未知值保持 fail-closed。
  • GET /health 返回 { "status": "ok" }
  • WebSocket 路由是 GET /ws/pluginGET /ws/mind;每条路由在 upgrade 前绑定自己的连接类型并检查精确的 Origin allowlist。GET /ws 是仅拒绝的兼容行为(426 onetalk_protocol_upgrade_required),没有 legacy handler、outbox 或分发路径。畸形或不支持的协议帧以 1003 关闭,连接/handler 错误以 1011 关闭。
  • createApp 永不调用 listenentry.ts 是唯一进程入口,它调用 runtime.ts 的双 listener lifecycleruntime.ts 只绑定 public HOST:PORT 与 internal 0.0.0.0:7777,并在任一 bind 失败时关闭两者。业务/HTTP 模块不得直接监听端口。
  • 数据库资源通过应用 onClose hook 关闭;URL 和凭证不进入响应或日志。
  • createApp 在同一个 database.db 上组合注入/默认的 OneTalkServiceOneTalkProfileServiceOneTalkReadServiceAppDependencies 可以为测试或部署适配器注入这些领域端口、连接 registry 和 publisher 失败 sink。
  • WebSocket 业务帧经过 service boundary;成功观察的顺序是数据库提交 → 插件 message.ack → 已授权 Mind message.createdmessage.created 与 HTTP history 都必须从同一 normalized JSONB fact 投影 shared OneTalkCenterMessage,不得暴露顶层 text/contentType 或 raw payload。
  • contact.profile.observed 是 Bright 持久化路径:canonical binding/read/sync 授权 → profile service → guarded Bright transaction → post-write fence → contact.profile.ack;它不调用 Mind profile HTTP 或消息服务。AppDependencies.profileService 是测试/部署接缝。

4. Validation & Error Matrix

条件 行为
缺失或空白的 HOST 抛出 Missing HOST
缺失或空白的 PORT 抛出 Missing PORT
PORT 不在 1..65535 或非整数 抛出 Invalid PORT: expected integer 1-65535
缺失或空白的 DATABASE_URL 抛出 Missing DATABASE_URL
收到畸形或不支持的 WebSocket 帧 发送稳定协议错误,然后以 1003 关闭当前 socket
收到 WebSocket 错误 1011 关闭当前 socket
profile future-skew 或 guarded 持久化被拒绝 稳定的 profile_observed_at_future/数据库失败;无 profile ACK
profile 事务成功但授权/连接/policy fence 已过期 无迟到 ACK;插件 pending 保留

5. Good / Base / Bad Cases

  • Good:测试注入 { db, close },调用 app.ready(),验证 /health,然后观察 close 恰好被调用一次。
  • Base:生产启动从 DATABASE_URL 创建 Drizzle client,并在配置的 host/port 上监听。
  • Bad:路由 handler 读取 process.env、创建第二个 postgres client,或在错误中返回连接 URL。

6. Tests Required

  • 配置测试断言缺失 URL 和非法端口的错误包含字段/错误类别,但不包含秘密值。
  • Health 测试断言 HTTP 200 和精确的 { status: "ok" } 响应。
  • Lifecycle 测试断言注入的数据库 closeapp.close() 时恰好执行一次。
  • WebSocket 注册测试断言 websocketServer 存在、GET /ws/pluginGET /ws/mind 已注册、GET /ws 为仅拒绝;OneTalk 协议测试断言 mock 授权握手、heartbeat、版本拒绝和授权失败。
  • OneTalk 业务测试断言原始观察校验、会话发现、同步完成、anchor snapshot、仅插件写入和提交后发布行为。

7. Wrong vs Correct

Wrong

app.get("/health", async () => ({ databaseUrl: process.env.DATABASE_URL }));

Correct

app.get("/health", async () => ({ status: "ok" }));

health 边界稳定且不含秘密;数据库探测属于后续的运维契约。

Scenario: OneTalk authenticated WebSocket route metadata

1. Scope / Trigger

  • Trigger:新增、迁移或重构已认证 OneTalk client frame 的 endpoint ownership、authorization 或生产分发。
  • Scopewebsocket/authenticated-router.ts 拥有 non-hello route metadataendpoint-routes.ts 用它校验固定 endpoint 的 per-socket handler table 并分发。handler.ts/protocol-kernel.ts 继续拥有 decode、固定 endpoint/scope gate 和 socket FIFO;业务 Flow、registry 与 pending-send coordinator 继续拥有状态及最终副作用。
  • Excluded:不改 shared wire/decoder、ws.hello bootstrap、connection registry 或业务 Flow 的授权/commit/ACK/publish 次序。

2. Signatures

createOneTalkAuthenticatedRouter() -> OneTalkAuthenticatedRouter
createOneTalkEndpointAuthenticatedRouter(endpoint, router, handlers) -> { dispatch(frame): Promise<void> }
defineOneTalkEndpointRouteHandler(type, handler) -> OneTalkEndpointRouteHandler

3. Contracts

  • ONETALK_AUTHENTICATED_ROUTE_DEFINITIONS 是每个 non-hello client frame 的 endpoint ownership、session | pending_send authorization 与 operation 的唯一声明;必须从 ONETALK_CLIENT_FRAME_TYPES 完整校验 missing、duplicate、unknown 与 ws.hello
  • installWebsocket 只构造一次 shared router 并注入 Plugin/Mind endpoint。每个已建立 socket 都从同一 metadata 建立 fixed-endpoint viewview 的 handler table 必须与该 endpoint 的可拥有 type 完全相等,不能有 no-op/fallback handler 或另一份 endpoint type list。
  • dispatch 在调用业务 collaborator 前检查 frame connection type 与 endpoint ownership。正常生产顺序保持 decode -> admission -> fixed endpoint/scope -> FIFO -> endpoint router -> existing handlerrouter 不得重新解析 raw data、创建第二个 session/FIFO 或把 pending_send 路径改为通用 session authorization。
  • ws.hello 永不注册为 authenticated route;它继续由 endpoint bootstrap 处理。

4. Validation & Error Matrix

条件 必须行为
metadata 缺少、重复、未知或包含 ws.hello router construction 失败
endpoint handler 缺少、重复或不归属该 endpoint endpoint view construction 失败
合法 client frame 发往另一个 fixed endpoint kernel 在业务 collaborator 前发 ws.error(scope_mismatch) 并 close 1008
send.request / send.confirmation 仍走原 pending-send coordinator;不得改为 session authorization
同 endpoint 的非 client/未认证/重复 hello 保持 kernel 既有 error/close 优先级,不进入 router

5. Good / Base / Bad Cases

  • GoodPlugin 的 handler table 从 shared metadata 验证后只把收窄 frame 委派给既有 sync/profile/buyer/confirmation FlowMind 的 send.request 仍委派现有 request Flow。
  • Baseheartbeat 是唯一双 endpoint route,但两个 endpoint 各有自己的 authorization adapter 与 handler closure。
  • Bad:在 Plugin/Mind endpoint 保留 if (frame.type === ...) 作为第二份 owner selector,或只在启动期检查独立 ONETALK_*_ROUTE_TYPES

6. Tests Required

  • router tests 必须从 canonical metadata 断言全量 non-hello coverage、metadata failure、exact endpoint handler table 与 wrong-ownership collaborator 零调用。
  • injectWS 必须发送合法 Mind frame 到 /ws/plugin 和合法 Plugin frame 到 /ws/mind,断言 scope_mismatch、close 1008,并观察目标业务 collaborator 为零调用。
  • 保留 full client-frame wire matrix 与 pending confirmation、profile/buyer post-write fence、observation ACK/publish、sync completion flush 的 focused tests;重构不能以私有 router 调用替代这些 wire 断言。

7. Wrong vs Correct

// Wrong: endpoint type list 与生产选择器可能各自漂移。
if (frame.type === "send.request") return sendRequestFlow.handleRequest(socket, frame, epoch);

// Correct: 固定 endpoint 分发由 shared metadata 校验过的精确 handler table。
const router = createOneTalkEndpointAuthenticatedRouter("mind_page", metadata, handlers);
return router.dispatch(frame);

Scenario: Bright v3 authorization and commit fences

1. Scope / Trigger

  • TriggerMind 授权、Bright WebSocket 生命周期和 OneTalk 事实写入共享多个异步边界,需要避免 pause、连接替换或迟到回报穿透副作用。
  • Scopecutover-policy.tswebsocket/registry.tswebsocket/handler.tsonetalk/service.ts 和 guarded repository ports。
  • ExcludedMind 的远端 revocation push/lease、真实 Mind 页面、浏览器/TLS、跨仓库 legacy 删除和 PostgreSQL 环境验收。

2. Signatures

createOneTalkCutoverPolicy(initial?: { enabled: boolean; paused: boolean }): OneTalkCutoverPolicy
registry.requestSend({ mindSocket, frame }): Promise<OneTalkSendResultFrame["payload"]>
registry.createCommitGuard(connection, policyEpoch): OneTalkCommitGuard
store.isPluginLeaseFresh(connection): boolean
repository.guardedInsertMessage(context, source, message, guard): Promise<InsertResult>
repository.guardedUpdateSyncState(context, update, conversationId, guard): Promise<Conversation | null>

3. Contracts

  • Bright v3 policy 只通过 enabled/paused/epoch 控制 admissionpause/resume 可以恢复 Bright v3,不依赖或保存 firstBrightFactWritten/markBrightFactWritten
  • OneTalkConnectionRegistry 是 handler-facing façade;其内部 connection-store 是 canonical connection、generation 和 plugin presence 的唯一 ownermind-publisher 只做 Mind 二次授权/帧发送/失败上报,pending-send-coordinator 是 in-flight sendRequestId reserve、phase、terminal transition、timeout、disconnect、pause 和 late confirmation 的唯一 owner。它只保存进行中的 attempt;settle 必须清除 pendingSendspendingConversationSends,不得持有终态 payload、终态 ID 去重历史或重放缓存。三个协作者必须注入同一 store,不能在 handler 或其它模块复制 socket/generation/pending map;所有 wire send 后结果不可自动重试。
  • Plugin 的可路由性由 store 的 canonical lease 唯一决定:isPluginLeaseFresh 只接受仍注册的 plugin,且 lastHeartbeatAtMs > now() - heartbeatTimeoutMsrequestSend 必须在候选选择和全部异步授权后的无 await 最终 fence 都调用它;expired lease 即使 sweep 尚未运行也只能得到既有 rejected_before_send/waiting_for_page,不得发出 send.command
  • HTTP 读取只依赖 OneTalkPluginPresenceReader.isPluginOnline(scope),不得为读取路径注入或依赖完整 registry 的发送、连接管理或发布能力。
  • 远程 observation/discovery/sync/confirmation 的 database side effect 必须带同一 canonical connection/generation/policy guard。guard 失效必须使事务回滚,不返回伪造的 accepted/duplicate。

4. Validation & Error Matrix

条件 结果
相同 ID 的并发 send 在 attempt 仍进行中 一个 attempt,另一个 rejected_before_send/duplicate_request;终态后复用 ID 不属于 coordinator 协议契约
候选 plugin 已过 heartbeat lease,或在授权期间 lease 过期 rejected_before_send/waiting_for_page;无 send.command
wire send 前断线、替换或 pause rejected_before_send/waiting_for_page
wire send 后断线、替换或 pause delivery_unknown/send_connection_lost
confirmation timeout/non-success/duplicate/late 唯一 terminal;清 timer 与全部 pending 索引;终态后的 confirmation no-op
pause 期间的既有 WS close 1013/authorization_unavailable,不先发 ws.error
guarded transaction 在任一异步边界失效 rollback;不 ACK、publish 或返回 confirmed result

5. Good / Base / Bad Cases

  • Good:最后一次授权返回后只做同步 canonical/generation/epoch/open 检查,再调用 socket.send
  • Good:store 在一个查询内同时确认 plugin 身份、canonical membership 与 heartbeat deadlinecoordinator 在选择和发送前均复用该查询。
  • Base:本地 fake repository 和 fake Mind fetch 证明调用顺序与 fail-closed 逻辑;真实 PostgreSQL/Mind/浏览器仍需独立环境验收。
  • Bad:把 pending 记录放在 authorization await 之后、在 confirmation 中重新授权原 Mind Session、或为 guarded write fallback 到普通 insert。

6. Tests Required

  • 使用可控 Promise latch 覆盖 authorization、service、transaction、publish 各 await 点的 pause/disconnect/replacement;关键竞态连续 100 次运行。
  • 断言 message.created 只在 DB commit 后 publishterminal/guard 失败不写库、不 ACK、不 publish;独立 PostgreSQL 测试配置 TEST_DATABASE_URL 后再验证真实 rollback/commit 线性化。
  • registry 重构测试必须锁定 façade 的 canonical cleanup/plugin replacement、精确 scope 发布与二次授权、presence 通知、pending-send timeout/connection-loss 映射和 confirmation 单次 claim;拆分后 connection/generation 与 pending/terminal 状态各自只能有一个 owner。
  • send admission 测试以可控 clock 覆盖 lease 边界和授权 await 后过期;两种情况都断言无 send.command,非过期 lease 保持既有 dispatch/confirmation 行为。

7. Wrong vs Correct

Wrong

const pending = new Map<string, PendingSend>();
const connections = new Map<WebSocket, Connection>();
// handler 或 publisher 再维护自己的 connection/generation/pending 真相。

Correct

const store = createOneTalkConnectionStore({ cutoverPolicy });
const publisher = createOneTalkMindPublisher({ authorization, store, onPublishFailure });
const pendingSends = createOneTalkPendingSendCoordinator({ authorization, store });
const registry = { ...store, ...publisher, ...pendingSends };

协作者共享同一 canonical storefaçade 维持调用方兼容;不会因拆文件而改变 guard、publish 或 send 的既有时序。

Scenario: 环境驱动的 Mind HTTP 授权

1. Scope / Trigger

  • Trigger:服务端需要通过独立的本地 Mind 授权模拟运行 OneTalk HTTP/WS 联调,同时保留非开发环境的安全默认。
  • Scopeapps/server/src/config.tsapps/server/src/mind-authorization.tsapps/server/src/app.ts、server dev script 和 apps/mind-test-harness/
  • Excluded:真实 Mind Session/Cookie 认证、业务授权后台、数据库权限和请求级认证逻辑不由本地模拟代替。

2. Signatures

loadConfig(environment: Record<string, string | undefined>): ServerConfig
// ServerConfig.environment: "development" | "non_development"
createApp(config: ServerConfig, dependencies?: AppDependencies): FastifyInstance

3. Contracts

  • loadConfig trim NODE_ENV;只有值严格为 development 时返回 environment: "development",空值、缺失、大小写变体和其它值返回 non_development
  • loadConfigNODE_ENV=development 时读取 MIND_AUTH_BASE_URLMIND_PAGE_ORIGINONETALK_PLUGIN_ORIGINS 和可选 MIND_AUTH_TIMEOUT_MS;其中 HTTP 只允许 loopback Origin,供 mind-test-harness 的 Mind 授权模拟使用。
  • createApp 在未显式提供 dependencies.authorization 时使用配置的 createMindAuthorizationReader;显式 reader 在所有环境优先。development 不再从 ONETALK_DEV_* 构造进程内授权 fixture。
  • Mind 授权模拟只拥有独立的本地固定 fixture,Bright 仍通过两个 HTTP endpoint 验证 binding/Cookie/status/body;模拟不读取 Mind DB、不推断请求主体,也不改变生产授权边界。
  • apps/server/package.jsondev script 明确设置 NODE_ENV=development;其它启动路径不设置 bypass。
  • installWebsocket 未传 authorization 时仍使用 fail-closed reader,不能因 server dev 默认而改变独立安装语义。

4. Validation & Error Matrix

Condition Result
NODE_ENV=development 且 Mind 授权模拟配置完整 createApp 通过 Mind HTTP adapter 授权;模拟返回允许时匹配 scope 的 HTTP/WS 请求可进入已有业务边界
NODE_ENV=developmentMIND_AUTH_* 配置缺失/非法 启动失败,不创建任意 scope 的默认授权
NODE_ENV=development 但 Mind 授权模拟不可用 授权返回 authorization_unavailable,不绕过网络边界
缺失、空值、未知值或非精确 development createApp 默认 authorization_unavailablefail-closed
development 下显式注入拒绝 reader 按注入 reader 拒绝,不被 bypass 覆盖
development 下显式注入允许 reader 按注入 reader 返回其 binding/version/permissions
独立 installWebsocket 未注入 reader 仍返回 authorization_unavailable

5. Good / Base / Bad Cases

  • Good:需要时显式运行 pnpm dev:harness 启动本地 Mind 授权模拟,createApp 通过固定 HTTP adapter 授权;pnpm dev 与 server/extension 主流程不依赖该低优先级工具,测试/生产仍按各自 URL/HTTPS 规则运行。
  • Base:真实 Mind HTTP auth 通过同一个 createMindAuthorizationReaderAppDependencies.authorization 接入,环境选择不改变业务授权调用。
  • Bad:在 createApp 中无条件 allow、把 NODE_ENV 缺失当作 development,或在 HTTP header/host 上推断开发模式。

6. Tests Required

  • 配置测试覆盖 development(含首尾空格)、loopback HTTP、缺失、空值、test/staging/production、大小写变体和拼写错误。
  • App/adapter 测试覆盖默认 development HTTP/WS 授权、Mind 授权模拟不可用、non-development/unknown 默认拒绝,以及显式允许/拒绝 reader 优先级。
  • WebSocket 安装测试覆盖独立 installWebsocket 的默认 fail-closed;断言 bypass 标识和凭证不出现在可见响应。
  • 根级 typecheck、build、test、format check 和 server db:check 必须保持通过。

7. Wrong vs Correct

Wrong

const authorization = dependencies.authorization ?? createUnavailableAuthorizationReader();

Correct

const authorization =
    dependencies.authorization ??
    (config.mindAuthorization
        ? createMindAuthorizationReader(config.mindAuthorization)
        : createUnavailableAuthorizationReader());

授权默认值必须来自固定的 Mind HTTP 配置或显式注入,不能在应用组合层无条件 allow。

Scenario: Bright 历史读取与 Mind 联调页

1. Scope / Trigger

  • TriggerBright 事实消息已经由 OneTalk repository 持久化,需要供 Mind 页面读取历史,并通过同一服务接收授权后的实时状态/消息事件。
  • Scopeapps/server/src/http/onetalk/apps/server/src/onetalk/read-cursor.tsapps/server/src/websocket/registry.ts 的跨层契约;Mind 联调页本身归 apps/mind-test-harness/server 不注册 /harness 页面路由。
  • ExcludedMind legacy 消息源、插件同步代理、发送 outbox 和未确认发送事实不属于此读取边界。

2. Signatures

GET /api/bright/onetalk/accounts/:channelAccountId/conversations
GET /api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId
GET /api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages?fromSentAtMs=<inclusive>&toSentAtMs=<exclusive>&limit=1..100&cursor=<opaque>

OneTalkReadService.listConversations({ scope, query?, cursor?, limit? }) -> result
OneTalkReadService.readConversation({ scope, conversationId }) -> result
OneTalkReadService.readHistory({ scope, conversationId, fromSentAtMs?, toSentAtMs?, cursor?, limit?, purpose? }) -> result
encodeOneTalkListCursor(cursor) -> string
decodeOneTalkListCursor(value) -> cursor | null
encodeOneTalkHistoryReadCursor(cursor) -> string
decodeOneTalkHistoryReadCursor(value) -> cursor | null

3. Contracts

  • HTTP scope 只取路径 channelAccountId 加 Mind Session 授权返回的完整 mindScope;不接受客户端 user/workspace/device header 作为主体。每个请求都使用 OneTalkAuthorizationReader.authorize({ connectionType: "mind_page", operation: "read", scope: { channelAccountId }, cookie? })
  • 会话只读取 conversation_kind = "direct" 的显式 direct fact。列表/详情返回共享 CenterConversationname/avatarUrl 为当前 profile row 的实时值,participantIds 固定为空数组,unreadCount 固定为 0latestMessageId 来自已确认业务锚点,latestMessageAtMs 来自 OneTalk 会话列表活动时间,两者允许独立为空。
  • 列表 query 先 trim,按名称或 conversationId 做 Unicode-insensitive substring;列表直接读取 onetalk_conversation.last_message_at_ms,排序与 cursor 都使用绑定账号、query、asOf、(latestMessageAtMs, conversationId) 的同一 keyset。实时消息只单调推进会话时间;活跃会话在跨页期间前移时由列表刷新重新出现。profile 必须以同一账号与页面会话复合键另行受限读取,再在内存组合;不得把实时资料当作 SQL JOIN 例外。
  • 历史 cursor 不透明且独立绑定账号、会话、from/to 半开窗口、asOf 和 (sentAtMs, messageId) keyset;时间窗为 from <= sentAtMs < to。内部 7777 summary listener 必须同时提供两端时间。
  • HTTP history 与 message.created 都只返回 shared OneTalkCenterMessage:语义 readStatus 加同一 content.version=1text | image | file | business_card | inquiry | order | product union。business_card 在消息事实中严格是 { version: 1, kind: "business_card" } markerread projection 先按同一 channelAccountId + conversationId 受限读取当前 profile,再以内存方式扩展为 contactNamecompanyNamecountryCodeavatarUrl 四项 view 字段。没有 profile 时返回 markerprofile 部分缺失时对应字段为 null,且 view 不得写回 message/profile。read projection 不得解 Base64、custom.datacontentType、卡片原始正文/params、文件名或 URL fallback;它只复制 shared contract 已批准的字段。
  • Mind 联调页由 apps/mind-test-harness/ 提供,只通过同源 Bright HTTP/WS 访问数据;页面侧的运行时形状校验、文本转义、图片/文件展示和去重边界见 mind-test-harness 规范,不属于 server 路由契约。
  • 插件 plugin.statussync.statusmessage.created 只发送给当前仍通过二次 read 授权的精确 Mind scope;消息必须遵循数据库提交 → plugin ACK → Mind publish。public HTTP CORS 只允许精确 Origin 和 Content-Typeinternal summary listener 不注册 CORS。

4. Validation & Error Matrix

Condition Result
Cookie/Session 授权上下文缺失或无效 HTTP 401 auth_required 或授权适配器稳定拒绝
路径账号与授权返回 scope 不一致,或请求带未允许身份 header HTTP 403 scope_mismatch
授权拒绝/撤销/版本变化 HTTP 403 与对应稳定授权错误码
授权依赖不可用 HTTP 503authorization_unavailable
limit 非整数或不在 1..100 HTTP 400invalid_limit
cursor 无法解码或跨账号/会话使用 HTTP 400invalid_cursor
internal summary 缺少 from/to,或 from >= to HTTP 400invalid_time_range
会话不存在 HTTP 404conversation_not_found
history 尚未完整且请求 internal summary HTTP 503 history_incompleteRetry-After: 30
数据库读取失败 HTTP 503database_unavailable;不返回内部异常
插件无在线连接 仍可读历史;响应/事件状态为 offline,实时发送能力保持禁用
消息 kind 为 business_card 且存储 content 含客户资料字段 数据库 CHECK/应用 exact-shape 拒绝;迁移先归一化为 marker
名片读取时同账号同会话没有 profile 返回 marker,不回退到登录人或 item.contact
名片 profile 仅有部分批准字段 返回 view,其余批准字段为 null
profile 属于其他账号或会话 不参与组合;按无 profile 处理

5. Good / Base / Bad Cases

  • Good:页面先读取 Bright direct list/detail/history,保存并回传 opaque cursor,再连接 WS;断线刷新依靠历史恢复,收到重复 message.created 不重复渲染。
  • Base:插件状态来自当前进程 registry;页面/接口显示 offline 不代表历史不可读,当前 profile 变化可以实时反映到列表名称和头像;名片没有 profile 时仍能稳定返回 marker。
  • BadHTTP 路由访问 Mind legacy 表或代理插件接口,使用 anchor/latestMessageId 作为 cursor,放宽 direct filter,在消息观察时使用登录人的 item.contact,把读取 view 写回 message,或把未提交消息先推给页面。

6. Tests Required

  • HTTP:public 列表、详情、历史首/后续页、internal summary gate、direct filter、profile 实时内存组合、query、独立 cursor/asOf、半开窗口、scope/CORS 校验、授权失败、未知会话、offline 状态、非法 limit/cursor/time range、数据库失败和无秘密响应;六类 content 都必须只含 shared normalized 字段,业务卡必须验证 marker→同账号同会话 profile view、无 profile marker、部分 profile null,且不能恢复 Base64 或 raw SDK 字段。
  • WebSocketMind hello/accepted、plugin online/offline、sync status、精确 scope、二次授权、提交后 ACK/publish 顺序、history/live 对同一事实的公开投影等价,以及断线后的连接清理。
  • Mind 联调页行为(offline send gate、list/history query paging、异步代际 fence、active-scope guard、runtime shape validation、文本转义、image load error、conditional file links 和去重关键字段)按 mind-test-harness 规范手工验证;server 自动化测试只覆盖 Bright public HTTP/WS 路径与稳定错误。
  • PostgreSQL:复合索引上的 direct-only keyset 分页跨页不丢不重,cursor 与 anchor 独立,profile 两次受限读取与内存组合、asOf 和真实 migration 后读取仍按账号/会话隔离;历史业务卡仅能存 marker,旧行清理后数据库 CHECK 拒绝附带资料字段。

7. Wrong vs Correct

Wrong

const cursor = conversation.latestMessageId;
return mindLegacyMessages(conversationId, cursor);

Correct

const cursor = decodeOneTalkHistoryReadCursor(request.query.cursor);
const page = await readService.readHistory({ scope, conversationId, cursor, limit });
return reply.send(page);

读取边界只消费 Bright 事实表和独立 keyset cursor;同步 anchor 仍只表达插件同步状态。

// Wrong: 把消息观察时拿到的登录人/发送者 contact 当成会话客户。
const content = normalizeBusinessCard(item, item.contact);
await messageRepository.insertMessage(context, source, { ...message, content });

// Correct: message 事实只存 marker;读取时按受限会话 profile 组合 view。
const stored = { version: 1, kind: "business_card" } as const;
const conversation = await conversationRepository.read(scope, conversationId);
const profile = await contactProfileRepository.read(scope.channelAccountId, conversation.id);
return projectCenterMessage({ ...message, content: stored }, profile ?? undefined);

Scenario: OneTalk 渲染卡补全的 Mind 公共投影

1. Scope / Trigger

  • Trigger:插件已经独立持久化 rendered.card.observed,而 Bright 要经 HTTP history、message.createdmessage.updated 把对应展示信息提供给 Mind。
  • Scopepackages/onetalk-contract/src/content.ts 的 Center-only content union 与 apps/server/src/onetalk/read-projection.ts 的唯一投影;读取、live sync、send confirmation 和 rendered-card flow 都是该投影的消费者。
  • Excluded:插件 wire、ACK、onetalk_rendered_card_content schema、补全去重/冲突规则、Trade-Mind 解码或展示;它们不得把内部 rendered_* type 当作 public content。

2. Signatures

toOneTalkCenterMessage(
    message: OneTalkMessage,
    renderedCardContent?: OneTalkRenderedCardContent,
    customerProfile?: OneTalkReadCustomerProfile,
): OneTalkCenterMessage;

OneTalkCenterMessageContentinquiryproductorder 分支分别可增加已批准的展示字段;OneTalkMessageContentOneTalkRenderedCardContent 仍是彼此独立的持久化/输入契约。

3. Contracts

  • 输出的判别字段始终是基础 inquiry | product | order,绝不输出 rendered_inquiry | rendered_product | rendered_order。无补全时,基础内容保持 exact shape,不用 null 或空对象伪造展示字段。
  • rendered_inquiry 只能补充 productpurchaseQuantityrequirementTextinquiryReferenceactionsrendered_product 只能补充 titleimageUrlpriceDisplayminimumOrderserviceBadgesrendered_order 只能补充 titleproductsproductCountstatuspaymentdeliveryaction
  • product 的 sourceUrlproductId 永远来自基础消息,且必须与补全中的身份一致。补全 kind 或 product 身份不一致、补全形状非法时显式失败,不能丢弃补全、猜测类型或发布部分内容。
  • projectCenterMessage、rendered-card update、live/history sync 和 send-confirmation 都只把基础 message 与 optional supplement 传给 toOneTalkCenterMessage;不得在调用点以 renderedCardContent ?? message.content 替换内容。
  • 补全先到时,后续单条或批量 accepted 基础消息都必须按同一 channelAccountId + conversationId + messageId 受限读取已有 supplement,并在 message.created 投影前带回。提交、guard、ACK 和 publish 顺序保持不变。

4. Validation & Error Matrix

Condition Result
无 supplement 公开基础 content exact shape,不新增可选字段
supplement 与基础 kind 不一致 投影失败,不发布 HTTP/WS 伪内容
rendered product 的 URL 或 product ID 不一致 投影失败,不以补全覆盖基础身份
unknown key、错误嵌套或 ledger 字段进入 Center content strict guard 拒绝
supplement 先到、base message 后由 batch live accepted repository 带回 supplementmessage.created 使用基础 kind 加批准字段
duplicate/conflict supplement 或补全写失败 保持既有 ACK/冲突语义;不改写基础消息事实

5. Good / Base / Bad Cases

  • GoodHTTP、message.createdmessage.updated 都调用同一个投影,订单公开为 kind: "order" 并可带 status/payment,内部 rendered_order 不离开服务端边界。
  • Base:基础 message 先到且没有 supplement 时,后续读取保留原始 shape;补全到达后只发一次同一基础 kind 的 message.updated
  • Badrepository 直接构造 Center message、每个 WebSocket flow 各自 merge 字段,或 batch accepted 分支遗漏既有 supplement;这些都会导致 HTTP/live 形状分叉。

6. Tests Required

  • Contract:三种基础 kind 的无补全 exact shape、补全后的批准可选字段、unknown key/错误嵌套拒绝,以及 supplement kind/product identity 不一致拒绝。
  • Read/WebSocketHTTP 与 message.created/message.updated 对同一事实完全一致;覆盖补全后到、补全先到后单条 live、补全先到后 batch live 和 send confirmation,断言公开 kind 永不为 rendered_*
  • Repositorybatch accepted 以完整复合键读取已有 supplement,并保留 guard、transaction、ACK 与 publish 的既有顺序;测试不以 SQL JOIN 替代受限读取。

7. Wrong vs Correct

// Wrong: internal supplement replaces the public discriminated content.
toOneTalkCenterMessage({ ...message, content: renderedCardContent ?? message.content });

// Correct: the sole projection validates and merges two separate facts.
toOneTalkCenterMessage(message, renderedCardContent);

Scenario: OneTalk WS 固定协议内核与静态已认证路由

1. Scope / Trigger

  • Trigger:新增、删除或重组 OneTalk server 的 inbound WebSocket frame,或调整 Plugin 与 Mind endpoint 的职责边界。
  • Scopewebsocket/protocol-kernel.tswebsocket/handler.tswebsocket/endpoint-routes.tswebsocket/plugin/index.tswebsocket/plugin/flows/{sync-flows,profile-flow,observation-batcher,send-confirmation-flow}.tswebsocket/mind/{index,send-request-flow,session-authorization,publisher}.tswebsocket/authenticated-router.ts;不改变 shared contract、registry/store、pending-send 或领域事实的状态所有权。
  • Excludedwire version/error-code 变更、动态模块注册、extension frame routing、send/profile/buyer-fact 的业务顺序调整。唯一既定的 output 收紧是 confirmed send.result.message 投影为既有 OneTalkCenterMessage,不允许以修改 shared decoder 接受 raw message 字段代替。

2. Signatures

createOneTalkProtocolKernel<TAuthenticatedFrame extends OneTalkFrame>({
    session,
    canAdmit,
    expectedConnectionType,
    isAuthenticatedClientFrame,
    onHello,
    onAuthenticatedFrame,
    // exact error/close/diagnostic callbacks
}): OneTalkProtocolKernel;

createOneTalkAuthenticatedRouter(routes): OneTalkAuthenticatedRouter<TContext>;
// routes: type + connectionType + authorization + operation + handler

createOneTalkWebSocketHandler(
    { expectedConnectionType, authorization, registry, ...sharedPorts },
    { create: endpointCallbacks },
): (socket: WebSocket) => void;
createOneTalkPluginWebSocketHandler(pluginOptions): (socket: WebSocket) => void;
createOneTalkMindWebSocketHandler(mindOptions): (socket: WebSocket) => void;
createOneTalkEndpointAuthenticatedRouter(endpoint, { plugin, mind_page }): OneTalkAuthenticatedRouter<T>;
assertOneTalkEndpointFrameSets({ plugin, mind_page }): void;

createOneTalkSendRequestFlow({ registry, sendFrame, ...narrowPorts }): OneTalkSendRequestFlow;
createOneTalkSendConfirmationFlow({ registry, service, ...narrowPorts }): OneTalkSendConfirmationFlow;
createOneTalkProfileFlow({ profileService, registry, reauthorize, ...narrowPorts }): OneTalkProfileFlow;

TAuthenticatedFrame 必须由 type predicate 从完整 decoded OneTalkFrame 收窄;handler 不得在 callback 边界用类型断言伪造已认证 client frame。

3. Contracts

  • handler.ts 是每 socket 唯一的共享 transport runtimeFIFO、字节限制、JSON parse、strict decode、cutover admission、固定 endpoint connection type、inbound/outbound diagnostics、socket close/unregister 与共享 session reauthorization primitive。它注入 endpoint callback,不能再按 connectionType 选择 Plugin/Mind hello 或业务 Flow,也不能建立 shadow authentication/session state。
  • plugin/index.ts 只拥有 Plugin hello/binding authorization、heartbeat、discovery/sync/observation/profile/buyer facts 与 send.confirmationmind/index.ts 只拥有 Mind Cookie/session hello、heartbeat、send.request 和 Mind subscriber lifecycle。index.ts 仍是唯一 Fastify WebSocket 安装点,只按 URL 组合这两个 fixed handler。
  • 未认证连接只允许 ws.hellohello 后再次收到 ws.hellounknown_requestws.hello 永远不进入 authenticated routerendpoint bootstrap 保留 accepted -> register -> anchor snapshot 顺序。
  • 已认证 frame 先检查 session connection type 与 scope,再分类是否为 client route。因此 decoder 接受但不是 client frame 的错 scope 输入必须是 scope_mismatch 并关闭 1008;同 scope 输入才是 unknown_request,且不关闭连接。
  • authenticated route set 从 ONETALK_CLIENT_FRAME_TYPES 排除 ws.hello 推导。assertOneTalkEndpointFrameSets 要求 Plugin/Mind 两端都恰有一个 heartbeat,其余 frame 必须恰有一个 endpoint owner;它在 Fastify route 注册前拒绝缺失、重复、未知和 hello route。endpoint router 在调用 collaborator 前同时校验 frame 的 connection type 和该 endpoint 的 ownership;禁止模块副作用、运行时自动发现、fallback route 或直接 dispatch 绕过 endpoint view。
  • 每个 route metadata 显式声明 connectionTypeauthorization: "session" | "pending_send" 和 operation。普通 session route 仍经过现有 binding/scope/version/permission/cutover guardsend.request / send.confirmationpending_send 策略只委托既有 registry coordinator,不能复制或绕过其授权窗口、pending/terminal state。
  • endpoint reauthorization 共用同一 binding/scope/version/canonical permission primitive,但 endpoint 自己选择 operation。授权 reader 明确返回 allowed: false 时,Plugin/Mind 都必须先发送稳定 ws.error,再以 1008 关闭;reader 已允许、但现有连接或返回 permission 缺失时,仍只发送 authorization_rejected,保持原有 error-only 行为。Cookie 只在 Mind endpoint 的单次 session authorization 内存中转发,不能进入 wire 或 diagnostics。
  • kernel/router/handler 只做固定 guard、选择和委托。connection/generation、pending-send、commit guard 与 checkpoint 保持原有唯一 owner;每条 plugin connection 的 sync Flow 独占 discovery fragment timer/Set 和 ObservationBatcher。Mind request Flow 只编排 send.request -> registry.requestSend -> send.resultPlugin confirmation Flow 才拥有 confirmation observation/publish。profile Flow 只编排 guarded ingestion、post-write fences、conversation update 与 ACK;这些 Flow 都不能建立第二份连接、pending、timer 或 durable state。
  • send Flow 在 request 前后保留 policy fenceconfirmation 只能使用 coordinator 已同步 claim 的 pending snapshot 与 guard。accepted 的顺序固定为 durable observation -> message.created -> conversation update -> terminal send.resultmatching duplicate 不重发 message.created。唯一 server output seam 必须用 toOneTalkCenterMessage 把 accepted 与 duplicate 的 send.result.message 投影为 strict center fields。
  • profile Flow 只接收 typed contact.profile.observed、canonical source context、store-created guard 与 handler 注入的 reauthorization/close/writer ports。future-skew 必须在 second authorization 前以 profile_observed_at_future 结束;guarded write 成功后仍须依次确认 policy、guard、canonical connection、reauthorization,再对 writtenConversationIds 发布 update,最后用 decoded input 的原始 profile count ACK。
  • observation Flow 保持 database commit -> plugin ACK -> Mind publish;仅 live + accepted 发布 message.createdconversation.updatedduplicate/anomaly/rejected/history/incremental 不发布。completion 必须先等待同一 Flow 的 observation flush,再调用 completeSync、发布 sync.status 与 completion conversation.updated

4. Validation & Error Matrix

Condition Result
payload 超限、非 text 或 strict decode 失败 既有稳定 error 后关闭 1003;不执行 admission 或业务 handler
cutover 不准入 关闭 1013/authorization_unavailable;不发业务 frame
endpoint connection type 不匹配 scope_mismatch 后关闭 1008
未认证的非 hello frame auth_required 后关闭 1008
已认证后再次 hello unknown_request;保持既有连接行为
已认证且 scope/connection type 不匹配(包括 non-client frame scope_mismatch 后关闭 1008,优先于 client classification
已认证、scope 匹配但 frame 非 client unknown_request;不调用 route handler
route 表缺失/重复/未知/hello entry factory 构造失败;服务不能以不完整 registry 启动
route metadata connection type 不匹配 authorization_rejected;不调用业务 collaborator
endpoint router 收到非本端 frame 或其 route 无本端 ownership 拒绝且不调用 route collaborator
endpoint route set 遗漏任一端 heartbeat、业务 frame 双归属或无归属 Fastify route 注册失败
reauthorization reader 返回 allowed: false 稳定 ws.error 后关闭当前 socket 1008
reader 已允许但连接/decision 缺必要 permission authorization_rejected,保持既有 error-only 行为
confirmed send.result 含完整 OneTalkMessage 的 raw-only 字段 shared decoder 拒绝;Flow 必须投影为 OneTalkCenterMessage,不得放宽 contract
profile write 后 policy/guard/canonical/reauthorization 任一失效 不 publish 新 update,且无 late contact.profile.ack

5. Good / Base / Bad Cases

  • Goodshared handler 只组合 transport callback 与窄错误 callbackPlugin/Mind endpoint 分别拥有 hello 和业务 dispatchkernel 拥有固定分派顺序,静态 endpoint 表完整列出当前 authenticated v5 framesend/profile Flow 只接收已收窄 frame 与窄端口。
  • Base:新增 client frame 时,contract list 变化使 typed route table 与 runtime validation 都要求在同一改动中添加 metadata/handler/test;这不是协议兼容层。
  • Bad:在 shared handler 中以 connectionType 分派业务 Flow、在 Flow handler 中 parse raw WebSocket data、先判 unknown 再判 session scope、把 hello 注册为普通 route、让 endpoint router dispatch 绕过 ownership、为 send 再实现 authorization/pending map,或将完整 OneTalkMessage 直接写入 send.result

6. Tests Required

  • router unit test 从 contract list 推导所有非 hello client type;覆盖完整 endpoint 表、两端 heartbeat、missing、duplicate、unknown、hello、单 owner 与 metadata/endpoint connection mismatch,且 mismatch 不调用 collaborator。
  • WebSocket regression 必须验证 FIFO/strict decode、unauthenticated gate、hello/repeated-hello、endpoint/cutover,以及同 scope non-client 为 unknown_request、错 scope non-client 为 scope_mismatch + 1008 的优先级。
  • WebSocket regression 必须分别覆盖 Plugin/Mind 的 operation authorization denialws.error1008 close)及 allowed-but-missing-readerror-only、不关闭);Mind Cookie 只作为 authorization adapter 输入,不能出现在 diagnostics 或 wire。
  • 保留真实 wire matrix 与 sync/observation/send/profile/buyer focused tests,验证所有 client frame 的可见输出、commit -> ACK -> publish、pending-send terminal 和 post-write fenceaccepted 与 matching-duplicate send.result 都必须可被 decodeOneTalkFrame round-trip 接受,并断言 duplicate 不发布;sync completion 用 deferred observation latch 验证 flush 未完成前不得调用 completeSync
  • 运行 server typecheck/build、focused source 与 compiled tests、Oxfmt 和 git diff --check;若遗留 generated test 无法导入未导出的旧 contract symbol,单独记录为非本路由 diff 的基础设施问题,不修改生成产物掩盖它。

7. Wrong vs Correct

// Wrong: non-client 分类可能让跨会话 frame 绕过 scope guard。
if (!isAuthenticatedClientFrame(frame)) return sendUnknown(frame);
if (!isSameOneTalkScope(session.scope, frame.scope)) return closeScopeMismatch(frame);
// Correct: 在路由分类之前,decoder 合法的 frame 必须先确认属于当前会话。
if (!isSameOneTalkScope(session.scope, frame.scope)) return closeScopeMismatch(frame);
if (!isAuthenticatedClientFrame(frame)) return sendUnknown(frame);
return router.dispatch(context, frame);
// Wrong: 共享 transport 决定 endpoint 的业务行为。
if (state.connectionType === "plugin") return pluginSyncFlow.handle(frame);
return mindSendFlow.handle(frame);

// Correct: 每个固定 endpoint 只注入自己的 hello 和已认证回调。
return createOneTalkWebSocketHandler(
    { ...sharedOptions, expectedConnectionType: "plugin" },
    { create: createPluginEndpointCallbacks },
);