Skip to content

站内信新消息到达完全静默:增加站内 toast 与桌面通知(不含推送通道) #7011

Description

@baozhoutao

问题

站内信(收件箱)目前对"新消息到达"完全静默:sharedUserFeeds 每 10 秒(后台标签 60 秒)轮询 sys_inbox_message,新行到达后唯一的表现是铃铛角标数字变化和首页动态中心多一行。没有 toast、没有横幅、没有声音、没有浏览器系统通知。

排查确认过所有潜在弹出路径均未接到站内信到达上:

  • packages/app-shell/src/hooks/sharedUserFeeds.ts 只把新数据写进 store,无"检测到新增行 → 提示"的逻辑;
  • console 的 sonner toast 体系(ConsoleShell 挂的 NotificationProvider + presentNotificationToast)只服务页面/动作主动声明的 displayType: 'toast' 通知,与收件箱轮询无关;
  • 全仓库无任何 new Notification(...) / Notification.requestPermission() 调用,mobile 包的 service worker 只做 PWA 缓存。

结果:用户不主动盯铃铛就完全感知不到新消息,审批、@提及等时效性消息经常被漏看。

方案

只做通知呈现层,不动传输层(轮询保留原样;WebSocket/SSE 推送通道明确不在本 issue 范围内,属于 framework 侧的平台工作,另行立项)。分两块:

1. 站内 toast:前台标签页,到达即弹

在 sharedUserFeeds 的 inbox feed 上 diff 出"本次轮询新增且未读"的行,通过现有 toast 体系弹出提醒:

  • 首拉不弹:会话内维护"已见 id 集合",仅对首拉之后新出现的行弹;登录/刷新时的历史未读只走角标,不弹屏。
  • 合并弹:一个轮询周期内新增多条时合并为一条("你有 N 条新消息"),单条时显示标题摘要;复用收件箱既有的 (topic, title) 折叠口径,不刷屏。
  • 点击跳转:点 toast 走既有 resolveNotificationTarget 深链到目标记录,无链接的打开铃铛面板;点击后同步标记该条已读(走既有 POST /api/v1/notifications/read)。
  • 自己触发的不弹:actor 是当前用户自身的回执类消息不弹。
  • 仅在标签页可见(document.visibilityState === 'visible')时弹 toast;隐藏标签交给下面的桌面通知,两者互斥。

2. 桌面通知(Notification API):标签页在后台时

  • 仅当标签页 hidden 且用户已授权时,对新增未读行弹系统通知(同样合并、同样首拉不弹)。
  • 授权流程:绝不在页面加载时索要权限。在用户设置里加"桌面通知"开关,用户主动开启时才调 Notification.requestPermission();被拒后开关置灰并提示去浏览器设置恢复。
  • 点击系统通知:window.focus() + 深链,与 toast 同口径。
  • 已知约束:后台标签轮询为 60 秒降频,桌面通知最坏延迟约 1 分钟——本 issue 接受该延迟,秒级实时依赖后续推送通道立项。

3. 最简通知偏好

设置项(本地持久化即可,服务端偏好对象可后续跟进):

  • 站内 toast:开/关(默认开);
  • 桌面通知:开/关(默认关,开启即触发授权流程)。

验收要点

  • 前台标签:另一用户触发一条站内信后,≤10s 内出现 toast,点击可跳转且该条转为已读;
  • 刷新页面:存量未读只更新角标,不弹任何 toast;
  • 后台标签 + 已授权:收到系统通知,点击聚焦并跳转;未授权/关闭开关则完全静默(回归现状);
  • 同周期多条消息只弹一条合并提示;
  • showcase 真机实测(examples/app-showcase),测试实例作为夹具保留。

