Skip to content

test(runtime,service-messaging): notification 族响应体一致性 —— 双断言口径,8 个 emit 站点逐条覆盖 (#5792) - #6379

Merged
qq9340100 merged 1 commit into
mainfrom
claude/issue-5792-notification-conformance
Aug 7, 2026
Merged

test(runtime,service-messaging): notification 族响应体一致性 —— 双断言口径,8 个 emit 站点逐条覆盖 (#5792)#6379
qq9340100 merged 1 commit into
mainfrom
claude/issue-5792-notification-conformance

Conversation

@qq9340100

@qq9340100 qq9340100 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

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 不是路由数:

计法 出处
catalog 声明的端点(DEFAULT_NOTIFICATION_ROUTES) 3 packages/spec/src/api/plugin-rest-api.zod.ts:1042-1103(endpoints:1059-1097)
route-ledger 记录的挂载路由 3 packages/runtime/src/route-ledger.ts:168-170
该族唯一域文件里的 emit 站点 8 packages/runtime/src/domains/notifications.ts
#3899 之前 catalog 里的端点数(含 4 条从未建过的幽灵) 7 git show a1b61e01c^

DEFAULT_NOTIFICATION_ROUTES 曾经声明 devices / preferences 共 4 条从未被任何服务器挂载的端点,
#3899(2026-07-31 合并)把它们摘掉了 —— 而 #3877 是 2026-07-28 立的单,早三天。所以那个 8
最可能是当时的旧表遗留。

本 PR 按站点口径落实「8 条逐条覆盖」,这是今天唯一说得通、也是覆盖面最大的读法:

# emit 站点 形状来源 本 PR 怎么覆盖
1 :79 capabilityUnavailable(deps,'notification') — 槽位空 / 自称非 handler / 无 listInbox inline literal 501 + 声明信封 + serviceUnavailableMessage('notification')
2 :86 deps.error('Authentication required', 401) inline literal 401 + 声明信封
3 :111 deps.success(listInbox 结果) forwarded(producer 在 service-messaging) 双断言 vs ListNotificationsResponseSchema
4 :116 capabilityUnavailable — 无 markRead inline literal 501 + 声明信封
5 :128 throw validationFailure(...) — mark-read body 不合法 inline literal VALIDATION_FAILED + fields 指名 ids
6 :134 deps.success(markRead 结果) forwarded 双断言 vs MarkNotificationsReadResponseSchema
7 :139 capabilityUnavailable — 无 markAllRead inline literal 501 + 声明信封
8 :141 deps.success(markAllRead 结果) forwarded 双断言 vs MarkAllNotificationsReadResponseSchema
:144 return { handled: false } — 未匹配子路径 非 emit 也钉了一条,把「8」这个数关上而不是默认

1.2 三条路由 / 声明 schema / emit 形状来源

路由 声明的 responseSchema emit 形状真正的作者
GET /api/v1/notifications ListNotificationsResponseSchema(行内 NotificationSchema) MessagingService.listInboxmessaging-service.ts:284-333
POST /api/v1/notifications/read MarkNotificationsReadResponseSchema MessagingService.markRead:344-361
POST /api/v1/notifications/read/all MarkAllNotificationsReadResponseSchema MessagingService.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[])。

偏离,逐条给理由:

  1. 没有等价性钉子,因为本族没有宽严 schema 并存。 feat(spec,runtime,metadata-protocol)!: discovery 两个生产者统一到一个 schema —— capabilities 正名、features/endpoints 退役、scoping 声明 (#4828) #5682 的钉子是因为 discovery 有
    GetDiscoveryResponseSchema(宽松 consumer 解析)与 DiscoverySchema(严格 producer)两份。
    本族 catalog 的 responseSchema 就是 producer 契约,没有第二份宽松副本:
    client.notifications.*z.infer 取类型、运行时不 parse
    (packages/client/src/index.ts:3675-3712)。改钉了同一风险的另一形状:
    MarkNotificationsReadMarkAllNotificationsRead 是两份分别声明的 schema,而
    markAllRead 委托给 markRead,一个 producer 方法产出两个 body —— 钉住两者键集相等。
  2. 多了一个集成 gate。有两个事实只有真实 boot 能测:createdAt 经真实 SQL 驱动往返后是否
    仍是 ISO-8601(z.string().datetime() 比「是个字符串」严),以及 actionUrl
    JSON 视图(JSON.stringify 丢弃 undefined)与对象视图(Object.keys 看得见)
    是否都合规 —— 后者正是 routes.mcp 是 REST /discovery 发出、objectui 真实消费、但 ApiRoutesSchema 从未声明的键(#4828 同族,低一层) #5679routes.mcp 上量到的同一处细节。
  3. 非成功站点也覆盖。Stage A 只要求「带 responseSchema 的路由」,但只覆盖 happy path 的
    一致性主张是半个主张,所以 5 个非成功站点按它们确实声明的东西(共享信封 + 状态码)钉住。

2. 违规清单:双断言 3/3 全绿,两个方向都没有

本单的心理预期是按 discovery 族的 2/2 双向排的。实测不成立,如实报告:

方向 结果
漏发必填键(schema.parse 判值) 0 处 —— 三条路由的 body 都满足声明 schema,notifications[] 每行也满足 NotificationSchema
多发未声明键(键集 ⊆ 声明键集) 0 处 —— 顶层与 notifications[] 一层下都没有多余键

实测证据(真实 boot,sqlite-wasm + ObjectQL + service-messaging + hono + dispatcher,真实 socket):

GET /api/v1/notifications
  data keys              ["notifications","unreadCount"]
  declared list keys     ["notifications","unreadCount","cursor"]
  parse                  OK
  row keys               ["id","type","title","body","read","createdAt"]
                         ["id","type","title","body","read","actionUrl","createdAt"]
  declared row keys      ["id","type","title","body","read","data","actionUrl","createdAt"]
  row parse              OK / OK
  createdAt              "2026-08-07T15:02:26.891Z"   (string, 真实驱动往返)

POST /api/v1/notifications/read      → {"success":true,"readCount":1}   parse OK,无未声明键
POST /api/v1/notifications/read/all  → {"success":true,"readCount":1}   parse OK,无未声明键

顺带排除的一个假设:createdAt 可能被驱动还回 Date 或 SQL 方言 —— 不成立,
driver-sql 在读路径上已经统一归一成 toISOString()(sql-driver.ts:225 起,注释原文
"so reads are uniform and unambiguous")。这条也钉进了集成 gate,不靠推断。

2.1 但测量出了两条双断言结构上看不见的真实缺口(已另立单,本 PR 不修)

这是本族最有价值的产出,也是给 #3877 Stage D 棘轮设计的直接输入。

缺口 为什么两条断言都看不见
#6361(请求侧) ListNotificationsRequestSchema 在本路由从不被解析:cursor 声明了但服务端不读,而 client.notifications.list({cursor}) 确实会发;limit 声明默认 20,服务端实际默认 50 请求侧根本不在「响应体一致性」的射程内
#6363(响应侧) unreadCount 声明为 "Total number of unread notifications",实际只数 limit 窗口内的未读;响应侧声明的 cursor 从无 producer 发出 unreadCount 无论对错都是 number,值断言看不见语义;cursoroptional,「从不发出」是合法解析,键断言是 ⊆ 而不是 =,也看不见

实测(60 条未读):

无 limit  →  rows 50   unreadCount 50      (真值 60;声明说是 total)
limit=10  →  rows 10   unreadCount 10
page1 = ?limit=5                                   ids [Ymk…, N4e…, PsG…, 6xL…, tZx…]
page2 = ?limit=5&cursor=tZx2EQQ4MfeKKfWp            ids [Ymk…, N4e…, PsG…, 6xL…, tZx…]
page2 === page1 ?  true      ← 照 SDK 声明分页的调用者会无限停在第一页

处置:两条都是判断题,⛔ 不猜,本 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。
再往下 dataz.record,按 #5679/#4828 的既定判断属于开放键,不递归。


3. 三个文件,分别证明什么

文件 驱动的真实对象 唯一的 double
packages/services/service-messaging/src/notification-schema-conformance.test.ts REAL MessagingService;fixture 由 REAL createInboxChannel 经 REAL emit() 写入 存储引擎
packages/runtime/src/notification-schema-conformance.test.ts REAL HttpDispatcher.handleNotification + REAL domainDeps(真信封构造器) notification 槽位的 provider
packages/runtime/src/notification-schema-conformance.integration.test.ts 全真:sqlite-wasm + ObjectQL + service-messaging + hono + dispatcher,真实 socket 只有 auth

producer gate 的 fixture 刻意不手写行:行由真实的 inbox channel 写进存储,再由真实的
listInbox 读出来 —— 否则测试作者既发明了输入又期待了输出。


4. 反向验证(先预测方向,再跑)

这个族没有可以「把删掉的肢体装回去」的缺陷,所以反向验证是注入式的,而且刻意做了
两个相反方向,因为这一族要证明的正是「两条断言各自看得见对方看不见的东西」。

实验一 —— 多发一个未声明键

预测:断言转红,每一条 safeParse 值断言保持绿(zod object 默认 strip 未知键)。
这个不对称正是第二条断言存在的全部理由;如果值断言也红了,反倒说明我误解了 zod 的行为。

在 producer 的行映射器上临时加 severity: (m.severity as string) ?? 'info':

producer gate:   1 failed | 12 passed
  × emits NO `notifications[]` key the protocol does not declare (KEY assertion, one level down)
    AssertionError: undeclared keys inside notifications[] on the listInbox() body:
      expected [ 'severity' ] to deeply equal []

wire gate:       1 failed | 6 passed
  × emits NO key the protocol does not declare, at the top level and one level down (KEY assertion)
    AssertionError: undeclared keys inside notifications[] on the wire body:
      expected [ 'severity' ] to deeply equal []

方向与逐条身份都吻合:只有键断言红,所有值断言绿

实验二 —— 漏发一个必填键(相反方向)

预测:断言转红(invalid_type),断言保持绿 —— ⊆ 检查对「缺失」结构上是瞎的。

临时删掉行映射器里的 type: (m.topic as string) ?? 'notification':

producer gate:   3 failed | 10 passed
  × satisfies the declared schema (VALUE assertion)
      expected []  received [ "notifications.0.type: invalid_type",
                              "notifications.1.type: invalid_type" ]
  × every row carries the REQUIRED keys, with `createdAt` a real ISO-8601 instant
      row row_4 does not satisfy NotificationSchema:
      {"id":"row_4","title":"Task two","body":"second","read":false,"createdAt":"2026-08-07T15:16:39.032Z"}
  × anti-vacuity: the fixture really produces rows …

两条断言("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.jsontestdependsOn: ["^build"],依赖恒为新构建。
不加 alias 是刻意的 —— 既有的 notifications.hono.integration.test.ts 一直是这个解析方式,
加 alias 会顺带改变它,属越界。


5. 一条非测试改动:service-messaging 新增 @objectstack/metadata-core devDependency

check:engine-double-contract 要求 fake engine 的 update() 以生产者自己的
assertEngineUpdateDispatch(data, options) 开场,而 service-messaging 此前没有这条依赖。
本地实测:不加则该门禁 exit 1(2 处 PINNED);加上并改写后 OK — 80 pinned, 133 DEBT, 4 exempt

devDependencies 不随包发布(files: ["dist", …]),对使用者零影响。
CI 的 Validate Package Dependenciessuccess


6. 验证

pnpm --filter @objectstack/service-messaging test    → Test Files 16 passed, Tests 175 passed
pnpm --filter @objectstack/runtime test              → Test Files 110 passed, Tests 1603 passed
pnpm --filter @objectstack/{runtime,service-messaging} typecheck → 两个包 Done,无错
npx eslint --no-inline-config 3 个新文件              → 无输出,exit 0
node scripts/check-nul-bytes.mjs                     → OK(6005 个文件,无裸控制字节)
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' 3 个新文件 → 无命中
check:engine-double-contract                         → OK — 80 pinned, 133 DEBT, 4 exempt
check:route-envelope / error-code-casing / empty-changeset /
  type-check-coverage / published-files / override-consistency /
  slot-lookup / service-providers / authz-resolver /
  wildcard-fallthrough / query-options-erasure / spec-parsed-alias → 全部 PASS
node scripts/check-changeset-fixed.mjs               → ✓ 与 69 个 public 包同步
pnpm --filter @objectstack/spec check:generated      → All 10 generated artifacts up to date

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,两个方向都没有。


Generated by Claude Code

…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
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 3:39pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-messaging.

4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx (via @objectstack/service-messaging)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-messaging)
  • content/docs/plugins/packages.mdx (via @objectstack/service-messaging)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-messaging)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file tests labels Aug 7, 2026
@qq9340100 qq9340100 added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 7, 2026 — with Claude
@qq9340100
qq9340100 marked this pull request as ready for review August 7, 2026 15:59
@qq9340100
qq9340100 added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 466bd92 Aug 7, 2026
32 of 33 checks passed
@qq9340100
qq9340100 deleted the claude/issue-5792-notification-conformance branch August 7, 2026 16:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

conformance(Stage A / #3877):notification 族响应体一致性测试 —— 双断言口径,预期违规率按 2/2 双向查

2 participants