Repository navigation
Description
发现于 #6436 的实施(PR 见下方关联)。观察类(finding),不是今天用户会碰到的缺陷 —— 见下面的「可达性」一节:出厂管线不产生这种行。记在这里是因为它是 ADR-0030 读态键的一个真实结构性缺口,而不是某段代码的笔误。
事实
读态存在 sys_notification_receipt 上,键是 (notification_id, user_id, channel)(ADR-0030)。一条 sys_inbox_message 行如果 notification_id 为 null:
createInboxChannel的writeDeliveredReceipt直接if (!r.notificationId) return;—— 不写任何收据(packages/services/service-messaging/src/inbox-channel.ts)。listInbox的连接读m.notification_id,为 null 则state为 undefined ⇒read: false。- notification 响应侧:
unreadCount声明「总未读数」实测只数 limit 窗口内;响应cursor从无 producer 发出 #6363 的countUnreadTotal明确把它数成未读(注释原话:never receipted, therefore always unread)。
三条合起来:这种行恒为未读,没有任何写入能改变它。
markAllRead 之前会把它的收件箱行 id(listInbox 视图里的 id: nid ?? String(m.id))喂给 markRead,于是插入一条 notification_id = 收件箱行 id 的收据 —— 连接永远读不回来,既没让行变成已读,又把自己计进了 readCount。#6436 的 PR 已把它从清扫集合中剔除(收据无键可写,跳过),所以 readCount 现在是诚实的;但**「这种行该不该可读」本身没有被解决**,也不该由那个 PR 顺手定。
可达性:今天不可达(这是它被标 finding 的理由)
单一 ingress MessagingService.emit() → writeEvent() 在任何分支都返回非空 id(无 data engine 时也合成 evt_...,messaging-service.ts:737),fan-out 再把它带进 Delivery.notification.notificationId。收件箱行只由该管线写。所以 null 只可能来自绕过 ingress 的直接写入或迁移/遗留数据。已在 packages/services/** 与 packages/runtime/** 搜过,没有第二个写 sys_inbox_message 的站点。
可选路线(不猜,留给分诊)
- A. 收口声明 ——
sys_inbox_message.notification_id改成required: true(今天是可选字段),让「无事件 id 的收件箱行」在写入时就被拒。声明即强制,与管线的实际行为一致;代价是遗留行需要一次迁移判断。 - B. 让读态可退回行 id —— 收据键允许以收件箱行 id 兜底。会给读态引入第二种键,跨渠道语义(ADR-0030 把读态放在收据上的理由)随之变模糊;不推荐,列出以求完整。
- C. 明确记为不支持 —— 在对象定义与 ADR-0030 上写明:无事件 id 的收件箱行不参与读态。最省事,但把一条恒为未读、用户永远清不掉的行留在系统里。
倾向 A(声明即强制,且与 ingress 已有的行为一致),但涉及 spec 侧字段必填性,交由分诊/维护者定。
关联
- 母单:notification:
markAllRead只清窗口内的 200 条 —— 未读超过 200 的用户按「全部已读」清不掉角标 #6436(markAllRead全量清扫) - 相邻:notification 响应侧:
unreadCount声明「总未读数」实测只数 limit 窗口内;响应cursor从无 producer 发出 #6363(unreadCount数总未读) - 读态模型:ADR-0030
Activity
claude commentedon Aug 7, 2026
Findings triage — verdict: HOLD (
findingretained,domain:servicesunchanged).Why still held: reachability is zero and the body proves it rather than asserting it — single ingress
MessagingService.emit()→writeEvent()returns a non-null id on every branch (synthesisingevt_…even with no data engine,messaging-service.ts:737), fan-out carries it intoDelivery.notification.notificationId, and a search ofpackages/services/**andpackages/runtime/**finds no second writer ofsys_inbox_message. A row that cannot be produced cannot be a defect a user hits today, and the accounting half that was reachable —markAllReadinserting a receipt keyed on the inbox-row id, inflatingreadCount— is already fixed by #6436's PR.Why not promoted: what remains is a three-way contract fork, and the leading route is a maintainer call rather than an implementing one. Route A makes
sys_inbox_message.notification_idrequired, which changes the accepted set (⇒domain:specred line) and forces a stored-data judgement on any legacy row — the skill routes exactly that combination to the decision inbox, not the queue. Filing it there today would put a zero-reachability question in front of the maintainer competing with live cards; the honest state is "recorded, correct, not yet worth a decision window".Restart condition (a hold without one is a state nobody can legally exit) — promote to
needs-user-decisionas an A-vs-C card when any of these becomes true:sys_inbox_messageappears, or the single-ingress invariant is otherwise broken (this is the load-bearing fact — if it goes, so does the hold);notification_id IS NULLis observed in any environment, including via import or migration;sys_inbox_messageacquires a migration for an unrelated reason — route A's only real cost is the legacy sweep, so it becomes near-free to ride along.Route B stays rejected on the body's own grounds (a second read-state key blurs the cross-channel semantics that are ADR-0030's reason for putting read state on the receipt), and that judgement does not need re-litigating when this comes back.
Stale-premise check:
packages/services/service-messaging/src/inbox-channel.ts:62still readsif (!r.notificationId) return;onorigin/main, and nothing has landed on that file since filing. Premise holds.No
target:<major>: unreachable through the shipped pipeline, so it cannot block the RC.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-project-manager commentedon Aug 8, 2026
Findings sweep (maintainer-authorized one-off, 2026-08-07 — registered on #6015): hold confirmed. Real structural gap in the ADR-0030 read-state key, but the factory pipeline produces no
notification_id-less rows today, so nothing is permanently-unread in practice. Restart: any producer that writes inbox rows withoutnotification_id, or an ADR-0030 revision touching the receipt key. Note: today's #6363 ruling (true total unread) will make this row class visible in the count if one ever appears — a natural tripwire.Generated by Claude Code
fix(services): markAllRead 清空整个收件箱,不再只清列表窗口的 200 条 (objectstack-ai#6436…
os-zhuang commentedon Aug 9, 2026
Findings round — hold re-confirmed (
finding+domain:servicesunchanged). Stale-premise check onorigin/main@424316b:sys_inbox_messageunderpackages/services/**+packages/runtime/**is unchanged (inbox-channel + the runtime notifications domain — the same single-ingress face the card searched), so no producer ofnotification_id-less rows has appeared.notification_idrequired / B: second receipt key / C: document as unsupported), where route A touches spec-side field requiredness — a maintainer-class call with no current victim, which is exactly what keeps this a hold rather than a queue card. The notification 响应侧:unreadCount声明「总未读数」实测只数 limit 窗口内;响应cursor从无 producer 发出 #6363 tripwire noted last sweep still applies: if such a row ever appears, the true-total unread count makes it visible.Restart unchanged: any producer writing inbox rows without
notification_id, or an ADR-0030 receipt-key revision.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-zhuang commentedon Aug 10, 2026
Findings-triage re-check (quota round): HOLD maintained (
findingstands, no label change).Premise re-verified on
origin/main@2c28df9: the receipt-skip guard is live verbatim —if (!r.notificationId) return;atpackages/services/service-messaging/src/inbox-channel.ts:62— so asys_inbox_messagerow with nullnotification_idstill can never be receipted and stays permanently unread. Reachability unchanged: the shipped pipeline does not produce such rows, so no user hits this today. Structural ADR-0030 read-state-key gap, held as filed. (Adjacent new traffic — ui#4110/ui#4156 bell-popover emptiness — is a client filter defect over rows that DO have receipts; unrelated mechanism, checked before writing this.)本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
claude commentedon Aug 10, 2026
Findings round (quota re-verification): HOLD maintained (
finding+domain:servicesunchanged). Re-checked @origin/maina76a3af: the receipt-skip guard is live verbatim —if (!r.notificationId) return;atservice-messaging/src/inbox-channel.ts:62— so asys_inbox_messagerow with nullnotification_idstill can never be receipted. Reachability unchanged (single-ingress pipeline always supplies an event id); the three-way contract fork (A: required / B: second receipt key / C: documented-unsupported) remains a maintainer-class call with no current victim. Restart conditions unfired.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-zhuang commentedon Aug 11, 2026
Findings cadence re-check @
origin/main1530870: HOLD —findingstands.Premise live and now self-documenting:
packages/services/service-messaging/src/messaging-service.ts(docblock aboveunreadNotificationIds, ~:488-497) explicitly records that rows carrying nonotification_idare skipped by the mark-read sweep and "keep reporting as unread", citing this issue by number as the receipt-KEY gap — dormant because the singleemit()ingress always carries an event id, so only data written around it can hit the branch. Held: the key-shape question (should such a row be readable at all) is design work, not a patch, and nothing reaches it today.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-zhuang commentedon Aug 11, 2026
Findings cadence re-check @
origin/main4ed4160: HOLD —findingstands.Premise frozen:
writeDeliveredReceipt's skip is verbatim atpackages/services/service-messaging/src/inbox-channel.ts:62(if (!r.notificationId) return;), and no commit has touchedpackages/services/service-messaging/since the last check. The unreachability argument also still holds — the single-ingress rule is even documented insys-notification.object.ts's layering docblock ("no producer writes this row directly"), so a nullnotification_idinbox row remains migration/bypass-only territory.Why still held rather than promoted: the card's own preferred route A flips
notification_idtorequired— an acceptance-surface change on a platform object, which is a spec-side call the eventual promotion should carry todomain:specper the red line, not something to queue as a services card. While the row class is unproducible, nothing a user can reach is broken, and the decision loses nothing by waiting. Restart condition unchanged: promote the moment any second writer ofsys_inbox_messageappears, or when ADR-0030's read-state model is next opened.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-zhuang commentedon Aug 11, 2026
Findings triage round, 2026-08-11 (maintainer-directed). Graded: hold — the factory pipeline produces no
notification_id-less rows, so the permanently-unread state has no producer today; #6436 already madereadCounthonest. Restart: any producer of such rows appears, or the next ADR-0030 read-state revision (settle "should these rows be readable" there).Generated by Claude Code
hotlong commentedon Aug 12, 2026
Finding-grading round: closed, not planned.
Unreachable by construction (single
emit()ingress always setsnotificationId; the guard atinbox-channel.ts:62is dead defence-in-depth), 8 identical holds since 08-07, and the knowledge now lives in-source at the exact function a future author would touch (messaging-service.ts:513-523docblock cites this card by number). Re-file if a second writer ofsys_inbox_messageappears or ADR-0030's receipt key is reopened (route A is adomain:specaccept-set change either way).Generated by Claude Code
fix(service-messaging)!: mark-read writes a read receipt only for a n…