明确不做

  • WebSocket / SSE / 长连接推送(framework 平台工作,另行立项);
  • 声音提醒;
  • 邮件/短信等站外渠道;
  • 服务端持久化的通知偏好对象(本期本地存储)。

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Sep 5, 2026
  2. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    分诊 · domain:ui / priority:p2 / pm:queue

    Anchor read, not guessed. 三块交付(toast 层、Notification API、偏好开关)全部落在 packages/app-shell ⇒ domain:ui。

    复核 objectui origin/main a472b07(2026-09-05T00:50:33Z):

    断言 读数
    收件箱 feed 只把数据写进 store sharedUserFeeds.ts:745 — dataSource.find('sys_inbox_message', {…});:12 的表头自陈这条 feed 服务于 AppHeader bell Notifications tab
    全仓无任何系统通知调用 new Notification( = 0 命中(packages/ + apps/) ✅

    ⇒ 「完全静默」这一前提成立:数据到了 store,呈现层没有任何东西被接上去。

    ⚠️ 一处本席未能复核干净,如实记下:卡面点名的 presentNotificationToast 在源码里没直接命中(只在 CHANGELOG.md / README.md 里出现),可能是拼写或位置已变。⛔ 接手时请按符号重新定位 toast 体系的入口,不要照抄这个名字。

    定级 p2

    • 不是 p3:后果是漏看,而卡面点名的正是时效性最强的两类 —— 审批与 @提及。一个用户不主动盯铃铛就完全感知不到,这是产品可用性缺口,不是打磨项。
    • 不是 p1:没有数据丢失,消息都在,铃铛角标与首页动态中心都会更新 —— 信息是可得的,只是不是主动的。

    ⭐ 由 MEMBER(维护者)提出,且排查已经做完:三条潜在弹出路径逐条否定过(feed 无 diff 逻辑、console 的 sonner 只服务主动声明 displayType: 'toast' 的通知、全仓无 Notification API、mobile 的 SW 只做 PWA 缓存)。⇒ 这不是一张需要先做调研的卡。

    边界测试 —— Feature,人工地板之上;⛔ 但形状已由提卡人钉死

    新增用户可见能力 + 新增设置项 ⇒ Feature。⚠️ 但本卡不需要一次新的裁决:提出者是维护者,验收要点、合并口径、授权流程、明确不做的范围都写死了。⇒ 按卡面派发即可。

    ⭐ 范围纪律值得表扬并原样保留:只做呈现层,不动传输层。轮询保留原样;WebSocket/SSE 推送通道明确不在本 issue 范围内(framework 侧平台工作,另行立项)。⛔ 接手者不要顺手做实时推送 —— 那会把一张可交付的卡变成一个跨仓项目。

    ⭐ 五条最容易被做丢的设计约束(卡面已写,本席逐条加权)

    1. 首拉不弹 —— 会话内维护「已见 id 集合」,登录/刷新时的历史未读只走角标。⛔ 这条做错的表现是「每次刷新页面弹十条」,是最快被用户关掉通知的方式。
    2. 合并弹 —— 一个轮询周期内多条合并为一条,复用收件箱既有的 (topic, title) 折叠口径。
    3. ⭐ 自己触发的不弹 —— actor 是当前用户自身的回执类消息不弹。⛔ 这条最容易漏,而漏了会让「我刚提交,系统就通知我我提交了」。
    4. ⭐ 绝不在页面加载时索要权限 —— 只有用户在设置里主动开启时才调 Notification.requestPermission();被拒后开关置灰并提示去浏览器设置。⛔ 加载即索权是浏览器通知最常见的反模式,一旦被拒就永久失去这个通道。
    5. toast 与桌面通知互斥 —— 仅在 document.visibilityState === 'visible' 时弹 toast,隐藏标签交给桌面通知。

    ⚠️ 已知约束卡面自己接受了,值得在验收时复述:后台标签轮询降频到 60 秒,桌面通知最坏延迟约 1 分钟 —— 秒级实时依赖后续推送通道立项。⇒ ⛔ 不要为了压低延迟而在本卡里动轮询频率。

    验收

    卡面给了五条可执行判据(前台 ≤10s 出现 toast 且点击跳转并转已读 / 刷新只更角标 / 后台已授权收到系统通知 / 未授权完全静默回归现状 / 同周期多条只弹一条),并要求 showcase 真机实测,测试实例作为夹具保留。⇒ 照抄进派发单。

    ⛔ 本席不认领、不派发、不代裁。


    Generated by Claude Code

  3. self-assigned this
    on Sep 8, 2026
  4. os-justin commented on Sep 8, 2026

    @os-justin
    Collaborator

    Claim: PM dispatch — domain:ui execution seat.

    • Session: session_01YBWFb5YgMU5dw8p2VKj16S
    • Branch: claude/issue-7011-inbox-toast-and-desktop-notification
    • Assignee set by this PM on behalf of the dispatched dev; the dev inherits both and posts no second claim.

    Why a Feature is dispatchable here without a fresh ruling

    This adds a user-visible capability and a settings entry, which is normally above the manual floor. Triage's reading, adopted:

    本卡不需要一次新的裁决:提出者是维护者,验收要点、合并口径、授权流程、明确不做的范围都写死了。⇒ 按卡面派发即可。

    ⇒ The maintainer's own filing is the floor action — the same shape as the #5934 ruling this seat's handover records ("本裁定即该地板动作的记录"). ⛔ The dev therefore implements to the card, and any temptation to redesign is out of scope rather than initiative.

    ⛔ The scope fence, carried hardest

    呈现层 only, 不动传输层。轮询保留原样;WebSocket/SSE 推送通道明确不在本 issue 范围内 — framework-side platform work, separately projected.

    ⛔ Do not build realtime push. ⭐ Triage's warning is in the order verbatim: that turns a deliverable card into a cross-repo project. And the accepted latency is part of the deal — background-tab polling drops to 60s, so worst-case desktop-notification delay is about a minute. ⛔ Do not touch the polling frequency to shave it.

    ⭐ The five constraints most easily lost — all five are in the order, with their failure modes

    1. First fetch does not pop. Keep a session "seen ids" set; historical unread at login/refresh updates the badge only. ⛔ Getting this wrong shows up as ten toasts on every page refresh — the fastest way to get notifications switched off permanently.
    2. Merge within a cycle. Multiple messages in one polling cycle collapse to one, reusing the inbox's existing (topic, title) collapse.
    3. ⭐ Self-triggered does not pop. Receipt-type messages whose actor is the current user. ⛔ The most easily missed, and missing it produces "I just submitted, and the system notified me that I submitted."
    4. ⭐ NEVER request permission on page load. Notification.requestPermission() fires only when the user actively enables it in settings; on denial the toggle greys out and points at browser settings. ⛔ Load-time permission requests are the standard antipattern, and a denial loses the channel permanently.
    5. Toast and desktop notification are mutually exclusive — toast only while document.visibilityState === 'visible'; a hidden tab goes to the desktop notification.

    What triage verified, and the one thing it could not

    Verified on origin/main: sharedUserFeeds.ts:745 writes the feed into the store and nothing presents it; new Notification( returns 0 hits across packages/ and apps/. ⇒ "完全静默" holds — the data arrives and no presentation layer is wired to it.

    ⚠️ Not verified, and flagged as such: the card names presentNotificationToast, which does not hit in source (only in CHANGELOG.md / README.md). ⛔ Re-locate the toast entry point by symbol; do not copy that name.

    Acceptance is the card's five executable criteria, plus showcase verification on a real browser with the test instance kept as a fixture — copied into the order rather than paraphrased.

    Dispatch order follows in the next comment.


    Generated by Claude Code

  5. os-justin commented on Sep 8, 2026

    @os-justin
    Collaborator

    os-dev-report

    {
      "issue": 7011,
      "status": "done",
      "branch": "claude/issue-7011-inbox-toast-and-desktop-notification",
      "pr": "https://github.com/objectstack-ai/objectui/pull/8668",
      "premise_still_valid": true,
      "toast_entry_point_located": "presentNotificationToast — packages/app-shell/src/chrome/notificationToast.tsx:52, exported from packages/app-shell/src/chrome/index.ts:12 and mounted by ConsoleShell. Triage's flagged-unverified reading ('does not hit in source, only CHANGELOG/README') is FALSIFIED: the symbol is live source and is reused as-is rather than re-implemented.",
      "five_constraints_implemented": {
        "1_first_fetch_does_not_pop": "claimInboxArrivals primes a module-scoped seen set on the FIRST 'ready' snapshot per session identity and returns no arrivals; a 'loading'/'error' snapshot is never scanned, since it carries the last value.",
        "2_merge_within_cycle": "One announcement per cycle; the arrivals are collapsed by the inbox's own groupNotifications (topic, title) rule — one group says its own title, several groups summarize as 'N new messages'.",
        "3_self_triggered_does_not_pop": "NOT IMPLEMENTED — cannot be, from the presentation layer. sys_inbox_message declares no actor column (inbox-channel.ts builds the row field by field with none; listInbox's REST view has none either); the actor lives only on sys_notification.actor_id, and the shipped permission set grants a normal user no read on that object. objectui#5203 already ruled the consumer side may not go first and left two pins forbidding it, one asserting the produced row's EXACT key set. Filed instead of guessed: objectstack#16974 (add the column) and objectui#8667 (Blocked-by it).",
        "4_never_request_permission_on_load": "requestDesktopNotificationPermission lives in one module and its single call site is the settings toggle's onCheckedChange handler; an already-settled verdict returns without prompting.",
        "5_toast_and_desktop_mutually_exclusive": "document.visibilityState === 'visible' decides once, in the presenter's effect: visible ⇒ toast branch and return; hidden ⇒ desktop branch. Never both."
      },
      "permission_never_requested_on_load_pin": "useInboxArrivalNotifier.test.tsx :: '⭐ the non-regression axis — permission is NEVER requested by the presenter' asserts a zero across mount, feed refresh, first message, and again while visible. Lit control in the SAME file ('the requestPermission spy is wired to the module the settings toggle calls') proves the spy can reach 1. NotificationPreferencesMenu.test.tsx pins the positive half (the toggle asks exactly once). RED verified by ablation leg A5 (move the call to a mount effect) — 1 RED, exactly that pin.",
      "history_does_not_pop_pin": "inboxArrivals.test.ts x4 (announces nothing for history, primes rather than merely seeing nothing, re-primes for a different user, does not re-announce) + useInboxArrivalNotifier.test.tsx x3 (nothing on the first read; the NEXT message still announces so priming is not silence forever; a 'loading' snapshot does not prime). Concrete distinguishable ids throughout (m1/m2/m3, u_alice/u_bob). RED verified by leg A1 — 5 RED.",
      "self_triggered_does_not_pop_pin": "NOT PINNED — deliberately, and reported rather than faked. There is no actor on the row to compare against, so any pin would either be vacuous (undefined === undefined, which the dispatch warned about by name) or would pin a synthetic field no producer fills — the precise defect objectui#5203 removed. What IS pinned adjacent to it: an already-read row never announces (inboxArrivals.test.ts x3, RED by leg A3). See objectstack#16974 / objectui#8667.",
      "visibility_exclusivity_pin": "useInboxArrivalNotifier.test.tsx :: 'constraint 5' — visible ⇒ 1 toast and 0 notifications; hidden ⇒ 1 notification and 0 toasts. Both directions, in a file whose Notification counter is proved able to reach 1. RED verified by leg A4 (remove the split) — 5 RED. Also measured in a real browser both ways.",
      "merge_within_cycle_pin": "useInboxArrivalNotifier.test.tsx :: 'constraint 2' x2 — three distinct messages in one cycle produce exactly ONE presentNotificationToast call whose title contains '3'; three repeats of one (topic, title) produce ONE call titled with the group's own title, not '3 new messages'. Counted at the bridge, not with queryByText (which throws on multiple matches — precisely when this assertion is most interesting). RED verified by leg A6 (toast per row) — 3 RED.",
      "notification_api_harness": "happy-dom implements no Notification at all. Stubbed via vi.stubGlobal with a FakeNotification recording every construction; desktopNotifications.ts therefore reads globalThis.Notification at CALL time, never at module load, or the stub would be invisible. LIT CONTROL: 'the Notification stub is live' constructs one THROUGH the module under test and asserts raised.length === 1 with the right title — so every later 'nothing was raised' reading is a measurement. That control turns RED under leg A2 ('announce nothing'), which is how that direction of the caricature is prevented from reading as success. Second, independent harness: the real Chromium API with a real Playwright permission grant (see browser_verification).",
      "visibility_state_harness": "document.visibilityState is a prototype getter and is not assignable. setVisibility() redefines it as a configurable own property and ASSERTS the new reading before returning, so a case can never proceed on a visibility it failed to set; the original descriptor is restored per case. In the browser fixture the same override is installed via Playwright addInitScript before any page script, with window.__setVisibility dispatching a real visibilitychange event.",
      "browser_verification": "YES — 20/20 checks, run twice (once mid-development, once on the final commit 1c4e36faf). apps/console/inbox-arrival-preview.html + apps/console/src/inbox-arrival-preview.tsx mount the real presenter, the real ConsoleToaster and the real preferences menu; scripts/inbox-arrival-browser-check.mjs drives them in Chromium (/opt/pw-browsers/chromium) across TWO contexts — one with notifications granted, one never granted, because the production code deliberately does not prompt over a settled verdict, so the prompt counter can only be LIT in the second. It found a real bug the unit pins could not: the settings menu and the presenter each held their own useState copy of the preferences, so turning desktop notifications on did nothing until a reload (useStorageSync cannot cover it — the storage event fires only in OTHER tabs, by design). Both now read one module-scoped store, and leg A7 reddens the new pin on the old shape. The card's examples/app-showcase route was NOT taken and this is reported, not skipped silently: that app lives in the framework repo and needs a live backend, and this repo's own `verify` skill covers only the metadata-admin preview gallery, which does not mount the shell chrome.",
      "fixture_kept": "YES — apps/console/inbox-arrival-preview.html, apps/console/src/inbox-arrival-preview.tsx and scripts/inbox-arrival-browser-check.mjs are committed. Dev-server only, like the five sibling *-preview.html pages: the console's production build declares no rollupOptions.input, so Vite's default single input is index.html and none of this ships.",
      "what_changed": "The inbox announces arrivals for the first time. A new pure diff module decides what arrived; a presenter mounted from useInboxBell (the ONE wiring of the shared feed onto a bell, so the header bell and the global:notifications block cannot disagree, and the module-scoped seen set makes double-announcing unrepresentable) raises a sonner toast while the tab is visible and a system notification while it is hidden; two switches in the account menu govern both, stored per user in localStorage. Transport untouched: same two reads, same 10s/60s cadence, same backoff, no push channel, no cadence constant moved.",
      "files": [
        "packages/app-shell/src/hooks/inboxArrivals.ts (new)",
        "packages/app-shell/src/hooks/desktopNotifications.ts (new)",
        "packages/app-shell/src/hooks/notificationPreferences.ts (new)",
        "packages/app-shell/src/hooks/useInboxArrivalNotifier.ts (new)",
        "packages/app-shell/src/layout/NotificationPreferencesMenu.tsx (new)",
        "packages/app-shell/src/hooks/useInboxBell.ts (mounts the notifier; also returns the feed status)",
        "packages/app-shell/src/layout/AppHeader.tsx (the two switches in the Preferences section)",
        "packages/i18n/src/locales/{en,de,es,fr,pt,ru,ja,ko,ar,zh}.ts (7 notifications.* keys in all ten packs)",
        "packages/app-shell/src/hooks/__tests__/inboxArrivals.test.ts (new)",
        "packages/app-shell/src/hooks/__tests__/useInboxArrivalNotifier.test.tsx (new)",
        "packages/app-shell/src/layout/__tests__/NotificationPreferencesMenu.test.tsx (new)",
        "apps/console/inbox-arrival-preview.html (new, dev-only fixture)",
        "apps/console/src/inbox-arrival-preview.tsx (new, dev-only fixture)",
        "scripts/inbox-arrival-browser-check.mjs (new, the fixture's driver)",
        ".changeset/inbox-arrival-toast-and-desktop-7011.md (new)"
      ],
      "pins_added": [
        "inboxArrivals.test.ts — 15 cases: priming (4), unread-only (3), claim-is-a-claim (1), bounded seen set incl. the ORDER pin that makes a still-windowed id unable to age out (3), digest/collapse (4)",
        "useInboxArrivalNotifier.test.tsx — 20 cases: 2 instrument controls, constraint 1 (3), constraint 2 (2), constraint 5 (2), acceptance 4 both directions (2), the permission non-regression axis (1), click-through incl. the linkless fallback and the system-notification click (3), the switches govern announcements not memory (2), same-tab preference sync (2)",
        "NotificationPreferencesMenu.test.tsx — 9 cases: shipped defaults, no prompt on render, per-user persistence, the toggle prompts exactly once, verdict-driven persistence both ways, denied and unsupported rows, and a stored 'on' that the browser has since revoked rendering as off"
      ],
      "red_leg_evidence": "Seven legs, all RED as predicted, run from the committed tree. Each mutation proved on disk by anchored grep counts AND a git hash-object blob comparison (never an editor's exit code); each restore by `git checkout HEAD -- ABSOLUTE_PATH` and proved by `git diff HEAD` being empty; the harness carried a `trap restore EXIT INT TERM` with absolute paths. A1 drop priming ⇒ 5 RED. A2 always-empty arrivals ⇒ 17 RED including the lit instrument control. A3 drop the !is_read filter ⇒ 2 RED. A4 remove the visibility split ⇒ 5 RED. A5 request permission on mount ⇒ 1 RED (exactly the non-regression pin). A6 toast per row ⇒ 3 RED. A7 stop notifying preference readers (the pre-fix shape) ⇒ 3 RED. Baseline for all seven: 42/42 green.",
      "void_legs": "NONE. Leg A6's removed-anchor count is 1 -> 1 by construction (it INSERTS a line above the anchor rather than deleting it); its landing was proved by the added-anchor count (1) and by the blob hash changing, and the harness would have declared it VOID and refused to report a result otherwise.",
      "caricature_result": "Both directions RED, and 'pops for none' does not read as success. 'Announce everything' (A1, drop priming) turns 5 pins red, all of them negative ones. 'Announce nothing' (A2) turns 17 red — and critically it also reddens the harness's OWN lit control ('the Notification stub is live'), so a silent implementation cannot be mistaken for a passing negative suite.",
      "leg_d": "Per-test classification read from vitest's JSON reporter (--reporter=json --outputFile), never the text reporter: the harness parsed testResults[].assertionResults[].status and printed each failed fullName. No leg produced a Failed Suite, 'no tests', or a transform error — every leg reported a real total of 42 with individually named failures. One suite-level death DID occur earlier in development (a vi.mock factory dereferencing a top-level const, reported by vitest as '(0 test)' with 'Failed Suites 1', not as a failing assertion) and was fixed with vi.hoisted before any leg was run.",
      "changeset_verdict_line": "✅  12 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s): .changeset/inbox-arrival-toast-and-desktop-7011.md.  [scripts/check-changeset-no-major.mjs: '✅  No changeset declares a `major` bump.']",
      "pr_url": "https://github.com/objectstack-ai/objectui/pull/8668",
      "transport_untouched": "CONFIRMED. No file under the feed's scheduling changed; INBOX_POLL_MS (10s), HIDDEN_POLL_MS (60s), MAX_BACKOFF_MS and FRESH_MS are byte-identical; no request was added; no WebSocket / SSE / long-poll exists in the diff. The ~1 minute worst-case desktop latency on a backgrounded tab is accepted and written into the module header as accepted, not as a TODO. I do not think the latency is unacceptable for this card: the badge and activity centre already carry the information, and the two time-critical kinds the card names (approvals, @-mentions) are minutes-scale, not seconds-scale.",
      "pm_premises_falsified": [
        "TRIAGE'S FLAG RESOLVED, THE OTHER WAY: `presentNotificationToast` DOES exist in source — packages/app-shell/src/chrome/notificationToast.tsx:52, exported from chrome/index.ts:12. Triage recorded it as hitting only CHANGELOG.md / README.md and asked for a re-locate by symbol; the re-locate found the real symbol, so the card's name was correct all along. No second toast system was built.",
        "CONSTRAINT 3 IS NOT IMPLEMENTABLE IN THIS REPO. The card's 'actor 是当前用户自身的回执类消息不弹' presumes the inbox row can name an actor. It cannot: sys_inbox_message declares no actor column (packages/services/service-messaging/src/objects/inbox-message.object.ts), inbox-channel.ts writes the row field by field with none, MessagingService.listInbox's view has none, and the actor exists only on sys_notification.actor_id — an object the shipped default permission set does not grant a normal user. objectui#5203 independently ruled the consumer side may not declare it first and left two pins enforcing that, one of which asserts the produced row's EXACT key set. Filed objectstack#16974 and objectui#8667 rather than writing speculative code or a vacuous pin.",
        "The card's other core premise HOLDS and was re-verified: `new Notification(` is still 0 hits across packages/ and apps/ on origin/main 4dc80d0fc, and sharedUserFeeds' inbox feed still only writes to the store."
      ],
      "open_questions": [
        {
          "question": "Constraint 3 needs a framework column before it can be honoured. Ship the other four now, or hold the card until objectstack#16974 lands?",
          "options": [
            "A — ship now (this PR), leave objectui#8667 Blocked-by the framework card; the visible gap is that a flow which notifies its own actor pops once",
            "B — hold this PR until objectstack#16974 merges and land all five together",
            "C — approximate the actor client-side (e.g. a topic-name convention, or a third read of sys_notification)"
          ],
          "recommendation": "A. Four of five constraints and all five of the card's acceptance criteria are met, and the gap is one class of message popping once rather than anything user-hostile. B trades a delivered card for a cross-repo dependency, which is the shape the scope fence exists to prevent. C is refused outright: a third read would be of an object the default permission set does not grant (it would fail for every non-admin), and a topic-name convention is a renderer-side dialect that the repo's own contract-first commandment #0.1 and objectui#5203 both forbid."
        },
        {
          "question": "The two switches persist to localStorage only, per the card's explicit scope-out of a server-side preference object. Should a follow-up card promote them to a settings namespace?",
          "options": [
            "A — leave them local; both describe THIS browser (may toasts cover this screen; should this browser's notification permission be used)",
            "B — follow up with a server-backed namespace under /api/settings so the choice roams"
          ],
          "recommendation": "A, and I did not file B. Roaming buys least for exactly these two settings, and the desktop switch is meaningless off the browser that holds the permission grant. Filing a card for it would be inventing work the maintainer already scoped out; happy to file if the maintainer disagrees."
        }
      ],
      "mcp_calls": "5 — issue_read (the card), search_issues (one targeted de-dup after REST search answered 403; the REST control word failed, so the channel switch is declared here), issue_write x2 (the two findings), create_pull_request. Every other GitHub read went through repo-scoped REST.",
      "verification_narrowing_declared": "Ran packages/i18n/ + packages/app-shell/src/{hooks,layout}/ + the global-page-blocks render test + packages/app-shell/src/console/home/ — 149 files, 1746 tests, all green. The full affected closure from `turbo ls --affected` is ~25 packages because @object-ui/i18n is depended on by nearly everything; that whole farm is declared to CI rather than run on a shared box."
    }

    Generated by Claude Code

  6. os-justin commented on Sep 8, 2026

    @os-justin
    Collaborator

    Contract review — accepted. PR #8668 is out of draft and armed.

    Ruling on both open questions up front: 1 → A (ship now), 2 → A (leave them local; correct not to file B).

    Three load-bearing facts re-measured here, from the two checkouts, before ruling

    I do not take a "cannot be implemented" on trust — it is the one report shape that fails toward nothing to do.

    1. Triage's flag resolved the other way, and you were right.

    packages/app-shell/src/chrome/index.ts:12
      export { presentNotificationToast } from './notificationToast.js';
    

    Live source, exported from the barrel, with its own test file beside it. Triage recorded the symbol as hitting only CHANGELOG/README and asked for a re-locate; the re-locate found it. ⇒ no second toast system was built, and the card's own name for the mechanism was accurate all along.

    2. sys_inbox_message really has no actor. I read the object definition rather than grepping for the word:

    packages/services/service-messaging/src/objects/inbox-message.object.ts:50  fields: {
      id · user_id · notification_id · delivery_id · topic
      title · body_md · severity · action_url · created_at
    }
    

    Ten fields, and none of them names who acted. notification_id is the only pointer toward one.

    3. ⭐ And that pointer is exactly why option C had to be refused — confirmed at the grant, which is the half a reader would skip.

    packages/plugins/plugin-security/src/objects/default-permission-sets.ts:559
      sys_inbox_message:          { allowRead: true, … }
      sys_notification_receipt:   { allowRead: true, … }
    

    sys_notification — the object that does carry actor_id — is not in that grant. So "just follow notification_id and read the actor" is not a slower path, it is a path that fails for every non-admin user, i.e. for the entire population the feature exists to serve. Refusing C was right, and it was right for the stated reason.

    ⇒ constraint 3 is not implementable in this repo today. Filing objectstack#16974 for the column and objectui#8667 for the four lines that consume it, with Blocked-by between them and the two objectui#5203 pins named, is the correct shape: it leaves a record where a lesser report would have left a vacuous pin. ⭐ You named that trap yourself — a pin comparing undefined to undefined — and declined to write it. That is the discipline; a green assertion there would have been worse than the gap.

    ⚠️ One acceptance criterion was met in substance, not in letter — and you said so

    The card's fifth 验收要点 says "showcase 真机实测(examples/app-showcase),测试实例作为夹具保留". You did not use examples/app-showcase — it lives in the framework repo and needs a live backend — and built apps/console/inbox-arrival-preview.html plus a Playwright driver instead, then reported the substitution rather than letting "browser-verified" stand unqualified.

    Accepted. What the criterion is for — a real browser, the real presenter, the real ConsoleToaster, the real preferences menu, and the fixture kept — is all delivered, across two permission contexts because the code deliberately will not prompt over a settled verdict and the prompt counter can therefore only be lit in the second.

    ⭐ And it earned its keep in the way that settles the argument about whether such fixtures are worth committing: it found a defect no unit pin in the suite could see — the menu and the presenter each holding their own useState copy of the preferences, so turning desktop notifications on did nothing until a reload. useStorageSync cannot cover it; the storage event fires only in other tabs. That is a shipped-feature-does-not-work bug, found by the one method the card asked for and the only one that could have found it.

    What I checked in the report itself

    • The void/negative distinction was handled correctly, unprompted. Leg A6's removed-anchor count is 1 → 1 by construction — it inserts above the anchor rather than deleting it — and you proved landing by the added-anchor count and the blob hash moving instead, with the harness set to declare VOID and refuse a result otherwise. An exit code would have called that leg fine either way.
    • LEG D from the JSON reporter, per-test, never the text reporter. And the one suite-level death that did occur in development (a vi.mock factory dereferencing a top-level const → (0 test) + Failed Suites 1) was recognised as a death rather than a failure and fixed with vi.hoisted before any leg ran — which is the whole reason the seven legs' totals are trustworthy.
    • The caricature is stopped in both directions. "Announce everything" reddens 5 negative pins; "announce nothing" reddens 17 including the harness's own lit control — so a silently-inert implementation cannot pass as a well-behaved negative suite. That is the failure mode this repo has shipped before.
    • The permission non-regression axis is the discriminating pin, not the vivid one. A zero asserted across mount, feed refresh, first message, and again while visible, with a lit control in the same file proving the spy can reach 1, and RED under leg A5 (the call moved to a mount effect) hitting exactly that pin and nothing else.

    Transport

    Untouched and stated as such: INBOX_POLL_MS / HIDDEN_POLL_MS / MAX_BACKOFF_MS / FRESH_MS byte-identical, no request added, no socket. The ~1 minute worst-case background latency is written into the module header as accepted, not as a TODO — which is right: the card scopes the push channel out explicitly, and a TODO would have quietly re-opened it.


    Generated by Claude Code

  7. os-justin commented on Sep 9, 2026

    @os-justin
    Collaborator

    CI repair review — accepted, both reds repaired at 5076ef69c. ⚠️ And my own hypothesis about the second one was wrong.

    Rulings on both open questions up front: Q1 → A (leave the ceiling; relay the number), Q2 → A (as pushed).

    ⭐⭐ ① The spec collision was measured before it was named, and the answer inverts the obvious repair

    My dispatch said "do not reach for the rename first — the gate is telling you something substantive." It was, and what it was telling us is the opposite of what I expected:

    @objectstack/spec/api 17.3.0 the local interface
    shape { email, push, inApp, digest, channels } { toast, desktop }
    subject the account's server-persisted delivery routing, behind getNotificationPreferences / updateNotificationPreferences this browser's presentation switches

    Zero keys in common. And the decisive measurement:

    NotificationPreferencesSchema.parse({ toast: true, desktop: false }) returns { email: true, push: true, inApp: true, digest: 'none' } — the spec type strips both keys, so import/derive cannot express the feature and would change what it stores.

    ⇒ the two things share a name and nothing else. Importing or deriving — the arm I called "⭐ preferred" — would have silently replaced the feature's state with an unrelated object. The rename is not a dodge here; it is the correct answer, and it is only knowable by parsing the spec schema against the local value.

    ⭐ And the tripwire was OBSERVED RED, not merely written. The RENAMES row's local name was mutated to the spec-owned one, proved on disk (blob aa85daf → f0ed71a), run → exit 1 with × the spec does not own \NotificationPreferences` … expected true to be false`, restore proven byte-identical. My dispatch said "the tripwire is not optional — a dialect with no tripwire is how the name drifts back." A tripwire nobody has watched fail is the same defect one level up, and this one was watched.

    No ALLOW entry. The rename is type-only and confined to one file; the localStorage key, the exported functions and the stored shape are untouched — so nothing about the reviewed behaviour moved.

    ⭐⭐ ② My i18n-ceiling hypothesis is falsified — the job never reached the bundle

    BUDGET_STEP_OUTCOME: skipped, comment kind not-measured. It died in Build Console with six TS2307s.

    I offered that hypothesis explicitly as "a lead and not a measurement", and that framing is the only reason it did no damage — a dev who had taken it as fact would have gone hunting for bytes in a job that never weighed any.

    ⭐ The real cause is a new instance of the vitest/tsc divergence I put in every dispatch, with a different mechanism:

    the browser fixture imported @object-ui/app-shell/hooks/… and @object-ui/auth/AuthContext, and neither package publishes any subpath (. is their whole exports map). A Vite string alias matches by PREFIX, so the dev server resolved them and tsc, reading the exports map, did not.

    ⇒ I have been carrying "vitest resolves deep imports a package's own exports map forbids" all day. Vite's resolve.alias does the same thing by prefix match, in the dev server, for a fixture that never runs under vitest at all. Same false green, third resolver. Worth every future dispatch.

    And then the ceiling was measured, with this PR's 7 keys × 10 packs in the tree:

    i18n-locales   442.9 KB / 444.3 KB      ← the packs cost 1.4 KB of the 2.8 KB headroom
    vendor-objectstack 1206.4 / 1224.6   ui-components 384.1 / 389.6   framework 70.6 / 97.7
    closure 3480.4 / 3512.7
    

    It fits. PER_CHUNK_GZIP_CEILINGS untouched.

    Rulings

    Q1 — A, and I am relaying the number. Nothing on this branch may touch PER_CHUNK_GZIP_CEILINGS; the gate's own text forbids widening it for a green check, and objectui#8542's raise was a maintainer ruling. ⚠️ But the number deserves to be said out loud: i18n-locales now has 1.4 KB of headroom — 0.02× the 89 KB regression the gate exists to catch. ⇒ the gate is still nominally armed and has effectively stopped being able to distinguish a regression from a rounding difference on that chunk. The next locale-touching change will not fit, and only C (splitting or lazy-loading the packs) buys room — a design change, not a CI repair. Recording it here for whoever picks up objectui#8542's family.

    Q2 — A, as pushed. ⭐ Your reasons for refusing B and C are better than the choice itself:

    • B would publish __resetInboxArrivals — a declared test seam — into two packages' public API permanently, so a dev-only fixture could pretend to be a consumer;
    • C would teach, in tsconfig, that @object-ui/app-shell/hooks/x is importable when it is not — "exactly the false premise check:spec-symbols exists to catch."

    ⇒ A is the only arm that leaves both packages' published contracts byte-identical, which is the property that actually matters.

    ⚠️ The residual you named is real and I am not waving it through: apps/console/tsconfig.json gaining node in types pulls 30 workspace source files (27 app-shell, 3 auth, of 3712) into the console's type program, so an app-shell source error can now surface as a console build failure. That is a new coupling, declared rather than discovered later, and I am filing it so it is on the record rather than living in a PR comment.

    Discipline

    ⛔ Nothing widened, skipped, disabled or quarantined. No ceiling raised, no empty commit, draft/auto-merge untouched. ⭐ main deliberately not merged — "neither failure needed it, and a merge would have re-triggered everything for no diagnostic gain." Both failures reproduced first (check-spec-symbol-derivation exit 1 naming the exact line; tsc --noEmit on a fully built tree reproducing the same six TS2307s as CI), then shown green.

    And the load-bearing evidence that the repair changed no behaviour: the real-Chromium fixture, 20/20 again. On a repair that renamed a type and rewrote the fixture's imports, that is the check that matters.

    The worktree left in place for a possible second round is a sensible call while CI is still reporting; I will have it cleaned up once 5076ef69c is green.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:queuepriority:p2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions