test(runtime,service-messaging): notification 族响应体一致性 —— 双断言口径,8 个 emit 站点逐条覆盖 (#5792) - #6379
Merged
Merged
Conversation
…it 站点逐条覆盖 (#5792) Part of #3877(Stage A 逐族推进,notification 为本族),照 PR #5682(discovery 族) 的成套做法:每条声明了 responseSchema 的路由上两条刻意不同的断言 —— schema.parse() 判值,发出键集 ⊆ 声明键集判键(单一 parse 对「多发未声明键」永远绿, 因为 zod object 默认 strip 未知键)。允许集一律从 schema 的 shape 推导,不手写数组。 三个 gate,按 producer 分文件(#5682 的形状): - service-messaging:REAL MessagingService,fixture 由 REAL inbox channel 经 emit() 写入,只有存储是 double; - runtime(单元):REAL 域处理器 handleNotification,覆盖全部 8 个 emit 站点 —— 3 个 success 走双断言,5 个非 success(501×3 / 401 / VALIDATION_FAILED)走声明的 信封 + 各自的状态码; - runtime(集成):真实 boot(sqlite-wasm + ObjectQL + messaging + hono + dispatcher), 校验浏览器真正收到的 JSON —— createdAt 经真实驱动往返仍是 ISO-8601, actionUrl 的 JSON 视图与对象视图都合规。 违规率:双断言 **3/3 全绿**,两个方向都没有发现不一致。测量中另发现两条真实的 declared-vs-delivered 缺口,两条断言结构上都看不见,已按判断题另立单 (#6361 请求侧 cursor/limit,#6363 响应侧 unreadCount/cursor),本 PR 只如实钉住现状。 service-messaging 新增 @objectstack/metadata-core devDependency: check:engine-double-contract 要求 fake engine 的 update() 用生产者自己的 assertEngineUpdateDispatch 开场,而该包此前没有这条依赖(五个兄弟 service 包已有先例)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
qq9340100
marked this pull request as ready for review
August 7, 2026 15:59
This was referenced Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5792
Part of #3877(Stage A 逐族推进,维护者 2026-08-06 裁决第 1 条:discovery 已落地,下一族 notification)。
照 PR #5682(discovery 族)的成套做法。纯测试 PR(外加一条为过门禁必需的 devDependency,见下),
不改任何生产代码。
1. 前置侦察:这个族到底是什么(实读
origin/main@881a3cc06)1.1 「8 条薄 emit 路由」的口径更正 —— 是 8 个 emit 站点,不是 8 条路由
#3877 正文 Stage A 表里写
notification (8)。实读之后这个 8 不是路由数:DEFAULT_NOTIFICATION_ROUTES)packages/spec/src/api/plugin-rest-api.zod.ts:1042-1103(endpoints在:1059-1097)packages/runtime/src/route-ledger.ts:168-170packages/runtime/src/domains/notifications.tsgit show a1b61e01c^DEFAULT_NOTIFICATION_ROUTES曾经声明 devices / preferences 共 4 条从未被任何服务器挂载的端点,#3899(2026-07-31 合并)把它们摘掉了 —— 而 #3877 是 2026-07-28 立的单,早三天。所以那个 8
最可能是当时的旧表遗留。
本 PR 按站点口径落实「8 条逐条覆盖」,这是今天唯一说得通、也是覆盖面最大的读法:
capabilityUnavailable(deps,'notification')— 槽位空 / 自称非 handler / 无listInboxserviceUnavailableMessage('notification')deps.error('Authentication required', 401)deps.success(listInbox 结果)ListNotificationsResponseSchemacapabilityUnavailable— 无markReadthrow validationFailure(...)— mark-read body 不合法VALIDATION_FAILED+fields指名idsdeps.success(markRead 结果)MarkNotificationsReadResponseSchemacapabilityUnavailable— 无markAllReaddeps.success(markAllRead 结果)MarkAllNotificationsReadResponseSchemareturn { handled: false }— 未匹配子路径1.2 三条路由 / 声明 schema / emit 形状来源
responseSchemaGET /api/v1/notificationsListNotificationsResponseSchema(行内NotificationSchema)MessagingService.listInbox—messaging-service.ts:284-333POST /api/v1/notifications/readMarkNotificationsReadResponseSchemaMessagingService.markRead—:344-361POST /api/v1/notifications/read/allMarkAllNotificationsReadResponseSchemaMessagingService.markAllRead—:365-370关键结构事实:
domains/notifications.ts对三条成功路径都是deps.success(result)原样透传,所以线上 body 的作者是
service-messaging,域层只是通道。这决定了 gate 必须分两处放—— 只测域层等于只测通道,只测 producer 又看不见通道会不会加键。
1.3 模板沿用 / 偏离(#5682)
沿用:每条路由两条刻意不同的断言;允许集一律
Object.keys(Schema.shape)推导,⛔ 不手写数组;按 producer 分文件(#5682 是 metadata-protocol / rest / runtime 三个,本族是
service-messaging / runtime 单元 / runtime 集成三个);向下延伸一层不递归(#5679 对
routes的同一判断,这里是notifications[])。偏离,逐条给理由:
GetDiscoveryResponseSchema(宽松 consumer 解析)与DiscoverySchema(严格 producer)两份。本族 catalog 的
responseSchema就是 producer 契约,没有第二份宽松副本:client.notifications.*用z.infer取类型、运行时不 parse(
packages/client/src/index.ts:3675-3712)。改钉了同一风险的另一形状:MarkNotificationsRead与MarkAllNotificationsRead是两份分别声明的 schema,而markAllRead委托给markRead,一个 producer 方法产出两个 body —— 钉住两者键集相等。createdAt经真实 SQL 驱动往返后是否仍是 ISO-8601(
z.string().datetime()比「是个字符串」严),以及actionUrl的JSON 视图(
JSON.stringify丢弃undefined)与对象视图(Object.keys看得见)是否都合规 —— 后者正是 routes.mcp 是 REST /discovery 发出、objectui 真实消费、但 ApiRoutesSchema 从未声明的键(#4828 同族,低一层) #5679 在
routes.mcp上量到的同一处细节。一致性主张是半个主张,所以 5 个非成功站点按它们确实声明的东西(共享信封 + 状态码)钉住。
2. 违规清单:双断言 3/3 全绿,两个方向都没有
本单的心理预期是按 discovery 族的 2/2 双向排的。实测不成立,如实报告:
schema.parse判值)notifications[]每行也满足NotificationSchemanotifications[]一层下都没有多余键实测证据(真实 boot,sqlite-wasm + ObjectQL + service-messaging + hono + dispatcher,真实 socket):
顺带排除的一个假设:
createdAt可能被驱动还回Date或 SQL 方言 —— 不成立,driver-sql在读路径上已经统一归一成toISOString()(sql-driver.ts:225起,注释原文"so reads are uniform and unambiguous")。这条也钉进了集成 gate,不靠推断。
2.1 但测量出了两条双断言结构上看不见的真实缺口(已另立单,本 PR 不修)
这是本族最有价值的产出,也是给 #3877 Stage D 棘轮设计的直接输入。
ListNotificationsRequestSchema在本路由从不被解析:cursor声明了但服务端不读,而client.notifications.list({cursor})确实会发;limit声明默认 20,服务端实际默认 50unreadCount声明为 "Total number of unread notifications",实际只数limit窗口内的未读;响应侧声明的cursor从无 producer 发出unreadCount无论对错都是number,值断言看不见语义;cursor是optional,「从不发出」是合法解析,键断言是 ⊆ 而不是 =,也看不见实测(60 条未读):
处置:两条都是判断题,⛔ 不猜,本 PR 一条都不修。 #3676 的结论是「删声明」,#3847 的结论是
「改实现」,方向相反;而且
cursor的请求侧与响应侧必须同向裁决(分页是一个能力的两半)。两单里都按三轴(真实业务需求 / 长远合理性 / 防 AI 犯错)逐条列了证据和建议,请维护者定。
一条对判断有用的实测:
GET /api/v1/notifications的实测消费者只有 SDK。objectui 的通知铃不走这条路由 —— 它直接
dataSource.find('sys_inbox_message', …)、自己 join 回执、自己算未读(
objectui/packages/app-shell/src/layout/AppHeader.tsx:354/391/496),只用本族的两条 mark-read POST。本 PR 对这两条缺口的处理是如实钉住现状、标明「recorded, not endorsed」并写上单号
(#6348 对 ETag 代价的同一做法):裁决一旦落地,该翻的就是这几条断言 —— 这正是钉它们而不是
留给下一个人重新发现的意义。
2.2 #5679 的嵌套键盲区:本族无同形状
notifications[]这一层本 PR 是查了的(否则顶层只有两个键,gate 近乎空转),结果为 0。再往下
data是z.record,按 #5679/#4828 的既定判断属于开放键,不递归。3. 三个文件,分别证明什么
packages/services/service-messaging/src/notification-schema-conformance.test.tsMessagingService;fixture 由 REALcreateInboxChannel经 REALemit()写入packages/runtime/src/notification-schema-conformance.test.tsHttpDispatcher.handleNotification+ REALdomainDeps(真信封构造器)packages/runtime/src/notification-schema-conformance.integration.test.tsproducer gate 的 fixture 刻意不手写行:行由真实的 inbox channel 写进存储,再由真实的
listInbox读出来 —— 否则测试作者既发明了输入又期待了输出。4. 反向验证(先预测方向,再跑)
这个族没有可以「把删掉的肢体装回去」的缺陷,所以反向验证是注入式的,而且刻意做了
两个相反方向,因为这一族要证明的正是「两条断言各自看得见对方看不见的东西」。
实验一 —— 多发一个未声明键
预测:键断言转红,每一条
safeParse值断言保持绿(zod object 默认 strip 未知键)。这个不对称正是第二条断言存在的全部理由;如果值断言也红了,反倒说明我误解了 zod 的行为。
在 producer 的行映射器上临时加
severity: (m.severity as string) ?? 'info':方向与逐条身份都吻合:只有键断言红,所有值断言绿。
实验二 —— 漏发一个必填键(相反方向)
预测:值断言转红(
invalid_type),键断言保持绿 —— ⊆ 检查对「缺失」结构上是瞎的。临时删掉行映射器里的
type: (m.topic as string) ?? 'notification':两条键断言("emits NO top-level key…" / "emits NO
notifications[]key…")保持绿,与预测一致。两个实验合起来正是维护者裁决第 3 条要求的那件事:任一条断言单独都不够。
两个实验都已还原,复跑全绿(见验证段)。
一个顺带量到的 harness 事实,写下来免得下一个人误判
实验一第一次跑时集成 gate 是绿的,不是因为它不敏感,而是因为
packages/runtime的 vitest没有给
@objectstack/service-messaging配 src alias,它读的是已构建的 dist —— 我改的是 src。pnpm --filter @objectstack/service-messaging build之后立刻转红(上面那份输出)。CI 不受影响:
turbo.json里test的dependsOn: ["^build"],依赖恒为新构建。不加 alias 是刻意的 —— 既有的
notifications.hono.integration.test.ts一直是这个解析方式,加 alias 会顺带改变它,属越界。
5. 一条非测试改动:
service-messaging新增@objectstack/metadata-coredevDependencycheck:engine-double-contract要求 fake engine 的update()以生产者自己的assertEngineUpdateDispatch(data, options)开场,而service-messaging此前没有这条依赖。本地实测:不加则该门禁 exit 1(2 处 PINNED);加上并改写后 OK — 80 pinned, 133 DEBT, 4 exempt。
metadata-core而非objectql:谓词自 [engine-double-contract] 把 assertEngineDeleteDispatch 下沉到 @objectstack/metadata-core —— 七条 metadata-protocol 基线条目唯一存在的关闭路线(#4987 只修了处方文字) #5619 起就住在这里,依赖面更小;五个兄弟 service 包已有先例(
service-package用 metadata-core,service-automation/service-knowledge/service-queue/service-storage用 objectql)。pnpm-lock.yaml的 diff 是 3 行,只在 importers 段加了一个 workspace link,别无他物。这是为过门禁必需的最小改动;另一条路(往 shrink-only 的 baseline 里加豁免)会把棘轮放宽,更差。
delete/count/aggregate—— 没人跑的假动作就是没人检查的第二份契约。
devDependencies不随包发布(files: ["dist", …]),对使用者零影响。CI 的
Validate Package Dependencies已 success。6. 验证
rebase 到
origin/main@881a3cc06之后重跑(§9:pnpm install --frozen-lockfile+ 重建依赖 +rm -rf packages/runtime/.objectstack)。进入的 8 个提交里packages/runtime/src/standalone-stack.ts与
packages/objectql/src/engine.ts有改动,与本 PR 的 diff(notification 域 + service-messaging)无重叠;packages/spec那侧动的是bulk-action.zod.ts/flow.zod.ts,不是本族的 schema。changeset:无,且这是刻意的。 纯测试 + 一条不发布的 devDependency,不释放任何东西。
因此
Check Changeset现在是红的,需要skip-changeset标签才会转绿 —— 按本次派工要求,本 agent 不自贴任何标签,请 PM 落标(读回现有标签后写并集:
size/l+dependencies+tests+skip-changeset)。7. 回报 #3877(PM 验收后转发)
notification 族违规率:双断言 0/3,两个方向都没有。
在这一族不成立。样本至此:i18n 4/5、discovery 2/2、notification 0/3 ——
三个样本方差极大,建议后续族(analytics / automation 已声明路由)不要再预设违规率,
按「小步买信息」逐族实测。
(GET /api/v1/notifications 从不解析它声明的请求 schema ——
cursor被静默丢弃(SDK 分页永远第一页),limit默认 20 声明 vs 50 实现 #6361 / notification 响应侧:unreadCount声明「总未读数」实测只数 limit 窗口内;响应cursor从无 producer 发出 #6363),而双断言结构上两条都看不见:unreadCount声明「总未读」实际「窗口内未读」,两种实现都是number;cursor)。「每个声明的键都要有 producer 交付,或显式记为不交付」。它的对象正是
GetTranslationsRequestdeclaresnamespace/keysthat no server reads — declared ≠ enforced #3676 那一类(声明了服务器不读 / 不发的东西),也是本族仅有的两条真实缺陷所在。代价可控:它只需要在
已有 gate 上加一次「declared − emitted」的差集,并允许一份带理由的豁免表。
deps.success(result),body 的作者在service 包),与 discovery 的「域层自己拼 body」不同。透传族要把 gate 放两处——
只测域层等于只测通道,只测 producer 又看不见通道加键。后续 analytics / automation
的已声明路由属同一形状,建议照此分文件。
Generated by Claude Code