发现于 #5792(Stage A / #3877 notification 族响应体一致性测试)的前置测量。不在该 PR 修——两条都是
#3676(删声明)与 #3847(改实现)方向相反的判断题,按纪律另立单。
这是 #6361 的响应侧对照单(那单是请求侧)。两条都活在双断言看不见的盲区里,值得单说:
schema.parse() 判值、键集 ⊆ 声明键集判键——两条都绿,因为 unreadCount 无论对错都是个
number,而 cursor 是 optional 所以「从不发出」也是合法的。这一点本身是给 #3877 Stage D
棘轮设计的输入。
事实 1:unreadCount 的声明语义与交付语义不一致
声明(packages/spec/src/api/protocol.zod.ts:924):
unreadCount: z.number().describe('Total number of unread notifications')
.describe() 不是注释——它进 content/docs/references/,是发布出去的 API 文档原文。
实现(packages/services/service-messaging/src/messaging-service.ts:284-333)自己的注释就写着相反的话:
unreadCount is computed over the fetched window (bounded by limit, like the Console bell's poll).
计数发生在 rows.map(...) 里,而 rows 已经被 limit 截断过(默认 clamp 到 50,上限 200)。
实测(真实 boot:sqlite-wasm + ObjectQL + service-messaging + hono + dispatcher,60 条未读):
无 limit 查询 → rows: 50 unreadCount: 50 ← 真值是 60
?limit=10 → rows: 10 unreadCount: 10 ← 真值是 60
也就是说:一个未读超过窗口的用户,拿到的角标永远等于窗口大小,而契约说这是总数。
事实 2:响应侧 cursor 没有任何 producer 发出
声明(ListNotificationsResponseSchema,protocol.zod.ts:925):
cursor: z.string().optional().describe('Next page cursor')
实测:GET /api/v1/notifications?limit=5 的响应体里 cursor 键不存在
(Object.prototype.hasOwnProperty(body.data, 'cursor') === false),listInbox 的返回类型
(InboxListResult,packages/spec/src/contracts/notification-service.ts:100-104)也只有
{ notifications, unreadCount }。与 #6361 的请求侧 cursor 是同一个未实现的分页能力的两半。
谁在消费(三轴的「业务需求」证据,实读得来)
GET /api/v1/notifications 的实测消费者只有 SDK(client.notifications.list)。
objectui 的通知铃不调这条路由:它 dataSource.find('sys_inbox_message', …),自己 join
sys_notification_receipt,自己 reduce 出未读数
(objectui/packages/app-shell/src/layout/AppHeader.tsx:354 / :391 / :496),
只用本族的两条 mark-read POST。所以今天没有界面被这个错值伤到——但它是发布出去的契约。
判断题(不猜,列证据供裁决)
关于 unreadCount
A. 改实现(#3847 方向) —— 真的数总未读:窗口外的未读也要算进去。
- 长远合理性轴:角标就该是总数,这是「通知铃」这个 UI 元件的通用语义;声明本来就是对的。
- 代价:读路径要多一次计数。读态存在另一个对象(
sys_notification_receipt,ADR-0030),
所以「未读总数」不是一次 count(sys_inbox_message) 能答的——要么反连接,要么维护计数。
这是真实设计工作。
- 防 AI 轴:强。声明与交付一致,消费者不需要知道实现细节。
B. 改声明(#3676 方向) —— 把 .describe() 改成「所返回窗口内的未读数」,并按惯例
提供「50+」式的上限表达。
- 业务需求轴:今天没有实测消费者被伤到(见上),而按窗口计数正是 Console bell 轮询的实际用法。
- 长远合理性轴:弱一些——把一个可以正确交付的语义降级为窗口语义,是让契约迁就实现。
- 注意:改
.describe() 会动 content/docs/references/(生成物),按 AGENTS.md 走
pnpm --filter @objectstack/spec gen:schema && gen:docs。
建议:A(改实现),理由是三轴里长远合理性与防 AI 都指向同一边,而「没人消费」在这里
不是削减能力的理由——unreadCount 是本路由存在的主要产出之一(另一半 notifications[] 本就
可以从 sys_inbox_message 直接读到,objectui 正是这么做的),把它降级为窗口计数等于让这条路由
只剩一个更差的数据 API。但代价落在读路径设计上,请维护者定。
关于响应 cursor
与 #6361 的请求侧 cursor 必须同向裁决——分页是一个能力的两半,只删一半或只实现一半都是新的不一致。
建议随 #6361 一并决定。
关联
发现于 #5792(Stage A / #3877 notification 族响应体一致性测试)的前置测量。不在该 PR 修——两条都是
#3676(删声明)与 #3847(改实现)方向相反的判断题,按纪律另立单。
这是 #6361 的响应侧对照单(那单是请求侧)。两条都活在双断言看不见的盲区里,值得单说:
schema.parse()判值、键集 ⊆ 声明键集判键——两条都绿,因为unreadCount无论对错都是个number,而cursor是optional所以「从不发出」也是合法的。这一点本身是给 #3877 Stage D棘轮设计的输入。
事实 1:
unreadCount的声明语义与交付语义不一致声明(
packages/spec/src/api/protocol.zod.ts:924):.describe()不是注释——它进content/docs/references/,是发布出去的 API 文档原文。实现(
packages/services/service-messaging/src/messaging-service.ts:284-333)自己的注释就写着相反的话:计数发生在
rows.map(...)里,而rows已经被limit截断过(默认 clamp 到 50,上限 200)。实测(真实 boot:sqlite-wasm + ObjectQL + service-messaging + hono + dispatcher,60 条未读):
也就是说:一个未读超过窗口的用户,拿到的角标永远等于窗口大小,而契约说这是总数。
事实 2:响应侧
cursor没有任何 producer 发出声明(
ListNotificationsResponseSchema,protocol.zod.ts:925):实测:
GET /api/v1/notifications?limit=5的响应体里cursor键不存在(
Object.prototype.hasOwnProperty(body.data, 'cursor') === false),listInbox的返回类型(
InboxListResult,packages/spec/src/contracts/notification-service.ts:100-104)也只有{ notifications, unreadCount }。与 #6361 的请求侧cursor是同一个未实现的分页能力的两半。谁在消费(三轴的「业务需求」证据,实读得来)
GET /api/v1/notifications的实测消费者只有 SDK(client.notifications.list)。objectui 的通知铃不调这条路由:它
dataSource.find('sys_inbox_message', …),自己 joinsys_notification_receipt,自己 reduce 出未读数(
objectui/packages/app-shell/src/layout/AppHeader.tsx:354/:391/:496),只用本族的两条 mark-read POST。所以今天没有界面被这个错值伤到——但它是发布出去的契约。
判断题(不猜,列证据供裁决)
关于
unreadCountA. 改实现(#3847 方向) —— 真的数总未读:窗口外的未读也要算进去。
sys_notification_receipt,ADR-0030),所以「未读总数」不是一次
count(sys_inbox_message)能答的——要么反连接,要么维护计数。这是真实设计工作。
B. 改声明(#3676 方向) —— 把
.describe()改成「所返回窗口内的未读数」,并按惯例提供「50+」式的上限表达。
.describe()会动content/docs/references/(生成物),按 AGENTS.md 走pnpm --filter @objectstack/spec gen:schema && gen:docs。建议:A(改实现),理由是三轴里长远合理性与防 AI 都指向同一边,而「没人消费」在这里
不是削减能力的理由——
unreadCount是本路由存在的主要产出之一(另一半notifications[]本就可以从
sys_inbox_message直接读到,objectui 正是这么做的),把它降级为窗口计数等于让这条路由只剩一个更差的数据 API。但代价落在读路径设计上,请维护者定。
关于响应
cursor与 #6361 的请求侧
cursor必须同向裁决——分页是一个能力的两半,只删一半或只实现一半都是新的不一致。建议随 #6361 一并决定。
关联
cursor被静默丢弃(SDK 分页永远第一页),limit默认 20 声明 vs 50 实现 #6361(cursor不被读取 /limit默认 20 vs 50)GetTranslationsRequestdeclaresnamespace/keysthat no server reads — declared ≠ enforced #3676(删声明)、GetFieldLabelsResponsedeclares rich label entries; both surfaces emit bare strings #3847(改实现)、dispatcher 多个 domain 调用契约里没有的方法 —— #4087 的同类,只是方向相反(契约缺声明,不是调用点乱编) #4127(InboxQuery已删cursor)——棘轮若只有「值 + 键」两问,这一类「声明了但从不交付 / 交付了但语义不同」会整类漏掉