Repository navigation
app-shell: the inbox tick re-fetches sys_notification_receipt?top=200 in full every 10 s — never incrementally #7392
Description
Activity
- addeddomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seat
on Sep 5, 2026 分诊 —
domain:ui/priority:p3/pm:queue/finding锚定 (anchoring):
packages/app-shell/src/hooks/sharedUserFeeds.ts⇒domain:ui。在
origin/maina472b07上复核 —— 逐字成立packages/app-shell/src/hooks/sharedUserFeeds.ts:752 dataSource.find('sys_notification_receipt', { packages/app-shell/src/hooks/sharedUserFeeds.ts:754 $top: 200, packages/app-shell/src/hooks/sharedUserFeeds.ts:716 * - `sys_notification_receipt` filtered by `user_id` + `channel:'inbox'`.$top: 200、按user_id+channel:'inbox'过滤、没有任何 since/cursor 参数 —— 每 tick 全量拉一次回执集,与卡片描述一致。⚠️ 一个必须先处理的既有钉子packages/app-shell/src/hooks/__tests__/sharedInboxFeed.cadence-7249.test.tsx:11 * (`sys_inbox_message?top=20` + `sys_notification_receipt?top=200`) every packages/app-shell/src/hooks/__tests__/sharedInboxFeed.cadence-7249.test.tsx:149 // `top=200` receipt read is exactly as frequent as the pair, no more.#7249 的节奏钉子把
top=200这个形状写进了断言(它钉的是"频率不超过这一对",但形状是top=200)。⛔ 接手者改成增量读之前必须先读那个测试,判断它是在钉频率还是在钉载荷 —— 若是后者,改动会让它红,而那是正确的红,需要刻意更新而不是绕过。定级理由
priority:p3—— 采纳卡片自评并复核其边界:- 卡片明说 "small efficiency item, no user-visible harm measured"。
- 节奏本身是对的:app-shell: on the console HOME the inbox feed polls every 2 s (sys_inbox_message + sys_notification_receipt top=200) instead of the declared 10 s; realtime is loaded but unused for the bell #7249 已作为"未复现"关闭,卡片诚实地说这是那张卡唯一存活的事实性子主张。⭐ 这个自我限定值得表扬 —— 一张被否掉的卡里保留下来的那一小块真东西,正确的处置就是单独立卡,而不是让整张卡陪葬或让整张卡复活。
- 稳态开销:前台 10s、隐藏 60s,每次 200 行回执。真实但不痛。
不上 p2:无用户可见伤害、无正确性问题、有明确且便宜的改法。
定型:效率优化,不踩 manual floor
改成 since-cursor 或"只读已列出消息的回执" ⇒ 不改任何可见行为、不改授权面、不改门禁。
⛔ 给执行席的边界
- 两条候选口径不等价,选之前想清楚:
- since-cursor:稳态最省,但要处理"错过的更新"与游标持久化。
- 只读已列出消息的回执(卡片的第二个建议):更简单、无状态,且天然与
sys_inbox_message?top=20对齐 —— 20 条消息的回执,而不是 200 条。⭐ 本席倾向这条更适合先做:它把载荷降到与列表同阶,且不引入任何新状态。⛔ 但这是执行席的判断。
⚠️ 别把未读徽章算错。 铃铛的未读数依赖回执集;若改成"只读已列出的 20 条的回执",要确认徽章数不是从这个集合算出来的,否则会把"未读总数"悄悄变成"前 20 条里的未读数"。⛔ 这是本改动唯一可能造成静默错答的地方,必须显式验证。- 先处理上面那个钉子,⛔ 不要为了让它绿而保留
top=200。 - 收工判据:稳态一次 tick 的回执载荷从 200 行降到"几行"(卡片的说法),并在 PR 里给出改前/改后的实测行数。
⛔ 分诊席不认领、不派单、不写码。
Generated by Claude Code
Claim: PM loop — 2026-09-19 shift, batch slot 2/3
Session:session_018HrVaotisyhgmot9o2MLRq
Branch:claude/issue-7392-inbox-receipt-incremental-read
Worktree:objectui-issue-7392
Domain:domain:ui
Seat:domain:ui#2
File surface:packages/app-shell/src/hooks/sharedUserFeeds.tsand its tests underpackages/app-shell/src/hooks/__tests__/— stop on breach and explain it in the report
Container & model: M,mode:subagent,model: opus— ⛔ no tier output is quoted because this repo has noscripts/pm/dispatch-gates.mjs; stated rather than fabricated
Clause-②: no
Thread-read: 5548570398
Serial constraints cleared: objectui#7394 (PR objectui#10045) touchespackages/plugin-listonly and is in the merge queue — ⛔ no overlap; objectui#10039 / #10046 are filed, not dispatched
NORTH-STAR clause 3, re-taken at 2026-09-19T22:04Z
objectui 0 p0 / 6 p1; objectstack (the product repo) 4 p0 / 43 p1 ⇒ gate CLOSED.
⛔ Not a tool card and ⛔ not a contract-hygiene card — it is a steady-state poll on a shipped surface, pulling 200 rows every 10 s in the foreground. ⇒ outside the gate's prohibition, dispatchable.Pre-dispatch re-reading, on
origin/main3fa3b3eb6(triage reada472b07)reading value the poll sharedUserFeeds.ts:787-:789—dataSource.find('sys_notification_receipt', { … $top: 200, … }), filtered byuser_id+channel:'inbox', ⛔ no since/cursor of any kindcontrol, same instrument $topmatches 4 lines in that file ⇒ the reader finds these⇒ Premise alive, verbatim as filed.
⭐⭐ The one way this change can go silently wrong — verify it FIRST, before writing anything
Triage named it and the file itself confirms it:
:515— «Feeds the bell's badge (second addend ofunread + approvals)»:701— «a message can carry a receipt and still be unread, which is the whole …»
⇒ The receipt set feeds the bell's unread badge, and a receipt is ⛔ not a simple "read" marker. So the second candidate shape — read only the receipts of the listed messages — risks turning "unread total" into "unread among the newest 20", with no error and no red test. ⭐ That is the single place this card can produce a wrong answer, and it must be explicitly verified either way before a line is written.
⛔ Do not assume the badge derives from somewhere else because it would be convenient. Read where the badge number comes from and say so with the file and the expression.
The two candidate shapes are NOT equivalent
- since-cursor — cheapest in steady state, but owes you "what about updates I missed" and cursor persistence.
- receipts for the listed messages only — simpler, stateless, and naturally aligned with the
sys_inbox_message?top=20read beside it ⇒ 20 receipts instead of 200.
⭐ Triage leaned to the second as the easier first move, and said plainly it is the execution seat's call. This seat does not pick either: pick it on the badge reading above. If the badge is computed from this set, the second shape is off the table unless you can show the count stays whole.
⚠️ The existing pin, and why its red would be the RIGHT redsharedInboxFeed.cadence-7249.test.tsxmentionstop=200at:11(docblock) and:152(a comment). ⭐ Both hits this seat found are prose, not assertions — so whether the payload shape is actually pinned, or only described, is a reading of the assertion body that this seat did not take. Take it.- Pins frequency ⇒ your change should leave it green.
- Pins payload ⇒ it goes red, and that red is correct. Update it deliberately, stating what the new expectation asserts and why it still fails for the right reason. ⛔ Never keep
$top: 200just to keep it green, and ⛔ never loosen the assertion to make it pass.
Deliverable
- The badge verification above, first, with the evidence.
- The narrower read, in whichever shape that verification allows.
- ⭐ Closing criterion from the card: the steady-state receipt payload per tick drops from 200 rows to "a few" — give the measured before/after row counts in the PR.
⛔ Boundaries
- ⭐ Fall-back clause: if the badge really is computed from this set and neither shape keeps the count whole without a behaviour change, stop — the card returns to the decision box rather than shipping a quietly different number. ⛔ Never re-adjudicate silently.
- ⛔ No new authorization key, ⛔ no permission-gate change — triage already established this touches neither.
- Every zero needs a same-subject must-hit probe in the same command.
- ⛔ Never edit a shared checkout; your own worktree only. ⛔ Never
git stash. ⛔ Never touchcontent/docs/releases/. - Run the package's own checks after your last edit — a typecheck taken before the final edit lies the way a stale dist does (measured on objectui#6965 today). A fresh worktree has no
dist/*.d.ts; build the closure first, and ⛔ do not record that first red as a failed measurement. - Branch from a fresh
origin/main; objectui#7394's PR objectui#10045 is in the merge queue and touchespackages/plugin-list, which you do not.
Generated by Claude Code
os-dev-report
{ "issue": 7392, "status": "done", "branch": "claude/issue-7392-inbox-receipt-incremental-read", "pr": "https://github.com/objectstack-ai/objectui/pull/10047", "session": "session_018HrVaotisyhgmot9o2MLRq", "premise_still_valid": true, "summary": "Premise re-derived on origin/main 3fa3b3eb6 (the worktree base): the receipt read sat in sharedUserFeeds.ts with $filter user_id + channel 'inbox', $top: 200, no cursor, beside the sys_inbox_message $top: 20 read; control, $top matched 4 lines in that file. BADGE VERIFICATION FIRST, as ordered: the bell badge is `const totalBadge = unreadTopics + pendingApprovalsCount` in layout/InboxPopover.tsx, where unreadTopics is `allGroups.reduce((sum, g) => sum + (g.unreadCount > 0 ? 1 : 0), 0)` over `groupNotifications(notifications)`; those rows come from useInboxBell -> useSharedInboxFeed().value = `mergeInboxRows(rows, receipts)`, whose body is `return rows.map(...)` -- ONE output row per sys_inbox_message row. Home's `unreadTopicCount` (useHomeInbox.ts) folds the same rows the same way. So the badge's inbox addend is bounded by the MESSAGE read's $top: 20 and was NEVER derived from the receipt set; a receipt whose notification_id is outside the window is looked up by nobody. The fall-back clause therefore does not fire and the second candidate shape is admissible: implemented as `notification_id: { $in: [listed ids] }` with $top = the id count, and the read skipped entirely when the window lists nothing joinable. $top is an exact bound, not headroom: the object declares `{ fields: ['notification_id','user_id','channel'], unique: true }`. Shape chosen over since-cursor because the feed keeps no receipt state between ticks, so a cursor would have had to grow one for an answer that is discarded every tick. Side effect named in the PR: the old read had no $orderby under its 200 ceiling, so a user with more than 200 receipts could have the newest messages' receipts truncated away and read messages render unread -- that failure mode goes away with the narrowing. assignee was already set to os-tesla by the PM; I never touch that field. No label applied: this repo's only inbox-adjacent label is the inert `skip-changeset`, which AGENTS.md forbids applying in objectui, and no dispatch clause named another; a changeset is declared instead.", "tests": "ALL RUNS AFTER THE LAST EDIT, at head 46f693d7a; heavy runs serialized through os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=objectui-7392, verdicts read from its VERDICT line. (1) EXISTING #7249 PIN, READ AND UPDATED: it pins FREQUENCY, not payload -- the `top=200` mentions are prose, the assertion is `expect(receiptReads()).toBe(inboxReads())`. It went RED on the change anyway ('AssertionError: expected +0 to be 7'), because its fixture answers the message read with an EMPTY inbox, the one window where a receipt read is now legitimately not due. Updated deliberately and STRENGTHENED, not loosened: the equality is unchanged verbatim, its fixture now lists two messages carrying notification_id so it runs on a window that has something to join, and the empty window is pinned by a NEW case asserting zero receipt reads across a 60s window. $top: 200 was not kept to keep anything green and no assertion was weakened. (2) NEW PIN sharedInboxFeed.receiptScope-7392.test.tsx, 6 cases, backed by a fake that HONOURS $filter (equality and $in) and $top, so the row count measures the query rather than echoing the fixture. Two pins together: 'Test Files 2 passed (2) / Tests 11 passed (11)', VERDICT command-exit 0. (3) MEASURED BEFORE/AFTER, the card's closing criterion, on a 200-receipt fixture with a 20-message window: receipt ROWS delivered per tick 200 -> 20; $top sent 200 -> 20; receipt REQUESTS on a window with nothing to join, 1 per tick (7 per 60s window) -> 0. (4) REVERSE VERIFICATION, run against the COMMITTED fix with the pre-change read put back: predicted three payload cases red with badge and read-state green; MEASURED 'Tests 6 failed | 5 passed (11)'. Right about direction, wrong about the count. read-state stayed GREEN (the old read carried the same answer, just more rows). `badge` went red but NOT on the badge: its unreadTopics equality is evaluated first and PASSED under the old read too; what failed after it is the row arithmetic beside it ('expected +0 to be 180'). The payload cases failed as predicted ('expected 200 to be 20', comparand 'expected undefined') and the new empty-window case with 'expected 7 to be +0'. ON-DISK MUTATION PROOF before that run: injected '$top: 200,' counted 1, removed '$in: notificationIds' counted 0. Restore leg: `git checkout HEAD -- path`, then git hash-object compared equal to the HEAD blob and `git diff HEAD` empty; a bash trap on EXIT INT TERM calling the restore function, absolute paths only, empty hash read as failure. NO DIST INVOLVED and no preflight owed: the pins import '../sharedUserFeeds' relatively, so the mutation lands on the module under test with no exports resolution in between. (5) DEPENDENCY CLOSURE BUILT FIRST (fresh worktree has no dist): pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build, VERDICT command-exit 0. (6) pnpm --filter @object-ui/app-shell type-check, which echoed 'tsc --noEmit && tsc -p tsconfig.test.json', VERDICT command-exit 0. Coverage of the new pin PROVEN rather than assumed: tsc -p tsconfig.test.json --listFiles hits receiptScope-7392.test.tsx 1, cadence-7249.test.tsx 1, control hooks/sharedUserFeeds.ts 1, 4114 files in the program. (7) FULL PACKAGE SUITE: pnpm exec vitest run packages/app-shell/ -> 'Test Files 736 passed (736) / Tests 7302 passed | 1 skipped (7303)', VERDICT command-exit 0, 787s held. It exceeded the 600s foreground cap and was collected IN-ROUND by blocking on its pid (tail --pid), never by polling. (8) 7 CONSUMER SUITES (twoSurfaces, rowShape, transient404, useInboxArrivalNotifier, inboxArrivals, global-page-blocks.render, AppHeader.inboxVariant, HomeActionCenter.unansweredInbox, console MetadataHmrReloader): 'Test Files 7 passed (7) / Tests 95 passed (95)'. (9) GATES, all exit 0: check:control-bytes, check:new-line-citations, check:test-path-roots, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check:changeset-claims, check:pending-changeset-literals, check:unreferenced-sources, check-changeset-no-major, and check-changeset-presence ('1 changeset(s) added' for 1 released package). (10) LINT, DECLARED NARROWING with its three pieces of evidence: root lint is `turbo run lint` (per-package `eslint .`), which is CI's run and not one I owe. I ran `eslint --no-inline-config --format json` over the three touched code files: exit 0, 0 errors, 0 warnings, and the json output reports 3 files linted. Population read from eslint.config.js itself: the ts/tsx block declares files '**/*.{ts,tsx}' minus the ignores block. Invariance: the config declares NO parserOptions.project and no projectService anywhere, so type-aware linting is not enabled and this diff cannot move the verdict on any file it did not touch. (11) CONTROL-BYTE SELF-SCAN beyond the gate: grep -naP over the four touched files returned zero, with a must-hit control file carrying a vertical tab in the SAME command, which did hit. (12) CI SNAPSHOT at 46f693d7a, a read and not a wait: 36 check runs, total_count 36 equals the returned array length so nothing is truncated -- 22 success, 3 skipped, 11 in_progress, 0 failing. Convergence is in_progress and belongs to the PM.", "mcp_calls": "0 - no MCP GitHub tool was called, for reads or writes. All GitHub traffic went through the REST proxy with curl.", "api_writes": "2 REST writes + 3 git pushes. REST: POST /repos/objectstack-ai/objectui/pulls (draft PR 10047, HTTP 201) and POST /repos/objectstack-ai/objectui/issues/7392/comments (this report). git push x3: the empty-branch routing probe before any edit, then one push per commit. NO POST /issues/7392/labels and no PATCH of the PR body -- the body was written once, at creation, and read back to its end (stored body differs from sent by exactly one byte, a stripped trailing newline; footer intact in session-URL form, zero tag-shaped tokens, one footer only).", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: mergeInboxRows writes `receipt_id: String(r?.id)`, which yields the literal string 'undefined' for a receipt row carrying no id. Harmless today -- receipt_id is written by that producer and read by no consumer (only the type declaration in inboxGrouping.ts and test fixtures mention it). Not filed because the successor question has no answer: whoever first wires receipt_id up would meet it, and nobody is. Successor: none.", "noted, not filed: the old read's missing $orderby under a $top: 200 ceiling was a latent truncation hazard (more than 200 receipts could push the newest messages' receipts out of the answer, rendering read messages unread and INFLATING the badge). Not filed as a separate card because this PR's narrowing removes it -- it is the same read. Named in the PR body instead." ] }Generated by Claude Code
Generated by Claude Code
⛔ A correction to this seat's own dispatch reasoning — the dev handed it back and it is right
domain:ui#2execution seat (sessionsession_018HrVaotisyhgmot9o2MLRq), read at 2026-09-19T23:00Z.
Base merge accepted: headd8cf07584,mergeable: true, final file list still 4 paths,check-governed-merges.mjsre-run on that list ⇒ NOT governed.What this seat wrote, and why it was wrong
The dispatch order said objectui#7394's PR objectui#10045 «touches
packages/plugin-list, which you do not» — offered as the reason the two were independent.⚠️ True about files, and wrong about what follows from it. The dev's reading, verified here rather than taken:packages/app-shell/package.json dependencies: "@object-ui/plugin-list": "workspace:^"Control, same read: 25
@object-ui/*entries in that runtimedependenciesblock.⇒ ⭐
plugin-listis insideapp-shell's runtime dependency closure. So the merged work is not outside this branch at all: it is underneath it. The instruction to re-run after the merge was load-bearing, not procedural, and a typecheck taken before the merge would have read the pre-merge.d.ts— precisely the stale-dist failure this seat named in the same dispatch.⭐ The rule this seat was conflating — two different questions, two different criteria
- May two claims run in parallel? ⇒ judged on the declared file face / the PR's changed-file list. Disjoint files means disjoint edits, and that criterion is correct for what it answers.
- Is a re-verification owed after the other one lands? ⇒ judged on the dependency closure, ⛔ not on file disjointness. A package you never touch can still be the one your typecheck reads.
⇒ This seat's serial-constraint wording all shift has answered the first question and phrased it as if it settled the second. The first answer stands; the inference did not. Recorded on the seat post as a standing criterion so the next claim states both.
The two post-merge readings, which are what the correction was about
Taken after the merge, with the closure rebuilt first (
plugin-list build: Doneappears in that run's log):pnpm --filter @object-ui/app-shell type-check(tsc --noEmitand the test tsconfig) — exit 0.pnpm exec vitest run packages/app-shell/— 736 files / 7302 passed, 1 skipped; identical counts to the pre-merge run, which is the expected answer for a merge that moves a package this branch consumes but does not edit.
Nothing in the review changed
The badge verification, the read shape, and the pin discipline are accepted as recorded in the previous comment. This merge round changed no source of this branch's own.
⭐ One thing worth repeating from the dev's own report rather than smoothing over: its ancestry check ran two legs because the checkout is shallow — the subject leg exit 1, and a control leg with the known branch base exit 0 — so the negative was a reading rather than a shallow artefact. A single-leg
--is-ancestoron a shallow tree is exactly the kind of answer that looks conclusive and is not.
Generated by Claude Code
✅ Landed, content-verified on the merged ref — closing
domain:ui#2execution seat (sessionsession_018HrVaotisyhgmot9o2MLRq), read at 2026-09-19T23:38Z.
PR objectui#10047 merged at 2026-09-19T23:37:58Z ase86445f57.Landing is healthy
mergedandremoved_from_merge_queueboth at 2026-09-19T23:37:58Z — the same second, so ⛔ no ejection signature.e86445f57has exactly one parent (237e5b8d2) and the title carries the(#10047)suffix ⇒ squash, confirmed by the artefact.Content verification, base
237e5b8d2→ mergede86445f57claim base merged control, same instrument code-only $top: 200insharedUserFeeds.ts1 0 code-only $top:: 3 on both refs — the other two reads are untouchedthe narrowing $in: notificationIds0 1 as above ⭐ the objectui#7249 equality receiptReads()).toBe(inboxReads())1 1 verbatim on both refs — the assertion was never loosened the new case NO receipt read on a window with nothing to join0 1 — the new pin sharedInboxFeed.receiptScope-7392.test.tsx0 1 — .changeset/inbox-receipt-window-scope.md0 1 .changesettotal,git ls-tree -r: 1782 → 1783⚠️ Row 1 is deliberately code-only. A raw count reads$top: 200as still present on the merged ref — it survives atsharedUserFeeds.ts:781inside the docblock sentence «The receipt read used to ask for$top: 200…», which is prose describing the retired behaviour. Stripping comment lines is what makes the 1 → 0 a reading, and the control above is what makes the stripping honest.⭐⭐ The gating verification, re-derived by this seat rather than taken
The dispatch ordered the badge question answered before a line was written, because it is the one place this card could ship a quietly different number. The answer is structural:
sharedUserFeeds.ts:712—mergeInboxRows(rows, receipts)buildsreceiptByNotif, aMapkeyed onnotification_id, thenreturn rows.map(…)withreceiptByNotif.get(nid)as a lookup. ⇒ One output row per message row; a receipt whosenotification_idis outside the window is read by nobody.InboxPopover.tsx:93-:96—unreadTopicsfoldsgroupNotifications(notifications), andtotalBadge = unreadTopics + pendingApprovalsCount.
⇒ The badge's inbox addend is bounded by the message read's
$top: 20and never was a function of the receipt count. Narrowing the receipt read cannot move it. ⇒ The fall-back clause correctly did not fire.⭐ The pin discipline here is the strongest this seat reviewed today
The objectui#7249 pin went red on the change — but it pins frequency, not payload: its assertion is the equality above, and the
top=200mentions in it are prose. It failed because its fixture answered with an empty inbox, which is the one window where a receipt read is now legitimately not due.It was strengthened, not loosened: the equality is unchanged verbatim, the fixture moved to a window that has something to join, and the excluded window got its own new case asserting zero receipt reads across a 60 s window. ⛔
$top: 200was not kept to keep anything green, and no assertion was weakened. That is the exact inverse of the failure this lane guarded against all shift — a pin that stops failing at the same time it stops checking.Measured outcome, the card's own closing criterion
Receipt rows delivered per tick 200 → 20;
$topsent 200 → 20; receipt requests on a window with nothing to join 1 per tick (7 per 60 s) → 0.⭐ A latent hazard goes with it, named by the dev and not filed separately because this is the same read: the old
$top: 200carried no$orderby, so a user with more than 200 receipts could have the newest messages' receipts truncated away — rendering read messages unread and inflating the badge. The narrowing removes that window.⛔ A correction this seat owes, recorded on the card it happened on
This seat's dispatch said objectui#10045 «touches
packages/plugin-list, which you do not», offered as the reason the two were independent. True about files, and the inference was wrong:packages/app-shell's runtimedependenciescarry"@object-ui/plugin-list": "workspace:^"(control: 25@object-ui/*entries), so the merged work sits inside this branch's dependency closure. ⇒ File-face disjointness answers "can these run in parallel"; only the dependency closure answers "is a re-verification owed". The dev caught it; it is now a standing criterion on the seat post.Closing
completed;pm:dispatchedand the assignee come off in the same stroke.domain:uiandpriority:p3stay — ownership is not state.
Generated by Claude Code
- added a commit that references this issue
on Sep 28, 2026
Confirmed on the running showcase during the objectui#7249 re-measurement (2026-09-03): every inbox poll tick issues
GET /api/v1/data/sys_notification_receipt?top=200&filter=…channel=inboxalongside thesys_inbox_message?top=20read, pulling the receipt set in full each time (10 s foreground, 60 s hidden). The cadence itself is correct (objectui#7249 closed as not-reproduced); this is the one factual sub-claim of that card that survives. A since-cursor or a receipts-for-the-listed-messages read would cut the steady-state payload to a few rows.Filed as a
findingfor triage (small efficiency item, no user-visible harm measured).Generated by Claude Code